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.
@@ -2196,7 +2196,7 @@ return { computeHomography, projectPoint, detectCodeGeometry, autoColorImageData
2196
2196
  const __qqr_quadqr_js = (function () {
2197
2197
  const { MAX_CODEWORD_SYMBOLS, rsEncode, rsDecode } = __qqr_reed_solomon_js;
2198
2198
  const { alignmentPatternCentersForVersion, alignmentPatternIsBlack, alignmentPatternRadius, sizeForVersion, versionFromSize } = __qqr_geometry_js;
2199
- const { detectCodeGeometry, samplePerspectiveMatrix, rectifyImageData, sampleObservedPalette, spatiallyNormalizeRgbGrid, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, autoColorImageData, findActiveBounds, sampleAxisAlignedGrid } = __qqr_vision_js;
2199
+ const { detectCodeGeometry, computeHomography, projectPoint, samplePerspectiveMatrix, rectifyImageData, sampleObservedPalette, spatiallyNormalizeRgbGrid, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, autoColorImageData, findActiveBounds, sampleAxisAlignedGrid } = __qqr_vision_js;
2200
2200
  const { decryptSecurePayload, encryptSecurePayload, inspectSecureEnvelope, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex } = __qqr_security_js;
2201
2201
  /**
2202
2202
  * QuadQR
@@ -2220,6 +2220,28 @@ const MIN_VERSION = 1;
2220
2220
  const MAX_VERSION = 40;
2221
2221
  const DEFAULT_ECC_LEVEL = "M";
2222
2222
 
2223
+ const RENDER_MODES = Object.freeze({
2224
+ SCREEN: "screen",
2225
+ PRINT: "print"
2226
+ });
2227
+
2228
+ // Print-safe defaults deliberately use darker, more ink-tolerant primaries and
2229
+ // a true white background. Applications can still override individual colors.
2230
+ const PRINT_PALETTE = Object.freeze({
2231
+ black: "#000000",
2232
+ white: "#ffffff",
2233
+ red: "#d71932",
2234
+ green: "#087f3e",
2235
+ blue: "#174ea6"
2236
+ });
2237
+
2238
+ const COMPRESSION_MODES = Object.freeze({
2239
+ NONE: "none",
2240
+ AUTO: "auto",
2241
+ LZ: "lz"
2242
+ });
2243
+ const SIGNATURE_ALGORITHMS = Object.freeze({ ED25519: "Ed25519" });
2244
+
2223
2245
  // Data cells are the four values 0..3. Structural white deliberately shares
2224
2246
  // value 3 with data-white because both render and scan identically.
2225
2247
  const CELL = Object.freeze({
@@ -2284,6 +2306,22 @@ const COMPACT_ECC_LEVELS = Object.freeze({
2284
2306
  const CRC_BYTES = 4;
2285
2307
  const TEXT_FLAG = 1;
2286
2308
  const SECURE_FLAG = 1 << 3;
2309
+ const EXTENDED_PAYLOAD_FLAG = 1 << 4;
2310
+ const SIGNED_FLAG = 1 << 5;
2311
+
2312
+ // Internal payload extension envelope. This is deliberately not a public
2313
+ // payload mode or content-type system. It exists only when compression or
2314
+ // signing needs metadata around an otherwise normal text/binary payload.
2315
+ const PAYLOAD_ENVELOPE_VERSION = 2;
2316
+ const PAYLOAD_ENVELOPE_MAGIC = new Uint8Array([0x51, 0x50, 0x58, 0x31]); // QPX1
2317
+ const PAYLOAD_ENVELOPE_HEADER_BYTES = 16;
2318
+ const PAYLOAD_ENVELOPE_SIGNED_FLAG = 1;
2319
+ const PAYLOAD_ENVELOPE_COMPRESSED_FLAG = 1 << 1;
2320
+ const PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG = 1 << 2;
2321
+ const PAYLOAD_ENVELOPE_COMPRESSION_IDS = Object.freeze({ none: 0, lz: 1 });
2322
+ const PAYLOAD_ENVELOPE_COMPRESSION_BY_ID = Object.freeze({ 0: "none", 1: "lz" });
2323
+ const PAYLOAD_ENVELOPE_SIGNATURE_IDS = Object.freeze({ none: 0, Ed25519: 1 });
2324
+ const PAYLOAD_ENVELOPE_SIGNATURE_BY_ID = Object.freeze({ 0: null, 1: "Ed25519" });
2287
2325
  const ECC_SHIFT = 1;
2288
2326
  const ECC_MASK = 0b00000110;
2289
2327
 
@@ -2362,6 +2400,367 @@ function readU32be(bytes, offset = 0) {
2362
2400
  ) >>> 0;
2363
2401
  }
2364
2402
 
2403
+ function u16be(value) {
2404
+ const v = Number(value) >>> 0;
2405
+ assert(v <= 0xffff, "Value does not fit in uint16.");
2406
+ return new Uint8Array([(v >>> 8) & 0xff, v & 0xff]);
2407
+ }
2408
+
2409
+ function readU16be(bytes, offset = 0) {
2410
+ return ((bytes[offset] << 8) | bytes[offset + 1]) >>> 0;
2411
+ }
2412
+
2413
+ function normalizeCompressionMode(value = "none") {
2414
+ const key = String(value).toLowerCase();
2415
+ assert(["none", "auto", "lz"].includes(key), "compression must be none, auto, or lz.");
2416
+ return key;
2417
+ }
2418
+
2419
+ function asBytes(input) {
2420
+ if (input instanceof Uint8Array) return input;
2421
+ if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
2422
+ if (input instanceof ArrayBuffer) return new Uint8Array(input);
2423
+ return new Uint8Array(input);
2424
+ }
2425
+
2426
+ /** Portable LZSS-style compressor used by QuadQR payload compression. */
2427
+ function compressPayload(input) {
2428
+ const bytes = asBytes(input);
2429
+ if (bytes.length < 4) return bytes.slice();
2430
+ const out = [];
2431
+ const recent = new Map();
2432
+ let pos = 0;
2433
+
2434
+ const keyAt = (i) => i + 2 < bytes.length ? ((bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]) : -1;
2435
+ const remember = (i) => {
2436
+ const key = keyAt(i);
2437
+ if (key < 0) return;
2438
+ let list = recent.get(key);
2439
+ if (!list) recent.set(key, list = []);
2440
+ list.push(i);
2441
+ while (list.length > 32) list.shift();
2442
+ while (list.length && i - list[0] > 4095) list.shift();
2443
+ };
2444
+
2445
+ while (pos < bytes.length) {
2446
+ const flagIndex = out.length;
2447
+ out.push(0);
2448
+ let flags = 0;
2449
+ for (let token = 0; token < 8 && pos < bytes.length; token++) {
2450
+ let bestLength = 0;
2451
+ let bestOffset = 0;
2452
+ const key = keyAt(pos);
2453
+ const candidates = key >= 0 ? (recent.get(key) ?? []) : [];
2454
+ for (let ci = candidates.length - 1; ci >= 0; ci--) {
2455
+ const candidate = candidates[ci];
2456
+ const offset = pos - candidate;
2457
+ if (offset <= 0 || offset > 4095) continue;
2458
+ let length = 0;
2459
+ while (length < 18 && pos + length < bytes.length && bytes[candidate + length] === bytes[pos + length]) length++;
2460
+ if (length >= 3 && length > bestLength) {
2461
+ bestLength = length;
2462
+ bestOffset = offset;
2463
+ if (length === 18) break;
2464
+ }
2465
+ }
2466
+
2467
+ if (bestLength >= 3) {
2468
+ flags |= (1 << token);
2469
+ const encoded = ((bestOffset & 0x0fff) << 4) | ((bestLength - 3) & 0x0f);
2470
+ out.push((encoded >>> 8) & 0xff, encoded & 0xff);
2471
+ for (let i = 0; i < bestLength; i++) remember(pos + i);
2472
+ pos += bestLength;
2473
+ } else {
2474
+ out.push(bytes[pos]);
2475
+ remember(pos);
2476
+ pos++;
2477
+ }
2478
+ }
2479
+ out[flagIndex] = flags;
2480
+ }
2481
+ return Uint8Array.from(out);
2482
+ }
2483
+
2484
+ function decompressPayload(input, expectedLength = null) {
2485
+ const bytes = asBytes(input);
2486
+ const out = [];
2487
+ let pos = 0;
2488
+ while (pos < bytes.length) {
2489
+ const flags = bytes[pos++];
2490
+ for (let token = 0; token < 8 && pos < bytes.length; token++) {
2491
+ if (flags & (1 << token)) {
2492
+ assert(pos + 1 < bytes.length, "Compressed payload is truncated.");
2493
+ const encoded = (bytes[pos++] << 8) | bytes[pos++];
2494
+ const offset = (encoded >>> 4) & 0x0fff;
2495
+ const length = (encoded & 0x0f) + 3;
2496
+ assert(offset > 0 && offset <= out.length, "Compressed payload has an invalid back-reference.");
2497
+ for (let i = 0; i < length; i++) out.push(out[out.length - offset]);
2498
+ } else {
2499
+ out.push(bytes[pos++]);
2500
+ }
2501
+ if (expectedLength != null && out.length >= expectedLength) break;
2502
+ }
2503
+ }
2504
+ if (expectedLength != null) assert(out.length === expectedLength, "Decompressed payload length mismatch.");
2505
+ return Uint8Array.from(out);
2506
+ }
2507
+
2508
+ function prepareCompressedBody(payload, compression = "none") {
2509
+ const mode = normalizeCompressionMode(compression);
2510
+ if (mode === "none") return { body: payload, compression: "none" };
2511
+ const compressed = compressPayload(payload);
2512
+ if (mode === "auto" && compressed.length >= payload.length - 2) {
2513
+ return { body: payload, compression: "none" };
2514
+ }
2515
+ return { body: compressed, compression: "lz" };
2516
+ }
2517
+
2518
+ function normalizeSigningKeyId(value) {
2519
+ if (value == null || value === false || value === "") return new Uint8Array(0);
2520
+ const bytes = typeof value === "string" ? getTextEncoder().encode(value) : asBytes(value);
2521
+ assert(bytes.length <= 64, "Signing key ID must be 64 bytes or fewer.");
2522
+ return bytes;
2523
+ }
2524
+
2525
+ function makePayloadEnvelopeHeader({ compression, flags = 0, originalLength, keyIdLength = 0, publicKeyLength = 0, signatureLength = 0, signatureAlgorithm = null, version = PAYLOAD_ENVELOPE_VERSION }) {
2526
+ assert(keyIdLength <= 0xff, "Signing key ID is too long.");
2527
+ assert(publicKeyLength <= 0xff, "Signing public key is too large.");
2528
+ assert(signatureLength <= 0xff, "Signature is too large.");
2529
+ const header = new Uint8Array(PAYLOAD_ENVELOPE_HEADER_BYTES);
2530
+ header.set(PAYLOAD_ENVELOPE_MAGIC, 0);
2531
+ header[4] = version;
2532
+ header[5] = flags & 0xff;
2533
+ header[6] = PAYLOAD_ENVELOPE_COMPRESSION_IDS[compression];
2534
+ header[7] = PAYLOAD_ENVELOPE_SIGNATURE_IDS[signatureAlgorithm ?? "none"] ?? 0;
2535
+ header.set(u32be(originalLength), 8);
2536
+ header[12] = keyIdLength;
2537
+ header[13] = publicKeyLength;
2538
+ header[14] = signatureLength;
2539
+ header[15] = 0;
2540
+ return header;
2541
+ }
2542
+
2543
+ function isPayloadEnvelope(input) {
2544
+ const bytes = asBytes(input);
2545
+ return bytes.length >= PAYLOAD_ENVELOPE_HEADER_BYTES && PAYLOAD_ENVELOPE_MAGIC.every((value, index) => bytes[index] === value);
2546
+ }
2547
+
2548
+ function packPayloadEnvelope(input, options = {}) {
2549
+ const payload = asBytes(input);
2550
+ const prepared = prepareCompressedBody(payload, options.compression ?? "none");
2551
+ const flags = prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0;
2552
+ const header = makePayloadEnvelopeHeader({
2553
+ compression: prepared.compression,
2554
+ flags,
2555
+ originalLength: payload.length
2556
+ });
2557
+ return concatBytes(header, prepared.body);
2558
+ }
2559
+
2560
+ function parsePayloadEnvelope(input) {
2561
+ const container = asBytes(input);
2562
+ assert(isPayloadEnvelope(container), "Payload extension envelope is not recognized.");
2563
+ const version = container[4];
2564
+ assert(version === 1 || version === PAYLOAD_ENVELOPE_VERSION, `Unsupported payload extension version ${version}.`);
2565
+ const flags = container[5];
2566
+ const compression = PAYLOAD_ENVELOPE_COMPRESSION_BY_ID[container[6]];
2567
+ assert(compression != null, `Unknown payload compression id ${container[6]}.`);
2568
+ const signatureAlgorithm = PAYLOAD_ENVELOPE_SIGNATURE_BY_ID[container[7]];
2569
+ const originalLength = readU32be(container, 8);
2570
+ const metadataLength = container[12];
2571
+ const publicKeyLength = container[13];
2572
+ const signatureLength = container[14];
2573
+ let offset = PAYLOAD_ENVELOPE_HEADER_BYTES;
2574
+ const endMetadata = offset + metadataLength;
2575
+ const endPublicKey = endMetadata + publicKeyLength;
2576
+ const endSignature = endPublicKey + signatureLength;
2577
+ assert(endSignature <= container.length, "Payload extension length fields exceed the envelope size.");
2578
+ const metadataBytes = container.slice(offset, endMetadata);
2579
+ const publicKey = container.slice(endMetadata, endPublicKey);
2580
+ const signature = container.slice(endPublicKey, endSignature);
2581
+ const stored = container.slice(endSignature);
2582
+ const payload = compression === "lz" ? decompressPayload(stored, originalLength) : stored;
2583
+ assert(payload.length === originalLength, "Payload length mismatch after extension decoding.");
2584
+ const signed = Boolean(flags & PAYLOAD_ENVELOPE_SIGNED_FLAG);
2585
+ const signedBytes = signed ? concatBytes(container.slice(0, PAYLOAD_ENVELOPE_HEADER_BYTES), metadataBytes, publicKey, stored) : null;
2586
+
2587
+ // v1 used byte 12 for a human-readable signer label and always embedded
2588
+ // the public key. v2 uses the same field for a compact key ID and makes
2589
+ // public-key embedding optional.
2590
+ const legacySigner = version === 1 && metadataLength ? getTextDecoder().decode(metadataBytes) : null;
2591
+ const keyId = version >= 2 && metadataLength ? getTextDecoder().decode(metadataBytes) : null;
2592
+ const keyIdHex = version >= 2 && metadataLength
2593
+ ? Array.from(metadataBytes, (value) => value.toString(16).padStart(2, "0")).join("")
2594
+ : null;
2595
+
2596
+ return {
2597
+ container,
2598
+ version,
2599
+ compression,
2600
+ compressed: compression !== "none",
2601
+ originalBytes: originalLength,
2602
+ storedBytes: stored.length,
2603
+ savedBytes: originalLength - stored.length,
2604
+ payload,
2605
+ signed,
2606
+ signer: legacySigner,
2607
+ keyId,
2608
+ keyIdHex,
2609
+ publicKey,
2610
+ hasEmbeddedPublicKey: publicKey.length > 0,
2611
+ signature,
2612
+ signatureAlgorithm,
2613
+ signedBytes,
2614
+ signatureVerified: signed ? null : undefined,
2615
+ signatureTrusted: signed ? null : undefined
2616
+ };
2617
+ }
2618
+
2619
+ function subtleCrypto() {
2620
+ const subtle = globalThis.crypto?.subtle;
2621
+ assert(subtle, "Web Crypto is required for QuadQR signing.");
2622
+ return subtle;
2623
+ }
2624
+
2625
+ async function normalizeEd25519PublicKey(key) {
2626
+ const subtle = subtleCrypto();
2627
+ if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) return key;
2628
+ if (key && typeof key === "object" && key.type === "public" && key.algorithm?.name === "Ed25519") return key;
2629
+ const bytes = asBytes(key);
2630
+ assert(bytes.length === 32, "Ed25519 public key must be a 32-byte raw key or CryptoKey.");
2631
+ return subtle.importKey("raw", bytes, { name: "Ed25519" }, true, ["verify"]);
2632
+ }
2633
+
2634
+ async function normalizeEd25519PrivateKey(key) {
2635
+ const subtle = subtleCrypto();
2636
+ if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) return key;
2637
+ if (key && typeof key === "object" && key.type === "private" && key.algorithm?.name === "Ed25519") return key;
2638
+ const bytes = asBytes(key);
2639
+ return subtle.importKey("pkcs8", bytes, { name: "Ed25519" }, false, ["sign"]);
2640
+ }
2641
+
2642
+ async function deriveSigningKeyId(publicKey, bytes = 8) {
2643
+ const subtle = subtleCrypto();
2644
+ const normalized = await normalizeEd25519PublicKey(publicKey);
2645
+ const raw = new Uint8Array(await subtle.exportKey("raw", normalized));
2646
+ const digest = new Uint8Array(await subtle.digest("SHA-256", raw));
2647
+ const count = Math.max(4, Math.min(16, Math.floor(bytes)));
2648
+ return Array.from(digest.slice(0, count), (value) => value.toString(16).padStart(2, "0")).join("");
2649
+ }
2650
+
2651
+ async function generateSigningKeyPair() {
2652
+ const subtle = subtleCrypto();
2653
+ const pair = await subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
2654
+ const publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", pair.publicKey));
2655
+ const privateKeyPkcs8 = new Uint8Array(await subtle.exportKey("pkcs8", pair.privateKey));
2656
+ const keyId = await deriveSigningKeyId(pair.publicKey);
2657
+ return {
2658
+ privateKey: pair.privateKey,
2659
+ publicKey: pair.publicKey,
2660
+ privateKeyPkcs8,
2661
+ publicKeyBytes,
2662
+ keyId,
2663
+ algorithm: "Ed25519"
2664
+ };
2665
+ }
2666
+
2667
+ async function packSignedPayloadEnvelope(input, options = {}) {
2668
+ const payload = asBytes(input);
2669
+ assert(options.privateKey, "privateKey is required to sign a QuadQR payload.");
2670
+ const subtle = subtleCrypto();
2671
+ const prepared = prepareCompressedBody(payload, options.compression ?? "auto");
2672
+ const keyIdBytes = normalizeSigningKeyId(options.keyId);
2673
+
2674
+ let publicKeyBytes = new Uint8Array(0);
2675
+ if (options.embedPublicKey) {
2676
+ assert(options.publicKey, "publicKey is required when embedPublicKey is enabled.");
2677
+ const publicKeyObject = await normalizeEd25519PublicKey(options.publicKey);
2678
+ publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", publicKeyObject));
2679
+ }
2680
+
2681
+ const flags = PAYLOAD_ENVELOPE_SIGNED_FLAG |
2682
+ (prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0) |
2683
+ (publicKeyBytes.length ? PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG : 0);
2684
+ const header = makePayloadEnvelopeHeader({
2685
+ compression: prepared.compression,
2686
+ flags,
2687
+ originalLength: payload.length,
2688
+ keyIdLength: keyIdBytes.length,
2689
+ publicKeyLength: publicKeyBytes.length,
2690
+ signatureLength: 64,
2691
+ signatureAlgorithm: "Ed25519"
2692
+ });
2693
+ const signedBytes = concatBytes(header, keyIdBytes, publicKeyBytes, prepared.body);
2694
+ const privateKey = await normalizeEd25519PrivateKey(options.privateKey);
2695
+ const signature = new Uint8Array(await subtle.sign({ name: "Ed25519" }, privateKey, signedBytes));
2696
+ assert(signature.length === 64, "Unexpected Ed25519 signature length.");
2697
+ return concatBytes(header, keyIdBytes, publicKeyBytes, signature, prepared.body);
2698
+ }
2699
+
2700
+ function lookupTrustedSigningKey(trustedKeys, keyId) {
2701
+ if (!trustedKeys || !keyId) return null;
2702
+ if (trustedKeys instanceof Map) return trustedKeys.get(keyId) ?? null;
2703
+ if (typeof trustedKeys === "object") return trustedKeys[keyId] ?? null;
2704
+ return null;
2705
+ }
2706
+
2707
+ async function verifyPayloadEnvelopeSignature(input, options = {}) {
2708
+ const parsed = input?.container && input?.signedBytes ? input : parsePayloadEnvelope(input);
2709
+ assert(parsed.signed, "QuadQR payload is not signed.");
2710
+ assert(parsed.signatureAlgorithm === "Ed25519", "Unsupported signature algorithm.");
2711
+ const subtle = subtleCrypto();
2712
+
2713
+ let trusted = options.publicKey ?? options.trustedPublicKey ?? lookupTrustedSigningKey(options.trustedKeys, parsed.keyId);
2714
+ let trustSource = trusted ? "external" : null;
2715
+
2716
+ // Legacy v1 symbols embedded the public key by default, so preserve their
2717
+ // historical self-verification behavior. v2 requires explicit opt-in.
2718
+ if (!trusted && parsed.publicKey.length && (parsed.version === 1 || options.allowEmbeddedKey === true)) {
2719
+ trusted = parsed.publicKey;
2720
+ trustSource = "embedded";
2721
+ }
2722
+
2723
+ assert(
2724
+ trusted,
2725
+ parsed.keyId
2726
+ ? `A trusted Ed25519 public key is required to verify signing key ID "${parsed.keyId}".`
2727
+ : "A trusted Ed25519 public key is required to verify this signed QuadQR."
2728
+ );
2729
+
2730
+ const publicKey = await normalizeEd25519PublicKey(trusted);
2731
+ if (trustSource === "external" && parsed.publicKey.length) {
2732
+ const trustedBytes = new Uint8Array(await subtle.exportKey("raw", publicKey));
2733
+ assert(
2734
+ trustedBytes.length === parsed.publicKey.length && trustedBytes.every((value, index) => value === parsed.publicKey[index]),
2735
+ "Embedded signing key does not match the trusted public key."
2736
+ );
2737
+ }
2738
+
2739
+ const verified = await subtle.verify({ name: "Ed25519" }, publicKey, parsed.signature, parsed.signedBytes);
2740
+ return {
2741
+ verified,
2742
+ trusted: verified && trustSource === "external",
2743
+ trustSource,
2744
+ keyId: parsed.keyId ?? null
2745
+ };
2746
+ }
2747
+
2748
+ function prepareOptionalCompressedPayload(input, compression = "none") {
2749
+ const payload = asBytes(input);
2750
+ const mode = normalizeCompressionMode(compression);
2751
+ if (mode === "none") return { payload, extended: false, compression: "none" };
2752
+ const prepared = prepareCompressedBody(payload, mode);
2753
+ // Auto mode is truly zero-overhead when compression does not help.
2754
+ if (mode === "auto" && prepared.compression === "none") {
2755
+ return { payload, extended: false, compression: "none" };
2756
+ }
2757
+ return {
2758
+ payload: packPayloadEnvelope(payload, { compression: prepared.compression }),
2759
+ extended: true,
2760
+ compression: prepared.compression
2761
+ };
2762
+ }
2763
+
2365
2764
  let CRC_TABLE = null;
2366
2765
  let CRC32_ACCELERATOR = null;
2367
2766
 
@@ -2426,9 +2825,11 @@ function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYT
2426
2825
  return out;
2427
2826
  }
