pdf-import.ts

26.8 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_PAGE,38  CODES_PER_ROW,39  type ChunkEnvelope,40} from "../../shared/codex-types";41import { verifyChunkIntegrity } from "./helpers";42import { IMPORT_LIMITS } from "../../shared/import-limits";43import {44  CODE_SIZE,45  COL_GAP,46  HEADER_GAP,47  HEADER_HEIGHT,48  MARGIN,49  PAGE_HEIGHT,50  PAGE_WIDTH,51  ROW_STEP,52} from "./pdf-layout";5354/**55 * Lazily-loaded pdf.js module. Top-level `import * as pdfjsLib` would fail at56 * test time because pdfjs-dist references DOMMatrix at module init, which57 * vitest's node environment lacks. We only need pdf.js inside importCodexPDF;58 * the parser surface (parseChunkEnvelope) can stay importable from tests.59 */60type PdfJsModule = typeof import("pdfjs-dist");61type PdfPage = any;62let pdfJsPromise: Promise<PdfJsModule> | null = null;6364async function getPdfJs(): Promise<PdfJsModule> {65  if (!pdfJsPromise) {66    pdfJsPromise = import("pdfjs-dist").then((mod) => {67      if (68        typeof mod.GlobalWorkerOptions.workerSrc !== "string" ||69        mod.GlobalWorkerOptions.workerSrc.length === 070      ) {71        if (typeof chrome !== "undefined" && chrome.runtime?.getURL) {72          mod.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL(73            "pdf.worker.min.mjs",74          );75        } else {76          mod.GlobalWorkerOptions.workerSrc = "./pdf.worker.min.mjs";77        }78      }79      return mod;80    });81  }82  return pdfJsPromise;83}8485export interface PDFImportResult {86  /** Number of envelopes successfully decoded across all pages. */87  chunksFound: number;88  /** Total chunk count printed on the Codex: sum of every stripe's `t`. */89  totalExpected: number | null;90  /** Decoded envelopes. Caller feeds these into ChunkBuffer. */91  envelopes: ChunkEnvelope[];92  /** Page-level error notes — empty when every page rendered cleanly. */93  errors: string[];94}9596export type PDFImportProgress = (page: number, totalPages: number) => void9798/**99 * Render scales that produce a 1:1 canvas-to-source-PNG mapping for known100 * Codex generations, ordered from newest to oldest. ZXing's AztecDetector101 * requires near-exact 1:1 rendering — even a 1% scale error causes102 * ReedSolomonException (downscale) or FormatException (upscale). The formula103 * is: scale = (modules × 4 + 16) / CODE_SIZE_pt where modules is the Aztec104 * matrix size and CODE_SIZE_pt = 80.105 *106 * Current generation (CHUNK_SIZE=350): ZXing → 75-module codes → 316px PNG107 *   → scale = 3.95108 * Prior generation (CHUNK_SIZE=800, ZXing path): 101-module codes → 420px PNG109 *   → scale = 5.25110 * Prior generation (CHUNK_SIZE=800, bwip-js path): 117-module codes → 484px PNG111 *   → scale = 6.05112 */113const CANDIDATE_SCALES = [3.95, 5.25, 6.05] as const;114115// PCH-01-014: bound attacker-controlled page count and page dimensions. A real116// Codex needs ~200 pages; 250 is generous headroom.117const MAX_PDF_PAGES = 250;118// Largest render scale we ever probe — derived from CANDIDATE_SCALES so the119// dimension clamp can never drift from the scale set.120const MAX_RENDER_SCALE = Math.max(...CANDIDATE_SCALES);121// A4 in points is 595×842. At MAX_RENDER_SCALE the long axis is ~5052 px; ×1.5122// slack tolerates Letter / landscape / slightly larger legitimate layouts while123// rejecting the 14400-pt MediaBox pathological case (~tens of GB RGBA).124const MAX_RENDERED_DIMENSION = Math.ceil(842 * MAX_RENDER_SCALE * 1.5);125const MAX_PDF_RENDER_PIXELS = 2_000_000_000;126127export interface PDFRenderBudget {128  remainingPixels: number;129}130131class PDFRenderBudgetError extends Error {}132133export function createPDFRenderBudget(maxPixels = MAX_PDF_RENDER_PIXELS): PDFRenderBudget {134  return { remainingPixels: maxPixels };135}136137export function claimPDFRenderPixels(138  budget: PDFRenderBudget,139  width: number,140  height: number,141): void {142  const pixels = width * height;143  if (pixels > budget.remainingPixels) {144    throw new PDFRenderBudgetError("PDF aggregate render limit exceeded");145  }146  budget.remainingPixels -= pixels;147}148149interface CellDecodeStrategy {150  cropFactor: number;151  normalize: boolean;152  upscale: number;153}154155const SMALL_SCALE_STRATEGIES: CellDecodeStrategy[] = [156  { cropFactor: 1.0, normalize: false, upscale: 1.0 },157  { cropFactor: 0.88, normalize: false, upscale: 1.0 },158  { cropFactor: 1.12, normalize: false, upscale: 1.0 },159  { cropFactor: 1.0, normalize: true, upscale: 1.0 },160  { cropFactor: 0.88, normalize: true, upscale: 1.0 },161  { cropFactor: 1.0, normalize: false, upscale: 2.0 },162  { cropFactor: 1.0, normalize: true, upscale: 2.0 },163];164165const LARGE_SCALE_STRATEGIES: CellDecodeStrategy[] = [166  { cropFactor: 1.0, normalize: false, upscale: 1.0 },167  { cropFactor: 1.12, normalize: false, upscale: 1.0 },168  { cropFactor: 0.88, normalize: false, upscale: 1.0 },169  { cropFactor: 1.0, normalize: true, upscale: 1.0 },170  { cropFactor: 1.12, normalize: true, upscale: 1.0 },171  { cropFactor: 1.0, normalize: false, upscale: 2.0 },172  { cropFactor: 1.0, normalize: true, upscale: 2.0 },173];174175function getCellDecodeStrategies(renderScale: number): CellDecodeStrategy[] {176  return renderScale < 4.5 ? SMALL_SCALE_STRATEGIES : LARGE_SCALE_STRATEGIES;177}178179/**180 * Probe up to three pages of `pdf` at every candidate scale and return the181 * scale that decodes the highest fraction of codes. "First scale that decodes182 * anything" is not sufficient — scale 5.25 can occasionally decode 1–2 codes183 * from a 6.05 (117-module) Codex, producing a false-positive that makes the184 * full import use the wrong scale. Taking the maximum across multiple pages185 * avoids this: the correct scale wins by a large margin.186 *187 * Early exit: once a scale achieves a perfect probe score we can stop — no188 * later candidate can beat 100% decode rate on the sampled pages.189 */190async function detectRenderScale(191  pdf: Awaited<ReturnType<PdfJsModule["getDocument"]>["promise"]>,192  budget: PDFRenderBudget,193): Promise<number> {194  let bestScale: number = CANDIDATE_SCALES[0];195  let bestRate = -1;196  const probePages = Math.min(3, pdf.numPages);197198  for (const scale of CANDIDATE_SCALES) {199    try {200      let decoded = 0;201      let total = 0;202      for (let pageNum = 1; pageNum <= probePages; pageNum++) {203        const page = await pdf.getPage(pageNum);204        const result = await probePageDecodeRate(page, scale, budget);205        decoded += result.decoded;206        total += result.total;207      }208      const rate = total > 0 ? decoded / total : 0;209      if (rate > bestRate) {210        bestRate = rate;211        bestScale = scale;212      }213      if (rate >= 0.99) {214        return scale;215      }216    } catch (error) {217      if (error instanceof PDFRenderBudgetError) throw error;218      // continue to next candidate219    }220  }221222  return bestScale;223}224225async function probePageDecodeRate(226  page: PdfPage,227  scale: number,228  budget: PDFRenderBudget,229): Promise<{ decoded: number; total: number }> {230  const canvas = await renderPageToCanvas(page, scale, budget);231  try {232    const ctx = canvas.getContext("2d");233    if (!ctx) return { decoded: 0, total: CODES_PER_PAGE };234235    let decoded = 0;236    for (let row = 0; row < CODES_PER_COL; row++) {237      for (let col = 0; col < CODES_PER_ROW; col++) {238        const rect = cellRectFor(canvas.width, canvas.height, row, col);239        if (!rect.sw || !rect.sh) continue;240        const text = decodeAztecCellRobust(241          canvas,242          ctx,243          { x: rect.sx, y: rect.sy, w: rect.sw, h: rect.sh },244          scale,245        );246        if (text !== null) decoded++;247      }248    }249    return { decoded, total: CODES_PER_PAGE };250  } finally {251    canvas.width = 0;252    canvas.height = 0;253  }254}255256async function renderPageToCanvas(257  page: PdfPage,258  scale: number,259  budget: PDFRenderBudget,260): Promise<HTMLCanvasElement> {261  const viewport = page.getViewport({ scale });262  // PCH-01-014: refuse pages whose scaled dimensions blow past a generous263  // A4-at-max-scale budget. A tiny PDF can declare a 14400×14400 pt MediaBox,264  // which at this scale would allocate a multi-GB RGBA canvas. The throw lands265  // in the page loop's existing catch, which records and skips the page.266  const w = Math.ceil(viewport.width);267  const h = Math.ceil(viewport.height);268  if (w > MAX_RENDERED_DIMENSION || h > MAX_RENDERED_DIMENSION) {269    throw new Error(270      `page dimensions ${w}×${h}px exceed the ${MAX_RENDERED_DIMENSION}px render limit`,271    );272  }273  claimPDFRenderPixels(budget, w, h);274  const canvas = document.createElement("canvas");275  canvas.width = w;276  canvas.height = h;277  const ctx = canvas.getContext("2d");278  if (!ctx) {279    return canvas;280  }281  ctx.fillStyle = "white";282  ctx.fillRect(0, 0, canvas.width, canvas.height);283  await page.render({ canvas, canvasContext: ctx, viewport }).promise;284  return canvas;285}286287/**288 * Import a Peach Codex PDF and decode every Aztec code it contains.289 *290 * Automatically detects the render scale by probing up to three pages at291 * each candidate scale in CANDIDATE_SCALES — the scale with the highest292 * decode rate is used for all pages. This makes the importer293 * backward-compatible with Codexes generated at any prior CHUNK_SIZE, and294 * forward-compatible with future changes, without requiring any version295 * metadata in the PDF.296 */297export async function importCodexPDF(298  file: File,299  onProgress?: PDFImportProgress,300): Promise<PDFImportResult> {301  // MV-5: refuse oversized files before reading them into memory — and before302  // even loading pdf.js. A real Codex tops out well under this (200 pages ≈303  // 4 MiB of Aztec PNGs).304  if (file.size > IMPORT_LIMITS.MAX_PDF_BYTES) {305    return {306      chunksFound: 0,307      totalExpected: null,308      envelopes: [],309      errors: [310        `PDF is ${(file.size / (1024 * 1024)).toFixed(1)} MiB — Codex PDFs are limited to ${IMPORT_LIMITS.MAX_PDF_BYTES / (1024 * 1024)} MiB.`,311      ],312    };313  }314315  const pdfjsLib = await getPdfJs();316317  const envelopes: ChunkEnvelope[] = [];318  const errors: string[] = [];319  // Key is `${stripe}:${chunkIndex}` so multi-stripe Codexes don't treat320  // stripe 1's chunk 0 as a duplicate of stripe 0's chunk 0.321  const seenIndices = new Set<string>();322  // PCH-01-026 #4: `t` is the per-stripe chunk total, not the Codex total. Track323  // each stripe's declared total once and sum after the scan so a multi-stripe324  // Codex reports the true global count, not just stripe 0's.325  const stripeTotalsForExpected = new Map<number, number>();326  const renderBudget = createPDFRenderBudget();327328  let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;329  try {330    const arrayBuffer = await file.arrayBuffer();331    // MV-9: this parses arbitrary user-selected PDFs. The popup CSP already332    // blocks eval, but disable pdf.js's eval-dependent font paths in depth333    // (CVE-2024-4367 class), and turn off features Aztec extraction never334    // needs.335    pdf = await pdfjsLib.getDocument({336      data: arrayBuffer,337      // isEvalSupported is a valid pdf.js runtime option (defends the338      // CVE-2024-4367 eval-via-font path) but is absent from pdfjs-dist's339      // DocumentInitParameters typings — cast to add it.340      isEvalSupported: false,341      disableFontFace: true, // fonts not needed for Aztec bitmap extraction342      disableRange: true,    // whole buffer already in memory343      disableStream: true,   // ditto344    } as Parameters<typeof pdfjsLib.getDocument>[0]).promise;345  } catch (err) {346    return {347      chunksFound: 0,348      totalExpected: null,349      envelopes,350      errors: [351        `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,352      ],353    };354  }355356  // PCH-01-014: cap pages so a PDF declaring thousands of pages cannot wedge357  // the popup in a multi-minute render loop.358  const declaredPages = pdf.numPages;359  const pageCount = Math.min(declaredPages, MAX_PDF_PAGES);360  if (declaredPages > MAX_PDF_PAGES) {361    errors.push(362      `PDF has ${declaredPages} pages; only the first ${MAX_PDF_PAGES} were scanned.`,363    );364  }365  let renderScale: number;366  try {367    renderScale = await detectRenderScale(pdf, renderBudget);368  } catch (error) {369    errors.push(error instanceof Error ? error.message : String(error));370    return { chunksFound: 0, totalExpected: null, envelopes, errors };371  }372373  for (let pageNum = 1; pageNum <= pageCount; pageNum++) {374    onProgress?.(pageNum, pageCount);375376    let canvas: HTMLCanvasElement | undefined;377    try {378      const page = await pdf.getPage(pageNum);379      canvas = await renderPageToCanvas(page, renderScale, renderBudget);380      const ctx = canvas.getContext("2d");381      if (!ctx) {382        errors.push(`Page ${pageNum}: no canvas context`);383        continue;384      }385386      const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices, renderScale);387      for (const env of pageEnvelopes) envelopes.push(env);388389      for (const env of pageEnvelopes) {390        const stripe = env.v === 3 ? (env.s ?? 0) : 0;391        // Each stripe's `t` is constant across its envelopes; last write wins.392        stripeTotalsForExpected.set(stripe, env.t);393      }394    } catch (err) {395      errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`);396      if (err instanceof PDFRenderBudgetError) break;397    } finally {398      if (canvas) {399        canvas.width = 0;400        canvas.height = 0;401      }402    }403  }404405  const metricsEnabled =406    typeof window !== "undefined" &&407    (import.meta.env.MODE === "test" ||408      new URLSearchParams(window.location.search).has("codexMetrics"));409  if (metricsEnabled) {410    const stripeTotals = new Map<number, number>();411    const stripeDecoded = new Map<number, number>();412    for (const env of envelopes) {413      const stripe = env.v === 3 ? (env.s ?? 0) : 0;414      stripeTotals.set(stripe, env.t);415      stripeDecoded.set(stripe, (stripeDecoded.get(stripe) ?? 0) + 1);416    }417    const totalPrinted = Array.from(stripeTotals.values()).reduce((sum, total) => sum + total, 0);418    const stripeIndices = Array.from(stripeTotals.keys());419    const maxStripe = stripeIndices.length > 0 ? Math.max(...stripeIndices) : -1;420    (window as Window & {421      __codexImportMetrics?: {422        decodeRate: number;423        shortStripeCount: number;424        perStripeDecoded: number[];425      };426    }).__codexImportMetrics = {427      decodeRate: totalPrinted > 0 ? envelopes.length / totalPrinted : 0,428      shortStripeCount: Array.from(stripeTotals.entries()).filter(429        ([stripe, total]) => (stripeDecoded.get(stripe) ?? 0) < total,430      ).length,431      perStripeDecoded:432        maxStripe >= 0433          ? Array.from({ length: maxStripe + 1 }, (_, stripe) => stripeDecoded.get(stripe) ?? 0)434          : [],435    };436  }437438  // PCH-01-026 #4: the Codex total is the sum of every stripe's `t`.439  const totalExpected =440    stripeTotalsForExpected.size > 0441      ? Array.from(stripeTotalsForExpected.values()).reduce((sum, t) => sum + t, 0)442      : null;443444  return {445    chunksFound: envelopes.length,446    totalExpected,447    envelopes,448    errors,449  };450}451452/**453 * Crop each of the 36 (6×6) printed Aztec codes out of the rendered page and454 * try to decode it. Cells that contain no code — the trailing empty cells on455 * the last page — just throw NotFoundException and are skipped.456 *457 * Two non-obvious things this function gets right that earlier revisions did458 * not:459 *460 *   1. Cropping follows the *exact* layout drawn by `pdf.ts` (margins,461 *      header band, code size, column/row gaps) instead of a naive462 *      `canvas / 6` slice. The naive slicer clipped every Aztec on its463 *      right edge — ZXing's binarizer can't find a finder pattern in a464 *      chopped symbol. Geometry constants are shared with the generator465 *      via `pdf-layout.ts` so the two can't drift again.466 *467 *   2. We decode the cell pixels directly through `AztecCodeReader` +468 *      `RGBLuminanceSource` rather than handing the canvas to469 *      `BrowserAztecCodeReader.decode()`. ZXing's BrowserCodeReader only470 *      knows how to read `HTMLVideoElement` and `HTMLImageElement` — when471 *      you pass it a canvas, its internal `createCaptureCanvas` falls472 *      through to no branch and creates a zero-sized capture canvas, so473 *      every decode returns `NotFoundException`. The DOM-free path here is474 *      the same one the generator uses for round-trip verification.475 *476 * Around each code we also include a small quiet-zone margin (≈12% of the477 * code size). The PDF prints codes against a cream background with no478 * required quiet zone; ZXing's locator is happier with a white one.479 */480async function scanCanvasGrid(481  canvas: HTMLCanvasElement,482  seenIndices: Set<string>,483  renderScale: number,484): Promise<ChunkEnvelope[]> {485  const found: ChunkEnvelope[] = [];486487  // One scratch canvas reused across all cells — cheaper than allocating488  // 36 canvases per page when a Codex spans dozens of pages.489  const cellCanvas = document.createElement("canvas");490  const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true });491  if (!cellCtx) return found;492493  for (let row = 0; row < CODES_PER_COL; row++) {494    for (let col = 0; col < CODES_PER_ROW; col++) {495      const { sx, sy, sw, sh } = cellRectFor(canvas.width, canvas.height, row, col);496      if (sw <= 0 || sh <= 0) continue;497498      cellCanvas.width = sw;499      cellCanvas.height = sh;500      // Paint the quiet-zone background white before drawing — the source501      // PDF's cream is light but not pure white, and a clean white border502      // gives the binarizer the cleanest finder-pattern contrast.503      cellCtx.fillStyle = "white";504      cellCtx.fillRect(0, 0, sw, sh);505      cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);506507      const text = decodeAztecCellRobust(508        cellCanvas,509        cellCtx,510        { x: 0, y: 0, w: sw, h: sh },511        renderScale,512      );513      if (!text) continue;514515      const envelope = parseChunkEnvelope(text);516      if (!envelope) continue;517      const stripeKey = `${envelope.s ?? 0}:${envelope.i}`;518      if (seenIndices.has(stripeKey)) continue;519      if (!(await verifyChunkIntegrity(envelope))) continue;520521      seenIndices.add(stripeKey);522      found.push(envelope);523    }524  }525526  return found;527}528529function decodeAztecCellRobust(530  canvas: HTMLCanvasElement,531  ctx: CanvasRenderingContext2D,532  cellRect: { x: number; y: number; w: number; h: number },533  renderScale: number,534): string | null {535  const strategies = getCellDecodeStrategies(renderScale);536  for (const strategy of strategies) {537    const result = decodeAztecCellWithStrategy(canvas, ctx, cellRect, strategy);538    if (result !== null) {539      return result;540    }541  }542  return null;543}544545function decodeAztecCellWithStrategy(546  canvas: HTMLCanvasElement,547  ctx: CanvasRenderingContext2D,548  cellRect: { x: number; y: number; w: number; h: number },549  strategy: CellDecodeStrategy,550): string | null {551  const { x, y, w, h } = applyCropFactor(cellRect, strategy.cropFactor, canvas.width, canvas.height);552  if (w <= 0 || h <= 0) return null;553554  const imageData = ctx.getImageData(x, y, w, h);555  const processedData = strategy.normalize556    ? normalizeToBlackWhite(imageData)557    : imageData;558559  const argb = imageDataToArgb(processedData);560  const { buffer, width, height } =561    strategy.upscale > 1562      ? upscaleArgb(argb, processedData.width, processedData.height, strategy.upscale)563      : { buffer: argb, width: processedData.width, height: processedData.height };564565  return decodeAztecFromArgb(buffer, width, height);566}567568function applyCropFactor(569  rect: { x: number; y: number; w: number; h: number },570  factor: number,571  canvasW: number,572  canvasH: number,573): { x: number; y: number; w: number; h: number } {574  const cx = rect.x + rect.w / 2;575  const cy = rect.y + rect.h / 2;576  const nw = Math.max(1, Math.round(rect.w * factor));577  const nh = Math.max(1, Math.round(rect.h * factor));578  const x = Math.max(0, Math.min(canvasW - 1, Math.round(cx - nw / 2)));579  const y = Math.max(0, Math.min(canvasH - 1, Math.round(cy - nh / 2)));580  const w = Math.max(1, Math.min(canvasW - x, nw));581  const h = Math.max(1, Math.min(canvasH - y, nh));582  return { x, y, w, h };583}584585function normalizeToBlackWhite(imageData: ImageData): ImageData {586  const data = new Uint8ClampedArray(imageData.data);587  for (let i = 0; i < data.length; i += 4) {588    const lum = 0.299 * data[i]! + 0.587 * data[i + 1]! + 0.114 * data[i + 2]!;589    const v = lum > 128 ? 255 : 0;590    data[i] = v;591    data[i + 1] = v;592    data[i + 2] = v;593    data[i + 3] = 255;594  }595  return new ImageData(data, imageData.width, imageData.height);596}597598function upscaleArgb(599  argb: Int32Array,600  width: number,601  height: number,602  scale: number,603): { buffer: Int32Array; width: number; height: number } {604  const targetW = Math.max(1, Math.round(width * scale));605  const targetH = Math.max(1, Math.round(height * scale));606  const out = new Int32Array(targetW * targetH);607  for (let y = 0; y < targetH; y++) {608    const srcY = Math.min(height - 1, Math.floor(y / scale));609    for (let x = 0; x < targetW; x++) {610      const srcX = Math.min(width - 1, Math.floor(x / scale));611      out[y * targetW + x] = argb[srcY * width + srcX]!;612    }613  }614  return { buffer: out, width: targetW, height: targetH };615}616617/**618 * Compute the canvas-pixel sub-rectangle for the printed Aztec at (row, col)619 * on a page that was rendered at uniform scale into the given canvas620 * dimensions.621 *622 * Exported (rather than inlined into `scanCanvasGrid`) so the geometry can be623 * verified directly in tests — `scanCanvasGrid` itself needs an624 * `HTMLCanvasElement` and `getImageData()` which vitest's node environment625 * lacks, but this pure function does not.626 */627export function cellRectFor(628  canvasW: number,629  canvasH: number,630  row: number,631  col: number,632): { sx: number; sy: number; sw: number; sh: number } {633  // Canvas pixels per PDF point. Independent x/y scales keep us correct if634  // the renderer ever applies non-uniform scaling.635  const scaleX = canvasW / PAGE_WIDTH;636  const scaleY = canvasH / PAGE_HEIGHT;637638  // Top edge of row 0 in PDF coords (bottom-origin) is just below the header.639  // Convert to canvas coords (top-origin): canvas_y = (PAGE_HEIGHT - pdf_y) * scaleY.640  const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;641  const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;642643  const codeWpx = CODE_SIZE * scaleX;644  const codeHpx = CODE_SIZE * scaleY;645  const quietX = Math.round(codeWpx * 0.12);646  const quietY = Math.round(codeHpx * 0.12);647648  const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;649  const codeY = gridTopCanvas + row * ROW_STEP * scaleY;650651  const sx = Math.max(0, Math.floor(codeX - quietX));652  const sy = Math.max(0, Math.floor(codeY - quietY));653  const sw = Math.min(canvasW - sx, Math.ceil(codeWpx + 2 * quietX));654  const sh = Math.min(canvasH - sy, Math.ceil(codeHpx + 2 * quietY));655  return { sx, sy, sw, sh };656}657658/**659 * Convert an RGBA `ImageData.data` buffer into the packed ARGB `Int32Array`660 * that `RGBLuminanceSource` expects. Exposed for tests that synthesize a cell661 * buffer directly rather than going through a canvas.662 */663export function imageDataToArgb(imageData: ImageData): Int32Array {664  const raw = imageData.data;665  const out = new Int32Array(imageData.width * imageData.height);666  for (let i = 0, j = 0; i < raw.length; i += 4, j++) {667    out[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;668  }669  return out;670}671672/**673 * Decode a single Aztec code from a packed ARGB pixel buffer. Returns the674 * decoded text on success, or `null` if no code was found.675 *676 * We try `HybridBinarizer` first (good for rendered images with subtle677 * shading from anti-aliasing) and fall back to `GlobalHistogramBinarizer`678 * (better when the image is dominated by clean black/white modules). A fresh679 * `AztecCodeReader` is allocated per attempt — the reader caches internal680 * state that must be reset between bitmaps.681 *682 * This is the exact decode path the importer used to skip by handing canvas683 * elements to `BrowserAztecCodeReader.decode()`, which silently fails on684 * canvases — its `createCaptureCanvas` only recognizes video and image685 * elements and produces a zero-sized capture for anything else. Keeping this686 * function exported lets the test suite verify the path works on synthesized687 * Aztec pixels without needing a real DOM canvas.688 */689export function decodeAztecFromArgb(690  argb: Int32Array,691  width: number,692  height: number,693): string | null {694  const source = new RGBLuminanceSource(argb, width, height);695696  try {697    const bitmap = new BinaryBitmap(new HybridBinarizer(source));698    return new AztecCodeReader().decode(bitmap).getText();699  } catch (err) {700    if (!(err instanceof NotFoundException)) return null;701  }702703  try {704    const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));705    return new AztecCodeReader().decode(bitmap).getText();706  } catch {707    return null;708  }709}710711/**712 * Parse a raw decoded string as a Peach ChunkEnvelope. Returns null for any713 * non-Peach payload — the same defensive contract as `restore.ts/parseRawScan`714 * but inlined here so this module stays decoupled from the camera scan path.715 *716 * Accepts current v3 envelopes. Older paper formats are rejected.717 */718export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {719  let parsed: unknown;720  try {721    parsed = JSON.parse(raw);722  } catch {723    return null;724  }725  if (typeof parsed !== "object" || parsed === null) return null;726  const obj = parsed as Record<string, unknown>;727728  if (obj.v !== 3) return null;729730  const baseShapeOk =731    typeof obj.c === "string" &&732    Number.isInteger(obj.i) && // PCH-01-012: reject NaN/Infinity/fractional indices733    Number.isInteger(obj.t) &&734    typeof obj.p === "boolean" &&735    typeof obj.d === "string" &&736    typeof obj.h === "string";737  if (!baseShapeOk) return null;738739  if (!Number.isInteger(obj.s) || !Number.isInteger(obj.S)) {740    return null;741  }742743  return obj as unknown as ChunkEnvelope;744}