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.
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * QuadQR
3
3
  *
4
- * Experimental four-state RGBW matrix code written in pure JavaScript.
4
+ * Experimental RGBW / Triangle16 matrix code written in pure JavaScript.
5
5
  *
6
6
  * Core format:
7
- * - Red / Green / Blue / White data cells
8
- * - exactly 2 bits per data cell
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 CELLS_PER_BYTE = 4;
101
- const HEADER_CODEWORD_CELLS = HEADER_CODEWORD_BYTES * CELLS_PER_BYTE;
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 * CELLS_PER_BYTE;
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 out = new Array(bytes.length * CELLS_PER_BYTE);
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 cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYTE)) {
250
- assert(cells.length >= byteCount * CELLS_PER_BYTE, "Not enough RGBW cells to rebuild bytes.");
251
- const out = new Uint8Array(byteCount);
252
- for (let i = 0; i < byteCount; i++) {
253
- const offset = i * CELLS_PER_BYTE;
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 flagsFor(text, eccLevel, secure = false) {
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(layout, rawCells, maskId, spectralInterleaving = true) {
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] = rawCells[logicalIndex] ^ maskValue(row, col, maskId);
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 quaternaryPenalty(matrix, reserved) {
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 counts = [0, 0, 0, 0];
580
- let dataCount = 0;
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 = matrix[r][c];
592
- counts[value]++;
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 = matrix[r][c];
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 (dataCount > 0) {
625
- const ideal = dataCount / 4;
626
- penalty += counts.reduce((sum, count) => sum + Math.abs(count - ideal), 0) / 2;
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 * CELLS_PER_BYTE
1229
+ encodedCells: encodedSymbols * cellsPerByteForEncoding(cellEncoding)
678
1230
  };
679
1231
  }
680
1232
 
681
- function streamCellCount(payloadLength, eccLevel, version) {
682
- return getHeaderPlan(version).codewordCells + getBodyRsPlan(payloadLength, eccLevel, version).encodedCells;
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
- if (!streamFitsLayout(layout, eccLevel, 0, version)) return 0;
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 / CELLS_PER_BYTE);
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 bytesToCells(codeword);
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 = quaternaryPenalty(candidate, layout.reserved);
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
- export function encodeText(text, options = {}) {
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 = input instanceof Uint8Array ? input : new Uint8Array(input);
1399
+ const payload = asBytes(input);
837
1400
  const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
838
1401
  const secure = Boolean(options.secure);
839
- const flags = flagsFor(Boolean(options.text), eccLevel, secure);
840
- const version = chooseVersion(payload.length, { ...options, ecc: eccLevel });
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 = input instanceof Uint8Array ? input : new Uint8Array(input);
1506
+ const sourcePayload = asBytes(input);
881
1507
  const security = options.security ?? {};
882
- const encrypted = await encryptSecurePayload(sourcePayload, security);
883
- return encodeBytes(encrypted.envelope, {
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
- * The encrypted envelope remains available as encryptedPayload.
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
- payload: plaintext,
905
- text: isText ? getTextDecoder().decode(plaintext) : null,
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 > 3) {
1000
- throw new Error("Data region contains an invalid QuadQR data cell.");
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(confidences, byteCount) {
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 * CELLS_PER_BYTE) return null;
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 * CELLS_PER_BYTE;
1030
- // A single wrong 2-bit cell changes the GF(256) symbol, so the weakest
1031
- // constituent cell is the useful confidence bound for that symbol.
1032
- out[symbolIndex] = Math.min(
1033
- confidences[offset] ?? 1,
1034
- confidences[offset + 1] ?? 1,
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(cellsToBytes(headerCells, plan.codewordBytes));
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
- // New symbols use spectral-spatial interleaving. Legacy order remains a
1152
- // decode fallback so existing QuadQR images do not become unreadable.
1153
- for (const spectralInterleaving of [true, false]) {
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
- const bodyDecoded = decodeProtectedBody(
1171
- raw,
1172
- payloadLength,
1173
- eccLevel,
1174
- version,
1175
- rawConfidences,
1176
- options
1177
- );
1178
- const body = bodyDecoded.body;
1179
- const payload = body.slice(0, payloadLength);
1180
- const expectedCrc = readU32be(body, payloadLength);
1181
- const actualCrc = crc32(concatBytes(header, payload));
1182
- if (expectedCrc !== actualCrc) throw new Error("CRC mismatch after ECC.");
1183
-
1184
- const isText = (flags & TEXT_FLAG) !== 0;
1185
- const secure = (flags & SECURE_FLAG) !== 0;
1186
- const security = secure ? inspectSecureEnvelope(payload) : null;
1187
- const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
1188
- const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
1189
- return {
1190
- ok: true,
1191
- format: "QuadQR",
1192
- formatVersion: FORMAT_VERSION,
1193
- version,
1194
- size,
1195
- alignmentPatterns: layout.alignments.length,
1196
- maskId,
1197
- rotation,
1198
- flags,
1199
- eccLevel,
1200
- eccParitySymbols: bodyDecoded.plan.paritySymbols,
1201
- eccBlocks: bodyDecoded.plan.dataBlockLengths.length,
1202
- correctableSymbolsPerBlock: bodyDecoded.plan.correctableSymbolsPerBlock,
1203
- spectralInterleaving,
1204
- confidenceAwareEcc: Boolean(rawConfidences),
1205
- confidenceAssisted: headerDecoded.confidenceAssisted || bodyDecoded.confidenceAssisted,
1206
- erasureSymbols,
1207
- unknownErrorSymbols,
1208
- correctedHeaderSymbols: headerDecoded.correctedSymbols,
1209
- correctedBodySymbols: bodyDecoded.correctedSymbols,
1210
- correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
1211
- payload,
1212
- text: isText && !secure ? getTextDecoder().decode(payload) : null,
1213
- secure,
1214
- encrypted: secure,
1215
- decrypted: false,
1216
- requiresDecryption: secure,
1217
- security,
1218
- crc32: actualCrc >>> 0
1219
- };
1220
- } catch (error) {
1221
- errors.push(`${spectralInterleaving ? "spectral" : "legacy"} mask ${maskId}: ${error.message}`);
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
- for (let r = 0; r < size; r++) {
1456
- for (let c = 0; c < size; c++) {
1457
- const cell = matrix[r][c];
1458
- const structural = isStructuralRenderCell(layout, r, c, cell);
1459
- const x = (c + quietZone) * moduleSize;
1460
- const y = (r + quietZone) * moduleSize;
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
- if (style === RENDER_STYLES.CLASSIC || structural) {
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("&", "&amp;")
2560
+ .replaceAll('"', "&quot;")
2561
+ .replaceAll("<", "&lt;")
2562
+ .replaceAll(">", "&gt;");
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, moduleSize, moduleSize);
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, moduleSize, moduleSize);
2674
+ ctx.fillRect(x, y, cellWidth, cellHeight);
1475
2675
 
1476
- if (moduleSize >= 6) {
1477
- const edge = Math.max(1, Math.floor(moduleSize * 0.08));
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, moduleSize, edge);
1482
- ctx.fillRect(x, y, edge, moduleSize);
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 + moduleSize - edge, moduleSize, edge);
1485
- ctx.fillRect(x + moduleSize - edge, y, edge, moduleSize);
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 = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
1492
- const width = moduleSize - inset * 2;
1493
- const radius = Math.max(1, Math.floor(moduleSize * 0.20));
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, width, radius);
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, moduleSize, moduleSize);
2705
+ ctx.fillRect(x, y, cellWidth, cellHeight);
1505
2706
  if (cell === CELL.WHITE) continue;
1506
2707
 
1507
- const edge = safeStyleEdge(moduleSize);
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, moduleSize, edge);
1512
- ctx.fillRect(x, y, edge, moduleSize);
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 + moduleSize - edge, moduleSize, edge);
1515
- ctx.fillRect(x + moduleSize - edge, y, edge, moduleSize);
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 moduleSize = Math.max(1, Math.floor(options.moduleSize ?? 8));
1527
- const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
1528
- const palette = paletteRgb(options.palette);
1529
- const style = normalizeRenderStyle(options.style);
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 y0 = (r + quietZone) * moduleSize;
1550
- const x0 = (c + quietZone) * moduleSize;
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, moduleSize, moduleSize, rgb);
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, moduleSize, moduleSize, base);
1561
- if (moduleSize >= 6) {
1562
- const edge = Math.max(1, Math.floor(moduleSize * 0.08));
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, moduleSize, edge, highlight);
1566
- fillImageRect(data, pixelSize, x0, y0, edge, moduleSize, highlight);
1567
- fillImageRect(data, pixelSize, x0, y0 + moduleSize - edge, moduleSize, edge, shadow);
1568
- fillImageRect(data, pixelSize, x0 + moduleSize - edge, y0, edge, moduleSize, shadow);
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
- fillImageRoundedRect(data, pixelSize, x0 + inset, y0 + inset, width, width, radius, rgb);
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
- fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, rgb);
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, palette);
1587
- fillImageRect(data, pixelSize, x0, y0, moduleSize, edge, fx.shadow);
1588
- fillImageRect(data, pixelSize, x0, y0, edge, moduleSize, fx.shadow);
1589
- fillImageRect(data, pixelSize, x0, y0 + moduleSize - edge, moduleSize, edge, fx.highlight);
1590
- fillImageRect(data, pixelSize, x0 + moduleSize - edge, y0, edge, moduleSize, fx.highlight);
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
- return { width: pixelSize, height: pixelSize, data };
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
- sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, {
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 < (options.refinementDecodeThreshold ?? 0.95)) continue;
2047
- refinementCandidates.push({
2048
- attempt,
2049
- classified,
2050
- observedPalette,
2051
- offsetX,
2052
- offsetY,
2053
- structureScore
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
- refinementCandidates.sort((a, b) =>
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
- for (const candidate of refinementCandidates.slice(0, decodeLimit)) {
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: "refined-center",
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
- try {
2170
- const classified = classifySampledRgbGrid(attempt.rgbGrid ?? sampled.rgbGrid, attempt.classifier, layout);
2171
- const structureScore = structuralAccuracy(classified.matrix, layout);
2172
- pushObservation(options, {
2173
- version,
2174
- matrix: classified.matrix,
2175
- confidence: classified.confidence,
2176
- bounds,
2177
- samplingMode: profileIndex === 0 ? "axis" : "axis-center",
2178
- colorNormalization: attempt.colorNormalization,
2179
- structureScore,
2180
- averageCellConfidence: classified.averageCellConfidence,
2181
- lowConfidenceCells: classified.lowConfidenceCells
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 scanOptions = { ...options, _geometryCollector: geometryCollector };
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
- throw new Error(
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: options.maxDimension ?? 1080,
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 = diagnosticsEnabled ? {} : null;
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: options.maxDimension ?? 1080,
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
  });