pdf-import.ts

11.4 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 scanned individually with @zxing/library, which decodes far more7 * reliably than a full-page scan because adjacent Aztec codes no longer8 * compete for the binarizer's attention.9 *10 * This module is browser-only — it depends on canvas and pdf.js. It is never11 * imported from tests; PDF rendering needs a DOM.12 *13 * Why PDF import matters: a printed Codex requires a phone camera and good14 * light. The PDF is a pristine vector source — the same Aztec codes without15 * the print → photo loop. When a user has the PDF on a USB drive or in16 * cloud storage, this path is faster and more reliable than camera scanning.17 */1819import {20  AztecCodeReader,21  BinaryBitmap,22  GlobalHistogramBinarizer,23  HybridBinarizer,24  NotFoundException,25  RGBLuminanceSource,26} from "@zxing/library";2728import {29  CODES_PER_COL,30  CODES_PER_ROW,31  type ChunkEnvelope,32} from "../../shared/codex-types";33import { verifyChunkIntegrity } from "./helpers";34import {35  CODE_SIZE,36  COL_GAP,37  HEADER_GAP,38  HEADER_HEIGHT,39  MARGIN,40  PAGE_HEIGHT,41  PAGE_WIDTH,42  ROW_STEP,43} from "./pdf-layout";4445/**46 * Lazily-loaded pdf.js module. Top-level `import * as pdfjsLib` would fail at47 * test time because pdfjs-dist v5 references DOMMatrix at module init, which48 * vitest's node environment lacks. We only need pdf.js inside importCodexPDF;49 * the parser surface (parseChunkEnvelope) can stay importable from tests.50 */51type PdfJsModule = typeof import("pdfjs-dist");52let pdfJsPromise: Promise<PdfJsModule> | null = null;5354async function getPdfJs(): Promise<PdfJsModule> {55  if (!pdfJsPromise) {56    pdfJsPromise = import("pdfjs-dist").then((mod) => {57      if (58        typeof mod.GlobalWorkerOptions.workerSrc !== "string" ||59        mod.GlobalWorkerOptions.workerSrc.length === 060      ) {61        if (typeof chrome !== "undefined" && chrome.runtime?.getURL) {62          mod.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL(63            "pdf.worker.min.mjs",64          );65        } else {66          mod.GlobalWorkerOptions.workerSrc = "./pdf.worker.min.mjs";67        }68      }69      return mod;70    });71  }72  return pdfJsPromise;73}7475export interface PDFImportResult {76  /** Number of envelopes successfully decoded across all pages. */77  chunksFound: number;78  /** Total chunk count printed on the Codex, taken from the first envelope. */79  totalExpected: number | null;80  /** Decoded envelopes. Caller feeds these into ChunkBuffer. */81  envelopes: ChunkEnvelope[];82  /** Page-level error notes — empty when every page rendered cleanly. */83  errors: string[];84}8586export interface PDFImportProgress {87  (page: number, totalPages: number): void;88}8990/**91 * Import a Peach Codex PDF and decode every Aztec code it contains.92 *93 * Renders each page at 2× scale (≈150dpi equivalent — well above the density94 * required for reliable Aztec decoding) and grid-scans each rendered page.95 */96export async function importCodexPDF(97  file: File,98  onProgress?: PDFImportProgress,99): Promise<PDFImportResult> {100  const pdfjsLib = await getPdfJs();101102  const envelopes: ChunkEnvelope[] = [];103  const errors: string[] = [];104  const seenIndices = new Set<number>();105  let totalExpected: number | null = null;106107  let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;108  try {109    const arrayBuffer = await file.arrayBuffer();110    pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;111  } catch (err) {112    return {113      chunksFound: 0,114      totalExpected: null,115      envelopes,116      errors: [117        `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,118      ],119    };120  }121122  const pageCount = pdf.numPages;123124  for (let pageNum = 1; pageNum <= pageCount; pageNum++) {125    onProgress?.(pageNum, pageCount);126127    try {128      const page = await pdf.getPage(pageNum);129      const viewport = page.getViewport({ scale: 2.0 });130131      const canvas = document.createElement("canvas");132      canvas.width = Math.ceil(viewport.width);133      canvas.height = Math.ceil(viewport.height);134      const ctx = canvas.getContext("2d");135      if (!ctx) {136        errors.push(`Page ${pageNum}: no canvas context`);137        continue;138      }139140      // pdf.js requires the canvas to be filled before render to avoid the141      // transparent-on-transparent dark mode regression in v4 — paint white.142      ctx.fillStyle = "white";143      ctx.fillRect(0, 0, canvas.width, canvas.height);144145      await page.render({ canvas, canvasContext: ctx, viewport }).promise;146147      const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices);148      for (const env of pageEnvelopes) envelopes.push(env);149150      if (totalExpected === null && pageEnvelopes.length > 0) {151        totalExpected = pageEnvelopes[0]!.t;152      }153    } catch (err) {154      errors.push(155        `Page ${pageNum} failed: ${err instanceof Error ? err.message : String(err)}`,156      );157    }158  }159160  return {161    chunksFound: envelopes.length,162    totalExpected,163    envelopes,164    errors,165  };166}167168/**169 * Crop each of the 36 (6×6) printed Aztec codes out of the rendered page and170 * try to decode it. Cells that contain no code — the trailing empty cells on171 * the last page — just throw NotFoundException and are skipped.172 *173 * Two non-obvious things this function gets right that earlier revisions did174 * not:175 *176 *   1. Cropping follows the *exact* layout drawn by `pdf.ts` (margins,177 *      header band, code size, column/row gaps) instead of a naive178 *      `canvas / 6` slice. The naive slicer clipped every Aztec on its179 *      right edge — ZXing's binarizer can't find a finder pattern in a180 *      chopped symbol. Geometry constants are shared with the generator181 *      via `pdf-layout.ts` so the two can't drift again.182 *183 *   2. We decode the cell pixels directly through `AztecCodeReader` +184 *      `RGBLuminanceSource` rather than handing the canvas to185 *      `BrowserAztecCodeReader.decode()`. ZXing's BrowserCodeReader only186 *      knows how to read `HTMLVideoElement` and `HTMLImageElement` — when187 *      you pass it a canvas, its internal `createCaptureCanvas` falls188 *      through to no branch and creates a zero-sized capture canvas, so189 *      every decode returns `NotFoundException`. The DOM-free path here is190 *      the same one the generator uses for round-trip verification.191 *192 * Around each code we also include a small quiet-zone margin (≈12% of the193 * code size). The PDF prints codes against a cream background with no194 * required quiet zone; ZXing's locator is happier with a white one.195 */196async function scanCanvasGrid(197  canvas: HTMLCanvasElement,198  seenIndices: Set<number>,199): Promise<ChunkEnvelope[]> {200  const found: ChunkEnvelope[] = [];201202  // The page was rendered at a uniform scale, so canvas pixels per PDF point203  // is just canvas.width / PAGE_WIDTH (≈ canvas.height / PAGE_HEIGHT for an204  // A4 source). Independent x/y scales keep us correct if the renderer ever205  // applies non-uniform scaling.206  const scaleX = canvas.width / PAGE_WIDTH;207  const scaleY = canvas.height / PAGE_HEIGHT;208209  // Top edge of row 0 in PDF coords (bottom-origin) is just below the header.210  // Convert to canvas coords (top-origin): canvas_y = (PAGE_HEIGHT - pdf_y) * scaleY.211  const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;212  const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;213214  const codeWpx = CODE_SIZE * scaleX;215  const codeHpx = CODE_SIZE * scaleY;216  const quietX = Math.round(codeWpx * 0.12);217  const quietY = Math.round(codeHpx * 0.12);218219  // One scratch canvas reused across all cells — cheaper than allocating220  // 36 canvases per page when a Codex spans dozens of pages.221  const cellCanvas = document.createElement("canvas");222  const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true });223  if (!cellCtx) return found;224225  for (let row = 0; row < CODES_PER_COL; row++) {226    for (let col = 0; col < CODES_PER_ROW; col++) {227      const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;228      const codeY = gridTopCanvas + row * ROW_STEP * scaleY;229230      const sx = Math.max(0, Math.floor(codeX - quietX));231      const sy = Math.max(0, Math.floor(codeY - quietY));232      const sw = Math.min(233        canvas.width - sx,234        Math.ceil(codeWpx + 2 * quietX),235      );236      const sh = Math.min(237        canvas.height - sy,238        Math.ceil(codeHpx + 2 * quietY),239      );240      if (sw <= 0 || sh <= 0) continue;241242      cellCanvas.width = sw;243      cellCanvas.height = sh;244      // Paint the quiet-zone background white before drawing — the source245      // PDF's cream is light but not pure white, and a clean white border246      // gives the binarizer the cleanest finder-pattern contrast.247      cellCtx.fillStyle = "white";248      cellCtx.fillRect(0, 0, sw, sh);249      cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);250251      const text = decodeAztecFromCellPixels(cellCtx, sw, sh);252      if (!text) continue;253254      const envelope = parseChunkEnvelope(text);255      if (!envelope) continue;256      if (seenIndices.has(envelope.i)) continue;257      if (!(await verifyChunkIntegrity(envelope))) continue;258259      seenIndices.add(envelope.i);260      found.push(envelope);261    }262  }263264  return found;265}266267/**268 * Read the cell canvas's pixels into an ARGB Int32Array, wrap it in a269 * `RGBLuminanceSource`, and try to decode it as an Aztec code. Returns the270 * decoded text on success, or `null` if no code was found.271 *272 * We try `HybridBinarizer` first (good for rendered images with subtle273 * shading from anti-aliasing) and fall back to `GlobalHistogramBinarizer`274 * (better when the image is dominated by clean black/white modules). A fresh275 * `AztecCodeReader` is allocated per attempt — the reader caches internal276 * state that must be reset between bitmaps.277 */278function decodeAztecFromCellPixels(279  cellCtx: CanvasRenderingContext2D,280  sw: number,281  sh: number,282): string | null {283  const imageData = cellCtx.getImageData(0, 0, sw, sh);284  const raw = imageData.data;285  const argb = new Int32Array(sw * sh);286  for (let i = 0, j = 0; i < raw.length; i += 4, j++) {287    argb[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;288  }289290  const source = new RGBLuminanceSource(argb, sw, sh);291292  try {293    const bitmap = new BinaryBitmap(new HybridBinarizer(source));294    return new AztecCodeReader().decode(bitmap).getText();295  } catch (err) {296    if (!(err instanceof NotFoundException)) return null;297  }298299  try {300    const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));301    return new AztecCodeReader().decode(bitmap).getText();302  } catch {303    return null;304  }305}306307/**308 * Parse a raw decoded string as a Peach ChunkEnvelope. Returns null for any309 * non-Peach payload — the same defensive contract as restore.ts/parseRawScan310 * but inlined here so this module stays decoupled from the camera scan path.311 */312export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {313  let parsed: unknown;314  try {315    parsed = JSON.parse(raw);316  } catch {317    return null;318  }319  if (typeof parsed !== "object" || parsed === null) return null;320  const obj = parsed as Record<string, unknown>;321  if (322    obj.v === 2 &&323    typeof obj.c === "string" &&324    typeof obj.i === "number" &&325    typeof obj.t === "number" &&326    typeof obj.p === "boolean" &&327    typeof obj.d === "string" &&328    typeof obj.h === "string"329  ) {330    return obj as unknown as ChunkEnvelope;331  }332  return null;333}