From ce1d0057465d3b917eaf83fdf0b31bbf251a5490 Mon Sep 17 00:00:00 2001 From: fadeltd Date: Fri, 30 Oct 2026 00:04:18 +0700 Subject: [PATCH 01/17] Tailwind: canonical class spellings, with a guard test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the 55 classes the Tailwind editor extension flagged as having a canonical spelling (w-[220px] → w-55, rounded-[2px] → rounded-xs, bg-[length:…] → bg-size-[…], [scrollbar-width:none] → scrollbar-none, [&>*]: → *:, and so on). Computed styles are identical before and after on 14 routes at 1280px and 400px. scripts/tailwind.ts runs Tailwind's own canonicalizer (the engine behind the editor hints) over every string and template literal in src/**/*.tsx, parsed with oxc so JSX text is never touched. `pnpm tw:fix` reports them and `--write` rewrites them; scripts/tailwind.test.ts fails CI when one appears, so new code, including agent-written tools, stays canonical. Adds @tailwindcss/node 4.3.3 as a dev dependency for its design-system loader. --- package.json | 2 + pnpm-lock.yaml | 3 + scripts/tailwind.test.ts | 40 ++++++++ scripts/tailwind.ts | 123 +++++++++++++++++++++++++ scripts/tw-fix.ts | 24 +++++ src/components/CommandPalette.tsx | 10 +- src/components/ShortcutCheatsheet.tsx | 2 +- src/components/layout/Sidebar.tsx | 2 +- src/components/layout/ToolFrame.tsx | 2 +- src/components/layout/TwoPane.tsx | 2 +- src/components/ui/Badge.tsx | 2 +- src/components/ui/CodeArea.tsx | 2 +- src/components/ui/Kbd.tsx | 2 +- src/components/ui/Select.tsx | 2 +- src/tools/diff/MergePane.tsx | 2 +- src/tools/hash/HashTool.tsx | 2 +- src/tools/hmac/HmacTool.tsx | 4 +- src/tools/id-gen/IdGenTool.tsx | 4 +- src/tools/json/JsonTool.tsx | 2 +- src/tools/jwt/JwtTool.tsx | 12 +-- src/tools/number/NumberTool.tsx | 2 +- src/tools/text-stats/TextStatsTool.tsx | 4 +- src/tools/timestamp/TimestampTool.tsx | 4 +- src/tools/unicode/UnicodeTool.tsx | 4 +- src/tools/url/UrlTool.tsx | 2 +- 25 files changed, 226 insertions(+), 34 deletions(-) create mode 100644 scripts/tailwind.test.ts create mode 100644 scripts/tailwind.ts create mode 100644 scripts/tw-fix.ts diff --git a/package.json b/package.json index e69b854..50cccd4 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "lint": "oxlint", "deploy": "pnpm build && wrangler deploy", "seo": "vite-node scripts/seo.ts", + "tw:fix": "vite-node scripts/tw-fix.ts", "core:build": "pnpm --filter @fadeltd/devtools-core build" }, "dependencies": { @@ -36,6 +37,7 @@ "react-router": "8.4.0" }, "devDependencies": { + "@tailwindcss/node": "4.3.3", "@tailwindcss/vite": "4.3.3", "@types/node": "24.9.2", "@types/react": "19.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80210dc..8e9143e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: specifier: 8.4.0 version: 8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) devDependencies: + '@tailwindcss/node': + specifier: 4.3.3 + version: 4.3.3 '@tailwindcss/vite': specifier: 4.3.3 version: 4.3.3(vite@8.3.0(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.7.0)) diff --git a/scripts/tailwind.test.ts b/scripts/tailwind.test.ts new file mode 100644 index 0000000..3f3c821 --- /dev/null +++ b/scripts/tailwind.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { applyFindings, findInSource, findNonCanonical } from './tailwind' + +// A stand-in canonicalizer, so the extraction tests don't depend on Tailwind. +const fake = (token: string) => ({ 'w-[220px]': 'w-55', 'rounded-[2px]': 'rounded-xs' })[token] ?? token + +describe('findInSource', () => { + it('finds classes in className strings, cn() arguments and template literals', () => { + const src = [ + `const a =
`, + `const b = cn('p-2', on && 'rounded-[2px]')`, + 'const c = `gap-2 ${x} w-[220px]`', + ].join('\n') + const found = findInSource('x.tsx', src, fake) + expect(found.map((f) => [f.line, f.from, f.to])).toEqual([ + [1, 'w-[220px]', 'w-55'], + [2, 'rounded-[2px]', 'rounded-xs'], + [3, 'w-[220px]', 'w-55'], + ]) + }) + + it('ignores JSX text, so an apostrophe cannot open a fake string', () => { + const src = `const a =

Don't w-[220px] it's fine

` + expect(findInSource('x.tsx', src, fake)).toEqual([]) + }) + + it('reports offsets that applyFindings can rewrite exactly', () => { + const src = `` + const out = applyFindings(src, findInSource('x.tsx', src, fake)) + expect(out).toBe(``) + }) +}) + +describe('src/', () => { + // The guard: new code must use the canonical spelling. Run `pnpm tw:fix --write`. + it('has no non-canonical Tailwind classes', { timeout: 60_000 }, async () => { + const findings = await findNonCanonical() + expect(findings.map((f) => `${f.file}:${f.line} ${f.from} → ${f.to}`)).toEqual([]) + }) +}) diff --git a/scripts/tailwind.ts b/scripts/tailwind.ts new file mode 100644 index 0000000..01be5d3 --- /dev/null +++ b/scripts/tailwind.ts @@ -0,0 +1,123 @@ +/** + * Find Tailwind classes that have a canonical spelling, using Tailwind's own + * canonicalizer -- the engine behind the editor's "can be written as" hints. + * + * Only string and template literals are inspected (parsed with oxc via Vite), + * never JSX text, so an apostrophe in "Don't" cannot open a fake string. + * Each token is canonicalized on its own: collapsing `mt-2 mb-2` into `my-2` + * would change which classes a conditional `cn()` branch toggles. + */ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' +import { __unstable__loadDesignSystem } from '@tailwindcss/node' +import { parseAst } from 'vite' + +export interface Finding { + file: string + line: number + column: number + /** Absolute offset of the token in the file. */ + offset: number + from: string + to: string +} + +type DesignSystem = Awaited> + +const ROOT = join(import.meta.dirname, '..') +const CSS_ENTRY = join(ROOT, 'src/styles/global.css') + +export async function loadDesignSystem(): Promise { + return __unstable__loadDesignSystem(readFileSync(CSS_ENTRY, 'utf8'), { + base: join(ROOT, 'src/styles'), + }) +} + +export function sourceFiles(dir = join(ROOT, 'src')): string[] { + const out: string[] = [] + for (const name of readdirSync(dir)) { + const path = join(dir, name) + if (statSync(path).isDirectory()) out.push(...sourceFiles(path)) + else if (name.endsWith('.tsx')) out.push(path) + } + return out +} + +interface Segment { + start: number + text: string +} + +/** Raw text of every string literal and template chunk, with its offset. */ +function stringSegments(source: string): Segment[] { + const segments: Segment[] = [] + const visit = (node: unknown): void => { + if (Array.isArray(node)) { + for (const child of node) visit(child) + return + } + if (node === null || typeof node !== 'object') return + const n = node as { type?: string; start: number; end: number; value?: unknown } + if (n.type === 'Literal' && typeof n.value === 'string') { + // start/end include the quotes. + segments.push({ start: n.start + 1, text: source.slice(n.start + 1, n.end - 1) }) + } else if (n.type === 'TemplateElement') { + // oxc's span includes the opening delimiter (` or }) and the closing one + // (` or ${), so the raw text starts one character in. + const { raw } = n.value as { raw: string } + segments.push({ start: n.start + 1, text: raw }) + } + for (const key in n) { + if (key !== 'type' && key !== 'start' && key !== 'end') visit((n as Record)[key]) + } + } + visit(parseAst(source, { lang: 'tsx' })) + return segments +} + +export function findInSource( + file: string, + source: string, + canonical: (token: string) => string, +): Finding[] { + const findings: Finding[] = [] + for (const segment of stringSegments(source)) { + for (const match of segment.text.matchAll(/\S+/g)) { + const from = match[0] + const to = canonical(from) + if (to === from) continue + const offset = segment.start + match.index + const before = source.slice(0, offset) + const line = before.split('\n').length + findings.push({ file, line, column: offset - before.lastIndexOf('\n'), offset, from, to }) + } + } + return findings +} + +export function canonicalizer(ds: DesignSystem): (token: string) => string { + const cache = new Map() + return (token) => { + let hit = cache.get(token) + if (hit === undefined) { + // rem: 16 is what turns `w-[220px]` into `w-55`, as the editor suggests. + hit = ds.canonicalizeCandidates([token], { rem: 16 })[0] ?? token + cache.set(token, hit) + } + return hit + } +} + +export async function findNonCanonical(files = sourceFiles()): Promise { + const canonical = canonicalizer(await loadDesignSystem()) + return files.flatMap((file) => findInSource(relative(ROOT, file), readFileSync(file, 'utf8'), canonical)) +} + +/** Apply findings for one file, last offset first so earlier offsets stay valid. */ +export function applyFindings(source: string, findings: Finding[]): string { + let out = source + for (const f of findings.toSorted((a, b) => b.offset - a.offset)) { + out = out.slice(0, f.offset) + f.to + out.slice(f.offset + f.from.length) + } + return out +} diff --git a/scripts/tw-fix.ts b/scripts/tw-fix.ts new file mode 100644 index 0000000..521e4ae --- /dev/null +++ b/scripts/tw-fix.ts @@ -0,0 +1,24 @@ +/** + * List Tailwind classes in src/ that have a canonical spelling, and rewrite + * them with --write. The test in tailwind.test.ts fails CI on any that remain. + * + * pnpm tw:fix # report + * pnpm tw:fix --write # rewrite in place + */ +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { applyFindings, findNonCanonical } from './tailwind' + +const findings = await findNonCanonical() +for (const f of findings) console.log(`${f.file}:${f.line}:${f.column} ${f.from} → ${f.to}`) + +if (process.argv.includes('--write')) { + const byFile = Map.groupBy(findings, (f) => f.file) + for (const [file, list] of byFile) { + const path = join(import.meta.dirname, '..', file) + writeFileSync(path, applyFindings(readFileSync(path, 'utf8'), list)) + } + console.log(`tw:fix: rewrote ${findings.length} classes in ${byFile.size} files`) +} else { + console.log(`tw:fix: ${findings.length} non-canonical classes${findings.length > 0 ? ' (run with --write)' : ''}`) +} diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index b2b18f8..2b1fc65 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -49,7 +49,7 @@ export function CommandPalette({ open, onOpenChange }: Props) { shouldFilter={false} loop label="Search tools" - className="panel relative z-10 flex max-h-[400px] w-full max-w-[640px] flex-col overflow-hidden bg-surface-2" + className="panel relative z-10 flex max-h-100 w-full max-w-160 flex-col overflow-hidden bg-surface-2" onKeyDown={(e) => { if (e.key === 'Escape') { e.preventDefault() @@ -73,7 +73,7 @@ export function CommandPalette({ open, onOpenChange }: Props) { {recent.length > 0 && ( {recent.map((tool) => ( {rest.map((tool) => ( {/* Pinning is an action you go looking for, so it only appears once you have typed something. */} @@ -181,7 +181,7 @@ function ToolRow({ {tool.title} {pinIndex >= 0 && ( - + {renderCombo(`mod+${pinIndex + 1}`)} )} diff --git a/src/components/ShortcutCheatsheet.tsx b/src/components/ShortcutCheatsheet.tsx index 0ec23d1..478669a 100644 --- a/src/components/ShortcutCheatsheet.tsx +++ b/src/components/ShortcutCheatsheet.tsx @@ -21,7 +21,7 @@ export function ShortcutCheatsheet({ open, onClose }: { open: boolean; onClose: return (
+ + + } + > + set('input', e.target.value)} + placeholder={'Paste a .env file or a JSON object\n\nAPI_URL=https://api.example.com\nexport TOKEN="…"'} + aria-label="Input" + /> + } + right={ +
+ + {result.issues.length > 0 && } + {state.input.trim() !== '' && ( +
+ + {formatCount(result.entries.length)} {result.entries.length === 1 ? 'variable' : 'variables'} + + + read as {FORMAT_NAME[result.from]} + {state.from === 'auto' ? ' (detected)' : ''} + + {errorCount > 0 && {plural(errorCount, 'error')}} + {warningCount > 0 && {plural(warningCount, 'warning')}} + {state.mask && values masked · Copy copies the real ones} +
+ )} +
+ } + /> + + ) +} + +function plural(n: number, word: string): string { + return `${formatCount(n)} ${word}${n === 1 ? '' : 's'}` +} + +function Issues({ issues }: { issues: readonly EnvIssue[] }) { + const shown = issues.slice(0, MAX_ISSUES) + return ( +
    + {shown.map((issue) => { + const error = issue.severity === 'error' + const Icon = error ? CircleX : TriangleAlert + return ( +
  • + + L{issue.line} + {issue.message} +
  • + ) + })} + {issues.length > MAX_ISSUES && ( +
  • … and {formatCount(issues.length - MAX_ISSUES)} more
  • + )} +
+ ) } From d3fa469807dac6a62fb81024eb49bfb49da8526e Mon Sep 17 00:00:00 2001 From: fadeltd Date: Fri, 30 Oct 2026 00:20:59 +0700 Subject: [PATCH 06/17] QR Code: text, URL and Wi-Fi, as SVG or PNG (B-23) Core `qr` builds the payload (plain text; a URL checked by the platform parser, with https:// added to a bare host and warnings for non-http(s) schemes; a Wi-Fi `WIFI:` string with `\ ; , : "` escaped and WPA/WEP/open security), encodes it with uqr at a fixed ECC level and quiet zone, and returns the module matrix with its version, size, mode and byte count. Too-long input is reported as data with the byte count and the version 40 limit for that mode and ECC level, checked before uqr runs so a pasted megabyte fails fast; the capacity table is pinned against uqr in the tests. Lone surrogates are refused instead of being silently replaced. The SVG download is a single run-length-merged path plus a background rect, with colours validated as #rrggbb before they reach markup. The same path string drives the live preview as a React `d` prop, so no SVG string ever enters the DOM. The PNG is drawn to a canvas in the shell at a whole number of pixels per module, so edges stay sharp. The tool persists everything except the Wi-Fi password and its show/hide state, via the `redact` option, and the encoded-text readout masks the password while the field is hidden. Colours warn below 4:1 contrast and when inverted, and the code warns on a quiet zone under 2 modules and on dense versions. --- packages/core/src/qr/color.test.ts | 51 +++ packages/core/src/qr/color.ts | 80 +++++ packages/core/src/qr/encode.test.ts | 144 ++++++++ packages/core/src/qr/encode.ts | 145 ++++++++ packages/core/src/qr/index.ts | 6 +- packages/core/src/qr/payload.test.ts | 160 +++++++++ packages/core/src/qr/payload.ts | 142 ++++++++ packages/core/src/qr/svg.test.ts | 89 +++++ packages/core/src/qr/svg.ts | 81 +++++ src/tools/qr/QrTool.tsx | 486 ++++++++++++++++++++++++++- src/tools/qr/png.ts | 38 +++ 11 files changed, 1419 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/qr/color.test.ts create mode 100644 packages/core/src/qr/color.ts create mode 100644 packages/core/src/qr/encode.test.ts create mode 100644 packages/core/src/qr/encode.ts create mode 100644 packages/core/src/qr/payload.test.ts create mode 100644 packages/core/src/qr/payload.ts create mode 100644 packages/core/src/qr/svg.test.ts create mode 100644 packages/core/src/qr/svg.ts create mode 100644 src/tools/qr/png.ts diff --git a/packages/core/src/qr/color.test.ts b/packages/core/src/qr/color.test.ts new file mode 100644 index 0000000..414fecb --- /dev/null +++ b/packages/core/src/qr/color.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { checkColors, contrastRatio, normalizeHex } from './color.ts' + +describe('normalizeHex', () => { + it.each([ + ['#000', '#000000'], + ['#ABCDEF', '#abcdef'], + ['fff', '#ffffff'], + [' #123456 ', '#123456'], + ])('%j → %j', (input, expected) => { + expect(normalizeHex(input)).toBe(expected) + }) + + it.each(['', '#', '#12', '#1234', '#12345678', 'red', '#ggg'])('rejects %j', (input) => { + expect(normalizeHex(input)).toBeNull() + }) +}) + +describe('contrastRatio', () => { + it('is 21 for black on white and 1 for a colour on itself', () => { + expect(contrastRatio({ r: 0, g: 0, b: 0 }, { r: 255, g: 255, b: 255 })).toBeCloseTo(21, 5) + expect(contrastRatio({ r: 10, g: 200, b: 30 }, { r: 10, g: 200, b: 30 })).toBe(1) + }) +}) + +describe('checkColors', () => { + it('passes black on white with no warnings', () => { + expect(checkColors('#000', '#fff')).toMatchObject({ + ok: true, + foreground: '#000000', + background: '#ffffff', + warnings: [], + }) + }) + + it('warns on low contrast', () => { + const r = checkColors('#777777', '#888888') + expect(r.ok && r.warnings.some((w) => w.startsWith('Low contrast'))).toBe(true) + }) + + it('warns on an inverted code even at full contrast', () => { + const r = checkColors('#ffffff', '#000000') + expect(r.ok && r.warnings).toHaveLength(1) + expect(r.ok && r.warnings[0]).toMatch(/inverted/) + }) + + it('names the invalid field', () => { + expect(checkColors('nope', '#fff')).toMatchObject({ ok: false, field: 'foreground' }) + expect(checkColors('#000', 'nope')).toMatchObject({ ok: false, field: 'background' }) + }) +}) diff --git a/packages/core/src/qr/color.ts b/packages/core/src/qr/color.ts new file mode 100644 index 0000000..bcc7865 --- /dev/null +++ b/packages/core/src/qr/color.ts @@ -0,0 +1,80 @@ +/** + * Colour checks for the code's two colours. A QR scanner thresholds the image + * to black and white, so what matters is the luminance gap, not the hue. + */ + +export interface Rgb { + r: number + g: number + b: number +} + +const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i + +/** + * `#rgb` or `#rrggbb`, with or without the `#`, normalised to lowercase + * `#rrggbb`. No alpha: a translucent code scans differently on every + * background it is printed over. + */ +export function normalizeHex(input: string): string | null { + const m = HEX.exec(input.trim()) + if (m === null) return null + const hex = m[1]!.toLowerCase() + const full = hex.length === 3 ? [...hex].map((c) => c + c).join('') : hex + return `#${full}` +} + +function toRgb(hex: string): Rgb { + const n = Number.parseInt(hex.slice(1), 16) + return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff } +} + +/** sRGB channel (0-255) to linear light, the WCAG 2 formula. */ +function linear(c: number): number { + const s = c / 255 + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 +} + +/** WCAG 2 relative luminance, 0 (black) to 1 (white). */ +export function luminance({ r, g, b }: Rgb): number { + return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) +} + +/** WCAG 2 contrast ratio, 1 to 21. */ +export function contrastRatio(a: Rgb, b: Rgb): number { + const la = luminance(a) + const lb = luminance(b) + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05) +} + +/** + * Below this the code tends to fail on phone cameras under poor light. It is + * stricter than the 3:1 WCAG asks of UI parts, because a scanner has to + * binarise the image, and a glossy print or dim room eats contrast. + */ +export const MIN_SCAN_CONTRAST = 4 + +export type ColorCheck = + | { ok: true; foreground: string; background: string; contrast: number; warnings: string[] } + | { ok: false; field: 'foreground' | 'background'; message: string } + +export function checkColors(foreground: string, background: string): ColorCheck { + const fg = normalizeHex(foreground) + if (fg === null) return { ok: false, field: 'foreground', message: `“${foreground}” is not a hex colour like #000000.` } + const bg = normalizeHex(background) + if (bg === null) return { ok: false, field: 'background', message: `“${background}” is not a hex colour like #ffffff.` } + + const fgRgb = toRgb(fg) + const bgRgb = toRgb(bg) + const contrast = contrastRatio(fgRgb, bgRgb) + const warnings: string[] = [] + if (contrast < MIN_SCAN_CONTRAST) { + warnings.push(`Low contrast (${contrast.toFixed(1)}:1). Scanners may fail; aim for ${MIN_SCAN_CONTRAST}:1 or more.`) + } + // Checked separately from contrast: white-on-black has full contrast but + // many scanners only look for dark modules on a light ground. + if (luminance(fgRgb) > luminance(bgRgb)) { + warnings.push('Light modules on a dark background: some scanners cannot read an inverted code.') + } + return { ok: true, foreground: fg, background: bg, contrast, warnings } +} diff --git a/packages/core/src/qr/encode.test.ts b/packages/core/src/qr/encode.test.ts new file mode 100644 index 0000000..9e28c2a --- /dev/null +++ b/packages/core/src/qr/encode.test.ts @@ -0,0 +1,144 @@ +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' +import { CAPACITY, ECC_LEVELS, detectMode, encodeQr, type QrCode, type QrMode } from './encode.ts' + +function ok(result: ReturnType): QrCode { + if (!result.ok) throw new Error(result.message) + return result +} + +/** A 7x7 finder: dark ring, light ring, dark 3x3 centre. */ +function hasFinder(matrix: boolean[][], top: number, left: number): boolean { + for (let dy = 0; dy < 7; dy++) { + for (let dx = 0; dx < 7; dx++) { + const ring = Math.max(Math.abs(dy - 3), Math.abs(dx - 3)) + if (matrix[top + dy]![left + dx] !== (ring !== 2)) return false + } + } + return true +} + +describe('encodeQr: shape', () => { + it('is square, sized 17 + 4v + 2·border', () => { + fc.assert( + fc.property( + fc.string({ minLength: 1, maxLength: 300 }).filter((s) => !/[\uD800-\uDFFF]/u.test(s)), + fc.constantFrom(...ECC_LEVELS), + fc.integer({ min: 0, max: 8 }), + (text, ecc, border) => { + const qr = ok(encodeQr(text, { ecc, border })) + expect(qr.size).toBe(17 + 4 * qr.version + 2 * border) + expect(qr.modules).toBe(17 + 4 * qr.version) + expect(qr.matrix).toHaveLength(qr.size) + for (const row of qr.matrix) expect(row).toHaveLength(qr.size) + }, + ), + { numRuns: 60 }, + ) + }) + + it('has finder patterns in three corners and not the fourth', () => { + const qr = ok(encodeQr('https://devtools.fadeltd.dev/qr', { ecc: 'M', border: 4 })) + const far = qr.size - qr.border - 7 + expect(hasFinder(qr.matrix, qr.border, qr.border)).toBe(true) + expect(hasFinder(qr.matrix, qr.border, far)).toBe(true) + expect(hasFinder(qr.matrix, far, qr.border)).toBe(true) + expect(hasFinder(qr.matrix, far, far)).toBe(false) + }) + + it('keeps the quiet zone light', () => { + const qr = ok(encodeQr('hello', { border: 4 })) + for (let i = 0; i < qr.size; i++) { + for (let b = 0; b < 4; b++) { + expect(qr.matrix[b]![i]).toBe(false) + expect(qr.matrix[qr.size - 1 - b]![i]).toBe(false) + expect(qr.matrix[i]![b]).toBe(false) + expect(qr.matrix[i]![qr.size - 1 - b]).toBe(false) + } + } + }) + + it('defaults to border 4 and clamps nonsense borders', () => { + expect(ok(encodeQr('x')).border).toBe(4) + expect(ok(encodeQr('x', { border: -3 })).border).toBe(0) + expect(ok(encodeQr('x', { border: Number.NaN })).border).toBe(4) + expect(ok(encodeQr('x', { border: 2.6 })).border).toBe(3) + }) + + it('reports the ECC level asked for, without boosting it', () => { + // "hi" fits version 1 at H, so a boosting encoder would raise L to H. + expect(ok(encodeQr('hi', { ecc: 'L' })).ecc).toBe('L') + }) + + it('picks a larger version at a higher ECC level for the same data', () => { + const text = 'x'.repeat(100) + expect(ok(encodeQr(text, { ecc: 'H' })).version).toBeGreaterThan(ok(encodeQr(text, { ecc: 'L' })).version) + }) +}) + +describe('encodeQr: warnings', () => { + it('warns on a thin quiet zone, not on the default', () => { + expect(ok(encodeQr('x', { border: 1 })).warnings[0]).toMatch(/quiet zone of 1 module /) + expect(ok(encodeQr('x', { border: 2 })).warnings).toEqual([]) + }) + + it('warns on a dense version', () => { + expect(ok(encodeQr('x'.repeat(1500), { ecc: 'L' })).warnings.some((w) => w.includes('dense'))).toBe(true) + expect(ok(encodeQr('x'.repeat(50))).warnings).toEqual([]) + }) +}) + +describe('encodeQr: modes and byte counts', () => { + it('detects the mode uqr will use', () => { + expect(detectMode('0123')).toBe('numeric') + expect(detectMode('HELLO WORLD: $1.50')).toBe('alphanumeric') + expect(detectMode('hello')).toBe('byte') + // Non-ASCII digits are not numeric mode. + expect(detectMode('١٢٣')).toBe('byte') + }) + + it('counts UTF-8 bytes in byte mode', () => { + expect(ok(encodeQr('é☕🛜')).bytes).toBe(2 + 3 + 4) + }) + + it('refuses empty input and lone surrogates as data', () => { + expect(encodeQr('')).toMatchObject({ ok: false, reason: 'empty' }) + expect(encodeQr('a\uD800b')).toMatchObject({ ok: false, reason: 'invalid' }) + }) +}) + +describe('encodeQr: too long', () => { + const sample: Record = { numeric: '7', alphanumeric: 'A', byte: 'a' } + + // Each limit fits at version 40, and one more character fails. This pins the + // capacity table to what uqr actually does. + for (const mode of ['numeric', 'alphanumeric', 'byte'] as const) { + for (const ecc of ECC_LEVELS) { + it(`${mode} at ${ecc}: ${CAPACITY[mode][ecc]} fits, one more does not`, () => { + const limit = CAPACITY[mode][ecc] + expect(ok(encodeQr(sample[mode].repeat(limit), { ecc })).version).toBe(40) + expect(encodeQr(sample[mode].repeat(limit + 1), { ecc })).toMatchObject({ + ok: false, + reason: 'too-long', + bytes: limit + 1, + limit, + mode, + ecc, + }) + }) + } + } + + it('counts multibyte characters against the byte limit', () => { + // 1000 × 2-byte é = 2000 bytes: over Q's 1663 although only 1000 characters. + const r = encodeQr('é'.repeat(1000), { ecc: 'Q' }) + expect(r).toMatchObject({ ok: false, reason: 'too-long', bytes: 2000, limit: 1663 }) + expect(!r.ok && r.message).toMatch(/2000 bytes, the limit is 1663/) + }) + + it('fails fast on a huge paste', () => { + const start = performance.now() + expect(encodeQr('x'.repeat(5_000_000)).ok).toBe(false) + expect(performance.now() - start).toBeLessThan(500) + }) +}) diff --git a/packages/core/src/qr/encode.ts b/packages/core/src/qr/encode.ts new file mode 100644 index 0000000..77e8462 --- /dev/null +++ b/packages/core/src/qr/encode.ts @@ -0,0 +1,145 @@ +import { encode } from 'uqr' + +export type Ecc = 'L' | 'M' | 'Q' | 'H' + +export const ECC_LEVELS: readonly Ecc[] = ['L', 'M', 'Q', 'H'] + +/** How much of the symbol can be damaged and still read, for the UI's labels. */ +export const ECC_RECOVERY: Readonly> = { L: '7%', M: '15%', Q: '25%', H: '30%' } + +/** The encoding uqr picks: the densest single mode the whole payload fits. */ +export type QrMode = 'numeric' | 'alphanumeric' | 'byte' + +/** + * Characters that fit in a version 40 symbol, from ISO/IEC 18004 table 7. In + * numeric and alphanumeric mode every character is one ASCII byte, so these + * are byte limits too. Checked against uqr itself in the tests. + */ +export const CAPACITY: Readonly>>> = { + numeric: { L: 7089, M: 5596, Q: 3993, H: 3057 }, + alphanumeric: { L: 4296, M: 3391, Q: 2420, H: 1852 }, + byte: { L: 2953, M: 2331, Q: 1663, H: 1273 }, +} + +export const DEFAULT_BORDER = 4 +const MAX_BORDER = 32 +// Below two modules, a busy surround blends into the finder patterns. +const MIN_SAFE_BORDER = 2 +// From here a phone has to be held close or the code shown big: modules get +// small and the camera's focus starts to matter. +const DENSE_VERSION = 20 + +export interface QrOptions { + ecc: Ecc + /** Quiet zone in modules. The spec asks for 4; scanners struggle below 2. */ + border: number +} + +export const DEFAULT_QR_OPTIONS: QrOptions = { ecc: 'M', border: DEFAULT_BORDER } + +export interface QrCode { + ok: true + /** `matrix[y][x]`, true for a dark module, quiet zone included. */ + matrix: boolean[][] + /** Width and height of `matrix`: `17 + 4 * version + 2 * border`. */ + size: number + /** 1 to 40. */ + version: number + /** Modules across the symbol itself, without the quiet zone. */ + modules: number + border: number + ecc: Ecc + mode: QrMode + /** UTF-8 length of the payload. */ + bytes: number + /** Will encode, but may scan badly. */ + warnings: string[] +} + +export type QrError = + | { ok: false; reason: 'empty' | 'invalid'; message: string } + | { ok: false; reason: 'too-long'; message: string; bytes: number; limit: number; mode: QrMode; ecc: Ecc } + +export type QrResult = QrCode | QrError + +// The same tests uqr uses to choose a mode, so the capacity check agrees with +// what it will actually encode. No `u` flag: `\d` must stay ASCII-only. +const NUMERIC = /^\d*$/ +const ALPHANUMERIC = /^[A-Z0-9 $%*+./:-]*$/ + +export function detectMode(text: string): QrMode { + if (NUMERIC.test(text)) return 'numeric' + if (ALPHANUMERIC.test(text)) return 'alphanumeric' + return 'byte' +} + +// With the `u` flag a surrogate pair is one code point outside this range, so +// only an unpaired half matches. +const LONE_SURROGATE = /[\uD800-\uDFFF]/u + +function clampBorder(border: number): number { + if (!Number.isFinite(border)) return DEFAULT_BORDER + return Math.min(MAX_BORDER, Math.max(0, Math.round(border))) +} + +/** + * Encode a payload into a module matrix at a fixed ECC level. The level is the + * user's choice, so uqr's `boostEcc` stays off: the result reports exactly the + * level that was asked for. + */ +export function encodeQr(payload: string, options: Partial = {}): QrResult { + const ecc = options.ecc ?? DEFAULT_QR_OPTIONS.ecc + const border = clampBorder(options.border ?? DEFAULT_QR_OPTIONS.border) + + if (payload === '') return { ok: false, reason: 'empty', message: 'Nothing to encode.' } + // uqr converts to UTF-8 through encodeURI, which throws on a lone surrogate; + // TextEncoder would instead swap it for U+FFFD and encode something else. + if (LONE_SURROGATE.test(payload)) { + return { ok: false, reason: 'invalid', message: 'The text contains a broken character (an unpaired surrogate).' } + } + + const mode = detectMode(payload) + const bytes = mode === 'byte' ? new TextEncoder().encode(payload).length : payload.length + const limit = CAPACITY[mode][ecc] + // Checked up front rather than by catching uqr's error: for a pasted + // megabyte, uqr would build an eight-million-entry bit array before failing. + if (bytes > limit) { + const unit = mode === 'byte' ? 'bytes' : 'characters' + const hint = ecc === 'L' ? '' : ' A lower error correction level fits more.' + return { + ok: false, + reason: 'too-long', + message: `Too long for any QR version at error correction ${ecc}: ${bytes} ${unit}, the limit is ${limit}.${hint}`, + bytes, + limit, + mode, + ecc, + } + } + + try { + const qr = encode(payload, { ecc, border, boostEcc: false }) + const warnings: string[] = [] + if (border < MIN_SAFE_BORDER) { + warnings.push(`A quiet zone of ${border} module${border === 1 ? '' : 's'} only scans on a plain, light surround. The spec asks for ${DEFAULT_BORDER}.`) + } + if (qr.version >= DENSE_VERSION) { + warnings.push(`Version ${qr.version} is dense. Print or show it large, or shorten the content.`) + } + return { + ok: true, + matrix: qr.data, + size: qr.size, + version: qr.version, + modules: 17 + 4 * qr.version, + border, + ecc, + mode, + bytes, + warnings, + } + } catch (err) { + // Not expected after the checks above, but a library throw must not reach the UI. + return { ok: false, reason: 'invalid', message: err instanceof Error ? err.message : String(err) } + } +} diff --git a/packages/core/src/qr/index.ts b/packages/core/src/qr/index.ts index 7b1c1c7..710ce29 100644 --- a/packages/core/src/qr/index.ts +++ b/packages/core/src/qr/index.ts @@ -1,2 +1,4 @@ -// Placeholder until the engine lands. -export type Pending = never +export * from './color.ts' +export * from './encode.ts' +export * from './payload.ts' +export * from './svg.ts' diff --git a/packages/core/src/qr/payload.test.ts b/packages/core/src/qr/payload.test.ts new file mode 100644 index 0000000..ffb791e --- /dev/null +++ b/packages/core/src/qr/payload.test.ts @@ -0,0 +1,160 @@ +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' +import { buildText, buildUrl, buildWifi, escapeWifi, type WifiInput } from './payload.ts' + +/** The inverse of escapeWifi, as a scanner reads a field. */ +const unescapeWifi = (s: string) => s.replace(/\\(.)/gs, '$1') + +describe('escapeWifi', () => { + it.each([ + ['home', 'home'], + ['a;b', 'a\\;b'], + ['a,b', 'a\\,b'], + ['a:b', 'a\\:b'], + ['a"b', 'a\\"b'], + ['a\\b', 'a\\\\b'], + // The backslash is escaped once, not again after the others are. + ['\\;', '\\\\\\;'], + ['"My;Net,1:2"', '\\"My\\;Net\\,1\\:2\\"'], + ['café ☕ 🛜', 'café ☕ 🛜'], + ["it's (5G) & more!", "it's (5G) & more!"], + ])('%j → %j', (input, expected) => { + expect(escapeWifi(input)).toBe(expected) + }) + + it('round-trips through a scanner-side unescape', () => { + fc.assert(fc.property(fc.string(), (s) => unescapeWifi(escapeWifi(s)) === s)) + }) + + it('leaves no unescaped reserved character', () => { + fc.assert( + fc.property(fc.string(), (s) => { + // Strip escape pairs; whatever is left must hold no reserved character. + return !/[\\;,:"]/.test(escapeWifi(s).replace(/\\./gs, '')) + }), + ) + }) +}) + +describe('buildWifi', () => { + const base: WifiInput = { ssid: 'Home', password: 'correct horse', security: 'WPA', hidden: false } + + it('builds a WPA payload', () => { + expect(buildWifi(base)).toEqual({ ok: true, payload: 'WIFI:T:WPA;S:Home;P:correct horse;;', warnings: [] }) + }) + + it('adds H:true only for a hidden network', () => { + const r = buildWifi({ ...base, hidden: true }) + expect(r.ok && r.payload).toBe('WIFI:T:WPA;S:Home;P:correct horse;H:true;;') + }) + + it('escapes both the SSID and the password', () => { + const r = buildWifi({ ...base, ssid: 'Cafe;Bar', password: 'p:a"s,s\\w' }) + expect(r.ok && r.payload).toBe('WIFI:T:WPA;S:Cafe\\;Bar;P:p\\:a\\"s\\,s\\\\w;;') + }) + + it('omits the password for an open network', () => { + const r = buildWifi({ ...base, security: 'nopass', password: 'ignored' }) + expect(r.ok && r.payload).toBe('WIFI:T:nopass;S:Home;;') + }) + + it('builds a WEP payload', () => { + const r = buildWifi({ ...base, security: 'WEP', password: 'abcde' }) + expect(r).toEqual({ ok: true, payload: 'WIFI:T:WEP;S:Home;P:abcde;;', warnings: [] }) + }) + + it('requires an SSID, and a password unless open', () => { + expect(buildWifi({ ...base, ssid: '' }).ok).toBe(false) + expect(buildWifi({ ...base, password: '' }).ok).toBe(false) + expect(buildWifi({ ...base, security: 'WEP', password: '' }).ok).toBe(false) + expect(buildWifi({ ...base, security: 'nopass', password: '' }).ok).toBe(true) + }) + + it('warns on a WPA password outside 8..63, but accepts a 64-hex raw key', () => { + const short = buildWifi({ ...base, password: 'short' }) + expect(short.ok && short.warnings).toHaveLength(1) + const long = buildWifi({ ...base, password: 'x'.repeat(64) }) + expect(long.ok && long.warnings).toHaveLength(1) + const raw = buildWifi({ ...base, password: 'a1'.repeat(32) }) + expect(raw.ok && raw.warnings).toEqual([]) + }) + + it('warns on a WEP key of the wrong shape', () => { + const bad = buildWifi({ ...base, security: 'WEP', password: 'abcdef' }) + expect(bad.ok && bad.warnings).toHaveLength(1) + const hex = buildWifi({ ...base, security: 'WEP', password: '0123456789' }) + expect(hex.ok && hex.warnings).toEqual([]) + }) + + it('warns when the SSID is over 32 bytes, counting UTF-8', () => { + // Eight emoji are eight characters but 32 bytes; nine are 36. + const fits = buildWifi({ ...base, ssid: '🛜'.repeat(8) }) + expect(fits.ok && fits.warnings).toEqual([]) + const over = buildWifi({ ...base, ssid: '🛜'.repeat(9) }) + expect(over.ok && over.warnings[0]).toMatch(/36 bytes/) + }) +}) + +describe('buildText', () => { + it('passes text through untouched, whitespace included', () => { + expect(buildText(' hi\n')).toEqual({ ok: true, payload: ' hi\n', warnings: [] }) + }) + + it('refuses empty text', () => { + expect(buildText('').ok).toBe(false) + }) +}) + +describe('buildUrl', () => { + it('accepts an https URL as typed', () => { + expect(buildUrl('https://example.com')).toEqual({ + ok: true, + payload: 'https://example.com', + warnings: [], + addedScheme: false, + }) + }) + + it('trims surrounding whitespace', () => { + const r = buildUrl(' https://example.com/a?b=1 ') + expect(r.ok && r.payload).toBe('https://example.com/a?b=1') + }) + + it('adds https:// to a bare host', () => { + expect(buildUrl('example.com/path')).toMatchObject({ ok: true, payload: 'https://example.com/path', addedScheme: true }) + }) + + it('treats host:port as a host, not a scheme', () => { + expect(buildUrl('localhost:3000')).toMatchObject({ payload: 'https://localhost:3000', addedScheme: true }) + expect(buildUrl('example.com:8080/x')).toMatchObject({ payload: 'https://example.com:8080/x', addedScheme: true }) + }) + + it('keeps a real scheme followed by digits', () => { + expect(buildUrl('tel:12345')).toMatchObject({ ok: true, payload: 'tel:12345', addedScheme: false }) + }) + + it('warns on non-http(s) schemes', () => { + const tel = buildUrl('tel:+15551234') + expect(tel.ok && tel.warnings[0]).toMatch(/Not an http\(s\) URL/) + const js = buildUrl('javascript:alert(1)') + expect(js.ok && js.warnings[0]).toMatch(/blocked/) + }) + + it('warns on plain http', () => { + const r = buildUrl('http://example.com') + expect(r.ok && r.warnings[0]).toMatch(/unencrypted/) + }) + + it('warns on a dotless public host but not on localhost or IPv6', () => { + const r = buildUrl('https://intranet/x') + expect(r.ok && r.warnings[0]).toMatch(/no dot/) + expect(buildUrl('https://localhost/')).toMatchObject({ warnings: [] }) + expect(buildUrl('https://[::1]/')).toMatchObject({ warnings: [] }) + }) + + it('rejects what the URL parser rejects', () => { + expect(buildUrl('https://exa mple.com').ok).toBe(false) + expect(buildUrl('').ok).toBe(false) + expect(buildUrl(' ').ok).toBe(false) + }) +}) diff --git a/packages/core/src/qr/payload.ts b/packages/core/src/qr/payload.ts new file mode 100644 index 0000000..7934289 --- /dev/null +++ b/packages/core/src/qr/payload.ts @@ -0,0 +1,142 @@ +/** + * What goes inside the QR code. Each builder returns the exact string to + * encode plus any warnings, or an error -- never a throw, so the UI can render + * a half-typed form without a try/catch. + */ + +export interface PayloadOk { + ok: true + payload: string + /** Things that will encode fine but may not scan the way the user expects. */ + warnings: string[] +} + +export interface PayloadError { + ok: false + message: string +} + +export type PayloadResult = PayloadOk | PayloadError + +/** Plain text is encoded as-is: no trimming, since trailing spaces may be meant. */ +export function buildText(text: string): PayloadResult { + if (text === '') return { ok: false, message: 'Type some text to encode.' } + return { ok: true, payload: text, warnings: [] } +} + +const SCHEME = /^[a-z][a-z0-9+.-]*:/i + +// An IPv6 literal has no dot either, but is a perfectly good host. +const isDotless = (host: string) => !host.includes('.') && host !== 'localhost' && !host.startsWith('[') + +export interface UrlPayloadOk extends PayloadOk { + /** True when the input had no scheme and `https://` was put in front. */ + addedScheme: boolean +} + +export type UrlPayloadResult = UrlPayloadOk | PayloadError + +/** + * A URL, checked by the platform parser. A bare `example.com` gets `https://` + * in front, because without a scheme every scanner treats it as plain text and + * offers to search for it instead of opening it. + * + * The user's text is encoded as typed rather than as `URL.href`: the parser + * would add a trailing slash and rewrite the host, and the QR code should say + * what the user wrote. + */ +export function buildUrl(input: string): UrlPayloadResult { + const trimmed = input.trim() + if (trimmed === '') return { ok: false, message: 'Type a URL to encode.' } + + // `localhost:3000` and `example.com:8080` match SCHEME but are a host and + // port. `tel:123` is a real scheme followed by digits, so the test is the + // shape of the host, not the digits. + const hostAndPort = /^(?:localhost|[a-z0-9-]+(?:\.[a-z0-9-]+)+):\d+(?:[/?#]|$)/i.test(trimmed) + const hasScheme = SCHEME.test(trimmed) && !hostAndPort + const candidate = hasScheme ? trimmed : `https://${trimmed}` + + let url: URL + try { + url = new URL(candidate) + } catch { + return { ok: false, message: `“${trimmed}” is not a valid URL.` } + } + + const warnings: string[] = [] + const scheme = url.protocol.slice(0, -1).toLowerCase() + if (scheme === 'javascript' || scheme === 'data' || scheme === 'file') { + warnings.push(`${scheme}: URLs are blocked or ignored by most scanners.`) + } else if (scheme !== 'http' && scheme !== 'https') { + warnings.push(`Not an http(s) URL: scanners only open ${scheme}: if an app handles it.`) + } else if (scheme === 'http') { + warnings.push('http:// is unencrypted; some phones warn before opening it.') + } + if ((scheme === 'http' || scheme === 'https') && isDotless(url.hostname)) { + warnings.push(`“${url.hostname}” has no dot, so it is not a public host name.`) + } + if (/\s/.test(trimmed)) { + warnings.push('The URL contains spaces; percent-encode them as %20 for reliable scanning.') + } + + return { ok: true, payload: candidate, warnings, addedScheme: !hasScheme } +} + +export type WifiSecurity = 'WPA' | 'WEP' | 'nopass' + +export interface WifiInput { + ssid: string + password: string + security: WifiSecurity + hidden: boolean +} + +/** + * Escape a field of the `WIFI:` scheme. The ZXing spec reserves exactly these + * five characters; the backslash must go first or it would double-escape the + * others. + */ +export function escapeWifi(value: string): string { + return value.replace(/[\\;,:"]/g, (ch) => `\\${ch}`) +} + +const utf8Length = (s: string) => new TextEncoder().encode(s).length +const isHex = (s: string) => /^[0-9a-f]+$/i.test(s) + +/** + * `WIFI:T:WPA;S:;P:;H:true;;` -- the format iOS and Android + * cameras join a network from. `T:WPA` covers WPA2 and WPA3 as well; phones + * negotiate the actual protocol. + */ +export function buildWifi(input: WifiInput): PayloadResult { + if (input.ssid === '') return { ok: false, message: 'Type the network name (SSID).' } + + const warnings: string[] = [] + // 802.11 caps an SSID at 32 bytes, not characters: an emoji is four. + if (utf8Length(input.ssid) > 32) { + warnings.push(`The network name is ${utf8Length(input.ssid)} bytes; Wi-Fi allows at most 32.`) + } + + const pw = input.password + if (input.security === 'WPA') { + if (pw === '') return { ok: false, message: 'WPA needs a password. Choose “None” for an open network.' } + // A 64-character hex string is a raw pre-shared key, which WPA accepts. + const rawKey = pw.length === 64 && isHex(pw) + if (!rawKey && (pw.length < 8 || pw.length > 63)) { + warnings.push(`A WPA password is 8 to 63 characters; this one is ${pw.length}.`) + } + } else if (input.security === 'WEP') { + if (pw === '') return { ok: false, message: 'WEP needs a key. Choose “None” for an open network.' } + const valid = pw.length === 5 || pw.length === 13 || ((pw.length === 10 || pw.length === 26) && isHex(pw)) + if (!valid) warnings.push('A WEP key is 5 or 13 characters, or 10 or 26 hex digits.') + } + + let payload = `WIFI:T:${input.security};S:${escapeWifi(input.ssid)};` + // An open network has no password field at all; an empty `P:;` confuses some + // Android scanners into prompting for one. + if (input.security !== 'nopass') payload += `P:${escapeWifi(pw)};` + if (input.hidden) payload += 'H:true;' + payload += ';' + + return { ok: true, payload, warnings } +} diff --git a/packages/core/src/qr/svg.test.ts b/packages/core/src/qr/svg.test.ts new file mode 100644 index 0000000..c2064d7 --- /dev/null +++ b/packages/core/src/qr/svg.test.ts @@ -0,0 +1,89 @@ +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' +import { encodeQr } from './encode.ts' +import { buildSvg, modulePath, pixelScale, PIXEL_SIZE_MAX, PIXEL_SIZE_MIN } from './svg.ts' + +/** Paint a path made of `M x y h w v1 h-w z` runs back into cells. */ +function paint(d: string, size: number): boolean[][] { + const grid = Array.from({ length: size }, () => Array.from({ length: size }, () => false)) + for (const [, x, y, w] of d.matchAll(/M(\d+) (\d+)h(\d+)v1h-\3z/g)) { + for (let i = 0; i < Number(w); i++) grid[Number(y)]![Number(x) + i] = true + } + return grid +} + +describe('modulePath', () => { + it('merges horizontal runs', () => { + expect(modulePath([[true, true, false, true]])).toBe('M0 0h2v1h-2zM3 0h1v1h-1z') + }) + + it('is empty for an all-light matrix', () => { + expect( + modulePath([ + [false, false], + [false, false], + ]), + ).toBe('') + }) + + it('paints back exactly the dark modules', () => { + fc.assert( + fc.property( + fc + .integer({ min: 1, max: 24 }) + .chain((n) => fc.array(fc.array(fc.boolean(), { minLength: n, maxLength: n }), { minLength: n, maxLength: n })), + (matrix) => { + expect(paint(modulePath(matrix), matrix.length)).toEqual(matrix) + }, + ), + ) + }) +}) + +describe('pixelScale', () => { + it('snaps down to whole pixels per module', () => { + expect(pixelScale(512, 29)).toEqual({ scale: 17, pixels: 493 }) + }) + + it('never goes below one pixel per module', () => { + // A version 40 code with a border is 185 modules: more than the 64 px minimum. + expect(pixelScale(10, 185)).toEqual({ scale: 1, pixels: 185 }) + }) + + it('clamps the request to the allowed range', () => { + expect(pixelScale(1e9, 21).pixels).toBeLessThanOrEqual(PIXEL_SIZE_MAX) + expect(pixelScale(0, 21).pixels).toBeGreaterThan(PIXEL_SIZE_MIN - 21) + expect(pixelScale(Number.NaN, 21).scale).toBeGreaterThan(1) + }) +}) + +describe('buildSvg', () => { + const qr = encodeQr('https://example.com', { ecc: 'M', border: 4 }) + if (!qr.ok) throw new Error(qr.message) + const opts = { foreground: '#000000', background: '#ffffff', pixelSize: 512 } + + it('has exactly one path', () => { + const r = buildSvg(qr.matrix, opts) + expect(r.ok && r.svg.match(/ { + const r = buildSvg(qr.matrix, opts) + if (!r.ok) throw new Error(r.message) + const { pixels } = pixelScale(512, qr.size) + expect(r.pixels).toBe(pixels) + expect(r.svg).toContain(`viewBox="0 0 ${qr.size} ${qr.size}"`) + expect(r.svg).toContain(`width="${pixels}" height="${pixels}"`) + expect(r.svg).toContain(``) + expect(r.svg).toContain(`d="${modulePath(qr.matrix)}"`) + }) + + it('refuses anything but a #rrggbb colour, so nothing can break out of the attribute', () => { + expect(buildSvg(qr.matrix, { ...opts, foreground: 'red' }).ok).toBe(false) + expect(buildSvg(qr.matrix, { ...opts, background: '#fff"/>