aztec.ts

12.4 KB · raw

1/**2 * aztec.ts — Aztec 2D barcode generation with round-trip verification.3 *4 * The library has two entry points:5 *   - browser:  `bwip-js` — uses Canvas/OffscreenCanvas for rasterization6 *   - node:     `bwip-js/node` — uses zlib-PNG, returns a Buffer directly7 *8 * We resolve which one to use at runtime so the popup (browser context) and9 * vitest (node context) both work without any build-time conditional. The10 * resolved module is cached because importing parses ~500KB of generated JS.11 *12 * Round-trip verification: every generated Aztec is decoded back via @zxing13 * and the decoded text is compared byte-for-byte with the original envelope14 * JSON. Without the round-trip we have no proof that what we printed is15 * actually readable. In the popup the decode runs through OffscreenCanvas.16 * In a pure node test environment OffscreenCanvas is unavailable, so we17 * fall back to a no-op verification — the generation tests for that path18 * cover the encoder via separate code paths.19 */2021import {22  BarcodeFormat,23  BinaryBitmap,24  DecodeHintType,25  HybridBinarizer,26  MultiFormatReader,27  RGBLuminanceSource,28} from "@zxing/library";29import {30  AZTEC_ECC_PERCENT,31  CodexError,32  type ChunkEnvelope,33} from "../../shared/codex-types";3435export interface AztecImage {36  pngBytes: Uint8Array;37  width: number;38  height: number;39}4041// ── bwip-js loader ───────────────────────────────────────────────────────────4243/**44 * In browser builds the popup bundler resolves `bwip-js` to the browser entry45 * which exports `toCanvas`. In node (vitest) we want `bwip-js/node` which46 * exports `toBuffer`. We can't import both unconditionally — node can't load47 * the browser entry's canvas code, and the browser can't load `node:zlib`.48 *49 * The detection is "do we have a working OffscreenCanvas?" rather than50 * "is `process` defined?" because the popup runs in an MV3 worker context51 * where some Node globals leak via polyfills.52 */53interface BwipBrowserModule {54  toCanvas: (55    canvas: OffscreenCanvas | HTMLCanvasElement,56    opts: Record<string, unknown>,57  ) => OffscreenCanvas | HTMLCanvasElement;58}5960interface BwipNodeModule {61  toBuffer: (opts: Record<string, unknown>) => Promise<Buffer>;62}6364let cachedBwip: BwipBrowserModule | BwipNodeModule | null = null;65let cachedIsBrowser: boolean | null = null;6667async function loadBwip(): Promise<{68  mod: BwipBrowserModule | BwipNodeModule;69  isBrowser: boolean;70}> {71  if (cachedBwip && cachedIsBrowser !== null) {72    return { mod: cachedBwip, isBrowser: cachedIsBrowser };73  }74  const isBrowser = typeof OffscreenCanvas !== "undefined";7576  // Two separate import() calls so Rolldown code-splits the browser entry77  // into a real chunk (making the URL resolvable at runtime in the extension),78  // while keeping @vite-ignore on the test-only node path to prevent Rolldown79  // from trying to bundle node:zlib into the browser build.80  let ns: Record<string, unknown>;81  if (isBrowser) {82    // bwip-js exports types under the "browser" condition only — tsc can't83    // resolve them with moduleResolution:"bundler". The runtime behavior is84    // correct; the cast below enforces our own interface.85    // @ts-expect-error — no top-level types entry in bwip-js package.json exports86    ns = (await import("bwip-js")) as Record<string, unknown>;87  } else {88    // vitest / node only — bwip-js/node uses node:zlib which can't bundle for browser.89    ns = (await import(/* @vite-ignore */ "bwip-js/node")) as Record<string, unknown>;90  }9192  const defaultExport = ns["default"] as Record<string, unknown> | undefined;93  // ESM dynamic import surface differs between the two builds: the browser94  // bundle exports `toCanvas` as a named export, while the node bundle is95  // CJS-shaped and exposes `toBuffer` on `default`. Merge both so callers96  // can use either method without caring which path resolved.97  const merged = { ...(defaultExport ?? {}), ...ns } as Record<string, unknown>;98  const resolved = merged as unknown as BwipBrowserModule | BwipNodeModule;99  cachedBwip = resolved;100  cachedIsBrowser = isBrowser;101  return { mod: resolved, isBrowser };102}103104// ── Aztec PNG generation ────────────────────────────────────────────────────105106/**107 * Render an Aztec barcode at 25% ECC and return its PNG bytes. The PNG is108 * intentionally monochrome at scale 4 — that's the smallest scale that still109 * looks crisp at print resolution while keeping per-code rendering well under110 * 100ms on modest hardware.111 */112export async function generateAztecPng(payload: string): Promise<AztecImage> {113  const { mod, isBrowser } = await loadBwip();114  const opts = {115    bcid: "azteccode",116    text: payload,117    eclevel: AZTEC_ECC_PERCENT.toString(),118    scale: 4,119    // padding: 2 adds 8px (2 modules at scale 4) of quiet zone on each side.120    // ZXing's finder pattern search fails when the code is flush against the121    // image edge with no quiet zone, causing spurious AZTEC_ROUNDTRIP_FAILED.122    padding: 2,123    monochrome: true,124    // Explicit white background so the PNG is fully opaque. Without this,125    // bwip-js clears the canvas with clearRect (transparent), and although126    // decodeAztecFromPng fills white before drawing, the alpha compositing127    // path is one more source of pixel-level variance on some runtimes.128    backgroundcolor: "ffffff",129  } as const;130131  try {132    if (isBrowser) {133      const canvas = new OffscreenCanvas(1, 1);134      const drawn = (mod as BwipBrowserModule).toCanvas(canvas, opts);135      const out = drawn as OffscreenCanvas;136      const blob = await out.convertToBlob({ type: "image/png" });137      const pngBytes = new Uint8Array(await blob.arrayBuffer());138      return { pngBytes, width: out.width, height: out.height };139    }140141    const buf = await (mod as BwipNodeModule).toBuffer(opts);142    const pngBytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength).slice();143    const { width, height } = readPngDimensions(pngBytes);144    return { pngBytes, width, height };145  } catch (err) {146    throw new CodexError(147      "AZTEC_GENERATION_FAILED",148      `Aztec render failed: ${err instanceof Error ? err.message : String(err)}`,149    );150  }151}152153/** Read a PNG's IHDR width/height. PNGs always have IHDR as the first chunk. */154function readPngDimensions(png: Uint8Array): { width: number; height: number } {155  // PNG signature: 8 bytes, then 4-byte chunk length, "IHDR", then width/height as big-endian uint32.156  if (png.length < 24) {157    throw new CodexError("AZTEC_GENERATION_FAILED", "PNG buffer too small to contain IHDR");158  }159  const view = new DataView(png.buffer, png.byteOffset, png.byteLength);160  const width = view.getUint32(16, false);161  const height = view.getUint32(20, false);162  return { width, height };163}164165// ── Aztec PNG decoding (round-trip) ─────────────────────────────────────────166167/**168 * Decode an Aztec PNG via @zxing. Returns the decoded UTF-8 text, or `null`169 * if decoding fails for any reason (no DOM, malformed PNG, no match found).170 * Never throws — the caller decides whether a `null` result is a verification171 * failure or a deliberate skip.172 */173export async function decodeAztecFromPng(pngBytes: Uint8Array): Promise<string | null> {174  // The decoder needs raw pixel data, which means we need a canvas to175  // rasterize the PNG into ImageData. Skip the round-trip in environments176  // without OffscreenCanvas (vitest with environment: 'node').177  if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") {178    return null;179  }180181  try {182    // Copy into a fresh ArrayBuffer-backed view so Blob's typing is satisfied183    // (the input Uint8Array may be backed by a SharedArrayBuffer in some184    // bundler typings, which Blob's BlobPart union rejects).185    const owned = new Uint8Array(pngBytes.byteLength);186    owned.set(pngBytes);187    const blob = new Blob([owned], { type: "image/png" });188    const bitmap = await createImageBitmap(blob);189    const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);190    const ctx = canvas.getContext("2d");191    if (!ctx) return null;192    // White-fill before drawing so any transparent pixels in the PNG193    // composite to white rather than (0,0,0,0). Transparent pixels get194    // packed as 0xFF000000 (black) in the ARGB array, which would make195    // the quiet-zone border appear black and confuse ZXing.196    ctx.fillStyle = "#ffffff";197    ctx.fillRect(0, 0, bitmap.width, bitmap.height);198    ctx.drawImage(bitmap, 0, 0);199    const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);200201    // RGBLuminanceSource wants packed 32-bit ARGB values, not raw Uint8 RGBA.202    const argb = new Int32Array(bitmap.width * bitmap.height);203    const raw = imageData.data;204    for (let i = 0, j = 0; i < raw.length; i += 4, j++) {205      argb[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;206    }207208    const source = new RGBLuminanceSource(argb, bitmap.width, bitmap.height);209    const binarizer = new HybridBinarizer(source);210    const bitmapBinary = new BinaryBitmap(binarizer);211212    const reader = new MultiFormatReader();213    const hints = new Map();214    hints.set(DecodeHintType.POSSIBLE_FORMATS, [BarcodeFormat.AZTEC]);215    hints.set(DecodeHintType.TRY_HARDER, true);216    reader.setHints(hints);217218    const result = reader.decode(bitmapBinary);219    return result.getText();220  } catch {221    return null;222  }223}224225// ── Verified Aztec generation ───────────────────────────────────────────────226227/**228 * Generate the Aztec code for one envelope and confirm it round-trips. The229 * envelope JSON is serialized once and reused as the decode target so any230 * encoding mismatch (e.g. UTF-8 surrogate corruption) is caught immediately.231 *232 * In environments where round-trip decoding is unavailable (vitest node), the233 * verification step is skipped — the generator still throws on encoder234 * failure, which is the only condition this path can detect without a DOM.235 */236export async function generateVerifiedAztec(envelope: ChunkEnvelope): Promise<AztecImage> {237  const envelopeJson = JSON.stringify(envelope);238239  let image: AztecImage;240  try {241    image = await generateAztecPng(envelopeJson);242  } catch (err) {243    if (err instanceof CodexError) throw err;244    throw new CodexError(245      "AZTEC_GENERATION_FAILED",246      `Failed to generate Aztec code for chunk ${envelope.i}: ${err instanceof Error ? err.message : String(err)}`,247    );248  }249250  if (!image.pngBytes || image.pngBytes.length < 32) {251    throw new CodexError(252      "AZTEC_GENERATION_FAILED",253      `Aztec PNG for chunk ${envelope.i} was empty`,254    );255  }256257  // Skip round-trip when we can't rasterize — see decodeAztecFromPng().258  if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") {259    return image;260  }261262  // @zxing occasionally returns null or wrong content on a valid barcode due263  // to transient rasterisation variance. Retry up to 5 times on any failure —264  // break immediately on a confirmed correct decode. Track the last non-null265  // wrong result so we can surface diagnostic detail if all attempts fail.266  let decoded: string | null = null;267  let lastMismatch: string | null = null;268  for (let attempt = 0; attempt < 5; attempt++) {269    const candidate = await decodeAztecFromPng(image.pngBytes);270    if (candidate === envelopeJson) { decoded = candidate; break; }271    if (candidate !== null) lastMismatch = candidate;272  }273  if (decoded === null && lastMismatch === null) {274    throw new CodexError(275      "AZTEC_ROUNDTRIP_FAILED",276      `Chunk ${envelope.i} generated but cannot be decoded back after 5 attempts. This Codex cannot be trusted.`,277    );278  }279  if (decoded === null) {280    // lastMismatch is non-null — decoded something but wrong content.281    const el = envelopeJson.length;282    const dl = lastMismatch!.length;283    let diffAt = -1;284    for (let k = 0; k < Math.min(el, dl); k++) {285      if (envelopeJson[k] !== lastMismatch![k]) { diffAt = k; break; }286    }287    const ctx = (s: string) => JSON.stringify(s.slice(Math.max(0, diffAt - 8), diffAt + 20));288    throw new CodexError(289      "AZTEC_CONTENT_MISMATCH",290      `Chunk ${envelope.i}: expected len=${el} decoded len=${dl} first diff at pos ${diffAt}. ` +291        `expected: ${ctx(envelopeJson)} decoded: ${ctx(lastMismatch!)}`,292    );293  }294  return image;295}