quadqr-js 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/quadqr.js CHANGED
@@ -2233,7 +2233,7 @@ return { computeHomography, projectPoint, detectCodeGeometry, autoColorImageData
2233
2233
  const __qqr_quadqr_js = (function () {
2234
2234
  const { MAX_CODEWORD_SYMBOLS, rsEncode, rsDecode } = __qqr_reed_solomon_js;
2235
2235
  const { alignmentPatternCentersForVersion, alignmentPatternIsBlack, alignmentPatternRadius, sizeForVersion, versionFromSize } = __qqr_geometry_js;
2236
- const { detectCodeGeometry, samplePerspectiveMatrix, rectifyImageData, sampleObservedPalette, spatiallyNormalizeRgbGrid, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, autoColorImageData, findActiveBounds, sampleAxisAlignedGrid } = __qqr_vision_js;
2236
+ const { detectCodeGeometry, computeHomography, projectPoint, samplePerspectiveMatrix, rectifyImageData, sampleObservedPalette, spatiallyNormalizeRgbGrid, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, autoColorImageData, findActiveBounds, sampleAxisAlignedGrid } = __qqr_vision_js;
2237
2237
  const { decryptSecurePayload, encryptSecurePayload, inspectSecureEnvelope, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex } = __qqr_security_js;
2238
2238
  /**
2239
2239
  * QuadQR
@@ -2257,6 +2257,28 @@ const MIN_VERSION = 1;
2257
2257
  const MAX_VERSION = 40;
2258
2258
  const DEFAULT_ECC_LEVEL = "M";
2259
2259
 
2260
+ const RENDER_MODES = Object.freeze({
2261
+ SCREEN: "screen",
2262
+ PRINT: "print"
2263
+ });
2264
+
2265
+ // Print-safe defaults deliberately use darker, more ink-tolerant primaries and
2266
+ // a true white background. Applications can still override individual colors.
2267
+ const PRINT_PALETTE = Object.freeze({
2268
+ black: "#000000",
2269
+ white: "#ffffff",
2270
+ red: "#d71932",
2271
+ green: "#087f3e",
2272
+ blue: "#174ea6"
2273
+ });
2274
+
2275
+ const COMPRESSION_MODES = Object.freeze({
2276
+ NONE: "none",
2277
+ AUTO: "auto",
2278
+ LZ: "lz"
2279
+ });
2280
+ const SIGNATURE_ALGORITHMS = Object.freeze({ ED25519: "Ed25519" });
2281
+
2260
2282
  // Data cells are the four values 0..3. Structural white deliberately shares
2261
2283
  // value 3 with data-white because both render and scan identically.
2262
2284
  const CELL = Object.freeze({
@@ -2321,6 +2343,22 @@ const COMPACT_ECC_LEVELS = Object.freeze({
2321
2343
  const CRC_BYTES = 4;
2322
2344
  const TEXT_FLAG = 1;
2323
2345
  const SECURE_FLAG = 1 << 3;
2346
+ const EXTENDED_PAYLOAD_FLAG = 1 << 4;
2347
+ const SIGNED_FLAG = 1 << 5;
2348
+
2349
+ // Internal payload extension envelope. This is deliberately not a public
2350
+ // payload mode or content-type system. It exists only when compression or
2351
+ // signing needs metadata around an otherwise normal text/binary payload.
2352
+ const PAYLOAD_ENVELOPE_VERSION = 2;
2353
+ const PAYLOAD_ENVELOPE_MAGIC = new Uint8Array([0x51, 0x50, 0x58, 0x31]); // QPX1
2354
+ const PAYLOAD_ENVELOPE_HEADER_BYTES = 16;
2355
+ const PAYLOAD_ENVELOPE_SIGNED_FLAG = 1;
2356
+ const PAYLOAD_ENVELOPE_COMPRESSED_FLAG = 1 << 1;
2357
+ const PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG = 1 << 2;
2358
+ const PAYLOAD_ENVELOPE_COMPRESSION_IDS = Object.freeze({ none: 0, lz: 1 });
2359
+ const PAYLOAD_ENVELOPE_COMPRESSION_BY_ID = Object.freeze({ 0: "none", 1: "lz" });
2360
+ const PAYLOAD_ENVELOPE_SIGNATURE_IDS = Object.freeze({ none: 0, Ed25519: 1 });
2361
+ const PAYLOAD_ENVELOPE_SIGNATURE_BY_ID = Object.freeze({ 0: null, 1: "Ed25519" });
2324
2362
  const ECC_SHIFT = 1;
2325
2363
  const ECC_MASK = 0b00000110;
2326
2364
 
@@ -2399,6 +2437,368 @@ function readU32be(bytes, offset = 0) {
2399
2437
  ) >>> 0;
2400
2438
  }
2401
2439
 
2440
+ function u16be(value) {
2441
+ const v = Number(value) >>> 0;
2442
+ assert(v <= 0xffff, "Value does not fit in uint16.");
2443
+ return new Uint8Array([(v >>> 8) & 0xff, v & 0xff]);
2444
+ }
2445
+
2446
+ function readU16be(bytes, offset = 0) {
2447
+ return ((bytes[offset] << 8) | bytes[offset + 1]) >>> 0;
2448
+ }
2449
+
2450
+ function normalizeCompressionMode(value = "none") {
2451
+ const key = String(value).toLowerCase();
2452
+ assert(["none", "auto", "lz"].includes(key), "compression must be none, auto, or lz.");
2453
+ return key;
2454
+ }
2455
+
2456
+ function asBytes(input) {
2457
+ if (input instanceof Uint8Array) return input;
2458
+ if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
2459
+ if (input instanceof ArrayBuffer) return new Uint8Array(input);
2460
+ return new Uint8Array(input);
2461
+ }
2462
+
2463
+ /** Portable LZSS-style compressor used by QuadQR payload compression. */
2464
+ function compressPayload(input) {
2465
+ const bytes = asBytes(input);
2466
+ if (bytes.length < 4) return bytes.slice();
2467
+ const out = [];
2468
+ const recent = new Map();
2469
+ let pos = 0;
2470
+
2471
+ const keyAt = (i) => i + 2 < bytes.length ? ((bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]) : -1;
2472
+ const remember = (i) => {
2473
+ const key = keyAt(i);
2474
+ if (key < 0) return;
2475
+ let list = recent.get(key);
2476
+ if (!list) recent.set(key, list = []);
2477
+ list.push(i);
2478
+ while (list.length > 32) list.shift();
2479
+ while (list.length && i - list[0] > 4095) list.shift();
2480
+ };
2481
+
2482
+ while (pos < bytes.length) {
2483
+ const flagIndex = out.length;
2484
+ out.push(0);
2485
+ let flags = 0;
2486
+ for (let token = 0; token < 8 && pos < bytes.length; token++) {
2487
+ let bestLength = 0;
2488
+ let bestOffset = 0;
2489
+ const key = keyAt(pos);
2490
+ const candidates = key >= 0 ? (recent.get(key) ?? []) : [];
2491
+ for (let ci = candidates.length - 1; ci >= 0; ci--) {
2492
+ const candidate = candidates[ci];
2493
+ const offset = pos - candidate;
2494
+ if (offset <= 0 || offset > 4095) continue;
2495
+ let length = 0;
2496
+ while (length < 18 && pos + length < bytes.length && bytes[candidate + length] === bytes[pos + length]) length++;
2497
+ if (length >= 3 && length > bestLength) {
2498
+ bestLength = length;
2499
+ bestOffset = offset;
2500
+ if (length === 18) break;
2501
+ }
2502
+ }
2503
+
2504
+ if (bestLength >= 3) {
2505
+ flags |= (1 << token);
2506
+ const encoded = ((bestOffset & 0x0fff) << 4) | ((bestLength - 3) & 0x0f);
2507
+ out.push((encoded >>> 8) & 0xff, encoded & 0xff);
2508
+ for (let i = 0; i < bestLength; i++) remember(pos + i);
2509
+ pos += bestLength;
2510
+ } else {
2511
+ out.push(bytes[pos]);
2512
+ remember(pos);
2513
+ pos++;
2514
+ }
2515
+ }
2516
+ out[flagIndex] = flags;
2517
+ }
2518
+ return Uint8Array.from(out);
2519
+ }
2520
+
2521
+ function decompressPayload(input, expectedLength = null) {
2522
+ const bytes = asBytes(input);
2523
+ const out = [];
2524
+ let pos = 0;
2525
+ while (pos < bytes.length) {
2526
+ const flags = bytes[pos++];
2527
+ for (let token = 0; token < 8 && pos < bytes.length; token++) {
2528
+ if (flags & (1 << token)) {
2529
+ assert(pos + 1 < bytes.length, "Compressed payload is truncated.");
2530
+ const encoded = (bytes[pos++] << 8) | bytes[pos++];
2531
+ const offset = (encoded >>> 4) & 0x0fff;
2532
+ const length = (encoded & 0x0f) + 3;
2533
+ assert(offset > 0 && offset <= out.length, "Compressed payload has an invalid back-reference.");
2534
+ for (let i = 0; i < length; i++) out.push(out[out.length - offset]);
2535
+ } else {
2536
+ out.push(bytes[pos++]);
2537
+ }
2538
+ if (expectedLength != null && out.length >= expectedLength) break;
2539
+ }
2540
+ }
2541
+ if (expectedLength != null) assert(out.length === expectedLength, "Decompressed payload length mismatch.");
2542
+ return Uint8Array.from(out);
2543
+ }
2544
+
2545
+ function prepareCompressedBody(payload, compression = "none") {
2546
+ const mode = normalizeCompressionMode(compression);
2547
+ if (mode === "none") return { body: payload, compression: "none" };
2548
+ const compressed = compressPayload(payload);
2549
+ if (mode === "auto" && compressed.length >= payload.length - 2) {
2550
+ return { body: payload, compression: "none" };
2551
+ }
2552
+ return { body: compressed, compression: "lz" };
2553
+ }
2554
+
2555
+
2556
+ function normalizeSigningKeyId(value) {
2557
+ if (value == null || value === false || value === "") return new Uint8Array(0);
2558
+ const bytes = typeof value === "string" ? getTextEncoder().encode(value) : asBytes(value);
2559
+ assert(bytes.length <= 64, "Signing key ID must be 64 bytes or fewer.");
2560
+ return bytes;
2561
+ }
2562
+
2563
+ function makePayloadEnvelopeHeader({ compression, flags = 0, originalLength, keyIdLength = 0, publicKeyLength = 0, signatureLength = 0, signatureAlgorithm = null, version = PAYLOAD_ENVELOPE_VERSION }) {
2564
+ assert(keyIdLength <= 0xff, "Signing key ID is too long.");
2565
+ assert(publicKeyLength <= 0xff, "Signing public key is too large.");
2566
+ assert(signatureLength <= 0xff, "Signature is too large.");
2567
+ const header = new Uint8Array(PAYLOAD_ENVELOPE_HEADER_BYTES);
2568
+ header.set(PAYLOAD_ENVELOPE_MAGIC, 0);
2569
+ header[4] = version;
2570
+ header[5] = flags & 0xff;
2571
+ header[6] = PAYLOAD_ENVELOPE_COMPRESSION_IDS[compression];
2572
+ header[7] = PAYLOAD_ENVELOPE_SIGNATURE_IDS[signatureAlgorithm ?? "none"] ?? 0;
2573
+ header.set(u32be(originalLength), 8);
2574
+ header[12] = keyIdLength;
2575
+ header[13] = publicKeyLength;
2576
+ header[14] = signatureLength;
2577
+ header[15] = 0;
2578
+ return header;
2579
+ }
2580
+
2581
+ function isPayloadEnvelope(input) {
2582
+ const bytes = asBytes(input);
2583
+ return bytes.length >= PAYLOAD_ENVELOPE_HEADER_BYTES && PAYLOAD_ENVELOPE_MAGIC.every((value, index) => bytes[index] === value);
2584
+ }
2585
+
2586
+ function packPayloadEnvelope(input, options = {}) {
2587
+ const payload = asBytes(input);
2588
+ const prepared = prepareCompressedBody(payload, options.compression ?? "none");
2589
+ const flags = prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0;
2590
+ const header = makePayloadEnvelopeHeader({
2591
+ compression: prepared.compression,
2592
+ flags,
2593
+ originalLength: payload.length
2594
+ });
2595
+ return concatBytes(header, prepared.body);
2596
+ }
2597
+
2598
+ function parsePayloadEnvelope(input) {
2599
+ const container = asBytes(input);
2600
+ assert(isPayloadEnvelope(container), "Payload extension envelope is not recognized.");
2601
+ const version = container[4];
2602
+ assert(version === 1 || version === PAYLOAD_ENVELOPE_VERSION, `Unsupported payload extension version ${version}.`);
2603
+ const flags = container[5];
2604
+ const compression = PAYLOAD_ENVELOPE_COMPRESSION_BY_ID[container[6]];
2605
+ assert(compression != null, `Unknown payload compression id ${container[6]}.`);
2606
+ const signatureAlgorithm = PAYLOAD_ENVELOPE_SIGNATURE_BY_ID[container[7]];
2607
+ const originalLength = readU32be(container, 8);
2608
+ const metadataLength = container[12];
2609
+ const publicKeyLength = container[13];
2610
+ const signatureLength = container[14];
2611
+ let offset = PAYLOAD_ENVELOPE_HEADER_BYTES;
2612
+ const endMetadata = offset + metadataLength;
2613
+ const endPublicKey = endMetadata + publicKeyLength;
2614
+ const endSignature = endPublicKey + signatureLength;
2615
+ assert(endSignature <= container.length, "Payload extension length fields exceed the envelope size.");
2616
+ const metadataBytes = container.slice(offset, endMetadata);
2617
+ const publicKey = container.slice(endMetadata, endPublicKey);
2618
+ const signature = container.slice(endPublicKey, endSignature);
2619
+ const stored = container.slice(endSignature);
2620
+ const payload = compression === "lz" ? decompressPayload(stored, originalLength) : stored;
2621
+ assert(payload.length === originalLength, "Payload length mismatch after extension decoding.");
2622
+ const signed = Boolean(flags & PAYLOAD_ENVELOPE_SIGNED_FLAG);
2623
+ const signedBytes = signed ? concatBytes(container.slice(0, PAYLOAD_ENVELOPE_HEADER_BYTES), metadataBytes, publicKey, stored) : null;
2624
+
2625
+ // v1 used byte 12 for a human-readable signer label and always embedded
2626
+ // the public key. v2 uses the same field for a compact key ID and makes
2627
+ // public-key embedding optional.
2628
+ const legacySigner = version === 1 && metadataLength ? getTextDecoder().decode(metadataBytes) : null;
2629
+ const keyId = version >= 2 && metadataLength ? getTextDecoder().decode(metadataBytes) : null;
2630
+ const keyIdHex = version >= 2 && metadataLength
2631
+ ? Array.from(metadataBytes, (value) => value.toString(16).padStart(2, "0")).join("")
2632
+ : null;
2633
+
2634
+ return {
2635
+ container,
2636
+ version,
2637
+ compression,
2638
+ compressed: compression !== "none",
2639
+ originalBytes: originalLength,
2640
+ storedBytes: stored.length,
2641
+ savedBytes: originalLength - stored.length,
2642
+ payload,
2643
+ signed,
2644
+ signer: legacySigner,
2645
+ keyId,
2646
+ keyIdHex,
2647
+ publicKey,
2648
+ hasEmbeddedPublicKey: publicKey.length > 0,
2649
+ signature,
2650
+ signatureAlgorithm,
2651
+ signedBytes,
2652
+ signatureVerified: signed ? null : undefined,
2653
+ signatureTrusted: signed ? null : undefined
2654
+ };
2655
+ }
2656
+
2657
+ function subtleCrypto() {
2658
+ const subtle = globalThis.crypto?.subtle;
2659
+ assert(subtle, "Web Crypto is required for QuadQR signing.");
2660
+ return subtle;
2661
+ }
2662
+
2663
+ async function normalizeEd25519PublicKey(key) {
2664
+ const subtle = subtleCrypto();
2665
+ if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) return key;
2666
+ if (key && typeof key === "object" && key.type === "public" && key.algorithm?.name === "Ed25519") return key;
2667
+ const bytes = asBytes(key);
2668
+ assert(bytes.length === 32, "Ed25519 public key must be a 32-byte raw key or CryptoKey.");
2669
+ return subtle.importKey("raw", bytes, { name: "Ed25519" }, true, ["verify"]);
2670
+ }
2671
+
2672
+ async function normalizeEd25519PrivateKey(key) {
2673
+ const subtle = subtleCrypto();
2674
+ if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) return key;
2675
+ if (key && typeof key === "object" && key.type === "private" && key.algorithm?.name === "Ed25519") return key;
2676
+ const bytes = asBytes(key);
2677
+ return subtle.importKey("pkcs8", bytes, { name: "Ed25519" }, false, ["sign"]);
2678
+ }
2679
+
2680
+ async function deriveSigningKeyId(publicKey, bytes = 8) {
2681
+ const subtle = subtleCrypto();
2682
+ const normalized = await normalizeEd25519PublicKey(publicKey);
2683
+ const raw = new Uint8Array(await subtle.exportKey("raw", normalized));
2684
+ const digest = new Uint8Array(await subtle.digest("SHA-256", raw));
2685
+ const count = Math.max(4, Math.min(16, Math.floor(bytes)));
2686
+ return Array.from(digest.slice(0, count), (value) => value.toString(16).padStart(2, "0")).join("");
2687
+ }
2688
+
2689
+ async function generateSigningKeyPair() {
2690
+ const subtle = subtleCrypto();
2691
+ const pair = await subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
2692
+ const publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", pair.publicKey));
2693
+ const privateKeyPkcs8 = new Uint8Array(await subtle.exportKey("pkcs8", pair.privateKey));
2694
+ const keyId = await deriveSigningKeyId(pair.publicKey);
2695
+ return {
2696
+ privateKey: pair.privateKey,
2697
+ publicKey: pair.publicKey,
2698
+ privateKeyPkcs8,
2699
+ publicKeyBytes,
2700
+ keyId,
2701
+ algorithm: "Ed25519"
2702
+ };
2703
+ }
2704
+
2705
+ async function packSignedPayloadEnvelope(input, options = {}) {
2706
+ const payload = asBytes(input);
2707
+ assert(options.privateKey, "privateKey is required to sign a QuadQR payload.");
2708
+ const subtle = subtleCrypto();
2709
+ const prepared = prepareCompressedBody(payload, options.compression ?? "auto");
2710
+ const keyIdBytes = normalizeSigningKeyId(options.keyId);
2711
+
2712
+ let publicKeyBytes = new Uint8Array(0);
2713
+ if (options.embedPublicKey) {
2714
+ assert(options.publicKey, "publicKey is required when embedPublicKey is enabled.");
2715
+ const publicKeyObject = await normalizeEd25519PublicKey(options.publicKey);
2716
+ publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", publicKeyObject));
2717
+ }
2718
+
2719
+ const flags = PAYLOAD_ENVELOPE_SIGNED_FLAG |
2720
+ (prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0) |
2721
+ (publicKeyBytes.length ? PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG : 0);
2722
+ const header = makePayloadEnvelopeHeader({
2723
+ compression: prepared.compression,
2724
+ flags,
2725
+ originalLength: payload.length,
2726
+ keyIdLength: keyIdBytes.length,
2727
+ publicKeyLength: publicKeyBytes.length,
2728
+ signatureLength: 64,
2729
+ signatureAlgorithm: "Ed25519"
2730
+ });
2731
+ const signedBytes = concatBytes(header, keyIdBytes, publicKeyBytes, prepared.body);
2732
+ const privateKey = await normalizeEd25519PrivateKey(options.privateKey);
2733
+ const signature = new Uint8Array(await subtle.sign({ name: "Ed25519" }, privateKey, signedBytes));
2734
+ assert(signature.length === 64, "Unexpected Ed25519 signature length.");
2735
+ return concatBytes(header, keyIdBytes, publicKeyBytes, signature, prepared.body);
2736
+ }
2737
+
2738
+ function lookupTrustedSigningKey(trustedKeys, keyId) {
2739
+ if (!trustedKeys || !keyId) return null;
2740
+ if (trustedKeys instanceof Map) return trustedKeys.get(keyId) ?? null;
2741
+ if (typeof trustedKeys === "object") return trustedKeys[keyId] ?? null;
2742
+ return null;
2743
+ }
2744
+
2745
+ async function verifyPayloadEnvelopeSignature(input, options = {}) {
2746
+ const parsed = input?.container && input?.signedBytes ? input : parsePayloadEnvelope(input);
2747
+ assert(parsed.signed, "QuadQR payload is not signed.");
2748
+ assert(parsed.signatureAlgorithm === "Ed25519", "Unsupported signature algorithm.");
2749
+ const subtle = subtleCrypto();
2750
+
2751
+ let trusted = options.publicKey ?? options.trustedPublicKey ?? lookupTrustedSigningKey(options.trustedKeys, parsed.keyId);
2752
+ let trustSource = trusted ? "external" : null;
2753
+
2754
+ // Legacy v1 symbols embedded the public key by default, so preserve their
2755
+ // historical self-verification behavior. v2 requires explicit opt-in.
2756
+ if (!trusted && parsed.publicKey.length && (parsed.version === 1 || options.allowEmbeddedKey === true)) {
2757
+ trusted = parsed.publicKey;
2758
+ trustSource = "embedded";
2759
+ }
2760
+
2761
+ assert(
2762
+ trusted,
2763
+ parsed.keyId
2764
+ ? `A trusted Ed25519 public key is required to verify signing key ID "${parsed.keyId}".`
2765
+ : "A trusted Ed25519 public key is required to verify this signed QuadQR."
2766
+ );
2767
+
2768
+ const publicKey = await normalizeEd25519PublicKey(trusted);
2769
+ if (trustSource === "external" && parsed.publicKey.length) {
2770
+ const trustedBytes = new Uint8Array(await subtle.exportKey("raw", publicKey));
2771
+ assert(
2772
+ trustedBytes.length === parsed.publicKey.length && trustedBytes.every((value, index) => value === parsed.publicKey[index]),
2773
+ "Embedded signing key does not match the trusted public key."
2774
+ );
2775
+ }
2776
+
2777
+ const verified = await subtle.verify({ name: "Ed25519" }, publicKey, parsed.signature, parsed.signedBytes);
2778
+ return {
2779
+ verified,
2780
+ trusted: verified && trustSource === "external",
2781
+ trustSource,
2782
+ keyId: parsed.keyId ?? null
2783
+ };
2784
+ }
2785
+
2786
+ function prepareOptionalCompressedPayload(input, compression = "none") {
2787
+ const payload = asBytes(input);
2788
+ const mode = normalizeCompressionMode(compression);
2789
+ if (mode === "none") return { payload, extended: false, compression: "none" };
2790
+ const prepared = prepareCompressedBody(payload, mode);
2791
+ // Auto mode is truly zero-overhead when compression does not help.
2792
+ if (mode === "auto" && prepared.compression === "none") {
2793
+ return { payload, extended: false, compression: "none" };
2794
+ }
2795
+ return {
2796
+ payload: packPayloadEnvelope(payload, { compression: prepared.compression }),
2797
+ extended: true,
2798
+ compression: prepared.compression
2799
+ };
2800
+ }
2801
+
2402
2802
  let CRC_TABLE = null;
2403
2803
  let CRC32_ACCELERATOR = null;
2404
2804
 
@@ -2463,9 +2863,11 @@ function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYT
2463
2863
  return out;
2464
2864
  }
