You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/components/qr-code/qr-code.svelte

163 lines
5.4 KiB

<script lang="ts">
/**
* Eidos `<QrCode>` — themeable QR from the own zero-dep encoder (`$libs/qr`).
* Renders the module matrix as a single SVG `<path>` (decorative); the svg
* is `role="img"` with `aria-label`. Finder patterns stay square for any
* `cellShape` so scanners lock on. A `logo` clears the centre + boosts ECC
* to H so it stays scannable.
*/
import './qr-code.css';
import { ActiveEidos } from '$uix/eidos';
import { encode } from '$libs/qr';
import { setQrCodeContext } from './context';
import type { QrCodeProps, QrCodeDownloadFormat } from './types';
let {
value = '',
size = 'md',
errorCorrection = 'M',
color,
background,
cellShape = 'square',
margin = 4,
alt,
logo,
children,
...rest
}: QrCodeProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
const px = $derived(
typeof resolvedSize === 'number' ? `${resolvedSize}px` : `var(--qr-code-size-${resolvedSize})`
);
// A logo needs max redundancy + a cleared centre to stay scannable.
const ecc = $derived(logo ? 'H' : errorCorrection);
const qr = $derived(encode(value, { errorCorrection: ecc }));
const dim = $derived(qr.size + margin * 2);
const clear = $derived(logo ? Math.max(1, Math.round(qr.size * 0.24)) : 0);
const clearLo = $derived(Math.floor((qr.size - clear) / 2));
const clearHi = $derived(clearLo + clear);
function cell(x: number, y: number, shape: string): string {
const cx = x + margin;
const cy = y + margin;
if (shape === 'dots') {
const r = 0.5;
return `M${cx + 0.5 - r},${cy + 0.5}a${r},${r} 0 1,0 ${2 * r},0a${r},${r} 0 1,0 ${-2 * r},0z`;
}
if (shape === 'rounded') {
const rr = 0.3;
const s = 1 - 2 * rr;
return `M${cx + rr},${cy}h${s}a${rr},${rr} 0 0 1 ${rr},${rr}v${s}a${rr},${rr} 0 0 1 ${-rr},${rr}h${-s}a${rr},${rr} 0 0 1 ${-rr},${-rr}v${-s}a${rr},${rr} 0 0 1 ${rr},${-rr}z`;
}
return `M${cx},${cy}h1v1h-1z`;
}
const path = $derived.by(() => {
const m = qr.matrix;
const fn = qr.reserved;
const n = qr.size;
let d = '';
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
if (!m[y][x]) continue;
if (clear && x >= clearLo && x < clearHi && y >= clearLo && y < clearHi) continue;
// Function patterns stay square so scanners lock on; data takes the shape.
d += cell(x, y, fn[y][x] ? 'square' : cellShape);
}
}
return d;
});
const overlayPos = $derived((dim - clear) / 2);
const frameStyle = $derived(
[
`width: ${px}`,
`height: ${px}`,
color ? `--qr-code-fg: ${color}` : '',
background ? `--qr-code-bg: ${background}` : ''
]
.filter(Boolean)
.join('; ')
);
let svgEl: SVGSVGElement | null = $state(null);
// Export the code (PNG / SVG). The serialized SVG loses the document's CSS
// custom properties, so we bake the resolved fills into the clone first.
async function download(format: QrCodeDownloadFormat, filename = 'qrcode'): Promise<void> {
if (!svgEl) return;
const doc = eidos.dom.getDocument(svgEl);
const win = eidos.dom.getWindow(svgEl) as Window & typeof globalThis;
const clone = svgEl.cloneNode(true) as SVGSVGElement;
const srcRect = svgEl.querySelector('rect');
const srcPath = svgEl.querySelector('path');
if (srcRect) clone.querySelector('rect')?.setAttribute('fill', win.getComputedStyle(srcRect).fill);
if (srcPath) clone.querySelector('path')?.setAttribute('fill', win.getComputedStyle(srcPath).fill);
const exportPx = Math.max(512, dim * 12);
clone.setAttribute('width', String(exportPx));
clone.setAttribute('height', String(exportPx));
const xml = new XMLSerializer().serializeToString(clone);
const click = (url: string, name: string) => {
const a = doc.createElement('a');
a.href = url;
a.download = name;
a.style.display = 'none';
doc.body.appendChild(a);
a.click();
a.remove();
eidos.timers.schedule(`qr-code-revoke-${name}`, 2000, () => win.URL.revokeObjectURL(url));
};
if (format === 'svg') {
click(
win.URL.createObjectURL(new Blob([xml], { type: 'image/svg+xml;charset=utf-8' })),
`${filename}.svg`
);
return;
}
const img = new win.Image();
img.src = 'data:image/svg+xml;base64,' + win.btoa(unescape(encodeURIComponent(xml)));
await img.decode();
const canvas = doc.createElement('canvas');
canvas.width = exportPx;
canvas.height = exportPx;
canvas.getContext('2d')?.drawImage(img, 0, 0, exportPx, exportPx);
canvas.toBlob((blob) => {
if (blob) click(win.URL.createObjectURL(blob), `${filename}.png`);
}, 'image/png');
}
setQrCodeContext({ download });
</script>
<svg
bind:this={svgEl}
data-qr-code=""
data-cell-shape={cellShape}
role="img"
aria-label={alt ?? value}
viewBox="0 0 {dim} {dim}"
shape-rendering={cellShape === 'square' ? 'crispEdges' : 'geometricPrecision'}
style={frameStyle}
{...rest}
>
<rect width={dim} height={dim} fill="var(--qr-code-bg)" />
<path d={path} fill="var(--qr-code-fg)" />
{#if logo}
<foreignObject x={overlayPos} y={overlayPos} width={clear} height={clear}>
<!-- Explicit px size: inside a foreignObject px == user units == the fo box;
percentages don't resolve against a foreignObject, so the overlay needs
a deterministic size to fill the cleared centre. --><div
data-qr-code-overlay
xmlns="http://www.w3.org/1999/xhtml"
style="width:{clear}px;height:{clear}px;padding:{(clear * 0.1).toFixed(2)}px"
>{@render logo()}</div>
</foreignObject>
{/if}
</svg>
{@render children?.()}

Powered by TurnKey Linux.