quadqr-js 1.0.2 → 1.3.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 +40 -3
- package/README.md +199 -20
- package/SPECIFICATION.md +337 -0
- package/bin/quadqr.js +105 -14
- package/dist/esm/benchmark.js +113 -8
- package/dist/esm/node.js +13 -1
- package/dist/esm/quadqr.js +2312 -299
- package/dist/esm/vision.js +452 -14
- package/dist/quadqr.js +2746 -299
- package/dist/quadqr.min.js +2736 -297
- package/docs/API.md +230 -8
- package/docs/BROWSER_CDN.md +51 -6
- package/docs/CLI.md +156 -76
- package/docs/GETTING_STARTED.md +80 -7
- package/docs/HIGH_DENSITY_MODE.md +100 -0
- package/docs/NODE.md +137 -123
- package/docs/README.md +60 -41
- package/docs/TRIANGLE16.md +108 -0
- package/docs/WASM.md +2 -2
- package/package.json +10 -3
- package/types/benchmark.d.ts +4 -3
- package/types/index.d.ts +144 -2
- package/types/node.d.ts +2 -0
package/dist/esm/quadqr.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* QuadQR
|
|
3
3
|
*
|
|
4
|
-
* Experimental
|
|
4
|
+
* Experimental RGBW / Triangle16 matrix code written in pure JavaScript.
|
|
5
5
|
*
|
|
6
6
|
* Core format:
|
|
7
|
-
* - Red / Green / Blue / White data
|
|
8
|
-
* -
|
|
7
|
+
* - RGBW profile: Red / Green / Blue / White, exactly 2 bits per data cell
|
|
8
|
+
* - Triangle16 profile: two fixed RGBW triangle regions, 16 states / 4 bits per body data cell
|
|
9
9
|
* - GF(256) Reed-Solomon ECC over byte symbols
|
|
10
10
|
* - interleaved body ECC blocks
|
|
11
11
|
* - zero-overhead spectral-spatial cell placement
|
|
@@ -29,7 +29,10 @@ import {
|
|
|
29
29
|
} from "./geometry.js";
|
|
30
30
|
import {
|
|
31
31
|
detectCodeGeometry,
|
|
32
|
+
computeHomography,
|
|
33
|
+
projectPoint,
|
|
32
34
|
samplePerspectiveMatrix,
|
|
35
|
+
samplePerspectiveTriangleMatrix,
|
|
33
36
|
rectifyImageData,
|
|
34
37
|
sampleObservedPalette,
|
|
35
38
|
spatiallyNormalizeRgbGrid,
|
|
@@ -37,7 +40,8 @@ import {
|
|
|
37
40
|
autoToneContrastColorImageData,
|
|
38
41
|
autoColorImageData,
|
|
39
42
|
findActiveBounds,
|
|
40
|
-
sampleAxisAlignedGrid
|
|
43
|
+
sampleAxisAlignedGrid,
|
|
44
|
+
sampleAxisAlignedTriangleGrid
|
|
41
45
|
} from "./vision.js";
|
|
42
46
|
import {
|
|
43
47
|
decryptSecurePayload,
|
|
@@ -57,6 +61,34 @@ export const MIN_VERSION = 1;
|
|
|
57
61
|
export const MAX_VERSION = 40;
|
|
58
62
|
export const DEFAULT_ECC_LEVEL = "M";
|
|
59
63
|
|
|
64
|
+
export const RENDER_MODES = Object.freeze({
|
|
65
|
+
SCREEN: "screen",
|
|
66
|
+
PRINT: "print"
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export const CELL_ENCODINGS = Object.freeze({
|
|
70
|
+
RGBW: "rgbw",
|
|
71
|
+
TRIANGLE16: "triangle16"
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
// Print-safe defaults deliberately use darker, more ink-tolerant primaries and
|
|
76
|
+
// a true white background. Applications can still override individual colors.
|
|
77
|
+
export const PRINT_PALETTE = Object.freeze({
|
|
78
|
+
black: "#000000",
|
|
79
|
+
white: "#ffffff",
|
|
80
|
+
red: "#d71932",
|
|
81
|
+
green: "#087f3e",
|
|
82
|
+
blue: "#174ea6"
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
export const COMPRESSION_MODES = Object.freeze({
|
|
86
|
+
NONE: "none",
|
|
87
|
+
AUTO: "auto",
|
|
88
|
+
LZ: "lz"
|
|
89
|
+
});
|
|
90
|
+
export const SIGNATURE_ALGORITHMS = Object.freeze({ ED25519: "Ed25519" });
|
|
91
|
+
|
|
60
92
|
// Data cells are the four values 0..3. Structural white deliberately shares
|
|
61
93
|
// value 3 with data-white because both render and scan identically.
|
|
62
94
|
export const CELL = Object.freeze({
|
|
@@ -97,8 +129,10 @@ const MAGIC = new Uint8Array([0x51, 0x51, 0x52, 0x57]); // QQRW (QuadQR RGBW)
|
|
|
97
129
|
const HEADER_BYTES = 10;
|
|
98
130
|
const HEADER_RS_PARITY = 8;
|
|
99
131
|
const HEADER_CODEWORD_BYTES = HEADER_BYTES + HEADER_RS_PARITY;
|
|
100
|
-
const
|
|
101
|
-
const
|
|
132
|
+
const RGBW_CELLS_PER_BYTE = 4;
|
|
133
|
+
const TRIANGLE16_CELLS_PER_BYTE = 2;
|
|
134
|
+
const CELLS_PER_BYTE = RGBW_CELLS_PER_BYTE; // legacy internal alias
|
|
135
|
+
const HEADER_CODEWORD_CELLS = HEADER_CODEWORD_BYTES * RGBW_CELLS_PER_BYTE;
|
|
102
136
|
|
|
103
137
|
// Version 1 is a deliberately compact small-symbol profile. A 21x21 matrix
|
|
104
138
|
// cannot afford the normal 18-byte protected header plus a 24-byte M body
|
|
@@ -110,7 +144,7 @@ const COMPACT_HEADER_MAGIC = 0xc3;
|
|
|
110
144
|
const COMPACT_HEADER_BYTES = 4;
|
|
111
145
|
const COMPACT_HEADER_RS_PARITY = 4;
|
|
112
146
|
const COMPACT_HEADER_CODEWORD_BYTES = COMPACT_HEADER_BYTES + COMPACT_HEADER_RS_PARITY;
|
|
113
|
-
const COMPACT_HEADER_CODEWORD_CELLS = COMPACT_HEADER_CODEWORD_BYTES *
|
|
147
|
+
const COMPACT_HEADER_CODEWORD_CELLS = COMPACT_HEADER_CODEWORD_BYTES * RGBW_CELLS_PER_BYTE;
|
|
114
148
|
const COMPACT_ECC_LEVELS = Object.freeze({
|
|
115
149
|
L: Object.freeze({ paritySymbols: 4, correctableSymbolsPerBlock: 2 }),
|
|
116
150
|
M: Object.freeze({ paritySymbols: 8, correctableSymbolsPerBlock: 4 }),
|
|
@@ -121,6 +155,23 @@ const COMPACT_ECC_LEVELS = Object.freeze({
|
|
|
121
155
|
const CRC_BYTES = 4;
|
|
122
156
|
const TEXT_FLAG = 1;
|
|
123
157
|
const SECURE_FLAG = 1 << 3;
|
|
158
|
+
const EXTENDED_PAYLOAD_FLAG = 1 << 4;
|
|
159
|
+
const SIGNED_FLAG = 1 << 5;
|
|
160
|
+
const TRIANGLE16_FLAG = 1 << 6;
|
|
161
|
+
|
|
162
|
+
// Internal payload extension envelope. This is deliberately not a public
|
|
163
|
+
// payload mode or content-type system. It exists only when compression or
|
|
164
|
+
// signing needs metadata around an otherwise normal text/binary payload.
|
|
165
|
+
const PAYLOAD_ENVELOPE_VERSION = 2;
|
|
166
|
+
const PAYLOAD_ENVELOPE_MAGIC = new Uint8Array([0x51, 0x50, 0x58, 0x31]); // QPX1
|
|
167
|
+
const PAYLOAD_ENVELOPE_HEADER_BYTES = 16;
|
|
168
|
+
const PAYLOAD_ENVELOPE_SIGNED_FLAG = 1;
|
|
169
|
+
const PAYLOAD_ENVELOPE_COMPRESSED_FLAG = 1 << 1;
|
|
170
|
+
const PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG = 1 << 2;
|
|
171
|
+
const PAYLOAD_ENVELOPE_COMPRESSION_IDS = Object.freeze({ none: 0, lz: 1 });
|
|
172
|
+
const PAYLOAD_ENVELOPE_COMPRESSION_BY_ID = Object.freeze({ 0: "none", 1: "lz" });
|
|
173
|
+
const PAYLOAD_ENVELOPE_SIGNATURE_IDS = Object.freeze({ none: 0, Ed25519: 1 });
|
|
174
|
+
const PAYLOAD_ENVELOPE_SIGNATURE_BY_ID = Object.freeze({ 0: null, 1: "Ed25519" });
|
|
124
175
|
const ECC_SHIFT = 1;
|
|
125
176
|
const ECC_MASK = 0b00000110;
|
|
126
177
|
|
|
@@ -169,6 +220,45 @@ function normalizeEccLevel(level = DEFAULT_ECC_LEVEL) {
|
|
|
169
220
|
return value;
|
|
170
221
|
}
|
|
171
222
|
|
|
223
|
+
function normalizeCellEncoding(value = CELL_ENCODINGS.RGBW) {
|
|
224
|
+
const key = String(value).toLowerCase();
|
|
225
|
+
assert(
|
|
226
|
+
key === CELL_ENCODINGS.RGBW || key === CELL_ENCODINGS.TRIANGLE16,
|
|
227
|
+
`Internal cell encoding must be ${CELL_ENCODINGS.RGBW} or ${CELL_ENCODINGS.TRIANGLE16}.`
|
|
228
|
+
);
|
|
229
|
+
return key;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Public API presents Triangle16 as one experimental High Density Mode toggle.
|
|
233
|
+
// cellEncoding remains an internal/backward-compatible detail so existing
|
|
234
|
+
// experimental branch symbols and renderer metadata continue to work.
|
|
235
|
+
function cellEncodingFromOptions(options = {}) {
|
|
236
|
+
if (typeof options.highDensity === "boolean") {
|
|
237
|
+
return options.highDensity ? CELL_ENCODINGS.TRIANGLE16 : CELL_ENCODINGS.RGBW;
|
|
238
|
+
}
|
|
239
|
+
return normalizeCellEncoding(options.cellEncoding ?? CELL_ENCODINGS.RGBW);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function cellsPerByteForEncoding(cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
243
|
+
return normalizeCellEncoding(cellEncoding) === CELL_ENCODINGS.TRIANGLE16
|
|
244
|
+
? TRIANGLE16_CELLS_PER_BYTE
|
|
245
|
+
: RGBW_CELLS_PER_BYTE;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function packTriangleCell(first, second) {
|
|
249
|
+
assert(first >= 0 && first <= 3 && second >= 0 && second <= 3, "Invalid Triangle16 color.");
|
|
250
|
+
return ((first & 3) << 2) | (second & 3);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function unpackTriangleCell(value) {
|
|
254
|
+
assert(Number.isInteger(value) && value >= 0 && value <= 15, "Invalid Triangle16 data cell.");
|
|
255
|
+
return { first: (value >>> 2) & 3, second: value & 3 };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function solidTriangleCell(color) {
|
|
259
|
+
return packTriangleCell(color, color);
|
|
260
|
+
}
|
|
261
|
+
|
|
172
262
|
function concatBytes(...arrays) {
|
|
173
263
|
const total = arrays.reduce((sum, item) => sum + item.length, 0);
|
|
174
264
|
const out = new Uint8Array(total);
|
|
@@ -199,6 +289,368 @@ function readU32be(bytes, offset = 0) {
|
|
|
199
289
|
) >>> 0;
|
|
200
290
|
}
|
|
201
291
|
|
|
292
|
+
function u16be(value) {
|
|
293
|
+
const v = Number(value) >>> 0;
|
|
294
|
+
assert(v <= 0xffff, "Value does not fit in uint16.");
|
|
295
|
+
return new Uint8Array([(v >>> 8) & 0xff, v & 0xff]);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function readU16be(bytes, offset = 0) {
|
|
299
|
+
return ((bytes[offset] << 8) | bytes[offset + 1]) >>> 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function normalizeCompressionMode(value = "none") {
|
|
303
|
+
const key = String(value).toLowerCase();
|
|
304
|
+
assert(["none", "auto", "lz"].includes(key), "compression must be none, auto, or lz.");
|
|
305
|
+
return key;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function asBytes(input) {
|
|
309
|
+
if (input instanceof Uint8Array) return input;
|
|
310
|
+
if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
311
|
+
if (input instanceof ArrayBuffer) return new Uint8Array(input);
|
|
312
|
+
return new Uint8Array(input);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Portable LZSS-style compressor used by QuadQR payload compression. */
|
|
316
|
+
export function compressPayload(input) {
|
|
317
|
+
const bytes = asBytes(input);
|
|
318
|
+
if (bytes.length < 4) return bytes.slice();
|
|
319
|
+
const out = [];
|
|
320
|
+
const recent = new Map();
|
|
321
|
+
let pos = 0;
|
|
322
|
+
|
|
323
|
+
const keyAt = (i) => i + 2 < bytes.length ? ((bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]) : -1;
|
|
324
|
+
const remember = (i) => {
|
|
325
|
+
const key = keyAt(i);
|
|
326
|
+
if (key < 0) return;
|
|
327
|
+
let list = recent.get(key);
|
|
328
|
+
if (!list) recent.set(key, list = []);
|
|
329
|
+
list.push(i);
|
|
330
|
+
while (list.length > 32) list.shift();
|
|
331
|
+
while (list.length && i - list[0] > 4095) list.shift();
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
while (pos < bytes.length) {
|
|
335
|
+
const flagIndex = out.length;
|
|
336
|
+
out.push(0);
|
|
337
|
+
let flags = 0;
|
|
338
|
+
for (let token = 0; token < 8 && pos < bytes.length; token++) {
|
|
339
|
+
let bestLength = 0;
|
|
340
|
+
let bestOffset = 0;
|
|
341
|
+
const key = keyAt(pos);
|
|
342
|
+
const candidates = key >= 0 ? (recent.get(key) ?? []) : [];
|
|
343
|
+
for (let ci = candidates.length - 1; ci >= 0; ci--) {
|
|
344
|
+
const candidate = candidates[ci];
|
|
345
|
+
const offset = pos - candidate;
|
|
346
|
+
if (offset <= 0 || offset > 4095) continue;
|
|
347
|
+
let length = 0;
|
|
348
|
+
while (length < 18 && pos + length < bytes.length && bytes[candidate + length] === bytes[pos + length]) length++;
|
|
349
|
+
if (length >= 3 && length > bestLength) {
|
|
350
|
+
bestLength = length;
|
|
351
|
+
bestOffset = offset;
|
|
352
|
+
if (length === 18) break;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (bestLength >= 3) {
|
|
357
|
+
flags |= (1 << token);
|
|
358
|
+
const encoded = ((bestOffset & 0x0fff) << 4) | ((bestLength - 3) & 0x0f);
|
|
359
|
+
out.push((encoded >>> 8) & 0xff, encoded & 0xff);
|
|
360
|
+
for (let i = 0; i < bestLength; i++) remember(pos + i);
|
|
361
|
+
pos += bestLength;
|
|
362
|
+
} else {
|
|
363
|
+
out.push(bytes[pos]);
|
|
364
|
+
remember(pos);
|
|
365
|
+
pos++;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
out[flagIndex] = flags;
|
|
369
|
+
}
|
|
370
|
+
return Uint8Array.from(out);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function decompressPayload(input, expectedLength = null) {
|
|
374
|
+
const bytes = asBytes(input);
|
|
375
|
+
const out = [];
|
|
376
|
+
let pos = 0;
|
|
377
|
+
while (pos < bytes.length) {
|
|
378
|
+
const flags = bytes[pos++];
|
|
379
|
+
for (let token = 0; token < 8 && pos < bytes.length; token++) {
|
|
380
|
+
if (flags & (1 << token)) {
|
|
381
|
+
assert(pos + 1 < bytes.length, "Compressed payload is truncated.");
|
|
382
|
+
const encoded = (bytes[pos++] << 8) | bytes[pos++];
|
|
383
|
+
const offset = (encoded >>> 4) & 0x0fff;
|
|
384
|
+
const length = (encoded & 0x0f) + 3;
|
|
385
|
+
assert(offset > 0 && offset <= out.length, "Compressed payload has an invalid back-reference.");
|
|
386
|
+
for (let i = 0; i < length; i++) out.push(out[out.length - offset]);
|
|
387
|
+
} else {
|
|
388
|
+
out.push(bytes[pos++]);
|
|
389
|
+
}
|
|
390
|
+
if (expectedLength != null && out.length >= expectedLength) break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (expectedLength != null) assert(out.length === expectedLength, "Decompressed payload length mismatch.");
|
|
394
|
+
return Uint8Array.from(out);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function prepareCompressedBody(payload, compression = "none") {
|
|
398
|
+
const mode = normalizeCompressionMode(compression);
|
|
399
|
+
if (mode === "none") return { body: payload, compression: "none" };
|
|
400
|
+
const compressed = compressPayload(payload);
|
|
401
|
+
if (mode === "auto" && compressed.length >= payload.length - 2) {
|
|
402
|
+
return { body: payload, compression: "none" };
|
|
403
|
+
}
|
|
404
|
+
return { body: compressed, compression: "lz" };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
function normalizeSigningKeyId(value) {
|
|
409
|
+
if (value == null || value === false || value === "") return new Uint8Array(0);
|
|
410
|
+
const bytes = typeof value === "string" ? getTextEncoder().encode(value) : asBytes(value);
|
|
411
|
+
assert(bytes.length <= 64, "Signing key ID must be 64 bytes or fewer.");
|
|
412
|
+
return bytes;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function makePayloadEnvelopeHeader({ compression, flags = 0, originalLength, keyIdLength = 0, publicKeyLength = 0, signatureLength = 0, signatureAlgorithm = null, version = PAYLOAD_ENVELOPE_VERSION }) {
|
|
416
|
+
assert(keyIdLength <= 0xff, "Signing key ID is too long.");
|
|
417
|
+
assert(publicKeyLength <= 0xff, "Signing public key is too large.");
|
|
418
|
+
assert(signatureLength <= 0xff, "Signature is too large.");
|
|
419
|
+
const header = new Uint8Array(PAYLOAD_ENVELOPE_HEADER_BYTES);
|
|
420
|
+
header.set(PAYLOAD_ENVELOPE_MAGIC, 0);
|
|
421
|
+
header[4] = version;
|
|
422
|
+
header[5] = flags & 0xff;
|
|
423
|
+
header[6] = PAYLOAD_ENVELOPE_COMPRESSION_IDS[compression];
|
|
424
|
+
header[7] = PAYLOAD_ENVELOPE_SIGNATURE_IDS[signatureAlgorithm ?? "none"] ?? 0;
|
|
425
|
+
header.set(u32be(originalLength), 8);
|
|
426
|
+
header[12] = keyIdLength;
|
|
427
|
+
header[13] = publicKeyLength;
|
|
428
|
+
header[14] = signatureLength;
|
|
429
|
+
header[15] = 0;
|
|
430
|
+
return header;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function isPayloadEnvelope(input) {
|
|
434
|
+
const bytes = asBytes(input);
|
|
435
|
+
return bytes.length >= PAYLOAD_ENVELOPE_HEADER_BYTES && PAYLOAD_ENVELOPE_MAGIC.every((value, index) => bytes[index] === value);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function packPayloadEnvelope(input, options = {}) {
|
|
439
|
+
const payload = asBytes(input);
|
|
440
|
+
const prepared = prepareCompressedBody(payload, options.compression ?? "none");
|
|
441
|
+
const flags = prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0;
|
|
442
|
+
const header = makePayloadEnvelopeHeader({
|
|
443
|
+
compression: prepared.compression,
|
|
444
|
+
flags,
|
|
445
|
+
originalLength: payload.length
|
|
446
|
+
});
|
|
447
|
+
return concatBytes(header, prepared.body);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function parsePayloadEnvelope(input) {
|
|
451
|
+
const container = asBytes(input);
|
|
452
|
+
assert(isPayloadEnvelope(container), "Payload extension envelope is not recognized.");
|
|
453
|
+
const version = container[4];
|
|
454
|
+
assert(version === 1 || version === PAYLOAD_ENVELOPE_VERSION, `Unsupported payload extension version ${version}.`);
|
|
455
|
+
const flags = container[5];
|
|
456
|
+
const compression = PAYLOAD_ENVELOPE_COMPRESSION_BY_ID[container[6]];
|
|
457
|
+
assert(compression != null, `Unknown payload compression id ${container[6]}.`);
|
|
458
|
+
const signatureAlgorithm = PAYLOAD_ENVELOPE_SIGNATURE_BY_ID[container[7]];
|
|
459
|
+
const originalLength = readU32be(container, 8);
|
|
460
|
+
const metadataLength = container[12];
|
|
461
|
+
const publicKeyLength = container[13];
|
|
462
|
+
const signatureLength = container[14];
|
|
463
|
+
let offset = PAYLOAD_ENVELOPE_HEADER_BYTES;
|
|
464
|
+
const endMetadata = offset + metadataLength;
|
|
465
|
+
const endPublicKey = endMetadata + publicKeyLength;
|
|
466
|
+
const endSignature = endPublicKey + signatureLength;
|
|
467
|
+
assert(endSignature <= container.length, "Payload extension length fields exceed the envelope size.");
|
|
468
|
+
const metadataBytes = container.slice(offset, endMetadata);
|
|
469
|
+
const publicKey = container.slice(endMetadata, endPublicKey);
|
|
470
|
+
const signature = container.slice(endPublicKey, endSignature);
|
|
471
|
+
const stored = container.slice(endSignature);
|
|
472
|
+
const payload = compression === "lz" ? decompressPayload(stored, originalLength) : stored;
|
|
473
|
+
assert(payload.length === originalLength, "Payload length mismatch after extension decoding.");
|
|
474
|
+
const signed = Boolean(flags & PAYLOAD_ENVELOPE_SIGNED_FLAG);
|
|
475
|
+
const signedBytes = signed ? concatBytes(container.slice(0, PAYLOAD_ENVELOPE_HEADER_BYTES), metadataBytes, publicKey, stored) : null;
|
|
476
|
+
|
|
477
|
+
// v1 used byte 12 for a human-readable signer label and always embedded
|
|
478
|
+
// the public key. v2 uses the same field for a compact key ID and makes
|
|
479
|
+
// public-key embedding optional.
|
|
480
|
+
const legacySigner = version === 1 && metadataLength ? getTextDecoder().decode(metadataBytes) : null;
|
|
481
|
+
const keyId = version >= 2 && metadataLength ? getTextDecoder().decode(metadataBytes) : null;
|
|
482
|
+
const keyIdHex = version >= 2 && metadataLength
|
|
483
|
+
? Array.from(metadataBytes, (value) => value.toString(16).padStart(2, "0")).join("")
|
|
484
|
+
: null;
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
container,
|
|
488
|
+
version,
|
|
489
|
+
compression,
|
|
490
|
+
compressed: compression !== "none",
|
|
491
|
+
originalBytes: originalLength,
|
|
492
|
+
storedBytes: stored.length,
|
|
493
|
+
savedBytes: originalLength - stored.length,
|
|
494
|
+
payload,
|
|
495
|
+
signed,
|
|
496
|
+
signer: legacySigner,
|
|
497
|
+
keyId,
|
|
498
|
+
keyIdHex,
|
|
499
|
+
publicKey,
|
|
500
|
+
hasEmbeddedPublicKey: publicKey.length > 0,
|
|
501
|
+
signature,
|
|
502
|
+
signatureAlgorithm,
|
|
503
|
+
signedBytes,
|
|
504
|
+
signatureVerified: signed ? null : undefined,
|
|
505
|
+
signatureTrusted: signed ? null : undefined
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function subtleCrypto() {
|
|
510
|
+
const subtle = globalThis.crypto?.subtle;
|
|
511
|
+
assert(subtle, "Web Crypto is required for QuadQR signing.");
|
|
512
|
+
return subtle;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function normalizeEd25519PublicKey(key) {
|
|
516
|
+
const subtle = subtleCrypto();
|
|
517
|
+
if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) return key;
|
|
518
|
+
if (key && typeof key === "object" && key.type === "public" && key.algorithm?.name === "Ed25519") return key;
|
|
519
|
+
const bytes = asBytes(key);
|
|
520
|
+
assert(bytes.length === 32, "Ed25519 public key must be a 32-byte raw key or CryptoKey.");
|
|
521
|
+
return subtle.importKey("raw", bytes, { name: "Ed25519" }, true, ["verify"]);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function normalizeEd25519PrivateKey(key) {
|
|
525
|
+
const subtle = subtleCrypto();
|
|
526
|
+
if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) return key;
|
|
527
|
+
if (key && typeof key === "object" && key.type === "private" && key.algorithm?.name === "Ed25519") return key;
|
|
528
|
+
const bytes = asBytes(key);
|
|
529
|
+
return subtle.importKey("pkcs8", bytes, { name: "Ed25519" }, false, ["sign"]);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export async function deriveSigningKeyId(publicKey, bytes = 8) {
|
|
533
|
+
const subtle = subtleCrypto();
|
|
534
|
+
const normalized = await normalizeEd25519PublicKey(publicKey);
|
|
535
|
+
const raw = new Uint8Array(await subtle.exportKey("raw", normalized));
|
|
536
|
+
const digest = new Uint8Array(await subtle.digest("SHA-256", raw));
|
|
537
|
+
const count = Math.max(4, Math.min(16, Math.floor(bytes)));
|
|
538
|
+
return Array.from(digest.slice(0, count), (value) => value.toString(16).padStart(2, "0")).join("");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export async function generateSigningKeyPair() {
|
|
542
|
+
const subtle = subtleCrypto();
|
|
543
|
+
const pair = await subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
|
|
544
|
+
const publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", pair.publicKey));
|
|
545
|
+
const privateKeyPkcs8 = new Uint8Array(await subtle.exportKey("pkcs8", pair.privateKey));
|
|
546
|
+
const keyId = await deriveSigningKeyId(pair.publicKey);
|
|
547
|
+
return {
|
|
548
|
+
privateKey: pair.privateKey,
|
|
549
|
+
publicKey: pair.publicKey,
|
|
550
|
+
privateKeyPkcs8,
|
|
551
|
+
publicKeyBytes,
|
|
552
|
+
keyId,
|
|
553
|
+
algorithm: "Ed25519"
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function packSignedPayloadEnvelope(input, options = {}) {
|
|
558
|
+
const payload = asBytes(input);
|
|
559
|
+
assert(options.privateKey, "privateKey is required to sign a QuadQR payload.");
|
|
560
|
+
const subtle = subtleCrypto();
|
|
561
|
+
const prepared = prepareCompressedBody(payload, options.compression ?? "auto");
|
|
562
|
+
const keyIdBytes = normalizeSigningKeyId(options.keyId);
|
|
563
|
+
|
|
564
|
+
let publicKeyBytes = new Uint8Array(0);
|
|
565
|
+
if (options.embedPublicKey) {
|
|
566
|
+
assert(options.publicKey, "publicKey is required when embedPublicKey is enabled.");
|
|
567
|
+
const publicKeyObject = await normalizeEd25519PublicKey(options.publicKey);
|
|
568
|
+
publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", publicKeyObject));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const flags = PAYLOAD_ENVELOPE_SIGNED_FLAG |
|
|
572
|
+
(prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0) |
|
|
573
|
+
(publicKeyBytes.length ? PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG : 0);
|
|
574
|
+
const header = makePayloadEnvelopeHeader({
|
|
575
|
+
compression: prepared.compression,
|
|
576
|
+
flags,
|
|
577
|
+
originalLength: payload.length,
|
|
578
|
+
keyIdLength: keyIdBytes.length,
|
|
579
|
+
publicKeyLength: publicKeyBytes.length,
|
|
580
|
+
signatureLength: 64,
|
|
581
|
+
signatureAlgorithm: "Ed25519"
|
|
582
|
+
});
|
|
583
|
+
const signedBytes = concatBytes(header, keyIdBytes, publicKeyBytes, prepared.body);
|
|
584
|
+
const privateKey = await normalizeEd25519PrivateKey(options.privateKey);
|
|
585
|
+
const signature = new Uint8Array(await subtle.sign({ name: "Ed25519" }, privateKey, signedBytes));
|
|
586
|
+
assert(signature.length === 64, "Unexpected Ed25519 signature length.");
|
|
587
|
+
return concatBytes(header, keyIdBytes, publicKeyBytes, signature, prepared.body);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function lookupTrustedSigningKey(trustedKeys, keyId) {
|
|
591
|
+
if (!trustedKeys || !keyId) return null;
|
|
592
|
+
if (trustedKeys instanceof Map) return trustedKeys.get(keyId) ?? null;
|
|
593
|
+
if (typeof trustedKeys === "object") return trustedKeys[keyId] ?? null;
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function verifyPayloadEnvelopeSignature(input, options = {}) {
|
|
598
|
+
const parsed = input?.container && input?.signedBytes ? input : parsePayloadEnvelope(input);
|
|
599
|
+
assert(parsed.signed, "QuadQR payload is not signed.");
|
|
600
|
+
assert(parsed.signatureAlgorithm === "Ed25519", "Unsupported signature algorithm.");
|
|
601
|
+
const subtle = subtleCrypto();
|
|
602
|
+
|
|
603
|
+
let trusted = options.publicKey ?? options.trustedPublicKey ?? lookupTrustedSigningKey(options.trustedKeys, parsed.keyId);
|
|
604
|
+
let trustSource = trusted ? "external" : null;
|
|
605
|
+
|
|
606
|
+
// Legacy v1 symbols embedded the public key by default, so preserve their
|
|
607
|
+
// historical self-verification behavior. v2 requires explicit opt-in.
|
|
608
|
+
if (!trusted && parsed.publicKey.length && (parsed.version === 1 || options.allowEmbeddedKey === true)) {
|
|
609
|
+
trusted = parsed.publicKey;
|
|
610
|
+
trustSource = "embedded";
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
assert(
|
|
614
|
+
trusted,
|
|
615
|
+
parsed.keyId
|
|
616
|
+
? `A trusted Ed25519 public key is required to verify signing key ID "${parsed.keyId}".`
|
|
617
|
+
: "A trusted Ed25519 public key is required to verify this signed QuadQR."
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
const publicKey = await normalizeEd25519PublicKey(trusted);
|
|
621
|
+
if (trustSource === "external" && parsed.publicKey.length) {
|
|
622
|
+
const trustedBytes = new Uint8Array(await subtle.exportKey("raw", publicKey));
|
|
623
|
+
assert(
|
|
624
|
+
trustedBytes.length === parsed.publicKey.length && trustedBytes.every((value, index) => value === parsed.publicKey[index]),
|
|
625
|
+
"Embedded signing key does not match the trusted public key."
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const verified = await subtle.verify({ name: "Ed25519" }, publicKey, parsed.signature, parsed.signedBytes);
|
|
630
|
+
return {
|
|
631
|
+
verified,
|
|
632
|
+
trusted: verified && trustSource === "external",
|
|
633
|
+
trustSource,
|
|
634
|
+
keyId: parsed.keyId ?? null
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function prepareOptionalCompressedPayload(input, compression = "none") {
|
|
639
|
+
const payload = asBytes(input);
|
|
640
|
+
const mode = normalizeCompressionMode(compression);
|
|
641
|
+
if (mode === "none") return { payload, extended: false, compression: "none" };
|
|
642
|
+
const prepared = prepareCompressedBody(payload, mode);
|
|
643
|
+
// Auto mode is truly zero-overhead when compression does not help.
|
|
644
|
+
if (mode === "auto" && prepared.compression === "none") {
|
|
645
|
+
return { payload, extended: false, compression: "none" };
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
payload: packPayloadEnvelope(payload, { compression: prepared.compression }),
|
|
649
|
+
extended: true,
|
|
650
|
+
compression: prepared.compression
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
202
654
|
let CRC_TABLE = null;
|
|
203
655
|
let CRC32_ACCELERATOR = null;
|
|
204
656
|
|
|
@@ -234,8 +686,19 @@ export function crc32(bytes) {
|
|
|
234
686
|
return (crc ^ 0xffffffff) >>> 0;
|
|
235
687
|
}
|
|
236
688
|
|
|
237
|
-
function bytesToCells(bytes) {
|
|
238
|
-
const
|
|
689
|
+
function bytesToCells(bytes, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
690
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
691
|
+
if (encoding === CELL_ENCODINGS.TRIANGLE16) {
|
|
692
|
+
const out = new Array(bytes.length * TRIANGLE16_CELLS_PER_BYTE);
|
|
693
|
+
let cursor = 0;
|
|
694
|
+
for (const byte of bytes) {
|
|
695
|
+
out[cursor++] = (byte >>> 4) & 0x0f;
|
|
696
|
+
out[cursor++] = byte & 0x0f;
|
|
697
|
+
}
|
|
698
|
+
return out;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const out = new Array(bytes.length * RGBW_CELLS_PER_BYTE);
|
|
239
702
|
let cursor = 0;
|
|
240
703
|
for (const byte of bytes) {
|
|
241
704
|
out[cursor++] = (byte >>> 6) & 0b11;
|
|
@@ -246,11 +709,36 @@ function bytesToCells(bytes) {
|
|
|
246
709
|
return out;
|
|
247
710
|
}
|
|
248
711
|
|
|
249
|
-
function
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
712
|
+
function bytesToProtectedHeaderCells(bytes, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
713
|
+
const base = bytesToCells(bytes, CELL_ENCODINGS.RGBW);
|
|
714
|
+
if (normalizeCellEncoding(cellEncoding) !== CELL_ENCODINGS.TRIANGLE16) return base;
|
|
715
|
+
// Triangle16 deliberately keeps the protected bootstrap/header as solid-color
|
|
716
|
+
// modules. This costs a few cells but makes mode detection and damaged-camera
|
|
717
|
+
// recovery substantially more reliable.
|
|
718
|
+
return base.map(solidTriangleCell);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function cellsToBytes(cells, byteCount, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
722
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
723
|
+
const cellsPerByte = cellsPerByteForEncoding(encoding);
|
|
724
|
+
const count = byteCount ?? Math.floor(cells.length / cellsPerByte);
|
|
725
|
+
assert(cells.length >= count * cellsPerByte, "Not enough data cells to rebuild bytes.");
|
|
726
|
+
const out = new Uint8Array(count);
|
|
727
|
+
|
|
728
|
+
if (encoding === CELL_ENCODINGS.TRIANGLE16) {
|
|
729
|
+
for (let i = 0; i < count; i++) {
|
|
730
|
+
const offset = i * TRIANGLE16_CELLS_PER_BYTE;
|
|
731
|
+
const a = cells[offset];
|
|
732
|
+
const b = cells[offset + 1];
|
|
733
|
+
assert(Number.isInteger(a) && a >= 0 && a <= 15, "Invalid Triangle16 data cell.");
|
|
734
|
+
assert(Number.isInteger(b) && b >= 0 && b <= 15, "Invalid Triangle16 data cell.");
|
|
735
|
+
out[i] = (a << 4) | b;
|
|
736
|
+
}
|
|
737
|
+
return out;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
for (let i = 0; i < count; i++) {
|
|
741
|
+
const offset = i * RGBW_CELLS_PER_BYTE;
|
|
254
742
|
const a = cells[offset];
|
|
255
743
|
const b = cells[offset + 1];
|
|
256
744
|
const c = cells[offset + 2];
|
|
@@ -263,12 +751,31 @@ function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYT
|
|
|
263
751
|
return out;
|
|
264
752
|
}
|
|
265
753
|
|
|
266
|
-
function
|
|
754
|
+
function protectedHeaderCellsToBytes(cells, byteCount, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
755
|
+
if (normalizeCellEncoding(cellEncoding) !== CELL_ENCODINGS.TRIANGLE16) {
|
|
756
|
+
return cellsToBytes(cells, byteCount, CELL_ENCODINGS.RGBW);
|
|
757
|
+
}
|
|
758
|
+
const colors = cells.map((value) => {
|
|
759
|
+
const pair = unpackTriangleCell(value);
|
|
760
|
+
if (pair.first !== pair.second) throw new Error("Triangle16 protected header lost solid-color integrity.");
|
|
761
|
+
return pair.first;
|
|
762
|
+
});
|
|
763
|
+
return cellsToBytes(colors, byteCount, CELL_ENCODINGS.RGBW);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function flagsFor(text, eccLevel, secure = false, extended = false, signed = false, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
267
767
|
return (text ? TEXT_FLAG : 0) |
|
|
268
768
|
(secure ? SECURE_FLAG : 0) |
|
|
769
|
+
(extended ? EXTENDED_PAYLOAD_FLAG : 0) |
|
|
770
|
+
(signed ? SIGNED_FLAG : 0) |
|
|
771
|
+
(normalizeCellEncoding(cellEncoding) === CELL_ENCODINGS.TRIANGLE16 ? TRIANGLE16_FLAG : 0) |
|
|
269
772
|
((ECC_LEVELS[eccLevel].id << ECC_SHIFT) & ECC_MASK);
|
|
270
773
|
}
|
|
271
774
|
|
|
775
|
+
function cellEncodingFromFlags(flags) {
|
|
776
|
+
return (flags & TRIANGLE16_FLAG) !== 0 ? CELL_ENCODINGS.TRIANGLE16 : CELL_ENCODINGS.RGBW;
|
|
777
|
+
}
|
|
778
|
+
|
|
272
779
|
function eccFromFlags(flags) {
|
|
273
780
|
const id = (flags & ECC_MASK) >> ECC_SHIFT;
|
|
274
781
|
const name = ECC_BY_ID[id];
|
|
@@ -518,7 +1025,8 @@ function maskValue(row, col, maskId) {
|
|
|
518
1025
|
}
|
|
519
1026
|
}
|
|
520
1027
|
|
|
521
|
-
function makePaddingCells(count, seed) {
|
|
1028
|
+
function makePaddingCells(count, seed, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1029
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
522
1030
|
let state = (seed >>> 0) || 0x6d2b79f5;
|
|
523
1031
|
const out = new Array(count);
|
|
524
1032
|
for (let i = 0; i < count; i++) {
|
|
@@ -526,11 +1034,27 @@ function makePaddingCells(count, seed) {
|
|
|
526
1034
|
state ^= state >>> 17;
|
|
527
1035
|
state ^= state << 5;
|
|
528
1036
|
state >>>= 0;
|
|
529
|
-
out[i] = state & 3;
|
|
1037
|
+
out[i] = encoding === CELL_ENCODINGS.TRIANGLE16 ? (state & 0x0f) : (state & 3);
|
|
530
1038
|
}
|
|
531
1039
|
return out;
|
|
532
1040
|
}
|
|
533
1041
|
|
|
1042
|
+
function triangleMaskValue(row, col, maskId) {
|
|
1043
|
+
const first = maskValue(row, col, maskId);
|
|
1044
|
+
const second = maskValue(row + 1, col + 2, (maskId + 1) & 3);
|
|
1045
|
+
return packTriangleCell(first, second);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function applyCellMask(value, row, col, maskId, cellEncoding, protectedHeader = false) {
|
|
1049
|
+
if (cellEncoding !== CELL_ENCODINGS.TRIANGLE16) return value ^ maskValue(row, col, maskId);
|
|
1050
|
+
if (protectedHeader) {
|
|
1051
|
+
const pair = unpackTriangleCell(value);
|
|
1052
|
+
if (pair.first !== pair.second) throw new Error("Triangle16 protected header must use solid-color cells.");
|
|
1053
|
+
return solidTriangleCell(pair.first ^ maskValue(row, col, maskId));
|
|
1054
|
+
}
|
|
1055
|
+
return value ^ triangleMaskValue(row, col, maskId);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
534
1058
|
const SPECTRAL_PERMUTATION_CACHE = new Map();
|
|
535
1059
|
|
|
536
1060
|
function spectralPermutation(length, version) {
|
|
@@ -559,7 +1083,15 @@ function spectralPermutation(length, version) {
|
|
|
559
1083
|
return permutation;
|
|
560
1084
|
}
|
|
561
1085
|
|
|
562
|
-
function applyData(
|
|
1086
|
+
function applyData(
|
|
1087
|
+
layout,
|
|
1088
|
+
rawCells,
|
|
1089
|
+
maskId,
|
|
1090
|
+
spectralInterleaving = true,
|
|
1091
|
+
cellEncoding = CELL_ENCODINGS.RGBW,
|
|
1092
|
+
protectedHeaderCells = getHeaderPlan(layout.version).codewordCells
|
|
1093
|
+
) {
|
|
1094
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
563
1095
|
const matrix = cloneMatrix(layout.matrix);
|
|
564
1096
|
const permutation = spectralInterleaving
|
|
565
1097
|
? spectralPermutation(layout.dataPositions.length, layout.version)
|
|
@@ -568,16 +1100,37 @@ function applyData(layout, rawCells, maskId, spectralInterleaving = true) {
|
|
|
568
1100
|
for (let logicalIndex = 0; logicalIndex < layout.dataPositions.length; logicalIndex++) {
|
|
569
1101
|
const physicalIndex = permutation ? permutation[logicalIndex] : logicalIndex;
|
|
570
1102
|
const [row, col] = layout.dataPositions[physicalIndex];
|
|
571
|
-
matrix[row][col] =
|
|
1103
|
+
matrix[row][col] = applyCellMask(
|
|
1104
|
+
rawCells[logicalIndex],
|
|
1105
|
+
row,
|
|
1106
|
+
col,
|
|
1107
|
+
maskId,
|
|
1108
|
+
encoding,
|
|
1109
|
+
encoding === CELL_ENCODINGS.TRIANGLE16 && logicalIndex < protectedHeaderCells
|
|
1110
|
+
);
|
|
572
1111
|
}
|
|
573
1112
|
return matrix;
|
|
574
1113
|
}
|
|
575
1114
|
|
|
576
|
-
function
|
|
1115
|
+
function dataCellPenalty(matrix, reserved, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1116
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
577
1117
|
const size = matrix.length;
|
|
578
1118
|
let penalty = 0;
|
|
579
|
-
const
|
|
580
|
-
let
|
|
1119
|
+
const colorCounts = [0, 0, 0, 0];
|
|
1120
|
+
let colorCount = 0;
|
|
1121
|
+
|
|
1122
|
+
const symbolAt = (r, c) => matrix[r][c];
|
|
1123
|
+
const addColors = (value) => {
|
|
1124
|
+
if (encoding === CELL_ENCODINGS.TRIANGLE16) {
|
|
1125
|
+
const pair = unpackTriangleCell(value);
|
|
1126
|
+
colorCounts[pair.first]++;
|
|
1127
|
+
colorCounts[pair.second]++;
|
|
1128
|
+
colorCount += 2;
|
|
1129
|
+
} else {
|
|
1130
|
+
colorCounts[value]++;
|
|
1131
|
+
colorCount++;
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
581
1134
|
|
|
582
1135
|
for (let r = 0; r < size; r++) {
|
|
583
1136
|
let previous = null;
|
|
@@ -588,9 +1141,8 @@ function quaternaryPenalty(matrix, reserved) {
|
|
|
588
1141
|
run = 0;
|
|
589
1142
|
continue;
|
|
590
1143
|
}
|
|
591
|
-
const value =
|
|
592
|
-
|
|
593
|
-
dataCount++;
|
|
1144
|
+
const value = symbolAt(r, c);
|
|
1145
|
+
addColors(value);
|
|
594
1146
|
if (value === previous) {
|
|
595
1147
|
run++;
|
|
596
1148
|
if (run >= 4) penalty += 2;
|
|
@@ -610,7 +1162,7 @@ function quaternaryPenalty(matrix, reserved) {
|
|
|
610
1162
|
run = 0;
|
|
611
1163
|
continue;
|
|
612
1164
|
}
|
|
613
|
-
const value =
|
|
1165
|
+
const value = symbolAt(r, c);
|
|
614
1166
|
if (value === previous) {
|
|
615
1167
|
run++;
|
|
616
1168
|
if (run >= 4) penalty += 2;
|
|
@@ -621,9 +1173,9 @@ function quaternaryPenalty(matrix, reserved) {
|
|
|
621
1173
|
}
|
|
622
1174
|
}
|
|
623
1175
|
|
|
624
|
-
if (
|
|
625
|
-
const ideal =
|
|
626
|
-
penalty +=
|
|
1176
|
+
if (colorCount > 0) {
|
|
1177
|
+
const ideal = colorCount / 4;
|
|
1178
|
+
penalty += colorCounts.reduce((sum, count) => sum + Math.abs(count - ideal), 0) / 2;
|
|
627
1179
|
}
|
|
628
1180
|
return penalty;
|
|
629
1181
|
}
|
|
@@ -652,7 +1204,7 @@ function deinterleaveBlocks(stream, blockLengths) {
|
|
|
652
1204
|
return blocks;
|
|
653
1205
|
}
|
|
654
1206
|
|
|
655
|
-
function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
|
|
1207
|
+
function getBodyRsPlan(payloadLength, eccLevel, version = 2, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
656
1208
|
const ecc = getEffectiveEcc(version, eccLevel);
|
|
657
1209
|
const bodyByteCount = payloadLength + CRC_BYTES;
|
|
658
1210
|
const dataSymbols = bodyByteCount;
|
|
@@ -674,25 +1226,27 @@ function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
|
|
|
674
1226
|
dataBlockLengths,
|
|
675
1227
|
codewordBlockLengths,
|
|
676
1228
|
encodedSymbols,
|
|
677
|
-
encodedCells: encodedSymbols *
|
|
1229
|
+
encodedCells: encodedSymbols * cellsPerByteForEncoding(cellEncoding)
|
|
678
1230
|
};
|
|
679
1231
|
}
|
|
680
1232
|
|
|
681
|
-
function streamCellCount(payloadLength, eccLevel, version) {
|
|
682
|
-
return getHeaderPlan(version).codewordCells +
|
|
1233
|
+
function streamCellCount(payloadLength, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1234
|
+
return getHeaderPlan(version).codewordCells +
|
|
1235
|
+
getBodyRsPlan(payloadLength, eccLevel, version, cellEncoding).encodedCells;
|
|
683
1236
|
}
|
|
684
1237
|
|
|
685
|
-
function streamFitsLayout(layout, eccLevel, payloadLength, version) {
|
|
686
|
-
return streamCellCount(payloadLength, eccLevel, version) <= layout.dataPositions.length;
|
|
1238
|
+
function streamFitsLayout(layout, eccLevel, payloadLength, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1239
|
+
return streamCellCount(payloadLength, eccLevel, version, cellEncoding) <= layout.dataPositions.length;
|
|
687
1240
|
}
|
|
688
1241
|
|
|
689
|
-
function getCapacityForLayout(layout, eccLevel, version) {
|
|
690
|
-
|
|
1242
|
+
function getCapacityForLayout(layout, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1243
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
1244
|
+
if (!streamFitsLayout(layout, eccLevel, 0, version, encoding)) return 0;
|
|
691
1245
|
let low = 0;
|
|
692
|
-
let high = Math.floor(layout.dataPositions.length /
|
|
1246
|
+
let high = Math.floor(layout.dataPositions.length / cellsPerByteForEncoding(encoding));
|
|
693
1247
|
while (low < high) {
|
|
694
1248
|
const mid = Math.ceil((low + high) / 2);
|
|
695
|
-
if (streamFitsLayout(layout, eccLevel, mid, version)) low = mid;
|
|
1249
|
+
if (streamFitsLayout(layout, eccLevel, mid, version, encoding)) low = mid;
|
|
696
1250
|
else high = mid - 1;
|
|
697
1251
|
}
|
|
698
1252
|
return low;
|
|
@@ -701,6 +1255,7 @@ function getCapacityForLayout(layout, eccLevel, version) {
|
|
|
701
1255
|
export function getVersionInfo(version, options = {}) {
|
|
702
1256
|
validateVersion(version);
|
|
703
1257
|
const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
|
|
1258
|
+
const cellEncoding = cellEncodingFromOptions(options);
|
|
704
1259
|
const layout = createLayout(version);
|
|
705
1260
|
const headerPlan = getHeaderPlan(version);
|
|
706
1261
|
const effectiveEcc = getEffectiveEcc(version, eccLevel);
|
|
@@ -710,8 +1265,8 @@ export function getVersionInfo(version, options = {}) {
|
|
|
710
1265
|
eccLevel,
|
|
711
1266
|
size: layout.size,
|
|
712
1267
|
dataCells: layout.dataPositions.length,
|
|
713
|
-
theoreticalBits: layout.dataPositions.length * 2,
|
|
714
|
-
capacityBytes: getCapacityForLayout(layout, eccLevel, version),
|
|
1268
|
+
theoreticalBits: layout.dataPositions.length * (cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2),
|
|
1269
|
+
capacityBytes: getCapacityForLayout(layout, eccLevel, version, cellEncoding),
|
|
715
1270
|
headerCells: headerPlan.codewordCells,
|
|
716
1271
|
headerBytes: headerPlan.headerBytes,
|
|
717
1272
|
headerParitySymbols: headerPlan.paritySymbols,
|
|
@@ -723,8 +1278,11 @@ export function getVersionInfo(version, options = {}) {
|
|
|
723
1278
|
hasAlignmentMarker: true,
|
|
724
1279
|
alignmentPatterns: layout.alignments.length,
|
|
725
1280
|
alignmentCenters: layout.alignments.map(({ row, col }) => [row, col]),
|
|
726
|
-
bitsPerDataCell: 2,
|
|
1281
|
+
bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
|
|
1282
|
+
highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
|
|
1283
|
+
cellEncoding,
|
|
727
1284
|
colors: 4,
|
|
1285
|
+
statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
|
|
728
1286
|
spectralInterleaving: true,
|
|
729
1287
|
confidenceAwareEcc: true
|
|
730
1288
|
};
|
|
@@ -735,6 +1293,7 @@ function chooseVersion(payloadLength, options = {}) {
|
|
|
735
1293
|
const minVersion = options.minVersion ?? MIN_VERSION;
|
|
736
1294
|
const maxVersion = options.maxVersion ?? MAX_VERSION;
|
|
737
1295
|
const ecc = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
|
|
1296
|
+
const cellEncoding = cellEncodingFromOptions(options);
|
|
738
1297
|
|
|
739
1298
|
validateVersion(minVersion);
|
|
740
1299
|
validateVersion(maxVersion);
|
|
@@ -743,10 +1302,10 @@ function chooseVersion(payloadLength, options = {}) {
|
|
|
743
1302
|
if (requested !== "auto") {
|
|
744
1303
|
validateVersion(requested);
|
|
745
1304
|
assert(requested >= minVersion && requested <= maxVersion, "Requested version is outside selected bounds.");
|
|
746
|
-
const info = getVersionInfo(requested, { ecc });
|
|
1305
|
+
const info = getVersionInfo(requested, { ecc, cellEncoding });
|
|
747
1306
|
const layout = createLayout(requested);
|
|
748
1307
|
assert(
|
|
749
|
-
payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested),
|
|
1308
|
+
payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested, cellEncoding),
|
|
750
1309
|
`Payload does not fit version ${requested} with ${ecc} ECC. Maximum is ${info.capacityBytes} bytes.`
|
|
751
1310
|
);
|
|
752
1311
|
return requested;
|
|
@@ -754,20 +1313,20 @@ function chooseVersion(payloadLength, options = {}) {
|
|
|
754
1313
|
|
|
755
1314
|
for (let version = minVersion; version <= maxVersion; version++) {
|
|
756
1315
|
const layout = createLayout(version);
|
|
757
|
-
if (streamFitsLayout(layout, ecc, payloadLength, version)) return version;
|
|
1316
|
+
if (streamFitsLayout(layout, ecc, payloadLength, version, cellEncoding)) return version;
|
|
758
1317
|
}
|
|
759
1318
|
throw new Error(`Payload is too large for versions ${minVersion}..${maxVersion}.`);
|
|
760
1319
|
}
|
|
761
1320
|
|
|
762
|
-
function encodeProtectedHeader(header, version) {
|
|
1321
|
+
function encodeProtectedHeader(header, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
763
1322
|
const plan = getHeaderPlan(version);
|
|
764
1323
|
const codeword = rsEncode(Array.from(header), plan.paritySymbols);
|
|
765
1324
|
assert(codeword.length === plan.codewordBytes, "Header RS symbol calculation mismatch.");
|
|
766
|
-
return
|
|
1325
|
+
return bytesToProtectedHeaderCells(codeword, cellEncoding);
|
|
767
1326
|
}
|
|
768
1327
|
|
|
769
|
-
function encodeProtectedBody(bodyBytes, eccLevel, version) {
|
|
770
|
-
const plan = getBodyRsPlan(bodyBytes.length - CRC_BYTES, eccLevel, version);
|
|
1328
|
+
function encodeProtectedBody(bodyBytes, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1329
|
+
const plan = getBodyRsPlan(bodyBytes.length - CRC_BYTES, eccLevel, version, cellEncoding);
|
|
771
1330
|
assert(bodyBytes.length === plan.dataSymbols, "Body RS symbol calculation mismatch.");
|
|
772
1331
|
|
|
773
1332
|
const blocks = [];
|
|
@@ -776,7 +1335,7 @@ function encodeProtectedBody(bodyBytes, eccLevel, version) {
|
|
|
776
1335
|
blocks.push(rsEncode(Array.from(bodyBytes.slice(offset, offset + length)), plan.paritySymbols));
|
|
777
1336
|
offset += length;
|
|
778
1337
|
}
|
|
779
|
-
return { cells: bytesToCells(interleaveBlocks(blocks)), plan };
|
|
1338
|
+
return { cells: bytesToCells(interleaveBlocks(blocks), cellEncoding), plan };
|
|
780
1339
|
}
|
|
781
1340
|
|
|
782
1341
|
function finalizeMatrix(layout, rawCells, meta) {
|
|
@@ -784,9 +1343,10 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
784
1343
|
let bestMatrix = null;
|
|
785
1344
|
let bestPenalty = Infinity;
|
|
786
1345
|
|
|
1346
|
+
const cellEncoding = normalizeCellEncoding(meta.cellEncoding ?? CELL_ENCODINGS.RGBW);
|
|
787
1347
|
for (let maskId = 0; maskId < 4; maskId++) {
|
|
788
|
-
const candidate = applyData(layout, rawCells, maskId, true);
|
|
789
|
-
const penalty =
|
|
1348
|
+
const candidate = applyData(layout, rawCells, maskId, true, cellEncoding);
|
|
1349
|
+
const penalty = dataCellPenalty(candidate, layout.reserved, cellEncoding);
|
|
790
1350
|
if (penalty < bestPenalty) {
|
|
791
1351
|
bestPenalty = penalty;
|
|
792
1352
|
bestMaskId = maskId;
|
|
@@ -794,7 +1354,7 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
794
1354
|
}
|
|
795
1355
|
}
|
|
796
1356
|
|
|
797
|
-
const info = getVersionInfo(meta.version, { ecc: meta.eccLevel });
|
|
1357
|
+
const info = getVersionInfo(meta.version, { ecc: meta.eccLevel, cellEncoding });
|
|
798
1358
|
return {
|
|
799
1359
|
format: "QuadQR",
|
|
800
1360
|
formatVersion: FORMAT_VERSION,
|
|
@@ -805,6 +1365,11 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
805
1365
|
payloadBytes: meta.payloadBytes,
|
|
806
1366
|
sourcePayloadBytes: meta.sourcePayloadBytes ?? meta.payloadBytes,
|
|
807
1367
|
secure: Boolean(meta.secure),
|
|
1368
|
+
compressed: meta.compression != null && meta.compression !== "none",
|
|
1369
|
+
compression: meta.compression ?? "none",
|
|
1370
|
+
signed: Boolean(meta.signed),
|
|
1371
|
+
signingKeyId: meta.signingKeyId ?? null,
|
|
1372
|
+
hasEmbeddedPublicKey: Boolean(meta.hasEmbeddedPublicKey),
|
|
808
1373
|
requiresDecryption: Boolean(meta.secure),
|
|
809
1374
|
security: meta.security ?? null,
|
|
810
1375
|
meaningfulCells: meta.meaningfulCells,
|
|
@@ -812,7 +1377,10 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
812
1377
|
capacityBytes: info.capacityBytes,
|
|
813
1378
|
alignmentPatterns: layout.alignments.length,
|
|
814
1379
|
utilization: meta.meaningfulCells / layout.dataPositions.length,
|
|
815
|
-
bitsPerDataCell: 2,
|
|
1380
|
+
bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
|
|
1381
|
+
highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
|
|
1382
|
+
cellEncoding,
|
|
1383
|
+
statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
|
|
816
1384
|
eccLevel: meta.eccLevel,
|
|
817
1385
|
eccParitySymbols: meta.eccParitySymbols,
|
|
818
1386
|
eccBlocks: meta.eccBlocks,
|
|
@@ -823,39 +1391,44 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
823
1391
|
};
|
|
824
1392
|
}
|
|
825
1393
|
|
|
826
|
-
|
|
827
|
-
assert(typeof text === "string", "encodeText expects a string.");
|
|
828
|
-
return encodeBytes(getTextEncoder().encode(text), { ...options, text: true });
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
export function encodeBytes(input, options = {}) {
|
|
1394
|
+
function encodePreparedBytes(input, options = {}) {
|
|
832
1395
|
if (options.formatVersion != null && options.formatVersion !== FORMAT_VERSION) {
|
|
833
1396
|
throw new Error(`Only QuadQR format version ${FORMAT_VERSION} is supported.`);
|
|
834
1397
|
}
|
|
835
1398
|
|
|
836
|
-
const payload =
|
|
1399
|
+
const payload = asBytes(input);
|
|
837
1400
|
const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
|
|
838
1401
|
const secure = Boolean(options.secure);
|
|
839
|
-
const
|
|
840
|
-
const
|
|
1402
|
+
const extended = Boolean(options.extended);
|
|
1403
|
+
const signed = Boolean(options.signed);
|
|
1404
|
+
const cellEncoding = cellEncodingFromOptions(options);
|
|
1405
|
+
const flags = flagsFor(Boolean(options.text), eccLevel, secure, extended, signed, cellEncoding);
|
|
1406
|
+
const version = chooseVersion(payload.length, { ...options, ecc: eccLevel, cellEncoding });
|
|
841
1407
|
const layout = createLayout(version);
|
|
842
1408
|
const header = makeHeader(payload.length, flags, version);
|
|
843
1409
|
const crc = crc32(concatBytes(header, payload));
|
|
844
|
-
const headerCells = encodeProtectedHeader(header, version);
|
|
845
|
-
const bodyEncoded = encodeProtectedBody(concatBytes(payload, u32be(crc)), eccLevel, version);
|
|
1410
|
+
const headerCells = encodeProtectedHeader(header, version, cellEncoding);
|
|
1411
|
+
const bodyEncoded = encodeProtectedBody(concatBytes(payload, u32be(crc)), eccLevel, version, cellEncoding);
|
|
846
1412
|
const meaningfulCells = headerCells.concat(bodyEncoded.cells);
|
|
847
1413
|
assert(meaningfulCells.length <= layout.dataPositions.length, "Internal QuadQR capacity calculation error.");
|
|
848
1414
|
|
|
849
1415
|
const padding = makePaddingCells(
|
|
850
1416
|
layout.dataPositions.length - meaningfulCells.length,
|
|
851
|
-
crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16)
|
|
1417
|
+
crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16),
|
|
1418
|
+
cellEncoding
|
|
852
1419
|
);
|
|
853
1420
|
|
|
854
1421
|
return finalizeMatrix(layout, meaningfulCells.concat(padding), {
|
|
855
1422
|
version,
|
|
1423
|
+
cellEncoding,
|
|
856
1424
|
payloadBytes: payload.length,
|
|
857
1425
|
sourcePayloadBytes: options.sourcePayloadBytes ?? payload.length,
|
|
858
1426
|
secure,
|
|
1427
|
+
extended,
|
|
1428
|
+
signed,
|
|
1429
|
+
compression: options.compressionMetadata ?? "none",
|
|
1430
|
+
signingKeyId: options.signingKeyId ?? null,
|
|
1431
|
+
hasEmbeddedPublicKey: Boolean(options.hasEmbeddedPublicKey),
|
|
859
1432
|
security: options.securityMetadata ?? null,
|
|
860
1433
|
meaningfulCells: meaningfulCells.length,
|
|
861
1434
|
eccLevel,
|
|
@@ -866,6 +1439,59 @@ export function encodeBytes(input, options = {}) {
|
|
|
866
1439
|
});
|
|
867
1440
|
}
|
|
868
1441
|
|
|
1442
|
+
export function encodeText(text, options = {}) {
|
|
1443
|
+
assert(typeof text === "string", "encodeText expects a string.");
|
|
1444
|
+
const source = getTextEncoder().encode(text);
|
|
1445
|
+
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
|
|
1446
|
+
return encodePreparedBytes(prepared.payload, {
|
|
1447
|
+
...options,
|
|
1448
|
+
text: true,
|
|
1449
|
+
extended: prepared.extended,
|
|
1450
|
+
sourcePayloadBytes: source.length,
|
|
1451
|
+
compressionMetadata: prepared.compression
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
export function encodeBytes(input, options = {}) {
|
|
1456
|
+
const source = asBytes(input);
|
|
1457
|
+
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
|
|
1458
|
+
return encodePreparedBytes(prepared.payload, {
|
|
1459
|
+
...options,
|
|
1460
|
+
text: Boolean(options.text),
|
|
1461
|
+
extended: prepared.extended,
|
|
1462
|
+
sourcePayloadBytes: source.length,
|
|
1463
|
+
compressionMetadata: prepared.compression
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/** Explicit Uint8Array convenience API. Equivalent to encodeBytes(). */
|
|
1468
|
+
export function encodeUint8Array(input, options = {}) {
|
|
1469
|
+
return encodeBytes(asBytes(input), options);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/** Encode and sign a normal payload using an internal Ed25519 envelope. */
|
|
1473
|
+
export async function encodeSignedBytes(input, options = {}) {
|
|
1474
|
+
const source = asBytes(input);
|
|
1475
|
+
const envelope = await packSignedPayloadEnvelope(source, options);
|
|
1476
|
+
const parsed = parsePayloadEnvelope(envelope);
|
|
1477
|
+
return encodePreparedBytes(envelope, {
|
|
1478
|
+
...options,
|
|
1479
|
+
text: Boolean(options.text),
|
|
1480
|
+
extended: true,
|
|
1481
|
+
signed: true,
|
|
1482
|
+
sourcePayloadBytes: source.length,
|
|
1483
|
+
compressionMetadata: parsed.compression,
|
|
1484
|
+
signingKeyId: parsed.keyId,
|
|
1485
|
+
hasEmbeddedPublicKey: parsed.hasEmbeddedPublicKey
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
export async function encodeSignedText(text, options = {}) {
|
|
1490
|
+
assert(typeof text === "string", "encodeSignedText expects a string.");
|
|
1491
|
+
return encodeSignedBytes(getTextEncoder().encode(text), { ...options, text: true });
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
|
|
869
1495
|
/**
|
|
870
1496
|
* Encode text using the optional QuadQR Secure Payload v1 layer.
|
|
871
1497
|
* This API is async because Web Crypto performs AES-GCM and password KDF work asynchronously.
|
|
@@ -877,32 +1503,75 @@ export async function encodeSecureText(text, options = {}) {
|
|
|
877
1503
|
|
|
878
1504
|
/** Encode arbitrary bytes using password or raw 256-bit key security. */
|
|
879
1505
|
export async function encodeSecureBytes(input, options = {}) {
|
|
880
|
-
const sourcePayload =
|
|
1506
|
+
const sourcePayload = asBytes(input);
|
|
881
1507
|
const security = options.security ?? {};
|
|
882
|
-
|
|
883
|
-
|
|
1508
|
+
let protectedPayload = sourcePayload;
|
|
1509
|
+
let extended = false;
|
|
1510
|
+
let signed = false;
|
|
1511
|
+
let compression = "none";
|
|
1512
|
+
let signingKeyId = null;
|
|
1513
|
+
let hasEmbeddedPublicKey = false;
|
|
1514
|
+
|
|
1515
|
+
if (options.signing) {
|
|
1516
|
+
protectedPayload = await packSignedPayloadEnvelope(sourcePayload, {
|
|
1517
|
+
...options,
|
|
1518
|
+
...options.signing,
|
|
1519
|
+
compression: options.compression ?? "auto"
|
|
1520
|
+
});
|
|
1521
|
+
const parsed = parsePayloadEnvelope(protectedPayload);
|
|
1522
|
+
extended = true;
|
|
1523
|
+
signed = true;
|
|
1524
|
+
compression = parsed.compression;
|
|
1525
|
+
signingKeyId = parsed.keyId;
|
|
1526
|
+
hasEmbeddedPublicKey = parsed.hasEmbeddedPublicKey;
|
|
1527
|
+
} else {
|
|
1528
|
+
const prepared = prepareOptionalCompressedPayload(sourcePayload, options.compression ?? "none");
|
|
1529
|
+
protectedPayload = prepared.payload;
|
|
1530
|
+
extended = prepared.extended;
|
|
1531
|
+
compression = prepared.compression;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
const encrypted = await encryptSecurePayload(protectedPayload, security);
|
|
1535
|
+
return encodePreparedBytes(encrypted.envelope, {
|
|
884
1536
|
...options,
|
|
885
1537
|
text: Boolean(options.text),
|
|
886
1538
|
secure: true,
|
|
1539
|
+
extended,
|
|
1540
|
+
signed,
|
|
887
1541
|
sourcePayloadBytes: sourcePayload.length,
|
|
1542
|
+
compressionMetadata: compression,
|
|
1543
|
+
signingKeyId,
|
|
1544
|
+
hasEmbeddedPublicKey,
|
|
888
1545
|
securityMetadata: encrypted.metadata
|
|
889
1546
|
});
|
|
890
1547
|
}
|
|
891
1548
|
|
|
892
1549
|
/**
|
|
893
1550
|
* Decrypt a result returned by decodeMatrix/scanImageData/scanFile.
|
|
894
|
-
*
|
|
1551
|
+
* Compression and signatures are automatically unwrapped after decryption.
|
|
895
1552
|
*/
|
|
896
1553
|
export async function decryptDecoded(result, security = {}) {
|
|
897
1554
|
assert(result?.secure && result?.payload, "decryptDecoded expects an encrypted QuadQR decode result.");
|
|
898
1555
|
const plaintext = await decryptSecurePayload(result.payload, security);
|
|
899
1556
|
const isText = (result.flags & TEXT_FLAG) !== 0;
|
|
1557
|
+
const extended = (result.flags & EXTENDED_PAYLOAD_FLAG) !== 0;
|
|
1558
|
+
const envelope = extended ? parsePayloadEnvelope(plaintext) : null;
|
|
1559
|
+
const payload = envelope ? envelope.payload : plaintext;
|
|
900
1560
|
return {
|
|
901
1561
|
...result,
|
|
902
1562
|
encryptedPayload: result.payload,
|
|
903
1563
|
encryptedPayloadBytes: result.payload.length,
|
|
904
|
-
|
|
905
|
-
|
|
1564
|
+
protectedPayload: envelope ? plaintext : undefined,
|
|
1565
|
+
payload,
|
|
1566
|
+
text: isText ? getTextDecoder().decode(payload) : null,
|
|
1567
|
+
compressed: envelope?.compressed ?? false,
|
|
1568
|
+
compression: envelope?.compression ?? "none",
|
|
1569
|
+
signed: envelope?.signed ?? result.signed ?? false,
|
|
1570
|
+
signatureVerified: envelope?.signatureVerified ?? result.signatureVerified,
|
|
1571
|
+
signatureTrusted: envelope?.signatureTrusted ?? result.signatureTrusted,
|
|
1572
|
+
signingKeyId: envelope?.keyId ?? result.signingKeyId ?? null,
|
|
1573
|
+
hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? result.hasEmbeddedPublicKey ?? false,
|
|
1574
|
+
signer: envelope?.signer ?? result.signer ?? null,
|
|
906
1575
|
decrypted: true,
|
|
907
1576
|
requiresDecryption: false,
|
|
908
1577
|
security: { ...result.security, decrypted: true }
|
|
@@ -992,12 +1661,14 @@ function rotate90(matrix) {
|
|
|
992
1661
|
return out;
|
|
993
1662
|
}
|
|
994
1663
|
|
|
995
|
-
function extractVisibleCells(matrix, layout) {
|
|
1664
|
+
function extractVisibleCells(matrix, layout, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1665
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
1666
|
+
const maxValue = encoding === CELL_ENCODINGS.TRIANGLE16 ? 15 : 3;
|
|
996
1667
|
const out = [];
|
|
997
1668
|
for (const [row, col] of layout.dataPositions) {
|
|
998
1669
|
const value = matrix[row][col];
|
|
999
|
-
if (!Number.isInteger(value) || value < 0 || value >
|
|
1000
|
-
throw new Error(
|
|
1670
|
+
if (!Number.isInteger(value) || value < 0 || value > maxValue) {
|
|
1671
|
+
throw new Error(`Data region contains an invalid ${encoding === CELL_ENCODINGS.TRIANGLE16 ? "Triangle16" : "RGBW"} data cell.`);
|
|
1001
1672
|
}
|
|
1002
1673
|
out.push(value);
|
|
1003
1674
|
}
|
|
@@ -1011,6 +1682,34 @@ function unmaskCells(visibleCells, positions, maskId) {
|
|
|
1011
1682
|
});
|
|
1012
1683
|
}
|
|
1013
1684
|
|
|
1685
|
+
function unmaskAndRestoreLogicalCells(
|
|
1686
|
+
visibleCells,
|
|
1687
|
+
positions,
|
|
1688
|
+
maskId,
|
|
1689
|
+
version,
|
|
1690
|
+
spectralInterleaving,
|
|
1691
|
+
cellEncoding = CELL_ENCODINGS.RGBW
|
|
1692
|
+
) {
|
|
1693
|
+
const encoding = normalizeCellEncoding(cellEncoding);
|
|
1694
|
+
const permutation = spectralInterleaving ? spectralPermutation(visibleCells.length, version) : null;
|
|
1695
|
+
const logical = new Array(visibleCells.length);
|
|
1696
|
+
const headerCells = getHeaderPlan(version).codewordCells;
|
|
1697
|
+
|
|
1698
|
+
for (let logicalIndex = 0; logicalIndex < visibleCells.length; logicalIndex++) {
|
|
1699
|
+
const physicalIndex = permutation ? permutation[logicalIndex] : logicalIndex;
|
|
1700
|
+
const [row, col] = positions[physicalIndex];
|
|
1701
|
+
logical[logicalIndex] = applyCellMask(
|
|
1702
|
+
visibleCells[physicalIndex],
|
|
1703
|
+
row,
|
|
1704
|
+
col,
|
|
1705
|
+
maskId,
|
|
1706
|
+
encoding,
|
|
1707
|
+
encoding === CELL_ENCODINGS.TRIANGLE16 && logicalIndex < headerCells
|
|
1708
|
+
);
|
|
1709
|
+
}
|
|
1710
|
+
return logical;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1014
1713
|
function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
|
|
1015
1714
|
if (!spectralInterleaving) return physicalValues.slice();
|
|
1016
1715
|
const permutation = spectralPermutation(physicalValues.length, version);
|
|
@@ -1021,20 +1720,21 @@ function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
|
|
|
1021
1720
|
return logical;
|
|
1022
1721
|
}
|
|
1023
1722
|
|
|
1024
|
-
function cellsToSymbolConfidences(
|
|
1723
|
+
function cellsToSymbolConfidences(
|
|
1724
|
+
confidences,
|
|
1725
|
+
byteCount,
|
|
1726
|
+
cellsPerByte = RGBW_CELLS_PER_BYTE
|
|
1727
|
+
) {
|
|
1025
1728
|
if (!confidences) return null;
|
|
1026
|
-
if (confidences.length < byteCount *
|
|
1729
|
+
if (confidences.length < byteCount * cellsPerByte) return null;
|
|
1027
1730
|
const out = new Array(byteCount);
|
|
1028
1731
|
for (let symbolIndex = 0; symbolIndex < byteCount; symbolIndex++) {
|
|
1029
|
-
const offset = symbolIndex *
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
confidences[offset + 2] ?? 1,
|
|
1036
|
-
confidences[offset + 3] ?? 1
|
|
1037
|
-
);
|
|
1732
|
+
const offset = symbolIndex * cellsPerByte;
|
|
1733
|
+
let value = 1;
|
|
1734
|
+
for (let cellIndex = 0; cellIndex < cellsPerByte; cellIndex++) {
|
|
1735
|
+
value = Math.min(value, confidences[offset + cellIndex] ?? 1);
|
|
1736
|
+
}
|
|
1737
|
+
out[symbolIndex] = value;
|
|
1038
1738
|
}
|
|
1039
1739
|
return out;
|
|
1040
1740
|
}
|
|
@@ -1071,11 +1771,11 @@ function decodeRsAdaptive(codeword, paritySymbols, symbolConfidences, options =
|
|
|
1071
1771
|
}
|
|
1072
1772
|
}
|
|
1073
1773
|
|
|
1074
|
-
function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}) {
|
|
1774
|
+
function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1075
1775
|
const plan = getHeaderPlan(version);
|
|
1076
1776
|
const headerCells = rawCells.slice(0, plan.codewordCells);
|
|
1077
|
-
const codeword = Array.from(
|
|
1078
|
-
const confidences = cellsToSymbolConfidences(rawConfidences?.slice(0, plan.codewordCells), plan.codewordBytes);
|
|
1777
|
+
const codeword = Array.from(protectedHeaderCellsToBytes(headerCells, plan.codewordBytes, cellEncoding));
|
|
1778
|
+
const confidences = cellsToSymbolConfidences(rawConfidences?.slice(0, plan.codewordCells), plan.codewordBytes, RGBW_CELLS_PER_BYTE);
|
|
1079
1779
|
const decoded = decodeRsAdaptive(codeword, plan.paritySymbols, confidences, options);
|
|
1080
1780
|
const header = new Uint8Array(decoded.data);
|
|
1081
1781
|
const parsed = parseHeader(header, version);
|
|
@@ -1090,16 +1790,17 @@ function decodeProtectedHeader(rawCells, version, rawConfidences = null, options
|
|
|
1090
1790
|
};
|
|
1091
1791
|
}
|
|
1092
1792
|
|
|
1093
|
-
function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConfidences = null, options = {}) {
|
|
1094
|
-
const plan = getBodyRsPlan(payloadLength, eccLevel, version);
|
|
1793
|
+
function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConfidences = null, options = {}, cellEncoding = CELL_ENCODINGS.RGBW) {
|
|
1794
|
+
const plan = getBodyRsPlan(payloadLength, eccLevel, version, cellEncoding);
|
|
1095
1795
|
const bodyStart = getHeaderPlan(version).codewordCells;
|
|
1096
1796
|
const encodedCells = rawCells.slice(bodyStart, bodyStart + plan.encodedCells);
|
|
1097
1797
|
if (encodedCells.length !== plan.encodedCells) throw new Error("Protected body is incomplete.");
|
|
1098
1798
|
|
|
1099
|
-
const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols));
|
|
1799
|
+
const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols, cellEncoding));
|
|
1100
1800
|
const symbolConfidences = cellsToSymbolConfidences(
|
|
1101
1801
|
rawConfidences?.slice(bodyStart, bodyStart + plan.encodedCells),
|
|
1102
|
-
plan.encodedSymbols
|
|
1802
|
+
plan.encodedSymbols,
|
|
1803
|
+
cellsPerByteForEncoding(cellEncoding)
|
|
1103
1804
|
);
|
|
1104
1805
|
const blocks = deinterleaveBlocks(encodedSymbols, plan.codewordBlockLengths);
|
|
1105
1806
|
const confidenceBlocks = symbolConfidences
|
|
@@ -1142,83 +1843,136 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
|
|
|
1142
1843
|
if (!validateStructure(matrix, tolerance)) throw new Error("QuadQR finder/alignment structure does not match.");
|
|
1143
1844
|
|
|
1144
1845
|
const layout = createLayout(version);
|
|
1145
|
-
const visible = extractVisibleCells(matrix, layout);
|
|
1146
|
-
const visibleConfidences = confidenceMatrix
|
|
1147
|
-
? layout.dataPositions.map(([row, col]) => confidenceMatrix[row]?.[col] ?? 1)
|
|
1148
|
-
: null;
|
|
1149
1846
|
const errors = [];
|
|
1847
|
+
const hintedEncoding = options.cellEncodingHint != null
|
|
1848
|
+
? normalizeCellEncoding(options.cellEncodingHint)
|
|
1849
|
+
: null;
|
|
1850
|
+
const hasExtendedCells = layout.dataPositions.some(([row, col]) => (matrix[row]?.[col] ?? 0) > 3);
|
|
1851
|
+
const encodingAttempts = hintedEncoding
|
|
1852
|
+
? [hintedEncoding]
|
|
1853
|
+
: hasExtendedCells
|
|
1854
|
+
? [CELL_ENCODINGS.TRIANGLE16, CELL_ENCODINGS.RGBW]
|
|
1855
|
+
: [CELL_ENCODINGS.RGBW, CELL_ENCODINGS.TRIANGLE16];
|
|
1856
|
+
|
|
1857
|
+
for (const cellEncoding of encodingAttempts) {
|
|
1858
|
+
let visible;
|
|
1859
|
+
try {
|
|
1860
|
+
visible = extractVisibleCells(matrix, layout, cellEncoding);
|
|
1861
|
+
} catch (error) {
|
|
1862
|
+
errors.push(`${cellEncoding}: ${error.message}`);
|
|
1863
|
+
continue;
|
|
1864
|
+
}
|
|
1150
1865
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
for (let maskId = 0; maskId < 4; maskId++) {
|
|
1155
|
-
try {
|
|
1156
|
-
const physicalRaw = unmaskCells(visible, layout.dataPositions, maskId);
|
|
1157
|
-
const raw = restoreLogicalOrder(physicalRaw, version, spectralInterleaving);
|
|
1158
|
-
const rawConfidences = visibleConfidences
|
|
1159
|
-
? restoreLogicalOrder(visibleConfidences, version, spectralInterleaving)
|
|
1160
|
-
: null;
|
|
1161
|
-
const headerDecoded = decodeProtectedHeader(raw, version, rawConfidences, options);
|
|
1162
|
-
const header = headerDecoded.header;
|
|
1163
|
-
const flags = headerDecoded.flags;
|
|
1164
|
-
const eccLevel = eccFromFlags(flags);
|
|
1165
|
-
const payloadLength = headerDecoded.payloadLength;
|
|
1166
|
-
if (streamCellCount(payloadLength, eccLevel, version) > raw.length) {
|
|
1167
|
-
throw new Error("Declared payload exceeds matrix capacity.");
|
|
1168
|
-
}
|
|
1866
|
+
const visibleConfidences = confidenceMatrix
|
|
1867
|
+
? layout.dataPositions.map(([row, col]) => confidenceMatrix[row]?.[col] ?? 1)
|
|
1868
|
+
: null;
|
|
1169
1869
|
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
eccLevel
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1870
|
+
// New symbols use spectral-spatial interleaving. Legacy order remains a
|
|
1871
|
+
// decode fallback so existing RGBW QuadQR images do not become unreadable.
|
|
1872
|
+
for (const spectralInterleaving of [true, false]) {
|
|
1873
|
+
for (let maskId = 0; maskId < 4; maskId++) {
|
|
1874
|
+
try {
|
|
1875
|
+
const raw = unmaskAndRestoreLogicalCells(
|
|
1876
|
+
visible,
|
|
1877
|
+
layout.dataPositions,
|
|
1878
|
+
maskId,
|
|
1879
|
+
version,
|
|
1880
|
+
spectralInterleaving,
|
|
1881
|
+
cellEncoding
|
|
1882
|
+
);
|
|
1883
|
+
const rawConfidences = visibleConfidences
|
|
1884
|
+
? restoreLogicalOrder(visibleConfidences, version, spectralInterleaving)
|
|
1885
|
+
: null;
|
|
1886
|
+
const headerDecoded = decodeProtectedHeader(
|
|
1887
|
+
raw,
|
|
1888
|
+
version,
|
|
1889
|
+
rawConfidences,
|
|
1890
|
+
options,
|
|
1891
|
+
cellEncoding
|
|
1892
|
+
);
|
|
1893
|
+
const header = headerDecoded.header;
|
|
1894
|
+
const flags = headerDecoded.flags;
|
|
1895
|
+
const declaredCellEncoding = cellEncodingFromFlags(flags);
|
|
1896
|
+
if (declaredCellEncoding !== cellEncoding) {
|
|
1897
|
+
throw new Error(`Header declares ${declaredCellEncoding}, not ${cellEncoding}.`);
|
|
1898
|
+
}
|
|
1899
|
+
const eccLevel = eccFromFlags(flags);
|
|
1900
|
+
const payloadLength = headerDecoded.payloadLength;
|
|
1901
|
+
if (streamCellCount(payloadLength, eccLevel, version, cellEncoding) > raw.length) {
|
|
1902
|
+
throw new Error("Declared payload exceeds matrix capacity.");
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
const bodyDecoded = decodeProtectedBody(
|
|
1906
|
+
raw,
|
|
1907
|
+
payloadLength,
|
|
1908
|
+
eccLevel,
|
|
1909
|
+
version,
|
|
1910
|
+
rawConfidences,
|
|
1911
|
+
options,
|
|
1912
|
+
cellEncoding
|
|
1913
|
+
);
|
|
1914
|
+
const body = bodyDecoded.body;
|
|
1915
|
+
const payload = body.slice(0, payloadLength);
|
|
1916
|
+
const expectedCrc = readU32be(body, payloadLength);
|
|
1917
|
+
const actualCrc = crc32(concatBytes(header, payload));
|
|
1918
|
+
if (expectedCrc !== actualCrc) throw new Error("CRC mismatch after ECC.");
|
|
1919
|
+
|
|
1920
|
+
const isText = (flags & TEXT_FLAG) !== 0;
|
|
1921
|
+
const secure = (flags & SECURE_FLAG) !== 0;
|
|
1922
|
+
const extendedFlag = (flags & EXTENDED_PAYLOAD_FLAG) !== 0;
|
|
1923
|
+
const signedFlag = (flags & SIGNED_FLAG) !== 0;
|
|
1924
|
+
const security = secure ? inspectSecureEnvelope(payload) : null;
|
|
1925
|
+
const envelope = extendedFlag && !secure ? parsePayloadEnvelope(payload) : null;
|
|
1926
|
+
const applicationPayload = envelope ? envelope.payload : payload;
|
|
1927
|
+
const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
|
|
1928
|
+
const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
|
|
1929
|
+
return {
|
|
1930
|
+
ok: true,
|
|
1931
|
+
format: "QuadQR",
|
|
1932
|
+
formatVersion: FORMAT_VERSION,
|
|
1933
|
+
version,
|
|
1934
|
+
size,
|
|
1935
|
+
alignmentPatterns: layout.alignments.length,
|
|
1936
|
+
maskId,
|
|
1937
|
+
rotation,
|
|
1938
|
+
flags,
|
|
1939
|
+
highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
|
|
1940
|
+
cellEncoding,
|
|
1941
|
+
bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
|
|
1942
|
+
statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
|
|
1943
|
+
eccLevel,
|
|
1944
|
+
eccParitySymbols: bodyDecoded.plan.paritySymbols,
|
|
1945
|
+
eccBlocks: bodyDecoded.plan.dataBlockLengths.length,
|
|
1946
|
+
correctableSymbolsPerBlock: bodyDecoded.plan.correctableSymbolsPerBlock,
|
|
1947
|
+
spectralInterleaving,
|
|
1948
|
+
confidenceAwareEcc: Boolean(rawConfidences),
|
|
1949
|
+
confidenceAssisted: headerDecoded.confidenceAssisted || bodyDecoded.confidenceAssisted,
|
|
1950
|
+
erasureSymbols,
|
|
1951
|
+
unknownErrorSymbols,
|
|
1952
|
+
correctedHeaderSymbols: headerDecoded.correctedSymbols,
|
|
1953
|
+
correctedBodySymbols: bodyDecoded.correctedSymbols,
|
|
1954
|
+
correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
|
|
1955
|
+
protectedPayload: envelope ? payload : undefined,
|
|
1956
|
+
payload: secure ? payload : applicationPayload,
|
|
1957
|
+
text: secure ? null : (isText ? getTextDecoder().decode(applicationPayload) : null),
|
|
1958
|
+
compressed: envelope?.compressed ?? false,
|
|
1959
|
+
compression: envelope?.compression ?? "none",
|
|
1960
|
+
signed: envelope?.signed ?? signedFlag,
|
|
1961
|
+
signatureVerified: envelope?.signatureVerified,
|
|
1962
|
+
signatureTrusted: envelope?.signatureTrusted,
|
|
1963
|
+
signingKeyId: envelope?.keyId ?? null,
|
|
1964
|
+
hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? false,
|
|
1965
|
+
signer: envelope?.signer ?? null,
|
|
1966
|
+
secure,
|
|
1967
|
+
encrypted: secure,
|
|
1968
|
+
decrypted: false,
|
|
1969
|
+
requiresDecryption: secure,
|
|
1970
|
+
security,
|
|
1971
|
+
crc32: actualCrc >>> 0
|
|
1972
|
+
};
|
|
1973
|
+
} catch (error) {
|
|
1974
|
+
errors.push(`${cellEncoding} ${spectralInterleaving ? "spectral" : "legacy"} mask ${maskId}: ${error.message}`);
|
|
1975
|
+
}
|
|
1222
1976
|
}
|
|
1223
1977
|
}
|
|
1224
1978
|
}
|
|
@@ -1254,6 +2008,27 @@ export function decodeMatrix(inputMatrix, options = {}) {
|
|
|
1254
2008
|
throw new Error(`Unable to decode matrix. ${errors.join(" || ")}`);
|
|
1255
2009
|
}
|
|
1256
2010
|
|
|
2011
|
+
|
|
2012
|
+
/** Decode a matrix and return only its application payload bytes. */
|
|
2013
|
+
export function decodeUint8Array(inputMatrix, options = {}) {
|
|
2014
|
+
return decodeMatrix(inputMatrix, options).payload;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
/** Verify a signed decode result and return the result with verification state attached. */
|
|
2018
|
+
export async function verifyDecodedSignature(result, options = {}) {
|
|
2019
|
+
assert(result?.signed, "Decode result is not signed.");
|
|
2020
|
+
const source = result.protectedPayload;
|
|
2021
|
+
assert(source, "Signed payload metadata is unavailable. Decrypt secure content first if necessary.");
|
|
2022
|
+
const verification = await verifyPayloadEnvelopeSignature(source, options);
|
|
2023
|
+
return {
|
|
2024
|
+
...result,
|
|
2025
|
+
signatureVerified: verification.verified,
|
|
2026
|
+
signatureTrusted: verification.trusted,
|
|
2027
|
+
signingKeyId: verification.keyId ?? result.signingKeyId ?? null,
|
|
2028
|
+
signatureTrustSource: verification.trustSource
|
|
2029
|
+
};
|
|
2030
|
+
}
|
|
2031
|
+
|
|
1257
2032
|
function hexToRgb(hex) {
|
|
1258
2033
|
const clean = hex.replace("#", "");
|
|
1259
2034
|
assert(clean.length === 6, `Invalid hex color ${hex}.`);
|
|
@@ -1268,6 +2043,18 @@ function resolvePalette(palette = {}) {
|
|
|
1268
2043
|
return { ...DEFAULT_PALETTE, ...palette };
|
|
1269
2044
|
}
|
|
1270
2045
|
|
|
2046
|
+
function normalizeRenderMode(mode = RENDER_MODES.SCREEN) {
|
|
2047
|
+
const value = String(mode).toLowerCase();
|
|
2048
|
+
assert(value === RENDER_MODES.SCREEN || value === RENDER_MODES.PRINT, "Render mode must be screen or print.");
|
|
2049
|
+
return value;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
function resolveRenderPalette(options = {}) {
|
|
2053
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2054
|
+
const base = mode === RENDER_MODES.PRINT ? PRINT_PALETTE : DEFAULT_PALETTE;
|
|
2055
|
+
return { ...base, ...(options.palette ?? {}) };
|
|
2056
|
+
}
|
|
2057
|
+
|
|
1271
2058
|
function paletteRgb(palette = {}) {
|
|
1272
2059
|
const p = resolvePalette(palette);
|
|
1273
2060
|
return {
|
|
@@ -1302,6 +2089,80 @@ function cellRgb(cell, palette) {
|
|
|
1302
2089
|
}
|
|
1303
2090
|
|
|
1304
2091
|
|
|
2092
|
+
function inferCellEncoding(codeOrMatrix, matrix, layout = null) {
|
|
2093
|
+
if (!Array.isArray(codeOrMatrix) && codeOrMatrix?.cellEncoding) {
|
|
2094
|
+
return normalizeCellEncoding(codeOrMatrix.cellEncoding);
|
|
2095
|
+
}
|
|
2096
|
+
const activeLayout = layout ?? renderLayoutForMatrix(matrix);
|
|
2097
|
+
if (activeLayout) {
|
|
2098
|
+
for (const [row, col] of activeLayout.dataPositions) {
|
|
2099
|
+
if ((matrix[row]?.[col] ?? 0) > 3) return CELL_ENCODINGS.TRIANGLE16;
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
return CELL_ENCODINGS.RGBW;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
function triangleCellColors(cell) {
|
|
2106
|
+
const { first, second } = unpackTriangleCell(cell);
|
|
2107
|
+
return { first, second };
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
function drawTriangleCellCanvas(ctx, x, y, width, height, cell, palette) {
|
|
2111
|
+
const pair = triangleCellColors(cell);
|
|
2112
|
+
if (pair.first === pair.second) {
|
|
2113
|
+
ctx.fillStyle = cellColor(pair.first, palette);
|
|
2114
|
+
ctx.fillRect(x, y, width, height);
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
// Fixed "/" split. first = upper-left triangle, second = lower-right.
|
|
2119
|
+
ctx.beginPath();
|
|
2120
|
+
ctx.moveTo(x, y);
|
|
2121
|
+
ctx.lineTo(x + width, y);
|
|
2122
|
+
ctx.lineTo(x, y + height);
|
|
2123
|
+
ctx.closePath();
|
|
2124
|
+
ctx.fillStyle = cellColor(pair.first, palette);
|
|
2125
|
+
ctx.fill();
|
|
2126
|
+
|
|
2127
|
+
ctx.beginPath();
|
|
2128
|
+
ctx.moveTo(x + width, y);
|
|
2129
|
+
ctx.lineTo(x + width, y + height);
|
|
2130
|
+
ctx.lineTo(x, y + height);
|
|
2131
|
+
ctx.closePath();
|
|
2132
|
+
ctx.fillStyle = cellColor(pair.second, palette);
|
|
2133
|
+
ctx.fill();
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
function fillImageTriangleCell(data, imageWidth, x, y, width, height, cell, palette) {
|
|
2137
|
+
const pair = triangleCellColors(cell);
|
|
2138
|
+
const firstRgb = cellRgb(pair.first, palette);
|
|
2139
|
+
const secondRgb = cellRgb(pair.second, palette);
|
|
2140
|
+
for (let yy = 0; yy < height; yy++) {
|
|
2141
|
+
for (let xx = 0; xx < width; xx++) {
|
|
2142
|
+
const normalizedX = (xx + 0.5) / Math.max(1, width);
|
|
2143
|
+
const normalizedY = (yy + 0.5) / Math.max(1, height);
|
|
2144
|
+
const rgb = normalizedX + normalizedY <= 1 ? firstRgb : secondRgb;
|
|
2145
|
+
const p = ((y + yy) * imageWidth + (x + xx)) * 4;
|
|
2146
|
+
data[p] = rgb.r;
|
|
2147
|
+
data[p + 1] = rgb.g;
|
|
2148
|
+
data[p + 2] = rgb.b;
|
|
2149
|
+
data[p + 3] = 255;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
function svgTriangleCell(x, y, size, cell, palette) {
|
|
2155
|
+
const pair = triangleCellColors(cell);
|
|
2156
|
+
if (pair.first === pair.second) return svgRect(x, y, size, size, cellColor(pair.first, palette));
|
|
2157
|
+
const first = cellColor(pair.first, palette);
|
|
2158
|
+
const second = cellColor(pair.second, palette);
|
|
2159
|
+
return [
|
|
2160
|
+
`<polygon points="${x},${y} ${x + size},${y} ${x},${y + size}" fill="${first}"/>`,
|
|
2161
|
+
`<polygon points="${x + size},${y} ${x + size},${y + size} ${x},${y + size}" fill="${second}"/>`
|
|
2162
|
+
].join("");
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
|
|
1305
2166
|
function normalizeRenderStyle(style = RENDER_STYLES.CLASSIC) {
|
|
1306
2167
|
const value = String(style).toLowerCase();
|
|
1307
2168
|
const allowed = Object.values(RENDER_STYLES);
|
|
@@ -1432,36 +2293,375 @@ function fillImageRoundedRect(data, width, x, y, rectWidth, rectHeight, radius,
|
|
|
1432
2293
|
}
|
|
1433
2294
|
}
|
|
1434
2295
|
|
|
1435
|
-
export function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
1436
|
-
assert(canvas && typeof canvas.getContext === "function", "A canvas element is required.");
|
|
1437
|
-
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
|
|
1438
|
-
assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
|
|
1439
|
-
const moduleSize = Math.max(1, Math.floor(options.moduleSize ?? 12));
|
|
1440
|
-
const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
|
|
1441
|
-
const palette = resolvePalette(options.palette);
|
|
1442
|
-
const paletteValues = paletteRgb(options.palette);
|
|
1443
|
-
const style = normalizeRenderStyle(options.style);
|
|
1444
|
-
const layout = renderLayoutForMatrix(matrix);
|
|
1445
|
-
const size = matrix.length;
|
|
1446
|
-
const pixelSize = (size + quietZone * 2) * moduleSize;
|
|
1447
|
-
|
|
1448
|
-
canvas.width = pixelSize;
|
|
1449
|
-
canvas.height = pixelSize;
|
|
1450
|
-
const ctx = canvas.getContext("2d", { alpha: false, willReadFrequently: true });
|
|
1451
|
-
ctx.imageSmoothingEnabled = false;
|
|
1452
|
-
ctx.fillStyle = palette.white;
|
|
1453
|
-
ctx.fillRect(0, 0, pixelSize, pixelSize);
|
|
1454
2296
|
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
2297
|
+
/** Estimate a conservative center-logo ratio from ECC, utilization and output mode. */
|
|
2298
|
+
export function estimateSafeLogoSize(codeOrMatrix, options = {}) {
|
|
2299
|
+
const code = Array.isArray(codeOrMatrix) ? null : codeOrMatrix;
|
|
2300
|
+
const ecc = String(code?.eccLevel ?? options.ecc ?? DEFAULT_ECC_LEVEL).toUpperCase();
|
|
2301
|
+
const base = { L: 0.10, M: 0.13, Q: 0.16, H: 0.19 }[ecc] ?? 0.12;
|
|
2302
|
+
const utilization = Number.isFinite(code?.utilization) ? code.utilization : Number(options.utilization ?? 0.65);
|
|
2303
|
+
const utilizationPenalty = utilization <= 0.55 ? 1 : Math.max(0.72, 1 - (utilization - 0.55) * 0.56);
|
|
2304
|
+
const clearBackground = Boolean(options.clearBackground ?? options.logoClearBackground ?? options.logo?.clearBackground);
|
|
2305
|
+
const clearPenalty = clearBackground ? 0.90 : 1;
|
|
2306
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2307
|
+
const printPenalty = mode === RENDER_MODES.PRINT ? 0.88 : 1;
|
|
2308
|
+
const versionPenalty = code?.version === 1 ? 0.88 : 1;
|
|
2309
|
+
return Math.max(0.07, Math.min(0.22, base * utilizationPenalty * clearPenalty * printPenalty * versionPenalty));
|
|
2310
|
+
}
|
|
1461
2311
|
|
|
1462
|
-
|
|
2312
|
+
/**
|
|
2313
|
+
* Empirically search for the largest logo ratio that still decodes from the
|
|
2314
|
+
* dependency-free ImageData renderer. The logo source must therefore be ImageData-like.
|
|
2315
|
+
*/
|
|
2316
|
+
export function findMaxSafeLogoSize(code, options = {}) {
|
|
2317
|
+
assert(code?.matrix, "findMaxSafeLogoSize expects an encoded QuadQR object.");
|
|
2318
|
+
const logo = options.logo;
|
|
2319
|
+
const source = logo?.source ?? logo;
|
|
2320
|
+
assert(source?.data && source?.width && source?.height,
|
|
2321
|
+
"findMaxSafeLogoSize requires an ImageData-like logo source so it can test pixel output without DOM dependencies.");
|
|
2322
|
+
let low = Math.max(0.05, Number(options.minSize ?? 0.05));
|
|
2323
|
+
let high = Math.min(0.30, Number(options.maxSize ?? 0.28));
|
|
2324
|
+
let best = 0;
|
|
2325
|
+
const iterations = Math.max(3, Math.min(10, Math.floor(options.iterations ?? 7)));
|
|
2326
|
+
for (let i = 0; i < iterations; i++) {
|
|
2327
|
+
const probe = (low + high) / 2;
|
|
2328
|
+
try {
|
|
2329
|
+
const image = renderToImageData(code, {
|
|
2330
|
+
...options,
|
|
2331
|
+
logo: {
|
|
2332
|
+
...(typeof logo === "object" && Object.prototype.hasOwnProperty.call(logo, "source") ? logo : { source }),
|
|
2333
|
+
size: probe
|
|
2334
|
+
}
|
|
2335
|
+
});
|
|
2336
|
+
const decoded = scanImageData(image, { minVersion: code.version, maxVersion: code.version });
|
|
2337
|
+
if (decoded.crc32 !== code.crc32) throw new Error("Decoded a different payload.");
|
|
2338
|
+
best = probe;
|
|
2339
|
+
low = probe;
|
|
2340
|
+
} catch {
|
|
2341
|
+
high = probe;
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
return {
|
|
2345
|
+
safeSize: best || estimateSafeLogoSize(code, options),
|
|
2346
|
+
testedMax: Number(options.maxSize ?? 0.28),
|
|
2347
|
+
iterations,
|
|
2348
|
+
empirical: best > 0
|
|
2349
|
+
};
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
export function getPrintGuidance(codeOrMatrix, options = {}) {
|
|
2353
|
+
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix?.matrix;
|
|
2354
|
+
assert(matrix?.length, "getPrintGuidance expects a QuadQR code or matrix.");
|
|
2355
|
+
const quietZone = Math.max(4, Math.floor(options.quietZone ?? 4));
|
|
2356
|
+
const physicalSizeMm = Number(options.physicalSizeMm ?? 35);
|
|
2357
|
+
const totalModules = matrix.length + quietZone * 2;
|
|
2358
|
+
const moduleSizeMm = physicalSizeMm / totalModules;
|
|
2359
|
+
const dpi = Number(options.dpi ?? 300);
|
|
2360
|
+
const modulePixels = moduleSizeMm / 25.4 * dpi;
|
|
2361
|
+
const recommendedMinimumModuleMm = Number(options.minimumModuleMm ?? 0.40);
|
|
2362
|
+
return {
|
|
2363
|
+
mode: "print",
|
|
2364
|
+
physicalSizeMm,
|
|
2365
|
+
quietZone,
|
|
2366
|
+
moduleSizeMm,
|
|
2367
|
+
modulePixelsAtDpi: modulePixels,
|
|
2368
|
+
dpi,
|
|
2369
|
+
recommendedMinimumModuleMm,
|
|
2370
|
+
meetsRecommendedModuleSize: moduleSizeMm >= recommendedMinimumModuleMm,
|
|
2371
|
+
recommendedMinimumPhysicalSizeMm: totalModules * recommendedMinimumModuleMm,
|
|
2372
|
+
palette: PRINT_PALETTE,
|
|
2373
|
+
recommendations: [
|
|
2374
|
+
"Use classic rendering for print unless a styled output has been physically validated.",
|
|
2375
|
+
"Keep at least a 4-module quiet zone.",
|
|
2376
|
+
`Target at least ${recommendedMinimumModuleMm.toFixed(2)} mm per module for general-purpose printing.`,
|
|
2377
|
+
"Avoid glossy reflections and low-ink/toner modes when reliable color classification matters."
|
|
2378
|
+
]
|
|
2379
|
+
};
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
function resolveLogoRenderOptions(options, matrixSize, moduleSize, quietZone, palette, codeOrMatrix = null) {
|
|
2383
|
+
if (!options.logo) return null;
|
|
2384
|
+
|
|
2385
|
+
const nested = (
|
|
2386
|
+
options.logo &&
|
|
2387
|
+
typeof options.logo === "object" &&
|
|
2388
|
+
Object.prototype.hasOwnProperty.call(options.logo, "source")
|
|
2389
|
+
) ? options.logo : null;
|
|
2390
|
+
const source = nested ? nested.source : options.logo;
|
|
2391
|
+
if (!source) return null;
|
|
2392
|
+
|
|
2393
|
+
const requestedSize = nested?.size ?? options.logoSize ?? 0.18;
|
|
2394
|
+
const autoSize = String(requestedSize).toLowerCase() === "auto";
|
|
2395
|
+
const rawSize = Number(requestedSize);
|
|
2396
|
+
const sizeRatio = autoSize
|
|
2397
|
+
? estimateSafeLogoSize(codeOrMatrix, { ...options, clearBackground: nested?.clearBackground ?? options.logoClearBackground })
|
|
2398
|
+
: Math.max(0.05, Math.min(0.30, Number.isFinite(rawSize) ? rawSize : 0.18));
|
|
2399
|
+
const rawPadding = Number(nested?.padding ?? options.logoPadding ?? 0.65);
|
|
2400
|
+
const rawRadius = Number(nested?.radius ?? options.logoRadius ?? 0.8);
|
|
2401
|
+
const paddingModules = Math.max(0, Number.isFinite(rawPadding) ? rawPadding : 0.65);
|
|
2402
|
+
const radiusModules = Math.max(0, Number.isFinite(rawRadius) ? rawRadius : 0.8);
|
|
2403
|
+
const clearBackground = Boolean(nested?.clearBackground ?? options.logoClearBackground ?? false);
|
|
2404
|
+
const backgroundColor = nested?.backgroundColor ?? options.logoBackgroundColor ?? palette.white;
|
|
2405
|
+
|
|
2406
|
+
const codePixels = matrixSize * moduleSize;
|
|
2407
|
+
const canvasPixels = (matrixSize + quietZone * 2) * moduleSize;
|
|
2408
|
+
const logoPixels = Math.max(1, codePixels * sizeRatio);
|
|
2409
|
+
const center = canvasPixels / 2;
|
|
2410
|
+
const paddingPixels = paddingModules * moduleSize;
|
|
2411
|
+
const backgroundPixels = logoPixels + paddingPixels * 2;
|
|
2412
|
+
|
|
2413
|
+
return {
|
|
2414
|
+
source,
|
|
2415
|
+
sizeRatio,
|
|
2416
|
+
clearBackground,
|
|
2417
|
+
backgroundColor,
|
|
2418
|
+
logoX: center - logoPixels / 2,
|
|
2419
|
+
logoY: center - logoPixels / 2,
|
|
2420
|
+
logoSize: logoPixels,
|
|
2421
|
+
backgroundX: center - backgroundPixels / 2,
|
|
2422
|
+
backgroundY: center - backgroundPixels / 2,
|
|
2423
|
+
backgroundSize: backgroundPixels,
|
|
2424
|
+
backgroundRadius: radiusModules * moduleSize
|
|
2425
|
+
};
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
function canvasLogoSource(source) {
|
|
2429
|
+
if (!source || typeof source === "string") return source;
|
|
2430
|
+
if (source.data && Number.isInteger(source.width) && Number.isInteger(source.height)) {
|
|
2431
|
+
let scratch = null;
|
|
2432
|
+
if (typeof OffscreenCanvas !== "undefined") {
|
|
2433
|
+
scratch = new OffscreenCanvas(source.width, source.height);
|
|
2434
|
+
} else if (typeof document !== "undefined") {
|
|
2435
|
+
scratch = document.createElement("canvas");
|
|
2436
|
+
scratch.width = source.width;
|
|
2437
|
+
scratch.height = source.height;
|
|
2438
|
+
}
|
|
2439
|
+
if (!scratch) return source;
|
|
2440
|
+
const ctx = scratch.getContext("2d");
|
|
2441
|
+
if (!ctx) return source;
|
|
2442
|
+
const pixels = source.data instanceof Uint8ClampedArray
|
|
2443
|
+
? source.data
|
|
2444
|
+
: new Uint8ClampedArray(source.data);
|
|
2445
|
+
ctx.putImageData(new ImageData(pixels, source.width, source.height), 0, 0);
|
|
2446
|
+
return scratch;
|
|
2447
|
+
}
|
|
2448
|
+
return source;
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
function logoSourceDimensions(source) {
|
|
2452
|
+
const width = source?.naturalWidth ?? source?.videoWidth ?? source?.width;
|
|
2453
|
+
const height = source?.naturalHeight ?? source?.videoHeight ?? source?.height;
|
|
2454
|
+
if (!(Number(width) > 0 && Number(height) > 0)) return null;
|
|
2455
|
+
return { width: Number(width), height: Number(height) };
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
function containRect(x, y, size, dimensions) {
|
|
2459
|
+
if (!dimensions) return { x, y, width: size, height: size };
|
|
2460
|
+
const scale = Math.min(size / dimensions.width, size / dimensions.height);
|
|
2461
|
+
const width = dimensions.width * scale;
|
|
2462
|
+
const height = dimensions.height * scale;
|
|
2463
|
+
return {
|
|
2464
|
+
x: x + (size - width) / 2,
|
|
2465
|
+
y: y + (size - height) / 2,
|
|
2466
|
+
width,
|
|
2467
|
+
height
|
|
2468
|
+
};
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
function drawLogoToCanvas(ctx, logo, palette) {
|
|
2472
|
+
if (!logo) return;
|
|
2473
|
+
if (logo.clearBackground) {
|
|
2474
|
+
ctx.fillStyle = logo.backgroundColor || palette.white;
|
|
2475
|
+
roundedRectPath(
|
|
2476
|
+
ctx,
|
|
2477
|
+
logo.backgroundX,
|
|
2478
|
+
logo.backgroundY,
|
|
2479
|
+
logo.backgroundSize,
|
|
2480
|
+
logo.backgroundSize,
|
|
2481
|
+
logo.backgroundRadius
|
|
2482
|
+
);
|
|
2483
|
+
ctx.fill();
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
const source = canvasLogoSource(logo.source);
|
|
2487
|
+
assert(
|
|
2488
|
+
source && typeof source !== "string",
|
|
2489
|
+
"renderToCanvas() logo must be a loaded CanvasImageSource or ImageData-like object. Load URL/data-URL logos into an Image first."
|
|
2490
|
+
);
|
|
2491
|
+
const rect = containRect(logo.logoX, logo.logoY, logo.logoSize, logoSourceDimensions(source));
|
|
2492
|
+
const previousSmoothing = ctx.imageSmoothingEnabled;
|
|
2493
|
+
ctx.imageSmoothingEnabled = true;
|
|
2494
|
+
ctx.drawImage(source, rect.x, rect.y, rect.width, rect.height);
|
|
2495
|
+
ctx.imageSmoothingEnabled = previousSmoothing;
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
function blendImagePixel(data, width, x, y, rgb, alpha) {
|
|
2499
|
+
if (x < 0 || y < 0 || x >= width) return;
|
|
2500
|
+
const height = data.length / 4 / width;
|
|
2501
|
+
if (y >= height) return;
|
|
2502
|
+
const p = (y * width + x) * 4;
|
|
2503
|
+
const a = Math.max(0, Math.min(1, alpha));
|
|
2504
|
+
data[p] = Math.round(data[p] * (1 - a) + rgb.r * a);
|
|
2505
|
+
data[p + 1] = Math.round(data[p + 1] * (1 - a) + rgb.g * a);
|
|
2506
|
+
data[p + 2] = Math.round(data[p + 2] * (1 - a) + rgb.b * a);
|
|
2507
|
+
data[p + 3] = 255;
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
function drawLogoToImageData(data, width, logo) {
|
|
2511
|
+
if (!logo) return;
|
|
2512
|
+
if (logo.clearBackground) {
|
|
2513
|
+
const rgb = hexToRgb(logo.backgroundColor);
|
|
2514
|
+
fillImageRoundedRect(
|
|
2515
|
+
data,
|
|
2516
|
+
width,
|
|
2517
|
+
Math.round(logo.backgroundX),
|
|
2518
|
+
Math.round(logo.backgroundY),
|
|
2519
|
+
Math.round(logo.backgroundSize),
|
|
2520
|
+
Math.round(logo.backgroundSize),
|
|
2521
|
+
Math.round(logo.backgroundRadius),
|
|
2522
|
+
rgb
|
|
2523
|
+
);
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
const source = logo.source;
|
|
2527
|
+
assert(
|
|
2528
|
+
source?.data && Number.isInteger(source.width) && Number.isInteger(source.height),
|
|
2529
|
+
"renderToImageData() logo must be an ImageData-like object with width, height, and RGBA data."
|
|
2530
|
+
);
|
|
2531
|
+
const dest = containRect(logo.logoX, logo.logoY, logo.logoSize, {
|
|
2532
|
+
width: source.width,
|
|
2533
|
+
height: source.height
|
|
2534
|
+
});
|
|
2535
|
+
const x0 = Math.round(dest.x);
|
|
2536
|
+
const y0 = Math.round(dest.y);
|
|
2537
|
+
const destWidth = Math.max(1, Math.round(dest.width));
|
|
2538
|
+
const destHeight = Math.max(1, Math.round(dest.height));
|
|
2539
|
+
const src = source.data;
|
|
2540
|
+
|
|
2541
|
+
for (let y = 0; y < destHeight; y++) {
|
|
2542
|
+
const sy = Math.min(source.height - 1, Math.floor((y + 0.5) * source.height / destHeight));
|
|
2543
|
+
for (let x = 0; x < destWidth; x++) {
|
|
2544
|
+
const sx = Math.min(source.width - 1, Math.floor((x + 0.5) * source.width / destWidth));
|
|
2545
|
+
const p = (sy * source.width + sx) * 4;
|
|
2546
|
+
const alpha = (src[p + 3] ?? 255) / 255;
|
|
2547
|
+
if (alpha <= 0) continue;
|
|
2548
|
+
blendImagePixel(data, width, x0 + x, y0 + y, {
|
|
2549
|
+
r: src[p],
|
|
2550
|
+
g: src[p + 1],
|
|
2551
|
+
b: src[p + 2]
|
|
2552
|
+
}, alpha);
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
function escapeXmlAttribute(value) {
|
|
2558
|
+
return String(value)
|
|
2559
|
+
.replaceAll("&", "&")
|
|
2560
|
+
.replaceAll('"', """)
|
|
2561
|
+
.replaceAll("<", "<")
|
|
2562
|
+
.replaceAll(">", ">");
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
function svgRect(x, y, width, height, fill, radius = 0) {
|
|
2566
|
+
const rounded = radius > 0 ? ` rx="${radius}" ry="${radius}"` : "";
|
|
2567
|
+
return `<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="${escapeXmlAttribute(fill)}"${rounded}/>`;
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
function svgLogoHref(source) {
|
|
2571
|
+
if (typeof source === "string") return source;
|
|
2572
|
+
if (typeof source?.src === "string" && source.src) return source.src;
|
|
2573
|
+
return null;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
function resolveRenderSizing(options, matrixSize) {
|
|
2577
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2578
|
+
const requestedQuietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
|
|
2579
|
+
const quietZone = mode === RENDER_MODES.PRINT && options.allowUnsafePrintQuietZone !== true
|
|
2580
|
+
? Math.max(4, requestedQuietZone)
|
|
2581
|
+
: requestedQuietZone;
|
|
2582
|
+
const totalModules = matrixSize + quietZone * 2;
|
|
2583
|
+
const hasImageSize = options.imageSize !== undefined && options.imageSize !== null;
|
|
2584
|
+
const hasModuleSize = options.moduleSize !== undefined && options.moduleSize !== null;
|
|
2585
|
+
|
|
2586
|
+
let pixelSize;
|
|
2587
|
+
if (hasImageSize) {
|
|
2588
|
+
const requested = Number(options.imageSize);
|
|
2589
|
+
assert(Number.isFinite(requested) && requested > 0, "imageSize must be a positive number of pixels.");
|
|
2590
|
+
pixelSize = Math.max(totalModules, Math.round(requested));
|
|
2591
|
+
} else if (hasModuleSize) {
|
|
2592
|
+
const requested = Number(options.moduleSize);
|
|
2593
|
+
assert(Number.isFinite(requested) && requested > 0, "moduleSize must be a positive number of pixels.");
|
|
2594
|
+
const moduleSize = Math.max(1, Math.floor(requested));
|
|
2595
|
+
pixelSize = totalModules * moduleSize;
|
|
2596
|
+
} else {
|
|
2597
|
+
// A practical export default that is large enough for screens/social use
|
|
2598
|
+
// while keeping the API independent from the encoded matrix version.
|
|
2599
|
+
pixelSize = Math.max(totalModules, 720);
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
return {
|
|
2603
|
+
quietZone,
|
|
2604
|
+
totalModules,
|
|
2605
|
+
pixelSize,
|
|
2606
|
+
moduleSize: pixelSize / totalModules,
|
|
2607
|
+
mode
|
|
2608
|
+
};
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
function rasterModuleRect(row, col, quietZone, moduleSize) {
|
|
2612
|
+
const x0 = Math.round((col + quietZone) * moduleSize);
|
|
2613
|
+
const y0 = Math.round((row + quietZone) * moduleSize);
|
|
2614
|
+
const x1 = Math.round((col + quietZone + 1) * moduleSize);
|
|
2615
|
+
const y1 = Math.round((row + quietZone + 1) * moduleSize);
|
|
2616
|
+
return {
|
|
2617
|
+
x: x0,
|
|
2618
|
+
y: y0,
|
|
2619
|
+
width: Math.max(1, x1 - x0),
|
|
2620
|
+
height: Math.max(1, y1 - y0)
|
|
2621
|
+
};
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
export function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
2625
|
+
assert(canvas && typeof canvas.getContext === "function", "A canvas element is required.");
|
|
2626
|
+
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
|
|
2627
|
+
assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
|
|
2628
|
+
const sizing = resolveRenderSizing(options, matrix.length);
|
|
2629
|
+
const { moduleSize, quietZone, pixelSize } = sizing;
|
|
2630
|
+
const palette = resolveRenderPalette(options);
|
|
2631
|
+
const paletteValues = paletteRgb(palette);
|
|
2632
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2633
|
+
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
2634
|
+
const layout = renderLayoutForMatrix(matrix);
|
|
2635
|
+
const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
|
|
2636
|
+
const size = matrix.length;
|
|
2637
|
+
const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
|
|
2638
|
+
|
|
2639
|
+
canvas.width = pixelSize;
|
|
2640
|
+
canvas.height = pixelSize;
|
|
2641
|
+
const ctx = canvas.getContext("2d", { alpha: false, willReadFrequently: true });
|
|
2642
|
+
ctx.imageSmoothingEnabled = false;
|
|
2643
|
+
ctx.fillStyle = palette.white;
|
|
2644
|
+
ctx.fillRect(0, 0, pixelSize, pixelSize);
|
|
2645
|
+
|
|
2646
|
+
for (let r = 0; r < size; r++) {
|
|
2647
|
+
for (let c = 0; c < size; c++) {
|
|
2648
|
+
const cell = matrix[r][c];
|
|
2649
|
+
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
2650
|
+
const rect = rasterModuleRect(r, c, quietZone, moduleSize);
|
|
2651
|
+
const { x, y, width: cellWidth, height: cellHeight } = rect;
|
|
2652
|
+
const cellSize = Math.min(cellWidth, cellHeight);
|
|
2653
|
+
|
|
2654
|
+
if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
|
|
2655
|
+
// Experimental Triangle16 always renders payload cells with exact,
|
|
2656
|
+
// hard-edged geometry. Decorative per-cell styles would contaminate the
|
|
2657
|
+
// two independent color samples and reduce scan reliability.
|
|
2658
|
+
drawTriangleCellCanvas(ctx, x, y, cellWidth, cellHeight, cell, palette);
|
|
2659
|
+
continue;
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
1463
2663
|
ctx.fillStyle = cellColor(cell, palette);
|
|
1464
|
-
ctx.fillRect(x, y,
|
|
2664
|
+
ctx.fillRect(x, y, cellWidth, cellHeight);
|
|
1465
2665
|
continue;
|
|
1466
2666
|
}
|
|
1467
2667
|
|
|
@@ -1471,28 +2671,29 @@ export function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
|
1471
2671
|
const opacity = depthOpacity(r, c, cell);
|
|
1472
2672
|
const base = mixRgb(paletteValues.white, rgb, opacity);
|
|
1473
2673
|
ctx.fillStyle = rgbCss(base);
|
|
1474
|
-
ctx.fillRect(x, y,
|
|
2674
|
+
ctx.fillRect(x, y, cellWidth, cellHeight);
|
|
1475
2675
|
|
|
1476
|
-
if (
|
|
1477
|
-
const edge = Math.max(1, Math.floor(
|
|
2676
|
+
if (cellSize >= 6) {
|
|
2677
|
+
const edge = Math.max(1, Math.floor(cellSize * 0.08));
|
|
1478
2678
|
const highlight = mixRgb(base, paletteValues.white, 0.17);
|
|
1479
2679
|
const shadow = mixRgb(base, paletteValues.black, 0.12);
|
|
1480
2680
|
ctx.fillStyle = rgbCss(highlight);
|
|
1481
|
-
ctx.fillRect(x, y,
|
|
1482
|
-
ctx.fillRect(x, y, edge,
|
|
2681
|
+
ctx.fillRect(x, y, cellWidth, edge);
|
|
2682
|
+
ctx.fillRect(x, y, edge, cellHeight);
|
|
1483
2683
|
ctx.fillStyle = rgbCss(shadow);
|
|
1484
|
-
ctx.fillRect(x, y +
|
|
1485
|
-
ctx.fillRect(x +
|
|
2684
|
+
ctx.fillRect(x, y + cellHeight - edge, cellWidth, edge);
|
|
2685
|
+
ctx.fillRect(x + cellWidth - edge, y, edge, cellHeight);
|
|
1486
2686
|
}
|
|
1487
2687
|
continue;
|
|
1488
2688
|
}
|
|
1489
2689
|
|
|
1490
2690
|
if (style === RENDER_STYLES.SOFT) {
|
|
1491
|
-
const inset =
|
|
1492
|
-
const width =
|
|
1493
|
-
const
|
|
2691
|
+
const inset = cellSize >= 6 ? Math.max(1, Math.floor(cellSize * 0.07)) : 0;
|
|
2692
|
+
const width = cellWidth - inset * 2;
|
|
2693
|
+
const height = cellHeight - inset * 2;
|
|
2694
|
+
const radius = Math.max(1, Math.floor(cellSize * 0.20));
|
|
1494
2695
|
ctx.fillStyle = cellColor(cell, palette);
|
|
1495
|
-
roundedRectPath(ctx, x + inset, y + inset, width,
|
|
2696
|
+
roundedRectPath(ctx, x + inset, y + inset, width, height, radius);
|
|
1496
2697
|
ctx.fill();
|
|
1497
2698
|
continue;
|
|
1498
2699
|
}
|
|
@@ -1501,37 +2702,41 @@ export function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
|
1501
2702
|
// Keep the exact encoded color across the full module, then limit the
|
|
1502
2703
|
// recessed effect to a narrow edge band so center sampling stays intact.
|
|
1503
2704
|
ctx.fillStyle = cellColor(cell, palette);
|
|
1504
|
-
ctx.fillRect(x, y,
|
|
2705
|
+
ctx.fillRect(x, y, cellWidth, cellHeight);
|
|
1505
2706
|
if (cell === CELL.WHITE) continue;
|
|
1506
2707
|
|
|
1507
|
-
const edge = safeStyleEdge(
|
|
2708
|
+
const edge = safeStyleEdge(cellSize);
|
|
1508
2709
|
if (edge > 0) {
|
|
1509
2710
|
const fx = insetStyleColors(rgb, paletteValues);
|
|
1510
2711
|
ctx.fillStyle = rgbCss(fx.shadow);
|
|
1511
|
-
ctx.fillRect(x, y,
|
|
1512
|
-
ctx.fillRect(x, y, edge,
|
|
2712
|
+
ctx.fillRect(x, y, cellWidth, edge);
|
|
2713
|
+
ctx.fillRect(x, y, edge, cellHeight);
|
|
1513
2714
|
ctx.fillStyle = rgbCss(fx.highlight);
|
|
1514
|
-
ctx.fillRect(x, y +
|
|
1515
|
-
ctx.fillRect(x +
|
|
2715
|
+
ctx.fillRect(x, y + cellHeight - edge, cellWidth, edge);
|
|
2716
|
+
ctx.fillRect(x + cellWidth - edge, y, edge, cellHeight);
|
|
1516
2717
|
}
|
|
1517
2718
|
}
|
|
1518
2719
|
}
|
|
1519
2720
|
}
|
|
2721
|
+
drawLogoToCanvas(ctx, logo, palette);
|
|
1520
2722
|
return canvas;
|
|
1521
2723
|
}
|
|
1522
2724
|
|
|
1523
2725
|
export function renderToImageData(codeOrMatrix, options = {}) {
|
|
1524
2726
|
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
|
|
1525
2727
|
assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
|
|
1526
|
-
const
|
|
1527
|
-
const
|
|
1528
|
-
const
|
|
1529
|
-
const
|
|
2728
|
+
const sizing = resolveRenderSizing(options, matrix.length);
|
|
2729
|
+
const { moduleSize, quietZone, pixelSize } = sizing;
|
|
2730
|
+
const resolvedPalette = resolveRenderPalette(options);
|
|
2731
|
+
const palette = paletteRgb(resolvedPalette);
|
|
2732
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2733
|
+
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
1530
2734
|
const layout = renderLayoutForMatrix(matrix);
|
|
2735
|
+
const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
|
|
1531
2736
|
const size = matrix.length;
|
|
1532
|
-
const pixelSize = (size + quietZone * 2) * moduleSize;
|
|
1533
2737
|
const data = new Uint8ClampedArray(pixelSize * pixelSize * 4);
|
|
1534
2738
|
const white = palette.white;
|
|
2739
|
+
const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, resolvedPalette, codeOrMatrix);
|
|
1535
2740
|
|
|
1536
2741
|
for (let i = 0; i < pixelSize * pixelSize; i++) {
|
|
1537
2742
|
const p = i * 4;
|
|
@@ -1544,28 +2749,119 @@ export function renderToImageData(codeOrMatrix, options = {}) {
|
|
|
1544
2749
|
for (let r = 0; r < size; r++) {
|
|
1545
2750
|
for (let c = 0; c < size; c++) {
|
|
1546
2751
|
const cell = matrix[r][c];
|
|
1547
|
-
const rgb = cellRgb(cell, palette);
|
|
1548
2752
|
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
1549
|
-
const
|
|
1550
|
-
const x0 =
|
|
2753
|
+
const rect = rasterModuleRect(r, c, quietZone, moduleSize);
|
|
2754
|
+
const x0 = rect.x;
|
|
2755
|
+
const y0 = rect.y;
|
|
2756
|
+
const cellWidth = rect.width;
|
|
2757
|
+
const cellHeight = rect.height;
|
|
2758
|
+
const cellSize = Math.min(cellWidth, cellHeight);
|
|
2759
|
+
|
|
2760
|
+
if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
|
|
2761
|
+
fillImageTriangleCell(data, pixelSize, x0, y0, cellWidth, cellHeight, cell, palette);
|
|
2762
|
+
continue;
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
const rgb = cellRgb(cell, palette);
|
|
1551
2766
|
|
|
1552
2767
|
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
1553
|
-
fillImageRect(data, pixelSize, x0, y0,
|
|
2768
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
|
|
1554
2769
|
continue;
|
|
1555
2770
|
}
|
|
1556
2771
|
|
|
1557
2772
|
if (style === RENDER_STYLES.DEPTH) {
|
|
1558
2773
|
const opacity = depthOpacity(r, c, cell);
|
|
1559
2774
|
const base = mixRgb(white, rgb, opacity);
|
|
1560
|
-
fillImageRect(data, pixelSize, x0, y0,
|
|
1561
|
-
if (
|
|
1562
|
-
const edge = Math.max(1, Math.floor(
|
|
2775
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, base);
|
|
2776
|
+
if (cellSize >= 6) {
|
|
2777
|
+
const edge = Math.max(1, Math.floor(cellSize * 0.08));
|
|
1563
2778
|
const highlight = mixRgb(base, white, 0.17);
|
|
1564
2779
|
const shadow = mixRgb(base, palette.black, 0.12);
|
|
1565
|
-
fillImageRect(data, pixelSize, x0, y0,
|
|
1566
|
-
fillImageRect(data, pixelSize, x0, y0, edge,
|
|
1567
|
-
fillImageRect(data, pixelSize, x0, y0 +
|
|
1568
|
-
fillImageRect(data, pixelSize, x0 +
|
|
2780
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, edge, highlight);
|
|
2781
|
+
fillImageRect(data, pixelSize, x0, y0, edge, cellHeight, highlight);
|
|
2782
|
+
fillImageRect(data, pixelSize, x0, y0 + cellHeight - edge, cellWidth, edge, shadow);
|
|
2783
|
+
fillImageRect(data, pixelSize, x0 + cellWidth - edge, y0, edge, cellHeight, shadow);
|
|
2784
|
+
}
|
|
2785
|
+
continue;
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
if (style === RENDER_STYLES.SOFT) {
|
|
2789
|
+
const inset = cellSize >= 6 ? Math.max(1, Math.floor(cellSize * 0.07)) : 0;
|
|
2790
|
+
const width = cellWidth - inset * 2;
|
|
2791
|
+
const height = cellHeight - inset * 2;
|
|
2792
|
+
const radius = Math.max(1, Math.floor(cellSize * 0.20));
|
|
2793
|
+
fillImageRoundedRect(data, pixelSize, x0 + inset, y0 + inset, width, height, radius, rgb);
|
|
2794
|
+
continue;
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
if (style === RENDER_STYLES.INSET) {
|
|
2798
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
|
|
2799
|
+
if (cell === CELL.WHITE) continue;
|
|
2800
|
+
const edge = safeStyleEdge(cellSize);
|
|
2801
|
+
if (edge > 0) {
|
|
2802
|
+
const fx = insetStyleColors(rgb, palette);
|
|
2803
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, edge, fx.shadow);
|
|
2804
|
+
fillImageRect(data, pixelSize, x0, y0, edge, cellHeight, fx.shadow);
|
|
2805
|
+
fillImageRect(data, pixelSize, x0, y0 + cellHeight - edge, cellWidth, edge, fx.highlight);
|
|
2806
|
+
fillImageRect(data, pixelSize, x0 + cellWidth - edge, y0, edge, cellHeight, fx.highlight);
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
drawLogoToImageData(data, pixelSize, logo);
|
|
2813
|
+
|
|
2814
|
+
return { width: pixelSize, height: pixelSize, data };
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
/** Render a QuadQR code/matrix to a standalone SVG string. */
|
|
2818
|
+
export function renderToSVG(codeOrMatrix, options = {}) {
|
|
2819
|
+
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
|
|
2820
|
+
assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
|
|
2821
|
+
const sizing = resolveRenderSizing(options, matrix.length);
|
|
2822
|
+
const { moduleSize, quietZone, pixelSize } = sizing;
|
|
2823
|
+
const palette = resolveRenderPalette(options);
|
|
2824
|
+
const paletteValues = paletteRgb(palette);
|
|
2825
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2826
|
+
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
2827
|
+
const layout = renderLayoutForMatrix(matrix);
|
|
2828
|
+
const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
|
|
2829
|
+
const size = matrix.length;
|
|
2830
|
+
const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
|
|
2831
|
+
const body = [svgRect(0, 0, pixelSize, pixelSize, palette.white)];
|
|
2832
|
+
|
|
2833
|
+
for (let r = 0; r < size; r++) {
|
|
2834
|
+
for (let c = 0; c < size; c++) {
|
|
2835
|
+
const cell = matrix[r][c];
|
|
2836
|
+
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
2837
|
+
const x = (c + quietZone) * moduleSize;
|
|
2838
|
+
const y = (r + quietZone) * moduleSize;
|
|
2839
|
+
const fill = cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural ? null : cellColor(cell, palette);
|
|
2840
|
+
|
|
2841
|
+
if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
|
|
2842
|
+
body.push(svgTriangleCell(x, y, moduleSize, cell, palette));
|
|
2843
|
+
continue;
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
2847
|
+
body.push(svgRect(x, y, moduleSize, moduleSize, fill));
|
|
2848
|
+
continue;
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
const rgb = cellRgb(cell, paletteValues);
|
|
2852
|
+
|
|
2853
|
+
if (style === RENDER_STYLES.DEPTH) {
|
|
2854
|
+
const opacity = depthOpacity(r, c, cell);
|
|
2855
|
+
const base = mixRgb(paletteValues.white, rgb, opacity);
|
|
2856
|
+
body.push(svgRect(x, y, moduleSize, moduleSize, rgbCss(base)));
|
|
2857
|
+
if (moduleSize >= 6) {
|
|
2858
|
+
const edge = Math.max(1, Math.floor(moduleSize * 0.08));
|
|
2859
|
+
const highlight = rgbCss(mixRgb(base, paletteValues.white, 0.17));
|
|
2860
|
+
const shadow = rgbCss(mixRgb(base, paletteValues.black, 0.12));
|
|
2861
|
+
body.push(svgRect(x, y, moduleSize, edge, highlight));
|
|
2862
|
+
body.push(svgRect(x, y, edge, moduleSize, highlight));
|
|
2863
|
+
body.push(svgRect(x, y + moduleSize - edge, moduleSize, edge, shadow));
|
|
2864
|
+
body.push(svgRect(x + moduleSize - edge, y, edge, moduleSize, shadow));
|
|
1569
2865
|
}
|
|
1570
2866
|
continue;
|
|
1571
2867
|
}
|
|
@@ -1574,26 +2870,51 @@ export function renderToImageData(codeOrMatrix, options = {}) {
|
|
|
1574
2870
|
const inset = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
|
|
1575
2871
|
const width = moduleSize - inset * 2;
|
|
1576
2872
|
const radius = Math.max(1, Math.floor(moduleSize * 0.20));
|
|
1577
|
-
|
|
2873
|
+
body.push(svgRect(x + inset, y + inset, width, width, fill, radius));
|
|
1578
2874
|
continue;
|
|
1579
2875
|
}
|
|
1580
2876
|
|
|
1581
2877
|
if (style === RENDER_STYLES.INSET) {
|
|
1582
|
-
|
|
2878
|
+
body.push(svgRect(x, y, moduleSize, moduleSize, fill));
|
|
1583
2879
|
if (cell === CELL.WHITE) continue;
|
|
1584
2880
|
const edge = safeStyleEdge(moduleSize);
|
|
1585
2881
|
if (edge > 0) {
|
|
1586
|
-
const fx = insetStyleColors(rgb,
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
2882
|
+
const fx = insetStyleColors(rgb, paletteValues);
|
|
2883
|
+
const shadow = rgbCss(fx.shadow);
|
|
2884
|
+
const highlight = rgbCss(fx.highlight);
|
|
2885
|
+
body.push(svgRect(x, y, moduleSize, edge, shadow));
|
|
2886
|
+
body.push(svgRect(x, y, edge, moduleSize, shadow));
|
|
2887
|
+
body.push(svgRect(x, y + moduleSize - edge, moduleSize, edge, highlight));
|
|
2888
|
+
body.push(svgRect(x + moduleSize - edge, y, edge, moduleSize, highlight));
|
|
1591
2889
|
}
|
|
1592
2890
|
}
|
|
1593
2891
|
}
|
|
1594
2892
|
}
|
|
1595
2893
|
|
|
1596
|
-
|
|
2894
|
+
if (logo) {
|
|
2895
|
+
if (logo.clearBackground) {
|
|
2896
|
+
body.push(svgRect(
|
|
2897
|
+
logo.backgroundX,
|
|
2898
|
+
logo.backgroundY,
|
|
2899
|
+
logo.backgroundSize,
|
|
2900
|
+
logo.backgroundSize,
|
|
2901
|
+
logo.backgroundColor,
|
|
2902
|
+
logo.backgroundRadius
|
|
2903
|
+
));
|
|
2904
|
+
}
|
|
2905
|
+
const href = svgLogoHref(logo.source);
|
|
2906
|
+
assert(href, "renderToSVG() logo must be a URL/data URL string or an object with a src string.");
|
|
2907
|
+
body.push(
|
|
2908
|
+
`<image href="${escapeXmlAttribute(href)}" x="${logo.logoX}" y="${logo.logoY}" width="${logo.logoSize}" height="${logo.logoSize}" preserveAspectRatio="xMidYMid meet"/>`
|
|
2909
|
+
);
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
return [
|
|
2913
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
2914
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${pixelSize}" height="${pixelSize}" viewBox="0 0 ${pixelSize} ${pixelSize}" shape-rendering="crispEdges">`,
|
|
2915
|
+
...body,
|
|
2916
|
+
"</svg>"
|
|
2917
|
+
].join("\n");
|
|
1597
2918
|
}
|
|
1598
2919
|
|
|
1599
2920
|
function clampNumber(value, min, max) {
|
|
@@ -1747,6 +3068,60 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
|
|
|
1747
3068
|
};
|
|
1748
3069
|
}
|
|
1749
3070
|
|
|
3071
|
+
function classifyTriangleSampledRgbGrid(rgbGrid, triangleGrid, classifier, layout) {
|
|
3072
|
+
const size = rgbGrid.length;
|
|
3073
|
+
const matrix = make2D(size, CELL.WHITE);
|
|
3074
|
+
const confidence = make2D(size, 1);
|
|
3075
|
+
const dataClassifier = {
|
|
3076
|
+
...classifier,
|
|
3077
|
+
entries: classifier.entries.filter(({ cell }) => cell !== CELL.BLACK)
|
|
3078
|
+
};
|
|
3079
|
+
let distanceSum = 0;
|
|
3080
|
+
let confidenceSum = 0;
|
|
3081
|
+
let minimumConfidence = 1;
|
|
3082
|
+
let lowConfidenceCells = 0;
|
|
3083
|
+
|
|
3084
|
+
for (let r = 0; r < size; r++) {
|
|
3085
|
+
for (let c = 0; c < size; c++) {
|
|
3086
|
+
const isDataCell = layout && !layout.reserved[r][c];
|
|
3087
|
+
if (!isDataCell) {
|
|
3088
|
+
const classified = classifyRgb(rgbGrid[r][c], classifier);
|
|
3089
|
+
matrix[r][c] = classified.cell;
|
|
3090
|
+
confidence[r][c] = classified.confidence;
|
|
3091
|
+
distanceSum += classified.distance;
|
|
3092
|
+
confidenceSum += classified.confidence;
|
|
3093
|
+
minimumConfidence = Math.min(minimumConfidence, classified.confidence);
|
|
3094
|
+
if (classified.confidence < 0.4) lowConfidenceCells++;
|
|
3095
|
+
continue;
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
const samples = triangleGrid?.[r]?.[c];
|
|
3099
|
+
if (!samples) throw new Error("Triangle16 sampling grid is incomplete.");
|
|
3100
|
+
const classifyData = (rgb) => classifier.dataMode === "hue"
|
|
3101
|
+
? classifyDataHue(rgb, classifier)
|
|
3102
|
+
: classifyRgb(rgb, dataClassifier);
|
|
3103
|
+
const first = classifyData(samples.first);
|
|
3104
|
+
const second = classifyData(samples.second);
|
|
3105
|
+
const cellConfidence = Math.min(first.confidence, second.confidence);
|
|
3106
|
+
matrix[r][c] = packTriangleCell(first.cell, second.cell);
|
|
3107
|
+
confidence[r][c] = cellConfidence;
|
|
3108
|
+
distanceSum += (first.distance + second.distance) / 2;
|
|
3109
|
+
confidenceSum += cellConfidence;
|
|
3110
|
+
minimumConfidence = Math.min(minimumConfidence, cellConfidence);
|
|
3111
|
+
if (cellConfidence < 0.4) lowConfidenceCells++;
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
return {
|
|
3116
|
+
matrix,
|
|
3117
|
+
confidence,
|
|
3118
|
+
averageColorDistance: distanceSum / (size * size),
|
|
3119
|
+
averageCellConfidence: confidenceSum / (size * size),
|
|
3120
|
+
minimumCellConfidence: minimumConfidence,
|
|
3121
|
+
lowConfidenceCells
|
|
3122
|
+
};
|
|
3123
|
+
}
|
|
3124
|
+
|
|
1750
3125
|
function structuralAccuracy(matrix, layout) {
|
|
1751
3126
|
let matches = 0;
|
|
1752
3127
|
let total = 0;
|
|
@@ -1786,6 +3161,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
1786
3161
|
finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
|
|
1787
3162
|
finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
|
|
1788
3163
|
finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
|
|
3164
|
+
preciseAlignment: options.preciseAlignment,
|
|
1789
3165
|
diagnostics: options._visionDiagnostics,
|
|
1790
3166
|
diagnosticLabel: options._diagnosticLabel ?? "normal"
|
|
1791
3167
|
});
|
|
@@ -1811,9 +3187,18 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
1811
3187
|
let bestStructureScore = 0;
|
|
1812
3188
|
for (const profile of sampleProfiles) {
|
|
1813
3189
|
let sampled;
|
|
3190
|
+
let triangleSampled = null;
|
|
1814
3191
|
let observedPalette;
|
|
1815
3192
|
try {
|
|
1816
3193
|
sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, profile);
|
|
3194
|
+
if (options.highDensitySampling !== false && options.triangle16 !== false) {
|
|
3195
|
+
triangleSampled = samplePerspectiveTriangleMatrix(
|
|
3196
|
+
imageData,
|
|
3197
|
+
geometry.homography,
|
|
3198
|
+
layout.size,
|
|
3199
|
+
profile
|
|
3200
|
+
);
|
|
3201
|
+
}
|
|
1817
3202
|
observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, {
|
|
1818
3203
|
robust: profile.robustCalibration
|
|
1819
3204
|
});
|
|
@@ -1872,11 +3257,67 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
1872
3257
|
}
|
|
1873
3258
|
};
|
|
1874
3259
|
|
|
3260
|
+
const tryTriangleAttempt = (attempt, metadata = {}) => {
|
|
3261
|
+
if (!triangleSampled?.triangleGrid) return false;
|
|
3262
|
+
const activeObservedPalette = metadata.observedPalette ?? observedPalette;
|
|
3263
|
+
const activeSamplingMode = metadata.samplingMode ?? `${profile.sampleMode}-triangle16`;
|
|
3264
|
+
const classified = classifyTriangleSampledRgbGrid(
|
|
3265
|
+
sampled.rgbGrid,
|
|
3266
|
+
triangleSampled.triangleGrid,
|
|
3267
|
+
attempt.classifier,
|
|
3268
|
+
layout
|
|
3269
|
+
);
|
|
3270
|
+
const structureScore = structuralAccuracy(classified.matrix, layout);
|
|
3271
|
+
bestStructureScore = Math.max(bestStructureScore, structureScore);
|
|
3272
|
+
pushObservation(options, {
|
|
3273
|
+
version: geometry.version,
|
|
3274
|
+
matrix: classified.matrix,
|
|
3275
|
+
confidence: classified.confidence,
|
|
3276
|
+
geometry,
|
|
3277
|
+
observedPalette: activeObservedPalette,
|
|
3278
|
+
samplingMode: activeSamplingMode,
|
|
3279
|
+
colorNormalization: attempt.colorNormalization,
|
|
3280
|
+
cellEncoding: CELL_ENCODINGS.TRIANGLE16,
|
|
3281
|
+
structureScore,
|
|
3282
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
3283
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
3284
|
+
});
|
|
3285
|
+
|
|
3286
|
+
try {
|
|
3287
|
+
const decoded = decodeMatrix(classified.matrix, {
|
|
3288
|
+
structureTolerance: options.structureTolerance ?? 0.18,
|
|
3289
|
+
cellConfidence: classified.confidence,
|
|
3290
|
+
cellEncodingHint: CELL_ENCODINGS.TRIANGLE16,
|
|
3291
|
+
maxErasureConfidence: options.maxErasureConfidence
|
|
3292
|
+
});
|
|
3293
|
+
if (decoded.version !== geometry.version) return false;
|
|
3294
|
+
results.push({
|
|
3295
|
+
...decoded,
|
|
3296
|
+
perspectiveCorrected: true,
|
|
3297
|
+
colorCalibrated: true,
|
|
3298
|
+
colorNormalization: attempt.colorNormalization,
|
|
3299
|
+
samplingMode: activeSamplingMode,
|
|
3300
|
+
geometry,
|
|
3301
|
+
observedPalette: activeObservedPalette,
|
|
3302
|
+
averageColorDistance: classified.averageColorDistance,
|
|
3303
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
3304
|
+
minimumCellConfidence: classified.minimumCellConfidence,
|
|
3305
|
+
lowConfidenceCells: classified.lowConfidenceCells,
|
|
3306
|
+
rectified: options.includeRectified
|
|
3307
|
+
? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
|
|
3308
|
+
: undefined
|
|
3309
|
+
});
|
|
3310
|
+
return true;
|
|
3311
|
+
} catch {
|
|
3312
|
+
return false;
|
|
3313
|
+
}
|
|
3314
|
+
};
|
|
3315
|
+
|
|
1875
3316
|
// Fast path: preserve the original observed-RGB classifier first, then
|
|
1876
3317
|
// try per-channel white balancing. Most clean frames stop here without
|
|
1877
3318
|
// paying for the more expensive spatial normalization fallback.
|
|
1878
3319
|
for (const attempt of paletteClassifierAttempts(observedPalette)) {
|
|
1879
|
-
if (tryAttempt(attempt, sampled.rgbGrid)) {
|
|
3320
|
+
if (tryAttempt(attempt, sampled.rgbGrid) || tryTriangleAttempt(attempt)) {
|
|
1880
3321
|
geometryDecoded = true;
|
|
1881
3322
|
break;
|
|
1882
3323
|
}
|
|
@@ -2003,17 +3444,30 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
2003
3444
|
[step, -step], [step, step], [-step, -step], [-step, step]
|
|
2004
3445
|
];
|
|
2005
3446
|
const refinementCandidates = [];
|
|
3447
|
+
const triangleRefinementCandidates = [];
|
|
2006
3448
|
|
|
2007
3449
|
for (const [offsetX, offsetY] of refinementOffsets) {
|
|
2008
3450
|
let sampled;
|
|
3451
|
+
let triangleSampled = null;
|
|
2009
3452
|
let observedPalette;
|
|
2010
3453
|
try {
|
|
2011
|
-
|
|
3454
|
+
const samplingOptions = {
|
|
2012
3455
|
sampleMode: "cross",
|
|
2013
3456
|
sampleRadius: 0,
|
|
2014
3457
|
sampleOffsetX: offsetX,
|
|
2015
|
-
sampleOffsetY: offsetY
|
|
2016
|
-
|
|
3458
|
+
sampleOffsetY: offsetY,
|
|
3459
|
+
triangleSampleInset: options.highDensitySampleInset ?? options.triangleSampleInset,
|
|
3460
|
+
triangleSampleRadius: options.highDensitySampleRadius ?? options.triangleSampleRadius
|
|
3461
|
+
};
|
|
3462
|
+
sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, samplingOptions);
|
|
3463
|
+
if (options.highDensitySampling !== false && options.triangle16 !== false) {
|
|
3464
|
+
triangleSampled = samplePerspectiveTriangleMatrix(
|
|
3465
|
+
imageData,
|
|
3466
|
+
geometry.homography,
|
|
3467
|
+
layout.size,
|
|
3468
|
+
samplingOptions
|
|
3469
|
+
);
|
|
3470
|
+
}
|
|
2017
3471
|
observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, { robust: true });
|
|
2018
3472
|
} catch {
|
|
2019
3473
|
continue;
|
|
@@ -2043,30 +3497,80 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
2043
3497
|
|
|
2044
3498
|
// Do not spend RS work on a refinement that made the known finder /
|
|
2045
3499
|
// alignment structure worse. This keeps the recovery path bounded.
|
|
2046
|
-
if (structureScore
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
3500
|
+
if (structureScore >= (options.refinementDecodeThreshold ?? 0.95)) {
|
|
3501
|
+
refinementCandidates.push({
|
|
3502
|
+
attempt,
|
|
3503
|
+
classified,
|
|
3504
|
+
observedPalette,
|
|
3505
|
+
offsetX,
|
|
3506
|
+
offsetY,
|
|
3507
|
+
structureScore,
|
|
3508
|
+
cellEncoding: CELL_ENCODINGS.RGBW,
|
|
3509
|
+
samplingMode: "refined-center"
|
|
3510
|
+
});
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
if (triangleSampled?.triangleGrid) {
|
|
3514
|
+
const triangleClassified = classifyTriangleSampledRgbGrid(
|
|
3515
|
+
sampled.rgbGrid,
|
|
3516
|
+
triangleSampled.triangleGrid,
|
|
3517
|
+
attempt.classifier,
|
|
3518
|
+
layout
|
|
3519
|
+
);
|
|
3520
|
+
const triangleStructureScore = structuralAccuracy(triangleClassified.matrix, layout);
|
|
3521
|
+
bestStructureScore = Math.max(bestStructureScore, triangleStructureScore);
|
|
3522
|
+
pushObservation(options, {
|
|
3523
|
+
version: geometry.version,
|
|
3524
|
+
matrix: triangleClassified.matrix,
|
|
3525
|
+
confidence: triangleClassified.confidence,
|
|
3526
|
+
geometry,
|
|
3527
|
+
observedPalette,
|
|
3528
|
+
samplingMode: "refined-triangle16",
|
|
3529
|
+
samplingOffset: { x: offsetX, y: offsetY },
|
|
3530
|
+
colorNormalization: attempt.colorNormalization,
|
|
3531
|
+
cellEncoding: CELL_ENCODINGS.TRIANGLE16,
|
|
3532
|
+
structureScore: triangleStructureScore,
|
|
3533
|
+
averageCellConfidence: triangleClassified.averageCellConfidence,
|
|
3534
|
+
lowConfidenceCells: triangleClassified.lowConfidenceCells
|
|
3535
|
+
});
|
|
3536
|
+
if (triangleStructureScore >= (options.refinementDecodeThreshold ?? 0.95)) {
|
|
3537
|
+
triangleRefinementCandidates.push({
|
|
3538
|
+
attempt,
|
|
3539
|
+
classified: triangleClassified,
|
|
3540
|
+
observedPalette,
|
|
3541
|
+
offsetX,
|
|
3542
|
+
offsetY,
|
|
3543
|
+
structureScore: triangleStructureScore,
|
|
3544
|
+
cellEncoding: CELL_ENCODINGS.TRIANGLE16,
|
|
3545
|
+
samplingMode: "refined-triangle16"
|
|
3546
|
+
});
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
2055
3549
|
}
|
|
2056
3550
|
}
|
|
2057
3551
|
|
|
2058
|
-
|
|
3552
|
+
const sortRefinementCandidates = (items) => items.sort((a, b) =>
|
|
2059
3553
|
(b.structureScore - a.structureScore) ||
|
|
2060
3554
|
(b.classified.averageCellConfidence - a.classified.averageCellConfidence) ||
|
|
2061
3555
|
(a.classified.averageColorDistance - b.classified.averageColorDistance)
|
|
2062
3556
|
);
|
|
3557
|
+
sortRefinementCandidates(refinementCandidates);
|
|
3558
|
+
sortRefinementCandidates(triangleRefinementCandidates);
|
|
2063
3559
|
|
|
2064
3560
|
const decodeLimit = Math.max(1, Math.min(8, Math.round(options.refinementDecodeCandidates ?? 4)));
|
|
2065
|
-
|
|
3561
|
+
// Keep separate per-encoding limits. A Triangle16 image can produce very
|
|
3562
|
+
// confident but meaningless centre samples, and those must not crowd out
|
|
3563
|
+
// the dual-region candidates during the final bounded recovery pass.
|
|
3564
|
+
const decodeCandidates = [
|
|
3565
|
+
...refinementCandidates.slice(0, decodeLimit),
|
|
3566
|
+
...triangleRefinementCandidates.slice(0, decodeLimit)
|
|
3567
|
+
];
|
|
3568
|
+
for (const candidate of decodeCandidates) {
|
|
2066
3569
|
try {
|
|
2067
3570
|
const decoded = decodeMatrix(candidate.classified.matrix, {
|
|
2068
3571
|
structureTolerance: options.structureTolerance ?? 0.18,
|
|
2069
3572
|
cellConfidence: candidate.classified.confidence,
|
|
3573
|
+
cellEncodingHint: candidate.cellEncoding,
|
|
2070
3574
|
maxErasureConfidence: options.maxErasureConfidence
|
|
2071
3575
|
});
|
|
2072
3576
|
if (decoded.version !== geometry.version) continue;
|
|
@@ -2077,7 +3581,7 @@ function tryPerspectiveScan(imageData, options) {
|
|
|
2077
3581
|
samplingOffset: { x: candidate.offsetX, y: candidate.offsetY },
|
|
2078
3582
|
colorCalibrated: true,
|
|
2079
3583
|
colorNormalization: candidate.attempt.colorNormalization,
|
|
2080
|
-
samplingMode:
|
|
3584
|
+
samplingMode: candidate.samplingMode,
|
|
2081
3585
|
geometry,
|
|
2082
3586
|
observedPalette: candidate.observedPalette,
|
|
2083
3587
|
averageColorDistance: candidate.classified.averageColorDistance,
|
|
@@ -2127,6 +3631,15 @@ function tryAxisAlignedScan(imageData, options) {
|
|
|
2127
3631
|
for (let profileIndex = 0; profileIndex < radiusProfiles.length && !accepted; profileIndex++) {
|
|
2128
3632
|
try {
|
|
2129
3633
|
const sampled = sampleAxisAlignedGrid(imageData, bounds, size, radiusProfiles[profileIndex]);
|
|
3634
|
+
const triangleSampled = options.highDensitySampling === false || options.triangle16 === false
|
|
3635
|
+
? null
|
|
3636
|
+
: sampleAxisAlignedTriangleGrid(
|
|
3637
|
+
imageData,
|
|
3638
|
+
bounds,
|
|
3639
|
+
size,
|
|
3640
|
+
Math.min(0.10, Math.max(0.04, radiusProfiles[profileIndex] * 0.48)),
|
|
3641
|
+
options.highDensitySampleInset ?? options.triangleSampleInset ?? 0.28
|
|
3642
|
+
);
|
|
2130
3643
|
const layout = createLayout(version);
|
|
2131
3644
|
const classifierAttempts = [];
|
|
2132
3645
|
try {
|
|
@@ -2166,45 +3679,70 @@ function tryAxisAlignedScan(imageData, options) {
|
|
|
2166
3679
|
});
|
|
2167
3680
|
|
|
2168
3681
|
for (const attempt of classifierAttempts) {
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
3682
|
+
const centerGrid = attempt.rgbGrid ?? sampled.rgbGrid;
|
|
3683
|
+
const samplingMode = profileIndex === 0 ? "axis" : "axis-center";
|
|
3684
|
+
const classifiedAttempts = [{
|
|
3685
|
+
classified: classifySampledRgbGrid(centerGrid, attempt.classifier, layout),
|
|
3686
|
+
cellEncoding: CELL_ENCODINGS.RGBW,
|
|
3687
|
+
samplingMode
|
|
3688
|
+
}];
|
|
3689
|
+
if (triangleSampled?.triangleGrid && attempt.rgbGrid === sampled.rgbGrid) {
|
|
3690
|
+
classifiedAttempts.push({
|
|
3691
|
+
classified: classifyTriangleSampledRgbGrid(
|
|
3692
|
+
centerGrid,
|
|
3693
|
+
triangleSampled.triangleGrid,
|
|
3694
|
+
attempt.classifier,
|
|
3695
|
+
layout
|
|
3696
|
+
),
|
|
3697
|
+
cellEncoding: CELL_ENCODINGS.TRIANGLE16,
|
|
3698
|
+
samplingMode: `${samplingMode}-triangle16`
|
|
2182
3699
|
});
|
|
2183
|
-
const decoded = decodeMatrix(classified.matrix, {
|
|
2184
|
-
structureTolerance: options.structureTolerance ?? 0.12,
|
|
2185
|
-
cellConfidence: classified.confidence,
|
|
2186
|
-
maxErasureConfidence: options.maxErasureConfidence
|
|
2187
|
-
});
|
|
2188
|
-
candidates.push({
|
|
2189
|
-
...decoded,
|
|
2190
|
-
bounds,
|
|
2191
|
-
sampledVersion: version,
|
|
2192
|
-
moduleWidth: moduleW,
|
|
2193
|
-
moduleHeight: moduleH,
|
|
2194
|
-
perspectiveCorrected: false,
|
|
2195
|
-
colorCalibrated: attempt.calibrated,
|
|
2196
|
-
colorNormalization: attempt.colorNormalization,
|
|
2197
|
-
samplingMode: profileIndex === 0 ? "axis" : "axis-center",
|
|
2198
|
-
averageColorDistance: classified.averageColorDistance,
|
|
2199
|
-
averageCellConfidence: classified.averageCellConfidence,
|
|
2200
|
-
minimumCellConfidence: classified.minimumCellConfidence,
|
|
2201
|
-
lowConfidenceCells: classified.lowConfidenceCells
|
|
2202
|
-
});
|
|
2203
|
-
accepted = true;
|
|
2204
|
-
break;
|
|
2205
|
-
} catch {
|
|
2206
|
-
// Try next classifier/profile.
|
|
2207
3700
|
}
|
|
3701
|
+
|
|
3702
|
+
for (const classifiedAttempt of classifiedAttempts) {
|
|
3703
|
+
try {
|
|
3704
|
+
const classified = classifiedAttempt.classified;
|
|
3705
|
+
const structureScore = structuralAccuracy(classified.matrix, layout);
|
|
3706
|
+
pushObservation(options, {
|
|
3707
|
+
version,
|
|
3708
|
+
matrix: classified.matrix,
|
|
3709
|
+
confidence: classified.confidence,
|
|
3710
|
+
bounds,
|
|
3711
|
+
samplingMode: classifiedAttempt.samplingMode,
|
|
3712
|
+
colorNormalization: attempt.colorNormalization,
|
|
3713
|
+
cellEncoding: classifiedAttempt.cellEncoding,
|
|
3714
|
+
structureScore,
|
|
3715
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
3716
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
3717
|
+
});
|
|
3718
|
+
const decoded = decodeMatrix(classified.matrix, {
|
|
3719
|
+
structureTolerance: options.structureTolerance ?? 0.12,
|
|
3720
|
+
cellConfidence: classified.confidence,
|
|
3721
|
+
cellEncodingHint: classifiedAttempt.cellEncoding,
|
|
3722
|
+
maxErasureConfidence: options.maxErasureConfidence
|
|
3723
|
+
});
|
|
3724
|
+
candidates.push({
|
|
3725
|
+
...decoded,
|
|
3726
|
+
bounds,
|
|
3727
|
+
sampledVersion: version,
|
|
3728
|
+
moduleWidth: moduleW,
|
|
3729
|
+
moduleHeight: moduleH,
|
|
3730
|
+
perspectiveCorrected: false,
|
|
3731
|
+
colorCalibrated: attempt.calibrated,
|
|
3732
|
+
colorNormalization: attempt.colorNormalization,
|
|
3733
|
+
samplingMode: classifiedAttempt.samplingMode,
|
|
3734
|
+
averageColorDistance: classified.averageColorDistance,
|
|
3735
|
+
averageCellConfidence: classified.averageCellConfidence,
|
|
3736
|
+
minimumCellConfidence: classified.minimumCellConfidence,
|
|
3737
|
+
lowConfidenceCells: classified.lowConfidenceCells
|
|
3738
|
+
});
|
|
3739
|
+
accepted = true;
|
|
3740
|
+
break;
|
|
3741
|
+
} catch {
|
|
3742
|
+
// Try the next cell encoding/classifier/profile.
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
if (accepted) break;
|
|
2208
3746
|
}
|
|
2209
3747
|
} catch {
|
|
2210
3748
|
// Try a narrower centre sample, then next version.
|
|
@@ -2218,6 +3756,366 @@ function tryAxisAlignedScan(imageData, options) {
|
|
|
2218
3756
|
return candidates[0] ?? null;
|
|
2219
3757
|
}
|
|
2220
3758
|
|
|
3759
|
+
|
|
3760
|
+
|
|
3761
|
+
export const STRESS_PROFILES = Object.freeze([
|
|
3762
|
+
Object.freeze({ id: "clean", label: "Clean", type: "clean", severity: 0, weight: 2 }),
|
|
3763
|
+
Object.freeze({ id: "blur", label: "Blur", type: "blur", severity: 0.42, weight: 1 }),
|
|
3764
|
+
Object.freeze({ id: "dark", label: "Low brightness", type: "brightness-low", severity: 0.48, weight: 1 }),
|
|
3765
|
+
Object.freeze({ id: "bright", label: "High exposure", type: "brightness-high", severity: 0.34, weight: 1 }),
|
|
3766
|
+
Object.freeze({ id: "shadow", label: "Uneven shadow", type: "shadow", severity: 0.55, weight: 1.2 }),
|
|
3767
|
+
Object.freeze({ id: "contrast", label: "Contrast loss", type: "contrast-loss", severity: 0.42, weight: 1 }),
|
|
3768
|
+
Object.freeze({ id: "perspective", label: "Perspective", type: "perspective", severity: 0.42, weight: 1.3 }),
|
|
3769
|
+
Object.freeze({ id: "jpeg", label: "JPEG-like compression", type: "jpeg", severity: 0.50, weight: 0.8 }),
|
|
3770
|
+
Object.freeze({ id: "downscale", label: "Downscale", type: "downscale", severity: 0.52, weight: 0.9 })
|
|
3771
|
+
]);
|
|
3772
|
+
|
|
3773
|
+
function cloneImageDataLike(imageData) {
|
|
3774
|
+
return { width: imageData.width, height: imageData.height, data: new Uint8ClampedArray(imageData.data) };
|
|
3775
|
+
}
|
|
3776
|
+
|
|
3777
|
+
function sampleRgbaBilinear(imageData, x, y) {
|
|
3778
|
+
const { width, height, data } = imageData;
|
|
3779
|
+
const cx = clampNumber(x, 0, width - 1);
|
|
3780
|
+
const cy = clampNumber(y, 0, height - 1);
|
|
3781
|
+
const x0 = Math.floor(cx), y0 = Math.floor(cy);
|
|
3782
|
+
const x1 = Math.min(width - 1, x0 + 1), y1 = Math.min(height - 1, y0 + 1);
|
|
3783
|
+
const tx = cx - x0, ty = cy - y0;
|
|
3784
|
+
const get = (xx, yy, channel) => data[(yy * width + xx) * 4 + channel];
|
|
3785
|
+
const out = [0, 0, 0, 255];
|
|
3786
|
+
for (let ch = 0; ch < 4; ch++) {
|
|
3787
|
+
const a = get(x0, y0, ch) * (1 - tx) + get(x1, y0, ch) * tx;
|
|
3788
|
+
const b = get(x0, y1, ch) * (1 - tx) + get(x1, y1, ch) * tx;
|
|
3789
|
+
out[ch] = a * (1 - ty) + b * ty;
|
|
3790
|
+
}
|
|
3791
|
+
return out;
|
|
3792
|
+
}
|
|
3793
|
+
|
|
3794
|
+
function resizeImageDataLike(imageData, width, height) {
|
|
3795
|
+
width = Math.max(1, Math.round(width));
|
|
3796
|
+
height = Math.max(1, Math.round(height));
|
|
3797
|
+
const out = new Uint8ClampedArray(width * height * 4);
|
|
3798
|
+
for (let y = 0; y < height; y++) {
|
|
3799
|
+
for (let x = 0; x < width; x++) {
|
|
3800
|
+
const sx = (x + 0.5) * imageData.width / width - 0.5;
|
|
3801
|
+
const sy = (y + 0.5) * imageData.height / height - 0.5;
|
|
3802
|
+
const rgba = sampleRgbaBilinear(imageData, sx, sy);
|
|
3803
|
+
const p = (y * width + x) * 4;
|
|
3804
|
+
out[p] = rgba[0]; out[p + 1] = rgba[1]; out[p + 2] = rgba[2]; out[p + 3] = rgba[3];
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
return { width, height, data: out };
|
|
3808
|
+
}
|
|
3809
|
+
|
|
3810
|
+
function boxBlurImage(imageData, radius) {
|
|
3811
|
+
radius = Math.max(1, Math.round(radius));
|
|
3812
|
+
const { width, height, data } = imageData;
|
|
3813
|
+
const horizontal = new Float32Array(data.length);
|
|
3814
|
+
const out = new Uint8ClampedArray(data.length);
|
|
3815
|
+
for (let y = 0; y < height; y++) {
|
|
3816
|
+
for (let x = 0; x < width; x++) {
|
|
3817
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
3818
|
+
let sum = 0, count = 0;
|
|
3819
|
+
for (let dx = -radius; dx <= radius; dx++) {
|
|
3820
|
+
const xx = x + dx;
|
|
3821
|
+
if (xx < 0 || xx >= width) continue;
|
|
3822
|
+
sum += data[(y * width + xx) * 4 + ch]; count++;
|
|
3823
|
+
}
|
|
3824
|
+
horizontal[(y * width + x) * 4 + ch] = sum / count;
|
|
3825
|
+
}
|
|
3826
|
+
horizontal[(y * width + x) * 4 + 3] = 255;
|
|
3827
|
+
}
|
|
3828
|
+
}
|
|
3829
|
+
for (let y = 0; y < height; y++) {
|
|
3830
|
+
for (let x = 0; x < width; x++) {
|
|
3831
|
+
const p = (y * width + x) * 4;
|
|
3832
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
3833
|
+
let sum = 0, count = 0;
|
|
3834
|
+
for (let dy = -radius; dy <= radius; dy++) {
|
|
3835
|
+
const yy = y + dy;
|
|
3836
|
+
if (yy < 0 || yy >= height) continue;
|
|
3837
|
+
sum += horizontal[(yy * width + x) * 4 + ch]; count++;
|
|
3838
|
+
}
|
|
3839
|
+
out[p + ch] = sum / count;
|
|
3840
|
+
}
|
|
3841
|
+
out[p + 3] = 255;
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
3844
|
+
return { width, height, data: out };
|
|
3845
|
+
}
|
|
3846
|
+
|
|
3847
|
+
function perspectiveStress(imageData, severity) {
|
|
3848
|
+
const { width, height } = imageData;
|
|
3849
|
+
const inset = Math.min(width, height) * (0.025 + 0.11 * severity);
|
|
3850
|
+
const source = [{ x: 0, y: 0 }, { x: width - 1, y: 0 }, { x: width - 1, y: height - 1 }, { x: 0, y: height - 1 }];
|
|
3851
|
+
const destination = [
|
|
3852
|
+
{ x: inset * 1.15, y: inset * 0.25 },
|
|
3853
|
+
{ x: width - 1 - inset * 0.25, y: inset },
|
|
3854
|
+
{ x: width - 1 - inset * 1.0, y: height - 1 - inset * 0.35 },
|
|
3855
|
+
{ x: inset * 0.20, y: height - 1 - inset * 0.95 }
|
|
3856
|
+
];
|
|
3857
|
+
const inverse = computeHomography(source, destination);
|
|
3858
|
+
const out = new Uint8ClampedArray(width * height * 4);
|
|
3859
|
+
out.fill(255);
|
|
3860
|
+
for (let y = 0; y < height; y++) {
|
|
3861
|
+
for (let x = 0; x < width; x++) {
|
|
3862
|
+
const src = projectPoint(inverse, x, y);
|
|
3863
|
+
if (src.x < 0 || src.y < 0 || src.x >= width || src.y >= height) continue;
|
|
3864
|
+
const rgba = sampleRgbaBilinear(imageData, src.x, src.y);
|
|
3865
|
+
const p = (y * width + x) * 4;
|
|
3866
|
+
out[p] = rgba[0]; out[p + 1] = rgba[1]; out[p + 2] = rgba[2]; out[p + 3] = 255;
|
|
3867
|
+
}
|
|
3868
|
+
}
|
|
3869
|
+
return { width, height, data: out };
|
|
3870
|
+
}
|
|
3871
|
+
|
|
3872
|
+
/** Apply one deterministic camera/print-style distortion to ImageData. */
|
|
3873
|
+
export function applyStressDistortion(imageData, type, severity = 0.5, options = {}) {
|
|
3874
|
+
assert(imageData?.data && imageData.width && imageData.height, "applyStressDistortion requires ImageData-like input.");
|
|
3875
|
+
const s = clampNumber(Number(severity), 0, 1);
|
|
3876
|
+
if (type === "clean") return cloneImageDataLike(imageData);
|
|
3877
|
+
if (type === "blur") return boxBlurImage(imageData, 1 + s * 2.2);
|
|
3878
|
+
if (type === "perspective") return perspectiveStress(imageData, s);
|
|
3879
|
+
if (type === "downscale") {
|
|
3880
|
+
const scale = Math.max(0.20, 1 - s * 0.72);
|
|
3881
|
+
const small = resizeImageDataLike(imageData, imageData.width * scale, imageData.height * scale);
|
|
3882
|
+
return resizeImageDataLike(small, imageData.width, imageData.height);
|
|
3883
|
+
}
|
|
3884
|
+
|
|
3885
|
+
const out = cloneImageDataLike(imageData);
|
|
3886
|
+
const { width, height, data } = out;
|
|
3887
|
+
const quantStep = 4 + Math.round(s * 30);
|
|
3888
|
+
for (let y = 0; y < height; y++) {
|
|
3889
|
+
for (let x = 0; x < width; x++) {
|
|
3890
|
+
const p = (y * width + x) * 4;
|
|
3891
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
3892
|
+
let value = data[p + ch];
|
|
3893
|
+
if (type === "brightness-low") value *= 1 - s * 0.52;
|
|
3894
|
+
else if (type === "brightness-high") value = value + (255 - value) * s * 0.48;
|
|
3895
|
+
else if (type === "contrast-loss") value = 128 + (value - 128) * (1 - s * 0.48);
|
|
3896
|
+
else if (type === "shadow") {
|
|
3897
|
+
const nx = x / Math.max(1, width - 1);
|
|
3898
|
+
const ny = y / Math.max(1, height - 1);
|
|
3899
|
+
const shadow = clampNumber((nx * 0.65 + ny * 0.35 - 0.18) / 0.82, 0, 1);
|
|
3900
|
+
value *= 1 - shadow * s * 0.62;
|
|
3901
|
+
} else if (type === "jpeg") {
|
|
3902
|
+
value = Math.round(value / quantStep) * quantStep;
|
|
3903
|
+
const block = ((Math.floor(x / 8) + Math.floor(y / 8)) & 1) ? 1 : -1;
|
|
3904
|
+
value += block * s * 3;
|
|
3905
|
+
} else if (type === "warm") {
|
|
3906
|
+
if (ch === 0) value *= 1 + s * 0.12;
|
|
3907
|
+
if (ch === 2) value *= 1 - s * 0.22;
|
|
3908
|
+
}
|
|
3909
|
+
data[p + ch] = clampNumber(Math.round(value), 0, 255);
|
|
3910
|
+
}
|
|
3911
|
+
data[p + 3] = 255;
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
return out;
|
|
3915
|
+
}
|
|
3916
|
+
|
|
3917
|
+
export function runImageStressTest(imageData, expected = {}, options = {}) {
|
|
3918
|
+
const profiles = options.profiles ?? STRESS_PROFILES;
|
|
3919
|
+
const results = [];
|
|
3920
|
+
let passedWeight = 0;
|
|
3921
|
+
let totalWeight = 0;
|
|
3922
|
+
for (const profile of profiles) {
|
|
3923
|
+
const weight = Number(profile.weight ?? 1);
|
|
3924
|
+
totalWeight += weight;
|
|
3925
|
+
const distorted = applyStressDistortion(imageData, profile.type ?? profile.id, profile.severity ?? 0.5, profile);
|
|
3926
|
+
const started = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
3927
|
+
try {
|
|
3928
|
+
const decoded = scanImageData(distorted, {
|
|
3929
|
+
minVersion: expected.version ?? options.minVersion ?? MIN_VERSION,
|
|
3930
|
+
maxVersion: expected.version ?? options.maxVersion ?? MAX_VERSION,
|
|
3931
|
+
debug: false
|
|
3932
|
+
});
|
|
3933
|
+
const matches = expected.crc32 == null || decoded.crc32 === expected.crc32;
|
|
3934
|
+
if (matches) passedWeight += weight;
|
|
3935
|
+
results.push({
|
|
3936
|
+
id: profile.id,
|
|
3937
|
+
label: profile.label,
|
|
3938
|
+
type: profile.type,
|
|
3939
|
+
severity: profile.severity,
|
|
3940
|
+
passed: matches,
|
|
3941
|
+
confidence: decoded.confidence ?? decoded.diagnostics?.confidence ?? null,
|
|
3942
|
+
correctedSymbols: decoded.correctedSymbols ?? 0,
|
|
3943
|
+
elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
|
|
3944
|
+
image: options.includeImages ? distorted : undefined,
|
|
3945
|
+
error: matches ? null : "Decoded payload did not match expected CRC."
|
|
3946
|
+
});
|
|
3947
|
+
} catch (error) {
|
|
3948
|
+
results.push({
|
|
3949
|
+
id: profile.id,
|
|
3950
|
+
label: profile.label,
|
|
3951
|
+
type: profile.type,
|
|
3952
|
+
severity: profile.severity,
|
|
3953
|
+
passed: false,
|
|
3954
|
+
confidence: 0,
|
|
3955
|
+
correctedSymbols: null,
|
|
3956
|
+
elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
|
|
3957
|
+
image: options.includeImages ? distorted : undefined,
|
|
3958
|
+
error: error.message
|
|
3959
|
+
});
|
|
3960
|
+
}
|
|
3961
|
+
}
|
|
3962
|
+
const passPercent = totalWeight ? passedWeight / totalWeight * 100 : 0;
|
|
3963
|
+
const averageConfidence = results.filter((r) => r.passed && Number.isFinite(r.confidence)).reduce((sum, r, _, arr) => sum + r.confidence / arr.length, 0);
|
|
3964
|
+
const score = clampNumber(passPercent * 0.86 + averageConfidence * 100 * 0.14, 0, 100);
|
|
3965
|
+
const rating = score >= 90 ? "Excellent" : score >= 75 ? "Good" : score >= 50 ? "Risky" : "Likely unscannable";
|
|
3966
|
+
return {
|
|
3967
|
+
score,
|
|
3968
|
+
rating,
|
|
3969
|
+
passPercent,
|
|
3970
|
+
passed: results.filter((r) => r.passed).length,
|
|
3971
|
+
total: results.length,
|
|
3972
|
+
averageConfidence,
|
|
3973
|
+
results
|
|
3974
|
+
};
|
|
3975
|
+
}
|
|
3976
|
+
|
|
3977
|
+
/** Render and run the standard scanability torture suite. */
|
|
3978
|
+
export function assessScanability(code, renderOptions = {}, options = {}) {
|
|
3979
|
+
assert(code?.matrix, "assessScanability expects an encoded QuadQR object.");
|
|
3980
|
+
const testImageSize = Math.max(240, Math.min(Number(options.testImageSize ?? 480), Number(renderOptions.imageSize ?? 720)));
|
|
3981
|
+
const image = renderToImageData(code, { ...renderOptions, imageSize: testImageSize });
|
|
3982
|
+
const report = runImageStressTest(image, { version: code.version, crc32: code.crc32 }, options);
|
|
3983
|
+
const autoLogoSize = renderOptions.logo ? estimateSafeLogoSize(code, renderOptions) : null;
|
|
3984
|
+
return {
|
|
3985
|
+
...report,
|
|
3986
|
+
version: code.version,
|
|
3987
|
+
eccLevel: code.eccLevel,
|
|
3988
|
+
utilization: code.utilization,
|
|
3989
|
+
testImageSize,
|
|
3990
|
+
autoLogoSize,
|
|
3991
|
+
recommendations: [
|
|
3992
|
+
...(report.score < 75 ? ["Increase ECC, reduce logo size, use Classic style, or increase output/physical size."] : []),
|
|
3993
|
+
...(Number(renderOptions.quietZone ?? 4) < 4 ? ["Use a quiet zone of at least 4 modules for robust camera/print scanning."] : []),
|
|
3994
|
+
...(renderOptions.logo && autoLogoSize != null ? [`Auto-safe logo estimate: ${(autoLogoSize * 100).toFixed(1)}% of the symbol width.`] : []),
|
|
3995
|
+
...(normalizeRenderMode(renderOptions.mode ?? renderOptions.renderMode ?? "screen") === "print" ? getPrintGuidance(code, renderOptions).recommendations : [])
|
|
3996
|
+
]
|
|
3997
|
+
};
|
|
3998
|
+
}
|
|
3999
|
+
|
|
4000
|
+
function paletteConfidence(observedPalette) {
|
|
4001
|
+
if (!observedPalette) return 0.5;
|
|
4002
|
+
const values = ["black", "white", "red", "green", "blue"].map((name) => observedPalette[name]).filter(Boolean);
|
|
4003
|
+
if (values.length < 5) return 0.5;
|
|
4004
|
+
let minDistance = Infinity;
|
|
4005
|
+
for (let i = 0; i < values.length; i++) {
|
|
4006
|
+
for (let j = i + 1; j < values.length; j++) {
|
|
4007
|
+
const a = values[i], b = values[j];
|
|
4008
|
+
minDistance = Math.min(minDistance, Math.hypot(a.r - b.r, a.g - b.g, a.b - b.b));
|
|
4009
|
+
}
|
|
4010
|
+
}
|
|
4011
|
+
return clampNumber((minDistance - 28) / 120, 0, 1);
|
|
4012
|
+
}
|
|
4013
|
+
|
|
4014
|
+
function scannerDiagnostics(result, context = {}, options = {}) {
|
|
4015
|
+
const info = result ? getVersionInfo(result.version, { ecc: result.eccLevel }) : null;
|
|
4016
|
+
const correctableTotal = result && info
|
|
4017
|
+
? Number(info.correctableHeaderSymbols ?? 0) + Number(result.eccBlocks ?? 0) * Number(result.correctableSymbolsPerBlock ?? 0)
|
|
4018
|
+
: 0;
|
|
4019
|
+
const eccUtilization = correctableTotal > 0 ? clampNumber((result.correctedSymbols ?? 0) / correctableTotal, 0, 1) : 0;
|
|
4020
|
+
const structureConfidence = clampNumber(
|
|
4021
|
+
result?.structureScore ?? context.observations?.at(-1)?.structureScore ?? (result ? 0.92 : 0), 0, 1
|
|
4022
|
+
);
|
|
4023
|
+
const cellConfidence = clampNumber(result?.averageCellConfidence ?? 0.75, 0, 1);
|
|
4024
|
+
const geometryConfidence = result
|
|
4025
|
+
? clampNumber(result.perspectiveCorrected ? Math.max(0.55, structureConfidence) : Math.max(0.65, structureConfidence * 0.9), 0, 1)
|
|
4026
|
+
: (context.geometry?.length ? 0.45 : 0);
|
|
4027
|
+
const calibrationConfidence = result
|
|
4028
|
+
? clampNumber(result.colorCalibrated ? paletteConfidence(result.observedPalette) : 0.55, 0, 1)
|
|
4029
|
+
: 0;
|
|
4030
|
+
const confidence = result
|
|
4031
|
+
? clampNumber(cellConfidence * 0.50 + structureConfidence * 0.20 + geometryConfidence * 0.15 + calibrationConfidence * 0.15 - eccUtilization * 0.12, 0, 1)
|
|
4032
|
+
: 0;
|
|
4033
|
+
const geometryCandidates = (context.geometry ?? []).slice(0, 8).map((geometry) => ({
|
|
4034
|
+
version: geometry.version,
|
|
4035
|
+
size: geometry.size,
|
|
4036
|
+
score: geometry.score,
|
|
4037
|
+
estimatedSize: geometry.estimatedSize,
|
|
4038
|
+
alignmentScore: geometry.alignmentScore,
|
|
4039
|
+
alignmentGridScore: geometry.alignmentGridScore,
|
|
4040
|
+
corners: geometry.corners ?? geometry.quad ?? null,
|
|
4041
|
+
finders: geometry.finders ?? null
|
|
4042
|
+
}));
|
|
4043
|
+
const observations = context.observations ?? [];
|
|
4044
|
+
const latest = observations.at(-1) ?? null;
|
|
4045
|
+
const stages = {
|
|
4046
|
+
finderDetection: geometryCandidates.length > 0 || Boolean(result && !result.perspectiveCorrected),
|
|
4047
|
+
geometry: geometryCandidates.length > 0 || Boolean(result && !result.perspectiveCorrected),
|
|
4048
|
+
sampling: observations.length > 0 || Boolean(result),
|
|
4049
|
+
colorClassification: observations.length > 0 || Boolean(result),
|
|
4050
|
+
ecc: Boolean(result),
|
|
4051
|
+
crc: Boolean(result),
|
|
4052
|
+
payload: Boolean(result)
|
|
4053
|
+
};
|
|
4054
|
+
let failedStage = null;
|
|
4055
|
+
if (!result) {
|
|
4056
|
+
failedStage = !stages.finderDetection ? "finder-detection"
|
|
4057
|
+
: !stages.sampling ? "perspective-geometry"
|
|
4058
|
+
: "color-classification-or-ecc";
|
|
4059
|
+
}
|
|
4060
|
+
return {
|
|
4061
|
+
confidence,
|
|
4062
|
+
cellConfidence,
|
|
4063
|
+
structureConfidence,
|
|
4064
|
+
geometryConfidence,
|
|
4065
|
+
calibrationConfidence,
|
|
4066
|
+
eccUtilization,
|
|
4067
|
+
correctedErrors: result?.correctedSymbols ?? 0,
|
|
4068
|
+
erasureSymbols: result?.erasureSymbols ?? 0,
|
|
4069
|
+
lowConfidenceCells: result?.lowConfidenceCells ?? latest?.lowConfidenceCells ?? null,
|
|
4070
|
+
stages,
|
|
4071
|
+
failedStage,
|
|
4072
|
+
geometryCandidates,
|
|
4073
|
+
vision: context.vision ?? null,
|
|
4074
|
+
sampled: latest ? {
|
|
4075
|
+
version: latest.version,
|
|
4076
|
+
samplingMode: latest.samplingMode,
|
|
4077
|
+
colorNormalization: latest.colorNormalization,
|
|
4078
|
+
structureScore: latest.structureScore,
|
|
4079
|
+
averageCellConfidence: latest.averageCellConfidence,
|
|
4080
|
+
observedPalette: latest.observedPalette ?? null,
|
|
4081
|
+
matrix: options.debugMatrices === false ? undefined : latest.matrix,
|
|
4082
|
+
confidenceMatrix: options.debugMatrices === false ? undefined : latest.confidence
|
|
4083
|
+
} : null
|
|
4084
|
+
};
|
|
4085
|
+
}
|
|
4086
|
+
|
|
4087
|
+
function decorateScanResult(result, context, options) {
|
|
4088
|
+
const diagnostics = scannerDiagnostics(result, context, options);
|
|
4089
|
+
return {
|
|
4090
|
+
...result,
|
|
4091
|
+
confidence: diagnostics.confidence,
|
|
4092
|
+
geometryConfidence: diagnostics.geometryConfidence,
|
|
4093
|
+
calibrationConfidence: diagnostics.calibrationConfidence,
|
|
4094
|
+
structureConfidence: diagnostics.structureConfidence,
|
|
4095
|
+
correctedErrors: result.correctedSymbols ?? 0,
|
|
4096
|
+
eccUtilization: diagnostics.eccUtilization,
|
|
4097
|
+
diagnostics: options.debug ? diagnostics : {
|
|
4098
|
+
confidence: diagnostics.confidence,
|
|
4099
|
+
geometryConfidence: diagnostics.geometryConfidence,
|
|
4100
|
+
calibrationConfidence: diagnostics.calibrationConfidence,
|
|
4101
|
+
structureConfidence: diagnostics.structureConfidence,
|
|
4102
|
+
eccUtilization: diagnostics.eccUtilization,
|
|
4103
|
+
correctedErrors: diagnostics.correctedErrors,
|
|
4104
|
+
erasureSymbols: diagnostics.erasureSymbols
|
|
4105
|
+
}
|
|
4106
|
+
};
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
/** Non-throwing scanner debug API with detailed stage diagnostics. */
|
|
4110
|
+
export function debugScanImageData(imageData, options = {}) {
|
|
4111
|
+
try {
|
|
4112
|
+
const result = scanImageData(imageData, { ...options, debug: true });
|
|
4113
|
+
return { ok: true, result, debug: result.diagnostics };
|
|
4114
|
+
} catch (error) {
|
|
4115
|
+
return { ok: false, error: error.message, debug: error.debug ?? null };
|
|
4116
|
+
}
|
|
4117
|
+
}
|
|
4118
|
+
|
|
2221
4119
|
export function scanImageData(imageData, options = {}) {
|
|
2222
4120
|
assert(imageData && imageData.data && imageData.width && imageData.height, "Valid ImageData is required.");
|
|
2223
4121
|
const minVersion = options.minVersion ?? MIN_VERSION;
|
|
@@ -2226,11 +4124,49 @@ export function scanImageData(imageData, options = {}) {
|
|
|
2226
4124
|
validateVersion(maxVersion);
|
|
2227
4125
|
|
|
2228
4126
|
const geometryCollector = [];
|
|
2229
|
-
const
|
|
4127
|
+
const observationCollector = options.debug ? [] : (Array.isArray(options._observationCollector) ? options._observationCollector : []);
|
|
4128
|
+
const visionDiagnostics = options.debug ? { passes: [] } : (options._visionDiagnostics ?? null);
|
|
4129
|
+
const debugContext = { geometry: geometryCollector, observations: observationCollector, vision: visionDiagnostics };
|
|
4130
|
+
const scanOptions = {
|
|
4131
|
+
...options,
|
|
4132
|
+
_geometryCollector: geometryCollector,
|
|
4133
|
+
_observationCollector: observationCollector,
|
|
4134
|
+
_visionDiagnostics: visionDiagnostics
|
|
4135
|
+
};
|
|
2230
4136
|
|
|
2231
4137
|
if (options.perspective !== false) {
|
|
2232
4138
|
const perspective = tryPerspectiveScan(imageData, scanOptions);
|
|
2233
|
-
if (perspective) return perspective;
|
|
4139
|
+
if (perspective) return decorateScanResult(perspective, debugContext, options);
|
|
4140
|
+
}
|
|
4141
|
+
|
|
4142
|
+
// Triangle16 has half-cell color regions, so geometry that is adequate for
|
|
4143
|
+
// solid RGBW cells can still be off by a few tenths of a module and mix the
|
|
4144
|
+
// two triangle colors. If normal geometry was found but no payload decoded,
|
|
4145
|
+
// retry geometry once with finer alignment localization. This stays off the
|
|
4146
|
+
// clean/normal camera fast path.
|
|
4147
|
+
if (
|
|
4148
|
+
options.perspective !== false &&
|
|
4149
|
+
options.preciseAlignmentRecovery !== false &&
|
|
4150
|
+
!options._preciseAlignmentRecovery &&
|
|
4151
|
+
geometryCollector.length > 0
|
|
4152
|
+
) {
|
|
4153
|
+
try {
|
|
4154
|
+
const precise = tryPerspectiveScan(imageData, {
|
|
4155
|
+
...scanOptions,
|
|
4156
|
+
preciseAlignment: true,
|
|
4157
|
+
_preciseAlignmentRecovery: true,
|
|
4158
|
+
_diagnosticLabel: "precise-alignment"
|
|
4159
|
+
});
|
|
4160
|
+
if (precise) {
|
|
4161
|
+
return decorateScanResult({
|
|
4162
|
+
...precise,
|
|
4163
|
+
geometryRefined: true,
|
|
4164
|
+
recoveryMode: precise.recoveryMode ?? "precise-alignment"
|
|
4165
|
+
}, debugContext, options);
|
|
4166
|
+
}
|
|
4167
|
+
} catch {
|
|
4168
|
+
// Continue to color recovery and axis-aligned fallback.
|
|
4169
|
+
}
|
|
2234
4170
|
}
|
|
2235
4171
|
|
|
2236
4172
|
// If the normal scanner could see QuadQR geometry but could not decode the
|
|
@@ -2257,12 +4193,12 @@ export function scanImageData(imageData, options = {}) {
|
|
|
2257
4193
|
_geometryCollector: []
|
|
2258
4194
|
});
|
|
2259
4195
|
if (recovered) {
|
|
2260
|
-
return {
|
|
4196
|
+
return decorateScanResult({
|
|
2261
4197
|
...recovered,
|
|
2262
4198
|
autoEnhanced: true,
|
|
2263
4199
|
recoveryMode: "auto-tone-contrast-color",
|
|
2264
4200
|
originalColorNormalization: recovered.colorNormalization
|
|
2265
|
-
};
|
|
4201
|
+
}, debugContext, options);
|
|
2266
4202
|
}
|
|
2267
4203
|
} catch {
|
|
2268
4204
|
// Continue to the axis-aligned fallback.
|
|
@@ -2271,7 +4207,7 @@ export function scanImageData(imageData, options = {}) {
|
|
|
2271
4207
|
|
|
2272
4208
|
if (options.axisAlignedFallback !== false) {
|
|
2273
4209
|
const axis = tryAxisAlignedScan(imageData, scanOptions);
|
|
2274
|
-
if (axis) return axis;
|
|
4210
|
+
if (axis) return decorateScanResult(axis, debugContext, options);
|
|
2275
4211
|
}
|
|
2276
4212
|
|
|
2277
4213
|
// Static images sometimes have such low contrast that locator detection itself
|
|
@@ -2298,20 +4234,22 @@ export function scanImageData(imageData, options = {}) {
|
|
|
2298
4234
|
};
|
|
2299
4235
|
if (options.perspective !== false) {
|
|
2300
4236
|
const perspective = tryPerspectiveScan(enhanced, recoveryOptions);
|
|
2301
|
-
if (perspective) return { ...perspective, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" };
|
|
4237
|
+
if (perspective) return decorateScanResult({ ...perspective, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
|
|
2302
4238
|
}
|
|
2303
4239
|
if (options.axisAlignedFallback !== false) {
|
|
2304
4240
|
const axis = tryAxisAlignedScan(enhanced, recoveryOptions);
|
|
2305
|
-
if (axis) return { ...axis, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" };
|
|
4241
|
+
if (axis) return decorateScanResult({ ...axis, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
|
|
2306
4242
|
}
|
|
2307
4243
|
} catch {
|
|
2308
4244
|
// Fall through to the normal scan failure below.
|
|
2309
4245
|
}
|
|
2310
4246
|
}
|
|
2311
4247
|
|
|
2312
|
-
|
|
4248
|
+
const error = new Error(
|
|
2313
4249
|
"No valid QuadQR code found. Try better lighting, fill more of the frame, keep all locator patterns visible, or use a less blurred image."
|
|
2314
4250
|
);
|
|
4251
|
+
if (options.debug) error.debug = scannerDiagnostics(null, debugContext, options);
|
|
4252
|
+
throw error;
|
|
2315
4253
|
}
|
|
2316
4254
|
|
|
2317
4255
|
export async function scanFile(file, options = {}) {
|
|
@@ -2634,6 +4572,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
2634
4572
|
|
|
2635
4573
|
const scanInterval = Math.max(80, options.scanInterval ?? 180);
|
|
2636
4574
|
const scratchCanvas = document.createElement("canvas");
|
|
4575
|
+
const highResolutionCanvas = document.createElement("canvas");
|
|
2637
4576
|
const multiFrameEnabled = options.multiFrame !== false;
|
|
2638
4577
|
const multiFrameWindow = Math.max(2, Math.min(8, Math.round(options.multiFrameWindow ?? 4)));
|
|
2639
4578
|
const multiFrameMinFrames = Math.max(2, Math.min(multiFrameWindow, Math.round(options.multiFrameMinFrames ?? 2)));
|
|
@@ -2641,6 +4580,12 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
2641
4580
|
const cameraAutoColorEvery = Math.max(1, Math.round(options.cameraAutoColorEvery ?? 1));
|
|
2642
4581
|
const cameraAutoEnhanceEvery = Math.max(1, Math.round(options.cameraAutoEnhanceEvery ?? 2));
|
|
2643
4582
|
const cameraFinderRecoveryEvery = Math.max(1, Math.round(options.cameraFinderRecoveryEvery ?? 2));
|
|
4583
|
+
const cameraHighResolutionEvery = Math.max(1, Math.round(options.cameraHighResolutionEvery ?? 2));
|
|
4584
|
+
const baseCameraMaxDimension = Math.max(480, Math.round(options.maxDimension ?? 1080));
|
|
4585
|
+
const cameraHighResolutionMaxDimension = Math.max(
|
|
4586
|
+
baseCameraMaxDimension,
|
|
4587
|
+
Math.round(options.cameraHighResolutionMaxDimension ?? 1600)
|
|
4588
|
+
);
|
|
2644
4589
|
let missStreak = 0;
|
|
2645
4590
|
let stopped = false;
|
|
2646
4591
|
let busy = false;
|
|
@@ -2688,7 +4633,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
2688
4633
|
|
|
2689
4634
|
const scanNow = () => scanVideoFrame(video, {
|
|
2690
4635
|
...options,
|
|
2691
|
-
maxDimension:
|
|
4636
|
+
maxDimension: baseCameraMaxDimension,
|
|
2692
4637
|
canvas: scratchCanvas
|
|
2693
4638
|
});
|
|
2694
4639
|
|
|
@@ -2755,7 +4700,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
2755
4700
|
busy = true;
|
|
2756
4701
|
frameNumber++;
|
|
2757
4702
|
const observations = [];
|
|
2758
|
-
const frameDiagnostics =
|
|
4703
|
+
const frameDiagnostics = {};
|
|
2759
4704
|
const capturedFrame = {};
|
|
2760
4705
|
const frameStarted = nowMs();
|
|
2761
4706
|
let allowAutoEnhance = false;
|
|
@@ -2783,7 +4728,7 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
2783
4728
|
// If geometry exists, the rectified QR-only pixel enhancer is both
|
|
2784
4729
|
// stronger and much cheaper than reprocessing the whole live frame.
|
|
2785
4730
|
fullFrameAutoEnhanceRecovery: options.fullFrameAutoEnhanceRecovery ?? false,
|
|
2786
|
-
maxDimension:
|
|
4731
|
+
maxDimension: baseCameraMaxDimension,
|
|
2787
4732
|
canvas: scratchCanvas,
|
|
2788
4733
|
_capturedFrame: capturedFrame,
|
|
2789
4734
|
_observationCollector: observations,
|
|
@@ -2824,6 +4769,71 @@ export async function startCameraScanner(video, options = {}) {
|
|
|
2824
4769
|
...frameDiagnostics
|
|
2825
4770
|
});
|
|
2826
4771
|
|
|
4772
|
+
// Geometry-aware high-resolution retry. A dense QuadQR can look large
|
|
4773
|
+
// enough in the preview while each individual module has become too
|
|
4774
|
+
// small after the normal 1080 px scanner cap. If the fast pass already
|
|
4775
|
+
// sees at least two convincing finders, spend one bounded retry on a
|
|
4776
|
+
// higher-resolution copy of the visible camera ROI. Empty frames and
|
|
4777
|
+
// ordinary small codes never pay this cost.
|
|
4778
|
+
const shouldTryHighResolution = options.cameraHighResolutionRecovery !== false &&
|
|
4779
|
+
cameraHighResolutionMaxDimension > baseCameraMaxDimension &&
|
|
4780
|
+
(frameDiagnostics?.finderCount ?? 0) >= (options.cameraHighResolutionMinFinders ?? 2) &&
|
|
4781
|
+
((missStreak - 1) % cameraHighResolutionEvery === 0);
|
|
4782
|
+
if (shouldTryHighResolution) {
|
|
4783
|
+
const highResolutionObservations = [];
|
|
4784
|
+
const highResolutionDiagnostics = {};
|
|
4785
|
+
const highResolutionCapturedFrame = {};
|
|
4786
|
+
emitDiagnostic({
|
|
4787
|
+
type: "method",
|
|
4788
|
+
state: "trying",
|
|
4789
|
+
method: "high-resolution-geometry-recovery",
|
|
4790
|
+
message: `Dense geometry detected · retrying at up to ${cameraHighResolutionMaxDimension}px`,
|
|
4791
|
+
...frameDiagnostics
|
|
4792
|
+
});
|
|
4793
|
+
try {
|
|
4794
|
+
const recoveryStarted = nowMs();
|
|
4795
|
+
const recovered = scanVideoFrame(video, {
|
|
4796
|
+
...options,
|
|
4797
|
+
_diagnosticLabel: "high-resolution-geometry-recovery",
|
|
4798
|
+
finderRecovery: true,
|
|
4799
|
+
autoEnhanceRecovery: false,
|
|
4800
|
+
fullFrameAutoEnhanceRecovery: false,
|
|
4801
|
+
maxDimension: cameraHighResolutionMaxDimension,
|
|
4802
|
+
canvas: highResolutionCanvas,
|
|
4803
|
+
_capturedFrame: highResolutionCapturedFrame,
|
|
4804
|
+
_observationCollector: highResolutionObservations,
|
|
4805
|
+
_frameDiagnostics: highResolutionDiagnostics
|
|
4806
|
+
});
|
|
4807
|
+
const recoveryElapsedMs = nowMs() - recoveryStarted;
|
|
4808
|
+
emitDiagnostic({
|
|
4809
|
+
type: "success",
|
|
4810
|
+
state: "decoded",
|
|
4811
|
+
method: "high-resolution-geometry-recovery",
|
|
4812
|
+
elapsedMs: recoveryElapsedMs,
|
|
4813
|
+
message: `High-resolution retry decoded v${recovered.version} · ${Math.round(recoveryElapsedMs)} ms`,
|
|
4814
|
+
...(highResolutionDiagnostics ?? frameDiagnostics)
|
|
4815
|
+
});
|
|
4816
|
+
missStreak = 0;
|
|
4817
|
+
observationHistory.clear();
|
|
4818
|
+
if (emitResult({
|
|
4819
|
+
...recovered,
|
|
4820
|
+
cameraHighResolutionRecovery: true,
|
|
4821
|
+
cameraProgressiveRecovery: true,
|
|
4822
|
+
recoveryMode: recovered.recoveryMode ?? "high-resolution-geometry-recovery"
|
|
4823
|
+
}, highResolutionCapturedFrame, highResolutionDiagnostics ?? frameDiagnostics)) return;
|
|
4824
|
+
} catch (highResolutionError) {
|
|
4825
|
+
observations.push(...highResolutionObservations);
|
|
4826
|
+
emitDiagnostic({
|
|
4827
|
+
type: "method",
|
|
4828
|
+
state: "failed",
|
|
4829
|
+
method: "high-resolution-geometry-recovery",
|
|
4830
|
+
message: `High-resolution geometry retry did not decode${highResolutionDiagnostics?.finderCount != null ? ` · ${highResolutionDiagnostics.finderCount} finder(s)` : ""}`,
|
|
4831
|
+
error: highResolutionError?.message ?? String(highResolutionError),
|
|
4832
|
+
...(highResolutionDiagnostics ?? frameDiagnostics)
|
|
4833
|
+
});
|
|
4834
|
+
}
|
|
4835
|
+
}
|
|
4836
|
+
|
|
2827
4837
|
// The user's real phone-camera case is dominated by color cast before
|
|
2828
4838
|
// finder detection: Photoshop Auto Color alone makes the same live QR
|
|
2829
4839
|
// immediately detectable. Retry the exact captured frame with a cheap
|
|
@@ -3128,6 +5138,9 @@ export const internals = Object.freeze({
|
|
|
3128
5138
|
HEADER_CODEWORD_CELLS,
|
|
3129
5139
|
COMPACT_HEADER_CODEWORD_CELLS,
|
|
3130
5140
|
CELLS_PER_BYTE,
|
|
5141
|
+
RGBW_CELLS_PER_BYTE,
|
|
5142
|
+
TRIANGLE16_CELLS_PER_BYTE,
|
|
5143
|
+
TRIANGLE16_FLAG,
|
|
3131
5144
|
TEXT_FLAG,
|
|
3132
5145
|
SECURE_FLAG
|
|
3133
5146
|
});
|