/** * pdf-import.ts — restore from a Codex PDF instead of a camera scan. * * The PDF is rendered page-by-page to a canvas (via pdf.js) and each page * canvas is divided into the same 6×6 grid the generator prints into. Each * cell is decoded individually via `RGBLuminanceSource` + `AztecCodeReader`, * which is far more reliable than a full-page scan because adjacent Aztec * codes don't compete for the binarizer's attention. * * The top-level `importCodexPDF` entry point needs a DOM (canvas + pdf.js). * The pure helpers below — `cellRectFor`, `imageDataToArgb`, * `decodeAztecFromArgb`, `parseChunkEnvelope` — are deliberately separated * out so the geometry math and the ZXing decode path can be exercised in * vitest's node environment. That coverage exists because earlier revisions * shipped two latent bugs (an even-grid slice that clipped every Aztec, and a * `BrowserAztecCodeReader.decode(canvas)` call that ZXing silently no-ops on) * which no unit test could have caught while everything DOM-adjacent was off * limits. * * Why PDF import matters: a printed Codex requires a phone camera and good * light. The PDF is a pristine vector source — the same Aztec codes without * the print → photo loop. When a user has the PDF on a USB drive or in * cloud storage, this path is faster and more reliable than camera scanning. */ import { AztecCodeReader, BinaryBitmap, GlobalHistogramBinarizer, HybridBinarizer, NotFoundException, RGBLuminanceSource, } from "@zxing/library"; import { CODES_PER_COL, CODES_PER_PAGE, CODES_PER_ROW, type ChunkEnvelope, } from "../../shared/codex-types"; import { verifyChunkIntegrity } from "./helpers"; import { IMPORT_LIMITS } from "../../shared/import-limits"; import { CODE_SIZE, COL_GAP, HEADER_GAP, HEADER_HEIGHT, MARGIN, PAGE_HEIGHT, PAGE_WIDTH, ROW_STEP, } from "./pdf-layout"; /** * Lazily-loaded pdf.js module. Top-level `import * as pdfjsLib` would fail at * test time because pdfjs-dist references DOMMatrix at module init, which * vitest's node environment lacks. We only need pdf.js inside importCodexPDF; * the parser surface (parseChunkEnvelope) can stay importable from tests. */ type PdfJsModule = typeof import("pdfjs-dist"); type PdfPage = any; let pdfJsPromise: Promise | null = null; async function getPdfJs(): Promise { if (!pdfJsPromise) { pdfJsPromise = import("pdfjs-dist").then((mod) => { if ( typeof mod.GlobalWorkerOptions.workerSrc !== "string" || mod.GlobalWorkerOptions.workerSrc.length === 0 ) { if (typeof chrome !== "undefined" && chrome.runtime?.getURL) { mod.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL( "pdf.worker.min.mjs", ); } else { mod.GlobalWorkerOptions.workerSrc = "./pdf.worker.min.mjs"; } } return mod; }); } return pdfJsPromise; } export interface PDFImportResult { /** Number of envelopes successfully decoded across all pages. */ chunksFound: number; /** Total chunk count printed on the Codex: sum of every stripe's `t`. */ totalExpected: number | null; /** Decoded envelopes. Caller feeds these into ChunkBuffer. */ envelopes: ChunkEnvelope[]; /** Page-level error notes — empty when every page rendered cleanly. */ errors: string[]; } export type PDFImportProgress = (page: number, totalPages: number) => void /** * Render scales that produce a 1:1 canvas-to-source-PNG mapping for known * Codex generations, ordered from newest to oldest. ZXing's AztecDetector * requires near-exact 1:1 rendering — even a 1% scale error causes * ReedSolomonException (downscale) or FormatException (upscale). The formula * is: scale = (modules × 4 + 16) / CODE_SIZE_pt where modules is the Aztec * matrix size and CODE_SIZE_pt = 80. * * Current generation (CHUNK_SIZE=350): ZXing → 75-module codes → 316px PNG * → scale = 3.95 * Prior generation (CHUNK_SIZE=800, ZXing path): 101-module codes → 420px PNG * → scale = 5.25 * Prior generation (CHUNK_SIZE=800, bwip-js path): 117-module codes → 484px PNG * → scale = 6.05 */ const CANDIDATE_SCALES = [3.95, 5.25, 6.05] as const; // PCH-01-014: bound attacker-controlled page count and page dimensions. A real // Codex needs ~200 pages; 250 is generous headroom. const MAX_PDF_PAGES = 250; // Largest render scale we ever probe — derived from CANDIDATE_SCALES so the // dimension clamp can never drift from the scale set. const MAX_RENDER_SCALE = Math.max(...CANDIDATE_SCALES); // A4 in points is 595×842. At MAX_RENDER_SCALE the long axis is ~5052 px; ×1.5 // slack tolerates Letter / landscape / slightly larger legitimate layouts while // rejecting the 14400-pt MediaBox pathological case (~tens of GB RGBA). const MAX_RENDERED_DIMENSION = Math.ceil(842 * MAX_RENDER_SCALE * 1.5); interface CellDecodeStrategy { cropFactor: number; normalize: boolean; upscale: number; } const SMALL_SCALE_STRATEGIES: CellDecodeStrategy[] = [ { cropFactor: 1.0, normalize: false, upscale: 1.0 }, { cropFactor: 0.88, normalize: false, upscale: 1.0 }, { cropFactor: 1.12, normalize: false, upscale: 1.0 }, { cropFactor: 1.0, normalize: true, upscale: 1.0 }, { cropFactor: 0.88, normalize: true, upscale: 1.0 }, { cropFactor: 1.0, normalize: false, upscale: 2.0 }, { cropFactor: 1.0, normalize: true, upscale: 2.0 }, ]; const LARGE_SCALE_STRATEGIES: CellDecodeStrategy[] = [ { cropFactor: 1.0, normalize: false, upscale: 1.0 }, { cropFactor: 1.12, normalize: false, upscale: 1.0 }, { cropFactor: 0.88, normalize: false, upscale: 1.0 }, { cropFactor: 1.0, normalize: true, upscale: 1.0 }, { cropFactor: 1.12, normalize: true, upscale: 1.0 }, { cropFactor: 1.0, normalize: false, upscale: 2.0 }, { cropFactor: 1.0, normalize: true, upscale: 2.0 }, ]; function getCellDecodeStrategies(renderScale: number): CellDecodeStrategy[] { return renderScale < 4.5 ? SMALL_SCALE_STRATEGIES : LARGE_SCALE_STRATEGIES; } /** * Probe up to three pages of `pdf` at every candidate scale and return the * scale that decodes the highest fraction of codes. "First scale that decodes * anything" is not sufficient — scale 5.25 can occasionally decode 1–2 codes * from a 6.05 (117-module) Codex, producing a false-positive that makes the * full import use the wrong scale. Taking the maximum across multiple pages * avoids this: the correct scale wins by a large margin. * * Early exit: once a scale achieves a perfect probe score we can stop — no * later candidate can beat 100% decode rate on the sampled pages. */ async function detectRenderScale( pdf: Awaited["promise"]>, ): Promise { let bestScale: number = CANDIDATE_SCALES[0]; let bestRate = -1; const probePages = Math.min(3, pdf.numPages); for (const scale of CANDIDATE_SCALES) { try { let decoded = 0; let total = 0; for (let pageNum = 1; pageNum <= probePages; pageNum++) { const page = await pdf.getPage(pageNum); const result = await probePageDecodeRate(page, scale); decoded += result.decoded; total += result.total; } const rate = total > 0 ? decoded / total : 0; if (rate > bestRate) { bestRate = rate; bestScale = scale; } if (rate >= 0.99) { return scale; } } catch { // continue to next candidate } } return bestScale; } async function probePageDecodeRate( page: PdfPage, scale: number, ): Promise<{ decoded: number; total: number }> { const canvas = await renderPageToCanvas(page, scale); const ctx = canvas.getContext("2d"); if (!ctx) return { decoded: 0, total: CODES_PER_PAGE }; let decoded = 0; for (let row = 0; row < CODES_PER_COL; row++) { for (let col = 0; col < CODES_PER_ROW; col++) { const rect = cellRectFor(canvas.width, canvas.height, row, col); if (!rect.sw || !rect.sh) continue; const text = decodeAztecCellRobust( canvas, ctx, { x: rect.sx, y: rect.sy, w: rect.sw, h: rect.sh }, scale, ); if (text !== null) decoded++; } } return { decoded, total: CODES_PER_PAGE }; } async function renderPageToCanvas( page: PdfPage, scale: number, ): Promise { const viewport = page.getViewport({ scale }); // PCH-01-014: refuse pages whose scaled dimensions blow past a generous // A4-at-max-scale budget. A tiny PDF can declare a 14400×14400 pt MediaBox, // which at this scale would allocate a multi-GB RGBA canvas. The throw lands // in the page loop's existing catch, which records and skips the page. const w = Math.ceil(viewport.width); const h = Math.ceil(viewport.height); if (w > MAX_RENDERED_DIMENSION || h > MAX_RENDERED_DIMENSION) { throw new Error( `page dimensions ${w}×${h}px exceed the ${MAX_RENDERED_DIMENSION}px render limit`, ); } const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; const ctx = canvas.getContext("2d"); if (!ctx) { return canvas; } ctx.fillStyle = "white"; ctx.fillRect(0, 0, canvas.width, canvas.height); await page.render({ canvas, canvasContext: ctx, viewport }).promise; return canvas; } /** * Import a Peach Codex PDF and decode every Aztec code it contains. * * Automatically detects the render scale by probing up to three pages at * each candidate scale in CANDIDATE_SCALES — the scale with the highest * decode rate is used for all pages. This makes the importer * backward-compatible with Codexes generated at any prior CHUNK_SIZE, and * forward-compatible with future changes, without requiring any version * metadata in the PDF. */ export async function importCodexPDF( file: File, onProgress?: PDFImportProgress, ): Promise { // MV-5: refuse oversized files before reading them into memory — and before // even loading pdf.js. A real Codex tops out well under this (200 pages ≈ // 4 MiB of Aztec PNGs). if (file.size > IMPORT_LIMITS.MAX_PDF_BYTES) { return { chunksFound: 0, totalExpected: null, envelopes: [], errors: [ `PDF is ${(file.size / (1024 * 1024)).toFixed(1)} MiB — Codex PDFs are limited to ${IMPORT_LIMITS.MAX_PDF_BYTES / (1024 * 1024)} MiB.`, ], }; } const pdfjsLib = await getPdfJs(); const envelopes: ChunkEnvelope[] = []; const errors: string[] = []; // Key is `${stripe}:${chunkIndex}` so multi-stripe Codexes don't treat // stripe 1's chunk 0 as a duplicate of stripe 0's chunk 0. const seenIndices = new Set(); // PCH-01-026 #4: `t` is the per-stripe chunk total, not the Codex total. Track // each stripe's declared total once and sum after the scan so a multi-stripe // Codex reports the true global count, not just stripe 0's. const stripeTotalsForExpected = new Map(); let pdf: Awaited["promise"]>; try { const arrayBuffer = await file.arrayBuffer(); // MV-9: this parses arbitrary user-selected PDFs. The popup CSP already // blocks eval, but disable pdf.js's eval-dependent font paths in depth // (CVE-2024-4367 class), and turn off features Aztec extraction never // needs. pdf = await pdfjsLib.getDocument({ data: arrayBuffer, // isEvalSupported is a valid pdf.js runtime option (defends the // CVE-2024-4367 eval-via-font path) but is absent from pdfjs-dist's // DocumentInitParameters typings — cast to add it. isEvalSupported: false, disableFontFace: true, // fonts not needed for Aztec bitmap extraction disableRange: true, // whole buffer already in memory disableStream: true, // ditto } as Parameters[0]).promise; } catch (err) { return { chunksFound: 0, totalExpected: null, envelopes, errors: [ `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`, ], }; } // PCH-01-014: cap pages so a PDF declaring thousands of pages cannot wedge // the popup in a multi-minute render loop. const declaredPages = pdf.numPages; const pageCount = Math.min(declaredPages, MAX_PDF_PAGES); if (declaredPages > MAX_PDF_PAGES) { errors.push( `PDF has ${declaredPages} pages; only the first ${MAX_PDF_PAGES} were scanned.`, ); } const renderScale = await detectRenderScale(pdf); for (let pageNum = 1; pageNum <= pageCount; pageNum++) { onProgress?.(pageNum, pageCount); try { const page = await pdf.getPage(pageNum); const canvas = await renderPageToCanvas(page, renderScale); const ctx = canvas.getContext("2d"); if (!ctx) { errors.push(`Page ${pageNum}: no canvas context`); continue; } const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices, renderScale); for (const env of pageEnvelopes) envelopes.push(env); for (const env of pageEnvelopes) { const stripe = env.v === 3 ? (env.s ?? 0) : 0; // Each stripe's `t` is constant across its envelopes; last write wins. stripeTotalsForExpected.set(stripe, env.t); } } catch (err) { errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`); } } const metricsEnabled = typeof window !== "undefined" && (import.meta.env.MODE === "test" || new URLSearchParams(window.location.search).has("codexMetrics")); if (metricsEnabled) { const stripeTotals = new Map(); const stripeDecoded = new Map(); for (const env of envelopes) { const stripe = env.v === 3 ? (env.s ?? 0) : 0; stripeTotals.set(stripe, env.t); stripeDecoded.set(stripe, (stripeDecoded.get(stripe) ?? 0) + 1); } const totalPrinted = Array.from(stripeTotals.values()).reduce((sum, total) => sum + total, 0); const stripeIndices = Array.from(stripeTotals.keys()); const maxStripe = stripeIndices.length > 0 ? Math.max(...stripeIndices) : -1; (window as Window & { __codexImportMetrics?: { decodeRate: number; shortStripeCount: number; perStripeDecoded: number[]; }; }).__codexImportMetrics = { decodeRate: totalPrinted > 0 ? envelopes.length / totalPrinted : 0, shortStripeCount: Array.from(stripeTotals.entries()).filter( ([stripe, total]) => (stripeDecoded.get(stripe) ?? 0) < total, ).length, perStripeDecoded: maxStripe >= 0 ? Array.from({ length: maxStripe + 1 }, (_, stripe) => stripeDecoded.get(stripe) ?? 0) : [], }; } // PCH-01-026 #4: the Codex total is the sum of every stripe's `t`. const totalExpected = stripeTotalsForExpected.size > 0 ? Array.from(stripeTotalsForExpected.values()).reduce((sum, t) => sum + t, 0) : null; return { chunksFound: envelopes.length, totalExpected, envelopes, errors, }; } /** * Crop each of the 36 (6×6) printed Aztec codes out of the rendered page and * try to decode it. Cells that contain no code — the trailing empty cells on * the last page — just throw NotFoundException and are skipped. * * Two non-obvious things this function gets right that earlier revisions did * not: * * 1. Cropping follows the *exact* layout drawn by `pdf.ts` (margins, * header band, code size, column/row gaps) instead of a naive * `canvas / 6` slice. The naive slicer clipped every Aztec on its * right edge — ZXing's binarizer can't find a finder pattern in a * chopped symbol. Geometry constants are shared with the generator * via `pdf-layout.ts` so the two can't drift again. * * 2. We decode the cell pixels directly through `AztecCodeReader` + * `RGBLuminanceSource` rather than handing the canvas to * `BrowserAztecCodeReader.decode()`. ZXing's BrowserCodeReader only * knows how to read `HTMLVideoElement` and `HTMLImageElement` — when * you pass it a canvas, its internal `createCaptureCanvas` falls * through to no branch and creates a zero-sized capture canvas, so * every decode returns `NotFoundException`. The DOM-free path here is * the same one the generator uses for round-trip verification. * * Around each code we also include a small quiet-zone margin (≈12% of the * code size). The PDF prints codes against a cream background with no * required quiet zone; ZXing's locator is happier with a white one. */ async function scanCanvasGrid( canvas: HTMLCanvasElement, seenIndices: Set, renderScale: number, ): Promise { const found: ChunkEnvelope[] = []; // One scratch canvas reused across all cells — cheaper than allocating // 36 canvases per page when a Codex spans dozens of pages. const cellCanvas = document.createElement("canvas"); const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true }); if (!cellCtx) return found; for (let row = 0; row < CODES_PER_COL; row++) { for (let col = 0; col < CODES_PER_ROW; col++) { const { sx, sy, sw, sh } = cellRectFor(canvas.width, canvas.height, row, col); if (sw <= 0 || sh <= 0) continue; cellCanvas.width = sw; cellCanvas.height = sh; // Paint the quiet-zone background white before drawing — the source // PDF's cream is light but not pure white, and a clean white border // gives the binarizer the cleanest finder-pattern contrast. cellCtx.fillStyle = "white"; cellCtx.fillRect(0, 0, sw, sh); cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh); const text = decodeAztecCellRobust( cellCanvas, cellCtx, { x: 0, y: 0, w: sw, h: sh }, renderScale, ); if (!text) continue; const envelope = parseChunkEnvelope(text); if (!envelope) continue; const stripeKey = `${envelope.s ?? 0}:${envelope.i}`; if (seenIndices.has(stripeKey)) continue; if (!(await verifyChunkIntegrity(envelope))) continue; seenIndices.add(stripeKey); found.push(envelope); } } return found; } function decodeAztecCellRobust( canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D, cellRect: { x: number; y: number; w: number; h: number }, renderScale: number, ): string | null { const strategies = getCellDecodeStrategies(renderScale); for (const strategy of strategies) { const result = decodeAztecCellWithStrategy(canvas, ctx, cellRect, strategy); if (result !== null) { return result; } } return null; } function decodeAztecCellWithStrategy( canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D, cellRect: { x: number; y: number; w: number; h: number }, strategy: CellDecodeStrategy, ): string | null { const { x, y, w, h } = applyCropFactor(cellRect, strategy.cropFactor, canvas.width, canvas.height); if (w <= 0 || h <= 0) return null; const imageData = ctx.getImageData(x, y, w, h); const processedData = strategy.normalize ? normalizeToBlackWhite(imageData) : imageData; const argb = imageDataToArgb(processedData); const { buffer, width, height } = strategy.upscale > 1 ? upscaleArgb(argb, processedData.width, processedData.height, strategy.upscale) : { buffer: argb, width: processedData.width, height: processedData.height }; return decodeAztecFromArgb(buffer, width, height); } function applyCropFactor( rect: { x: number; y: number; w: number; h: number }, factor: number, canvasW: number, canvasH: number, ): { x: number; y: number; w: number; h: number } { const cx = rect.x + rect.w / 2; const cy = rect.y + rect.h / 2; const nw = Math.max(1, Math.round(rect.w * factor)); const nh = Math.max(1, Math.round(rect.h * factor)); const x = Math.max(0, Math.min(canvasW - 1, Math.round(cx - nw / 2))); const y = Math.max(0, Math.min(canvasH - 1, Math.round(cy - nh / 2))); const w = Math.max(1, Math.min(canvasW - x, nw)); const h = Math.max(1, Math.min(canvasH - y, nh)); return { x, y, w, h }; } function normalizeToBlackWhite(imageData: ImageData): ImageData { const data = new Uint8ClampedArray(imageData.data); for (let i = 0; i < data.length; i += 4) { const lum = 0.299 * data[i]! + 0.587 * data[i + 1]! + 0.114 * data[i + 2]!; const v = lum > 128 ? 255 : 0; data[i] = v; data[i + 1] = v; data[i + 2] = v; data[i + 3] = 255; } return new ImageData(data, imageData.width, imageData.height); } function upscaleArgb( argb: Int32Array, width: number, height: number, scale: number, ): { buffer: Int32Array; width: number; height: number } { const targetW = Math.max(1, Math.round(width * scale)); const targetH = Math.max(1, Math.round(height * scale)); const out = new Int32Array(targetW * targetH); for (let y = 0; y < targetH; y++) { const srcY = Math.min(height - 1, Math.floor(y / scale)); for (let x = 0; x < targetW; x++) { const srcX = Math.min(width - 1, Math.floor(x / scale)); out[y * targetW + x] = argb[srcY * width + srcX]!; } } return { buffer: out, width: targetW, height: targetH }; } /** * Compute the canvas-pixel sub-rectangle for the printed Aztec at (row, col) * on a page that was rendered at uniform scale into the given canvas * dimensions. * * Exported (rather than inlined into `scanCanvasGrid`) so the geometry can be * verified directly in tests — `scanCanvasGrid` itself needs an * `HTMLCanvasElement` and `getImageData()` which vitest's node environment * lacks, but this pure function does not. */ export function cellRectFor( canvasW: number, canvasH: number, row: number, col: number, ): { sx: number; sy: number; sw: number; sh: number } { // Canvas pixels per PDF point. Independent x/y scales keep us correct if // the renderer ever applies non-uniform scaling. const scaleX = canvasW / PAGE_WIDTH; const scaleY = canvasH / PAGE_HEIGHT; // Top edge of row 0 in PDF coords (bottom-origin) is just below the header. // Convert to canvas coords (top-origin): canvas_y = (PAGE_HEIGHT - pdf_y) * scaleY. const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP; const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY; const codeWpx = CODE_SIZE * scaleX; const codeHpx = CODE_SIZE * scaleY; const quietX = Math.round(codeWpx * 0.12); const quietY = Math.round(codeHpx * 0.12); const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX; const codeY = gridTopCanvas + row * ROW_STEP * scaleY; const sx = Math.max(0, Math.floor(codeX - quietX)); const sy = Math.max(0, Math.floor(codeY - quietY)); const sw = Math.min(canvasW - sx, Math.ceil(codeWpx + 2 * quietX)); const sh = Math.min(canvasH - sy, Math.ceil(codeHpx + 2 * quietY)); return { sx, sy, sw, sh }; } /** * Convert an RGBA `ImageData.data` buffer into the packed ARGB `Int32Array` * that `RGBLuminanceSource` expects. Exposed for tests that synthesize a cell * buffer directly rather than going through a canvas. */ export function imageDataToArgb(imageData: ImageData): Int32Array { const raw = imageData.data; const out = new Int32Array(imageData.width * imageData.height); for (let i = 0, j = 0; i < raw.length; i += 4, j++) { out[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!; } return out; } /** * Decode a single Aztec code from a packed ARGB pixel buffer. Returns the * decoded text on success, or `null` if no code was found. * * We try `HybridBinarizer` first (good for rendered images with subtle * shading from anti-aliasing) and fall back to `GlobalHistogramBinarizer` * (better when the image is dominated by clean black/white modules). A fresh * `AztecCodeReader` is allocated per attempt — the reader caches internal * state that must be reset between bitmaps. * * This is the exact decode path the importer used to skip by handing canvas * elements to `BrowserAztecCodeReader.decode()`, which silently fails on * canvases — its `createCaptureCanvas` only recognizes video and image * elements and produces a zero-sized capture for anything else. Keeping this * function exported lets the test suite verify the path works on synthesized * Aztec pixels without needing a real DOM canvas. */ export function decodeAztecFromArgb( argb: Int32Array, width: number, height: number, ): string | null { const source = new RGBLuminanceSource(argb, width, height); try { const bitmap = new BinaryBitmap(new HybridBinarizer(source)); return new AztecCodeReader().decode(bitmap).getText(); } catch (err) { if (!(err instanceof NotFoundException)) return null; } try { const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source)); return new AztecCodeReader().decode(bitmap).getText(); } catch { return null; } } /** * Parse a raw decoded string as a Peach ChunkEnvelope. Returns null for any * non-Peach payload — the same defensive contract as `restore.ts/parseRawScan` * but inlined here so this module stays decoupled from the camera scan path. * * Accepts current v3 envelopes. Older paper formats are rejected. */ export function parseChunkEnvelope(raw: string): ChunkEnvelope | null { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return null; } if (typeof parsed !== "object" || parsed === null) return null; const obj = parsed as Record; if (obj.v !== 3) return null; const baseShapeOk = typeof obj.c === "string" && Number.isInteger(obj.i) && // PCH-01-012: reject NaN/Infinity/fractional indices Number.isInteger(obj.t) && typeof obj.p === "boolean" && typeof obj.d === "string" && typeof obj.h === "string"; if (!baseShapeOk) return null; if (!Number.isInteger(obj.s) || !Number.isInteger(obj.S)) { return null; } return obj as unknown as ChunkEnvelope; }