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.
172 lines
5.7 KiB
172 lines
5.7 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'));
|
|
// Literal map (not interpolated) so each --qr-code-size-* token name appears
|
|
// verbatim in the source — the recipe contract scans for exact token names.
|
|
const SIZE_VARS = {
|
|
xs: 'var(--qr-code-size-xs)',
|
|
sm: 'var(--qr-code-size-sm)',
|
|
md: 'var(--qr-code-size-md)',
|
|
lg: 'var(--qr-code-size-lg)',
|
|
xl: 'var(--qr-code-size-xl)'
|
|
} as const;
|
|
const px = $derived(
|
|
typeof resolvedSize === 'number' ? `${resolvedSize}px` : SIZE_VARS[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?.()}
|