/** * aztec.ts — Aztec 2D barcode generation with round-trip verification. * * Encode path (both environments): ZXing AztecEncoder → BitMatrix. * * Verification: in-memory BitMatrix → Int32Array → GlobalHistogramBinarizer → * AztecCodeReader. No canvas, no PNG decode, no DOM. Works identically in * the browser popup and in vitest's node environment. * * GlobalHistogramBinarizer is used rather than HybridBinarizer because the * latter is tuned for camera images with spatially uneven illumination. On * synthetically-generated images (perfectly flat black/white pixels) the * local-block threshold inside HybridBinarizer can misclassify an all-black * region (e.g. the Aztec bull's-eye) as white, producing a spurious decode. * A global histogram threshold has no such blind spot. * * PNG render path: * Browser — ZXing BitMatrix → OffscreenCanvas → PNG. * Node/test — bwip-js/node (OffscreenCanvas unavailable in vitest). The * bwip-js PNG is used only for the PDF pipeline test; all correctness * verification already happened via the in-memory path. */ import { AztecCodeReader, AztecEncoder, BinaryBitmap, GlobalHistogramBinarizer, RGBLuminanceSource, } from "@zxing/library"; import type { BitMatrix } from "@zxing/library"; import { AZTEC_ECC_PERCENT, CodexError, type ChunkEnvelope, } from "../../shared/codex-types"; export interface AztecImage { pngBytes: Uint8Array; width: number; height: number; } // ── Matrix building ─────────────────────────────────────────────────────────── /** * Encode `payload` (ISO-8859-1 bytes = ASCII for our JSON envelopes) into a * ZXing Aztec BitMatrix at 25% ECC with automatic layer selection. */ export function buildAztecMatrix(payload: string): BitMatrix { const dataBytes = new Uint8Array(payload.length); for (let i = 0; i < payload.length; i++) dataBytes[i] = payload.charCodeAt(i); const aztecCode = AztecEncoder.encode(dataBytes, AZTEC_ECC_PERCENT, 0 /* auto layers */); const matrix = aztecCode.getMatrix(); if (!matrix) throw new Error("AztecEncoder returned null matrix"); return matrix; } // ── In-memory round-trip verification ───────────────────────────────────────── /** * Decode `matrix` in memory and confirm it reads back as `payload`. * * Builds a pixel array directly from the BitMatrix — no canvas, no PNG, no * OffscreenCanvas — so this works in both the browser popup and in vitest's * node environment. Uses GlobalHistogramBinarizer (global threshold) which is * appropriate for synthetically-perfect images. */ export function verifyMatrixRoundTrip(matrix: BitMatrix, payload: string): boolean { const modules = matrix.getWidth(); // Aztec codes are always square const scale = 4; const pad = 2 * scale; // 2-module quiet zone = 8px at scale 4 const imgW = modules * scale + pad * 2; const imgH = imgW; // RGBLuminanceSource wants packed 32-bit ARGB as a signed Int32Array. const WHITE = -1; // 0xFFFFFFFF as signed int32 const BLACK = (0xff << 24) | 0; // 0xFF000000 as signed int32 = -16777216 const argb = new Int32Array(imgW * imgH); argb.fill(WHITE); for (let y = 0; y < modules; y++) { for (let x = 0; x < modules; x++) { if (matrix.get(x, y)) { const baseRow = (pad + y * scale) * imgW + pad + x * scale; for (let dy = 0; dy < scale; dy++) { for (let dx = 0; dx < scale; dx++) { argb[baseRow + dy * imgW + dx] = BLACK; } } } } } try { const source = new RGBLuminanceSource(argb, imgW, imgH); const binarizer = new GlobalHistogramBinarizer(source); const bitmap = new BinaryBitmap(binarizer); const reader = new AztecCodeReader(); return reader.decode(bitmap).getText() === payload; } catch { return false; } } // ── PNG rendering — browser path ────────────────────────────────────────────── async function matrixToPngBrowser(matrix: BitMatrix): Promise { const modules = matrix.getWidth(); const scale = 4; const pad = 2 * scale; const size = modules * scale + pad * 2; const canvas = new OffscreenCanvas(size, size); const ctx = canvas.getContext("2d")!; ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, size, size); ctx.fillStyle = "#000000"; for (let y = 0; y < modules; y++) { for (let x = 0; x < modules; x++) { if (matrix.get(x, y)) { ctx.fillRect(pad + x * scale, pad + y * scale, scale, scale); } } } const blob = await canvas.convertToBlob({ type: "image/png" }); const pngBytes = new Uint8Array(await blob.arrayBuffer()); return { pngBytes, width: size, height: size }; } // ── PNG rendering — node/test path ──────────────────────────────────────────── interface BwipNodeModule { toBuffer: (opts: Record) => Promise; } let cachedBwipNode: BwipNodeModule | null = null; async function loadBwipNode(): Promise { if (cachedBwipNode) return cachedBwipNode; // vitest / node only — bwip-js/node uses node:zlib which can't bundle for browser. const ns = (await import(/* @vite-ignore */ "bwip-js/node")) as Record; const def = ns["default"] as Record | undefined; const merged = { ...(def ?? {}), ...ns } as unknown as BwipNodeModule; cachedBwipNode = merged; return merged; } async function matrixToPngNode(payload: string): Promise { const mod = await loadBwipNode(); const opts = { bcid: "azteccode", text: payload, eclevel: AZTEC_ECC_PERCENT.toString(), scale: 4, padding: 2, monochrome: true, } as const; const buf = await mod.toBuffer(opts); const pngBytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength).slice(); const { width, height } = readPngDimensions(pngBytes); return { pngBytes, width, height }; } /** Read a PNG's IHDR width/height. PNGs always have IHDR as the first chunk. */ function readPngDimensions(png: Uint8Array): { width: number; height: number } { // PNG signature: 8 bytes, then 4-byte chunk length, "IHDR", then width/height as big-endian uint32. if (png.length < 24) { throw new CodexError("AZTEC_GENERATION_FAILED", "PNG buffer too small to contain IHDR"); } const view = new DataView(png.buffer, png.byteOffset, png.byteLength); const width = view.getUint32(16, false); const height = view.getUint32(20, false); return { width, height }; } // ── Verified Aztec generation ───────────────────────────────────────────────── /** * Generate the Aztec code for one envelope with in-memory round-trip * verification. The verification step is synchronous and DOM-free — it builds * the pixel array directly from the ZXing BitMatrix using * GlobalHistogramBinarizer, so it works identically in the browser popup and * in vitest's node environment (unlike the old canvas-based path which was * silently skipped in node). * * If the encoder produces a matrix that does not decode back to the envelope * JSON, we throw immediately — no retry. The ZXing encoder is deterministic; * a failure is a hard bug, not a transient error. */ export async function generateVerifiedAztec(envelope: ChunkEnvelope): Promise { const envelopeJson = JSON.stringify(envelope); // Step 1: Encode → BitMatrix (pure ZXing JS, no DOM) let matrix: BitMatrix; try { matrix = buildAztecMatrix(envelopeJson); } catch (err) { throw new CodexError( "AZTEC_GENERATION_FAILED", `AztecEncoder failed for chunk ${envelope.i}: ${err instanceof Error ? err.message : String(err)}`, ); } // Step 2: In-memory round-trip — verify before committing the PNG if (!verifyMatrixRoundTrip(matrix, envelopeJson)) { // Compute a diagnostic diff to aid debugging if this ever fires. let diagSuffix = ""; try { const modules = matrix.getWidth(); const scale = 4; const pad = 2 * scale; const imgW = modules * scale + pad * 2; const WHITE = -1; const BLACK = (0xff << 24) | 0; const argb = new Int32Array(imgW * imgW); argb.fill(WHITE); for (let y = 0; y < modules; y++) for (let x = 0; x < modules; x++) if (matrix.get(x, y)) { const base = (pad + y * scale) * imgW + pad + x * scale; for (let dy = 0; dy < scale; dy++) for (let dx = 0; dx < scale; dx++) argb[base + dy * imgW + dx] = BLACK; } const source = new RGBLuminanceSource(argb, imgW, imgW); const binarizer = new GlobalHistogramBinarizer(source); const bitmap = new BinaryBitmap(binarizer); const decoded = new AztecCodeReader().decode(bitmap).getText(); const el = envelopeJson.length; const dl = decoded.length; let diffAt = -1; for (let k = 0; k < Math.min(el, dl); k++) { if (envelopeJson[k] !== decoded[k]) { diffAt = k; break; } } const ctx = (s: string) => JSON.stringify(s.slice(Math.max(0, diffAt - 8), diffAt + 20)); diagSuffix = ` decoded len=${dl} expected len=${el} first diff at ${diffAt}: expected ${ctx(envelopeJson)} got ${ctx(decoded)}`; } catch (diagErr) { diagSuffix = ` (decode threw: ${diagErr instanceof Error ? diagErr.message : String(diagErr)})`; } throw new CodexError( "AZTEC_ROUNDTRIP_FAILED", `Chunk ${envelope.i}: in-memory round-trip failed.${diagSuffix}`, ); } // Step 3: Render to PNG try { if (typeof OffscreenCanvas !== "undefined") { return await matrixToPngBrowser(matrix); } return await matrixToPngNode(envelopeJson); } catch (err) { if (err instanceof CodexError) throw err; throw new CodexError( "AZTEC_GENERATION_FAILED", `PNG render failed for chunk ${envelope.i}: ${err instanceof Error ? err.message : String(err)}`, ); } }