2465
2865
 
2466
- function flagsFor(text, eccLevel, secure = false) {
2866
+ function flagsFor(text, eccLevel, secure = false, extended = false, signed = false) {
2467
2867
  return (text ? TEXT_FLAG : 0) |
2468
2868
  (secure ? SECURE_FLAG : 0) |
2869
+ (extended ? EXTENDED_PAYLOAD_FLAG : 0) |
2870
+ (signed ? SIGNED_FLAG : 0) |
2469
2871
  ((ECC_LEVELS[eccLevel].id << ECC_SHIFT) & ECC_MASK);
2470
2872
  }
2471
2873
 
@@ -3005,6 +3407,11 @@ function finalizeMatrix(layout, rawCells, meta) {
3005
3407
  payloadBytes: meta.payloadBytes,
3006
3408
  sourcePayloadBytes: meta.sourcePayloadBytes ?? meta.payloadBytes,
3007
3409
  secure: Boolean(meta.secure),
3410
+ compressed: meta.compression != null && meta.compression !== "none",
3411
+ compression: meta.compression ?? "none",
3412
+ signed: Boolean(meta.signed),
3413
+ signingKeyId: meta.signingKeyId ?? null,
3414
+ hasEmbeddedPublicKey: Boolean(meta.hasEmbeddedPublicKey),
3008
3415
  requiresDecryption: Boolean(meta.secure),
3009
3416
  security: meta.security ?? null,
3010
3417
  meaningfulCells: meta.meaningfulCells,
@@ -3023,20 +3430,17 @@ function finalizeMatrix(layout, rawCells, meta) {
3023
3430
  };
3024
3431
  }
3025
3432
 
3026
- function encodeText(text, options = {}) {
3027
- assert(typeof text === "string", "encodeText expects a string.");
3028
- return encodeBytes(getTextEncoder().encode(text), { ...options, text: true });
3029
- }
3030
-
3031
- function encodeBytes(input, options = {}) {
3433
+ function encodePreparedBytes(input, options = {}) {
3032
3434
  if (options.formatVersion != null && options.formatVersion !== FORMAT_VERSION) {
3033
3435
  throw new Error(`Only QuadQR format version ${FORMAT_VERSION} is supported.`);
3034
3436
  }
3035
3437
 
3036
- const payload = input instanceof Uint8Array ? input : new Uint8Array(input);
3438
+ const payload = asBytes(input);
3037
3439
  const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
3038
3440
  const secure = Boolean(options.secure);
3039
- const flags = flagsFor(Boolean(options.text), eccLevel, secure);
3441
+ const extended = Boolean(options.extended);
3442
+ const signed = Boolean(options.signed);
3443
+ const flags = flagsFor(Boolean(options.text), eccLevel, secure, extended, signed);
3040
3444
  const version = chooseVersion(payload.length, { ...options, ecc: eccLevel });
3041
3445
  const layout = createLayout(version);
3042
3446
  const header = makeHeader(payload.length, flags, version);
@@ -3056,6 +3460,11 @@ function encodeBytes(input, options = {}) {
3056
3460
  payloadBytes: payload.length,
3057
3461
  sourcePayloadBytes: options.sourcePayloadBytes ?? payload.length,
3058
3462
  secure,
3463
+ extended,
3464
+ signed,
3465
+ compression: options.compressionMetadata ?? "none",
3466
+ signingKeyId: options.signingKeyId ?? null,
3467
+ hasEmbeddedPublicKey: Boolean(options.hasEmbeddedPublicKey),
3059
3468
  security: options.securityMetadata ?? null,
3060
3469
  meaningfulCells: meaningfulCells.length,
3061
3470
  eccLevel,
@@ -3066,6 +3475,59 @@ function encodeBytes(input, options = {}) {
3066
3475
  });
3067
3476
  }
3068
3477
 
3478
+ function encodeText(text, options = {}) {
3479
+ assert(typeof text === "string", "encodeText expects a string.");
3480
+ const source = getTextEncoder().encode(text);
3481
+ const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
3482
+ return encodePreparedBytes(prepared.payload, {
3483
+ ...options,
3484
+ text: true,
3485
+ extended: prepared.extended,
3486
+ sourcePayloadBytes: source.length,
3487
+ compressionMetadata: prepared.compression
3488
+ });
3489
+ }
3490
+
3491
+ function encodeBytes(input, options = {}) {
3492
+ const source = asBytes(input);
3493
+ const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
3494
+ return encodePreparedBytes(prepared.payload, {
3495
+ ...options,
3496
+ text: Boolean(options.text),
3497
+ extended: prepared.extended,
3498
+ sourcePayloadBytes: source.length,
3499
+ compressionMetadata: prepared.compression
3500
+ });
3501
+ }
3502
+
3503
+ /** Explicit Uint8Array convenience API. Equivalent to encodeBytes(). */
3504
+ function encodeUint8Array(input, options = {}) {
3505
+ return encodeBytes(asBytes(input), options);
3506
+ }
3507
+
3508
+ /** Encode and sign a normal payload using an internal Ed25519 envelope. */
3509
+ async function encodeSignedBytes(input, options = {}) {
3510
+ const source = asBytes(input);
3511
+ const envelope = await packSignedPayloadEnvelope(source, options);
3512
+ const parsed = parsePayloadEnvelope(envelope);
3513
+ return encodePreparedBytes(envelope, {
3514
+ ...options,
3515
+ text: Boolean(options.text),
3516
+ extended: true,
3517
+ signed: true,
3518
+ sourcePayloadBytes: source.length,
3519
+ compressionMetadata: parsed.compression,
3520
+ signingKeyId: parsed.keyId,
3521
+ hasEmbeddedPublicKey: parsed.hasEmbeddedPublicKey
3522
+ });
3523
+ }
3524
+
3525
+ async function encodeSignedText(text, options = {}) {
3526
+ assert(typeof text === "string", "encodeSignedText expects a string.");
3527
+ return encodeSignedBytes(getTextEncoder().encode(text), { ...options, text: true });
3528
+ }
3529
+
3530
+
3069
3531
  /**
3070
3532
  * Encode text using the optional QuadQR Secure Payload v1 layer.
3071
3533
  * This API is async because Web Crypto performs AES-GCM and password KDF work asynchronously.
@@ -3077,32 +3539,75 @@ async function encodeSecureText(text, options = {}) {
3077
3539
 
3078
3540
  /** Encode arbitrary bytes using password or raw 256-bit key security. */
3079
3541
  async function encodeSecureBytes(input, options = {}) {
3080
- const sourcePayload = input instanceof Uint8Array ? input : new Uint8Array(input);
3542
+ const sourcePayload = asBytes(input);
3081
3543
  const security = options.security ?? {};
3082
- const encrypted = await encryptSecurePayload(sourcePayload, security);
3083
- return encodeBytes(encrypted.envelope, {
3544
+ let protectedPayload = sourcePayload;
3545
+ let extended = false;
3546
+ let signed = false;
3547
+ let compression = "none";
3548
+ let signingKeyId = null;
3549
+ let hasEmbeddedPublicKey = false;
3550
+
3551
+ if (options.signing) {
3552
+ protectedPayload = await packSignedPayloadEnvelope(sourcePayload, {
3553
+ ...options,
3554
+ ...options.signing,
3555
+ compression: options.compression ?? "auto"
3556
+ });
3557
+ const parsed = parsePayloadEnvelope(protectedPayload);
3558
+ extended = true;
3559
+ signed = true;
3560
+ compression = parsed.compression;
3561
+ signingKeyId = parsed.keyId;
3562
+ hasEmbeddedPublicKey = parsed.hasEmbeddedPublicKey;
3563
+ } else {
3564
+ const prepared = prepareOptionalCompressedPayload(sourcePayload, options.compression ?? "none");
3565
+ protectedPayload = prepared.payload;
3566
+ extended = prepared.extended;
3567
+ compression = prepared.compression;
3568
+ }
3569
+
3570
+ const encrypted = await encryptSecurePayload(protectedPayload, security);
3571
+ return encodePreparedBytes(encrypted.envelope, {
3084
3572
  ...options,
3085
3573
  text: Boolean(options.text),
3086
3574
  secure: true,
3575
+ extended,
3576
+ signed,
3087
3577
  sourcePayloadBytes: sourcePayload.length,
3578
+ compressionMetadata: compression,
3579
+ signingKeyId,
3580
+ hasEmbeddedPublicKey,
3088
3581
  securityMetadata: encrypted.metadata
3089
3582
  });
3090
3583
  }
3091
3584
 
3092
3585
  /**
3093
3586
  * Decrypt a result returned by decodeMatrix/scanImageData/scanFile.
3094
- * The encrypted envelope remains available as encryptedPayload.
3587
+ * Compression and signatures are automatically unwrapped after decryption.
3095
3588
  */
3096
3589
  async function decryptDecoded(result, security = {}) {
3097
3590
  assert(result?.secure && result?.payload, "decryptDecoded expects an encrypted QuadQR decode result.");
3098
3591
  const plaintext = await decryptSecurePayload(result.payload, security);
3099
3592
  const isText = (result.flags & TEXT_FLAG) !== 0;
3593
+ const extended = (result.flags & EXTENDED_PAYLOAD_FLAG) !== 0;
3594
+ const envelope = extended ? parsePayloadEnvelope(plaintext) : null;
3595
+ const payload = envelope ? envelope.payload : plaintext;
3100
3596
  return {
3101
3597
  ...result,
3102
3598
  encryptedPayload: result.payload,
3103
3599
  encryptedPayloadBytes: result.payload.length,
3104
- payload: plaintext,
3105
- text: isText ? getTextDecoder().decode(plaintext) : null,
3600
+ protectedPayload: envelope ? plaintext : undefined,
3601
+ payload,
3602
+ text: isText ? getTextDecoder().decode(payload) : null,
3603
+ compressed: envelope?.compressed ?? false,
3604
+ compression: envelope?.compression ?? "none",
3605
+ signed: envelope?.signed ?? result.signed ?? false,
3606
+ signatureVerified: envelope?.signatureVerified ?? result.signatureVerified,
3607
+ signatureTrusted: envelope?.signatureTrusted ?? result.signatureTrusted,
3608
+ signingKeyId: envelope?.keyId ?? result.signingKeyId ?? null,
3609
+ hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? result.hasEmbeddedPublicKey ?? false,
3610
+ signer: envelope?.signer ?? result.signer ?? null,
3106
3611
  decrypted: true,
3107
3612
  requiresDecryption: false,
3108
3613
  security: { ...result.security, decrypted: true }
@@ -3375,7 +3880,11 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
3375
3880
 
3376
3881
  const isText = (flags & TEXT_FLAG) !== 0;
3377
3882
  const secure = (flags & SECURE_FLAG) !== 0;
3883
+ const extendedFlag = (flags & EXTENDED_PAYLOAD_FLAG) !== 0;
3884
+ const signedFlag = (flags & SIGNED_FLAG) !== 0;
3378
3885
  const security = secure ? inspectSecureEnvelope(payload) : null;
3886
+ const envelope = extendedFlag && !secure ? parsePayloadEnvelope(payload) : null;
3887
+ const applicationPayload = envelope ? envelope.payload : payload;
3379
3888
  const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
3380
3889
  const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
3381
3890
  return {
@@ -3400,8 +3909,17 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
3400
3909
  correctedHeaderSymbols: headerDecoded.correctedSymbols,
3401
3910
  correctedBodySymbols: bodyDecoded.correctedSymbols,
3402
3911
  correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
3403
- payload,
3404
- text: isText && !secure ? getTextDecoder().decode(payload) : null,
3912
+ protectedPayload: envelope ? payload : undefined,
3913
+ payload: secure ? payload : applicationPayload,
3914
+ text: secure ? null : (isText ? getTextDecoder().decode(applicationPayload) : null),
3915
+ compressed: envelope?.compressed ?? false,
3916
+ compression: envelope?.compression ?? "none",
3917
+ signed: envelope?.signed ?? signedFlag,
3918
+ signatureVerified: envelope?.signatureVerified,
3919
+ signatureTrusted: envelope?.signatureTrusted,
3920
+ signingKeyId: envelope?.keyId ?? null,
3921
+ hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? false,
3922
+ signer: envelope?.signer ?? null,
3405
3923
  secure,
3406
3924
  encrypted: secure,
3407
3925
  decrypted: false,
@@ -3446,6 +3964,27 @@ function decodeMatrix(inputMatrix, options = {}) {
3446
3964
  throw new Error(`Unable to decode matrix. ${errors.join(" || ")}`);
3447
3965
  }
3448
3966
 
3967
+
3968
+ /** Decode a matrix and return only its application payload bytes. */
3969
+ function decodeUint8Array(inputMatrix, options = {}) {
3970
+ return decodeMatrix(inputMatrix, options).payload;
3971
+ }
3972
+
3973
+ /** Verify a signed decode result and return the result with verification state attached. */
3974
+ async function verifyDecodedSignature(result, options = {}) {
3975
+ assert(result?.signed, "Decode result is not signed.");
3976
+ const source = result.protectedPayload;
3977
+ assert(source, "Signed payload metadata is unavailable. Decrypt secure content first if necessary.");
3978
+ const verification = await verifyPayloadEnvelopeSignature(source, options);
3979
+ return {
3980
+ ...result,
3981
+ signatureVerified: verification.verified,
3982
+ signatureTrusted: verification.trusted,
3983
+ signingKeyId: verification.keyId ?? result.signingKeyId ?? null,
3984
+ signatureTrustSource: verification.trustSource
3985
+ };
3986
+ }
3987
+
3449
3988
  function hexToRgb(hex) {
3450
3989
  const clean = hex.replace("#", "");
3451
3990
  assert(clean.length === 6, `Invalid hex color ${hex}.`);
@@ -3460,6 +3999,18 @@ function resolvePalette(palette = {}) {
3460
3999
  return { ...DEFAULT_PALETTE, ...palette };
3461
4000
  }
3462
4001
 
4002
+ function normalizeRenderMode(mode = RENDER_MODES.SCREEN) {
4003
+ const value = String(mode).toLowerCase();
4004
+ assert(value === RENDER_MODES.SCREEN || value === RENDER_MODES.PRINT, "Render mode must be screen or print.");
4005
+ return value;
4006
+ }
4007
+
4008
+ function resolveRenderPalette(options = {}) {
4009
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4010
+ const base = mode === RENDER_MODES.PRINT ? PRINT_PALETTE : DEFAULT_PALETTE;
4011
+ return { ...base, ...(options.palette ?? {}) };
4012
+ }
4013
+
3463
4014
  function paletteRgb(palette = {}) {
3464
4015
  const p = resolvePalette(palette);
3465
4016
  return {
@@ -3624,18 +4175,347 @@ function fillImageRoundedRect(data, width, x, y, rectWidth, rectHeight, radius,
3624
4175
  }
3625
4176
  }
3626
4177
 
4178
+
4179
+ /** Estimate a conservative center-logo ratio from ECC, utilization and output mode. */
4180
+ function estimateSafeLogoSize(codeOrMatrix, options = {}) {
4181
+ const code = Array.isArray(codeOrMatrix) ? null : codeOrMatrix;
4182
+ const ecc = String(code?.eccLevel ?? options.ecc ?? DEFAULT_ECC_LEVEL).toUpperCase();
4183
+ const base = { L: 0.10, M: 0.13, Q: 0.16, H: 0.19 }[ecc] ?? 0.12;
4184
+ const utilization = Number.isFinite(code?.utilization) ? code.utilization : Number(options.utilization ?? 0.65);
4185
+ const utilizationPenalty = utilization <= 0.55 ? 1 : Math.max(0.72, 1 - (utilization - 0.55) * 0.56);
4186
+ const clearBackground = Boolean(options.clearBackground ?? options.logoClearBackground ?? options.logo?.clearBackground);
4187
+ const clearPenalty = clearBackground ? 0.90 : 1;
4188
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4189
+ const printPenalty = mode === RENDER_MODES.PRINT ? 0.88 : 1;
4190
+ const versionPenalty = code?.version === 1 ? 0.88 : 1;
4191
+ return Math.max(0.07, Math.min(0.22, base * utilizationPenalty * clearPenalty * printPenalty * versionPenalty));
4192
+ }
4193
+
4194
+ /**
4195
+ * Empirically search for the largest logo ratio that still decodes from the
4196
+ * dependency-free ImageData renderer. The logo source must therefore be ImageData-like.
4197
+ */
4198
+ function findMaxSafeLogoSize(code, options = {}) {
4199
+ assert(code?.matrix, "findMaxSafeLogoSize expects an encoded QuadQR object.");
4200
+ const logo = options.logo;
4201
+ const source = logo?.source ?? logo;
4202
+ assert(source?.data && source?.width && source?.height,
4203
+ "findMaxSafeLogoSize requires an ImageData-like logo source so it can test pixel output without DOM dependencies.");
4204
+ let low = Math.max(0.05, Number(options.minSize ?? 0.05));
4205
+ let high = Math.min(0.30, Number(options.maxSize ?? 0.28));
4206
+ let best = 0;
4207
+ const iterations = Math.max(3, Math.min(10, Math.floor(options.iterations ?? 7)));
4208
+ for (let i = 0; i < iterations; i++) {
4209
+ const probe = (low + high) / 2;
4210
+ try {
4211
+ const image = renderToImageData(code, {
4212
+ ...options,
4213
+ logo: {
4214
+ ...(typeof logo === "object" && Object.prototype.hasOwnProperty.call(logo, "source") ? logo : { source }),
4215
+ size: probe
4216
+ }
4217
+ });
4218
+ const decoded = scanImageData(image, { minVersion: code.version, maxVersion: code.version });
4219
+ if (decoded.crc32 !== code.crc32) throw new Error("Decoded a different payload.");
4220
+ best = probe;
4221
+ low = probe;
4222
+ } catch {
4223
+ high = probe;
4224
+ }
4225
+ }
4226
+ return {
4227
+ safeSize: best || estimateSafeLogoSize(code, options),
4228
+ testedMax: Number(options.maxSize ?? 0.28),
4229
+ iterations,
4230
+ empirical: best > 0
4231
+ };
4232
+ }
4233
+
4234
+ function getPrintGuidance(codeOrMatrix, options = {}) {
4235
+ const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix?.matrix;
4236
+ assert(matrix?.length, "getPrintGuidance expects a QuadQR code or matrix.");
4237
+ const quietZone = Math.max(4, Math.floor(options.quietZone ?? 4));
4238
+ const physicalSizeMm = Number(options.physicalSizeMm ?? 35);
4239
+ const totalModules = matrix.length + quietZone * 2;
4240
+ const moduleSizeMm = physicalSizeMm / totalModules;
4241
+ const dpi = Number(options.dpi ?? 300);
4242
+ const modulePixels = moduleSizeMm / 25.4 * dpi;
4243
+ const recommendedMinimumModuleMm = Number(options.minimumModuleMm ?? 0.40);
4244
+ return {
4245
+ mode: "print",
4246
+ physicalSizeMm,
4247
+ quietZone,
4248
+ moduleSizeMm,
4249
+ modulePixelsAtDpi: modulePixels,
4250
+ dpi,
4251
+ recommendedMinimumModuleMm,
4252
+ meetsRecommendedModuleSize: moduleSizeMm >= recommendedMinimumModuleMm,
4253
+ recommendedMinimumPhysicalSizeMm: totalModules * recommendedMinimumModuleMm,
4254
+ palette: PRINT_PALETTE,
4255
+ recommendations: [
4256
+ "Use classic rendering for print unless a styled output has been physically validated.",
4257
+ "Keep at least a 4-module quiet zone.",
4258
+ `Target at least ${recommendedMinimumModuleMm.toFixed(2)} mm per module for general-purpose printing.`,
4259
+ "Avoid glossy reflections and low-ink/toner modes when reliable color classification matters."
4260
+ ]
4261
+ };
4262
+ }
4263
+
4264
+ function resolveLogoRenderOptions(options, matrixSize, moduleSize, quietZone, palette, codeOrMatrix = null) {
4265
+ if (!options.logo) return null;
4266
+
4267
+ const nested = (
4268
+ options.logo &&
4269
+ typeof options.logo === "object" &&
4270
+ Object.prototype.hasOwnProperty.call(options.logo, "source")
4271
+ ) ? options.logo : null;
4272
+ const source = nested ? nested.source : options.logo;
4273
+ if (!source) return null;
4274
+
4275
+ const requestedSize = nested?.size ?? options.logoSize ?? 0.18;
4276
+ const autoSize = String(requestedSize).toLowerCase() === "auto";
4277
+ const rawSize = Number(requestedSize);
4278
+ const sizeRatio = autoSize
4279
+ ? estimateSafeLogoSize(codeOrMatrix, { ...options, clearBackground: nested?.clearBackground ?? options.logoClearBackground })
4280
+ : Math.max(0.05, Math.min(0.30, Number.isFinite(rawSize) ? rawSize : 0.18));
4281
+ const rawPadding = Number(nested?.padding ?? options.logoPadding ?? 0.65);
4282
+ const rawRadius = Number(nested?.radius ?? options.logoRadius ?? 0.8);
4283
+ const paddingModules = Math.max(0, Number.isFinite(rawPadding) ? rawPadding : 0.65);
4284
+ const radiusModules = Math.max(0, Number.isFinite(rawRadius) ? rawRadius : 0.8);
4285
+ const clearBackground = Boolean(nested?.clearBackground ?? options.logoClearBackground ?? false);
4286
+ const backgroundColor = nested?.backgroundColor ?? options.logoBackgroundColor ?? palette.white;
4287
+
4288
+ const codePixels = matrixSize * moduleSize;
4289
+ const canvasPixels = (matrixSize + quietZone * 2) * moduleSize;
4290
+ const logoPixels = Math.max(1, codePixels * sizeRatio);
4291
+ const center = canvasPixels / 2;
4292
+ const paddingPixels = paddingModules * moduleSize;
4293
+ const backgroundPixels = logoPixels + paddingPixels * 2;
4294
+
4295
+ return {
4296
+ source,
4297
+ sizeRatio,
4298
+ clearBackground,
4299
+ backgroundColor,
4300
+ logoX: center - logoPixels / 2,
4301
+ logoY: center - logoPixels / 2,
4302
+ logoSize: logoPixels,
4303
+ backgroundX: center - backgroundPixels / 2,
4304
+ backgroundY: center - backgroundPixels / 2,
4305
+ backgroundSize: backgroundPixels,
4306
+ backgroundRadius: radiusModules * moduleSize
4307
+ };
4308
+ }
4309
+
4310
+ function canvasLogoSource(source) {
4311
+ if (!source || typeof source === "string") return source;
4312
+ if (source.data && Number.isInteger(source.width) && Number.isInteger(source.height)) {
4313
+ let scratch = null;
4314
+ if (typeof OffscreenCanvas !== "undefined") {
4315
+ scratch = new OffscreenCanvas(source.width, source.height);
4316
+ } else if (typeof document !== "undefined") {
4317
+ scratch = document.createElement("canvas");
4318
+ scratch.width = source.width;
4319
+ scratch.height = source.height;
4320
+ }
4321
+ if (!scratch) return source;
4322
+ const ctx = scratch.getContext("2d");
4323
+ if (!ctx) return source;
4324
+ const pixels = source.data instanceof Uint8ClampedArray
4325
+ ? source.data
4326
+ : new Uint8ClampedArray(source.data);
4327
+ ctx.putImageData(new ImageData(pixels, source.width, source.height), 0, 0);
4328
+ return scratch;
4329
+ }
4330
+ return source;
4331
+ }
4332
+
4333
+ function logoSourceDimensions(source) {
4334
+ const width = source?.naturalWidth ?? source?.videoWidth ?? source?.width;
4335
+ const height = source?.naturalHeight ?? source?.videoHeight ?? source?.height;
4336
+ if (!(Number(width) > 0 && Number(height) > 0)) return null;
4337
+ return { width: Number(width), height: Number(height) };
4338
+ }
4339
+
4340
+ function containRect(x, y, size, dimensions) {
4341
+ if (!dimensions) return { x, y, width: size, height: size };
4342
+ const scale = Math.min(size / dimensions.width, size / dimensions.height);
4343
+ const width = dimensions.width * scale;
4344
+ const height = dimensions.height * scale;
4345
+ return {
4346
+ x: x + (size - width) / 2,
4347
+ y: y + (size - height) / 2,
4348
+ width,
4349
+ height
4350
+ };
4351
+ }
4352
+
4353
+ function drawLogoToCanvas(ctx, logo, palette) {
4354
+ if (!logo) return;
4355
+ if (logo.clearBackground) {
4356
+ ctx.fillStyle = logo.backgroundColor || palette.white;
4357
+ roundedRectPath(
4358
+ ctx,
4359
+ logo.backgroundX,
4360
+ logo.backgroundY,
4361
+ logo.backgroundSize,
4362
+ logo.backgroundSize,
4363
+ logo.backgroundRadius
4364
+ );
4365
+ ctx.fill();
4366
+ }
4367
+
4368
+ const source = canvasLogoSource(logo.source);
4369
+ assert(
4370
+ source && typeof source !== "string",
4371
+ "renderToCanvas() logo must be a loaded CanvasImageSource or ImageData-like object. Load URL/data-URL logos into an Image first."
4372
+ );
4373
+ const rect = containRect(logo.logoX, logo.logoY, logo.logoSize, logoSourceDimensions(source));
4374
+ const previousSmoothing = ctx.imageSmoothingEnabled;
4375
+ ctx.imageSmoothingEnabled = true;
4376
+ ctx.drawImage(source, rect.x, rect.y, rect.width, rect.height);
4377
+ ctx.imageSmoothingEnabled = previousSmoothing;
4378
+ }
4379
+
4380
+ function blendImagePixel(data, width, x, y, rgb, alpha) {
4381
+ if (x < 0 || y < 0 || x >= width) return;
4382
+ const height = data.length / 4 / width;
4383
+ if (y >= height) return;
4384
+ const p = (y * width + x) * 4;
4385
+ const a = Math.max(0, Math.min(1, alpha));
4386
+ data[p] = Math.round(data[p] * (1 - a) + rgb.r * a);
4387
+ data[p + 1] = Math.round(data[p + 1] * (1 - a) + rgb.g * a);
4388
+ data[p + 2] = Math.round(data[p + 2] * (1 - a) + rgb.b * a);
4389
+ data[p + 3] = 255;
4390
+ }
4391
+
4392
+ function drawLogoToImageData(data, width, logo) {
4393
+ if (!logo) return;
4394
+ if (logo.clearBackground) {
4395
+ const rgb = hexToRgb(logo.backgroundColor);
4396
+ fillImageRoundedRect(
4397
+ data,
4398
+ width,
4399
+ Math.round(logo.backgroundX),
4400
+ Math.round(logo.backgroundY),
4401
+ Math.round(logo.backgroundSize),
4402
+ Math.round(logo.backgroundSize),
4403
+ Math.round(logo.backgroundRadius),
4404
+ rgb
4405
+ );
4406
+ }
4407
+
4408
+ const source = logo.source;
4409
+ assert(
4410
+ source?.data && Number.isInteger(source.width) && Number.isInteger(source.height),
4411
+ "renderToImageData() logo must be an ImageData-like object with width, height, and RGBA data."
4412
+ );
4413
+ const dest = containRect(logo.logoX, logo.logoY, logo.logoSize, {
4414
+ width: source.width,
4415
+ height: source.height
4416
+ });
4417
+ const x0 = Math.round(dest.x);
4418
+ const y0 = Math.round(dest.y);
4419
+ const destWidth = Math.max(1, Math.round(dest.width));
4420
+ const destHeight = Math.max(1, Math.round(dest.height));
4421
+ const src = source.data;
4422
+
4423
+ for (let y = 0; y < destHeight; y++) {
4424
+ const sy = Math.min(source.height - 1, Math.floor((y + 0.5) * source.height / destHeight));
4425
+ for (let x = 0; x < destWidth; x++) {
4426
+ const sx = Math.min(source.width - 1, Math.floor((x + 0.5) * source.width / destWidth));
4427
+ const p = (sy * source.width + sx) * 4;
4428
+ const alpha = (src[p + 3] ?? 255) / 255;
4429
+ if (alpha <= 0) continue;
4430
+ blendImagePixel(data, width, x0 + x, y0 + y, {
4431
+ r: src[p],
4432
+ g: src[p + 1],
4433
+ b: src[p + 2]
4434
+ }, alpha);
4435
+ }
4436
+ }
4437
+ }
4438
+
4439
+ function escapeXmlAttribute(value) {
4440
+ return String(value)
4441
+ .replaceAll("&", "&amp;")
4442
+ .replaceAll('"', "&quot;")
4443
+ .replaceAll("<", "&lt;")
4444
+ .replaceAll(">", "&gt;");
4445
+ }
4446
+
4447
+ function svgRect(x, y, width, height, fill, radius = 0) {
4448
+ const rounded = radius > 0 ? ` rx="${radius}" ry="${radius}"` : "";
4449
+ return `<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="${escapeXmlAttribute(fill)}"${rounded}/>`;
4450
+ }
4451
+
4452
+ function svgLogoHref(source) {
4453
+ if (typeof source === "string") return source;
4454
+ if (typeof source?.src === "string" && source.src) return source.src;
4455
+ return null;
4456
+ }
4457
+
4458
+ function resolveRenderSizing(options, matrixSize) {
4459
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4460
+ const requestedQuietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
4461
+ const quietZone = mode === RENDER_MODES.PRINT && options.allowUnsafePrintQuietZone !== true
4462
+ ? Math.max(4, requestedQuietZone)
4463
+ : requestedQuietZone;
4464
+ const totalModules = matrixSize + quietZone * 2;
4465
+ const hasImageSize = options.imageSize !== undefined && options.imageSize !== null;
4466
+ const hasModuleSize = options.moduleSize !== undefined && options.moduleSize !== null;
4467
+
4468
+ let pixelSize;
4469
+ if (hasImageSize) {
4470
+ const requested = Number(options.imageSize);
4471
+ assert(Number.isFinite(requested) && requested > 0, "imageSize must be a positive number of pixels.");
4472
+ pixelSize = Math.max(totalModules, Math.round(requested));
4473
+ } else if (hasModuleSize) {
4474
+ const requested = Number(options.moduleSize);
4475
+ assert(Number.isFinite(requested) && requested > 0, "moduleSize must be a positive number of pixels.");
4476
+ const moduleSize = Math.max(1, Math.floor(requested));
4477
+ pixelSize = totalModules * moduleSize;
4478
+ } else {
4479
+ // A practical export default that is large enough for screens/social use
4480
+ // while keeping the API independent from the encoded matrix version.
4481
+ pixelSize = Math.max(totalModules, 720);
4482
+ }
4483
+
4484
+ return {
4485
+ quietZone,
4486
+ totalModules,
4487
+ pixelSize,
4488
+ moduleSize: pixelSize / totalModules,
4489
+ mode
4490
+ };
4491
+ }
4492
+
4493
+ function rasterModuleRect(row, col, quietZone, moduleSize) {
4494
+ const x0 = Math.round((col + quietZone) * moduleSize);
4495
+ const y0 = Math.round((row + quietZone) * moduleSize);
4496
+ const x1 = Math.round((col + quietZone + 1) * moduleSize);
4497
+ const y1 = Math.round((row + quietZone + 1) * moduleSize);
4498
+ return {
4499
+ x: x0,
4500
+ y: y0,
4501
+ width: Math.max(1, x1 - x0),
4502
+ height: Math.max(1, y1 - y0)
4503
+ };
4504
+ }
4505
+
3627
4506
  function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3628
4507
  assert(canvas && typeof canvas.getContext === "function", "A canvas element is required.");
3629
4508
  const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
3630
4509
  assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
3631
- const moduleSize = Math.max(1, Math.floor(options.moduleSize ?? 12));
3632
- const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
3633
- const palette = resolvePalette(options.palette);
3634
- const paletteValues = paletteRgb(options.palette);
3635
- const style = normalizeRenderStyle(options.style);
4510
+ const sizing = resolveRenderSizing(options, matrix.length);
4511
+ const { moduleSize, quietZone, pixelSize } = sizing;
4512
+ const palette = resolveRenderPalette(options);
4513
+ const paletteValues = paletteRgb(palette);
4514
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4515
+ const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
3636
4516
  const layout = renderLayoutForMatrix(matrix);
3637
4517
  const size = matrix.length;
3638
- const pixelSize = (size + quietZone * 2) * moduleSize;
4518
+ const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
3639
4519
 
3640
4520
  canvas.width = pixelSize;
3641
4521
  canvas.height = pixelSize;
@@ -3648,12 +4528,13 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3648
4528
  for (let c = 0; c < size; c++) {
3649
4529
  const cell = matrix[r][c];
3650
4530
  const structural = isStructuralRenderCell(layout, r, c, cell);
3651
- const x = (c + quietZone) * moduleSize;
3652
- const y = (r + quietZone) * moduleSize;
4531
+ const rect = rasterModuleRect(r, c, quietZone, moduleSize);
4532
+ const { x, y, width: cellWidth, height: cellHeight } = rect;
4533
+ const cellSize = Math.min(cellWidth, cellHeight);
3653
4534
 
3654
4535
  if (style === RENDER_STYLES.CLASSIC || structural) {
3655
4536
  ctx.fillStyle = cellColor(cell, palette);
3656
- ctx.fillRect(x, y, moduleSize, moduleSize);
4537
+ ctx.fillRect(x, y, cellWidth, cellHeight);
3657
4538
  continue;
3658
4539
  }
3659
4540
 
@@ -3663,28 +4544,29 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3663
4544
  const opacity = depthOpacity(r, c, cell);
3664
4545
  const base = mixRgb(paletteValues.white, rgb, opacity);
3665
4546
  ctx.fillStyle = rgbCss(base);
3666
- ctx.fillRect(x, y, moduleSize, moduleSize);
4547
+ ctx.fillRect(x, y, cellWidth, cellHeight);
3667
4548
 
3668
- if (moduleSize >= 6) {
3669
- const edge = Math.max(1, Math.floor(moduleSize * 0.08));
4549
+ if (cellSize >= 6) {
4550
+ const edge = Math.max(1, Math.floor(cellSize * 0.08));
3670
4551
  const highlight = mixRgb(base, paletteValues.white, 0.17);
3671
4552
  const shadow = mixRgb(base, paletteValues.black, 0.12);
3672
4553
  ctx.fillStyle = rgbCss(highlight);
3673
- ctx.fillRect(x, y, moduleSize, edge);
3674
- ctx.fillRect(x, y, edge, moduleSize);
4554
+ ctx.fillRect(x, y, cellWidth, edge);
4555
+ ctx.fillRect(x, y, edge, cellHeight);
3675
4556
  ctx.fillStyle = rgbCss(shadow);
3676
- ctx.fillRect(x, y + moduleSize - edge, moduleSize, edge);
3677
- ctx.fillRect(x + moduleSize - edge, y, edge, moduleSize);
4557
+ ctx.fillRect(x, y + cellHeight - edge, cellWidth, edge);
4558
+ ctx.fillRect(x + cellWidth - edge, y, edge, cellHeight);
3678
4559
  }
3679
4560
  continue;
3680
4561
  }
3681
4562
 
3682
4563
  if (style === RENDER_STYLES.SOFT) {
3683
- const inset = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
3684
- const width = moduleSize - inset * 2;
3685
- const radius = Math.max(1, Math.floor(moduleSize * 0.20));
4564
+ const inset = cellSize >= 6 ? Math.max(1, Math.floor(cellSize * 0.07)) : 0;
4565
+ const width = cellWidth - inset * 2;
4566
+ const height = cellHeight - inset * 2;
4567
+ const radius = Math.max(1, Math.floor(cellSize * 0.20));
3686
4568
  ctx.fillStyle = cellColor(cell, palette);
3687
- roundedRectPath(ctx, x + inset, y + inset, width, width, radius);
4569
+ roundedRectPath(ctx, x + inset, y + inset, width, height, radius);
3688
4570
  ctx.fill();
3689
4571
  continue;
3690
4572
  }
@@ -3693,37 +4575,40 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3693
4575
  // Keep the exact encoded color across the full module, then limit the
3694
4576
  // recessed effect to a narrow edge band so center sampling stays intact.
3695
4577
  ctx.fillStyle = cellColor(cell, palette);
3696
- ctx.fillRect(x, y, moduleSize, moduleSize);
4578
+ ctx.fillRect(x, y, cellWidth, cellHeight);
3697
4579
  if (cell === CELL.WHITE) continue;
3698
4580
 
3699
- const edge = safeStyleEdge(moduleSize);
4581
+ const edge = safeStyleEdge(cellSize);
3700
4582
  if (edge > 0) {
3701
4583
  const fx = insetStyleColors(rgb, paletteValues);
3702
4584
  ctx.fillStyle = rgbCss(fx.shadow);
3703
- ctx.fillRect(x, y, moduleSize, edge);
3704
- ctx.fillRect(x, y, edge, moduleSize);
4585
+ ctx.fillRect(x, y, cellWidth, edge);
4586
+ ctx.fillRect(x, y, edge, cellHeight);
3705
4587
  ctx.fillStyle = rgbCss(fx.highlight);
3706
- ctx.fillRect(x, y + moduleSize - edge, moduleSize, edge);
3707
- ctx.fillRect(x + moduleSize - edge, y, edge, moduleSize);
4588
+ ctx.fillRect(x, y + cellHeight - edge, cellWidth, edge);
4589
+ ctx.fillRect(x + cellWidth - edge, y, edge, cellHeight);
3708
4590
  }
3709
4591
  }
3710
4592
  }
3711
4593
  }
4594
+ drawLogoToCanvas(ctx, logo, palette);
3712
4595
  return canvas;
3713
4596
  }
3714
4597
 
3715
4598
  function renderToImageData(codeOrMatrix, options = {}) {
3716
4599
  const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
3717
4600
  assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
3718
- const moduleSize = Math.max(1, Math.floor(options.moduleSize ?? 8));
3719
- const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
3720
- const palette = paletteRgb(options.palette);
3721
- const style = normalizeRenderStyle(options.style);
4601
+ const sizing = resolveRenderSizing(options, matrix.length);
4602
+ const { moduleSize, quietZone, pixelSize } = sizing;
4603
+ const resolvedPalette = resolveRenderPalette(options);
4604
+ const palette = paletteRgb(resolvedPalette);
4605
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4606
+ const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
3722
4607
  const layout = renderLayoutForMatrix(matrix);
3723
4608
  const size = matrix.length;
3724
- const pixelSize = (size + quietZone * 2) * moduleSize;
3725
4609
  const data = new Uint8ClampedArray(pixelSize * pixelSize * 4);
3726
4610
  const white = palette.white;
4611
+ const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, resolvedPalette, codeOrMatrix);
3727
4612
 
3728
4613
  for (let i = 0; i < pixelSize * pixelSize; i++) {
3729
4614
  const p = i * 4;
@@ -3738,26 +4623,105 @@ function renderToImageData(codeOrMatrix, options = {}) {
3738
4623
  const cell = matrix[r][c];
3739
4624
  const rgb = cellRgb(cell, palette);
3740
4625
  const structural = isStructuralRenderCell(layout, r, c, cell);
3741
- const y0 = (r + quietZone) * moduleSize;
3742
- const x0 = (c + quietZone) * moduleSize;
4626
+ const rect = rasterModuleRect(r, c, quietZone, moduleSize);
4627
+ const x0 = rect.x;
4628
+ const y0 = rect.y;
4629
+ const cellWidth = rect.width;
4630
+ const cellHeight = rect.height;
4631
+ const cellSize = Math.min(cellWidth, cellHeight);
3743
4632
 
3744
4633
  if (style === RENDER_STYLES.CLASSIC || structural) {
3745
- fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, rgb);
4634
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
3746
4635
  continue;
3747
4636
  }
3748
4637
 
3749
4638
  if (style === RENDER_STYLES.DEPTH) {
3750
4639
  const opacity = depthOpacity(r, c, cell);
3751
4640
  const base = mixRgb(white, rgb, opacity);
3752
- fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, base);
3753
- if (moduleSize >= 6) {
3754
- const edge = Math.max(1, Math.floor(moduleSize * 0.08));
4641
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, base);
4642
+ if (cellSize >= 6) {
4643
+ const edge = Math.max(1, Math.floor(cellSize * 0.08));
3755
4644
  const highlight = mixRgb(base, white, 0.17);
3756
4645
  const shadow = mixRgb(base, palette.black, 0.12);
3757
- fillImageRect(data, pixelSize, x0, y0, moduleSize, edge, highlight);
3758
- fillImageRect(data, pixelSize, x0, y0, edge, moduleSize, highlight);
3759
- fillImageRect(data, pixelSize, x0, y0 + moduleSize - edge, moduleSize, edge, shadow);
3760
- fillImageRect(data, pixelSize, x0 + moduleSize - edge, y0, edge, moduleSize, shadow);
4646
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, edge, highlight);
4647
+ fillImageRect(data, pixelSize, x0, y0, edge, cellHeight, highlight);
4648
+ fillImageRect(data, pixelSize, x0, y0 + cellHeight - edge, cellWidth, edge, shadow);
4649
+ fillImageRect(data, pixelSize, x0 + cellWidth - edge, y0, edge, cellHeight, shadow);
4650
+ }
4651
+ continue;
4652
+ }
4653
+
4654
+ if (style === RENDER_STYLES.SOFT) {
4655
+ const inset = cellSize >= 6 ? Math.max(1, Math.floor(cellSize * 0.07)) : 0;
4656
+ const width = cellWidth - inset * 2;
4657
+ const height = cellHeight - inset * 2;
4658
+ const radius = Math.max(1, Math.floor(cellSize * 0.20));
4659
+ fillImageRoundedRect(data, pixelSize, x0 + inset, y0 + inset, width, height, radius, rgb);
4660
+ continue;
4661
+ }
4662
+
4663
+ if (style === RENDER_STYLES.INSET) {
4664
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
4665
+ if (cell === CELL.WHITE) continue;
4666
+ const edge = safeStyleEdge(cellSize);
4667
+ if (edge > 0) {
4668
+ const fx = insetStyleColors(rgb, palette);
4669
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, edge, fx.shadow);
4670
+ fillImageRect(data, pixelSize, x0, y0, edge, cellHeight, fx.shadow);
4671
+ fillImageRect(data, pixelSize, x0, y0 + cellHeight - edge, cellWidth, edge, fx.highlight);
4672
+ fillImageRect(data, pixelSize, x0 + cellWidth - edge, y0, edge, cellHeight, fx.highlight);
4673
+ }
4674
+ }
4675
+ }
4676
+ }
4677
+
4678
+ drawLogoToImageData(data, pixelSize, logo);
4679
+
4680
+ return { width: pixelSize, height: pixelSize, data };
4681
+ }
4682
+
4683
+ /** Render a QuadQR code/matrix to a standalone SVG string. */
4684
+ function renderToSVG(codeOrMatrix, options = {}) {
4685
+ const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
4686
+ assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
4687
+ const sizing = resolveRenderSizing(options, matrix.length);
4688
+ const { moduleSize, quietZone, pixelSize } = sizing;
4689
+ const palette = resolveRenderPalette(options);
4690
+ const paletteValues = paletteRgb(palette);
4691
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4692
+ const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
4693
+ const layout = renderLayoutForMatrix(matrix);
4694
+ const size = matrix.length;
4695
+ const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
4696
+ const body = [svgRect(0, 0, pixelSize, pixelSize, palette.white)];
4697
+
4698
+ for (let r = 0; r < size; r++) {
4699
+ for (let c = 0; c < size; c++) {
4700
+ const cell = matrix[r][c];
4701
+ const structural = isStructuralRenderCell(layout, r, c, cell);
4702
+ const x = (c + quietZone) * moduleSize;
4703
+ const y = (r + quietZone) * moduleSize;
4704
+ const fill = cellColor(cell, palette);
4705
+
4706
+ if (style === RENDER_STYLES.CLASSIC || structural) {
4707
+ body.push(svgRect(x, y, moduleSize, moduleSize, fill));
4708
+ continue;
4709
+ }
4710
+
4711
+ const rgb = cellRgb(cell, paletteValues);
4712
+
4713
+ if (style === RENDER_STYLES.DEPTH) {
4714
+ const opacity = depthOpacity(r, c, cell);
4715
+ const base = mixRgb(paletteValues.white, rgb, opacity);
4716
+ body.push(svgRect(x, y, moduleSize, moduleSize, rgbCss(base)));
4717
+ if (moduleSize >= 6) {
4718
+ const edge = Math.max(1, Math.floor(moduleSize * 0.08));
4719
+ const highlight = rgbCss(mixRgb(base, paletteValues.white, 0.17));
4720
+ const shadow = rgbCss(mixRgb(base, paletteValues.black, 0.12));
4721
+ body.push(svgRect(x, y, moduleSize, edge, highlight));
4722
+ body.push(svgRect(x, y, edge, moduleSize, highlight));
4723
+ body.push(svgRect(x, y + moduleSize - edge, moduleSize, edge, shadow));
4724
+ body.push(svgRect(x + moduleSize - edge, y, edge, moduleSize, shadow));
3761
4725
  }
3762
4726
  continue;
3763
4727
  }
@@ -3766,26 +4730,51 @@ function renderToImageData(codeOrMatrix, options = {}) {
3766
4730
  const inset = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
3767
4731
  const width = moduleSize - inset * 2;
3768
4732
  const radius = Math.max(1, Math.floor(moduleSize * 0.20));
3769
- fillImageRoundedRect(data, pixelSize, x0 + inset, y0 + inset, width, width, radius, rgb);
4733
+ body.push(svgRect(x + inset, y + inset, width, width, fill, radius));
3770
4734
  continue;
3771
4735
  }
3772
4736
 
3773
4737
  if (style === RENDER_STYLES.INSET) {
3774
- fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, rgb);
4738
+ body.push(svgRect(x, y, moduleSize, moduleSize, fill));
3775
4739
  if (cell === CELL.WHITE) continue;
3776
4740
  const edge = safeStyleEdge(moduleSize);
3777
4741
  if (edge > 0) {
3778
- const fx = insetStyleColors(rgb, palette);
3779
- fillImageRect(data, pixelSize, x0, y0, moduleSize, edge, fx.shadow);
3780
- fillImageRect(data, pixelSize, x0, y0, edge, moduleSize, fx.shadow);
3781
- fillImageRect(data, pixelSize, x0, y0 + moduleSize - edge, moduleSize, edge, fx.highlight);
3782
- fillImageRect(data, pixelSize, x0 + moduleSize - edge, y0, edge, moduleSize, fx.highlight);
4742
+ const fx = insetStyleColors(rgb, paletteValues);
4743
+ const shadow = rgbCss(fx.shadow);
4744
+ const highlight = rgbCss(fx.highlight);
4745
+ body.push(svgRect(x, y, moduleSize, edge, shadow));
4746
+ body.push(svgRect(x, y, edge, moduleSize, shadow));
4747
+ body.push(svgRect(x, y + moduleSize - edge, moduleSize, edge, highlight));
4748
+ body.push(svgRect(x + moduleSize - edge, y, edge, moduleSize, highlight));
3783
4749
  }
3784
4750
  }
3785
4751
  }
3786
4752
  }
3787
4753
 
3788
- return { width: pixelSize, height: pixelSize, data };
4754
+ if (logo) {
4755
+ if (logo.clearBackground) {
4756
+ body.push(svgRect(
4757
+ logo.backgroundX,
4758
+ logo.backgroundY,
4759
+ logo.backgroundSize,
4760
+ logo.backgroundSize,
4761
+ logo.backgroundColor,
4762
+ logo.backgroundRadius
4763
+ ));
4764
+ }
4765
+ const href = svgLogoHref(logo.source);
4766
+ assert(href, "renderToSVG() logo must be a URL/data URL string or an object with a src string.");
4767
+ body.push(
4768
+ `<image href="${escapeXmlAttribute(href)}" x="${logo.logoX}" y="${logo.logoY}" width="${logo.logoSize}" height="${logo.logoSize}" preserveAspectRatio="xMidYMid meet"/>`
4769
+ );
4770
+ }
4771
+
4772
+ return [
4773
+ '<?xml version="1.0" encoding="UTF-8"?>',
4774
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${pixelSize}" height="${pixelSize}" viewBox="0 0 ${pixelSize} ${pixelSize}" shape-rendering="crispEdges">`,
4775
+ ...body,
4776
+ "</svg>"
4777
+ ].join("\n");
3789
4778
  }
