/** * aztec.ts — Aztec 2D barcode generation with round-trip verification. * * Browser path: ZXing's AztecEncoder renders to OffscreenCanvas → PNG. * Using the same library for both encoding and decoding guarantees * round-trip compatibility. bwip-js's mode-encoding choices (LATCH/SHIFT * sequences) differed from ZXing's decoder expectations on certain data * patterns, causing systematic AZTEC_CONTENT_MISMATCH errors. Switching * to ZXing's own AztecEncoder eliminates the incompatibility entirely and * also removes the 918 kB bwip-js chunk from all browser builds. * * Node/test path: bwip-js/node → PNG Buffer. * OffscreenCanvas is unavailable in vitest; round-trip verification is * skipped (decodeAztecFromPng returns null without a DOM). The generation * tests for that path cover the encoder via separate code paths. */ import { AztecEncoder, 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; } // ── Browser: ZXing Aztec encoding ──────────────────────────────────────────── /** * Render an Aztec barcode using ZXing's own encoder at 25% ECC, scale 4, * with a 2-module quiet zone. Using ZXing to both encode and decode guarantees * round-trip fidelity; the encoder uses the same HighLevelEncoder mode tables * that the decoder expects, so no mode-switching incompatibility is possible. */ async function generateAztecPngBrowser(payload: string): Promise { // Encode payload as ISO-8859-1 bytes (= ASCII for our JSON envelope). const dataBytes = new Uint8Array(payload.length); for (let i = 0; i < payload.length; i++) dataBytes[i] = payload.charCodeAt(i); try { const aztecCode = AztecEncoder.encode(dataBytes, AZTEC_ECC_PERCENT, 0 /* auto layers */); const matrix = aztecCode.getMatrix(); if (!matrix) throw new Error("encoder returned null matrix"); const modules = matrix.getWidth(); // Aztec codes are always square const scale = 4; const pad = 2 * scale; // 2-module quiet zone (8 px at scale 4) 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 }; } catch (err) { throw new CodexError( "AZTEC_GENERATION_FAILED", `Aztec render failed: ${err instanceof Error ? err.message : String(err)}`, ); } } // ── Node/test: bwip-js 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 generateAztecPngNode(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; try { 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 }; } 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 generation (public) ──────────────────────────────────────────── /** * Render an Aztec barcode and return its PNG bytes + dimensions. * Browser: ZXing AztecEncoder → OffscreenCanvas → PNG. * Node/test: bwip-js/node → Buffer → PNG. */ export async function generateAztecPng(payload: string): Promise { if (typeof OffscreenCanvas !== "undefined") { return generateAztecPngBrowser(payload); } return generateAztecPngNode(payload); } // ── 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; // White-fill before drawing so any transparent pixels composite to white. ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, bitmap.width, bitmap.height); 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; } // Retry up to 5 times on any decode failure — break immediately on a // confirmed correct decode. Track the last non-null wrong result so we // can surface diagnostic detail if all attempts fail. let decoded: string | null = null; let lastMismatch: string | null = null; for (let attempt = 0; attempt < 5; attempt++) { const candidate = await decodeAztecFromPng(image.pngBytes); if (candidate === envelopeJson) { decoded = candidate; break; } if (candidate !== null) lastMismatch = candidate; } if (decoded === null && lastMismatch === null) { throw new CodexError( "AZTEC_ROUNDTRIP_FAILED", `Chunk ${envelope.i} generated but cannot be decoded back after 5 attempts. This Codex cannot be trusted.`, ); } if (decoded === null) { // lastMismatch is non-null — decoded something but wrong content. const el = envelopeJson.length; const dl = lastMismatch!.length; let diffAt = -1; for (let k = 0; k < Math.min(el, dl); k++) { if (envelopeJson[k] !== lastMismatch![k]) { diffAt = k; break; } } const ctx = (s: string) => JSON.stringify(s.slice(Math.max(0, diffAt - 8), diffAt + 20)); throw new CodexError( "AZTEC_CONTENT_MISMATCH", `Chunk ${envelope.i}: expected len=${el} decoded len=${dl} first diff at pos ${diffAt}. ` + `expected: ${ctx(envelopeJson)} decoded: ${ctx(lastMismatch!)}`, ); } return image; }