aztec.ts

10.1 KB · raw

1/**2 * aztec.ts — Aztec 2D barcode generation with round-trip verification.3 *4 * Encode path (both environments): ZXing AztecEncoder → BitMatrix.5 *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.22 */2324import {25  AztecCodeReader,26  AztecEncoder,27  BinaryBitmap,28  GlobalHistogramBinarizer,29  RGBLuminanceSource,30} from "@zxing/library";31import type { BitMatrix } from "@zxing/library";32import {33  AZTEC_ECC_PERCENT,34  CodexError,35  type ChunkEnvelope,36} from "../../shared/codex-types";3738export interface AztecImage {39  pngBytes: Uint8Array;40  width: number;41  height: number;42}4344// ── Matrix building ───────────────────────────────────────────────────────────4546/**47 * Encode `payload` (ISO-8859-1 bytes = ASCII for our JSON envelopes) into a48 * ZXing Aztec BitMatrix at 25% ECC with automatic layer selection.49 */50export function buildAztecMatrix(payload: string): BitMatrix {51  const dataBytes = new Uint8Array(payload.length);52  for (let i = 0; i < payload.length; i++) dataBytes[i] = payload.charCodeAt(i);53  const aztecCode = AztecEncoder.encode(dataBytes, AZTEC_ECC_PERCENT, 0 /* auto layers */);54  const matrix = aztecCode.getMatrix();55  if (!matrix) throw new Error("AztecEncoder returned null matrix");56  return matrix;57}5859// ── In-memory round-trip verification ─────────────────────────────────────────6061/**62 * Decode `matrix` in memory and confirm it reads back as `payload`.63 *64 * Builds a pixel array directly from the BitMatrix — no canvas, no PNG, no65 * OffscreenCanvas — so this works in both the browser popup and in vitest's66 * node environment. Uses GlobalHistogramBinarizer (global threshold) which is67 * appropriate for synthetically-perfect images.68 */69export function verifyMatrixRoundTrip(matrix: BitMatrix, payload: string): boolean {70  const modules = matrix.getWidth(); // Aztec codes are always square71  const scale = 4;72  const pad = 2 * scale; // 2-module quiet zone = 8px at scale 473  const imgW = modules * scale + pad * 2;74  const imgH = imgW;7576  // RGBLuminanceSource wants packed 32-bit ARGB as a signed Int32Array.77  const WHITE = -1; // 0xFFFFFFFF as signed int3278  const BLACK = (0xff << 24) | 0; // 0xFF000000 as signed int32 = -167772167980  const argb = new Int32Array(imgW * imgH);81  argb.fill(WHITE);8283  for (let y = 0; y < modules; y++) {84    for (let x = 0; x < modules; x++) {85      if (matrix.get(x, y)) {86        const baseRow = (pad + y * scale) * imgW + pad + x * scale;87        for (let dy = 0; dy < scale; dy++) {88          for (let dx = 0; dx < scale; dx++) {89            argb[baseRow + dy * imgW + dx] = BLACK;90          }91        }92      }93    }94  }9596  try {97    const source = new RGBLuminanceSource(argb, imgW, imgH);98    const binarizer = new GlobalHistogramBinarizer(source);99    const bitmap = new BinaryBitmap(binarizer);100    const reader = new AztecCodeReader();101    return reader.decode(bitmap).getText() === payload;102  } catch {103    return false;104  }105}106107// ── PNG rendering — browser path ──────────────────────────────────────────────108109async function matrixToPngBrowser(matrix: BitMatrix): Promise<AztecImage> {110  const modules = matrix.getWidth();111  const scale = 4;112  const pad = 2 * scale;113  const size = modules * scale + pad * 2;114115  const canvas = new OffscreenCanvas(size, size);116  const ctx = canvas.getContext("2d")!;117  ctx.fillStyle = "#ffffff";118  ctx.fillRect(0, 0, size, size);119  ctx.fillStyle = "#000000";120  for (let y = 0; y < modules; y++) {121    for (let x = 0; x < modules; x++) {122      if (matrix.get(x, y)) {123        ctx.fillRect(pad + x * scale, pad + y * scale, scale, scale);124      }125    }126  }127128  const blob = await canvas.convertToBlob({ type: "image/png" });129  const pngBytes = new Uint8Array(await blob.arrayBuffer());130  return { pngBytes, width: size, height: size };131}132133// ── PNG rendering — node/test path ────────────────────────────────────────────134135interface BwipNodeModule {136  toBuffer: (opts: Record<string, unknown>) => Promise<Buffer>;137}138139let cachedBwipNode: BwipNodeModule | null = null;140141async function loadBwipNode(): Promise<BwipNodeModule> {142  if (cachedBwipNode) return cachedBwipNode;143  // vitest / node only — bwip-js/node uses node:zlib which can't bundle for browser.144  const ns = (await import(/* @vite-ignore */ "bwip-js/node")) as Record<string, unknown>;145  const def = ns["default"] as Record<string, unknown> | undefined;146  const merged = { ...(def ?? {}), ...ns } as unknown as BwipNodeModule;147  cachedBwipNode = merged;148  return merged;149}150151async function matrixToPngNode(payload: string): Promise<AztecImage> {152  const mod = await loadBwipNode();153  const opts = {154    bcid: "azteccode",155    text: payload,156    eclevel: AZTEC_ECC_PERCENT.toString(),157    scale: 4,158    padding: 2,159    monochrome: true,160  } as const;161162  const buf = await mod.toBuffer(opts);163  const pngBytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength).slice();164  const { width, height } = readPngDimensions(pngBytes);165  return { pngBytes, width, height };166}167168/** Read a PNG's IHDR width/height. PNGs always have IHDR as the first chunk. */169function readPngDimensions(png: Uint8Array): { width: number; height: number } {170  // PNG signature: 8 bytes, then 4-byte chunk length, "IHDR", then width/height as big-endian uint32.171  if (png.length < 24) {172    throw new CodexError("AZTEC_GENERATION_FAILED", "PNG buffer too small to contain IHDR");173  }174  const view = new DataView(png.buffer, png.byteOffset, png.byteLength);175  const width = view.getUint32(16, false);176  const height = view.getUint32(20, false);177  return { width, height };178}179180// ── Verified Aztec generation ─────────────────────────────────────────────────181182/**183 * Generate the Aztec code for one envelope with in-memory round-trip184 * verification. The verification step is synchronous and DOM-free — it builds185 * the pixel array directly from the ZXing BitMatrix using186 * GlobalHistogramBinarizer, so it works identically in the browser popup and187 * in vitest's node environment (unlike the old canvas-based path which was188 * silently skipped in node).189 *190 * If the encoder produces a matrix that does not decode back to the envelope191 * JSON, we throw immediately — no retry. The ZXing encoder is deterministic;192 * a failure is a hard bug, not a transient error.193 */194export async function generateVerifiedAztec(envelope: ChunkEnvelope): Promise<AztecImage> {195  const envelopeJson = JSON.stringify(envelope);196197  // Step 1: Encode → BitMatrix (pure ZXing JS, no DOM)198  let matrix: BitMatrix;199  try {200    matrix = buildAztecMatrix(envelopeJson);201  } catch (err) {202    throw new CodexError(203      "AZTEC_GENERATION_FAILED",204      `AztecEncoder failed for chunk ${envelope.i}: ${err instanceof Error ? err.message : String(err)}`,205    );206  }207208  // Step 2: In-memory round-trip — verify before committing the PNG209  if (!verifyMatrixRoundTrip(matrix, envelopeJson)) {210    // Compute a diagnostic diff to aid debugging if this ever fires.211    let diagSuffix = "";212    try {213      const modules = matrix.getWidth();214      const scale = 4;215      const pad = 2 * scale;216      const imgW = modules * scale + pad * 2;217      const WHITE = -1;218      const BLACK = (0xff << 24) | 0;219      const argb = new Int32Array(imgW * imgW);220      argb.fill(WHITE);221      for (let y = 0; y < modules; y++)222        for (let x = 0; x < modules; x++)223          if (matrix.get(x, y)) {224            const base = (pad + y * scale) * imgW + pad + x * scale;225            for (let dy = 0; dy < scale; dy++)226              for (let dx = 0; dx < scale; dx++)227                argb[base + dy * imgW + dx] = BLACK;228          }229      const source = new RGBLuminanceSource(argb, imgW, imgW);230      const binarizer = new GlobalHistogramBinarizer(source);231      const bitmap = new BinaryBitmap(binarizer);232      const decoded = new AztecCodeReader().decode(bitmap).getText();233      const el = envelopeJson.length;234      const dl = decoded.length;235      let diffAt = -1;236      for (let k = 0; k < Math.min(el, dl); k++) {237        if (envelopeJson[k] !== decoded[k]) { diffAt = k; break; }238      }239      const ctx = (s: string) => JSON.stringify(s.slice(Math.max(0, diffAt - 8), diffAt + 20));240      diagSuffix = ` decoded len=${dl} expected len=${el} first diff at ${diffAt}: expected ${ctx(envelopeJson)} got ${ctx(decoded)}`;241    } catch (diagErr) {242      diagSuffix = ` (decode threw: ${diagErr instanceof Error ? diagErr.message : String(diagErr)})`;243    }244    throw new CodexError(245      "AZTEC_ROUNDTRIP_FAILED",246      `Chunk ${envelope.i}: in-memory round-trip failed.${diagSuffix}`,247    );248  }249250  // Step 3: Render to PNG251  try {252    if (typeof OffscreenCanvas !== "undefined") {253      return await matrixToPngBrowser(matrix);254    }255    return await matrixToPngNode(envelopeJson);256  } catch (err) {257    if (err instanceof CodexError) throw err;258    throw new CodexError(259      "AZTEC_GENERATION_FAILED",260      `PNG render failed for chunk ${envelope.i}: ${err instanceof Error ? err.message : String(err)}`,261    );262  }263}