/** * aztec.ts — Aztec 2D barcode generation with round-trip verification. * * The library has two entry points: * - browser: `bwip-js` — uses Canvas/OffscreenCanvas for rasterization * - node: `bwip-js/node` — uses zlib-PNG, returns a Buffer directly * * We resolve which one to use at runtime so the popup (browser context) and * vitest (node context) both work without any build-time conditional. The * resolved module is cached because importing parses ~500KB of generated JS. * * Round-trip verification: every generated Aztec is decoded back via @zxing * and the decoded text is compared byte-for-byte with the original envelope * JSON. Without the round-trip we have no proof that what we printed is * actually readable. In the popup the decode runs through OffscreenCanvas. * In a pure node test environment OffscreenCanvas is unavailable, so we * fall back to a no-op verification — the generation tests for that path * cover the encoder via separate code paths. */ import { BarcodeFormat, BinaryBitmap, DecodeHintType, HybridBinarizer, MultiFormatReader, RGBLuminanceSource, } from "@zxing/library"; import { AZTEC_ECC_PERCENT, CodexError, type ChunkEnvelope, } from "../../shared/codex-types"; export interface AztecImage { pngBytes: Uint8Array; width: number; height: number; } // ── bwip-js loader ─────────────────────────────────────────────────────────── /** * In browser builds the popup bundler resolves `bwip-js` to the browser entry * which exports `toCanvas`. In node (vitest) we want `bwip-js/node` which * exports `toBuffer`. We can't import both unconditionally — node can't load * the browser entry's canvas code, and the browser can't load `node:zlib`. * * The detection is "do we have a working OffscreenCanvas?" rather than * "is `process` defined?" because the popup runs in an MV3 worker context * where some Node globals leak via polyfills. */ interface BwipBrowserModule { toCanvas: ( canvas: OffscreenCanvas | HTMLCanvasElement, opts: Record, ) => OffscreenCanvas | HTMLCanvasElement; } interface BwipNodeModule { toBuffer: (opts: Record) => Promise; } let cachedBwip: BwipBrowserModule | BwipNodeModule | null = null; let cachedIsBrowser: boolean | null = null; async function loadBwip(): Promise<{ mod: BwipBrowserModule | BwipNodeModule; isBrowser: boolean; }> { if (cachedBwip && cachedIsBrowser !== null) { return { mod: cachedBwip, isBrowser: cachedIsBrowser }; } const isBrowser = typeof OffscreenCanvas !== "undefined"; // Pick the entrypoint explicitly. Vite (and therefore vitest) tends to // resolve the bare `bwip-js` specifier against the package's browser export // condition even when running under node — so we route node test runs // through `bwip-js/node` directly, which gives us the toBuffer-shaped API. const spec = isBrowser ? "bwip-js" : "bwip-js" + "/node"; const ns = (await import(/* @vite-ignore */ spec)) as Record; const defaultExport = ns["default"] as Record | undefined; // ESM dynamic import surface differs between the two builds: the browser // bundle exports `toCanvas` as a named export, while the node bundle is // CJS-shaped and exposes `toBuffer` on `default`. Merge both so callers // can use either method without caring which path resolved. const merged = { ...(defaultExport ?? {}), ...ns } as Record; const resolved = merged as unknown as BwipBrowserModule | BwipNodeModule; cachedBwip = resolved; cachedIsBrowser = isBrowser; return { mod: resolved, isBrowser }; } // ── Aztec PNG generation ──────────────────────────────────────────────────── /** * Render an Aztec barcode at 25% ECC and return its PNG bytes. The PNG is * intentionally monochrome at scale 4 — that's the smallest scale that still * looks crisp at print resolution while keeping per-code rendering well under * 100ms on modest hardware. */ export async function generateAztecPng(payload: string): Promise { const { mod, isBrowser } = await loadBwip(); const opts = { bcid: "azteccode", text: payload, eclevel: AZTEC_ECC_PERCENT.toString(), scale: 4, // padding: 2 adds 8px (2 modules at scale 4) of quiet zone on each side. // ZXing's finder pattern search fails when the code is flush against the // image edge with no quiet zone, causing spurious AZTEC_ROUNDTRIP_FAILED. padding: 2, monochrome: true, } as const; try { if (isBrowser) { const canvas = new OffscreenCanvas(1, 1); const drawn = (mod as BwipBrowserModule).toCanvas(canvas, opts); const out = drawn as OffscreenCanvas; const blob = await out.convertToBlob({ type: "image/png" }); const pngBytes = new Uint8Array(await blob.arrayBuffer()); return { pngBytes, width: out.width, height: out.height }; } const buf = await (mod as BwipNodeModule).toBuffer(opts); const pngBytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength).slice(); const { width, height } = readPngDimensions(pngBytes); return { pngBytes, width, height }; } catch (err) { throw new CodexError( "AZTEC_GENERATION_FAILED", `Aztec render failed: ${err instanceof Error ? err.message : String(err)}`, ); } } /** 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 }; } // ── Aztec PNG decoding (round-trip) ───────────────────────────────────────── /** * Decode an Aztec PNG via @zxing. Returns the decoded UTF-8 text, or `null` * if decoding fails for any reason (no DOM, malformed PNG, no match found). * Never throws — the caller decides whether a `null` result is a verification * failure or a deliberate skip. */ export async function decodeAztecFromPng(pngBytes: Uint8Array): Promise { // The decoder needs raw pixel data, which means we need a canvas to // rasterize the PNG into ImageData. Skip the round-trip in environments // without OffscreenCanvas (vitest with environment: 'node'). if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") { return null; } try { // Copy into a fresh ArrayBuffer-backed view so Blob's typing is satisfied // (the input Uint8Array may be backed by a SharedArrayBuffer in some // bundler typings, which Blob's BlobPart union rejects). const owned = new Uint8Array(pngBytes.byteLength); owned.set(pngBytes); const blob = new Blob([owned], { type: "image/png" }); const bitmap = await createImageBitmap(blob); const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); const ctx = canvas.getContext("2d"); if (!ctx) return null; ctx.drawImage(bitmap, 0, 0); const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height); // RGBLuminanceSource wants packed 32-bit ARGB values, not raw Uint8 RGBA. const argb = new Int32Array(bitmap.width * bitmap.height); const raw = imageData.data; for (let i = 0, j = 0; i < raw.length; i += 4, j++) { argb[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!; } const source = new RGBLuminanceSource(argb, bitmap.width, bitmap.height); const binarizer = new HybridBinarizer(source); const bitmapBinary = new BinaryBitmap(binarizer); const reader = new MultiFormatReader(); const hints = new Map(); hints.set(DecodeHintType.POSSIBLE_FORMATS, [BarcodeFormat.AZTEC]); hints.set(DecodeHintType.TRY_HARDER, true); reader.setHints(hints); const result = reader.decode(bitmapBinary); return result.getText(); } catch { return null; } } // ── Verified Aztec generation ─────────────────────────────────────────────── /** * Generate the Aztec code for one envelope and confirm it round-trips. The * envelope JSON is serialized once and reused as the decode target so any * encoding mismatch (e.g. UTF-8 surrogate corruption) is caught immediately. * * In environments where round-trip decoding is unavailable (vitest node), the * verification step is skipped — the generator still throws on encoder * failure, which is the only condition this path can detect without a DOM. */ export async function generateVerifiedAztec(envelope: ChunkEnvelope): Promise { const envelopeJson = JSON.stringify(envelope); let image: AztecImage; try { image = await generateAztecPng(envelopeJson); } catch (err) { if (err instanceof CodexError) throw err; throw new CodexError( "AZTEC_GENERATION_FAILED", `Failed to generate Aztec code for chunk ${envelope.i}: ${err instanceof Error ? err.message : String(err)}`, ); } if (!image.pngBytes || image.pngBytes.length < 32) { throw new CodexError( "AZTEC_GENERATION_FAILED", `Aztec PNG for chunk ${envelope.i} was empty`, ); } // Skip round-trip when we can't rasterize — see decodeAztecFromPng(). if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") { return image; } // @zxing occasionally returns null on a valid barcode due to transient // rasterisation issues. Five attempts avoids aborting a full Codex build // for a momentary DOM hiccup. Content-mismatch is never retried. let decoded: string | null = null; for (let attempt = 0; attempt < 5; attempt++) { decoded = await decodeAztecFromPng(image.pngBytes); if (decoded !== null) break; } if (decoded === null) { throw new CodexError( "AZTEC_ROUNDTRIP_FAILED", `Chunk ${envelope.i} generated but cannot be decoded back after 3 attempts. This Codex cannot be trusted.`, ); } if (decoded !== envelopeJson) { throw new CodexError( "AZTEC_CONTENT_MISMATCH", `Chunk ${envelope.i} decoded content does not match original envelope. Possible encoding corruption.`, ); } return image; }