From bea0f5cf03cb36d79088b211a6d24366d2f8d040 Mon Sep 17 00:00:00 2001 From: infrost Date: Wed, 8 Jul 2026 03:20:34 +0100 Subject: [PATCH] delete js-qrcode dependencies (no needed anymore) --- package.json | 3 +- pnpm-lock.yaml | 8 - scripts/copy-cli-wasm-assets.mjs | 19 +++ src/cli/qr-stream.ts | 3 +- src/core/qr/fast_qr_wasm.ts | 9 +- src/core/qr/frame_raster.ts | 2 +- src/core/qr/qr_encode.ts | 70 +-------- src/core/qr/qr_encoder.ts | 62 +------- src/core/qr/qr_encoder_browser.ts | 80 ++++++---- src/core/qr/qr_encoder_node.ts | 148 ++++++++++++++++++ src/core/wasm/node_assets.ts | 63 ++++++++ src/fast_qr_wasm/build_fast_qr_wasm_colab.py | 142 ++++++++++++++--- src/fast_qr_wasm/wasm/manifest.json | 2 +- .../wasm/qrstream_fast_qr_wasm.d.ts | 47 +++++- .../wasm/qrstream_fast_qr_wasm.js | 125 +++++++++++++-- .../wasm/qrstream_fast_qr_wasm_bg.wasm | Bin 54677 -> 57425 bytes .../wasm/qrstream_fast_qr_wasm_bg.wasm.d.ts | 7 + src/tests/cli_rendering.test.ts | 15 +- src/tests/frame_decode.test.ts | 13 +- src/tests/gif_roundtrip.test.ts | 8 +- src/tests/prod_roundtrip.test.ts | 6 +- src/tests/test_qr_modules.test.ts | 88 ++++++++--- src/workers/qr_render.worker.ts | 27 ++-- 23 files changed, 680 insertions(+), 267 deletions(-) create mode 100644 scripts/copy-cli-wasm-assets.mjs create mode 100644 src/core/qr/qr_encoder_node.ts create mode 100644 src/core/wasm/node_assets.ts diff --git a/package.json b/package.json index 661f82b..7e7a460 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "verify:raptorq-wasm": "node scripts/verify-raptorq-wasm.mjs", "cli": "bun run src/cli/qr-stream.ts", "cli:file": "bun run src/cli/qr-stream.ts", - "build:cli": "esbuild src/cli/qr-stream.ts --bundle --platform=node --target=node18 --outfile=dist/qr-stream.js --format=esm --external:node:*", + "build:cli": "esbuild src/cli/qr-stream.ts --bundle --platform=node --target=node18 --outfile=dist/qr-stream.js --format=esm --external:node:* && node scripts/copy-cli-wasm-assets.mjs", "prepublishOnly": "pnpm run build" }, "keywords": [ @@ -65,7 +65,6 @@ "fflate": "^0.8.2", "gifenc": "^1.0.3", "preact": "10.29.4", - "qrcode-generator": "1", "zxing-wasm": "^3.1.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f31f53..c8dc75c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,6 @@ importers: preact: specifier: 10.29.4 version: 10.29.4 - qrcode-generator: - specifier: '1' - version: 1.5.2 zxing-wasm: specifier: ^3.1.0 version: 3.1.0(@types/emscripten@1.41.5) @@ -904,9 +901,6 @@ packages: preact@10.29.4: resolution: {integrity: sha512-GMpwh9+NJ8tSmqwIaVyFRQkiKfBEzQ+k7r7tle4W+kaJ+7wJiB9hFz9BixAomMtenPPSBfM4bZhXozGxhf0uFQ==} - qrcode-generator@1.5.2: - resolution: {integrity: sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==} - rollup@4.62.2: resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1830,8 +1824,6 @@ snapshots: preact@10.29.4: {} - qrcode-generator@1.5.2: {} - rollup@4.62.2: dependencies: '@types/estree': 1.0.9 diff --git a/scripts/copy-cli-wasm-assets.mjs b/scripts/copy-cli-wasm-assets.mjs new file mode 100644 index 0000000..5326ecd --- /dev/null +++ b/scripts/copy-cli-wasm-assets.mjs @@ -0,0 +1,19 @@ +import { copyFileSync, mkdirSync } from 'node:fs'; +import { basename, join } from 'node:path'; + +const root = process.cwd(); +const dist = join(root, 'dist'); + +const assets = [ + { + source: join(root, 'src', 'fast_qr_wasm', 'wasm', 'qrstream_fast_qr_wasm_bg.wasm'), + target: join(dist, 'qrstream_fast_qr_wasm_bg.wasm'), + }, +]; + +mkdirSync(dist, { recursive: true }); + +for (const asset of assets) { + copyFileSync(asset.source, asset.target); + console.log(`copied ${basename(asset.target)}`); +} diff --git a/src/cli/qr-stream.ts b/src/cli/qr-stream.ts index 2cf3ad9..918de5b 100644 --- a/src/cli/qr-stream.ts +++ b/src/cli/qr-stream.ts @@ -17,7 +17,7 @@ import { readFileSync, existsSync, openSync, closeSync } from 'fs'; import { ReadStream } from 'tty'; -import { COMPATIBLE_QR_ENCODER, encodeQRCodeMatrix } from '../core/qr/qr_encoder'; +import { encodeQRCodeMatrix } from '../core/qr/qr_encoder_node'; import { packetize } from '../core/sender/packetizer'; import { scheduleFrames } from '../core/sender/scheduler'; import { QR_VERSION, ECC_LEVEL } from '../core/protocol/constants'; @@ -232,7 +232,6 @@ async function main() { pkt, QR_VERSION, ECC_LEVEL, - COMPATIBLE_QR_ENCODER, ); frames.push(renderToTerminal(matrix)); } diff --git a/src/core/qr/fast_qr_wasm.ts b/src/core/qr/fast_qr_wasm.ts index 486d2d9..0fc1590 100644 --- a/src/core/qr/fast_qr_wasm.ts +++ b/src/core/qr/fast_qr_wasm.ts @@ -4,11 +4,10 @@ * Each Worker that needs a `QrRenderer` should call `ensureFastQrWasm()` once, * then instantiate `new QrRenderer()` from this module. The WASM linear memory * is exposed via `getFastQrWasmMemory()` so callers can build a zero-copy - * `Uint8ClampedArray` view after each `render()` call. + * view after each `render_rgba()` or `render_matrix()` call. * - * If the WASM artifacts have not been built yet (stub JS throws on init) every - * call silently falls back — callers should check `isFastQrAvailable()` and use - * the JS QR path when it returns false. + * If the WASM artifacts have not been built yet (stub JS throws on init), + * callers surface a controlled unavailable error. * * @module */ @@ -51,7 +50,7 @@ export async function ensureFastQrWasm(): Promise { /** * Returns true after `ensureFastQrWasm()` has resolved successfully. - * Use this to decide whether to use the WASM or JS fallback path. + * Use this for diagnostics and tests. */ export function isFastQrAvailable(): boolean { return wasmOutput !== null; diff --git a/src/core/qr/frame_raster.ts b/src/core/qr/frame_raster.ts index 2dbfa59..eb1b014 100644 --- a/src/core/qr/frame_raster.ts +++ b/src/core/qr/frame_raster.ts @@ -29,7 +29,7 @@ function calcPixelSize(moduleCount: number, scale: number): number { * A 4-module quiet zone (white) is added on all four sides. * Output is pure black (0,0,0,255) and white (255,255,255,255). * - * @param matrix QR module matrix from `generateQRMatrix` + * @param matrix QR module matrix where `true` is a dark module * @param scale Pixels per module (default 3) * @returns RGBA `ImageData` (non-premultiplied, 4 bytes per pixel) */ diff --git a/src/core/qr/qr_encode.ts b/src/core/qr/qr_encode.ts index 322b5cc..d8a925f 100644 --- a/src/core/qr/qr_encode.ts +++ b/src/core/qr/qr_encode.ts @@ -1,13 +1,11 @@ /** - * QR code generation wrapper. + * QR capacity helpers. * - * Uses the `qrcode-generator` library to create QR codes in byte mode - * with exact version and ECC level. Throws if the data payload exceeds - * the capacity of the requested version/ECC combination. + * The actual QR symbol writers live in `qr_encoder_browser.ts` and + * `qr_encoder_node.ts`; this module only keeps the shared capacity math used + * by transfer profiles and packet sizing. */ -import qrcode from 'qrcode-generator'; - // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -15,7 +13,7 @@ import qrcode from 'qrcode-generator'; export type EccLevel = 'L' | 'M' | 'Q' | 'H'; // --------------------------------------------------------------------------- -// RS block table (copied from qrcode-generator's internal RS_BLOCK_TABLE) +// RS block table // Layout per version: [L-entry, M-entry, Q-entry, H-entry] // Each entry is a flat array of [count, totalCodewords, dataCodewords, …] // repeating for each block-group type. @@ -125,7 +123,7 @@ export function getMaxByteCapacity(version: number, eccLevel: EccLevel): number /** * Maximum raw bytes accepted by `zxing-wasm/writer` when the input is a * Uint8Array. ZXing emits a binary ECI segment for byte input, which consumes - * two extra QR codewords versus the legacy qrcode-generator Byte-mode path. + * two extra QR codewords versus the plain Byte-mode path used by fast_qr. */ export function getMaxZXingWriterByteCapacity( version: number, @@ -179,59 +177,3 @@ function getTotalDataCodewords(version: number, eccLevel: EccLevel): number { return totalDataCodewords; } -// --------------------------------------------------------------------------- -// QR generation -// --------------------------------------------------------------------------- - -/** - * Generate a QR code symbol in Byte mode for the given raw data. - * - * @param data The raw bytes to encode - * @param version QR code version (1 – 40) - * @param eccLevel Error correction level - * @returns A 2-D boolean array where `true` = black module, `false` = white - * @throws `Error` if `data` exceeds the maximum payload for the requested - * version and ECC level. - */ -export function generateQRMatrix( - data: Uint8Array, - version: number, - eccLevel: EccLevel, -): boolean[][] { - if (version < 1 || version > 40) { - throw new Error(`Invalid QR version: ${version}. Must be 1-40.`); - } - - // Capacity check - const maxBytes = getMaxByteCapacity(version, eccLevel); - if (data.length > maxBytes) { - const minVer = getMinVersion(data.length, eccLevel); - throw new Error( - `Data too large for V${version}-${eccLevel}. ` + - `Maximum ${maxBytes} data bytes in byte mode, got ${data.length}. ` + - `Minimum required version: V${minVer}.`, - ); - } - - // Convert Uint8Array → string (lossless for bytes 0-255) - // The library's default stringToBytes does s.charCodeAt(i) & 0xff, - // which preserves byte values through the string encoding. - const dataStr = String.fromCharCode(...data); - - const qr = qrcode(version as any, eccLevel); - qr.addData(dataStr, 'Byte'); - qr.make(); - - const moduleCount = qr.getModuleCount(); - const matrix: boolean[][] = []; - - for (let row = 0; row < moduleCount; row++) { - const rowArr: boolean[] = []; - for (let col = 0; col < moduleCount; col++) { - rowArr.push(qr.isDark(row, col)); - } - matrix.push(rowArr); - } - - return matrix; -} diff --git a/src/core/qr/qr_encoder.ts b/src/core/qr/qr_encoder.ts index f3f6f2f..7bb61c8 100644 --- a/src/core/qr/qr_encoder.ts +++ b/src/core/qr/qr_encoder.ts @@ -9,22 +9,17 @@ * - This file must stay browser-WASM-free. * - Browser-only encoders such as ZXing WASM and fast_qr WASM are wired in * `qr_encoder_browser.ts`. - * - Node/CLI-safe fallback is always `js-qrcode`. + * - Node/CLI WASM loading is wired in `qr_encoder_node.ts`. */ -import { rasterizeQR } from './frame_raster'; -import { generateQRMatrix, type EccLevel } from './qr_encode'; - export const QR_ENCODERS = [ 'fast-qr-wasm', 'zxing-wasm', - 'js-qrcode', ] as const; export type QREncoder = typeof QR_ENCODERS[number]; export const DEFAULT_QR_ENCODER: QREncoder = 'fast-qr-wasm'; -export const COMPATIBLE_QR_ENCODER: QREncoder = 'js-qrcode'; export function normalizeQREncoder(value: unknown): QREncoder { switch (value) { @@ -38,11 +33,6 @@ export function normalizeQREncoder(value: unknown): QREncoder { case 'zxingWasm': return 'zxing-wasm'; - case 'js-qrcode': - case 'js': - case 'jsQRCode': - return 'js-qrcode'; - default: return DEFAULT_QR_ENCODER; } @@ -54,55 +44,5 @@ export function formatQREncoder(encoder: QREncoder): string { return 'fast_qr WASM'; case 'zxing-wasm': return 'ZXing WASM'; - case 'js-qrcode': - return 'JS QR'; } } - -export function encodeQRCodeMatrixWithJS( - data: Uint8Array, - version: number, - eccLevel: EccLevel, -): boolean[][] { - return generateQRMatrix(data, version, eccLevel); -} - -export function renderQRCodeImageDataWithJS( - data: Uint8Array, - version: number, - eccLevel: EccLevel, - scale: number, -): ImageData { - return rasterizeQR(generateQRMatrix(data, version, eccLevel), scale); -} - -export async function encodeQRCodeMatrix( - data: Uint8Array, - version: number, - eccLevel: EccLevel, - encoder: QREncoder = COMPATIBLE_QR_ENCODER, -): Promise { - if (encoder !== 'js-qrcode') { - throw new Error( - `${formatQREncoder(encoder)} is only available through qr_encoder_browser.`, - ); - } - - return encodeQRCodeMatrixWithJS(data, version, eccLevel); -} - -export async function renderQRCodeImageData( - data: Uint8Array, - version: number, - eccLevel: EccLevel, - scale: number, - encoder: QREncoder = COMPATIBLE_QR_ENCODER, -): Promise { - if (encoder !== 'js-qrcode') { - throw new Error( - `${formatQREncoder(encoder)} is only available through qr_encoder_browser.`, - ); - } - - return renderQRCodeImageDataWithJS(data, version, eccLevel, scale); -} \ No newline at end of file diff --git a/src/core/qr/qr_encoder_browser.ts b/src/core/qr/qr_encoder_browser.ts index 657e943..8189561 100644 --- a/src/core/qr/qr_encoder_browser.ts +++ b/src/core/qr/qr_encoder_browser.ts @@ -2,15 +2,11 @@ * Browser QR encoder facade. * * This file is allowed to import browser-only WASM encoders. Node/CLI code - * should import `qr_encoder.ts` so esbuild does not pull browser-only wasm + * should import `qr_encoder_node.ts` so esbuild does not pull browser-only wasm * assets into the terminal bundle. */ -import { - type QREncoder, - encodeQRCodeMatrixWithJS, - renderQRCodeImageDataWithJS, -} from './qr_encoder'; +import { type QREncoder } from './qr_encoder'; import type { EccLevel } from './qr_encode'; import { generateQRMatrixWithZXing, @@ -21,6 +17,7 @@ import { isFastQrAvailable, getFastQrWasmMemory, QrRenderer, + fastQrUnavailableMessage, } from './fast_qr_wasm'; export * from './qr_encoder'; @@ -32,7 +29,7 @@ const ECC_TO_NUM: Record = { H: 3, }; -let fastQrRendererPromise: Promise | null = null; +let fastQrRendererPromise: Promise | null = null; export async function encodeQRCodeMatrix( data: Uint8Array, @@ -42,12 +39,7 @@ export async function encodeQRCodeMatrix( ): Promise { switch (encoder) { case 'fast-qr-wasm': - // fast_qr path currently exposes a raster renderer, not a matrix API. - // For matrix callers, use the existing JS encoder as a safe fallback. - return encodeQRCodeMatrixWithJS(data, version, eccLevel); - - case 'js-qrcode': - return encodeQRCodeMatrixWithJS(data, version, eccLevel); + return encodeQRCodeMatrixWithFastQr(data, version, eccLevel); case 'zxing-wasm': return generateQRMatrixWithZXing(data, version, eccLevel); @@ -65,22 +57,27 @@ export async function renderQRCodeImageData( case 'fast-qr-wasm': return renderQRCodeImageDataWithFastQr(data, version, eccLevel, scale); - case 'js-qrcode': - return renderQRCodeImageDataWithJS(data, version, eccLevel, scale); - case 'zxing-wasm': return renderQRCodeImageDataWithZXing(data, version, eccLevel, scale); } } -async function getFastQrRenderer(): Promise { +async function getFastQrRenderer(): Promise { if (!fastQrRendererPromise) { fastQrRendererPromise = ensureFastQrWasm() .then(() => new QrRenderer()) - .catch(() => null); + .catch((err: unknown) => { + fastQrRendererPromise = null; + const message = err instanceof Error ? err.message : String(err); + throw new Error(`${fastQrUnavailableMessage()} ${message}`); + }); } - return fastQrRendererPromise; + const renderer = await fastQrRendererPromise; + if (!renderer || !isFastQrAvailable()) { + throw new Error(fastQrUnavailableMessage()); + } + return renderer; } async function renderQRCodeImageDataWithFastQr( @@ -90,21 +87,50 @@ async function renderQRCodeImageDataWithFastQr( scale: number, ): Promise { const renderer = await getFastQrRenderer(); - - if (!renderer || !isFastQrAvailable()) { - return renderQRCodeImageDataWithJS(data, version, eccLevel, scale); - } - const eccNum = ECC_TO_NUM[eccLevel]; - const sidePx = renderer.render(data, version, eccNum, scale); + const sidePx = renderer.render_rgba(data, version, eccNum, scale); const byteLen = sidePx * sidePx * 4; const memory = getFastQrWasmMemory(); - const ptr = renderer.buf_ptr(); + const ptr = renderer.rgba_ptr(); const view = new Uint8ClampedArray(memory.buffer, ptr, byteLen); const copy = new Uint8ClampedArray(byteLen); copy.set(view); return new ImageData(copy, sidePx, sidePx); -} \ No newline at end of file +} + +async function encodeQRCodeMatrixWithFastQr( + data: Uint8Array, + version: number, + eccLevel: EccLevel, +): Promise { + const renderer = await getFastQrRenderer(); + const eccNum = ECC_TO_NUM[eccLevel]; + const sideModules = renderer.render_matrix(data, version, eccNum); + const byteLen = sideModules * sideModules; + + const memory = getFastQrWasmMemory(); + const ptr = renderer.matrix_ptr(); + const modules = new Uint8Array(memory.buffer, ptr, byteLen); + + return matrixFromModuleBytes(modules, sideModules); +} + +function matrixFromModuleBytes(modules: Uint8Array, sideModules: number): boolean[][] { + const matrix: boolean[][] = []; + + for (let row = 0; row < sideModules; row++) { + const outRow: boolean[] = []; + const rowOffset = row * sideModules; + + for (let col = 0; col < sideModules; col++) { + outRow.push(modules[rowOffset + col] === 1); + } + + matrix.push(outRow); + } + + return matrix; +} diff --git a/src/core/qr/qr_encoder_node.ts b/src/core/qr/qr_encoder_node.ts new file mode 100644 index 0000000..e0791c4 --- /dev/null +++ b/src/core/qr/qr_encoder_node.ts @@ -0,0 +1,148 @@ +/** + * Node/CLI QR encoder facade. + * + * The web app uses `qr_encoder_browser.ts`, where Vite owns WASM URL loading. + * The CLI uses this file instead so fast_qr's wasm-bindgen module can be + * initialised from filesystem bytes. + */ + +import { + QrRenderer, + initSync, + type InitOutput, +} from '../../fast_qr_wasm/wasm/qrstream_fast_qr_wasm.js'; +import { + DEFAULT_QR_ENCODER, + formatQREncoder, + type QREncoder, +} from './qr_encoder'; +import type { EccLevel } from './qr_encode'; +import { readNodeWasmAsset } from '@/core/wasm/node_assets'; + +export * from './qr_encoder'; + +export const DEFAULT_CLI_QR_ENCODER: QREncoder = DEFAULT_QR_ENCODER; + +const FAST_QR_WASM_ASSET = { + distFileName: 'qrstream_fast_qr_wasm_bg.wasm', + sourceRelativePath: 'src/fast_qr_wasm/wasm/qrstream_fast_qr_wasm_bg.wasm', + envVar: 'QRSTREAM_FAST_QR_WASM', +}; + +const ECC_TO_NUM: Record = { + L: 0, + M: 1, + Q: 2, + H: 3, +}; + +let fastQrInitOutput: InitOutput | null = null; +let fastQrRenderer: QrRenderer | null = null; +let fastQrInitFailed = false; + +export function isFastQrNodeAvailable(): boolean { + try { + getFastQrNodeRenderer(); + return true; + } catch { + return false; + } +} + +export async function encodeQRCodeMatrix( + data: Uint8Array, + version: number, + eccLevel: EccLevel, + encoder: QREncoder = DEFAULT_CLI_QR_ENCODER, +): Promise { + switch (encoder) { + case 'fast-qr-wasm': + return encodeQRCodeMatrixWithFastQr(data, version, eccLevel); + + case 'zxing-wasm': + throw new Error( + `${formatQREncoder(encoder)} is not available in the CLI yet. ` + + 'Use fast_qr WASM.', + ); + } +} + +export async function renderQRCodeImageData( + data: Uint8Array, + version: number, + eccLevel: EccLevel, + scale: number, + encoder: QREncoder = DEFAULT_CLI_QR_ENCODER, +): Promise { + if (encoder === 'zxing-wasm') { + throw new Error( + `${formatQREncoder(encoder)} is not available in the CLI yet. ` + + 'Use fast_qr WASM.', + ); + } + + const renderer = getFastQrNodeRenderer(); + const eccNum = ECC_TO_NUM[eccLevel]; + const sidePx = renderer.render_rgba(data, version, eccNum, scale); + const byteLen = sidePx * sidePx * 4; + const view = new Uint8ClampedArray( + fastQrInitOutput!.memory.buffer, + renderer.rgba_ptr(), + byteLen, + ); + const copy = new Uint8ClampedArray(byteLen); + copy.set(view); + return new ImageData(copy, sidePx, sidePx); +} + +function encodeQRCodeMatrixWithFastQr( + data: Uint8Array, + version: number, + eccLevel: EccLevel, +): boolean[][] { + const renderer = getFastQrNodeRenderer(); + const eccNum = ECC_TO_NUM[eccLevel]; + const sideModules = renderer.render_matrix(data, version, eccNum); + const byteLen = sideModules * sideModules; + const modules = new Uint8Array( + fastQrInitOutput.memory.buffer, + renderer.matrix_ptr(), + byteLen, + ); + + return matrixFromModuleBytes(modules, sideModules); +} + +function getFastQrNodeRenderer(): QrRenderer { + if (fastQrRenderer) return fastQrRenderer; + if (fastQrInitFailed) { + throw new Error('fast_qr WASM is unavailable in Node.'); + } + + try { + const wasmBytes = readNodeWasmAsset(FAST_QR_WASM_ASSET); + fastQrInitOutput = initSync({ module: wasmBytes }); + fastQrRenderer = new QrRenderer(); + return fastQrRenderer; + } catch (err) { + fastQrInitFailed = true; + throw err instanceof Error ? err : new Error(String(err)); + } +} + +function matrixFromModuleBytes(modules: Uint8Array, sideModules: number): boolean[][] { + const matrix: boolean[][] = []; + + for (let row = 0; row < sideModules; row++) { + const outRow: boolean[] = []; + const rowOffset = row * sideModules; + + for (let col = 0; col < sideModules; col++) { + outRow.push(modules[rowOffset + col] === 1); + } + + matrix.push(outRow); + } + + return matrix; +} diff --git a/src/core/wasm/node_assets.ts b/src/core/wasm/node_assets.ts new file mode 100644 index 0000000..3c60db4 --- /dev/null +++ b/src/core/wasm/node_assets.ts @@ -0,0 +1,63 @@ +/** + * Node-only WASM asset resolution. + * + * Browser builds let Vite rewrite `*.wasm?url` imports. The CLI is bundled as + * a plain Node ESM file, so WASM sidecars need an explicit filesystem lookup. + * Keep this generic so future CLI ZXing/RaptorQ loaders can reuse the same + * package-relative asset convention. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, parse, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export interface NodeWasmAsset { + /** File copied next to the bundled CLI entrypoint in `dist/`. */ + distFileName: string; + /** Repo/package-relative source path kept for dev and npm source fallback. */ + sourceRelativePath: string; + /** Optional absolute path override for local debugging. */ + envVar?: string; +} + +export function readNodeWasmAsset(asset: NodeWasmAsset): Uint8Array { + for (const candidate of nodeWasmAssetCandidates(asset)) { + if (existsSync(candidate)) { + return new Uint8Array(readFileSync(candidate)); + } + } + + throw new Error( + `WASM asset not found: ${asset.distFileName}. ` + + `Expected it next to the CLI bundle or at ${asset.sourceRelativePath}.`, + ); +} + +export function nodeWasmAssetCandidates(asset: NodeWasmAsset): string[] { + const here = dirname(fileURLToPath(import.meta.url)); + const candidates: Array = [ + asset.envVar ? process.env[asset.envVar] : undefined, + resolve(here, asset.distFileName), + resolve(process.cwd(), 'dist', asset.distFileName), + resolve(process.cwd(), asset.sourceRelativePath), + findUp(here, asset.sourceRelativePath), + ]; + + return dedupe(candidates.filter((value): value is string => Boolean(value))); +} + +function findUp(startDir: string, relativePath: string): string | undefined { + let dir = resolve(startDir); + const root = parse(dir).root; + + while (true) { + const candidate = resolve(dir, relativePath); + if (existsSync(candidate)) return candidate; + if (dir === root) return undefined; + dir = dirname(dir); + } +} + +function dedupe(values: string[]): string[] { + return Array.from(new Set(values)); +} diff --git a/src/fast_qr_wasm/build_fast_qr_wasm_colab.py b/src/fast_qr_wasm/build_fast_qr_wasm_colab.py index 8fd17a3..94b8a0b 100644 --- a/src/fast_qr_wasm/build_fast_qr_wasm_colab.py +++ b/src/fast_qr_wasm/build_fast_qr_wasm_colab.py @@ -6,10 +6,11 @@ not need a Rust toolchain. It can be pasted directly into a Colab cell. If a QR Stream repo is present, artifacts are copied into src/fast_qr_wasm/wasm; otherwise they are written to /content/qrstream_fast_qr_wasm_artifacts and zipped. -The crate exposes a single `QrRenderer` struct whose internal RGBA buffer is -allocated exactly once at construction time. Callers invoke `render()` and then -read the result directly through a JavaScript `Uint8ClampedArray` view into the -WASM linear memory — zero heap allocation per frame on the Rust side. +The crate exposes a single `QrRenderer` struct whose internal RGBA and matrix +buffers are allocated exactly once at construction time. Callers invoke +`render_rgba()` for app/live/GIF rendering or `render_matrix()` for CLI/tests, +then read the result directly through JavaScript views into WASM linear memory. +The legacy `render()` method remains as an alias for `render_rgba()`. """ from __future__ import annotations @@ -60,17 +61,26 @@ wasm-opt = false # src/lib.rs # --------------------------------------------------------------------------- # Design: -# - `QrRenderer` allocates a single fixed RGBA buffer large enough to hold -# a version-40 QR at scale 8 (the largest sensible combination): +# - `QrRenderer` allocates two fixed buffers: +# 1. An RGBA buffer large enough to hold a version-40 QR at scale 8: # (177 modules + 4*2 quiet-zone) * 8 px = 1480 px per side # 1480 * 1480 * 4 bytes ≈ 8.76 MB -# - `render()` writes RGBA directly into that buffer and returns the side -# pixel count so JS knows how many bytes are valid. -# - `buf_ptr()` / `buf_len()` expose the raw pointer and total capacity. +# 2. A 177*177 byte matrix buffer for raw module data. +# - `render_rgba()` writes RGBA directly into the RGBA buffer and returns the +# side pixel count so JS knows how many bytes are valid. +# - `render_matrix()` writes 0/1 bytes directly into the matrix buffer and +# returns the side module count. +# - `render()` is a backward-compatible alias for `render_rgba()`; it also +# outputs RGBA into the same fixed RGBA buffer. +# - `buf_ptr()` / `buf_len()` expose the RGBA buffer for compatibility with +# the previous wrapper. `rgba_ptr()` / `rgba_len()` and +# `matrix_ptr()` / `matrix_len()` are the explicit APIs. # - JS zero-copy path: -# const sidePx = renderer.render(data, version, ecc, scale); -# const view = new Uint8ClampedArray(wasm.memory.buffer, renderer.buf_ptr(), sidePx*sidePx*4); +# const sidePx = renderer.render_rgba(data, version, ecc, scale); +# const view = new Uint8ClampedArray(wasm.memory.buffer, renderer.rgba_ptr(), sidePx*sidePx*4); # const imageData = new ImageData(view.slice(), sidePx, sidePx); +# const sideMods = renderer.render_matrix(data, version, ecc); +# const modules = new Uint8Array(wasm.memory.buffer, renderer.matrix_ptr(), sideMods*sideMods); WRAPPER_LIB_RS = r""" use fast_qr::{ECL, Version, QRBuilder}; @@ -85,6 +95,12 @@ const MAX_SIDE_PX: u32 = (177 + QZ * 2) * 8; /// Total bytes in the fixed RGBA buffer. const MAX_BUF: usize = (MAX_SIDE_PX as usize) * (MAX_SIDE_PX as usize) * 4; +/// Maximum raw module count in a QR matrix (V40 = 177x177). +const MAX_MATRIX_SIDE: usize = 177; + +/// Total bytes in the fixed matrix buffer. Each module is 0=light, 1=dark. +const MAX_MATRIX_BUF: usize = MAX_MATRIX_SIDE * MAX_MATRIX_SIDE; + // --------------------------------------------------------------------------- // ECC helpers // --------------------------------------------------------------------------- @@ -131,18 +147,25 @@ fn version_from_u8(v: u8) -> Result { #[wasm_bindgen] pub struct QrRenderer { /// Fixed-capacity RGBA buffer — one lifetime allocation. - buf: Vec, + rgba_buf: Vec, + + /// Fixed-capacity raw QR module buffer. Values are 0=light, 1=dark. + matrix_buf: Vec, + + /// Side module count written by the latest `render_matrix()` call. + matrix_size: u32, } #[wasm_bindgen] impl QrRenderer { - /// Allocate the renderer and its fixed buffer once. - /// No further heap allocation occurs inside `render()`. + /// Allocate the renderer and its fixed buffers once. #[wasm_bindgen(constructor)] pub fn new() -> QrRenderer { // Pre-fill with white (255) so any untouched pixels are white. QrRenderer { - buf: vec![0xffu8; MAX_BUF], + rgba_buf: vec![0xffu8; MAX_BUF], + matrix_buf: vec![0u8; MAX_MATRIX_BUF], + matrix_size: 0, } } @@ -159,7 +182,13 @@ impl QrRenderer { /// `[buf_ptr .. buf_ptr + sidePx*sidePx*4)`. /// /// Throws a `JsValue` error string on failure. - pub fn render(&mut self, data: &[u8], version: u8, ecc: u8, scale: u8) -> Result { + pub fn render_rgba( + &mut self, + data: &[u8], + version: u8, + ecc: u8, + scale: u8, + ) -> Result { if scale == 0 || scale > 8 { return Err(JsValue::from_str(&format!("Scale must be 1-8, got {scale}"))); } @@ -179,7 +208,7 @@ impl QrRenderer { let side_px = side_mods * scale_u; let required = (side_px as usize) * (side_px as usize) * 4; - if required > self.buf.len() { + if required > self.rgba_buf.len() { return Err(JsValue::from_str(&format!( "Buffer too small: need {required} bytes for V{version} scale {scale}" ))); @@ -202,26 +231,91 @@ impl QrRenderer { let v = if is_dark { 0u8 } else { 0xffu8 }; let idx = ((py * side_px + px) * 4) as usize; - self.buf[idx] = v; // R - self.buf[idx + 1] = v; // G - self.buf[idx + 2] = v; // B - self.buf[idx + 3] = 0xff; // A + self.rgba_buf[idx] = v; // R + self.rgba_buf[idx + 1] = v; // G + self.rgba_buf[idx + 2] = v; // B + self.rgba_buf[idx + 3] = 0xff; // A } } Ok(side_px) } + /// Backward-compatible alias for the previous wrapper API. + /// This is exactly `render_rgba()`: it outputs RGBA pixels into the fixed + /// RGBA buffer and returns the side pixel count. + pub fn render(&mut self, data: &[u8], version: u8, ecc: u8, scale: u8) -> Result { + self.render_rgba(data, version, ecc, scale) + } + + /// Generate a QR code in-place and write its raw module matrix to the + /// fixed matrix buffer. + /// + /// Matrix values are one byte per module: 0 = light, 1 = dark. The matrix + /// does not include quiet-zone modules. The valid byte region is + /// `[matrix_ptr .. matrix_ptr + sideMods*sideMods)`. + pub fn render_matrix(&mut self, data: &[u8], version: u8, ecc: u8) -> Result { + let ecl = ecc_from_u8(ecc)?; + let ver = version_from_u8(version)?; + + let qr = QRBuilder::new(data) + .version(ver) + .ecl(ecl) + .build() + .map_err(|e| JsValue::from_str(&format!("fast_qr build error: {e:?}")))?; + + let size = qr.size; + let required = size * size; + + if required > self.matrix_buf.len() { + return Err(JsValue::from_str(&format!( + "Matrix buffer too small: need {required} bytes for V{version}" + ))); + } + + for idx in 0..required { + self.matrix_buf[idx] = if qr.data[idx].value() { 1 } else { 0 }; + } + + self.matrix_size = size as u32; + Ok(self.matrix_size) + } + /// Raw pointer to the start of the RGBA buffer inside WASM linear memory. /// Valid for the lifetime of this `QrRenderer` instance. pub fn buf_ptr(&self) -> u32 { - self.buf.as_ptr() as u32 + self.rgba_ptr() } /// Total capacity of the buffer in bytes (not the valid region — use /// `sidePx * sidePx * 4` after `render()` to get the valid byte count). pub fn buf_len(&self) -> u32 { - self.buf.len() as u32 + self.rgba_len() + } + + /// Explicit raw pointer to the start of the RGBA buffer. + pub fn rgba_ptr(&self) -> u32 { + self.rgba_buf.as_ptr() as u32 + } + + /// Total capacity of the RGBA buffer in bytes. + pub fn rgba_len(&self) -> u32 { + self.rgba_buf.len() as u32 + } + + /// Raw pointer to the start of the 0/1 module matrix buffer. + pub fn matrix_ptr(&self) -> u32 { + self.matrix_buf.as_ptr() as u32 + } + + /// Total capacity of the matrix buffer in bytes. + pub fn matrix_len(&self) -> u32 { + self.matrix_buf.len() as u32 + } + + /// Side module count from the latest `render_matrix()` call. + pub fn last_matrix_size(&self) -> u32 { + self.matrix_size } } """ @@ -383,4 +477,4 @@ def run_shell(command: str) -> None: if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/src/fast_qr_wasm/wasm/manifest.json b/src/fast_qr_wasm/wasm/manifest.json index d1a3408..7ec19e8 100644 --- a/src/fast_qr_wasm/wasm/manifest.json +++ b/src/fast_qr_wasm/wasm/manifest.json @@ -1,5 +1,5 @@ { - "builtAt": "2026-07-07T22:19:12.577608+00:00", + "builtAt": "2026-07-08T01:50:04.220654+00:00", "fast_qr": "0.13", "outputs": [ "package.json", diff --git a/src/fast_qr_wasm/wasm/qrstream_fast_qr_wasm.d.ts b/src/fast_qr_wasm/wasm/qrstream_fast_qr_wasm.d.ts index 72ce2f3..3a00b3a 100644 --- a/src/fast_qr_wasm/wasm/qrstream_fast_qr_wasm.d.ts +++ b/src/fast_qr_wasm/wasm/qrstream_fast_qr_wasm.d.ts @@ -15,10 +15,36 @@ export class QrRenderer { */ buf_ptr(): number; /** - * Allocate the renderer and its fixed buffer once. - * No further heap allocation occurs inside `render()`. + * Side module count from the latest `render_matrix()` call. + */ + last_matrix_size(): number; + /** + * Total capacity of the matrix buffer in bytes. + */ + matrix_len(): number; + /** + * Raw pointer to the start of the 0/1 module matrix buffer. + */ + matrix_ptr(): number; + /** + * Allocate the renderer and its fixed buffers once. */ constructor(); + /** + * Backward-compatible alias for the previous wrapper API. + * This is exactly `render_rgba()`: it outputs RGBA pixels into the fixed + * RGBA buffer and returns the side pixel count. + */ + render(data: Uint8Array, version: number, ecc: number, scale: number): number; + /** + * Generate a QR code in-place and write its raw module matrix to the + * fixed matrix buffer. + * + * Matrix values are one byte per module: 0 = light, 1 = dark. The matrix + * does not include quiet-zone modules. The valid byte region is + * `[matrix_ptr .. matrix_ptr + sideMods*sideMods)`. + */ + render_matrix(data: Uint8Array, version: number, ecc: number): number; /** * Generate a QR code in-place and write RGBA pixels to the fixed buffer. * @@ -34,7 +60,15 @@ export class QrRenderer { * * Throws a `JsValue` error string on failure. */ - render(data: Uint8Array, version: number, ecc: number, scale: number): number; + render_rgba(data: Uint8Array, version: number, ecc: number, scale: number): number; + /** + * Total capacity of the RGBA buffer in bytes. + */ + rgba_len(): number; + /** + * Explicit raw pointer to the start of the RGBA buffer. + */ + rgba_ptr(): number; } export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; @@ -44,8 +78,15 @@ export interface InitOutput { readonly __wbg_qrrenderer_free: (a: number, b: number) => void; readonly qrrenderer_buf_len: (a: number) => number; readonly qrrenderer_buf_ptr: (a: number) => number; + readonly qrrenderer_last_matrix_size: (a: number) => number; + readonly qrrenderer_matrix_len: (a: number) => number; + readonly qrrenderer_matrix_ptr: (a: number) => number; readonly qrrenderer_new: () => number; readonly qrrenderer_render: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; + readonly qrrenderer_render_matrix: (a: number, b: number, c: number, d: number, e: number, f: number) => void; + readonly qrrenderer_render_rgba: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; + readonly qrrenderer_rgba_len: (a: number) => number; + readonly qrrenderer_rgba_ptr: (a: number) => number; readonly __wbindgen_add_to_stack_pointer: (a: number) => number; readonly __wbindgen_export: (a: number, b: number) => number; } diff --git a/src/fast_qr_wasm/wasm/qrstream_fast_qr_wasm.js b/src/fast_qr_wasm/wasm/qrstream_fast_qr_wasm.js index 1512187..4d3b0b0 100644 --- a/src/fast_qr_wasm/wasm/qrstream_fast_qr_wasm.js +++ b/src/fast_qr_wasm/wasm/qrstream_fast_qr_wasm.js @@ -30,8 +30,31 @@ export class QrRenderer { return ret >>> 0; } /** - * Allocate the renderer and its fixed buffer once. - * No further heap allocation occurs inside `render()`. + * Side module count from the latest `render_matrix()` call. + * @returns {number} + */ + last_matrix_size() { + const ret = wasm.qrrenderer_last_matrix_size(this.__wbg_ptr); + return ret >>> 0; + } + /** + * Total capacity of the matrix buffer in bytes. + * @returns {number} + */ + matrix_len() { + const ret = wasm.qrrenderer_matrix_len(this.__wbg_ptr); + return ret >>> 0; + } + /** + * Raw pointer to the start of the 0/1 module matrix buffer. + * @returns {number} + */ + matrix_ptr() { + const ret = wasm.qrrenderer_matrix_ptr(this.__wbg_ptr); + return ret >>> 0; + } + /** + * Allocate the renderer and its fixed buffers once. */ constructor() { const ret = wasm.qrrenderer_new(); @@ -40,19 +63,9 @@ export class QrRenderer { return this; } /** - * Generate a QR code in-place and write RGBA pixels to the fixed buffer. - * - * # Parameters - * - `data` – raw packet bytes to encode - * - `version` – QR version 1-40 - * - `ecc` – error correction level (0=L, 1=M, 2=Q, 3=H) - * - `scale` – pixels per module (1-8) - * - * # Returns - * Side pixel count (`sidePx`). The valid pixel region is - * `[buf_ptr .. buf_ptr + sidePx*sidePx*4)`. - * - * Throws a `JsValue` error string on failure. + * Backward-compatible alias for the previous wrapper API. + * This is exactly `render_rgba()`: it outputs RGBA pixels into the fixed + * RGBA buffer and returns the side pixel count. * @param {Uint8Array} data * @param {number} version * @param {number} ecc @@ -76,6 +89,88 @@ export class QrRenderer { wasm.__wbindgen_add_to_stack_pointer(16); } } + /** + * Generate a QR code in-place and write its raw module matrix to the + * fixed matrix buffer. + * + * Matrix values are one byte per module: 0 = light, 1 = dark. The matrix + * does not include quiet-zone modules. The valid byte region is + * `[matrix_ptr .. matrix_ptr + sideMods*sideMods)`. + * @param {Uint8Array} data + * @param {number} version + * @param {number} ecc + * @returns {number} + */ + render_matrix(data, version, ecc) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_export); + const len0 = WASM_VECTOR_LEN; + wasm.qrrenderer_render_matrix(retptr, this.__wbg_ptr, ptr0, len0, version, ecc); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); + if (r2) { + throw takeObject(r1); + } + return r0 >>> 0; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + } + /** + * Generate a QR code in-place and write RGBA pixels to the fixed buffer. + * + * # Parameters + * - `data` – raw packet bytes to encode + * - `version` – QR version 1-40 + * - `ecc` – error correction level (0=L, 1=M, 2=Q, 3=H) + * - `scale` – pixels per module (1-8) + * + * # Returns + * Side pixel count (`sidePx`). The valid pixel region is + * `[buf_ptr .. buf_ptr + sidePx*sidePx*4)`. + * + * Throws a `JsValue` error string on failure. + * @param {Uint8Array} data + * @param {number} version + * @param {number} ecc + * @param {number} scale + * @returns {number} + */ + render_rgba(data, version, ecc, scale) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_export); + const len0 = WASM_VECTOR_LEN; + wasm.qrrenderer_render_rgba(retptr, this.__wbg_ptr, ptr0, len0, version, ecc, scale); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true); + if (r2) { + throw takeObject(r1); + } + return r0 >>> 0; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + } + /** + * Total capacity of the RGBA buffer in bytes. + * @returns {number} + */ + rgba_len() { + const ret = wasm.qrrenderer_rgba_len(this.__wbg_ptr); + return ret >>> 0; + } + /** + * Explicit raw pointer to the start of the RGBA buffer. + * @returns {number} + */ + rgba_ptr() { + const ret = wasm.qrrenderer_rgba_ptr(this.__wbg_ptr); + return ret >>> 0; + } } if (Symbol.dispose) QrRenderer.prototype[Symbol.dispose] = QrRenderer.prototype.free; function __wbg_get_imports() { diff --git a/src/fast_qr_wasm/wasm/qrstream_fast_qr_wasm_bg.wasm b/src/fast_qr_wasm/wasm/qrstream_fast_qr_wasm_bg.wasm index 7d5dd4fab25929a2370f9f96f66191d6582a6eba..5e5674af3677c9976a97477679bcb35d7ded89ae 100644 GIT binary patch delta 23147 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ŷKA5G}@tS*m)_Sv2w(eSf29u6bmc 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 OZ}$#UXE+u6YX1+yI+qOq delta 20749 zcmb7M3t$x0xt^KbOm?%g$>zcPk=f;y5W<`A63Bok5fo7oUlkOn8m#harS>rqqof)T zoPYtNg$f!4}D8g@4f2gKmYgt=ggV! z{O3Ps=Ina7DX?~HAfjBiO9C~fN>B@@1vO0zrs-+HV48E0IwX7F;U*}!sy&@uYTnp^Ig1uAo`1`{`HSZ-o_+lfzdw87(#6W4Ob>X={M(el z*&aZ;D*@+8ZR+?L*|TTgcKuDa%)4R!Ewkq@TX^f@rOJ?SLRBm^(%l^2K`pyrR_BSs zI$n6*@J<(>J9g@o)~K$1u0DUtoQxNaXtSM>feS-T&y-q$rLk^Kbs(Ykc6J1&=v5n= zl-LO8zXQ{?6AwEV1*cKe1uup9RIme;H)Dm);Iu9*PEA`0d{g#NC!&`TxLh|WN^^!f zPv|3A+!^WMwCTgup-ypnC;Xk9-XDJ#rcc1%o#~S@_de95Sa+#b#lyJnXh#io%3aOk z2qj|1OwHOT6zAqZXMb;R2~A7(wkagKnHd)1n(!6vy*(J7;_c}r&Ob5=3B)6*p8ha0 zTK05Yw2M`Ib3DV$ELPN57w2D*{%EL=abEH|i;S-HTxqOvZ~ItQ#JMIqo?u-x)!?b9 zxb?!!68mWKUNX#VtL2yOjZtY!Dux-gY6a`KUiUc7eC; z|8sMm{$!v5+1%NNU`HU|**Z4Qc{r;uNzvdW;_ppeyE$K{MV)JNMN8Xq6ZANgTVkhfkDW^~N5wLnPwnDt&tq^j=iM|cX^LG0bA}mi=Rdtbt6iy^8Rdx# zGox5h&_!)&0q9{gC zb=&RTasJkEA*^#deT=^sbiVHV6afahoC-H8sc?%TKvJYdQp1a`Q<3+QY`06ZcIaVe zT-PEre^b}R?D+d#hf(Bo`LG^qL8)N!>NQi%)cAgN&J`&*4?Xw7G6Z9;QCF=yz_kD`T0|GM?bVJi~S0Uuc~4()pk-5ZM%x4VWOzzW=6LJCg6op#n3Rvm~C5< z?&(&uR&J(SH7oUUv%A$Sum5C0AH7A8=e%2Rj#}$^q>pS;Zo_K;q7@>Rkl^ffx{qvv5FnyCvzAj&xha zYm&V%7iU+o(pjV3617^A3frn;=U9h3f_hiMCBs(L6YlkOSXz>9VRUAH(oMB7HDi-R zoT91Jq?Em+nkRb03$-Lw-}b<>esE3mtSp{I_l%Xrv*z?>za#CH(ydd74LS5y$ZFh( zE{t@ZEFP#g%i^Dk5s88*iF?>yo7Rz^Vy*3ddu1LJfYVzxJsN) zhai2hIMV4pw7=dg(P={wITHQZ(4oauPqLbz7XwOMjxGkYT|tNnrQz7n>r}Pg89#iu zURNhBvTS%CeVZ&kGJLaslEo3ug(JrHJtm3>u2XBUFD|fZ?}ur!Uw57y(JS|0(p9k( zYp-nL#E7w7PqSD8va3jWfrcjz{H;-A+0N(^vuiar+``$KVo z19Zz@(NeKc=2+^Ic#feUMRCKtUxW|4h?@1TA}5k}$#mSyepf*qOwi;#rdx+d ztF?b%MmYk#mUDHabKPk0D2tDd?xQ!$;^EO);AAm<%mBSsQaXCfp!`NrM4*Q`bORDb zSyU0pW53|qSuv*EE;2isd9fl~t73Vmytk4iyH(Ey*~bvNwZC0K9(EOR$5rm2!3}rp zD&W+!(e*0(OVv!fmtvjzOJG4Fy|<#3Has%6zv>+N#TXmiR41c+{K zHOp44+^VQ(q8WO5EQ`G4C4vyP);Y!@1rRfXvHnH_w$bTk+8CwcOBL;>iN=Fu@Qk)$ z^|pTM-m|uT_Qr7svhkp0+t}QTOBDQXmw2%H|9S~ai3bsAwYTqv0wsp29tW& z)Xc!?3}qa`Hh`@l?k+S&0XqRyFoWks(-1Mia%>mTRCzpT8pVoYT`alIKwmNa;z4KA z*n}M@MWb|vN5ZgPu%%O1-jLy~0&Pw?Ji1PeYL;pmORZhacbbbPM>X_8TqhAcA1zDL zGlQxY2x2tE5M%~J;fx6O=K-th^k~4nMZ?&u+Qd2#Q}Gy}#)HPQs1&fW32A}}!}yJW zB7_1i^lLRDYT!2DJ-94-1H>CjS@a6XzeCP~d{8ZmUJkhrawg=5ke5N$YGu(&ArEO~ zcJvYyssm-w8IW&7UQ7ps%yh_;kQYJLrIkf5g#0JuG{|@KvglOEt?6aa3m_i|l|{=S zmxs%uQy~8Yc|PQ8;QBnsry^z1$&{GP=R!UnEsIWqd_L1Ii%vx0w^?OT3-aw)SrkKP z?aVHVPJnzVrz|=i^4;9B=s3uKv^2?&vl75t5i`*v2!0NfBCBzv)<0F%ZTLBJ`~B)U|k; zz@Anm_y>h(z=wV<(4Y@}P^E904{Z>rPSnQ5k_~?#;B=q!3XM&Kd}yOU!#?y2foAy7 z%>lL$@u4RKY8yUyO_0t}ANsC9GkxfqH2P-w&_4<^=0kVs^v(96tL${bIX?J+Ky!U) zb%?%sKJ*KLcJQGehUweUhwjKA+R203jJ9om7 z`x=t;t#;|Ncc4K2UV|imCm_imc_n{!kmThNB>7tpKAGZQfh2vOLz2EH!7J%I z2ub?x0G*_76C~;T3nb~Q2Cbmawmw9G{M`qB$=?f*@92)>6>ha3X=1^6Be`5Y$YF4Y*sRW@h3?XJce^6(z9%wl$O zF%BX=4Y8jJSeWu$j17h?2LLsZbmU@8uU!}uxu(R+qJ{K?M~NK0H$iv-)K4M%L9PJ5 zeIdCv_u>7&h(d1we}?P@xf%o~AW3)}@)V>A`2+~ghdcn;6Y>`zxCeE}?vRY*Zjf(6 zc7*9^!C#A}!mYj+5;5^HxcWHo|z0OY?Qi)@#; zd$5rAH+G2NW?*CeE^vt~DvMqTWP;?60ZsnMC-@TqP5Rcm^gRVh`hE{d`c6ZVzKx)f z^c{pGeV;;tKHGW-e3HMHA<5rYkmPR{cqM;-g(QCuf>-jlACmkrfIC3G2TA%?gICh` zF(m2R41P)9$B?A&G0;o;{t8L@{s5YzO1X5Bz&9zNqXeE7 z8zDB*K>{aIK%NBFiR}+PawTvu1>{KJR0_zB@Cgz_C^ko|6O%6gl>)LPuu|-Z=#eRb zcTzx90xQHmh#rOn-c11!39J!&B6?&<;Po)_pA3MoL@LDwi7p`t>`wvd5?C$vO7s{Y zfnzD4NCMl$4v8N98O{~h8a<(kC_^a9WeXA9SI%zR>VgH!g?q^*y;vs1u`(ZWCF6wV z#OH+m2$B=J4U!Z39!zjf=sHN|!`+Z#K0|UozYodz{52%!^Lk8t&gW+!J8}LUL4gzc zTS!jm8YBQt=tf9R=ru}dOKBz7qtc~v||^S!A1NV-M6Y(|;A1u2&oq{JrC*bIp+r11zO zHkih*AhG>4HbG)@YHWnW-qrXeBzCsO!;skb8V^BYS8UWlV$WlerZeu+p zw&O-EBsS~D1CZFt+r~N+u+ulzLgG2VsDZ>|g5f~oA;P#H5|0=-c8*wh05MiW;_<|| z4-yYA#wtiW+ZZb$@#JGvL*lu}xEB&nO~yTtc&0Lb0f|R0qY4raWX1}}Aojn;-6-H` z&bSK_QH!-d&8S2fn^@ycND{jol9b;8$-$^_r7g+(x#aF=lDeNt=6)iHyIu13V@caG z$=Yp_v>!!mLD~-`XGHCtS`I4e}5z8h;b0tI9OM$kz_ zE8@2o*AMddJ;os>aF#o>k6@VqWu*CPnz(v%E>+u@m7HYU4sr4wfE2qFceZZAq~t8- zvd!83Pe{)CZJ3nI27F_|U6X@J$nC%%Ai2$Y1e322@>9qn$R{!RxcR8XTpI-WJIEoB z_9iUP!%%n|lH14Eu{?8IxdO}cXvq7qI**0?0CF7UlUSW6K(4~-Y(f4LlKb3GusU=9 z`)A}f?u$Ri>Rbl-4OZu=kiWrh=t9W%u^XBW`4Vu3H5VG4p9@R<`7kZY7Wt7kQ}0? zKsAS`9t?Acs=zRZ=rzbf=D#;VbrIxqpqOLCw?rJHXFx5-XeFrS5Um2W9HK`-Er*B? zNF1VPKrM&p3HK$@7oe6y^bc$>IYf73Z^W7Vi46Tp9log#w9Z3p~KW9$z{l2MAwho$(}}i9X2v<++RTbfYdp$ zrckf2u}gsi6ViDE9+f&1)w$F^mpT*JMC!Y+A%Q<C=Vp9Jr-g-din$wf|DmkCH7Imlogq%-Yqq>Uvm`m{I+SNJ2 z_qv)Rb`v@myFBWjW3Iu@MKFi@t9X+Eor`0P`eUZhxoBomZ;K0^i>X2V zGscfxC-995%!58c=Q0wCF#dP;696-SPKTEYh0Y8hV-DE+MFQiRgfB7RVG}}3`u?2s z%)!L)-3JJPbJ~ksOe4NsBftuGU!9f$dHuZtMQ%=^04v1y8G-5rIMHUJQ1;8 z@D0SN!gbdRJ}&mkh~0J&{I3+-VZO@r^z<&V0p^=b57;U|zR2`|--?YeUt@Z}H^e51 zZ!taKM`EAEmzeFKZQX%@l`k>dQER2x!}1-b2Yg<<oTlk1HcwzPI#%FCfh2YfBIKQi32~T6(}YdlTe4OAq*QUxIvP=>hL9 zw25Nsc+m}rb@|59rp^l`A~)MOK*LwIGR=&e6TuSgbgPOB;Ly-W7*nGG zGXovrSFv!$gfqcnB{oK(OS+|%XqE0kApjia;Y`lGu(e@%6VLm2$R`gAail1Y3$>ar z)#Q00j}~!aXv=OPbMUI6cor@f!zUXjY1wAv7VD?w2^{oYfL=wXEQ;sgOdAJk*;H?i z=bACRCsX2S8V=Et{{~8NYw%^7J|sIQ0!Ks1x>6h;E81h6b`_h!dYXqwxiDcS(CKZqs|kvMj9vIYEsG*Dtuet zaz2~YFaPw>(hH-yiDPd|DS&fnLYYkcMg+Qb?65QJinla~^eY?K#NI0>yG^9ylpIY& zuPP>f&Q*iIhn5mNUS~U%Zhq1)Ea&9nlo`)!L#c*i}jF6qYp=s49+ z_6PhW`{Uetb(cb3TW`O%9uM)_M9^E&pLhr=ZgSVvmqg^X5gDhCI+@q>CMUzM*-!D! zH9sQwqwnn%;I((gWOtBXlHK|3wG%GM5qp4S4@wh=XO|IOGJ>#AG17)vm82w1QUWN& zNt)#3irMqBd6x(u`z<7)W(O-}+%B=`9WVs{l zc&orq3%p9;(&x0mE055BHE_J*lTMYwsahCnMOivki*k*?H+p<(M7d7j^&X!(QQj=@ zogSafqKvDyh#qVc4vj}_c3FCF6CTpAN0j#nrzTH%uPE;o_{ zc(bQ`RFsb%X|mCy<4FbOW1@UaRA}*(Pl)mffuHh}Pm1zMfwzfrt0-4sieQXHxmDn& zMY&4g(&sdA@LzdU42CdBr%F+-7I>{FOP^{{t`Yb~k57##*9pAd<5MTfn+3kpkMvQVKvu1CzPPblMqJ_dzL~?FMK^Snd5`yn6u0h%zFEjOpa4ykCon3N&Vd_7 z4Py$yro3FHZG5+ijmg|&stec_u7_brV1dM6)T?kxZp^=+=Iv7P5Jdijf6^6=W6Q)! z=$oY?Ee3HrxGo$;BBmpH!d}1;UHj#YeTv|Z9LI0;${sFAAYK34J8jbU*NCsYJ$2Kx zuDOypXn{KSJQ4j|E#@glfBx0v#qzF8aguc2WRhp z8_gq}+8yuwm3}EL{2v*9mGZVIZI&ZWz4 zWo9@ps%oXEDdxc8W&JSizFKy^YB<)92Qk(b|9FMF9neMW1#h>)hLJ8NO@(vO?N^R# z-G>1kXl;H4Bfzi3?m+?iiKPA??aqnY2V{t_6?xC;`jhEFY_ByZ-=5kH(T~ll87R<# zR$!48IG_6)P7CzwF1s(fy3Ov@L)-+nl)MF*MFN;e*x6{Ym~+z3&lK&nSM5?U$j)K@ zDiF&%Hk_C`Sm;ePsuM(ZOK^+$Z9WnU-&>flpD|^zt3)q2WmOXo1)Q=VF}e9dpJjFw z>4WJ69boE1+sFb5Oxm7!*ps^v>^?-9^Eqf0mPLci-v3oGtOF{^PIB(cE8@P3@0gT@ zzVhr%gPj{r$G+oi`8M#~d>cR~kQ86OV-RlAX}x1~Hg7xVrV)M-w8j~~e5PK%R!p|_ z%ctx6W%2Xn6TUO+bwpny&1d;KFB(Nk5JqR-ov-OtvYBC(Q@%6NLkwc+xwbOVFnh&B z&AGUG9DZcAs=70Wy1trGd8oQua;P=lw>#7vcnoy_LmgV_8EUzC9kFuAj2tglVBb>R zt!O}QMbW}px1t*PQ@PHhwxayDEckn7W%1Q#!~-7uWcEc0$KyHUK@N8^^5mK###2NC z_wwJ#@3NKCM;j)rimU24=eql*;_vqR?or1zj9ML3F)7Eb`JrkwG^`n|nyq-UL-=X2 z2)^eD60*u;#W+oyCQj3Wu}O~MyvIFyi!(2V#Tbtad?eu`10v>{n)BU_JD7Ag?hOq! zan(OMj6kc7-%TIke7<%{)-n8Ywmcq?p@(OW$?J+U@C0B6anEf>8-BWOoT|00b)J1- zkkxV^m^YJp} zGi`mb54n5&TzoJ7ll49DH?(0U{$9JGu=7**mEuvF%aCT_2Q7HE#tG+wSk(FDhWTp3 zDX4qRdE>z=)m{w+bxX9^T7JJ+#X@a0@h739ovjZ~bnei=t|5 zjFZ2)`!E=BAIdbQNJ|lom>RwSjBsdrwMnsFSF9Gu*l5JawMyh6^vU`5SG_Vqam_^A z!Fa$K^GK1=87n_ujw7!6JGVX3JNukS7S3tWN)6)8nh?9p$=hI0Jc;zti^lN9HiEd#ss)k+0fmg=TI`Y<(ytYJ0N$cv>Dwoocbm%g%8`MP^)&C4uQaUHA|#zf4ZTWc$~h~z zG4UFT9vfj6xs)rg5*Y<1Ig*CxKc*#O^ziO{T?Vc5bi?fI-}3i4g{GRFj$d9N18jHt z?ir7Ss3$$96Z@RmPxY`7WXvSIUMxM5 zOGn@RWh>SZ&az(C2?$QrEPAzP`UpIJaUVR2tes3(*lqziy&A|ctCqjkN!AhRORNr& z2-@hSl+WyqzYzluU?A1%7Q0M|ATpfaLU~i67KR_5y1pk$=xprrROu@Ef~2)%w=;T= zX0Y!Wqil4pd%E+8D`aIZa`@jVpqW^K5(&xXlt=*C7&!imd@uvt@dqQ3eKQ)~fBIbg z!h6zUN`|5+KgXvtK1qE!%3VDAoj$$Nr{6{0v7fsXp?u`I`&6f4?(>hR>a|XK)1V<` zftYd)9M<9!$H%MZd-M%SyK_U+t#;Du{3PnOLfu;ntIZj$Quo>i`s~!b_Jq$)9dX^J11}uhJxatQW;6sXv4= zZ^o-(_IX9X=iLN$RA@tH0>e z-+-Ru@qgB}A5&#}W1&az@#!Qw{{Y4rI*R0Clgu)MuyewOc+rb+7$gVYl0R zNWkkb7&=2Dc?Z-PX-R#E&raQIXUKT#Q}@~#LRq+eszCx?hp(Y~8KCa%;Ax+oy4U`# z&raQIS5)xty#N8PLjbzhPTgw{`s~!b_8 z?zM05*{OT&bv`@wc02fIrt{t*^>znESq?oNpCkj+(>(egQT7Hgb#Ih^=(AH#**X4y z_Bl{bIiUO(pPjnb{)Nv@-E04=&rUsQw;53H_#CJw9cXX%*{OT&hkSPGUi)F6ow{qc z#p3fK0B@92_XgohK09@+!_%K;v}1Z56F(oQ|wqmM$Fqn@-= z_qI10I@-%gIgAA$8gxEt-l5KRoOdVS?~!+lv`@AB-@6=toh#nE-F_7N_c?H_#D~{Q z>i4_)@t!edaDGgwz~>M?^URpifX@tkSpF2BVEjy-c4Bp|{S8;A^nTzoQ8=*ipCtbOl)0$}+bcR3zrW6A+sVEq`<3L4 zuC%j1#CY5Oz7eCJpOUj-ejn&L->DdMZu{T?n>3M*WEYa%pq}i4m#$ diff --git a/src/fast_qr_wasm/wasm/qrstream_fast_qr_wasm_bg.wasm.d.ts b/src/fast_qr_wasm/wasm/qrstream_fast_qr_wasm_bg.wasm.d.ts index f27e1b8..bf0c9a1 100644 --- a/src/fast_qr_wasm/wasm/qrstream_fast_qr_wasm_bg.wasm.d.ts +++ b/src/fast_qr_wasm/wasm/qrstream_fast_qr_wasm_bg.wasm.d.ts @@ -4,7 +4,14 @@ export const memory: WebAssembly.Memory; export const __wbg_qrrenderer_free: (a: number, b: number) => void; export const qrrenderer_buf_len: (a: number) => number; export const qrrenderer_buf_ptr: (a: number) => number; +export const qrrenderer_last_matrix_size: (a: number) => number; +export const qrrenderer_matrix_len: (a: number) => number; +export const qrrenderer_matrix_ptr: (a: number) => number; export const qrrenderer_new: () => number; export const qrrenderer_render: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; +export const qrrenderer_render_matrix: (a: number, b: number, c: number, d: number, e: number, f: number) => void; +export const qrrenderer_render_rgba: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; +export const qrrenderer_rgba_len: (a: number) => number; +export const qrrenderer_rgba_ptr: (a: number) => number; export const __wbindgen_add_to_stack_pointer: (a: number) => number; export const __wbindgen_export: (a: number, b: number) => number; diff --git a/src/tests/cli_rendering.test.ts b/src/tests/cli_rendering.test.ts index 876c92c..97a90a7 100644 --- a/src/tests/cli_rendering.test.ts +++ b/src/tests/cli_rendering.test.ts @@ -190,7 +190,11 @@ describe('CLI Encoder Pipeline', () => { it('should produce the same frames as the web app (reuse common logic)', async () => { const { packetize } = await import('@/core/sender/packetizer'); const { scheduleFrames } = await import('@/core/sender/scheduler'); - const { COMPATIBLE_QR_ENCODER, encodeQRCodeMatrix } = await import('@/core/qr/qr_encoder'); + const { + DEFAULT_CLI_QR_ENCODER, + encodeQRCodeMatrix, + isFastQrNodeAvailable, + } = await import('@/core/qr/qr_encoder_node'); const { QR_VERSION, ECC_LEVEL } = await import('@/core/protocol/constants'); const { parseHeader } = await import('@/core/protocol/packet'); @@ -199,8 +203,11 @@ describe('CLI Encoder Pipeline', () => { const ordered = scheduleFrames(result.packets, result.totalGenerations); const genIndices = ordered.map((pkt) => parseHeader(pkt).generationIndex); + expect(DEFAULT_CLI_QR_ENCODER).toBe('fast-qr-wasm'); + expect(isFastQrNodeAvailable()).toBe(true); + for (const pkt of ordered) { - const matrix = await encodeQRCodeMatrix(pkt, QR_VERSION, ECC_LEVEL, COMPATIBLE_QR_ENCODER); + const matrix = await encodeQRCodeMatrix(pkt, QR_VERSION, ECC_LEVEL); expect(matrix.length).toBe(57); expect(matrix[0]!.length).toBe(57); } @@ -236,7 +243,7 @@ describe('CLI Frame Cycle', () => { it('should generate valid QR matrix for every scheduled frame', async () => { const { packetize } = await import('@/core/sender/packetizer'); const { scheduleFrames } = await import('@/core/sender/scheduler'); - const { COMPATIBLE_QR_ENCODER, encodeQRCodeMatrix } = await import('@/core/qr/qr_encoder'); + const { encodeQRCodeMatrix } = await import('@/core/qr/qr_encoder_node'); const { QR_VERSION, ECC_LEVEL } = await import('@/core/protocol/constants'); const data = new TextEncoder().encode('Every frame QR test — medium payload'); @@ -244,7 +251,7 @@ describe('CLI Frame Cycle', () => { const ordered = scheduleFrames(result.packets, result.totalGenerations); for (const pkt of ordered) { - const matrix = await encodeQRCodeMatrix(pkt, QR_VERSION, ECC_LEVEL, COMPATIBLE_QR_ENCODER); + const matrix = await encodeQRCodeMatrix(pkt, QR_VERSION, ECC_LEVEL); expect(matrix.length).toBe(57); expect(matrix[0]!.length).toBe(57); const hasDark = matrix.some((row) => row.some((cell) => cell)); diff --git a/src/tests/frame_decode.test.ts b/src/tests/frame_decode.test.ts index 37a88b1..daa526a 100644 --- a/src/tests/frame_decode.test.ts +++ b/src/tests/frame_decode.test.ts @@ -4,8 +4,7 @@ import { describe, it, expect } from 'vitest'; import { packetize } from '@/core/sender/packetizer'; import { scheduleFrames } from '@/core/sender/scheduler'; -import { generateQRMatrix } from '@/core/qr/qr_encode'; -import { rasterizeQR } from '@/core/qr/frame_raster'; +import { renderQRCodeImageData } from '@/core/qr/qr_encoder_browser'; import { decodeQRFromCanvas } from '@/core/qr/qr_decode'; import { parsePacket } from '@/core/protocol/packet'; import { QR_VERSION, ECC_LEVEL } from '@/core/protocol/constants'; @@ -22,9 +21,13 @@ describe('Frame Decode', () => { for (let i = 0; i < frames.length; i++) { const originalPacket = frames[i]!; - // Encode as QR - const matrix = generateQRMatrix(originalPacket, QR_VERSION, ECC_LEVEL); - const imageData = rasterizeQR(matrix, 4); + const imageData = await renderQRCodeImageData( + originalPacket, + QR_VERSION, + ECC_LEVEL, + 4, + 'fast-qr-wasm', + ); // Decode QR const decodedQR = await decodeQRFromCanvas(imageData); diff --git a/src/tests/gif_roundtrip.test.ts b/src/tests/gif_roundtrip.test.ts index 31cf92e..9637ece 100644 --- a/src/tests/gif_roundtrip.test.ts +++ b/src/tests/gif_roundtrip.test.ts @@ -4,8 +4,7 @@ import { describe, it, expect } from 'vitest'; import { packetize } from '@/core/sender/packetizer'; import { scheduleFrames } from '@/core/sender/scheduler'; -import { generateQRMatrix } from '@/core/qr/qr_encode'; -import { rasterizeQR } from '@/core/qr/frame_raster'; +import { renderQRCodeImageData } from '@/core/qr/qr_encoder_browser'; import { createQRGif } from '@/core/gif/gif_render'; import { parseGif, renderGifFrame } from '@/core/gif/gif_parser'; import { decodeQRFromCanvas } from '@/core/qr/qr_decode'; @@ -20,13 +19,12 @@ describe('GIF Roundtrip', () => { const result = packetize(data, false, false); const frames = scheduleFrames(result.packets, result.totalGenerations); - // Generate QR matrices and rasterize + // Generate QR image frames const imageFrames: Uint8Array[] = []; let width = 0; let height = 0; for (const frame of frames) { - const matrix = generateQRMatrix(frame, QR_VERSION, ECC_LEVEL); - const imageData = rasterizeQR(matrix, 4); + const imageData = await renderQRCodeImageData(frame, QR_VERSION, ECC_LEVEL, 4, 'fast-qr-wasm'); if (width === 0) { width = imageData.width; height = imageData.height; diff --git a/src/tests/prod_roundtrip.test.ts b/src/tests/prod_roundtrip.test.ts index c065916..75cab96 100644 --- a/src/tests/prod_roundtrip.test.ts +++ b/src/tests/prod_roundtrip.test.ts @@ -4,8 +4,7 @@ import { describe, it, expect } from 'vitest'; import { packetize } from '@/core/sender/packetizer'; import { scheduleFrames } from '@/core/sender/scheduler'; -import { generateQRMatrix } from '@/core/qr/qr_encode'; -import { rasterizeQR } from '@/core/qr/frame_raster'; +import { renderQRCodeImageData } from '@/core/qr/qr_encoder_browser'; import { createQRGif } from '@/core/gif/gif_render'; import { parseGif, renderGifFrame } from '@/core/gif/gif_parser'; import { decodeQRFromCanvas } from '@/core/qr/qr_decode'; @@ -35,8 +34,7 @@ describe('Production Roundtrip', () => { let width = 0; let height = 0; for (const frame of frames) { - const matrix = generateQRMatrix(frame, QR_VERSION, ECC_LEVEL); - const imageData = rasterizeQR(matrix, 4); + const imageData = await renderQRCodeImageData(frame, QR_VERSION, ECC_LEVEL, 4, 'fast-qr-wasm'); if (width === 0) { width = imageData.width; height = imageData.height; diff --git a/src/tests/test_qr_modules.test.ts b/src/tests/test_qr_modules.test.ts index b15eebb..0bfa9ad 100644 --- a/src/tests/test_qr_modules.test.ts +++ b/src/tests/test_qr_modules.test.ts @@ -1,8 +1,6 @@ import { - generateQRMatrix, getMaxByteCapacity, getMaxZXingWriterByteCapacity, - getMinVersion, } from '../core/qr/qr_encode.ts'; import { rasterizeQR, rasterizeToGrayscale, getRasterDimensions } from '../core/qr/frame_raster.ts'; import { decodeQRFromBuffer, decodeQRCodesFromCanvas } from '../core/qr/qr_decode.ts'; @@ -22,6 +20,31 @@ import { renderQRCodeImageDataWithZXing } from '../core/qr/qr_write_wasm.ts'; import { createQRGif, estimateGifSize } from '../core/gif/gif_render.ts'; import { describe, it, expect } from 'vitest'; +function matrixFromModuleBytes(modules: Uint8Array, sideModules: number): boolean[][] { + const matrix: boolean[][] = []; + + for (let row = 0; row < sideModules; row++) { + const outRow: boolean[] = []; + const rowOffset = row * sideModules; + + for (let col = 0; col < sideModules; col++) { + outRow.push(modules[rowOffset + col] === 1); + } + + matrix.push(outRow); + } + + return matrix; +} + +function makeTestMatrix(size = 21): boolean[][] { + return Array.from({ length: size }, (_, row) => + Array.from({ length: size }, (_, col) => + row === col || row === Math.floor(size / 2) || col === Math.floor(size / 2), + ), + ); +} + describe('QR encode', () => { it('should compute capacities for profile versions', () => { expect(getMaxByteCapacity(31, 'Q')).toBeGreaterThan(1000); @@ -36,8 +59,6 @@ describe('QR encode', () => { expect(getMaxZXingWriterByteCapacity(20, 'L')).toBe(856); expect(getQRTransferProfile('v20-l').maxPacketSize).toBe(858); expect(getQRTransferProfile('v20-l').maxPayloadSize).toBe(846); - expect(createQRTransferProfile(20, 'L', 'js-qrcode').maxPacketSize).toBe(858); - expect(createQRTransferProfile(20, 'L', 'js-qrcode').maxPayloadSize).toBe(846); expect(createQRTransferProfile(20, 'L', 'fast-qr-wasm').maxPacketSize).toBe(858); expect(createQRTransferProfile(20, 'L', 'fast-qr-wasm').maxPayloadSize).toBe(846); expect(createQRTransferProfile(20, 'L', 'zxing-wasm').maxPacketSize).toBe(856); @@ -54,18 +75,20 @@ describe('QR encode', () => { expect(low.version).toBe(medium.version); }); - it('should throw on data too large', () => { - expect(() => generateQRMatrix(new Uint8Array(100), 1, 'L')).toThrow(); + it('should throw on data too large', async () => { + await expect( + encodeQRCodeMatrix(new Uint8Array(100), 1, 'L', 'fast-qr-wasm'), + ).rejects.toThrow(); }); - it('should generate a matrix', () => { + it('should generate a matrix', async () => { const data = new Uint8Array([72, 101, 108, 108, 111]); // 'Hello' - const matrix = generateQRMatrix(data, 1, 'L'); + const matrix = await encodeQRCodeMatrix(data, 1, 'L', 'fast-qr-wasm'); expect(matrix.length).toBe(21); expect(matrix[0]!.length).toBe(21); }); - it('should expose an encoder abstraction for JS QR, ZXing WASM, and fast_qr WASM', async () => { + it('should expose an encoder abstraction for ZXing WASM and fast_qr WASM', async () => { const payload = new TextEncoder().encode('encoder facade'); expect(DEFAULT_QR_ENCODER).toBe('fast-qr-wasm'); @@ -87,16 +110,40 @@ describe('QR encode', () => { expect(isFastQrAvailable()).toBe(true); const renderer = new QrRenderer(); - const sidePx = renderer.render(new TextEncoder().encode('fast qr buffer'), 10, 0, 2); + const payload = new TextEncoder().encode('fast qr buffer'); + const sidePx = renderer.render_rgba(payload, 10, 0, 2); const byteLength = sidePx * sidePx * 4; const memory = getFastQrWasmMemory(); - const view = new Uint8ClampedArray(memory.buffer, renderer.buf_ptr(), byteLength); + const view = new Uint8ClampedArray(memory.buffer, renderer.rgba_ptr(), byteLength); expect(sidePx).toBe((10 * 4 + 17 + 8) * 2); - expect(renderer.buf_len()).toBeGreaterThanOrEqual(byteLength); + expect(renderer.rgba_len()).toBeGreaterThanOrEqual(byteLength); + expect(renderer.buf_ptr()).toBe(renderer.rgba_ptr()); + expect(renderer.buf_len()).toBe(renderer.rgba_len()); + expect(renderer.render(payload, 10, 0, 2)).toBe(sidePx); expect(view.byteLength).toBe(byteLength); }); + it('should expose fast_qr WASM matrix output through the browser encoder facade', async () => { + await ensureFastQrWasm(); + + const payload = new TextEncoder().encode('fast qr matrix'); + const renderer = new QrRenderer(); + const sideModules = renderer.render_matrix(payload, 10, 0); + const byteLength = sideModules * sideModules; + const memory = getFastQrWasmMemory(); + const modules = new Uint8Array(memory.buffer, renderer.matrix_ptr(), byteLength); + const expectedMatrix = matrixFromModuleBytes(modules.slice(), sideModules); + const matrix = await encodeQRCodeMatrix(payload, 10, 'L', 'fast-qr-wasm'); + + expect(sideModules).toBe(10 * 4 + 17); + expect(renderer.last_matrix_size()).toBe(sideModules); + expect(renderer.matrix_len()).toBeGreaterThanOrEqual(byteLength); + expect(modules.some((value) => value === 1)).toBe(true); + expect(modules.every((value) => value === 0 || value === 1)).toBe(true); + expect(matrix).toEqual(expectedMatrix); + }); + it('should round-trip binary payloads through fast_qr WASM', async () => { const payload = new Uint8Array([0, 1, 2, 3, 4, 5, 31, 127, 128, 200, 255]); const image = await renderQRCodeImageData(payload, 10, 'L', 4, 'fast-qr-wasm'); @@ -133,14 +180,14 @@ describe('QR encode', () => { describe('Frame raster', () => { it('should produce correct dimensions', () => { - const matrix = generateQRMatrix(new Uint8Array([1,2,3]), 1, 'L'); + const matrix = makeTestMatrix(); const dims = getRasterDimensions(matrix.length, 3); expect(dims.width).toBe((21 + 8) * 3); // 87 expect(dims.height).toBe(87); }); it('should have quiet zone white', () => { - const matrix = generateQRMatrix(new Uint8Array([1,2,3]), 1, 'L'); + const matrix = makeTestMatrix(); const rgba = rasterizeQR(matrix, 3); expect(rgba.data[0]).toBe(255); // corner pixel should be white expect(rgba.data[1]).toBe(255); @@ -150,7 +197,7 @@ describe('Frame raster', () => { it('should round-trip through decode', async () => { const original = 'Hello QR'; const data = new TextEncoder().encode(original); - const matrix = generateQRMatrix(data, 1, 'L'); + const matrix = await encodeQRCodeMatrix(data, 1, 'L', 'fast-qr-wasm'); const gray = rasterizeToGrayscale(matrix, 3); const decoded = await decodeQRFromBuffer(gray.data, gray.width, gray.height); expect(decoded).not.toBeNull(); @@ -160,10 +207,11 @@ describe('Frame raster', () => { it('should decode multiple QR codes from one image', async () => { const payloads = ['left QR', 'right QR']; - const images = payloads.map((text) => { - const matrix = generateQRMatrix(new TextEncoder().encode(text), 1, 'L'); - return rasterizeQR(matrix, 4); - }); + const images = await Promise.all( + payloads.map((text) => + renderQRCodeImageData(new TextEncoder().encode(text), 1, 'L', 4, 'fast-qr-wasm'), + ), + ); const tileWidth = images[0]!.width; const tileHeight = images[0]!.height; const width = tileWidth * images.length; @@ -222,7 +270,7 @@ describe('Frame raster', () => { describe('GIF render', () => { it('should produce valid GIF', () => { - const matrix = generateQRMatrix(new Uint8Array([1,2,3]), 1, 'L'); + const matrix = makeTestMatrix(); const rgba = rasterizeQR(matrix, 3); const gif = createQRGif([rgba.data], 100, rgba.width, rgba.height); expect(gif[0]).toBe(0x47); // G diff --git a/src/workers/qr_render.worker.ts b/src/workers/qr_render.worker.ts index 848fdd4..1d5a7ac 100644 --- a/src/workers/qr_render.worker.ts +++ b/src/workers/qr_render.worker.ts @@ -14,14 +14,13 @@ import { isFastQrAvailable, getFastQrWasmMemory, QrRenderer, + fastQrUnavailableMessage, } from '@/core/qr/fast_qr_wasm'; import { normalizeQREncoder, renderQRCodeImageData, type QREncoder, } from '@/core/qr/qr_encoder_browser'; -import { generateQRMatrix } from '@/core/qr/qr_encode'; -import { rasterizeQR } from '@/core/qr/frame_raster'; import type { EccLevel } from '@/core/qr/qr_encode'; export interface RenderRequest { @@ -57,12 +56,14 @@ const ECC_TO_NUM: Record = { let renderer: QrRenderer | null = null; -void ensureFastQrWasm() +const rendererReady = ensureFastQrWasm() .then(() => { renderer = new QrRenderer(); + return renderer; }) .catch(() => { renderer = null; + return null; }); self.onmessage = (e: MessageEvent) => { @@ -82,13 +83,15 @@ async function renderPacket(msg: RenderRequest): Promise { let height: number; if (shouldUseFastQrWasm(qrEncoder)) { - if (isFastQrAvailable() && renderer !== null) { + const activeRenderer = renderer ?? await rendererReady; + + if (isFastQrAvailable() && activeRenderer !== null) { const eccNum = ECC_TO_NUM[msg.ecc]; - const sidePx = renderer.render(packet, msg.version, eccNum, msg.scale); + const sidePx = activeRenderer.render_rgba(packet, msg.version, eccNum, msg.scale); const byteLen = sidePx * sidePx * 4; const memory = getFastQrWasmMemory(); - const ptr = renderer.buf_ptr(); + const ptr = activeRenderer.rgba_ptr(); const view = new Uint8ClampedArray(memory.buffer, ptr, byteLen); const copy = new Uint8ClampedArray(byteLen); @@ -98,15 +101,7 @@ async function renderPacket(msg: RenderRequest): Promise { width = sidePx; height = sidePx; } else { - const matrix = generateQRMatrix(packet, msg.version, msg.ecc); - const imageData = rasterizeQR(matrix, msg.scale); - - buffer = imageData.data.buffer.slice( - imageData.data.byteOffset, - imageData.data.byteOffset + imageData.data.byteLength, - ) as ArrayBuffer; - width = imageData.width; - height = imageData.height; + throw new Error(fastQrUnavailableMessage()); } } else { const imageData = await renderQRCodeImageData( @@ -147,4 +142,4 @@ async function renderPacket(msg: RenderRequest): Promise { function shouldUseFastQrWasm(qrEncoder: QREncoder): boolean { const id = String(qrEncoder); return id === 'fast-qr-wasm' || id === 'fast_qr_wasm' || id === 'fastQrWasm'; -} \ No newline at end of file +}