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.
- package/FORMAT.md +390 -0
- package/LICENSE +661 -0
- package/README.md +1042 -0
- package/bin/quadqr.js +102 -0
- package/dist/benchmark.cjs +1 -0
- package/dist/benchmark.js +1 -0
- package/dist/browser.js +2 -0
- package/dist/esm/benchmark.js +193 -0
- package/dist/esm/geometry.js +149 -0
- package/dist/esm/node.js +275 -0
- package/dist/esm/quadqr.js +1962 -0
- package/dist/esm/reed-solomon.js +371 -0
- package/dist/esm/security.js +402 -0
- package/dist/esm/vision.js +752 -0
- package/dist/esm/wasm.js +98 -0
- package/dist/index.cjs +1 -0
- package/dist/index.js +2 -0
- package/dist/node.cjs +1 -0
- package/dist/node.js +1 -0
- package/dist/quadqr.js +3651 -0
- package/dist/quadqr.min.js +3593 -0
- package/dist/wasm/quadqr-core.wasm +0 -0
- package/docs/API.md +188 -0
- package/docs/BROWSER_CDN.md +83 -0
- package/docs/GETTING_STARTED.md +91 -0
- package/docs/NODE.md +92 -0
- package/docs/PUBLISHING.md +115 -0
- package/docs/README.md +26 -0
- package/docs/SECURITY.md +78 -0
- package/docs/WASM.md +36 -0
- package/package.json +95 -0
- package/types/benchmark.d.ts +7 -0
- package/types/index.d.ts +139 -0
- package/types/node.d.ts +9 -0
|
@@ -0,0 +1,3593 @@
|
|
|
1
|
+
(function (global) {
|
|
2
|
+
"use strict";
|
|
3
|
+
const __qqrScriptSrc = typeof document !== 'undefined' ? document.currentScript?.src || null : null;
|
|
4
|
+
const __qqr_geometry_js = (function () {
|
|
5
|
+
|
|
6
|
+
const MIN_GEOMETRY_VERSION = 1;
|
|
7
|
+
const MAX_GEOMETRY_VERSION = 40;
|
|
8
|
+
|
|
9
|
+
// Index by version. Entry 0 is unused. Values are module indices of alignment
|
|
10
|
+
// pattern centers, matching the standard QR Code version layout for v2..v40.
|
|
11
|
+
const ALIGNMENT_PATTERN_AXES = Object.freeze([
|
|
12
|
+
null,
|
|
13
|
+
Object.freeze([]),
|
|
14
|
+
Object.freeze([6, 18]),
|
|
15
|
+
Object.freeze([6, 22]),
|
|
16
|
+
Object.freeze([6, 26]),
|
|
17
|
+
Object.freeze([6, 30]),
|
|
18
|
+
Object.freeze([6, 34]),
|
|
19
|
+
Object.freeze([6, 22, 38]),
|
|
20
|
+
Object.freeze([6, 24, 42]),
|
|
21
|
+
Object.freeze([6, 26, 46]),
|
|
22
|
+
Object.freeze([6, 28, 50]),
|
|
23
|
+
Object.freeze([6, 30, 54]),
|
|
24
|
+
Object.freeze([6, 32, 58]),
|
|
25
|
+
Object.freeze([6, 34, 62]),
|
|
26
|
+
Object.freeze([6, 26, 46, 66]),
|
|
27
|
+
Object.freeze([6, 26, 48, 70]),
|
|
28
|
+
Object.freeze([6, 26, 50, 74]),
|
|
29
|
+
Object.freeze([6, 30, 54, 78]),
|
|
30
|
+
Object.freeze([6, 30, 56, 82]),
|
|
31
|
+
Object.freeze([6, 30, 58, 86]),
|
|
32
|
+
Object.freeze([6, 34, 62, 90]),
|
|
33
|
+
Object.freeze([6, 28, 50, 72, 94]),
|
|
34
|
+
Object.freeze([6, 26, 50, 74, 98]),
|
|
35
|
+
Object.freeze([6, 30, 54, 78, 102]),
|
|
36
|
+
Object.freeze([6, 28, 54, 80, 106]),
|
|
37
|
+
Object.freeze([6, 32, 58, 84, 110]),
|
|
38
|
+
Object.freeze([6, 30, 58, 86, 114]),
|
|
39
|
+
Object.freeze([6, 34, 62, 90, 118]),
|
|
40
|
+
Object.freeze([6, 26, 50, 74, 98, 122]),
|
|
41
|
+
Object.freeze([6, 30, 54, 78, 102, 126]),
|
|
42
|
+
Object.freeze([6, 26, 52, 78, 104, 130]),
|
|
43
|
+
Object.freeze([6, 30, 56, 82, 108, 134]),
|
|
44
|
+
Object.freeze([6, 34, 60, 86, 112, 138]),
|
|
45
|
+
Object.freeze([6, 30, 58, 86, 114, 142]),
|
|
46
|
+
Object.freeze([6, 34, 62, 90, 118, 146]),
|
|
47
|
+
Object.freeze([6, 30, 54, 78, 102, 126, 150]),
|
|
48
|
+
Object.freeze([6, 24, 50, 76, 102, 128, 154]),
|
|
49
|
+
Object.freeze([6, 28, 54, 80, 106, 132, 158]),
|
|
50
|
+
Object.freeze([6, 32, 58, 84, 110, 136, 162]),
|
|
51
|
+
Object.freeze([6, 26, 54, 82, 110, 138, 166]),
|
|
52
|
+
Object.freeze([6, 30, 58, 86, 114, 142, 170])
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
function assertVersion(version) {
|
|
56
|
+
if (!Number.isInteger(version) || version < MIN_GEOMETRY_VERSION || version > MAX_GEOMETRY_VERSION) {
|
|
57
|
+
throw new Error(`Version must be ${MIN_GEOMETRY_VERSION}..${MAX_GEOMETRY_VERSION}.`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sizeForVersion(version) {
|
|
62
|
+
assertVersion(version);
|
|
63
|
+
return 21 + 4 * (version - 1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function versionFromSize(size) {
|
|
67
|
+
const delta = size - 21;
|
|
68
|
+
if (delta < 0 || delta % 4 !== 0) return null;
|
|
69
|
+
const version = delta / 4 + 1;
|
|
70
|
+
return Number.isInteger(version) && version >= MIN_GEOMETRY_VERSION && version <= MAX_GEOMETRY_VERSION
|
|
71
|
+
? version
|
|
72
|
+
: null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function alignmentPatternCentersForVersion(version) {
|
|
76
|
+
assertVersion(version);
|
|
77
|
+
const size = sizeForVersion(version);
|
|
78
|
+
|
|
79
|
+
if (version === 1) {
|
|
80
|
+
const center = size - 4;
|
|
81
|
+
return [{
|
|
82
|
+
row: center,
|
|
83
|
+
col: center,
|
|
84
|
+
size: 5,
|
|
85
|
+
primary: true,
|
|
86
|
+
bootstrap: true,
|
|
87
|
+
separator: true
|
|
88
|
+
}];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const axes = ALIGNMENT_PATTERN_AXES[version];
|
|
92
|
+
const last = axes[axes.length - 1];
|
|
93
|
+
const centers = [];
|
|
94
|
+
|
|
95
|
+
for (const row of axes) {
|
|
96
|
+
for (const col of axes) {
|
|
97
|
+
// These three locations are occupied by the primary finder patterns.
|
|
98
|
+
if (
|
|
99
|
+
(row === 6 && col === 6) ||
|
|
100
|
+
(row === 6 && col === last) ||
|
|
101
|
+
(row === last && col === 6)
|
|
102
|
+
) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const primary = row === last && col === last;
|
|
106
|
+
centers.push({
|
|
107
|
+
row,
|
|
108
|
+
col,
|
|
109
|
+
size: primary ? 5 : 3,
|
|
110
|
+
primary,
|
|
111
|
+
bootstrap: false,
|
|
112
|
+
separator: false
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return centers;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function primaryAlignmentPatternForVersion(version) {
|
|
121
|
+
const centers = alignmentPatternCentersForVersion(version);
|
|
122
|
+
return centers.find((pattern) => pattern.primary) ?? centers[centers.length - 1];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function alignmentPatternRadius(pattern) {
|
|
126
|
+
return pattern.size === 3 ? 1 : 2;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function alignmentPatternIsBlack(pattern, rowOffset, colOffset) {
|
|
130
|
+
const radius = alignmentPatternRadius(pattern);
|
|
131
|
+
if (Math.abs(rowOffset) > radius || Math.abs(colOffset) > radius) return null;
|
|
132
|
+
|
|
133
|
+
if (pattern.size === 3) {
|
|
134
|
+
// Compact secondary marker: black 3x3 ring with a white center.
|
|
135
|
+
return rowOffset !== 0 || colOffset !== 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Primary 5x5 marker: black outer ring, white inner ring, black center.
|
|
139
|
+
const outer = Math.abs(rowOffset) === 2 || Math.abs(colOffset) === 2;
|
|
140
|
+
const center = rowOffset === 0 && colOffset === 0;
|
|
141
|
+
return outer || center;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { sizeForVersion, versionFromSize, alignmentPatternCentersForVersion, primaryAlignmentPatternForVersion, alignmentPatternRadius, alignmentPatternIsBlack, MIN_GEOMETRY_VERSION, MAX_GEOMETRY_VERSION, ALIGNMENT_PATTERN_AXES };
|
|
145
|
+
})();
|
|
146
|
+
const __qqr_reed_solomon_js = (function () {
|
|
147
|
+
|
|
148
|
+
const FIELD_SIZE = 256;
|
|
149
|
+
const FIELD_ORDER = 255;
|
|
150
|
+
const MAX_CODEWORD_SYMBOLS = 255;
|
|
151
|
+
|
|
152
|
+
const PRIMITIVE_POLYNOMIAL = 0x11d;
|
|
153
|
+
const EXP = new Uint16Array(FIELD_ORDER * 2);
|
|
154
|
+
const LOG = new Int16Array(FIELD_SIZE);
|
|
155
|
+
LOG.fill(-1);
|
|
156
|
+
|
|
157
|
+
(function initTables() {
|
|
158
|
+
let value = 1;
|
|
159
|
+
for (let i = 0; i < FIELD_ORDER; i++) {
|
|
160
|
+
EXP[i] = value;
|
|
161
|
+
LOG[value] = i;
|
|
162
|
+
value <<= 1;
|
|
163
|
+
if (value & 0x100) value ^= PRIMITIVE_POLYNOMIAL;
|
|
164
|
+
}
|
|
165
|
+
for (let i = FIELD_ORDER; i < EXP.length; i++) {
|
|
166
|
+
EXP[i] = EXP[i - FIELD_ORDER];
|
|
167
|
+
}
|
|
168
|
+
})();
|
|
169
|
+
|
|
170
|
+
function assertFieldValue(value) {
|
|
171
|
+
if (!Number.isInteger(value) || value < 0 || value >= FIELD_SIZE) {
|
|
172
|
+
throw new Error("GF(256) symbol must be an integer from 0 to 255.");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function gfAdd(a, b) {
|
|
177
|
+
return a ^ b;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function gfNeg(a) {
|
|
181
|
+
return a;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function gfSub(a, b) {
|
|
185
|
+
return a ^ b;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function gfMul(a, b) {
|
|
189
|
+
if (a === 0 || b === 0) return 0;
|
|
190
|
+
return EXP[(LOG[a] + LOG[b]) % FIELD_ORDER];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function gfDiv(a, b) {
|
|
194
|
+
if (b === 0) throw new Error("Division by zero in GF(256).");
|
|
195
|
+
if (a === 0) return 0;
|
|
196
|
+
let power = LOG[a] - LOG[b];
|
|
197
|
+
if (power < 0) power += FIELD_ORDER;
|
|
198
|
+
return EXP[power];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function gfPow(a, power) {
|
|
202
|
+
if (power === 0) return 1;
|
|
203
|
+
if (a === 0) return 0;
|
|
204
|
+
let p = (LOG[a] * power) % FIELD_ORDER;
|
|
205
|
+
if (p < 0) p += FIELD_ORDER;
|
|
206
|
+
return EXP[p];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function gfAlphaPow(power) {
|
|
210
|
+
let p = power % FIELD_ORDER;
|
|
211
|
+
if (p < 0) p += FIELD_ORDER;
|
|
212
|
+
return EXP[p];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function polyEvalHigh(coeffs, x) {
|
|
216
|
+
let y = 0;
|
|
217
|
+
for (const coeff of coeffs) y = gfAdd(gfMul(y, x), coeff);
|
|
218
|
+
return y;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function locatorEvalLow(coeffs, x) {
|
|
222
|
+
let y = 0;
|
|
223
|
+
let xp = 1;
|
|
224
|
+
for (const coeff of coeffs) {
|
|
225
|
+
y = gfAdd(y, gfMul(coeff, xp));
|
|
226
|
+
xp = gfMul(xp, x);
|
|
227
|
+
}
|
|
228
|
+
return y;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function buildGenerator(paritySymbols, firstConsecutiveRoot = 0) {
|
|
232
|
+
let generator = [1];
|
|
233
|
+
for (let i = 0; i < paritySymbols; i++) {
|
|
234
|
+
const root = gfAlphaPow(firstConsecutiveRoot + i);
|
|
235
|
+
const factor = [1, root]; // x - root === x + root in characteristic 2
|
|
236
|
+
const next = new Array(generator.length + 1).fill(0);
|
|
237
|
+
for (let a = 0; a < generator.length; a++) {
|
|
238
|
+
for (let b = 0; b < factor.length; b++) {
|
|
239
|
+
next[a + b] = gfAdd(next[a + b], gfMul(generator[a], factor[b]));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
generator = next;
|
|
243
|
+
}
|
|
244
|
+
return generator;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const GENERATOR_CACHE = new Map();
|
|
248
|
+
function getGenerator(paritySymbols, firstConsecutiveRoot = 0) {
|
|
249
|
+
const key = `${paritySymbols}:${firstConsecutiveRoot}`;
|
|
250
|
+
if (!GENERATOR_CACHE.has(key)) {
|
|
251
|
+
GENERATOR_CACHE.set(key, buildGenerator(paritySymbols, firstConsecutiveRoot));
|
|
252
|
+
}
|
|
253
|
+
return GENERATOR_CACHE.get(key);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function rsEncode(dataSymbols, paritySymbols, options = {}) {
|
|
257
|
+
const data = Array.from(dataSymbols);
|
|
258
|
+
const fcr = options.firstConsecutiveRoot ?? 0;
|
|
259
|
+
|
|
260
|
+
if (!Number.isInteger(paritySymbols) || paritySymbols <= 0) {
|
|
261
|
+
throw new Error("paritySymbols must be a positive integer.");
|
|
262
|
+
}
|
|
263
|
+
if (data.length + paritySymbols > MAX_CODEWORD_SYMBOLS) {
|
|
264
|
+
throw new Error(`RS codeword exceeds ${MAX_CODEWORD_SYMBOLS} GF(256) symbols.`);
|
|
265
|
+
}
|
|
266
|
+
for (const value of data) assertFieldValue(value);
|
|
267
|
+
|
|
268
|
+
const generator = getGenerator(paritySymbols, fcr);
|
|
269
|
+
const work = data.concat(new Array(paritySymbols).fill(0));
|
|
270
|
+
|
|
271
|
+
for (let i = 0; i < data.length; i++) {
|
|
272
|
+
const coef = work[i];
|
|
273
|
+
if (coef === 0) continue;
|
|
274
|
+
for (let j = 0; j < generator.length; j++) {
|
|
275
|
+
work[i + j] = gfSub(work[i + j], gfMul(coef, generator[j]));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const parity = work.slice(data.length).map(gfNeg);
|
|
280
|
+
return data.concat(parity);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function rsSyndromes(codeword, paritySymbols, options = {}) {
|
|
284
|
+
const fcr = options.firstConsecutiveRoot ?? 0;
|
|
285
|
+
const values = Array.from(codeword);
|
|
286
|
+
return Array.from({ length: paritySymbols }, (_, i) =>
|
|
287
|
+
polyEvalHigh(values, gfAlphaPow(fcr + i))
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function allZero(values) {
|
|
292
|
+
return values.every((value) => value === 0);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function berlekampMassey(syndromes) {
|
|
296
|
+
const n = syndromes.length;
|
|
297
|
+
const C = new Array(n + 1).fill(0);
|
|
298
|
+
const B = new Array(n + 1).fill(0);
|
|
299
|
+
C[0] = 1;
|
|
300
|
+
B[0] = 1;
|
|
301
|
+
|
|
302
|
+
let L = 0;
|
|
303
|
+
let m = 1;
|
|
304
|
+
let b = 1;
|
|
305
|
+
|
|
306
|
+
for (let index = 0; index < n; index++) {
|
|
307
|
+
let discrepancy = syndromes[index];
|
|
308
|
+
for (let i = 1; i <= L; i++) {
|
|
309
|
+
discrepancy = gfAdd(discrepancy, gfMul(C[i], syndromes[index - i]));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (discrepancy === 0) {
|
|
313
|
+
m++;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const T = C.slice();
|
|
318
|
+
const scale = gfDiv(discrepancy, b);
|
|
319
|
+
for (let i = 0; i + m < C.length; i++) {
|
|
320
|
+
if (B[i] !== 0) C[i + m] = gfSub(C[i + m], gfMul(scale, B[i]));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (2 * L <= index) {
|
|
324
|
+
L = index + 1 - L;
|
|
325
|
+
for (let i = 0; i < B.length; i++) B[i] = T[i];
|
|
326
|
+
b = discrepancy;
|
|
327
|
+
m = 1;
|
|
328
|
+
} else {
|
|
329
|
+
m++;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return { locator: C.slice(0, L + 1), errorCount: L };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function findErrorPositions(locator, codewordLength) {
|
|
337
|
+
const positions = [];
|
|
338
|
+
for (let power = 0; power < codewordLength; power++) {
|
|
339
|
+
const x = gfAlphaPow(-power);
|
|
340
|
+
if (locatorEvalLow(locator, x) === 0) {
|
|
341
|
+
positions.push(codewordLength - 1 - power);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return positions;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function solveLinearSystem(matrix, vector) {
|
|
348
|
+
const n = vector.length;
|
|
349
|
+
const a = matrix.map((row, r) => row.slice().concat([vector[r]]));
|
|
350
|
+
|
|
351
|
+
for (let col = 0; col < n; col++) {
|
|
352
|
+
let pivot = col;
|
|
353
|
+
while (pivot < n && a[pivot][col] === 0) pivot++;
|
|
354
|
+
if (pivot === n) throw new Error("Singular GF(256) system while solving RS magnitudes.");
|
|
355
|
+
|
|
356
|
+
if (pivot !== col) [a[col], a[pivot]] = [a[pivot], a[col]];
|
|
357
|
+
|
|
358
|
+
const invPivot = gfDiv(1, a[col][col]);
|
|
359
|
+
for (let j = col; j <= n; j++) a[col][j] = gfMul(a[col][j], invPivot);
|
|
360
|
+
|
|
361
|
+
for (let row = 0; row < n; row++) {
|
|
362
|
+
if (row === col || a[row][col] === 0) continue;
|
|
363
|
+
const factor = a[row][col];
|
|
364
|
+
for (let j = col; j <= n; j++) {
|
|
365
|
+
a[row][j] = gfSub(a[row][j], gfMul(factor, a[col][j]));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return a.map((row) => row[n]);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function solveErrorMagnitudes(syndromes, positions, codewordLength, firstConsecutiveRoot) {
|
|
374
|
+
const count = positions.length;
|
|
375
|
+
const matrix = Array.from({ length: count }, () => new Array(count).fill(0));
|
|
376
|
+
const vector = syndromes.slice(0, count);
|
|
377
|
+
|
|
378
|
+
for (let row = 0; row < count; row++) {
|
|
379
|
+
const rootPower = firstConsecutiveRoot + row;
|
|
380
|
+
for (let col = 0; col < count; col++) {
|
|
381
|
+
const polynomialPower = codewordLength - 1 - positions[col];
|
|
382
|
+
matrix[row][col] = gfAlphaPow(rootPower * polynomialPower);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return solveLinearSystem(matrix, vector);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function normalizeErasurePositions(positions, codewordLength) {
|
|
390
|
+
if (positions == null) return [];
|
|
391
|
+
if (!Array.isArray(positions)) throw new Error("erasurePositions must be an array of symbol indexes.");
|
|
392
|
+
const unique = [...new Set(positions)];
|
|
393
|
+
for (const position of unique) {
|
|
394
|
+
if (!Number.isInteger(position) || position < 0 || position >= codewordLength) {
|
|
395
|
+
throw new Error(`Invalid Reed-Solomon erasure position ${position}.`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return unique.sort((a, b) => a - b);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Remove the contribution of known erasure locations from the syndrome
|
|
402
|
+
// sequence. Berlekamp-Massey can then solve only for the remaining unknown
|
|
403
|
+
// errors. This is the standard Forney-syndrome reduction, expressed using the
|
|
404
|
+
// same coefficient/root convention as the rest of this small codec.
|
|
405
|
+
function forneySyndromes(syndromes, erasurePositions, codewordLength) {
|
|
406
|
+
const reduced = syndromes.slice();
|
|
407
|
+
for (const position of erasurePositions) {
|
|
408
|
+
const polynomialPower = codewordLength - 1 - position;
|
|
409
|
+
const x = gfAlphaPow(polynomialPower);
|
|
410
|
+
for (let i = 0; i < reduced.length - 1; i++) {
|
|
411
|
+
reduced[i] = gfAdd(gfMul(reduced[i], x), reduced[i + 1]);
|
|
412
|
+
}
|
|
413
|
+
reduced.pop();
|
|
414
|
+
}
|
|
415
|
+
return reduced;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function rsDecode(codeword, paritySymbols, options = {}) {
|
|
419
|
+
const values = Array.from(codeword);
|
|
420
|
+
const fcr = options.firstConsecutiveRoot ?? 0;
|
|
421
|
+
|
|
422
|
+
if (values.length > MAX_CODEWORD_SYMBOLS) {
|
|
423
|
+
throw new Error(`RS codeword exceeds ${MAX_CODEWORD_SYMBOLS} GF(256) symbols.`);
|
|
424
|
+
}
|
|
425
|
+
if (paritySymbols <= 0 || paritySymbols >= values.length) {
|
|
426
|
+
throw new Error("Invalid Reed-Solomon parity length.");
|
|
427
|
+
}
|
|
428
|
+
for (const value of values) assertFieldValue(value);
|
|
429
|
+
|
|
430
|
+
const erasurePositions = normalizeErasurePositions(options.erasurePositions, values.length);
|
|
431
|
+
if (erasurePositions.length > paritySymbols) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
`Reed-Solomon has ${erasurePositions.length} erasures but only ${paritySymbols} parity symbols.`
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const syndromes = rsSyndromes(values, paritySymbols, { firstConsecutiveRoot: fcr });
|
|
438
|
+
if (allZero(syndromes)) {
|
|
439
|
+
return {
|
|
440
|
+
corrected: values,
|
|
441
|
+
data: values.slice(0, values.length - paritySymbols),
|
|
442
|
+
correctedSymbols: 0,
|
|
443
|
+
erasureSymbols: 0,
|
|
444
|
+
unknownErrorSymbols: 0,
|
|
445
|
+
errorPositions: []
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
let unknownPositions = [];
|
|
450
|
+
if (erasurePositions.length < paritySymbols) {
|
|
451
|
+
const reducedSyndromes = erasurePositions.length
|
|
452
|
+
? forneySyndromes(syndromes, erasurePositions, values.length)
|
|
453
|
+
: syndromes;
|
|
454
|
+
|
|
455
|
+
if (!allZero(reducedSyndromes)) {
|
|
456
|
+
const { locator, errorCount } = berlekampMassey(reducedSyndromes);
|
|
457
|
+
const unknownCorrectionLimit = Math.floor((paritySymbols - erasurePositions.length) / 2);
|
|
458
|
+
if (errorCount <= 0 || errorCount > unknownCorrectionLimit) {
|
|
459
|
+
throw new Error(
|
|
460
|
+
`Reed-Solomon found ${errorCount} unknown symbol errors with ${erasurePositions.length} erasures; ` +
|
|
461
|
+
`limit is ${unknownCorrectionLimit} unknown errors.`
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
unknownPositions = findErrorPositions(locator, values.length)
|
|
466
|
+
.filter((position) => !erasurePositions.includes(position));
|
|
467
|
+
if (unknownPositions.length !== errorCount) {
|
|
468
|
+
throw new Error(`Reed-Solomon locator found ${unknownPositions.length}/${errorCount} unknown error positions.`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const positions = [...new Set(erasurePositions.concat(unknownPositions))].sort((a, b) => a - b);
|
|
474
|
+
if (positions.length === 0) {
|
|
475
|
+
throw new Error("Reed-Solomon syndromes are non-zero but no correction positions were found.");
|
|
476
|
+
}
|
|
477
|
+
if (2 * unknownPositions.length + erasurePositions.length > paritySymbols) {
|
|
478
|
+
throw new Error(
|
|
479
|
+
`Reed-Solomon error/erasure budget exceeded: 2*${unknownPositions.length} + ` +
|
|
480
|
+
`${erasurePositions.length} > ${paritySymbols}.`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const magnitudes = solveErrorMagnitudes(syndromes, positions, values.length, fcr);
|
|
485
|
+
const corrected = values.slice();
|
|
486
|
+
for (let i = 0; i < positions.length; i++) {
|
|
487
|
+
corrected[positions[i]] = gfSub(corrected[positions[i]], magnitudes[i]);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const check = rsSyndromes(corrected, paritySymbols, { firstConsecutiveRoot: fcr });
|
|
491
|
+
if (!allZero(check)) throw new Error("Reed-Solomon correction failed syndrome verification.");
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
corrected,
|
|
495
|
+
data: corrected.slice(0, corrected.length - paritySymbols),
|
|
496
|
+
correctedSymbols: positions.length,
|
|
497
|
+
erasureSymbols: erasurePositions.length,
|
|
498
|
+
unknownErrorSymbols: unknownPositions.length,
|
|
499
|
+
errorPositions: positions
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const gf256Internals = Object.freeze({
|
|
504
|
+
EXP,
|
|
505
|
+
LOG,
|
|
506
|
+
PRIMITIVE_POLYNOMIAL,
|
|
507
|
+
buildGenerator,
|
|
508
|
+
berlekampMassey,
|
|
509
|
+
findErrorPositions
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
return { gfAdd, gfNeg, gfSub, gfMul, gfDiv, gfPow, gfAlphaPow, rsEncode, rsSyndromes, rsDecode, FIELD_SIZE, FIELD_ORDER, MAX_CODEWORD_SYMBOLS, gf256Internals };
|
|
513
|
+
})();
|
|
514
|
+
const __qqr_security_js = (function () {
|
|
515
|
+
|
|
516
|
+
const SECURE_PAYLOAD_VERSION = 1;
|
|
517
|
+
const DEFAULT_PBKDF2_ITERATIONS = 600_000;
|
|
518
|
+
const MIN_PBKDF2_ITERATIONS = 100_000;
|
|
519
|
+
const MAX_PBKDF2_ITERATIONS = 2_000_000;
|
|
520
|
+
|
|
521
|
+
const SECURITY_MODES = Object.freeze({
|
|
522
|
+
PASSWORD: "password",
|
|
523
|
+
RAW_KEY: "raw-key"
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
const SECURITY_ALGORITHMS = Object.freeze({
|
|
527
|
+
AES_256_GCM: "AES-256-GCM"
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
const MAGIC = new Uint8Array([0x51, 0x53, 0x45, 0x43]); // QSEC
|
|
531
|
+
const MODE_PASSWORD = 1;
|
|
532
|
+
const MODE_RAW_KEY = 2;
|
|
533
|
+
const ALGORITHM_AES_256_GCM = 1;
|
|
534
|
+
const KDF_NONE = 0;
|
|
535
|
+
const KDF_PBKDF2_SHA256 = 1;
|
|
536
|
+
const FLAG_KEY_ID_UTF8 = 1;
|
|
537
|
+
const FLAG_KEY_ID_AUTO = 1 << 1;
|
|
538
|
+
const FIXED_HEADER_BYTES = 24;
|
|
539
|
+
const SALT_BYTES = 16;
|
|
540
|
+
const NONCE_BYTES = 12;
|
|
541
|
+
const TAG_BYTES = 16;
|
|
542
|
+
const AUTO_KEY_ID_BYTES = 8;
|
|
543
|
+
const MAX_KEY_ID_BYTES = 32;
|
|
544
|
+
|
|
545
|
+
const encoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : null;
|
|
546
|
+
const decoder = typeof TextDecoder !== "undefined" ? new TextDecoder("utf-8", { fatal: false }) : null;
|
|
547
|
+
|
|
548
|
+
function assert(condition, message) {
|
|
549
|
+
if (!condition) throw new Error(message);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function cryptoApi() {
|
|
553
|
+
const api = globalThis.crypto;
|
|
554
|
+
assert(api?.subtle && typeof api.getRandomValues === "function", "Web Crypto API is required for QuadQR secure payloads.");
|
|
555
|
+
return api;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function concatBytes(...arrays) {
|
|
559
|
+
const length = arrays.reduce((sum, value) => sum + value.length, 0);
|
|
560
|
+
const output = new Uint8Array(length);
|
|
561
|
+
let offset = 0;
|
|
562
|
+
for (const value of arrays) {
|
|
563
|
+
output.set(value, offset);
|
|
564
|
+
offset += value.length;
|
|
565
|
+
}
|
|
566
|
+
return output;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function u32be(value) {
|
|
570
|
+
const v = value >>> 0;
|
|
571
|
+
return new Uint8Array([
|
|
572
|
+
(v >>> 24) & 0xff,
|
|
573
|
+
(v >>> 16) & 0xff,
|
|
574
|
+
(v >>> 8) & 0xff,
|
|
575
|
+
v & 0xff
|
|
576
|
+
]);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function readU32be(bytes, offset) {
|
|
580
|
+
return (
|
|
581
|
+
((bytes[offset] << 24) >>> 0) |
|
|
582
|
+
(bytes[offset + 1] << 16) |
|
|
583
|
+
(bytes[offset + 2] << 8) |
|
|
584
|
+
bytes[offset + 3]
|
|
585
|
+
) >>> 0;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function equalPrefix(bytes, prefix) {
|
|
589
|
+
if (bytes.length < prefix.length) return false;
|
|
590
|
+
for (let index = 0; index < prefix.length; index++) {
|
|
591
|
+
if (bytes[index] !== prefix[index]) return false;
|
|
592
|
+
}
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function toBytes(value) {
|
|
597
|
+
if (value instanceof Uint8Array) return new Uint8Array(value);
|
|
598
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value.slice(0));
|
|
599
|
+
if (ArrayBuffer.isView(value)) {
|
|
600
|
+
return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength));
|
|
601
|
+
}
|
|
602
|
+
return new Uint8Array(value);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function bytesToHex(bytes) {
|
|
606
|
+
return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("");
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function hexToBytes(hex) {
|
|
610
|
+
const clean = String(hex).trim().replace(/^0x/i, "").replace(/[\s:-]/g, "");
|
|
611
|
+
assert(clean.length % 2 === 0 && /^[0-9a-f]*$/i.test(clean), "Expected a hexadecimal byte string.");
|
|
612
|
+
const output = new Uint8Array(clean.length / 2);
|
|
613
|
+
for (let index = 0; index < output.length; index++) {
|
|
614
|
+
output[index] = parseInt(clean.slice(index * 2, index * 2 + 2), 16);
|
|
615
|
+
}
|
|
616
|
+
return output;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function normalizeRaw256Key(key) {
|
|
620
|
+
const bytes = typeof key === "string" ? hexToBytes(key) : toBytes(key);
|
|
621
|
+
assert(bytes.length === 32, "Raw QuadQR encryption key must be exactly 32 bytes (256 bits / 64 hex characters).");
|
|
622
|
+
return bytes;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function generateRaw256Key() {
|
|
626
|
+
const key = new Uint8Array(32);
|
|
627
|
+
cryptoApi().getRandomValues(key);
|
|
628
|
+
return key;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function randomBytes(length) {
|
|
632
|
+
const bytes = new Uint8Array(length);
|
|
633
|
+
cryptoApi().getRandomValues(bytes);
|
|
634
|
+
return bytes;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function normalizeMode(mode) {
|
|
638
|
+
const value = String(mode ?? "").toLowerCase();
|
|
639
|
+
if (["password", "passphrase"].includes(value)) return SECURITY_MODES.PASSWORD;
|
|
640
|
+
if (["raw-key", "raw", "key", "raw256", "raw-256"].includes(value)) return SECURITY_MODES.RAW_KEY;
|
|
641
|
+
throw new Error('Security mode must be "password" or "raw-key".');
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function encodeKeyId(keyId) {
|
|
645
|
+
if (keyId == null || keyId === false) return { bytes: new Uint8Array(0), utf8: false, auto: false };
|
|
646
|
+
if (typeof keyId === "string") {
|
|
647
|
+
assert(encoder, "TextEncoder is required for string key IDs.");
|
|
648
|
+
const bytes = encoder.encode(keyId);
|
|
649
|
+
assert(bytes.length > 0 && bytes.length <= MAX_KEY_ID_BYTES, `Key ID must be 1..${MAX_KEY_ID_BYTES} UTF-8 bytes.`);
|
|
650
|
+
return { bytes, utf8: true, auto: false };
|
|
651
|
+
}
|
|
652
|
+
const bytes = toBytes(keyId);
|
|
653
|
+
assert(bytes.length > 0 && bytes.length <= MAX_KEY_ID_BYTES, `Key ID must be 1..${MAX_KEY_ID_BYTES} bytes.`);
|
|
654
|
+
return { bytes, utf8: false, auto: false };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function autoKeyId(rawKey) {
|
|
658
|
+
const digest = new Uint8Array(await cryptoApi().subtle.digest("SHA-256", rawKey));
|
|
659
|
+
return digest.slice(0, AUTO_KEY_ID_BYTES);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function derivePasswordKey(password, salt, iterations, usages) {
|
|
663
|
+
assert(typeof password === "string" && password.length > 0, "A non-empty password is required.");
|
|
664
|
+
assert(encoder, "TextEncoder is required for password encryption.");
|
|
665
|
+
assert(Number.isInteger(iterations), "PBKDF2 iteration count must be an integer.");
|
|
666
|
+
assert(
|
|
667
|
+
iterations >= MIN_PBKDF2_ITERATIONS && iterations <= MAX_PBKDF2_ITERATIONS,
|
|
668
|
+
`PBKDF2 iterations must be ${MIN_PBKDF2_ITERATIONS}..${MAX_PBKDF2_ITERATIONS}.`
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
const baseKey = await cryptoApi().subtle.importKey(
|
|
672
|
+
"raw",
|
|
673
|
+
encoder.encode(password),
|
|
674
|
+
"PBKDF2",
|
|
675
|
+
false,
|
|
676
|
+
["deriveKey"]
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
return cryptoApi().subtle.deriveKey(
|
|
680
|
+
{ name: "PBKDF2", hash: "SHA-256", salt, iterations },
|
|
681
|
+
baseKey,
|
|
682
|
+
{ name: "AES-GCM", length: 256 },
|
|
683
|
+
false,
|
|
684
|
+
usages
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async function importRawAesKey(rawKey, usages) {
|
|
689
|
+
return cryptoApi().subtle.importKey(
|
|
690
|
+
"raw",
|
|
691
|
+
normalizeRaw256Key(rawKey),
|
|
692
|
+
{ name: "AES-GCM", length: 256 },
|
|
693
|
+
false,
|
|
694
|
+
usages
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function makeFixedHeader({ modeId, kdfId, flags, saltLength, nonceLength, keyIdLength, iterations, plaintextLength }) {
|
|
699
|
+
const header = new Uint8Array(FIXED_HEADER_BYTES);
|
|
700
|
+
header.set(MAGIC, 0);
|
|
701
|
+
header[4] = SECURE_PAYLOAD_VERSION;
|
|
702
|
+
header[5] = modeId;
|
|
703
|
+
header[6] = ALGORITHM_AES_256_GCM;
|
|
704
|
+
header[7] = kdfId;
|
|
705
|
+
header[8] = flags;
|
|
706
|
+
header[9] = saltLength;
|
|
707
|
+
header[10] = nonceLength;
|
|
708
|
+
header[11] = TAG_BYTES;
|
|
709
|
+
header[12] = keyIdLength;
|
|
710
|
+
header[13] = 0;
|
|
711
|
+
header[14] = 0;
|
|
712
|
+
header[15] = 0;
|
|
713
|
+
header.set(u32be(iterations), 16);
|
|
714
|
+
header.set(u32be(plaintextLength), 20);
|
|
715
|
+
return header;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function parseEnvelope(envelope) {
|
|
719
|
+
const bytes = toBytes(envelope);
|
|
720
|
+
assert(bytes.length >= FIXED_HEADER_BYTES + NONCE_BYTES + TAG_BYTES, "Secure payload envelope is too short.");
|
|
721
|
+
assert(equalPrefix(bytes, MAGIC), "Secure payload magic mismatch.");
|
|
722
|
+
assert(bytes[4] === SECURE_PAYLOAD_VERSION, `Unsupported secure payload version ${bytes[4]}.`);
|
|
723
|
+
assert(bytes[6] === ALGORITHM_AES_256_GCM, `Unsupported secure payload algorithm id ${bytes[6]}.`);
|
|
724
|
+
|
|
725
|
+
const modeId = bytes[5];
|
|
726
|
+
const kdfId = bytes[7];
|
|
727
|
+
const flags = bytes[8];
|
|
728
|
+
const saltLength = bytes[9];
|
|
729
|
+
const nonceLength = bytes[10];
|
|
730
|
+
const tagLength = bytes[11];
|
|
731
|
+
const keyIdLength = bytes[12];
|
|
732
|
+
const iterations = readU32be(bytes, 16);
|
|
733
|
+
const plaintextLength = readU32be(bytes, 20);
|
|
734
|
+
|
|
735
|
+
assert(nonceLength === NONCE_BYTES, `Unsupported AES-GCM nonce length ${nonceLength}.`);
|
|
736
|
+
assert(tagLength === TAG_BYTES, `Unsupported AES-GCM tag length ${tagLength}.`);
|
|
737
|
+
assert(keyIdLength <= MAX_KEY_ID_BYTES, "Secure payload key ID is too long.");
|
|
738
|
+
|
|
739
|
+
let mode;
|
|
740
|
+
if (modeId === MODE_PASSWORD) {
|
|
741
|
+
mode = SECURITY_MODES.PASSWORD;
|
|
742
|
+
assert(kdfId === KDF_PBKDF2_SHA256, "Password payload uses an unsupported KDF.");
|
|
743
|
+
assert(saltLength === SALT_BYTES, `Password payload salt must be ${SALT_BYTES} bytes.`);
|
|
744
|
+
assert(iterations >= MIN_PBKDF2_ITERATIONS && iterations <= MAX_PBKDF2_ITERATIONS, "Password payload PBKDF2 iteration count is outside the supported safety range.");
|
|
745
|
+
} else if (modeId === MODE_RAW_KEY) {
|
|
746
|
+
mode = SECURITY_MODES.RAW_KEY;
|
|
747
|
+
assert(kdfId === KDF_NONE, "Raw-key payload must not declare a password KDF.");
|
|
748
|
+
assert(saltLength === 0, "Raw-key payload must not contain a password salt.");
|
|
749
|
+
assert(iterations === 0, "Raw-key payload must not declare PBKDF2 iterations.");
|
|
750
|
+
} else {
|
|
751
|
+
throw new Error(`Unsupported secure payload mode id ${modeId}.`);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const metadataLength = FIXED_HEADER_BYTES + keyIdLength + saltLength + nonceLength;
|
|
755
|
+
const expectedLength = metadataLength + plaintextLength + tagLength;
|
|
756
|
+
assert(bytes.length === expectedLength, `Secure payload length mismatch: expected ${expectedLength} bytes, got ${bytes.length}.`);
|
|
757
|
+
|
|
758
|
+
let cursor = FIXED_HEADER_BYTES;
|
|
759
|
+
const keyIdBytes = bytes.slice(cursor, cursor + keyIdLength);
|
|
760
|
+
cursor += keyIdLength;
|
|
761
|
+
const salt = bytes.slice(cursor, cursor + saltLength);
|
|
762
|
+
cursor += saltLength;
|
|
763
|
+
const nonce = bytes.slice(cursor, cursor + nonceLength);
|
|
764
|
+
cursor += nonceLength;
|
|
765
|
+
const ciphertextWithTag = bytes.slice(cursor);
|
|
766
|
+
const aad = bytes.slice(0, cursor);
|
|
767
|
+
const keyIdUtf8 = Boolean(flags & FLAG_KEY_ID_UTF8);
|
|
768
|
+
const keyIdAuto = Boolean(flags & FLAG_KEY_ID_AUTO);
|
|
769
|
+
|
|
770
|
+
return {
|
|
771
|
+
bytes,
|
|
772
|
+
mode,
|
|
773
|
+
modeId,
|
|
774
|
+
kdfId,
|
|
775
|
+
flags,
|
|
776
|
+
salt,
|
|
777
|
+
nonce,
|
|
778
|
+
iterations,
|
|
779
|
+
plaintextLength,
|
|
780
|
+
keyIdBytes,
|
|
781
|
+
keyIdUtf8,
|
|
782
|
+
keyIdAuto,
|
|
783
|
+
ciphertextWithTag,
|
|
784
|
+
aad
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function inspectSecureEnvelope(envelope) {
|
|
789
|
+
const parsed = parseEnvelope(envelope);
|
|
790
|
+
return {
|
|
791
|
+
securePayloadVersion: SECURE_PAYLOAD_VERSION,
|
|
792
|
+
mode: parsed.mode,
|
|
793
|
+
algorithm: SECURITY_ALGORITHMS.AES_256_GCM,
|
|
794
|
+
kdf: parsed.mode === SECURITY_MODES.PASSWORD ? "PBKDF2-HMAC-SHA-256" : null,
|
|
795
|
+
iterations: parsed.mode === SECURITY_MODES.PASSWORD ? parsed.iterations : null,
|
|
796
|
+
keyId: parsed.keyIdUtf8 && decoder ? decoder.decode(parsed.keyIdBytes) : null,
|
|
797
|
+
keyIdHex: parsed.keyIdBytes.length ? bytesToHex(parsed.keyIdBytes) : null,
|
|
798
|
+
keyIdAuto: parsed.keyIdAuto,
|
|
799
|
+
plaintextBytes: parsed.plaintextLength,
|
|
800
|
+
envelopeBytes: parsed.bytes.length,
|
|
801
|
+
overheadBytes: parsed.bytes.length - parsed.plaintextLength,
|
|
802
|
+
authenticated: true
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async function encryptSecurePayload(input, security = {}) {
|
|
807
|
+
const plaintext = toBytes(input);
|
|
808
|
+
const mode = normalizeMode(security.mode);
|
|
809
|
+
const nonce = randomBytes(NONCE_BYTES);
|
|
810
|
+
let aesKey;
|
|
811
|
+
let salt = new Uint8Array(0);
|
|
812
|
+
let iterations = 0;
|
|
813
|
+
let modeId;
|
|
814
|
+
let kdfId;
|
|
815
|
+
let keyId = { bytes: new Uint8Array(0), utf8: false, auto: false };
|
|
816
|
+
|
|
817
|
+
if (mode === SECURITY_MODES.PASSWORD) {
|
|
818
|
+
modeId = MODE_PASSWORD;
|
|
819
|
+
kdfId = KDF_PBKDF2_SHA256;
|
|
820
|
+
iterations = security.iterations ?? DEFAULT_PBKDF2_ITERATIONS;
|
|
821
|
+
salt = randomBytes(SALT_BYTES);
|
|
822
|
+
aesKey = await derivePasswordKey(security.password, salt, iterations, ["encrypt"]);
|
|
823
|
+
if (security.keyId != null && security.keyId !== false) keyId = encodeKeyId(security.keyId);
|
|
824
|
+
} else {
|
|
825
|
+
modeId = MODE_RAW_KEY;
|
|
826
|
+
kdfId = KDF_NONE;
|
|
827
|
+
const rawKey = normalizeRaw256Key(security.key);
|
|
828
|
+
aesKey = await importRawAesKey(rawKey, ["encrypt"]);
|
|
829
|
+
if (security.keyId === false) {
|
|
830
|
+
keyId = { bytes: new Uint8Array(0), utf8: false, auto: false };
|
|
831
|
+
} else if (security.keyId != null) {
|
|
832
|
+
keyId = encodeKeyId(security.keyId);
|
|
833
|
+
} else {
|
|
834
|
+
keyId = { bytes: await autoKeyId(rawKey), utf8: false, auto: true };
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const flags = (keyId.utf8 ? FLAG_KEY_ID_UTF8 : 0) | (keyId.auto ? FLAG_KEY_ID_AUTO : 0);
|
|
839
|
+
const fixedHeader = makeFixedHeader({
|
|
840
|
+
modeId,
|
|
841
|
+
kdfId,
|
|
842
|
+
flags,
|
|
843
|
+
saltLength: salt.length,
|
|
844
|
+
nonceLength: nonce.length,
|
|
845
|
+
keyIdLength: keyId.bytes.length,
|
|
846
|
+
iterations,
|
|
847
|
+
plaintextLength: plaintext.length
|
|
848
|
+
});
|
|
849
|
+
const aad = concatBytes(fixedHeader, keyId.bytes, salt, nonce);
|
|
850
|
+
const ciphertextWithTag = new Uint8Array(await cryptoApi().subtle.encrypt(
|
|
851
|
+
{ name: "AES-GCM", iv: nonce, additionalData: aad, tagLength: TAG_BYTES * 8 },
|
|
852
|
+
aesKey,
|
|
853
|
+
plaintext
|
|
854
|
+
));
|
|
855
|
+
const envelope = concatBytes(aad, ciphertextWithTag);
|
|
856
|
+
|
|
857
|
+
return {
|
|
858
|
+
envelope,
|
|
859
|
+
metadata: inspectSecureEnvelope(envelope)
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
async function decryptSecurePayload(envelope, security = {}) {
|
|
864
|
+
const parsed = parseEnvelope(envelope);
|
|
865
|
+
let aesKey;
|
|
866
|
+
|
|
867
|
+
if (parsed.mode === SECURITY_MODES.PASSWORD) {
|
|
868
|
+
aesKey = await derivePasswordKey(security.password, parsed.salt, parsed.iterations, ["decrypt"]);
|
|
869
|
+
} else {
|
|
870
|
+
aesKey = await importRawAesKey(security.key, ["decrypt"]);
|
|
871
|
+
|
|
872
|
+
// Key IDs are routing hints, not secrets. When the envelope has an auto
|
|
873
|
+
// fingerprint ID, verify it before doing the more expensive GCM operation.
|
|
874
|
+
if (parsed.keyIdBytes.length && parsed.keyIdAuto && security.verifyKeyId !== false) {
|
|
875
|
+
const expected = await autoKeyId(normalizeRaw256Key(security.key));
|
|
876
|
+
if (expected.length === parsed.keyIdBytes.length) {
|
|
877
|
+
let mismatch = 0;
|
|
878
|
+
for (let index = 0; index < expected.length; index++) mismatch |= expected[index] ^ parsed.keyIdBytes[index];
|
|
879
|
+
assert(mismatch === 0, "Raw encryption key does not match this QuadQR key ID.");
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
try {
|
|
885
|
+
const plaintext = new Uint8Array(await cryptoApi().subtle.decrypt(
|
|
886
|
+
{ name: "AES-GCM", iv: parsed.nonce, additionalData: parsed.aad, tagLength: TAG_BYTES * 8 },
|
|
887
|
+
aesKey,
|
|
888
|
+
parsed.ciphertextWithTag
|
|
889
|
+
));
|
|
890
|
+
assert(plaintext.length === parsed.plaintextLength, "Secure payload plaintext length mismatch after decryption.");
|
|
891
|
+
return plaintext;
|
|
892
|
+
} catch (error) {
|
|
893
|
+
if (/key ID/i.test(error?.message ?? "")) throw error;
|
|
894
|
+
throw new Error("Secure QuadQR decryption failed. The password/key is wrong or the encrypted payload was modified.");
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const securityInternals = Object.freeze({
|
|
899
|
+
FIXED_HEADER_BYTES,
|
|
900
|
+
SALT_BYTES,
|
|
901
|
+
NONCE_BYTES,
|
|
902
|
+
TAG_BYTES,
|
|
903
|
+
AUTO_KEY_ID_BYTES,
|
|
904
|
+
parseEnvelope
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
return { bytesToHex, hexToBytes, normalizeRaw256Key, generateRaw256Key, inspectSecureEnvelope, encryptSecurePayload, decryptSecurePayload, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, MIN_PBKDF2_ITERATIONS, MAX_PBKDF2_ITERATIONS, SECURITY_MODES, SECURITY_ALGORITHMS, securityInternals };
|
|
908
|
+
})();
|
|
909
|
+
const __qqr_vision_js = (function () {
|
|
910
|
+
const { alignmentPatternCentersForVersion, alignmentPatternIsBlack, alignmentPatternRadius, primaryAlignmentPatternForVersion, sizeForVersion } = __qqr_geometry_js;
|
|
911
|
+
|
|
912
|
+
function assert(condition, message) {
|
|
913
|
+
if (!condition) throw new Error(message);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function clamp(value, min, max) {
|
|
917
|
+
return Math.max(min, Math.min(max, value));
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function pixelRgb(imageData, x, y) {
|
|
921
|
+
const ix = clamp(Math.round(x), 0, imageData.width - 1);
|
|
922
|
+
const iy = clamp(Math.round(y), 0, imageData.height - 1);
|
|
923
|
+
const index = (iy * imageData.width + ix) * 4;
|
|
924
|
+
const a = imageData.data[index + 3] / 255;
|
|
925
|
+
return {
|
|
926
|
+
r: imageData.data[index] * a + 255 * (1 - a),
|
|
927
|
+
g: imageData.data[index + 1] * a + 255 * (1 - a),
|
|
928
|
+
b: imageData.data[index + 2] * a + 255 * (1 - a)
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function bilinearRgb(imageData, x, y) {
|
|
933
|
+
const x0 = clamp(Math.floor(x), 0, imageData.width - 1);
|
|
934
|
+
const y0 = clamp(Math.floor(y), 0, imageData.height - 1);
|
|
935
|
+
const x1 = clamp(x0 + 1, 0, imageData.width - 1);
|
|
936
|
+
const y1 = clamp(y0 + 1, 0, imageData.height - 1);
|
|
937
|
+
const fx = clamp(x - x0, 0, 1);
|
|
938
|
+
const fy = clamp(y - y0, 0, 1);
|
|
939
|
+
|
|
940
|
+
const p00 = pixelRgb(imageData, x0, y0);
|
|
941
|
+
const p10 = pixelRgb(imageData, x1, y0);
|
|
942
|
+
const p01 = pixelRgb(imageData, x0, y1);
|
|
943
|
+
const p11 = pixelRgb(imageData, x1, y1);
|
|
944
|
+
|
|
945
|
+
const mix = (a, b, t) => a + (b - a) * t;
|
|
946
|
+
return {
|
|
947
|
+
r: mix(mix(p00.r, p10.r, fx), mix(p01.r, p11.r, fx), fy),
|
|
948
|
+
g: mix(mix(p00.g, p10.g, fx), mix(p01.g, p11.g, fx), fy),
|
|
949
|
+
b: mix(mix(p00.b, p10.b, fx), mix(p01.b, p11.b, fx), fy)
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function luminance(rgb) {
|
|
954
|
+
return 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function buildGray(imageData) {
|
|
958
|
+
const gray = new Uint8Array(imageData.width * imageData.height);
|
|
959
|
+
for (let i = 0; i < gray.length; i++) {
|
|
960
|
+
const p = i * 4;
|
|
961
|
+
const a = imageData.data[p + 3] / 255;
|
|
962
|
+
const r = imageData.data[p] * a + 255 * (1 - a);
|
|
963
|
+
const g = imageData.data[p + 1] * a + 255 * (1 - a);
|
|
964
|
+
const b = imageData.data[p + 2] * a + 255 * (1 - a);
|
|
965
|
+
gray[i] = Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b);
|
|
966
|
+
}
|
|
967
|
+
return gray;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function otsuThreshold(gray) {
|
|
971
|
+
const histogram = new Uint32Array(256);
|
|
972
|
+
for (const value of gray) histogram[value]++;
|
|
973
|
+
const total = gray.length;
|
|
974
|
+
let sum = 0;
|
|
975
|
+
for (let i = 0; i < 256; i++) sum += i * histogram[i];
|
|
976
|
+
|
|
977
|
+
let sumBackground = 0;
|
|
978
|
+
let weightBackground = 0;
|
|
979
|
+
let maxVariance = -1;
|
|
980
|
+
let threshold = 127;
|
|
981
|
+
|
|
982
|
+
for (let t = 0; t < 256; t++) {
|
|
983
|
+
weightBackground += histogram[t];
|
|
984
|
+
if (weightBackground === 0) continue;
|
|
985
|
+
const weightForeground = total - weightBackground;
|
|
986
|
+
if (weightForeground === 0) break;
|
|
987
|
+
sumBackground += t * histogram[t];
|
|
988
|
+
const meanBackground = sumBackground / weightBackground;
|
|
989
|
+
const meanForeground = (sum - sumBackground) / weightForeground;
|
|
990
|
+
const diff = meanBackground - meanForeground;
|
|
991
|
+
const variance = weightBackground * weightForeground * diff * diff;
|
|
992
|
+
if (variance > maxVariance) {
|
|
993
|
+
maxVariance = variance;
|
|
994
|
+
threshold = t;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
return threshold;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function buildBinary(imageData) {
|
|
1002
|
+
const gray = buildGray(imageData);
|
|
1003
|
+
const threshold = otsuThreshold(gray);
|
|
1004
|
+
const binary = new Uint8Array(gray.length);
|
|
1005
|
+
for (let i = 0; i < gray.length; i++) binary[i] = gray[i] <= threshold ? 1 : 0;
|
|
1006
|
+
return { gray, binary, threshold };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function runsForRow(binary, width, row) {
|
|
1010
|
+
const runs = [];
|
|
1011
|
+
let color = binary[row * width];
|
|
1012
|
+
let start = 0;
|
|
1013
|
+
for (let x = 1; x < width; x++) {
|
|
1014
|
+
const next = binary[row * width + x];
|
|
1015
|
+
if (next !== color) {
|
|
1016
|
+
runs.push({ color, start, length: x - start });
|
|
1017
|
+
color = next;
|
|
1018
|
+
start = x;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
runs.push({ color, start, length: width - start });
|
|
1022
|
+
return runs;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function runsForColumn(binary, width, height, col) {
|
|
1026
|
+
const runs = [];
|
|
1027
|
+
let color = binary[col];
|
|
1028
|
+
let start = 0;
|
|
1029
|
+
for (let y = 1; y < height; y++) {
|
|
1030
|
+
const next = binary[y * width + col];
|
|
1031
|
+
if (next !== color) {
|
|
1032
|
+
runs.push({ color, start, length: y - start });
|
|
1033
|
+
color = next;
|
|
1034
|
+
start = y;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
runs.push({ color, start, length: height - start });
|
|
1038
|
+
return runs;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function finderRatioScore(lengths) {
|
|
1042
|
+
const total = lengths.reduce((sum, value) => sum + value, 0);
|
|
1043
|
+
if (total < 7) return Infinity;
|
|
1044
|
+
const module = total / 7;
|
|
1045
|
+
const expected = [module, module, 3 * module, module, module];
|
|
1046
|
+
let score = 0;
|
|
1047
|
+
for (let i = 0; i < 5; i++) {
|
|
1048
|
+
const tolerance = i === 2 ? module * 1.25 : module * 0.8;
|
|
1049
|
+
const diff = Math.abs(lengths[i] - expected[i]);
|
|
1050
|
+
if (diff > tolerance) return Infinity;
|
|
1051
|
+
score += diff / Math.max(1, expected[i]);
|
|
1052
|
+
}
|
|
1053
|
+
return score;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function findWindowContainingCoordinate(runs, coordinate) {
|
|
1057
|
+
for (let i = 2; i < runs.length - 2; i++) {
|
|
1058
|
+
const centerRun = runs[i];
|
|
1059
|
+
if (centerRun.color !== 1) continue;
|
|
1060
|
+
if (coordinate < centerRun.start || coordinate >= centerRun.start + centerRun.length) continue;
|
|
1061
|
+
const window = runs.slice(i - 2, i + 3);
|
|
1062
|
+
if (window.map((run) => run.color).join("") !== "10101") continue;
|
|
1063
|
+
const score = finderRatioScore(window.map((run) => run.length));
|
|
1064
|
+
if (!Number.isFinite(score)) continue;
|
|
1065
|
+
const first = window[0].start;
|
|
1066
|
+
const total = window.reduce((sum, run) => sum + run.length, 0);
|
|
1067
|
+
return {
|
|
1068
|
+
center: first + total / 2,
|
|
1069
|
+
moduleSize: total / 7,
|
|
1070
|
+
score
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
return null;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function crossCheckVertical(binary, width, height, x, y) {
|
|
1077
|
+
const col = clamp(Math.round(x), 0, width - 1);
|
|
1078
|
+
return findWindowContainingCoordinate(runsForColumn(binary, width, height, col), y);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function crossCheckHorizontal(binary, width, height, x, y) {
|
|
1082
|
+
const row = clamp(Math.round(y), 0, height - 1);
|
|
1083
|
+
return findWindowContainingCoordinate(runsForRow(binary, width, row), x);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function clusterFinderCandidates(raw) {
|
|
1087
|
+
const clusters = [];
|
|
1088
|
+
raw.sort((a, b) => a.moduleSize - b.moduleSize);
|
|
1089
|
+
|
|
1090
|
+
for (const candidate of raw) {
|
|
1091
|
+
let best = null;
|
|
1092
|
+
let bestDistance = Infinity;
|
|
1093
|
+
for (const cluster of clusters) {
|
|
1094
|
+
const moduleRatio = Math.abs(cluster.moduleSize - candidate.moduleSize) /
|
|
1095
|
+
Math.max(cluster.moduleSize, candidate.moduleSize);
|
|
1096
|
+
if (moduleRatio > 0.45) continue;
|
|
1097
|
+
const dx = cluster.x - candidate.x;
|
|
1098
|
+
const dy = cluster.y - candidate.y;
|
|
1099
|
+
const distance = Math.hypot(dx, dy);
|
|
1100
|
+
if (distance <= Math.max(cluster.moduleSize, candidate.moduleSize) * 2.25 && distance < bestDistance) {
|
|
1101
|
+
best = cluster;
|
|
1102
|
+
bestDistance = distance;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if (!best) {
|
|
1107
|
+
clusters.push({ ...candidate, confirmations: 1 });
|
|
1108
|
+
} else {
|
|
1109
|
+
const n = best.confirmations;
|
|
1110
|
+
best.x = (best.x * n + candidate.x) / (n + 1);
|
|
1111
|
+
best.y = (best.y * n + candidate.y) / (n + 1);
|
|
1112
|
+
best.moduleSize = (best.moduleSize * n + candidate.moduleSize) / (n + 1);
|
|
1113
|
+
best.score = (best.score * n + candidate.score) / (n + 1);
|
|
1114
|
+
best.confirmations++;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
return clusters
|
|
1119
|
+
.filter((candidate) => candidate.confirmations >= 2)
|
|
1120
|
+
.sort((a, b) =>
|
|
1121
|
+
(b.confirmations - a.confirmations) ||
|
|
1122
|
+
(a.score - b.score) ||
|
|
1123
|
+
(b.moduleSize - a.moduleSize)
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function detectFinderCandidates(binary, width, height) {
|
|
1128
|
+
const raw = [];
|
|
1129
|
+
const rowStep = height > 1200 ? 2 : 1;
|
|
1130
|
+
|
|
1131
|
+
for (let y = 0; y < height; y += rowStep) {
|
|
1132
|
+
const runs = runsForRow(binary, width, y);
|
|
1133
|
+
for (let i = 0; i <= runs.length - 5; i++) {
|
|
1134
|
+
const window = runs.slice(i, i + 5);
|
|
1135
|
+
if (window.map((run) => run.color).join("") !== "10101") continue;
|
|
1136
|
+
const lengths = window.map((run) => run.length);
|
|
1137
|
+
const ratioScore = finderRatioScore(lengths);
|
|
1138
|
+
if (!Number.isFinite(ratioScore)) continue;
|
|
1139
|
+
const total = lengths.reduce((sum, value) => sum + value, 0);
|
|
1140
|
+
const centerX = window[0].start + total / 2;
|
|
1141
|
+
const vertical = crossCheckVertical(binary, width, height, centerX, y);
|
|
1142
|
+
if (!vertical) continue;
|
|
1143
|
+
const horizontal = crossCheckHorizontal(binary, width, height, centerX, vertical.center);
|
|
1144
|
+
if (!horizontal) continue;
|
|
1145
|
+
|
|
1146
|
+
const moduleSize = (total / 7 + vertical.moduleSize + horizontal.moduleSize) / 3;
|
|
1147
|
+
const moduleSpread = Math.max(
|
|
1148
|
+
Math.abs(moduleSize - total / 7),
|
|
1149
|
+
Math.abs(moduleSize - vertical.moduleSize),
|
|
1150
|
+
Math.abs(moduleSize - horizontal.moduleSize)
|
|
1151
|
+
) / moduleSize;
|
|
1152
|
+
if (moduleSpread > 0.45) continue;
|
|
1153
|
+
|
|
1154
|
+
raw.push({
|
|
1155
|
+
x: horizontal.center,
|
|
1156
|
+
y: vertical.center,
|
|
1157
|
+
moduleSize,
|
|
1158
|
+
score: ratioScore + vertical.score + horizontal.score
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
return clusterFinderCandidates(raw);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function dot(a, b) {
|
|
1167
|
+
return a.x * b.x + a.y * b.y;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function sub(a, b) {
|
|
1171
|
+
return { x: a.x - b.x, y: a.y - b.y };
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function cross(a, b) {
|
|
1175
|
+
return a.x * b.y - a.y * b.x;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function chooseFinderTriples(candidates, maxTriples = 16) {
|
|
1179
|
+
const top = candidates.slice(0, Math.min(candidates.length, 14));
|
|
1180
|
+
const triples = [];
|
|
1181
|
+
|
|
1182
|
+
for (let a = 0; a < top.length - 2; a++) {
|
|
1183
|
+
for (let b = a + 1; b < top.length - 1; b++) {
|
|
1184
|
+
for (let c = b + 1; c < top.length; c++) {
|
|
1185
|
+
const points = [top[a], top[b], top[c]];
|
|
1186
|
+
for (let corner = 0; corner < 3; corner++) {
|
|
1187
|
+
const tl = points[corner];
|
|
1188
|
+
const other = points.filter((_, index) => index !== corner);
|
|
1189
|
+
let tr = other[0];
|
|
1190
|
+
let bl = other[1];
|
|
1191
|
+
let u = sub(tr, tl);
|
|
1192
|
+
let v = sub(bl, tl);
|
|
1193
|
+
let d1 = Math.hypot(u.x, u.y);
|
|
1194
|
+
let d2 = Math.hypot(v.x, v.y);
|
|
1195
|
+
if (d1 < tl.moduleSize * 10 || d2 < tl.moduleSize * 10) continue;
|
|
1196
|
+
const cos = Math.abs(dot(u, v) / (d1 * d2));
|
|
1197
|
+
if (cos > 0.55) continue;
|
|
1198
|
+
if (cross(u, v) < 0) {
|
|
1199
|
+
[tr, bl] = [bl, tr];
|
|
1200
|
+
u = sub(tr, tl);
|
|
1201
|
+
v = sub(bl, tl);
|
|
1202
|
+
d1 = Math.hypot(u.x, u.y);
|
|
1203
|
+
d2 = Math.hypot(v.x, v.y);
|
|
1204
|
+
}
|
|
1205
|
+
const moduleMean = (tl.moduleSize + tr.moduleSize + bl.moduleSize) / 3;
|
|
1206
|
+
const moduleSpread = Math.max(
|
|
1207
|
+
Math.abs(tl.moduleSize - moduleMean),
|
|
1208
|
+
Math.abs(tr.moduleSize - moduleMean),
|
|
1209
|
+
Math.abs(bl.moduleSize - moduleMean)
|
|
1210
|
+
) / moduleMean;
|
|
1211
|
+
if (moduleSpread > 0.5) continue;
|
|
1212
|
+
const legRatio = Math.max(d1, d2) / Math.min(d1, d2);
|
|
1213
|
+
if (legRatio > 2.1) continue;
|
|
1214
|
+
const area = Math.abs(cross(u, v));
|
|
1215
|
+
const confirmScore = tl.confirmations + tr.confirmations + bl.confirmations;
|
|
1216
|
+
const score = area / (1 + cos * 8 + moduleSpread * 5 + Math.max(0, legRatio - 1) * 2) + confirmScore * 100;
|
|
1217
|
+
triples.push({ tl, tr, bl, moduleMean, score, orthogonality: 1 - cos });
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
triples.sort((a, b) => b.score - a.score);
|
|
1224
|
+
return triples.slice(0, maxTriples);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function nearestVersionFromEstimate(value, minVersion, maxVersion) {
|
|
1228
|
+
const candidates = [];
|
|
1229
|
+
for (let version = minVersion; version <= maxVersion; version++) {
|
|
1230
|
+
const size = sizeForVersion(version);
|
|
1231
|
+
candidates.push({ version, error: Math.abs(size - value) });
|
|
1232
|
+
}
|
|
1233
|
+
candidates.sort((a, b) => a.error - b.error);
|
|
1234
|
+
return candidates;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function sampleBinaryAt(binary, width, height, x, y) {
|
|
1238
|
+
const ix = Math.round(x);
|
|
1239
|
+
const iy = Math.round(y);
|
|
1240
|
+
if (ix < 0 || iy < 0 || ix >= width || iy >= height) return null;
|
|
1241
|
+
return binary[iy * width + ix];
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function alignmentTemplateValue(pattern, r, c) {
|
|
1245
|
+
return alignmentPatternIsBlack(pattern, r, c) ? 1 : 0;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function alignmentScore(binary, width, height, center, basisU, basisV, scale, pattern) {
|
|
1249
|
+
let matches = 0;
|
|
1250
|
+
let total = 0;
|
|
1251
|
+
const radius = alignmentPatternRadius(pattern);
|
|
1252
|
+
for (let r = -radius; r <= radius; r++) {
|
|
1253
|
+
for (let c = -radius; c <= radius; c++) {
|
|
1254
|
+
const x = center.x + basisU.x * c * scale + basisV.x * r * scale;
|
|
1255
|
+
const y = center.y + basisU.y * c * scale + basisV.y * r * scale;
|
|
1256
|
+
const value = sampleBinaryAt(binary, width, height, x, y);
|
|
1257
|
+
if (value === null) continue;
|
|
1258
|
+
total++;
|
|
1259
|
+
if (value === alignmentTemplateValue(pattern, r, c)) matches++;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
return total ? matches / total : 0;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function searchAlignment(binary, width, height, triple, version) {
|
|
1266
|
+
const size = sizeForVersion(version);
|
|
1267
|
+
const separation = size - 7;
|
|
1268
|
+
const basisU = {
|
|
1269
|
+
x: (triple.tr.x - triple.tl.x) / separation,
|
|
1270
|
+
y: (triple.tr.y - triple.tl.y) / separation
|
|
1271
|
+
};
|
|
1272
|
+
const basisV = {
|
|
1273
|
+
x: (triple.bl.x - triple.tl.x) / separation,
|
|
1274
|
+
y: (triple.bl.y - triple.tl.y) / separation
|
|
1275
|
+
};
|
|
1276
|
+
const target = primaryAlignmentPatternForVersion(version);
|
|
1277
|
+
const targetX = target.col + 0.5;
|
|
1278
|
+
const targetY = target.row + 0.5;
|
|
1279
|
+
const predicted = {
|
|
1280
|
+
x: triple.tl.x + basisU.x * (targetX - 3.5) + basisV.x * (targetY - 3.5),
|
|
1281
|
+
y: triple.tl.y + basisU.y * (targetX - 3.5) + basisV.y * (targetY - 3.5)
|
|
1282
|
+
};
|
|
1283
|
+
|
|
1284
|
+
let best = { score: 0, center: predicted, scale: 1 };
|
|
1285
|
+
const offsets = [-2.5, -1.5, -0.75, 0, 0.75, 1.5, 2.5];
|
|
1286
|
+
const scales = [0.72, 0.85, 1, 1.15, 1.3];
|
|
1287
|
+
|
|
1288
|
+
for (const ou of offsets) {
|
|
1289
|
+
for (const ov of offsets) {
|
|
1290
|
+
const center = {
|
|
1291
|
+
x: predicted.x + basisU.x * ou + basisV.x * ov,
|
|
1292
|
+
y: predicted.y + basisU.y * ou + basisV.y * ov
|
|
1293
|
+
};
|
|
1294
|
+
for (const scale of scales) {
|
|
1295
|
+
const score = alignmentScore(binary, width, height, center, basisU, basisV, scale, target);
|
|
1296
|
+
if (score > best.score) best = { score, center, scale };
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
return { ...best, basisU, basisV, predicted, target };
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function projectedAlignmentScore(binary, width, height, homography, pattern) {
|
|
1305
|
+
let matches = 0;
|
|
1306
|
+
let total = 0;
|
|
1307
|
+
const radius = alignmentPatternRadius(pattern);
|
|
1308
|
+
for (let r = -radius; r <= radius; r++) {
|
|
1309
|
+
for (let c = -radius; c <= radius; c++) {
|
|
1310
|
+
const point = projectPoint(homography, pattern.col + c + 0.5, pattern.row + r + 0.5);
|
|
1311
|
+
const value = sampleBinaryAt(binary, width, height, point.x, point.y);
|
|
1312
|
+
if (value === null) continue;
|
|
1313
|
+
total++;
|
|
1314
|
+
if (value === alignmentTemplateValue(pattern, r, c)) matches++;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
return total ? matches / total : 0;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function scoreAlignmentGrid(binary, width, height, homography, version) {
|
|
1321
|
+
const patterns = alignmentPatternCentersForVersion(version);
|
|
1322
|
+
if (!patterns.length) return { score: 1, patternScores: [] };
|
|
1323
|
+
const patternScores = patterns.map((pattern) => ({
|
|
1324
|
+
row: pattern.row,
|
|
1325
|
+
col: pattern.col,
|
|
1326
|
+
size: pattern.size,
|
|
1327
|
+
primary: Boolean(pattern.primary),
|
|
1328
|
+
score: projectedAlignmentScore(binary, width, height, homography, pattern)
|
|
1329
|
+
}));
|
|
1330
|
+
const totalWeight = patternScores.reduce((sum, item) => sum + (item.primary ? 2 : 1), 0);
|
|
1331
|
+
const score = patternScores.reduce(
|
|
1332
|
+
(sum, item) => sum + item.score * (item.primary ? 2 : 1),
|
|
1333
|
+
0
|
|
1334
|
+
) / totalWeight;
|
|
1335
|
+
return { score, patternScores };
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function solveLinearSystemFloat(matrix, vector) {
|
|
1339
|
+
const n = vector.length;
|
|
1340
|
+
const a = matrix.map((row, index) => row.slice().concat([vector[index]]));
|
|
1341
|
+
for (let col = 0; col < n; col++) {
|
|
1342
|
+
let pivot = col;
|
|
1343
|
+
for (let row = col + 1; row < n; row++) {
|
|
1344
|
+
if (Math.abs(a[row][col]) > Math.abs(a[pivot][col])) pivot = row;
|
|
1345
|
+
}
|
|
1346
|
+
if (Math.abs(a[pivot][col]) < 1e-10) throw new Error("Singular homography system.");
|
|
1347
|
+
if (pivot !== col) [a[col], a[pivot]] = [a[pivot], a[col]];
|
|
1348
|
+
const divisor = a[col][col];
|
|
1349
|
+
for (let j = col; j <= n; j++) a[col][j] /= divisor;
|
|
1350
|
+
for (let row = 0; row < n; row++) {
|
|
1351
|
+
if (row === col) continue;
|
|
1352
|
+
const factor = a[row][col];
|
|
1353
|
+
if (Math.abs(factor) < 1e-12) continue;
|
|
1354
|
+
for (let j = col; j <= n; j++) a[row][j] -= factor * a[col][j];
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
return a.map((row) => row[n]);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function computeHomography(sourcePoints, destinationPoints) {
|
|
1361
|
+
// Returns transform mapping destination -> source.
|
|
1362
|
+
assert(sourcePoints.length === 4 && destinationPoints.length === 4, "Four point pairs are required.");
|
|
1363
|
+
const A = [];
|
|
1364
|
+
const b = [];
|
|
1365
|
+
for (let i = 0; i < 4; i++) {
|
|
1366
|
+
const u = destinationPoints[i].x;
|
|
1367
|
+
const v = destinationPoints[i].y;
|
|
1368
|
+
const x = sourcePoints[i].x;
|
|
1369
|
+
const y = sourcePoints[i].y;
|
|
1370
|
+
A.push([u, v, 1, 0, 0, 0, -u * x, -v * x]);
|
|
1371
|
+
b.push(x);
|
|
1372
|
+
A.push([0, 0, 0, u, v, 1, -u * y, -v * y]);
|
|
1373
|
+
b.push(y);
|
|
1374
|
+
}
|
|
1375
|
+
const h = solveLinearSystemFloat(A, b);
|
|
1376
|
+
return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1];
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function projectPoint(h, u, v) {
|
|
1380
|
+
const denominator = h[6] * u + h[7] * v + h[8];
|
|
1381
|
+
return {
|
|
1382
|
+
x: (h[0] * u + h[1] * v + h[2]) / denominator,
|
|
1383
|
+
y: (h[3] * u + h[4] * v + h[5]) / denominator
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
function detectCodeGeometry(imageData, options = {}) {
|
|
1388
|
+
assert(imageData?.data && imageData.width && imageData.height, "Valid image data is required.");
|
|
1389
|
+
const minVersion = options.minVersion ?? 1;
|
|
1390
|
+
const maxVersion = options.maxVersion ?? 40;
|
|
1391
|
+
const maxCandidates = options.maxCandidates ?? 8;
|
|
1392
|
+
const { binary, threshold } = buildBinary(imageData);
|
|
1393
|
+
const finders = detectFinderCandidates(binary, imageData.width, imageData.height);
|
|
1394
|
+
if (finders.length < 3) return [];
|
|
1395
|
+
|
|
1396
|
+
const triples = chooseFinderTriples(finders, 20);
|
|
1397
|
+
const geometries = [];
|
|
1398
|
+
|
|
1399
|
+
for (const triple of triples) {
|
|
1400
|
+
const legH = Math.hypot(triple.tr.x - triple.tl.x, triple.tr.y - triple.tl.y);
|
|
1401
|
+
const legV = Math.hypot(triple.bl.x - triple.tl.x, triple.bl.y - triple.tl.y);
|
|
1402
|
+
const estimatedSize = ((legH + legV) / 2) / triple.moduleMean + 7;
|
|
1403
|
+
const versions = nearestVersionFromEstimate(estimatedSize, minVersion, maxVersion).slice(0, 9);
|
|
1404
|
+
|
|
1405
|
+
for (const item of versions) {
|
|
1406
|
+
const version = item.version;
|
|
1407
|
+
const size = sizeForVersion(version);
|
|
1408
|
+
const alignment = searchAlignment(binary, imageData.width, imageData.height, triple, version);
|
|
1409
|
+
if (alignment.score < 0.72) continue;
|
|
1410
|
+
|
|
1411
|
+
const alignmentTarget = alignment.target;
|
|
1412
|
+
const dest = [
|
|
1413
|
+
{ x: 3.5, y: 3.5 },
|
|
1414
|
+
{ x: size - 3.5, y: 3.5 },
|
|
1415
|
+
{ x: 3.5, y: size - 3.5 },
|
|
1416
|
+
{ x: alignmentTarget.col + 0.5, y: alignmentTarget.row + 0.5 }
|
|
1417
|
+
];
|
|
1418
|
+
const src = [
|
|
1419
|
+
{ x: triple.tl.x, y: triple.tl.y },
|
|
1420
|
+
{ x: triple.tr.x, y: triple.tr.y },
|
|
1421
|
+
{ x: triple.bl.x, y: triple.bl.y },
|
|
1422
|
+
alignment.center
|
|
1423
|
+
];
|
|
1424
|
+
|
|
1425
|
+
let homography;
|
|
1426
|
+
try {
|
|
1427
|
+
homography = computeHomography(src, dest);
|
|
1428
|
+
} catch {
|
|
1429
|
+
continue;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
const alignmentGrid = scoreAlignmentGrid(binary, imageData.width, imageData.height, homography, version);
|
|
1433
|
+
if (alignmentGrid.score < 0.68) continue;
|
|
1434
|
+
|
|
1435
|
+
const versionPenalty = item.error / 4;
|
|
1436
|
+
const alignmentConfidence = 0.55 * alignment.score + 0.45 * alignmentGrid.score;
|
|
1437
|
+
const score = triple.score * (0.5 + 0.5 * alignmentConfidence) / (1 + versionPenalty * 0.08);
|
|
1438
|
+
geometries.push({
|
|
1439
|
+
version,
|
|
1440
|
+
size,
|
|
1441
|
+
sourcePoints: src,
|
|
1442
|
+
destinationPoints: dest,
|
|
1443
|
+
homography,
|
|
1444
|
+
finders: { topLeft: triple.tl, topRight: triple.tr, bottomLeft: triple.bl },
|
|
1445
|
+
alignment: {
|
|
1446
|
+
center: alignment.center,
|
|
1447
|
+
score: alignment.score,
|
|
1448
|
+
scale: alignment.scale,
|
|
1449
|
+
target: { row: alignmentTarget.row, col: alignmentTarget.col },
|
|
1450
|
+
patterns: alignmentGrid.patternScores.length,
|
|
1451
|
+
gridScore: alignmentGrid.score,
|
|
1452
|
+
patternScores: alignmentGrid.patternScores
|
|
1453
|
+
},
|
|
1454
|
+
threshold,
|
|
1455
|
+
estimatedSize,
|
|
1456
|
+
score
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// Deduplicate roughly identical geometry/version hypotheses.
|
|
1462
|
+
const deduped = [];
|
|
1463
|
+
geometries.sort((a, b) => b.score - a.score);
|
|
1464
|
+
for (const geometry of geometries) {
|
|
1465
|
+
const duplicate = deduped.some((other) =>
|
|
1466
|
+
other.version === geometry.version &&
|
|
1467
|
+
Math.hypot(
|
|
1468
|
+
other.sourcePoints[0].x - geometry.sourcePoints[0].x,
|
|
1469
|
+
other.sourcePoints[0].y - geometry.sourcePoints[0].y
|
|
1470
|
+
) < Math.max(3, geometry.finders.topLeft.moduleSize * 2)
|
|
1471
|
+
);
|
|
1472
|
+
if (!duplicate) deduped.push(geometry);
|
|
1473
|
+
if (deduped.length >= maxCandidates) break;
|
|
1474
|
+
}
|
|
1475
|
+
return deduped;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function averageProjectedSample(imageData, homography, moduleX, moduleY, radius = 0.16) {
|
|
1479
|
+
const offsets = radius > 0
|
|
1480
|
+
? [[0, 0], [-radius, 0], [radius, 0], [0, -radius], [0, radius]]
|
|
1481
|
+
: [[0, 0]];
|
|
1482
|
+
let r = 0;
|
|
1483
|
+
let g = 0;
|
|
1484
|
+
let b = 0;
|
|
1485
|
+
let count = 0;
|
|
1486
|
+
for (const [dx, dy] of offsets) {
|
|
1487
|
+
const point = projectPoint(homography, moduleX + dx, moduleY + dy);
|
|
1488
|
+
if (point.x < 0 || point.y < 0 || point.x >= imageData.width || point.y >= imageData.height) continue;
|
|
1489
|
+
const rgb = bilinearRgb(imageData, point.x, point.y);
|
|
1490
|
+
r += rgb.r;
|
|
1491
|
+
g += rgb.g;
|
|
1492
|
+
b += rgb.b;
|
|
1493
|
+
count++;
|
|
1494
|
+
}
|
|
1495
|
+
if (!count) return { r: 255, g: 255, b: 255 };
|
|
1496
|
+
return { r: r / count, g: g / count, b: b / count };
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function samplePerspectiveMatrix(imageData, homography, size, options = {}) {
|
|
1500
|
+
const rgbGrid = Array.from({ length: size }, () => new Array(size));
|
|
1501
|
+
const radius = options.sampleRadius ?? 0.16;
|
|
1502
|
+
for (let r = 0; r < size; r++) {
|
|
1503
|
+
for (let c = 0; c < size; c++) {
|
|
1504
|
+
rgbGrid[r][c] = averageProjectedSample(imageData, homography, c + 0.5, r + 0.5, radius);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
return { rgbGrid };
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function meanRgb(values) {
|
|
1511
|
+
if (!values.length) throw new Error("No calibration samples available.");
|
|
1512
|
+
return values.reduce(
|
|
1513
|
+
(sum, rgb) => ({ r: sum.r + rgb.r, g: sum.g + rgb.g, b: sum.b + rgb.b }),
|
|
1514
|
+
{ r: 0, g: 0, b: 0 }
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function averageRgb(values) {
|
|
1519
|
+
const sum = meanRgb(values);
|
|
1520
|
+
return { r: sum.r / values.length, g: sum.g / values.length, b: sum.b / values.length };
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
function pickCalibrationSamples(rgbGrid, positions, limit = 24) {
|
|
1524
|
+
if (!positions?.length) return [];
|
|
1525
|
+
const step = Math.max(1, Math.floor(positions.length / limit));
|
|
1526
|
+
const out = [];
|
|
1527
|
+
for (let i = 0; i < positions.length; i += step) {
|
|
1528
|
+
const [row, col] = positions[i];
|
|
1529
|
+
out.push(rgbGrid[row][col]);
|
|
1530
|
+
if (out.length >= limit) break;
|
|
1531
|
+
}
|
|
1532
|
+
return out;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
function sampleObservedPalette(rgbGrid, calibration) {
|
|
1536
|
+
const palette = {
|
|
1537
|
+
black: averageRgb(pickCalibrationSamples(rgbGrid, calibration.black, 36)),
|
|
1538
|
+
white: averageRgb(pickCalibrationSamples(rgbGrid, calibration.white, 36)),
|
|
1539
|
+
red: averageRgb(pickCalibrationSamples(rgbGrid, calibration.red, 12)),
|
|
1540
|
+
green: averageRgb(pickCalibrationSamples(rgbGrid, calibration.green, 12)),
|
|
1541
|
+
blue: averageRgb(pickCalibrationSamples(rgbGrid, calibration.blue, 12))
|
|
1542
|
+
};
|
|
1543
|
+
|
|
1544
|
+
// All five observed classes must remain separated enough for reliable RGBW
|
|
1545
|
+
// classification: structural black plus the four visible data states.
|
|
1546
|
+
const colors = [palette.black, palette.red, palette.green, palette.blue, palette.white];
|
|
1547
|
+
const distances = [];
|
|
1548
|
+
for (let i = 0; i < colors.length; i++) {
|
|
1549
|
+
for (let j = i + 1; j < colors.length; j++) {
|
|
1550
|
+
distances.push(Math.hypot(
|
|
1551
|
+
colors[i].r - colors[j].r,
|
|
1552
|
+
colors[i].g - colors[j].g,
|
|
1553
|
+
colors[i].b - colors[j].b
|
|
1554
|
+
));
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
if (Math.min(...distances) < 28) throw new Error("RGBW calibration references are not separable in this image.");
|
|
1558
|
+
return palette;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
function rectifyImageData(imageData, homography, size, moduleSize = 8) {
|
|
1562
|
+
const outputSize = Math.max(1, Math.round(size * moduleSize));
|
|
1563
|
+
const data = new Uint8ClampedArray(outputSize * outputSize * 4);
|
|
1564
|
+
for (let y = 0; y < outputSize; y++) {
|
|
1565
|
+
for (let x = 0; x < outputSize; x++) {
|
|
1566
|
+
const moduleX = (x + 0.5) / moduleSize;
|
|
1567
|
+
const moduleY = (y + 0.5) / moduleSize;
|
|
1568
|
+
const source = projectPoint(homography, moduleX, moduleY);
|
|
1569
|
+
const rgb = bilinearRgb(imageData, source.x, source.y);
|
|
1570
|
+
const p = (y * outputSize + x) * 4;
|
|
1571
|
+
data[p] = clamp(Math.round(rgb.r), 0, 255);
|
|
1572
|
+
data[p + 1] = clamp(Math.round(rgb.g), 0, 255);
|
|
1573
|
+
data[p + 2] = clamp(Math.round(rgb.b), 0, 255);
|
|
1574
|
+
data[p + 3] = 255;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
return { width: outputSize, height: outputSize, data };
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
function findActiveBounds(imageData, whiteThreshold = 238) {
|
|
1581
|
+
const { width, height, data } = imageData;
|
|
1582
|
+
let minX = width;
|
|
1583
|
+
let minY = height;
|
|
1584
|
+
let maxX = -1;
|
|
1585
|
+
let maxY = -1;
|
|
1586
|
+
for (let y = 0; y < height; y++) {
|
|
1587
|
+
for (let x = 0; x < width; x++) {
|
|
1588
|
+
const i = (y * width + x) * 4;
|
|
1589
|
+
const a = data[i + 3];
|
|
1590
|
+
const active = a > 16 && (data[i] < whiteThreshold || data[i + 1] < whiteThreshold || data[i + 2] < whiteThreshold);
|
|
1591
|
+
if (!active) continue;
|
|
1592
|
+
minX = Math.min(minX, x);
|
|
1593
|
+
maxX = Math.max(maxX, x);
|
|
1594
|
+
minY = Math.min(minY, y);
|
|
1595
|
+
maxY = Math.max(maxY, y);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
if (maxX < minX || maxY < minY) throw new Error("No code-like non-white area found.");
|
|
1599
|
+
return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
function sampleAverageAxis(imageData, centerX, centerY, radius) {
|
|
1603
|
+
const { width, height, data } = imageData;
|
|
1604
|
+
const minX = Math.max(0, Math.floor(centerX - radius));
|
|
1605
|
+
const maxX = Math.min(width - 1, Math.ceil(centerX + radius));
|
|
1606
|
+
const minY = Math.max(0, Math.floor(centerY - radius));
|
|
1607
|
+
const maxY = Math.min(height - 1, Math.ceil(centerY + radius));
|
|
1608
|
+
let r = 0;
|
|
1609
|
+
let g = 0;
|
|
1610
|
+
let b = 0;
|
|
1611
|
+
let count = 0;
|
|
1612
|
+
for (let y = minY; y <= maxY; y++) {
|
|
1613
|
+
for (let x = minX; x <= maxX; x++) {
|
|
1614
|
+
const i = (y * width + x) * 4;
|
|
1615
|
+
const a = data[i + 3] / 255;
|
|
1616
|
+
r += data[i] * a + 255 * (1 - a);
|
|
1617
|
+
g += data[i + 1] * a + 255 * (1 - a);
|
|
1618
|
+
b += data[i + 2] * a + 255 * (1 - a);
|
|
1619
|
+
count++;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
return count ? { r: r / count, g: g / count, b: b / count } : { r: 255, g: 255, b: 255 };
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
function sampleAxisAlignedGrid(imageData, bounds, size, radiusRatio = 0.18) {
|
|
1626
|
+
const moduleW = bounds.width / size;
|
|
1627
|
+
const moduleH = bounds.height / size;
|
|
1628
|
+
const radius = Math.max(0, Math.min(moduleW, moduleH) * radiusRatio);
|
|
1629
|
+
const rgbGrid = Array.from({ length: size }, () => new Array(size));
|
|
1630
|
+
for (let r = 0; r < size; r++) {
|
|
1631
|
+
for (let c = 0; c < size; c++) {
|
|
1632
|
+
const x = bounds.x + (c + 0.5) * moduleW;
|
|
1633
|
+
const y = bounds.y + (r + 0.5) * moduleH;
|
|
1634
|
+
rgbGrid[r][c] = sampleAverageAxis(imageData, x, y, radius);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
return { rgbGrid, moduleWidth: moduleW, moduleHeight: moduleH };
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
const visionInternals = Object.freeze({
|
|
1641
|
+
buildGray,
|
|
1642
|
+
otsuThreshold,
|
|
1643
|
+
buildBinary,
|
|
1644
|
+
finderRatioScore,
|
|
1645
|
+
detectFinderCandidates,
|
|
1646
|
+
chooseFinderTriples,
|
|
1647
|
+
searchAlignment,
|
|
1648
|
+
bilinearRgb,
|
|
1649
|
+
luminance
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
return { computeHomography, projectPoint, detectCodeGeometry, samplePerspectiveMatrix, sampleObservedPalette, rectifyImageData, findActiveBounds, sampleAxisAlignedGrid, visionInternals };
|
|
1653
|
+
})();
|
|
1654
|
+
const __qqr_quadqr_js = (function () {
|
|
1655
|
+
const { MAX_CODEWORD_SYMBOLS, rsEncode, rsDecode } = __qqr_reed_solomon_js;
|
|
1656
|
+
const { alignmentPatternCentersForVersion, alignmentPatternIsBlack, alignmentPatternRadius, sizeForVersion, versionFromSize } = __qqr_geometry_js;
|
|
1657
|
+
const { detectCodeGeometry, samplePerspectiveMatrix, rectifyImageData, sampleObservedPalette, findActiveBounds, sampleAxisAlignedGrid } = __qqr_vision_js;
|
|
1658
|
+
const { decryptSecurePayload, encryptSecurePayload, inspectSecureEnvelope, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex } = __qqr_security_js;
|
|
1659
|
+
|
|
1660
|
+
const FORMAT_VERSION = 5;
|
|
1661
|
+
const MIN_VERSION = 1;
|
|
1662
|
+
const MAX_VERSION = 40;
|
|
1663
|
+
const DEFAULT_ECC_LEVEL = "M";
|
|
1664
|
+
|
|
1665
|
+
// Data cells are the four values 0..3. Structural white deliberately shares
|
|
1666
|
+
// value 3 with data-white because both render and scan identically.
|
|
1667
|
+
const CELL = Object.freeze({
|
|
1668
|
+
BLACK: -1,
|
|
1669
|
+
RED: 0,
|
|
1670
|
+
GREEN: 1,
|
|
1671
|
+
BLUE: 2,
|
|
1672
|
+
WHITE: 3
|
|
1673
|
+
});
|
|
1674
|
+
|
|
1675
|
+
const DEFAULT_PALETTE = Object.freeze({
|
|
1676
|
+
black: "#000000",
|
|
1677
|
+
white: "#ffffff",
|
|
1678
|
+
red: "#ef233c",
|
|
1679
|
+
green: "#16a34a",
|
|
1680
|
+
blue: "#2563eb"
|
|
1681
|
+
});
|
|
1682
|
+
|
|
1683
|
+
const RENDER_STYLES = Object.freeze({
|
|
1684
|
+
CLASSIC: "classic",
|
|
1685
|
+
DEPTH: "depth",
|
|
1686
|
+
SOFT: "soft",
|
|
1687
|
+
INSET: "inset"
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1690
|
+
const ECC_LEVELS = Object.freeze({
|
|
1691
|
+
L: Object.freeze({ id: 0, paritySymbols: 12, correctableSymbolsPerBlock: 6 }),
|
|
1692
|
+
M: Object.freeze({ id: 1, paritySymbols: 24, correctableSymbolsPerBlock: 12 }),
|
|
1693
|
+
Q: Object.freeze({ id: 2, paritySymbols: 36, correctableSymbolsPerBlock: 18 }),
|
|
1694
|
+
H: Object.freeze({ id: 3, paritySymbols: 48, correctableSymbolsPerBlock: 24 })
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1697
|
+
const ECC_BY_ID = Object.freeze(
|
|
1698
|
+
Object.fromEntries(Object.entries(ECC_LEVELS).map(([name, info]) => [info.id, name]))
|
|
1699
|
+
);
|
|
1700
|
+
|
|
1701
|
+
const MAGIC = new Uint8Array([0x51, 0x51, 0x52, 0x57]); // QQRW (QuadQR RGBW)
|
|
1702
|
+
const HEADER_BYTES = 10;
|
|
1703
|
+
const HEADER_RS_PARITY = 8;
|
|
1704
|
+
const HEADER_CODEWORD_BYTES = HEADER_BYTES + HEADER_RS_PARITY;
|
|
1705
|
+
const CELLS_PER_BYTE = 4;
|
|
1706
|
+
const HEADER_CODEWORD_CELLS = HEADER_CODEWORD_BYTES * CELLS_PER_BYTE;
|
|
1707
|
+
|
|
1708
|
+
// Version 1 is a deliberately compact small-symbol profile. A 21x21 matrix
|
|
1709
|
+
// cannot afford the normal 18-byte protected header plus a 24-byte M body
|
|
1710
|
+
// parity block. The matrix size already identifies v1, so it uses a compact
|
|
1711
|
+
// 4-byte logical header protected by 4 RS parity bytes and size-appropriate
|
|
1712
|
+
// body parity. Larger versions keep the normal framing unchanged.
|
|
1713
|
+
const COMPACT_VERSION = 1;
|
|
1714
|
+
const COMPACT_HEADER_MAGIC = 0xc3;
|
|
1715
|
+
const COMPACT_HEADER_BYTES = 4;
|
|
1716
|
+
const COMPACT_HEADER_RS_PARITY = 4;
|
|
1717
|
+
const COMPACT_HEADER_CODEWORD_BYTES = COMPACT_HEADER_BYTES + COMPACT_HEADER_RS_PARITY;
|
|
1718
|
+
const COMPACT_HEADER_CODEWORD_CELLS = COMPACT_HEADER_CODEWORD_BYTES * CELLS_PER_BYTE;
|
|
1719
|
+
const COMPACT_ECC_LEVELS = Object.freeze({
|
|
1720
|
+
L: Object.freeze({ paritySymbols: 4, correctableSymbolsPerBlock: 2 }),
|
|
1721
|
+
M: Object.freeze({ paritySymbols: 8, correctableSymbolsPerBlock: 4 }),
|
|
1722
|
+
Q: Object.freeze({ paritySymbols: 12, correctableSymbolsPerBlock: 6 }),
|
|
1723
|
+
H: Object.freeze({ paritySymbols: 16, correctableSymbolsPerBlock: 8 })
|
|
1724
|
+
});
|
|
1725
|
+
|
|
1726
|
+
const CRC_BYTES = 4;
|
|
1727
|
+
const TEXT_FLAG = 1;
|
|
1728
|
+
const SECURE_FLAG = 1 << 3;
|
|
1729
|
+
const ECC_SHIFT = 1;
|
|
1730
|
+
const ECC_MASK = 0b00000110;
|
|
1731
|
+
|
|
1732
|
+
const textEncoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : null;
|
|
1733
|
+
const textDecoder = typeof TextDecoder !== "undefined"
|
|
1734
|
+
? new TextDecoder("utf-8", { fatal: false })
|
|
1735
|
+
: null;
|
|
1736
|
+
|
|
1737
|
+
function assert(condition, message) {
|
|
1738
|
+
if (!condition) throw new Error(message);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
function getTextEncoder() {
|
|
1742
|
+
assert(textEncoder, "TextEncoder is required in this environment.");
|
|
1743
|
+
return textEncoder;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
function getTextDecoder() {
|
|
1747
|
+
assert(textDecoder, "TextDecoder is required in this environment.");
|
|
1748
|
+
return textDecoder;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
function cloneMatrix(matrix) {
|
|
1752
|
+
return matrix.map((row) => row.slice());
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
function make2D(size, valueFactory) {
|
|
1756
|
+
return Array.from({ length: size }, (_, r) =>
|
|
1757
|
+
Array.from({ length: size }, (_, c) =>
|
|
1758
|
+
typeof valueFactory === "function" ? valueFactory(r, c) : valueFactory
|
|
1759
|
+
)
|
|
1760
|
+
);
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
function validateVersion(version) {
|
|
1764
|
+
assert(Number.isInteger(version), "Version must be an integer.");
|
|
1765
|
+
assert(
|
|
1766
|
+
version >= MIN_VERSION && version <= MAX_VERSION,
|
|
1767
|
+
`Version must be ${MIN_VERSION}..${MAX_VERSION}.`
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
function normalizeEccLevel(level = DEFAULT_ECC_LEVEL) {
|
|
1772
|
+
const value = String(level).toUpperCase();
|
|
1773
|
+
assert(ECC_LEVELS[value], `ECC level must be one of ${Object.keys(ECC_LEVELS).join(", ")}.`);
|
|
1774
|
+
return value;
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
function concatBytes(...arrays) {
|
|
1778
|
+
const total = arrays.reduce((sum, item) => sum + item.length, 0);
|
|
1779
|
+
const out = new Uint8Array(total);
|
|
1780
|
+
let offset = 0;
|
|
1781
|
+
for (const item of arrays) {
|
|
1782
|
+
out.set(item, offset);
|
|
1783
|
+
offset += item.length;
|
|
1784
|
+
}
|
|
1785
|
+
return out;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
function u32be(value) {
|
|
1789
|
+
const v = value >>> 0;
|
|
1790
|
+
return new Uint8Array([
|
|
1791
|
+
(v >>> 24) & 0xff,
|
|
1792
|
+
(v >>> 16) & 0xff,
|
|
1793
|
+
(v >>> 8) & 0xff,
|
|
1794
|
+
v & 0xff
|
|
1795
|
+
]);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function readU32be(bytes, offset = 0) {
|
|
1799
|
+
return (
|
|
1800
|
+
((bytes[offset] << 24) >>> 0) |
|
|
1801
|
+
(bytes[offset + 1] << 16) |
|
|
1802
|
+
(bytes[offset + 2] << 8) |
|
|
1803
|
+
bytes[offset + 3]
|
|
1804
|
+
) >>> 0;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
let CRC_TABLE = null;
|
|
1808
|
+
let CRC32_ACCELERATOR = null;
|
|
1809
|
+
|
|
1810
|
+
function installCrc32Accelerator(accelerator = null) {
|
|
1811
|
+
assert(
|
|
1812
|
+
accelerator == null || typeof accelerator === "function",
|
|
1813
|
+
"CRC-32 accelerator must be a function or null."
|
|
1814
|
+
);
|
|
1815
|
+
CRC32_ACCELERATOR = accelerator;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
function getCrcTable() {
|
|
1819
|
+
if (CRC_TABLE) return CRC_TABLE;
|
|
1820
|
+
CRC_TABLE = new Uint32Array(256);
|
|
1821
|
+
for (let n = 0; n < 256; n++) {
|
|
1822
|
+
let c = n;
|
|
1823
|
+
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
|
1824
|
+
CRC_TABLE[n] = c >>> 0;
|
|
1825
|
+
}
|
|
1826
|
+
return CRC_TABLE;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
function crc32(bytes) {
|
|
1830
|
+
if (CRC32_ACCELERATOR) return CRC32_ACCELERATOR(bytes) >>> 0;
|
|
1831
|
+
const table = getCrcTable();
|
|
1832
|
+
let crc = 0xffffffff;
|
|
1833
|
+
for (const byte of bytes) crc = table[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
1834
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
function bytesToCells(bytes) {
|
|
1838
|
+
const out = new Array(bytes.length * CELLS_PER_BYTE);
|
|
1839
|
+
let cursor = 0;
|
|
1840
|
+
for (const byte of bytes) {
|
|
1841
|
+
out[cursor++] = (byte >>> 6) & 0b11;
|
|
1842
|
+
out[cursor++] = (byte >>> 4) & 0b11;
|
|
1843
|
+
out[cursor++] = (byte >>> 2) & 0b11;
|
|
1844
|
+
out[cursor++] = byte & 0b11;
|
|
1845
|
+
}
|
|
1846
|
+
return out;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYTE)) {
|
|
1850
|
+
assert(cells.length >= byteCount * CELLS_PER_BYTE, "Not enough RGBW cells to rebuild bytes.");
|
|
1851
|
+
const out = new Uint8Array(byteCount);
|
|
1852
|
+
for (let i = 0; i < byteCount; i++) {
|
|
1853
|
+
const offset = i * CELLS_PER_BYTE;
|
|
1854
|
+
const a = cells[offset];
|
|
1855
|
+
const b = cells[offset + 1];
|
|
1856
|
+
const c = cells[offset + 2];
|
|
1857
|
+
const d = cells[offset + 3];
|
|
1858
|
+
for (const value of [a, b, c, d]) {
|
|
1859
|
+
assert(Number.isInteger(value) && value >= 0 && value <= 3, "Invalid RGBW data cell.");
|
|
1860
|
+
}
|
|
1861
|
+
out[i] = (a << 6) | (b << 4) | (c << 2) | d;
|
|
1862
|
+
}
|
|
1863
|
+
return out;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
function flagsFor(text, eccLevel, secure = false) {
|
|
1867
|
+
return (text ? TEXT_FLAG : 0) |
|
|
1868
|
+
(secure ? SECURE_FLAG : 0) |
|
|
1869
|
+
((ECC_LEVELS[eccLevel].id << ECC_SHIFT) & ECC_MASK);
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
function eccFromFlags(flags) {
|
|
1873
|
+
const id = (flags & ECC_MASK) >> ECC_SHIFT;
|
|
1874
|
+
const name = ECC_BY_ID[id];
|
|
1875
|
+
if (!name) throw new Error(`Unknown ECC profile id ${id}.`);
|
|
1876
|
+
return name;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
function getHeaderPlan(version) {
|
|
1880
|
+
if (version === COMPACT_VERSION) {
|
|
1881
|
+
return {
|
|
1882
|
+
compact: true,
|
|
1883
|
+
headerBytes: COMPACT_HEADER_BYTES,
|
|
1884
|
+
paritySymbols: COMPACT_HEADER_RS_PARITY,
|
|
1885
|
+
codewordBytes: COMPACT_HEADER_CODEWORD_BYTES,
|
|
1886
|
+
codewordCells: COMPACT_HEADER_CODEWORD_CELLS,
|
|
1887
|
+
correctableSymbols: COMPACT_HEADER_RS_PARITY / 2
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
return {
|
|
1891
|
+
compact: false,
|
|
1892
|
+
headerBytes: HEADER_BYTES,
|
|
1893
|
+
paritySymbols: HEADER_RS_PARITY,
|
|
1894
|
+
codewordBytes: HEADER_CODEWORD_BYTES,
|
|
1895
|
+
codewordCells: HEADER_CODEWORD_CELLS,
|
|
1896
|
+
correctableSymbols: HEADER_RS_PARITY / 2
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
function getEffectiveEcc(version, eccLevel) {
|
|
1901
|
+
const normalized = normalizeEccLevel(eccLevel);
|
|
1902
|
+
return version === COMPACT_VERSION ? COMPACT_ECC_LEVELS[normalized] : ECC_LEVELS[normalized];
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
function makeHeader(payloadLength, flags, version) {
|
|
1906
|
+
assert(payloadLength >= 0 && payloadLength <= 0xffffffff, "Payload is too large.");
|
|
1907
|
+
|
|
1908
|
+
if (version === COMPACT_VERSION) {
|
|
1909
|
+
assert(payloadLength <= 0xff, "Version 1 compact header supports payloads up to 255 bytes.");
|
|
1910
|
+
const header = new Uint8Array(COMPACT_HEADER_BYTES);
|
|
1911
|
+
header[0] = COMPACT_HEADER_MAGIC;
|
|
1912
|
+
header[1] = flags & 0xff;
|
|
1913
|
+
header[2] = payloadLength & 0xff;
|
|
1914
|
+
header[3] = (payloadLength ^ 0xff) & 0xff;
|
|
1915
|
+
return header;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
const header = new Uint8Array(HEADER_BYTES);
|
|
1919
|
+
header.set(MAGIC, 0);
|
|
1920
|
+
header[4] = FORMAT_VERSION;
|
|
1921
|
+
header[5] = flags & 0xff;
|
|
1922
|
+
header.set(u32be(payloadLength), 6);
|
|
1923
|
+
return header;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
function magicMatches(header) {
|
|
1927
|
+
return MAGIC.every((value, index) => header[index] === value);
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
function parseHeader(header, version) {
|
|
1931
|
+
if (version === COMPACT_VERSION) {
|
|
1932
|
+
if (
|
|
1933
|
+
header.length !== COMPACT_HEADER_BYTES ||
|
|
1934
|
+
header[0] !== COMPACT_HEADER_MAGIC ||
|
|
1935
|
+
header[3] !== ((header[2] ^ 0xff) & 0xff)
|
|
1936
|
+
) {
|
|
1937
|
+
throw new Error("QuadQR compact header mismatch.");
|
|
1938
|
+
}
|
|
1939
|
+
return { flags: header[1], payloadLength: header[2] };
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
if (!magicMatches(header) || header[4] !== FORMAT_VERSION) {
|
|
1943
|
+
throw new Error("QuadQR magic/version mismatch.");
|
|
1944
|
+
}
|
|
1945
|
+
return { flags: header[5], payloadLength: readU32be(header, 6) };
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
function createLayout(version) {
|
|
1949
|
+
validateVersion(version);
|
|
1950
|
+
const size = sizeForVersion(version);
|
|
1951
|
+
const matrix = make2D(size, CELL.WHITE);
|
|
1952
|
+
const reserved = make2D(size, false);
|
|
1953
|
+
const calibration = { red: [], green: [], blue: [], black: [], white: [] };
|
|
1954
|
+
|
|
1955
|
+
function reserveAndSet(row, col, value, calibrationKey = null) {
|
|
1956
|
+
if (row < 0 || col < 0 || row >= size || col >= size) return;
|
|
1957
|
+
reserved[row][col] = true;
|
|
1958
|
+
matrix[row][col] = value;
|
|
1959
|
+
if (calibrationKey) calibration[calibrationKey].push([row, col]);
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
function drawFinder(top, left) {
|
|
1963
|
+
for (let r = -1; r <= 7; r++) {
|
|
1964
|
+
for (let c = -1; c <= 7; c++) {
|
|
1965
|
+
const rr = top + r;
|
|
1966
|
+
const cc = left + c;
|
|
1967
|
+
if (rr < 0 || cc < 0 || rr >= size || cc >= size) continue;
|
|
1968
|
+
if (r === -1 || c === -1 || r === 7 || c === 7) {
|
|
1969
|
+
reserveAndSet(rr, cc, CELL.WHITE, "white");
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
for (let r = 0; r < 7; r++) {
|
|
1975
|
+
for (let c = 0; c < 7; c++) {
|
|
1976
|
+
const outer = r === 0 || c === 0 || r === 6 || c === 6;
|
|
1977
|
+
const center = r >= 2 && r <= 4 && c >= 2 && c <= 4;
|
|
1978
|
+
const black = outer || center;
|
|
1979
|
+
reserveAndSet(top + r, left + c, black ? CELL.BLACK : CELL.WHITE, black ? "black" : "white");
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
function drawAlignmentPattern(definition) {
|
|
1985
|
+
const { row: centerRow, col: centerCol, separator = false } = definition;
|
|
1986
|
+
const radius = alignmentPatternRadius(definition);
|
|
1987
|
+
|
|
1988
|
+
if (separator) {
|
|
1989
|
+
const separatorRadius = radius + 1;
|
|
1990
|
+
for (let r = -separatorRadius; r <= separatorRadius; r++) {
|
|
1991
|
+
for (let c = -separatorRadius; c <= separatorRadius; c++) {
|
|
1992
|
+
if (Math.abs(r) === separatorRadius || Math.abs(c) === separatorRadius) {
|
|
1993
|
+
reserveAndSet(centerRow + r, centerCol + c, CELL.WHITE, "white");
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
for (let r = -radius; r <= radius; r++) {
|
|
2000
|
+
for (let c = -radius; c <= radius; c++) {
|
|
2001
|
+
const black = alignmentPatternIsBlack(definition, r, c);
|
|
2002
|
+
reserveAndSet(
|
|
2003
|
+
centerRow + r,
|
|
2004
|
+
centerCol + c,
|
|
2005
|
+
black ? CELL.BLACK : CELL.WHITE,
|
|
2006
|
+
black ? "black" : "white"
|
|
2007
|
+
);
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
return {
|
|
2012
|
+
row: centerRow,
|
|
2013
|
+
col: centerCol,
|
|
2014
|
+
size: definition.size,
|
|
2015
|
+
primary: Boolean(definition.primary),
|
|
2016
|
+
center: [centerCol + 0.5, centerRow + 0.5],
|
|
2017
|
+
bootstrap: Boolean(definition.bootstrap),
|
|
2018
|
+
separator: Boolean(separator)
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
function isAreaFree(row0, col0, height, width) {
|
|
2023
|
+
if (row0 < 0 || col0 < 0 || row0 + height > size || col0 + width > size) return false;
|
|
2024
|
+
for (let r = row0; r < row0 + height; r++) {
|
|
2025
|
+
for (let c = col0; c < col0 + width; c++) {
|
|
2026
|
+
if (reserved[r][c]) return false;
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
return true;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
function findCalibrationStripOrigin() {
|
|
2033
|
+
const preferred = { row: size - 6, col: size - 13 };
|
|
2034
|
+
if (isAreaFree(preferred.row, preferred.col, 2, 6)) return preferred;
|
|
2035
|
+
|
|
2036
|
+
// Keep the swatches away from the three large finders and timing axes, but
|
|
2037
|
+
// allow their location to move when a version's alignment grid occupies
|
|
2038
|
+
// the old bottom-right calibration area.
|
|
2039
|
+
for (let row = size - 8; row >= 8; row--) {
|
|
2040
|
+
for (let col = size - 8; col >= 8; col--) {
|
|
2041
|
+
if (isAreaFree(row, col, 2, 6)) return { row, col };
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
throw new Error(`Unable to reserve QuadQR calibration strip for version ${version}.`);
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
function drawCalibrationStrip() {
|
|
2049
|
+
const origin = findCalibrationStripOrigin();
|
|
2050
|
+
const entries = [
|
|
2051
|
+
{ key: "red", cell: CELL.RED, offset: 0 },
|
|
2052
|
+
{ key: "green", cell: CELL.GREEN, offset: 2 },
|
|
2053
|
+
{ key: "blue", cell: CELL.BLUE, offset: 4 }
|
|
2054
|
+
];
|
|
2055
|
+
|
|
2056
|
+
for (const entry of entries) {
|
|
2057
|
+
for (const row of [origin.row, origin.row + 1]) {
|
|
2058
|
+
for (let dc = 0; dc < 2; dc++) {
|
|
2059
|
+
reserveAndSet(row, origin.col + entry.offset + dc, entry.cell, entry.key);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
return { top: origin.row, left: origin.col, width: 6, height: 2 };
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
drawFinder(0, 0);
|
|
2067
|
+
drawFinder(0, size - 7);
|
|
2068
|
+
drawFinder(size - 7, 0);
|
|
2069
|
+
|
|
2070
|
+
for (let col = 8; col < size - 8; col++) {
|
|
2071
|
+
reserveAndSet(6, col, col % 2 === 0 ? CELL.BLACK : CELL.WHITE);
|
|
2072
|
+
}
|
|
2073
|
+
for (let row = 8; row < size - 8; row++) {
|
|
2074
|
+
reserveAndSet(row, 6, row % 2 === 0 ? CELL.BLACK : CELL.WHITE);
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
const alignments = alignmentPatternCentersForVersion(version).map(drawAlignmentPattern);
|
|
2078
|
+
const alignment = alignments[alignments.length - 1];
|
|
2079
|
+
const calibrationStrip = drawCalibrationStrip();
|
|
2080
|
+
|
|
2081
|
+
const dataPositions = [];
|
|
2082
|
+
let upward = true;
|
|
2083
|
+
let col = size - 1;
|
|
2084
|
+
while (col >= 0) {
|
|
2085
|
+
if (col === 6) col--;
|
|
2086
|
+
for (let i = 0; i < size; i++) {
|
|
2087
|
+
const row = upward ? size - 1 - i : i;
|
|
2088
|
+
for (let dx = 0; dx < 2; dx++) {
|
|
2089
|
+
const c = col - dx;
|
|
2090
|
+
if (c < 0) continue;
|
|
2091
|
+
if (!reserved[row][c]) dataPositions.push([row, c]);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
upward = !upward;
|
|
2095
|
+
col -= 2;
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
return {
|
|
2099
|
+
version,
|
|
2100
|
+
size,
|
|
2101
|
+
matrix,
|
|
2102
|
+
reserved,
|
|
2103
|
+
dataPositions,
|
|
2104
|
+
calibration,
|
|
2105
|
+
calibrationStrip,
|
|
2106
|
+
alignment,
|
|
2107
|
+
alignments
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
function maskValue(row, col, maskId) {
|
|
2112
|
+
switch (maskId) {
|
|
2113
|
+
case 0: return (row + col) & 3;
|
|
2114
|
+
case 1: return (2 * row + col) & 3;
|
|
2115
|
+
case 2: return (row + 2 * col) & 3;
|
|
2116
|
+
case 3: return (row * col + row + col) & 3;
|
|
2117
|
+
default: throw new Error(`Unknown mask ${maskId}.`);
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
function makePaddingCells(count, seed) {
|
|
2122
|
+
let state = (seed >>> 0) || 0x6d2b79f5;
|
|
2123
|
+
const out = new Array(count);
|
|
2124
|
+
for (let i = 0; i < count; i++) {
|
|
2125
|
+
state ^= state << 13;
|
|
2126
|
+
state ^= state >>> 17;
|
|
2127
|
+
state ^= state << 5;
|
|
2128
|
+
state >>>= 0;
|
|
2129
|
+
out[i] = state & 3;
|
|
2130
|
+
}
|
|
2131
|
+
return out;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
const SPECTRAL_PERMUTATION_CACHE = new Map();
|
|
2135
|
+
|
|
2136
|
+
function spectralPermutation(length, version) {
|
|
2137
|
+
const cacheKey = `${version}:${length}`;
|
|
2138
|
+
const cached = SPECTRAL_PERMUTATION_CACHE.get(cacheKey);
|
|
2139
|
+
if (cached) return cached;
|
|
2140
|
+
|
|
2141
|
+
const permutation = Array.from({ length }, (_, index) => index);
|
|
2142
|
+
let state = (0x9e3779b9 ^ Math.imul(version + 1, 0x85ebca6b) ^ Math.imul(length + 17, 0xc2b2ae35)) >>> 0;
|
|
2143
|
+
|
|
2144
|
+
function nextRandom() {
|
|
2145
|
+
state ^= state << 13;
|
|
2146
|
+
state ^= state >>> 17;
|
|
2147
|
+
state ^= state << 5;
|
|
2148
|
+
state >>>= 0;
|
|
2149
|
+
return state;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// Deterministic Fisher-Yates shuffle. The mapping costs no cells and makes
|
|
2153
|
+
// neighboring logical symbols land at widely separated physical modules.
|
|
2154
|
+
for (let index = length - 1; index > 0; index--) {
|
|
2155
|
+
const swapIndex = nextRandom() % (index + 1);
|
|
2156
|
+
[permutation[index], permutation[swapIndex]] = [permutation[swapIndex], permutation[index]];
|
|
2157
|
+
}
|
|
2158
|
+
SPECTRAL_PERMUTATION_CACHE.set(cacheKey, permutation);
|
|
2159
|
+
return permutation;
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
function applyData(layout, rawCells, maskId, spectralInterleaving = true) {
|
|
2163
|
+
const matrix = cloneMatrix(layout.matrix);
|
|
2164
|
+
const permutation = spectralInterleaving
|
|
2165
|
+
? spectralPermutation(layout.dataPositions.length, layout.version)
|
|
2166
|
+
: null;
|
|
2167
|
+
|
|
2168
|
+
for (let logicalIndex = 0; logicalIndex < layout.dataPositions.length; logicalIndex++) {
|
|
2169
|
+
const physicalIndex = permutation ? permutation[logicalIndex] : logicalIndex;
|
|
2170
|
+
const [row, col] = layout.dataPositions[physicalIndex];
|
|
2171
|
+
matrix[row][col] = rawCells[logicalIndex] ^ maskValue(row, col, maskId);
|
|
2172
|
+
}
|
|
2173
|
+
return matrix;
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
function quaternaryPenalty(matrix, reserved) {
|
|
2177
|
+
const size = matrix.length;
|
|
2178
|
+
let penalty = 0;
|
|
2179
|
+
const counts = [0, 0, 0, 0];
|
|
2180
|
+
let dataCount = 0;
|
|
2181
|
+
|
|
2182
|
+
for (let r = 0; r < size; r++) {
|
|
2183
|
+
let previous = null;
|
|
2184
|
+
let run = 0;
|
|
2185
|
+
for (let c = 0; c < size; c++) {
|
|
2186
|
+
if (reserved[r][c]) {
|
|
2187
|
+
previous = null;
|
|
2188
|
+
run = 0;
|
|
2189
|
+
continue;
|
|
2190
|
+
}
|
|
2191
|
+
const value = matrix[r][c];
|
|
2192
|
+
counts[value]++;
|
|
2193
|
+
dataCount++;
|
|
2194
|
+
if (value === previous) {
|
|
2195
|
+
run++;
|
|
2196
|
+
if (run >= 4) penalty += 2;
|
|
2197
|
+
} else {
|
|
2198
|
+
previous = value;
|
|
2199
|
+
run = 1;
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
for (let c = 0; c < size; c++) {
|
|
2205
|
+
let previous = null;
|
|
2206
|
+
let run = 0;
|
|
2207
|
+
for (let r = 0; r < size; r++) {
|
|
2208
|
+
if (reserved[r][c]) {
|
|
2209
|
+
previous = null;
|
|
2210
|
+
run = 0;
|
|
2211
|
+
continue;
|
|
2212
|
+
}
|
|
2213
|
+
const value = matrix[r][c];
|
|
2214
|
+
if (value === previous) {
|
|
2215
|
+
run++;
|
|
2216
|
+
if (run >= 4) penalty += 2;
|
|
2217
|
+
} else {
|
|
2218
|
+
previous = value;
|
|
2219
|
+
run = 1;
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
if (dataCount > 0) {
|
|
2225
|
+
const ideal = dataCount / 4;
|
|
2226
|
+
penalty += counts.reduce((sum, count) => sum + Math.abs(count - ideal), 0) / 2;
|
|
2227
|
+
}
|
|
2228
|
+
return penalty;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
function interleaveBlocks(blocks) {
|
|
2232
|
+
const maxLength = Math.max(...blocks.map((block) => block.length), 0);
|
|
2233
|
+
const out = [];
|
|
2234
|
+
for (let index = 0; index < maxLength; index++) {
|
|
2235
|
+
for (const block of blocks) {
|
|
2236
|
+
if (index < block.length) out.push(block[index]);
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
return out;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
function deinterleaveBlocks(stream, blockLengths) {
|
|
2243
|
+
const blocks = blockLengths.map((length) => new Array(length));
|
|
2244
|
+
let cursor = 0;
|
|
2245
|
+
const maxLength = Math.max(...blockLengths, 0);
|
|
2246
|
+
for (let index = 0; index < maxLength; index++) {
|
|
2247
|
+
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
|
|
2248
|
+
if (index < blockLengths[blockIndex]) blocks[blockIndex][index] = stream[cursor++];
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
assert(cursor === stream.length, "Interleaved RS symbol length mismatch.");
|
|
2252
|
+
return blocks;
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
|
|
2256
|
+
const ecc = getEffectiveEcc(version, eccLevel);
|
|
2257
|
+
const bodyByteCount = payloadLength + CRC_BYTES;
|
|
2258
|
+
const dataSymbols = bodyByteCount;
|
|
2259
|
+
const maxDataPerBlock = MAX_CODEWORD_SYMBOLS - ecc.paritySymbols;
|
|
2260
|
+
const dataBlockLengths = [];
|
|
2261
|
+
let remaining = dataSymbols;
|
|
2262
|
+
while (remaining > 0) {
|
|
2263
|
+
const length = Math.min(maxDataPerBlock, remaining);
|
|
2264
|
+
dataBlockLengths.push(length);
|
|
2265
|
+
remaining -= length;
|
|
2266
|
+
}
|
|
2267
|
+
const codewordBlockLengths = dataBlockLengths.map((length) => length + ecc.paritySymbols);
|
|
2268
|
+
const encodedSymbols = codewordBlockLengths.reduce((sum, value) => sum + value, 0);
|
|
2269
|
+
return {
|
|
2270
|
+
bodyByteCount,
|
|
2271
|
+
dataSymbols,
|
|
2272
|
+
paritySymbols: ecc.paritySymbols,
|
|
2273
|
+
correctableSymbolsPerBlock: ecc.correctableSymbolsPerBlock,
|
|
2274
|
+
dataBlockLengths,
|
|
2275
|
+
codewordBlockLengths,
|
|
2276
|
+
encodedSymbols,
|
|
2277
|
+
encodedCells: encodedSymbols * CELLS_PER_BYTE
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
function streamCellCount(payloadLength, eccLevel, version) {
|
|
2282
|
+
return getHeaderPlan(version).codewordCells + getBodyRsPlan(payloadLength, eccLevel, version).encodedCells;
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
function streamFitsLayout(layout, eccLevel, payloadLength, version) {
|
|
2286
|
+
return streamCellCount(payloadLength, eccLevel, version) <= layout.dataPositions.length;
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
function getCapacityForLayout(layout, eccLevel, version) {
|
|
2290
|
+
if (!streamFitsLayout(layout, eccLevel, 0, version)) return 0;
|
|
2291
|
+
let low = 0;
|
|
2292
|
+
let high = Math.floor(layout.dataPositions.length / CELLS_PER_BYTE);
|
|
2293
|
+
while (low < high) {
|
|
2294
|
+
const mid = Math.ceil((low + high) / 2);
|
|
2295
|
+
if (streamFitsLayout(layout, eccLevel, mid, version)) low = mid;
|
|
2296
|
+
else high = mid - 1;
|
|
2297
|
+
}
|
|
2298
|
+
return low;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
function getVersionInfo(version, options = {}) {
|
|
2302
|
+
validateVersion(version);
|
|
2303
|
+
const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
|
|
2304
|
+
const layout = createLayout(version);
|
|
2305
|
+
const headerPlan = getHeaderPlan(version);
|
|
2306
|
+
const effectiveEcc = getEffectiveEcc(version, eccLevel);
|
|
2307
|
+
return {
|
|
2308
|
+
version,
|
|
2309
|
+
formatVersion: FORMAT_VERSION,
|
|
2310
|
+
eccLevel,
|
|
2311
|
+
size: layout.size,
|
|
2312
|
+
dataCells: layout.dataPositions.length,
|
|
2313
|
+
theoreticalBits: layout.dataPositions.length * 2,
|
|
2314
|
+
capacityBytes: getCapacityForLayout(layout, eccLevel, version),
|
|
2315
|
+
headerCells: headerPlan.codewordCells,
|
|
2316
|
+
headerBytes: headerPlan.headerBytes,
|
|
2317
|
+
headerParitySymbols: headerPlan.paritySymbols,
|
|
2318
|
+
bodyParitySymbols: effectiveEcc.paritySymbols,
|
|
2319
|
+
correctableHeaderSymbols: headerPlan.correctableSymbols,
|
|
2320
|
+
correctableSymbolsPerBlock: effectiveEcc.correctableSymbolsPerBlock,
|
|
2321
|
+
compactSmallSymbol: version === COMPACT_VERSION,
|
|
2322
|
+
calibrationCells: 12,
|
|
2323
|
+
hasAlignmentMarker: true,
|
|
2324
|
+
alignmentPatterns: layout.alignments.length,
|
|
2325
|
+
alignmentCenters: layout.alignments.map(({ row, col }) => [row, col]),
|
|
2326
|
+
bitsPerDataCell: 2,
|
|
2327
|
+
colors: 4,
|
|
2328
|
+
spectralInterleaving: true,
|
|
2329
|
+
confidenceAwareEcc: true
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
function chooseVersion(payloadLength, options = {}) {
|
|
2334
|
+
const requested = options.version ?? "auto";
|
|
2335
|
+
const minVersion = options.minVersion ?? MIN_VERSION;
|
|
2336
|
+
const maxVersion = options.maxVersion ?? MAX_VERSION;
|
|
2337
|
+
const ecc = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
|
|
2338
|
+
|
|
2339
|
+
validateVersion(minVersion);
|
|
2340
|
+
validateVersion(maxVersion);
|
|
2341
|
+
assert(minVersion <= maxVersion, "minVersion must be <= maxVersion.");
|
|
2342
|
+
|
|
2343
|
+
if (requested !== "auto") {
|
|
2344
|
+
validateVersion(requested);
|
|
2345
|
+
assert(requested >= minVersion && requested <= maxVersion, "Requested version is outside selected bounds.");
|
|
2346
|
+
const info = getVersionInfo(requested, { ecc });
|
|
2347
|
+
const layout = createLayout(requested);
|
|
2348
|
+
assert(
|
|
2349
|
+
payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested),
|
|
2350
|
+
`Payload does not fit version ${requested} with ${ecc} ECC. Maximum is ${info.capacityBytes} bytes.`
|
|
2351
|
+
);
|
|
2352
|
+
return requested;
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
for (let version = minVersion; version <= maxVersion; version++) {
|
|
2356
|
+
const layout = createLayout(version);
|
|
2357
|
+
if (streamFitsLayout(layout, ecc, payloadLength, version)) return version;
|
|
2358
|
+
}
|
|
2359
|
+
throw new Error(`Payload is too large for versions ${minVersion}..${maxVersion}.`);
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
function encodeProtectedHeader(header, version) {
|
|
2363
|
+
const plan = getHeaderPlan(version);
|
|
2364
|
+
const codeword = rsEncode(Array.from(header), plan.paritySymbols);
|
|
2365
|
+
assert(codeword.length === plan.codewordBytes, "Header RS symbol calculation mismatch.");
|
|
2366
|
+
return bytesToCells(codeword);
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
function encodeProtectedBody(bodyBytes, eccLevel, version) {
|
|
2370
|
+
const plan = getBodyRsPlan(bodyBytes.length - CRC_BYTES, eccLevel, version);
|
|
2371
|
+
assert(bodyBytes.length === plan.dataSymbols, "Body RS symbol calculation mismatch.");
|
|
2372
|
+
|
|
2373
|
+
const blocks = [];
|
|
2374
|
+
let offset = 0;
|
|
2375
|
+
for (const length of plan.dataBlockLengths) {
|
|
2376
|
+
blocks.push(rsEncode(Array.from(bodyBytes.slice(offset, offset + length)), plan.paritySymbols));
|
|
2377
|
+
offset += length;
|
|
2378
|
+
}
|
|
2379
|
+
return { cells: bytesToCells(interleaveBlocks(blocks)), plan };
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
function finalizeMatrix(layout, rawCells, meta) {
|
|
2383
|
+
let bestMaskId = 0;
|
|
2384
|
+
let bestMatrix = null;
|
|
2385
|
+
let bestPenalty = Infinity;
|
|
2386
|
+
|
|
2387
|
+
for (let maskId = 0; maskId < 4; maskId++) {
|
|
2388
|
+
const candidate = applyData(layout, rawCells, maskId, true);
|
|
2389
|
+
const penalty = quaternaryPenalty(candidate, layout.reserved);
|
|
2390
|
+
if (penalty < bestPenalty) {
|
|
2391
|
+
bestPenalty = penalty;
|
|
2392
|
+
bestMaskId = maskId;
|
|
2393
|
+
bestMatrix = candidate;
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
const info = getVersionInfo(meta.version, { ecc: meta.eccLevel });
|
|
2398
|
+
return {
|
|
2399
|
+
format: "QuadQR",
|
|
2400
|
+
formatVersion: FORMAT_VERSION,
|
|
2401
|
+
version: meta.version,
|
|
2402
|
+
size: layout.size,
|
|
2403
|
+
matrix: bestMatrix,
|
|
2404
|
+
maskId: bestMaskId,
|
|
2405
|
+
payloadBytes: meta.payloadBytes,
|
|
2406
|
+
sourcePayloadBytes: meta.sourcePayloadBytes ?? meta.payloadBytes,
|
|
2407
|
+
secure: Boolean(meta.secure),
|
|
2408
|
+
requiresDecryption: Boolean(meta.secure),
|
|
2409
|
+
security: meta.security ?? null,
|
|
2410
|
+
meaningfulCells: meta.meaningfulCells,
|
|
2411
|
+
dataCells: layout.dataPositions.length,
|
|
2412
|
+
capacityBytes: info.capacityBytes,
|
|
2413
|
+
alignmentPatterns: layout.alignments.length,
|
|
2414
|
+
utilization: meta.meaningfulCells / layout.dataPositions.length,
|
|
2415
|
+
bitsPerDataCell: 2,
|
|
2416
|
+
eccLevel: meta.eccLevel,
|
|
2417
|
+
eccParitySymbols: meta.eccParitySymbols,
|
|
2418
|
+
eccBlocks: meta.eccBlocks,
|
|
2419
|
+
correctableSymbolsPerBlock: meta.correctableSymbolsPerBlock,
|
|
2420
|
+
spectralInterleaving: true,
|
|
2421
|
+
confidenceAwareEcc: true,
|
|
2422
|
+
crc32: meta.crc >>> 0
|
|
2423
|
+
};
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
function encodeText(text, options = {}) {
|
|
2427
|
+
assert(typeof text === "string", "encodeText expects a string.");
|
|
2428
|
+
return encodeBytes(getTextEncoder().encode(text), { ...options, text: true });
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
function encodeBytes(input, options = {}) {
|
|
2432
|
+
if (options.formatVersion != null && options.formatVersion !== FORMAT_VERSION) {
|
|
2433
|
+
throw new Error(`Only QuadQR format version ${FORMAT_VERSION} is supported.`);
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
const payload = input instanceof Uint8Array ? input : new Uint8Array(input);
|
|
2437
|
+
const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
|
|
2438
|
+
const secure = Boolean(options.secure);
|
|
2439
|
+
const flags = flagsFor(Boolean(options.text), eccLevel, secure);
|
|
2440
|
+
const version = chooseVersion(payload.length, { ...options, ecc: eccLevel });
|
|
2441
|
+
const layout = createLayout(version);
|
|
2442
|
+
const header = makeHeader(payload.length, flags, version);
|
|
2443
|
+
const crc = crc32(concatBytes(header, payload));
|
|
2444
|
+
const headerCells = encodeProtectedHeader(header, version);
|
|
2445
|
+
const bodyEncoded = encodeProtectedBody(concatBytes(payload, u32be(crc)), eccLevel, version);
|
|
2446
|
+
const meaningfulCells = headerCells.concat(bodyEncoded.cells);
|
|
2447
|
+
assert(meaningfulCells.length <= layout.dataPositions.length, "Internal QuadQR capacity calculation error.");
|
|
2448
|
+
|
|
2449
|
+
const padding = makePaddingCells(
|
|
2450
|
+
layout.dataPositions.length - meaningfulCells.length,
|
|
2451
|
+
crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16)
|
|
2452
|
+
);
|
|
2453
|
+
|
|
2454
|
+
return finalizeMatrix(layout, meaningfulCells.concat(padding), {
|
|
2455
|
+
version,
|
|
2456
|
+
payloadBytes: payload.length,
|
|
2457
|
+
sourcePayloadBytes: options.sourcePayloadBytes ?? payload.length,
|
|
2458
|
+
secure,
|
|
2459
|
+
security: options.securityMetadata ?? null,
|
|
2460
|
+
meaningfulCells: meaningfulCells.length,
|
|
2461
|
+
eccLevel,
|
|
2462
|
+
eccParitySymbols: bodyEncoded.plan.paritySymbols,
|
|
2463
|
+
eccBlocks: bodyEncoded.plan.dataBlockLengths.length,
|
|
2464
|
+
correctableSymbolsPerBlock: bodyEncoded.plan.correctableSymbolsPerBlock,
|
|
2465
|
+
crc
|
|
2466
|
+
});
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
/**
|
|
2470
|
+
* Encode text using the optional QuadQR Secure Payload v1 layer.
|
|
2471
|
+
* This API is async because Web Crypto performs AES-GCM and password KDF work asynchronously.
|
|
2472
|
+
*/
|
|
2473
|
+
async function encodeSecureText(text, options = {}) {
|
|
2474
|
+
assert(typeof text === "string", "encodeSecureText expects a string.");
|
|
2475
|
+
return encodeSecureBytes(getTextEncoder().encode(text), { ...options, text: true });
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
/** Encode arbitrary bytes using password or raw 256-bit key security. */
|
|
2479
|
+
async function encodeSecureBytes(input, options = {}) {
|
|
2480
|
+
const sourcePayload = input instanceof Uint8Array ? input : new Uint8Array(input);
|
|
2481
|
+
const security = options.security ?? {};
|
|
2482
|
+
const encrypted = await encryptSecurePayload(sourcePayload, security);
|
|
2483
|
+
return encodeBytes(encrypted.envelope, {
|
|
2484
|
+
...options,
|
|
2485
|
+
text: Boolean(options.text),
|
|
2486
|
+
secure: true,
|
|
2487
|
+
sourcePayloadBytes: sourcePayload.length,
|
|
2488
|
+
securityMetadata: encrypted.metadata
|
|
2489
|
+
});
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
/**
|
|
2493
|
+
* Decrypt a result returned by decodeMatrix/scanImageData/scanFile.
|
|
2494
|
+
* The encrypted envelope remains available as encryptedPayload.
|
|
2495
|
+
*/
|
|
2496
|
+
async function decryptDecoded(result, security = {}) {
|
|
2497
|
+
assert(result?.secure && result?.payload, "decryptDecoded expects an encrypted QuadQR decode result.");
|
|
2498
|
+
const plaintext = await decryptSecurePayload(result.payload, security);
|
|
2499
|
+
const isText = (result.flags & TEXT_FLAG) !== 0;
|
|
2500
|
+
return {
|
|
2501
|
+
...result,
|
|
2502
|
+
encryptedPayload: result.payload,
|
|
2503
|
+
encryptedPayloadBytes: result.payload.length,
|
|
2504
|
+
payload: plaintext,
|
|
2505
|
+
text: isText ? getTextDecoder().decode(plaintext) : null,
|
|
2506
|
+
decrypted: true,
|
|
2507
|
+
requiresDecryption: false,
|
|
2508
|
+
security: { ...result.security, decrypted: true }
|
|
2509
|
+
};
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
function finderMismatchRatio(matrix, top, left) {
|
|
2513
|
+
let mismatches = 0;
|
|
2514
|
+
let total = 0;
|
|
2515
|
+
for (let r = 0; r < 7; r++) {
|
|
2516
|
+
for (let c = 0; c < 7; c++) {
|
|
2517
|
+
const outer = r === 0 || c === 0 || r === 6 || c === 6;
|
|
2518
|
+
const center = r >= 2 && r <= 4 && c >= 2 && c <= 4;
|
|
2519
|
+
const expected = outer || center ? CELL.BLACK : CELL.WHITE;
|
|
2520
|
+
total++;
|
|
2521
|
+
if (matrix[top + r]?.[left + c] !== expected) mismatches++;
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
return mismatches / total;
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
function alignmentPatternMismatchRatio(matrix, pattern) {
|
|
2528
|
+
let mismatches = 0;
|
|
2529
|
+
let total = 0;
|
|
2530
|
+
const radius = alignmentPatternRadius(pattern);
|
|
2531
|
+
for (let r = -radius; r <= radius; r++) {
|
|
2532
|
+
for (let c = -radius; c <= radius; c++) {
|
|
2533
|
+
const black = alignmentPatternIsBlack(pattern, r, c);
|
|
2534
|
+
const expected = black ? CELL.BLACK : CELL.WHITE;
|
|
2535
|
+
total++;
|
|
2536
|
+
if (matrix[pattern.row + r]?.[pattern.col + c] !== expected) mismatches++;
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
return mismatches / total;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
function alignmentGridMismatchRatio(matrix, version) {
|
|
2543
|
+
const patterns = alignmentPatternCentersForVersion(version);
|
|
2544
|
+
let weightedMismatch = 0;
|
|
2545
|
+
let totalWeight = 0;
|
|
2546
|
+
for (const pattern of patterns) {
|
|
2547
|
+
// Give the 5x5 primary marker a little more influence than compact 3x3 markers.
|
|
2548
|
+
const weight = pattern.primary ? 2 : 1;
|
|
2549
|
+
weightedMismatch += alignmentPatternMismatchRatio(matrix, pattern) * weight;
|
|
2550
|
+
totalWeight += weight;
|
|
2551
|
+
}
|
|
2552
|
+
return totalWeight ? weightedMismatch / totalWeight : 0;
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
function validateStructure(matrix, tolerance = 0) {
|
|
2556
|
+
const size = matrix.length;
|
|
2557
|
+
if (size < 21 || matrix.some((row) => row.length !== size)) return false;
|
|
2558
|
+
const version = versionFromSize(size);
|
|
2559
|
+
if (!version) return false;
|
|
2560
|
+
const finderRatios = [
|
|
2561
|
+
finderMismatchRatio(matrix, 0, 0),
|
|
2562
|
+
finderMismatchRatio(matrix, 0, size - 7),
|
|
2563
|
+
finderMismatchRatio(matrix, size - 7, 0)
|
|
2564
|
+
];
|
|
2565
|
+
if (finderRatios.some((ratio) => ratio > tolerance)) return false;
|
|
2566
|
+
|
|
2567
|
+
const alignmentTolerance = Math.max(tolerance, 0.12);
|
|
2568
|
+
const primary = alignmentPatternCentersForVersion(version).at(-1);
|
|
2569
|
+
if (primary && alignmentPatternMismatchRatio(matrix, primary) > alignmentTolerance) {
|
|
2570
|
+
return false;
|
|
2571
|
+
}
|
|
2572
|
+
if (alignmentGridMismatchRatio(matrix, version) > alignmentTolerance) return false;
|
|
2573
|
+
return true;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
function rotate90(matrix) {
|
|
2577
|
+
const size = matrix.length;
|
|
2578
|
+
const out = make2D(size, CELL.WHITE);
|
|
2579
|
+
for (let r = 0; r < size; r++) {
|
|
2580
|
+
for (let c = 0; c < size; c++) out[c][size - 1 - r] = matrix[r][c];
|
|
2581
|
+
}
|
|
2582
|
+
return out;
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
function extractVisibleCells(matrix, layout) {
|
|
2586
|
+
const out = [];
|
|
2587
|
+
for (const [row, col] of layout.dataPositions) {
|
|
2588
|
+
const value = matrix[row][col];
|
|
2589
|
+
if (!Number.isInteger(value) || value < 0 || value > 3) {
|
|
2590
|
+
throw new Error("Data region contains an invalid QuadQR data cell.");
|
|
2591
|
+
}
|
|
2592
|
+
out.push(value);
|
|
2593
|
+
}
|
|
2594
|
+
return out;
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
function unmaskCells(visibleCells, positions, maskId) {
|
|
2598
|
+
return visibleCells.map((value, index) => {
|
|
2599
|
+
const [row, col] = positions[index];
|
|
2600
|
+
return value ^ maskValue(row, col, maskId);
|
|
2601
|
+
});
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
|
|
2605
|
+
if (!spectralInterleaving) return physicalValues.slice();
|
|
2606
|
+
const permutation = spectralPermutation(physicalValues.length, version);
|
|
2607
|
+
const logical = new Array(physicalValues.length);
|
|
2608
|
+
for (let logicalIndex = 0; logicalIndex < permutation.length; logicalIndex++) {
|
|
2609
|
+
logical[logicalIndex] = physicalValues[permutation[logicalIndex]];
|
|
2610
|
+
}
|
|
2611
|
+
return logical;
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
function cellsToSymbolConfidences(confidences, byteCount) {
|
|
2615
|
+
if (!confidences) return null;
|
|
2616
|
+
if (confidences.length < byteCount * CELLS_PER_BYTE) return null;
|
|
2617
|
+
const out = new Array(byteCount);
|
|
2618
|
+
for (let symbolIndex = 0; symbolIndex < byteCount; symbolIndex++) {
|
|
2619
|
+
const offset = symbolIndex * CELLS_PER_BYTE;
|
|
2620
|
+
// A single wrong 2-bit cell changes the GF(256) symbol, so the weakest
|
|
2621
|
+
// constituent cell is the useful confidence bound for that symbol.
|
|
2622
|
+
out[symbolIndex] = Math.min(
|
|
2623
|
+
confidences[offset] ?? 1,
|
|
2624
|
+
confidences[offset + 1] ?? 1,
|
|
2625
|
+
confidences[offset + 2] ?? 1,
|
|
2626
|
+
confidences[offset + 3] ?? 1
|
|
2627
|
+
);
|
|
2628
|
+
}
|
|
2629
|
+
return out;
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
function decodeRsAdaptive(codeword, paritySymbols, symbolConfidences, options = {}) {
|
|
2633
|
+
try {
|
|
2634
|
+
return { ...rsDecode(codeword, paritySymbols), confidenceAssisted: false };
|
|
2635
|
+
} catch (hardError) {
|
|
2636
|
+
if (!symbolConfidences || symbolConfidences.length !== codeword.length) throw hardError;
|
|
2637
|
+
|
|
2638
|
+
const maxErasureConfidence = options.maxErasureConfidence ?? 0.68;
|
|
2639
|
+
const ranked = symbolConfidences
|
|
2640
|
+
.map((confidence, position) => ({ confidence: Number.isFinite(confidence) ? confidence : 1, position }))
|
|
2641
|
+
.filter(({ confidence }) => confidence <= maxErasureConfidence)
|
|
2642
|
+
.sort((a, b) => (a.confidence - b.confidence) || (a.position - b.position));
|
|
2643
|
+
|
|
2644
|
+
const limit = Math.min(paritySymbols, ranked.length);
|
|
2645
|
+
let lastError = hardError;
|
|
2646
|
+
// Start with the least-confident symbol and progressively promote more
|
|
2647
|
+
// uncertain symbols to erasures. Syndrome verification inside rsDecode
|
|
2648
|
+
// prevents accepting an invalid correction.
|
|
2649
|
+
for (let count = 1; count <= limit; count++) {
|
|
2650
|
+
const erasurePositions = ranked.slice(0, count).map(({ position }) => position);
|
|
2651
|
+
try {
|
|
2652
|
+
return {
|
|
2653
|
+
...rsDecode(codeword, paritySymbols, { erasurePositions }),
|
|
2654
|
+
confidenceAssisted: true
|
|
2655
|
+
};
|
|
2656
|
+
} catch (error) {
|
|
2657
|
+
lastError = error;
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
throw lastError;
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}) {
|
|
2665
|
+
const plan = getHeaderPlan(version);
|
|
2666
|
+
const headerCells = rawCells.slice(0, plan.codewordCells);
|
|
2667
|
+
const codeword = Array.from(cellsToBytes(headerCells, plan.codewordBytes));
|
|
2668
|
+
const confidences = cellsToSymbolConfidences(rawConfidences?.slice(0, plan.codewordCells), plan.codewordBytes);
|
|
2669
|
+
const decoded = decodeRsAdaptive(codeword, plan.paritySymbols, confidences, options);
|
|
2670
|
+
const header = new Uint8Array(decoded.data);
|
|
2671
|
+
const parsed = parseHeader(header, version);
|
|
2672
|
+
return {
|
|
2673
|
+
header,
|
|
2674
|
+
...parsed,
|
|
2675
|
+
correctedSymbols: decoded.correctedSymbols,
|
|
2676
|
+
erasureSymbols: decoded.erasureSymbols ?? 0,
|
|
2677
|
+
unknownErrorSymbols: decoded.unknownErrorSymbols ?? decoded.correctedSymbols,
|
|
2678
|
+
confidenceAssisted: decoded.confidenceAssisted,
|
|
2679
|
+
plan
|
|
2680
|
+
};
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConfidences = null, options = {}) {
|
|
2684
|
+
const plan = getBodyRsPlan(payloadLength, eccLevel, version);
|
|
2685
|
+
const bodyStart = getHeaderPlan(version).codewordCells;
|
|
2686
|
+
const encodedCells = rawCells.slice(bodyStart, bodyStart + plan.encodedCells);
|
|
2687
|
+
if (encodedCells.length !== plan.encodedCells) throw new Error("Protected body is incomplete.");
|
|
2688
|
+
|
|
2689
|
+
const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols));
|
|
2690
|
+
const symbolConfidences = cellsToSymbolConfidences(
|
|
2691
|
+
rawConfidences?.slice(bodyStart, bodyStart + plan.encodedCells),
|
|
2692
|
+
plan.encodedSymbols
|
|
2693
|
+
);
|
|
2694
|
+
const blocks = deinterleaveBlocks(encodedSymbols, plan.codewordBlockLengths);
|
|
2695
|
+
const confidenceBlocks = symbolConfidences
|
|
2696
|
+
? deinterleaveBlocks(symbolConfidences, plan.codewordBlockLengths)
|
|
2697
|
+
: blocks.map(() => null);
|
|
2698
|
+
const decodedDataSymbols = [];
|
|
2699
|
+
let correctedSymbols = 0;
|
|
2700
|
+
let erasureSymbols = 0;
|
|
2701
|
+
let unknownErrorSymbols = 0;
|
|
2702
|
+
let confidenceAssisted = false;
|
|
2703
|
+
|
|
2704
|
+
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
|
|
2705
|
+
const decoded = decodeRsAdaptive(
|
|
2706
|
+
blocks[blockIndex],
|
|
2707
|
+
plan.paritySymbols,
|
|
2708
|
+
confidenceBlocks[blockIndex],
|
|
2709
|
+
options
|
|
2710
|
+
);
|
|
2711
|
+
correctedSymbols += decoded.correctedSymbols;
|
|
2712
|
+
erasureSymbols += decoded.erasureSymbols ?? 0;
|
|
2713
|
+
unknownErrorSymbols += decoded.unknownErrorSymbols ?? decoded.correctedSymbols;
|
|
2714
|
+
confidenceAssisted ||= decoded.confidenceAssisted;
|
|
2715
|
+
decodedDataSymbols.push(...decoded.data);
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
return {
|
|
2719
|
+
body: new Uint8Array(decodedDataSymbols.slice(0, plan.bodyByteCount)),
|
|
2720
|
+
correctedSymbols,
|
|
2721
|
+
erasureSymbols,
|
|
2722
|
+
unknownErrorSymbols,
|
|
2723
|
+
confidenceAssisted,
|
|
2724
|
+
plan
|
|
2725
|
+
};
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = null, options = {}) {
|
|
2729
|
+
const size = matrix.length;
|
|
2730
|
+
const version = versionFromSize(size);
|
|
2731
|
+
if (!version) throw new Error(`Unsupported matrix size ${size}.`);
|
|
2732
|
+
if (!validateStructure(matrix, tolerance)) throw new Error("QuadQR finder/alignment structure does not match.");
|
|
2733
|
+
|
|
2734
|
+
const layout = createLayout(version);
|
|
2735
|
+
const visible = extractVisibleCells(matrix, layout);
|
|
2736
|
+
const visibleConfidences = confidenceMatrix
|
|
2737
|
+
? layout.dataPositions.map(([row, col]) => confidenceMatrix[row]?.[col] ?? 1)
|
|
2738
|
+
: null;
|
|
2739
|
+
const errors = [];
|
|
2740
|
+
|
|
2741
|
+
// New symbols use spectral-spatial interleaving. Legacy order remains a
|
|
2742
|
+
// decode fallback so existing QuadQR images do not become unreadable.
|
|
2743
|
+
for (const spectralInterleaving of [true, false]) {
|
|
2744
|
+
for (let maskId = 0; maskId < 4; maskId++) {
|
|
2745
|
+
try {
|
|
2746
|
+
const physicalRaw = unmaskCells(visible, layout.dataPositions, maskId);
|
|
2747
|
+
const raw = restoreLogicalOrder(physicalRaw, version, spectralInterleaving);
|
|
2748
|
+
const rawConfidences = visibleConfidences
|
|
2749
|
+
? restoreLogicalOrder(visibleConfidences, version, spectralInterleaving)
|
|
2750
|
+
: null;
|
|
2751
|
+
const headerDecoded = decodeProtectedHeader(raw, version, rawConfidences, options);
|
|
2752
|
+
const header = headerDecoded.header;
|
|
2753
|
+
const flags = headerDecoded.flags;
|
|
2754
|
+
const eccLevel = eccFromFlags(flags);
|
|
2755
|
+
const payloadLength = headerDecoded.payloadLength;
|
|
2756
|
+
if (streamCellCount(payloadLength, eccLevel, version) > raw.length) {
|
|
2757
|
+
throw new Error("Declared payload exceeds matrix capacity.");
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
const bodyDecoded = decodeProtectedBody(
|
|
2761
|
+
raw,
|
|
2762
|
+
payloadLength,
|
|
2763
|
+
eccLevel,
|
|
2764
|
+
version,
|
|
2765
|
+
rawConfidences,
|
|
2766
|
+
options
|
|
2767
|
+
);
|
|
2768
|
+
const body = bodyDecoded.body;
|
|
2769
|
+
const payload = body.slice(0, payloadLength);
|
|
2770
|
+
const expectedCrc = readU32be(body, payloadLength);
|
|
2771
|
+
const actualCrc = crc32(concatBytes(header, payload));
|
|
2772
|
+
if (expectedCrc !== actualCrc) throw new Error("CRC mismatch after ECC.");
|
|
2773
|
+
|
|
2774
|
+
const isText = (flags & TEXT_FLAG) !== 0;
|
|
2775
|
+
const secure = (flags & SECURE_FLAG) !== 0;
|
|
2776
|
+
const security = secure ? inspectSecureEnvelope(payload) : null;
|
|
2777
|
+
const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
|
|
2778
|
+
const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
|
|
2779
|
+
return {
|
|
2780
|
+
ok: true,
|
|
2781
|
+
format: "QuadQR",
|
|
2782
|
+
formatVersion: FORMAT_VERSION,
|
|
2783
|
+
version,
|
|
2784
|
+
size,
|
|
2785
|
+
alignmentPatterns: layout.alignments.length,
|
|
2786
|
+
maskId,
|
|
2787
|
+
rotation,
|
|
2788
|
+
flags,
|
|
2789
|
+
eccLevel,
|
|
2790
|
+
eccParitySymbols: bodyDecoded.plan.paritySymbols,
|
|
2791
|
+
eccBlocks: bodyDecoded.plan.dataBlockLengths.length,
|
|
2792
|
+
correctableSymbolsPerBlock: bodyDecoded.plan.correctableSymbolsPerBlock,
|
|
2793
|
+
spectralInterleaving,
|
|
2794
|
+
confidenceAwareEcc: Boolean(rawConfidences),
|
|
2795
|
+
confidenceAssisted: headerDecoded.confidenceAssisted || bodyDecoded.confidenceAssisted,
|
|
2796
|
+
erasureSymbols,
|
|
2797
|
+
unknownErrorSymbols,
|
|
2798
|
+
correctedHeaderSymbols: headerDecoded.correctedSymbols,
|
|
2799
|
+
correctedBodySymbols: bodyDecoded.correctedSymbols,
|
|
2800
|
+
correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
|
|
2801
|
+
payload,
|
|
2802
|
+
text: isText && !secure ? getTextDecoder().decode(payload) : null,
|
|
2803
|
+
secure,
|
|
2804
|
+
encrypted: secure,
|
|
2805
|
+
decrypted: false,
|
|
2806
|
+
requiresDecryption: secure,
|
|
2807
|
+
security,
|
|
2808
|
+
crc32: actualCrc >>> 0
|
|
2809
|
+
};
|
|
2810
|
+
} catch (error) {
|
|
2811
|
+
errors.push(`${spectralInterleaving ? "spectral" : "legacy"} mask ${maskId}: ${error.message}`);
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
throw new Error(`QuadQR decode failed. ${errors.join(" | ")}`);
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
function decodeMatrix(inputMatrix, options = {}) {
|
|
2820
|
+
assert(Array.isArray(inputMatrix) && inputMatrix.length > 0, "Matrix is required.");
|
|
2821
|
+
let matrix = cloneMatrix(inputMatrix);
|
|
2822
|
+
let confidenceMatrix = options.cellConfidence ? cloneMatrix(options.cellConfidence) : null;
|
|
2823
|
+
const errors = [];
|
|
2824
|
+
const tolerance = options.structureTolerance ?? 0;
|
|
2825
|
+
|
|
2826
|
+
if (confidenceMatrix) {
|
|
2827
|
+
assert(
|
|
2828
|
+
confidenceMatrix.length === matrix.length && confidenceMatrix.every((row) => row.length === matrix.length),
|
|
2829
|
+
"cellConfidence must be a square matrix matching the QuadQR matrix."
|
|
2830
|
+
);
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
for (let rotationIndex = 0; rotationIndex < 4; rotationIndex++) {
|
|
2834
|
+
const degrees = rotationIndex * 90;
|
|
2835
|
+
try {
|
|
2836
|
+
return decodeCanonical(matrix, degrees, tolerance, confidenceMatrix, options);
|
|
2837
|
+
} catch (error) {
|
|
2838
|
+
errors.push(`${degrees}°: ${error.message}`);
|
|
2839
|
+
}
|
|
2840
|
+
matrix = rotate90(matrix);
|
|
2841
|
+
if (confidenceMatrix) confidenceMatrix = rotate90(confidenceMatrix);
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
throw new Error(`Unable to decode matrix. ${errors.join(" || ")}`);
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
function hexToRgb(hex) {
|
|
2848
|
+
const clean = hex.replace("#", "");
|
|
2849
|
+
assert(clean.length === 6, `Invalid hex color ${hex}.`);
|
|
2850
|
+
return {
|
|
2851
|
+
r: parseInt(clean.slice(0, 2), 16),
|
|
2852
|
+
g: parseInt(clean.slice(2, 4), 16),
|
|
2853
|
+
b: parseInt(clean.slice(4, 6), 16)
|
|
2854
|
+
};
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
function resolvePalette(palette = {}) {
|
|
2858
|
+
return { ...DEFAULT_PALETTE, ...palette };
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
function paletteRgb(palette = {}) {
|
|
2862
|
+
const p = resolvePalette(palette);
|
|
2863
|
+
return {
|
|
2864
|
+
black: hexToRgb(p.black),
|
|
2865
|
+
white: hexToRgb(p.white),
|
|
2866
|
+
red: hexToRgb(p.red),
|
|
2867
|
+
green: hexToRgb(p.green),
|
|
2868
|
+
blue: hexToRgb(p.blue)
|
|
2869
|
+
};
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
function cellColor(cell, palette) {
|
|
2873
|
+
switch (cell) {
|
|
2874
|
+
case CELL.BLACK: return palette.black;
|
|
2875
|
+
case CELL.RED: return palette.red;
|
|
2876
|
+
case CELL.GREEN: return palette.green;
|
|
2877
|
+
case CELL.BLUE: return palette.blue;
|
|
2878
|
+
case CELL.WHITE: return palette.white;
|
|
2879
|
+
default: throw new Error(`Unknown cell value ${cell}.`);
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
function cellRgb(cell, palette) {
|
|
2884
|
+
switch (cell) {
|
|
2885
|
+
case CELL.BLACK: return palette.black;
|
|
2886
|
+
case CELL.RED: return palette.red;
|
|
2887
|
+
case CELL.GREEN: return palette.green;
|
|
2888
|
+
case CELL.BLUE: return palette.blue;
|
|
2889
|
+
case CELL.WHITE: return palette.white;
|
|
2890
|
+
default: throw new Error(`Unknown cell value ${cell}.`);
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
function normalizeRenderStyle(style = RENDER_STYLES.CLASSIC) {
|
|
2895
|
+
const value = String(style).toLowerCase();
|
|
2896
|
+
const allowed = Object.values(RENDER_STYLES);
|
|
2897
|
+
assert(allowed.includes(value), `Render style must be one of ${allowed.join(", ")}.`);
|
|
2898
|
+
return value;
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
function renderLayoutForMatrix(matrix) {
|
|
2902
|
+
const version = versionFromSize(matrix.length);
|
|
2903
|
+
if (!version) return null;
|
|
2904
|
+
try {
|
|
2905
|
+
return createLayout(version);
|
|
2906
|
+
} catch {
|
|
2907
|
+
return null;
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
function isStructuralRenderCell(layout, row, col, cell) {
|
|
2912
|
+
// BLACK never represents payload data in QuadQR. The layout check also keeps
|
|
2913
|
+
// structural white and RGB calibration cells untouched by visual styles.
|
|
2914
|
+
return Boolean(layout?.reserved?.[row]?.[col]) || cell === CELL.BLACK;
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
function renderNoise(row, col, cell) {
|
|
2918
|
+
let value = (
|
|
2919
|
+
Math.imul(row + 1, 0x9e3779b1) ^
|
|
2920
|
+
Math.imul(col + 1, 0x85ebca6b) ^
|
|
2921
|
+
Math.imul((cell + 2) & 0xff, 0xc2b2ae35)
|
|
2922
|
+
) >>> 0;
|
|
2923
|
+
value ^= value >>> 16;
|
|
2924
|
+
value = Math.imul(value, 0x7feb352d) >>> 0;
|
|
2925
|
+
value ^= value >>> 15;
|
|
2926
|
+
value = Math.imul(value, 0x846ca68b) >>> 0;
|
|
2927
|
+
value ^= value >>> 16;
|
|
2928
|
+
return (value >>> 0) / 0xffffffff;
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
function depthOpacity(row, col, cell) {
|
|
2932
|
+
const value = renderNoise(row, col, cell);
|
|
2933
|
+
if (value < 0.16) return 0.80;
|
|
2934
|
+
if (value < 0.36) return 0.87;
|
|
2935
|
+
if (value < 0.56) return 0.94;
|
|
2936
|
+
return 1;
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
function mixRgb(a, b, amount) {
|
|
2940
|
+
const t = Math.max(0, Math.min(1, amount));
|
|
2941
|
+
return {
|
|
2942
|
+
r: Math.round(a.r + (b.r - a.r) * t),
|
|
2943
|
+
g: Math.round(a.g + (b.g - a.g) * t),
|
|
2944
|
+
b: Math.round(a.b + (b.b - a.b) * t)
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
function rgbCss(rgb) {
|
|
2949
|
+
return `rgb(${rgb.r} ${rgb.g} ${rgb.b})`;
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
// Scan-safe decorative profiles deliberately alter only a narrow edge band.
|
|
2953
|
+
// The scanner samples around the center of each module (roughly +/- 0.16 to
|
|
2954
|
+
// 0.18 module widths), so the central classification area remains the exact
|
|
2955
|
+
// encoded R/G/B/W color. Structural/calibration cells bypass styling entirely.
|
|
2956
|
+
function safeStyleEdge(moduleSize) {
|
|
2957
|
+
if (moduleSize < 5) return 0;
|
|
2958
|
+
return Math.max(1, Math.min(Math.floor(moduleSize * 0.10), Math.floor(moduleSize * 0.16)));
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
function insetStyleColors(rgb, palette) {
|
|
2962
|
+
return {
|
|
2963
|
+
highlight: mixRgb(rgb, palette.white, 0.12),
|
|
2964
|
+
shadow: mixRgb(rgb, palette.black, 0.10)
|
|
2965
|
+
};
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
function roundedRectPath(ctx, x, y, width, height, radius) {
|
|
2969
|
+
const r = Math.max(0, Math.min(radius, width / 2, height / 2));
|
|
2970
|
+
ctx.beginPath();
|
|
2971
|
+
ctx.moveTo(x + r, y);
|
|
2972
|
+
ctx.lineTo(x + width - r, y);
|
|
2973
|
+
ctx.quadraticCurveTo(x + width, y, x + width, y + r);
|
|
2974
|
+
ctx.lineTo(x + width, y + height - r);
|
|
2975
|
+
ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
|
|
2976
|
+
ctx.lineTo(x + r, y + height);
|
|
2977
|
+
ctx.quadraticCurveTo(x, y + height, x, y + height - r);
|
|
2978
|
+
ctx.lineTo(x, y + r);
|
|
2979
|
+
ctx.quadraticCurveTo(x, y, x + r, y);
|
|
2980
|
+
ctx.closePath();
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
function setImagePixel(data, width, x, y, rgb) {
|
|
2984
|
+
if (x < 0 || y < 0 || x >= width) return;
|
|
2985
|
+
const height = data.length / 4 / width;
|
|
2986
|
+
if (y >= height) return;
|
|
2987
|
+
const p = (y * width + x) * 4;
|
|
2988
|
+
data[p] = rgb.r;
|
|
2989
|
+
data[p + 1] = rgb.g;
|
|
2990
|
+
data[p + 2] = rgb.b;
|
|
2991
|
+
data[p + 3] = 255;
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
function fillImageRect(data, width, x, y, rectWidth, rectHeight, rgb) {
|
|
2995
|
+
for (let yy = y; yy < y + rectHeight; yy++) {
|
|
2996
|
+
for (let xx = x; xx < x + rectWidth; xx++) setImagePixel(data, width, xx, yy, rgb);
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
function fillImageRoundedRect(data, width, x, y, rectWidth, rectHeight, radius, rgb) {
|
|
3001
|
+
const r = Math.max(0, Math.min(radius, Math.floor(rectWidth / 2), Math.floor(rectHeight / 2)));
|
|
3002
|
+
if (r <= 0) {
|
|
3003
|
+
fillImageRect(data, width, x, y, rectWidth, rectHeight, rgb);
|
|
3004
|
+
return;
|
|
3005
|
+
}
|
|
3006
|
+
const leftCenter = x + r;
|
|
3007
|
+
const rightCenter = x + rectWidth - r - 1;
|
|
3008
|
+
const topCenter = y + r;
|
|
3009
|
+
const bottomCenter = y + rectHeight - r - 1;
|
|
3010
|
+
const rr = r * r;
|
|
3011
|
+
for (let yy = y; yy < y + rectHeight; yy++) {
|
|
3012
|
+
for (let xx = x; xx < x + rectWidth; xx++) {
|
|
3013
|
+
let dx = 0;
|
|
3014
|
+
let dy = 0;
|
|
3015
|
+
if (xx < leftCenter) dx = leftCenter - xx;
|
|
3016
|
+
else if (xx > rightCenter) dx = xx - rightCenter;
|
|
3017
|
+
if (yy < topCenter) dy = topCenter - yy;
|
|
3018
|
+
else if (yy > bottomCenter) dy = yy - bottomCenter;
|
|
3019
|
+
if (dx * dx + dy * dy <= rr) setImagePixel(data, width, xx, yy, rgb);
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
3025
|
+
assert(canvas && typeof canvas.getContext === "function", "A canvas element is required.");
|
|
3026
|
+
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
|
|
3027
|
+
assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
|
|
3028
|
+
const moduleSize = Math.max(1, Math.floor(options.moduleSize ?? 12));
|
|
3029
|
+
const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
|
|
3030
|
+
const palette = resolvePalette(options.palette);
|
|
3031
|
+
const paletteValues = paletteRgb(options.palette);
|
|
3032
|
+
const style = normalizeRenderStyle(options.style);
|
|
3033
|
+
const layout = renderLayoutForMatrix(matrix);
|
|
3034
|
+
const size = matrix.length;
|
|
3035
|
+
const pixelSize = (size + quietZone * 2) * moduleSize;
|
|
3036
|
+
|
|
3037
|
+
canvas.width = pixelSize;
|
|
3038
|
+
canvas.height = pixelSize;
|
|
3039
|
+
const ctx = canvas.getContext("2d", { alpha: false, willReadFrequently: true });
|
|
3040
|
+
ctx.imageSmoothingEnabled = false;
|
|
3041
|
+
ctx.fillStyle = palette.white;
|
|
3042
|
+
ctx.fillRect(0, 0, pixelSize, pixelSize);
|
|
3043
|
+
|
|
3044
|
+
for (let r = 0; r < size; r++) {
|
|
3045
|
+
for (let c = 0; c < size; c++) {
|
|
3046
|
+
const cell = matrix[r][c];
|
|
3047
|
+
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
3048
|
+
const x = (c + quietZone) * moduleSize;
|
|
3049
|
+
const y = (r + quietZone) * moduleSize;
|
|
3050
|
+
|
|
3051
|
+
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
3052
|
+
ctx.fillStyle = cellColor(cell, palette);
|
|
3053
|
+
ctx.fillRect(x, y, moduleSize, moduleSize);
|
|
3054
|
+
continue;
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
const rgb = cellRgb(cell, paletteValues);
|
|
3058
|
+
|
|
3059
|
+
if (style === RENDER_STYLES.DEPTH) {
|
|
3060
|
+
const opacity = depthOpacity(r, c, cell);
|
|
3061
|
+
const base = mixRgb(paletteValues.white, rgb, opacity);
|
|
3062
|
+
ctx.fillStyle = rgbCss(base);
|
|
3063
|
+
ctx.fillRect(x, y, moduleSize, moduleSize);
|
|
3064
|
+
|
|
3065
|
+
if (moduleSize >= 6) {
|
|
3066
|
+
const edge = Math.max(1, Math.floor(moduleSize * 0.08));
|
|
3067
|
+
const highlight = mixRgb(base, paletteValues.white, 0.17);
|
|
3068
|
+
const shadow = mixRgb(base, paletteValues.black, 0.12);
|
|
3069
|
+
ctx.fillStyle = rgbCss(highlight);
|
|
3070
|
+
ctx.fillRect(x, y, moduleSize, edge);
|
|
3071
|
+
ctx.fillRect(x, y, edge, moduleSize);
|
|
3072
|
+
ctx.fillStyle = rgbCss(shadow);
|
|
3073
|
+
ctx.fillRect(x, y + moduleSize - edge, moduleSize, edge);
|
|
3074
|
+
ctx.fillRect(x + moduleSize - edge, y, edge, moduleSize);
|
|
3075
|
+
}
|
|
3076
|
+
continue;
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
if (style === RENDER_STYLES.SOFT) {
|
|
3080
|
+
const inset = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
|
|
3081
|
+
const width = moduleSize - inset * 2;
|
|
3082
|
+
const radius = Math.max(1, Math.floor(moduleSize * 0.20));
|
|
3083
|
+
ctx.fillStyle = cellColor(cell, palette);
|
|
3084
|
+
roundedRectPath(ctx, x + inset, y + inset, width, width, radius);
|
|
3085
|
+
ctx.fill();
|
|
3086
|
+
continue;
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
if (style === RENDER_STYLES.INSET) {
|
|
3090
|
+
// Keep the exact encoded color across the full module, then limit the
|
|
3091
|
+
// recessed effect to a narrow edge band so center sampling stays intact.
|
|
3092
|
+
ctx.fillStyle = cellColor(cell, palette);
|
|
3093
|
+
ctx.fillRect(x, y, moduleSize, moduleSize);
|
|
3094
|
+
if (cell === CELL.WHITE) continue;
|
|
3095
|
+
|
|
3096
|
+
const edge = safeStyleEdge(moduleSize);
|
|
3097
|
+
if (edge > 0) {
|
|
3098
|
+
const fx = insetStyleColors(rgb, paletteValues);
|
|
3099
|
+
ctx.fillStyle = rgbCss(fx.shadow);
|
|
3100
|
+
ctx.fillRect(x, y, moduleSize, edge);
|
|
3101
|
+
ctx.fillRect(x, y, edge, moduleSize);
|
|
3102
|
+
ctx.fillStyle = rgbCss(fx.highlight);
|
|
3103
|
+
ctx.fillRect(x, y + moduleSize - edge, moduleSize, edge);
|
|
3104
|
+
ctx.fillRect(x + moduleSize - edge, y, edge, moduleSize);
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
return canvas;
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
function renderToImageData(codeOrMatrix, options = {}) {
|
|
3113
|
+
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
|
|
3114
|
+
assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
|
|
3115
|
+
const moduleSize = Math.max(1, Math.floor(options.moduleSize ?? 8));
|
|
3116
|
+
const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
|
|
3117
|
+
const palette = paletteRgb(options.palette);
|
|
3118
|
+
const style = normalizeRenderStyle(options.style);
|
|
3119
|
+
const layout = renderLayoutForMatrix(matrix);
|
|
3120
|
+
const size = matrix.length;
|
|
3121
|
+
const pixelSize = (size + quietZone * 2) * moduleSize;
|
|
3122
|
+
const data = new Uint8ClampedArray(pixelSize * pixelSize * 4);
|
|
3123
|
+
const white = palette.white;
|
|
3124
|
+
|
|
3125
|
+
for (let i = 0; i < pixelSize * pixelSize; i++) {
|
|
3126
|
+
const p = i * 4;
|
|
3127
|
+
data[p] = white.r;
|
|
3128
|
+
data[p + 1] = white.g;
|
|
3129
|
+
data[p + 2] = white.b;
|
|
3130
|
+
data[p + 3] = 255;
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
for (let r = 0; r < size; r++) {
|
|
3134
|
+
for (let c = 0; c < size; c++) {
|
|
3135
|
+
const cell = matrix[r][c];
|
|
3136
|
+
const rgb = cellRgb(cell, palette);
|
|
3137
|
+
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
3138
|
+
const y0 = (r + quietZone) * moduleSize;
|
|
3139
|
+
const x0 = (c + quietZone) * moduleSize;
|
|
3140
|
+
|
|
3141
|
+
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
3142
|
+
fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, rgb);
|
|
3143
|
+
continue;
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
if (style === RENDER_STYLES.DEPTH) {
|
|
3147
|
+
const opacity = depthOpacity(r, c, cell);
|
|
3148
|
+
const base = mixRgb(white, rgb, opacity);
|
|
3149
|
+
fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, base);
|
|
3150
|
+
if (moduleSize >= 6) {
|
|
3151
|
+
const edge = Math.max(1, Math.floor(moduleSize * 0.08));
|
|
3152
|
+
const highlight = mixRgb(base, white, 0.17);
|
|
3153
|
+
const shadow = mixRgb(base, palette.black, 0.12);
|
|
3154
|
+
fillImageRect(data, pixelSize, x0, y0, moduleSize, edge, highlight);
|
|
3155
|
+
fillImageRect(data, pixelSize, x0, y0, edge, moduleSize, highlight);
|
|
3156
|
+
fillImageRect(data, pixelSize, x0, y0 + moduleSize - edge, moduleSize, edge, shadow);
|
|
3157
|
+
fillImageRect(data, pixelSize, x0 + moduleSize - edge, y0, edge, moduleSize, shadow);
|
|
3158
|
+
}
|
|
3159
|
+
continue;
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
if (style === RENDER_STYLES.SOFT) {
|
|
3163
|
+
const inset = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
|
|
3164
|
+
const width = moduleSize - inset * 2;
|
|
3165
|
+
const radius = Math.max(1, Math.floor(moduleSize * 0.20));
|
|
3166
|
+
fillImageRoundedRect(data, pixelSize, x0 + inset, y0 + inset, width, width, radius, rgb);
|
|
3167
|
+
continue;
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
if (style === RENDER_STYLES.INSET) {
|
|
3171
|
+
fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, rgb);
|
|
3172
|
+
if (cell === CELL.WHITE) continue;
|
|
3173
|
+
const edge = safeStyleEdge(moduleSize);
|
|
3174
|
+
if (edge > 0) {
|
|
3175
|
+
const fx = insetStyleColors(rgb, palette);
|
|
3176
|
+
fillImageRect(data, pixelSize, x0, y0, moduleSize, edge, fx.shadow);
|
|
3177
|
+
fillImageRect(data, pixelSize, x0, y0, edge, moduleSize, fx.shadow);
|
|
3178
|
+
fillImageRect(data, pixelSize, x0, y0 + moduleSize - edge, moduleSize, edge, fx.highlight);
|
|
3179
|
+
fillImageRect(data, pixelSize, x0 + moduleSize - edge, y0, edge, moduleSize, fx.highlight);
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
return { width: pixelSize, height: pixelSize, data };
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
function classifierFromPaletteRgb(observed) {
|
|
3189
|
+
return [
|
|
3190
|
+
{ cell: CELL.BLACK, rgb: observed.black },
|
|
3191
|
+
{ cell: CELL.WHITE, rgb: observed.white },
|
|
3192
|
+
{ cell: CELL.RED, rgb: observed.red },
|
|
3193
|
+
{ cell: CELL.GREEN, rgb: observed.green },
|
|
3194
|
+
{ cell: CELL.BLUE, rgb: observed.blue }
|
|
3195
|
+
];
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
function colorDistanceSq(a, b) {
|
|
3199
|
+
const dr = a.r - b.r;
|
|
3200
|
+
const dg = a.g - b.g;
|
|
3201
|
+
const db = a.b - b.b;
|
|
3202
|
+
return dr * dr + dg * dg + db * db;
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
function classifyRgb(rgb, classifier) {
|
|
3206
|
+
let best = null;
|
|
3207
|
+
let bestDistanceSq = Infinity;
|
|
3208
|
+
let secondDistanceSq = Infinity;
|
|
3209
|
+
|
|
3210
|
+
for (const candidate of classifier) {
|
|
3211
|
+
const distanceSq = colorDistanceSq(rgb, candidate.rgb);
|
|
3212
|
+
if (distanceSq < bestDistanceSq) {
|
|
3213
|
+
secondDistanceSq = bestDistanceSq;
|
|
3214
|
+
bestDistanceSq = distanceSq;
|
|
3215
|
+
best = candidate;
|
|
3216
|
+
} else if (distanceSq < secondDistanceSq) {
|
|
3217
|
+
secondDistanceSq = distanceSq;
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
const distance = Math.sqrt(bestDistanceSq);
|
|
3222
|
+
const secondDistance = Number.isFinite(secondDistanceSq) ? Math.sqrt(secondDistanceSq) : distance + 1;
|
|
3223
|
+
// Confidence is the normalized separation between the nearest and second
|
|
3224
|
+
// nearest calibrated palette states. 1 means unambiguous, 0 means tied.
|
|
3225
|
+
const confidence = Math.max(0, Math.min(1, (secondDistance - distance) / Math.max(secondDistance, 1e-6)));
|
|
3226
|
+
return { cell: best.cell, distance, confidence };
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
|
|
3230
|
+
const size = rgbGrid.length;
|
|
3231
|
+
const matrix = make2D(size, CELL.WHITE);
|
|
3232
|
+
const confidence = make2D(size, 1);
|
|
3233
|
+
const dataClassifier = classifier.filter(({ cell }) => cell !== CELL.BLACK);
|
|
3234
|
+
let distanceSum = 0;
|
|
3235
|
+
let confidenceSum = 0;
|
|
3236
|
+
let minimumConfidence = 1;
|
|
3237
|
+
let lowConfidenceCells = 0;
|
|
3238
|
+
|
|
3239
|
+
for (let r = 0; r < size; r++) {
|
|
3240
|
+
for (let c = 0; c < size; c++) {
|
|
3241
|
+
// Black is structural only. Restricting data modules to RGBW keeps a
|
|
3242
|
+
// shadowed/blurred data sample representable so confidence-aware ECC can
|
|
3243
|
+
// promote it to an erasure instead of aborting before RS gets a chance.
|
|
3244
|
+
const candidates = layout && !layout.reserved[r][c] ? dataClassifier : classifier;
|
|
3245
|
+
const classified = classifyRgb(rgbGrid[r][c], candidates);
|
|
3246
|
+
matrix[r][c] = classified.cell;
|
|
3247
|
+
confidence[r][c] = classified.confidence;
|
|
3248
|
+
distanceSum += classified.distance;
|
|
3249
|
+
confidenceSum += classified.confidence;
|
|
3250
|
+
minimumConfidence = Math.min(minimumConfidence, classified.confidence);
|
|
3251
|
+
if (classified.confidence < 0.4) lowConfidenceCells++;
|
|
3252
|
+
}
|
|
3253
|
+
}
|
|
3254
|
+
return {
|
|
3255
|
+
matrix,
|
|
3256
|
+
confidence,
|
|
3257
|
+
averageColorDistance: distanceSum / (size * size),
|
|
3258
|
+
averageCellConfidence: confidenceSum / (size * size),
|
|
3259
|
+
minimumCellConfidence: minimumConfidence,
|
|
3260
|
+
lowConfidenceCells
|
|
3261
|
+
};
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
function tryPerspectiveScan(imageData, options) {
|
|
3265
|
+
const geometryCandidates = detectCodeGeometry(imageData, {
|
|
3266
|
+
minVersion: options.minVersion ?? MIN_VERSION,
|
|
3267
|
+
maxVersion: options.maxVersion ?? MAX_VERSION,
|
|
3268
|
+
maxCandidates: options.maxGeometryCandidates ?? 8
|
|
3269
|
+
});
|
|
3270
|
+
const results = [];
|
|
3271
|
+
|
|
3272
|
+
for (const geometry of geometryCandidates) {
|
|
3273
|
+
try {
|
|
3274
|
+
const layout = createLayout(geometry.version);
|
|
3275
|
+
const sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, {
|
|
3276
|
+
sampleRadius: options.sampleRadius ?? 0.16
|
|
3277
|
+
});
|
|
3278
|
+
const observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration);
|
|
3279
|
+
const classifier = classifierFromPaletteRgb(observedPalette);
|
|
3280
|
+
const classified = classifySampledRgbGrid(sampled.rgbGrid, classifier, layout);
|
|
3281
|
+
const decoded = decodeMatrix(classified.matrix, {
|
|
3282
|
+
structureTolerance: options.structureTolerance ?? 0.18,
|
|
3283
|
+
cellConfidence: classified.confidence,
|
|
3284
|
+
maxErasureConfidence: options.maxErasureConfidence
|
|
3285
|
+
});
|
|
3286
|
+
if (decoded.version !== geometry.version) continue;
|
|
3287
|
+
|
|
3288
|
+
results.push({
|
|
3289
|
+
...decoded,
|
|
3290
|
+
perspectiveCorrected: true,
|
|
3291
|
+
colorCalibrated: true,
|
|
3292
|
+
geometry,
|
|
3293
|
+
observedPalette,
|
|
3294
|
+
averageColorDistance: classified.averageColorDistance,
|
|
3295
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
3296
|
+
minimumCellConfidence: classified.minimumCellConfidence,
|
|
3297
|
+
lowConfidenceCells: classified.lowConfidenceCells,
|
|
3298
|
+
rectified: options.includeRectified
|
|
3299
|
+
? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
|
|
3300
|
+
: undefined
|
|
3301
|
+
});
|
|
3302
|
+
} catch {
|
|
3303
|
+
// Continue trying geometry hypotheses.
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
results.sort((a, b) =>
|
|
3308
|
+
(a.correctedSymbols - b.correctedSymbols) ||
|
|
3309
|
+
(a.averageColorDistance - b.averageColorDistance) ||
|
|
3310
|
+
(b.geometry.score - a.geometry.score)
|
|
3311
|
+
);
|
|
3312
|
+
return results[0] ?? null;
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3315
|
+
function tryAxisAlignedScan(imageData, options) {
|
|
3316
|
+
const bounds = options.bounds ?? findActiveBounds(imageData, options.whiteThreshold ?? 238);
|
|
3317
|
+
const fixedClassifier = classifierFromPaletteRgb(paletteRgb(options.palette));
|
|
3318
|
+
const minVersion = options.minVersion ?? MIN_VERSION;
|
|
3319
|
+
const maxVersion = options.maxVersion ?? MAX_VERSION;
|
|
3320
|
+
const candidates = [];
|
|
3321
|
+
|
|
3322
|
+
for (let version = minVersion; version <= maxVersion; version++) {
|
|
3323
|
+
const size = sizeForVersion(version);
|
|
3324
|
+
const moduleW = bounds.width / size;
|
|
3325
|
+
const moduleH = bounds.height / size;
|
|
3326
|
+
if (moduleW < 0.75 || moduleH < 0.75) continue;
|
|
3327
|
+
const aspectError = Math.abs(moduleW - moduleH) / Math.max(moduleW, moduleH);
|
|
3328
|
+
if (aspectError > (options.maxModuleAspectError ?? 0.16)) continue;
|
|
3329
|
+
|
|
3330
|
+
try {
|
|
3331
|
+
const sampled = sampleAxisAlignedGrid(imageData, bounds, size, options.sampleRadius ?? 0.18);
|
|
3332
|
+
const classifierAttempts = [];
|
|
3333
|
+
const layout = createLayout(version);
|
|
3334
|
+
try {
|
|
3335
|
+
const observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration);
|
|
3336
|
+
classifierAttempts.push({ classifier: classifierFromPaletteRgb(observedPalette), calibrated: true });
|
|
3337
|
+
} catch {
|
|
3338
|
+
// Fixed palette fallback below.
|
|
3339
|
+
}
|
|
3340
|
+
classifierAttempts.push({ classifier: fixedClassifier, calibrated: false });
|
|
3341
|
+
|
|
3342
|
+
let accepted = false;
|
|
3343
|
+
for (const attempt of classifierAttempts) {
|
|
3344
|
+
try {
|
|
3345
|
+
const classified = classifySampledRgbGrid(sampled.rgbGrid, attempt.classifier, layout);
|
|
3346
|
+
const decoded = decodeMatrix(classified.matrix, {
|
|
3347
|
+
structureTolerance: options.structureTolerance ?? 0.12,
|
|
3348
|
+
cellConfidence: classified.confidence,
|
|
3349
|
+
maxErasureConfidence: options.maxErasureConfidence
|
|
3350
|
+
});
|
|
3351
|
+
candidates.push({
|
|
3352
|
+
...decoded,
|
|
3353
|
+
bounds,
|
|
3354
|
+
sampledVersion: version,
|
|
3355
|
+
moduleWidth: moduleW,
|
|
3356
|
+
moduleHeight: moduleH,
|
|
3357
|
+
perspectiveCorrected: false,
|
|
3358
|
+
colorCalibrated: attempt.calibrated,
|
|
3359
|
+
averageColorDistance: classified.averageColorDistance,
|
|
3360
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
3361
|
+
minimumCellConfidence: classified.minimumCellConfidence,
|
|
3362
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
3363
|
+
});
|
|
3364
|
+
accepted = true;
|
|
3365
|
+
break;
|
|
3366
|
+
} catch {
|
|
3367
|
+
// Try next classifier.
|
|
3368
|
+
}
|
|
3369
|
+
}
|
|
3370
|
+
if (!accepted) throw new Error("Axis-aligned candidate did not decode.");
|
|
3371
|
+
} catch {
|
|
3372
|
+
// Try next version.
|
|
3373
|
+
}
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
candidates.sort((a, b) =>
|
|
3377
|
+
(a.correctedSymbols - b.correctedSymbols) || (a.averageColorDistance - b.averageColorDistance)
|
|
3378
|
+
);
|
|
3379
|
+
return candidates[0] ?? null;
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
function scanImageData(imageData, options = {}) {
|
|
3383
|
+
assert(imageData && imageData.data && imageData.width && imageData.height, "Valid ImageData is required.");
|
|
3384
|
+
const minVersion = options.minVersion ?? MIN_VERSION;
|
|
3385
|
+
const maxVersion = options.maxVersion ?? MAX_VERSION;
|
|
3386
|
+
validateVersion(minVersion);
|
|
3387
|
+
validateVersion(maxVersion);
|
|
3388
|
+
|
|
3389
|
+
if (options.perspective !== false) {
|
|
3390
|
+
const perspective = tryPerspectiveScan(imageData, options);
|
|
3391
|
+
if (perspective) return perspective;
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
if (options.axisAlignedFallback !== false) {
|
|
3395
|
+
const axis = tryAxisAlignedScan(imageData, options);
|
|
3396
|
+
if (axis) return axis;
|
|
3397
|
+
}
|
|
3398
|
+
|
|
3399
|
+
throw new Error(
|
|
3400
|
+
"No valid QuadQR code found. Try better lighting, fill more of the frame, keep all locator patterns visible, or use a less blurred image."
|
|
3401
|
+
);
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
async function scanFile(file, options = {}) {
|
|
3405
|
+
assert(typeof document !== "undefined", "scanFile is a browser API.");
|
|
3406
|
+
assert(file, "A file is required.");
|
|
3407
|
+
let bitmap;
|
|
3408
|
+
|
|
3409
|
+
if (typeof createImageBitmap === "function") {
|
|
3410
|
+
bitmap = await createImageBitmap(file);
|
|
3411
|
+
} else {
|
|
3412
|
+
bitmap = await new Promise((resolve, reject) => {
|
|
3413
|
+
const img = new Image();
|
|
3414
|
+
const url = URL.createObjectURL(file);
|
|
3415
|
+
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
|
|
3416
|
+
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("Unable to load image.")); };
|
|
3417
|
+
img.src = url;
|
|
3418
|
+
});
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
const canvas = document.createElement("canvas");
|
|
3422
|
+
canvas.width = bitmap.width;
|
|
3423
|
+
canvas.height = bitmap.height;
|
|
3424
|
+
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
3425
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
3426
|
+
if (typeof bitmap.close === "function") bitmap.close();
|
|
3427
|
+
return scanImageData(ctx.getImageData(0, 0, canvas.width, canvas.height), options);
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
function scanVideoFrame(video, options = {}) {
|
|
3431
|
+
assert(typeof document !== "undefined", "scanVideoFrame is a browser API.");
|
|
3432
|
+
assert(video && video.videoWidth && video.videoHeight, "Video frame is not ready.");
|
|
3433
|
+
const maxDimension = options.maxDimension ?? 960;
|
|
3434
|
+
const scale = Math.min(1, maxDimension / Math.max(video.videoWidth, video.videoHeight));
|
|
3435
|
+
const width = Math.max(1, Math.round(video.videoWidth * scale));
|
|
3436
|
+
const height = Math.max(1, Math.round(video.videoHeight * scale));
|
|
3437
|
+
const canvas = options.canvas ?? document.createElement("canvas");
|
|
3438
|
+
canvas.width = width;
|
|
3439
|
+
canvas.height = height;
|
|
3440
|
+
const ctx = canvas.getContext("2d", { alpha: false, willReadFrequently: true });
|
|
3441
|
+
ctx.drawImage(video, 0, 0, width, height);
|
|
3442
|
+
return scanImageData(ctx.getImageData(0, 0, width, height), options);
|
|
3443
|
+
}
|
|
3444
|
+
|
|
3445
|
+
async function startCameraScanner(video, options = {}) {
|
|
3446
|
+
assert(typeof navigator !== "undefined" && navigator.mediaDevices?.getUserMedia, "Camera API is unavailable.");
|
|
3447
|
+
assert(video, "A video element is required.");
|
|
3448
|
+
|
|
3449
|
+
const stream = await navigator.mediaDevices.getUserMedia(
|
|
3450
|
+
options.constraints ?? {
|
|
3451
|
+
audio: false,
|
|
3452
|
+
video: {
|
|
3453
|
+
facingMode: { ideal: "environment" },
|
|
3454
|
+
width: { ideal: 1280 },
|
|
3455
|
+
height: { ideal: 720 }
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
);
|
|
3459
|
+
|
|
3460
|
+
video.srcObject = stream;
|
|
3461
|
+
video.setAttribute("playsinline", "");
|
|
3462
|
+
video.muted = true;
|
|
3463
|
+
await video.play();
|
|
3464
|
+
|
|
3465
|
+
const scanInterval = Math.max(80, options.scanInterval ?? 180);
|
|
3466
|
+
const scratchCanvas = document.createElement("canvas");
|
|
3467
|
+
let stopped = false;
|
|
3468
|
+
let busy = false;
|
|
3469
|
+
let timer = null;
|
|
3470
|
+
|
|
3471
|
+
const stop = () => {
|
|
3472
|
+
stopped = true;
|
|
3473
|
+
if (timer) clearTimeout(timer);
|
|
3474
|
+
for (const track of stream.getTracks()) track.stop();
|
|
3475
|
+
if (video.srcObject === stream) video.srcObject = null;
|
|
3476
|
+
};
|
|
3477
|
+
|
|
3478
|
+
const scanNow = () => scanVideoFrame(video, { ...options, canvas: scratchCanvas });
|
|
3479
|
+
|
|
3480
|
+
const loop = async () => {
|
|
3481
|
+
if (stopped) return;
|
|
3482
|
+
if (!busy && video.readyState >= 2) {
|
|
3483
|
+
busy = true;
|
|
3484
|
+
try {
|
|
3485
|
+
const result = scanNow();
|
|
3486
|
+
options.onResult?.(result);
|
|
3487
|
+
if (options.stopOnResult ?? true) {
|
|
3488
|
+
stop();
|
|
3489
|
+
return;
|
|
3490
|
+
}
|
|
3491
|
+
} catch (error) {
|
|
3492
|
+
options.onScanMiss?.(error);
|
|
3493
|
+
} finally {
|
|
3494
|
+
busy = false;
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
timer = setTimeout(loop, scanInterval);
|
|
3498
|
+
};
|
|
3499
|
+
|
|
3500
|
+
timer = setTimeout(loop, 0);
|
|
3501
|
+
return { stream, stop, scanNow, video };
|
|
3502
|
+
}
|
|
3503
|
+
|
|
3504
|
+
function rectifyDetectedCode(imageData, options = {}) {
|
|
3505
|
+
const candidates = detectCodeGeometry(imageData, {
|
|
3506
|
+
minVersion: options.minVersion ?? MIN_VERSION,
|
|
3507
|
+
maxVersion: options.maxVersion ?? MAX_VERSION,
|
|
3508
|
+
maxCandidates: 1
|
|
3509
|
+
});
|
|
3510
|
+
if (!candidates.length) throw new Error("Unable to locate QuadQR geometry.");
|
|
3511
|
+
const geometry = candidates[0];
|
|
3512
|
+
return {
|
|
3513
|
+
geometry,
|
|
3514
|
+
imageData: rectifyImageData(
|
|
3515
|
+
imageData,
|
|
3516
|
+
geometry.homography,
|
|
3517
|
+
sizeForVersion(geometry.version),
|
|
3518
|
+
options.moduleSize ?? 8
|
|
3519
|
+
)
|
|
3520
|
+
};
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
function rotateMatrix(matrix, quarterTurns = 1) {
|
|
3524
|
+
let out = cloneMatrix(matrix);
|
|
3525
|
+
const turns = ((quarterTurns % 4) + 4) % 4;
|
|
3526
|
+
for (let i = 0; i < turns; i++) out = rotate90(out);
|
|
3527
|
+
return out;
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3530
|
+
const internals = Object.freeze({
|
|
3531
|
+
sizeForVersion,
|
|
3532
|
+
versionFromSize,
|
|
3533
|
+
streamCellCount,
|
|
3534
|
+
createLayout,
|
|
3535
|
+
alignmentPatternCentersForVersion,
|
|
3536
|
+
bytesToCells,
|
|
3537
|
+
cellsToBytes,
|
|
3538
|
+
getBodyRsPlan,
|
|
3539
|
+
getHeaderPlan,
|
|
3540
|
+
interleaveBlocks,
|
|
3541
|
+
deinterleaveBlocks,
|
|
3542
|
+
spectralPermutation,
|
|
3543
|
+
applyData,
|
|
3544
|
+
unmaskCells,
|
|
3545
|
+
restoreLogicalOrder,
|
|
3546
|
+
cellsToSymbolConfidences,
|
|
3547
|
+
decodeRsAdaptive,
|
|
3548
|
+
encodeProtectedHeader,
|
|
3549
|
+
decodeProtectedHeader,
|
|
3550
|
+
HEADER_CODEWORD_CELLS,
|
|
3551
|
+
COMPACT_HEADER_CODEWORD_CELLS,
|
|
3552
|
+
CELLS_PER_BYTE,
|
|
3553
|
+
TEXT_FLAG,
|
|
3554
|
+
SECURE_FLAG
|
|
3555
|
+
});
|
|
3556
|
+
|
|
3557
|
+
return { installCrc32Accelerator, crc32, getVersionInfo, encodeText, encodeBytes, encodeSecureText, encodeSecureBytes, decryptDecoded, decodeMatrix, renderToCanvas, renderToImageData, scanImageData, scanFile, scanVideoFrame, startCameraScanner, rectifyDetectedCode, rotateMatrix, FORMAT_VERSION, MIN_VERSION, MAX_VERSION, DEFAULT_ECC_LEVEL, CELL, DEFAULT_PALETTE, RENDER_STYLES, ECC_LEVELS, internals, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex };
|
|
3558
|
+
})();
|
|
3559
|
+
const QuadQR = __qqr_quadqr_js;
|
|
3560
|
+
|
|
3561
|
+
QuadQR.initWasm = async function initWasm(options = {}) {
|
|
3562
|
+
const url = options.url
|
|
3563
|
+
? new URL(options.url, __qqrScriptSrc || (typeof location !== "undefined" ? location.href : undefined))
|
|
3564
|
+
: (__qqrScriptSrc
|
|
3565
|
+
? new URL("./wasm/quadqr-core.wasm", __qqrScriptSrc)
|
|
3566
|
+
: new URL("./wasm/quadqr-core.wasm", location.href));
|
|
3567
|
+
const response = options.bytes ? null : await fetch(url);
|
|
3568
|
+
if (response && !response.ok) throw new Error("Unable to load QuadQR WASM (" + response.status + ").");
|
|
3569
|
+
const bytes = options.bytes instanceof Uint8Array
|
|
3570
|
+
? options.bytes
|
|
3571
|
+
: options.bytes
|
|
3572
|
+
? new Uint8Array(options.bytes)
|
|
3573
|
+
: new Uint8Array(await response.arrayBuffer());
|
|
3574
|
+
const result = await WebAssembly.instantiate(bytes, {});
|
|
3575
|
+
const instance = result.instance;
|
|
3576
|
+
const memory = instance.exports.memory;
|
|
3577
|
+
const crc32Bytes = instance.exports.crc32_bytes;
|
|
3578
|
+
const heapExport = instance.exports.__heap_base;
|
|
3579
|
+
const heapBase = Number((heapExport && heapExport.value) || heapExport || 65536);
|
|
3580
|
+
if (!(memory instanceof WebAssembly.Memory) || typeof crc32Bytes !== "function") {
|
|
3581
|
+
throw new Error("QuadQR WASM exports are invalid.");
|
|
3582
|
+
}
|
|
3583
|
+
QuadQR.installCrc32Accelerator(function (input) {
|
|
3584
|
+
const data = input instanceof Uint8Array ? input : new Uint8Array(input);
|
|
3585
|
+
const required = heapBase + data.length;
|
|
3586
|
+
if (required > memory.buffer.byteLength) memory.grow(Math.ceil((required - memory.buffer.byteLength) / 65536));
|
|
3587
|
+
new Uint8Array(memory.buffer, heapBase, data.length).set(data);
|
|
3588
|
+
return crc32Bytes(heapBase, data.length) >>> 0;
|
|
3589
|
+
});
|
|
3590
|
+
return Object.freeze({ enabled: true, module: "quadqr-core", accelerators: Object.freeze(["crc32"]), bytes: bytes.length });
|
|
3591
|
+
};
|
|
3592
|
+
global.QuadQR = QuadQR;
|
|
3593
|
+
})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
|