123456789101112131415161718import {19 AztecEncoder,20 BarcodeFormat,21 BinaryBitmap,22 DecodeHintType,23 HybridBinarizer,24 MultiFormatReader,25 RGBLuminanceSource,26} from "@zxing/library";27import {28 AZTEC_ECC_PERCENT,29 CodexError,30 type ChunkEnvelope,31} from "../../shared/codex-types";3233export interface AztecImage {34 pngBytes: Uint8Array;35 width: number;36 height: number;37}38394041424344454647async function generateAztecPngBrowser(payload: string): Promise<AztecImage> {48 49 const dataBytes = new Uint8Array(payload.length);50 for (let i = 0; i < payload.length; i++) dataBytes[i] = payload.charCodeAt(i);5152 try {53 const aztecCode = AztecEncoder.encode(dataBytes, AZTEC_ECC_PERCENT, 0 );54 const matrix = aztecCode.getMatrix();55 if (!matrix) throw new Error("encoder returned null matrix");5657 const modules = matrix.getWidth(); 58 const scale = 4;59 const pad = 2 * scale; 60 const size = modules * scale + pad * 2;6162 const canvas = new OffscreenCanvas(size, size);63 const ctx = canvas.getContext("2d")!;64 ctx.fillStyle = "#ffffff";65 ctx.fillRect(0, 0, size, size);66 ctx.fillStyle = "#000000";67 for (let y = 0; y < modules; y++) {68 for (let x = 0; x < modules; x++) {69 if (matrix.get(x, y)) {70 ctx.fillRect(pad + x * scale, pad + y * scale, scale, scale);71 }72 }73 }7475 const blob = await canvas.convertToBlob({ type: "image/png" });76 const pngBytes = new Uint8Array(await blob.arrayBuffer());77 return { pngBytes, width: size, height: size };78 } catch (err) {79 throw new CodexError(80 "AZTEC_GENERATION_FAILED",81 `Aztec render failed: ${err instanceof Error ? err.message : String(err)}`,82 );83 }84}85868788interface BwipNodeModule {89 toBuffer: (opts: Record<string, unknown>) => Promise<Buffer>;90}9192let cachedBwipNode: BwipNodeModule | null = null;9394async function loadBwipNode(): Promise<BwipNodeModule> {95 if (cachedBwipNode) return cachedBwipNode;96 97 const ns = (await import( "bwip-js/node")) as Record<string, unknown>;98 const def = ns["default"] as Record<string, unknown> | undefined;99 const merged = { ...(def ?? {}), ...ns } as unknown as BwipNodeModule;100 cachedBwipNode = merged;101 return merged;102}103104async function generateAztecPngNode(payload: string): Promise<AztecImage> {105 const mod = await loadBwipNode();106 const opts = {107 bcid: "azteccode",108 text: payload,109 eclevel: AZTEC_ECC_PERCENT.toString(),110 scale: 4,111 padding: 2,112 monochrome: true,113 } as const;114115 try {116 const buf = await mod.toBuffer(opts);117 const pngBytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength).slice();118 const { width, height } = readPngDimensions(pngBytes);119 return { pngBytes, width, height };120 } catch (err) {121 throw new CodexError(122 "AZTEC_GENERATION_FAILED",123 `Aztec render failed: ${err instanceof Error ? err.message : String(err)}`,124 );125 }126}127128129function readPngDimensions(png: Uint8Array): { width: number; height: number } {130 131 if (png.length < 24) {132 throw new CodexError("AZTEC_GENERATION_FAILED", "PNG buffer too small to contain IHDR");133 }134 const view = new DataView(png.buffer, png.byteOffset, png.byteLength);135 const width = view.getUint32(16, false);136 const height = view.getUint32(20, false);137 return { width, height };138}139140141142143144145146147export async function generateAztecPng(payload: string): Promise<AztecImage> {148 if (typeof OffscreenCanvas !== "undefined") {149 return generateAztecPngBrowser(payload);150 }151 return generateAztecPngNode(payload);152}153154155156157158159160161162export async function decodeAztecFromPng(pngBytes: Uint8Array): Promise<string | null> {163 164 165 166 if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") {167 return null;168 }169170 try {171 172 173 174 const owned = new Uint8Array(pngBytes.byteLength);175 owned.set(pngBytes);176 const blob = new Blob([owned], { type: "image/png" });177 const bitmap = await createImageBitmap(blob);178 const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);179 const ctx = canvas.getContext("2d");180 if (!ctx) return null;181 182 ctx.fillStyle = "#ffffff";183 ctx.fillRect(0, 0, bitmap.width, bitmap.height);184 ctx.drawImage(bitmap, 0, 0);185 const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);186187 188 const argb = new Int32Array(bitmap.width * bitmap.height);189 const raw = imageData.data;190 for (let i = 0, j = 0; i < raw.length; i += 4, j++) {191 argb[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;192 }193194 const source = new RGBLuminanceSource(argb, bitmap.width, bitmap.height);195 const binarizer = new HybridBinarizer(source);196 const bitmapBinary = new BinaryBitmap(binarizer);197198 const reader = new MultiFormatReader();199 const hints = new Map();200 hints.set(DecodeHintType.POSSIBLE_FORMATS, [BarcodeFormat.AZTEC]);201 hints.set(DecodeHintType.TRY_HARDER, true);202 reader.setHints(hints);203204 const result = reader.decode(bitmapBinary);205 return result.getText();206 } catch {207 return null;208 }209}210211212213214215216217218219220221222export async function generateVerifiedAztec(envelope: ChunkEnvelope): Promise<AztecImage> {223 const envelopeJson = JSON.stringify(envelope);224225 let image: AztecImage;226 try {227 image = await generateAztecPng(envelopeJson);228 } catch (err) {229 if (err instanceof CodexError) throw err;230 throw new CodexError(231 "AZTEC_GENERATION_FAILED",232 `Failed to generate Aztec code for chunk ${envelope.i}: ${err instanceof Error ? err.message : String(err)}`,233 );234 }235236 if (!image.pngBytes || image.pngBytes.length < 32) {237 throw new CodexError(238 "AZTEC_GENERATION_FAILED",239 `Aztec PNG for chunk ${envelope.i} was empty`,240 );241 }242243 244 if (typeof OffscreenCanvas === "undefined" || typeof createImageBitmap === "undefined") {245 return image;246 }247248 249 250 251 let decoded: string | null = null;252 let lastMismatch: string | null = null;253 for (let attempt = 0; attempt < 5; attempt++) {254 const candidate = await decodeAztecFromPng(image.pngBytes);255 if (candidate === envelopeJson) { decoded = candidate; break; }256 if (candidate !== null) lastMismatch = candidate;257 }258 if (decoded === null && lastMismatch === null) {259 throw new CodexError(260 "AZTEC_ROUNDTRIP_FAILED",261 `Chunk ${envelope.i} generated but cannot be decoded back after 5 attempts. This Codex cannot be trusted.`,262 );263 }264 if (decoded === null) {265 266 const el = envelopeJson.length;267 const dl = lastMismatch!.length;268 let diffAt = -1;269 for (let k = 0; k < Math.min(el, dl); k++) {270 if (envelopeJson[k] !== lastMismatch![k]) { diffAt = k; break; }271 }272 const ctx = (s: string) => JSON.stringify(s.slice(Math.max(0, diffAt - 8), diffAt + 20));273 throw new CodexError(274 "AZTEC_CONTENT_MISMATCH",275 `Chunk ${envelope.i}: expected len=${el} decoded len=${dl} first diff at pos ${diffAt}. ` +276 `expected: ${ctx(envelopeJson)} decoded: ${ctx(lastMismatch!)}`,277 );278 }279 return image;280}