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 const spec = isBrowser ? "bwip-js" : "bwip-js" + "/node";81 const ns = (await import( spec)) as Record<string, unknown>;82 const defaultExport = ns["default"] as Record<string, unknown> | undefined;83 84 85 86 87 const merged = { ...(defaultExport ?? {}), ...ns } as Record<string, unknown>;88 const resolved = merged as unknown as BwipBrowserModule | BwipNodeModule;89 cachedBwip = resolved;90 cachedIsBrowser = isBrowser;91 return { mod: resolved, isBrowser };92}93949596979899100101102export async function generateAztecPng(payload: string): Promise<AztecImage> {103 const { mod, isBrowser } = await loadBwip();104 const opts = {105 bcid: "azteccode",106 text: payload,107 eclevel: AZTEC_ECC_PERCENT.toString(),108 scale: 4,109 padding: 0,110 monochrome: true,111 } as const;112113 try {114 if (isBrowser) {115 const canvas = new OffscreenCanvas(1, 1);116 const drawn = (mod as BwipBrowserModule).toCanvas(canvas, opts);117 const out = drawn as OffscreenCanvas;118 const blob = await out.convertToBlob({ type: "image/png" });119 const pngBytes = new Uint8Array(await blob.arrayBuffer());120 return { pngBytes, width: out.width, height: out.height };121 }122123 const buf = await (mod as BwipNodeModule).toBuffer(opts);124 const pngBytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength).slice();125 const { width, height } = readPngDimensions(pngBytes);126 return { pngBytes, width, height };127 } catch (err) {128 throw new CodexError(129 "AZTEC_GENERATION_FAILED",130 `Aztec render failed: ${err instanceof Error ? err.message : String(err)}`,131 );132 }133}134135136function readPngDimensions(png: Uint8Array): { width: number; height: number } {137 138 if (png.length < 24) {139 throw new CodexError("AZTEC_GENERATION_FAILED", "PNG buffer too small to contain IHDR");140 }141 const view = new DataView(png.buffer, png.byteOffset, png.byteLength);142 const width = view.getUint32(16, false);143 const height = view.getUint32(20, false);144 return { width, height };145}146147148149150151152153154155export async function decodeAztecFromPng(pngBytes: Uint8Array): Promise<string | null> {156 157 158 159 if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") {160 return null;161 }162163 try {164 165 166 167 const owned = new Uint8Array(pngBytes.byteLength);168 owned.set(pngBytes);169 const blob = new Blob([owned], { type: "image/png" });170 const bitmap = await createImageBitmap(blob);171 const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);172 const ctx = canvas.getContext("2d");173 if (!ctx) return null;174 ctx.drawImage(bitmap, 0, 0);175 const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);176177 178 const argb = new Int32Array(bitmap.width * bitmap.height);179 const raw = imageData.data;180 for (let i = 0, j = 0; i < raw.length; i += 4, j++) {181 argb[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;182 }183184 const source = new RGBLuminanceSource(argb, bitmap.width, bitmap.height);185 const binarizer = new HybridBinarizer(source);186 const bitmapBinary = new BinaryBitmap(binarizer);187188 const reader = new MultiFormatReader();189 const hints = new Map();190 hints.set(DecodeHintType.POSSIBLE_FORMATS, [BarcodeFormat.AZTEC]);191 hints.set(DecodeHintType.TRY_HARDER, true);192 reader.setHints(hints);193194 const result = reader.decode(bitmapBinary);195 return result.getText();196 } catch {197 return null;198 }199}200201202203204205206207208209210211212export async function generateVerifiedAztec(envelope: ChunkEnvelope): Promise<AztecImage> {213 const envelopeJson = JSON.stringify(envelope);214215 let image: AztecImage;216 try {217 image = await generateAztecPng(envelopeJson);218 } catch (err) {219 if (err instanceof CodexError) throw err;220 throw new CodexError(221 "AZTEC_GENERATION_FAILED",222 `Failed to generate Aztec code for chunk ${envelope.i}: ${err instanceof Error ? err.message : String(err)}`,223 );224 }225226 if (!image.pngBytes || image.pngBytes.length < 32) {227 throw new CodexError(228 "AZTEC_GENERATION_FAILED",229 `Aztec PNG for chunk ${envelope.i} was empty`,230 );231 }232233 234 if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") {235 return image;236 }237238 239 240 241 let decoded: string | null = null;242 for (let attempt = 0; attempt < 3; attempt++) {243 decoded = await decodeAztecFromPng(image.pngBytes);244 if (decoded !== null) break;245 }246 if (decoded === null) {247 throw new CodexError(248 "AZTEC_ROUNDTRIP_FAILED",249 `Chunk ${envelope.i} generated but cannot be decoded back after 3 attempts. This Codex cannot be trusted.`,250 );251 }252 if (decoded !== envelopeJson) {253 throw new CodexError(254 "AZTEC_CONTENT_MISMATCH",255 `Chunk ${envelope.i} decoded content does not match original envelope. Possible encoding corruption.`,256 );257 }258 return image;259}