pdf-import.ts
1/**2 * pdf-import.ts — restore from a Codex PDF instead of a camera scan.3 *4 * The PDF is rendered page-by-page to a canvas (via pdf.js) and each page5 * canvas is divided into the same 6×6 grid the generator prints into. Each6 * cell is decoded individually via `RGBLuminanceSource` + `AztecCodeReader`,7 * which is far more reliable than a full-page scan because adjacent Aztec8 * codes don't compete for the binarizer's attention.9 *10 * The top-level `importCodexPDF` entry point needs a DOM (canvas + pdf.js).11 * The pure helpers below — `cellRectFor`, `imageDataToArgb`,12 * `decodeAztecFromArgb`, `parseChunkEnvelope` — are deliberately separated13 * out so the geometry math and the ZXing decode path can be exercised in14 * vitest's node environment. That coverage exists because earlier revisions15 * shipped two latent bugs (an even-grid slice that clipped every Aztec, and a16 * `BrowserAztecCodeReader.decode(canvas)` call that ZXing silently no-ops on)17 * which no unit test could have caught while everything DOM-adjacent was off18 * limits.19 *20 * Why PDF import matters: a printed Codex requires a phone camera and good21 * light. The PDF is a pristine vector source — the same Aztec codes without22 * the print → photo loop. When a user has the PDF on a USB drive or in23 * cloud storage, this path is faster and more reliable than camera scanning.24 */2526import {27 AztecCodeReader,28 BinaryBitmap,29 GlobalHistogramBinarizer,30 HybridBinarizer,31 NotFoundException,32 RGBLuminanceSource,33} from "@zxing/library";3435import {36 CODES_PER_COL,37 CODES_PER_ROW,38 type ChunkEnvelope,39} from "../../shared/codex-types";40import { verifyChunkIntegrity } from "./helpers";41import {42 CODE_SIZE,43 COL_GAP,44 HEADER_GAP,45 HEADER_HEIGHT,46 MARGIN,47 PAGE_HEIGHT,48 PAGE_WIDTH,49 ROW_STEP,50} from "./pdf-layout";5152/**53 * Lazily-loaded pdf.js module. Top-level `import * as pdfjsLib` would fail at54 * test time because pdfjs-dist v5 references DOMMatrix at module init, which55 * vitest's node environment lacks. We only need pdf.js inside importCodexPDF;56 * the parser surface (parseChunkEnvelope) can stay importable from tests.57 */58type PdfJsModule = typeof import("pdfjs-dist");59let pdfJsPromise: Promise<PdfJsModule> | null = null;6061async function getPdfJs(): Promise<PdfJsModule> {62 if (!pdfJsPromise) {63 pdfJsPromise = import("pdfjs-dist").then((mod) => {64 if (65 typeof mod.GlobalWorkerOptions.workerSrc !== "string" ||66 mod.GlobalWorkerOptions.workerSrc.length === 067 ) {68 if (typeof chrome !== "undefined" && chrome.runtime?.getURL) {69 mod.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL(70 "pdf.worker.min.mjs",71 );72 } else {73 mod.GlobalWorkerOptions.workerSrc = "./pdf.worker.min.mjs";74 }75 }76 return mod;77 });78 }79 return pdfJsPromise;80}8182export interface PDFImportResult {83 /** Number of envelopes successfully decoded across all pages. */84 chunksFound: number;85 /** Total chunk count printed on the Codex, taken from the first envelope. */86 totalExpected: number | null;87 /** Decoded envelopes. Caller feeds these into ChunkBuffer. */88 envelopes: ChunkEnvelope[];89 /** Page-level error notes — empty when every page rendered cleanly. */90 errors: string[];91}9293export interface PDFImportProgress {94 (page: number, totalPages: number): void;95}9697/**98 * Render scales that produce a 1:1 canvas-to-source-PNG mapping for known99 * Codex generations, ordered from newest to oldest. ZXing's AztecDetector100 * requires near-exact 1:1 rendering — even a 1% scale error causes101 * ReedSolomonException (downscale) or FormatException (upscale). The formula102 * is: scale = (modules × 4 + 16) / CODE_SIZE_pt where modules is the Aztec103 * matrix size and CODE_SIZE_pt = 80.104 *105 * Current generation (CHUNK_SIZE=350): ZXing → 75-module codes → 316px PNG106 * → scale = 3.95107 * Prior generation (CHUNK_SIZE=800, ZXing path): 101-module codes → 420px PNG108 * → scale = 5.25109 * Prior generation (CHUNK_SIZE=800, bwip-js path): 117-module codes → 484px PNG110 * → scale = 6.05111 */112const CANDIDATE_SCALES = [3.95, 5.25, 6.05] as const;113114/**115 * Probe page 1 of `pdf` at every candidate scale and return the scale that116 * decodes the most codes. "First scale that decodes anything" is not117 * sufficient — scale 5.25 can occasionally decode 1–2 codes from a 6.05118 * (117-module) Codex, producing a false-positive that makes the full import119 * use the wrong scale. Taking the maximum across all candidates avoids this:120 * the correct scale always wins by a large margin (30+ codes vs 0–2).121 *122 * Early exit: once a scale decodes a full row's worth of codes (CODES_PER_ROW)123 * we have a confident match and stop probing — this keeps detection fast for124 * the common case where the first or second candidate is correct.125 */126async function detectRenderScale(127 pdf: Awaited<ReturnType<PdfJsModule["getDocument"]>["promise"]>,128): Promise<number> {129 let page: Awaited<ReturnType<typeof pdf.getPage>>;130 try {131 page = await pdf.getPage(1);132 } catch {133 return CANDIDATE_SCALES[0];134 }135136 let bestScale: number = CANDIDATE_SCALES[0];137 let bestCount = 0;138139 for (const scale of CANDIDATE_SCALES) {140 try {141 const viewport = page.getViewport({ scale });142 const canvas = document.createElement("canvas");143 canvas.width = Math.ceil(viewport.width);144 canvas.height = Math.ceil(viewport.height);145 const ctx = canvas.getContext("2d");146 if (!ctx) continue;147 ctx.fillStyle = "white";148 ctx.fillRect(0, 0, canvas.width, canvas.height);149 await page.render({ canvas, canvasContext: ctx, viewport }).promise;150151 const probe = new Set<string>();152 const found = await scanCanvasGrid(canvas, probe);153 if (found.length > bestCount) {154 bestCount = found.length;155 bestScale = scale;156 }157 // A full row of codes decoded = unambiguous match; no need to probe further.158 if (found.length >= CODES_PER_ROW) break;159 } catch {160 // continue to next candidate161 }162 }163164 return bestScale;165}166167/**168 * Import a Peach Codex PDF and decode every Aztec code it contains.169 *170 * Automatically detects the render scale by probing page 1 at each candidate171 * scale in CANDIDATE_SCALES — the scale that decodes at least one code is used172 * for all pages. This makes the importer backward-compatible with Codexes173 * generated at any prior CHUNK_SIZE, and forward-compatible with future174 * changes, without requiring any version metadata in the PDF.175 */176export async function importCodexPDF(177 file: File,178 onProgress?: PDFImportProgress,179): Promise<PDFImportResult> {180 const pdfjsLib = await getPdfJs();181182 const envelopes: ChunkEnvelope[] = [];183 const errors: string[] = [];184 // Key is `${stripe}:${chunkIndex}` so multi-stripe Codexes don't treat185 // stripe 1's chunk 0 as a duplicate of stripe 0's chunk 0.186 const seenIndices = new Set<string>();187 let totalExpected: number | null = null;188189 let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;190 try {191 const arrayBuffer = await file.arrayBuffer();192 pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;193 } catch (err) {194 return {195 chunksFound: 0,196 totalExpected: null,197 envelopes,198 errors: [199 `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,200 ],201 };202 }203204 const pageCount = pdf.numPages;205 const renderScale = await detectRenderScale(pdf);206207 for (let pageNum = 1; pageNum <= pageCount; pageNum++) {208 onProgress?.(pageNum, pageCount);209210 try {211 const page = await pdf.getPage(pageNum);212 const viewport = page.getViewport({ scale: renderScale });213214 const canvas = document.createElement("canvas");215 canvas.width = Math.ceil(viewport.width);216 canvas.height = Math.ceil(viewport.height);217 const ctx = canvas.getContext("2d");218 if (!ctx) {219 errors.push(`Page ${pageNum}: no canvas context`);220 continue;221 }222223 // pdf.js requires the canvas to be filled before render to avoid the224 // transparent-on-transparent dark mode regression in v4 — paint white.225 ctx.fillStyle = "white";226 ctx.fillRect(0, 0, canvas.width, canvas.height);227228 await page.render({ canvas, canvasContext: ctx, viewport }).promise;229230 const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices);231 for (const env of pageEnvelopes) envelopes.push(env);232233 if (totalExpected === null && pageEnvelopes.length > 0) {234 totalExpected = pageEnvelopes[0]!.t;235 }236 } catch (err) {237 errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`);238 }239 }240241 return {242 chunksFound: envelopes.length,243 totalExpected,244 envelopes,245 errors,246 };247}248249/**250 * Crop each of the 36 (6×6) printed Aztec codes out of the rendered page and251 * try to decode it. Cells that contain no code — the trailing empty cells on252 * the last page — just throw NotFoundException and are skipped.253 *254 * Two non-obvious things this function gets right that earlier revisions did255 * not:256 *257 * 1. Cropping follows the *exact* layout drawn by `pdf.ts` (margins,258 * header band, code size, column/row gaps) instead of a naive259 * `canvas / 6` slice. The naive slicer clipped every Aztec on its260 * right edge — ZXing's binarizer can't find a finder pattern in a261 * chopped symbol. Geometry constants are shared with the generator262 * via `pdf-layout.ts` so the two can't drift again.263 *264 * 2. We decode the cell pixels directly through `AztecCodeReader` +265 * `RGBLuminanceSource` rather than handing the canvas to266 * `BrowserAztecCodeReader.decode()`. ZXing's BrowserCodeReader only267 * knows how to read `HTMLVideoElement` and `HTMLImageElement` — when268 * you pass it a canvas, its internal `createCaptureCanvas` falls269 * through to no branch and creates a zero-sized capture canvas, so270 * every decode returns `NotFoundException`. The DOM-free path here is271 * the same one the generator uses for round-trip verification.272 *273 * Around each code we also include a small quiet-zone margin (≈12% of the274 * code size). The PDF prints codes against a cream background with no275 * required quiet zone; ZXing's locator is happier with a white one.276 */277async function scanCanvasGrid(278 canvas: HTMLCanvasElement,279 seenIndices: Set<string>,280): Promise<ChunkEnvelope[]> {281 const found: ChunkEnvelope[] = [];282283 // One scratch canvas reused across all cells — cheaper than allocating284 // 36 canvases per page when a Codex spans dozens of pages.285 const cellCanvas = document.createElement("canvas");286 const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true });287 if (!cellCtx) return found;288289 for (let row = 0; row < CODES_PER_COL; row++) {290 for (let col = 0; col < CODES_PER_ROW; col++) {291 const { sx, sy, sw, sh } = cellRectFor(canvas.width, canvas.height, row, col);292 if (sw <= 0 || sh <= 0) continue;293294 cellCanvas.width = sw;295 cellCanvas.height = sh;296 // Paint the quiet-zone background white before drawing — the source297 // PDF's cream is light but not pure white, and a clean white border298 // gives the binarizer the cleanest finder-pattern contrast.299 cellCtx.fillStyle = "white";300 cellCtx.fillRect(0, 0, sw, sh);301 cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);302303 const imageData = cellCtx.getImageData(0, 0, sw, sh);304 const argb = imageDataToArgb(imageData);305306 const text = decodeAztecFromArgb(argb, sw, sh);307 if (!text) continue;308309 const envelope = parseChunkEnvelope(text);310 if (!envelope) continue;311 const stripeKey = `${envelope.s ?? 0}:${envelope.i}`;312 if (seenIndices.has(stripeKey)) continue;313 if (!(await verifyChunkIntegrity(envelope))) continue;314315 seenIndices.add(stripeKey);316 found.push(envelope);317 }318 }319320 return found;321}322323/**324 * Compute the canvas-pixel sub-rectangle for the printed Aztec at (row, col)325 * on a page that was rendered at uniform scale into the given canvas326 * dimensions.327 *328 * Exported (rather than inlined into `scanCanvasGrid`) so the geometry can be329 * verified directly in tests — `scanCanvasGrid` itself needs an330 * `HTMLCanvasElement` and `getImageData()` which vitest's node environment331 * lacks, but this pure function does not.332 */333export function cellRectFor(334 canvasW: number,335 canvasH: number,336 row: number,337 col: number,338): { sx: number; sy: number; sw: number; sh: number } {339 // Canvas pixels per PDF point. Independent x/y scales keep us correct if340 // the renderer ever applies non-uniform scaling.341 const scaleX = canvasW / PAGE_WIDTH;342 const scaleY = canvasH / PAGE_HEIGHT;343344 // Top edge of row 0 in PDF coords (bottom-origin) is just below the header.345 // Convert to canvas coords (top-origin): canvas_y = (PAGE_HEIGHT - pdf_y) * scaleY.346 const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;347 const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;348349 const codeWpx = CODE_SIZE * scaleX;350 const codeHpx = CODE_SIZE * scaleY;351 const quietX = Math.round(codeWpx * 0.12);352 const quietY = Math.round(codeHpx * 0.12);353354 const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;355 const codeY = gridTopCanvas + row * ROW_STEP * scaleY;356357 const sx = Math.max(0, Math.floor(codeX - quietX));358 const sy = Math.max(0, Math.floor(codeY - quietY));359 const sw = Math.min(canvasW - sx, Math.ceil(codeWpx + 2 * quietX));360 const sh = Math.min(canvasH - sy, Math.ceil(codeHpx + 2 * quietY));361 return { sx, sy, sw, sh };362}363364/**365 * Convert an RGBA `ImageData.data` buffer into the packed ARGB `Int32Array`366 * that `RGBLuminanceSource` expects. Exposed for tests that synthesize a cell367 * buffer directly rather than going through a canvas.368 */369export function imageDataToArgb(imageData: ImageData): Int32Array {370 const raw = imageData.data;371 const out = new Int32Array(imageData.width * imageData.height);372 for (let i = 0, j = 0; i < raw.length; i += 4, j++) {373 out[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;374 }375 return out;376}377378/**379 * Decode a single Aztec code from a packed ARGB pixel buffer. Returns the380 * decoded text on success, or `null` if no code was found.381 *382 * We try `HybridBinarizer` first (good for rendered images with subtle383 * shading from anti-aliasing) and fall back to `GlobalHistogramBinarizer`384 * (better when the image is dominated by clean black/white modules). A fresh385 * `AztecCodeReader` is allocated per attempt — the reader caches internal386 * state that must be reset between bitmaps.387 *388 * This is the exact decode path the importer used to skip by handing canvas389 * elements to `BrowserAztecCodeReader.decode()`, which silently fails on390 * canvases — its `createCaptureCanvas` only recognizes video and image391 * elements and produces a zero-sized capture for anything else. Keeping this392 * function exported lets the test suite verify the path works on synthesized393 * Aztec pixels without needing a real DOM canvas.394 */395export function decodeAztecFromArgb(396 argb: Int32Array,397 width: number,398 height: number,399): string | null {400 const source = new RGBLuminanceSource(argb, width, height);401402 try {403 const bitmap = new BinaryBitmap(new HybridBinarizer(source));404 return new AztecCodeReader().decode(bitmap).getText();405 } catch (err) {406 if (!(err instanceof NotFoundException)) return null;407 }408409 try {410 const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));411 return new AztecCodeReader().decode(bitmap).getText();412 } catch {413 return null;414 }415}416417/**418 * Parse a raw decoded string as a Peach ChunkEnvelope. Returns null for any419 * non-Peach payload — the same defensive contract as `restore.ts/parseRawScan`420 * but inlined here so this module stays decoupled from the camera scan path.421 *422 * Accepts v2 (legacy single-stripe) and v3 (current multi-stripe) envelopes.423 * The generator has emitted v3 envelopes since the multi-stripe rollout —424 * earlier revisions of this function only accepted v2, which silently425 * rejected every envelope produced by current builds.426 */427export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {428 let parsed: unknown;429 try {430 parsed = JSON.parse(raw);431 } catch {432 return null;433 }434 if (typeof parsed !== "object" || parsed === null) return null;435 const obj = parsed as Record<string, unknown>;436437 if (obj.v !== 2 && obj.v !== 3) return null;438439 const baseShapeOk =440 typeof obj.c === "string" &&441 typeof obj.i === "number" &&442 typeof obj.t === "number" &&443 typeof obj.p === "boolean" &&444 typeof obj.d === "string" &&445 typeof obj.h === "string";446 if (!baseShapeOk) return null;447448 // v3 envelopes must also carry stripe metadata.449 if (obj.v === 3 && (typeof obj.s !== "number" || typeof obj.S !== "number")) {450 return null;451 }452453 return obj as unknown as ChunkEnvelope;454}