@zymbol/qr 0.0.0-bootstrap.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +13 -0
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +65 -3
- package/dist/browser.d.ts +6 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +20 -0
- package/dist/browser.js.map +1 -0
- package/dist/build.json +1 -0
- package/dist/core.d.ts +6 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +9 -0
- package/dist/core.js.map +1 -0
- package/dist/engine.d.ts +18 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +449 -0
- package/dist/engine.js.map +1 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +13 -0
- package/dist/errors.js.map +1 -0
- package/dist/node.d.ts +6 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +23 -0
- package/dist/node.js.map +1 -0
- package/dist/types.d.ts +218 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/wasm.d.ts +20 -0
- package/dist/wasm.d.ts.map +1 -0
- package/dist/wasm.js +105 -0
- package/dist/wasm.js.map +1 -0
- package/dist/zymbol.wasm +0 -0
- package/package.json +59 -8
- package/src/browser.ts +26 -0
- package/src/core.ts +13 -0
- package/src/engine.ts +462 -0
- package/src/errors.ts +33 -0
- package/src/node.ts +29 -0
- package/src/types.ts +264 -0
- package/src/wasm.ts +115 -0
- package/index.mjs +0 -1
package/src/engine.ts
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { ZymbolError } from './errors.js';
|
|
2
|
+
import type { Operation } from './errors.js';
|
|
3
|
+
import type {
|
|
4
|
+
DecodeResult, EncodeOptions, ErrorCorrection, Input, MicroErrorCorrection,
|
|
5
|
+
MicroOptions, MicroSymbol, MicroVersion, ModuleGrid, QrDecodeResult,
|
|
6
|
+
PngOptions, PngEncodeOptions, RasterOptions, Raster, Rgb, Limits,
|
|
7
|
+
SvgOptions, SvgEncodeOptions, QrOptions, QrSymbol, QrVersion, Symbol,
|
|
8
|
+
MicroSegment, QrSegment, QrSegmentOptions, Fnc1, StructuredAppend,
|
|
9
|
+
} from './types.js';
|
|
10
|
+
import type { WasmExports } from './wasm.js';
|
|
11
|
+
|
|
12
|
+
interface BridgeExports extends WasmExports {
|
|
13
|
+
zymbol_input_ptr(): number;
|
|
14
|
+
zymbol_grid_ptr(): number;
|
|
15
|
+
zymbol_payload_ptr(): number;
|
|
16
|
+
zymbol_meta_ptr(): number;
|
|
17
|
+
zymbol_input_capacity(): number;
|
|
18
|
+
zymbol_grid_capacity(): number;
|
|
19
|
+
zymbol_encode(length: number, kind: number, family: number, min: number, max: number, ec: number, boost: number, mask: number): number;
|
|
20
|
+
zymbol_decode(side: number): number;
|
|
21
|
+
zymbol_controls_ptr(): number;
|
|
22
|
+
zymbol_encode_segments(): number;
|
|
23
|
+
zymbol_render(format: number, side: number, family: number, version: number, level: number, mask: number, scale: number, quiet: number, foreground: number, background: number, reversed: number, svgSize: number, maxOutput: number, maxSide: number): number;
|
|
24
|
+
zymbol_output_ptr(): number;
|
|
25
|
+
zymbol_output_len(): number;
|
|
26
|
+
zymbol_output_side(): number;
|
|
27
|
+
zymbol_structured_append_parity(length: number): number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface QrEngine {
|
|
31
|
+
encode(input: Input, options?: QrOptions): QrSymbol;
|
|
32
|
+
encode(input: Input, options: MicroOptions): MicroSymbol;
|
|
33
|
+
encode(input: Input, options: EncodeOptions): Symbol;
|
|
34
|
+
encodeSegments(segments: readonly QrSegment[], options: QrSegmentOptions): QrSymbol;
|
|
35
|
+
encodeSegments(segments: readonly MicroSegment[], options: MicroOptions): MicroSymbol;
|
|
36
|
+
decode(grid: ModuleGrid): DecodeResult;
|
|
37
|
+
renderSvg(symbol: Symbol, options?: SvgOptions): string;
|
|
38
|
+
renderPng(symbol: Symbol, options?: PngOptions): Uint8Array<ArrayBuffer>;
|
|
39
|
+
renderRaster(symbol: Symbol, options?: RasterOptions): Raster;
|
|
40
|
+
svg(input: Input, options?: SvgEncodeOptions): string;
|
|
41
|
+
png(input: Input, options?: PngEncodeOptions): Uint8Array<ArrayBuffer>;
|
|
42
|
+
structuredAppendParity(bytes: Uint8Array): number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const EC_LEVELS = ['L', 'M', 'Q', 'H'] as const;
|
|
46
|
+
const MICRO_VERSIONS = ['M1', 'M2', 'M3', 'M4'] as const;
|
|
47
|
+
const META_FIELDS = 18;
|
|
48
|
+
|
|
49
|
+
function invalid(reason: string, operation: 'encode' | 'decode'): never {
|
|
50
|
+
throw new ZymbolError('INVALID_INPUT', operation, reason, { reason });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function invalidOption(reason: string): never {
|
|
54
|
+
throw new ZymbolError('INVALID_OPTIONS', 'encode', reason, { reason });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isIndex(value: unknown, max: number): value is number {
|
|
58
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= max;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function versionNumber(value: QrVersion | MicroVersion | undefined, micro: boolean, fallback: number): number {
|
|
62
|
+
if (value === undefined) return fallback;
|
|
63
|
+
if (micro) {
|
|
64
|
+
const position = MICRO_VERSIONS.indexOf(value as MicroVersion);
|
|
65
|
+
if (position === -1) return invalidOption('Invalid Micro QR version');
|
|
66
|
+
return position + 1;
|
|
67
|
+
}
|
|
68
|
+
if (!isIndex(value, 40) || value < 1) return invalidOption('Invalid QR version');
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function qrHeaderFields(fnc1: Fnc1 | undefined, append: StructuredAppend | undefined): [number, number, number, number, number, number] {
|
|
73
|
+
let fnc1Kind = 0;
|
|
74
|
+
let indicator = 0;
|
|
75
|
+
if (fnc1 !== undefined) {
|
|
76
|
+
if (!fnc1 || typeof fnc1 !== 'object') invalidOption('Invalid FNC1 header');
|
|
77
|
+
if (fnc1.position === 'first') fnc1Kind = 1;
|
|
78
|
+
else if (fnc1.position === 'second') {
|
|
79
|
+
fnc1Kind = 2;
|
|
80
|
+
const raw = fnc1.applicationIndicator;
|
|
81
|
+
if (typeof raw === 'number' && isIndex(raw, 99)) indicator = raw;
|
|
82
|
+
else if (typeof raw === 'string' && /^[A-Za-z]$/.test(raw)) indicator = raw.charCodeAt(0) + 100;
|
|
83
|
+
else invalidOption('Invalid FNC1 application indicator');
|
|
84
|
+
} else invalidOption('Invalid FNC1 position');
|
|
85
|
+
}
|
|
86
|
+
if (append !== undefined && (!append || typeof append !== 'object'
|
|
87
|
+
|| !isIndex(append.index, 15) || !isIndex(append.count, 16)
|
|
88
|
+
|| append.count < 1 || append.index >= append.count || !isIndex(append.parity, 255))) {
|
|
89
|
+
invalidOption('Invalid Structured Append header');
|
|
90
|
+
}
|
|
91
|
+
return [fnc1Kind, indicator, append ? 1 : 0, append?.index ?? 0, append?.count ?? 0, append?.parity ?? 0];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validateUnicode(value: string): void {
|
|
95
|
+
for (let i = 0; i < value.length; i++) {
|
|
96
|
+
const code = value.charCodeAt(i);
|
|
97
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
98
|
+
if (++i >= value.length || value.charCodeAt(i) < 0xdc00 || value.charCodeAt(i) > 0xdfff) {
|
|
99
|
+
invalid('Unpaired UTF-16 high surrogate', 'encode');
|
|
100
|
+
}
|
|
101
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
102
|
+
invalid('Unpaired UTF-16 low surrogate', 'encode');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function memoryView(memory: WebAssembly.Memory, pointer: number, length: number): Uint8Array {
|
|
108
|
+
if (!isIndex(pointer, memory.buffer.byteLength) || !isIndex(length, memory.buffer.byteLength - pointer)) {
|
|
109
|
+
throw new ZymbolError('WASM_ABI_MISMATCH', 'initialize', 'Invalid WebAssembly buffer bounds');
|
|
110
|
+
}
|
|
111
|
+
return new Uint8Array(memory.buffer, pointer, length);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function createEngine(wasm: WasmExports, limits: Limits = {}): QrEngine {
|
|
115
|
+
const maxOutputBytes = limits.maxOutputBytes ?? (16 * 1024 * 1024);
|
|
116
|
+
const maxImageSide = limits.maxImageSide ?? 4096;
|
|
117
|
+
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > 64 * 1024 * 1024 ||
|
|
118
|
+
!Number.isSafeInteger(maxImageSide) || maxImageSide < 1 || maxImageSide > 65535) {
|
|
119
|
+
throw new ZymbolError('INVALID_OPTIONS', 'initialize', 'Invalid output limits');
|
|
120
|
+
}
|
|
121
|
+
const bridge = wasm as BridgeExports;
|
|
122
|
+
const functions = [
|
|
123
|
+
'zymbol_input_ptr', 'zymbol_grid_ptr', 'zymbol_payload_ptr',
|
|
124
|
+
'zymbol_meta_ptr', 'zymbol_input_capacity', 'zymbol_grid_capacity',
|
|
125
|
+
'zymbol_encode', 'zymbol_decode', 'zymbol_render',
|
|
126
|
+
'zymbol_output_ptr', 'zymbol_output_len', 'zymbol_output_side',
|
|
127
|
+
'zymbol_structured_append_parity', 'zymbol_controls_ptr', 'zymbol_encode_segments',
|
|
128
|
+
] as const;
|
|
129
|
+
for (const name of functions) {
|
|
130
|
+
if (typeof bridge[name] !== 'function') {
|
|
131
|
+
throw new ZymbolError('WASM_ABI_MISMATCH', 'initialize', `Missing WebAssembly export: ${name}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const capacity = bridge.zymbol_input_capacity();
|
|
135
|
+
const gridCapacity = bridge.zymbol_grid_capacity();
|
|
136
|
+
if (!isIndex(capacity, 1 << 20) || capacity < 8192 || gridCapacity < 177 * 177) {
|
|
137
|
+
throw new ZymbolError('WASM_ABI_MISMATCH', 'initialize', 'Invalid WebAssembly workspace capacity');
|
|
138
|
+
}
|
|
139
|
+
let alive = true;
|
|
140
|
+
|
|
141
|
+
function call(operation: Operation, run: () => number): void {
|
|
142
|
+
if (!alive) throw new ZymbolError('INSTANCE_UNUSABLE', operation, 'WebAssembly instance is unusable after a trap');
|
|
143
|
+
let code: number;
|
|
144
|
+
try {
|
|
145
|
+
code = run();
|
|
146
|
+
} catch (cause) {
|
|
147
|
+
alive = false;
|
|
148
|
+
throw new ZymbolError('WASM_TRAP', operation, 'WebAssembly operation trapped', { cause });
|
|
149
|
+
}
|
|
150
|
+
if (code === 0) return;
|
|
151
|
+
const errorCodes = {
|
|
152
|
+
1: 'INVALID_INPUT', 2: 'INVALID_OPTIONS', 3: 'DATA_TOO_LONG',
|
|
153
|
+
4: 'DECODE_FAILED', 5: 'INTERNAL_ERROR',
|
|
154
|
+
6: 'OUTPUT_LIMIT', 7: 'OUT_OF_MEMORY',
|
|
155
|
+
} as const;
|
|
156
|
+
const errorCode = errorCodes[code as keyof typeof errorCodes];
|
|
157
|
+
if (!errorCode) {
|
|
158
|
+
alive = false;
|
|
159
|
+
throw new ZymbolError('WASM_ABI_MISMATCH', operation, `Unknown WebAssembly status: ${code}`);
|
|
160
|
+
}
|
|
161
|
+
throw new ZymbolError(errorCode, operation, `Zymbol ${operation} failed (${errorCode})`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function metadata(): number[] {
|
|
165
|
+
const bytes = memoryView(bridge.memory, bridge.zymbol_meta_ptr(), META_FIELDS * 4);
|
|
166
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
167
|
+
return Array.from({ length: META_FIELDS }, (_, i) => view.getUint32(i * 4, true));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function getField(fields: readonly number[], index: number): number {
|
|
171
|
+
const value = fields[index];
|
|
172
|
+
if (value === undefined) throw new ZymbolError('WASM_ABI_MISMATCH', 'initialize', 'Incomplete metadata');
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function encode(input: Input, options: EncodeOptions = {}): Symbol {
|
|
177
|
+
if (typeof input !== 'string' && !(input instanceof Uint8Array)) invalid('Expected a string or Uint8Array', 'encode');
|
|
178
|
+
if (!options || typeof options !== 'object') invalidOption('Expected encode options');
|
|
179
|
+
const micro = options.family === 'micro';
|
|
180
|
+
if (options.family !== undefined && options.family !== 'qr' && !micro) invalidOption('Invalid symbol family');
|
|
181
|
+
if (options.version !== undefined && (options.minVersion !== undefined || options.maxVersion !== undefined)) {
|
|
182
|
+
invalidOption('Exact version cannot be combined with version ranges');
|
|
183
|
+
}
|
|
184
|
+
if (micro && ('fnc1' in options || 'structuredAppend' in options)) invalidOption('Micro QR does not support QR control headers');
|
|
185
|
+
const first = versionNumber(options.version ?? options.minVersion, micro, 1);
|
|
186
|
+
const last = versionNumber(options.version ?? options.maxVersion, micro, micro ? 4 : 40);
|
|
187
|
+
if (first > last) invalidOption('Invalid version range');
|
|
188
|
+
const level = options.errorCorrection ?? (micro ? 'L' : 'M');
|
|
189
|
+
const ec = EC_LEVELS.indexOf(level as ErrorCorrection);
|
|
190
|
+
if (ec < 0 || (micro && ec > 2)) invalidOption('Invalid error correction level');
|
|
191
|
+
const boost = options.boostErrorCorrection ?? true;
|
|
192
|
+
if (typeof boost !== 'boolean') invalidOption('boostErrorCorrection must be boolean');
|
|
193
|
+
const mask = options.mask ?? -1;
|
|
194
|
+
if (mask !== -1 && !isIndex(mask, micro ? 3 : 7)) invalidOption('Invalid mask');
|
|
195
|
+
if (typeof input === 'string') {
|
|
196
|
+
validateUnicode(input);
|
|
197
|
+
if (micro && /[^\x00-\x7f]/.test(input)) invalid('Micro QR text must be ASCII', 'encode');
|
|
198
|
+
}
|
|
199
|
+
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : input;
|
|
200
|
+
if (bytes.byteLength > capacity) throw new ZymbolError('DATA_TOO_LONG', 'encode', 'Payload exceeds WebAssembly input workspace');
|
|
201
|
+
memoryView(bridge.memory, bridge.zymbol_input_ptr(), bytes.byteLength).set(bytes);
|
|
202
|
+
const headers = micro ? [0, 0, 0, 0, 0, 0] : qrHeaderFields(options.fnc1, options.structuredAppend);
|
|
203
|
+
const headerMemory = memoryView(bridge.memory, bridge.zymbol_controls_ptr() + 6 * 4, 6 * 4);
|
|
204
|
+
const headerView = new DataView(headerMemory.buffer, headerMemory.byteOffset, headerMemory.byteLength);
|
|
205
|
+
headers.forEach((value, index) => headerView.setUint32(index * 4, value, true));
|
|
206
|
+
call('encode', () => bridge.zymbol_encode(bytes.byteLength, typeof input === 'string' ? 0 : 1, Number(micro), first, last, ec, Number(boost), mask));
|
|
207
|
+
return readSymbol(micro, first, last);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function readSymbol(micro: boolean, first: number, last: number): Symbol {
|
|
211
|
+
const fields = metadata();
|
|
212
|
+
const size = getField(fields, 0);
|
|
213
|
+
const version = getField(fields, 1);
|
|
214
|
+
const outputLevel = getField(fields, 2);
|
|
215
|
+
const outputMask = getField(fields, 3);
|
|
216
|
+
const outputFamily = getField(fields, 4);
|
|
217
|
+
if ((micro ? size < 11 || size > 17 || (size % 2) === 0 : size < 21 || size > 177 || (size - 17) % 4 !== 0)
|
|
218
|
+
|| size * size > gridCapacity || version < first || version > last
|
|
219
|
+
|| size !== (micro ? 9 + 2 * version : 17 + 4 * version)
|
|
220
|
+
|| !isIndex(outputLevel, micro ? 2 : 3) || !isIndex(outputMask, micro ? 3 : 7)
|
|
221
|
+
|| outputFamily !== Number(micro)) {
|
|
222
|
+
throw new ZymbolError('WASM_ABI_MISMATCH', 'encode', 'WebAssembly returned invalid symbol metadata');
|
|
223
|
+
}
|
|
224
|
+
const modules = memoryView(bridge.memory, bridge.zymbol_grid_ptr(), size * size).slice();
|
|
225
|
+
if (modules.some(cell => cell > 1)) throw new ZymbolError('WASM_ABI_MISMATCH', 'encode', 'WebAssembly returned non-binary modules');
|
|
226
|
+
return micro
|
|
227
|
+
? { family: 'micro', size, version: MICRO_VERSIONS[version - 1]!, errorCorrection: EC_LEVELS[outputLevel]! as MicroErrorCorrection, mask: outputMask as MicroSymbol['mask'], modules }
|
|
228
|
+
: { family: 'qr', size, version: version as QrVersion, errorCorrection: EC_LEVELS[outputLevel]!, mask: outputMask as QrSymbol['mask'], modules };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function encodeSegments(segments: readonly QrSegment[] | readonly MicroSegment[], options: QrSegmentOptions | MicroOptions): Symbol {
|
|
232
|
+
if (!Array.isArray(segments) || segments.length > 128 || !options || typeof options !== 'object') {
|
|
233
|
+
throw new ZymbolError('INVALID_OPTIONS', 'encodeSegments', 'Expected at most 128 segments and encoding options');
|
|
234
|
+
}
|
|
235
|
+
const micro = options.family === 'micro';
|
|
236
|
+
const first = versionNumber(options.version ?? (micro ? options.minVersion : undefined), micro, 1);
|
|
237
|
+
const last = versionNumber(options.version ?? (micro ? options.maxVersion : undefined), micro, micro ? 4 : first);
|
|
238
|
+
if (first > last || (!micro && !options.version)) invalidOption('QR manual segments require an exact version');
|
|
239
|
+
const level = EC_LEVELS.indexOf((options.errorCorrection ?? (micro ? 'L' : 'M')) as ErrorCorrection);
|
|
240
|
+
if (level < 0 || (micro && level > 2)) invalidOption('Invalid error correction level');
|
|
241
|
+
const mask = options.mask ?? -1;
|
|
242
|
+
if (mask !== -1 && !isIndex(mask, micro ? 3 : 7)) invalidOption('Invalid mask');
|
|
243
|
+
if (!micro && (options as QrSegmentOptions).errorCorrection === undefined) invalidOption('QR manual segments require error correction');
|
|
244
|
+
const boost = micro && (options.boostErrorCorrection ?? true) ? 1 : 0;
|
|
245
|
+
const fnc1 = !micro ? (options as QrSegmentOptions).fnc1 : undefined;
|
|
246
|
+
const structured = !micro ? (options as QrSegmentOptions).structuredAppend : undefined;
|
|
247
|
+
let fnc1Kind = 0;
|
|
248
|
+
let indicator = 0;
|
|
249
|
+
if (fnc1 !== undefined) {
|
|
250
|
+
if (fnc1.position === 'first') fnc1Kind = 1;
|
|
251
|
+
else if (fnc1.position === 'second') {
|
|
252
|
+
fnc1Kind = 2;
|
|
253
|
+
const raw = fnc1.applicationIndicator;
|
|
254
|
+
if (typeof raw === 'number' && isIndex(raw, 99)) indicator = raw;
|
|
255
|
+
else if (typeof raw === 'string' && /^[A-Za-z]$/.test(raw)) indicator = raw.charCodeAt(0) + 100;
|
|
256
|
+
else invalidOption('Invalid FNC1 application indicator');
|
|
257
|
+
} else invalidOption('Invalid FNC1 position');
|
|
258
|
+
}
|
|
259
|
+
if (structured !== undefined && (!isIndex(structured.index, 15) || !isIndex(structured.count, 16)
|
|
260
|
+
|| structured.count < 1 || structured.index >= structured.count || !isIndex(structured.parity, 255))) {
|
|
261
|
+
invalidOption('Invalid Structured Append header');
|
|
262
|
+
}
|
|
263
|
+
const packets: Uint8Array[] = [];
|
|
264
|
+
let bytesNeeded = 0;
|
|
265
|
+
const tags = { numeric: 0, alphanumeric: 1, byte: 2, kanji: 3, eci: 4 } as const;
|
|
266
|
+
for (const segment of segments) {
|
|
267
|
+
if (!segment || typeof segment !== 'object' || !('mode' in segment) || !(segment.mode in tags)) {
|
|
268
|
+
invalid('Invalid segment mode', 'encode');
|
|
269
|
+
}
|
|
270
|
+
const mode = segment.mode as keyof typeof tags;
|
|
271
|
+
if (micro && mode === 'eci') invalidOption('Micro QR does not support ECI');
|
|
272
|
+
let data: Uint8Array;
|
|
273
|
+
if (mode === 'eci') {
|
|
274
|
+
const assignment = (segment as Extract<QrSegment, { mode: 'eci' }>).assignment;
|
|
275
|
+
if (!isIndex(assignment, 999999)) invalidOption('Invalid ECI assignment');
|
|
276
|
+
data = new Uint8Array(4);
|
|
277
|
+
new DataView(data.buffer).setUint32(0, assignment, true);
|
|
278
|
+
} else if (mode === 'numeric' || mode === 'alphanumeric') {
|
|
279
|
+
const value = (segment as { data: string }).data;
|
|
280
|
+
if (typeof value !== 'string' || /[^\x00-\x7f]/.test(value)) invalid('Expected ASCII segment text', 'encode');
|
|
281
|
+
data = new TextEncoder().encode(value);
|
|
282
|
+
} else {
|
|
283
|
+
data = (segment as { data: Uint8Array }).data;
|
|
284
|
+
if (!(data instanceof Uint8Array)) invalid('Expected bytes for byte or Kanji segment', 'encode');
|
|
285
|
+
}
|
|
286
|
+
if (data.length > 65535) throw new ZymbolError('DATA_TOO_LONG', 'encodeSegments', 'Segment exceeds packet size');
|
|
287
|
+
const packet = new Uint8Array(data.length + 3);
|
|
288
|
+
packet[0] = tags[mode];
|
|
289
|
+
packet[1] = data.length & 255;
|
|
290
|
+
packet[2] = data.length >>> 8;
|
|
291
|
+
packet.set(data, 3);
|
|
292
|
+
packets.push(packet);
|
|
293
|
+
bytesNeeded += packet.length;
|
|
294
|
+
}
|
|
295
|
+
if (bytesNeeded > capacity) throw new ZymbolError('DATA_TOO_LONG', 'encodeSegments', 'Segments exceed WASM input workspace');
|
|
296
|
+
const data = memoryView(bridge.memory, bridge.zymbol_input_ptr(), bytesNeeded);
|
|
297
|
+
let offset = 0;
|
|
298
|
+
for (const packet of packets) { data.set(packet, offset); offset += packet.length; }
|
|
299
|
+
const control = memoryView(bridge.memory, bridge.zymbol_controls_ptr(), 14 * 4);
|
|
300
|
+
const view = new DataView(control.buffer, control.byteOffset, control.byteLength);
|
|
301
|
+
const values = [Number(micro), first, last, level, mask < 0 ? 0xffffffff : mask, boost,
|
|
302
|
+
fnc1Kind, indicator, structured ? 1 : 0, structured?.index ?? 0,
|
|
303
|
+
structured?.count ?? 0, structured?.parity ?? 0, bytesNeeded, segments.length];
|
|
304
|
+
values.forEach((value, index) => view.setUint32(index * 4, value, true));
|
|
305
|
+
call('encodeSegments', () => bridge.zymbol_encode_segments());
|
|
306
|
+
return readSymbol(micro, first, last);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function decode(grid: ModuleGrid): DecodeResult {
|
|
310
|
+
if (!grid || typeof grid !== 'object' || !isIndex(grid.size, 177)) invalid('Expected a sampled module grid', 'decode');
|
|
311
|
+
const side = grid.size;
|
|
312
|
+
if (!(side === 11 || side === 13 || side === 15 || side === 17 || (side >= 21 && (side - 17) % 4 === 0))) {
|
|
313
|
+
invalid('Invalid symbol size', 'decode');
|
|
314
|
+
}
|
|
315
|
+
if (!(grid.modules instanceof Uint8Array) || grid.modules.byteLength !== side * side || side * side > gridCapacity) {
|
|
316
|
+
invalid('Expected exactly size x size modules', 'decode');
|
|
317
|
+
}
|
|
318
|
+
if (grid.modules.some(value => value > 1)) invalid('Module values must be 0 or 1', 'decode');
|
|
319
|
+
memoryView(bridge.memory, bridge.zymbol_grid_ptr(), grid.modules.length).set(grid.modules);
|
|
320
|
+
call('decode', () => bridge.zymbol_decode(side));
|
|
321
|
+
const fields = metadata();
|
|
322
|
+
const family = getField(fields, 4);
|
|
323
|
+
const version = getField(fields, 1);
|
|
324
|
+
const level = getField(fields, 2);
|
|
325
|
+
const mask = getField(fields, 3);
|
|
326
|
+
const length = getField(fields, 5);
|
|
327
|
+
if (getField(fields, 0) !== side || (family !== 0 && family !== 1) || !isIndex(level, family ? 2 : 3)
|
|
328
|
+
|| !isIndex(mask, family ? 3 : 7) || length > capacity
|
|
329
|
+
|| (family === 0 && (version < 1 || version > 40 || 17 + version * 4 !== side))
|
|
330
|
+
|| (family === 1 && (version < 1 || version > 4 || 9 + version * 2 !== side))) {
|
|
331
|
+
throw new ZymbolError('WASM_ABI_MISMATCH', 'decode', 'WebAssembly returned invalid decode metadata');
|
|
332
|
+
}
|
|
333
|
+
const bytes = memoryView(bridge.memory, bridge.zymbol_payload_ptr(), length).slice();
|
|
334
|
+
const mirrored = getField(fields, 15);
|
|
335
|
+
const reversed = getField(fields, 16);
|
|
336
|
+
if (!isIndex(mirrored, 1) || !isIndex(reversed, 1)) {
|
|
337
|
+
throw new ZymbolError('WASM_ABI_MISMATCH', 'decode', 'Invalid decoded transform metadata');
|
|
338
|
+
}
|
|
339
|
+
const common = {
|
|
340
|
+
bytes,
|
|
341
|
+
mirrored: mirrored === 1,
|
|
342
|
+
reflectanceReversed: reversed === 1,
|
|
343
|
+
errorsCorrected: getField(fields, 17),
|
|
344
|
+
};
|
|
345
|
+
if (family === 1) {
|
|
346
|
+
return { ...common, family: 'micro', version: MICRO_VERSIONS[version - 1]!,
|
|
347
|
+
errorCorrection: EC_LEVELS[level]! as MicroErrorCorrection, mask: mask as MicroSymbol['mask'], symbologyIdentifier: ']Q1' };
|
|
348
|
+
}
|
|
349
|
+
const eciKind = getField(fields, 6);
|
|
350
|
+
if (eciKind === 1 && !isIndex(getField(fields, 7), 999999)) {
|
|
351
|
+
throw new ZymbolError('WASM_ABI_MISMATCH', 'decode', 'Invalid ECI assignment');
|
|
352
|
+
}
|
|
353
|
+
const eci = eciKind === 0 ? { kind: 'none' as const }
|
|
354
|
+
: eciKind === 1 ? { kind: 'assignment' as const, assignment: getField(fields, 7) }
|
|
355
|
+
: eciKind === 2 ? { kind: 'multiple' as const } : invalid('Invalid decoded ECI metadata', 'decode');
|
|
356
|
+
const fnc1Kind = getField(fields, 8);
|
|
357
|
+
const indicator = getField(fields, 9);
|
|
358
|
+
if (fnc1Kind === 2 && !(isIndex(indicator, 99) || (indicator >= 165 && indicator <= 190) || (indicator >= 197 && indicator <= 222))) {
|
|
359
|
+
throw new ZymbolError('WASM_ABI_MISMATCH', 'decode', 'Invalid FNC1 indicator');
|
|
360
|
+
}
|
|
361
|
+
const appendFlag = getField(fields, 10);
|
|
362
|
+
const appendIndex = getField(fields, 11);
|
|
363
|
+
const appendCount = getField(fields, 12);
|
|
364
|
+
const appendParity = getField(fields, 13);
|
|
365
|
+
if (!isIndex(appendFlag, 1) || (appendFlag === 1
|
|
366
|
+
&& (!isIndex(appendIndex, 15) || !isIndex(appendCount, 16) || appendCount < 1
|
|
367
|
+
|| appendIndex >= appendCount || !isIndex(appendParity, 255)))) {
|
|
368
|
+
throw new ZymbolError('WASM_ABI_MISMATCH', 'decode', 'Invalid Structured Append metadata');
|
|
369
|
+
}
|
|
370
|
+
const fnc1 = fnc1Kind === 0 ? null : fnc1Kind === 1 ? { position: 'first' as const }
|
|
371
|
+
: fnc1Kind === 2 ? { position: 'second' as const, applicationIndicator: indicator <= 99 ? indicator : String.fromCharCode(indicator - 100) }
|
|
372
|
+
: invalid('Invalid decoded FNC1 metadata', 'decode');
|
|
373
|
+
const structuredAppend = appendFlag === 0 ? null : {
|
|
374
|
+
index: appendIndex, count: appendCount, parity: appendParity,
|
|
375
|
+
};
|
|
376
|
+
const modifier = getField(fields, 14);
|
|
377
|
+
if (!isIndex(modifier, 6) || modifier < 1) throw new ZymbolError('WASM_ABI_MISMATCH', 'decode', 'Invalid symbology modifier');
|
|
378
|
+
return { ...common, family: 'qr', version: version as QrVersion, errorCorrection: EC_LEVELS[level]!,
|
|
379
|
+
mask: mask as QrSymbol['mask'], eci, fnc1, structuredAppend,
|
|
380
|
+
symbologyIdentifier: (`]Q${modifier}` as QrDecodeResult['symbologyIdentifier']) };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
function packRgb(rgb: Rgb | undefined): number {
|
|
385
|
+
if (rgb === undefined) return 0;
|
|
386
|
+
if (!Array.isArray(rgb) || rgb.length !== 3 || !rgb.every(value => isIndex(value, 255))) {
|
|
387
|
+
throw new ZymbolError('INVALID_OPTIONS', 'renderPng', 'RGB must contain three integer channels from 0 to 255');
|
|
388
|
+
}
|
|
389
|
+
return rgb[0]! | (rgb[1]! << 8) | (rgb[2]! << 16);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function renderBytes(symbol: Symbol, format: 0 | 1 | 2, options: SvgOptions | PngOptions | RasterOptions = {}): { bytes: Uint8Array<ArrayBuffer>; side: number } {
|
|
393
|
+
const operation = format === 0 ? 'renderSvg' : format === 1 ? 'renderPng' : 'renderRaster';
|
|
394
|
+
if (!symbol || (symbol.family !== 'qr' && symbol.family !== 'micro') || !(symbol.modules instanceof Uint8Array)) {
|
|
395
|
+
throw new ZymbolError('INVALID_SYMBOL', operation, 'Expected a QR or Micro QR symbol');
|
|
396
|
+
}
|
|
397
|
+
const micro = symbol.family === 'micro';
|
|
398
|
+
const version = micro ? MICRO_VERSIONS.indexOf(symbol.version as MicroVersion) + 1 : symbol.version;
|
|
399
|
+
const expectedSide = micro ? 9 + version * 2 : 17 + version * 4;
|
|
400
|
+
const level = EC_LEVELS.indexOf(symbol.errorCorrection);
|
|
401
|
+
if (version < 1 || version > (micro ? 4 : 40) || level < 0 || (micro && level > 2)
|
|
402
|
+
|| !isIndex(symbol.mask, micro ? 3 : 7) || symbol.size !== expectedSide
|
|
403
|
+
|| symbol.modules.length !== expectedSide * expectedSide || symbol.modules.some(value => value > 1)) {
|
|
404
|
+
throw new ZymbolError('INVALID_SYMBOL', operation, 'Invalid symbol geometry or metadata');
|
|
405
|
+
}
|
|
406
|
+
const quiet = options.quietZone ?? -1;
|
|
407
|
+
const scale = 'scale' in options ? (options.scale ?? (format === 1 ? 4 : 1)) : 1;
|
|
408
|
+
const intrinsic = 'size' in options ? (options.size ?? 0) : 0;
|
|
409
|
+
if (!Number.isInteger(quiet) || quiet < -1 || quiet > 65535 || (format !== 0 && (!Number.isInteger(scale) || scale < 1 || scale > 65535))
|
|
410
|
+
|| !isIndex(intrinsic, 0xffffffff)) {
|
|
411
|
+
throw new ZymbolError('INVALID_OPTIONS', operation, 'Invalid rendering dimensions');
|
|
412
|
+
}
|
|
413
|
+
const foreground = packRgb(options.foreground);
|
|
414
|
+
const background = options.background === null ? -1 : options.background === undefined ? 0xffffff : packRgb(options.background);
|
|
415
|
+
const reversed = options.reflectance === 'reversed' ? 1 : 0;
|
|
416
|
+
if ((options.reflectance !== undefined && options.reflectance !== 'normal' && options.reflectance !== 'reversed')
|
|
417
|
+
|| (reversed === 1 && background === -1)) {
|
|
418
|
+
throw new ZymbolError('INVALID_OPTIONS', operation, 'Invalid reflectance settings');
|
|
419
|
+
}
|
|
420
|
+
memoryView(bridge.memory, bridge.zymbol_grid_ptr(), symbol.modules.length).set(symbol.modules);
|
|
421
|
+
call(operation, () => bridge.zymbol_render(format, symbol.size, Number(micro), version, level, symbol.mask, scale, quiet, foreground, background, reversed, intrinsic, maxOutputBytes, maxImageSide));
|
|
422
|
+
const length = bridge.zymbol_output_len();
|
|
423
|
+
const side = bridge.zymbol_output_side();
|
|
424
|
+
if (!isIndex(length, maxOutputBytes) || (format !== 0 && (!isIndex(side, maxImageSide) || side < 1))
|
|
425
|
+
|| (format === 2 && length !== side * side * 4)) {
|
|
426
|
+
throw new ZymbolError('WASM_ABI_MISMATCH', operation, 'Invalid WASM rendering output dimensions');
|
|
427
|
+
}
|
|
428
|
+
return { bytes: memoryView(bridge.memory, bridge.zymbol_output_ptr(), length).slice(), side };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function renderSvg(symbol: Symbol, options?: SvgOptions): string {
|
|
432
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(renderBytes(symbol, 0, options).bytes);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function renderPng(symbol: Symbol, options?: PngOptions): Uint8Array<ArrayBuffer> {
|
|
436
|
+
return renderBytes(symbol, 1, options).bytes;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function renderRaster(symbol: Symbol, options?: RasterOptions): Raster {
|
|
440
|
+
const { bytes, side } = renderBytes(symbol, 2, options);
|
|
441
|
+
return { width: side, height: side, data: Uint8ClampedArray.from(bytes) };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function structuredAppendParity(bytes: Uint8Array): number {
|
|
445
|
+
if (!(bytes instanceof Uint8Array)) invalid('Expected bytes', 'encode');
|
|
446
|
+
if (bytes.byteLength > capacity) throw new ZymbolError('DATA_TOO_LONG', 'structuredAppendParity', 'Payload exceeds WebAssembly input workspace');
|
|
447
|
+
memoryView(bridge.memory, bridge.zymbol_input_ptr(), bytes.length).set(bytes);
|
|
448
|
+
const parity = bridge.zymbol_structured_append_parity(bytes.length);
|
|
449
|
+
if (!isIndex(parity, 255)) throw new ZymbolError('WASM_ABI_MISMATCH', 'structuredAppendParity', 'Invalid native parity result');
|
|
450
|
+
return parity;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function svg(input: Input, options?: SvgEncodeOptions): string {
|
|
454
|
+
return renderSvg(encode(input, options?.encode), options?.render);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function png(input: Input, options?: PngEncodeOptions): Uint8Array<ArrayBuffer> {
|
|
458
|
+
return renderPng(encode(input, options?.encode), options?.render);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return { encode, encodeSegments, decode, renderSvg, renderPng, renderRaster, svg, png, structuredAppendParity } as QrEngine;
|
|
462
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type ZymbolErrorCode =
|
|
2
|
+
| 'INVALID_INPUT'
|
|
3
|
+
| 'INVALID_OPTIONS'
|
|
4
|
+
| 'DATA_TOO_LONG'
|
|
5
|
+
| 'INVALID_SYMBOL'
|
|
6
|
+
| 'DECODE_FAILED'
|
|
7
|
+
| 'OUTPUT_LIMIT'
|
|
8
|
+
| 'OUT_OF_MEMORY'
|
|
9
|
+
| 'WASM_LOAD_FAILED'
|
|
10
|
+
| 'WASM_ABI_MISMATCH'
|
|
11
|
+
| 'WASM_TRAP'
|
|
12
|
+
| 'INSTANCE_UNUSABLE'
|
|
13
|
+
| 'INTERNAL_ERROR';
|
|
14
|
+
|
|
15
|
+
export type Operation =
|
|
16
|
+
| 'initialize' | 'encode' | 'encodeSegments' | 'decode'
|
|
17
|
+
| 'renderSvg' | 'renderPng' | 'renderRaster'
|
|
18
|
+
| 'svg' | 'png' | 'structuredAppendParity';
|
|
19
|
+
|
|
20
|
+
export class ZymbolError extends Error {
|
|
21
|
+
override readonly name = 'ZymbolError';
|
|
22
|
+
readonly reason: string | undefined;
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
readonly code: ZymbolErrorCode,
|
|
26
|
+
readonly operation: Operation,
|
|
27
|
+
message: string,
|
|
28
|
+
options: { readonly reason?: string; readonly cause?: unknown } = {},
|
|
29
|
+
) {
|
|
30
|
+
super(message, { cause: options.cause });
|
|
31
|
+
this.reason = options.reason;
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/node.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { createEngine } from './engine.js';
|
|
3
|
+
import type { InitOptions, Zymbol } from './types.js';
|
|
4
|
+
import type { WasmInit } from './wasm.js';
|
|
5
|
+
|
|
6
|
+
export { ZymbolError } from './errors.js';
|
|
7
|
+
export type * from './types.js';
|
|
8
|
+
import { compileWasm, instantiateWasm } from './wasm.js';
|
|
9
|
+
|
|
10
|
+
let defaultModule: Promise<WebAssembly.Module> | undefined;
|
|
11
|
+
|
|
12
|
+
async function loadDefault(): Promise<WebAssembly.Module> {
|
|
13
|
+
if (!defaultModule) {
|
|
14
|
+
defaultModule = readFile(new URL('./zymbol.wasm', import.meta.url))
|
|
15
|
+
.then(bytes => compileWasm({ wasm: bytes }))
|
|
16
|
+
.catch(error => {
|
|
17
|
+
defaultModule = undefined;
|
|
18
|
+
throw error;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
return defaultModule;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Load the packaged WASM asset from disk and create an isolated runtime. */
|
|
25
|
+
export async function createZymbol(options: InitOptions = {}): Promise<Zymbol> {
|
|
26
|
+
const explicit = 'wasm' in options || 'module' in options;
|
|
27
|
+
const module = explicit ? await compileWasm(options as WasmInit) : await loadDefault();
|
|
28
|
+
return createEngine(await instantiateWasm(module), options.limits);
|
|
29
|
+
}
|