pdf-import.ts

7.9 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";2728/**29 * Lazily-loaded pdf.js module. Top-level `import * as pdfjsLib` would fail at30 * test time because pdfjs-dist v5 references DOMMatrix at module init, which31 * vitest's node environment lacks. We only need pdf.js inside importCodexPDF;32 * the parser surface (parseChunkEnvelope) can stay importable from tests.33 */34type PdfJsModule = typeof import("pdfjs-dist");35let pdfJsPromise: Promise<PdfJsModule> | null = null;3637async function getPdfJs(): Promise<PdfJsModule> {38  if (!pdfJsPromise) {39    pdfJsPromise = import("pdfjs-dist").then((mod) => {40      if (41        typeof mod.GlobalWorkerOptions.workerSrc !== "string" ||42        mod.GlobalWorkerOptions.workerSrc.length === 043      ) {44        if (typeof chrome !== "undefined" && chrome.runtime?.getURL) {45          mod.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL(46            "pdf.worker.min.mjs",47          );48        } else {49          mod.GlobalWorkerOptions.workerSrc = "./pdf.worker.min.mjs";50        }51      }52      return mod;53    });54  }55  return pdfJsPromise;56}5758export interface PDFImportResult {59  /** Number of envelopes successfully decoded across all pages. */60  chunksFound: number;61  /** Total chunk count printed on the Codex, taken from the first envelope. */62  totalExpected: number | null;63  /** Decoded envelopes. Caller feeds these into ChunkBuffer. */64  envelopes: ChunkEnvelope[];65  /** Page-level error notes — empty when every page rendered cleanly. */66  errors: string[];67}6869export interface PDFImportProgress {70  (page: number, totalPages: number): void;71}7273/**74 * Import a Peach Codex PDF and decode every Aztec code it contains.75 *76 * Renders each page at 2× scale (≈150dpi equivalent — well above the density77 * required for reliable Aztec decoding) and grid-scans each rendered page.78 */79export async function importCodexPDF(80  file: File,81  onProgress?: PDFImportProgress,82): Promise<PDFImportResult> {83  const pdfjsLib = await getPdfJs();8485  const envelopes: ChunkEnvelope[] = [];86  const errors: string[] = [];87  const seenIndices = new Set<number>();88  let totalExpected: number | null = null;8990  let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;91  try {92    const arrayBuffer = await file.arrayBuffer();93    pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;94  } catch (err) {95    return {96      chunksFound: 0,97      totalExpected: null,98      envelopes,99      errors: [100        `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,101      ],102    };103  }104105  const reader = new BrowserAztecCodeReader();106  const pageCount = pdf.numPages;107108  for (let pageNum = 1; pageNum <= pageCount; pageNum++) {109    onProgress?.(pageNum, pageCount);110111    try {112      const page = await pdf.getPage(pageNum);113      const viewport = page.getViewport({ scale: 2.0 });114115      const canvas = document.createElement("canvas");116      canvas.width = Math.ceil(viewport.width);117      canvas.height = Math.ceil(viewport.height);118      const ctx = canvas.getContext("2d");119      if (!ctx) {120        errors.push(`Page ${pageNum}: no canvas context`);121        continue;122      }123124      // pdf.js requires the canvas to be filled before render to avoid the125      // transparent-on-transparent dark mode regression in v4 — paint white.126      ctx.fillStyle = "white";127      ctx.fillRect(0, 0, canvas.width, canvas.height);128129      await page.render({ canvas, canvasContext: ctx, viewport }).promise;130131      const pageEnvelopes = await scanCanvasGrid(canvas, reader, seenIndices);132      for (const env of pageEnvelopes) envelopes.push(env);133134      if (totalExpected === null && pageEnvelopes.length > 0) {135        totalExpected = pageEnvelopes[0]!.t;136      }137    } catch (err) {138      errors.push(139        `Page ${pageNum} failed: ${err instanceof Error ? err.message : String(err)}`,140      );141    }142  }143144  return {145    chunksFound: envelopes.length,146    totalExpected,147    envelopes,148    errors,149  };150}151152/**153 * Slice a rendered page canvas into the printed 6×6 grid and try to decode154 * each cell as a single Aztec code. Cells that contain no code (the trailing155 * empty cells on the last page) just throw NotFoundException and are skipped.156 *157 * Grid scanning beats whole-page scanning because zxing's binarizer locks158 * onto one symbol per call; with 36 symbols on the page it would otherwise159 * pick one and ignore the rest. We pay one decode attempt per cell — fast.160 */161async function scanCanvasGrid(162  canvas: HTMLCanvasElement,163  reader: BrowserAztecCodeReader,164  seenIndices: Set<number>,165): Promise<ChunkEnvelope[]> {166  const found: ChunkEnvelope[] = [];167  const cellW = canvas.width / CODES_PER_ROW;168  const cellH = canvas.height / CODES_PER_COL;169170  for (let row = 0; row < CODES_PER_COL; row++) {171    for (let col = 0; col < CODES_PER_ROW; col++) {172      const cellCanvas = document.createElement("canvas");173      cellCanvas.width = Math.floor(cellW);174      cellCanvas.height = Math.floor(cellH);175      const cellCtx = cellCanvas.getContext("2d");176      if (!cellCtx) continue;177178      cellCtx.drawImage(179        canvas,180        col * cellW,181        row * cellH,182        cellW,183        cellH,184        0,185        0,186        cellCanvas.width,187        cellCanvas.height,188      );189190      let text: string;191      try {192        // @zxing/library's BrowserCodeReader.decode accepts any media element193        // at runtime — it branches on instanceof — but the TS signature only194        // lists HTMLImageElement. Cast through unknown to bypass the false195        // positive.196        const result = reader.decode(cellCanvas as unknown as HTMLImageElement);197        text = result.getText();198      } catch (err) {199        // NotFoundException is normal — the trailing cells on the last page200        // contain no code. Re-throw anything else to surface real failures.201        if (err instanceof NotFoundException) continue;202        continue; // any other decode failure: skip this cell203      }204205      const envelope = parseChunkEnvelope(text);206      if (!envelope) continue;207      if (seenIndices.has(envelope.i)) continue;208      if (!(await verifyChunkIntegrity(envelope))) continue;209210      seenIndices.add(envelope.i);211      found.push(envelope);212    }213  }214215  return found;216}217218/**219 * Parse a raw decoded string as a Peach ChunkEnvelope. Returns null for any220 * non-Peach payload — the same defensive contract as restore.ts/parseRawScan221 * but inlined here so this module stays decoupled from the camera scan path.222 */223export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {224  let parsed: unknown;225  try {226    parsed = JSON.parse(raw);227  } catch {228    return null;229  }230  if (typeof parsed !== "object" || parsed === null) return null;231  const obj = parsed as Record<string, unknown>;232  if (233    obj.v === 2 &&234    typeof obj.c === "string" &&235    typeof obj.i === "number" &&236    typeof obj.t === "number" &&237    typeof obj.p === "boolean" &&238    typeof obj.d === "string" &&239    typeof obj.h === "string"240  ) {241    return obj as unknown as ChunkEnvelope;242  }243  return null;244}