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 110 111 112 padding: 2,113 monochrome: true,114 } as const;115116 try {117 if (isBrowser) {118 const canvas = new OffscreenCanvas(1, 1);119 const drawn = (mod as BwipBrowserModule).toCanvas(canvas, opts);120 const out = drawn as OffscreenCanvas;121 const blob = await out.convertToBlob({ type: "image/png" });122 const pngBytes = new Uint8Array(await blob.arrayBuffer());123 return { pngBytes, width: out.width, height: out.height };124 }125126 const buf = await (mod as BwipNodeModule).toBuffer(opts);127 const pngBytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength).slice();128 const { width, height } = readPngDimensions(pngBytes);129 return { pngBytes, width, height };130 } catch (err) {131 throw new CodexError(132 "AZTEC_GENERATION_FAILED",133 `Aztec render failed: ${err instanceof Error ? err.message : String(err)}`,134 );135 }136}137138139function readPngDimensions(png: Uint8Array): { width: number; height: number } {140 141 if (png.length < 24) {142 throw new CodexError("AZTEC_GENERATION_FAILED", "PNG buffer too small to contain IHDR");143 }144 const view = new DataView(png.buffer, png.byteOffset, png.byteLength);145 const width = view.getUint32(16, false);146 const height = view.getUint32(20, false);147 return { width, height };148}149150151152153154155156157158export async function decodeAztecFromPng(pngBytes: Uint8Array): Promise<string | null> {159 160 161 162 if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") {163 return null;164 }165166 try {167 168 169 170 const owned = new Uint8Array(pngBytes.byteLength);171 owned.set(pngBytes);172 const blob = new Blob([owned], { type: "image/png" });173 const bitmap = await createImageBitmap(blob);174 const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);175 const ctx = canvas.getContext("2d");176 if (!ctx) return null;177 178 179 180 181 ctx.fillStyle = "#ffffff";182 ctx.fillRect(0, 0, bitmap.width, bitmap.height);183 ctx.drawImage(bitmap, 0, 0);184 const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);185186 187 const argb = new Int32Array(bitmap.width * bitmap.height);188 const raw = imageData.data;189 for (let i = 0, j = 0; i < raw.length; i += 4, j++) {190 argb[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;191 }192193 const source = new RGBLuminanceSource(argb, bitmap.width, bitmap.height);194 const binarizer = new HybridBinarizer(source);195 const bitmapBinary = new BinaryBitmap(binarizer);196197 const reader = new MultiFormatReader();198 const hints = new Map();199 hints.set(DecodeHintType.POSSIBLE_FORMATS, [BarcodeFormat.AZTEC]);200 hints.set(DecodeHintType.TRY_HARDER, true);201 reader.setHints(hints);202203 const result = reader.decode(bitmapBinary);204 return result.getText();205 } catch {206 return null;207 }208}209210211212213214215216217218219220221export async function generateVerifiedAztec(envelope: ChunkEnvelope): Promise<AztecImage> {222 const envelopeJson = JSON.stringify(envelope);223224 let image: AztecImage;225 try {226 image = await generateAztecPng(envelopeJson);227 } catch (err) {228 if (err instanceof CodexError) throw err;229 throw new CodexError(230 "AZTEC_GENERATION_FAILED",231 `Failed to generate Aztec code for chunk ${envelope.i}: ${err instanceof Error ? err.message : String(err)}`,232 );233 }234235 if (!image.pngBytes || image.pngBytes.length < 32) {236 throw new CodexError(237 "AZTEC_GENERATION_FAILED",238 `Aztec PNG for chunk ${envelope.i} was empty`,239 );240 }241242 243 if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") {244 return image;245 }246247 248 249 250 let decoded: string | null = null;251 for (let attempt = 0; attempt < 5; attempt++) {252 decoded = await decodeAztecFromPng(image.pngBytes);253 if (decoded !== null) break;254 }255 if (decoded === null) {256 throw new CodexError(257 "AZTEC_ROUNDTRIP_FAILED",258 `Chunk ${envelope.i} generated but cannot be decoded back after 3 attempts. This Codex cannot be trusted.`,259 );260 }261 if (decoded !== envelopeJson) {262 throw new CodexError(263 "AZTEC_CONTENT_MISMATCH",264 `Chunk ${envelope.i} decoded content does not match original envelope. Possible encoding corruption.`,265 );266 }267 return image;268}