123456789101112@zxing131415161718192021import {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}4041424344454647484950515253interface 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 77 78 79 80 let ns: Record<string, unknown>;81 if (isBrowser) {82 83 84 85 86 ns = (await import("bwip-js")) as Record<string, unknown>;87 } else {88 89 ns = (await import( "bwip-js/node")) as Record<string, unknown>;90 }9192 const defaultExport = ns["default"] as Record<string, unknown> | undefined;93 94 95 96 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}103104105106107108109110111112export 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 120 121 122 padding: 2,123 monochrome: true,124 125 126 127 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}152153154function readPngDimensions(png: Uint8Array): { width: number; height: number } {155 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}164165166167168169170171172173export async function decodeAztecFromPng(pngBytes: Uint8Array): Promise<string | null> {174 175 176 177 if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") {178 return null;179 }180181 try {182 183 184 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 193 194 195 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 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}224225226227228229230231232233234235236export 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 258 if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") {259 return image;260 }261262 263 264 265 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 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}