aztec.ts

11.0 KB · raw

1/**2 * aztec.ts — Aztec 2D barcode generation with round-trip verification.3 *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.11 *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.16 */1718import {19  AztecEncoder,20  BarcodeFormat,21  BinaryBitmap,22  DecodeHintType,23  HybridBinarizer,24  MultiFormatReader,25  RGBLuminanceSource,26} from "@zxing/library";27import {28  AZTEC_ECC_PERCENT,29  CodexError,30  type ChunkEnvelope,31} from "../../shared/codex-types";3233export interface AztecImage {34  pngBytes: Uint8Array;35  width: number;36  height: number;37}3839// ── Browser: ZXing Aztec encoding ────────────────────────────────────────────4041/**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.46 */47async function generateAztecPngBrowser(payload: string): Promise<AztecImage> {48  // Encode payload as ISO-8859-1 bytes (= ASCII for our JSON envelope).49  const dataBytes = new Uint8Array(payload.length);50  for (let i = 0; i < payload.length; i++) dataBytes[i] = payload.charCodeAt(i);5152  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");5657    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;6162    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);71        }72      }73    }7475    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    );83  }84}8586// ── Node/test: bwip-js path ───────────────────────────────────────────────────8788interface BwipNodeModule {89  toBuffer: (opts: Record<string, unknown>) => Promise<Buffer>;90}9192let cachedBwipNode: BwipNodeModule | null = null;9394async function loadBwipNode(): Promise<BwipNodeModule> {95  if (cachedBwipNode) return cachedBwipNode;96  // vitest / node only — bwip-js/node uses node:zlib which can't bundle for browser.97  const ns = (await import(/* @vite-ignore */ "bwip-js/node")) as Record<string, unknown>;98  const def = ns["default"] as Record<string, unknown> | undefined;99  const merged = { ...(def ?? {}), ...ns } as unknown as BwipNodeModule;100  cachedBwipNode = merged;101  return merged;102}103104async function generateAztecPngNode(payload: string): Promise<AztecImage> {105  const mod = await loadBwipNode();106  const opts = {107    bcid: "azteccode",108    text: payload,109    eclevel: AZTEC_ECC_PERCENT.toString(),110    scale: 4,111    padding: 2,112    monochrome: true,113  } as const;114115  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  }126}127128/** Read a PNG's IHDR width/height. PNGs always have IHDR as the first chunk. */129function readPngDimensions(png: Uint8Array): { width: number; height: number } {130  // PNG signature: 8 bytes, then 4-byte chunk length, "IHDR", then width/height as big-endian uint32.131  if (png.length < 24) {132    throw new CodexError("AZTEC_GENERATION_FAILED", "PNG buffer too small to contain IHDR");133  }134  const view = new DataView(png.buffer, png.byteOffset, png.byteLength);135  const width = view.getUint32(16, false);136  const height = view.getUint32(20, false);137  return { width, height };138}139140// ── Aztec PNG generation (public) ────────────────────────────────────────────141142/**143 * Render an Aztec barcode and return its PNG bytes + dimensions.144 * Browser: ZXing AztecEncoder → OffscreenCanvas → PNG.145 * Node/test: bwip-js/node → Buffer → PNG.146 */147export async function generateAztecPng(payload: string): Promise<AztecImage> {148  if (typeof OffscreenCanvas !== "undefined") {149    return generateAztecPngBrowser(payload);150  }151  return generateAztecPngNode(payload);152}153154// ── Aztec PNG decoding (round-trip) ─────────────────────────────────────────155156/**157 * Decode an Aztec PNG via @zxing. Returns the decoded UTF-8 text, or `null`158 * 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.161 */162export 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').166  if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") {167    return null;168  }169170  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).174    const owned = new Uint8Array(pngBytes.byteLength);175    owned.set(pngBytes);176    const blob = new Blob([owned], { type: "image/png" });177    const bitmap = await createImageBitmap(blob);178    const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);179    const ctx = canvas.getContext("2d");180    if (!ctx) return null;181    // White-fill before drawing so any transparent pixels composite to white.182    ctx.fillStyle = "#ffffff";183    ctx.fillRect(0, 0, bitmap.width, bitmap.height);184    ctx.drawImage(bitmap, 0, 0);185    const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);186187    // RGBLuminanceSource wants packed 32-bit ARGB values, not raw Uint8 RGBA.188    const argb = new Int32Array(bitmap.width * bitmap.height);189    const raw = imageData.data;190    for (let i = 0, j = 0; i < raw.length; i += 4, j++) {191      argb[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;192    }193194    const source = new RGBLuminanceSource(argb, bitmap.width, bitmap.height);195    const binarizer = new HybridBinarizer(source);196    const bitmapBinary = new BinaryBitmap(binarizer);197198    const reader = new MultiFormatReader();199    const hints = new Map();200    hints.set(DecodeHintType.POSSIBLE_FORMATS, [BarcodeFormat.AZTEC]);201    hints.set(DecodeHintType.TRY_HARDER, true);202    reader.setHints(hints);203204    const result = reader.decode(bitmapBinary);205    return result.getText();206  } catch {207    return null;208  }209}210211// ── Verified Aztec generation ───────────────────────────────────────────────212213/**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.217 *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.221 */222export async function generateVerifiedAztec(envelope: ChunkEnvelope): Promise<AztecImage> {223  const envelopeJson = JSON.stringify(envelope);224225  let image: AztecImage;226  try {227    image = await generateAztecPng(envelopeJson);228  } catch (err) {229    if (err instanceof CodexError) throw err;230    throw new CodexError(231      "AZTEC_GENERATION_FAILED",232      `Failed to generate Aztec code for chunk ${envelope.i}: ${err instanceof Error ? err.message : String(err)}`,233    );234  }235236  if (!image.pngBytes || image.pngBytes.length < 32) {237    throw new CodexError(238      "AZTEC_GENERATION_FAILED",239      `Aztec PNG for chunk ${envelope.i} was empty`,240    );241  }242243  // Skip round-trip when we can't rasterize — see decodeAztecFromPng().244  if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") {245    return image;246  }247248  // 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(260      "AZTEC_ROUNDTRIP_FAILED",261      `Chunk ${envelope.i} generated but cannot be decoded back after 5 attempts. This Codex cannot be trusted.`,262    );263  }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; }271    }272    const ctx = (s: string) => JSON.stringify(s.slice(Math.max(0, diffAt - 8), diffAt + 20));273    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!)}`,277    );278  }279  return image;280}