3790
4779
 
3791
4780
  function clampNumber(value, min, max) {
@@ -4410,6 +5399,366 @@ function tryAxisAlignedScan(imageData, options) {
4410
5399
  return candidates[0] ?? null;
4411
5400
  }
4412
5401
 
5402
+
5403
+
5404
+ const STRESS_PROFILES = Object.freeze([
5405
+ Object.freeze({ id: "clean", label: "Clean", type: "clean", severity: 0, weight: 2 }),
5406
+ Object.freeze({ id: "blur", label: "Blur", type: "blur", severity: 0.42, weight: 1 }),
5407
+ Object.freeze({ id: "dark", label: "Low brightness", type: "brightness-low", severity: 0.48, weight: 1 }),
5408
+ Object.freeze({ id: "bright", label: "High exposure", type: "brightness-high", severity: 0.34, weight: 1 }),
5409
+ Object.freeze({ id: "shadow", label: "Uneven shadow", type: "shadow", severity: 0.55, weight: 1.2 }),
5410
+ Object.freeze({ id: "contrast", label: "Contrast loss", type: "contrast-loss", severity: 0.42, weight: 1 }),
5411
+ Object.freeze({ id: "perspective", label: "Perspective", type: "perspective", severity: 0.42, weight: 1.3 }),
5412
+ Object.freeze({ id: "jpeg", label: "JPEG-like compression", type: "jpeg", severity: 0.50, weight: 0.8 }),
5413
+ Object.freeze({ id: "downscale", label: "Downscale", type: "downscale", severity: 0.52, weight: 0.9 })
5414
+ ]);
5415
+
5416
+ function cloneImageDataLike(imageData) {
5417
+ return { width: imageData.width, height: imageData.height, data: new Uint8ClampedArray(imageData.data) };
5418
+ }
5419
+
5420
+ function sampleRgbaBilinear(imageData, x, y) {
5421
+ const { width, height, data } = imageData;
5422
+ const cx = clampNumber(x, 0, width - 1);
5423
+ const cy = clampNumber(y, 0, height - 1);
5424
+ const x0 = Math.floor(cx), y0 = Math.floor(cy);
5425
+ const x1 = Math.min(width - 1, x0 + 1), y1 = Math.min(height - 1, y0 + 1);
5426
+ const tx = cx - x0, ty = cy - y0;
5427
+ const get = (xx, yy, channel) => data[(yy * width + xx) * 4 + channel];
5428
+ const out = [0, 0, 0, 255];
5429
+ for (let ch = 0; ch < 4; ch++) {
5430
+ const a = get(x0, y0, ch) * (1 - tx) + get(x1, y0, ch) * tx;
5431
+ const b = get(x0, y1, ch) * (1 - tx) + get(x1, y1, ch) * tx;
5432
+ out[ch] = a * (1 - ty) + b * ty;
5433
+ }
5434
+ return out;
5435
+ }
5436
+
5437
+ function resizeImageDataLike(imageData, width, height) {
5438
+ width = Math.max(1, Math.round(width));
5439
+ height = Math.max(1, Math.round(height));
5440
+ const out = new Uint8ClampedArray(width * height * 4);
5441
+ for (let y = 0; y < height; y++) {
5442
+ for (let x = 0; x < width; x++) {
5443
+ const sx = (x + 0.5) * imageData.width / width - 0.5;
5444
+ const sy = (y + 0.5) * imageData.height / height - 0.5;
5445
+ const rgba = sampleRgbaBilinear(imageData, sx, sy);
5446
+ const p = (y * width + x) * 4;
5447
+ out[p] = rgba[0]; out[p + 1] = rgba[1]; out[p + 2] = rgba[2]; out[p + 3] = rgba[3];
5448
+ }
5449
+ }
5450
+ return { width, height, data: out };
5451
+ }
5452
+
5453
+ function boxBlurImage(imageData, radius) {
5454
+ radius = Math.max(1, Math.round(radius));
5455
+ const { width, height, data } = imageData;
5456
+ const horizontal = new Float32Array(data.length);
5457
+ const out = new Uint8ClampedArray(data.length);
5458
+ for (let y = 0; y < height; y++) {
5459
+ for (let x = 0; x < width; x++) {
5460
+ for (let ch = 0; ch < 3; ch++) {
5461
+ let sum = 0, count = 0;
5462
+ for (let dx = -radius; dx <= radius; dx++) {
5463
+ const xx = x + dx;
5464
+ if (xx < 0 || xx >= width) continue;
5465
+ sum += data[(y * width + xx) * 4 + ch]; count++;
5466
+ }
5467
+ horizontal[(y * width + x) * 4 + ch] = sum / count;
5468
+ }
5469
+ horizontal[(y * width + x) * 4 + 3] = 255;
5470
+ }
5471
+ }
5472
+ for (let y = 0; y < height; y++) {
5473
+ for (let x = 0; x < width; x++) {
5474
+ const p = (y * width + x) * 4;
5475
+ for (let ch = 0; ch < 3; ch++) {
5476
+ let sum = 0, count = 0;
5477
+ for (let dy = -radius; dy <= radius; dy++) {
5478
+ const yy = y + dy;
5479
+ if (yy < 0 || yy >= height) continue;
5480
+ sum += horizontal[(yy * width + x) * 4 + ch]; count++;
5481
+ }
5482
+ out[p + ch] = sum / count;
5483
+ }
5484
+ out[p + 3] = 255;
5485
+ }
5486
+ }
5487
+ return { width, height, data: out };
5488
+ }
5489
+
5490
+ function perspectiveStress(imageData, severity) {
5491
+ const { width, height } = imageData;
5492
+ const inset = Math.min(width, height) * (0.025 + 0.11 * severity);
5493
+ const source = [{ x: 0, y: 0 }, { x: width - 1, y: 0 }, { x: width - 1, y: height - 1 }, { x: 0, y: height - 1 }];
5494
+ const destination = [
5495
+ { x: inset * 1.15, y: inset * 0.25 },
5496
+ { x: width - 1 - inset * 0.25, y: inset },
5497
+ { x: width - 1 - inset * 1.0, y: height - 1 - inset * 0.35 },
5498
+ { x: inset * 0.20, y: height - 1 - inset * 0.95 }
5499
+ ];
5500
+ const inverse = computeHomography(destination, source);
5501
+ const out = new Uint8ClampedArray(width * height * 4);
5502
+ out.fill(255);
5503
+ for (let y = 0; y < height; y++) {
5504
+ for (let x = 0; x < width; x++) {
5505
+ const src = projectPoint(inverse, x, y);
5506
+ if (src.x < 0 || src.y < 0 || src.x >= width || src.y >= height) continue;
5507
+ const rgba = sampleRgbaBilinear(imageData, src.x, src.y);
5508
+ const p = (y * width + x) * 4;
5509
+ out[p] = rgba[0]; out[p + 1] = rgba[1]; out[p + 2] = rgba[2]; out[p + 3] = 255;
5510
+ }
5511
+ }
5512
+ return { width, height, data: out };
5513
+ }
5514
+
5515
+ /** Apply one deterministic camera/print-style distortion to ImageData. */
5516
+ function applyStressDistortion(imageData, type, severity = 0.5, options = {}) {
5517
+ assert(imageData?.data && imageData.width && imageData.height, "applyStressDistortion requires ImageData-like input.");
5518
+ const s = clampNumber(Number(severity), 0, 1);
5519
+ if (type === "clean") return cloneImageDataLike(imageData);
5520
+ if (type === "blur") return boxBlurImage(imageData, 1 + s * 2.2);
5521
+ if (type === "perspective") return perspectiveStress(imageData, s);
5522
+ if (type === "downscale") {
5523
+ const scale = Math.max(0.20, 1 - s * 0.72);
5524
+ const small = resizeImageDataLike(imageData, imageData.width * scale, imageData.height * scale);
5525
+ return resizeImageDataLike(small, imageData.width, imageData.height);
5526
+ }
5527
+
5528
+ const out = cloneImageDataLike(imageData);
5529
+ const { width, height, data } = out;
5530
+ const quantStep = 4 + Math.round(s * 30);
5531
+ for (let y = 0; y < height; y++) {
5532
+ for (let x = 0; x < width; x++) {
5533
+ const p = (y * width + x) * 4;
5534
+ for (let ch = 0; ch < 3; ch++) {
5535
+ let value = data[p + ch];
5536
+ if (type === "brightness-low") value *= 1 - s * 0.52;
5537
+ else if (type === "brightness-high") value = value + (255 - value) * s * 0.48;
5538
+ else if (type === "contrast-loss") value = 128 + (value - 128) * (1 - s * 0.48);
5539
+ else if (type === "shadow") {
5540
+ const nx = x / Math.max(1, width - 1);
5541
+ const ny = y / Math.max(1, height - 1);
5542
+ const shadow = clampNumber((nx * 0.65 + ny * 0.35 - 0.18) / 0.82, 0, 1);
5543
+ value *= 1 - shadow * s * 0.62;
5544
+ } else if (type === "jpeg") {
5545
+ value = Math.round(value / quantStep) * quantStep;
5546
+ const block = ((Math.floor(x / 8) + Math.floor(y / 8)) & 1) ? 1 : -1;
5547
+ value += block * s * 3;
5548
+ } else if (type === "warm") {
5549
+ if (ch === 0) value *= 1 + s * 0.12;
5550
+ if (ch === 2) value *= 1 - s * 0.22;
5551
+ }
5552
+ data[p + ch] = clampNumber(Math.round(value), 0, 255);
5553
+ }
5554
+ data[p + 3] = 255;
5555
+ }
5556
+ }
5557
+ return out;
5558
+ }
5559
+
5560
+ function runImageStressTest(imageData, expected = {}, options = {}) {
5561
+ const profiles = options.profiles ?? STRESS_PROFILES;
5562
+ const results = [];
5563
+ let passedWeight = 0;
5564
+ let totalWeight = 0;
5565
+ for (const profile of profiles) {
5566
+ const weight = Number(profile.weight ?? 1);
5567
+ totalWeight += weight;
5568
+ const distorted = applyStressDistortion(imageData, profile.type ?? profile.id, profile.severity ?? 0.5, profile);
5569
+ const started = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
5570
+ try {
5571
+ const decoded = scanImageData(distorted, {
5572
+ minVersion: expected.version ?? options.minVersion ?? MIN_VERSION,
5573
+ maxVersion: expected.version ?? options.maxVersion ?? MAX_VERSION,
5574
+ debug: false
5575
+ });
5576
+ const matches = expected.crc32 == null || decoded.crc32 === expected.crc32;
5577
+ if (matches) passedWeight += weight;
5578
+ results.push({
5579
+ id: profile.id,
5580
+ label: profile.label,
5581
+ type: profile.type,
5582
+ severity: profile.severity,
5583
+ passed: matches,
5584
+ confidence: decoded.confidence ?? decoded.diagnostics?.confidence ?? null,
5585
+ correctedSymbols: decoded.correctedSymbols ?? 0,
5586
+ elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
5587
+ image: options.includeImages ? distorted : undefined,
5588
+ error: matches ? null : "Decoded payload did not match expected CRC."
5589
+ });
5590
+ } catch (error) {
5591
+ results.push({
5592
+ id: profile.id,
5593
+ label: profile.label,
5594
+ type: profile.type,
5595
+ severity: profile.severity,
5596
+ passed: false,
5597
+ confidence: 0,
5598
+ correctedSymbols: null,
5599
+ elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
5600
+ image: options.includeImages ? distorted : undefined,
5601
+ error: error.message
5602
+ });
5603
+ }
5604
+ }
5605
+ const passPercent = totalWeight ? passedWeight / totalWeight * 100 : 0;
5606
+ const averageConfidence = results.filter((r) => r.passed && Number.isFinite(r.confidence)).reduce((sum, r, _, arr) => sum + r.confidence / arr.length, 0);
5607
+ const score = clampNumber(passPercent * 0.86 + averageConfidence * 100 * 0.14, 0, 100);
5608
+ const rating = score >= 90 ? "Excellent" : score >= 75 ? "Good" : score >= 50 ? "Risky" : "Likely unscannable";
5609
+ return {
5610
+ score,
5611
+ rating,
5612
+ passPercent,
5613
+ passed: results.filter((r) => r.passed).length,
5614
+ total: results.length,
5615
+ averageConfidence,
5616
+ results
5617
+ };
5618
+ }
5619
+
5620
+ /** Render and run the standard scanability torture suite. */
5621
+ function assessScanability(code, renderOptions = {}, options = {}) {
5622
+ assert(code?.matrix, "assessScanability expects an encoded QuadQR object.");
5623
+ const testImageSize = Math.max(240, Math.min(Number(options.testImageSize ?? 480), Number(renderOptions.imageSize ?? 720)));
5624
+ const image = renderToImageData(code, { ...renderOptions, imageSize: testImageSize });
5625
+ const report = runImageStressTest(image, { version: code.version, crc32: code.crc32 }, options);
5626
+ const autoLogoSize = renderOptions.logo ? estimateSafeLogoSize(code, renderOptions) : null;
5627
+ return {
5628
+ ...report,
5629
+ version: code.version,
5630
+ eccLevel: code.eccLevel,
5631
+ utilization: code.utilization,
5632
+ testImageSize,
5633
+ autoLogoSize,
5634
+ recommendations: [
5635
+ ...(report.score < 75 ? ["Increase ECC, reduce logo size, use Classic style, or increase output/physical size."] : []),
5636
+ ...(Number(renderOptions.quietZone ?? 4) < 4 ? ["Use a quiet zone of at least 4 modules for robust camera/print scanning."] : []),
5637
+ ...(renderOptions.logo && autoLogoSize != null ? [`Auto-safe logo estimate: ${(autoLogoSize * 100).toFixed(1)}% of the symbol width.`] : []),
5638
+ ...(normalizeRenderMode(renderOptions.mode ?? renderOptions.renderMode ?? "screen") === "print" ? getPrintGuidance(code, renderOptions).recommendations : [])
5639
+ ]
5640
+ };
5641
+ }
5642
+
5643
+ function paletteConfidence(observedPalette) {
5644
+ if (!observedPalette) return 0.5;
5645
+ const values = ["black", "white", "red", "green", "blue"].map((name) => observedPalette[name]).filter(Boolean);
5646
+ if (values.length < 5) return 0.5;
5647
+ let minDistance = Infinity;
5648
+ for (let i = 0; i < values.length; i++) {
5649
+ for (let j = i + 1; j < values.length; j++) {
5650
+ const a = values[i], b = values[j];
5651
+ minDistance = Math.min(minDistance, Math.hypot(a.r - b.r, a.g - b.g, a.b - b.b));
5652
+ }
5653
+ }
5654
+ return clampNumber((minDistance - 28) / 120, 0, 1);
5655
+ }
5656
+
5657
+ function scannerDiagnostics(result, context = {}, options = {}) {
5658
+ const info = result ? getVersionInfo(result.version, { ecc: result.eccLevel }) : null;
5659
+ const correctableTotal = result && info
5660
+ ? Number(info.correctableHeaderSymbols ?? 0) + Number(result.eccBlocks ?? 0) * Number(result.correctableSymbolsPerBlock ?? 0)
5661
+ : 0;
5662
+ const eccUtilization = correctableTotal > 0 ? clampNumber((result.correctedSymbols ?? 0) / correctableTotal, 0, 1) : 0;
5663
+ const structureConfidence = clampNumber(
5664
+ result?.structureScore ?? context.observations?.at(-1)?.structureScore ?? (result ? 0.92 : 0), 0, 1
5665
+ );
5666
+ const cellConfidence = clampNumber(result?.averageCellConfidence ?? 0.75, 0, 1);
5667
+ const geometryConfidence = result
5668
+ ? clampNumber(result.perspectiveCorrected ? Math.max(0.55, structureConfidence) : Math.max(0.65, structureConfidence * 0.9), 0, 1)
5669
+ : (context.geometry?.length ? 0.45 : 0);
5670
+ const calibrationConfidence = result
5671
+ ? clampNumber(result.colorCalibrated ? paletteConfidence(result.observedPalette) : 0.55, 0, 1)
5672
+ : 0;
5673
+ const confidence = result
5674
+ ? clampNumber(cellConfidence * 0.50 + structureConfidence * 0.20 + geometryConfidence * 0.15 + calibrationConfidence * 0.15 - eccUtilization * 0.12, 0, 1)
5675
+ : 0;
5676
+ const geometryCandidates = (context.geometry ?? []).slice(0, 8).map((geometry) => ({
5677
+ version: geometry.version,
5678
+ size: geometry.size,
5679
+ score: geometry.score,
5680
+ estimatedSize: geometry.estimatedSize,
5681
+ alignmentScore: geometry.alignmentScore,
5682
+ alignmentGridScore: geometry.alignmentGridScore,
5683
+ corners: geometry.corners ?? geometry.quad ?? null,
5684
+ finders: geometry.finders ?? null
5685
+ }));
5686
+ const observations = context.observations ?? [];
5687
+ const latest = observations.at(-1) ?? null;
5688
+ const stages = {
5689
+ finderDetection: geometryCandidates.length > 0 || Boolean(result && !result.perspectiveCorrected),
5690
+ geometry: geometryCandidates.length > 0 || Boolean(result && !result.perspectiveCorrected),
5691
+ sampling: observations.length > 0 || Boolean(result),
5692
+ colorClassification: observations.length > 0 || Boolean(result),
5693
+ ecc: Boolean(result),
5694
+ crc: Boolean(result),
5695
+ payload: Boolean(result)
5696
+ };
5697
+ let failedStage = null;
5698
+ if (!result) {
5699
+ failedStage = !stages.finderDetection ? "finder-detection"
5700
+ : !stages.sampling ? "perspective-geometry"
5701
+ : "color-classification-or-ecc";
5702
+ }
5703
+ return {
5704
+ confidence,
5705
+ cellConfidence,
5706
+ structureConfidence,
5707
+ geometryConfidence,
5708
+ calibrationConfidence,
5709
+ eccUtilization,
5710
+ correctedErrors: result?.correctedSymbols ?? 0,
5711
+ erasureSymbols: result?.erasureSymbols ?? 0,
5712
+ lowConfidenceCells: result?.lowConfidenceCells ?? latest?.lowConfidenceCells ?? null,
5713
+ stages,
5714
+ failedStage,
5715
+ geometryCandidates,
5716
+ vision: context.vision ?? null,
5717
+ sampled: latest ? {
5718
+ version: latest.version,
5719
+ samplingMode: latest.samplingMode,
5720
+ colorNormalization: latest.colorNormalization,
5721
+ structureScore: latest.structureScore,
5722
+ averageCellConfidence: latest.averageCellConfidence,
5723
+ observedPalette: latest.observedPalette ?? null,
5724
+ matrix: options.debugMatrices === false ? undefined : latest.matrix,
5725
+ confidenceMatrix: options.debugMatrices === false ? undefined : latest.confidence
5726
+ } : null
5727
+ };
5728
+ }
5729
+
5730
+ function decorateScanResult(result, context, options) {
5731
+ const diagnostics = scannerDiagnostics(result, context, options);
5732
+ return {
5733
+ ...result,
5734
+ confidence: diagnostics.confidence,
5735
+ geometryConfidence: diagnostics.geometryConfidence,
5736
+ calibrationConfidence: diagnostics.calibrationConfidence,
5737
+ structureConfidence: diagnostics.structureConfidence,
5738
+ correctedErrors: result.correctedSymbols ?? 0,
5739
+ eccUtilization: diagnostics.eccUtilization,
5740
+ diagnostics: options.debug ? diagnostics : {
5741
+ confidence: diagnostics.confidence,
5742
+ geometryConfidence: diagnostics.geometryConfidence,
5743
+ calibrationConfidence: diagnostics.calibrationConfidence,
5744
+ structureConfidence: diagnostics.structureConfidence,
5745
+ eccUtilization: diagnostics.eccUtilization,
5746
+ correctedErrors: diagnostics.correctedErrors,
5747
+ erasureSymbols: diagnostics.erasureSymbols
5748
+ }
5749
+ };
5750
+ }
5751
+
5752
+ /** Non-throwing scanner debug API with detailed stage diagnostics. */
5753
+ function debugScanImageData(imageData, options = {}) {
5754
+ try {
5755
+ const result = scanImageData(imageData, { ...options, debug: true });
5756
+ return { ok: true, result, debug: result.diagnostics };
5757
+ } catch (error) {
5758
+ return { ok: false, error: error.message, debug: error.debug ?? null };
5759
+ }
5760
+ }
5761
+
4413
5762
  function scanImageData(imageData, options = {}) {
4414
5763
  assert(imageData && imageData.data && imageData.width && imageData.height, "Valid ImageData is required.");
4415
5764
  const minVersion = options.minVersion ?? MIN_VERSION;
@@ -4418,11 +5767,19 @@ function scanImageData(imageData, options = {}) {
4418
5767
  validateVersion(maxVersion);
4419
5768
 
4420
5769
  const geometryCollector = [];
4421
- const scanOptions = { ...options, _geometryCollector: geometryCollector };
5770
+ const observationCollector = options.debug ? [] : (Array.isArray(options._observationCollector) ? options._observationCollector : []);
5771
+ const visionDiagnostics = options.debug ? { passes: [] } : (options._visionDiagnostics ?? null);
5772
+ const debugContext = { geometry: geometryCollector, observations: observationCollector, vision: visionDiagnostics };
5773
+ const scanOptions = {
5774
+ ...options,
5775
+ _geometryCollector: geometryCollector,
5776
+ _observationCollector: observationCollector,
5777
+ _visionDiagnostics: visionDiagnostics
5778
+ };
4422
5779
 
4423
5780
  if (options.perspective !== false) {
4424
5781
  const perspective = tryPerspectiveScan(imageData, scanOptions);
4425
- if (perspective) return perspective;
5782
+ if (perspective) return decorateScanResult(perspective, debugContext, options);
4426
5783
  }
4427
5784
 
4428
5785
  // If the normal scanner could see QuadQR geometry but could not decode the
@@ -4449,12 +5806,12 @@ function scanImageData(imageData, options = {}) {
4449
5806
  _geometryCollector: []
4450
5807
  });
4451
5808
  if (recovered) {
4452
- return {
5809
+ return decorateScanResult({
4453
5810
  ...recovered,
4454
5811
  autoEnhanced: true,
4455
5812
  recoveryMode: "auto-tone-contrast-color",
4456
5813
  originalColorNormalization: recovered.colorNormalization
4457
- };
5814
+ }, debugContext, options);
4458
5815
  }
4459
5816
  } catch {
4460
5817
  // Continue to the axis-aligned fallback.
@@ -4463,7 +5820,7 @@ function scanImageData(imageData, options = {}) {
4463
5820
 
4464
5821
  if (options.axisAlignedFallback !== false) {
4465
5822
  const axis = tryAxisAlignedScan(imageData, scanOptions);
4466
- if (axis) return axis;
5823
+ if (axis) return decorateScanResult(axis, debugContext, options);
4467
5824
  }
4468
5825
 
4469
5826
  // Static images sometimes have such low contrast that locator detection itself
@@ -4490,20 +5847,22 @@ function scanImageData(imageData, options = {}) {
4490
5847
  };
4491
5848
  if (options.perspective !== false) {
4492
5849
  const perspective = tryPerspectiveScan(enhanced, recoveryOptions);
4493
- if (perspective) return { ...perspective, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" };
5850
+ if (perspective) return decorateScanResult({ ...perspective, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
4494
5851
  }
4495
5852
  if (options.axisAlignedFallback !== false) {
4496
5853
  const axis = tryAxisAlignedScan(enhanced, recoveryOptions);
4497
- if (axis) return { ...axis, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" };
5854
+ if (axis) return decorateScanResult({ ...axis, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
4498
5855
  }
4499
5856
  } catch {
4500
5857
  // Fall through to the normal scan failure below.
4501
5858
  }
4502
5859
  }
4503
5860
 
4504
- throw new Error(
5861
+ const error = new Error(
4505
5862
  "No valid QuadQR code found. Try better lighting, fill more of the frame, keep all locator patterns visible, or use a less blurred image."
4506
5863
  );
5864
+ if (options.debug) error.debug = scannerDiagnostics(null, debugContext, options);
5865
+ throw error;
4507
5866
  }
4508
5867
 
4509
5868
  async function scanFile(file, options = {}) {
@@ -5324,7 +6683,7 @@ const internals = Object.freeze({
5324
6683
  SECURE_FLAG
5325
6684
  });
5326
6685
 
5327
- return { installCrc32Accelerator, crc32, getVersionInfo, encodeText, encodeBytes, encodeSecureText, encodeSecureBytes, decryptDecoded, decodeMatrix, renderToCanvas, renderToImageData, scanImageData, scanFile, scanVideoFrame, startCameraScanner, rectifyDetectedCode, rotateMatrix, FORMAT_VERSION, MIN_VERSION, MAX_VERSION, DEFAULT_ECC_LEVEL, CELL, DEFAULT_PALETTE, RENDER_STYLES, ECC_LEVELS, internals, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex };
6686
+ return { compressPayload, decompressPayload, deriveSigningKeyId, generateSigningKeyPair, installCrc32Accelerator, crc32, getVersionInfo, encodeText, encodeBytes, encodeUint8Array, encodeSignedBytes, encodeSignedText, encodeSecureText, encodeSecureBytes, decryptDecoded, decodeMatrix, decodeUint8Array, verifyDecodedSignature, estimateSafeLogoSize, findMaxSafeLogoSize, getPrintGuidance, renderToCanvas, renderToImageData, renderToSVG, applyStressDistortion, runImageStressTest, assessScanability, debugScanImageData, scanImageData, scanFile, scanVideoFrame, startCameraScanner, rectifyDetectedCode, rotateMatrix, FORMAT_VERSION, MIN_VERSION, MAX_VERSION, DEFAULT_ECC_LEVEL, RENDER_MODES, PRINT_PALETTE, COMPRESSION_MODES, SIGNATURE_ALGORITHMS, CELL, DEFAULT_PALETTE, RENDER_STYLES, ECC_LEVELS, STRESS_PROFILES, internals, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex };
5328
6687
  })();
5329
6688
  const QuadQR = __qqr_quadqr_js;
5330
6689