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 } as const;125126 try {127 if (isBrowser) {128 const canvas = new OffscreenCanvas(1, 1);129 const drawn = (mod as BwipBrowserModule).toCanvas(canvas, opts);130 const out = drawn as OffscreenCanvas;131 const blob = await out.convertToBlob({ type: "image/png" });132 const pngBytes = new Uint8Array(await blob.arrayBuffer());133 return { pngBytes, width: out.width, height: out.height };134 }135136 const buf = await (mod as BwipNodeModule).toBuffer(opts);137 const pngBytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength).slice();138 const { width, height } = readPngDimensions(pngBytes);139 return { pngBytes, width, height };140 } catch (err) {141 throw new CodexError(142 "AZTEC_GENERATION_FAILED",143 `Aztec render failed: ${err instanceof Error ? err.message : String(err)}`,144 );145 }146}147148149function readPngDimensions(png: Uint8Array): { width: number; height: number } {150 151 if (png.length < 24) {152 throw new CodexError("AZTEC_GENERATION_FAILED", "PNG buffer too small to contain IHDR");153 }154 const view = new DataView(png.buffer, png.byteOffset, png.byteLength);155 const width = view.getUint32(16, false);156 const height = view.getUint32(20, false);157 return { width, height };158}159160161162163164165166167168export async function decodeAztecFromPng(pngBytes: Uint8Array): Promise<string | null> {169 170 171 172 if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") {173 return null;174 }175176 try {177 178 179 180 const owned = new Uint8Array(pngBytes.byteLength);181 owned.set(pngBytes);182 const blob = new Blob([owned], { type: "image/png" });183 const bitmap = await createImageBitmap(blob);184 const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);185 const ctx = canvas.getContext("2d");186 if (!ctx) return null;187 188 189 190 191 ctx.fillStyle = "#ffffff";192 ctx.fillRect(0, 0, bitmap.width, bitmap.height);193 ctx.drawImage(bitmap, 0, 0);194 const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);195196 197 const argb = new Int32Array(bitmap.width * bitmap.height);198 const raw = imageData.data;199 for (let i = 0, j = 0; i < raw.length; i += 4, j++) {200 argb[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;201 }202203 const source = new RGBLuminanceSource(argb, bitmap.width, bitmap.height);204 const binarizer = new HybridBinarizer(source);205 const bitmapBinary = new BinaryBitmap(binarizer);206207 const reader = new MultiFormatReader();208 const hints = new Map();209 hints.set(DecodeHintType.POSSIBLE_FORMATS, [BarcodeFormat.AZTEC]);210 hints.set(DecodeHintType.TRY_HARDER, true);211 reader.setHints(hints);212213 const result = reader.decode(bitmapBinary);214 return result.getText();215 } catch {216 return null;217 }218}219220221222223224225226227228229230231export async function generateVerifiedAztec(envelope: ChunkEnvelope): Promise<AztecImage> {232 const envelopeJson = JSON.stringify(envelope);233234 let image: AztecImage;235 try {236 image = await generateAztecPng(envelopeJson);237 } catch (err) {238 if (err instanceof CodexError) throw err;239 throw new CodexError(240 "AZTEC_GENERATION_FAILED",241 `Failed to generate Aztec code for chunk ${envelope.i}: ${err instanceof Error ? err.message : String(err)}`,242 );243 }244245 if (!image.pngBytes || image.pngBytes.length < 32) {246 throw new CodexError(247 "AZTEC_GENERATION_FAILED",248 `Aztec PNG for chunk ${envelope.i} was empty`,249 );250 }251252 253 if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") {254 return image;255 }256257 258 259 260 let decoded: string | null = null;261 for (let attempt = 0; attempt < 5; attempt++) {262 decoded = await decodeAztecFromPng(image.pngBytes);263 if (decoded !== null) break;264 }265 if (decoded === null) {266 throw new CodexError(267 "AZTEC_ROUNDTRIP_FAILED",268 `Chunk ${envelope.i} generated but cannot be decoded back after 3 attempts. This Codex cannot be trusted.`,269 );270 }271 if (decoded !== envelopeJson) {272 throw new CodexError(273 "AZTEC_CONTENT_MISMATCH",274 `Chunk ${envelope.i} decoded content does not match original envelope. Possible encoding corruption.`,275 );276 }277 return image;278}