pdf-import.ts

25.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 v5 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 interface PDFImportProgress {97  (page: number, totalPages: number): void;98}99100/**101 * Render scales that produce a 1:1 canvas-to-source-PNG mapping for known102 * Codex generations, ordered from newest to oldest. ZXing's AztecDetector103 * requires near-exact 1:1 rendering — even a 1% scale error causes104 * ReedSolomonException (downscale) or FormatException (upscale). The formula105 * is: scale = (modules × 4 + 16) / CODE_SIZE_pt where modules is the Aztec106 * matrix size and CODE_SIZE_pt = 80.107 *108 * Current generation (CHUNK_SIZE=350): ZXing → 75-module codes → 316px PNG109 *   → scale = 3.95110 * Prior generation (CHUNK_SIZE=800, ZXing path): 101-module codes → 420px PNG111 *   → scale = 5.25112 * Prior generation (CHUNK_SIZE=800, bwip-js path): 117-module codes → 484px PNG113 *   → scale = 6.05114 */115const CANDIDATE_SCALES = [3.95, 5.25, 6.05] as const;116117// PCH-01-014: bound attacker-controlled page count and page dimensions. A real118// Codex needs ~200 pages; 250 is generous headroom.119const MAX_PDF_PAGES = 250;120// Largest render scale we ever probe — derived from CANDIDATE_SCALES so the121// dimension clamp can never drift from the scale set.122const MAX_RENDER_SCALE = Math.max(...CANDIDATE_SCALES);123// A4 in points is 595×842. At MAX_RENDER_SCALE the long axis is ~5052 px; ×1.5124// slack tolerates Letter / landscape / slightly larger legitimate layouts while125// rejecting the 14400-pt MediaBox pathological case (~tens of GB RGBA).126const MAX_RENDERED_DIMENSION = Math.ceil(842 * MAX_RENDER_SCALE * 1.5);127128interface CellDecodeStrategy {129  cropFactor: number;130  normalize: boolean;131  upscale: number;132}133134const SMALL_SCALE_STRATEGIES: CellDecodeStrategy[] = [135  { cropFactor: 1.0, normalize: false, upscale: 1.0 },136  { cropFactor: 0.88, normalize: false, upscale: 1.0 },137  { cropFactor: 1.12, normalize: false, upscale: 1.0 },138  { cropFactor: 1.0, normalize: true, upscale: 1.0 },139  { cropFactor: 0.88, normalize: true, upscale: 1.0 },140  { cropFactor: 1.0, normalize: false, upscale: 2.0 },141  { cropFactor: 1.0, normalize: true, upscale: 2.0 },142];143144const LARGE_SCALE_STRATEGIES: CellDecodeStrategy[] = [145  { cropFactor: 1.0, normalize: false, upscale: 1.0 },146  { cropFactor: 1.12, normalize: false, upscale: 1.0 },147  { cropFactor: 0.88, normalize: false, upscale: 1.0 },148  { cropFactor: 1.0, normalize: true, upscale: 1.0 },149  { cropFactor: 1.12, normalize: true, upscale: 1.0 },150  { cropFactor: 1.0, normalize: false, upscale: 2.0 },151  { cropFactor: 1.0, normalize: true, upscale: 2.0 },152];153154function getCellDecodeStrategies(renderScale: number): CellDecodeStrategy[] {155  return renderScale < 4.5 ? SMALL_SCALE_STRATEGIES : LARGE_SCALE_STRATEGIES;156}157158/**159 * Probe up to three pages of `pdf` at every candidate scale and return the160 * scale that decodes the highest fraction of codes. "First scale that decodes161 * anything" is not sufficient — scale 5.25 can occasionally decode 1–2 codes162 * from a 6.05 (117-module) Codex, producing a false-positive that makes the163 * full import use the wrong scale. Taking the maximum across multiple pages164 * avoids this: the correct scale wins by a large margin.165 *166 * Early exit: once a scale achieves a perfect probe score we can stop — no167 * later candidate can beat 100% decode rate on the sampled pages.168 */169async function detectRenderScale(170  pdf: Awaited<ReturnType<PdfJsModule["getDocument"]>["promise"]>,171): Promise<number> {172  let bestScale: number = CANDIDATE_SCALES[0];173  let bestRate = -1;174  const probePages = Math.min(3, pdf.numPages);175176  for (const scale of CANDIDATE_SCALES) {177    try {178      let decoded = 0;179      let total = 0;180      for (let pageNum = 1; pageNum <= probePages; pageNum++) {181        const page = await pdf.getPage(pageNum);182        const result = await probePageDecodeRate(page, scale);183        decoded += result.decoded;184        total += result.total;185      }186      const rate = total > 0 ? decoded / total : 0;187      if (rate > bestRate) {188        bestRate = rate;189        bestScale = scale;190      }191      if (rate >= 0.99) {192        return scale;193      }194    } catch {195      // continue to next candidate196    }197  }198199  return bestScale;200}201202async function probePageDecodeRate(203  page: PdfPage,204  scale: number,205): Promise<{ decoded: number; total: number }> {206  const canvas = await renderPageToCanvas(page, scale);207  const ctx = canvas.getContext("2d");208  if (!ctx) return { decoded: 0, total: CODES_PER_PAGE };209210  let decoded = 0;211  for (let row = 0; row < CODES_PER_COL; row++) {212    for (let col = 0; col < CODES_PER_ROW; col++) {213      const rect = cellRectFor(canvas.width, canvas.height, row, col);214      if (!rect.sw || !rect.sh) continue;215      const text = decodeAztecCellRobust(216        canvas,217        ctx,218        { x: rect.sx, y: rect.sy, w: rect.sw, h: rect.sh },219        scale,220      );221      if (text !== null) decoded++;222    }223  }224  return { decoded, total: CODES_PER_PAGE };225}226227async function renderPageToCanvas(228  page: PdfPage,229  scale: number,230): Promise<HTMLCanvasElement> {231  const viewport = page.getViewport({ scale });232  // PCH-01-014: refuse pages whose scaled dimensions blow past a generous233  // A4-at-max-scale budget. A tiny PDF can declare a 14400×14400 pt MediaBox,234  // which at this scale would allocate a multi-GB RGBA canvas. The throw lands235  // in the page loop's existing catch, which records and skips the page.236  const w = Math.ceil(viewport.width);237  const h = Math.ceil(viewport.height);238  if (w > MAX_RENDERED_DIMENSION || h > MAX_RENDERED_DIMENSION) {239    throw new Error(240      `page dimensions ${w}×${h}px exceed the ${MAX_RENDERED_DIMENSION}px render limit`,241    );242  }243  const canvas = document.createElement("canvas");244  canvas.width = w;245  canvas.height = h;246  const ctx = canvas.getContext("2d");247  if (!ctx) {248    return canvas;249  }250  ctx.fillStyle = "white";251  ctx.fillRect(0, 0, canvas.width, canvas.height);252  await page.render({ canvas, canvasContext: ctx, viewport }).promise;253  return canvas;254}255256/**257 * Import a Peach Codex PDF and decode every Aztec code it contains.258 *259 * Automatically detects the render scale by probing up to three pages at260 * each candidate scale in CANDIDATE_SCALES — the scale with the highest261 * decode rate is used for all pages. This makes the importer262 * backward-compatible with Codexes generated at any prior CHUNK_SIZE, and263 * forward-compatible with future changes, without requiring any version264 * metadata in the PDF.265 */266export async function importCodexPDF(267  file: File,268  onProgress?: PDFImportProgress,269): Promise<PDFImportResult> {270  // MV-5: refuse oversized files before reading them into memory — and before271  // even loading pdf.js. A real Codex tops out well under this (200 pages ≈272  // 4 MiB of Aztec PNGs).273  if (file.size > IMPORT_LIMITS.MAX_PDF_BYTES) {274    return {275      chunksFound: 0,276      totalExpected: null,277      envelopes: [],278      errors: [279        `PDF is ${(file.size / (1024 * 1024)).toFixed(1)} MiB — Codex PDFs are limited to ${IMPORT_LIMITS.MAX_PDF_BYTES / (1024 * 1024)} MiB.`,280      ],281    };282  }283284  const pdfjsLib = await getPdfJs();285286  const envelopes: ChunkEnvelope[] = [];287  const errors: string[] = [];288  // Key is `${stripe}:${chunkIndex}` so multi-stripe Codexes don't treat289  // stripe 1's chunk 0 as a duplicate of stripe 0's chunk 0.290  const seenIndices = new Set<string>();291  // PCH-01-026 #4: `t` is the per-stripe chunk total, not the Codex total. Track292  // each stripe's declared total once and sum after the scan so a multi-stripe293  // Codex reports the true global count, not just stripe 0's.294  const stripeTotalsForExpected = new Map<number, number>();295296  let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;297  try {298    const arrayBuffer = await file.arrayBuffer();299    // MV-9: this parses arbitrary user-selected PDFs. The popup CSP already300    // blocks eval, but disable pdf.js's eval-dependent font paths in depth301    // (CVE-2024-4367 class), and turn off features Aztec extraction never302    // needs.303    pdf = await pdfjsLib.getDocument({304      data: arrayBuffer,305      // isEvalSupported is a valid pdf.js runtime option (defends the306      // CVE-2024-4367 eval-via-font path) but is absent from pdfjs-dist307      // 5.7.284's DocumentInitParameters typings — cast to add it.308      isEvalSupported: false,309      disableFontFace: true, // fonts not needed for Aztec bitmap extraction310      disableRange: true,    // whole buffer already in memory311      disableStream: true,   // ditto312    } as Parameters<typeof pdfjsLib.getDocument>[0]).promise;313  } catch (err) {314    return {315      chunksFound: 0,316      totalExpected: null,317      envelopes,318      errors: [319        `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,320      ],321    };322  }323324  // PCH-01-014: cap pages so a PDF declaring thousands of pages cannot wedge325  // the popup in a multi-minute render loop.326  const declaredPages = pdf.numPages;327  const pageCount = Math.min(declaredPages, MAX_PDF_PAGES);328  if (declaredPages > MAX_PDF_PAGES) {329    errors.push(330      `PDF has ${declaredPages} pages; only the first ${MAX_PDF_PAGES} were scanned.`,331    );332  }333  const renderScale = await detectRenderScale(pdf);334335  for (let pageNum = 1; pageNum <= pageCount; pageNum++) {336    onProgress?.(pageNum, pageCount);337338    try {339      const page = await pdf.getPage(pageNum);340      const canvas = await renderPageToCanvas(page, renderScale);341      const ctx = canvas.getContext("2d");342      if (!ctx) {343        errors.push(`Page ${pageNum}: no canvas context`);344        continue;345      }346347      const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices, renderScale);348      for (const env of pageEnvelopes) envelopes.push(env);349350      for (const env of pageEnvelopes) {351        const stripe = env.v === 3 ? (env.s ?? 0) : 0;352        // Each stripe's `t` is constant across its envelopes; last write wins.353        stripeTotalsForExpected.set(stripe, env.t);354      }355    } catch (err) {356      errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`);357    }358  }359360  const metricsEnabled =361    typeof window !== "undefined" &&362    (import.meta.env.MODE === "test" ||363      new URLSearchParams(window.location.search).has("codexMetrics"));364  if (metricsEnabled) {365    const stripeTotals = new Map<number, number>();366    const stripeDecoded = new Map<number, number>();367    for (const env of envelopes) {368      const stripe = env.v === 3 ? (env.s ?? 0) : 0;369      stripeTotals.set(stripe, env.t);370      stripeDecoded.set(stripe, (stripeDecoded.get(stripe) ?? 0) + 1);371    }372    const totalPrinted = Array.from(stripeTotals.values()).reduce((sum, total) => sum + total, 0);373    const stripeIndices = Array.from(stripeTotals.keys());374    const maxStripe = stripeIndices.length > 0 ? Math.max(...stripeIndices) : -1;375    (window as Window & {376      __codexImportMetrics?: {377        decodeRate: number;378        shortStripeCount: number;379        perStripeDecoded: number[];380      };381    }).__codexImportMetrics = {382      decodeRate: totalPrinted > 0 ? envelopes.length / totalPrinted : 0,383      shortStripeCount: Array.from(stripeTotals.entries()).filter(384        ([stripe, total]) => (stripeDecoded.get(stripe) ?? 0) < total,385      ).length,386      perStripeDecoded:387        maxStripe >= 0388          ? Array.from({ length: maxStripe + 1 }, (_, stripe) => stripeDecoded.get(stripe) ?? 0)389          : [],390    };391  }392393  // PCH-01-026 #4: the Codex total is the sum of every stripe's `t`.394  const totalExpected =395    stripeTotalsForExpected.size > 0396      ? Array.from(stripeTotalsForExpected.values()).reduce((sum, t) => sum + t, 0)397      : null;398399  return {400    chunksFound: envelopes.length,401    totalExpected,402    envelopes,403    errors,404  };405}406407/**408 * Crop each of the 36 (6×6) printed Aztec codes out of the rendered page and409 * try to decode it. Cells that contain no code — the trailing empty cells on410 * the last page — just throw NotFoundException and are skipped.411 *412 * Two non-obvious things this function gets right that earlier revisions did413 * not:414 *415 *   1. Cropping follows the *exact* layout drawn by `pdf.ts` (margins,416 *      header band, code size, column/row gaps) instead of a naive417 *      `canvas / 6` slice. The naive slicer clipped every Aztec on its418 *      right edge — ZXing's binarizer can't find a finder pattern in a419 *      chopped symbol. Geometry constants are shared with the generator420 *      via `pdf-layout.ts` so the two can't drift again.421 *422 *   2. We decode the cell pixels directly through `AztecCodeReader` +423 *      `RGBLuminanceSource` rather than handing the canvas to424 *      `BrowserAztecCodeReader.decode()`. ZXing's BrowserCodeReader only425 *      knows how to read `HTMLVideoElement` and `HTMLImageElement` — when426 *      you pass it a canvas, its internal `createCaptureCanvas` falls427 *      through to no branch and creates a zero-sized capture canvas, so428 *      every decode returns `NotFoundException`. The DOM-free path here is429 *      the same one the generator uses for round-trip verification.430 *431 * Around each code we also include a small quiet-zone margin (≈12% of the432 * code size). The PDF prints codes against a cream background with no433 * required quiet zone; ZXing's locator is happier with a white one.434 */435async function scanCanvasGrid(436  canvas: HTMLCanvasElement,437  seenIndices: Set<string>,438  renderScale: number,439): Promise<ChunkEnvelope[]> {440  const found: ChunkEnvelope[] = [];441442  // One scratch canvas reused across all cells — cheaper than allocating443  // 36 canvases per page when a Codex spans dozens of pages.444  const cellCanvas = document.createElement("canvas");445  const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true });446  if (!cellCtx) return found;447448  for (let row = 0; row < CODES_PER_COL; row++) {449    for (let col = 0; col < CODES_PER_ROW; col++) {450      const { sx, sy, sw, sh } = cellRectFor(canvas.width, canvas.height, row, col);451      if (sw <= 0 || sh <= 0) continue;452453      cellCanvas.width = sw;454      cellCanvas.height = sh;455      // Paint the quiet-zone background white before drawing — the source456      // PDF's cream is light but not pure white, and a clean white border457      // gives the binarizer the cleanest finder-pattern contrast.458      cellCtx.fillStyle = "white";459      cellCtx.fillRect(0, 0, sw, sh);460      cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);461462      const text = decodeAztecCellRobust(463        cellCanvas,464        cellCtx,465        { x: 0, y: 0, w: sw, h: sh },466        renderScale,467      );468      if (!text) continue;469470      const envelope = parseChunkEnvelope(text);471      if (!envelope) continue;472      const stripeKey = `${envelope.s ?? 0}:${envelope.i}`;473      if (seenIndices.has(stripeKey)) continue;474      if (!(await verifyChunkIntegrity(envelope))) continue;475476      seenIndices.add(stripeKey);477      found.push(envelope);478    }479  }480481  return found;482}483484function decodeAztecCellRobust(485  canvas: HTMLCanvasElement,486  ctx: CanvasRenderingContext2D,487  cellRect: { x: number; y: number; w: number; h: number },488  renderScale: number,489): string | null {490  const strategies = getCellDecodeStrategies(renderScale);491  for (const strategy of strategies) {492    const result = decodeAztecCellWithStrategy(canvas, ctx, cellRect, strategy);493    if (result !== null) {494      return result;495    }496  }497  return null;498}499500function decodeAztecCellWithStrategy(501  canvas: HTMLCanvasElement,502  ctx: CanvasRenderingContext2D,503  cellRect: { x: number; y: number; w: number; h: number },504  strategy: CellDecodeStrategy,505): string | null {506  const { x, y, w, h } = applyCropFactor(cellRect, strategy.cropFactor, canvas.width, canvas.height);507  if (w <= 0 || h <= 0) return null;508509  const imageData = ctx.getImageData(x, y, w, h);510  const processedData = strategy.normalize511    ? normalizeToBlackWhite(imageData)512    : imageData;513514  const argb = imageDataToArgb(processedData);515  const { buffer, width, height } =516    strategy.upscale > 1517      ? upscaleArgb(argb, processedData.width, processedData.height, strategy.upscale)518      : { buffer: argb, width: processedData.width, height: processedData.height };519520  return decodeAztecFromArgb(buffer, width, height);521}522523function applyCropFactor(524  rect: { x: number; y: number; w: number; h: number },525  factor: number,526  canvasW: number,527  canvasH: number,528): { x: number; y: number; w: number; h: number } {529  const cx = rect.x + rect.w / 2;530  const cy = rect.y + rect.h / 2;531  const nw = Math.max(1, Math.round(rect.w * factor));532  const nh = Math.max(1, Math.round(rect.h * factor));533  const x = Math.max(0, Math.min(canvasW - 1, Math.round(cx - nw / 2)));534  const y = Math.max(0, Math.min(canvasH - 1, Math.round(cy - nh / 2)));535  const w = Math.max(1, Math.min(canvasW - x, nw));536  const h = Math.max(1, Math.min(canvasH - y, nh));537  return { x, y, w, h };538}539540function normalizeToBlackWhite(imageData: ImageData): ImageData {541  const data = new Uint8ClampedArray(imageData.data);542  for (let i = 0; i < data.length; i += 4) {543    const lum = 0.299 * data[i]! + 0.587 * data[i + 1]! + 0.114 * data[i + 2]!;544    const v = lum > 128 ? 255 : 0;545    data[i] = v;546    data[i + 1] = v;547    data[i + 2] = v;548    data[i + 3] = 255;549  }550  return new ImageData(data, imageData.width, imageData.height);551}552553function upscaleArgb(554  argb: Int32Array,555  width: number,556  height: number,557  scale: number,558): { buffer: Int32Array; width: number; height: number } {559  const targetW = Math.max(1, Math.round(width * scale));560  const targetH = Math.max(1, Math.round(height * scale));561  const out = new Int32Array(targetW * targetH);562  for (let y = 0; y < targetH; y++) {563    const srcY = Math.min(height - 1, Math.floor(y / scale));564    for (let x = 0; x < targetW; x++) {565      const srcX = Math.min(width - 1, Math.floor(x / scale));566      out[y * targetW + x] = argb[srcY * width + srcX]!;567    }568  }569  return { buffer: out, width: targetW, height: targetH };570}571572/**573 * Compute the canvas-pixel sub-rectangle for the printed Aztec at (row, col)574 * on a page that was rendered at uniform scale into the given canvas575 * dimensions.576 *577 * Exported (rather than inlined into `scanCanvasGrid`) so the geometry can be578 * verified directly in tests — `scanCanvasGrid` itself needs an579 * `HTMLCanvasElement` and `getImageData()` which vitest's node environment580 * lacks, but this pure function does not.581 */582export function cellRectFor(583  canvasW: number,584  canvasH: number,585  row: number,586  col: number,587): { sx: number; sy: number; sw: number; sh: number } {588  // Canvas pixels per PDF point. Independent x/y scales keep us correct if589  // the renderer ever applies non-uniform scaling.590  const scaleX = canvasW / PAGE_WIDTH;591  const scaleY = canvasH / PAGE_HEIGHT;592593  // Top edge of row 0 in PDF coords (bottom-origin) is just below the header.594  // Convert to canvas coords (top-origin): canvas_y = (PAGE_HEIGHT - pdf_y) * scaleY.595  const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;596  const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;597598  const codeWpx = CODE_SIZE * scaleX;599  const codeHpx = CODE_SIZE * scaleY;600  const quietX = Math.round(codeWpx * 0.12);601  const quietY = Math.round(codeHpx * 0.12);602603  const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;604  const codeY = gridTopCanvas + row * ROW_STEP * scaleY;605606  const sx = Math.max(0, Math.floor(codeX - quietX));607  const sy = Math.max(0, Math.floor(codeY - quietY));608  const sw = Math.min(canvasW - sx, Math.ceil(codeWpx + 2 * quietX));609  const sh = Math.min(canvasH - sy, Math.ceil(codeHpx + 2 * quietY));610  return { sx, sy, sw, sh };611}612613/**614 * Convert an RGBA `ImageData.data` buffer into the packed ARGB `Int32Array`615 * that `RGBLuminanceSource` expects. Exposed for tests that synthesize a cell616 * buffer directly rather than going through a canvas.617 */618export function imageDataToArgb(imageData: ImageData): Int32Array {619  const raw = imageData.data;620  const out = new Int32Array(imageData.width * imageData.height);621  for (let i = 0, j = 0; i < raw.length; i += 4, j++) {622    out[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;623  }624  return out;625}626627/**628 * Decode a single Aztec code from a packed ARGB pixel buffer. Returns the629 * decoded text on success, or `null` if no code was found.630 *631 * We try `HybridBinarizer` first (good for rendered images with subtle632 * shading from anti-aliasing) and fall back to `GlobalHistogramBinarizer`633 * (better when the image is dominated by clean black/white modules). A fresh634 * `AztecCodeReader` is allocated per attempt — the reader caches internal635 * state that must be reset between bitmaps.636 *637 * This is the exact decode path the importer used to skip by handing canvas638 * elements to `BrowserAztecCodeReader.decode()`, which silently fails on639 * canvases — its `createCaptureCanvas` only recognizes video and image640 * elements and produces a zero-sized capture for anything else. Keeping this641 * function exported lets the test suite verify the path works on synthesized642 * Aztec pixels without needing a real DOM canvas.643 */644export function decodeAztecFromArgb(645  argb: Int32Array,646  width: number,647  height: number,648): string | null {649  const source = new RGBLuminanceSource(argb, width, height);650651  try {652    const bitmap = new BinaryBitmap(new HybridBinarizer(source));653    return new AztecCodeReader().decode(bitmap).getText();654  } catch (err) {655    if (!(err instanceof NotFoundException)) return null;656  }657658  try {659    const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));660    return new AztecCodeReader().decode(bitmap).getText();661  } catch {662    return null;663  }664}665666/**667 * Parse a raw decoded string as a Peach ChunkEnvelope. Returns null for any668 * non-Peach payload — the same defensive contract as `restore.ts/parseRawScan`669 * but inlined here so this module stays decoupled from the camera scan path.670 *671 * Accepts v2 (legacy single-stripe) and v3 (current multi-stripe) envelopes.672 * The generator has emitted v3 envelopes since the multi-stripe rollout —673 * earlier revisions of this function only accepted v2, which silently674 * rejected every envelope produced by current builds.675 */676export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {677  let parsed: unknown;678  try {679    parsed = JSON.parse(raw);680  } catch {681    return null;682  }683  if (typeof parsed !== "object" || parsed === null) return null;684  const obj = parsed as Record<string, unknown>;685686  if (obj.v !== 2 && obj.v !== 3) return null;687688  const baseShapeOk =689    typeof obj.c === "string" &&690    Number.isInteger(obj.i) && // PCH-01-012: reject NaN/Infinity/fractional indices691    Number.isInteger(obj.t) &&692    typeof obj.p === "boolean" &&693    typeof obj.d === "string" &&694    typeof obj.h === "string";695  if (!baseShapeOk) return null;696697  // v3 envelopes must also carry stripe metadata.698  if (obj.v === 3 && (!Number.isInteger(obj.s) || !Number.isInteger(obj.S))) {699    return null;700  }701702  return obj as unknown as ChunkEnvelope;703}