2428
2827
 
2429
- function flagsFor(text, eccLevel, secure = false) {
2828
+ function flagsFor(text, eccLevel, secure = false, extended = false, signed = false) {
2430
2829
  return (text ? TEXT_FLAG : 0) |
2431
2830
  (secure ? SECURE_FLAG : 0) |
2831
+ (extended ? EXTENDED_PAYLOAD_FLAG : 0) |
2832
+ (signed ? SIGNED_FLAG : 0) |
2432
2833
  ((ECC_LEVELS[eccLevel].id << ECC_SHIFT) & ECC_MASK);
2433
2834
  }
2434
2835
 
@@ -2968,6 +3369,11 @@ function finalizeMatrix(layout, rawCells, meta) {
2968
3369
  payloadBytes: meta.payloadBytes,
2969
3370
  sourcePayloadBytes: meta.sourcePayloadBytes ?? meta.payloadBytes,
2970
3371
  secure: Boolean(meta.secure),
3372
+ compressed: meta.compression != null && meta.compression !== "none",
3373
+ compression: meta.compression ?? "none",
3374
+ signed: Boolean(meta.signed),
3375
+ signingKeyId: meta.signingKeyId ?? null,
3376
+ hasEmbeddedPublicKey: Boolean(meta.hasEmbeddedPublicKey),
2971
3377
  requiresDecryption: Boolean(meta.secure),
2972
3378
  security: meta.security ?? null,
2973
3379
  meaningfulCells: meta.meaningfulCells,
@@ -2986,20 +3392,17 @@ function finalizeMatrix(layout, rawCells, meta) {
2986
3392
  };
2987
3393
  }
2988
3394
 
2989
- function encodeText(text, options = {}) {
2990
- assert(typeof text === "string", "encodeText expects a string.");
2991
- return encodeBytes(getTextEncoder().encode(text), { ...options, text: true });
2992
- }
2993
-
2994
- function encodeBytes(input, options = {}) {
3395
+ function encodePreparedBytes(input, options = {}) {
2995
3396
  if (options.formatVersion != null && options.formatVersion !== FORMAT_VERSION) {
2996
3397
  throw new Error(`Only QuadQR format version ${FORMAT_VERSION} is supported.`);
2997
3398
  }
2998
3399
 
2999
- const payload = input instanceof Uint8Array ? input : new Uint8Array(input);
3400
+ const payload = asBytes(input);
3000
3401
  const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
3001
3402
  const secure = Boolean(options.secure);
3002
- const flags = flagsFor(Boolean(options.text), eccLevel, secure);
3403
+ const extended = Boolean(options.extended);
3404
+ const signed = Boolean(options.signed);
3405
+ const flags = flagsFor(Boolean(options.text), eccLevel, secure, extended, signed);
3003
3406
  const version = chooseVersion(payload.length, { ...options, ecc: eccLevel });
3004
3407
  const layout = createLayout(version);
3005
3408
  const header = makeHeader(payload.length, flags, version);
@@ -3019,6 +3422,11 @@ function encodeBytes(input, options = {}) {
3019
3422
  payloadBytes: payload.length,
3020
3423
  sourcePayloadBytes: options.sourcePayloadBytes ?? payload.length,
3021
3424
  secure,
3425
+ extended,
3426
+ signed,
3427
+ compression: options.compressionMetadata ?? "none",
3428
+ signingKeyId: options.signingKeyId ?? null,
3429
+ hasEmbeddedPublicKey: Boolean(options.hasEmbeddedPublicKey),
3022
3430
  security: options.securityMetadata ?? null,
3023
3431
  meaningfulCells: meaningfulCells.length,
3024
3432
  eccLevel,
@@ -3029,6 +3437,58 @@ function encodeBytes(input, options = {}) {
3029
3437
  });
3030
3438
  }
3031
3439
 
3440
+ function encodeText(text, options = {}) {
3441
+ assert(typeof text === "string", "encodeText expects a string.");
3442
+ const source = getTextEncoder().encode(text);
3443
+ const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
3444
+ return encodePreparedBytes(prepared.payload, {
3445
+ ...options,
3446
+ text: true,
3447
+ extended: prepared.extended,
3448
+ sourcePayloadBytes: source.length,
3449
+ compressionMetadata: prepared.compression
3450
+ });
3451
+ }
3452
+
3453
+ function encodeBytes(input, options = {}) {
3454
+ const source = asBytes(input);
3455
+ const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
3456
+ return encodePreparedBytes(prepared.payload, {
3457
+ ...options,
3458
+ text: Boolean(options.text),
3459
+ extended: prepared.extended,
3460
+ sourcePayloadBytes: source.length,
3461
+ compressionMetadata: prepared.compression
3462
+ });
3463
+ }
3464
+
3465
+ /** Explicit Uint8Array convenience API. Equivalent to encodeBytes(). */
3466
+ function encodeUint8Array(input, options = {}) {
3467
+ return encodeBytes(asBytes(input), options);
3468
+ }
3469
+
3470
+ /** Encode and sign a normal payload using an internal Ed25519 envelope. */
3471
+ async function encodeSignedBytes(input, options = {}) {
3472
+ const source = asBytes(input);
3473
+ const envelope = await packSignedPayloadEnvelope(source, options);
3474
+ const parsed = parsePayloadEnvelope(envelope);
3475
+ return encodePreparedBytes(envelope, {
3476
+ ...options,
3477
+ text: Boolean(options.text),
3478
+ extended: true,
3479
+ signed: true,
3480
+ sourcePayloadBytes: source.length,
3481
+ compressionMetadata: parsed.compression,
3482
+ signingKeyId: parsed.keyId,
3483
+ hasEmbeddedPublicKey: parsed.hasEmbeddedPublicKey
3484
+ });
3485
+ }
3486
+
3487
+ async function encodeSignedText(text, options = {}) {
3488
+ assert(typeof text === "string", "encodeSignedText expects a string.");
3489
+ return encodeSignedBytes(getTextEncoder().encode(text), { ...options, text: true });
3490
+ }
3491
+
3032
3492
  /**
3033
3493
  * Encode text using the optional QuadQR Secure Payload v1 layer.
3034
3494
  * This API is async because Web Crypto performs AES-GCM and password KDF work asynchronously.
@@ -3040,32 +3500,75 @@ async function encodeSecureText(text, options = {}) {
3040
3500
 
3041
3501
  /** Encode arbitrary bytes using password or raw 256-bit key security. */
3042
3502
  async function encodeSecureBytes(input, options = {}) {
3043
- const sourcePayload = input instanceof Uint8Array ? input : new Uint8Array(input);
3503
+ const sourcePayload = asBytes(input);
3044
3504
  const security = options.security ?? {};
3045
- const encrypted = await encryptSecurePayload(sourcePayload, security);
3046
- return encodeBytes(encrypted.envelope, {
3505
+ let protectedPayload = sourcePayload;
3506
+ let extended = false;
3507
+ let signed = false;
3508
+ let compression = "none";
3509
+ let signingKeyId = null;
3510
+ let hasEmbeddedPublicKey = false;
3511
+
3512
+ if (options.signing) {
3513
+ protectedPayload = await packSignedPayloadEnvelope(sourcePayload, {
3514
+ ...options,
3515
+ ...options.signing,
3516
+ compression: options.compression ?? "auto"
3517
+ });
3518
+ const parsed = parsePayloadEnvelope(protectedPayload);
3519
+ extended = true;
3520
+ signed = true;
3521
+ compression = parsed.compression;
3522
+ signingKeyId = parsed.keyId;
3523
+ hasEmbeddedPublicKey = parsed.hasEmbeddedPublicKey;
3524
+ } else {
3525
+ const prepared = prepareOptionalCompressedPayload(sourcePayload, options.compression ?? "none");
3526
+ protectedPayload = prepared.payload;
3527
+ extended = prepared.extended;
3528
+ compression = prepared.compression;
3529
+ }
3530
+
3531
+ const encrypted = await encryptSecurePayload(protectedPayload, security);
3532
+ return encodePreparedBytes(encrypted.envelope, {
3047
3533
  ...options,
3048
3534
  text: Boolean(options.text),
3049
3535
  secure: true,
3536
+ extended,
3537
+ signed,
3050
3538
  sourcePayloadBytes: sourcePayload.length,
3539
+ compressionMetadata: compression,
3540
+ signingKeyId,
3541
+ hasEmbeddedPublicKey,
3051
3542
  securityMetadata: encrypted.metadata
3052
3543
  });
3053
3544
  }
3054
3545
 
3055
3546
  /**
3056
3547
  * Decrypt a result returned by decodeMatrix/scanImageData/scanFile.
3057
- * The encrypted envelope remains available as encryptedPayload.
3548
+ * Compression and signatures are automatically unwrapped after decryption.
3058
3549
  */
3059
3550
  async function decryptDecoded(result, security = {}) {
3060
3551
  assert(result?.secure && result?.payload, "decryptDecoded expects an encrypted QuadQR decode result.");
3061
3552
  const plaintext = await decryptSecurePayload(result.payload, security);
3062
3553
  const isText = (result.flags & TEXT_FLAG) !== 0;
3554
+ const extended = (result.flags & EXTENDED_PAYLOAD_FLAG) !== 0;
3555
+ const envelope = extended ? parsePayloadEnvelope(plaintext) : null;
3556
+ const payload = envelope ? envelope.payload : plaintext;
3063
3557
  return {
3064
3558
  ...result,
3065
3559
  encryptedPayload: result.payload,
3066
3560
  encryptedPayloadBytes: result.payload.length,
3067
- payload: plaintext,
3068
- text: isText ? getTextDecoder().decode(plaintext) : null,
3561
+ protectedPayload: envelope ? plaintext : undefined,
3562
+ payload,
3563
+ text: isText ? getTextDecoder().decode(payload) : null,
3564
+ compressed: envelope?.compressed ?? false,
3565
+ compression: envelope?.compression ?? "none",
3566
+ signed: envelope?.signed ?? result.signed ?? false,
3567
+ signatureVerified: envelope?.signatureVerified ?? result.signatureVerified,
3568
+ signatureTrusted: envelope?.signatureTrusted ?? result.signatureTrusted,
3569
+ signingKeyId: envelope?.keyId ?? result.signingKeyId ?? null,
3570
+ hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? result.hasEmbeddedPublicKey ?? false,
3571
+ signer: envelope?.signer ?? result.signer ?? null,
3069
3572
  decrypted: true,
3070
3573
  requiresDecryption: false,
3071
3574
  security: { ...result.security, decrypted: true }
@@ -3336,7 +3839,11 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
3336
3839
 
3337
3840
  const isText = (flags & TEXT_FLAG) !== 0;
3338
3841
  const secure = (flags & SECURE_FLAG) !== 0;
3842
+ const extendedFlag = (flags & EXTENDED_PAYLOAD_FLAG) !== 0;
3843
+ const signedFlag = (flags & SIGNED_FLAG) !== 0;
3339
3844
  const security = secure ? inspectSecureEnvelope(payload) : null;
3845
+ const envelope = extendedFlag && !secure ? parsePayloadEnvelope(payload) : null;
3846
+ const applicationPayload = envelope ? envelope.payload : payload;
3340
3847
  const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
3341
3848
  const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
3342
3849
  return {
@@ -3361,8 +3868,17 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
3361
3868
  correctedHeaderSymbols: headerDecoded.correctedSymbols,
3362
3869
  correctedBodySymbols: bodyDecoded.correctedSymbols,
3363
3870
  correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
3364
- payload,
3365
- text: isText && !secure ? getTextDecoder().decode(payload) : null,
3871
+ protectedPayload: envelope ? payload : undefined,
3872
+ payload: secure ? payload : applicationPayload,
3873
+ text: secure ? null : (isText ? getTextDecoder().decode(applicationPayload) : null),
3874
+ compressed: envelope?.compressed ?? false,
3875
+ compression: envelope?.compression ?? "none",
3876
+ signed: envelope?.signed ?? signedFlag,
3877
+ signatureVerified: envelope?.signatureVerified,
3878
+ signatureTrusted: envelope?.signatureTrusted,
3879
+ signingKeyId: envelope?.keyId ?? null,
3880
+ hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? false,
3881
+ signer: envelope?.signer ?? null,
3366
3882
  secure,
3367
3883
  encrypted: secure,
3368
3884
  decrypted: false,
@@ -3407,6 +3923,26 @@ function decodeMatrix(inputMatrix, options = {}) {
3407
3923
  throw new Error(`Unable to decode matrix. ${errors.join(" || ")}`);
3408
3924
  }
3409
3925
 
3926
+ /** Decode a matrix and return only its application payload bytes. */
3927
+ function decodeUint8Array(inputMatrix, options = {}) {
3928
+ return decodeMatrix(inputMatrix, options).payload;
3929
+ }
3930
+
3931
+ /** Verify a signed decode result and return the result with verification state attached. */
3932
+ async function verifyDecodedSignature(result, options = {}) {
3933
+ assert(result?.signed, "Decode result is not signed.");
3934
+ const source = result.protectedPayload;
3935
+ assert(source, "Signed payload metadata is unavailable. Decrypt secure content first if necessary.");
3936
+ const verification = await verifyPayloadEnvelopeSignature(source, options);
3937
+ return {
3938
+ ...result,
3939
+ signatureVerified: verification.verified,
3940
+ signatureTrusted: verification.trusted,
3941
+ signingKeyId: verification.keyId ?? result.signingKeyId ?? null,
3942
+ signatureTrustSource: verification.trustSource
3943
+ };
3944
+ }
3945
+
3410
3946
  function hexToRgb(hex) {
3411
3947
  const clean = hex.replace("#", "");
3412
3948
  assert(clean.length === 6, `Invalid hex color ${hex}.`);
@@ -3421,6 +3957,18 @@ function resolvePalette(palette = {}) {
3421
3957
  return { ...DEFAULT_PALETTE, ...palette };
3422
3958
  }
3423
3959
 
3960
+ function normalizeRenderMode(mode = RENDER_MODES.SCREEN) {
3961
+ const value = String(mode).toLowerCase();
3962
+ assert(value === RENDER_MODES.SCREEN || value === RENDER_MODES.PRINT, "Render mode must be screen or print.");
3963
+ return value;
3964
+ }
3965
+
3966
+ function resolveRenderPalette(options = {}) {
3967
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
3968
+ const base = mode === RENDER_MODES.PRINT ? PRINT_PALETTE : DEFAULT_PALETTE;
3969
+ return { ...base, ...(options.palette ?? {}) };
3970
+ }
3971
+
3424
3972
  function paletteRgb(palette = {}) {
3425
3973
  const p = resolvePalette(palette);
3426
3974
  return {
@@ -3584,18 +4132,346 @@ function fillImageRoundedRect(data, width, x, y, rectWidth, rectHeight, radius,
3584
4132
  }
3585
4133
  }
3586
4134
 
4135
+ /** Estimate a conservative center-logo ratio from ECC, utilization and output mode. */
4136
+ function estimateSafeLogoSize(codeOrMatrix, options = {}) {
4137
+ const code = Array.isArray(codeOrMatrix) ? null : codeOrMatrix;
4138
+ const ecc = String(code?.eccLevel ?? options.ecc ?? DEFAULT_ECC_LEVEL).toUpperCase();
4139
+ const base = { L: 0.10, M: 0.13, Q: 0.16, H: 0.19 }[ecc] ?? 0.12;
4140
+ const utilization = Number.isFinite(code?.utilization) ? code.utilization : Number(options.utilization ?? 0.65);
4141
+ const utilizationPenalty = utilization <= 0.55 ? 1 : Math.max(0.72, 1 - (utilization - 0.55) * 0.56);
4142
+ const clearBackground = Boolean(options.clearBackground ?? options.logoClearBackground ?? options.logo?.clearBackground);
4143
+ const clearPenalty = clearBackground ? 0.90 : 1;
4144
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4145
+ const printPenalty = mode === RENDER_MODES.PRINT ? 0.88 : 1;
4146
+ const versionPenalty = code?.version === 1 ? 0.88 : 1;
4147
+ return Math.max(0.07, Math.min(0.22, base * utilizationPenalty * clearPenalty * printPenalty * versionPenalty));
4148
+ }
4149
+
4150
+ /**
4151
+ * Empirically search for the largest logo ratio that still decodes from the
4152
+ * dependency-free ImageData renderer. The logo source must therefore be ImageData-like.
4153
+ */
4154
+ function findMaxSafeLogoSize(code, options = {}) {
4155
+ assert(code?.matrix, "findMaxSafeLogoSize expects an encoded QuadQR object.");
4156
+ const logo = options.logo;
4157
+ const source = logo?.source ?? logo;
4158
+ assert(source?.data && source?.width && source?.height,
4159
+ "findMaxSafeLogoSize requires an ImageData-like logo source so it can test pixel output without DOM dependencies.");
4160
+ let low = Math.max(0.05, Number(options.minSize ?? 0.05));
4161
+ let high = Math.min(0.30, Number(options.maxSize ?? 0.28));
4162
+ let best = 0;
4163
+ const iterations = Math.max(3, Math.min(10, Math.floor(options.iterations ?? 7)));
4164
+ for (let i = 0; i < iterations; i++) {
4165
+ const probe = (low + high) / 2;
4166
+ try {
4167
+ const image = renderToImageData(code, {
4168
+ ...options,
4169
+ logo: {
4170
+ ...(typeof logo === "object" && Object.prototype.hasOwnProperty.call(logo, "source") ? logo : { source }),
4171
+ size: probe
4172
+ }
4173
+ });
4174
+ const decoded = scanImageData(image, { minVersion: code.version, maxVersion: code.version });
4175
+ if (decoded.crc32 !== code.crc32) throw new Error("Decoded a different payload.");
4176
+ best = probe;
4177
+ low = probe;
4178
+ } catch {
4179
+ high = probe;
4180
+ }
4181
+ }
4182
+ return {
4183
+ safeSize: best || estimateSafeLogoSize(code, options),
4184
+ testedMax: Number(options.maxSize ?? 0.28),
4185
+ iterations,
4186
+ empirical: best > 0
4187
+ };
4188
+ }
4189
+
4190
+ function getPrintGuidance(codeOrMatrix, options = {}) {
4191
+ const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix?.matrix;
4192
+ assert(matrix?.length, "getPrintGuidance expects a QuadQR code or matrix.");
4193
+ const quietZone = Math.max(4, Math.floor(options.quietZone ?? 4));
4194
+ const physicalSizeMm = Number(options.physicalSizeMm ?? 35);
4195
+ const totalModules = matrix.length + quietZone * 2;
4196
+ const moduleSizeMm = physicalSizeMm / totalModules;
4197
+ const dpi = Number(options.dpi ?? 300);
4198
+ const modulePixels = moduleSizeMm / 25.4 * dpi;
4199
+ const recommendedMinimumModuleMm = Number(options.minimumModuleMm ?? 0.40);
4200
+ return {
4201
+ mode: "print",
4202
+ physicalSizeMm,
4203
+ quietZone,
4204
+ moduleSizeMm,
4205
+ modulePixelsAtDpi: modulePixels,
4206
+ dpi,
4207
+ recommendedMinimumModuleMm,
4208
+ meetsRecommendedModuleSize: moduleSizeMm >= recommendedMinimumModuleMm,
4209
+ recommendedMinimumPhysicalSizeMm: totalModules * recommendedMinimumModuleMm,
4210
+ palette: PRINT_PALETTE,
4211
+ recommendations: [
4212
+ "Use classic rendering for print unless a styled output has been physically validated.",
4213
+ "Keep at least a 4-module quiet zone.",
4214
+ `Target at least ${recommendedMinimumModuleMm.toFixed(2)} mm per module for general-purpose printing.`,
4215
+ "Avoid glossy reflections and low-ink/toner modes when reliable color classification matters."
4216
+ ]
4217
+ };
4218
+ }
4219
+
4220
+ function resolveLogoRenderOptions(options, matrixSize, moduleSize, quietZone, palette, codeOrMatrix = null) {
4221
+ if (!options.logo) return null;
4222
+
4223
+ const nested = (
4224
+ options.logo &&
4225
+ typeof options.logo === "object" &&
4226
+ Object.prototype.hasOwnProperty.call(options.logo, "source")
4227
+ ) ? options.logo : null;
4228
+ const source = nested ? nested.source : options.logo;
4229
+ if (!source) return null;
4230
+
4231
+ const requestedSize = nested?.size ?? options.logoSize ?? 0.18;
4232
+ const autoSize = String(requestedSize).toLowerCase() === "auto";
4233
+ const rawSize = Number(requestedSize);
4234
+ const sizeRatio = autoSize
4235
+ ? estimateSafeLogoSize(codeOrMatrix, { ...options, clearBackground: nested?.clearBackground ?? options.logoClearBackground })
4236
+ : Math.max(0.05, Math.min(0.30, Number.isFinite(rawSize) ? rawSize : 0.18));
4237
+ const rawPadding = Number(nested?.padding ?? options.logoPadding ?? 0.65);
4238
+ const rawRadius = Number(nested?.radius ?? options.logoRadius ?? 0.8);
4239
+ const paddingModules = Math.max(0, Number.isFinite(rawPadding) ? rawPadding : 0.65);
4240
+ const radiusModules = Math.max(0, Number.isFinite(rawRadius) ? rawRadius : 0.8);
4241
+ const clearBackground = Boolean(nested?.clearBackground ?? options.logoClearBackground ?? false);
4242
+ const backgroundColor = nested?.backgroundColor ?? options.logoBackgroundColor ?? palette.white;
4243
+
4244
+ const codePixels = matrixSize * moduleSize;
4245
+ const canvasPixels = (matrixSize + quietZone * 2) * moduleSize;
4246
+ const logoPixels = Math.max(1, codePixels * sizeRatio);
4247
+ const center = canvasPixels / 2;
4248
+ const paddingPixels = paddingModules * moduleSize;
4249
+ const backgroundPixels = logoPixels + paddingPixels * 2;
4250
+
4251
+ return {
4252
+ source,
4253
+ sizeRatio,
4254
+ clearBackground,
4255
+ backgroundColor,
4256
+ logoX: center - logoPixels / 2,
4257
+ logoY: center - logoPixels / 2,
4258
+ logoSize: logoPixels,
4259
+ backgroundX: center - backgroundPixels / 2,
4260
+ backgroundY: center - backgroundPixels / 2,
4261
+ backgroundSize: backgroundPixels,
4262
+ backgroundRadius: radiusModules * moduleSize
4263
+ };
4264
+ }
4265
+
4266
+ function canvasLogoSource(source) {
4267
+ if (!source || typeof source === "string") return source;
4268
+ if (source.data && Number.isInteger(source.width) && Number.isInteger(source.height)) {
4269
+ let scratch = null;
4270
+ if (typeof OffscreenCanvas !== "undefined") {
4271
+ scratch = new OffscreenCanvas(source.width, source.height);
4272
+ } else if (typeof document !== "undefined") {
4273
+ scratch = document.createElement("canvas");
4274
+ scratch.width = source.width;
4275
+ scratch.height = source.height;
4276
+ }
4277
+ if (!scratch) return source;
4278
+ const ctx = scratch.getContext("2d");
4279
+ if (!ctx) return source;
4280
+ const pixels = source.data instanceof Uint8ClampedArray
4281
+ ? source.data
4282
+ : new Uint8ClampedArray(source.data);
4283
+ ctx.putImageData(new ImageData(pixels, source.width, source.height), 0, 0);
4284
+ return scratch;
4285
+ }
4286
+ return source;
4287
+ }
4288
+
4289
+ function logoSourceDimensions(source) {
4290
+ const width = source?.naturalWidth ?? source?.videoWidth ?? source?.width;
4291
+ const height = source?.naturalHeight ?? source?.videoHeight ?? source?.height;
4292
+ if (!(Number(width) > 0 && Number(height) > 0)) return null;
4293
+ return { width: Number(width), height: Number(height) };
4294
+ }
4295
+
4296
+ function containRect(x, y, size, dimensions) {
4297
+ if (!dimensions) return { x, y, width: size, height: size };
4298
+ const scale = Math.min(size / dimensions.width, size / dimensions.height);
4299
+ const width = dimensions.width * scale;
4300
+ const height = dimensions.height * scale;
4301
+ return {
4302
+ x: x + (size - width) / 2,
4303
+ y: y + (size - height) / 2,
4304
+ width,
4305
+ height
4306
+ };
4307
+ }
4308
+
4309
+ function drawLogoToCanvas(ctx, logo, palette) {
4310
+ if (!logo) return;
4311
+ if (logo.clearBackground) {
4312
+ ctx.fillStyle = logo.backgroundColor || palette.white;
4313
+ roundedRectPath(
4314
+ ctx,
4315
+ logo.backgroundX,
4316
+ logo.backgroundY,
4317
+ logo.backgroundSize,
4318
+ logo.backgroundSize,
4319
+ logo.backgroundRadius
4320
+ );
4321
+ ctx.fill();
4322
+ }
4323
+
4324
+ const source = canvasLogoSource(logo.source);
4325
+ assert(
4326
+ source && typeof source !== "string",
4327
+ "renderToCanvas() logo must be a loaded CanvasImageSource or ImageData-like object. Load URL/data-URL logos into an Image first."
4328
+ );
4329
+ const rect = containRect(logo.logoX, logo.logoY, logo.logoSize, logoSourceDimensions(source));
4330
+ const previousSmoothing = ctx.imageSmoothingEnabled;
4331
+ ctx.imageSmoothingEnabled = true;
4332
+ ctx.drawImage(source, rect.x, rect.y, rect.width, rect.height);
4333
+ ctx.imageSmoothingEnabled = previousSmoothing;
4334
+ }
4335
+
4336
+ function blendImagePixel(data, width, x, y, rgb, alpha) {
4337
+ if (x < 0 || y < 0 || x >= width) return;
4338
+ const height = data.length / 4 / width;
4339
+ if (y >= height) return;
4340
+ const p = (y * width + x) * 4;
4341
+ const a = Math.max(0, Math.min(1, alpha));
4342
+ data[p] = Math.round(data[p] * (1 - a) + rgb.r * a);
4343
+ data[p + 1] = Math.round(data[p + 1] * (1 - a) + rgb.g * a);
4344
+ data[p + 2] = Math.round(data[p + 2] * (1 - a) + rgb.b * a);
4345
+ data[p + 3] = 255;
4346
+ }
4347
+
4348
+ function drawLogoToImageData(data, width, logo) {
4349
+ if (!logo) return;
4350
+ if (logo.clearBackground) {
4351
+ const rgb = hexToRgb(logo.backgroundColor);
4352
+ fillImageRoundedRect(
4353
+ data,
4354
+ width,
4355
+ Math.round(logo.backgroundX),
4356
+ Math.round(logo.backgroundY),
4357
+ Math.round(logo.backgroundSize),
4358
+ Math.round(logo.backgroundSize),
4359
+ Math.round(logo.backgroundRadius),
4360
+ rgb
4361
+ );
4362
+ }
4363
+
4364
+ const source = logo.source;
4365
+ assert(
4366
+ source?.data && Number.isInteger(source.width) && Number.isInteger(source.height),
4367
+ "renderToImageData() logo must be an ImageData-like object with width, height, and RGBA data."
4368
+ );
4369
+ const dest = containRect(logo.logoX, logo.logoY, logo.logoSize, {
4370
+ width: source.width,
4371
+ height: source.height
4372
+ });
4373
+ const x0 = Math.round(dest.x);
4374
+ const y0 = Math.round(dest.y);
4375
+ const destWidth = Math.max(1, Math.round(dest.width));
4376
+ const destHeight = Math.max(1, Math.round(dest.height));
4377
+ const src = source.data;
4378
+
4379
+ for (let y = 0; y < destHeight; y++) {
4380
+ const sy = Math.min(source.height - 1, Math.floor((y + 0.5) * source.height / destHeight));
4381
+ for (let x = 0; x < destWidth; x++) {
4382
+ const sx = Math.min(source.width - 1, Math.floor((x + 0.5) * source.width / destWidth));
4383
+ const p = (sy * source.width + sx) * 4;
4384
+ const alpha = (src[p + 3] ?? 255) / 255;
4385
+ if (alpha <= 0) continue;
4386
+ blendImagePixel(data, width, x0 + x, y0 + y, {
4387
+ r: src[p],
4388
+ g: src[p + 1],
4389
+ b: src[p + 2]
4390
+ }, alpha);
4391
+ }
4392
+ }
4393
+ }
4394
+
4395
+ function escapeXmlAttribute(value) {
4396
+ return String(value)
4397
+ .replaceAll("&", "&amp;")
4398
+ .replaceAll('"', "&quot;")
4399
+ .replaceAll("<", "&lt;")
4400
+ .replaceAll(">", "&gt;");
4401
+ }
4402
+
4403
+ function svgRect(x, y, width, height, fill, radius = 0) {
4404
+ const rounded = radius > 0 ? ` rx="${radius}" ry="${radius}"` : "";
4405
+ return `<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="${escapeXmlAttribute(fill)}"${rounded}/>`;
4406
+ }
4407
+
4408
+ function svgLogoHref(source) {
4409
+ if (typeof source === "string") return source;
4410
+ if (typeof source?.src === "string" && source.src) return source.src;
4411
+ return null;
4412
+ }
4413
+
4414
+ function resolveRenderSizing(options, matrixSize) {
4415
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4416
+ const requestedQuietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
4417
+ const quietZone = mode === RENDER_MODES.PRINT && options.allowUnsafePrintQuietZone !== true
4418
+ ? Math.max(4, requestedQuietZone)
4419
+ : requestedQuietZone;
4420
+ const totalModules = matrixSize + quietZone * 2;
4421
+ const hasImageSize = options.imageSize !== undefined && options.imageSize !== null;
4422
+ const hasModuleSize = options.moduleSize !== undefined && options.moduleSize !== null;
4423
+
4424
+ let pixelSize;
4425
+ if (hasImageSize) {
4426
+ const requested = Number(options.imageSize);
4427
+ assert(Number.isFinite(requested) && requested > 0, "imageSize must be a positive number of pixels.");
4428
+ pixelSize = Math.max(totalModules, Math.round(requested));
4429
+ } else if (hasModuleSize) {
4430
+ const requested = Number(options.moduleSize);
4431
+ assert(Number.isFinite(requested) && requested > 0, "moduleSize must be a positive number of pixels.");
4432
+ const moduleSize = Math.max(1, Math.floor(requested));
4433
+ pixelSize = totalModules * moduleSize;
4434
+ } else {
4435
+ // A practical export default that is large enough for screens/social use
4436
+ // while keeping the API independent from the encoded matrix version.
4437
+ pixelSize = Math.max(totalModules, 720);
4438
+ }
4439
+
4440
+ return {
4441
+ quietZone,
4442
+ totalModules,
4443
+ pixelSize,
4444
+ moduleSize: pixelSize / totalModules,
4445
+ mode
4446
+ };
4447
+ }
4448
+
4449
+ function rasterModuleRect(row, col, quietZone, moduleSize) {
4450
+ const x0 = Math.round((col + quietZone) * moduleSize);
4451
+ const y0 = Math.round((row + quietZone) * moduleSize);
4452
+ const x1 = Math.round((col + quietZone + 1) * moduleSize);
4453
+ const y1 = Math.round((row + quietZone + 1) * moduleSize);
4454
+ return {
4455
+ x: x0,
4456
+ y: y0,
4457
+ width: Math.max(1, x1 - x0),
4458
+ height: Math.max(1, y1 - y0)
4459
+ };
4460
+ }
4461
+
3587
4462
  function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3588
4463
  assert(canvas && typeof canvas.getContext === "function", "A canvas element is required.");
3589
4464
  const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
3590
4465
  assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
3591
- const moduleSize = Math.max(1, Math.floor(options.moduleSize ?? 12));
3592
- const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
3593
- const palette = resolvePalette(options.palette);
3594
- const paletteValues = paletteRgb(options.palette);
3595
- const style = normalizeRenderStyle(options.style);
4466
+ const sizing = resolveRenderSizing(options, matrix.length);
4467
+ const { moduleSize, quietZone, pixelSize } = sizing;
4468
+ const palette = resolveRenderPalette(options);
4469
+ const paletteValues = paletteRgb(palette);
4470
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4471
+ const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
3596
4472
  const layout = renderLayoutForMatrix(matrix);
3597
4473
  const size = matrix.length;
3598
- const pixelSize = (size + quietZone * 2) * moduleSize;
4474
+ const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
3599
4475
 
3600
4476
  canvas.width = pixelSize;
3601
4477
  canvas.height = pixelSize;
@@ -3608,12 +4484,13 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3608
4484
  for (let c = 0; c < size; c++) {
3609
4485
  const cell = matrix[r][c];
3610
4486
  const structural = isStructuralRenderCell(layout, r, c, cell);
3611
- const x = (c + quietZone) * moduleSize;
3612
- const y = (r + quietZone) * moduleSize;
4487
+ const rect = rasterModuleRect(r, c, quietZone, moduleSize);
4488
+ const { x, y, width: cellWidth, height: cellHeight } = rect;
4489
+ const cellSize = Math.min(cellWidth, cellHeight);
3613
4490
 
3614
4491
  if (style === RENDER_STYLES.CLASSIC || structural) {
3615
4492
  ctx.fillStyle = cellColor(cell, palette);
3616
- ctx.fillRect(x, y, moduleSize, moduleSize);
4493
+ ctx.fillRect(x, y, cellWidth, cellHeight);
3617
4494
  continue;
3618
4495
  }
3619
4496
 
@@ -3623,28 +4500,29 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3623
4500
  const opacity = depthOpacity(r, c, cell);
3624
4501
  const base = mixRgb(paletteValues.white, rgb, opacity);
3625
4502
  ctx.fillStyle = rgbCss(base);
3626
- ctx.fillRect(x, y, moduleSize, moduleSize);
4503
+ ctx.fillRect(x, y, cellWidth, cellHeight);
3627
4504
 
3628
- if (moduleSize >= 6) {
3629
- const edge = Math.max(1, Math.floor(moduleSize * 0.08));
4505
+ if (cellSize >= 6) {
4506
+ const edge = Math.max(1, Math.floor(cellSize * 0.08));
3630
4507
  const highlight = mixRgb(base, paletteValues.white, 0.17);
3631
4508
  const shadow = mixRgb(base, paletteValues.black, 0.12);
3632
4509
  ctx.fillStyle = rgbCss(highlight);
3633
- ctx.fillRect(x, y, moduleSize, edge);
3634
- ctx.fillRect(x, y, edge, moduleSize);
4510
+ ctx.fillRect(x, y, cellWidth, edge);
4511
+ ctx.fillRect(x, y, edge, cellHeight);
3635
4512
  ctx.fillStyle = rgbCss(shadow);
3636
- ctx.fillRect(x, y + moduleSize - edge, moduleSize, edge);
3637
- ctx.fillRect(x + moduleSize - edge, y, edge, moduleSize);
4513
+ ctx.fillRect(x, y + cellHeight - edge, cellWidth, edge);
4514
+ ctx.fillRect(x + cellWidth - edge, y, edge, cellHeight);
3638
4515
  }
3639
4516
  continue;
3640
4517
  }
3641
4518
 
3642
4519
  if (style === RENDER_STYLES.SOFT) {
3643
- const inset = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
3644
- const width = moduleSize - inset * 2;
3645
- const radius = Math.max(1, Math.floor(moduleSize * 0.20));
4520
+ const inset = cellSize >= 6 ? Math.max(1, Math.floor(cellSize * 0.07)) : 0;
4521
+ const width = cellWidth - inset * 2;
4522
+ const height = cellHeight - inset * 2;
4523
+ const radius = Math.max(1, Math.floor(cellSize * 0.20));
3646
4524
  ctx.fillStyle = cellColor(cell, palette);
3647
- roundedRectPath(ctx, x + inset, y + inset, width, width, radius);
4525
+ roundedRectPath(ctx, x + inset, y + inset, width, height, radius);
3648
4526
  ctx.fill();
3649
4527
  continue;
3650
4528
  }
@@ -3653,37 +4531,40 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3653
4531
  // Keep the exact encoded color across the full module, then limit the
3654
4532
  // recessed effect to a narrow edge band so center sampling stays intact.
3655
4533
  ctx.fillStyle = cellColor(cell, palette);
3656
- ctx.fillRect(x, y, moduleSize, moduleSize);
4534
+ ctx.fillRect(x, y, cellWidth, cellHeight);
3657
4535
  if (cell === CELL.WHITE) continue;
3658
4536
 
3659
- const edge = safeStyleEdge(moduleSize);
4537
+ const edge = safeStyleEdge(cellSize);
3660
4538
  if (edge > 0) {
3661
4539
  const fx = insetStyleColors(rgb, paletteValues);
3662
4540
  ctx.fillStyle = rgbCss(fx.shadow);
3663
- ctx.fillRect(x, y, moduleSize, edge);
3664
- ctx.fillRect(x, y, edge, moduleSize);
4541
+ ctx.fillRect(x, y, cellWidth, edge);
4542
+ ctx.fillRect(x, y, edge, cellHeight);
3665
4543
  ctx.fillStyle = rgbCss(fx.highlight);
3666
- ctx.fillRect(x, y + moduleSize - edge, moduleSize, edge);
3667
- ctx.fillRect(x + moduleSize - edge, y, edge, moduleSize);
4544
+ ctx.fillRect(x, y + cellHeight - edge, cellWidth, edge);
4545
+ ctx.fillRect(x + cellWidth - edge, y, edge, cellHeight);
3668
4546
  }
3669
4547
  }
3670
4548
  }
3671
4549
  }
4550
+ drawLogoToCanvas(ctx, logo, palette);
3672
4551
  return canvas;
3673
4552
  }
3674
4553
 
3675
4554
  function renderToImageData(codeOrMatrix, options = {}) {
3676
4555
  const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
3677
4556
  assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
3678
- const moduleSize = Math.max(1, Math.floor(options.moduleSize ?? 8));
3679
- const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
3680
- const palette = paletteRgb(options.palette);
3681
- const style = normalizeRenderStyle(options.style);
4557
+ const sizing = resolveRenderSizing(options, matrix.length);
4558
+ const { moduleSize, quietZone, pixelSize } = sizing;
4559
+ const resolvedPalette = resolveRenderPalette(options);
4560
+ const palette = paletteRgb(resolvedPalette);
4561
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4562
+ const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
3682
4563
  const layout = renderLayoutForMatrix(matrix);
3683
4564
  const size = matrix.length;
3684
- const pixelSize = (size + quietZone * 2) * moduleSize;
3685
4565
  const data = new Uint8ClampedArray(pixelSize * pixelSize * 4);
3686
4566
  const white = palette.white;
4567
+ const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, resolvedPalette, codeOrMatrix);
3687
4568
 
3688
4569
  for (let i = 0; i < pixelSize * pixelSize; i++) {
3689
4570
  const p = i * 4;
@@ -3698,26 +4579,105 @@ function renderToImageData(codeOrMatrix, options = {}) {
3698
4579
  const cell = matrix[r][c];
3699
4580
  const rgb = cellRgb(cell, palette);
3700
4581
  const structural = isStructuralRenderCell(layout, r, c, cell);
3701
- const y0 = (r + quietZone) * moduleSize;
3702
- const x0 = (c + quietZone) * moduleSize;
4582
+ const rect = rasterModuleRect(r, c, quietZone, moduleSize);
4583
+ const x0 = rect.x;
4584
+ const y0 = rect.y;
4585
+ const cellWidth = rect.width;
4586
+ const cellHeight = rect.height;
4587
+ const cellSize = Math.min(cellWidth, cellHeight);
3703
4588
 
3704
4589
  if (style === RENDER_STYLES.CLASSIC || structural) {
3705
- fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, rgb);
4590
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
3706
4591
  continue;
3707
4592
  }
3708
4593
 
3709
4594
  if (style === RENDER_STYLES.DEPTH) {
3710
4595
  const opacity = depthOpacity(r, c, cell);
3711
4596
  const base = mixRgb(white, rgb, opacity);
3712
- fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, base);
3713
- if (moduleSize >= 6) {
3714
- const edge = Math.max(1, Math.floor(moduleSize * 0.08));
4597
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, base);
4598
+ if (cellSize >= 6) {
4599
+ const edge = Math.max(1, Math.floor(cellSize * 0.08));
3715
4600
  const highlight = mixRgb(base, white, 0.17);
3716
4601
  const shadow = mixRgb(base, palette.black, 0.12);
3717
- fillImageRect(data, pixelSize, x0, y0, moduleSize, edge, highlight);
3718
- fillImageRect(data, pixelSize, x0, y0, edge, moduleSize, highlight);
3719
- fillImageRect(data, pixelSize, x0, y0 + moduleSize - edge, moduleSize, edge, shadow);
3720
- fillImageRect(data, pixelSize, x0 + moduleSize - edge, y0, edge, moduleSize, shadow);
4602
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, edge, highlight);
4603
+ fillImageRect(data, pixelSize, x0, y0, edge, cellHeight, highlight);
4604
+ fillImageRect(data, pixelSize, x0, y0 + cellHeight - edge, cellWidth, edge, shadow);
4605
+ fillImageRect(data, pixelSize, x0 + cellWidth - edge, y0, edge, cellHeight, shadow);
4606
+ }
4607
+ continue;
4608
+ }
4609
+
4610
+ if (style === RENDER_STYLES.SOFT) {
4611
+ const inset = cellSize >= 6 ? Math.max(1, Math.floor(cellSize * 0.07)) : 0;
4612
+ const width = cellWidth - inset * 2;
4613
+ const height = cellHeight - inset * 2;
4614
+ const radius = Math.max(1, Math.floor(cellSize * 0.20));
4615
+ fillImageRoundedRect(data, pixelSize, x0 + inset, y0 + inset, width, height, radius, rgb);
4616
+ continue;
4617
+ }
4618
+
4619
+ if (style === RENDER_STYLES.INSET) {
4620
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
4621
+ if (cell === CELL.WHITE) continue;
4622
+ const edge = safeStyleEdge(cellSize);
4623
+ if (edge > 0) {
4624
+ const fx = insetStyleColors(rgb, palette);
4625
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, edge, fx.shadow);
4626
+ fillImageRect(data, pixelSize, x0, y0, edge, cellHeight, fx.shadow);
4627
+ fillImageRect(data, pixelSize, x0, y0 + cellHeight - edge, cellWidth, edge, fx.highlight);
4628
+ fillImageRect(data, pixelSize, x0 + cellWidth - edge, y0, edge, cellHeight, fx.highlight);
4629
+ }
4630
+ }
4631
+ }
4632
+ }
4633
+
4634
+ drawLogoToImageData(data, pixelSize, logo);
4635
+
4636
+ return { width: pixelSize, height: pixelSize, data };
4637
+ }
4638
+
4639
+ /** Render a QuadQR code/matrix to a standalone SVG string. */
4640
+ function renderToSVG(codeOrMatrix, options = {}) {
4641
+ const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
4642
+ assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
4643
+ const sizing = resolveRenderSizing(options, matrix.length);
4644
+ const { moduleSize, quietZone, pixelSize } = sizing;
4645
+ const palette = resolveRenderPalette(options);
4646
+ const paletteValues = paletteRgb(palette);
4647
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4648
+ const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
4649
+ const layout = renderLayoutForMatrix(matrix);
4650
+ const size = matrix.length;
4651
+ const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
4652
+ const body = [svgRect(0, 0, pixelSize, pixelSize, palette.white)];
4653
+
4654
+ for (let r = 0; r < size; r++) {
4655
+ for (let c = 0; c < size; c++) {
4656
+ const cell = matrix[r][c];
4657
+ const structural = isStructuralRenderCell(layout, r, c, cell);
4658
+ const x = (c + quietZone) * moduleSize;
4659
+ const y = (r + quietZone) * moduleSize;
4660
+ const fill = cellColor(cell, palette);
4661
+
4662
+ if (style === RENDER_STYLES.CLASSIC || structural) {
4663
+ body.push(svgRect(x, y, moduleSize, moduleSize, fill));
4664
+ continue;
4665
+ }
4666
+
4667
+ const rgb = cellRgb(cell, paletteValues);
4668
+
4669
+ if (style === RENDER_STYLES.DEPTH) {
4670
+ const opacity = depthOpacity(r, c, cell);
4671
+ const base = mixRgb(paletteValues.white, rgb, opacity);
4672
+ body.push(svgRect(x, y, moduleSize, moduleSize, rgbCss(base)));
4673
+ if (moduleSize >= 6) {
4674
+ const edge = Math.max(1, Math.floor(moduleSize * 0.08));
4675
+ const highlight = rgbCss(mixRgb(base, paletteValues.white, 0.17));
4676
+ const shadow = rgbCss(mixRgb(base, paletteValues.black, 0.12));
4677
+ body.push(svgRect(x, y, moduleSize, edge, highlight));
4678
+ body.push(svgRect(x, y, edge, moduleSize, highlight));
4679
+ body.push(svgRect(x, y + moduleSize - edge, moduleSize, edge, shadow));
4680
+ body.push(svgRect(x + moduleSize - edge, y, edge, moduleSize, shadow));
3721
4681
  }
3722
4682
  continue;
3723
4683
  }
@@ -3726,26 +4686,51 @@ function renderToImageData(codeOrMatrix, options = {}) {
3726
4686
  const inset = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
3727
4687
  const width = moduleSize - inset * 2;
3728
4688
  const radius = Math.max(1, Math.floor(moduleSize * 0.20));
3729
- fillImageRoundedRect(data, pixelSize, x0 + inset, y0 + inset, width, width, radius, rgb);
4689
+ body.push(svgRect(x + inset, y + inset, width, width, fill, radius));
3730
4690
  continue;
3731
4691
  }
3732
4692
 
3733
4693
  if (style === RENDER_STYLES.INSET) {
3734
- fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, rgb);
4694
+ body.push(svgRect(x, y, moduleSize, moduleSize, fill));
3735
4695
  if (cell === CELL.WHITE) continue;
3736
4696
  const edge = safeStyleEdge(moduleSize);
3737
4697
  if (edge > 0) {
3738
- const fx = insetStyleColors(rgb, palette);
3739
- fillImageRect(data, pixelSize, x0, y0, moduleSize, edge, fx.shadow);
3740
- fillImageRect(data, pixelSize, x0, y0, edge, moduleSize, fx.shadow);
3741
- fillImageRect(data, pixelSize, x0, y0 + moduleSize - edge, moduleSize, edge, fx.highlight);
3742
- fillImageRect(data, pixelSize, x0 + moduleSize - edge, y0, edge, moduleSize, fx.highlight);
4698
+ const fx = insetStyleColors(rgb, paletteValues);
4699
+ const shadow = rgbCss(fx.shadow);
4700
+ const highlight = rgbCss(fx.highlight);
4701
+ body.push(svgRect(x, y, moduleSize, edge, shadow));
4702
+ body.push(svgRect(x, y, edge, moduleSize, shadow));
4703
+ body.push(svgRect(x, y + moduleSize - edge, moduleSize, edge, highlight));
4704
+ body.push(svgRect(x + moduleSize - edge, y, edge, moduleSize, highlight));
3743
4705
  }
3744
4706
  }
3745
4707
  }
3746
4708
  }
3747
4709
 
3748
- return { width: pixelSize, height: pixelSize, data };
4710
+ if (logo) {
4711
+ if (logo.clearBackground) {
4712
+ body.push(svgRect(
4713
+ logo.backgroundX,
4714
+ logo.backgroundY,
4715
+ logo.backgroundSize,
4716
+ logo.backgroundSize,
4717
+ logo.backgroundColor,
4718
+ logo.backgroundRadius
4719
+ ));
4720
+ }
4721
+ const href = svgLogoHref(logo.source);
4722
+ assert(href, "renderToSVG() logo must be a URL/data URL string or an object with a src string.");
4723
+ body.push(
4724
+ `<image href="${escapeXmlAttribute(href)}" x="${logo.logoX}" y="${logo.logoY}" width="${logo.logoSize}" height="${logo.logoSize}" preserveAspectRatio="xMidYMid meet"/>`
4725
+ );
4726
+ }
4727
+
4728
+ return [
4729
+ '<?xml version="1.0" encoding="UTF-8"?>',
4730
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${pixelSize}" height="${pixelSize}" viewBox="0 0 ${pixelSize} ${pixelSize}" shape-rendering="crispEdges">`,
4731
+ ...body,
4732
+ "</svg>"
4733
+ ].join("\n");
3749
4734
  }
