pdf-import.ts

9.7 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 { BrowserAztecCodeReader, NotFoundException } from "@zxing/library";2021import {22  CODES_PER_COL,23  CODES_PER_ROW,24  type ChunkEnvelope,25} from "../../shared/codex-types";26import { verifyChunkIntegrity } from "./helpers";27import {28  CODE_SIZE,29  COL_GAP,30  HEADER_GAP,31  HEADER_HEIGHT,32  MARGIN,33  PAGE_HEIGHT,34  PAGE_WIDTH,35  ROW_STEP,36} from "./pdf-layout";3738/**39 * Lazily-loaded pdf.js module. Top-level `import * as pdfjsLib` would fail at40 * test time because pdfjs-dist v5 references DOMMatrix at module init, which41 * vitest's node environment lacks. We only need pdf.js inside importCodexPDF;42 * the parser surface (parseChunkEnvelope) can stay importable from tests.43 */44type PdfJsModule = typeof import("pdfjs-dist");45let pdfJsPromise: Promise<PdfJsModule> | null = null;4647async function getPdfJs(): Promise<PdfJsModule> {48  if (!pdfJsPromise) {49    pdfJsPromise = import("pdfjs-dist").then((mod) => {50      if (51        typeof mod.GlobalWorkerOptions.workerSrc !== "string" ||52        mod.GlobalWorkerOptions.workerSrc.length === 053      ) {54        if (typeof chrome !== "undefined" && chrome.runtime?.getURL) {55          mod.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL(56            "pdf.worker.min.mjs",57          );58        } else {59          mod.GlobalWorkerOptions.workerSrc = "./pdf.worker.min.mjs";60        }61      }62      return mod;63    });64  }65  return pdfJsPromise;66}6768export interface PDFImportResult {69  /** Number of envelopes successfully decoded across all pages. */70  chunksFound: number;71  /** Total chunk count printed on the Codex, taken from the first envelope. */72  totalExpected: number | null;73  /** Decoded envelopes. Caller feeds these into ChunkBuffer. */74  envelopes: ChunkEnvelope[];75  /** Page-level error notes — empty when every page rendered cleanly. */76  errors: string[];77}7879export interface PDFImportProgress {80  (page: number, totalPages: number): void;81}8283/**84 * Import a Peach Codex PDF and decode every Aztec code it contains.85 *86 * Renders each page at 2× scale (≈150dpi equivalent — well above the density87 * required for reliable Aztec decoding) and grid-scans each rendered page.88 */89export async function importCodexPDF(90  file: File,91  onProgress?: PDFImportProgress,92): Promise<PDFImportResult> {93  const pdfjsLib = await getPdfJs();9495  const envelopes: ChunkEnvelope[] = [];96  const errors: string[] = [];97  const seenIndices = new Set<number>();98  let totalExpected: number | null = null;99100  let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;101  try {102    const arrayBuffer = await file.arrayBuffer();103    pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;104  } catch (err) {105    return {106      chunksFound: 0,107      totalExpected: null,108      envelopes,109      errors: [110        `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,111      ],112    };113  }114115  const reader = new BrowserAztecCodeReader();116  const pageCount = pdf.numPages;117118  for (let pageNum = 1; pageNum <= pageCount; pageNum++) {119    onProgress?.(pageNum, pageCount);120121    try {122      const page = await pdf.getPage(pageNum);123      const viewport = page.getViewport({ scale: 2.0 });124125      const canvas = document.createElement("canvas");126      canvas.width = Math.ceil(viewport.width);127      canvas.height = Math.ceil(viewport.height);128      const ctx = canvas.getContext("2d");129      if (!ctx) {130        errors.push(`Page ${pageNum}: no canvas context`);131        continue;132      }133134      // pdf.js requires the canvas to be filled before render to avoid the135      // transparent-on-transparent dark mode regression in v4 — paint white.136      ctx.fillStyle = "white";137      ctx.fillRect(0, 0, canvas.width, canvas.height);138139      await page.render({ canvas, canvasContext: ctx, viewport }).promise;140141      const pageEnvelopes = await scanCanvasGrid(canvas, reader, seenIndices);142      for (const env of pageEnvelopes) envelopes.push(env);143144      if (totalExpected === null && pageEnvelopes.length > 0) {145        totalExpected = pageEnvelopes[0]!.t;146      }147    } catch (err) {148      errors.push(149        `Page ${pageNum} failed: ${err instanceof Error ? err.message : String(err)}`,150      );151    }152  }153154  return {155    chunksFound: envelopes.length,156    totalExpected,157    envelopes,158    errors,159  };160}161162/**163 * Crop each of the 36 (6×6) printed Aztec codes out of the rendered page and164 * try to decode it. Cells that contain no code — the trailing empty cells on165 * the last page — just throw NotFoundException and are skipped.166 *167 * Cropping must follow the *exact* layout drawn by `pdf.ts`, not a naive168 * `canvas / 6` slice: the page has 28pt margins, a 60pt header band, and169 * fixed 80pt code cells with small gaps. The earlier even-grid slicer cut170 * every Aztec on its right edge, producing "no Codex codes found" even on a171 * pristine digital PDF (the binarizer can't find a finder pattern in a172 * clipped symbol). Geometry constants are shared with the generator via173 * `pdf-layout.ts` so the two can't drift again.174 *175 * Around each code we also include a small quiet-zone margin (≈12% of the176 * code size). The PDF prints codes against the cream background with no177 * required quiet zone; ZXing's locator is happier with one.178 */179async function scanCanvasGrid(180  canvas: HTMLCanvasElement,181  reader: BrowserAztecCodeReader,182  seenIndices: Set<number>,183): Promise<ChunkEnvelope[]> {184  const found: ChunkEnvelope[] = [];185186  // The page was rendered at a uniform scale, so canvas pixels per PDF point187  // is just canvas.width / PAGE_WIDTH (≈ canvas.height / PAGE_HEIGHT for an188  // A4 source). Independent x/y scales keep us correct if the renderer ever189  // applies non-uniform scaling.190  const scaleX = canvas.width / PAGE_WIDTH;191  const scaleY = canvas.height / PAGE_HEIGHT;192193  // Top edge of row 0 in PDF coords (bottom-origin) is just below the header.194  // Convert to canvas coords (top-origin): canvas_y = (PAGE_HEIGHT - pdf_y) * scaleY.195  const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;196  const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;197198  const codeWpx = CODE_SIZE * scaleX;199  const codeHpx = CODE_SIZE * scaleY;200  const quietX = Math.round(codeWpx * 0.12);201  const quietY = Math.round(codeHpx * 0.12);202203  for (let row = 0; row < CODES_PER_COL; row++) {204    for (let col = 0; col < CODES_PER_ROW; col++) {205      const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;206      const codeY = gridTopCanvas + row * ROW_STEP * scaleY;207208      const sx = Math.max(0, Math.floor(codeX - quietX));209      const sy = Math.max(0, Math.floor(codeY - quietY));210      const sw = Math.min(211        canvas.width - sx,212        Math.ceil(codeWpx + 2 * quietX),213      );214      const sh = Math.min(215        canvas.height - sy,216        Math.ceil(codeHpx + 2 * quietY),217      );218      if (sw <= 0 || sh <= 0) continue;219220      const cellCanvas = document.createElement("canvas");221      cellCanvas.width = sw;222      cellCanvas.height = sh;223      const cellCtx = cellCanvas.getContext("2d");224      if (!cellCtx) continue;225226      // Paint the quiet-zone background white before drawing — the source227      // PDF's cream is light but not pure white, and a clean white border228      // gives ZXing the cleanest finder-pattern contrast.229      cellCtx.fillStyle = "white";230      cellCtx.fillRect(0, 0, sw, sh);231      cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);232233      let text: string;234      try {235        // @zxing/library's BrowserCodeReader.decode accepts any media element236        // at runtime — it branches on instanceof — but the TS signature only237        // lists HTMLImageElement. Cast through unknown to bypass the false238        // positive.239        const result = reader.decode(cellCanvas as unknown as HTMLImageElement);240        text = result.getText();241      } catch (err) {242        if (err instanceof NotFoundException) continue;243        continue;244      }245246      const envelope = parseChunkEnvelope(text);247      if (!envelope) continue;248      if (seenIndices.has(envelope.i)) continue;249      if (!(await verifyChunkIntegrity(envelope))) continue;250251      seenIndices.add(envelope.i);252      found.push(envelope);253    }254  }255256  return found;257}258259/**260 * Parse a raw decoded string as a Peach ChunkEnvelope. Returns null for any261 * non-Peach payload — the same defensive contract as restore.ts/parseRawScan262 * but inlined here so this module stays decoupled from the camera scan path.263 */264export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {265  let parsed: unknown;266  try {267    parsed = JSON.parse(raw);268  } catch {269    return null;270  }271  if (typeof parsed !== "object" || parsed === null) return null;272  const obj = parsed as Record<string, unknown>;273  if (274    obj.v === 2 &&275    typeof obj.c === "string" &&276    typeof obj.i === "number" &&277    typeof obj.t === "number" &&278    typeof obj.p === "boolean" &&279    typeof obj.d === "string" &&280    typeof obj.h === "string"281  ) {282    return obj as unknown as ChunkEnvelope;283  }284  return null;285}