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 7d5dd4f..5e5674a 100644 Binary files a/src/fast_qr_wasm/wasm/qrstream_fast_qr_wasm_bg.wasm and b/src/fast_qr_wasm/wasm/qrstream_fast_qr_wasm_bg.wasm differ 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 +}