3750
4735
 
3751
4736
  function clampNumber(value, min, max) {
@@ -4369,6 +5354,364 @@ function tryAxisAlignedScan(imageData, options) {
4369
5354
  return candidates[0] ?? null;
4370
5355
  }
4371
5356
 
5357
+ const STRESS_PROFILES = Object.freeze([
5358
+ Object.freeze({ id: "clean", label: "Clean", type: "clean", severity: 0, weight: 2 }),
5359
+ Object.freeze({ id: "blur", label: "Blur", type: "blur", severity: 0.42, weight: 1 }),
5360
+ Object.freeze({ id: "dark", label: "Low brightness", type: "brightness-low", severity: 0.48, weight: 1 }),
5361
+ Object.freeze({ id: "bright", label: "High exposure", type: "brightness-high", severity: 0.34, weight: 1 }),
5362
+ Object.freeze({ id: "shadow", label: "Uneven shadow", type: "shadow", severity: 0.55, weight: 1.2 }),
5363
+ Object.freeze({ id: "contrast", label: "Contrast loss", type: "contrast-loss", severity: 0.42, weight: 1 }),
5364
+ Object.freeze({ id: "perspective", label: "Perspective", type: "perspective", severity: 0.42, weight: 1.3 }),
5365
+ Object.freeze({ id: "jpeg", label: "JPEG-like compression", type: "jpeg", severity: 0.50, weight: 0.8 }),
5366
+ Object.freeze({ id: "downscale", label: "Downscale", type: "downscale", severity: 0.52, weight: 0.9 })
5367
+ ]);
5368
+
5369
+ function cloneImageDataLike(imageData) {
5370
+ return { width: imageData.width, height: imageData.height, data: new Uint8ClampedArray(imageData.data) };
5371
+ }
5372
+
5373
+ function sampleRgbaBilinear(imageData, x, y) {
5374
+ const { width, height, data } = imageData;
5375
+ const cx = clampNumber(x, 0, width - 1);
5376
+ const cy = clampNumber(y, 0, height - 1);
5377
+ const x0 = Math.floor(cx), y0 = Math.floor(cy);
5378
+ const x1 = Math.min(width - 1, x0 + 1), y1 = Math.min(height - 1, y0 + 1);
5379
+ const tx = cx - x0, ty = cy - y0;
5380
+ const get = (xx, yy, channel) => data[(yy * width + xx) * 4 + channel];
5381
+ const out = [0, 0, 0, 255];
5382
+ for (let ch = 0; ch < 4; ch++) {
5383
+ const a = get(x0, y0, ch) * (1 - tx) + get(x1, y0, ch) * tx;
5384
+ const b = get(x0, y1, ch) * (1 - tx) + get(x1, y1, ch) * tx;
5385
+ out[ch] = a * (1 - ty) + b * ty;
5386
+ }
5387
+ return out;
5388
+ }
5389
+
5390
+ function resizeImageDataLike(imageData, width, height) {
5391
+ width = Math.max(1, Math.round(width));
5392
+ height = Math.max(1, Math.round(height));
5393
+ const out = new Uint8ClampedArray(width * height * 4);
5394
+ for (let y = 0; y < height; y++) {
5395
+ for (let x = 0; x < width; x++) {
5396
+ const sx = (x + 0.5) * imageData.width / width - 0.5;
5397
+ const sy = (y + 0.5) * imageData.height / height - 0.5;
5398
+ const rgba = sampleRgbaBilinear(imageData, sx, sy);
5399
+ const p = (y * width + x) * 4;
5400
+ out[p] = rgba[0]; out[p + 1] = rgba[1]; out[p + 2] = rgba[2]; out[p + 3] = rgba[3];
5401
+ }
5402
+ }
5403
+ return { width, height, data: out };
5404
+ }
5405
+
5406
+ function boxBlurImage(imageData, radius) {
5407
+ radius = Math.max(1, Math.round(radius));
5408
+ const { width, height, data } = imageData;
5409
+ const horizontal = new Float32Array(data.length);
5410
+ const out = new Uint8ClampedArray(data.length);
5411
+ for (let y = 0; y < height; y++) {
5412
+ for (let x = 0; x < width; x++) {
5413
+ for (let ch = 0; ch < 3; ch++) {
5414
+ let sum = 0, count = 0;
5415
+ for (let dx = -radius; dx <= radius; dx++) {
5416
+ const xx = x + dx;
5417
+ if (xx < 0 || xx >= width) continue;
5418
+ sum += data[(y * width + xx) * 4 + ch]; count++;
5419
+ }
5420
+ horizontal[(y * width + x) * 4 + ch] = sum / count;
5421
+ }
5422
+ horizontal[(y * width + x) * 4 + 3] = 255;
5423
+ }
5424
+ }
5425
+ for (let y = 0; y < height; y++) {
5426
+ for (let x = 0; x < width; x++) {
5427
+ const p = (y * width + x) * 4;
5428
+ for (let ch = 0; ch < 3; ch++) {
5429
+ let sum = 0, count = 0;
5430
+ for (let dy = -radius; dy <= radius; dy++) {
5431
+ const yy = y + dy;
5432
+ if (yy < 0 || yy >= height) continue;
5433
+ sum += horizontal[(yy * width + x) * 4 + ch]; count++;
5434
+ }
5435
+ out[p + ch] = sum / count;
5436
+ }
5437
+ out[p + 3] = 255;
5438
+ }
5439
+ }
5440
+ return { width, height, data: out };
5441
+ }
5442
+
5443
+ function perspectiveStress(imageData, severity) {
5444
+ const { width, height } = imageData;
5445
+ const inset = Math.min(width, height) * (0.025 + 0.11 * severity);
5446
+ const source = [{ x: 0, y: 0 }, { x: width - 1, y: 0 }, { x: width - 1, y: height - 1 }, { x: 0, y: height - 1 }];
5447
+ const destination = [
5448
+ { x: inset * 1.15, y: inset * 0.25 },
5449
+ { x: width - 1 - inset * 0.25, y: inset },
5450
+ { x: width - 1 - inset * 1.0, y: height - 1 - inset * 0.35 },
5451
+ { x: inset * 0.20, y: height - 1 - inset * 0.95 }
5452
+ ];
5453
+ const inverse = computeHomography(destination, source);
5454
+ const out = new Uint8ClampedArray(width * height * 4);
5455
+ out.fill(255);
5456
+ for (let y = 0; y < height; y++) {
5457
+ for (let x = 0; x < width; x++) {
5458
+ const src = projectPoint(inverse, x, y);
5459
+ if (src.x < 0 || src.y < 0 || src.x >= width || src.y >= height) continue;
5460
+ const rgba = sampleRgbaBilinear(imageData, src.x, src.y);
5461
+ const p = (y * width + x) * 4;
5462
+ out[p] = rgba[0]; out[p + 1] = rgba[1]; out[p + 2] = rgba[2]; out[p + 3] = 255;
5463
+ }
5464
+ }
5465
+ return { width, height, data: out };
5466
+ }
5467
+
5468
+ /** Apply one deterministic camera/print-style distortion to ImageData. */
5469
+ function applyStressDistortion(imageData, type, severity = 0.5, options = {}) {
5470
+ assert(imageData?.data && imageData.width && imageData.height, "applyStressDistortion requires ImageData-like input.");
5471
+ const s = clampNumber(Number(severity), 0, 1);
5472
+ if (type === "clean") return cloneImageDataLike(imageData);
5473
+ if (type === "blur") return boxBlurImage(imageData, 1 + s * 2.2);
5474
+ if (type === "perspective") return perspectiveStress(imageData, s);
5475
+ if (type === "downscale") {
5476
+ const scale = Math.max(0.20, 1 - s * 0.72);
5477
+ const small = resizeImageDataLike(imageData, imageData.width * scale, imageData.height * scale);
5478
+ return resizeImageDataLike(small, imageData.width, imageData.height);
5479
+ }
5480
+
5481
+ const out = cloneImageDataLike(imageData);
5482
+ const { width, height, data } = out;
5483
+ const quantStep = 4 + Math.round(s * 30);
5484
+ for (let y = 0; y < height; y++) {
5485
+ for (let x = 0; x < width; x++) {
5486
+ const p = (y * width + x) * 4;
5487
+ for (let ch = 0; ch < 3; ch++) {
5488
+ let value = data[p + ch];
5489
+ if (type === "brightness-low") value *= 1 - s * 0.52;
5490
+ else if (type === "brightness-high") value = value + (255 - value) * s * 0.48;
5491
+ else if (type === "contrast-loss") value = 128 + (value - 128) * (1 - s * 0.48);
5492
+ else if (type === "shadow") {
5493
+ const nx = x / Math.max(1, width - 1);
5494
+ const ny = y / Math.max(1, height - 1);
5495
+ const shadow = clampNumber((nx * 0.65 + ny * 0.35 - 0.18) / 0.82, 0, 1);
5496
+ value *= 1 - shadow * s * 0.62;
5497
+ } else if (type === "jpeg") {
5498
+ value = Math.round(value / quantStep) * quantStep;
5499
+ const block = ((Math.floor(x / 8) + Math.floor(y / 8)) & 1) ? 1 : -1;
5500
+ value += block * s * 3;
5501
+ } else if (type === "warm") {
5502
+ if (ch === 0) value *= 1 + s * 0.12;
5503
+ if (ch === 2) value *= 1 - s * 0.22;
5504
+ }
5505
+ data[p + ch] = clampNumber(Math.round(value), 0, 255);
5506
+ }
5507
+ data[p + 3] = 255;
5508
+ }
5509
+ }
5510
+ return out;
5511
+ }
5512
+
5513
+ function runImageStressTest(imageData, expected = {}, options = {}) {
5514
+ const profiles = options.profiles ?? STRESS_PROFILES;
5515
+ const results = [];
5516
+ let passedWeight = 0;
5517
+ let totalWeight = 0;
5518
+ for (const profile of profiles) {
5519
+ const weight = Number(profile.weight ?? 1);
5520
+ totalWeight += weight;
5521
+ const distorted = applyStressDistortion(imageData, profile.type ?? profile.id, profile.severity ?? 0.5, profile);
5522
+ const started = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
5523
+ try {
5524
+ const decoded = scanImageData(distorted, {
5525
+ minVersion: expected.version ?? options.minVersion ?? MIN_VERSION,
5526
+ maxVersion: expected.version ?? options.maxVersion ?? MAX_VERSION,
5527
+ debug: false
5528
+ });
5529
+ const matches = expected.crc32 == null || decoded.crc32 === expected.crc32;
5530
+ if (matches) passedWeight += weight;
5531
+ results.push({
5532
+ id: profile.id,
5533
+ label: profile.label,
5534
+ type: profile.type,
5535
+ severity: profile.severity,
5536
+ passed: matches,
5537
+ confidence: decoded.confidence ?? decoded.diagnostics?.confidence ?? null,
5538
+ correctedSymbols: decoded.correctedSymbols ?? 0,
5539
+ elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
5540
+ image: options.includeImages ? distorted : undefined,
5541
+ error: matches ? null : "Decoded payload did not match expected CRC."
5542
+ });
5543
+ } catch (error) {
5544
+ results.push({
5545
+ id: profile.id,
5546
+ label: profile.label,
5547
+ type: profile.type,
5548
+ severity: profile.severity,
5549
+ passed: false,
5550
+ confidence: 0,
5551
+ correctedSymbols: null,
5552
+ elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
5553
+ image: options.includeImages ? distorted : undefined,
5554
+ error: error.message
5555
+ });
5556
+ }
5557
+ }
5558
+ const passPercent = totalWeight ? passedWeight / totalWeight * 100 : 0;
5559
+ const averageConfidence = results.filter((r) => r.passed && Number.isFinite(r.confidence)).reduce((sum, r, _, arr) => sum + r.confidence / arr.length, 0);
5560
+ const score = clampNumber(passPercent * 0.86 + averageConfidence * 100 * 0.14, 0, 100);
5561
+ const rating = score >= 90 ? "Excellent" : score >= 75 ? "Good" : score >= 50 ? "Risky" : "Likely unscannable";
5562
+ return {
5563
+ score,
5564
+ rating,
5565
+ passPercent,
5566
+ passed: results.filter((r) => r.passed).length,
5567
+ total: results.length,
5568
+ averageConfidence,
5569
+ results
5570
+ };
5571
+ }
5572
+
5573
+ /** Render and run the standard scanability torture suite. */
5574
+ function assessScanability(code, renderOptions = {}, options = {}) {
5575
+ assert(code?.matrix, "assessScanability expects an encoded QuadQR object.");
5576
+ const testImageSize = Math.max(240, Math.min(Number(options.testImageSize ?? 480), Number(renderOptions.imageSize ?? 720)));
5577
+ const image = renderToImageData(code, { ...renderOptions, imageSize: testImageSize });
5578
+ const report = runImageStressTest(image, { version: code.version, crc32: code.crc32 }, options);
5579
+ const autoLogoSize = renderOptions.logo ? estimateSafeLogoSize(code, renderOptions) : null;
5580
+ return {
5581
+ ...report,
5582
+ version: code.version,
5583
+ eccLevel: code.eccLevel,
5584
+ utilization: code.utilization,
5585
+ testImageSize,
5586
+ autoLogoSize,
5587
+ recommendations: [
5588
+ ...(report.score < 75 ? ["Increase ECC, reduce logo size, use Classic style, or increase output/physical size."] : []),
5589
+ ...(Number(renderOptions.quietZone ?? 4) < 4 ? ["Use a quiet zone of at least 4 modules for robust camera/print scanning."] : []),
5590
+ ...(renderOptions.logo && autoLogoSize != null ? [`Auto-safe logo estimate: ${(autoLogoSize * 100).toFixed(1)}% of the symbol width.`] : []),
5591
+ ...(normalizeRenderMode(renderOptions.mode ?? renderOptions.renderMode ?? "screen") === "print" ? getPrintGuidance(code, renderOptions).recommendations : [])
5592
+ ]
5593
+ };
5594
+ }
5595
+
5596
+ function paletteConfidence(observedPalette) {
5597
+ if (!observedPalette) return 0.5;
5598
+ const values = ["black", "white", "red", "green", "blue"].map((name) => observedPalette[name]).filter(Boolean);
5599
+ if (values.length < 5) return 0.5;
5600
+ let minDistance = Infinity;
5601
+ for (let i = 0; i < values.length; i++) {
5602
+ for (let j = i + 1; j < values.length; j++) {
5603
+ const a = values[i], b = values[j];
5604
+ minDistance = Math.min(minDistance, Math.hypot(a.r - b.r, a.g - b.g, a.b - b.b));
5605
+ }
5606
+ }
5607
+ return clampNumber((minDistance - 28) / 120, 0, 1);
5608
+ }
5609
+
5610
+ function scannerDiagnostics(result, context = {}, options = {}) {
5611
+ const info = result ? getVersionInfo(result.version, { ecc: result.eccLevel }) : null;
5612
+ const correctableTotal = result && info
5613
+ ? Number(info.correctableHeaderSymbols ?? 0) + Number(result.eccBlocks ?? 0) * Number(result.correctableSymbolsPerBlock ?? 0)
5614
+ : 0;
5615
+ const eccUtilization = correctableTotal > 0 ? clampNumber((result.correctedSymbols ?? 0) / correctableTotal, 0, 1) : 0;
5616
+ const structureConfidence = clampNumber(
5617
+ result?.structureScore ?? context.observations?.at(-1)?.structureScore ?? (result ? 0.92 : 0), 0, 1
5618
+ );
5619
+ const cellConfidence = clampNumber(result?.averageCellConfidence ?? 0.75, 0, 1);
5620
+ const geometryConfidence = result
5621
+ ? clampNumber(result.perspectiveCorrected ? Math.max(0.55, structureConfidence) : Math.max(0.65, structureConfidence * 0.9), 0, 1)
5622
+ : (context.geometry?.length ? 0.45 : 0);
5623
+ const calibrationConfidence = result
5624
+ ? clampNumber(result.colorCalibrated ? paletteConfidence(result.observedPalette) : 0.55, 0, 1)
5625
+ : 0;
5626
+ const confidence = result
5627
+ ? clampNumber(cellConfidence * 0.50 + structureConfidence * 0.20 + geometryConfidence * 0.15 + calibrationConfidence * 0.15 - eccUtilization * 0.12, 0, 1)
5628
+ : 0;
5629
+ const geometryCandidates = (context.geometry ?? []).slice(0, 8).map((geometry) => ({
5630
+ version: geometry.version,
5631
+ size: geometry.size,
5632
+ score: geometry.score,
5633
+ estimatedSize: geometry.estimatedSize,
5634
+ alignmentScore: geometry.alignmentScore,
5635
+ alignmentGridScore: geometry.alignmentGridScore,
5636
+ corners: geometry.corners ?? geometry.quad ?? null,
5637
+ finders: geometry.finders ?? null
5638
+ }));
5639
+ const observations = context.observations ?? [];
5640
+ const latest = observations.at(-1) ?? null;
5641
+ const stages = {
5642
+ finderDetection: geometryCandidates.length > 0 || Boolean(result && !result.perspectiveCorrected),
5643
+ geometry: geometryCandidates.length > 0 || Boolean(result && !result.perspectiveCorrected),
5644
+ sampling: observations.length > 0 || Boolean(result),
5645
+ colorClassification: observations.length > 0 || Boolean(result),
5646
+ ecc: Boolean(result),
5647
+ crc: Boolean(result),
5648
+ payload: Boolean(result)
5649
+ };
5650
+ let failedStage = null;
5651
+ if (!result) {
5652
+ failedStage = !stages.finderDetection ? "finder-detection"
5653
+ : !stages.sampling ? "perspective-geometry"
5654
+ : "color-classification-or-ecc";
5655
+ }
5656
+ return {
5657
+ confidence,
5658
+ cellConfidence,
5659
+ structureConfidence,
5660
+ geometryConfidence,
5661
+ calibrationConfidence,
5662
+ eccUtilization,
5663
+ correctedErrors: result?.correctedSymbols ?? 0,
5664
+ erasureSymbols: result?.erasureSymbols ?? 0,
5665
+ lowConfidenceCells: result?.lowConfidenceCells ?? latest?.lowConfidenceCells ?? null,
5666
+ stages,
5667
+ failedStage,
5668
+ geometryCandidates,
5669
+ vision: context.vision ?? null,
5670
+ sampled: latest ? {
5671
+ version: latest.version,
5672
+ samplingMode: latest.samplingMode,
5673
+ colorNormalization: latest.colorNormalization,
5674
+ structureScore: latest.structureScore,
5675
+ averageCellConfidence: latest.averageCellConfidence,
5676
+ observedPalette: latest.observedPalette ?? null,
5677
+ matrix: options.debugMatrices === false ? undefined : latest.matrix,
5678
+ confidenceMatrix: options.debugMatrices === false ? undefined : latest.confidence
5679
+ } : null
5680
+ };
5681
+ }
5682
+
5683
+ function decorateScanResult(result, context, options) {
5684
+ const diagnostics = scannerDiagnostics(result, context, options);
5685
+ return {
5686
+ ...result,
5687
+ confidence: diagnostics.confidence,
5688
+ geometryConfidence: diagnostics.geometryConfidence,
5689
+ calibrationConfidence: diagnostics.calibrationConfidence,
5690
+ structureConfidence: diagnostics.structureConfidence,
5691
+ correctedErrors: result.correctedSymbols ?? 0,
5692
+ eccUtilization: diagnostics.eccUtilization,
5693
+ diagnostics: options.debug ? diagnostics : {
5694
+ confidence: diagnostics.confidence,
5695
+ geometryConfidence: diagnostics.geometryConfidence,
5696
+ calibrationConfidence: diagnostics.calibrationConfidence,
5697
+ structureConfidence: diagnostics.structureConfidence,
5698
+ eccUtilization: diagnostics.eccUtilization,
5699
+ correctedErrors: diagnostics.correctedErrors,
5700
+ erasureSymbols: diagnostics.erasureSymbols
5701
+ }
5702
+ };
5703
+ }
5704
+
5705
+ /** Non-throwing scanner debug API with detailed stage diagnostics. */
5706
+ function debugScanImageData(imageData, options = {}) {
5707
+ try {
5708
+ const result = scanImageData(imageData, { ...options, debug: true });
5709
+ return { ok: true, result, debug: result.diagnostics };
5710
+ } catch (error) {
5711
+ return { ok: false, error: error.message, debug: error.debug ?? null };
5712
+ }
5713
+ }
5714
+
4372
5715
  function scanImageData(imageData, options = {}) {
4373
5716
  assert(imageData && imageData.data && imageData.width && imageData.height, "Valid ImageData is required.");
4374
5717
  const minVersion = options.minVersion ?? MIN_VERSION;
@@ -4377,11 +5720,19 @@ function scanImageData(imageData, options = {}) {
4377
5720
  validateVersion(maxVersion);
4378
5721
 
4379
5722
  const geometryCollector = [];
4380
- const scanOptions = { ...options, _geometryCollector: geometryCollector };
5723
+ const observationCollector = options.debug ? [] : (Array.isArray(options._observationCollector) ? options._observationCollector : []);
5724
+ const visionDiagnostics = options.debug ? { passes: [] } : (options._visionDiagnostics ?? null);
5725
+ const debugContext = { geometry: geometryCollector, observations: observationCollector, vision: visionDiagnostics };
5726
+ const scanOptions = {
5727
+ ...options,
5728
+ _geometryCollector: geometryCollector,
5729
+ _observationCollector: observationCollector,
5730
+ _visionDiagnostics: visionDiagnostics
5731
+ };
4381
5732
 
4382
5733
  if (options.perspective !== false) {
4383
5734
  const perspective = tryPerspectiveScan(imageData, scanOptions);
4384
- if (perspective) return perspective;
5735
+ if (perspective) return decorateScanResult(perspective, debugContext, options);
4385
5736
  }
4386
5737
 
4387
5738
  // If the normal scanner could see QuadQR geometry but could not decode the
@@ -4408,12 +5759,12 @@ function scanImageData(imageData, options = {}) {
4408
5759
  _geometryCollector: []
4409
5760
  });
4410
5761
  if (recovered) {
4411
- return {
5762
+ return decorateScanResult({
4412
5763
  ...recovered,
4413
5764
  autoEnhanced: true,
4414
5765
  recoveryMode: "auto-tone-contrast-color",
4415
5766
  originalColorNormalization: recovered.colorNormalization
4416
- };
5767
+ }, debugContext, options);
4417
5768
  }
4418
5769
  } catch {
4419
5770
  // Continue to the axis-aligned fallback.
@@ -4422,7 +5773,7 @@ function scanImageData(imageData, options = {}) {
4422
5773
 
4423
5774
  if (options.axisAlignedFallback !== false) {
4424
5775
  const axis = tryAxisAlignedScan(imageData, scanOptions);
4425
- if (axis) return axis;
5776
+ if (axis) return decorateScanResult(axis, debugContext, options);
4426
5777
  }
4427
5778
 
4428
5779
  // Static images sometimes have such low contrast that locator detection itself
@@ -4449,20 +5800,22 @@ function scanImageData(imageData, options = {}) {
4449
5800
  };
4450
5801
  if (options.perspective !== false) {
4451
5802
  const perspective = tryPerspectiveScan(enhanced, recoveryOptions);
4452
- if (perspective) return { ...perspective, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" };
5803
+ if (perspective) return decorateScanResult({ ...perspective, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
4453
5804
  }
4454
5805
  if (options.axisAlignedFallback !== false) {
4455
5806
  const axis = tryAxisAlignedScan(enhanced, recoveryOptions);
4456
- if (axis) return { ...axis, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" };
5807
+ if (axis) return decorateScanResult({ ...axis, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
4457
5808
  }
4458
5809
  } catch {
4459
5810
  // Fall through to the normal scan failure below.
4460
5811
  }
4461
5812
  }
4462
5813
 
4463
- throw new Error(
5814
+ const error = new Error(
4464
5815
  "No valid QuadQR code found. Try better lighting, fill more of the frame, keep all locator patterns visible, or use a less blurred image."
4465
5816
  );
5817
+ if (options.debug) error.debug = scannerDiagnostics(null, debugContext, options);
5818
+ throw error;
4466
5819
  }
4467
5820
 
4468
5821
  async function scanFile(file, options = {}) {
@@ -5282,7 +6635,7 @@ const internals = Object.freeze({
5282
6635
  SECURE_FLAG
5283
6636
  });
5284
6637
 
5285
- 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 };
6638
+ 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 };
5286
6639
  })();
5287
6640
  const QuadQR = __qqr_quadqr_js;
5288
6641