123456789101112131415161718192021222324import {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}4748495051525354export 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 );58 const matrix = aztecCode.getMatrix();59 if (!matrix) throw new Error("AztecEncoder returned null matrix");60 return matrix;61}626364656667686970717273export function verifyMatrixRoundTrip(matrix: BitMatrix, payload: string): boolean {74 const modules = matrix.getWidth(); 75 const scale = 4;76 const pad = 2 * scale; 77 const imgW = modules * scale + pad * 2;78 const imgH = imgW;7980 81 const WHITE = -1; 82 const BLACK = (0xff << 24) | 0; 8384 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}110111112113async 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}136137138139interface 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 148 const ns = (await import( "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}171172173function readPngDimensions(png: Uint8Array): { width: number; height: number } {174 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}183184185186187188189190191export async function generateAztecPng(payload: string): Promise<AztecImage> {192 if (typeof OffscreenCanvas !== "undefined") {193 return matrixToPngBrowser(buildAztecMatrix(payload));194 }195 return matrixToPngNode(payload);196}197198199200201202203204205206207208209export 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}249250251252253254255256257258259260261262263264export async function generateVerifiedAztec(envelope: ChunkEnvelope): Promise<AztecImage> {265 const envelopeJson = JSON.stringify(envelope);266267 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 279 if (!verifyMatrixRoundTrip(matrix, envelopeJson)) {280 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 321 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}