quadqr-js 0.7.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.
@@ -0,0 +1,1962 @@
1
+ /**
2
+ * QuadQR
3
+ *
4
+ * Experimental four-state RGBW matrix code written in pure JavaScript.
5
+ *
6
+ * Core format:
7
+ * - Red / Green / Blue / White data cells
8
+ * - exactly 2 bits per data cell
9
+ * - GF(256) Reed-Solomon ECC over byte symbols
10
+ * - interleaved body ECC blocks
11
+ * - zero-overhead spectral-spatial cell placement
12
+ * - confidence-aware Reed-Solomon error/erasure recovery
13
+ * - protected bootstrap/header
14
+ * - version-dependent distributed alignment patterns for perspective recovery
15
+ * - camera color calibration and image scanning
16
+ */
17
+
18
+ import {
19
+ MAX_CODEWORD_SYMBOLS,
20
+ rsEncode,
21
+ rsDecode
22
+ } from "./reed-solomon.js";
23
+ import {
24
+ alignmentPatternCentersForVersion,
25
+ alignmentPatternIsBlack,
26
+ alignmentPatternRadius,
27
+ sizeForVersion,
28
+ versionFromSize
29
+ } from "./geometry.js";
30
+ import {
31
+ detectCodeGeometry,
32
+ samplePerspectiveMatrix,
33
+ rectifyImageData,
34
+ sampleObservedPalette,
35
+ findActiveBounds,
36
+ sampleAxisAlignedGrid
37
+ } from "./vision.js";
38
+ import {
39
+ decryptSecurePayload,
40
+ encryptSecurePayload,
41
+ inspectSecureEnvelope,
42
+ SECURITY_MODES,
43
+ SECURITY_ALGORITHMS,
44
+ SECURE_PAYLOAD_VERSION,
45
+ DEFAULT_PBKDF2_ITERATIONS,
46
+ generateRaw256Key,
47
+ normalizeRaw256Key,
48
+ bytesToHex
49
+ } from "./security.js";
50
+
51
+ export const FORMAT_VERSION = 5;
52
+ export const MIN_VERSION = 1;
53
+ export const MAX_VERSION = 40;
54
+ export const DEFAULT_ECC_LEVEL = "M";
55
+
56
+ // Data cells are the four values 0..3. Structural white deliberately shares
57
+ // value 3 with data-white because both render and scan identically.
58
+ export const CELL = Object.freeze({
59
+ BLACK: -1,
60
+ RED: 0,
61
+ GREEN: 1,
62
+ BLUE: 2,
63
+ WHITE: 3
64
+ });
65
+
66
+ export const DEFAULT_PALETTE = Object.freeze({
67
+ black: "#000000",
68
+ white: "#ffffff",
69
+ red: "#ef233c",
70
+ green: "#16a34a",
71
+ blue: "#2563eb"
72
+ });
73
+
74
+ export const RENDER_STYLES = Object.freeze({
75
+ CLASSIC: "classic",
76
+ DEPTH: "depth",
77
+ SOFT: "soft",
78
+ INSET: "inset"
79
+ });
80
+
81
+ export const ECC_LEVELS = Object.freeze({
82
+ L: Object.freeze({ id: 0, paritySymbols: 12, correctableSymbolsPerBlock: 6 }),
83
+ M: Object.freeze({ id: 1, paritySymbols: 24, correctableSymbolsPerBlock: 12 }),
84
+ Q: Object.freeze({ id: 2, paritySymbols: 36, correctableSymbolsPerBlock: 18 }),
85
+ H: Object.freeze({ id: 3, paritySymbols: 48, correctableSymbolsPerBlock: 24 })
86
+ });
87
+
88
+ const ECC_BY_ID = Object.freeze(
89
+ Object.fromEntries(Object.entries(ECC_LEVELS).map(([name, info]) => [info.id, name]))
90
+ );
91
+
92
+ const MAGIC = new Uint8Array([0x51, 0x51, 0x52, 0x57]); // QQRW (QuadQR RGBW)
93
+ const HEADER_BYTES = 10;
94
+ const HEADER_RS_PARITY = 8;
95
+ const HEADER_CODEWORD_BYTES = HEADER_BYTES + HEADER_RS_PARITY;
96
+ const CELLS_PER_BYTE = 4;
97
+ const HEADER_CODEWORD_CELLS = HEADER_CODEWORD_BYTES * CELLS_PER_BYTE;
98
+
99
+ // Version 1 is a deliberately compact small-symbol profile. A 21x21 matrix
100
+ // cannot afford the normal 18-byte protected header plus a 24-byte M body
101
+ // parity block. The matrix size already identifies v1, so it uses a compact
102
+ // 4-byte logical header protected by 4 RS parity bytes and size-appropriate
103
+ // body parity. Larger versions keep the normal framing unchanged.
104
+ const COMPACT_VERSION = 1;
105
+ const COMPACT_HEADER_MAGIC = 0xc3;
106
+ const COMPACT_HEADER_BYTES = 4;
107
+ const COMPACT_HEADER_RS_PARITY = 4;
108
+ const COMPACT_HEADER_CODEWORD_BYTES = COMPACT_HEADER_BYTES + COMPACT_HEADER_RS_PARITY;
109
+ const COMPACT_HEADER_CODEWORD_CELLS = COMPACT_HEADER_CODEWORD_BYTES * CELLS_PER_BYTE;
110
+ const COMPACT_ECC_LEVELS = Object.freeze({
111
+ L: Object.freeze({ paritySymbols: 4, correctableSymbolsPerBlock: 2 }),
112
+ M: Object.freeze({ paritySymbols: 8, correctableSymbolsPerBlock: 4 }),
113
+ Q: Object.freeze({ paritySymbols: 12, correctableSymbolsPerBlock: 6 }),
114
+ H: Object.freeze({ paritySymbols: 16, correctableSymbolsPerBlock: 8 })
115
+ });
116
+
117
+ const CRC_BYTES = 4;
118
+ const TEXT_FLAG = 1;
119
+ const SECURE_FLAG = 1 << 3;
120
+ const ECC_SHIFT = 1;
121
+ const ECC_MASK = 0b00000110;
122
+
123
+ const textEncoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : null;
124
+ const textDecoder = typeof TextDecoder !== "undefined"
125
+ ? new TextDecoder("utf-8", { fatal: false })
126
+ : null;
127
+
128
+ function assert(condition, message) {
129
+ if (!condition) throw new Error(message);
130
+ }
131
+
132
+ function getTextEncoder() {
133
+ assert(textEncoder, "TextEncoder is required in this environment.");
134
+ return textEncoder;
135
+ }
136
+
137
+ function getTextDecoder() {
138
+ assert(textDecoder, "TextDecoder is required in this environment.");
139
+ return textDecoder;
140
+ }
141
+
142
+ function cloneMatrix(matrix) {
143
+ return matrix.map((row) => row.slice());
144
+ }
145
+
146
+ function make2D(size, valueFactory) {
147
+ return Array.from({ length: size }, (_, r) =>
148
+ Array.from({ length: size }, (_, c) =>
149
+ typeof valueFactory === "function" ? valueFactory(r, c) : valueFactory
150
+ )
151
+ );
152
+ }
153
+
154
+ function validateVersion(version) {
155
+ assert(Number.isInteger(version), "Version must be an integer.");
156
+ assert(
157
+ version >= MIN_VERSION && version <= MAX_VERSION,
158
+ `Version must be ${MIN_VERSION}..${MAX_VERSION}.`
159
+ );
160
+ }
161
+
162
+ function normalizeEccLevel(level = DEFAULT_ECC_LEVEL) {
163
+ const value = String(level).toUpperCase();
164
+ assert(ECC_LEVELS[value], `ECC level must be one of ${Object.keys(ECC_LEVELS).join(", ")}.`);
165
+ return value;
166
+ }
167
+
168
+ function concatBytes(...arrays) {
169
+ const total = arrays.reduce((sum, item) => sum + item.length, 0);
170
+ const out = new Uint8Array(total);
171
+ let offset = 0;
172
+ for (const item of arrays) {
173
+ out.set(item, offset);
174
+ offset += item.length;
175
+ }
176
+ return out;
177
+ }
178
+
179
+ function u32be(value) {
180
+ const v = value >>> 0;
181
+ return new Uint8Array([
182
+ (v >>> 24) & 0xff,
183
+ (v >>> 16) & 0xff,
184
+ (v >>> 8) & 0xff,
185
+ v & 0xff
186
+ ]);
187
+ }
188
+
189
+ function readU32be(bytes, offset = 0) {
190
+ return (
191
+ ((bytes[offset] << 24) >>> 0) |
192
+ (bytes[offset + 1] << 16) |
193
+ (bytes[offset + 2] << 8) |
194
+ bytes[offset + 3]
195
+ ) >>> 0;
196
+ }
197
+
198
+ let CRC_TABLE = null;
199
+ let CRC32_ACCELERATOR = null;
200
+
201
+ /**
202
+ * Install or clear an optional synchronous CRC-32 accelerator.
203
+ * QuadQR's prebuilt WASM helper uses this hook after initWasm() succeeds.
204
+ * The JavaScript implementation remains the default and fallback.
205
+ */
206
+ export function installCrc32Accelerator(accelerator = null) {
207
+ assert(
208
+ accelerator == null || typeof accelerator === "function",
209
+ "CRC-32 accelerator must be a function or null."
210
+ );
211
+ CRC32_ACCELERATOR = accelerator;
212
+ }
213
+
214
+ function getCrcTable() {
215
+ if (CRC_TABLE) return CRC_TABLE;
216
+ CRC_TABLE = new Uint32Array(256);
217
+ for (let n = 0; n < 256; n++) {
218
+ let c = n;
219
+ for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
220
+ CRC_TABLE[n] = c >>> 0;
221
+ }
222
+ return CRC_TABLE;
223
+ }
224
+
225
+ export function crc32(bytes) {
226
+ if (CRC32_ACCELERATOR) return CRC32_ACCELERATOR(bytes) >>> 0;
227
+ const table = getCrcTable();
228
+ let crc = 0xffffffff;
229
+ for (const byte of bytes) crc = table[(crc ^ byte) & 0xff] ^ (crc >>> 8);
230
+ return (crc ^ 0xffffffff) >>> 0;
231
+ }
232
+
233
+ function bytesToCells(bytes) {
234
+ const out = new Array(bytes.length * CELLS_PER_BYTE);
235
+ let cursor = 0;
236
+ for (const byte of bytes) {
237
+ out[cursor++] = (byte >>> 6) & 0b11;
238
+ out[cursor++] = (byte >>> 4) & 0b11;
239
+ out[cursor++] = (byte >>> 2) & 0b11;
240
+ out[cursor++] = byte & 0b11;
241
+ }
242
+ return out;
243
+ }
244
+
245
+ function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYTE)) {
246
+ assert(cells.length >= byteCount * CELLS_PER_BYTE, "Not enough RGBW cells to rebuild bytes.");
247
+ const out = new Uint8Array(byteCount);
248
+ for (let i = 0; i < byteCount; i++) {
249
+ const offset = i * CELLS_PER_BYTE;
250
+ const a = cells[offset];
251
+ const b = cells[offset + 1];
252
+ const c = cells[offset + 2];
253
+ const d = cells[offset + 3];
254
+ for (const value of [a, b, c, d]) {
255
+ assert(Number.isInteger(value) && value >= 0 && value <= 3, "Invalid RGBW data cell.");
256
+ }
257
+ out[i] = (a << 6) | (b << 4) | (c << 2) | d;
258
+ }
259
+ return out;
260
+ }
261
+
262
+ function flagsFor(text, eccLevel, secure = false) {
263
+ return (text ? TEXT_FLAG : 0) |
264
+ (secure ? SECURE_FLAG : 0) |
265
+ ((ECC_LEVELS[eccLevel].id << ECC_SHIFT) & ECC_MASK);
266
+ }
267
+
268
+ function eccFromFlags(flags) {
269
+ const id = (flags & ECC_MASK) >> ECC_SHIFT;
270
+ const name = ECC_BY_ID[id];
271
+ if (!name) throw new Error(`Unknown ECC profile id ${id}.`);
272
+ return name;
273
+ }
274
+
275
+ function getHeaderPlan(version) {
276
+ if (version === COMPACT_VERSION) {
277
+ return {
278
+ compact: true,
279
+ headerBytes: COMPACT_HEADER_BYTES,
280
+ paritySymbols: COMPACT_HEADER_RS_PARITY,
281
+ codewordBytes: COMPACT_HEADER_CODEWORD_BYTES,
282
+ codewordCells: COMPACT_HEADER_CODEWORD_CELLS,
283
+ correctableSymbols: COMPACT_HEADER_RS_PARITY / 2
284
+ };
285
+ }
286
+ return {
287
+ compact: false,
288
+ headerBytes: HEADER_BYTES,
289
+ paritySymbols: HEADER_RS_PARITY,
290
+ codewordBytes: HEADER_CODEWORD_BYTES,
291
+ codewordCells: HEADER_CODEWORD_CELLS,
292
+ correctableSymbols: HEADER_RS_PARITY / 2
293
+ };
294
+ }
295
+
296
+ function getEffectiveEcc(version, eccLevel) {
297
+ const normalized = normalizeEccLevel(eccLevel);
298
+ return version === COMPACT_VERSION ? COMPACT_ECC_LEVELS[normalized] : ECC_LEVELS[normalized];
299
+ }
300
+
301
+ function makeHeader(payloadLength, flags, version) {
302
+ assert(payloadLength >= 0 && payloadLength <= 0xffffffff, "Payload is too large.");
303
+
304
+ if (version === COMPACT_VERSION) {
305
+ assert(payloadLength <= 0xff, "Version 1 compact header supports payloads up to 255 bytes.");
306
+ const header = new Uint8Array(COMPACT_HEADER_BYTES);
307
+ header[0] = COMPACT_HEADER_MAGIC;
308
+ header[1] = flags & 0xff;
309
+ header[2] = payloadLength & 0xff;
310
+ header[3] = (payloadLength ^ 0xff) & 0xff;
311
+ return header;
312
+ }
313
+
314
+ const header = new Uint8Array(HEADER_BYTES);
315
+ header.set(MAGIC, 0);
316
+ header[4] = FORMAT_VERSION;
317
+ header[5] = flags & 0xff;
318
+ header.set(u32be(payloadLength), 6);
319
+ return header;
320
+ }
321
+
322
+ function magicMatches(header) {
323
+ return MAGIC.every((value, index) => header[index] === value);
324
+ }
325
+
326
+ function parseHeader(header, version) {
327
+ if (version === COMPACT_VERSION) {
328
+ if (
329
+ header.length !== COMPACT_HEADER_BYTES ||
330
+ header[0] !== COMPACT_HEADER_MAGIC ||
331
+ header[3] !== ((header[2] ^ 0xff) & 0xff)
332
+ ) {
333
+ throw new Error("QuadQR compact header mismatch.");
334
+ }
335
+ return { flags: header[1], payloadLength: header[2] };
336
+ }
337
+
338
+ if (!magicMatches(header) || header[4] !== FORMAT_VERSION) {
339
+ throw new Error("QuadQR magic/version mismatch.");
340
+ }
341
+ return { flags: header[5], payloadLength: readU32be(header, 6) };
342
+ }
343
+
344
+ function createLayout(version) {
345
+ validateVersion(version);
346
+ const size = sizeForVersion(version);
347
+ const matrix = make2D(size, CELL.WHITE);
348
+ const reserved = make2D(size, false);
349
+ const calibration = { red: [], green: [], blue: [], black: [], white: [] };
350
+
351
+ function reserveAndSet(row, col, value, calibrationKey = null) {
352
+ if (row < 0 || col < 0 || row >= size || col >= size) return;
353
+ reserved[row][col] = true;
354
+ matrix[row][col] = value;
355
+ if (calibrationKey) calibration[calibrationKey].push([row, col]);
356
+ }
357
+
358
+ function drawFinder(top, left) {
359
+ for (let r = -1; r <= 7; r++) {
360
+ for (let c = -1; c <= 7; c++) {
361
+ const rr = top + r;
362
+ const cc = left + c;
363
+ if (rr < 0 || cc < 0 || rr >= size || cc >= size) continue;
364
+ if (r === -1 || c === -1 || r === 7 || c === 7) {
365
+ reserveAndSet(rr, cc, CELL.WHITE, "white");
366
+ }
367
+ }
368
+ }
369
+
370
+ for (let r = 0; r < 7; r++) {
371
+ for (let c = 0; c < 7; c++) {
372
+ const outer = r === 0 || c === 0 || r === 6 || c === 6;
373
+ const center = r >= 2 && r <= 4 && c >= 2 && c <= 4;
374
+ const black = outer || center;
375
+ reserveAndSet(top + r, left + c, black ? CELL.BLACK : CELL.WHITE, black ? "black" : "white");
376
+ }
377
+ }
378
+ }
379
+
380
+ function drawAlignmentPattern(definition) {
381
+ const { row: centerRow, col: centerCol, separator = false } = definition;
382
+ const radius = alignmentPatternRadius(definition);
383
+
384
+ if (separator) {
385
+ const separatorRadius = radius + 1;
386
+ for (let r = -separatorRadius; r <= separatorRadius; r++) {
387
+ for (let c = -separatorRadius; c <= separatorRadius; c++) {
388
+ if (Math.abs(r) === separatorRadius || Math.abs(c) === separatorRadius) {
389
+ reserveAndSet(centerRow + r, centerCol + c, CELL.WHITE, "white");
390
+ }
391
+ }
392
+ }
393
+ }
394
+
395
+ for (let r = -radius; r <= radius; r++) {
396
+ for (let c = -radius; c <= radius; c++) {
397
+ const black = alignmentPatternIsBlack(definition, r, c);
398
+ reserveAndSet(
399
+ centerRow + r,
400
+ centerCol + c,
401
+ black ? CELL.BLACK : CELL.WHITE,
402
+ black ? "black" : "white"
403
+ );
404
+ }
405
+ }
406
+
407
+ return {
408
+ row: centerRow,
409
+ col: centerCol,
410
+ size: definition.size,
411
+ primary: Boolean(definition.primary),
412
+ center: [centerCol + 0.5, centerRow + 0.5],
413
+ bootstrap: Boolean(definition.bootstrap),
414
+ separator: Boolean(separator)
415
+ };
416
+ }
417
+
418
+ function isAreaFree(row0, col0, height, width) {
419
+ if (row0 < 0 || col0 < 0 || row0 + height > size || col0 + width > size) return false;
420
+ for (let r = row0; r < row0 + height; r++) {
421
+ for (let c = col0; c < col0 + width; c++) {
422
+ if (reserved[r][c]) return false;
423
+ }
424
+ }
425
+ return true;
426
+ }
427
+
428
+ function findCalibrationStripOrigin() {
429
+ const preferred = { row: size - 6, col: size - 13 };
430
+ if (isAreaFree(preferred.row, preferred.col, 2, 6)) return preferred;
431
+
432
+ // Keep the swatches away from the three large finders and timing axes, but
433
+ // allow their location to move when a version's alignment grid occupies
434
+ // the old bottom-right calibration area.
435
+ for (let row = size - 8; row >= 8; row--) {
436
+ for (let col = size - 8; col >= 8; col--) {
437
+ if (isAreaFree(row, col, 2, 6)) return { row, col };
438
+ }
439
+ }
440
+
441
+ throw new Error(`Unable to reserve QuadQR calibration strip for version ${version}.`);
442
+ }
443
+
444
+ function drawCalibrationStrip() {
445
+ const origin = findCalibrationStripOrigin();
446
+ const entries = [
447
+ { key: "red", cell: CELL.RED, offset: 0 },
448
+ { key: "green", cell: CELL.GREEN, offset: 2 },
449
+ { key: "blue", cell: CELL.BLUE, offset: 4 }
450
+ ];
451
+
452
+ for (const entry of entries) {
453
+ for (const row of [origin.row, origin.row + 1]) {
454
+ for (let dc = 0; dc < 2; dc++) {
455
+ reserveAndSet(row, origin.col + entry.offset + dc, entry.cell, entry.key);
456
+ }
457
+ }
458
+ }
459
+ return { top: origin.row, left: origin.col, width: 6, height: 2 };
460
+ }
461
+
462
+ drawFinder(0, 0);
463
+ drawFinder(0, size - 7);
464
+ drawFinder(size - 7, 0);
465
+
466
+ for (let col = 8; col < size - 8; col++) {
467
+ reserveAndSet(6, col, col % 2 === 0 ? CELL.BLACK : CELL.WHITE);
468
+ }
469
+ for (let row = 8; row < size - 8; row++) {
470
+ reserveAndSet(row, 6, row % 2 === 0 ? CELL.BLACK : CELL.WHITE);
471
+ }
472
+
473
+ const alignments = alignmentPatternCentersForVersion(version).map(drawAlignmentPattern);
474
+ const alignment = alignments[alignments.length - 1];
475
+ const calibrationStrip = drawCalibrationStrip();
476
+
477
+ const dataPositions = [];
478
+ let upward = true;
479
+ let col = size - 1;
480
+ while (col >= 0) {
481
+ if (col === 6) col--;
482
+ for (let i = 0; i < size; i++) {
483
+ const row = upward ? size - 1 - i : i;
484
+ for (let dx = 0; dx < 2; dx++) {
485
+ const c = col - dx;
486
+ if (c < 0) continue;
487
+ if (!reserved[row][c]) dataPositions.push([row, c]);
488
+ }
489
+ }
490
+ upward = !upward;
491
+ col -= 2;
492
+ }
493
+
494
+ return {
495
+ version,
496
+ size,
497
+ matrix,
498
+ reserved,
499
+ dataPositions,
500
+ calibration,
501
+ calibrationStrip,
502
+ alignment,
503
+ alignments
504
+ };
505
+ }
506
+
507
+ function maskValue(row, col, maskId) {
508
+ switch (maskId) {
509
+ case 0: return (row + col) & 3;
510
+ case 1: return (2 * row + col) & 3;
511
+ case 2: return (row + 2 * col) & 3;
512
+ case 3: return (row * col + row + col) & 3;
513
+ default: throw new Error(`Unknown mask ${maskId}.`);
514
+ }
515
+ }
516
+
517
+ function makePaddingCells(count, seed) {
518
+ let state = (seed >>> 0) || 0x6d2b79f5;
519
+ const out = new Array(count);
520
+ for (let i = 0; i < count; i++) {
521
+ state ^= state << 13;
522
+ state ^= state >>> 17;
523
+ state ^= state << 5;
524
+ state >>>= 0;
525
+ out[i] = state & 3;
526
+ }
527
+ return out;
528
+ }
529
+
530
+ const SPECTRAL_PERMUTATION_CACHE = new Map();
531
+
532
+ function spectralPermutation(length, version) {
533
+ const cacheKey = `${version}:${length}`;
534
+ const cached = SPECTRAL_PERMUTATION_CACHE.get(cacheKey);
535
+ if (cached) return cached;
536
+
537
+ const permutation = Array.from({ length }, (_, index) => index);
538
+ let state = (0x9e3779b9 ^ Math.imul(version + 1, 0x85ebca6b) ^ Math.imul(length + 17, 0xc2b2ae35)) >>> 0;
539
+
540
+ function nextRandom() {
541
+ state ^= state << 13;
542
+ state ^= state >>> 17;
543
+ state ^= state << 5;
544
+ state >>>= 0;
545
+ return state;
546
+ }
547
+
548
+ // Deterministic Fisher-Yates shuffle. The mapping costs no cells and makes
549
+ // neighboring logical symbols land at widely separated physical modules.
550
+ for (let index = length - 1; index > 0; index--) {
551
+ const swapIndex = nextRandom() % (index + 1);
552
+ [permutation[index], permutation[swapIndex]] = [permutation[swapIndex], permutation[index]];
553
+ }
554
+ SPECTRAL_PERMUTATION_CACHE.set(cacheKey, permutation);
555
+ return permutation;
556
+ }
557
+
558
+ function applyData(layout, rawCells, maskId, spectralInterleaving = true) {
559
+ const matrix = cloneMatrix(layout.matrix);
560
+ const permutation = spectralInterleaving
561
+ ? spectralPermutation(layout.dataPositions.length, layout.version)
562
+ : null;
563
+
564
+ for (let logicalIndex = 0; logicalIndex < layout.dataPositions.length; logicalIndex++) {
565
+ const physicalIndex = permutation ? permutation[logicalIndex] : logicalIndex;
566
+ const [row, col] = layout.dataPositions[physicalIndex];
567
+ matrix[row][col] = rawCells[logicalIndex] ^ maskValue(row, col, maskId);
568
+ }
569
+ return matrix;
570
+ }
571
+
572
+ function quaternaryPenalty(matrix, reserved) {
573
+ const size = matrix.length;
574
+ let penalty = 0;
575
+ const counts = [0, 0, 0, 0];
576
+ let dataCount = 0;
577
+
578
+ for (let r = 0; r < size; r++) {
579
+ let previous = null;
580
+ let run = 0;
581
+ for (let c = 0; c < size; c++) {
582
+ if (reserved[r][c]) {
583
+ previous = null;
584
+ run = 0;
585
+ continue;
586
+ }
587
+ const value = matrix[r][c];
588
+ counts[value]++;
589
+ dataCount++;
590
+ if (value === previous) {
591
+ run++;
592
+ if (run >= 4) penalty += 2;
593
+ } else {
594
+ previous = value;
595
+ run = 1;
596
+ }
597
+ }
598
+ }
599
+
600
+ for (let c = 0; c < size; c++) {
601
+ let previous = null;
602
+ let run = 0;
603
+ for (let r = 0; r < size; r++) {
604
+ if (reserved[r][c]) {
605
+ previous = null;
606
+ run = 0;
607
+ continue;
608
+ }
609
+ const value = matrix[r][c];
610
+ if (value === previous) {
611
+ run++;
612
+ if (run >= 4) penalty += 2;
613
+ } else {
614
+ previous = value;
615
+ run = 1;
616
+ }
617
+ }
618
+ }
619
+
620
+ if (dataCount > 0) {
621
+ const ideal = dataCount / 4;
622
+ penalty += counts.reduce((sum, count) => sum + Math.abs(count - ideal), 0) / 2;
623
+ }
624
+ return penalty;
625
+ }
626
+
627
+ function interleaveBlocks(blocks) {
628
+ const maxLength = Math.max(...blocks.map((block) => block.length), 0);
629
+ const out = [];
630
+ for (let index = 0; index < maxLength; index++) {
631
+ for (const block of blocks) {
632
+ if (index < block.length) out.push(block[index]);
633
+ }
634
+ }
635
+ return out;
636
+ }
637
+
638
+ function deinterleaveBlocks(stream, blockLengths) {
639
+ const blocks = blockLengths.map((length) => new Array(length));
640
+ let cursor = 0;
641
+ const maxLength = Math.max(...blockLengths, 0);
642
+ for (let index = 0; index < maxLength; index++) {
643
+ for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
644
+ if (index < blockLengths[blockIndex]) blocks[blockIndex][index] = stream[cursor++];
645
+ }
646
+ }
647
+ assert(cursor === stream.length, "Interleaved RS symbol length mismatch.");
648
+ return blocks;
649
+ }
650
+
651
+ function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
652
+ const ecc = getEffectiveEcc(version, eccLevel);
653
+ const bodyByteCount = payloadLength + CRC_BYTES;
654
+ const dataSymbols = bodyByteCount;
655
+ const maxDataPerBlock = MAX_CODEWORD_SYMBOLS - ecc.paritySymbols;
656
+ const dataBlockLengths = [];
657
+ let remaining = dataSymbols;
658
+ while (remaining > 0) {
659
+ const length = Math.min(maxDataPerBlock, remaining);
660
+ dataBlockLengths.push(length);
661
+ remaining -= length;
662
+ }
663
+ const codewordBlockLengths = dataBlockLengths.map((length) => length + ecc.paritySymbols);
664
+ const encodedSymbols = codewordBlockLengths.reduce((sum, value) => sum + value, 0);
665
+ return {
666
+ bodyByteCount,
667
+ dataSymbols,
668
+ paritySymbols: ecc.paritySymbols,
669
+ correctableSymbolsPerBlock: ecc.correctableSymbolsPerBlock,
670
+ dataBlockLengths,
671
+ codewordBlockLengths,
672
+ encodedSymbols,
673
+ encodedCells: encodedSymbols * CELLS_PER_BYTE
674
+ };
675
+ }
676
+
677
+ function streamCellCount(payloadLength, eccLevel, version) {
678
+ return getHeaderPlan(version).codewordCells + getBodyRsPlan(payloadLength, eccLevel, version).encodedCells;
679
+ }
680
+
681
+ function streamFitsLayout(layout, eccLevel, payloadLength, version) {
682
+ return streamCellCount(payloadLength, eccLevel, version) <= layout.dataPositions.length;
683
+ }
684
+
685
+ function getCapacityForLayout(layout, eccLevel, version) {
686
+ if (!streamFitsLayout(layout, eccLevel, 0, version)) return 0;
687
+ let low = 0;
688
+ let high = Math.floor(layout.dataPositions.length / CELLS_PER_BYTE);
689
+ while (low < high) {
690
+ const mid = Math.ceil((low + high) / 2);
691
+ if (streamFitsLayout(layout, eccLevel, mid, version)) low = mid;
692
+ else high = mid - 1;
693
+ }
694
+ return low;
695
+ }
696
+
697
+ export function getVersionInfo(version, options = {}) {
698
+ validateVersion(version);
699
+ const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
700
+ const layout = createLayout(version);
701
+ const headerPlan = getHeaderPlan(version);
702
+ const effectiveEcc = getEffectiveEcc(version, eccLevel);
703
+ return {
704
+ version,
705
+ formatVersion: FORMAT_VERSION,
706
+ eccLevel,
707
+ size: layout.size,
708
+ dataCells: layout.dataPositions.length,
709
+ theoreticalBits: layout.dataPositions.length * 2,
710
+ capacityBytes: getCapacityForLayout(layout, eccLevel, version),
711
+ headerCells: headerPlan.codewordCells,
712
+ headerBytes: headerPlan.headerBytes,
713
+ headerParitySymbols: headerPlan.paritySymbols,
714
+ bodyParitySymbols: effectiveEcc.paritySymbols,
715
+ correctableHeaderSymbols: headerPlan.correctableSymbols,
716
+ correctableSymbolsPerBlock: effectiveEcc.correctableSymbolsPerBlock,
717
+ compactSmallSymbol: version === COMPACT_VERSION,
718
+ calibrationCells: 12,
719
+ hasAlignmentMarker: true,
720
+ alignmentPatterns: layout.alignments.length,
721
+ alignmentCenters: layout.alignments.map(({ row, col }) => [row, col]),
722
+ bitsPerDataCell: 2,
723
+ colors: 4,
724
+ spectralInterleaving: true,
725
+ confidenceAwareEcc: true
726
+ };
727
+ }
728
+
729
+ function chooseVersion(payloadLength, options = {}) {
730
+ const requested = options.version ?? "auto";
731
+ const minVersion = options.minVersion ?? MIN_VERSION;
732
+ const maxVersion = options.maxVersion ?? MAX_VERSION;
733
+ const ecc = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
734
+
735
+ validateVersion(minVersion);
736
+ validateVersion(maxVersion);
737
+ assert(minVersion <= maxVersion, "minVersion must be <= maxVersion.");
738
+
739
+ if (requested !== "auto") {
740
+ validateVersion(requested);
741
+ assert(requested >= minVersion && requested <= maxVersion, "Requested version is outside selected bounds.");
742
+ const info = getVersionInfo(requested, { ecc });
743
+ const layout = createLayout(requested);
744
+ assert(
745
+ payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested),
746
+ `Payload does not fit version ${requested} with ${ecc} ECC. Maximum is ${info.capacityBytes} bytes.`
747
+ );
748
+ return requested;
749
+ }
750
+
751
+ for (let version = minVersion; version <= maxVersion; version++) {
752
+ const layout = createLayout(version);
753
+ if (streamFitsLayout(layout, ecc, payloadLength, version)) return version;
754
+ }
755
+ throw new Error(`Payload is too large for versions ${minVersion}..${maxVersion}.`);
756
+ }
757
+
758
+ function encodeProtectedHeader(header, version) {
759
+ const plan = getHeaderPlan(version);
760
+ const codeword = rsEncode(Array.from(header), plan.paritySymbols);
761
+ assert(codeword.length === plan.codewordBytes, "Header RS symbol calculation mismatch.");
762
+ return bytesToCells(codeword);
763
+ }
764
+
765
+ function encodeProtectedBody(bodyBytes, eccLevel, version) {
766
+ const plan = getBodyRsPlan(bodyBytes.length - CRC_BYTES, eccLevel, version);
767
+ assert(bodyBytes.length === plan.dataSymbols, "Body RS symbol calculation mismatch.");
768
+
769
+ const blocks = [];
770
+ let offset = 0;
771
+ for (const length of plan.dataBlockLengths) {
772
+ blocks.push(rsEncode(Array.from(bodyBytes.slice(offset, offset + length)), plan.paritySymbols));
773
+ offset += length;
774
+ }
775
+ return { cells: bytesToCells(interleaveBlocks(blocks)), plan };
776
+ }
777
+
778
+ function finalizeMatrix(layout, rawCells, meta) {
779
+ let bestMaskId = 0;
780
+ let bestMatrix = null;
781
+ let bestPenalty = Infinity;
782
+
783
+ for (let maskId = 0; maskId < 4; maskId++) {
784
+ const candidate = applyData(layout, rawCells, maskId, true);
785
+ const penalty = quaternaryPenalty(candidate, layout.reserved);
786
+ if (penalty < bestPenalty) {
787
+ bestPenalty = penalty;
788
+ bestMaskId = maskId;
789
+ bestMatrix = candidate;
790
+ }
791
+ }
792
+
793
+ const info = getVersionInfo(meta.version, { ecc: meta.eccLevel });
794
+ return {
795
+ format: "QuadQR",
796
+ formatVersion: FORMAT_VERSION,
797
+ version: meta.version,
798
+ size: layout.size,
799
+ matrix: bestMatrix,
800
+ maskId: bestMaskId,
801
+ payloadBytes: meta.payloadBytes,
802
+ sourcePayloadBytes: meta.sourcePayloadBytes ?? meta.payloadBytes,
803
+ secure: Boolean(meta.secure),
804
+ requiresDecryption: Boolean(meta.secure),
805
+ security: meta.security ?? null,
806
+ meaningfulCells: meta.meaningfulCells,
807
+ dataCells: layout.dataPositions.length,
808
+ capacityBytes: info.capacityBytes,
809
+ alignmentPatterns: layout.alignments.length,
810
+ utilization: meta.meaningfulCells / layout.dataPositions.length,
811
+ bitsPerDataCell: 2,
812
+ eccLevel: meta.eccLevel,
813
+ eccParitySymbols: meta.eccParitySymbols,
814
+ eccBlocks: meta.eccBlocks,
815
+ correctableSymbolsPerBlock: meta.correctableSymbolsPerBlock,
816
+ spectralInterleaving: true,
817
+ confidenceAwareEcc: true,
818
+ crc32: meta.crc >>> 0
819
+ };
820
+ }
821
+
822
+ export function encodeText(text, options = {}) {
823
+ assert(typeof text === "string", "encodeText expects a string.");
824
+ return encodeBytes(getTextEncoder().encode(text), { ...options, text: true });
825
+ }
826
+
827
+ export function encodeBytes(input, options = {}) {
828
+ if (options.formatVersion != null && options.formatVersion !== FORMAT_VERSION) {
829
+ throw new Error(`Only QuadQR format version ${FORMAT_VERSION} is supported.`);
830
+ }
831
+
832
+ const payload = input instanceof Uint8Array ? input : new Uint8Array(input);
833
+ const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
834
+ const secure = Boolean(options.secure);
835
+ const flags = flagsFor(Boolean(options.text), eccLevel, secure);
836
+ const version = chooseVersion(payload.length, { ...options, ecc: eccLevel });
837
+ const layout = createLayout(version);
838
+ const header = makeHeader(payload.length, flags, version);
839
+ const crc = crc32(concatBytes(header, payload));
840
+ const headerCells = encodeProtectedHeader(header, version);
841
+ const bodyEncoded = encodeProtectedBody(concatBytes(payload, u32be(crc)), eccLevel, version);
842
+ const meaningfulCells = headerCells.concat(bodyEncoded.cells);
843
+ assert(meaningfulCells.length <= layout.dataPositions.length, "Internal QuadQR capacity calculation error.");
844
+
845
+ const padding = makePaddingCells(
846
+ layout.dataPositions.length - meaningfulCells.length,
847
+ crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16)
848
+ );
849
+
850
+ return finalizeMatrix(layout, meaningfulCells.concat(padding), {
851
+ version,
852
+ payloadBytes: payload.length,
853
+ sourcePayloadBytes: options.sourcePayloadBytes ?? payload.length,
854
+ secure,
855
+ security: options.securityMetadata ?? null,
856
+ meaningfulCells: meaningfulCells.length,
857
+ eccLevel,
858
+ eccParitySymbols: bodyEncoded.plan.paritySymbols,
859
+ eccBlocks: bodyEncoded.plan.dataBlockLengths.length,
860
+ correctableSymbolsPerBlock: bodyEncoded.plan.correctableSymbolsPerBlock,
861
+ crc
862
+ });
863
+ }
864
+
865
+ /**
866
+ * Encode text using the optional QuadQR Secure Payload v1 layer.
867
+ * This API is async because Web Crypto performs AES-GCM and password KDF work asynchronously.
868
+ */
869
+ export async function encodeSecureText(text, options = {}) {
870
+ assert(typeof text === "string", "encodeSecureText expects a string.");
871
+ return encodeSecureBytes(getTextEncoder().encode(text), { ...options, text: true });
872
+ }
873
+
874
+ /** Encode arbitrary bytes using password or raw 256-bit key security. */
875
+ export async function encodeSecureBytes(input, options = {}) {
876
+ const sourcePayload = input instanceof Uint8Array ? input : new Uint8Array(input);
877
+ const security = options.security ?? {};
878
+ const encrypted = await encryptSecurePayload(sourcePayload, security);
879
+ return encodeBytes(encrypted.envelope, {
880
+ ...options,
881
+ text: Boolean(options.text),
882
+ secure: true,
883
+ sourcePayloadBytes: sourcePayload.length,
884
+ securityMetadata: encrypted.metadata
885
+ });
886
+ }
887
+
888
+ /**
889
+ * Decrypt a result returned by decodeMatrix/scanImageData/scanFile.
890
+ * The encrypted envelope remains available as encryptedPayload.
891
+ */
892
+ export async function decryptDecoded(result, security = {}) {
893
+ assert(result?.secure && result?.payload, "decryptDecoded expects an encrypted QuadQR decode result.");
894
+ const plaintext = await decryptSecurePayload(result.payload, security);
895
+ const isText = (result.flags & TEXT_FLAG) !== 0;
896
+ return {
897
+ ...result,
898
+ encryptedPayload: result.payload,
899
+ encryptedPayloadBytes: result.payload.length,
900
+ payload: plaintext,
901
+ text: isText ? getTextDecoder().decode(plaintext) : null,
902
+ decrypted: true,
903
+ requiresDecryption: false,
904
+ security: { ...result.security, decrypted: true }
905
+ };
906
+ }
907
+
908
+ export {
909
+ SECURITY_MODES,
910
+ SECURITY_ALGORITHMS,
911
+ SECURE_PAYLOAD_VERSION,
912
+ DEFAULT_PBKDF2_ITERATIONS,
913
+ generateRaw256Key,
914
+ normalizeRaw256Key,
915
+ bytesToHex
916
+ };
917
+
918
+ function finderMismatchRatio(matrix, top, left) {
919
+ let mismatches = 0;
920
+ let total = 0;
921
+ for (let r = 0; r < 7; r++) {
922
+ for (let c = 0; c < 7; c++) {
923
+ const outer = r === 0 || c === 0 || r === 6 || c === 6;
924
+ const center = r >= 2 && r <= 4 && c >= 2 && c <= 4;
925
+ const expected = outer || center ? CELL.BLACK : CELL.WHITE;
926
+ total++;
927
+ if (matrix[top + r]?.[left + c] !== expected) mismatches++;
928
+ }
929
+ }
930
+ return mismatches / total;
931
+ }
932
+
933
+ function alignmentPatternMismatchRatio(matrix, pattern) {
934
+ let mismatches = 0;
935
+ let total = 0;
936
+ const radius = alignmentPatternRadius(pattern);
937
+ for (let r = -radius; r <= radius; r++) {
938
+ for (let c = -radius; c <= radius; c++) {
939
+ const black = alignmentPatternIsBlack(pattern, r, c);
940
+ const expected = black ? CELL.BLACK : CELL.WHITE;
941
+ total++;
942
+ if (matrix[pattern.row + r]?.[pattern.col + c] !== expected) mismatches++;
943
+ }
944
+ }
945
+ return mismatches / total;
946
+ }
947
+
948
+ function alignmentGridMismatchRatio(matrix, version) {
949
+ const patterns = alignmentPatternCentersForVersion(version);
950
+ let weightedMismatch = 0;
951
+ let totalWeight = 0;
952
+ for (const pattern of patterns) {
953
+ // Give the 5x5 primary marker a little more influence than compact 3x3 markers.
954
+ const weight = pattern.primary ? 2 : 1;
955
+ weightedMismatch += alignmentPatternMismatchRatio(matrix, pattern) * weight;
956
+ totalWeight += weight;
957
+ }
958
+ return totalWeight ? weightedMismatch / totalWeight : 0;
959
+ }
960
+
961
+ function validateStructure(matrix, tolerance = 0) {
962
+ const size = matrix.length;
963
+ if (size < 21 || matrix.some((row) => row.length !== size)) return false;
964
+ const version = versionFromSize(size);
965
+ if (!version) return false;
966
+ const finderRatios = [
967
+ finderMismatchRatio(matrix, 0, 0),
968
+ finderMismatchRatio(matrix, 0, size - 7),
969
+ finderMismatchRatio(matrix, size - 7, 0)
970
+ ];
971
+ if (finderRatios.some((ratio) => ratio > tolerance)) return false;
972
+
973
+ const alignmentTolerance = Math.max(tolerance, 0.12);
974
+ const primary = alignmentPatternCentersForVersion(version).at(-1);
975
+ if (primary && alignmentPatternMismatchRatio(matrix, primary) > alignmentTolerance) {
976
+ return false;
977
+ }
978
+ if (alignmentGridMismatchRatio(matrix, version) > alignmentTolerance) return false;
979
+ return true;
980
+ }
981
+
982
+ function rotate90(matrix) {
983
+ const size = matrix.length;
984
+ const out = make2D(size, CELL.WHITE);
985
+ for (let r = 0; r < size; r++) {
986
+ for (let c = 0; c < size; c++) out[c][size - 1 - r] = matrix[r][c];
987
+ }
988
+ return out;
989
+ }
990
+
991
+ function extractVisibleCells(matrix, layout) {
992
+ const out = [];
993
+ for (const [row, col] of layout.dataPositions) {
994
+ const value = matrix[row][col];
995
+ if (!Number.isInteger(value) || value < 0 || value > 3) {
996
+ throw new Error("Data region contains an invalid QuadQR data cell.");
997
+ }
998
+ out.push(value);
999
+ }
1000
+ return out;
1001
+ }
1002
+
1003
+ function unmaskCells(visibleCells, positions, maskId) {
1004
+ return visibleCells.map((value, index) => {
1005
+ const [row, col] = positions[index];
1006
+ return value ^ maskValue(row, col, maskId);
1007
+ });
1008
+ }
1009
+
1010
+ function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
1011
+ if (!spectralInterleaving) return physicalValues.slice();
1012
+ const permutation = spectralPermutation(physicalValues.length, version);
1013
+ const logical = new Array(physicalValues.length);
1014
+ for (let logicalIndex = 0; logicalIndex < permutation.length; logicalIndex++) {
1015
+ logical[logicalIndex] = physicalValues[permutation[logicalIndex]];
1016
+ }
1017
+ return logical;
1018
+ }
1019
+
1020
+ function cellsToSymbolConfidences(confidences, byteCount) {
1021
+ if (!confidences) return null;
1022
+ if (confidences.length < byteCount * CELLS_PER_BYTE) return null;
1023
+ const out = new Array(byteCount);
1024
+ for (let symbolIndex = 0; symbolIndex < byteCount; symbolIndex++) {
1025
+ const offset = symbolIndex * CELLS_PER_BYTE;
1026
+ // A single wrong 2-bit cell changes the GF(256) symbol, so the weakest
1027
+ // constituent cell is the useful confidence bound for that symbol.
1028
+ out[symbolIndex] = Math.min(
1029
+ confidences[offset] ?? 1,
1030
+ confidences[offset + 1] ?? 1,
1031
+ confidences[offset + 2] ?? 1,
1032
+ confidences[offset + 3] ?? 1
1033
+ );
1034
+ }
1035
+ return out;
1036
+ }
1037
+
1038
+ function decodeRsAdaptive(codeword, paritySymbols, symbolConfidences, options = {}) {
1039
+ try {
1040
+ return { ...rsDecode(codeword, paritySymbols), confidenceAssisted: false };
1041
+ } catch (hardError) {
1042
+ if (!symbolConfidences || symbolConfidences.length !== codeword.length) throw hardError;
1043
+
1044
+ const maxErasureConfidence = options.maxErasureConfidence ?? 0.68;
1045
+ const ranked = symbolConfidences
1046
+ .map((confidence, position) => ({ confidence: Number.isFinite(confidence) ? confidence : 1, position }))
1047
+ .filter(({ confidence }) => confidence <= maxErasureConfidence)
1048
+ .sort((a, b) => (a.confidence - b.confidence) || (a.position - b.position));
1049
+
1050
+ const limit = Math.min(paritySymbols, ranked.length);
1051
+ let lastError = hardError;
1052
+ // Start with the least-confident symbol and progressively promote more
1053
+ // uncertain symbols to erasures. Syndrome verification inside rsDecode
1054
+ // prevents accepting an invalid correction.
1055
+ for (let count = 1; count <= limit; count++) {
1056
+ const erasurePositions = ranked.slice(0, count).map(({ position }) => position);
1057
+ try {
1058
+ return {
1059
+ ...rsDecode(codeword, paritySymbols, { erasurePositions }),
1060
+ confidenceAssisted: true
1061
+ };
1062
+ } catch (error) {
1063
+ lastError = error;
1064
+ }
1065
+ }
1066
+ throw lastError;
1067
+ }
1068
+ }
1069
+
1070
+ function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}) {
1071
+ const plan = getHeaderPlan(version);
1072
+ const headerCells = rawCells.slice(0, plan.codewordCells);
1073
+ const codeword = Array.from(cellsToBytes(headerCells, plan.codewordBytes));
1074
+ const confidences = cellsToSymbolConfidences(rawConfidences?.slice(0, plan.codewordCells), plan.codewordBytes);
1075
+ const decoded = decodeRsAdaptive(codeword, plan.paritySymbols, confidences, options);
1076
+ const header = new Uint8Array(decoded.data);
1077
+ const parsed = parseHeader(header, version);
1078
+ return {
1079
+ header,
1080
+ ...parsed,
1081
+ correctedSymbols: decoded.correctedSymbols,
1082
+ erasureSymbols: decoded.erasureSymbols ?? 0,
1083
+ unknownErrorSymbols: decoded.unknownErrorSymbols ?? decoded.correctedSymbols,
1084
+ confidenceAssisted: decoded.confidenceAssisted,
1085
+ plan
1086
+ };
1087
+ }
1088
+
1089
+ function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConfidences = null, options = {}) {
1090
+ const plan = getBodyRsPlan(payloadLength, eccLevel, version);
1091
+ const bodyStart = getHeaderPlan(version).codewordCells;
1092
+ const encodedCells = rawCells.slice(bodyStart, bodyStart + plan.encodedCells);
1093
+ if (encodedCells.length !== plan.encodedCells) throw new Error("Protected body is incomplete.");
1094
+
1095
+ const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols));
1096
+ const symbolConfidences = cellsToSymbolConfidences(
1097
+ rawConfidences?.slice(bodyStart, bodyStart + plan.encodedCells),
1098
+ plan.encodedSymbols
1099
+ );
1100
+ const blocks = deinterleaveBlocks(encodedSymbols, plan.codewordBlockLengths);
1101
+ const confidenceBlocks = symbolConfidences
1102
+ ? deinterleaveBlocks(symbolConfidences, plan.codewordBlockLengths)
1103
+ : blocks.map(() => null);
1104
+ const decodedDataSymbols = [];
1105
+ let correctedSymbols = 0;
1106
+ let erasureSymbols = 0;
1107
+ let unknownErrorSymbols = 0;
1108
+ let confidenceAssisted = false;
1109
+
1110
+ for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
1111
+ const decoded = decodeRsAdaptive(
1112
+ blocks[blockIndex],
1113
+ plan.paritySymbols,
1114
+ confidenceBlocks[blockIndex],
1115
+ options
1116
+ );
1117
+ correctedSymbols += decoded.correctedSymbols;
1118
+ erasureSymbols += decoded.erasureSymbols ?? 0;
1119
+ unknownErrorSymbols += decoded.unknownErrorSymbols ?? decoded.correctedSymbols;
1120
+ confidenceAssisted ||= decoded.confidenceAssisted;
1121
+ decodedDataSymbols.push(...decoded.data);
1122
+ }
1123
+
1124
+ return {
1125
+ body: new Uint8Array(decodedDataSymbols.slice(0, plan.bodyByteCount)),
1126
+ correctedSymbols,
1127
+ erasureSymbols,
1128
+ unknownErrorSymbols,
1129
+ confidenceAssisted,
1130
+ plan
1131
+ };
1132
+ }
1133
+
1134
+ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = null, options = {}) {
1135
+ const size = matrix.length;
1136
+ const version = versionFromSize(size);
1137
+ if (!version) throw new Error(`Unsupported matrix size ${size}.`);
1138
+ if (!validateStructure(matrix, tolerance)) throw new Error("QuadQR finder/alignment structure does not match.");
1139
+
1140
+ const layout = createLayout(version);
1141
+ const visible = extractVisibleCells(matrix, layout);
1142
+ const visibleConfidences = confidenceMatrix
1143
+ ? layout.dataPositions.map(([row, col]) => confidenceMatrix[row]?.[col] ?? 1)
1144
+ : null;
1145
+ const errors = [];
1146
+
1147
+ // New symbols use spectral-spatial interleaving. Legacy order remains a
1148
+ // decode fallback so existing QuadQR images do not become unreadable.
1149
+ for (const spectralInterleaving of [true, false]) {
1150
+ for (let maskId = 0; maskId < 4; maskId++) {
1151
+ try {
1152
+ const physicalRaw = unmaskCells(visible, layout.dataPositions, maskId);
1153
+ const raw = restoreLogicalOrder(physicalRaw, version, spectralInterleaving);
1154
+ const rawConfidences = visibleConfidences
1155
+ ? restoreLogicalOrder(visibleConfidences, version, spectralInterleaving)
1156
+ : null;
1157
+ const headerDecoded = decodeProtectedHeader(raw, version, rawConfidences, options);
1158
+ const header = headerDecoded.header;
1159
+ const flags = headerDecoded.flags;
1160
+ const eccLevel = eccFromFlags(flags);
1161
+ const payloadLength = headerDecoded.payloadLength;
1162
+ if (streamCellCount(payloadLength, eccLevel, version) > raw.length) {
1163
+ throw new Error("Declared payload exceeds matrix capacity.");
1164
+ }
1165
+
1166
+ const bodyDecoded = decodeProtectedBody(
1167
+ raw,
1168
+ payloadLength,
1169
+ eccLevel,
1170
+ version,
1171
+ rawConfidences,
1172
+ options
1173
+ );
1174
+ const body = bodyDecoded.body;
1175
+ const payload = body.slice(0, payloadLength);
1176
+ const expectedCrc = readU32be(body, payloadLength);
1177
+ const actualCrc = crc32(concatBytes(header, payload));
1178
+ if (expectedCrc !== actualCrc) throw new Error("CRC mismatch after ECC.");
1179
+
1180
+ const isText = (flags & TEXT_FLAG) !== 0;
1181
+ const secure = (flags & SECURE_FLAG) !== 0;
1182
+ const security = secure ? inspectSecureEnvelope(payload) : null;
1183
+ const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
1184
+ const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
1185
+ return {
1186
+ ok: true,
1187
+ format: "QuadQR",
1188
+ formatVersion: FORMAT_VERSION,
1189
+ version,
1190
+ size,
1191
+ alignmentPatterns: layout.alignments.length,
1192
+ maskId,
1193
+ rotation,
1194
+ flags,
1195
+ eccLevel,
1196
+ eccParitySymbols: bodyDecoded.plan.paritySymbols,
1197
+ eccBlocks: bodyDecoded.plan.dataBlockLengths.length,
1198
+ correctableSymbolsPerBlock: bodyDecoded.plan.correctableSymbolsPerBlock,
1199
+ spectralInterleaving,
1200
+ confidenceAwareEcc: Boolean(rawConfidences),
1201
+ confidenceAssisted: headerDecoded.confidenceAssisted || bodyDecoded.confidenceAssisted,
1202
+ erasureSymbols,
1203
+ unknownErrorSymbols,
1204
+ correctedHeaderSymbols: headerDecoded.correctedSymbols,
1205
+ correctedBodySymbols: bodyDecoded.correctedSymbols,
1206
+ correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
1207
+ payload,
1208
+ text: isText && !secure ? getTextDecoder().decode(payload) : null,
1209
+ secure,
1210
+ encrypted: secure,
1211
+ decrypted: false,
1212
+ requiresDecryption: secure,
1213
+ security,
1214
+ crc32: actualCrc >>> 0
1215
+ };
1216
+ } catch (error) {
1217
+ errors.push(`${spectralInterleaving ? "spectral" : "legacy"} mask ${maskId}: ${error.message}`);
1218
+ }
1219
+ }
1220
+ }
1221
+
1222
+ throw new Error(`QuadQR decode failed. ${errors.join(" | ")}`);
1223
+ }
1224
+
1225
+ export function decodeMatrix(inputMatrix, options = {}) {
1226
+ assert(Array.isArray(inputMatrix) && inputMatrix.length > 0, "Matrix is required.");
1227
+ let matrix = cloneMatrix(inputMatrix);
1228
+ let confidenceMatrix = options.cellConfidence ? cloneMatrix(options.cellConfidence) : null;
1229
+ const errors = [];
1230
+ const tolerance = options.structureTolerance ?? 0;
1231
+
1232
+ if (confidenceMatrix) {
1233
+ assert(
1234
+ confidenceMatrix.length === matrix.length && confidenceMatrix.every((row) => row.length === matrix.length),
1235
+ "cellConfidence must be a square matrix matching the QuadQR matrix."
1236
+ );
1237
+ }
1238
+
1239
+ for (let rotationIndex = 0; rotationIndex < 4; rotationIndex++) {
1240
+ const degrees = rotationIndex * 90;
1241
+ try {
1242
+ return decodeCanonical(matrix, degrees, tolerance, confidenceMatrix, options);
1243
+ } catch (error) {
1244
+ errors.push(`${degrees}°: ${error.message}`);
1245
+ }
1246
+ matrix = rotate90(matrix);
1247
+ if (confidenceMatrix) confidenceMatrix = rotate90(confidenceMatrix);
1248
+ }
1249
+
1250
+ throw new Error(`Unable to decode matrix. ${errors.join(" || ")}`);
1251
+ }
1252
+
1253
+ function hexToRgb(hex) {
1254
+ const clean = hex.replace("#", "");
1255
+ assert(clean.length === 6, `Invalid hex color ${hex}.`);
1256
+ return {
1257
+ r: parseInt(clean.slice(0, 2), 16),
1258
+ g: parseInt(clean.slice(2, 4), 16),
1259
+ b: parseInt(clean.slice(4, 6), 16)
1260
+ };
1261
+ }
1262
+
1263
+ function resolvePalette(palette = {}) {
1264
+ return { ...DEFAULT_PALETTE, ...palette };
1265
+ }
1266
+
1267
+ function paletteRgb(palette = {}) {
1268
+ const p = resolvePalette(palette);
1269
+ return {
1270
+ black: hexToRgb(p.black),
1271
+ white: hexToRgb(p.white),
1272
+ red: hexToRgb(p.red),
1273
+ green: hexToRgb(p.green),
1274
+ blue: hexToRgb(p.blue)
1275
+ };
1276
+ }
1277
+
1278
+ function cellColor(cell, palette) {
1279
+ switch (cell) {
1280
+ case CELL.BLACK: return palette.black;
1281
+ case CELL.RED: return palette.red;
1282
+ case CELL.GREEN: return palette.green;
1283
+ case CELL.BLUE: return palette.blue;
1284
+ case CELL.WHITE: return palette.white;
1285
+ default: throw new Error(`Unknown cell value ${cell}.`);
1286
+ }
1287
+ }
1288
+
1289
+ function cellRgb(cell, palette) {
1290
+ switch (cell) {
1291
+ case CELL.BLACK: return palette.black;
1292
+ case CELL.RED: return palette.red;
1293
+ case CELL.GREEN: return palette.green;
1294
+ case CELL.BLUE: return palette.blue;
1295
+ case CELL.WHITE: return palette.white;
1296
+ default: throw new Error(`Unknown cell value ${cell}.`);
1297
+ }
1298
+ }
1299
+
1300
+
1301
+ function normalizeRenderStyle(style = RENDER_STYLES.CLASSIC) {
1302
+ const value = String(style).toLowerCase();
1303
+ const allowed = Object.values(RENDER_STYLES);
1304
+ assert(allowed.includes(value), `Render style must be one of ${allowed.join(", ")}.`);
1305
+ return value;
1306
+ }
1307
+
1308
+ function renderLayoutForMatrix(matrix) {
1309
+ const version = versionFromSize(matrix.length);
1310
+ if (!version) return null;
1311
+ try {
1312
+ return createLayout(version);
1313
+ } catch {
1314
+ return null;
1315
+ }
1316
+ }
1317
+
1318
+ function isStructuralRenderCell(layout, row, col, cell) {
1319
+ // BLACK never represents payload data in QuadQR. The layout check also keeps
1320
+ // structural white and RGB calibration cells untouched by visual styles.
1321
+ return Boolean(layout?.reserved?.[row]?.[col]) || cell === CELL.BLACK;
1322
+ }
1323
+
1324
+ function renderNoise(row, col, cell) {
1325
+ let value = (
1326
+ Math.imul(row + 1, 0x9e3779b1) ^
1327
+ Math.imul(col + 1, 0x85ebca6b) ^
1328
+ Math.imul((cell + 2) & 0xff, 0xc2b2ae35)
1329
+ ) >>> 0;
1330
+ value ^= value >>> 16;
1331
+ value = Math.imul(value, 0x7feb352d) >>> 0;
1332
+ value ^= value >>> 15;
1333
+ value = Math.imul(value, 0x846ca68b) >>> 0;
1334
+ value ^= value >>> 16;
1335
+ return (value >>> 0) / 0xffffffff;
1336
+ }
1337
+
1338
+ function depthOpacity(row, col, cell) {
1339
+ const value = renderNoise(row, col, cell);
1340
+ if (value < 0.16) return 0.80;
1341
+ if (value < 0.36) return 0.87;
1342
+ if (value < 0.56) return 0.94;
1343
+ return 1;
1344
+ }
1345
+
1346
+ function mixRgb(a, b, amount) {
1347
+ const t = Math.max(0, Math.min(1, amount));
1348
+ return {
1349
+ r: Math.round(a.r + (b.r - a.r) * t),
1350
+ g: Math.round(a.g + (b.g - a.g) * t),
1351
+ b: Math.round(a.b + (b.b - a.b) * t)
1352
+ };
1353
+ }
1354
+
1355
+ function rgbCss(rgb) {
1356
+ return `rgb(${rgb.r} ${rgb.g} ${rgb.b})`;
1357
+ }
1358
+
1359
+ // Scan-safe decorative profiles deliberately alter only a narrow edge band.
1360
+ // The scanner samples around the center of each module (roughly +/- 0.16 to
1361
+ // 0.18 module widths), so the central classification area remains the exact
1362
+ // encoded R/G/B/W color. Structural/calibration cells bypass styling entirely.
1363
+ function safeStyleEdge(moduleSize) {
1364
+ if (moduleSize < 5) return 0;
1365
+ return Math.max(1, Math.min(Math.floor(moduleSize * 0.10), Math.floor(moduleSize * 0.16)));
1366
+ }
1367
+
1368
+ function insetStyleColors(rgb, palette) {
1369
+ return {
1370
+ highlight: mixRgb(rgb, palette.white, 0.12),
1371
+ shadow: mixRgb(rgb, palette.black, 0.10)
1372
+ };
1373
+ }
1374
+
1375
+ function roundedRectPath(ctx, x, y, width, height, radius) {
1376
+ const r = Math.max(0, Math.min(radius, width / 2, height / 2));
1377
+ ctx.beginPath();
1378
+ ctx.moveTo(x + r, y);
1379
+ ctx.lineTo(x + width - r, y);
1380
+ ctx.quadraticCurveTo(x + width, y, x + width, y + r);
1381
+ ctx.lineTo(x + width, y + height - r);
1382
+ ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
1383
+ ctx.lineTo(x + r, y + height);
1384
+ ctx.quadraticCurveTo(x, y + height, x, y + height - r);
1385
+ ctx.lineTo(x, y + r);
1386
+ ctx.quadraticCurveTo(x, y, x + r, y);
1387
+ ctx.closePath();
1388
+ }
1389
+
1390
+ function setImagePixel(data, width, x, y, rgb) {
1391
+ if (x < 0 || y < 0 || x >= width) return;
1392
+ const height = data.length / 4 / width;
1393
+ if (y >= height) return;
1394
+ const p = (y * width + x) * 4;
1395
+ data[p] = rgb.r;
1396
+ data[p + 1] = rgb.g;
1397
+ data[p + 2] = rgb.b;
1398
+ data[p + 3] = 255;
1399
+ }
1400
+
1401
+ function fillImageRect(data, width, x, y, rectWidth, rectHeight, rgb) {
1402
+ for (let yy = y; yy < y + rectHeight; yy++) {
1403
+ for (let xx = x; xx < x + rectWidth; xx++) setImagePixel(data, width, xx, yy, rgb);
1404
+ }
1405
+ }
1406
+
1407
+ function fillImageRoundedRect(data, width, x, y, rectWidth, rectHeight, radius, rgb) {
1408
+ const r = Math.max(0, Math.min(radius, Math.floor(rectWidth / 2), Math.floor(rectHeight / 2)));
1409
+ if (r <= 0) {
1410
+ fillImageRect(data, width, x, y, rectWidth, rectHeight, rgb);
1411
+ return;
1412
+ }
1413
+ const leftCenter = x + r;
1414
+ const rightCenter = x + rectWidth - r - 1;
1415
+ const topCenter = y + r;
1416
+ const bottomCenter = y + rectHeight - r - 1;
1417
+ const rr = r * r;
1418
+ for (let yy = y; yy < y + rectHeight; yy++) {
1419
+ for (let xx = x; xx < x + rectWidth; xx++) {
1420
+ let dx = 0;
1421
+ let dy = 0;
1422
+ if (xx < leftCenter) dx = leftCenter - xx;
1423
+ else if (xx > rightCenter) dx = xx - rightCenter;
1424
+ if (yy < topCenter) dy = topCenter - yy;
1425
+ else if (yy > bottomCenter) dy = yy - bottomCenter;
1426
+ if (dx * dx + dy * dy <= rr) setImagePixel(data, width, xx, yy, rgb);
1427
+ }
1428
+ }
1429
+ }
1430
+
1431
+ export function renderToCanvas(codeOrMatrix, canvas, options = {}) {
1432
+ assert(canvas && typeof canvas.getContext === "function", "A canvas element is required.");
1433
+ const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
1434
+ assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
1435
+ const moduleSize = Math.max(1, Math.floor(options.moduleSize ?? 12));
1436
+ const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
1437
+ const palette = resolvePalette(options.palette);
1438
+ const paletteValues = paletteRgb(options.palette);
1439
+ const style = normalizeRenderStyle(options.style);
1440
+ const layout = renderLayoutForMatrix(matrix);
1441
+ const size = matrix.length;
1442
+ const pixelSize = (size + quietZone * 2) * moduleSize;
1443
+
1444
+ canvas.width = pixelSize;
1445
+ canvas.height = pixelSize;
1446
+ const ctx = canvas.getContext("2d", { alpha: false, willReadFrequently: true });
1447
+ ctx.imageSmoothingEnabled = false;
1448
+ ctx.fillStyle = palette.white;
1449
+ ctx.fillRect(0, 0, pixelSize, pixelSize);
1450
+
1451
+ for (let r = 0; r < size; r++) {
1452
+ for (let c = 0; c < size; c++) {
1453
+ const cell = matrix[r][c];
1454
+ const structural = isStructuralRenderCell(layout, r, c, cell);
1455
+ const x = (c + quietZone) * moduleSize;
1456
+ const y = (r + quietZone) * moduleSize;
1457
+
1458
+ if (style === RENDER_STYLES.CLASSIC || structural) {
1459
+ ctx.fillStyle = cellColor(cell, palette);
1460
+ ctx.fillRect(x, y, moduleSize, moduleSize);
1461
+ continue;
1462
+ }
1463
+
1464
+ const rgb = cellRgb(cell, paletteValues);
1465
+
1466
+ if (style === RENDER_STYLES.DEPTH) {
1467
+ const opacity = depthOpacity(r, c, cell);
1468
+ const base = mixRgb(paletteValues.white, rgb, opacity);
1469
+ ctx.fillStyle = rgbCss(base);
1470
+ ctx.fillRect(x, y, moduleSize, moduleSize);
1471
+
1472
+ if (moduleSize >= 6) {
1473
+ const edge = Math.max(1, Math.floor(moduleSize * 0.08));
1474
+ const highlight = mixRgb(base, paletteValues.white, 0.17);
1475
+ const shadow = mixRgb(base, paletteValues.black, 0.12);
1476
+ ctx.fillStyle = rgbCss(highlight);
1477
+ ctx.fillRect(x, y, moduleSize, edge);
1478
+ ctx.fillRect(x, y, edge, moduleSize);
1479
+ ctx.fillStyle = rgbCss(shadow);
1480
+ ctx.fillRect(x, y + moduleSize - edge, moduleSize, edge);
1481
+ ctx.fillRect(x + moduleSize - edge, y, edge, moduleSize);
1482
+ }
1483
+ continue;
1484
+ }
1485
+
1486
+ if (style === RENDER_STYLES.SOFT) {
1487
+ const inset = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
1488
+ const width = moduleSize - inset * 2;
1489
+ const radius = Math.max(1, Math.floor(moduleSize * 0.20));
1490
+ ctx.fillStyle = cellColor(cell, palette);
1491
+ roundedRectPath(ctx, x + inset, y + inset, width, width, radius);
1492
+ ctx.fill();
1493
+ continue;
1494
+ }
1495
+
1496
+ if (style === RENDER_STYLES.INSET) {
1497
+ // Keep the exact encoded color across the full module, then limit the
1498
+ // recessed effect to a narrow edge band so center sampling stays intact.
1499
+ ctx.fillStyle = cellColor(cell, palette);
1500
+ ctx.fillRect(x, y, moduleSize, moduleSize);
1501
+ if (cell === CELL.WHITE) continue;
1502
+
1503
+ const edge = safeStyleEdge(moduleSize);
1504
+ if (edge > 0) {
1505
+ const fx = insetStyleColors(rgb, paletteValues);
1506
+ ctx.fillStyle = rgbCss(fx.shadow);
1507
+ ctx.fillRect(x, y, moduleSize, edge);
1508
+ ctx.fillRect(x, y, edge, moduleSize);
1509
+ ctx.fillStyle = rgbCss(fx.highlight);
1510
+ ctx.fillRect(x, y + moduleSize - edge, moduleSize, edge);
1511
+ ctx.fillRect(x + moduleSize - edge, y, edge, moduleSize);
1512
+ }
1513
+ }
1514
+ }
1515
+ }
1516
+ return canvas;
1517
+ }
1518
+
1519
+ export function renderToImageData(codeOrMatrix, options = {}) {
1520
+ const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
1521
+ assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
1522
+ const moduleSize = Math.max(1, Math.floor(options.moduleSize ?? 8));
1523
+ const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
1524
+ const palette = paletteRgb(options.palette);
1525
+ const style = normalizeRenderStyle(options.style);
1526
+ const layout = renderLayoutForMatrix(matrix);
1527
+ const size = matrix.length;
1528
+ const pixelSize = (size + quietZone * 2) * moduleSize;
1529
+ const data = new Uint8ClampedArray(pixelSize * pixelSize * 4);
1530
+ const white = palette.white;
1531
+
1532
+ for (let i = 0; i < pixelSize * pixelSize; i++) {
1533
+ const p = i * 4;
1534
+ data[p] = white.r;
1535
+ data[p + 1] = white.g;
1536
+ data[p + 2] = white.b;
1537
+ data[p + 3] = 255;
1538
+ }
1539
+
1540
+ for (let r = 0; r < size; r++) {
1541
+ for (let c = 0; c < size; c++) {
1542
+ const cell = matrix[r][c];
1543
+ const rgb = cellRgb(cell, palette);
1544
+ const structural = isStructuralRenderCell(layout, r, c, cell);
1545
+ const y0 = (r + quietZone) * moduleSize;
1546
+ const x0 = (c + quietZone) * moduleSize;
1547
+
1548
+ if (style === RENDER_STYLES.CLASSIC || structural) {
1549
+ fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, rgb);
1550
+ continue;
1551
+ }
1552
+
1553
+ if (style === RENDER_STYLES.DEPTH) {
1554
+ const opacity = depthOpacity(r, c, cell);
1555
+ const base = mixRgb(white, rgb, opacity);
1556
+ fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, base);
1557
+ if (moduleSize >= 6) {
1558
+ const edge = Math.max(1, Math.floor(moduleSize * 0.08));
1559
+ const highlight = mixRgb(base, white, 0.17);
1560
+ const shadow = mixRgb(base, palette.black, 0.12);
1561
+ fillImageRect(data, pixelSize, x0, y0, moduleSize, edge, highlight);
1562
+ fillImageRect(data, pixelSize, x0, y0, edge, moduleSize, highlight);
1563
+ fillImageRect(data, pixelSize, x0, y0 + moduleSize - edge, moduleSize, edge, shadow);
1564
+ fillImageRect(data, pixelSize, x0 + moduleSize - edge, y0, edge, moduleSize, shadow);
1565
+ }
1566
+ continue;
1567
+ }
1568
+
1569
+ if (style === RENDER_STYLES.SOFT) {
1570
+ const inset = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
1571
+ const width = moduleSize - inset * 2;
1572
+ const radius = Math.max(1, Math.floor(moduleSize * 0.20));
1573
+ fillImageRoundedRect(data, pixelSize, x0 + inset, y0 + inset, width, width, radius, rgb);
1574
+ continue;
1575
+ }
1576
+
1577
+ if (style === RENDER_STYLES.INSET) {
1578
+ fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, rgb);
1579
+ if (cell === CELL.WHITE) continue;
1580
+ const edge = safeStyleEdge(moduleSize);
1581
+ if (edge > 0) {
1582
+ const fx = insetStyleColors(rgb, palette);
1583
+ fillImageRect(data, pixelSize, x0, y0, moduleSize, edge, fx.shadow);
1584
+ fillImageRect(data, pixelSize, x0, y0, edge, moduleSize, fx.shadow);
1585
+ fillImageRect(data, pixelSize, x0, y0 + moduleSize - edge, moduleSize, edge, fx.highlight);
1586
+ fillImageRect(data, pixelSize, x0 + moduleSize - edge, y0, edge, moduleSize, fx.highlight);
1587
+ }
1588
+ }
1589
+ }
1590
+ }
1591
+
1592
+ return { width: pixelSize, height: pixelSize, data };
1593
+ }
1594
+
1595
+ function classifierFromPaletteRgb(observed) {
1596
+ return [
1597
+ { cell: CELL.BLACK, rgb: observed.black },
1598
+ { cell: CELL.WHITE, rgb: observed.white },
1599
+ { cell: CELL.RED, rgb: observed.red },
1600
+ { cell: CELL.GREEN, rgb: observed.green },
1601
+ { cell: CELL.BLUE, rgb: observed.blue }
1602
+ ];
1603
+ }
1604
+
1605
+ function colorDistanceSq(a, b) {
1606
+ const dr = a.r - b.r;
1607
+ const dg = a.g - b.g;
1608
+ const db = a.b - b.b;
1609
+ return dr * dr + dg * dg + db * db;
1610
+ }
1611
+
1612
+ function classifyRgb(rgb, classifier) {
1613
+ let best = null;
1614
+ let bestDistanceSq = Infinity;
1615
+ let secondDistanceSq = Infinity;
1616
+
1617
+ for (const candidate of classifier) {
1618
+ const distanceSq = colorDistanceSq(rgb, candidate.rgb);
1619
+ if (distanceSq < bestDistanceSq) {
1620
+ secondDistanceSq = bestDistanceSq;
1621
+ bestDistanceSq = distanceSq;
1622
+ best = candidate;
1623
+ } else if (distanceSq < secondDistanceSq) {
1624
+ secondDistanceSq = distanceSq;
1625
+ }
1626
+ }
1627
+
1628
+ const distance = Math.sqrt(bestDistanceSq);
1629
+ const secondDistance = Number.isFinite(secondDistanceSq) ? Math.sqrt(secondDistanceSq) : distance + 1;
1630
+ // Confidence is the normalized separation between the nearest and second
1631
+ // nearest calibrated palette states. 1 means unambiguous, 0 means tied.
1632
+ const confidence = Math.max(0, Math.min(1, (secondDistance - distance) / Math.max(secondDistance, 1e-6)));
1633
+ return { cell: best.cell, distance, confidence };
1634
+ }
1635
+
1636
+ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
1637
+ const size = rgbGrid.length;
1638
+ const matrix = make2D(size, CELL.WHITE);
1639
+ const confidence = make2D(size, 1);
1640
+ const dataClassifier = classifier.filter(({ cell }) => cell !== CELL.BLACK);
1641
+ let distanceSum = 0;
1642
+ let confidenceSum = 0;
1643
+ let minimumConfidence = 1;
1644
+ let lowConfidenceCells = 0;
1645
+
1646
+ for (let r = 0; r < size; r++) {
1647
+ for (let c = 0; c < size; c++) {
1648
+ // Black is structural only. Restricting data modules to RGBW keeps a
1649
+ // shadowed/blurred data sample representable so confidence-aware ECC can
1650
+ // promote it to an erasure instead of aborting before RS gets a chance.
1651
+ const candidates = layout && !layout.reserved[r][c] ? dataClassifier : classifier;
1652
+ const classified = classifyRgb(rgbGrid[r][c], candidates);
1653
+ matrix[r][c] = classified.cell;
1654
+ confidence[r][c] = classified.confidence;
1655
+ distanceSum += classified.distance;
1656
+ confidenceSum += classified.confidence;
1657
+ minimumConfidence = Math.min(minimumConfidence, classified.confidence);
1658
+ if (classified.confidence < 0.4) lowConfidenceCells++;
1659
+ }
1660
+ }
1661
+ return {
1662
+ matrix,
1663
+ confidence,
1664
+ averageColorDistance: distanceSum / (size * size),
1665
+ averageCellConfidence: confidenceSum / (size * size),
1666
+ minimumCellConfidence: minimumConfidence,
1667
+ lowConfidenceCells
1668
+ };
1669
+ }
1670
+
1671
+ function tryPerspectiveScan(imageData, options) {
1672
+ const geometryCandidates = detectCodeGeometry(imageData, {
1673
+ minVersion: options.minVersion ?? MIN_VERSION,
1674
+ maxVersion: options.maxVersion ?? MAX_VERSION,
1675
+ maxCandidates: options.maxGeometryCandidates ?? 8
1676
+ });
1677
+ const results = [];
1678
+
1679
+ for (const geometry of geometryCandidates) {
1680
+ try {
1681
+ const layout = createLayout(geometry.version);
1682
+ const sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, {
1683
+ sampleRadius: options.sampleRadius ?? 0.16
1684
+ });
1685
+ const observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration);
1686
+ const classifier = classifierFromPaletteRgb(observedPalette);
1687
+ const classified = classifySampledRgbGrid(sampled.rgbGrid, classifier, layout);
1688
+ const decoded = decodeMatrix(classified.matrix, {
1689
+ structureTolerance: options.structureTolerance ?? 0.18,
1690
+ cellConfidence: classified.confidence,
1691
+ maxErasureConfidence: options.maxErasureConfidence
1692
+ });
1693
+ if (decoded.version !== geometry.version) continue;
1694
+
1695
+ results.push({
1696
+ ...decoded,
1697
+ perspectiveCorrected: true,
1698
+ colorCalibrated: true,
1699
+ geometry,
1700
+ observedPalette,
1701
+ averageColorDistance: classified.averageColorDistance,
1702
+ averageCellConfidence: classified.averageCellConfidence,
1703
+ minimumCellConfidence: classified.minimumCellConfidence,
1704
+ lowConfidenceCells: classified.lowConfidenceCells,
1705
+ rectified: options.includeRectified
1706
+ ? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
1707
+ : undefined
1708
+ });
1709
+ } catch {
1710
+ // Continue trying geometry hypotheses.
1711
+ }
1712
+ }
1713
+
1714
+ results.sort((a, b) =>
1715
+ (a.correctedSymbols - b.correctedSymbols) ||
1716
+ (a.averageColorDistance - b.averageColorDistance) ||
1717
+ (b.geometry.score - a.geometry.score)
1718
+ );
1719
+ return results[0] ?? null;
1720
+ }
1721
+
1722
+ function tryAxisAlignedScan(imageData, options) {
1723
+ const bounds = options.bounds ?? findActiveBounds(imageData, options.whiteThreshold ?? 238);
1724
+ const fixedClassifier = classifierFromPaletteRgb(paletteRgb(options.palette));
1725
+ const minVersion = options.minVersion ?? MIN_VERSION;
1726
+ const maxVersion = options.maxVersion ?? MAX_VERSION;
1727
+ const candidates = [];
1728
+
1729
+ for (let version = minVersion; version <= maxVersion; version++) {
1730
+ const size = sizeForVersion(version);
1731
+ const moduleW = bounds.width / size;
1732
+ const moduleH = bounds.height / size;
1733
+ if (moduleW < 0.75 || moduleH < 0.75) continue;
1734
+ const aspectError = Math.abs(moduleW - moduleH) / Math.max(moduleW, moduleH);
1735
+ if (aspectError > (options.maxModuleAspectError ?? 0.16)) continue;
1736
+
1737
+ try {
1738
+ const sampled = sampleAxisAlignedGrid(imageData, bounds, size, options.sampleRadius ?? 0.18);
1739
+ const classifierAttempts = [];
1740
+ const layout = createLayout(version);
1741
+ try {
1742
+ const observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration);
1743
+ classifierAttempts.push({ classifier: classifierFromPaletteRgb(observedPalette), calibrated: true });
1744
+ } catch {
1745
+ // Fixed palette fallback below.
1746
+ }
1747
+ classifierAttempts.push({ classifier: fixedClassifier, calibrated: false });
1748
+
1749
+ let accepted = false;
1750
+ for (const attempt of classifierAttempts) {
1751
+ try {
1752
+ const classified = classifySampledRgbGrid(sampled.rgbGrid, attempt.classifier, layout);
1753
+ const decoded = decodeMatrix(classified.matrix, {
1754
+ structureTolerance: options.structureTolerance ?? 0.12,
1755
+ cellConfidence: classified.confidence,
1756
+ maxErasureConfidence: options.maxErasureConfidence
1757
+ });
1758
+ candidates.push({
1759
+ ...decoded,
1760
+ bounds,
1761
+ sampledVersion: version,
1762
+ moduleWidth: moduleW,
1763
+ moduleHeight: moduleH,
1764
+ perspectiveCorrected: false,
1765
+ colorCalibrated: attempt.calibrated,
1766
+ averageColorDistance: classified.averageColorDistance,
1767
+ averageCellConfidence: classified.averageCellConfidence,
1768
+ minimumCellConfidence: classified.minimumCellConfidence,
1769
+ lowConfidenceCells: classified.lowConfidenceCells
1770
+ });
1771
+ accepted = true;
1772
+ break;
1773
+ } catch {
1774
+ // Try next classifier.
1775
+ }
1776
+ }
1777
+ if (!accepted) throw new Error("Axis-aligned candidate did not decode.");
1778
+ } catch {
1779
+ // Try next version.
1780
+ }
1781
+ }
1782
+
1783
+ candidates.sort((a, b) =>
1784
+ (a.correctedSymbols - b.correctedSymbols) || (a.averageColorDistance - b.averageColorDistance)
1785
+ );
1786
+ return candidates[0] ?? null;
1787
+ }
1788
+
1789
+ export function scanImageData(imageData, options = {}) {
1790
+ assert(imageData && imageData.data && imageData.width && imageData.height, "Valid ImageData is required.");
1791
+ const minVersion = options.minVersion ?? MIN_VERSION;
1792
+ const maxVersion = options.maxVersion ?? MAX_VERSION;
1793
+ validateVersion(minVersion);
1794
+ validateVersion(maxVersion);
1795
+
1796
+ if (options.perspective !== false) {
1797
+ const perspective = tryPerspectiveScan(imageData, options);
1798
+ if (perspective) return perspective;
1799
+ }
1800
+
1801
+ if (options.axisAlignedFallback !== false) {
1802
+ const axis = tryAxisAlignedScan(imageData, options);
1803
+ if (axis) return axis;
1804
+ }
1805
+
1806
+ throw new Error(
1807
+ "No valid QuadQR code found. Try better lighting, fill more of the frame, keep all locator patterns visible, or use a less blurred image."
1808
+ );
1809
+ }
1810
+
1811
+ export async function scanFile(file, options = {}) {
1812
+ assert(typeof document !== "undefined", "scanFile is a browser API.");
1813
+ assert(file, "A file is required.");
1814
+ let bitmap;
1815
+
1816
+ if (typeof createImageBitmap === "function") {
1817
+ bitmap = await createImageBitmap(file);
1818
+ } else {
1819
+ bitmap = await new Promise((resolve, reject) => {
1820
+ const img = new Image();
1821
+ const url = URL.createObjectURL(file);
1822
+ img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
1823
+ img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("Unable to load image.")); };
1824
+ img.src = url;
1825
+ });
1826
+ }
1827
+
1828
+ const canvas = document.createElement("canvas");
1829
+ canvas.width = bitmap.width;
1830
+ canvas.height = bitmap.height;
1831
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
1832
+ ctx.drawImage(bitmap, 0, 0);
1833
+ if (typeof bitmap.close === "function") bitmap.close();
1834
+ return scanImageData(ctx.getImageData(0, 0, canvas.width, canvas.height), options);
1835
+ }
1836
+
1837
+ export function scanVideoFrame(video, options = {}) {
1838
+ assert(typeof document !== "undefined", "scanVideoFrame is a browser API.");
1839
+ assert(video && video.videoWidth && video.videoHeight, "Video frame is not ready.");
1840
+ const maxDimension = options.maxDimension ?? 960;
1841
+ const scale = Math.min(1, maxDimension / Math.max(video.videoWidth, video.videoHeight));
1842
+ const width = Math.max(1, Math.round(video.videoWidth * scale));
1843
+ const height = Math.max(1, Math.round(video.videoHeight * scale));
1844
+ const canvas = options.canvas ?? document.createElement("canvas");
1845
+ canvas.width = width;
1846
+ canvas.height = height;
1847
+ const ctx = canvas.getContext("2d", { alpha: false, willReadFrequently: true });
1848
+ ctx.drawImage(video, 0, 0, width, height);
1849
+ return scanImageData(ctx.getImageData(0, 0, width, height), options);
1850
+ }
1851
+
1852
+ export async function startCameraScanner(video, options = {}) {
1853
+ assert(typeof navigator !== "undefined" && navigator.mediaDevices?.getUserMedia, "Camera API is unavailable.");
1854
+ assert(video, "A video element is required.");
1855
+
1856
+ const stream = await navigator.mediaDevices.getUserMedia(
1857
+ options.constraints ?? {
1858
+ audio: false,
1859
+ video: {
1860
+ facingMode: { ideal: "environment" },
1861
+ width: { ideal: 1280 },
1862
+ height: { ideal: 720 }
1863
+ }
1864
+ }
1865
+ );
1866
+
1867
+ video.srcObject = stream;
1868
+ video.setAttribute("playsinline", "");
1869
+ video.muted = true;
1870
+ await video.play();
1871
+
1872
+ const scanInterval = Math.max(80, options.scanInterval ?? 180);
1873
+ const scratchCanvas = document.createElement("canvas");
1874
+ let stopped = false;
1875
+ let busy = false;
1876
+ let timer = null;
1877
+
1878
+ const stop = () => {
1879
+ stopped = true;
1880
+ if (timer) clearTimeout(timer);
1881
+ for (const track of stream.getTracks()) track.stop();
1882
+ if (video.srcObject === stream) video.srcObject = null;
1883
+ };
1884
+
1885
+ const scanNow = () => scanVideoFrame(video, { ...options, canvas: scratchCanvas });
1886
+
1887
+ const loop = async () => {
1888
+ if (stopped) return;
1889
+ if (!busy && video.readyState >= 2) {
1890
+ busy = true;
1891
+ try {
1892
+ const result = scanNow();
1893
+ options.onResult?.(result);
1894
+ if (options.stopOnResult ?? true) {
1895
+ stop();
1896
+ return;
1897
+ }
1898
+ } catch (error) {
1899
+ options.onScanMiss?.(error);
1900
+ } finally {
1901
+ busy = false;
1902
+ }
1903
+ }
1904
+ timer = setTimeout(loop, scanInterval);
1905
+ };
1906
+
1907
+ timer = setTimeout(loop, 0);
1908
+ return { stream, stop, scanNow, video };
1909
+ }
1910
+
1911
+ export function rectifyDetectedCode(imageData, options = {}) {
1912
+ const candidates = detectCodeGeometry(imageData, {
1913
+ minVersion: options.minVersion ?? MIN_VERSION,
1914
+ maxVersion: options.maxVersion ?? MAX_VERSION,
1915
+ maxCandidates: 1
1916
+ });
1917
+ if (!candidates.length) throw new Error("Unable to locate QuadQR geometry.");
1918
+ const geometry = candidates[0];
1919
+ return {
1920
+ geometry,
1921
+ imageData: rectifyImageData(
1922
+ imageData,
1923
+ geometry.homography,
1924
+ sizeForVersion(geometry.version),
1925
+ options.moduleSize ?? 8
1926
+ )
1927
+ };
1928
+ }
1929
+
1930
+ export function rotateMatrix(matrix, quarterTurns = 1) {
1931
+ let out = cloneMatrix(matrix);
1932
+ const turns = ((quarterTurns % 4) + 4) % 4;
1933
+ for (let i = 0; i < turns; i++) out = rotate90(out);
1934
+ return out;
1935
+ }
1936
+
1937
+ export const internals = Object.freeze({
1938
+ sizeForVersion,
1939
+ versionFromSize,
1940
+ streamCellCount,
1941
+ createLayout,
1942
+ alignmentPatternCentersForVersion,
1943
+ bytesToCells,
1944
+ cellsToBytes,
1945
+ getBodyRsPlan,
1946
+ getHeaderPlan,
1947
+ interleaveBlocks,
1948
+ deinterleaveBlocks,
1949
+ spectralPermutation,
1950
+ applyData,
1951
+ unmaskCells,
1952
+ restoreLogicalOrder,
1953
+ cellsToSymbolConfidences,
1954
+ decodeRsAdaptive,
1955
+ encodeProtectedHeader,
1956
+ decodeProtectedHeader,
1957
+ HEADER_CODEWORD_CELLS,
1958
+ COMPACT_HEADER_CODEWORD_CELLS,
1959
+ CELLS_PER_BYTE,
1960
+ TEXT_FLAG,
1961
+ SECURE_FLAG
1962
+ });