/** * pdf-import.ts — restore from a Codex PDF instead of a camera scan. * * The PDF is rendered page-by-page to a canvas (via pdf.js) and each page * canvas is divided into the same 6×6 grid the generator prints into. Each * cell is decoded individually via `RGBLuminanceSource` + `AztecCodeReader`, * which is far more reliable than a full-page scan because adjacent Aztec * codes don't compete for the binarizer's attention. * * The top-level `importCodexPDF` entry point needs a DOM (canvas + pdf.js). * The pure helpers below — `cellRectFor`, `imageDataToArgb`, * `decodeAztecFromArgb`, `parseChunkEnvelope` — are deliberately separated * out so the geometry math and the ZXing decode path can be exercised in * vitest's node environment. That coverage exists because earlier revisions * shipped two latent bugs (an even-grid slice that clipped every Aztec, and a * `BrowserAztecCodeReader.decode(canvas)` call that ZXing silently no-ops on) * which no unit test could have caught while everything DOM-adjacent was off * limits. * * Why PDF import matters: a printed Codex requires a phone camera and good * light. The PDF is a pristine vector source — the same Aztec codes without * the print → photo loop. When a user has the PDF on a USB drive or in * cloud storage, this path is faster and more reliable than camera scanning. */ import { AztecCodeReader, BinaryBitmap, GlobalHistogramBinarizer, HybridBinarizer, NotFoundException, RGBLuminanceSource, } from "@zxing/library"; import { CODES_PER_COL, CODES_PER_ROW, type ChunkEnvelope, } from "../../shared/codex-types"; import { verifyChunkIntegrity } from "./helpers"; import { CODE_SIZE, COL_GAP, HEADER_GAP, HEADER_HEIGHT, MARGIN, PAGE_HEIGHT, PAGE_WIDTH, ROW_STEP, } from "./pdf-layout"; /** * Lazily-loaded pdf.js module. Top-level `import * as pdfjsLib` would fail at * test time because pdfjs-dist v5 references DOMMatrix at module init, which * vitest's node environment lacks. We only need pdf.js inside importCodexPDF; * the parser surface (parseChunkEnvelope) can stay importable from tests. */ type PdfJsModule = typeof import("pdfjs-dist"); let pdfJsPromise: Promise | null = null; async function getPdfJs(): Promise { if (!pdfJsPromise) { pdfJsPromise = import("pdfjs-dist").then((mod) => { if ( typeof mod.GlobalWorkerOptions.workerSrc !== "string" || mod.GlobalWorkerOptions.workerSrc.length === 0 ) { if (typeof chrome !== "undefined" && chrome.runtime?.getURL) { mod.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL( "pdf.worker.min.mjs", ); } else { mod.GlobalWorkerOptions.workerSrc = "./pdf.worker.min.mjs"; } } return mod; }); } return pdfJsPromise; } export interface PDFImportResult { /** Number of envelopes successfully decoded across all pages. */ chunksFound: number; /** Total chunk count printed on the Codex, taken from the first envelope. */ totalExpected: number | null; /** Decoded envelopes. Caller feeds these into ChunkBuffer. */ envelopes: ChunkEnvelope[]; /** Page-level error notes — empty when every page rendered cleanly. */ errors: string[]; } export interface PDFImportProgress { (page: number, totalPages: number): void; } /** * Render scales that produce a 1:1 canvas-to-source-PNG mapping for known * Codex generations, ordered from newest to oldest. ZXing's AztecDetector * requires near-exact 1:1 rendering — even a 1% scale error causes * ReedSolomonException (downscale) or FormatException (upscale). The formula * is: scale = (modules × 4 + 16) / CODE_SIZE_pt where modules is the Aztec * matrix size and CODE_SIZE_pt = 80. * * Current generation (CHUNK_SIZE=350): ZXing → 75-module codes → 316px PNG * → scale = 3.95 * Prior generation (CHUNK_SIZE=800, ZXing path): 101-module codes → 420px PNG * → scale = 5.25 * Prior generation (CHUNK_SIZE=800, bwip-js path): 117-module codes → 484px PNG * → scale = 6.05 */ const CANDIDATE_SCALES = [3.95, 5.25, 6.05] as const; /** * Probe page 1 of `pdf` at every candidate scale and return the scale that * decodes the most codes. "First scale that decodes anything" is not * sufficient — scale 5.25 can occasionally decode 1–2 codes from a 6.05 * (117-module) Codex, producing a false-positive that makes the full import * use the wrong scale. Taking the maximum across all candidates avoids this: * the correct scale always wins by a large margin (30+ codes vs 0–2). * * Early exit: once a scale decodes a full row's worth of codes (CODES_PER_ROW) * we have a confident match and stop probing — this keeps detection fast for * the common case where the first or second candidate is correct. */ async function detectRenderScale( pdf: Awaited["promise"]>, ): Promise { let page: Awaited>; try { page = await pdf.getPage(1); } catch { return CANDIDATE_SCALES[0]; } let bestScale: number = CANDIDATE_SCALES[0]; let bestCount = 0; for (const scale of CANDIDATE_SCALES) { try { const viewport = page.getViewport({ scale }); const canvas = document.createElement("canvas"); canvas.width = Math.ceil(viewport.width); canvas.height = Math.ceil(viewport.height); const ctx = canvas.getContext("2d"); if (!ctx) continue; ctx.fillStyle = "white"; ctx.fillRect(0, 0, canvas.width, canvas.height); await page.render({ canvas, canvasContext: ctx, viewport }).promise; const probe = new Set(); const found = await scanCanvasGrid(canvas, probe); if (found.length > bestCount) { bestCount = found.length; bestScale = scale; } // A full row of codes decoded = unambiguous match; no need to probe further. if (found.length >= CODES_PER_ROW) break; } catch { // continue to next candidate } } return bestScale; } /** * Import a Peach Codex PDF and decode every Aztec code it contains. * * Automatically detects the render scale by probing page 1 at each candidate * scale in CANDIDATE_SCALES — the scale that decodes at least one code is used * for all pages. This makes the importer backward-compatible with Codexes * generated at any prior CHUNK_SIZE, and forward-compatible with future * changes, without requiring any version metadata in the PDF. */ export async function importCodexPDF( file: File, onProgress?: PDFImportProgress, ): Promise { const pdfjsLib = await getPdfJs(); const envelopes: ChunkEnvelope[] = []; const errors: string[] = []; // Key is `${stripe}:${chunkIndex}` so multi-stripe Codexes don't treat // stripe 1's chunk 0 as a duplicate of stripe 0's chunk 0. const seenIndices = new Set(); let totalExpected: number | null = null; let pdf: Awaited["promise"]>; try { const arrayBuffer = await file.arrayBuffer(); pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; } catch (err) { return { chunksFound: 0, totalExpected: null, envelopes, errors: [ `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`, ], }; } const pageCount = pdf.numPages; const renderScale = await detectRenderScale(pdf); for (let pageNum = 1; pageNum <= pageCount; pageNum++) { onProgress?.(pageNum, pageCount); try { const page = await pdf.getPage(pageNum); const viewport = page.getViewport({ scale: renderScale }); const canvas = document.createElement("canvas"); canvas.width = Math.ceil(viewport.width); canvas.height = Math.ceil(viewport.height); const ctx = canvas.getContext("2d"); if (!ctx) { errors.push(`Page ${pageNum}: no canvas context`); continue; } // pdf.js requires the canvas to be filled before render to avoid the // transparent-on-transparent dark mode regression in v4 — paint white. ctx.fillStyle = "white"; ctx.fillRect(0, 0, canvas.width, canvas.height); await page.render({ canvas, canvasContext: ctx, viewport }).promise; const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices); for (const env of pageEnvelopes) envelopes.push(env); if (totalExpected === null && pageEnvelopes.length > 0) { totalExpected = pageEnvelopes[0]!.t; } } catch (err) { errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`); } } return { chunksFound: envelopes.length, totalExpected, envelopes, errors, }; } /** * Crop each of the 36 (6×6) printed Aztec codes out of the rendered page and * try to decode it. Cells that contain no code — the trailing empty cells on * the last page — just throw NotFoundException and are skipped. * * Two non-obvious things this function gets right that earlier revisions did * not: * * 1. Cropping follows the *exact* layout drawn by `pdf.ts` (margins, * header band, code size, column/row gaps) instead of a naive * `canvas / 6` slice. The naive slicer clipped every Aztec on its * right edge — ZXing's binarizer can't find a finder pattern in a * chopped symbol. Geometry constants are shared with the generator * via `pdf-layout.ts` so the two can't drift again. * * 2. We decode the cell pixels directly through `AztecCodeReader` + * `RGBLuminanceSource` rather than handing the canvas to * `BrowserAztecCodeReader.decode()`. ZXing's BrowserCodeReader only * knows how to read `HTMLVideoElement` and `HTMLImageElement` — when * you pass it a canvas, its internal `createCaptureCanvas` falls * through to no branch and creates a zero-sized capture canvas, so * every decode returns `NotFoundException`. The DOM-free path here is * the same one the generator uses for round-trip verification. * * Around each code we also include a small quiet-zone margin (≈12% of the * code size). The PDF prints codes against a cream background with no * required quiet zone; ZXing's locator is happier with a white one. */ async function scanCanvasGrid( canvas: HTMLCanvasElement, seenIndices: Set, ): Promise { const found: ChunkEnvelope[] = []; // One scratch canvas reused across all cells — cheaper than allocating // 36 canvases per page when a Codex spans dozens of pages. const cellCanvas = document.createElement("canvas"); const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true }); if (!cellCtx) return found; for (let row = 0; row < CODES_PER_COL; row++) { for (let col = 0; col < CODES_PER_ROW; col++) { const { sx, sy, sw, sh } = cellRectFor(canvas.width, canvas.height, row, col); if (sw <= 0 || sh <= 0) continue; cellCanvas.width = sw; cellCanvas.height = sh; // Paint the quiet-zone background white before drawing — the source // PDF's cream is light but not pure white, and a clean white border // gives the binarizer the cleanest finder-pattern contrast. cellCtx.fillStyle = "white"; cellCtx.fillRect(0, 0, sw, sh); cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh); const imageData = cellCtx.getImageData(0, 0, sw, sh); const argb = imageDataToArgb(imageData); const text = decodeAztecFromArgb(argb, sw, sh); if (!text) continue; const envelope = parseChunkEnvelope(text); if (!envelope) continue; const stripeKey = `${envelope.s ?? 0}:${envelope.i}`; if (seenIndices.has(stripeKey)) continue; if (!(await verifyChunkIntegrity(envelope))) continue; seenIndices.add(stripeKey); found.push(envelope); } } return found; } /** * Compute the canvas-pixel sub-rectangle for the printed Aztec at (row, col) * on a page that was rendered at uniform scale into the given canvas * dimensions. * * Exported (rather than inlined into `scanCanvasGrid`) so the geometry can be * verified directly in tests — `scanCanvasGrid` itself needs an * `HTMLCanvasElement` and `getImageData()` which vitest's node environment * lacks, but this pure function does not. */ export function cellRectFor( canvasW: number, canvasH: number, row: number, col: number, ): { sx: number; sy: number; sw: number; sh: number } { // Canvas pixels per PDF point. Independent x/y scales keep us correct if // the renderer ever applies non-uniform scaling. const scaleX = canvasW / PAGE_WIDTH; const scaleY = canvasH / PAGE_HEIGHT; // Top edge of row 0 in PDF coords (bottom-origin) is just below the header. // Convert to canvas coords (top-origin): canvas_y = (PAGE_HEIGHT - pdf_y) * scaleY. const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP; const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY; const codeWpx = CODE_SIZE * scaleX; const codeHpx = CODE_SIZE * scaleY; const quietX = Math.round(codeWpx * 0.12); const quietY = Math.round(codeHpx * 0.12); const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX; const codeY = gridTopCanvas + row * ROW_STEP * scaleY; const sx = Math.max(0, Math.floor(codeX - quietX)); const sy = Math.max(0, Math.floor(codeY - quietY)); const sw = Math.min(canvasW - sx, Math.ceil(codeWpx + 2 * quietX)); const sh = Math.min(canvasH - sy, Math.ceil(codeHpx + 2 * quietY)); return { sx, sy, sw, sh }; } /** * Convert an RGBA `ImageData.data` buffer into the packed ARGB `Int32Array` * that `RGBLuminanceSource` expects. Exposed for tests that synthesize a cell * buffer directly rather than going through a canvas. */ export function imageDataToArgb(imageData: ImageData): Int32Array { const raw = imageData.data; const out = new Int32Array(imageData.width * imageData.height); for (let i = 0, j = 0; i < raw.length; i += 4, j++) { out[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!; } return out; } /** * Decode a single Aztec code from a packed ARGB pixel buffer. Returns the * decoded text on success, or `null` if no code was found. * * We try `HybridBinarizer` first (good for rendered images with subtle * shading from anti-aliasing) and fall back to `GlobalHistogramBinarizer` * (better when the image is dominated by clean black/white modules). A fresh * `AztecCodeReader` is allocated per attempt — the reader caches internal * state that must be reset between bitmaps. * * This is the exact decode path the importer used to skip by handing canvas * elements to `BrowserAztecCodeReader.decode()`, which silently fails on * canvases — its `createCaptureCanvas` only recognizes video and image * elements and produces a zero-sized capture for anything else. Keeping this * function exported lets the test suite verify the path works on synthesized * Aztec pixels without needing a real DOM canvas. */ export function decodeAztecFromArgb( argb: Int32Array, width: number, height: number, ): string | null { const source = new RGBLuminanceSource(argb, width, height); try { const bitmap = new BinaryBitmap(new HybridBinarizer(source)); return new AztecCodeReader().decode(bitmap).getText(); } catch (err) { if (!(err instanceof NotFoundException)) return null; } try { const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source)); return new AztecCodeReader().decode(bitmap).getText(); } catch { return null; } } /** * Parse a raw decoded string as a Peach ChunkEnvelope. Returns null for any * non-Peach payload — the same defensive contract as `restore.ts/parseRawScan` * but inlined here so this module stays decoupled from the camera scan path. * * Accepts v2 (legacy single-stripe) and v3 (current multi-stripe) envelopes. * The generator has emitted v3 envelopes since the multi-stripe rollout — * earlier revisions of this function only accepted v2, which silently * rejected every envelope produced by current builds. */ export function parseChunkEnvelope(raw: string): ChunkEnvelope | null { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return null; } if (typeof parsed !== "object" || parsed === null) return null; const obj = parsed as Record; if (obj.v !== 2 && obj.v !== 3) return null; const baseShapeOk = typeof obj.c === "string" && typeof obj.i === "number" && typeof obj.t === "number" && typeof obj.p === "boolean" && typeof obj.d === "string" && typeof obj.h === "string"; if (!baseShapeOk) return null; // v3 envelopes must also carry stripe metadata. if (obj.v === 3 && (typeof obj.s !== "number" || typeof obj.S !== "number")) { return null; } return obj as unknown as ChunkEnvelope; }