/** * 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 scanned individually with @zxing/library, which decodes far more * reliably than a full-page scan because adjacent Aztec codes no longer * compete for the binarizer's attention. * * This module is browser-only — it depends on canvas and pdf.js. It is never * imported from tests; PDF rendering needs a DOM. * * 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; } /** * Import a Peach Codex PDF and decode every Aztec code it contains. * * Renders each page at 2× scale (≈150dpi equivalent — well above the density * required for reliable Aztec decoding) and grid-scans each rendered page. */ export async function importCodexPDF( file: File, onProgress?: PDFImportProgress, ): Promise { const pdfjsLib = await getPdfJs(); const envelopes: ChunkEnvelope[] = []; const errors: string[] = []; 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; for (let pageNum = 1; pageNum <= pageCount; pageNum++) { onProgress?.(pageNum, pageCount); try { const page = await pdf.getPage(pageNum); const viewport = page.getViewport({ scale: 2.0 }); 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} failed: ${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[] = []; // The page was rendered at a uniform scale, so canvas pixels per PDF point // is just canvas.width / PAGE_WIDTH (≈ canvas.height / PAGE_HEIGHT for an // A4 source). Independent x/y scales keep us correct if the renderer ever // applies non-uniform scaling. const scaleX = canvas.width / PAGE_WIDTH; const scaleY = canvas.height / 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); // 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 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( canvas.width - sx, Math.ceil(codeWpx + 2 * quietX), ); const sh = Math.min( canvas.height - sy, Math.ceil(codeHpx + 2 * quietY), ); 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 text = decodeAztecFromCellPixels(cellCtx, sw, sh); if (!text) continue; const envelope = parseChunkEnvelope(text); if (!envelope) continue; if (seenIndices.has(envelope.i)) continue; if (!(await verifyChunkIntegrity(envelope))) continue; seenIndices.add(envelope.i); found.push(envelope); } } return found; } /** * Read the cell canvas's pixels into an ARGB Int32Array, wrap it in a * `RGBLuminanceSource`, and try to decode it as an Aztec code. 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. */ function decodeAztecFromCellPixels( cellCtx: CanvasRenderingContext2D, sw: number, sh: number, ): string | null { const imageData = cellCtx.getImageData(0, 0, sw, sh); const raw = imageData.data; const argb = new Int32Array(sw * sh); for (let i = 0, j = 0; i < raw.length; i += 4, j++) { argb[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!; } const source = new RGBLuminanceSource(argb, sw, sh); 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. */ 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 && typeof obj.c === "string" && typeof obj.i === "number" && typeof obj.t === "number" && typeof obj.p === "boolean" && typeof obj.d === "string" && typeof obj.h === "string" ) { return obj as unknown as ChunkEnvelope; } return null; }