pdf-import.ts

16.6 KB · raw

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}