import type { QrCode } from '@robonen/encoding'; import { QrCodeDataType } from '@robonen/encoding'; import { clamp } from '@robonen/stdlib'; /** * Geometry helpers for rendering a {@link QrCode} matrix to SVG paths. * * Every coordinate is expressed in *module units*: a module at grid index * `(x, y)` occupies the square `[x, x + 1] × [y, y + 1]`, so its center sits at * `(x + 0.5, y + 0.5)`. Multiplying by a scale factor (or letting the SVG * viewBox do it) yields pixels — the paths themselves are resolution-independent. */ /** Visual style applied to each data module ("pixel") of the code. */ export type QrCellPattern = 'square' | 'dot' | 'rounded' | 'fluid'; /** Shape of the outer 7×7 ring of a finder ("eye") pattern. */ export type QrMarkerFrame = 'square' | 'rounded' | 'circle'; /** Shape of the inner 3×3 ball of a finder ("eye") pattern. */ export type QrMarkerBall = 'square' | 'rounded' | 'circle' | 'diamond'; /** Which of the three finder patterns a {@link MarkerPlacement} refers to. */ export type MarkerCorner = 'top-left' | 'top-right' | 'bottom-left'; /** Position of a single finder pattern, given as the top-left module of its 7×7 region. */ export interface MarkerPlacement { corner: MarkerCorner; /** X index of the finder's top-left module. */ x: number; /** Y index of the finder's top-left module. */ y: number; } /** A rectangular region of the matrix, in module units, used to knock out modules behind overlays. */ export interface QrCodeRegion { x: number; y: number; width: number; height: number; } /** Description of one dark module, passed to the `#cell` slot for custom rendering. */ export interface QrCellDescriptor { /** Column index. */ x: number; /** Row index. */ y: number; /** Center X (`x + 0.5`). */ cx: number; /** Center Y (`y + 0.5`). */ cy: number; /** Structural role of the module (data, timing, alignment, …). */ type: QrCodeDataType; } /** Returns the top-left module index of each of the three finder patterns. */ export function markerPlacements(size: number): MarkerPlacement[] { return [ { corner: 'top-left', x: 0, y: 0 }, { corner: 'top-right', x: size - 7, y: 0 }, { corner: 'bottom-left', x: 0, y: size - 7 }, ]; } /** Rounds to 4 decimals and strips trailing zeros to keep generated path strings compact. */ function fmt(value: number): string { return String(Math.round(value * 1e4) / 1e4); } /** * Builds a rectangle path with an independent corner radius per corner. A radius * of `0` collapses that corner to a sharp right angle, which is how the `fluid` * pattern merges a module into its dark neighbours. */ export function roundedRectPath( x: number, y: number, w: number, h: number, rTL: number, rTR: number, rBR: number, rBL: number, ): string { const max = Math.min(w, h) / 2; rTL = clamp(rTL, 0, max); rTR = clamp(rTR, 0, max); rBR = clamp(rBR, 0, max); rBL = clamp(rBL, 0, max); let d = `M${fmt(x + rTL)} ${fmt(y)}`; d += `L${fmt(x + w - rTR)} ${fmt(y)}`; if (rTR > 0) d += `A${fmt(rTR)} ${fmt(rTR)} 0 0 1 ${fmt(x + w)} ${fmt(y + rTR)}`; d += `L${fmt(x + w)} ${fmt(y + h - rBR)}`; if (rBR > 0) d += `A${fmt(rBR)} ${fmt(rBR)} 0 0 1 ${fmt(x + w - rBR)} ${fmt(y + h)}`; d += `L${fmt(x + rBL)} ${fmt(y + h)}`; if (rBL > 0) d += `A${fmt(rBL)} ${fmt(rBL)} 0 0 1 ${fmt(x)} ${fmt(y + h - rBL)}`; d += `L${fmt(x)} ${fmt(y + rTL)}`; if (rTL > 0) d += `A${fmt(rTL)} ${fmt(rTL)} 0 0 1 ${fmt(x + rTL)} ${fmt(y)}`; return `${d}Z`; } /** Full circle (disc) path, drawn as two arcs. */ export function circlePath(cx: number, cy: number, r: number): string { return `M${fmt(cx - r)} ${fmt(cy)}` + `A${fmt(r)} ${fmt(r)} 0 1 0 ${fmt(cx + r)} ${fmt(cy)}` + `A${fmt(r)} ${fmt(r)} 0 1 0 ${fmt(cx - r)} ${fmt(cy)}Z`; } function diamondPath(cx: number, cy: number, r: number): string { return `M${fmt(cx)} ${fmt(cy - r)}L${fmt(cx + r)} ${fmt(cy)}` + `L${fmt(cx)} ${fmt(cy + r)}L${fmt(cx - r)} ${fmt(cy)}Z`; } /** Predicate deciding whether a module participates in cell rendering. */ type ModuleFilter = (x: number, y: number) => boolean; interface CellOptions { pattern: QrCellPattern; /** Corner roundness in `[0, 1]` for `rounded`/`fluid` patterns. */ radius: number; /** Inset applied to every module in `[0, 1)` to create gaps between cells. */ gap: number; /** When `false`, modules belonging to a finder pattern are skipped (drawn by `QrCodeMarker`). */ includeMarkers: boolean; /** Returns `true` for modules covered by an overlay (e.g. a logo) — they are skipped. */ isReserved: ModuleFilter; } /** Whether a module should be drawn by `QrCodeCells` given the active options. */ function isCell(qr: QrCode, x: number, y: number, opts: CellOptions): boolean { if (x < 0 || y < 0 || x >= qr.size || y >= qr.size) return false; if (!qr.getModule(x, y)) return false; if (!opts.includeMarkers && qr.getType(x, y) === QrCodeDataType.Position) return false; return !opts.isReserved(x, y); } function cellSnippet(qr: QrCode, x: number, y: number, opts: CellOptions): string { const { pattern } = opts; if (pattern === 'fluid') { // Connect to dark neighbours by squaring off the corners between them. const r = clamp(opts.radius, 0, 1) * 0.5; const top = isCell(qr, x, y - 1, opts); const bottom = isCell(qr, x, y + 1, opts); const left = isCell(qr, x - 1, y, opts); const right = isCell(qr, x + 1, y, opts); return roundedRectPath( x, y, 1, 1, !top && !left ? r : 0, !top && !right ? r : 0, !bottom && !right ? r : 0, !bottom && !left ? r : 0, ); } const gap = clamp(opts.gap, 0, 0.95); const inset = gap / 2; const s = 1 - gap; if (pattern === 'dot') return circlePath(x + 0.5, y + 0.5, s / 2); const r = pattern === 'rounded' ? clamp(opts.radius, 0, 1) * (s / 2) : 0; return roundedRectPath(x + inset, y + inset, s, s, r, r, r, r); } /** Builds a single `` `d` string covering every rendered data module. */ export function cellsPath(qr: QrCode, opts: CellOptions): string { const size = qr.size; let d = ''; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { if (isCell(qr, x, y, opts)) d += cellSnippet(qr, x, y, opts); } } return d; } /** Collects descriptors for every rendered data module, for slot-based custom rendering. */ export function cellList( qr: QrCode, opts: Pick, ): QrCellDescriptor[] { const full: CellOptions = { ...opts, pattern: 'square', radius: 0, gap: 0 }; const size = qr.size; const cells: QrCellDescriptor[] = []; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { if (isCell(qr, x, y, full)) cells.push({ x, y, cx: x + 0.5, cy: y + 0.5, type: qr.getType(x, y) }); } } return cells; } /** * Path for a finder pattern's outer ring. The frame occupies the 7×7 region * with a 1-module-thick border (`square`/`rounded`) or an annulus (`circle`), * leaving the standard 1-module light gap before the inner ball. Render with * `fill-rule="evenodd"` so the inner cut-out reads as a hole. */ export function markerFramePath(mx: number, my: number, shape: QrMarkerFrame, radius: number): string { const cx = mx + 3.5; const cy = my + 3.5; const t = clamp(radius, 0, 1); if (shape === 'circle') return circlePath(cx, cy, 3.5) + circlePath(cx, cy, 2.5); const ro = shape === 'rounded' ? t * 3.5 : 0; const ri = shape === 'rounded' ? Math.max(0, ro - 1) : 0; return roundedRectPath(mx, my, 7, 7, ro, ro, ro, ro) + roundedRectPath(mx + 1, my + 1, 5, 5, ri, ri, ri, ri); } /** Path for a finder pattern's inner 3×3 ball. */ export function markerBallPath(mx: number, my: number, shape: QrMarkerBall, radius: number): string { const cx = mx + 3.5; const cy = my + 3.5; if (shape === 'circle') return circlePath(cx, cy, 1.5); if (shape === 'diamond') return diamondPath(cx, cy, 1.5); const r = shape === 'rounded' ? clamp(radius, 0, 1) * 1.5 : 0; return roundedRectPath(mx + 2, my + 2, 3, 3, r, r, r, r); }