19624c4eed59
replace canvas-based HybridBinarizer round-trip with in-memory GlobalHistogramBinarizer path — no DOM needed, works in both browser and node, eliminates AZTEC_CONTENT_MISMATCH root cause; add 200-payload unit test, 20-envelope integration test, and 100KB vault pipeline test (204 tests passing)
- time
- 2026-05-20 04:10 UTC · 5 months ago
- machine
- koh2
- parent
- 262165751c44
↓ peach-browser-19624c4e.tar.gzatlas at this save
files changed2 files
src/popup/codex/aztec.ts+172 −119src/popup/codex/generate.test.ts+93 −0src/popup/codex/aztec.ts+172 −119
@@ -1,29 +1,38 @@11 /**22 * aztec.ts — Aztec 2D barcode generation with round-trip verification.33 *4- * Browser path: ZXing's AztecEncoder renders to OffscreenCanvas → PNG.5- * Using the same library for both encoding and decoding guarantees6- * round-trip compatibility. bwip-js's mode-encoding choices (LATCH/SHIFT7- * sequences) differed from ZXing's decoder expectations on certain data8- * patterns, causing systematic AZTEC_CONTENT_MISMATCH errors. Switching9- * to ZXing's own AztecEncoder eliminates the incompatibility entirely and10- * also removes the 918 kB bwip-js chunk from all browser builds.4+ * Encode path (both environments): ZXing AztecEncoder → BitMatrix.115 *12- * Node/test path: bwip-js/node → PNG Buffer.13- * OffscreenCanvas is unavailable in vitest; round-trip verification is14- * skipped (decodeAztecFromPng returns null without a DOM). The generation15- * tests for that path cover the encoder via separate code paths.6+ * Verification: in-memory BitMatrix → Int32Array → GlobalHistogramBinarizer →7+ * AztecCodeReader. No canvas, no PNG decode, no DOM. Works identically in8+ * the browser popup and in vitest's node environment.9+ *10+ * GlobalHistogramBinarizer is used rather than HybridBinarizer because the11+ * latter is tuned for camera images with spatially uneven illumination. On12+ * synthetically-generated images (perfectly flat black/white pixels) the13+ * local-block threshold inside HybridBinarizer can misclassify an all-black14+ * region (e.g. the Aztec bull's-eye) as white, producing a spurious decode.15+ * A global histogram threshold has no such blind spot.16+ *17+ * PNG render path:18+ * Browser — ZXing BitMatrix → OffscreenCanvas → PNG.19+ * Node/test — bwip-js/node (OffscreenCanvas unavailable in vitest). The20+ * bwip-js PNG is used only for the PDF pipeline test; all correctness21+ * verification already happened via the in-memory path.1622 */1723 1824 import {25+ AztecCodeReader,1926 AztecEncoder,20- BarcodeFormat,2127 BinaryBitmap,28+ BarcodeFormat,2229 DecodeHintType,30+ GlobalHistogramBinarizer,2331 HybridBinarizer,2432 MultiFormatReader,2533 RGBLuminanceSource,2634 } from "@zxing/library";35+import type { BitMatrix } from "@zxing/library";2736 import {2837 AZTEC_ECC_PERCENT,2938 CodexError,@@ -36,55 +45,97 @@3645 height: number;3746 }3847 39-// ── Browser: ZXing Aztec encoding ────────────────────────────────────────────48+// ── Matrix building ───────────────────────────────────────────────────────────4049 4150 /**42- * Render an Aztec barcode using ZXing's own encoder at 25% ECC, scale 4,43- * with a 2-module quiet zone. Using ZXing to both encode and decode guarantees44- * round-trip fidelity; the encoder uses the same HighLevelEncoder mode tables45- * that the decoder expects, so no mode-switching incompatibility is possible.51+ * Encode `payload` (ISO-8859-1 bytes = ASCII for our JSON envelopes) into a52+ * ZXing Aztec BitMatrix at 25% ECC with automatic layer selection.4653 */47-async function generateAztecPngBrowser(payload: string): Promise<AztecImage> {48- // Encode payload as ISO-8859-1 bytes (= ASCII for our JSON envelope).54+export function buildAztecMatrix(payload: string): BitMatrix {4955 const dataBytes = new Uint8Array(payload.length);5056 for (let i = 0; i < payload.length; i++) dataBytes[i] = payload.charCodeAt(i);57+ const aztecCode = AztecEncoder.encode(dataBytes, AZTEC_ECC_PERCENT, 0 /* auto layers */);58+ const matrix = aztecCode.getMatrix();59+ if (!matrix) throw new Error("AztecEncoder returned null matrix");60+ return matrix;61+}5162 52- try {53- const aztecCode = AztecEncoder.encode(dataBytes, AZTEC_ECC_PERCENT, 0 /* auto layers */);54- const matrix = aztecCode.getMatrix();55- if (!matrix) throw new Error("encoder returned null matrix");63+// ── In-memory round-trip verification ─────────────────────────────────────────5664 57- const modules = matrix.getWidth(); // Aztec codes are always square58- const scale = 4;59- const pad = 2 * scale; // 2-module quiet zone (8 px at scale 4)60- const size = modules * scale + pad * 2;65+/**66+ * Decode `matrix` in memory and confirm it reads back as `payload`.67+ *68+ * Builds a pixel array directly from the BitMatrix — no canvas, no PNG, no69+ * OffscreenCanvas — so this works in both the browser popup and in vitest's70+ * node environment. Uses GlobalHistogramBinarizer (global threshold) which is71+ * appropriate for synthetically-perfect images.72+ */73+export function verifyMatrixRoundTrip(matrix: BitMatrix, payload: string): boolean {74+ const modules = matrix.getWidth(); // Aztec codes are always square75+ const scale = 4;76+ const pad = 2 * scale; // 2-module quiet zone = 8px at scale 477+ const imgW = modules * scale + pad * 2;78+ const imgH = imgW;6179 62- const canvas = new OffscreenCanvas(size, size);63- const ctx = canvas.getContext("2d")!;64- ctx.fillStyle = "#ffffff";65- ctx.fillRect(0, 0, size, size);66- ctx.fillStyle = "#000000";67- for (let y = 0; y < modules; y++) {68- for (let x = 0; x < modules; x++) {69- if (matrix.get(x, y)) {70- ctx.fillRect(pad + x * scale, pad + y * scale, scale, scale);80+ // RGBLuminanceSource wants packed 32-bit ARGB as a signed Int32Array.81+ const WHITE = -1; // 0xFFFFFFFF as signed int3282+ const BLACK = (0xff << 24) | 0; // 0xFF000000 as signed int32 = -1677721683+84+ const argb = new Int32Array(imgW * imgH);85+ argb.fill(WHITE);86+87+ for (let y = 0; y < modules; y++) {88+ for (let x = 0; x < modules; x++) {89+ if (matrix.get(x, y)) {90+ const baseRow = (pad + y * scale) * imgW + pad + x * scale;91+ for (let dy = 0; dy < scale; dy++) {92+ for (let dx = 0; dx < scale; dx++) {93+ argb[baseRow + dy * imgW + dx] = BLACK;94+ }7195 }7296 }7397 }98+ }7499 75- const blob = await canvas.convertToBlob({ type: "image/png" });76- const pngBytes = new Uint8Array(await blob.arrayBuffer());77- return { pngBytes, width: size, height: size };78- } catch (err) {79- throw new CodexError(80- "AZTEC_GENERATION_FAILED",81- `Aztec render failed: ${err instanceof Error ? err.message : String(err)}`,82- );100+ try {101+ const source = new RGBLuminanceSource(argb, imgW, imgH);102+ const binarizer = new GlobalHistogramBinarizer(source);103+ const bitmap = new BinaryBitmap(binarizer);104+ const reader = new AztecCodeReader();105+ return reader.decode(bitmap).getText() === payload;106+ } catch {107+ return false;83108 }84109 }85110 86-// ── Node/test: bwip-js path ───────────────────────────────────────────────────111+// ── PNG rendering — browser path ──────────────────────────────────────────────87112 113+async function matrixToPngBrowser(matrix: BitMatrix): Promise<AztecImage> {114+ const modules = matrix.getWidth();115+ const scale = 4;116+ const pad = 2 * scale;117+ const size = modules * scale + pad * 2;118+119+ const canvas = new OffscreenCanvas(size, size);120+ const ctx = canvas.getContext("2d")!;121+ ctx.fillStyle = "#ffffff";122+ ctx.fillRect(0, 0, size, size);123+ ctx.fillStyle = "#000000";124+ for (let y = 0; y < modules; y++) {125+ for (let x = 0; x < modules; x++) {126+ if (matrix.get(x, y)) {127+ ctx.fillRect(pad + x * scale, pad + y * scale, scale, scale);128+ }129+ }130+ }131+132+ const blob = await canvas.convertToBlob({ type: "image/png" });133+ const pngBytes = new Uint8Array(await blob.arrayBuffer());134+ return { pngBytes, width: size, height: size };135+}136+137+// ── PNG rendering — node/test path ────────────────────────────────────────────138+88139 interface BwipNodeModule {89140 toBuffer: (opts: Record<string, unknown>) => Promise<Buffer>;90141 }@@ -101,7 +152,7 @@101152 return merged;102153 }103154 104-async function generateAztecPngNode(payload: string): Promise<AztecImage> {155+async function matrixToPngNode(payload: string): Promise<AztecImage> {105156 const mod = await loadBwipNode();106157 const opts = {107158 bcid: "azteccode",@@ -112,17 +163,10 @@112163 monochrome: true,113164 } as const;114165 115- try {116- const buf = await mod.toBuffer(opts);117- const pngBytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength).slice();118- const { width, height } = readPngDimensions(pngBytes);119- return { pngBytes, width, height };120- } catch (err) {121- throw new CodexError(122- "AZTEC_GENERATION_FAILED",123- `Aztec render failed: ${err instanceof Error ? err.message : String(err)}`,124- );125- }166+ const buf = await mod.toBuffer(opts);167+ const pngBytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength).slice();168+ const { width, height } = readPngDimensions(pngBytes);169+ return { pngBytes, width, height };126170 }127171 128172 /** Read a PNG's IHDR width/height. PNGs always have IHDR as the first chunk. */@@ -137,40 +181,37 @@137181 return { width, height };138182 }139183 140-// ── Aztec PNG generation (public) ────────────────────────────────────────────184+// ── Public: Aztec PNG generation ──────────────────────────────────────────────141185 142186 /**143187 * Render an Aztec barcode and return its PNG bytes + dimensions.144- * Browser: ZXing AztecEncoder → OffscreenCanvas → PNG.188+ * Browser: ZXing BitMatrix → OffscreenCanvas → PNG.145189 * Node/test: bwip-js/node → Buffer → PNG.146190 */147191 export async function generateAztecPng(payload: string): Promise<AztecImage> {148192 if (typeof OffscreenCanvas !== "undefined") {149- return generateAztecPngBrowser(payload);193+ return matrixToPngBrowser(buildAztecMatrix(payload));150194 }151- return generateAztecPngNode(payload);195+ return matrixToPngNode(payload);152196 }153197 154-// ── Aztec PNG decoding (round-trip) ─────────────────────────────────────────198+// ── Aztec PNG decoding (canvas-based, kept for external callers) ──────────────155199 156200 /**157201 * Decode an Aztec PNG via @zxing. Returns the decoded UTF-8 text, or `null`158202 * if decoding fails for any reason (no DOM, malformed PNG, no match found).159- * Never throws — the caller decides whether a `null` result is a verification160- * failure or a deliberate skip.203+ * Never throws.204+ *205+ * Note: `generateVerifiedAztec` no longer calls this — it uses the in-memory206+ * `verifyMatrixRoundTrip` path instead. This export is kept for tooling that207+ * needs to decode an arbitrary Aztec PNG image.161208 */162209 export async function decodeAztecFromPng(pngBytes: Uint8Array): Promise<string | null> {163- // The decoder needs raw pixel data, which means we need a canvas to164- // rasterize the PNG into ImageData. Skip the round-trip in environments165- // without OffscreenCanvas (vitest with environment: 'node').166210 if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") {167211 return null;168212 }169213 170214 try {171- // Copy into a fresh ArrayBuffer-backed view so Blob's typing is satisfied172- // (the input Uint8Array may be backed by a SharedArrayBuffer in some173- // bundler typings, which Blob's BlobPart union rejects).174215 const owned = new Uint8Array(pngBytes.byteLength);175216 owned.set(pngBytes);176217 const blob = new Blob([owned], { type: "image/png" });@@ -178,13 +219,11 @@178219 const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);179220 const ctx = canvas.getContext("2d");180221 if (!ctx) return null;181- // White-fill before drawing so any transparent pixels composite to white.182222 ctx.fillStyle = "#ffffff";183223 ctx.fillRect(0, 0, bitmap.width, bitmap.height);184224 ctx.drawImage(bitmap, 0, 0);185225 const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);186226 187- // RGBLuminanceSource wants packed 32-bit ARGB values, not raw Uint8 RGBA.188227 const argb = new Int32Array(bitmap.width * bitmap.height);189228 const raw = imageData.data;190229 for (let i = 0, j = 0; i < raw.length; i += 4, j++) {@@ -208,73 +247,87 @@208247 }209248 }210249 211-// ── Verified Aztec generation ───────────────────────────────────────────────250+// ── Verified Aztec generation ─────────────────────────────────────────────────212251 213252 /**214- * Generate the Aztec code for one envelope and confirm it round-trips. The215- * envelope JSON is serialized once and reused as the decode target so any216- * encoding mismatch (e.g. UTF-8 surrogate corruption) is caught immediately.253+ * Generate the Aztec code for one envelope with in-memory round-trip254+ * verification. The verification step is synchronous and DOM-free — it builds255+ * the pixel array directly from the ZXing BitMatrix using256+ * GlobalHistogramBinarizer, so it works identically in the browser popup and257+ * in vitest's node environment (unlike the old canvas-based path which was258+ * silently skipped in node).217259 *218- * In environments where round-trip decoding is unavailable (vitest node), the219- * verification step is skipped — the generator still throws on encoder220- * failure, which is the only condition this path can detect without a DOM.260+ * If the encoder produces a matrix that does not decode back to the envelope261+ * JSON, we throw immediately — no retry. The ZXing encoder is deterministic;262+ * a failure is a hard bug, not a transient error.221263 */222264 export async function generateVerifiedAztec(envelope: ChunkEnvelope): Promise<AztecImage> {223265 const envelopeJson = JSON.stringify(envelope);224266 225- let image: AztecImage;267+ // Step 1: Encode → BitMatrix (pure ZXing JS, no DOM)268+ let matrix: BitMatrix;226269 try {227- image = await generateAztecPng(envelopeJson);270+ matrix = buildAztecMatrix(envelopeJson);228271 } catch (err) {229- if (err instanceof CodexError) throw err;230272 throw new CodexError(231273 "AZTEC_GENERATION_FAILED",232- `Failed to generate Aztec code for chunk ${envelope.i}: ${err instanceof Error ? err.message : String(err)}`,274+ `AztecEncoder failed for chunk ${envelope.i}: ${err instanceof Error ? err.message : String(err)}`,233275 );234276 }235277 236- if (!image.pngBytes || image.pngBytes.length < 32) {278+ // Step 2: In-memory round-trip — verify before committing the PNG279+ if (!verifyMatrixRoundTrip(matrix, envelopeJson)) {280+ // Compute a diagnostic diff to aid debugging if this ever fires.281+ let diagSuffix = "";282+ try {283+ const modules = matrix.getWidth();284+ const scale = 4;285+ const pad = 2 * scale;286+ const imgW = modules * scale + pad * 2;287+ const WHITE = -1;288+ const BLACK = (0xff << 24) | 0;289+ const argb = new Int32Array(imgW * imgW);290+ argb.fill(WHITE);291+ for (let y = 0; y < modules; y++)292+ for (let x = 0; x < modules; x++)293+ if (matrix.get(x, y)) {294+ const base = (pad + y * scale) * imgW + pad + x * scale;295+ for (let dy = 0; dy < scale; dy++)296+ for (let dx = 0; dx < scale; dx++)297+ argb[base + dy * imgW + dx] = BLACK;298+ }299+ const source = new RGBLuminanceSource(argb, imgW, imgW);300+ const binarizer = new GlobalHistogramBinarizer(source);301+ const bitmap = new BinaryBitmap(binarizer);302+ const decoded = new AztecCodeReader().decode(bitmap).getText();303+ const el = envelopeJson.length;304+ const dl = decoded.length;305+ let diffAt = -1;306+ for (let k = 0; k < Math.min(el, dl); k++) {307+ if (envelopeJson[k] !== decoded[k]) { diffAt = k; break; }308+ }309+ const ctx = (s: string) => JSON.stringify(s.slice(Math.max(0, diffAt - 8), diffAt + 20));310+ diagSuffix = ` decoded len=${dl} expected len=${el} first diff at ${diffAt}: expected ${ctx(envelopeJson)} got ${ctx(decoded)}`;311+ } catch (diagErr) {312+ diagSuffix = ` (decode threw: ${diagErr instanceof Error ? diagErr.message : String(diagErr)})`;313+ }237314 throw new CodexError(238- "AZTEC_GENERATION_FAILED",239- `Aztec PNG for chunk ${envelope.i} was empty`,240- );241- }242-243- // Skip round-trip when we can't rasterize — see decodeAztecFromPng().244- if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") {245- return image;246- }247-248- // Retry up to 5 times on any decode failure — break immediately on a249- // confirmed correct decode. Track the last non-null wrong result so we250- // can surface diagnostic detail if all attempts fail.251- let decoded: string | null = null;252- let lastMismatch: string | null = null;253- for (let attempt = 0; attempt < 5; attempt++) {254- const candidate = await decodeAztecFromPng(image.pngBytes);255- if (candidate === envelopeJson) { decoded = candidate; break; }256- if (candidate !== null) lastMismatch = candidate;257- }258- if (decoded === null && lastMismatch === null) {259- throw new CodexError(260315 "AZTEC_ROUNDTRIP_FAILED",261- `Chunk ${envelope.i} generated but cannot be decoded back after 5 attempts. This Codex cannot be trusted.`,316+ `Chunk ${envelope.i}: in-memory round-trip failed.${diagSuffix}`,262317 );263318 }264- if (decoded === null) {265- // lastMismatch is non-null — decoded something but wrong content.266- const el = envelopeJson.length;267- const dl = lastMismatch!.length;268- let diffAt = -1;269- for (let k = 0; k < Math.min(el, dl); k++) {270- if (envelopeJson[k] !== lastMismatch![k]) { diffAt = k; break; }319+320+ // Step 3: Render to PNG321+ try {322+ if (typeof OffscreenCanvas !== "undefined") {323+ return await matrixToPngBrowser(matrix);271324 }272- const ctx = (s: string) => JSON.stringify(s.slice(Math.max(0, diffAt - 8), diffAt + 20));325+ return await matrixToPngNode(envelopeJson);326+ } catch (err) {327+ if (err instanceof CodexError) throw err;273328 throw new CodexError(274- "AZTEC_CONTENT_MISMATCH",275- `Chunk ${envelope.i}: expected len=${el} decoded len=${dl} first diff at pos ${diffAt}. ` +276- `expected: ${ctx(envelopeJson)} decoded: ${ctx(lastMismatch!)}`,329+ "AZTEC_GENERATION_FAILED",330+ `PNG render failed for chunk ${envelope.i}: ${err instanceof Error ? err.message : String(err)}`,277331 );278332 }279- return image;280333 }src/popup/codex/generate.test.ts+93 −0
@@ -4,6 +4,7 @@44 CodexError,55 computeParityShards,66 STORAGE_KEY_VERIFY_CODE,7+ type ChunkEnvelope,78 type CodexDeps,89 } from "../../shared/codex-types";910 import type { VaultEntry } from "../../shared/types";@@ -19,6 +20,7 @@1920 encodeIntoStripes,2021 encodeWithErasure,2122 } from "./erasure";23+import { buildAztecMatrix, generateVerifiedAztec, verifyMatrixRoundTrip } from "./aztec";2224 import { generateCodex } from "./generate";2325 2426 // ── chrome.storage shim ─────────────────────────────────────────────────────@@ -334,4 +336,95 @@334336 decodeWithErasure(present, stripe.dataShards, stripe.totalChunks),335337 ).rejects.toThrow(CodexError);336338 }, 60_000);339+});340+341+// ── Aztec round-trip verification ─────────────────────────────────────────────342+343+describe("aztec round-trip", () => {344+ beforeEach(() => {345+ installChromeStorageMock();346+ });347+348+ it("buildAztecMatrix + verifyMatrixRoundTrip: 200 varied payloads all pass", () => {349+ // Exercise the core encode→decode path for a wide range of envelope-like350+ // JSON strings. This is fast (synchronous ZXing in-memory, no canvas, no351+ // bwip-js) and covers the payload patterns that previously caused352+ // AZTEC_CONTENT_MISMATCH with HybridBinarizer.353+ for (let i = 0; i < 200; i++) {354+ // Simulate an envelope JSON payload. Use CHUNK_SIZE-sized chunk data with355+ // a per-chunk bit pattern so each payload is unique.356+ const data = new Uint8Array(CHUNK_SIZE);357+ for (let j = 0; j < CHUNK_SIZE; j++) data[j] = ((i * 37 + j * 7) ^ (i << 3)) & 0xff;358+359+ // Build a short envelope-like JSON string resembling the real envelope format.360+ // Base64url encode the chunk data to keep it printable ASCII (matching the361+ // real pipeline which uses bytesToBase64Url).362+ const b64 = btoa(String.fromCharCode(...data.slice(0, 60))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");363+ const payload = JSON.stringify({364+ v: 3, i, t: 200, p: i >= 188, c: "PX-TEST-CODE",365+ d: b64,366+ h: "abcdef1234567890abcdef1234567890",367+ s: 0, st: 1,368+ });369+370+ const matrix = buildAztecMatrix(payload);371+ const ok = verifyMatrixRoundTrip(matrix, payload);372+ if (!ok) {373+ throw new Error(`verifyMatrixRoundTrip failed for chunk ${i} (payload len=${payload.length})`);374+ }375+ }376+ });377+378+ it("generateVerifiedAztec: 20 real envelopes survive round-trip", async () => {379+ // End-to-end test of generateVerifiedAztec using real chunk envelopes built380+ // by the same helpers used in the live pipeline. Any AZTEC_ROUNDTRIP_FAILED381+ // or AZTEC_CONTENT_MISMATCH throw will fail the test immediately.382+ const envelopes: ChunkEnvelope[] = [];383+ for (let i = 0; i < 20; i++) {384+ const data = new Uint8Array(CHUNK_SIZE);385+ for (let j = 0; j < CHUNK_SIZE; j++) data[j] = ((i * 53 + j * 11) ^ (i * 19)) & 0xff;386+ envelopes.push(await buildChunkEnvelope(data, i, 20, i >= 17, "PX-RTRP-CODE", 0, 1));387+ }388+389+ for (const env of envelopes) {390+ const img = await generateVerifiedAztec(env);391+ expect(img.pngBytes.length).toBeGreaterThan(0);392+ }393+ }, 120_000);394+395+ it("generateCodex: large vault (100 KB) completes without AZTEC errors", async () => {396+ // A realistic-sized vault that exercises multi-stripe encoding and produces397+ // O(100) Aztec codes. All codes must pass in-memory round-trip verification.398+ installChromeStorageMock();399+400+ // 80 KB of pseudo-random incompressible base64 data → ~100 data shards401+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";402+ let seed = 0xdeadbeef;403+ const chars: string[] = new Array(80_000);404+ for (let i = 0; i < 80_000; i++) {405+ seed = (seed * 1664525 + 1013904223) >>> 0;406+ chars[i] = alphabet[(seed >>> 16) & 0x3f]!;407+ }408+ const slotA = "ENCRYPTED::" + chars.join("");409+410+ const deps: CodexDeps = {411+ getSlotA: async () => slotA,412+ getSlotB: async () => null,413+ getLastModified: async () => "1700000000000",414+ getAllEntries: async () => [415+ { id: "1", title: "GitHub", username: "alice", password: "secret", createdAt: 1, modifiedAt: 2 },416+ ],417+ getVerifyCode: async () => "PX-LARG-TEST",418+ isVaultUnlocked: () => true,419+ };420+421+ const steps: string[] = [];422+ const { pdfBytes, manifest } = await generateCodex(deps, (step) => {423+ if (!steps.includes(step)) steps.push(step);424+ });425+426+ expect(pdfBytes[0]).toBe(0x25); // %PDF427+ expect(manifest.totalChunks).toBeGreaterThan(50);428+ expect(steps).toEqual(["compress", "erasure", "aztec", "verify", "pdf"]);429+ }, 300_000); // 5 min — bwip-js PNG for ~100 chunks dominates in node337430 });