pdf-import.ts

22.9 KB · raw

1/**2 * pdf-import.ts — restore from a Codex PDF instead of a camera scan.3 *4 * The PDF is rendered page-by-page to a canvas (via pdf.js) and each page5 * canvas is divided into the same 6×6 grid the generator prints into. Each6 * cell is 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    pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;275  } catch (err) {276    return {277      chunksFound: 0,278      totalExpected: null,279      envelopes,280      errors: [281        `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,282      ],283    };284  }285286  const pageCount = pdf.numPages;287  const renderScale = await detectRenderScale(pdf);288289  for (let pageNum = 1; pageNum <= pageCount; pageNum++) {290    onProgress?.(pageNum, pageCount);291292    try {293      const page = await pdf.getPage(pageNum);294      const canvas = await renderPageToCanvas(page, renderScale);295      const ctx = canvas.getContext("2d");296      if (!ctx) {297        errors.push(`Page ${pageNum}: no canvas context`);298        continue;299      }300301      const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices, renderScale);302      for (const env of pageEnvelopes) envelopes.push(env);303304      if (totalExpected === null && pageEnvelopes.length > 0) {305        totalExpected = pageEnvelopes[0]!.t;306      }307    } catch (err) {308      errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`);309    }310  }311312  const metricsEnabled =313    typeof window !== "undefined" &&314    (import.meta.env.MODE === "test" ||315      new URLSearchParams(window.location.search).has("codexMetrics"));316  if (metricsEnabled) {317    const stripeTotals = new Map<number, number>();318    const stripeDecoded = new Map<number, number>();319    for (const env of envelopes) {320      const stripe = env.v === 3 ? (env.s ?? 0) : 0;321      stripeTotals.set(stripe, env.t);322      stripeDecoded.set(stripe, (stripeDecoded.get(stripe) ?? 0) + 1);323    }324    const totalPrinted = Array.from(stripeTotals.values()).reduce((sum, total) => sum + total, 0);325    const stripeIndices = Array.from(stripeTotals.keys());326    const maxStripe = stripeIndices.length > 0 ? Math.max(...stripeIndices) : -1;327    (window as Window & {328      __codexImportMetrics?: {329        decodeRate: number;330        shortStripeCount: number;331        perStripeDecoded: number[];332      };333    }).__codexImportMetrics = {334      decodeRate: totalPrinted > 0 ? envelopes.length / totalPrinted : 0,335      shortStripeCount: Array.from(stripeTotals.entries()).filter(336        ([stripe, total]) => (stripeDecoded.get(stripe) ?? 0) < total,337      ).length,338      perStripeDecoded:339        maxStripe >= 0340          ? Array.from({ length: maxStripe + 1 }, (_, stripe) => stripeDecoded.get(stripe) ?? 0)341          : [],342    };343  }344345  return {346    chunksFound: envelopes.length,347    totalExpected,348    envelopes,349    errors,350  };351}352353/**354 * Crop each of the 36 (6×6) printed Aztec codes out of the rendered page and355 * try to decode it. Cells that contain no code — the trailing empty cells on356 * the last page — just throw NotFoundException and are skipped.357 *358 * Two non-obvious things this function gets right that earlier revisions did359 * not:360 *361 *   1. Cropping follows the *exact* layout drawn by `pdf.ts` (margins,362 *      header band, code size, column/row gaps) instead of a naive363 *      `canvas / 6` slice. The naive slicer clipped every Aztec on its364 *      right edge — ZXing's binarizer can't find a finder pattern in a365 *      chopped symbol. Geometry constants are shared with the generator366 *      via `pdf-layout.ts` so the two can't drift again.367 *368 *   2. We decode the cell pixels directly through `AztecCodeReader` +369 *      `RGBLuminanceSource` rather than handing the canvas to370 *      `BrowserAztecCodeReader.decode()`. ZXing's BrowserCodeReader only371 *      knows how to read `HTMLVideoElement` and `HTMLImageElement` — when372 *      you pass it a canvas, its internal `createCaptureCanvas` falls373 *      through to no branch and creates a zero-sized capture canvas, so374 *      every decode returns `NotFoundException`. The DOM-free path here is375 *      the same one the generator uses for round-trip verification.376 *377 * Around each code we also include a small quiet-zone margin (≈12% of the378 * code size). The PDF prints codes against a cream background with no379 * required quiet zone; ZXing's locator is happier with a white one.380 */381async function scanCanvasGrid(382  canvas: HTMLCanvasElement,383  seenIndices: Set<string>,384  renderScale: number,385): Promise<ChunkEnvelope[]> {386  const found: ChunkEnvelope[] = [];387388  // One scratch canvas reused across all cells — cheaper than allocating389  // 36 canvases per page when a Codex spans dozens of pages.390  const cellCanvas = document.createElement("canvas");391  const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true });392  if (!cellCtx) return found;393394  for (let row = 0; row < CODES_PER_COL; row++) {395    for (let col = 0; col < CODES_PER_ROW; col++) {396      const { sx, sy, sw, sh } = cellRectFor(canvas.width, canvas.height, row, col);397      if (sw <= 0 || sh <= 0) continue;398399      cellCanvas.width = sw;400      cellCanvas.height = sh;401      // Paint the quiet-zone background white before drawing — the source402      // PDF's cream is light but not pure white, and a clean white border403      // gives the binarizer the cleanest finder-pattern contrast.404      cellCtx.fillStyle = "white";405      cellCtx.fillRect(0, 0, sw, sh);406      cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);407408      const text = decodeAztecCellRobust(409        cellCanvas,410        cellCtx,411        { x: 0, y: 0, w: sw, h: sh },412        renderScale,413      );414      if (!text) continue;415416      const envelope = parseChunkEnvelope(text);417      if (!envelope) continue;418      const stripeKey = `${envelope.s ?? 0}:${envelope.i}`;419      if (seenIndices.has(stripeKey)) continue;420      if (!(await verifyChunkIntegrity(envelope))) continue;421422      seenIndices.add(stripeKey);423      found.push(envelope);424    }425  }426427  return found;428}429430function decodeAztecCellRobust(431  canvas: HTMLCanvasElement,432  ctx: CanvasRenderingContext2D,433  cellRect: { x: number; y: number; w: number; h: number },434  renderScale: number,435): string | null {436  const strategies = getCellDecodeStrategies(renderScale);437  for (const strategy of strategies) {438    const result = decodeAztecCellWithStrategy(canvas, ctx, cellRect, strategy);439    if (result !== null) {440      return result;441    }442  }443  return null;444}445446function decodeAztecCellWithStrategy(447  canvas: HTMLCanvasElement,448  ctx: CanvasRenderingContext2D,449  cellRect: { x: number; y: number; w: number; h: number },450  strategy: CellDecodeStrategy,451): string | null {452  const { x, y, w, h } = applyCropFactor(cellRect, strategy.cropFactor, canvas.width, canvas.height);453  if (w <= 0 || h <= 0) return null;454455  const imageData = ctx.getImageData(x, y, w, h);456  const processedData = strategy.normalize457    ? normalizeToBlackWhite(imageData)458    : imageData;459460  const argb = imageDataToArgb(processedData);461  const { buffer, width, height } =462    strategy.upscale > 1463      ? upscaleArgb(argb, processedData.width, processedData.height, strategy.upscale)464      : { buffer: argb, width: processedData.width, height: processedData.height };465466  return decodeAztecFromArgb(buffer, width, height);467}468469function applyCropFactor(470  rect: { x: number; y: number; w: number; h: number },471  factor: number,472  canvasW: number,473  canvasH: number,474): { x: number; y: number; w: number; h: number } {475  const cx = rect.x + rect.w / 2;476  const cy = rect.y + rect.h / 2;477  const nw = Math.max(1, Math.round(rect.w * factor));478  const nh = Math.max(1, Math.round(rect.h * factor));479  const x = Math.max(0, Math.min(canvasW - 1, Math.round(cx - nw / 2)));480  const y = Math.max(0, Math.min(canvasH - 1, Math.round(cy - nh / 2)));481  const w = Math.max(1, Math.min(canvasW - x, nw));482  const h = Math.max(1, Math.min(canvasH - y, nh));483  return { x, y, w, h };484}485486function normalizeToBlackWhite(imageData: ImageData): ImageData {487  const data = new Uint8ClampedArray(imageData.data);488  for (let i = 0; i < data.length; i += 4) {489    const lum = 0.299 * data[i]! + 0.587 * data[i + 1]! + 0.114 * data[i + 2]!;490    const v = lum > 128 ? 255 : 0;491    data[i] = v;492    data[i + 1] = v;493    data[i + 2] = v;494    data[i + 3] = 255;495  }496  return new ImageData(data, imageData.width, imageData.height);497}498499function upscaleArgb(500  argb: Int32Array,501  width: number,502  height: number,503  scale: number,504): { buffer: Int32Array; width: number; height: number } {505  const targetW = Math.max(1, Math.round(width * scale));506  const targetH = Math.max(1, Math.round(height * scale));507  const out = new Int32Array(targetW * targetH);508  for (let y = 0; y < targetH; y++) {509    const srcY = Math.min(height - 1, Math.floor(y / scale));510    for (let x = 0; x < targetW; x++) {511      const srcX = Math.min(width - 1, Math.floor(x / scale));512      out[y * targetW + x] = argb[srcY * width + srcX]!;513    }514  }515  return { buffer: out, width: targetW, height: targetH };516}517518/**519 * Compute the canvas-pixel sub-rectangle for the printed Aztec at (row, col)520 * on a page that was rendered at uniform scale into the given canvas521 * dimensions.522 *523 * Exported (rather than inlined into `scanCanvasGrid`) so the geometry can be524 * verified directly in tests — `scanCanvasGrid` itself needs an525 * `HTMLCanvasElement` and `getImageData()` which vitest's node environment526 * lacks, but this pure function does not.527 */528export function cellRectFor(529  canvasW: number,530  canvasH: number,531  row: number,532  col: number,533): { sx: number; sy: number; sw: number; sh: number } {534  // Canvas pixels per PDF point. Independent x/y scales keep us correct if535  // the renderer ever applies non-uniform scaling.536  const scaleX = canvasW / PAGE_WIDTH;537  const scaleY = canvasH / PAGE_HEIGHT;538539  // Top edge of row 0 in PDF coords (bottom-origin) is just below the header.540  // Convert to canvas coords (top-origin): canvas_y = (PAGE_HEIGHT - pdf_y) * scaleY.541  const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;542  const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;543544  const codeWpx = CODE_SIZE * scaleX;545  const codeHpx = CODE_SIZE * scaleY;546  const quietX = Math.round(codeWpx * 0.12);547  const quietY = Math.round(codeHpx * 0.12);548549  const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;550  const codeY = gridTopCanvas + row * ROW_STEP * scaleY;551552  const sx = Math.max(0, Math.floor(codeX - quietX));553  const sy = Math.max(0, Math.floor(codeY - quietY));554  const sw = Math.min(canvasW - sx, Math.ceil(codeWpx + 2 * quietX));555  const sh = Math.min(canvasH - sy, Math.ceil(codeHpx + 2 * quietY));556  return { sx, sy, sw, sh };557}558559/**560 * Convert an RGBA `ImageData.data` buffer into the packed ARGB `Int32Array`561 * that `RGBLuminanceSource` expects. Exposed for tests that synthesize a cell562 * buffer directly rather than going through a canvas.563 */564export function imageDataToArgb(imageData: ImageData): Int32Array {565  const raw = imageData.data;566  const out = new Int32Array(imageData.width * imageData.height);567  for (let i = 0, j = 0; i < raw.length; i += 4, j++) {568    out[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;569  }570  return out;571}572573/**574 * Decode a single Aztec code from a packed ARGB pixel buffer. Returns the575 * decoded text on success, or `null` if no code was found.576 *577 * We try `HybridBinarizer` first (good for rendered images with subtle578 * shading from anti-aliasing) and fall back to `GlobalHistogramBinarizer`579 * (better when the image is dominated by clean black/white modules). A fresh580 * `AztecCodeReader` is allocated per attempt — the reader caches internal581 * state that must be reset between bitmaps.582 *583 * This is the exact decode path the importer used to skip by handing canvas584 * elements to `BrowserAztecCodeReader.decode()`, which silently fails on585 * canvases — its `createCaptureCanvas` only recognizes video and image586 * elements and produces a zero-sized capture for anything else. Keeping this587 * function exported lets the test suite verify the path works on synthesized588 * Aztec pixels without needing a real DOM canvas.589 */590export function decodeAztecFromArgb(591  argb: Int32Array,592  width: number,593  height: number,594): string | null {595  const source = new RGBLuminanceSource(argb, width, height);596597  try {598    const bitmap = new BinaryBitmap(new HybridBinarizer(source));599    return new AztecCodeReader().decode(bitmap).getText();600  } catch (err) {601    if (!(err instanceof NotFoundException)) return null;602  }603604  try {605    const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));606    return new AztecCodeReader().decode(bitmap).getText();607  } catch {608    return null;609  }610}611612/**613 * Parse a raw decoded string as a Peach ChunkEnvelope. Returns null for any614 * non-Peach payload — the same defensive contract as `restore.ts/parseRawScan`615 * but inlined here so this module stays decoupled from the camera scan path.616 *617 * Accepts v2 (legacy single-stripe) and v3 (current multi-stripe) envelopes.618 * The generator has emitted v3 envelopes since the multi-stripe rollout —619 * earlier revisions of this function only accepted v2, which silently620 * rejected every envelope produced by current builds.621 */622export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {623  let parsed: unknown;624  try {625    parsed = JSON.parse(raw);626  } catch {627    return null;628  }629  if (typeof parsed !== "object" || parsed === null) return null;630  const obj = parsed as Record<string, unknown>;631632  if (obj.v !== 2 && obj.v !== 3) return null;633634  const baseShapeOk =635    typeof obj.c === "string" &&636    typeof obj.i === "number" &&637    typeof obj.t === "number" &&638    typeof obj.p === "boolean" &&639    typeof obj.d === "string" &&640    typeof obj.h === "string";641  if (!baseShapeOk) return null;642643  // v3 envelopes must also carry stripe metadata.644  if (obj.v === 3 && (typeof obj.s !== "number" || typeof obj.S !== "number")) {645    return null;646  }647648  return obj as unknown as ChunkEnvelope;649}