pdf-import.ts

23.6 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, taken from the first envelope. */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;116117interface CellDecodeStrategy {118  cropFactor: number;119  normalize: boolean;120  upscale: number;121}122123const SMALL_SCALE_STRATEGIES: CellDecodeStrategy[] = [124  { cropFactor: 1.0, normalize: false, upscale: 1.0 },125  { cropFactor: 0.88, normalize: false, upscale: 1.0 },126  { cropFactor: 1.12, normalize: false, upscale: 1.0 },127  { cropFactor: 1.0, normalize: true, upscale: 1.0 },128  { cropFactor: 0.88, normalize: true, upscale: 1.0 },129  { cropFactor: 1.0, normalize: false, upscale: 2.0 },130  { cropFactor: 1.0, normalize: true, upscale: 2.0 },131];132133const LARGE_SCALE_STRATEGIES: CellDecodeStrategy[] = [134  { cropFactor: 1.0, normalize: false, upscale: 1.0 },135  { cropFactor: 1.12, normalize: false, upscale: 1.0 },136  { cropFactor: 0.88, normalize: false, upscale: 1.0 },137  { cropFactor: 1.0, normalize: true, upscale: 1.0 },138  { cropFactor: 1.12, normalize: true, upscale: 1.0 },139  { cropFactor: 1.0, normalize: false, upscale: 2.0 },140  { cropFactor: 1.0, normalize: true, upscale: 2.0 },141];142143function getCellDecodeStrategies(renderScale: number): CellDecodeStrategy[] {144  return renderScale < 4.5 ? SMALL_SCALE_STRATEGIES : LARGE_SCALE_STRATEGIES;145}146147/**148 * Probe up to three pages of `pdf` at every candidate scale and return the149 * scale that decodes the highest fraction of codes. "First scale that decodes150 * anything" is not sufficient — scale 5.25 can occasionally decode 1–2 codes151 * from a 6.05 (117-module) Codex, producing a false-positive that makes the152 * full import use the wrong scale. Taking the maximum across multiple pages153 * avoids this: the correct scale wins by a large margin.154 *155 * Early exit: once a scale achieves a perfect probe score we can stop — no156 * later candidate can beat 100% decode rate on the sampled pages.157 */158async function detectRenderScale(159  pdf: Awaited<ReturnType<PdfJsModule["getDocument"]>["promise"]>,160): Promise<number> {161  let bestScale: number = CANDIDATE_SCALES[0];162  let bestRate = -1;163  const probePages = Math.min(3, pdf.numPages);164165  for (const scale of CANDIDATE_SCALES) {166    try {167      let decoded = 0;168      let total = 0;169      for (let pageNum = 1; pageNum <= probePages; pageNum++) {170        const page = await pdf.getPage(pageNum);171        const result = await probePageDecodeRate(page, scale);172        decoded += result.decoded;173        total += result.total;174      }175      const rate = total > 0 ? decoded / total : 0;176      if (rate > bestRate) {177        bestRate = rate;178        bestScale = scale;179      }180      if (rate >= 0.99) {181        return scale;182      }183    } catch {184      // continue to next candidate185    }186  }187188  return bestScale;189}190191async function probePageDecodeRate(192  page: PdfPage,193  scale: number,194): Promise<{ decoded: number; total: number }> {195  const canvas = await renderPageToCanvas(page, scale);196  const ctx = canvas.getContext("2d");197  if (!ctx) return { decoded: 0, total: CODES_PER_PAGE };198199  let decoded = 0;200  for (let row = 0; row < CODES_PER_COL; row++) {201    for (let col = 0; col < CODES_PER_ROW; col++) {202      const rect = cellRectFor(canvas.width, canvas.height, row, col);203      if (!rect.sw || !rect.sh) continue;204      const text = decodeAztecCellRobust(205        canvas,206        ctx,207        { x: rect.sx, y: rect.sy, w: rect.sw, h: rect.sh },208        scale,209      );210      if (text !== null) decoded++;211    }212  }213  return { decoded, total: CODES_PER_PAGE };214}215216async function renderPageToCanvas(217  page: PdfPage,218  scale: number,219): Promise<HTMLCanvasElement> {220  const viewport = page.getViewport({ scale });221  const canvas = document.createElement("canvas");222  canvas.width = Math.ceil(viewport.width);223  canvas.height = Math.ceil(viewport.height);224  const ctx = canvas.getContext("2d");225  if (!ctx) {226    return canvas;227  }228  ctx.fillStyle = "white";229  ctx.fillRect(0, 0, canvas.width, canvas.height);230  await page.render({ canvas, canvasContext: ctx, viewport }).promise;231  return canvas;232}233234/**235 * Import a Peach Codex PDF and decode every Aztec code it contains.236 *237 * Automatically detects the render scale by probing up to three pages at238 * each candidate scale in CANDIDATE_SCALES — the scale with the highest239 * decode rate is used for all pages. This makes the importer240 * backward-compatible with Codexes generated at any prior CHUNK_SIZE, and241 * forward-compatible with future changes, without requiring any version242 * metadata in the PDF.243 */244export async function importCodexPDF(245  file: File,246  onProgress?: PDFImportProgress,247): Promise<PDFImportResult> {248  // MV-5: refuse oversized files before reading them into memory — and before249  // even loading pdf.js. A real Codex tops out well under this (200 pages ≈250  // 4 MiB of Aztec PNGs).251  if (file.size > IMPORT_LIMITS.MAX_PDF_BYTES) {252    return {253      chunksFound: 0,254      totalExpected: null,255      envelopes: [],256      errors: [257        `PDF is ${(file.size / (1024 * 1024)).toFixed(1)} MiB — Codex PDFs are limited to ${IMPORT_LIMITS.MAX_PDF_BYTES / (1024 * 1024)} MiB.`,258      ],259    };260  }261262  const pdfjsLib = await getPdfJs();263264  const envelopes: ChunkEnvelope[] = [];265  const errors: string[] = [];266  // Key is `${stripe}:${chunkIndex}` so multi-stripe Codexes don't treat267  // stripe 1's chunk 0 as a duplicate of stripe 0's chunk 0.268  const seenIndices = new Set<string>();269  let totalExpected: number | null = null;270271  let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;272  try {273    const arrayBuffer = await file.arrayBuffer();274    // MV-9: this parses arbitrary user-selected PDFs. The popup CSP already275    // blocks eval, but disable pdf.js's eval-dependent font paths in depth276    // (CVE-2024-4367 class), and turn off features Aztec extraction never277    // needs.278    pdf = await pdfjsLib.getDocument({279      data: arrayBuffer,280      // isEvalSupported is a valid pdf.js runtime option (defends the281      // CVE-2024-4367 eval-via-font path) but is absent from pdfjs-dist282      // 5.7.284's DocumentInitParameters typings — cast to add it.283      isEvalSupported: false,284      disableFontFace: true, // fonts not needed for Aztec bitmap extraction285      disableRange: true,    // whole buffer already in memory286      disableStream: true,   // ditto287    } as Parameters<typeof pdfjsLib.getDocument>[0]).promise;288  } catch (err) {289    return {290      chunksFound: 0,291      totalExpected: null,292      envelopes,293      errors: [294        `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,295      ],296    };297  }298299  const pageCount = pdf.numPages;300  const renderScale = await detectRenderScale(pdf);301302  for (let pageNum = 1; pageNum <= pageCount; pageNum++) {303    onProgress?.(pageNum, pageCount);304305    try {306      const page = await pdf.getPage(pageNum);307      const canvas = await renderPageToCanvas(page, renderScale);308      const ctx = canvas.getContext("2d");309      if (!ctx) {310        errors.push(`Page ${pageNum}: no canvas context`);311        continue;312      }313314      const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices, renderScale);315      for (const env of pageEnvelopes) envelopes.push(env);316317      if (totalExpected === null && pageEnvelopes.length > 0) {318        totalExpected = pageEnvelopes[0]!.t;319      }320    } catch (err) {321      errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`);322    }323  }324325  const metricsEnabled =326    typeof window !== "undefined" &&327    (import.meta.env.MODE === "test" ||328      new URLSearchParams(window.location.search).has("codexMetrics"));329  if (metricsEnabled) {330    const stripeTotals = new Map<number, number>();331    const stripeDecoded = new Map<number, number>();332    for (const env of envelopes) {333      const stripe = env.v === 3 ? (env.s ?? 0) : 0;334      stripeTotals.set(stripe, env.t);335      stripeDecoded.set(stripe, (stripeDecoded.get(stripe) ?? 0) + 1);336    }337    const totalPrinted = Array.from(stripeTotals.values()).reduce((sum, total) => sum + total, 0);338    const stripeIndices = Array.from(stripeTotals.keys());339    const maxStripe = stripeIndices.length > 0 ? Math.max(...stripeIndices) : -1;340    (window as Window & {341      __codexImportMetrics?: {342        decodeRate: number;343        shortStripeCount: number;344        perStripeDecoded: number[];345      };346    }).__codexImportMetrics = {347      decodeRate: totalPrinted > 0 ? envelopes.length / totalPrinted : 0,348      shortStripeCount: Array.from(stripeTotals.entries()).filter(349        ([stripe, total]) => (stripeDecoded.get(stripe) ?? 0) < total,350      ).length,351      perStripeDecoded:352        maxStripe >= 0353          ? Array.from({ length: maxStripe + 1 }, (_, stripe) => stripeDecoded.get(stripe) ?? 0)354          : [],355    };356  }357358  return {359    chunksFound: envelopes.length,360    totalExpected,361    envelopes,362    errors,363  };364}365366/**367 * Crop each of the 36 (6×6) printed Aztec codes out of the rendered page and368 * try to decode it. Cells that contain no code — the trailing empty cells on369 * the last page — just throw NotFoundException and are skipped.370 *371 * Two non-obvious things this function gets right that earlier revisions did372 * not:373 *374 *   1. Cropping follows the *exact* layout drawn by `pdf.ts` (margins,375 *      header band, code size, column/row gaps) instead of a naive376 *      `canvas / 6` slice. The naive slicer clipped every Aztec on its377 *      right edge — ZXing's binarizer can't find a finder pattern in a378 *      chopped symbol. Geometry constants are shared with the generator379 *      via `pdf-layout.ts` so the two can't drift again.380 *381 *   2. We decode the cell pixels directly through `AztecCodeReader` +382 *      `RGBLuminanceSource` rather than handing the canvas to383 *      `BrowserAztecCodeReader.decode()`. ZXing's BrowserCodeReader only384 *      knows how to read `HTMLVideoElement` and `HTMLImageElement` — when385 *      you pass it a canvas, its internal `createCaptureCanvas` falls386 *      through to no branch and creates a zero-sized capture canvas, so387 *      every decode returns `NotFoundException`. The DOM-free path here is388 *      the same one the generator uses for round-trip verification.389 *390 * Around each code we also include a small quiet-zone margin (≈12% of the391 * code size). The PDF prints codes against a cream background with no392 * required quiet zone; ZXing's locator is happier with a white one.393 */394async function scanCanvasGrid(395  canvas: HTMLCanvasElement,396  seenIndices: Set<string>,397  renderScale: number,398): Promise<ChunkEnvelope[]> {399  const found: ChunkEnvelope[] = [];400401  // One scratch canvas reused across all cells — cheaper than allocating402  // 36 canvases per page when a Codex spans dozens of pages.403  const cellCanvas = document.createElement("canvas");404  const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true });405  if (!cellCtx) return found;406407  for (let row = 0; row < CODES_PER_COL; row++) {408    for (let col = 0; col < CODES_PER_ROW; col++) {409      const { sx, sy, sw, sh } = cellRectFor(canvas.width, canvas.height, row, col);410      if (sw <= 0 || sh <= 0) continue;411412      cellCanvas.width = sw;413      cellCanvas.height = sh;414      // Paint the quiet-zone background white before drawing — the source415      // PDF's cream is light but not pure white, and a clean white border416      // gives the binarizer the cleanest finder-pattern contrast.417      cellCtx.fillStyle = "white";418      cellCtx.fillRect(0, 0, sw, sh);419      cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);420421      const text = decodeAztecCellRobust(422        cellCanvas,423        cellCtx,424        { x: 0, y: 0, w: sw, h: sh },425        renderScale,426      );427      if (!text) continue;428429      const envelope = parseChunkEnvelope(text);430      if (!envelope) continue;431      const stripeKey = `${envelope.s ?? 0}:${envelope.i}`;432      if (seenIndices.has(stripeKey)) continue;433      if (!(await verifyChunkIntegrity(envelope))) continue;434435      seenIndices.add(stripeKey);436      found.push(envelope);437    }438  }439440  return found;441}442443function decodeAztecCellRobust(444  canvas: HTMLCanvasElement,445  ctx: CanvasRenderingContext2D,446  cellRect: { x: number; y: number; w: number; h: number },447  renderScale: number,448): string | null {449  const strategies = getCellDecodeStrategies(renderScale);450  for (const strategy of strategies) {451    const result = decodeAztecCellWithStrategy(canvas, ctx, cellRect, strategy);452    if (result !== null) {453      return result;454    }455  }456  return null;457}458459function decodeAztecCellWithStrategy(460  canvas: HTMLCanvasElement,461  ctx: CanvasRenderingContext2D,462  cellRect: { x: number; y: number; w: number; h: number },463  strategy: CellDecodeStrategy,464): string | null {465  const { x, y, w, h } = applyCropFactor(cellRect, strategy.cropFactor, canvas.width, canvas.height);466  if (w <= 0 || h <= 0) return null;467468  const imageData = ctx.getImageData(x, y, w, h);469  const processedData = strategy.normalize470    ? normalizeToBlackWhite(imageData)471    : imageData;472473  const argb = imageDataToArgb(processedData);474  const { buffer, width, height } =475    strategy.upscale > 1476      ? upscaleArgb(argb, processedData.width, processedData.height, strategy.upscale)477      : { buffer: argb, width: processedData.width, height: processedData.height };478479  return decodeAztecFromArgb(buffer, width, height);480}481482function applyCropFactor(483  rect: { x: number; y: number; w: number; h: number },484  factor: number,485  canvasW: number,486  canvasH: number,487): { x: number; y: number; w: number; h: number } {488  const cx = rect.x + rect.w / 2;489  const cy = rect.y + rect.h / 2;490  const nw = Math.max(1, Math.round(rect.w * factor));491  const nh = Math.max(1, Math.round(rect.h * factor));492  const x = Math.max(0, Math.min(canvasW - 1, Math.round(cx - nw / 2)));493  const y = Math.max(0, Math.min(canvasH - 1, Math.round(cy - nh / 2)));494  const w = Math.max(1, Math.min(canvasW - x, nw));495  const h = Math.max(1, Math.min(canvasH - y, nh));496  return { x, y, w, h };497}498499function normalizeToBlackWhite(imageData: ImageData): ImageData {500  const data = new Uint8ClampedArray(imageData.data);501  for (let i = 0; i < data.length; i += 4) {502    const lum = 0.299 * data[i]! + 0.587 * data[i + 1]! + 0.114 * data[i + 2]!;503    const v = lum > 128 ? 255 : 0;504    data[i] = v;505    data[i + 1] = v;506    data[i + 2] = v;507    data[i + 3] = 255;508  }509  return new ImageData(data, imageData.width, imageData.height);510}511512function upscaleArgb(513  argb: Int32Array,514  width: number,515  height: number,516  scale: number,517): { buffer: Int32Array; width: number; height: number } {518  const targetW = Math.max(1, Math.round(width * scale));519  const targetH = Math.max(1, Math.round(height * scale));520  const out = new Int32Array(targetW * targetH);521  for (let y = 0; y < targetH; y++) {522    const srcY = Math.min(height - 1, Math.floor(y / scale));523    for (let x = 0; x < targetW; x++) {524      const srcX = Math.min(width - 1, Math.floor(x / scale));525      out[y * targetW + x] = argb[srcY * width + srcX]!;526    }527  }528  return { buffer: out, width: targetW, height: targetH };529}530531/**532 * Compute the canvas-pixel sub-rectangle for the printed Aztec at (row, col)533 * on a page that was rendered at uniform scale into the given canvas534 * dimensions.535 *536 * Exported (rather than inlined into `scanCanvasGrid`) so the geometry can be537 * verified directly in tests — `scanCanvasGrid` itself needs an538 * `HTMLCanvasElement` and `getImageData()` which vitest's node environment539 * lacks, but this pure function does not.540 */541export function cellRectFor(542  canvasW: number,543  canvasH: number,544  row: number,545  col: number,546): { sx: number; sy: number; sw: number; sh: number } {547  // Canvas pixels per PDF point. Independent x/y scales keep us correct if548  // the renderer ever applies non-uniform scaling.549  const scaleX = canvasW / PAGE_WIDTH;550  const scaleY = canvasH / PAGE_HEIGHT;551552  // Top edge of row 0 in PDF coords (bottom-origin) is just below the header.553  // Convert to canvas coords (top-origin): canvas_y = (PAGE_HEIGHT - pdf_y) * scaleY.554  const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;555  const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;556557  const codeWpx = CODE_SIZE * scaleX;558  const codeHpx = CODE_SIZE * scaleY;559  const quietX = Math.round(codeWpx * 0.12);560  const quietY = Math.round(codeHpx * 0.12);561562  const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;563  const codeY = gridTopCanvas + row * ROW_STEP * scaleY;564565  const sx = Math.max(0, Math.floor(codeX - quietX));566  const sy = Math.max(0, Math.floor(codeY - quietY));567  const sw = Math.min(canvasW - sx, Math.ceil(codeWpx + 2 * quietX));568  const sh = Math.min(canvasH - sy, Math.ceil(codeHpx + 2 * quietY));569  return { sx, sy, sw, sh };570}571572/**573 * Convert an RGBA `ImageData.data` buffer into the packed ARGB `Int32Array`574 * that `RGBLuminanceSource` expects. Exposed for tests that synthesize a cell575 * buffer directly rather than going through a canvas.576 */577export function imageDataToArgb(imageData: ImageData): Int32Array {578  const raw = imageData.data;579  const out = new Int32Array(imageData.width * imageData.height);580  for (let i = 0, j = 0; i < raw.length; i += 4, j++) {581    out[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;582  }583  return out;584}585586/**587 * Decode a single Aztec code from a packed ARGB pixel buffer. Returns the588 * decoded text on success, or `null` if no code was found.589 *590 * We try `HybridBinarizer` first (good for rendered images with subtle591 * shading from anti-aliasing) and fall back to `GlobalHistogramBinarizer`592 * (better when the image is dominated by clean black/white modules). A fresh593 * `AztecCodeReader` is allocated per attempt — the reader caches internal594 * state that must be reset between bitmaps.595 *596 * This is the exact decode path the importer used to skip by handing canvas597 * elements to `BrowserAztecCodeReader.decode()`, which silently fails on598 * canvases — its `createCaptureCanvas` only recognizes video and image599 * elements and produces a zero-sized capture for anything else. Keeping this600 * function exported lets the test suite verify the path works on synthesized601 * Aztec pixels without needing a real DOM canvas.602 */603export function decodeAztecFromArgb(604  argb: Int32Array,605  width: number,606  height: number,607): string | null {608  const source = new RGBLuminanceSource(argb, width, height);609610  try {611    const bitmap = new BinaryBitmap(new HybridBinarizer(source));612    return new AztecCodeReader().decode(bitmap).getText();613  } catch (err) {614    if (!(err instanceof NotFoundException)) return null;615  }616617  try {618    const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));619    return new AztecCodeReader().decode(bitmap).getText();620  } catch {621    return null;622  }623}624625/**626 * Parse a raw decoded string as a Peach ChunkEnvelope. Returns null for any627 * non-Peach payload — the same defensive contract as `restore.ts/parseRawScan`628 * but inlined here so this module stays decoupled from the camera scan path.629 *630 * Accepts v2 (legacy single-stripe) and v3 (current multi-stripe) envelopes.631 * The generator has emitted v3 envelopes since the multi-stripe rollout —632 * earlier revisions of this function only accepted v2, which silently633 * rejected every envelope produced by current builds.634 */635export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {636  let parsed: unknown;637  try {638    parsed = JSON.parse(raw);639  } catch {640    return null;641  }642  if (typeof parsed !== "object" || parsed === null) return null;643  const obj = parsed as Record<string, unknown>;644645  if (obj.v !== 2 && obj.v !== 3) return null;646647  const baseShapeOk =648    typeof obj.c === "string" &&649    typeof obj.i === "number" &&650    typeof obj.t === "number" &&651    typeof obj.p === "boolean" &&652    typeof obj.d === "string" &&653    typeof obj.h === "string";654  if (!baseShapeOk) return null;655656  // v3 envelopes must also carry stripe metadata.657  if (obj.v === 3 && (typeof obj.s !== "number" || typeof obj.S !== "number")) {658    return null;659  }660661  return obj as unknown as ChunkEnvelope;662}