pdf-import.ts

13.5 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 * Import a Peach Codex PDF and decode every Aztec code it contains.99 *100 * Renders each page at 2× scale (≈150dpi equivalent — well above the density101 * required for reliable Aztec decoding) and grid-scans each rendered page.102 */103export async function importCodexPDF(104  file: File,105  onProgress?: PDFImportProgress,106): Promise<PDFImportResult> {107  const pdfjsLib = await getPdfJs();108109  const envelopes: ChunkEnvelope[] = [];110  const errors: string[] = [];111  const seenIndices = new Set<number>();112  let totalExpected: number | null = null;113114  let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;115  try {116    const arrayBuffer = await file.arrayBuffer();117    pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;118  } catch (err) {119    return {120      chunksFound: 0,121      totalExpected: null,122      envelopes,123      errors: [124        `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,125      ],126    };127  }128129  const pageCount = pdf.numPages;130131  for (let pageNum = 1; pageNum <= pageCount; pageNum++) {132    onProgress?.(pageNum, pageCount);133134    try {135      const page = await pdf.getPage(pageNum);136      const viewport = page.getViewport({ scale: 2.0 });137138      const canvas = document.createElement("canvas");139      canvas.width = Math.ceil(viewport.width);140      canvas.height = Math.ceil(viewport.height);141      const ctx = canvas.getContext("2d");142      if (!ctx) {143        errors.push(`Page ${pageNum}: no canvas context`);144        continue;145      }146147      // pdf.js requires the canvas to be filled before render to avoid the148      // transparent-on-transparent dark mode regression in v4 — paint white.149      ctx.fillStyle = "white";150      ctx.fillRect(0, 0, canvas.width, canvas.height);151152      await page.render({ canvas, canvasContext: ctx, viewport }).promise;153154      const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices);155      for (const env of pageEnvelopes) envelopes.push(env);156157      if (totalExpected === null && pageEnvelopes.length > 0) {158        totalExpected = pageEnvelopes[0]!.t;159      }160    } catch (err) {161      errors.push(162        `Page ${pageNum} failed: ${err instanceof Error ? err.message : String(err)}`,163      );164    }165  }166167  return {168    chunksFound: envelopes.length,169    totalExpected,170    envelopes,171    errors,172  };173}174175/**176 * Crop each of the 36 (6×6) printed Aztec codes out of the rendered page and177 * try to decode it. Cells that contain no code — the trailing empty cells on178 * the last page — just throw NotFoundException and are skipped.179 *180 * Two non-obvious things this function gets right that earlier revisions did181 * not:182 *183 *   1. Cropping follows the *exact* layout drawn by `pdf.ts` (margins,184 *      header band, code size, column/row gaps) instead of a naive185 *      `canvas / 6` slice. The naive slicer clipped every Aztec on its186 *      right edge — ZXing's binarizer can't find a finder pattern in a187 *      chopped symbol. Geometry constants are shared with the generator188 *      via `pdf-layout.ts` so the two can't drift again.189 *190 *   2. We decode the cell pixels directly through `AztecCodeReader` +191 *      `RGBLuminanceSource` rather than handing the canvas to192 *      `BrowserAztecCodeReader.decode()`. ZXing's BrowserCodeReader only193 *      knows how to read `HTMLVideoElement` and `HTMLImageElement` — when194 *      you pass it a canvas, its internal `createCaptureCanvas` falls195 *      through to no branch and creates a zero-sized capture canvas, so196 *      every decode returns `NotFoundException`. The DOM-free path here is197 *      the same one the generator uses for round-trip verification.198 *199 * Around each code we also include a small quiet-zone margin (≈12% of the200 * code size). The PDF prints codes against a cream background with no201 * required quiet zone; ZXing's locator is happier with a white one.202 */203async function scanCanvasGrid(204  canvas: HTMLCanvasElement,205  seenIndices: Set<number>,206): Promise<ChunkEnvelope[]> {207  const found: ChunkEnvelope[] = [];208209  // One scratch canvas reused across all cells — cheaper than allocating210  // 36 canvases per page when a Codex spans dozens of pages.211  const cellCanvas = document.createElement("canvas");212  const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true });213  if (!cellCtx) return found;214215  for (let row = 0; row < CODES_PER_COL; row++) {216    for (let col = 0; col < CODES_PER_ROW; col++) {217      const { sx, sy, sw, sh } = cellRectFor(canvas.width, canvas.height, row, col);218      if (sw <= 0 || sh <= 0) continue;219220      cellCanvas.width = sw;221      cellCanvas.height = sh;222      // Paint the quiet-zone background white before drawing — the source223      // PDF's cream is light but not pure white, and a clean white border224      // gives the binarizer the cleanest finder-pattern contrast.225      cellCtx.fillStyle = "white";226      cellCtx.fillRect(0, 0, sw, sh);227      cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);228229      const imageData = cellCtx.getImageData(0, 0, sw, sh);230      const argb = imageDataToArgb(imageData);231      const text = decodeAztecFromArgb(argb, sw, sh);232      if (!text) continue;233234      const envelope = parseChunkEnvelope(text);235      if (!envelope) continue;236      if (seenIndices.has(envelope.i)) continue;237      if (!(await verifyChunkIntegrity(envelope))) continue;238239      seenIndices.add(envelope.i);240      found.push(envelope);241    }242  }243244  return found;245}246247/**248 * Compute the canvas-pixel sub-rectangle for the printed Aztec at (row, col)249 * on a page that was rendered at uniform scale into the given canvas250 * dimensions.251 *252 * Exported (rather than inlined into `scanCanvasGrid`) so the geometry can be253 * verified directly in tests — `scanCanvasGrid` itself needs an254 * `HTMLCanvasElement` and `getImageData()` which vitest's node environment255 * lacks, but this pure function does not.256 */257export function cellRectFor(258  canvasW: number,259  canvasH: number,260  row: number,261  col: number,262): { sx: number; sy: number; sw: number; sh: number } {263  // Canvas pixels per PDF point. Independent x/y scales keep us correct if264  // the renderer ever applies non-uniform scaling.265  const scaleX = canvasW / PAGE_WIDTH;266  const scaleY = canvasH / PAGE_HEIGHT;267268  // Top edge of row 0 in PDF coords (bottom-origin) is just below the header.269  // Convert to canvas coords (top-origin): canvas_y = (PAGE_HEIGHT - pdf_y) * scaleY.270  const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;271  const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;272273  const codeWpx = CODE_SIZE * scaleX;274  const codeHpx = CODE_SIZE * scaleY;275  const quietX = Math.round(codeWpx * 0.12);276  const quietY = Math.round(codeHpx * 0.12);277278  const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;279  const codeY = gridTopCanvas + row * ROW_STEP * scaleY;280281  const sx = Math.max(0, Math.floor(codeX - quietX));282  const sy = Math.max(0, Math.floor(codeY - quietY));283  const sw = Math.min(canvasW - sx, Math.ceil(codeWpx + 2 * quietX));284  const sh = Math.min(canvasH - sy, Math.ceil(codeHpx + 2 * quietY));285  return { sx, sy, sw, sh };286}287288/**289 * Convert an RGBA `ImageData.data` buffer into the packed ARGB `Int32Array`290 * that `RGBLuminanceSource` expects. Exposed for tests that synthesize a cell291 * buffer directly rather than going through a canvas.292 */293export function imageDataToArgb(imageData: ImageData): Int32Array {294  const raw = imageData.data;295  const out = new Int32Array(imageData.width * imageData.height);296  for (let i = 0, j = 0; i < raw.length; i += 4, j++) {297    out[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;298  }299  return out;300}301302/**303 * Decode a single Aztec code from a packed ARGB pixel buffer. Returns the304 * decoded text on success, or `null` if no code was found.305 *306 * We try `HybridBinarizer` first (good for rendered images with subtle307 * shading from anti-aliasing) and fall back to `GlobalHistogramBinarizer`308 * (better when the image is dominated by clean black/white modules). A fresh309 * `AztecCodeReader` is allocated per attempt — the reader caches internal310 * state that must be reset between bitmaps.311 *312 * This is the exact decode path the importer used to skip by handing canvas313 * elements to `BrowserAztecCodeReader.decode()`, which silently fails on314 * canvases — its `createCaptureCanvas` only recognizes video and image315 * elements and produces a zero-sized capture for anything else. Keeping this316 * function exported lets the test suite verify the path works on synthesized317 * Aztec pixels without needing a real DOM canvas.318 */319export function decodeAztecFromArgb(320  argb: Int32Array,321  width: number,322  height: number,323): string | null {324  const source = new RGBLuminanceSource(argb, width, height);325326  try {327    const bitmap = new BinaryBitmap(new HybridBinarizer(source));328    return new AztecCodeReader().decode(bitmap).getText();329  } catch (err) {330    if (!(err instanceof NotFoundException)) return null;331  }332333  try {334    const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));335    return new AztecCodeReader().decode(bitmap).getText();336  } catch {337    return null;338  }339}340341/**342 * Parse a raw decoded string as a Peach ChunkEnvelope. Returns null for any343 * non-Peach payload — the same defensive contract as `restore.ts/parseRawScan`344 * but inlined here so this module stays decoupled from the camera scan path.345 *346 * Accepts v2 (legacy single-stripe) and v3 (current multi-stripe) envelopes.347 * The generator has emitted v3 envelopes since the multi-stripe rollout —348 * earlier revisions of this function only accepted v2, which silently349 * rejected every envelope produced by current builds.350 */351export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {352  let parsed: unknown;353  try {354    parsed = JSON.parse(raw);355  } catch {356    return null;357  }358  if (typeof parsed !== "object" || parsed === null) return null;359  const obj = parsed as Record<string, unknown>;360361  if (obj.v !== 2 && obj.v !== 3) return null;362363  const baseShapeOk =364    typeof obj.c === "string" &&365    typeof obj.i === "number" &&366    typeof obj.t === "number" &&367    typeof obj.p === "boolean" &&368    typeof obj.d === "string" &&369    typeof obj.h === "string";370  if (!baseShapeOk) return null;371372  // v3 envelopes must also carry stripe metadata.373  if (obj.v === 3 && (typeof obj.s !== "number" || typeof obj.S !== "number")) {374    return null;375  }376377  return obj as unknown as ChunkEnvelope;378}