/** * 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 { BrowserAztecCodeReader, NotFoundException } from "@zxing/library"; import { CODES_PER_COL, CODES_PER_ROW, type ChunkEnvelope, } from "../../shared/codex-types"; import { verifyChunkIntegrity } from "./helpers"; /** * 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 reader = new BrowserAztecCodeReader(); 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, reader, 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, }; } /** * Slice a rendered page canvas into the printed 6×6 grid and try to decode * each cell as a single Aztec code. Cells that contain no code (the trailing * empty cells on the last page) just throw NotFoundException and are skipped. * * Grid scanning beats whole-page scanning because zxing's binarizer locks * onto one symbol per call; with 36 symbols on the page it would otherwise * pick one and ignore the rest. We pay one decode attempt per cell — fast. */ async function scanCanvasGrid( canvas: HTMLCanvasElement, reader: BrowserAztecCodeReader, seenIndices: Set, ): Promise { const found: ChunkEnvelope[] = []; const cellW = canvas.width / CODES_PER_ROW; const cellH = canvas.height / CODES_PER_COL; for (let row = 0; row < CODES_PER_COL; row++) { for (let col = 0; col < CODES_PER_ROW; col++) { const cellCanvas = document.createElement("canvas"); cellCanvas.width = Math.floor(cellW); cellCanvas.height = Math.floor(cellH); const cellCtx = cellCanvas.getContext("2d"); if (!cellCtx) continue; cellCtx.drawImage( canvas, col * cellW, row * cellH, cellW, cellH, 0, 0, cellCanvas.width, cellCanvas.height, ); let text: string; try { // @zxing/library's BrowserCodeReader.decode accepts any media element // at runtime — it branches on instanceof — but the TS signature only // lists HTMLImageElement. Cast through unknown to bypass the false // positive. const result = reader.decode(cellCanvas as unknown as HTMLImageElement); text = result.getText(); } catch (err) { // NotFoundException is normal — the trailing cells on the last page // contain no code. Re-throw anything else to surface real failures. if (err instanceof NotFoundException) continue; continue; // any other decode failure: skip this cell } 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; } /** * 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; }