aztec.ts

12.7 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  BarcodeFormat,29  DecodeHintType,30  GlobalHistogramBinarizer,31  HybridBinarizer,32  MultiFormatReader,33  RGBLuminanceSource,34} from "@zxing/library";35import type { BitMatrix } from "@zxing/library";36import {37  AZTEC_ECC_PERCENT,38  CodexError,39  type ChunkEnvelope,40} from "../../shared/codex-types";4142export interface AztecImage {43  pngBytes: Uint8Array;44  width: number;45  height: number;46}4748// ── Matrix building ───────────────────────────────────────────────────────────4950/**51 * Encode `payload` (ISO-8859-1 bytes = ASCII for our JSON envelopes) into a52 * ZXing Aztec BitMatrix at 25% ECC with automatic layer selection.53 */54export function buildAztecMatrix(payload: string): BitMatrix {55  const dataBytes = new Uint8Array(payload.length);56  for (let i = 0; i < payload.length; i++) dataBytes[i] = payload.charCodeAt(i);57  const aztecCode = AztecEncoder.encode(dataBytes, AZTEC_ECC_PERCENT, 0 /* auto layers */);58  const matrix = aztecCode.getMatrix();59  if (!matrix) throw new Error("AztecEncoder returned null matrix");60  return matrix;61}6263// ── In-memory round-trip verification ─────────────────────────────────────────6465/**66 * Decode `matrix` in memory and confirm it reads back as `payload`.67 *68 * Builds a pixel array directly from the BitMatrix — no canvas, no PNG, no69 * OffscreenCanvas — so this works in both the browser popup and in vitest's70 * node environment. Uses GlobalHistogramBinarizer (global threshold) which is71 * appropriate for synthetically-perfect images.72 */73export function verifyMatrixRoundTrip(matrix: BitMatrix, payload: string): boolean {74  const modules = matrix.getWidth(); // Aztec codes are always square75  const scale = 4;76  const pad = 2 * scale; // 2-module quiet zone = 8px at scale 477  const imgW = modules * scale + pad * 2;78  const imgH = imgW;7980  // RGBLuminanceSource wants packed 32-bit ARGB as a signed Int32Array.81  const WHITE = -1; // 0xFFFFFFFF as signed int3282  const BLACK = (0xff << 24) | 0; // 0xFF000000 as signed int32 = -167772168384  const argb = new Int32Array(imgW * imgH);85  argb.fill(WHITE);8687  for (let y = 0; y < modules; y++) {88    for (let x = 0; x < modules; x++) {89      if (matrix.get(x, y)) {90        const baseRow = (pad + y * scale) * imgW + pad + x * scale;91        for (let dy = 0; dy < scale; dy++) {92          for (let dx = 0; dx < scale; dx++) {93            argb[baseRow + dy * imgW + dx] = BLACK;94          }95        }96      }97    }98  }99100  try {101    const source = new RGBLuminanceSource(argb, imgW, imgH);102    const binarizer = new GlobalHistogramBinarizer(source);103    const bitmap = new BinaryBitmap(binarizer);104    const reader = new AztecCodeReader();105    return reader.decode(bitmap).getText() === payload;106  } catch {107    return false;108  }109}110111// ── PNG rendering — browser path ──────────────────────────────────────────────112113async function matrixToPngBrowser(matrix: BitMatrix): Promise<AztecImage> {114  const modules = matrix.getWidth();115  const scale = 4;116  const pad = 2 * scale;117  const size = modules * scale + pad * 2;118119  const canvas = new OffscreenCanvas(size, size);120  const ctx = canvas.getContext("2d")!;121  ctx.fillStyle = "#ffffff";122  ctx.fillRect(0, 0, size, size);123  ctx.fillStyle = "#000000";124  for (let y = 0; y < modules; y++) {125    for (let x = 0; x < modules; x++) {126      if (matrix.get(x, y)) {127        ctx.fillRect(pad + x * scale, pad + y * scale, scale, scale);128      }129    }130  }131132  const blob = await canvas.convertToBlob({ type: "image/png" });133  const pngBytes = new Uint8Array(await blob.arrayBuffer());134  return { pngBytes, width: size, height: size };135}136137// ── PNG rendering — node/test path ────────────────────────────────────────────138139interface BwipNodeModule {140  toBuffer: (opts: Record<string, unknown>) => Promise<Buffer>;141}142143let cachedBwipNode: BwipNodeModule | null = null;144145async function loadBwipNode(): Promise<BwipNodeModule> {146  if (cachedBwipNode) return cachedBwipNode;147  // vitest / node only — bwip-js/node uses node:zlib which can't bundle for browser.148  const ns = (await import(/* @vite-ignore */ "bwip-js/node")) as Record<string, unknown>;149  const def = ns["default"] as Record<string, unknown> | undefined;150  const merged = { ...(def ?? {}), ...ns } as unknown as BwipNodeModule;151  cachedBwipNode = merged;152  return merged;153}154155async function matrixToPngNode(payload: string): Promise<AztecImage> {156  const mod = await loadBwipNode();157  const opts = {158    bcid: "azteccode",159    text: payload,160    eclevel: AZTEC_ECC_PERCENT.toString(),161    scale: 4,162    padding: 2,163    monochrome: true,164  } as const;165166  const buf = await mod.toBuffer(opts);167  const pngBytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength).slice();168  const { width, height } = readPngDimensions(pngBytes);169  return { pngBytes, width, height };170}171172/** Read a PNG's IHDR width/height. PNGs always have IHDR as the first chunk. */173function readPngDimensions(png: Uint8Array): { width: number; height: number } {174  // PNG signature: 8 bytes, then 4-byte chunk length, "IHDR", then width/height as big-endian uint32.175  if (png.length < 24) {176    throw new CodexError("AZTEC_GENERATION_FAILED", "PNG buffer too small to contain IHDR");177  }178  const view = new DataView(png.buffer, png.byteOffset, png.byteLength);179  const width = view.getUint32(16, false);180  const height = view.getUint32(20, false);181  return { width, height };182}183184// ── Public: Aztec PNG generation ──────────────────────────────────────────────185186/**187 * Render an Aztec barcode and return its PNG bytes + dimensions.188 * Browser: ZXing BitMatrix → OffscreenCanvas → PNG.189 * Node/test: bwip-js/node → Buffer → PNG.190 */191export async function generateAztecPng(payload: string): Promise<AztecImage> {192  if (typeof OffscreenCanvas !== "undefined") {193    return matrixToPngBrowser(buildAztecMatrix(payload));194  }195  return matrixToPngNode(payload);196}197198// ── Aztec PNG decoding (canvas-based, kept for external callers) ──────────────199200/**201 * Decode an Aztec PNG via @zxing. Returns the decoded UTF-8 text, or `null`202 * if decoding fails for any reason (no DOM, malformed PNG, no match found).203 * Never throws.204 *205 * Note: `generateVerifiedAztec` no longer calls this — it uses the in-memory206 * `verifyMatrixRoundTrip` path instead. This export is kept for tooling that207 * needs to decode an arbitrary Aztec PNG image.208 */209export async function decodeAztecFromPng(pngBytes: Uint8Array): Promise<string | null> {210  if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") {211    return null;212  }213214  try {215    const owned = new Uint8Array(pngBytes.byteLength);216    owned.set(pngBytes);217    const blob = new Blob([owned], { type: "image/png" });218    const bitmap = await createImageBitmap(blob);219    const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);220    const ctx = canvas.getContext("2d");221    if (!ctx) return null;222    ctx.fillStyle = "#ffffff";223    ctx.fillRect(0, 0, bitmap.width, bitmap.height);224    ctx.drawImage(bitmap, 0, 0);225    const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);226227    const argb = new Int32Array(bitmap.width * bitmap.height);228    const raw = imageData.data;229    for (let i = 0, j = 0; i < raw.length; i += 4, j++) {230      argb[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;231    }232233    const source = new RGBLuminanceSource(argb, bitmap.width, bitmap.height);234    const binarizer = new HybridBinarizer(source);235    const bitmapBinary = new BinaryBitmap(binarizer);236237    const reader = new MultiFormatReader();238    const hints = new Map();239    hints.set(DecodeHintType.POSSIBLE_FORMATS, [BarcodeFormat.AZTEC]);240    hints.set(DecodeHintType.TRY_HARDER, true);241    reader.setHints(hints);242243    const result = reader.decode(bitmapBinary);244    return result.getText();245  } catch {246    return null;247  }248}249250// ── Verified Aztec generation ─────────────────────────────────────────────────251252/**253 * Generate the Aztec code for one envelope with in-memory round-trip254 * verification. The verification step is synchronous and DOM-free — it builds255 * the pixel array directly from the ZXing BitMatrix using256 * GlobalHistogramBinarizer, so it works identically in the browser popup and257 * in vitest's node environment (unlike the old canvas-based path which was258 * silently skipped in node).259 *260 * If the encoder produces a matrix that does not decode back to the envelope261 * JSON, we throw immediately — no retry. The ZXing encoder is deterministic;262 * a failure is a hard bug, not a transient error.263 */264export async function generateVerifiedAztec(envelope: ChunkEnvelope): Promise<AztecImage> {265  const envelopeJson = JSON.stringify(envelope);266267  // Step 1: Encode → BitMatrix (pure ZXing JS, no DOM)268  let matrix: BitMatrix;269  try {270    matrix = buildAztecMatrix(envelopeJson);271  } catch (err) {272    throw new CodexError(273      "AZTEC_GENERATION_FAILED",274      `AztecEncoder failed for chunk ${envelope.i}: ${err instanceof Error ? err.message : String(err)}`,275    );276  }277278  // Step 2: In-memory round-trip — verify before committing the PNG279  if (!verifyMatrixRoundTrip(matrix, envelopeJson)) {280    // Compute a diagnostic diff to aid debugging if this ever fires.281    let diagSuffix = "";282    try {283      const modules = matrix.getWidth();284      const scale = 4;285      const pad = 2 * scale;286      const imgW = modules * scale + pad * 2;287      const WHITE = -1;288      const BLACK = (0xff << 24) | 0;289      const argb = new Int32Array(imgW * imgW);290      argb.fill(WHITE);291      for (let y = 0; y < modules; y++)292        for (let x = 0; x < modules; x++)293          if (matrix.get(x, y)) {294            const base = (pad + y * scale) * imgW + pad + x * scale;295            for (let dy = 0; dy < scale; dy++)296              for (let dx = 0; dx < scale; dx++)297                argb[base + dy * imgW + dx] = BLACK;298          }299      const source = new RGBLuminanceSource(argb, imgW, imgW);300      const binarizer = new GlobalHistogramBinarizer(source);301      const bitmap = new BinaryBitmap(binarizer);302      const decoded = new AztecCodeReader().decode(bitmap).getText();303      const el = envelopeJson.length;304      const dl = decoded.length;305      let diffAt = -1;306      for (let k = 0; k < Math.min(el, dl); k++) {307        if (envelopeJson[k] !== decoded[k]) { diffAt = k; break; }308      }309      const ctx = (s: string) => JSON.stringify(s.slice(Math.max(0, diffAt - 8), diffAt + 20));310      diagSuffix = ` decoded len=${dl} expected len=${el} first diff at ${diffAt}: expected ${ctx(envelopeJson)} got ${ctx(decoded)}`;311    } catch (diagErr) {312      diagSuffix = ` (decode threw: ${diagErr instanceof Error ? diagErr.message : String(diagErr)})`;313    }314    throw new CodexError(315      "AZTEC_ROUNDTRIP_FAILED",316      `Chunk ${envelope.i}: in-memory round-trip failed.${diagSuffix}`,317    );318  }319320  // Step 3: Render to PNG321  try {322    if (typeof OffscreenCanvas !== "undefined") {323      return await matrixToPngBrowser(matrix);324    }325    return await matrixToPngNode(envelopeJson);326  } catch (err) {327    if (err instanceof CodexError) throw err;328    throw new CodexError(329      "AZTEC_GENERATION_FAILED",330      `PNG render failed for chunk ${envelope.i}: ${err instanceof Error ? err.message : String(err)}`,331    );332  }333}