pdf-import.ts

22.4 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 {43  CODE_SIZE,44  COL_GAP,45  HEADER_GAP,46  HEADER_HEIGHT,47  MARGIN,48  PAGE_HEIGHT,49  PAGE_WIDTH,50  ROW_STEP,51} from "./pdf-layout";5253/**54 * Lazily-loaded pdf.js module. Top-level `import * as pdfjsLib` would fail at55 * test time because pdfjs-dist v5 references DOMMatrix at module init, which56 * vitest's node environment lacks. We only need pdf.js inside importCodexPDF;57 * the parser surface (parseChunkEnvelope) can stay importable from tests.58 */59type PdfJsModule = typeof import("pdfjs-dist");60type PdfPage = any;61let pdfJsPromise: Promise<PdfJsModule> | null = null;6263async function getPdfJs(): Promise<PdfJsModule> {64  if (!pdfJsPromise) {65    pdfJsPromise = import("pdfjs-dist").then((mod) => {66      if (67        typeof mod.GlobalWorkerOptions.workerSrc !== "string" ||68        mod.GlobalWorkerOptions.workerSrc.length === 069      ) {70        if (typeof chrome !== "undefined" && chrome.runtime?.getURL) {71          mod.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL(72            "pdf.worker.min.mjs",73          );74        } else {75          mod.GlobalWorkerOptions.workerSrc = "./pdf.worker.min.mjs";76        }77      }78      return mod;79    });80  }81  return pdfJsPromise;82}8384export interface PDFImportResult {85  /** Number of envelopes successfully decoded across all pages. */86  chunksFound: number;87  /** Total chunk count printed on the Codex, taken from the first envelope. */88  totalExpected: number | null;89  /** Decoded envelopes. Caller feeds these into ChunkBuffer. */90  envelopes: ChunkEnvelope[];91  /** Page-level error notes — empty when every page rendered cleanly. */92  errors: string[];93}9495export interface PDFImportProgress {96  (page: number, totalPages: number): void;97}9899/**100 * Render scales that produce a 1:1 canvas-to-source-PNG mapping for known101 * Codex generations, ordered from newest to oldest. ZXing's AztecDetector102 * requires near-exact 1:1 rendering — even a 1% scale error causes103 * ReedSolomonException (downscale) or FormatException (upscale). The formula104 * is: scale = (modules × 4 + 16) / CODE_SIZE_pt where modules is the Aztec105 * matrix size and CODE_SIZE_pt = 80.106 *107 * Current generation (CHUNK_SIZE=350): ZXing → 75-module codes → 316px PNG108 *   → scale = 3.95109 * Prior generation (CHUNK_SIZE=800, ZXing path): 101-module codes → 420px PNG110 *   → scale = 5.25111 * Prior generation (CHUNK_SIZE=800, bwip-js path): 117-module codes → 484px PNG112 *   → scale = 6.05113 */114const CANDIDATE_SCALES = [3.95, 5.25, 6.05] as const;115116interface CellDecodeStrategy {117  cropFactor: number;118  normalize: boolean;119  upscale: number;120}121122const SMALL_SCALE_STRATEGIES: CellDecodeStrategy[] = [123  { cropFactor: 1.0, normalize: false, upscale: 1.0 },124  { cropFactor: 0.88, normalize: false, upscale: 1.0 },125  { cropFactor: 1.12, normalize: false, upscale: 1.0 },126  { cropFactor: 1.0, normalize: true, upscale: 1.0 },127  { cropFactor: 0.88, normalize: true, upscale: 1.0 },128  { cropFactor: 1.0, normalize: false, upscale: 2.0 },129  { cropFactor: 1.0, normalize: true, upscale: 2.0 },130];131132const LARGE_SCALE_STRATEGIES: CellDecodeStrategy[] = [133  { cropFactor: 1.0, normalize: false, upscale: 1.0 },134  { cropFactor: 1.12, normalize: false, upscale: 1.0 },135  { cropFactor: 0.88, normalize: false, upscale: 1.0 },136  { cropFactor: 1.0, normalize: true, upscale: 1.0 },137  { cropFactor: 1.12, normalize: true, upscale: 1.0 },138  { cropFactor: 1.0, normalize: false, upscale: 2.0 },139  { cropFactor: 1.0, normalize: true, upscale: 2.0 },140];141142function getCellDecodeStrategies(renderScale: number): CellDecodeStrategy[] {143  return renderScale < 4.5 ? SMALL_SCALE_STRATEGIES : LARGE_SCALE_STRATEGIES;144}145146/**147 * Probe up to three pages of `pdf` at every candidate scale and return the148 * scale that decodes the highest fraction of codes. "First scale that decodes149 * anything" is not sufficient — scale 5.25 can occasionally decode 1–2 codes150 * from a 6.05 (117-module) Codex, producing a false-positive that makes the151 * full import use the wrong scale. Taking the maximum across multiple pages152 * avoids this: the correct scale wins by a large margin.153 *154 * Early exit: once a scale achieves a perfect probe score we can stop — no155 * later candidate can beat 100% decode rate on the sampled pages.156 */157async function detectRenderScale(158  pdf: Awaited<ReturnType<PdfJsModule["getDocument"]>["promise"]>,159): Promise<number> {160  let bestScale: number = CANDIDATE_SCALES[0];161  let bestRate = -1;162  const probePages = Math.min(3, pdf.numPages);163164  for (const scale of CANDIDATE_SCALES) {165    try {166      let decoded = 0;167      let total = 0;168      for (let pageNum = 1; pageNum <= probePages; pageNum++) {169        const page = await pdf.getPage(pageNum);170        const result = await probePageDecodeRate(page, scale);171        decoded += result.decoded;172        total += result.total;173      }174      const rate = total > 0 ? decoded / total : 0;175      if (rate > bestRate) {176        bestRate = rate;177        bestScale = scale;178      }179      if (rate >= 0.99) {180        return scale;181      }182    } catch {183      // continue to next candidate184    }185  }186187  return bestScale;188}189190async function probePageDecodeRate(191  page: PdfPage,192  scale: number,193): Promise<{ decoded: number; total: number }> {194  const canvas = await renderPageToCanvas(page, scale);195  const ctx = canvas.getContext("2d");196  if (!ctx) return { decoded: 0, total: CODES_PER_PAGE };197198  let decoded = 0;199  for (let row = 0; row < CODES_PER_COL; row++) {200    for (let col = 0; col < CODES_PER_ROW; col++) {201      const rect = cellRectFor(canvas.width, canvas.height, row, col);202      if (!rect.sw || !rect.sh) continue;203      const text = decodeAztecCellRobust(204        canvas,205        ctx,206        { x: rect.sx, y: rect.sy, w: rect.sw, h: rect.sh },207        scale,208      );209      if (text !== null) decoded++;210    }211  }212  return { decoded, total: CODES_PER_PAGE };213}214215async function renderPageToCanvas(216  page: PdfPage,217  scale: number,218): Promise<HTMLCanvasElement> {219  const viewport = page.getViewport({ scale });220  const canvas = document.createElement("canvas");221  canvas.width = Math.ceil(viewport.width);222  canvas.height = Math.ceil(viewport.height);223  const ctx = canvas.getContext("2d");224  if (!ctx) {225    return canvas;226  }227  ctx.fillStyle = "white";228  ctx.fillRect(0, 0, canvas.width, canvas.height);229  await page.render({ canvas, canvasContext: ctx, viewport }).promise;230  return canvas;231}232233/**234 * Import a Peach Codex PDF and decode every Aztec code it contains.235 *236 * Automatically detects the render scale by probing up to three pages at237 * each candidate scale in CANDIDATE_SCALES — the scale with the highest238 * decode rate is used for all pages. This makes the importer239 * backward-compatible with Codexes generated at any prior CHUNK_SIZE, and240 * forward-compatible with future changes, without requiring any version241 * metadata in the PDF.242 */243export async function importCodexPDF(244  file: File,245  onProgress?: PDFImportProgress,246): Promise<PDFImportResult> {247  const pdfjsLib = await getPdfJs();248249  const envelopes: ChunkEnvelope[] = [];250  const errors: string[] = [];251  // Key is `${stripe}:${chunkIndex}` so multi-stripe Codexes don't treat252  // stripe 1's chunk 0 as a duplicate of stripe 0's chunk 0.253  const seenIndices = new Set<string>();254  let totalExpected: number | null = null;255256  let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;257  try {258    const arrayBuffer = await file.arrayBuffer();259    pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;260  } catch (err) {261    return {262      chunksFound: 0,263      totalExpected: null,264      envelopes,265      errors: [266        `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,267      ],268    };269  }270271  const pageCount = pdf.numPages;272  const renderScale = await detectRenderScale(pdf);273274  for (let pageNum = 1; pageNum <= pageCount; pageNum++) {275    onProgress?.(pageNum, pageCount);276277    try {278      const page = await pdf.getPage(pageNum);279      const canvas = await renderPageToCanvas(page, renderScale);280      const ctx = canvas.getContext("2d");281      if (!ctx) {282        errors.push(`Page ${pageNum}: no canvas context`);283        continue;284      }285286      const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices, renderScale);287      for (const env of pageEnvelopes) envelopes.push(env);288289      if (totalExpected === null && pageEnvelopes.length > 0) {290        totalExpected = pageEnvelopes[0]!.t;291      }292    } catch (err) {293      errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`);294    }295  }296297  const metricsEnabled =298    typeof window !== "undefined" &&299    (import.meta.env.MODE === "test" ||300      new URLSearchParams(window.location.search).has("codexMetrics"));301  if (metricsEnabled) {302    const stripeTotals = new Map<number, number>();303    const stripeDecoded = new Map<number, number>();304    for (const env of envelopes) {305      const stripe = env.v === 3 ? (env.s ?? 0) : 0;306      stripeTotals.set(stripe, env.t);307      stripeDecoded.set(stripe, (stripeDecoded.get(stripe) ?? 0) + 1);308    }309    const totalPrinted = Array.from(stripeTotals.values()).reduce((sum, total) => sum + total, 0);310    const stripeIndices = Array.from(stripeTotals.keys());311    const maxStripe = stripeIndices.length > 0 ? Math.max(...stripeIndices) : -1;312    (window as Window & {313      __codexImportMetrics?: {314        decodeRate: number;315        shortStripeCount: number;316        perStripeDecoded: number[];317      };318    }).__codexImportMetrics = {319      decodeRate: totalPrinted > 0 ? envelopes.length / totalPrinted : 0,320      shortStripeCount: Array.from(stripeTotals.entries()).filter(321        ([stripe, total]) => (stripeDecoded.get(stripe) ?? 0) < total,322      ).length,323      perStripeDecoded:324        maxStripe >= 0325          ? Array.from({ length: maxStripe + 1 }, (_, stripe) => stripeDecoded.get(stripe) ?? 0)326          : [],327    };328  }329330  return {331    chunksFound: envelopes.length,332    totalExpected,333    envelopes,334    errors,335  };336}337338/**339 * Crop each of the 36 (6×6) printed Aztec codes out of the rendered page and340 * try to decode it. Cells that contain no code — the trailing empty cells on341 * the last page — just throw NotFoundException and are skipped.342 *343 * Two non-obvious things this function gets right that earlier revisions did344 * not:345 *346 *   1. Cropping follows the *exact* layout drawn by `pdf.ts` (margins,347 *      header band, code size, column/row gaps) instead of a naive348 *      `canvas / 6` slice. The naive slicer clipped every Aztec on its349 *      right edge — ZXing's binarizer can't find a finder pattern in a350 *      chopped symbol. Geometry constants are shared with the generator351 *      via `pdf-layout.ts` so the two can't drift again.352 *353 *   2. We decode the cell pixels directly through `AztecCodeReader` +354 *      `RGBLuminanceSource` rather than handing the canvas to355 *      `BrowserAztecCodeReader.decode()`. ZXing's BrowserCodeReader only356 *      knows how to read `HTMLVideoElement` and `HTMLImageElement` — when357 *      you pass it a canvas, its internal `createCaptureCanvas` falls358 *      through to no branch and creates a zero-sized capture canvas, so359 *      every decode returns `NotFoundException`. The DOM-free path here is360 *      the same one the generator uses for round-trip verification.361 *362 * Around each code we also include a small quiet-zone margin (≈12% of the363 * code size). The PDF prints codes against a cream background with no364 * required quiet zone; ZXing's locator is happier with a white one.365 */366async function scanCanvasGrid(367  canvas: HTMLCanvasElement,368  seenIndices: Set<string>,369  renderScale: number,370): Promise<ChunkEnvelope[]> {371  const found: ChunkEnvelope[] = [];372373  // One scratch canvas reused across all cells — cheaper than allocating374  // 36 canvases per page when a Codex spans dozens of pages.375  const cellCanvas = document.createElement("canvas");376  const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true });377  if (!cellCtx) return found;378379  for (let row = 0; row < CODES_PER_COL; row++) {380    for (let col = 0; col < CODES_PER_ROW; col++) {381      const { sx, sy, sw, sh } = cellRectFor(canvas.width, canvas.height, row, col);382      if (sw <= 0 || sh <= 0) continue;383384      cellCanvas.width = sw;385      cellCanvas.height = sh;386      // Paint the quiet-zone background white before drawing — the source387      // PDF's cream is light but not pure white, and a clean white border388      // gives the binarizer the cleanest finder-pattern contrast.389      cellCtx.fillStyle = "white";390      cellCtx.fillRect(0, 0, sw, sh);391      cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);392393      const text = decodeAztecCellRobust(394        cellCanvas,395        cellCtx,396        { x: 0, y: 0, w: sw, h: sh },397        renderScale,398      );399      if (!text) continue;400401      const envelope = parseChunkEnvelope(text);402      if (!envelope) continue;403      const stripeKey = `${envelope.s ?? 0}:${envelope.i}`;404      if (seenIndices.has(stripeKey)) continue;405      if (!(await verifyChunkIntegrity(envelope))) continue;406407      seenIndices.add(stripeKey);408      found.push(envelope);409    }410  }411412  return found;413}414415function decodeAztecCellRobust(416  canvas: HTMLCanvasElement,417  ctx: CanvasRenderingContext2D,418  cellRect: { x: number; y: number; w: number; h: number },419  renderScale: number,420): string | null {421  const strategies = getCellDecodeStrategies(renderScale);422  for (const strategy of strategies) {423    const result = decodeAztecCellWithStrategy(canvas, ctx, cellRect, strategy);424    if (result !== null) {425      return result;426    }427  }428  return null;429}430431function decodeAztecCellWithStrategy(432  canvas: HTMLCanvasElement,433  ctx: CanvasRenderingContext2D,434  cellRect: { x: number; y: number; w: number; h: number },435  strategy: CellDecodeStrategy,436): string | null {437  const { x, y, w, h } = applyCropFactor(cellRect, strategy.cropFactor, canvas.width, canvas.height);438  if (w <= 0 || h <= 0) return null;439440  const imageData = ctx.getImageData(x, y, w, h);441  const processedData = strategy.normalize442    ? normalizeToBlackWhite(imageData)443    : imageData;444445  const argb = imageDataToArgb(processedData);446  const { buffer, width, height } =447    strategy.upscale > 1448      ? upscaleArgb(argb, processedData.width, processedData.height, strategy.upscale)449      : { buffer: argb, width: processedData.width, height: processedData.height };450451  return decodeAztecFromArgb(buffer, width, height);452}453454function applyCropFactor(455  rect: { x: number; y: number; w: number; h: number },456  factor: number,457  canvasW: number,458  canvasH: number,459): { x: number; y: number; w: number; h: number } {460  const cx = rect.x + rect.w / 2;461  const cy = rect.y + rect.h / 2;462  const nw = Math.max(1, Math.round(rect.w * factor));463  const nh = Math.max(1, Math.round(rect.h * factor));464  const x = Math.max(0, Math.min(canvasW - 1, Math.round(cx - nw / 2)));465  const y = Math.max(0, Math.min(canvasH - 1, Math.round(cy - nh / 2)));466  const w = Math.max(1, Math.min(canvasW - x, nw));467  const h = Math.max(1, Math.min(canvasH - y, nh));468  return { x, y, w, h };469}470471function normalizeToBlackWhite(imageData: ImageData): ImageData {472  const data = new Uint8ClampedArray(imageData.data);473  for (let i = 0; i < data.length; i += 4) {474    const lum = 0.299 * data[i]! + 0.587 * data[i + 1]! + 0.114 * data[i + 2]!;475    const v = lum > 128 ? 255 : 0;476    data[i] = v;477    data[i + 1] = v;478    data[i + 2] = v;479    data[i + 3] = 255;480  }481  return new ImageData(data, imageData.width, imageData.height);482}483484function upscaleArgb(485  argb: Int32Array,486  width: number,487  height: number,488  scale: number,489): { buffer: Int32Array; width: number; height: number } {490  const targetW = Math.max(1, Math.round(width * scale));491  const targetH = Math.max(1, Math.round(height * scale));492  const out = new Int32Array(targetW * targetH);493  for (let y = 0; y < targetH; y++) {494    const srcY = Math.min(height - 1, Math.floor(y / scale));495    for (let x = 0; x < targetW; x++) {496      const srcX = Math.min(width - 1, Math.floor(x / scale));497      out[y * targetW + x] = argb[srcY * width + srcX]!;498    }499  }500  return { buffer: out, width: targetW, height: targetH };501}502503/**504 * Compute the canvas-pixel sub-rectangle for the printed Aztec at (row, col)505 * on a page that was rendered at uniform scale into the given canvas506 * dimensions.507 *508 * Exported (rather than inlined into `scanCanvasGrid`) so the geometry can be509 * verified directly in tests — `scanCanvasGrid` itself needs an510 * `HTMLCanvasElement` and `getImageData()` which vitest's node environment511 * lacks, but this pure function does not.512 */513export function cellRectFor(514  canvasW: number,515  canvasH: number,516  row: number,517  col: number,518): { sx: number; sy: number; sw: number; sh: number } {519  // Canvas pixels per PDF point. Independent x/y scales keep us correct if520  // the renderer ever applies non-uniform scaling.521  const scaleX = canvasW / PAGE_WIDTH;522  const scaleY = canvasH / PAGE_HEIGHT;523524  // Top edge of row 0 in PDF coords (bottom-origin) is just below the header.525  // Convert to canvas coords (top-origin): canvas_y = (PAGE_HEIGHT - pdf_y) * scaleY.526  const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;527  const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;528529  const codeWpx = CODE_SIZE * scaleX;530  const codeHpx = CODE_SIZE * scaleY;531  const quietX = Math.round(codeWpx * 0.12);532  const quietY = Math.round(codeHpx * 0.12);533534  const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;535  const codeY = gridTopCanvas + row * ROW_STEP * scaleY;536537  const sx = Math.max(0, Math.floor(codeX - quietX));538  const sy = Math.max(0, Math.floor(codeY - quietY));539  const sw = Math.min(canvasW - sx, Math.ceil(codeWpx + 2 * quietX));540  const sh = Math.min(canvasH - sy, Math.ceil(codeHpx + 2 * quietY));541  return { sx, sy, sw, sh };542}543544/**545 * Convert an RGBA `ImageData.data` buffer into the packed ARGB `Int32Array`546 * that `RGBLuminanceSource` expects. Exposed for tests that synthesize a cell547 * buffer directly rather than going through a canvas.548 */549export function imageDataToArgb(imageData: ImageData): Int32Array {550  const raw = imageData.data;551  const out = new Int32Array(imageData.width * imageData.height);552  for (let i = 0, j = 0; i < raw.length; i += 4, j++) {553    out[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;554  }555  return out;556}557558/**559 * Decode a single Aztec code from a packed ARGB pixel buffer. Returns the560 * decoded text on success, or `null` if no code was found.561 *562 * We try `HybridBinarizer` first (good for rendered images with subtle563 * shading from anti-aliasing) and fall back to `GlobalHistogramBinarizer`564 * (better when the image is dominated by clean black/white modules). A fresh565 * `AztecCodeReader` is allocated per attempt — the reader caches internal566 * state that must be reset between bitmaps.567 *568 * This is the exact decode path the importer used to skip by handing canvas569 * elements to `BrowserAztecCodeReader.decode()`, which silently fails on570 * canvases — its `createCaptureCanvas` only recognizes video and image571 * elements and produces a zero-sized capture for anything else. Keeping this572 * function exported lets the test suite verify the path works on synthesized573 * Aztec pixels without needing a real DOM canvas.574 */575export function decodeAztecFromArgb(576  argb: Int32Array,577  width: number,578  height: number,579): string | null {580  const source = new RGBLuminanceSource(argb, width, height);581582  try {583    const bitmap = new BinaryBitmap(new HybridBinarizer(source));584    return new AztecCodeReader().decode(bitmap).getText();585  } catch (err) {586    if (!(err instanceof NotFoundException)) return null;587  }588589  try {590    const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));591    return new AztecCodeReader().decode(bitmap).getText();592  } catch {593    return null;594  }595}596597/**598 * Parse a raw decoded string as a Peach ChunkEnvelope. Returns null for any599 * non-Peach payload — the same defensive contract as `restore.ts/parseRawScan`600 * but inlined here so this module stays decoupled from the camera scan path.601 *602 * Accepts v2 (legacy single-stripe) and v3 (current multi-stripe) envelopes.603 * The generator has emitted v3 envelopes since the multi-stripe rollout —604 * earlier revisions of this function only accepted v2, which silently605 * rejected every envelope produced by current builds.606 */607export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {608  let parsed: unknown;609  try {610    parsed = JSON.parse(raw);611  } catch {612    return null;613  }614  if (typeof parsed !== "object" || parsed === null) return null;615  const obj = parsed as Record<string, unknown>;616617  if (obj.v !== 2 && obj.v !== 3) return null;618619  const baseShapeOk =620    typeof obj.c === "string" &&621    typeof obj.i === "number" &&622    typeof obj.t === "number" &&623    typeof obj.p === "boolean" &&624    typeof obj.d === "string" &&625    typeof obj.h === "string";626  if (!baseShapeOk) return null;627628  // v3 envelopes must also carry stripe metadata.629  if (obj.v === 3 && (typeof obj.s !== "number" || typeof obj.S !== "number")) {630    return null;631  }632633  return obj as unknown as ChunkEnvelope;634}