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