From 71607385fa367b6242e7ffbe44c890625cda39a3 Mon Sep 17 00:00:00 2001 From: NePreby Date: Fri, 6 Nov 2026 12:10:05 +0700 Subject: [PATCH 1/6] feat(manifest): parse Cargo.lock and package-lock.json for transitive dependencies (closes #6) --- frontend/src/lib/__tests__/manifest.test.ts | 250 +++++++++++++++++- frontend/src/lib/manifest.ts | 272 +++++++++++++++++++- 2 files changed, 513 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/__tests__/manifest.test.ts b/frontend/src/lib/__tests__/manifest.test.ts index bc26051..aa7827a 100644 --- a/frontend/src/lib/__tests__/manifest.test.ts +++ b/frontend/src/lib/__tests__/manifest.test.ts @@ -1,9 +1,17 @@ import { describe, expect, it } from "vitest"; -import { equalWeights, parseManifest } from "../manifest"; +import { + equalWeights, + MAX_SPLIT_ROWS, + parseCargoLock, + parseCargoToml, + parseManifest, + parsePackageJson, + parsePackageLockJson, +} from "../manifest"; describe("parseCargoToml", () => { it("reads all common dependency forms", () => { - const m = parseManifest(` + const raw = ` [package] name = "demo" @@ -21,7 +29,8 @@ proptest = "1" [target.'cfg(unix)'.dependencies] libc = "0.2" # comment -`); +`; + const m = parseCargoToml(raw); expect(m.name).toBe("demo"); const byName = Object.fromEntries(m.deps.map((d) => [d.name, d])); expect(byName.serde.version).toBe("1.0"); @@ -36,17 +45,246 @@ libc = "0.2" # comment describe("parsePackageJson", () => { it("reads deps, peer and dev deps without duplicates", () => { - const m = parseManifest( - JSON.stringify({ name: "x", dependencies: { a: "^1" }, peerDependencies: { b: "2" }, devDependencies: { a: "1", c: "3" } }), - ); + const raw = JSON.stringify({ + name: "x", + dependencies: { a: "^1" }, + peerDependencies: { b: "2" }, + devDependencies: { a: "1", c: "3" }, + }); + const m = parsePackageJson(raw); expect(m.ecosystem).toBe("npm"); expect(m.deps.map((d) => `${d.name}:${d.kind}`)).toEqual(["a:normal", "b:peer", "c:dev"]); }); + it("errors on invalid json", () => { expect(() => parseManifest("{ nope", "package.json")).toThrow(/valid JSON/); }); }); +describe("parseCargoLock", () => { + const sampleCargoLock = ` +version = 3 + +[[package]] +name = "my-crate" +version = "0.1.0" +dependencies = [ + "serde", + "soroban-sdk 22.0.1", +] + +[[package]] +name = "serde" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "soroban-sdk" +version = "22.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "syn" +version = "2.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +`; + + it("parses direct dependencies only by default", () => { + const m = parseCargoLock(sampleCargoLock, { includeTransitive: false }); + expect(m.ecosystem).toBe("cargo"); + expect(m.name).toBe("my-crate"); + expect(m.deps.map((d) => d.name)).toEqual(["serde", "soroban-sdk"]); + expect(m.deps.find((d) => d.name === "serde")?.version).toBe("1.0.197"); + }); + + it("parses transitive dependencies when requested", () => { + const m = parseCargoLock(sampleCargoLock, { includeTransitive: true }); + expect(m.ecosystem).toBe("cargo"); + const names = m.deps.map((d) => d.name); + expect(names).toContain("serde"); + expect(names).toContain("serde_derive"); + expect(names).toContain("proc-macro2"); + expect(names).toContain("quote"); + expect(names).toContain("syn"); + expect(names).not.toContain("my-crate"); + }); + + it("caps results at MAX_SPLIT_ROWS (32) and de-duplicates", () => { + let bigLock = ` +[[package]] +name = "root" +version = "0.1.0" +dependencies = [] +`; + for (let i = 0; i < 50; i++) { + bigLock += ` +[[package]] +name = "dep-${i}" +version = "1.0.${i}" +source = "registry+https://..." +`; + } + // Duplicate one + bigLock += ` +[[package]] +name = "dep-0" +version = "1.0.99" +source = "registry+https://..." +`; + + const m = parseCargoLock(bigLock, { includeTransitive: true }); + expect(m.deps.length).toBe(MAX_SPLIT_ROWS); + expect(m.deps.length).toBeLessThanOrEqual(32); + const seen = new Set(m.deps.map((d) => d.name)); + expect(seen.size).toBe(m.deps.length); + }); + + it("routes through parseManifest with filename detection", () => { + const m = parseManifest(sampleCargoLock, "Cargo.lock", { includeTransitive: true }); + expect(m.ecosystem).toBe("cargo"); + expect(m.deps.length).toBeGreaterThan(2); + }); +}); + +describe("parsePackageLockJson", () => { + const sampleV3Lock = JSON.stringify({ + name: "flowpulse-frontend", + version: "0.1.0", + lockfileVersion: 3, + packages: { + "": { + name: "flowpulse-frontend", + version: "0.1.0", + dependencies: { + "@stellar/stellar-sdk": "^17.4.0", + react: "^19.3.1", + }, + devDependencies: { + typescript: "~5.9.3", + }, + }, + "node_modules/@stellar/stellar-sdk": { + version: "17.4.0", + dependencies: { + axios: "^1.7.0", + eventsource: "^2.0.2", + }, + }, + "node_modules/axios": { + version: "1.7.9", + }, + "node_modules/eventsource": { + version: "2.0.2", + }, + "node_modules/react": { + version: "19.3.1", + }, + "node_modules/typescript": { + version: "5.9.3", + dev: true, + }, + }, + }); + + it("extracts direct dependencies from v3 lockfile when includeTransitive is false", () => { + const m = parsePackageLockJson(sampleV3Lock, { includeTransitive: false }); + expect(m.ecosystem).toBe("npm"); + expect(m.name).toBe("flowpulse-frontend"); + const names = m.deps.map((d) => d.name); + expect(names).toEqual(["@stellar/stellar-sdk", "react", "typescript"]); + const ts = m.deps.find((d) => d.name === "typescript"); + expect(ts?.kind).toBe("dev"); + }); + + it("extracts transitive dependencies from v3 lockfile when includeTransitive is true", () => { + const m = parsePackageLockJson(sampleV3Lock, { includeTransitive: true }); + expect(m.ecosystem).toBe("npm"); + const names = m.deps.map((d) => d.name); + expect(names).toContain("@stellar/stellar-sdk"); + expect(names).toContain("axios"); + expect(names).toContain("eventsource"); + expect(names).toContain("react"); + expect(names).toContain("typescript"); + expect(names).not.toContain("flowpulse-frontend"); + }); + + it("extracts transitive dependencies from v1 lockfile", () => { + const sampleV1Lock = JSON.stringify({ + name: "v1-app", + version: "1.0.0", + lockfileVersion: 1, + dependencies: { + express: { + version: "4.19.2", + dependencies: { + accepts: { + version: "1.3.8", + dependencies: { + "mime-types": { + version: "2.1.35", + }, + }, + }, + }, + }, + }, + }); + + const direct = parsePackageLockJson(sampleV1Lock, { includeTransitive: false }); + expect(direct.deps.map((d) => d.name)).toEqual(["express"]); + + const transitive = parsePackageLockJson(sampleV1Lock, { includeTransitive: true }); + expect(transitive.deps.map((d) => d.name)).toEqual(["express", "accepts", "mime-types"]); + }); + + it("caps at maxRows and handles nested node_modules paths correctly", () => { + const sampleNested = JSON.stringify({ + name: "nested-app", + lockfileVersion: 2, + packages: { + "": { name: "nested-app" }, + "node_modules/a": { version: "1.0.0" }, + "node_modules/a/node_modules/b": { version: "2.0.0" }, + "node_modules/@scope/pkg": { version: "3.0.0" }, + }, + }); + + const m = parsePackageLockJson(sampleNested, { includeTransitive: true, maxRows: 2 }); + expect(m.deps.length).toBe(2); + expect(m.deps[0].name).toBe("a"); + expect(m.deps[1].name).toBe("b"); + }); + + it("routes through parseManifest with package-lock.json", () => { + const m = parseManifest(sampleV3Lock, "package-lock.json", { includeTransitive: true }); + expect(m.ecosystem).toBe("npm"); + expect(m.deps.map((d) => d.name)).toContain("axios"); + }); +}); + describe("equalWeights", () => { it("sums exactly to the target", () => { const w = equalWeights(7, 9000); diff --git a/frontend/src/lib/manifest.ts b/frontend/src/lib/manifest.ts index 306d01f..b4e4d68 100644 --- a/frontend/src/lib/manifest.ts +++ b/frontend/src/lib/manifest.ts @@ -1,8 +1,10 @@ /** * Dependency manifest parsing for split tables. - * Supports Cargo.toml, package.json, and Cargo.lock / package-lock.json (top-level only). + * Supports Cargo.toml, package.json, Cargo.lock, and package-lock.json (with transitive dependencies option). */ +export const MAX_SPLIT_ROWS = 32; + export type Ecosystem = "cargo" | "npm"; export interface Dependency { @@ -18,9 +20,34 @@ export interface Manifest { deps: Dependency[]; } -export function parseManifest(text: string, filename = ""): Manifest { +export interface ParseManifestOptions { + includeTransitive?: boolean; + maxRows?: number; +} + +export function parseManifest(text: string, filename = "", options?: ParseManifestOptions): Manifest { const t = text.trim(); - if (filename.endsWith(".json") || t.startsWith("{")) return parsePackageJson(t); + const lowerFile = filename.toLowerCase(); + + if (lowerFile.endsWith("cargo.lock") || (lowerFile.endsWith(".lock") && t.includes("[[package]]"))) { + return parseCargoLock(t, options); + } + + if ( + lowerFile.endsWith("package-lock.json") || + (lowerFile.endsWith(".json") && t.includes('"lockfileVersion"')) + ) { + return parsePackageLockJson(t, options); + } + + if (lowerFile.endsWith(".json") || t.startsWith("{")) { + return parsePackageJson(t); + } + + if (t.includes("[[package]]") && !t.includes("[package]") && !t.includes("[workspace]")) { + return parseCargoLock(t, options); + } + return parseCargoToml(t); } @@ -50,6 +77,245 @@ export function parsePackageJson(text: string): Manifest { return { ecosystem: "npm", name: typeof j.name === "string" ? j.name : undefined, deps }; } +/** + * Parses Cargo.lock and optionally includes transitive dependencies. + * De-duplicates and caps at maxRows (default MAX_SPLIT_ROWS = 32). + */ +export function parseCargoLock(text: string, options?: ParseManifestOptions): Manifest { + const includeTransitive = options?.includeTransitive ?? false; + const maxRows = options?.maxRows ?? MAX_SPLIT_ROWS; + + interface RawPackage { + name: string; + version: string; + source?: string; + dependencies: string[]; + } + + const packages: RawPackage[] = []; + let current: Partial | null = null; + let inDependencies = false; + + for (const raw of text.split(/\r?\n/)) { + const line = raw.replace(/\s+#.*$/, "").trim(); + if (!line || line.startsWith("#")) continue; + + if (line === "[[package]]") { + if (current && current.name && current.version) { + packages.push({ + name: current.name, + version: current.version, + source: current.source, + dependencies: current.dependencies ?? [], + }); + } + current = { dependencies: [] }; + inDependencies = false; + continue; + } + + if (!current) continue; + + if (inDependencies) { + if (line === "]") { + inDependencies = false; + continue; + } + const depMatch = line.match(/^"([^"]+)"/); + if (depMatch) { + const depRaw = depMatch[1].trim(); + const depName = depRaw.split(" ")[0]; + current.dependencies!.push(depName); + } + continue; + } + + const kv = line.match(/^("?)([A-Za-z0-9_\-]+)\1\s*=\s*(.+)$/); + if (!kv) continue; + const key = kv[2]; + const val = kv[3].trim(); + + if (key === "name") { + current.name = unquote(val); + } else if (key === "version") { + current.version = unquote(val); + } else if (key === "source") { + current.source = unquote(val); + } else if (key === "dependencies") { + if (val === "[") { + inDependencies = true; + } else if (val.startsWith("[") && val.endsWith("]")) { + const inner = val.slice(1, -1).trim(); + if (inner) { + const list = inner.split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean); + current.dependencies = list.map((d) => d.split(" ")[0]); + } + } + } + } + + if (current && current.name && current.version) { + packages.push({ + name: current.name, + version: current.version, + source: current.source, + dependencies: current.dependencies ?? [], + }); + } + + if (packages.length === 0) { + return { ecosystem: "cargo", deps: [] }; + } + + // Root packages are local packages without external source specification + const rootPackages = packages.filter((p) => !p.source); + const primaryRoot = rootPackages[0] ?? packages[0]; + const rootNames = new Set(rootPackages.map((p) => p.name)); + + const deps: Dependency[] = []; + const seen = new Set(); + + const pushDep = (name: string, version: string) => { + if (seen.has(name) || rootNames.has(name)) return; + seen.add(name); + deps.push({ + name, + version, + ecosystem: "cargo", + kind: "normal", + }); + }; + + if (!includeTransitive) { + // Only direct dependencies of the root packages + const directDeps = new Set(); + for (const r of rootPackages) { + for (const d of r.dependencies) { + directDeps.add(d); + } + } + for (const pkg of packages) { + if (directDeps.has(pkg.name)) { + pushDep(pkg.name, pkg.version); + if (deps.length >= maxRows) break; + } + } + } else { + // All unique transitive dependencies across the entire lock graph + for (const pkg of packages) { + pushDep(pkg.name, pkg.version); + if (deps.length >= maxRows) break; + } + } + + return { + ecosystem: "cargo", + name: primaryRoot?.name, + deps: deps.slice(0, maxRows), + }; +} + +/** + * Parses package-lock.json (v1, v2, v3 formats) and optionally includes transitive dependencies. + * De-duplicates and caps at maxRows (default MAX_SPLIT_ROWS = 32). + */ +export function parsePackageLockJson(text: string, options?: ParseManifestOptions): Manifest { + const includeTransitive = options?.includeTransitive ?? false; + const maxRows = options?.maxRows ?? MAX_SPLIT_ROWS; + + let j: Record; + try { + j = JSON.parse(text); + } catch { + throw new Error("package-lock.json is not valid JSON"); + } + + const rootName = typeof j.name === "string" ? j.name : undefined; + const deps: Dependency[] = []; + const seen = new Set(); + + const pushDep = (name: string, version: string, kind: Dependency["kind"] = "normal") => { + if (!name || seen.has(name) || name === rootName) return; + seen.add(name); + deps.push({ + name, + version: String(version || "*"), + ecosystem: "npm", + kind, + }); + }; + + // Modern npm lockfile format: v2 / v3 using "packages" + if (j.packages && typeof j.packages === "object") { + const pkgs = j.packages as Record>; + const rootPkg = pkgs[""] || {}; + const directDeps = (rootPkg.dependencies || {}) as Record; + const directDevDeps = (rootPkg.devDependencies || {}) as Record; + const directPeerDeps = (rootPkg.peerDependencies || {}) as Record; + + if (!includeTransitive) { + for (const [name, reqVer] of Object.entries(directDeps)) { + const resolved = pkgs[`node_modules/${name}`]?.version; + pushDep(name, String(resolved || reqVer), "normal"); + if (deps.length >= maxRows) break; + } + for (const [name, reqVer] of Object.entries(directPeerDeps)) { + if (deps.length >= maxRows) break; + const resolved = pkgs[`node_modules/${name}`]?.version; + pushDep(name, String(resolved || reqVer), "peer"); + } + for (const [name, reqVer] of Object.entries(directDevDeps)) { + if (deps.length >= maxRows) break; + const resolved = pkgs[`node_modules/${name}`]?.version; + pushDep(name, String(resolved || reqVer), "dev"); + } + } else { + // Collect all packages from node_modules/* + for (const [pathKey, val] of Object.entries(pkgs)) { + if (!pathKey || pathKey === "" || !val || typeof val !== "object") continue; + const lastIndex = pathKey.lastIndexOf("node_modules/"); + if (lastIndex === -1) continue; + const rawPkgName = pathKey.slice(lastIndex + "node_modules/".length); + if (!rawPkgName) continue; + + const isDev = Boolean(val.dev); + const isPeer = Boolean(val.peer); + const kind: Dependency["kind"] = isPeer ? "peer" : isDev ? "dev" : "normal"; + const ver = typeof val.version === "string" ? val.version : "*"; + + pushDep(rawPkgName, ver, kind); + if (deps.length >= maxRows) break; + } + } + } else if (j.dependencies && typeof j.dependencies === "object") { + // Legacy npm lockfile format: v1 + const depsTree = j.dependencies as Record>; + + const walkV1 = (tree: Record>) => { + for (const [name, node] of Object.entries(tree)) { + if (!node || typeof node !== "object") continue; + const ver = typeof node.version === "string" ? node.version : "*"; + const isDev = Boolean(node.dev); + pushDep(name, ver, isDev ? "dev" : "normal"); + if (deps.length >= maxRows) return; + + if (includeTransitive && node.dependencies && typeof node.dependencies === "object") { + walkV1(node.dependencies as Record>); + if (deps.length >= maxRows) return; + } + } + }; + + walkV1(depsTree); + } + + return { + ecosystem: "npm", + name: rootName, + deps: deps.slice(0, maxRows), + }; +} + /** * Minimal TOML reader covering the dependency tables used in practice: * [dependencies] foo = "1" | foo = { version = "1", ... } | foo.workspace = true From 1f8907d0432cba94925733fc943e9ea803dfbae1 Mon Sep 17 00:00:00 2001 From: NePreby Date: Fri, 6 Nov 2026 13:14:59 +0700 Subject: [PATCH 2/6] feat(ui): trap focus in sheets and restore focus on close (closes #14) --- frontend/src/components/ui.tsx | 53 ++++++++++- frontend/src/lib/__tests__/focus.test.ts | 116 +++++++++++++++++++++++ frontend/src/lib/focus.ts | 53 +++++++++++ 3 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/__tests__/focus.test.ts create mode 100644 frontend/src/lib/focus.ts diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx index 67d182a..0dbeee4 100644 --- a/frontend/src/components/ui.tsx +++ b/frontend/src/components/ui.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useState, useRef, type ReactNode } from "react"; +import { handleFocusTrap, FOCUSABLE_SELECTOR } from "../lib/focus"; export function Icon({ name, className = "", fill = false }: { name: string; className?: string; fill?: boolean }) { return ( @@ -60,20 +61,66 @@ export function Sheet({ children: ReactNode; wide?: boolean; }) { + const triggerRef = useRef(null); + const dialogRef = useRef(null); + useEffect(() => { if (!open) return; - const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + + // Capture the trigger element before opening + triggerRef.current = document.activeElement as HTMLElement | null; + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + return; + } + + if (dialogRef.current) { + handleFocusTrap({ + container: dialogRef.current, + event: e, + activeElement: document.activeElement, + }); + } + }; + window.addEventListener("keydown", onKey); const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; + + // Focus into dialog on open + const timer = setTimeout(() => { + if (dialogRef.current) { + const focusables = dialogRef.current.querySelectorAll(FOCUSABLE_SELECTOR); + if (focusables.length > 0) { + focusables[0].focus(); + } else { + dialogRef.current.focus(); + } + } + }, 0); + return () => { + clearTimeout(timer); window.removeEventListener("keydown", onKey); document.body.style.overflow = prev; + // Restore focus to trigger element on close + triggerRef.current?.focus(); }; }, [open, onClose]); + if (!open) return null; + return ( -
+
- + View on explorer
diff --git a/frontend/src/lib/__tests__/qr.test.ts b/frontend/src/lib/__tests__/qr.test.ts new file mode 100644 index 0000000..fe5cc53 --- /dev/null +++ b/frontend/src/lib/__tests__/qr.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { encodeQr, qrToSvgPath } from "../qr"; + +describe("QR Code Generator", () => { + it("encodes a basic URL into a valid QR matrix", () => { + const url = "https://stellar.expert/explorer/testnet/tx/1234567890abcdef"; + const qr = encodeQr(url); + + expect(qr.size).toBeGreaterThanOrEqual(21); + expect(qr.modules.length).toBe(qr.size); + expect(qr.modules[0].length).toBe(qr.size); + + // Verify top-left finder pattern (7x7 outer boundary has black modules) + expect(qr.modules[0][0]).toBe(true); + expect(qr.modules[0][6]).toBe(true); + expect(qr.modules[6][0]).toBe(true); + expect(qr.modules[6][6]).toBe(true); + expect(qr.modules[1][1]).toBe(false); // inner white space + expect(qr.modules[3][3]).toBe(true); // center black module + }); + + it("generates an SVG path string from the matrix", () => { + const qr = encodeQr("https://flowpulse.org"); + const path = qrToSvgPath(qr); + + expect(typeof path).toBe("string"); + expect(path.startsWith("M")).toBe(true); + expect(path).toContain("h1v1h-1z"); + }); + + it("handles empty or short strings without crashing", () => { + const qr = encodeQr("test"); + expect(qr.size).toBe(21); // Version 1 size: 17 + 4*1 = 21 + }); +}); diff --git a/frontend/src/lib/qr.ts b/frontend/src/lib/qr.ts new file mode 100644 index 0000000..533c8c8 --- /dev/null +++ b/frontend/src/lib/qr.ts @@ -0,0 +1,284 @@ +/** + * Lightweight, zero-dependency QR Code (Model 2) SVG generator. + * Implements Byte mode with standard Reed-Solomon error correction (Level M/L). + */ + +// Galois Field GF(256) tables with primitive polynomial 0x11d (285) +const EXP_TABLE = new Uint8Array(512); +const LOG_TABLE = new Uint8Array(256); + +(function initGaloisField() { + let x = 1; + for (let i = 0; i < 255; i++) { + EXP_TABLE[i] = x; + EXP_TABLE[i + 255] = x; + LOG_TABLE[x] = i; + x <<= 1; + if (x & 0x100) x ^= 0x11d; + } +})(); + +function gfMul(x: number, y: number): number { + if (x === 0 || y === 0) return 0; + return EXP_TABLE[LOG_TABLE[x] + LOG_TABLE[y]]; +} + +// Generator polynomials for Reed-Solomon codes +function rsGeneratorPoly(degree: number): Uint8Array { + let poly = new Uint8Array([1]); + for (let i = 0; i < degree; i++) { + const next = new Uint8Array(poly.length + 1); + for (let j = 0; j < poly.length; j++) { + next[j] ^= gfMul(poly[j], EXP_TABLE[i]); + next[j + 1] ^= poly[j]; + } + poly = next; + } + return poly; +} + +function rsComputeRemainder(data: Uint8Array, ecLength: number): Uint8Array { + const gen = rsGeneratorPoly(ecLength); + const res = new Uint8Array(ecLength); + for (const byte of data) { + const factor = byte ^ res[0]; + res.copyWithin(0, 1); + res[ecLength - 1] = 0; + for (let i = 0; i < ecLength; i++) { + res[i] ^= gfMul(gen[i], factor); + } + } + return res; +} + +// Version table capacities for Byte Mode (Level L) +// Version 1..10 capacities and EC parameters +interface QrVersionSpec { + version: number; + totalCodewords: number; + ecCodewords: number; + dataCodewords: number; + blocks: number; + alignment: number[]; +} + +const VERSION_SPECS: QrVersionSpec[] = [ + { version: 1, totalCodewords: 26, ecCodewords: 7, dataCodewords: 19, blocks: 1, alignment: [] }, + { version: 2, totalCodewords: 44, ecCodewords: 10, dataCodewords: 34, blocks: 1, alignment: [6, 18] }, + { version: 3, totalCodewords: 70, ecCodewords: 15, dataCodewords: 55, blocks: 1, alignment: [6, 22] }, + { version: 4, totalCodewords: 100, ecCodewords: 20, dataCodewords: 80, blocks: 1, alignment: [6, 26] }, + { version: 5, totalCodewords: 134, ecCodewords: 26, dataCodewords: 108, blocks: 1, alignment: [6, 30] }, + { version: 6, totalCodewords: 172, ecCodewords: 36, dataCodewords: 136, blocks: 2, alignment: [6, 34] }, + { version: 7, totalCodewords: 196, ecCodewords: 40, dataCodewords: 156, blocks: 2, alignment: [6, 22, 38] }, + { version: 8, totalCodewords: 242, ecCodewords: 48, dataCodewords: 194, blocks: 2, alignment: [6, 24, 42] }, + { version: 9, totalCodewords: 292, ecCodewords: 60, dataCodewords: 232, blocks: 2, alignment: [6, 26, 46] }, + { version: 10, totalCodewords: 346, ecCodewords: 72, dataCodewords: 274, blocks: 4, alignment: [6, 28, 50] }, +]; + +export interface QrMatrix { + size: number; + modules: boolean[][]; +} + +/** + * Encodes text into a QR code boolean matrix (Version 1-10, EC Level L). + */ +export function encodeQr(text: string): QrMatrix { + const utf8 = new TextEncoder().encode(text); + const len = utf8.length; + + // Find lowest suitable version + const spec = VERSION_SPECS.find((s) => s.dataCodewords >= len + 3); + if (!spec) { + throw new Error(`Text too long for supported QR versions (max ~270 bytes)`); + } + + // 1. Bitstream assembly: Mode indicator (0100 for Byte mode) + character count + data + const bits: number[] = []; + function pushBits(val: number, count: number) { + for (let i = count - 1; i >= 0; i--) { + bits.push((val >>> i) & 1); + } + } + + pushBits(0b0100, 4); // Byte mode indicator + pushBits(len, spec.version < 10 ? 8 : 16); // Character count indicator + for (const byte of utf8) { + pushBits(byte, 8); + } + + // Terminator (up to 4 zeroes) + const maxBits = spec.dataCodewords * 8; + const termLen = Math.min(4, maxBits - bits.length); + pushBits(0, termLen); + + // Pad to multiple of 8 + while (bits.length % 8 !== 0) { + bits.push(0); + } + + // Pad bytes (0xEC, 0x11 alternating) + const dataBytes = new Uint8Array(spec.dataCodewords); + for (let i = 0; i < bits.length / 8; i++) { + let b = 0; + for (let j = 0; j < 8; j++) { + b = (b << 1) | bits[i * 8 + j]; + } + dataBytes[i] = b; + } + + const pad = [0xec, 0x11]; + let padIdx = 0; + for (let i = bits.length / 8; i < spec.dataCodewords; i++) { + dataBytes[i] = pad[padIdx % 2]; + padIdx++; + } + + // 2. Reed-Solomon Error Correction + const ecLengthPerBlock = Math.floor(spec.ecCodewords / spec.blocks); + const dataLengthPerBlock = Math.floor(spec.dataCodewords / spec.blocks); + const allEcBlocks: Uint8Array[] = []; + const allDataBlocks: Uint8Array[] = []; + + for (let b = 0; b < spec.blocks; b++) { + const start = b * dataLengthPerBlock; + const blockData = dataBytes.slice(start, start + dataLengthPerBlock); + allDataBlocks.push(blockData); + allEcBlocks.push(rsComputeRemainder(blockData, ecLengthPerBlock)); + } + + // Interleave data and EC codewords + const finalCodewords: number[] = []; + for (let i = 0; i < dataLengthPerBlock; i++) { + for (let b = 0; b < spec.blocks; b++) { + finalCodewords.push(allDataBlocks[b][i]); + } + } + for (let i = 0; i < ecLengthPerBlock; i++) { + for (let b = 0; b < spec.blocks; b++) { + finalCodewords.push(allEcBlocks[b][i]); + } + } + + // 3. Matrix construction + const size = 17 + 4 * spec.version; + const matrix: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false)); + const isFunction: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false)); + + function setFunctionModule(r: number, c: number, val: boolean) { + matrix[r][c] = val; + isFunction[r][c] = true; + } + + // Finder patterns at (0,0), (size-7, 0), (0, size-7) + function drawFinderPattern(row: number, col: number) { + for (let r = -1; r <= 7; r++) { + for (let c = -1; c <= 7; c++) { + const nr = row + r; + const nc = col + c; + if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue; + const isBlack = + (r >= 0 && r <= 6 && (c === 0 || c === 6)) || + (c >= 0 && c <= 6 && (r === 0 || r === 6)) || + (r >= 2 && r <= 4 && c >= 2 && c <= 4); + setFunctionModule(nr, nc, isBlack); + } + } + } + + drawFinderPattern(0, 0); + drawFinderPattern(0, size - 7); + drawFinderPattern(size - 7, 0); + + // Alignment patterns for version >= 2 + const coords = spec.alignment; + for (let i = 0; i < coords.length; i++) { + for (let j = 0; j < coords.length; j++) { + const r = coords[i]; + const c = coords[j]; + if (isFunction[r][c]) continue; + for (let dr = -2; dr <= 2; dr++) { + for (let dc = -2; dc <= 2; dc++) { + const isBlack = Math.max(Math.abs(dr), Math.abs(dc)) !== 1; + setFunctionModule(r + dr, c + dc, isBlack); + } + } + } + } + + // Timing patterns + for (let i = 8; i < size - 8; i++) { + if (!isFunction[6][i]) setFunctionModule(6, i, i % 2 === 0); + if (!isFunction[i][6]) setFunctionModule(i, 6, i % 2 === 0); + } + + // Dark module + setFunctionModule(4 * spec.version + 9, 8, true); + + // Format info reserve + for (let i = 0; i <= 8; i++) { + if (!isFunction[8][i]) isFunction[8][i] = true; + if (!isFunction[i][8]) isFunction[i][8] = true; + } + for (let i = size - 8; i < size; i++) { + if (!isFunction[8][i]) isFunction[8][i] = true; + if (!isFunction[i][8]) isFunction[i][8] = true; + } + + // 4. Place data bits with Mask 0 ((row + col) % 2 === 0) + const bitArray: number[] = []; + for (const byte of finalCodewords) { + for (let i = 7; i >= 0; i--) { + bitArray.push((byte >>> i) & 1); + } + } + + let bitIdx = 0; + let upwards = true; + for (let right = size - 1; right > 0; right -= 2) { + if (right === 6) right--; // Skip vertical timing column + const rows = upwards + ? Array.from({ length: size }, (_, i) => size - 1 - i) + : Array.from({ length: size }, (_, i) => i); + + for (const r of rows) { + for (const c of [right, right - 1]) { + if (!isFunction[r][c]) { + const bit = bitIdx < bitArray.length ? bitArray[bitIdx++] : 0; + const mask = (r + c) % 2 === 0; + matrix[r][c] = (bit ^ (mask ? 1 : 0)) === 1; + } + } + } + upwards = !upwards; + } + + // 5. Write Format Information (Level L, Mask 0: bits 111011111000100) + const formatBits = [1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0]; + for (let i = 0; i < 6; i++) matrix[8][i] = formatBits[i] === 1; + matrix[8][7] = formatBits[6] === 1; + matrix[8][8] = formatBits[7] === 1; + matrix[7][8] = formatBits[8] === 1; + for (let i = 9; i < 15; i++) matrix[14 - i][8] = formatBits[i] === 1; + + for (let i = 0; i < 8; i++) matrix[size - 1 - i][8] = formatBits[i] === 1; + for (let i = 8; i < 15; i++) matrix[8][size - 15 + i] = formatBits[i] === 1; + + return { size, modules: matrix }; +} + +/** + * Generates an SVG path data string for rendering the QR matrix cleanly. + */ +export function qrToSvgPath(matrix: QrMatrix): string { + const parts: string[] = []; + const { size, modules } = matrix; + for (let r = 0; r < size; r++) { + for (let c = 0; c < size; c++) { + if (modules[r][c]) { + parts.push(`M${c},${r}h1v1h-1z`); + } + } + } + return parts.join(""); +}