pdf-import.ts

25.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 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);125126interface CellDecodeStrategy {127  cropFactor: number;128  normalize: boolean;129  upscale: number;130}131132const SMALL_SCALE_STRATEGIES: CellDecodeStrategy[] = [133  { cropFactor: 1.0, normalize: false, upscale: 1.0 },134  { cropFactor: 0.88, normalize: false, upscale: 1.0 },135  { cropFactor: 1.12, normalize: false, upscale: 1.0 },136  { cropFactor: 1.0, normalize: true, upscale: 1.0 },137  { cropFactor: 0.88, normalize: true, upscale: 1.0 },138  { cropFactor: 1.0, normalize: false, upscale: 2.0 },139  { cropFactor: 1.0, normalize: true, upscale: 2.0 },140];141142const LARGE_SCALE_STRATEGIES: CellDecodeStrategy[] = [143  { cropFactor: 1.0, normalize: false, upscale: 1.0 },144  { cropFactor: 1.12, normalize: false, upscale: 1.0 },145  { cropFactor: 0.88, normalize: false, upscale: 1.0 },146  { cropFactor: 1.0, normalize: true, upscale: 1.0 },147  { cropFactor: 1.12, normalize: true, upscale: 1.0 },148  { cropFactor: 1.0, normalize: false, upscale: 2.0 },149  { cropFactor: 1.0, normalize: true, upscale: 2.0 },150];151152function getCellDecodeStrategies(renderScale: number): CellDecodeStrategy[] {153  return renderScale < 4.5 ? SMALL_SCALE_STRATEGIES : LARGE_SCALE_STRATEGIES;154}155156/**157 * Probe up to three pages of `pdf` at every candidate scale and return the158 * scale that decodes the highest fraction of codes. "First scale that decodes159 * anything" is not sufficient — scale 5.25 can occasionally decode 1–2 codes160 * from a 6.05 (117-module) Codex, producing a false-positive that makes the161 * full import use the wrong scale. Taking the maximum across multiple pages162 * avoids this: the correct scale wins by a large margin.163 *164 * Early exit: once a scale achieves a perfect probe score we can stop — no165 * later candidate can beat 100% decode rate on the sampled pages.166 */167async function detectRenderScale(168  pdf: Awaited<ReturnType<PdfJsModule["getDocument"]>["promise"]>,169): Promise<number> {170  let bestScale: number = CANDIDATE_SCALES[0];171  let bestRate = -1;172  const probePages = Math.min(3, pdf.numPages);173174  for (const scale of CANDIDATE_SCALES) {175    try {176      let decoded = 0;177      let total = 0;178      for (let pageNum = 1; pageNum <= probePages; pageNum++) {179        const page = await pdf.getPage(pageNum);180        const result = await probePageDecodeRate(page, scale);181        decoded += result.decoded;182        total += result.total;183      }184      const rate = total > 0 ? decoded / total : 0;185      if (rate > bestRate) {186        bestRate = rate;187        bestScale = scale;188      }189      if (rate >= 0.99) {190        return scale;191      }192    } catch {193      // continue to next candidate194    }195  }196197  return bestScale;198}199200async function probePageDecodeRate(201  page: PdfPage,202  scale: number,203): Promise<{ decoded: number; total: number }> {204  const canvas = await renderPageToCanvas(page, scale);205  const ctx = canvas.getContext("2d");206  if (!ctx) return { decoded: 0, total: CODES_PER_PAGE };207208  let decoded = 0;209  for (let row = 0; row < CODES_PER_COL; row++) {210    for (let col = 0; col < CODES_PER_ROW; col++) {211      const rect = cellRectFor(canvas.width, canvas.height, row, col);212      if (!rect.sw || !rect.sh) continue;213      const text = decodeAztecCellRobust(214        canvas,215        ctx,216        { x: rect.sx, y: rect.sy, w: rect.sw, h: rect.sh },217        scale,218      );219      if (text !== null) decoded++;220    }221  }222  return { decoded, total: CODES_PER_PAGE };223}224225async function renderPageToCanvas(226  page: PdfPage,227  scale: number,228): Promise<HTMLCanvasElement> {229  const viewport = page.getViewport({ scale });230  // PCH-01-014: refuse pages whose scaled dimensions blow past a generous231  // A4-at-max-scale budget. A tiny PDF can declare a 14400×14400 pt MediaBox,232  // which at this scale would allocate a multi-GB RGBA canvas. The throw lands233  // in the page loop's existing catch, which records and skips the page.234  const w = Math.ceil(viewport.width);235  const h = Math.ceil(viewport.height);236  if (w > MAX_RENDERED_DIMENSION || h > MAX_RENDERED_DIMENSION) {237    throw new Error(238      `page dimensions ${w}×${h}px exceed the ${MAX_RENDERED_DIMENSION}px render limit`,239    );240  }241  const canvas = document.createElement("canvas");242  canvas.width = w;243  canvas.height = h;244  const ctx = canvas.getContext("2d");245  if (!ctx) {246    return canvas;247  }248  ctx.fillStyle = "white";249  ctx.fillRect(0, 0, canvas.width, canvas.height);250  await page.render({ canvas, canvasContext: ctx, viewport }).promise;251  return canvas;252}253254/**255 * Import a Peach Codex PDF and decode every Aztec code it contains.256 *257 * Automatically detects the render scale by probing up to three pages at258 * each candidate scale in CANDIDATE_SCALES — the scale with the highest259 * decode rate is used for all pages. This makes the importer260 * backward-compatible with Codexes generated at any prior CHUNK_SIZE, and261 * forward-compatible with future changes, without requiring any version262 * metadata in the PDF.263 */264export async function importCodexPDF(265  file: File,266  onProgress?: PDFImportProgress,267): Promise<PDFImportResult> {268  // MV-5: refuse oversized files before reading them into memory — and before269  // even loading pdf.js. A real Codex tops out well under this (200 pages ≈270  // 4 MiB of Aztec PNGs).271  if (file.size > IMPORT_LIMITS.MAX_PDF_BYTES) {272    return {273      chunksFound: 0,274      totalExpected: null,275      envelopes: [],276      errors: [277        `PDF is ${(file.size / (1024 * 1024)).toFixed(1)} MiB — Codex PDFs are limited to ${IMPORT_LIMITS.MAX_PDF_BYTES / (1024 * 1024)} MiB.`,278      ],279    };280  }281282  const pdfjsLib = await getPdfJs();283284  const envelopes: ChunkEnvelope[] = [];285  const errors: string[] = [];286  // Key is `${stripe}:${chunkIndex}` so multi-stripe Codexes don't treat287  // stripe 1's chunk 0 as a duplicate of stripe 0's chunk 0.288  const seenIndices = new Set<string>();289  // PCH-01-026 #4: `t` is the per-stripe chunk total, not the Codex total. Track290  // each stripe's declared total once and sum after the scan so a multi-stripe291  // Codex reports the true global count, not just stripe 0's.292  const stripeTotalsForExpected = new Map<number, number>();293294  let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;295  try {296    const arrayBuffer = await file.arrayBuffer();297    // MV-9: this parses arbitrary user-selected PDFs. The popup CSP already298    // blocks eval, but disable pdf.js's eval-dependent font paths in depth299    // (CVE-2024-4367 class), and turn off features Aztec extraction never300    // needs.301    pdf = await pdfjsLib.getDocument({302      data: arrayBuffer,303      // isEvalSupported is a valid pdf.js runtime option (defends the304      // CVE-2024-4367 eval-via-font path) but is absent from pdfjs-dist's305      // DocumentInitParameters typings — cast to add it.306      isEvalSupported: false,307      disableFontFace: true, // fonts not needed for Aztec bitmap extraction308      disableRange: true,    // whole buffer already in memory309      disableStream: true,   // ditto310    } as Parameters<typeof pdfjsLib.getDocument>[0]).promise;311  } catch (err) {312    return {313      chunksFound: 0,314      totalExpected: null,315      envelopes,316      errors: [317        `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,318      ],319    };320  }321322  // PCH-01-014: cap pages so a PDF declaring thousands of pages cannot wedge323  // the popup in a multi-minute render loop.324  const declaredPages = pdf.numPages;325  const pageCount = Math.min(declaredPages, MAX_PDF_PAGES);326  if (declaredPages > MAX_PDF_PAGES) {327    errors.push(328      `PDF has ${declaredPages} pages; only the first ${MAX_PDF_PAGES} were scanned.`,329    );330  }331  const renderScale = await detectRenderScale(pdf);332333  for (let pageNum = 1; pageNum <= pageCount; pageNum++) {334    onProgress?.(pageNum, pageCount);335336    try {337      const page = await pdf.getPage(pageNum);338      const canvas = await renderPageToCanvas(page, renderScale);339      const ctx = canvas.getContext("2d");340      if (!ctx) {341        errors.push(`Page ${pageNum}: no canvas context`);342        continue;343      }344345      const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices, renderScale);346      for (const env of pageEnvelopes) envelopes.push(env);347348      for (const env of pageEnvelopes) {349        const stripe = env.v === 3 ? (env.s ?? 0) : 0;350        // Each stripe's `t` is constant across its envelopes; last write wins.351        stripeTotalsForExpected.set(stripe, env.t);352      }353    } catch (err) {354      errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`);355    }356  }357358  const metricsEnabled =359    typeof window !== "undefined" &&360    (import.meta.env.MODE === "test" ||361      new URLSearchParams(window.location.search).has("codexMetrics"));362  if (metricsEnabled) {363    const stripeTotals = new Map<number, number>();364    const stripeDecoded = new Map<number, number>();365    for (const env of envelopes) {366      const stripe = env.v === 3 ? (env.s ?? 0) : 0;367      stripeTotals.set(stripe, env.t);368      stripeDecoded.set(stripe, (stripeDecoded.get(stripe) ?? 0) + 1);369    }370    const totalPrinted = Array.from(stripeTotals.values()).reduce((sum, total) => sum + total, 0);371    const stripeIndices = Array.from(stripeTotals.keys());372    const maxStripe = stripeIndices.length > 0 ? Math.max(...stripeIndices) : -1;373    (window as Window & {374      __codexImportMetrics?: {375        decodeRate: number;376        shortStripeCount: number;377        perStripeDecoded: number[];378      };379    }).__codexImportMetrics = {380      decodeRate: totalPrinted > 0 ? envelopes.length / totalPrinted : 0,381      shortStripeCount: Array.from(stripeTotals.entries()).filter(382        ([stripe, total]) => (stripeDecoded.get(stripe) ?? 0) < total,383      ).length,384      perStripeDecoded:385        maxStripe >= 0386          ? Array.from({ length: maxStripe + 1 }, (_, stripe) => stripeDecoded.get(stripe) ?? 0)387          : [],388    };389  }390391  // PCH-01-026 #4: the Codex total is the sum of every stripe's `t`.392  const totalExpected =393    stripeTotalsForExpected.size > 0394      ? Array.from(stripeTotalsForExpected.values()).reduce((sum, t) => sum + t, 0)395      : null;396397  return {398    chunksFound: envelopes.length,399    totalExpected,400    envelopes,401    errors,402  };403}404405/**406 * Crop each of the 36 (6×6) printed Aztec codes out of the rendered page and407 * try to decode it. Cells that contain no code — the trailing empty cells on408 * the last page — just throw NotFoundException and are skipped.409 *410 * Two non-obvious things this function gets right that earlier revisions did411 * not:412 *413 *   1. Cropping follows the *exact* layout drawn by `pdf.ts` (margins,414 *      header band, code size, column/row gaps) instead of a naive415 *      `canvas / 6` slice. The naive slicer clipped every Aztec on its416 *      right edge — ZXing's binarizer can't find a finder pattern in a417 *      chopped symbol. Geometry constants are shared with the generator418 *      via `pdf-layout.ts` so the two can't drift again.419 *420 *   2. We decode the cell pixels directly through `AztecCodeReader` +421 *      `RGBLuminanceSource` rather than handing the canvas to422 *      `BrowserAztecCodeReader.decode()`. ZXing's BrowserCodeReader only423 *      knows how to read `HTMLVideoElement` and `HTMLImageElement` — when424 *      you pass it a canvas, its internal `createCaptureCanvas` falls425 *      through to no branch and creates a zero-sized capture canvas, so426 *      every decode returns `NotFoundException`. The DOM-free path here is427 *      the same one the generator uses for round-trip verification.428 *429 * Around each code we also include a small quiet-zone margin (≈12% of the430 * code size). The PDF prints codes against a cream background with no431 * required quiet zone; ZXing's locator is happier with a white one.432 */433async function scanCanvasGrid(434  canvas: HTMLCanvasElement,435  seenIndices: Set<string>,436  renderScale: number,437): Promise<ChunkEnvelope[]> {438  const found: ChunkEnvelope[] = [];439440  // One scratch canvas reused across all cells — cheaper than allocating441  // 36 canvases per page when a Codex spans dozens of pages.442  const cellCanvas = document.createElement("canvas");443  const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true });444  if (!cellCtx) return found;445446  for (let row = 0; row < CODES_PER_COL; row++) {447    for (let col = 0; col < CODES_PER_ROW; col++) {448      const { sx, sy, sw, sh } = cellRectFor(canvas.width, canvas.height, row, col);449      if (sw <= 0 || sh <= 0) continue;450451      cellCanvas.width = sw;452      cellCanvas.height = sh;453      // Paint the quiet-zone background white before drawing — the source454      // PDF's cream is light but not pure white, and a clean white border455      // gives the binarizer the cleanest finder-pattern contrast.456      cellCtx.fillStyle = "white";457      cellCtx.fillRect(0, 0, sw, sh);458      cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);459460      const text = decodeAztecCellRobust(461        cellCanvas,462        cellCtx,463        { x: 0, y: 0, w: sw, h: sh },464        renderScale,465      );466      if (!text) continue;467468      const envelope = parseChunkEnvelope(text);469      if (!envelope) continue;470      const stripeKey = `${envelope.s ?? 0}:${envelope.i}`;471      if (seenIndices.has(stripeKey)) continue;472      if (!(await verifyChunkIntegrity(envelope))) continue;473474      seenIndices.add(stripeKey);475      found.push(envelope);476    }477  }478479  return found;480}481482function decodeAztecCellRobust(483  canvas: HTMLCanvasElement,484  ctx: CanvasRenderingContext2D,485  cellRect: { x: number; y: number; w: number; h: number },486  renderScale: number,487): string | null {488  const strategies = getCellDecodeStrategies(renderScale);489  for (const strategy of strategies) {490    const result = decodeAztecCellWithStrategy(canvas, ctx, cellRect, strategy);491    if (result !== null) {492      return result;493    }494  }495  return null;496}497498function decodeAztecCellWithStrategy(499  canvas: HTMLCanvasElement,500  ctx: CanvasRenderingContext2D,501  cellRect: { x: number; y: number; w: number; h: number },502  strategy: CellDecodeStrategy,503): string | null {504  const { x, y, w, h } = applyCropFactor(cellRect, strategy.cropFactor, canvas.width, canvas.height);505  if (w <= 0 || h <= 0) return null;506507  const imageData = ctx.getImageData(x, y, w, h);508  const processedData = strategy.normalize509    ? normalizeToBlackWhite(imageData)510    : imageData;511512  const argb = imageDataToArgb(processedData);513  const { buffer, width, height } =514    strategy.upscale > 1515      ? upscaleArgb(argb, processedData.width, processedData.height, strategy.upscale)516      : { buffer: argb, width: processedData.width, height: processedData.height };517518  return decodeAztecFromArgb(buffer, width, height);519}520521function applyCropFactor(522  rect: { x: number; y: number; w: number; h: number },523  factor: number,524  canvasW: number,525  canvasH: number,526): { x: number; y: number; w: number; h: number } {527  const cx = rect.x + rect.w / 2;528  const cy = rect.y + rect.h / 2;529  const nw = Math.max(1, Math.round(rect.w * factor));530  const nh = Math.max(1, Math.round(rect.h * factor));531  const x = Math.max(0, Math.min(canvasW - 1, Math.round(cx - nw / 2)));532  const y = Math.max(0, Math.min(canvasH - 1, Math.round(cy - nh / 2)));533  const w = Math.max(1, Math.min(canvasW - x, nw));534  const h = Math.max(1, Math.min(canvasH - y, nh));535  return { x, y, w, h };536}537538function normalizeToBlackWhite(imageData: ImageData): ImageData {539  const data = new Uint8ClampedArray(imageData.data);540  for (let i = 0; i < data.length; i += 4) {541    const lum = 0.299 * data[i]! + 0.587 * data[i + 1]! + 0.114 * data[i + 2]!;542    const v = lum > 128 ? 255 : 0;543    data[i] = v;544    data[i + 1] = v;545    data[i + 2] = v;546    data[i + 3] = 255;547  }548  return new ImageData(data, imageData.width, imageData.height);549}550551function upscaleArgb(552  argb: Int32Array,553  width: number,554  height: number,555  scale: number,556): { buffer: Int32Array; width: number; height: number } {557  const targetW = Math.max(1, Math.round(width * scale));558  const targetH = Math.max(1, Math.round(height * scale));559  const out = new Int32Array(targetW * targetH);560  for (let y = 0; y < targetH; y++) {561    const srcY = Math.min(height - 1, Math.floor(y / scale));562    for (let x = 0; x < targetW; x++) {563      const srcX = Math.min(width - 1, Math.floor(x / scale));564      out[y * targetW + x] = argb[srcY * width + srcX]!;565    }566  }567  return { buffer: out, width: targetW, height: targetH };568}569570/**571 * Compute the canvas-pixel sub-rectangle for the printed Aztec at (row, col)572 * on a page that was rendered at uniform scale into the given canvas573 * dimensions.574 *575 * Exported (rather than inlined into `scanCanvasGrid`) so the geometry can be576 * verified directly in tests — `scanCanvasGrid` itself needs an577 * `HTMLCanvasElement` and `getImageData()` which vitest's node environment578 * lacks, but this pure function does not.579 */580export function cellRectFor(581  canvasW: number,582  canvasH: number,583  row: number,584  col: number,585): { sx: number; sy: number; sw: number; sh: number } {586  // Canvas pixels per PDF point. Independent x/y scales keep us correct if587  // the renderer ever applies non-uniform scaling.588  const scaleX = canvasW / PAGE_WIDTH;589  const scaleY = canvasH / PAGE_HEIGHT;590591  // Top edge of row 0 in PDF coords (bottom-origin) is just below the header.592  // Convert to canvas coords (top-origin): canvas_y = (PAGE_HEIGHT - pdf_y) * scaleY.593  const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;594  const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;595596  const codeWpx = CODE_SIZE * scaleX;597  const codeHpx = CODE_SIZE * scaleY;598  const quietX = Math.round(codeWpx * 0.12);599  const quietY = Math.round(codeHpx * 0.12);600601  const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;602  const codeY = gridTopCanvas + row * ROW_STEP * scaleY;603604  const sx = Math.max(0, Math.floor(codeX - quietX));605  const sy = Math.max(0, Math.floor(codeY - quietY));606  const sw = Math.min(canvasW - sx, Math.ceil(codeWpx + 2 * quietX));607  const sh = Math.min(canvasH - sy, Math.ceil(codeHpx + 2 * quietY));608  return { sx, sy, sw, sh };609}610611/**612 * Convert an RGBA `ImageData.data` buffer into the packed ARGB `Int32Array`613 * that `RGBLuminanceSource` expects. Exposed for tests that synthesize a cell614 * buffer directly rather than going through a canvas.615 */616export function imageDataToArgb(imageData: ImageData): Int32Array {617  const raw = imageData.data;618  const out = new Int32Array(imageData.width * imageData.height);619  for (let i = 0, j = 0; i < raw.length; i += 4, j++) {620    out[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;621  }622  return out;623}624625/**626 * Decode a single Aztec code from a packed ARGB pixel buffer. Returns the627 * decoded text on success, or `null` if no code was found.628 *629 * We try `HybridBinarizer` first (good for rendered images with subtle630 * shading from anti-aliasing) and fall back to `GlobalHistogramBinarizer`631 * (better when the image is dominated by clean black/white modules). A fresh632 * `AztecCodeReader` is allocated per attempt — the reader caches internal633 * state that must be reset between bitmaps.634 *635 * This is the exact decode path the importer used to skip by handing canvas636 * elements to `BrowserAztecCodeReader.decode()`, which silently fails on637 * canvases — its `createCaptureCanvas` only recognizes video and image638 * elements and produces a zero-sized capture for anything else. Keeping this639 * function exported lets the test suite verify the path works on synthesized640 * Aztec pixels without needing a real DOM canvas.641 */642export function decodeAztecFromArgb(643  argb: Int32Array,644  width: number,645  height: number,646): string | null {647  const source = new RGBLuminanceSource(argb, width, height);648649  try {650    const bitmap = new BinaryBitmap(new HybridBinarizer(source));651    return new AztecCodeReader().decode(bitmap).getText();652  } catch (err) {653    if (!(err instanceof NotFoundException)) return null;654  }655656  try {657    const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));658    return new AztecCodeReader().decode(bitmap).getText();659  } catch {660    return null;661  }662}663664/**665 * Parse a raw decoded string as a Peach ChunkEnvelope. Returns null for any666 * non-Peach payload — the same defensive contract as `restore.ts/parseRawScan`667 * but inlined here so this module stays decoupled from the camera scan path.668 *669 * Accepts v2 (legacy single-stripe) and v3 (current multi-stripe) envelopes.670 * The generator has emitted v3 envelopes since the multi-stripe rollout —671 * earlier revisions of this function only accepted v2, which silently672 * rejected every envelope produced by current builds.673 */674export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {675  let parsed: unknown;676  try {677    parsed = JSON.parse(raw);678  } catch {679    return null;680  }681  if (typeof parsed !== "object" || parsed === null) return null;682  const obj = parsed as Record<string, unknown>;683684  if (obj.v !== 2 && obj.v !== 3) return null;685686  const baseShapeOk =687    typeof obj.c === "string" &&688    Number.isInteger(obj.i) && // PCH-01-012: reject NaN/Infinity/fractional indices689    Number.isInteger(obj.t) &&690    typeof obj.p === "boolean" &&691    typeof obj.d === "string" &&692    typeof obj.h === "string";693  if (!baseShapeOk) return null;694695  // v3 envelopes must also carry stripe metadata.696  if (obj.v === 3 && (!Number.isInteger(obj.s) || !Number.isInteger(obj.S))) {697    return null;698  }699700  return obj as unknown as ChunkEnvelope;701}