quadqr-js 1.0.1 → 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.
@@ -954,7 +954,7 @@ function luminance(rgb) {
954
954
  return 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b;
955
955
  }
956
956
 
957
- function buildGray(imageData) {
957
+ function buildGray(imageData, mode = "luminance") {
958
958
  const gray = new Uint8Array(imageData.width * imageData.height);
959
959
  for (let i = 0; i < gray.length; i++) {
960
960
  const p = i * 4;
@@ -962,7 +962,15 @@ function buildGray(imageData) {
962
962
  const r = imageData.data[p] * a + 255 * (1 - a);
963
963
  const g = imageData.data[p + 1] * a + 255 * (1 - a);
964
964
  const b = imageData.data[p + 2] * a + 255 * (1 - a);
965
- gray[i] = Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b);
965
+
966
+ // Finder detection is special for QuadQR. Saturated red/green/blue data
967
+ // cells can look very dark in normal luminance, especially blue under a
968
+ // warm phone camera. The HSV "value" channel keeps any cell with one
969
+ // strong RGB component bright while structural black stays dark in all
970
+ // channels. That makes the black/white finder rings far easier to isolate.
971
+ gray[i] = Math.round(mode === "value"
972
+ ? Math.max(r, g, b)
973
+ : 0.2126 * r + 0.7152 * g + 0.0722 * b);
966
974
  }
967
975
  return gray;
968
976
  }
@@ -998,12 +1006,28 @@ function otsuThreshold(gray) {
998
1006
  return threshold;
999
1007
  }
1000
1008
 
1001
- function buildBinary(imageData) {
1002
- const gray = buildGray(imageData);
1003
- const threshold = otsuThreshold(gray);
1009
+ function binaryAtThreshold(gray, threshold) {
1004
1010
  const binary = new Uint8Array(gray.length);
1005
1011
  for (let i = 0; i < gray.length; i++) binary[i] = gray[i] <= threshold ? 1 : 0;
1006
- return { gray, binary, threshold };
1012
+ return binary;
1013
+ }
1014
+
1015
+ function buildBinary(imageData, options = {}) {
1016
+ const grayMode = options.grayMode ?? "luminance";
1017
+ const gray = buildGray(imageData, grayMode);
1018
+ const baseThreshold = otsuThreshold(gray);
1019
+ const threshold = clamp(
1020
+ Math.round(baseThreshold + (options.thresholdOffset ?? 0)),
1021
+ 8,
1022
+ 247
1023
+ );
1024
+ return {
1025
+ gray,
1026
+ binary: binaryAtThreshold(gray, threshold),
1027
+ threshold,
1028
+ baseThreshold,
1029
+ grayMode
1030
+ };
1007
1031
  }
1008
1032
 
1009
1033
  function runsForRow(binary, width, row) {
@@ -1038,14 +1062,14 @@ function runsForColumn(binary, width, height, col) {
1038
1062
  return runs;
1039
1063
  }
1040
1064
 
1041
- function finderRatioScore(lengths) {
1065
+ function finderRatioScore(lengths, toleranceScale = 1) {
1042
1066
  const total = lengths.reduce((sum, value) => sum + value, 0);
1043
1067
  if (total < 7) return Infinity;
1044
1068
  const module = total / 7;
1045
1069
  const expected = [module, module, 3 * module, module, module];
1046
1070
  let score = 0;
1047
1071
  for (let i = 0; i < 5; i++) {
1048
- const tolerance = i === 2 ? module * 1.25 : module * 0.8;
1072
+ const tolerance = (i === 2 ? module * 1.25 : module * 0.8) * toleranceScale;
1049
1073
  const diff = Math.abs(lengths[i] - expected[i]);
1050
1074
  if (diff > tolerance) return Infinity;
1051
1075
  score += diff / Math.max(1, expected[i]);
@@ -1053,14 +1077,14 @@ function finderRatioScore(lengths) {
1053
1077
  return score;
1054
1078
  }
1055
1079
 
1056
- function findWindowContainingCoordinate(runs, coordinate) {
1080
+ function findWindowContainingCoordinate(runs, coordinate, toleranceScale = 1) {
1057
1081
  for (let i = 2; i < runs.length - 2; i++) {
1058
1082
  const centerRun = runs[i];
1059
1083
  if (centerRun.color !== 1) continue;
1060
1084
  if (coordinate < centerRun.start || coordinate >= centerRun.start + centerRun.length) continue;
1061
1085
  const window = runs.slice(i - 2, i + 3);
1062
1086
  if (window.map((run) => run.color).join("") !== "10101") continue;
1063
- const score = finderRatioScore(window.map((run) => run.length));
1087
+ const score = finderRatioScore(window.map((run) => run.length), toleranceScale);
1064
1088
  if (!Number.isFinite(score)) continue;
1065
1089
  const first = window[0].start;
1066
1090
  const total = window.reduce((sum, run) => sum + run.length, 0);
@@ -1073,17 +1097,17 @@ function findWindowContainingCoordinate(runs, coordinate) {
1073
1097
  return null;
1074
1098
  }
1075
1099
 
1076
- function crossCheckVertical(binary, width, height, x, y) {
1100
+ function crossCheckVertical(binary, width, height, x, y, toleranceScale = 1) {
1077
1101
  const col = clamp(Math.round(x), 0, width - 1);
1078
- return findWindowContainingCoordinate(runsForColumn(binary, width, height, col), y);
1102
+ return findWindowContainingCoordinate(runsForColumn(binary, width, height, col), y, toleranceScale);
1079
1103
  }
1080
1104
 
1081
- function crossCheckHorizontal(binary, width, height, x, y) {
1105
+ function crossCheckHorizontal(binary, width, height, x, y, toleranceScale = 1) {
1082
1106
  const row = clamp(Math.round(y), 0, height - 1);
1083
- return findWindowContainingCoordinate(runsForRow(binary, width, row), x);
1107
+ return findWindowContainingCoordinate(runsForRow(binary, width, row), x, toleranceScale);
1084
1108
  }
1085
1109
 
1086
- function clusterFinderCandidates(raw) {
1110
+ function clusterFinderCandidates(raw, minConfirmations = 2) {
1087
1111
  const clusters = [];
1088
1112
  raw.sort((a, b) => a.moduleSize - b.moduleSize);
1089
1113
 
@@ -1116,7 +1140,7 @@ function clusterFinderCandidates(raw) {
1116
1140
  }
1117
1141
 
1118
1142
  return clusters
1119
- .filter((candidate) => candidate.confirmations >= 2)
1143
+ .filter((candidate) => candidate.confirmations >= minConfirmations)
1120
1144
  .sort((a, b) =>
1121
1145
  (b.confirmations - a.confirmations) ||
1122
1146
  (a.score - b.score) ||
@@ -1124,9 +1148,12 @@ function clusterFinderCandidates(raw) {
1124
1148
  );
1125
1149
  }
1126
1150
 
1127
- function detectFinderCandidates(binary, width, height) {
1151
+ function detectFinderCandidates(binary, width, height, options = {}) {
1128
1152
  const raw = [];
1129
1153
  const rowStep = height > 1200 ? 2 : 1;
1154
+ const toleranceScale = options.toleranceScale ?? 1;
1155
+ const moduleSpreadLimit = options.moduleSpreadLimit ?? 0.45;
1156
+ const minConfirmations = options.minConfirmations ?? 2;
1130
1157
 
1131
1158
  for (let y = 0; y < height; y += rowStep) {
1132
1159
  const runs = runsForRow(binary, width, y);
@@ -1134,13 +1161,13 @@ function detectFinderCandidates(binary, width, height) {
1134
1161
  const window = runs.slice(i, i + 5);
1135
1162
  if (window.map((run) => run.color).join("") !== "10101") continue;
1136
1163
  const lengths = window.map((run) => run.length);
1137
- const ratioScore = finderRatioScore(lengths);
1164
+ const ratioScore = finderRatioScore(lengths, toleranceScale);
1138
1165
  if (!Number.isFinite(ratioScore)) continue;
1139
1166
  const total = lengths.reduce((sum, value) => sum + value, 0);
1140
1167
  const centerX = window[0].start + total / 2;
1141
- const vertical = crossCheckVertical(binary, width, height, centerX, y);
1168
+ const vertical = crossCheckVertical(binary, width, height, centerX, y, toleranceScale);
1142
1169
  if (!vertical) continue;
1143
- const horizontal = crossCheckHorizontal(binary, width, height, centerX, vertical.center);
1170
+ const horizontal = crossCheckHorizontal(binary, width, height, centerX, vertical.center, toleranceScale);
1144
1171
  if (!horizontal) continue;
1145
1172
 
1146
1173
  const moduleSize = (total / 7 + vertical.moduleSize + horizontal.moduleSize) / 3;
@@ -1149,7 +1176,7 @@ function detectFinderCandidates(binary, width, height) {
1149
1176
  Math.abs(moduleSize - vertical.moduleSize),
1150
1177
  Math.abs(moduleSize - horizontal.moduleSize)
1151
1178
  ) / moduleSize;
1152
- if (moduleSpread > 0.45) continue;
1179
+ if (moduleSpread > moduleSpreadLimit) continue;
1153
1180
 
1154
1181
  raw.push({
1155
1182
  x: horizontal.center,
@@ -1160,7 +1187,7 @@ function detectFinderCandidates(binary, width, height) {
1160
1187
  }
1161
1188
  }
1162
1189
 
1163
- return clusterFinderCandidates(raw);
1190
+ return clusterFinderCandidates(raw, minConfirmations);
1164
1191
  }
1165
1192
 
1166
1193
  function dot(a, b) {
@@ -1384,15 +1411,11 @@ function projectPoint(h, u, v) {
1384
1411
  };
1385
1412
  }
1386
1413
 
1387
- function detectCodeGeometry(imageData, options = {}) {
1388
- assert(imageData?.data && imageData.width && imageData.height, "Valid image data is required.");
1414
+ function geometryCandidatesFromBinary(binary, width, height, finders, threshold, options = {}) {
1415
+ if (finders.length < 3) return [];
1389
1416
  const minVersion = options.minVersion ?? 1;
1390
1417
  const maxVersion = options.maxVersion ?? 40;
1391
1418
  const maxCandidates = options.maxCandidates ?? 8;
1392
- const { binary, threshold } = buildBinary(imageData);
1393
- const finders = detectFinderCandidates(binary, imageData.width, imageData.height);
1394
- if (finders.length < 3) return [];
1395
-
1396
1419
  const triples = chooseFinderTriples(finders, 20);
1397
1420
  const geometries = [];
1398
1421
 
@@ -1405,8 +1428,8 @@ function detectCodeGeometry(imageData, options = {}) {
1405
1428
  for (const item of versions) {
1406
1429
  const version = item.version;
1407
1430
  const size = sizeForVersion(version);
1408
- const alignment = searchAlignment(binary, imageData.width, imageData.height, triple, version);
1409
- if (alignment.score < 0.72) continue;
1431
+ const alignment = searchAlignment(binary, width, height, triple, version);
1432
+ if (alignment.score < (options.alignmentThreshold ?? 0.72)) continue;
1410
1433
 
1411
1434
  const alignmentTarget = alignment.target;
1412
1435
  const dest = [
@@ -1429,8 +1452,8 @@ function detectCodeGeometry(imageData, options = {}) {
1429
1452
  continue;
1430
1453
  }
1431
1454
 
1432
- const alignmentGrid = scoreAlignmentGrid(binary, imageData.width, imageData.height, homography, version);
1433
- if (alignmentGrid.score < 0.68) continue;
1455
+ const alignmentGrid = scoreAlignmentGrid(binary, width, height, homography, version);
1456
+ if (alignmentGrid.score < (options.alignmentGridThreshold ?? 0.68)) continue;
1434
1457
 
1435
1458
  const versionPenalty = item.error / 4;
1436
1459
  const alignmentConfidence = 0.55 * alignment.score + 0.45 * alignmentGrid.score;
@@ -1452,13 +1475,13 @@ function detectCodeGeometry(imageData, options = {}) {
1452
1475
  patternScores: alignmentGrid.patternScores
1453
1476
  },
1454
1477
  threshold,
1478
+ finderMethod: options.finderMethod,
1455
1479
  estimatedSize,
1456
1480
  score
1457
1481
  });
1458
1482
  }
1459
1483
  }
1460
1484
 
1461
- // Deduplicate roughly identical geometry/version hypotheses.
1462
1485
  const deduped = [];
1463
1486
  geometries.sort((a, b) => b.score - a.score);
1464
1487
  for (const geometry of geometries) {
@@ -1475,33 +1498,486 @@ function detectCodeGeometry(imageData, options = {}) {
1475
1498
  return deduped;
1476
1499
  }
1477
1500
 
1501
+ function pushFinderDiagnostic(options, finderMethod, threshold, finders, geometries) {
1502
+ if (!options.diagnostics || typeof options.diagnostics !== "object") return;
1503
+ const passes = Array.isArray(options.diagnostics.passes)
1504
+ ? options.diagnostics.passes
1505
+ : (options.diagnostics.passes = []);
1506
+ passes.push({
1507
+ label: options.diagnosticLabel ?? "perspective",
1508
+ finderMethod,
1509
+ width: options.width,
1510
+ height: options.height,
1511
+ threshold,
1512
+ finderCount: finders.length,
1513
+ finders: finders.map((finder) => ({
1514
+ x: finder.x,
1515
+ y: finder.y,
1516
+ moduleSize: finder.moduleSize,
1517
+ confirmations: finder.confirmations,
1518
+ score: finder.score
1519
+ })),
1520
+ geometries: geometries.map((geometry) => ({
1521
+ version: geometry.version,
1522
+ size: geometry.size,
1523
+ score: geometry.score,
1524
+ estimatedSize: geometry.estimatedSize,
1525
+ alignmentScore: geometry.alignment.score,
1526
+ alignmentGridScore: geometry.alignment.gridScore,
1527
+ finderMethod: geometry.finderMethod,
1528
+ finders: {
1529
+ topLeft: { ...geometry.finders.topLeft },
1530
+ topRight: { ...geometry.finders.topRight },
1531
+ bottomLeft: { ...geometry.finders.bottomLeft }
1532
+ },
1533
+ alignmentCenter: { ...geometry.alignment.center },
1534
+ sourcePoints: geometry.sourcePoints.map((point) => ({ ...point }))
1535
+ }))
1536
+ });
1537
+ }
1538
+
1539
+ function detectCodeGeometry(imageData, options = {}) {
1540
+ assert(imageData?.data && imageData.width && imageData.height, "Valid image data is required.");
1541
+ const width = imageData.width;
1542
+ const height = imageData.height;
1543
+ const maxCandidates = options.maxCandidates ?? 8;
1544
+
1545
+ const evaluatePass = (pass, recovery = false) => {
1546
+ const finders = detectFinderCandidates(pass.binary, width, height, pass.detector ?? {});
1547
+ const geometries = geometryCandidatesFromBinary(
1548
+ pass.binary,
1549
+ width,
1550
+ height,
1551
+ finders,
1552
+ pass.threshold,
1553
+ {
1554
+ ...options,
1555
+ finderMethod: pass.finderMethod,
1556
+ alignmentThreshold: recovery ? 0.68 : 0.72,
1557
+ alignmentGridThreshold: recovery ? 0.64 : 0.68
1558
+ }
1559
+ );
1560
+
1561
+ pushFinderDiagnostic(
1562
+ { ...options, width, height },
1563
+ pass.finderMethod,
1564
+ pass.threshold,
1565
+ finders,
1566
+ geometries
1567
+ );
1568
+ return { finders, geometries };
1569
+ };
1570
+
1571
+ // Fast path stays exactly one grayscale + finder pass. No Auto Color, extra
1572
+ // thresholding, or luminance image is computed when a normal frame works.
1573
+ const valueInfo = buildBinary(imageData, { grayMode: "value" });
1574
+ const fast = evaluatePass({
1575
+ finderMethod: "rgb-value-otsu",
1576
+ binary: valueInfo.binary,
1577
+ threshold: valueInfo.threshold,
1578
+ detector: {}
1579
+ }, false);
1580
+ if (fast.geometries.length) return fast.geometries.slice(0, maxCandidates);
1581
+ if (options.finderRecovery === false) return [];
1582
+
1583
+ // Camera recovery #1: Photoshop Auto Color-style per-channel levels before
1584
+ // finder thresholding. Live camera frames are usually much larger than the
1585
+ // QR itself, so a single global histogram can be dominated by dark room/UI
1586
+ // pixels around the guide. Photoshop looked strong in the user's cropped
1587
+ // sample because its statistics were effectively code-centric. We emulate
1588
+ // that by trying a few center-weighted analysis windows, while still applying
1589
+ // each correction to the full frame so finder coordinates never move.
1590
+ const requestedInsets = Array.isArray(options.finderAutoColorAnalysisInsets)
1591
+ ? options.finderAutoColorAnalysisInsets
1592
+ : (Number.isFinite(options.finderAutoColorAnalysisInset)
1593
+ ? [options.finderAutoColorAnalysisInset]
1594
+ : [0.10, 0.20, 0.04]);
1595
+ const autoColorInsets = [];
1596
+ for (const value of requestedInsets) {
1597
+ const inset = clamp(Number(value), 0, 0.30);
1598
+ if (!autoColorInsets.some((item) => Math.abs(item - inset) < 0.001)) autoColorInsets.push(inset);
1599
+ }
1600
+
1601
+ for (const analysisInset of autoColorInsets) {
1602
+ try {
1603
+ const autoColorGray = buildAutoColorValueGray(imageData, {
1604
+ blackClip: options.finderAutoColorBlackClip ?? 0.0001,
1605
+ whiteClip: options.finderAutoColorWhiteClip,
1606
+ highlightPercentile: options.finderAutoColorHighlightPercentile ?? 0.95,
1607
+ outputHighlight: options.finderAutoColorOutputHighlight ?? 190,
1608
+ analysisInset,
1609
+ minimumInputRange: options.finderAutoColorMinimumInputRange ?? 72,
1610
+ targetSamples: options.finderAutoColorTargetSamples
1611
+ });
1612
+ const base = otsuThreshold(autoColorGray);
1613
+ const insetLabel = String(Math.round(analysisInset * 100)).padStart(2, "0");
1614
+ const thresholds = [
1615
+ { suffix: "otsu", value: base },
1616
+ { suffix: "high", value: clamp(base + 12, 8, 247) }
1617
+ ];
1618
+ for (const thresholdInfo of thresholds) {
1619
+ const autoColor = evaluatePass({
1620
+ finderMethod: `auto-color-center${insetLabel}-${thresholdInfo.suffix}`,
1621
+ binary: binaryAtThreshold(autoColorGray, thresholdInfo.value),
1622
+ threshold: thresholdInfo.value,
1623
+ detector: { toleranceScale: 1.22, moduleSpreadLimit: 0.60 }
1624
+ }, true);
1625
+ if (autoColor.geometries.length) return autoColor.geometries.slice(0, maxCandidates);
1626
+ }
1627
+ } catch {
1628
+ // Try the next center weighting, then continue with raw threshold bracketing.
1629
+ }
1630
+ }
1631
+
1632
+ // Camera recovery #2: bracket the raw value-channel threshold, then retain
1633
+ // the legacy luminance pass for unusual captures. These are only built after
1634
+ // both the normal and Auto Color finder passes fail.
1635
+ const highThreshold = clamp(valueInfo.baseThreshold + 18, 8, 247);
1636
+ const lowThreshold = clamp(valueInfo.baseThreshold - 14, 8, 247);
1637
+ const recoveryPasses = [];
1638
+ if (highThreshold !== valueInfo.threshold) {
1639
+ recoveryPasses.push({
1640
+ finderMethod: "rgb-value-high-threshold",
1641
+ binary: binaryAtThreshold(valueInfo.gray, highThreshold),
1642
+ threshold: highThreshold,
1643
+ detector: { toleranceScale: 1.18, moduleSpreadLimit: 0.56 }
1644
+ });
1645
+ }
1646
+ if (lowThreshold !== valueInfo.threshold) {
1647
+ recoveryPasses.push({
1648
+ finderMethod: "rgb-value-low-threshold",
1649
+ binary: binaryAtThreshold(valueInfo.gray, lowThreshold),
1650
+ threshold: lowThreshold,
1651
+ detector: { toleranceScale: 1.14, moduleSpreadLimit: 0.54 }
1652
+ });
1653
+ }
1654
+ const lumaInfo = buildBinary(imageData, { grayMode: "luminance" });
1655
+ recoveryPasses.push({
1656
+ finderMethod: "luminance-otsu",
1657
+ binary: lumaInfo.binary,
1658
+ threshold: lumaInfo.threshold,
1659
+ detector: { toleranceScale: 1.10, moduleSpreadLimit: 0.52 }
1660
+ });
1661
+
1662
+ for (const pass of recoveryPasses) {
1663
+ const recovery = evaluatePass(pass, true);
1664
+ if (recovery.geometries.length) return recovery.geometries.slice(0, maxCandidates);
1665
+ }
1666
+
1667
+ return [];
1668
+ }
1669
+
1670
+ function median(values) {
1671
+ if (!values.length) return 0;
1672
+ const sorted = values.slice().sort((a, b) => a - b);
1673
+ const middle = Math.floor(sorted.length / 2);
1674
+ return sorted.length % 2
1675
+ ? sorted[middle]
1676
+ : (sorted[middle - 1] + sorted[middle]) / 2;
1677
+ }
1678
+
1679
+ function robustRgb(values) {
1680
+ if (!values.length) throw new Error("No RGB samples available.");
1681
+ return {
1682
+ r: median(values.map((rgb) => rgb.r)),
1683
+ g: median(values.map((rgb) => rgb.g)),
1684
+ b: median(values.map((rgb) => rgb.b))
1685
+ };
1686
+ }
1687
+
1478
1688
  function averageProjectedSample(imageData, homography, moduleX, moduleY, radius = 0.16) {
1479
1689
  const offsets = radius > 0
1480
1690
  ? [[0, 0], [-radius, 0], [radius, 0], [0, -radius], [0, radius]]
1481
1691
  : [[0, 0]];
1482
- let r = 0;
1483
- let g = 0;
1484
- let b = 0;
1485
- let count = 0;
1692
+ const values = [];
1486
1693
  for (const [dx, dy] of offsets) {
1487
1694
  const point = projectPoint(homography, moduleX + dx, moduleY + dy);
1488
1695
  if (point.x < 0 || point.y < 0 || point.x >= imageData.width || point.y >= imageData.height) continue;
1489
- const rgb = bilinearRgb(imageData, point.x, point.y);
1490
- r += rgb.r;
1491
- g += rgb.g;
1492
- b += rgb.b;
1493
- count++;
1696
+ values.push(bilinearRgb(imageData, point.x, point.y));
1697
+ }
1698
+ if (!values.length) return { r: 255, g: 255, b: 255 };
1699
+ return averageRgb(values);
1700
+ }
1701
+
1702
+ function robustProjectedSample(imageData, homography, moduleX, moduleY, radius = 0.12) {
1703
+ // Fallback for dirty/soft-focus camera frames. A compact 3x3 patch stays
1704
+ // away from module edges and median aggregation rejects glare/speckles.
1705
+ const offsets = radius > 0 ? [-radius, 0, radius] : [0];
1706
+ const values = [];
1707
+ for (const dy of offsets) {
1708
+ for (const dx of offsets) {
1709
+ const point = projectPoint(homography, moduleX + dx, moduleY + dy);
1710
+ if (point.x < 0 || point.y < 0 || point.x >= imageData.width || point.y >= imageData.height) continue;
1711
+ values.push(bilinearRgb(imageData, point.x, point.y));
1712
+ }
1713
+ }
1714
+ return values.length ? robustRgb(values) : { r: 255, g: 255, b: 255 };
1715
+ }
1716
+
1717
+ function histogramPercentile(histogram, total, fraction) {
1718
+ if (!total) return 0;
1719
+ const target = Math.max(1, Math.ceil(total * clamp(fraction, 0, 1)));
1720
+ let seen = 0;
1721
+ for (let i = 0; i < histogram.length; i++) {
1722
+ seen += histogram[i];
1723
+ if (seen >= target) return i;
1724
+ }
1725
+ return histogram.length - 1;
1726
+ }
1727
+
1728
+ function buildAutoColorLevels(imageData, options = {}) {
1729
+ const analysisInset = clamp(options.analysisInset ?? 0, 0, 0.30);
1730
+ const x0 = Math.floor(imageData.width * analysisInset);
1731
+ const y0 = Math.floor(imageData.height * analysisInset);
1732
+ const x1 = Math.max(x0 + 1, Math.ceil(imageData.width * (1 - analysisInset)));
1733
+ const y1 = Math.max(y0 + 1, Math.ceil(imageData.height * (1 - analysisInset)));
1734
+ const analysisWidth = Math.max(1, x1 - x0);
1735
+ const analysisHeight = Math.max(1, y1 - y0);
1736
+ const pixelCount = analysisWidth * analysisHeight;
1737
+ const targetSamples = Math.max(4000, Math.round(options.targetSamples ?? 90000));
1738
+ const step = Math.max(1, Math.floor(pixelCount / targetSamples));
1739
+ const histograms = [new Uint32Array(256), new Uint32Array(256), new Uint32Array(256)];
1740
+ let samples = 0;
1741
+
1742
+ // Camera recovery deliberately supports analysing only the central part of
1743
+ // the visible frame. A phone preview often contains very dark UI/screen
1744
+ // edges outside the code; letting those pixels define the black point makes
1745
+ // an otherwise useful Auto Color pass far too weak.
1746
+ for (let index = 0; index < pixelCount; index += step) {
1747
+ const x = x0 + (index % analysisWidth);
1748
+ const y = y0 + Math.floor(index / analysisWidth);
1749
+ const p = (y * imageData.width + x) * 4;
1750
+ const a = imageData.data[p + 3] / 255;
1751
+ const rgb = [
1752
+ imageData.data[p] * a + 255 * (1 - a),
1753
+ imageData.data[p + 1] * a + 255 * (1 - a),
1754
+ imageData.data[p + 2] * a + 255 * (1 - a)
1755
+ ];
1756
+ for (let channel = 0; channel < 3; channel++) {
1757
+ histograms[channel][clamp(Math.round(rgb[channel]), 0, 255)]++;
1758
+ }
1759
+ samples++;
1760
+ }
1761
+
1762
+ const blackClip = clamp(options.blackClip ?? 0.004, 0, 0.06);
1763
+ const whiteClip = clamp(options.whiteClip ?? 0.004, 0, 0.06);
1764
+ const lows = histograms.map((histogram) => histogramPercentile(histogram, samples, blackClip));
1765
+
1766
+ let references;
1767
+ let highs;
1768
+ const outputHighlight = Number(options.outputHighlight);
1769
+ if (Number.isFinite(outputHighlight)) {
1770
+ // Strong Photoshop-like camera mode. Photoshop Auto Color on the supplied
1771
+ // warm camera sample does not stretch the brightest observed paper/white
1772
+ // cells all the way to 255. Instead it anchors the per-channel shadow
1773
+ // points close to black while keeping the observed highlight around a
1774
+ // neutral mid-high value. That produces much darker structural black and
1775
+ // much stronger RGB separation without washing the whole code out.
1776
+ const highlightPercentile = clamp(options.highlightPercentile ?? 0.95, 0.70, 0.9999);
1777
+ const target = clamp(outputHighlight, 96, 250);
1778
+ references = histograms.map((histogram) => histogramPercentile(histogram, samples, highlightPercentile));
1779
+ const minimumInputRange = Math.max(32, Math.round(options.minimumInputRange ?? 72));
1780
+ highs = references.map((reference, channel) => {
1781
+ const observedRange = Math.max(minimumInputRange, reference - lows[channel]);
1782
+ return lows[channel] + observedRange * 255 / target;
1783
+ });
1784
+ } else {
1785
+ references = histograms.map((histogram) => histogramPercentile(histogram, samples, 1 - whiteClip));
1786
+ highs = references.slice();
1787
+ for (let channel = 0; channel < 3; channel++) {
1788
+ highs[channel] = Math.max(lows[channel] + 24, highs[channel]);
1789
+ }
1790
+ }
1791
+
1792
+ const mapChannel = (value, channel) => clamp(
1793
+ (value - lows[channel]) * 255 / Math.max(1, highs[channel] - lows[channel]),
1794
+ 0,
1795
+ 255
1796
+ );
1797
+
1798
+ return { lows, highs, references, mapChannel };
1799
+ }
1800
+
1801
+ function autoColorImageData(imageData, options = {}) {
1802
+ const levels = buildAutoColorLevels(imageData, options);
1803
+ const pixelCount = imageData.width * imageData.height;
1804
+ const data = new Uint8ClampedArray(imageData.data.length);
1805
+ for (let index = 0; index < pixelCount; index++) {
1806
+ const p = index * 4;
1807
+ const a = imageData.data[p + 3] / 255;
1808
+ const r = imageData.data[p] * a + 255 * (1 - a);
1809
+ const g = imageData.data[p + 1] * a + 255 * (1 - a);
1810
+ const b = imageData.data[p + 2] * a + 255 * (1 - a);
1811
+ data[p] = levels.mapChannel(r, 0);
1812
+ data[p + 1] = levels.mapChannel(g, 1);
1813
+ data[p + 2] = levels.mapChannel(b, 2);
1814
+ data[p + 3] = 255;
1815
+ }
1816
+ return { width: imageData.width, height: imageData.height, data };
1817
+ }
1818
+
1819
+ function buildAutoColorValueGray(imageData, options = {}) {
1820
+ const levels = buildAutoColorLevels(imageData, options);
1821
+ const gray = new Uint8Array(imageData.width * imageData.height);
1822
+ for (let i = 0; i < gray.length; i++) {
1823
+ const p = i * 4;
1824
+ const a = imageData.data[p + 3] / 255;
1825
+ const r = imageData.data[p] * a + 255 * (1 - a);
1826
+ const g = imageData.data[p + 1] * a + 255 * (1 - a);
1827
+ const b = imageData.data[p + 2] * a + 255 * (1 - a);
1828
+ gray[i] = Math.round(Math.max(
1829
+ levels.mapChannel(r, 0),
1830
+ levels.mapChannel(g, 1),
1831
+ levels.mapChannel(b, 2)
1832
+ ));
1833
+ }
1834
+ return gray;
1835
+ }
1836
+
1837
+ function buildAutoToneContrastColorTransform(samples, options = {}) {
1838
+ if (!samples?.length) return (rgb) => rgb;
1839
+ const blackClip = clamp(options.blackClip ?? 0.006, 0, 0.08);
1840
+ const whiteClip = clamp(options.whiteClip ?? 0.004, 0, 0.08);
1841
+ const highlightFraction = clamp(options.highlightFraction ?? 0.14, 0.04, 0.35);
1842
+ const saturation = clamp(options.saturation ?? 1.12, 1, 1.5);
1843
+
1844
+ // Auto Color-style neutralization: use the brightest portion of the frame as
1845
+ // a likely white reference. This is especially effective on warm/yellow
1846
+ // phone-camera frames where the blue channel is suppressed.
1847
+ const luminanceHistogram = new Uint32Array(256);
1848
+ for (const rgb of samples) {
1849
+ const y = clamp(Math.round(luminance(rgb)), 0, 255);
1850
+ luminanceHistogram[y]++;
1851
+ }
1852
+ const highlightThreshold = histogramPercentile(
1853
+ luminanceHistogram,
1854
+ samples.length,
1855
+ 1 - highlightFraction
1856
+ );
1857
+ let highlightR = 0;
1858
+ let highlightG = 0;
1859
+ let highlightB = 0;
1860
+ let highlightCount = 0;
1861
+ for (const rgb of samples) {
1862
+ if (luminance(rgb) < highlightThreshold) continue;
1863
+ highlightR += rgb.r;
1864
+ highlightG += rgb.g;
1865
+ highlightB += rgb.b;
1866
+ highlightCount++;
1494
1867
  }
1495
- if (!count) return { r: 255, g: 255, b: 255 };
1496
- return { r: r / count, g: g / count, b: b / count };
1868
+ const highlightMean = highlightCount
1869
+ ? { r: highlightR / highlightCount, g: highlightG / highlightCount, b: highlightB / highlightCount }
1870
+ : { r: 255, g: 255, b: 255 };
1871
+ const neutralTarget = (highlightMean.r + highlightMean.g + highlightMean.b) / 3;
1872
+ const gains = {
1873
+ r: clamp(neutralTarget / Math.max(24, highlightMean.r), 0.72, 1.42),
1874
+ g: clamp(neutralTarget / Math.max(24, highlightMean.g), 0.72, 1.42),
1875
+ b: clamp(neutralTarget / Math.max(24, highlightMean.b), 0.72, 1.42)
1876
+ };
1877
+
1878
+ // Auto Tone-style per-channel levels after the neutralization above.
1879
+ const histograms = [new Uint32Array(256), new Uint32Array(256), new Uint32Array(256)];
1880
+ for (const rgb of samples) {
1881
+ const corrected = [rgb.r * gains.r, rgb.g * gains.g, rgb.b * gains.b];
1882
+ for (let channel = 0; channel < 3; channel++) {
1883
+ histograms[channel][clamp(Math.round(corrected[channel]), 0, 255)]++;
1884
+ }
1885
+ }
1886
+ const lows = histograms.map((histogram) => histogramPercentile(histogram, samples.length, blackClip));
1887
+ const highs = histograms.map((histogram) => histogramPercentile(histogram, samples.length, 1 - whiteClip));
1888
+ for (let channel = 0; channel < 3; channel++) {
1889
+ highs[channel] = Math.max(lows[channel] + 20, highs[channel]);
1890
+ }
1891
+
1892
+ const tone = (value, channel) => clamp(
1893
+ (value * [gains.r, gains.g, gains.b][channel] - lows[channel]) * 255 / (highs[channel] - lows[channel]),
1894
+ 0,
1895
+ 255
1896
+ );
1897
+
1898
+ // Auto Contrast-style common tonal expansion is calculated after the
1899
+ // per-channel levels so it does not undo white-balance correction.
1900
+ const toneLuminanceHistogram = new Uint32Array(256);
1901
+ for (const rgb of samples) {
1902
+ const corrected = { r: tone(rgb.r, 0), g: tone(rgb.g, 1), b: tone(rgb.b, 2) };
1903
+ toneLuminanceHistogram[clamp(Math.round(luminance(corrected)), 0, 255)]++;
1904
+ }
1905
+ const contrastLow = histogramPercentile(toneLuminanceHistogram, samples.length, blackClip);
1906
+ const contrastHigh = Math.max(
1907
+ contrastLow + 28,
1908
+ histogramPercentile(toneLuminanceHistogram, samples.length, 1 - whiteClip)
1909
+ );
1910
+ const contrast = (value) => clamp((value - contrastLow) * 255 / (contrastHigh - contrastLow), 0, 255);
1911
+
1912
+ return (rgb) => {
1913
+ let r = contrast(tone(rgb.r, 0));
1914
+ let g = contrast(tone(rgb.g, 1));
1915
+ let b = contrast(tone(rgb.b, 2));
1916
+
1917
+ // A small saturation recovery restores separation lost to lens haze and
1918
+ // bilinear camera scaling. It is intentionally conservative because this
1919
+ // path runs only after the normal scanner has failed.
1920
+ const y = luminance({ r, g, b });
1921
+ r = clamp(y + (r - y) * saturation, 0, 255);
1922
+ g = clamp(y + (g - y) * saturation, 0, 255);
1923
+ b = clamp(y + (b - y) * saturation, 0, 255);
1924
+ return { r, g, b };
1925
+ };
1926
+ }
1927
+
1928
+ function autoToneContrastColorRgbGrid(rgbGrid, options = {}) {
1929
+ const samples = [];
1930
+ for (const row of rgbGrid) for (const rgb of row) samples.push(rgb);
1931
+ const transform = buildAutoToneContrastColorTransform(samples, options);
1932
+ return rgbGrid.map((row) => row.map((rgb) => transform(rgb)));
1933
+ }
1934
+
1935
+ function autoToneContrastColorImageData(imageData, options = {}) {
1936
+ const pixelCount = imageData.width * imageData.height;
1937
+ const targetSamples = Math.max(8000, Math.round(options.targetSamples ?? 160000));
1938
+ const step = Math.max(1, Math.floor(pixelCount / targetSamples));
1939
+ const samples = [];
1940
+ for (let index = 0; index < pixelCount; index += step) {
1941
+ const p = index * 4;
1942
+ const a = imageData.data[p + 3] / 255;
1943
+ samples.push({
1944
+ r: imageData.data[p] * a + 255 * (1 - a),
1945
+ g: imageData.data[p + 1] * a + 255 * (1 - a),
1946
+ b: imageData.data[p + 2] * a + 255 * (1 - a)
1947
+ });
1948
+ }
1949
+
1950
+ const transform = buildAutoToneContrastColorTransform(samples, options);
1951
+ const data = new Uint8ClampedArray(imageData.data.length);
1952
+ for (let index = 0; index < pixelCount; index++) {
1953
+ const p = index * 4;
1954
+ const a = imageData.data[p + 3] / 255;
1955
+ const corrected = transform({
1956
+ r: imageData.data[p] * a + 255 * (1 - a),
1957
+ g: imageData.data[p + 1] * a + 255 * (1 - a),
1958
+ b: imageData.data[p + 2] * a + 255 * (1 - a)
1959
+ });
1960
+ data[p] = corrected.r;
1961
+ data[p + 1] = corrected.g;
1962
+ data[p + 2] = corrected.b;
1963
+ data[p + 3] = 255;
1964
+ }
1965
+ return { width: imageData.width, height: imageData.height, data };
1497
1966
  }
1498
1967
 
1499
1968
  function samplePerspectiveMatrix(imageData, homography, size, options = {}) {
1500
1969
  const rgbGrid = Array.from({ length: size }, () => new Array(size));
1501
- const radius = options.sampleRadius ?? 0.16;
1970
+ const mode = options.sampleMode ?? "cross";
1971
+ const radius = options.sampleRadius ?? (mode === "median" ? 0.12 : 0.16);
1972
+ const offsetX = Number.isFinite(options.sampleOffsetX) ? options.sampleOffsetX : 0;
1973
+ const offsetY = Number.isFinite(options.sampleOffsetY) ? options.sampleOffsetY : 0;
1502
1974
  for (let r = 0; r < size; r++) {
1503
1975
  for (let c = 0; c < size; c++) {
1504
- rgbGrid[r][c] = averageProjectedSample(imageData, homography, c + 0.5, r + 0.5, radius);
1976
+ const moduleX = c + 0.5 + offsetX;
1977
+ const moduleY = r + 0.5 + offsetY;
1978
+ rgbGrid[r][c] = mode === "median"
1979
+ ? robustProjectedSample(imageData, homography, moduleX, moduleY, radius)
1980
+ : averageProjectedSample(imageData, homography, moduleX, moduleY, radius);
1505
1981
  }
1506
1982
  }
1507
1983
  return { rgbGrid };
@@ -1532,13 +2008,14 @@ function pickCalibrationSamples(rgbGrid, positions, limit = 24) {
1532
2008
  return out;
1533
2009
  }
1534
2010
 
1535
- function sampleObservedPalette(rgbGrid, calibration) {
2011
+ function sampleObservedPalette(rgbGrid, calibration, options = {}) {
2012
+ const aggregate = options.robust ? robustRgb : averageRgb;
1536
2013
  const palette = {
1537
- black: averageRgb(pickCalibrationSamples(rgbGrid, calibration.black, 36)),
1538
- white: averageRgb(pickCalibrationSamples(rgbGrid, calibration.white, 36)),
1539
- red: averageRgb(pickCalibrationSamples(rgbGrid, calibration.red, 12)),
1540
- green: averageRgb(pickCalibrationSamples(rgbGrid, calibration.green, 12)),
1541
- blue: averageRgb(pickCalibrationSamples(rgbGrid, calibration.blue, 12))
2014
+ black: aggregate(pickCalibrationSamples(rgbGrid, calibration.black, 36)),
2015
+ white: aggregate(pickCalibrationSamples(rgbGrid, calibration.white, 36)),
2016
+ red: aggregate(pickCalibrationSamples(rgbGrid, calibration.red, 12)),
2017
+ green: aggregate(pickCalibrationSamples(rgbGrid, calibration.green, 12)),
2018
+ blue: aggregate(pickCalibrationSamples(rgbGrid, calibration.blue, 12))
1542
2019
  };
1543
2020
 
1544
2021
  // All five observed classes must remain separated enough for reliable RGBW
@@ -1558,6 +2035,71 @@ function sampleObservedPalette(rgbGrid, calibration) {
1558
2035
  return palette;
1559
2036
  }
1560
2037
 
2038
+ function calibrationReferencePoints(rgbGrid, positions, limit = 64) {
2039
+ if (!positions?.length) return [];
2040
+ const step = Math.max(1, Math.floor(positions.length / limit));
2041
+ const refs = [];
2042
+ for (let i = 0; i < positions.length; i += step) {
2043
+ const [row, col] = positions[i];
2044
+ refs.push({ row, col, rgb: rgbGrid[row][col] });
2045
+ if (refs.length >= limit) break;
2046
+ }
2047
+ return refs;
2048
+ }
2049
+
2050
+ function estimateLocalReference(refs, row, col) {
2051
+ if (!refs.length) return { r: 0, g: 0, b: 0 };
2052
+ let wr = 0;
2053
+ let wg = 0;
2054
+ let wb = 0;
2055
+ let total = 0;
2056
+ for (const ref of refs) {
2057
+ const dr = row - ref.row;
2058
+ const dc = col - ref.col;
2059
+ const distanceSq = dr * dr + dc * dc;
2060
+ if (distanceSq < 1e-9) return ref.rgb;
2061
+ // Smooth inverse-distance weighting. The +4 prevents one slightly blurred
2062
+ // structural cell from dominating the local estimate.
2063
+ const weight = 1 / (distanceSq + 4);
2064
+ wr += ref.rgb.r * weight;
2065
+ wg += ref.rgb.g * weight;
2066
+ wb += ref.rgb.b * weight;
2067
+ total += weight;
2068
+ }
2069
+ return {
2070
+ r: wr / total,
2071
+ g: wg / total,
2072
+ b: wb / total
2073
+ };
2074
+ }
2075
+
2076
+ function spatiallyNormalizeRgbGrid(rgbGrid, calibration, options = {}) {
2077
+ const size = rgbGrid.length;
2078
+ const limit = options.referenceLimit ?? 64;
2079
+ const blackRefs = calibrationReferencePoints(rgbGrid, calibration.black, limit);
2080
+ const whiteRefs = calibrationReferencePoints(rgbGrid, calibration.white, limit);
2081
+ if (!blackRefs.length || !whiteRefs.length) throw new Error("Black/white references are required for spatial color normalization.");
2082
+
2083
+ const normalized = Array.from({ length: size }, () => new Array(size));
2084
+ for (let row = 0; row < size; row++) {
2085
+ for (let col = 0; col < size; col++) {
2086
+ const rgb = rgbGrid[row][col];
2087
+ const black = estimateLocalReference(blackRefs, row, col);
2088
+ const white = estimateLocalReference(whiteRefs, row, col);
2089
+ const normalizeChannel = (value, low, high) => {
2090
+ const range = Math.max(28, high - low);
2091
+ return clamp((value - low) * 255 / range, -96, 384);
2092
+ };
2093
+ normalized[row][col] = {
2094
+ r: normalizeChannel(rgb.r, black.r, white.r),
2095
+ g: normalizeChannel(rgb.g, black.g, white.g),
2096
+ b: normalizeChannel(rgb.b, black.b, white.b)
2097
+ };
2098
+ }
2099
+ }
2100
+ return normalized;
2101
+ }
2102
+
1561
2103
  function rectifyImageData(imageData, homography, size, moduleSize = 8) {
1562
2104
  const outputSize = Math.max(1, Math.round(size * moduleSize));
1563
2105
  const data = new Uint8ClampedArray(outputSize * outputSize * 4);
@@ -1649,19 +2191,57 @@ const visionInternals = Object.freeze({
1649
2191
  luminance
1650
2192
  });
1651
2193
 
1652
- return { computeHomography, projectPoint, detectCodeGeometry, samplePerspectiveMatrix, sampleObservedPalette, rectifyImageData, findActiveBounds, sampleAxisAlignedGrid, visionInternals };
2194
+ return { computeHomography, projectPoint, detectCodeGeometry, autoColorImageData, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, samplePerspectiveMatrix, sampleObservedPalette, spatiallyNormalizeRgbGrid, rectifyImageData, findActiveBounds, sampleAxisAlignedGrid, visionInternals };
1653
2195
  })();
1654
2196
  const __qqr_quadqr_js = (function () {
1655
2197
  const { MAX_CODEWORD_SYMBOLS, rsEncode, rsDecode } = __qqr_reed_solomon_js;
1656
2198
  const { alignmentPatternCentersForVersion, alignmentPatternIsBlack, alignmentPatternRadius, sizeForVersion, versionFromSize } = __qqr_geometry_js;
1657
- const { detectCodeGeometry, samplePerspectiveMatrix, rectifyImageData, sampleObservedPalette, findActiveBounds, sampleAxisAlignedGrid } = __qqr_vision_js;
2199
+ const { detectCodeGeometry, computeHomography, projectPoint, samplePerspectiveMatrix, rectifyImageData, sampleObservedPalette, spatiallyNormalizeRgbGrid, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, autoColorImageData, findActiveBounds, sampleAxisAlignedGrid } = __qqr_vision_js;
1658
2200
  const { decryptSecurePayload, encryptSecurePayload, inspectSecureEnvelope, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex } = __qqr_security_js;
2201
+ /**
2202
+ * QuadQR
2203
+ *
2204
+ * Experimental four-state RGBW matrix code written in pure JavaScript.
2205
+ *
2206
+ * Core format:
2207
+ * - Red / Green / Blue / White data cells
2208
+ * - exactly 2 bits per data cell
2209
+ * - GF(256) Reed-Solomon ECC over byte symbols
2210
+ * - interleaved body ECC blocks
2211
+ * - zero-overhead spectral-spatial cell placement
2212
+ * - confidence-aware Reed-Solomon error/erasure recovery
2213
+ * - protected bootstrap/header
2214
+ * - version-dependent distributed alignment patterns for perspective recovery
2215
+ * - camera color calibration and image scanning
2216
+ */
1659
2217
 
1660
2218
  const FORMAT_VERSION = 5;
1661
2219
  const MIN_VERSION = 1;
1662
2220
  const MAX_VERSION = 40;
1663
2221
  const DEFAULT_ECC_LEVEL = "M";
1664
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
+
1665
2245
  // Data cells are the four values 0..3. Structural white deliberately shares
1666
2246
  // value 3 with data-white because both render and scan identically.
1667
2247
  const CELL = Object.freeze({
@@ -1726,6 +2306,22 @@ const COMPACT_ECC_LEVELS = Object.freeze({
1726
2306
  const CRC_BYTES = 4;
1727
2307
  const TEXT_FLAG = 1;
1728
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" });
1729
2325
  const ECC_SHIFT = 1;
1730
2326
  const ECC_MASK = 0b00000110;
1731
2327
 
@@ -1804,9 +2400,375 @@ function readU32be(bytes, offset = 0) {
1804
2400
  ) >>> 0;
1805
2401
  }
1806
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
+
1807
2764
  let CRC_TABLE = null;
1808
2765
  let CRC32_ACCELERATOR = null;
1809
2766
 
2767
+ /**
2768
+ * Install or clear an optional synchronous CRC-32 accelerator.
2769
+ * QuadQR's prebuilt WASM helper uses this hook after initWasm() succeeds.
2770
+ * The JavaScript implementation remains the default and fallback.
2771
+ */
1810
2772
  function installCrc32Accelerator(accelerator = null) {
1811
2773
  assert(
1812
2774
  accelerator == null || typeof accelerator === "function",
@@ -1863,9 +2825,11 @@ function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYT
1863
2825
  return out;
1864
2826
  }
1865
2827
 
1866
- function flagsFor(text, eccLevel, secure = false) {
2828
+ function flagsFor(text, eccLevel, secure = false, extended = false, signed = false) {
1867
2829
  return (text ? TEXT_FLAG : 0) |
1868
2830
  (secure ? SECURE_FLAG : 0) |
2831
+ (extended ? EXTENDED_PAYLOAD_FLAG : 0) |
2832
+ (signed ? SIGNED_FLAG : 0) |
1869
2833
  ((ECC_LEVELS[eccLevel].id << ECC_SHIFT) & ECC_MASK);
1870
2834
  }
1871
2835
 
@@ -2405,6 +3369,11 @@ function finalizeMatrix(layout, rawCells, meta) {
2405
3369
  payloadBytes: meta.payloadBytes,
2406
3370
  sourcePayloadBytes: meta.sourcePayloadBytes ?? meta.payloadBytes,
2407
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),
2408
3377
  requiresDecryption: Boolean(meta.secure),
2409
3378
  security: meta.security ?? null,
2410
3379
  meaningfulCells: meta.meaningfulCells,
@@ -2423,20 +3392,17 @@ function finalizeMatrix(layout, rawCells, meta) {
2423
3392
  };
2424
3393
  }
2425
3394
 
2426
- function encodeText(text, options = {}) {
2427
- assert(typeof text === "string", "encodeText expects a string.");
2428
- return encodeBytes(getTextEncoder().encode(text), { ...options, text: true });
2429
- }
2430
-
2431
- function encodeBytes(input, options = {}) {
3395
+ function encodePreparedBytes(input, options = {}) {
2432
3396
  if (options.formatVersion != null && options.formatVersion !== FORMAT_VERSION) {
2433
3397
  throw new Error(`Only QuadQR format version ${FORMAT_VERSION} is supported.`);
2434
3398
  }
2435
3399
 
2436
- const payload = input instanceof Uint8Array ? input : new Uint8Array(input);
3400
+ const payload = asBytes(input);
2437
3401
  const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
2438
3402
  const secure = Boolean(options.secure);
2439
- 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);
2440
3406
  const version = chooseVersion(payload.length, { ...options, ecc: eccLevel });
2441
3407
  const layout = createLayout(version);
2442
3408
  const header = makeHeader(payload.length, flags, version);
@@ -2456,6 +3422,11 @@ function encodeBytes(input, options = {}) {
2456
3422
  payloadBytes: payload.length,
2457
3423
  sourcePayloadBytes: options.sourcePayloadBytes ?? payload.length,
2458
3424
  secure,
3425
+ extended,
3426
+ signed,
3427
+ compression: options.compressionMetadata ?? "none",
3428
+ signingKeyId: options.signingKeyId ?? null,
3429
+ hasEmbeddedPublicKey: Boolean(options.hasEmbeddedPublicKey),
2459
3430
  security: options.securityMetadata ?? null,
2460
3431
  meaningfulCells: meaningfulCells.length,
2461
3432
  eccLevel,
@@ -2466,43 +3437,138 @@ function encodeBytes(input, options = {}) {
2466
3437
  });
2467
3438
  }
2468
3439
 
2469
- /**
2470
- * Encode text using the optional QuadQR Secure Payload v1 layer.
2471
- * This API is async because Web Crypto performs AES-GCM and password KDF work asynchronously.
2472
- */
2473
- async function encodeSecureText(text, options = {}) {
2474
- assert(typeof text === "string", "encodeSecureText expects a string.");
2475
- return encodeSecureBytes(getTextEncoder().encode(text), { ...options, text: true });
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
+
3492
+ /**
3493
+ * Encode text using the optional QuadQR Secure Payload v1 layer.
3494
+ * This API is async because Web Crypto performs AES-GCM and password KDF work asynchronously.
3495
+ */
3496
+ async function encodeSecureText(text, options = {}) {
3497
+ assert(typeof text === "string", "encodeSecureText expects a string.");
3498
+ return encodeSecureBytes(getTextEncoder().encode(text), { ...options, text: true });
2476
3499
  }
2477
3500
 
2478
3501
  /** Encode arbitrary bytes using password or raw 256-bit key security. */
2479
3502
  async function encodeSecureBytes(input, options = {}) {
2480
- const sourcePayload = input instanceof Uint8Array ? input : new Uint8Array(input);
3503
+ const sourcePayload = asBytes(input);
2481
3504
  const security = options.security ?? {};
2482
- const encrypted = await encryptSecurePayload(sourcePayload, security);
2483
- 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, {
2484
3533
  ...options,
2485
3534
  text: Boolean(options.text),
2486
3535
  secure: true,
3536
+ extended,
3537
+ signed,
2487
3538
  sourcePayloadBytes: sourcePayload.length,
3539
+ compressionMetadata: compression,
3540
+ signingKeyId,
3541
+ hasEmbeddedPublicKey,
2488
3542
  securityMetadata: encrypted.metadata
2489
3543
  });
2490
3544
  }
2491
3545
 
2492
3546
  /**
2493
3547
  * Decrypt a result returned by decodeMatrix/scanImageData/scanFile.
2494
- * The encrypted envelope remains available as encryptedPayload.
3548
+ * Compression and signatures are automatically unwrapped after decryption.
2495
3549
  */
2496
3550
  async function decryptDecoded(result, security = {}) {
2497
3551
  assert(result?.secure && result?.payload, "decryptDecoded expects an encrypted QuadQR decode result.");
2498
3552
  const plaintext = await decryptSecurePayload(result.payload, security);
2499
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;
2500
3557
  return {
2501
3558
  ...result,
2502
3559
  encryptedPayload: result.payload,
2503
3560
  encryptedPayloadBytes: result.payload.length,
2504
- payload: plaintext,
2505
- 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,
2506
3572
  decrypted: true,
2507
3573
  requiresDecryption: false,
2508
3574
  security: { ...result.security, decrypted: true }
@@ -2773,7 +3839,11 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
2773
3839
 
2774
3840
  const isText = (flags & TEXT_FLAG) !== 0;
2775
3841
  const secure = (flags & SECURE_FLAG) !== 0;
3842
+ const extendedFlag = (flags & EXTENDED_PAYLOAD_FLAG) !== 0;
3843
+ const signedFlag = (flags & SIGNED_FLAG) !== 0;
2776
3844
  const security = secure ? inspectSecureEnvelope(payload) : null;
3845
+ const envelope = extendedFlag && !secure ? parsePayloadEnvelope(payload) : null;
3846
+ const applicationPayload = envelope ? envelope.payload : payload;
2777
3847
  const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
2778
3848
  const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
2779
3849
  return {
@@ -2798,8 +3868,17 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
2798
3868
  correctedHeaderSymbols: headerDecoded.correctedSymbols,
2799
3869
  correctedBodySymbols: bodyDecoded.correctedSymbols,
2800
3870
  correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
2801
- payload,
2802
- 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,
2803
3882
  secure,
2804
3883
  encrypted: secure,
2805
3884
  decrypted: false,
@@ -2844,6 +3923,26 @@ function decodeMatrix(inputMatrix, options = {}) {
2844
3923
  throw new Error(`Unable to decode matrix. ${errors.join(" || ")}`);
2845
3924
  }
2846
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
+
2847
3946
  function hexToRgb(hex) {
2848
3947
  const clean = hex.replace("#", "");
2849
3948
  assert(clean.length === 6, `Invalid hex color ${hex}.`);
@@ -2858,6 +3957,18 @@ function resolvePalette(palette = {}) {
2858
3957
  return { ...DEFAULT_PALETTE, ...palette };
2859
3958
  }
2860
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
+
2861
3972
  function paletteRgb(palette = {}) {
2862
3973
  const p = resolvePalette(palette);
2863
3974
  return {
@@ -3021,18 +4132,346 @@ function fillImageRoundedRect(data, width, x, y, rectWidth, rectHeight, radius,
3021
4132
  }
3022
4133
  }
3023
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
+
3024
4462
  function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3025
4463
  assert(canvas && typeof canvas.getContext === "function", "A canvas element is required.");
3026
4464
  const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
3027
4465
  assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
3028
- const moduleSize = Math.max(1, Math.floor(options.moduleSize ?? 12));
3029
- const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
3030
- const palette = resolvePalette(options.palette);
3031
- const paletteValues = paletteRgb(options.palette);
3032
- 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);
3033
4472
  const layout = renderLayoutForMatrix(matrix);
3034
4473
  const size = matrix.length;
3035
- const pixelSize = (size + quietZone * 2) * moduleSize;
4474
+ const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
3036
4475
 
3037
4476
  canvas.width = pixelSize;
3038
4477
  canvas.height = pixelSize;
@@ -3045,12 +4484,13 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3045
4484
  for (let c = 0; c < size; c++) {
3046
4485
  const cell = matrix[r][c];
3047
4486
  const structural = isStructuralRenderCell(layout, r, c, cell);
3048
- const x = (c + quietZone) * moduleSize;
3049
- 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);
3050
4490
 
3051
4491
  if (style === RENDER_STYLES.CLASSIC || structural) {
3052
4492
  ctx.fillStyle = cellColor(cell, palette);
3053
- ctx.fillRect(x, y, moduleSize, moduleSize);
4493
+ ctx.fillRect(x, y, cellWidth, cellHeight);
3054
4494
  continue;
3055
4495
  }
3056
4496
 
@@ -3060,28 +4500,29 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3060
4500
  const opacity = depthOpacity(r, c, cell);
3061
4501
  const base = mixRgb(paletteValues.white, rgb, opacity);
3062
4502
  ctx.fillStyle = rgbCss(base);
3063
- ctx.fillRect(x, y, moduleSize, moduleSize);
4503
+ ctx.fillRect(x, y, cellWidth, cellHeight);
3064
4504
 
3065
- if (moduleSize >= 6) {
3066
- 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));
3067
4507
  const highlight = mixRgb(base, paletteValues.white, 0.17);
3068
4508
  const shadow = mixRgb(base, paletteValues.black, 0.12);
3069
4509
  ctx.fillStyle = rgbCss(highlight);
3070
- ctx.fillRect(x, y, moduleSize, edge);
3071
- ctx.fillRect(x, y, edge, moduleSize);
4510
+ ctx.fillRect(x, y, cellWidth, edge);
4511
+ ctx.fillRect(x, y, edge, cellHeight);
3072
4512
  ctx.fillStyle = rgbCss(shadow);
3073
- ctx.fillRect(x, y + moduleSize - edge, moduleSize, edge);
3074
- 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);
3075
4515
  }
3076
4516
  continue;
3077
4517
  }
3078
4518
 
3079
4519
  if (style === RENDER_STYLES.SOFT) {
3080
- const inset = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
3081
- const width = moduleSize - inset * 2;
3082
- 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));
3083
4524
  ctx.fillStyle = cellColor(cell, palette);
3084
- roundedRectPath(ctx, x + inset, y + inset, width, width, radius);
4525
+ roundedRectPath(ctx, x + inset, y + inset, width, height, radius);
3085
4526
  ctx.fill();
3086
4527
  continue;
3087
4528
  }
@@ -3090,37 +4531,40 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3090
4531
  // Keep the exact encoded color across the full module, then limit the
3091
4532
  // recessed effect to a narrow edge band so center sampling stays intact.
3092
4533
  ctx.fillStyle = cellColor(cell, palette);
3093
- ctx.fillRect(x, y, moduleSize, moduleSize);
4534
+ ctx.fillRect(x, y, cellWidth, cellHeight);
3094
4535
  if (cell === CELL.WHITE) continue;
3095
4536
 
3096
- const edge = safeStyleEdge(moduleSize);
4537
+ const edge = safeStyleEdge(cellSize);
3097
4538
  if (edge > 0) {
3098
4539
  const fx = insetStyleColors(rgb, paletteValues);
3099
4540
  ctx.fillStyle = rgbCss(fx.shadow);
3100
- ctx.fillRect(x, y, moduleSize, edge);
3101
- ctx.fillRect(x, y, edge, moduleSize);
4541
+ ctx.fillRect(x, y, cellWidth, edge);
4542
+ ctx.fillRect(x, y, edge, cellHeight);
3102
4543
  ctx.fillStyle = rgbCss(fx.highlight);
3103
- ctx.fillRect(x, y + moduleSize - edge, moduleSize, edge);
3104
- 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);
3105
4546
  }
3106
4547
  }
3107
4548
  }
3108
4549
  }
4550
+ drawLogoToCanvas(ctx, logo, palette);
3109
4551
  return canvas;
3110
4552
  }
3111
4553
 
3112
4554
  function renderToImageData(codeOrMatrix, options = {}) {
3113
4555
  const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
3114
4556
  assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
3115
- const moduleSize = Math.max(1, Math.floor(options.moduleSize ?? 8));
3116
- const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
3117
- const palette = paletteRgb(options.palette);
3118
- 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);
3119
4563
  const layout = renderLayoutForMatrix(matrix);
3120
4564
  const size = matrix.length;
3121
- const pixelSize = (size + quietZone * 2) * moduleSize;
3122
4565
  const data = new Uint8ClampedArray(pixelSize * pixelSize * 4);
3123
4566
  const white = palette.white;
4567
+ const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, resolvedPalette, codeOrMatrix);
3124
4568
 
3125
4569
  for (let i = 0; i < pixelSize * pixelSize; i++) {
3126
4570
  const p = i * 4;
@@ -3135,26 +4579,105 @@ function renderToImageData(codeOrMatrix, options = {}) {
3135
4579
  const cell = matrix[r][c];
3136
4580
  const rgb = cellRgb(cell, palette);
3137
4581
  const structural = isStructuralRenderCell(layout, r, c, cell);
3138
- const y0 = (r + quietZone) * moduleSize;
3139
- 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);
3140
4588
 
3141
4589
  if (style === RENDER_STYLES.CLASSIC || structural) {
3142
- fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, rgb);
4590
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
3143
4591
  continue;
3144
4592
  }
3145
4593
 
3146
4594
  if (style === RENDER_STYLES.DEPTH) {
3147
4595
  const opacity = depthOpacity(r, c, cell);
3148
4596
  const base = mixRgb(white, rgb, opacity);
3149
- fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, base);
3150
- if (moduleSize >= 6) {
3151
- 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));
3152
4600
  const highlight = mixRgb(base, white, 0.17);
3153
4601
  const shadow = mixRgb(base, palette.black, 0.12);
3154
- fillImageRect(data, pixelSize, x0, y0, moduleSize, edge, highlight);
3155
- fillImageRect(data, pixelSize, x0, y0, edge, moduleSize, highlight);
3156
- fillImageRect(data, pixelSize, x0, y0 + moduleSize - edge, moduleSize, edge, shadow);
3157
- 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));
3158
4681
  }
3159
4682
  continue;
3160
4683
  }
@@ -3163,36 +4686,102 @@ function renderToImageData(codeOrMatrix, options = {}) {
3163
4686
  const inset = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
3164
4687
  const width = moduleSize - inset * 2;
3165
4688
  const radius = Math.max(1, Math.floor(moduleSize * 0.20));
3166
- fillImageRoundedRect(data, pixelSize, x0 + inset, y0 + inset, width, width, radius, rgb);
4689
+ body.push(svgRect(x + inset, y + inset, width, width, fill, radius));
3167
4690
  continue;
3168
4691
  }
3169
4692
 
3170
4693
  if (style === RENDER_STYLES.INSET) {
3171
- fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, rgb);
4694
+ body.push(svgRect(x, y, moduleSize, moduleSize, fill));
3172
4695
  if (cell === CELL.WHITE) continue;
3173
4696
  const edge = safeStyleEdge(moduleSize);
3174
4697
  if (edge > 0) {
3175
- const fx = insetStyleColors(rgb, palette);
3176
- fillImageRect(data, pixelSize, x0, y0, moduleSize, edge, fx.shadow);
3177
- fillImageRect(data, pixelSize, x0, y0, edge, moduleSize, fx.shadow);
3178
- fillImageRect(data, pixelSize, x0, y0 + moduleSize - edge, moduleSize, edge, fx.highlight);
3179
- 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));
3180
4705
  }
3181
4706
  }
3182
4707
  }
3183
4708
  }
3184
4709
 
3185
- return { width: pixelSize, height: pixelSize, data };
3186
- }
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
+ }
3187
4727
 
3188
- function classifierFromPaletteRgb(observed) {
3189
4728
  return [
3190
- { cell: CELL.BLACK, rgb: observed.black },
3191
- { cell: CELL.WHITE, rgb: observed.white },
3192
- { cell: CELL.RED, rgb: observed.red },
3193
- { cell: CELL.GREEN, rgb: observed.green },
3194
- { cell: CELL.BLUE, rgb: observed.blue }
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");
4734
+ }
4735
+
4736
+ function clampNumber(value, min, max) {
4737
+ return Math.max(min, Math.min(max, value));
4738
+ }
4739
+
4740
+ function identityColorTransform(rgb) {
4741
+ return rgb;
4742
+ }
4743
+
4744
+ function makeWhiteBalanceTransform(observed) {
4745
+ const black = observed.black;
4746
+ const white = observed.white;
4747
+ const ranges = {
4748
+ r: Math.max(32, white.r - black.r),
4749
+ g: Math.max(32, white.g - black.g),
4750
+ b: Math.max(32, white.b - black.b)
4751
+ };
4752
+
4753
+ return (rgb) => ({
4754
+ // Normalize the camera's observed black/white points per channel. This
4755
+ // boosts a suppressed blue channel under warm/yellow illumination without
4756
+ // changing the QuadQR palette or payload capacity.
4757
+ r: clampNumber((rgb.r - black.r) * 255 / ranges.r, -96, 384),
4758
+ g: clampNumber((rgb.g - black.g) * 255 / ranges.g, -96, 384),
4759
+ b: clampNumber((rgb.b - black.b) * 255 / ranges.b, -96, 384)
4760
+ });
4761
+ }
4762
+
4763
+ function classifierFromPaletteRgb(observed, mode = "raw") {
4764
+ const transform = mode === "balanced" || mode === "hue"
4765
+ ? makeWhiteBalanceTransform(observed)
4766
+ : identityColorTransform;
4767
+ const entries = [
4768
+ { cell: CELL.BLACK, rgb: transform(observed.black) },
4769
+ { cell: CELL.WHITE, rgb: transform(observed.white) },
4770
+ { cell: CELL.RED, rgb: transform(observed.red) },
4771
+ { cell: CELL.GREEN, rgb: transform(observed.green) },
4772
+ { cell: CELL.BLUE, rgb: transform(observed.blue) }
3195
4773
  ];
4774
+ const colored = entries.filter(({ cell }) => cell === CELL.RED || cell === CELL.GREEN || cell === CELL.BLUE);
4775
+ const chroma = (rgb) => Math.max(rgb.r, rgb.g, rgb.b) - Math.min(rgb.r, rgb.g, rgb.b);
4776
+ const minimumColorChroma = Math.min(...colored.map(({ rgb }) => chroma(rgb)));
4777
+ return {
4778
+ entries,
4779
+ transform,
4780
+ mode,
4781
+ dataMode: mode === "hue" ? "hue" : "distance",
4782
+ whiteChroma: chroma(entries.find(({ cell }) => cell === CELL.WHITE).rgb),
4783
+ minimumColorChroma
4784
+ };
3196
4785
  }
3197
4786
 
3198
4787
  function colorDistanceSq(a, b) {
@@ -3203,12 +4792,13 @@ function colorDistanceSq(a, b) {
3203
4792
  }
3204
4793
 
3205
4794
  function classifyRgb(rgb, classifier) {
4795
+ const transformed = classifier.transform(rgb);
3206
4796
  let best = null;
3207
4797
  let bestDistanceSq = Infinity;
3208
4798
  let secondDistanceSq = Infinity;
3209
4799
 
3210
- for (const candidate of classifier) {
3211
- const distanceSq = colorDistanceSq(rgb, candidate.rgb);
4800
+ for (const candidate of classifier.entries) {
4801
+ const distanceSq = colorDistanceSq(transformed, candidate.rgb);
3212
4802
  if (distanceSq < bestDistanceSq) {
3213
4803
  secondDistanceSq = bestDistanceSq;
3214
4804
  bestDistanceSq = distanceSq;
@@ -3220,17 +4810,46 @@ function classifyRgb(rgb, classifier) {
3220
4810
 
3221
4811
  const distance = Math.sqrt(bestDistanceSq);
3222
4812
  const secondDistance = Number.isFinite(secondDistanceSq) ? Math.sqrt(secondDistanceSq) : distance + 1;
3223
- // Confidence is the normalized separation between the nearest and second
3224
- // nearest calibrated palette states. 1 means unambiguous, 0 means tied.
3225
4813
  const confidence = Math.max(0, Math.min(1, (secondDistance - distance) / Math.max(secondDistance, 1e-6)));
3226
4814
  return { cell: best.cell, distance, confidence };
3227
4815
  }
3228
4816
 
4817
+ function classifyDataHue(rgb, classifier) {
4818
+ const transformed = classifier.transform(rgb);
4819
+ const channels = [
4820
+ { cell: CELL.RED, value: transformed.r },
4821
+ { cell: CELL.GREEN, value: transformed.g },
4822
+ { cell: CELL.BLUE, value: transformed.b }
4823
+ ].sort((a, b) => b.value - a.value);
4824
+ const maxValue = channels[0].value;
4825
+ const secondValue = channels[1].value;
4826
+ const minValue = channels[2].value;
4827
+ const chroma = maxValue - minValue;
4828
+ const whiteThreshold = Math.max(18, Math.min(58, classifier.minimumColorChroma * 0.34));
4829
+
4830
+ if (chroma <= whiteThreshold) {
4831
+ const confidence = clampNumber((whiteThreshold - chroma) / Math.max(whiteThreshold, 1), 0, 1);
4832
+ return { cell: CELL.WHITE, distance: chroma, confidence: 0.35 + confidence * 0.65 };
4833
+ }
4834
+
4835
+ const margin = maxValue - secondValue;
4836
+ const hueConfidence = clampNumber(margin / Math.max(chroma, 1), 0, 1);
4837
+ const saturationConfidence = clampNumber((chroma - whiteThreshold) / Math.max(classifier.minimumColorChroma - whiteThreshold, 1), 0, 1);
4838
+ return {
4839
+ cell: channels[0].cell,
4840
+ distance: Math.max(0, classifier.minimumColorChroma - chroma),
4841
+ confidence: clampNumber(0.18 + hueConfidence * 0.52 + saturationConfidence * 0.30, 0, 1)
4842
+ };
4843
+ }
4844
+
3229
4845
  function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
3230
4846
  const size = rgbGrid.length;
3231
4847
  const matrix = make2D(size, CELL.WHITE);
3232
4848
  const confidence = make2D(size, 1);
3233
- const dataClassifier = classifier.filter(({ cell }) => cell !== CELL.BLACK);
4849
+ const dataClassifier = {
4850
+ ...classifier,
4851
+ entries: classifier.entries.filter(({ cell }) => cell !== CELL.BLACK)
4852
+ };
3234
4853
  let distanceSum = 0;
3235
4854
  let confidenceSum = 0;
3236
4855
  let minimumConfidence = 1;
@@ -3241,8 +4860,11 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
3241
4860
  // Black is structural only. Restricting data modules to RGBW keeps a
3242
4861
  // shadowed/blurred data sample representable so confidence-aware ECC can
3243
4862
  // promote it to an erasure instead of aborting before RS gets a chance.
3244
- const candidates = layout && !layout.reserved[r][c] ? dataClassifier : classifier;
3245
- const classified = classifyRgb(rgbGrid[r][c], candidates);
4863
+ const isDataCell = layout && !layout.reserved[r][c];
4864
+ const candidates = isDataCell ? dataClassifier : classifier;
4865
+ const classified = isDataCell && classifier.dataMode === "hue"
4866
+ ? classifyDataHue(rgbGrid[r][c], classifier)
4867
+ : classifyRgb(rgbGrid[r][c], candidates);
3246
4868
  matrix[r][c] = classified.cell;
3247
4869
  confidence[r][c] = classified.confidence;
3248
4870
  distanceSum += classified.distance;
@@ -3261,115 +4883,468 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
3261
4883
  };
3262
4884
  }
3263
4885
 
4886
+ function structuralAccuracy(matrix, layout) {
4887
+ let matches = 0;
4888
+ let total = 0;
4889
+ for (let r = 0; r < layout.size; r++) {
4890
+ for (let c = 0; c < layout.size; c++) {
4891
+ if (!layout.reserved[r][c]) continue;
4892
+ total++;
4893
+ if (matrix[r][c] === layout.matrix[r][c]) matches++;
4894
+ }
4895
+ }
4896
+ return total ? matches / total : 0;
4897
+ }
4898
+
4899
+ function pushObservation(options, observation) {
4900
+ if (!Array.isArray(options._observationCollector)) return;
4901
+ options._observationCollector.push(observation);
4902
+ }
4903
+
4904
+ function paletteClassifierAttempts(observedPalette) {
4905
+ return [
4906
+ { classifier: classifierFromPaletteRgb(observedPalette, "raw"), colorNormalization: "observed-rgb" },
4907
+ { classifier: classifierFromPaletteRgb(observedPalette, "balanced"), colorNormalization: "white-balanced" },
4908
+ { classifier: classifierFromPaletteRgb(observedPalette, "hue"), colorNormalization: "white-balanced-hue" }
4909
+ ];
4910
+ }
4911
+
3264
4912
  function tryPerspectiveScan(imageData, options) {
3265
4913
  const geometryCandidates = detectCodeGeometry(imageData, {
3266
4914
  minVersion: options.minVersion ?? MIN_VERSION,
3267
4915
  maxVersion: options.maxVersion ?? MAX_VERSION,
3268
- maxCandidates: options.maxGeometryCandidates ?? 8
4916
+ maxCandidates: options.maxGeometryCandidates ?? 8,
4917
+ finderRecovery: options.finderRecovery,
4918
+ finderAutoColorBlackClip: options.finderAutoColorBlackClip,
4919
+ finderAutoColorWhiteClip: options.finderAutoColorWhiteClip,
4920
+ finderAutoColorHighlightPercentile: options.finderAutoColorHighlightPercentile,
4921
+ finderAutoColorOutputHighlight: options.finderAutoColorOutputHighlight,
4922
+ finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
4923
+ finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
4924
+ finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
4925
+ diagnostics: options._visionDiagnostics,
4926
+ diagnosticLabel: options._diagnosticLabel ?? "normal"
3269
4927
  });
4928
+ if (Array.isArray(options._geometryCollector)) options._geometryCollector.push(...geometryCandidates);
3270
4929
  const results = [];
3271
4930
 
3272
4931
  for (const geometry of geometryCandidates) {
3273
- try {
3274
- const layout = createLayout(geometry.version);
3275
- const sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, {
3276
- sampleRadius: options.sampleRadius ?? 0.16
3277
- });
3278
- const observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration);
3279
- const classifier = classifierFromPaletteRgb(observedPalette);
3280
- const classified = classifySampledRgbGrid(sampled.rgbGrid, classifier, layout);
3281
- const decoded = decodeMatrix(classified.matrix, {
3282
- structureTolerance: options.structureTolerance ?? 0.18,
3283
- cellConfidence: classified.confidence,
3284
- maxErasureConfidence: options.maxErasureConfidence
3285
- });
3286
- if (decoded.version !== geometry.version) continue;
3287
-
3288
- results.push({
3289
- ...decoded,
3290
- perspectiveCorrected: true,
3291
- colorCalibrated: true,
3292
- geometry,
3293
- observedPalette,
3294
- averageColorDistance: classified.averageColorDistance,
3295
- averageCellConfidence: classified.averageCellConfidence,
3296
- minimumCellConfidence: classified.minimumCellConfidence,
3297
- lowConfidenceCells: classified.lowConfidenceCells,
3298
- rectified: options.includeRectified
3299
- ? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
3300
- : undefined
4932
+ const layout = createLayout(geometry.version);
4933
+ const sampleProfiles = [{
4934
+ sampleMode: options.sampleMode ?? "cross",
4935
+ sampleRadius: options.sampleRadius ?? 0.16,
4936
+ robustCalibration: false
4937
+ }];
4938
+ if (options.adaptiveSampling !== false) {
4939
+ sampleProfiles.push({
4940
+ sampleMode: "median",
4941
+ sampleRadius: options.robustSampleRadius ?? 0.12,
4942
+ robustCalibration: true
3301
4943
  });
3302
- } catch {
3303
- // Continue trying geometry hypotheses.
3304
4944
  }
3305
- }
3306
-
3307
- results.sort((a, b) =>
3308
- (a.correctedSymbols - b.correctedSymbols) ||
3309
- (a.averageColorDistance - b.averageColorDistance) ||
3310
- (b.geometry.score - a.geometry.score)
3311
- );
3312
- return results[0] ?? null;
3313
- }
3314
-
3315
- function tryAxisAlignedScan(imageData, options) {
3316
- const bounds = options.bounds ?? findActiveBounds(imageData, options.whiteThreshold ?? 238);
3317
- const fixedClassifier = classifierFromPaletteRgb(paletteRgb(options.palette));
3318
- const minVersion = options.minVersion ?? MIN_VERSION;
3319
- const maxVersion = options.maxVersion ?? MAX_VERSION;
3320
- const candidates = [];
3321
-
3322
- for (let version = minVersion; version <= maxVersion; version++) {
3323
- const size = sizeForVersion(version);
3324
- const moduleW = bounds.width / size;
3325
- const moduleH = bounds.height / size;
3326
- if (moduleW < 0.75 || moduleH < 0.75) continue;
3327
- const aspectError = Math.abs(moduleW - moduleH) / Math.max(moduleW, moduleH);
3328
- if (aspectError > (options.maxModuleAspectError ?? 0.16)) continue;
3329
4945
 
3330
- try {
3331
- const sampled = sampleAxisAlignedGrid(imageData, bounds, size, options.sampleRadius ?? 0.18);
3332
- const classifierAttempts = [];
3333
- const layout = createLayout(version);
4946
+ let geometryDecoded = false;
4947
+ let bestStructureScore = 0;
4948
+ for (const profile of sampleProfiles) {
4949
+ let sampled;
4950
+ let observedPalette;
3334
4951
  try {
3335
- const observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration);
3336
- classifierAttempts.push({ classifier: classifierFromPaletteRgb(observedPalette), calibrated: true });
4952
+ sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, profile);
4953
+ observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, {
4954
+ robust: profile.robustCalibration
4955
+ });
3337
4956
  } catch {
3338
- // Fixed palette fallback below.
4957
+ continue;
3339
4958
  }
3340
- classifierAttempts.push({ classifier: fixedClassifier, calibrated: false });
3341
4959
 
3342
- let accepted = false;
3343
- for (const attempt of classifierAttempts) {
4960
+ const tryAttempt = (attempt, rgbGrid, metadata = {}) => {
4961
+ const activeObservedPalette = metadata.observedPalette ?? observedPalette;
4962
+ const activeSamplingMode = metadata.samplingMode ?? profile.sampleMode;
4963
+ const classified = classifySampledRgbGrid(rgbGrid, attempt.classifier, layout);
4964
+ const structureScore = structuralAccuracy(classified.matrix, layout);
4965
+ bestStructureScore = Math.max(bestStructureScore, structureScore);
4966
+ pushObservation(options, {
4967
+ version: geometry.version,
4968
+ matrix: classified.matrix,
4969
+ confidence: classified.confidence,
4970
+ geometry,
4971
+ observedPalette: activeObservedPalette,
4972
+ samplingMode: activeSamplingMode,
4973
+ colorNormalization: attempt.colorNormalization,
4974
+ structureScore,
4975
+ averageCellConfidence: classified.averageCellConfidence,
4976
+ lowConfidenceCells: classified.lowConfidenceCells
4977
+ });
4978
+
3344
4979
  try {
3345
- const classified = classifySampledRgbGrid(sampled.rgbGrid, attempt.classifier, layout);
3346
4980
  const decoded = decodeMatrix(classified.matrix, {
3347
- structureTolerance: options.structureTolerance ?? 0.12,
4981
+ structureTolerance: options.structureTolerance ?? 0.18,
3348
4982
  cellConfidence: classified.confidence,
3349
4983
  maxErasureConfidence: options.maxErasureConfidence
3350
4984
  });
3351
- candidates.push({
4985
+ if (decoded.version !== geometry.version) return false;
4986
+
4987
+ results.push({
3352
4988
  ...decoded,
3353
- bounds,
3354
- sampledVersion: version,
3355
- moduleWidth: moduleW,
3356
- moduleHeight: moduleH,
3357
- perspectiveCorrected: false,
3358
- colorCalibrated: attempt.calibrated,
4989
+ perspectiveCorrected: true,
4990
+ colorCalibrated: true,
4991
+ colorNormalization: attempt.colorNormalization,
4992
+ samplingMode: activeSamplingMode,
4993
+ geometry,
4994
+ observedPalette: activeObservedPalette,
4995
+ autoEnhanced: metadata.autoEnhanced || undefined,
4996
+ recoveryMode: metadata.recoveryMode,
3359
4997
  averageColorDistance: classified.averageColorDistance,
3360
4998
  averageCellConfidence: classified.averageCellConfidence,
3361
4999
  minimumCellConfidence: classified.minimumCellConfidence,
5000
+ lowConfidenceCells: classified.lowConfidenceCells,
5001
+ rectified: options.includeRectified
5002
+ ? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
5003
+ : undefined
5004
+ });
5005
+ return true;
5006
+ } catch {
5007
+ return false;
5008
+ }
5009
+ };
5010
+
5011
+ // Fast path: preserve the original observed-RGB classifier first, then
5012
+ // try per-channel white balancing. Most clean frames stop here without
5013
+ // paying for the more expensive spatial normalization fallback.
5014
+ for (const attempt of paletteClassifierAttempts(observedPalette)) {
5015
+ if (tryAttempt(attempt, sampled.rgbGrid)) {
5016
+ geometryDecoded = true;
5017
+ break;
5018
+ }
5019
+ }
5020
+
5021
+ if (!geometryDecoded && options.spatialColorNormalization !== false) {
5022
+ try {
5023
+ const normalizedGrid = spatiallyNormalizeRgbGrid(sampled.rgbGrid, layout.calibration);
5024
+ const normalizedPalette = sampleObservedPalette(normalizedGrid, layout.calibration, { robust: true });
5025
+ geometryDecoded = tryAttempt({
5026
+ classifier: classifierFromPaletteRgb(normalizedPalette, "raw"),
5027
+ colorNormalization: "spatial-white-balanced"
5028
+ }, normalizedGrid);
5029
+ } catch {
5030
+ // Continue to the recovery profile below.
5031
+ }
5032
+ }
5033
+
5034
+ // Very cheap Photoshop-like recovery on the already-sampled module grid.
5035
+ // Normal camera frames never reach this path. The transform approximates
5036
+ // Auto Color + Auto Tone + Auto Contrast and costs only O(moduleCount).
5037
+ if (!geometryDecoded && options.autoEnhanceRecovery !== false) {
5038
+ try {
5039
+ const enhancedGrid = autoToneContrastColorRgbGrid(sampled.rgbGrid, {
5040
+ blackClip: options.autoEnhanceBlackClip,
5041
+ whiteClip: options.autoEnhanceWhiteClip,
5042
+ saturation: options.autoEnhanceSaturation
5043
+ });
5044
+ const enhancedPalette = sampleObservedPalette(enhancedGrid, layout.calibration, { robust: true });
5045
+ for (const attempt of paletteClassifierAttempts(enhancedPalette)) {
5046
+ const recoveredAttempt = {
5047
+ ...attempt,
5048
+ colorNormalization: `auto-tone-contrast-color/${attempt.colorNormalization}`
5049
+ };
5050
+ if (tryAttempt(recoveredAttempt, enhancedGrid, {
5051
+ observedPalette: enhancedPalette,
5052
+ autoEnhanced: true,
5053
+ recoveryMode: "module-grid-auto-tone-contrast-color"
5054
+ })) {
5055
+ geometryDecoded = true;
5056
+ break;
5057
+ }
5058
+ }
5059
+ } catch {
5060
+ // Continue to the QR-region pixel recovery below.
5061
+ }
5062
+ }
5063
+
5064
+ // Camera-specific Photoshop-style fallback. Enhancing the whole camera
5065
+ // frame is often ineffective because dark surroundings, browser/UI
5066
+ // reflections and unrelated objects skew the histograms. Once finder
5067
+ // geometry is known, rectify only the QuadQR region, run Auto Tone /
5068
+ // Contrast / Color there at pixel level, then sample modules again. This
5069
+ // closely matches what happens when the QR itself is corrected in an
5070
+ // editor before photographing it, while staying off the clean fast path.
5071
+ if (
5072
+ !geometryDecoded &&
5073
+ profile === sampleProfiles[sampleProfiles.length - 1] &&
5074
+ options.autoEnhanceRecovery !== false &&
5075
+ options.rectifiedAutoEnhanceRecovery !== false
5076
+ ) {
5077
+ try {
5078
+ const recoveryModuleSize = Math.max(4, Math.min(10, Math.round(options.rectifiedRecoveryModuleSize ?? 6)));
5079
+ const rectifiedRegion = rectifyImageData(
5080
+ imageData,
5081
+ geometry.homography,
5082
+ layout.size,
5083
+ recoveryModuleSize
5084
+ );
5085
+ const enhancedRegion = autoToneContrastColorImageData(rectifiedRegion, {
5086
+ blackClip: options.rectifiedAutoEnhanceBlackClip ?? options.autoEnhanceBlackClip ?? 0.008,
5087
+ whiteClip: options.rectifiedAutoEnhanceWhiteClip ?? options.autoEnhanceWhiteClip ?? 0.006,
5088
+ saturation: options.rectifiedAutoEnhanceSaturation ?? options.autoEnhanceSaturation ?? 1.22,
5089
+ highlightFraction: options.rectifiedAutoEnhanceHighlightFraction ?? 0.16,
5090
+ targetSamples: Math.min(120000, rectifiedRegion.width * rectifiedRegion.height)
5091
+ });
5092
+ const enhancedSampled = sampleAxisAlignedGrid(
5093
+ enhancedRegion,
5094
+ { x: 0, y: 0, width: enhancedRegion.width, height: enhancedRegion.height },
5095
+ layout.size,
5096
+ options.rectifiedRecoveryRadiusRatio ?? 0.16
5097
+ );
5098
+ const enhancedPalette = sampleObservedPalette(
5099
+ enhancedSampled.rgbGrid,
5100
+ layout.calibration,
5101
+ { robust: true }
5102
+ );
5103
+ for (const attempt of paletteClassifierAttempts(enhancedPalette)) {
5104
+ const recoveredAttempt = {
5105
+ ...attempt,
5106
+ colorNormalization: `rectified-auto-tone-contrast-color/${attempt.colorNormalization}`
5107
+ };
5108
+ if (tryAttempt(recoveredAttempt, enhancedSampled.rgbGrid, {
5109
+ observedPalette: enhancedPalette,
5110
+ samplingMode: "rectified-auto-enhance",
5111
+ autoEnhanced: true,
5112
+ recoveryMode: "rectified-auto-tone-contrast-color"
5113
+ })) {
5114
+ geometryDecoded = true;
5115
+ break;
5116
+ }
5117
+ }
5118
+ } catch {
5119
+ // Geometry refinement remains available as the final bounded fallback.
5120
+ }
5121
+ }
5122
+
5123
+ if (geometryDecoded) break;
5124
+ }
5125
+
5126
+ // Slow-path geometry micro-refinement. Finder/alignment detection can be
5127
+ // correct while a soft-focus or smeared camera shifts the effective module
5128
+ // centres by a small fraction of a cell. Clean scans never reach this code.
5129
+ // On a strong-but-undecodable symbol, probe a tiny 3x3 offset neighbourhood
5130
+ // using centre-only samples and attempt decoding from the best candidates.
5131
+ if (
5132
+ !geometryDecoded &&
5133
+ options.geometryRefinement !== false &&
5134
+ bestStructureScore >= (options.refinementStructureThreshold ?? 0.90)
5135
+ ) {
5136
+ const step = clampNumber(options.refinementOffset ?? 0.20, 0.05, 0.35);
5137
+ const refinementOffsets = [
5138
+ [step, 0], [-step, 0], [0, -step], [0, step],
5139
+ [step, -step], [step, step], [-step, -step], [-step, step]
5140
+ ];
5141
+ const refinementCandidates = [];
5142
+
5143
+ for (const [offsetX, offsetY] of refinementOffsets) {
5144
+ let sampled;
5145
+ let observedPalette;
5146
+ try {
5147
+ sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, {
5148
+ sampleMode: "cross",
5149
+ sampleRadius: 0,
5150
+ sampleOffsetX: offsetX,
5151
+ sampleOffsetY: offsetY
5152
+ });
5153
+ observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, { robust: true });
5154
+ } catch {
5155
+ continue;
5156
+ }
5157
+
5158
+ // Raw observed-palette classification is deliberately first here too.
5159
+ // It is cheap, and it handles the common case where blur biased the
5160
+ // geometry slightly but the camera colors themselves remain usable.
5161
+ const attempts = paletteClassifierAttempts(observedPalette);
5162
+ for (const attempt of attempts) {
5163
+ const classified = classifySampledRgbGrid(sampled.rgbGrid, attempt.classifier, layout);
5164
+ const structureScore = structuralAccuracy(classified.matrix, layout);
5165
+ bestStructureScore = Math.max(bestStructureScore, structureScore);
5166
+ pushObservation(options, {
5167
+ version: geometry.version,
5168
+ matrix: classified.matrix,
5169
+ confidence: classified.confidence,
5170
+ geometry,
5171
+ observedPalette,
5172
+ samplingMode: "refined-center",
5173
+ samplingOffset: { x: offsetX, y: offsetY },
5174
+ colorNormalization: attempt.colorNormalization,
5175
+ structureScore,
5176
+ averageCellConfidence: classified.averageCellConfidence,
3362
5177
  lowConfidenceCells: classified.lowConfidenceCells
3363
5178
  });
3364
- accepted = true;
5179
+
5180
+ // Do not spend RS work on a refinement that made the known finder /
5181
+ // alignment structure worse. This keeps the recovery path bounded.
5182
+ if (structureScore < (options.refinementDecodeThreshold ?? 0.95)) continue;
5183
+ refinementCandidates.push({
5184
+ attempt,
5185
+ classified,
5186
+ observedPalette,
5187
+ offsetX,
5188
+ offsetY,
5189
+ structureScore
5190
+ });
5191
+ }
5192
+ }
5193
+
5194
+ refinementCandidates.sort((a, b) =>
5195
+ (b.structureScore - a.structureScore) ||
5196
+ (b.classified.averageCellConfidence - a.classified.averageCellConfidence) ||
5197
+ (a.classified.averageColorDistance - b.classified.averageColorDistance)
5198
+ );
5199
+
5200
+ const decodeLimit = Math.max(1, Math.min(8, Math.round(options.refinementDecodeCandidates ?? 4)));
5201
+ for (const candidate of refinementCandidates.slice(0, decodeLimit)) {
5202
+ try {
5203
+ const decoded = decodeMatrix(candidate.classified.matrix, {
5204
+ structureTolerance: options.structureTolerance ?? 0.18,
5205
+ cellConfidence: candidate.classified.confidence,
5206
+ maxErasureConfidence: options.maxErasureConfidence
5207
+ });
5208
+ if (decoded.version !== geometry.version) continue;
5209
+ results.push({
5210
+ ...decoded,
5211
+ perspectiveCorrected: true,
5212
+ geometryRefined: true,
5213
+ samplingOffset: { x: candidate.offsetX, y: candidate.offsetY },
5214
+ colorCalibrated: true,
5215
+ colorNormalization: candidate.attempt.colorNormalization,
5216
+ samplingMode: "refined-center",
5217
+ geometry,
5218
+ observedPalette: candidate.observedPalette,
5219
+ averageColorDistance: candidate.classified.averageColorDistance,
5220
+ averageCellConfidence: candidate.classified.averageCellConfidence,
5221
+ minimumCellConfidence: candidate.classified.minimumCellConfidence,
5222
+ lowConfidenceCells: candidate.classified.lowConfidenceCells,
5223
+ rectified: options.includeRectified
5224
+ ? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
5225
+ : undefined
5226
+ });
5227
+ geometryDecoded = true;
3365
5228
  break;
3366
5229
  } catch {
3367
- // Try next classifier.
5230
+ // Try the next high-scoring micro-refinement.
3368
5231
  }
3369
5232
  }
3370
- if (!accepted) throw new Error("Axis-aligned candidate did not decode.");
3371
- } catch {
3372
- // Try next version.
5233
+ }
5234
+ }
5235
+
5236
+ results.sort((a, b) =>
5237
+ (a.correctedSymbols - b.correctedSymbols) ||
5238
+ (a.averageColorDistance - b.averageColorDistance) ||
5239
+ (b.geometry.score - a.geometry.score)
5240
+ );
5241
+ return results[0] ?? null;
5242
+ }
5243
+
5244
+ function tryAxisAlignedScan(imageData, options) {
5245
+ const bounds = options.bounds ?? findActiveBounds(imageData, options.whiteThreshold ?? 238);
5246
+ const fixedPalette = paletteRgb(options.palette);
5247
+ const minVersion = options.minVersion ?? MIN_VERSION;
5248
+ const maxVersion = options.maxVersion ?? MAX_VERSION;
5249
+ const candidates = [];
5250
+
5251
+ for (let version = minVersion; version <= maxVersion; version++) {
5252
+ const size = sizeForVersion(version);
5253
+ const moduleW = bounds.width / size;
5254
+ const moduleH = bounds.height / size;
5255
+ if (moduleW < 0.75 || moduleH < 0.75) continue;
5256
+ const aspectError = Math.abs(moduleW - moduleH) / Math.max(moduleW, moduleH);
5257
+ if (aspectError > (options.maxModuleAspectError ?? 0.16)) continue;
5258
+
5259
+ const radiusProfiles = [options.sampleRadius ?? 0.18];
5260
+ if (options.adaptiveSampling !== false) radiusProfiles.push(options.robustSampleRadius ?? 0.10);
5261
+ let accepted = false;
5262
+
5263
+ for (let profileIndex = 0; profileIndex < radiusProfiles.length && !accepted; profileIndex++) {
5264
+ try {
5265
+ const sampled = sampleAxisAlignedGrid(imageData, bounds, size, radiusProfiles[profileIndex]);
5266
+ const layout = createLayout(version);
5267
+ const classifierAttempts = [];
5268
+ try {
5269
+ const observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, {
5270
+ robust: profileIndex > 0
5271
+ });
5272
+ classifierAttempts.push(...paletteClassifierAttempts(observedPalette).map((attempt) => ({
5273
+ ...attempt,
5274
+ calibrated: true,
5275
+ observedPalette,
5276
+ rgbGrid: sampled.rgbGrid
5277
+ })));
5278
+ if (options.spatialColorNormalization !== false) {
5279
+ try {
5280
+ const normalizedGrid = spatiallyNormalizeRgbGrid(sampled.rgbGrid, layout.calibration);
5281
+ const normalizedPalette = sampleObservedPalette(normalizedGrid, layout.calibration, { robust: true });
5282
+ classifierAttempts.push({
5283
+ classifier: classifierFromPaletteRgb(normalizedPalette, "raw"),
5284
+ calibrated: true,
5285
+ colorNormalization: "spatial-white-balanced",
5286
+ observedPalette,
5287
+ rgbGrid: normalizedGrid
5288
+ });
5289
+ } catch {
5290
+ // Continue with global calibration/fixed palette.
5291
+ }
5292
+ }
5293
+ } catch {
5294
+ // Fixed palette fallback below.
5295
+ }
5296
+ classifierAttempts.push({
5297
+ classifier: classifierFromPaletteRgb(fixedPalette, "raw"),
5298
+ calibrated: false,
5299
+ colorNormalization: "fixed-rgb",
5300
+ observedPalette: fixedPalette,
5301
+ rgbGrid: sampled.rgbGrid
5302
+ });
5303
+
5304
+ for (const attempt of classifierAttempts) {
5305
+ try {
5306
+ const classified = classifySampledRgbGrid(attempt.rgbGrid ?? sampled.rgbGrid, attempt.classifier, layout);
5307
+ const structureScore = structuralAccuracy(classified.matrix, layout);
5308
+ pushObservation(options, {
5309
+ version,
5310
+ matrix: classified.matrix,
5311
+ confidence: classified.confidence,
5312
+ bounds,
5313
+ samplingMode: profileIndex === 0 ? "axis" : "axis-center",
5314
+ colorNormalization: attempt.colorNormalization,
5315
+ structureScore,
5316
+ averageCellConfidence: classified.averageCellConfidence,
5317
+ lowConfidenceCells: classified.lowConfidenceCells
5318
+ });
5319
+ const decoded = decodeMatrix(classified.matrix, {
5320
+ structureTolerance: options.structureTolerance ?? 0.12,
5321
+ cellConfidence: classified.confidence,
5322
+ maxErasureConfidence: options.maxErasureConfidence
5323
+ });
5324
+ candidates.push({
5325
+ ...decoded,
5326
+ bounds,
5327
+ sampledVersion: version,
5328
+ moduleWidth: moduleW,
5329
+ moduleHeight: moduleH,
5330
+ perspectiveCorrected: false,
5331
+ colorCalibrated: attempt.calibrated,
5332
+ colorNormalization: attempt.colorNormalization,
5333
+ samplingMode: profileIndex === 0 ? "axis" : "axis-center",
5334
+ averageColorDistance: classified.averageColorDistance,
5335
+ averageCellConfidence: classified.averageCellConfidence,
5336
+ minimumCellConfidence: classified.minimumCellConfidence,
5337
+ lowConfidenceCells: classified.lowConfidenceCells
5338
+ });
5339
+ accepted = true;
5340
+ break;
5341
+ } catch {
5342
+ // Try next classifier/profile.
5343
+ }
5344
+ }
5345
+ } catch {
5346
+ // Try a narrower centre sample, then next version.
5347
+ }
3373
5348
  }
3374
5349
  }
3375
5350
 
@@ -3379,6 +5354,364 @@ function tryAxisAlignedScan(imageData, options) {
3379
5354
  return candidates[0] ?? null;
3380
5355
  }
3381
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
+
3382
5715
  function scanImageData(imageData, options = {}) {
3383
5716
  assert(imageData && imageData.data && imageData.width && imageData.height, "Valid ImageData is required.");
3384
5717
  const minVersion = options.minVersion ?? MIN_VERSION;
@@ -3386,19 +5719,103 @@ function scanImageData(imageData, options = {}) {
3386
5719
  validateVersion(minVersion);
3387
5720
  validateVersion(maxVersion);
3388
5721
 
5722
+ const 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
+ };
5732
+
3389
5733
  if (options.perspective !== false) {
3390
- const perspective = tryPerspectiveScan(imageData, options);
3391
- if (perspective) return perspective;
5734
+ const perspective = tryPerspectiveScan(imageData, scanOptions);
5735
+ if (perspective) return decorateScanResult(perspective, debugContext, options);
5736
+ }
5737
+
5738
+ // If the normal scanner could see QuadQR geometry but could not decode the
5739
+ // colors, retry the same frame after a single global Auto Tone / Contrast /
5740
+ // Color-style pass. This is deliberately after the normal path so clean
5741
+ // camera scanning remains unchanged and fast.
5742
+ if (
5743
+ options.autoEnhanceRecovery !== false &&
5744
+ options.fullFrameAutoEnhanceRecovery !== false &&
5745
+ !options._autoEnhancedRecovery &&
5746
+ geometryCollector.length > 0
5747
+ ) {
5748
+ try {
5749
+ const enhanced = autoToneContrastColorImageData(imageData, {
5750
+ blackClip: options.autoEnhanceBlackClip,
5751
+ whiteClip: options.autoEnhanceWhiteClip,
5752
+ saturation: options.autoEnhanceSaturation,
5753
+ targetSamples: options.autoEnhanceTargetSamples
5754
+ });
5755
+ const recovered = tryPerspectiveScan(enhanced, {
5756
+ ...options,
5757
+ _autoEnhancedRecovery: true,
5758
+ autoEnhanceRecovery: false,
5759
+ _geometryCollector: []
5760
+ });
5761
+ if (recovered) {
5762
+ return decorateScanResult({
5763
+ ...recovered,
5764
+ autoEnhanced: true,
5765
+ recoveryMode: "auto-tone-contrast-color",
5766
+ originalColorNormalization: recovered.colorNormalization
5767
+ }, debugContext, options);
5768
+ }
5769
+ } catch {
5770
+ // Continue to the axis-aligned fallback.
5771
+ }
3392
5772
  }
3393
5773
 
3394
5774
  if (options.axisAlignedFallback !== false) {
3395
- const axis = tryAxisAlignedScan(imageData, options);
3396
- if (axis) return axis;
5775
+ const axis = tryAxisAlignedScan(imageData, scanOptions);
5776
+ if (axis) return decorateScanResult(axis, debugContext, options);
3397
5777
  }
3398
5778
 
3399
- throw new Error(
5779
+ // Static images sometimes have such low contrast that locator detection itself
5780
+ // fails. Permit one enhanced full retry in that case. The live camera disables
5781
+ // this on its first pass so an empty frame never becomes expensive.
5782
+ if (
5783
+ options.autoEnhanceRecovery !== false &&
5784
+ !options._autoEnhancedRecovery &&
5785
+ geometryCollector.length === 0 &&
5786
+ options.autoEnhanceWhenNoGeometry !== false
5787
+ ) {
5788
+ try {
5789
+ const enhanced = autoToneContrastColorImageData(imageData, {
5790
+ blackClip: options.autoEnhanceBlackClip,
5791
+ whiteClip: options.autoEnhanceWhiteClip,
5792
+ saturation: options.autoEnhanceSaturation,
5793
+ targetSamples: options.autoEnhanceTargetSamples
5794
+ });
5795
+ const recoveryOptions = {
5796
+ ...options,
5797
+ _autoEnhancedRecovery: true,
5798
+ autoEnhanceRecovery: false,
5799
+ _geometryCollector: []
5800
+ };
5801
+ if (options.perspective !== false) {
5802
+ const perspective = tryPerspectiveScan(enhanced, recoveryOptions);
5803
+ if (perspective) return decorateScanResult({ ...perspective, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
5804
+ }
5805
+ if (options.axisAlignedFallback !== false) {
5806
+ const axis = tryAxisAlignedScan(enhanced, recoveryOptions);
5807
+ if (axis) return decorateScanResult({ ...axis, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
5808
+ }
5809
+ } catch {
5810
+ // Fall through to the normal scan failure below.
5811
+ }
5812
+ }
5813
+
5814
+ const error = new Error(
3400
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."
3401
5816
  );
5817
+ if (options.debug) error.debug = scannerDiagnostics(null, debugContext, options);
5818
+ throw error;
3402
5819
  }
3403
5820
 
3404
5821
  async function scanFile(file, options = {}) {
@@ -3427,19 +5844,274 @@ async function scanFile(file, options = {}) {
3427
5844
  return scanImageData(ctx.getImageData(0, 0, canvas.width, canvas.height), options);
3428
5845
  }
3429
5846
 
5847
+ function parseObjectPositionFraction(value, fallback = 0.5) {
5848
+ if (typeof value !== "string") return fallback;
5849
+ const token = value.trim().toLowerCase();
5850
+ if (token === "left" || token === "top") return 0;
5851
+ if (token === "right" || token === "bottom") return 1;
5852
+ if (token === "center") return 0.5;
5853
+ if (token.endsWith("%")) {
5854
+ const parsed = Number.parseFloat(token);
5855
+ if (Number.isFinite(parsed)) return clampNumber(parsed / 100, 0, 1);
5856
+ }
5857
+ return fallback;
5858
+ }
5859
+
5860
+ function visibleVideoSourceRect(video, options = {}) {
5861
+ const sourceWidth = video.videoWidth;
5862
+ const sourceHeight = video.videoHeight;
5863
+ const full = { x: 0, y: 0, width: sourceWidth, height: sourceHeight, cropped: false };
5864
+ if (options.videoCropMode === "full") return full;
5865
+
5866
+ let boxWidth = Number(video.clientWidth) || 0;
5867
+ let boxHeight = Number(video.clientHeight) || 0;
5868
+ if ((!boxWidth || !boxHeight) && typeof video.getBoundingClientRect === "function") {
5869
+ const rect = video.getBoundingClientRect();
5870
+ boxWidth = boxWidth || rect.width;
5871
+ boxHeight = boxHeight || rect.height;
5872
+ }
5873
+ if (!boxWidth || !boxHeight) return full;
5874
+
5875
+ let objectFit = options.videoObjectFit;
5876
+ let objectPosition = options.videoObjectPosition;
5877
+ if ((!objectFit || !objectPosition) && typeof getComputedStyle === "function") {
5878
+ try {
5879
+ const style = getComputedStyle(video);
5880
+ objectFit = objectFit || style.objectFit;
5881
+ objectPosition = objectPosition || style.objectPosition;
5882
+ } catch {
5883
+ // Fall back to the demo's normal cover/center behavior.
5884
+ }
5885
+ }
5886
+ objectFit = objectFit || "cover";
5887
+ if (objectFit !== "cover") return full;
5888
+
5889
+ const scale = Math.max(boxWidth / sourceWidth, boxHeight / sourceHeight);
5890
+ if (!Number.isFinite(scale) || scale <= 0) return full;
5891
+ let cropWidth = Math.min(sourceWidth, boxWidth / scale);
5892
+ let cropHeight = Math.min(sourceHeight, boxHeight / scale);
5893
+ if (cropWidth >= sourceWidth - 1 && cropHeight >= sourceHeight - 1) return full;
5894
+
5895
+ const positionTokens = String(objectPosition || "50% 50%").trim().split(/\s+/);
5896
+ const positionX = parseObjectPositionFraction(positionTokens[0], 0.5);
5897
+ const positionY = parseObjectPositionFraction(positionTokens[1] ?? positionTokens[0], 0.5);
5898
+ let x = (sourceWidth - cropWidth) * positionX;
5899
+ let y = (sourceHeight - cropHeight) * positionY;
5900
+
5901
+ // Optional tiny inset removes the soft edge of a camera preview without
5902
+ // changing the visible composition. Leave at zero unless explicitly set.
5903
+ const inset = clampNumber(options.videoCropInset ?? 0, 0, 0.18);
5904
+ if (inset > 0) {
5905
+ const dx = cropWidth * inset;
5906
+ const dy = cropHeight * inset;
5907
+ x += dx;
5908
+ y += dy;
5909
+ cropWidth -= dx * 2;
5910
+ cropHeight -= dy * 2;
5911
+ }
5912
+
5913
+ x = clampNumber(x, 0, Math.max(0, sourceWidth - cropWidth));
5914
+ y = clampNumber(y, 0, Math.max(0, sourceHeight - cropHeight));
5915
+ return { x, y, width: cropWidth, height: cropHeight, cropped: true };
5916
+ }
5917
+
5918
+ function cropImageDataInset(imageData, insetFraction = 0) {
5919
+ const inset = clampNumber(Number(insetFraction) || 0, 0, 0.32);
5920
+ if (inset <= 0) {
5921
+ return {
5922
+ imageData,
5923
+ rect: { x: 0, y: 0, width: imageData.width, height: imageData.height }
5924
+ };
5925
+ }
5926
+
5927
+ const x = Math.max(0, Math.floor(imageData.width * inset));
5928
+ const y = Math.max(0, Math.floor(imageData.height * inset));
5929
+ const width = Math.max(1, imageData.width - x * 2);
5930
+ const height = Math.max(1, imageData.height - y * 2);
5931
+ const data = new Uint8ClampedArray(width * height * 4);
5932
+ for (let row = 0; row < height; row++) {
5933
+ const sourceStart = ((y + row) * imageData.width + x) * 4;
5934
+ const sourceEnd = sourceStart + width * 4;
5935
+ data.set(imageData.data.subarray(sourceStart, sourceEnd), row * width * 4);
5936
+ }
5937
+ return {
5938
+ imageData: { width, height, data },
5939
+ rect: { x, y, width, height }
5940
+ };
5941
+ }
5942
+
5943
+ function bestVisionDiagnosticPass(visionDiagnostics) {
5944
+ const passes = visionDiagnostics?.passes;
5945
+ if (!Array.isArray(passes) || !passes.length) return null;
5946
+ return passes.slice().sort((a, b) => {
5947
+ const aGeometry = a.geometries?.[0];
5948
+ const bGeometry = b.geometries?.[0];
5949
+ return (Boolean(bGeometry) - Boolean(aGeometry)) ||
5950
+ ((b.finderCount ?? 0) - (a.finderCount ?? 0)) ||
5951
+ ((bGeometry?.score ?? 0) - (aGeometry?.score ?? 0));
5952
+ })[0];
5953
+ }
5954
+
5955
+ function nowMs() {
5956
+ return typeof performance !== "undefined" && typeof performance.now === "function"
5957
+ ? performance.now()
5958
+ : Date.now();
5959
+ }
5960
+
5961
+ function normalizeFrameDiagnostics(frameDiagnostics, source, width, height, visionDiagnostics) {
5962
+ if (!frameDiagnostics || typeof frameDiagnostics !== "object") return;
5963
+ frameDiagnostics.scanWidth = width;
5964
+ frameDiagnostics.scanHeight = height;
5965
+ frameDiagnostics.frameWidth = width;
5966
+ frameDiagnostics.frameHeight = height;
5967
+ frameDiagnostics.scanRect = { x: 0, y: 0, width, height };
5968
+ frameDiagnostics.sourceRect = {
5969
+ x: source.x,
5970
+ y: source.y,
5971
+ width: source.width,
5972
+ height: source.height,
5973
+ cropped: Boolean(source.cropped)
5974
+ };
5975
+ frameDiagnostics.vision = visionDiagnostics;
5976
+ const bestPass = bestVisionDiagnosticPass(visionDiagnostics);
5977
+ frameDiagnostics.bestPass = bestPass;
5978
+ frameDiagnostics.finderCount = bestPass?.finderCount ?? 0;
5979
+ frameDiagnostics.finders = bestPass?.finders ?? [];
5980
+ frameDiagnostics.finderMethod = bestPass?.finderMethod ?? null;
5981
+ frameDiagnostics.finderPasses = Array.isArray(visionDiagnostics?.passes)
5982
+ ? visionDiagnostics.passes.map((pass) => ({
5983
+ method: pass.finderMethod ?? pass.label,
5984
+ finderCount: pass.finderCount ?? 0,
5985
+ threshold: pass.threshold,
5986
+ geometryCount: pass.geometries?.length ?? 0
5987
+ }))
5988
+ : [];
5989
+ frameDiagnostics.geometry = bestPass?.geometries?.[0] ?? null;
5990
+ }
5991
+
3430
5992
  function scanVideoFrame(video, options = {}) {
3431
5993
  assert(typeof document !== "undefined", "scanVideoFrame is a browser API.");
3432
5994
  assert(video && video.videoWidth && video.videoHeight, "Video frame is not ready.");
5995
+
5996
+ // Scan what the user actually sees. On phones the video element is usually a
5997
+ // portrait box with object-fit: cover while the camera sensor stream is 16:9.
5998
+ // Previously we scanned the entire hidden sensor frame, so the QR looked big
5999
+ // in the guide but became much smaller after canvas downscaling. Cropping to
6000
+ // the visible source region both improves module resolution and reduces work.
6001
+ const source = visibleVideoSourceRect(video, options);
3433
6002
  const maxDimension = options.maxDimension ?? 960;
3434
- const scale = Math.min(1, maxDimension / Math.max(video.videoWidth, video.videoHeight));
3435
- const width = Math.max(1, Math.round(video.videoWidth * scale));
3436
- const height = Math.max(1, Math.round(video.videoHeight * scale));
6003
+ const scale = Math.min(1, maxDimension / Math.max(source.width, source.height));
6004
+ const width = Math.max(1, Math.round(source.width * scale));
6005
+ const height = Math.max(1, Math.round(source.height * scale));
3437
6006
  const canvas = options.canvas ?? document.createElement("canvas");
3438
6007
  canvas.width = width;
3439
6008
  canvas.height = height;
3440
6009
  const ctx = canvas.getContext("2d", { alpha: false, willReadFrequently: true });
3441
- ctx.drawImage(video, 0, 0, width, height);
3442
- return scanImageData(ctx.getImageData(0, 0, width, height), options);
6010
+ // High-quality downscaling is preferable for finder geometry, but visible-ROI
6011
+ // cropping means many mobile frames now need little or no downscaling at all.
6012
+ if ("imageSmoothingEnabled" in ctx) ctx.imageSmoothingEnabled = true;
6013
+ if ("imageSmoothingQuality" in ctx) ctx.imageSmoothingQuality = "high";
6014
+ ctx.drawImage(video, source.x, source.y, source.width, source.height, 0, 0, width, height);
6015
+ const frameImageData = ctx.getImageData(0, 0, width, height);
6016
+ if (options._capturedFrame && typeof options._capturedFrame === "object") {
6017
+ options._capturedFrame.imageData = frameImageData;
6018
+ options._capturedFrame.scanWidth = width;
6019
+ options._capturedFrame.scanHeight = height;
6020
+ options._capturedFrame.source = { ...source };
6021
+ }
6022
+ const visionDiagnostics = options._frameDiagnostics ? { passes: [] } : null;
6023
+ try {
6024
+ const result = scanImageData(frameImageData, {
6025
+ ...options,
6026
+ _visionDiagnostics: visionDiagnostics
6027
+ });
6028
+ normalizeFrameDiagnostics(options._frameDiagnostics, source, width, height, visionDiagnostics);
6029
+ if (!source.cropped) return result;
6030
+ return {
6031
+ ...result,
6032
+ cameraVisibleCrop: true,
6033
+ cameraSourceRect: { x: source.x, y: source.y, width: source.width, height: source.height }
6034
+ };
6035
+ } catch (error) {
6036
+ normalizeFrameDiagnostics(options._frameDiagnostics, source, width, height, visionDiagnostics);
6037
+ throw error;
6038
+ }
6039
+ }
6040
+
6041
+ function selectBestFrameObservation(observations) {
6042
+ if (!observations?.length) return null;
6043
+ return observations
6044
+ .filter((item) => item?.matrix && item.structureScore >= 0.82)
6045
+ .sort((a, b) =>
6046
+ (b.structureScore - a.structureScore) ||
6047
+ (b.averageCellConfidence - a.averageCellConfidence) ||
6048
+ ((b.geometry?.score ?? 0) - (a.geometry?.score ?? 0))
6049
+ )[0] ?? null;
6050
+ }
6051
+
6052
+ function combineFrameObservations(observations) {
6053
+ if (!observations?.length) return null;
6054
+ const version = observations[0].version;
6055
+ const size = observations[0].matrix.length;
6056
+ if (!observations.every((item) => item.version === version && item.matrix.length === size)) return null;
6057
+
6058
+ const matrix = make2D(size, CELL.WHITE);
6059
+ const confidence = make2D(size, 0);
6060
+ for (let r = 0; r < size; r++) {
6061
+ for (let c = 0; c < size; c++) {
6062
+ const votes = new Map();
6063
+ const confidenceByCell = new Map();
6064
+ for (const observation of observations) {
6065
+ const cell = observation.matrix[r][c];
6066
+ const sourceConfidence = clampNumber(observation.confidence?.[r]?.[c] ?? 0.5, 0, 1);
6067
+ const weight = 0.35 + sourceConfidence * 0.65;
6068
+ votes.set(cell, (votes.get(cell) ?? 0) + weight);
6069
+ const stats = confidenceByCell.get(cell) ?? { weighted: 0, weight: 0 };
6070
+ stats.weighted += sourceConfidence * weight;
6071
+ stats.weight += weight;
6072
+ confidenceByCell.set(cell, stats);
6073
+ }
6074
+
6075
+ const ranked = Array.from(votes.entries()).sort((a, b) => b[1] - a[1]);
6076
+ const [bestCell, bestWeight] = ranked[0];
6077
+ const secondWeight = ranked[1]?.[1] ?? 0;
6078
+ const totalWeight = ranked.reduce((sum, item) => sum + item[1], 0);
6079
+ const sourceStats = confidenceByCell.get(bestCell);
6080
+ const sourceConfidence = sourceStats?.weight ? sourceStats.weighted / sourceStats.weight : 0.5;
6081
+ const support = totalWeight ? bestWeight / totalWeight : 0;
6082
+ const margin = totalWeight ? (bestWeight - secondWeight) / totalWeight : 0;
6083
+ const agreement = 0.5 * support + 0.5 * margin;
6084
+
6085
+ matrix[r][c] = bestCell;
6086
+ // Do not become overconfident just because several blurry frames agree.
6087
+ // Retaining source uncertainty lets Reed-Solomon treat repeated ambiguous
6088
+ // cells as erasures instead of hard, supposedly-certain errors.
6089
+ confidence[r][c] = clampNumber(sourceConfidence * 0.58 + agreement * 0.42, 0, 1);
6090
+ }
6091
+ }
6092
+ return { version, matrix, confidence };
6093
+ }
6094
+
6095
+ async function improveCameraTrack(stream) {
6096
+ const track = stream.getVideoTracks?.()[0];
6097
+ if (!track?.applyConstraints || !track.getCapabilities) return;
6098
+ try {
6099
+ const capabilities = track.getCapabilities();
6100
+ const advanced = {};
6101
+ if (Array.isArray(capabilities.focusMode) && capabilities.focusMode.includes("continuous")) {
6102
+ advanced.focusMode = "continuous";
6103
+ }
6104
+ if (Array.isArray(capabilities.exposureMode) && capabilities.exposureMode.includes("continuous")) {
6105
+ advanced.exposureMode = "continuous";
6106
+ }
6107
+ if (Array.isArray(capabilities.whiteBalanceMode) && capabilities.whiteBalanceMode.includes("continuous")) {
6108
+ advanced.whiteBalanceMode = "continuous";
6109
+ }
6110
+ if (Object.keys(advanced).length) await track.applyConstraints({ advanced: [advanced] });
6111
+ } catch {
6112
+ // Browsers expose different subsets of camera controls. Scanner-side
6113
+ // calibration remains the primary path when these hints are unsupported.
6114
+ }
3443
6115
  }
3444
6116
 
3445
6117
  async function startCameraScanner(video, options = {}) {
@@ -3451,11 +6123,13 @@ async function startCameraScanner(video, options = {}) {
3451
6123
  audio: false,
3452
6124
  video: {
3453
6125
  facingMode: { ideal: "environment" },
3454
- width: { ideal: 1280 },
3455
- height: { ideal: 720 }
6126
+ width: { ideal: 1920 },
6127
+ height: { ideal: 1080 },
6128
+ frameRate: { ideal: 30 }
3456
6129
  }
3457
6130
  }
3458
6131
  );
6132
+ await improveCameraTrack(stream);
3459
6133
 
3460
6134
  video.srcObject = stream;
3461
6135
  video.setAttribute("playsinline", "");
@@ -3464,32 +6138,435 @@ async function startCameraScanner(video, options = {}) {
3464
6138
 
3465
6139
  const scanInterval = Math.max(80, options.scanInterval ?? 180);
3466
6140
  const scratchCanvas = document.createElement("canvas");
6141
+ const multiFrameEnabled = options.multiFrame !== false;
6142
+ const multiFrameWindow = Math.max(2, Math.min(8, Math.round(options.multiFrameWindow ?? 4)));
6143
+ const multiFrameMinFrames = Math.max(2, Math.min(multiFrameWindow, Math.round(options.multiFrameMinFrames ?? 2)));
6144
+ const observationHistory = new Map();
6145
+ const cameraAutoColorEvery = Math.max(1, Math.round(options.cameraAutoColorEvery ?? 1));
6146
+ const cameraAutoEnhanceEvery = Math.max(1, Math.round(options.cameraAutoEnhanceEvery ?? 2));
6147
+ const cameraFinderRecoveryEvery = Math.max(1, Math.round(options.cameraFinderRecoveryEvery ?? 2));
6148
+ let missStreak = 0;
3467
6149
  let stopped = false;
3468
6150
  let busy = false;
3469
6151
  let timer = null;
6152
+ let frameNumber = 0;
6153
+
6154
+ const diagnosticsEnabled = typeof options.onDiagnostic === "function";
6155
+ const emitDiagnostic = (event) => {
6156
+ if (!diagnosticsEnabled) return;
6157
+ try {
6158
+ options.onDiagnostic({
6159
+ timestamp: Date.now(),
6160
+ frame: frameNumber,
6161
+ ...event
6162
+ });
6163
+ } catch {
6164
+ // Diagnostics are UI-only and must never interrupt scanning.
6165
+ }
6166
+ };
6167
+
6168
+ if (diagnosticsEnabled) {
6169
+ const track = stream.getVideoTracks?.()[0];
6170
+ const settings = track?.getSettings?.() ?? {};
6171
+ emitDiagnostic({
6172
+ type: "camera-ready",
6173
+ method: "camera",
6174
+ message: `Camera ready · ${settings.width ?? video.videoWidth}×${settings.height ?? video.videoHeight}` ,
6175
+ camera: {
6176
+ width: settings.width ?? video.videoWidth,
6177
+ height: settings.height ?? video.videoHeight,
6178
+ frameRate: settings.frameRate ?? null,
6179
+ facingMode: settings.facingMode ?? null
6180
+ }
6181
+ });
6182
+ }
3470
6183
 
3471
6184
  const stop = () => {
3472
6185
  stopped = true;
3473
6186
  if (timer) clearTimeout(timer);
6187
+ observationHistory.clear();
3474
6188
  for (const track of stream.getTracks()) track.stop();
3475
6189
  if (video.srcObject === stream) video.srcObject = null;
3476
6190
  };
3477
6191
 
3478
- const scanNow = () => scanVideoFrame(video, { ...options, canvas: scratchCanvas });
6192
+ const scanNow = () => scanVideoFrame(video, {
6193
+ ...options,
6194
+ maxDimension: options.maxDimension ?? 1080,
6195
+ canvas: scratchCanvas
6196
+ });
6197
+
6198
+ const emitResult = (result, capturedFrame = null, diagnostic = null) => {
6199
+ const frameMeta = capturedFrame?.imageData ? {
6200
+ frame: frameNumber,
6201
+ imageData: capturedFrame.imageData,
6202
+ scanWidth: capturedFrame.scanWidth,
6203
+ scanHeight: capturedFrame.scanHeight,
6204
+ sourceRect: capturedFrame.source ? { ...capturedFrame.source } : null,
6205
+ enhancedImageData: capturedFrame.enhancedImageData ?? null,
6206
+ enhancedRect: capturedFrame.enhancedRect ? { ...capturedFrame.enhancedRect } : null,
6207
+ enhancement: capturedFrame.enhancement ? { ...capturedFrame.enhancement } : null,
6208
+ diagnostic
6209
+ } : null;
6210
+ options.onResult?.(result, frameMeta);
6211
+ options.onDecode?.(result, frameMeta);
6212
+ if (options.stopOnResult ?? true) {
6213
+ stop();
6214
+ return true;
6215
+ }
6216
+ return false;
6217
+ };
6218
+
6219
+ const tryMultiFrameDecode = (observations) => {
6220
+ if (!multiFrameEnabled) return null;
6221
+ const best = selectBestFrameObservation(observations);
6222
+ if (!best) return null;
6223
+ const history = observationHistory.get(best.version) ?? [];
6224
+ history.push(best);
6225
+ while (history.length > multiFrameWindow) history.shift();
6226
+ observationHistory.set(best.version, history);
6227
+ if (history.length < multiFrameMinFrames) return null;
6228
+
6229
+ const combined = combineFrameObservations(history);
6230
+ if (!combined) return null;
6231
+ try {
6232
+ const decoded = decodeMatrix(combined.matrix, {
6233
+ structureTolerance: options.structureTolerance ?? 0.20,
6234
+ cellConfidence: combined.confidence,
6235
+ maxErasureConfidence: options.maxErasureConfidence
6236
+ });
6237
+ if (decoded.version !== best.version) return null;
6238
+ return {
6239
+ ...decoded,
6240
+ perspectiveCorrected: Boolean(best.geometry),
6241
+ colorCalibrated: true,
6242
+ colorNormalization: "multi-frame-vote",
6243
+ samplingMode: "multi-frame-vote",
6244
+ multiFrameCombined: history.length,
6245
+ geometry: best.geometry,
6246
+ observedPalette: best.observedPalette,
6247
+ averageCellConfidence: best.averageCellConfidence,
6248
+ lowConfidenceCells: best.lowConfidenceCells
6249
+ };
6250
+ } catch {
6251
+ return null;
6252
+ }
6253
+ };
3479
6254
 
3480
6255
  const loop = async () => {
3481
6256
  if (stopped) return;
3482
6257
  if (!busy && video.readyState >= 2) {
3483
6258
  busy = true;
6259
+ frameNumber++;
6260
+ const observations = [];
6261
+ const frameDiagnostics = diagnosticsEnabled ? {} : null;
6262
+ const capturedFrame = {};
6263
+ const frameStarted = nowMs();
6264
+ let allowAutoEnhance = false;
6265
+ let allowFinderRecovery = false;
3484
6266
  try {
3485
- const result = scanNow();
3486
- options.onResult?.(result);
3487
- if (options.stopOnResult ?? true) {
3488
- stop();
3489
- return;
3490
- }
6267
+ // The first frame stays minimal. The primary finder pass itself is now
6268
+ // QuadQR-aware (max RGB/value channel), so it remains cheap while no
6269
+ // longer confusing dark blue data with structural black. After a miss,
6270
+ // alternate frames may bracket finder thresholds and then use the
6271
+ // stronger color recovery path. Empty camera frames therefore do not
6272
+ // pay every recovery cost on every scan tick.
6273
+ allowFinderRecovery = options.finderRecovery !== false &&
6274
+ missStreak > 0 && ((missStreak - 1) % cameraFinderRecoveryEvery === 0);
6275
+ allowAutoEnhance = options.autoEnhanceRecovery !== false &&
6276
+ missStreak > 0 && ((missStreak - 1) % cameraAutoEnhanceEvery === 0);
6277
+ const method = allowAutoEnhance
6278
+ ? "progressive-color-recovery"
6279
+ : (allowFinderRecovery ? "finder-recovery" : "fast-scan");
6280
+ const result = scanVideoFrame(video, {
6281
+ ...options,
6282
+ _diagnosticLabel: method,
6283
+ finderRecovery: allowFinderRecovery,
6284
+ autoEnhanceRecovery: allowAutoEnhance,
6285
+ autoEnhanceWhenNoGeometry: allowAutoEnhance,
6286
+ // If geometry exists, the rectified QR-only pixel enhancer is both
6287
+ // stronger and much cheaper than reprocessing the whole live frame.
6288
+ fullFrameAutoEnhanceRecovery: options.fullFrameAutoEnhanceRecovery ?? false,
6289
+ maxDimension: options.maxDimension ?? 1080,
6290
+ canvas: scratchCanvas,
6291
+ _capturedFrame: capturedFrame,
6292
+ _observationCollector: observations,
6293
+ _frameDiagnostics: frameDiagnostics
6294
+ });
6295
+ const elapsedMs = nowMs() - frameStarted;
6296
+ emitDiagnostic({
6297
+ type: "frame",
6298
+ state: "decoded",
6299
+ method: result.recoveryMode ?? result.samplingMode ?? method,
6300
+ elapsedMs,
6301
+ missStreak,
6302
+ ...frameDiagnostics
6303
+ });
6304
+ emitDiagnostic({
6305
+ type: "success",
6306
+ state: "decoded",
6307
+ method: result.recoveryMode ?? result.samplingMode ?? method,
6308
+ elapsedMs,
6309
+ message: `Decoded v${result.version} · ECC ${result.eccLevel} · ${Math.round(elapsedMs)} ms`,
6310
+ ...frameDiagnostics
6311
+ });
6312
+ missStreak = 0;
6313
+ observationHistory.clear();
6314
+ if (emitResult(result, capturedFrame, frameDiagnostics)) return;
3491
6315
  } catch (error) {
3492
- options.onScanMiss?.(error);
6316
+ missStreak++;
6317
+ const fastElapsedMs = nowMs() - frameStarted;
6318
+ emitDiagnostic({
6319
+ type: "frame",
6320
+ state: "miss",
6321
+ method: allowAutoEnhance
6322
+ ? "progressive-color-recovery"
6323
+ : (allowFinderRecovery ? "finder-recovery" : "fast-scan"),
6324
+ elapsedMs: fastElapsedMs,
6325
+ missStreak,
6326
+ error: error?.message ?? String(error),
6327
+ ...frameDiagnostics
6328
+ });
6329
+
6330
+ // The user's real phone-camera case is dominated by color cast before
6331
+ // finder detection: Photoshop Auto Color alone makes the same live QR
6332
+ // immediately detectable. Retry the exact captured frame with a cheap
6333
+ // per-channel Auto Color levels correction before any geometry-dependent
6334
+ // recovery. This runs only after the normal fast scan fails, and by
6335
+ // default only on every other missed frame after the first one.
6336
+ const shouldTryCameraAutoColor = options.cameraAutoColorRecovery !== false &&
6337
+ capturedFrame.imageData &&
6338
+ ((missStreak - 1) % cameraAutoColorEvery === 0);
6339
+ if (shouldTryCameraAutoColor) {
6340
+ const requestedCrops = Array.isArray(options.cameraAutoColorCropInsets)
6341
+ ? options.cameraAutoColorCropInsets
6342
+ : [0.08, 0.16, 0.22, 0];
6343
+ const cropInsets = [];
6344
+ for (const value of requestedCrops) {
6345
+ const inset = clampNumber(Number(value), 0, 0.30);
6346
+ if (!cropInsets.some((item) => Math.abs(item - inset) < 0.001)) cropInsets.push(inset);
6347
+ }
6348
+ const explicitAnalysisInsets = Array.isArray(options.cameraAutoColorAnalysisInsets)
6349
+ ? options.cameraAutoColorAnalysisInsets
6350
+ : null;
6351
+
6352
+ emitDiagnostic({
6353
+ type: "method",
6354
+ state: "trying",
6355
+ method: "camera-auto-color",
6356
+ message: `Fast scan failed · Auto Color recovery inside camera guide (${cropInsets.map((v) => v ? `${Math.round(v * 100)}% crop` : "full frame").join(" → ")})`,
6357
+ ...frameDiagnostics
6358
+ });
6359
+
6360
+ let autoColorDecoded = false;
6361
+ for (let profileIndex = 0; profileIndex < cropInsets.length; profileIndex++) {
6362
+ const cropInset = cropInsets[profileIndex];
6363
+ const cropped = cropImageDataInset(capturedFrame.imageData, cropInset);
6364
+ const defaultAnalysisInsets = [0.10, 0.08, 0.04, 0.10];
6365
+ const analysisInset = clampNumber(
6366
+ Number(explicitAnalysisInsets?.[profileIndex]
6367
+ ?? options.cameraAutoColorAnalysisInset
6368
+ ?? defaultAnalysisInsets[Math.min(profileIndex, defaultAnalysisInsets.length - 1)]),
6369
+ 0,
6370
+ 0.30
6371
+ );
6372
+ const autoColorObservations = [];
6373
+ const autoColorVisionDiagnostics = diagnosticsEnabled ? { passes: [] } : null;
6374
+ const autoColorFrameDiagnostics = diagnosticsEnabled ? {} : null;
6375
+ const cropLabel = cropInset ? `${Math.round(cropInset * 100)}pct-crop` : "full";
6376
+ const profileName = `camera-auto-color-${cropLabel}`;
6377
+ try {
6378
+ const recoveryStarted = nowMs();
6379
+ const correctedFrame = autoColorImageData(cropped.imageData, {
6380
+ // This is intentionally performed on a centered recovery crop.
6381
+ // A live preview can contain large dark borders, browser UI, a
6382
+ // monitor bezel or room background. Those pixels completely
6383
+ // change global Auto Color/Otsu statistics even though the QR
6384
+ // itself looks identical to a saved crop. Photoshop succeeded
6385
+ // because the user's edited image was effectively QR-centric.
6386
+ blackClip: options.cameraAutoColorBlackClip ?? 0.0001,
6387
+ whiteClip: options.cameraAutoColorWhiteClip ?? 0.004,
6388
+ highlightPercentile: options.cameraAutoColorHighlightPercentile ?? 0.95,
6389
+ outputHighlight: options.cameraAutoColorOutputHighlight ?? 190,
6390
+ analysisInset,
6391
+ minimumInputRange: options.cameraAutoColorMinimumInputRange ?? 72,
6392
+ targetSamples: options.cameraAutoColorTargetSamples ?? 90000
6393
+ });
6394
+ const recovered = scanImageData(correctedFrame, {
6395
+ ...options,
6396
+ _diagnosticLabel: profileName,
6397
+ _visionDiagnostics: autoColorVisionDiagnostics,
6398
+ finderRecovery: true,
6399
+ autoEnhanceRecovery: false,
6400
+ fullFrameAutoEnhanceRecovery: false,
6401
+ _observationCollector: autoColorObservations
6402
+ });
6403
+ if (autoColorFrameDiagnostics) {
6404
+ normalizeFrameDiagnostics(
6405
+ autoColorFrameDiagnostics,
6406
+ capturedFrame.source ?? { x: 0, y: 0, width: capturedFrame.scanWidth, height: capturedFrame.scanHeight, cropped: false },
6407
+ correctedFrame.width,
6408
+ correctedFrame.height,
6409
+ autoColorVisionDiagnostics
6410
+ );
6411
+ autoColorFrameDiagnostics.frameWidth = capturedFrame.scanWidth;
6412
+ autoColorFrameDiagnostics.frameHeight = capturedFrame.scanHeight;
6413
+ autoColorFrameDiagnostics.scanRect = { ...cropped.rect };
6414
+ autoColorFrameDiagnostics.autoColorCropInset = cropInset;
6415
+ autoColorFrameDiagnostics.autoColorAnalysisInset = analysisInset;
6416
+ }
6417
+ const recoveryElapsedMs = nowMs() - recoveryStarted;
6418
+ emitDiagnostic({
6419
+ type: "frame",
6420
+ state: "decoded",
6421
+ method: profileName,
6422
+ elapsedMs: recoveryElapsedMs,
6423
+ missStreak,
6424
+ ...(autoColorFrameDiagnostics ?? frameDiagnostics)
6425
+ });
6426
+ emitDiagnostic({
6427
+ type: "success",
6428
+ state: "decoded",
6429
+ method: "camera-auto-color",
6430
+ elapsedMs: recoveryElapsedMs,
6431
+ message: `Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} decoded v${recovered.version} · ECC ${recovered.eccLevel} · ${Math.round(recoveryElapsedMs)} ms`,
6432
+ ...(autoColorFrameDiagnostics ?? frameDiagnostics)
6433
+ });
6434
+ capturedFrame.enhancedImageData = correctedFrame;
6435
+ capturedFrame.enhancedRect = { ...cropped.rect };
6436
+ capturedFrame.enhancement = {
6437
+ method: "camera-auto-color",
6438
+ cropInset,
6439
+ analysisInset
6440
+ };
6441
+ missStreak = 0;
6442
+ observationHistory.clear();
6443
+ autoColorDecoded = true;
6444
+ if (emitResult({
6445
+ ...recovered,
6446
+ autoColorCorrected: true,
6447
+ cameraProgressiveRecovery: true,
6448
+ recoveryMode: "camera-auto-color",
6449
+ cameraAutoColorCropInset: cropInset,
6450
+ cameraAutoColorAnalysisInset: analysisInset
6451
+ }, capturedFrame, autoColorFrameDiagnostics ?? frameDiagnostics)) return;
6452
+ break;
6453
+ } catch (recoveryError) {
6454
+ observations.push(...autoColorObservations);
6455
+ if (autoColorFrameDiagnostics) {
6456
+ normalizeFrameDiagnostics(
6457
+ autoColorFrameDiagnostics,
6458
+ capturedFrame.source ?? { x: 0, y: 0, width: capturedFrame.scanWidth, height: capturedFrame.scanHeight, cropped: false },
6459
+ cropped.imageData.width,
6460
+ cropped.imageData.height,
6461
+ autoColorVisionDiagnostics
6462
+ );
6463
+ autoColorFrameDiagnostics.frameWidth = capturedFrame.scanWidth;
6464
+ autoColorFrameDiagnostics.frameHeight = capturedFrame.scanHeight;
6465
+ autoColorFrameDiagnostics.scanRect = { ...cropped.rect };
6466
+ autoColorFrameDiagnostics.autoColorCropInset = cropInset;
6467
+ autoColorFrameDiagnostics.autoColorAnalysisInset = analysisInset;
6468
+ emitDiagnostic({
6469
+ type: "frame",
6470
+ state: "miss",
6471
+ method: profileName,
6472
+ elapsedMs: nowMs() - frameStarted,
6473
+ missStreak,
6474
+ error: recoveryError?.message ?? String(recoveryError),
6475
+ ...autoColorFrameDiagnostics
6476
+ });
6477
+ }
6478
+ emitDiagnostic({
6479
+ type: "method",
6480
+ state: "failed",
6481
+ method: profileName,
6482
+ message: `Auto Color ${cropInset ? `${Math.round(cropInset * 100)}% crop` : "full frame"} did not decode${autoColorFrameDiagnostics?.finderCount != null ? ` · ${autoColorFrameDiagnostics.finderCount} finder(s)` : ""}`,
6483
+ ...(autoColorFrameDiagnostics ?? frameDiagnostics)
6484
+ });
6485
+ }
6486
+ }
6487
+ if (!autoColorDecoded) {
6488
+ emitDiagnostic({
6489
+ type: "method",
6490
+ state: "failed",
6491
+ method: "camera-auto-color",
6492
+ message: "All camera Auto Color profiles failed · continuing deeper recovery",
6493
+ ...frameDiagnostics
6494
+ });
6495
+ }
6496
+ }
6497
+
6498
+ // If the fast pass already saw a convincing QuadQR structure, retry
6499
+ // the exact same captured ROI immediately with the stronger QR-region
6500
+ // color correction. Empty/non-QR frames do not pay this cost. This is
6501
+ // the live equivalent of: normal scan first, then Auto Tone / Contrast
6502
+ // / Color only when the normal decode actually needs help.
6503
+ if (!allowAutoEnhance && options.autoEnhanceRecovery !== false) {
6504
+ const strongObservation = selectBestFrameObservation(observations);
6505
+ if (strongObservation && scratchCanvas.width && scratchCanvas.height) {
6506
+ emitDiagnostic({
6507
+ type: "method",
6508
+ state: "trying",
6509
+ method: "qr-region-auto-enhance",
6510
+ message: "Fast decode failed · trying QR-region Auto Tone / Contrast / Color",
6511
+ ...frameDiagnostics
6512
+ });
6513
+ try {
6514
+ const recoveryStarted = nowMs();
6515
+ const frameContext = scratchCanvas.getContext("2d", { alpha: false, willReadFrequently: true });
6516
+ const capturedImageData = frameContext.getImageData(0, 0, scratchCanvas.width, scratchCanvas.height);
6517
+ const recoveryObservations = [];
6518
+ const recoveryVisionDiagnostics = diagnosticsEnabled ? { passes: [] } : null;
6519
+ const recovered = scanImageData(capturedImageData, {
6520
+ ...options,
6521
+ _diagnosticLabel: "qr-region-auto-enhance",
6522
+ _visionDiagnostics: recoveryVisionDiagnostics,
6523
+ autoEnhanceRecovery: true,
6524
+ autoEnhanceWhenNoGeometry: false,
6525
+ fullFrameAutoEnhanceRecovery: false,
6526
+ _observationCollector: recoveryObservations
6527
+ });
6528
+ const recoveryElapsedMs = nowMs() - recoveryStarted;
6529
+ emitDiagnostic({
6530
+ type: "success",
6531
+ state: "decoded",
6532
+ method: recovered.recoveryMode ?? recovered.samplingMode ?? "qr-region-auto-enhance",
6533
+ elapsedMs: recoveryElapsedMs,
6534
+ message: `Recovery decoded v${recovered.version} · ECC ${recovered.eccLevel} · ${Math.round(recoveryElapsedMs)} ms`,
6535
+ ...frameDiagnostics
6536
+ });
6537
+ missStreak = 0;
6538
+ observationHistory.clear();
6539
+ if (emitResult({
6540
+ ...recovered,
6541
+ cameraProgressiveRecovery: true
6542
+ }, capturedFrame, frameDiagnostics)) return;
6543
+ } catch {
6544
+ emitDiagnostic({
6545
+ type: "method",
6546
+ state: "failed",
6547
+ method: "qr-region-auto-enhance",
6548
+ message: "QR-region color recovery did not decode · trying multi-frame ECC",
6549
+ ...frameDiagnostics
6550
+ });
6551
+ // Keep the original observations for multi-frame voting below.
6552
+ }
6553
+ }
6554
+ }
6555
+
6556
+ const combined = tryMultiFrameDecode(observations);
6557
+ if (combined) {
6558
+ emitDiagnostic({
6559
+ type: "success",
6560
+ state: "decoded",
6561
+ method: "multi-frame-vote",
6562
+ message: `Multi-frame recovery decoded v${combined.version} from ${combined.multiFrameCombined} frames`,
6563
+ ...frameDiagnostics
6564
+ });
6565
+ missStreak = 0;
6566
+ if (emitResult(combined, capturedFrame, frameDiagnostics)) return;
6567
+ } else {
6568
+ options.onScanMiss?.(error);
6569
+ }
3493
6570
  } finally {
3494
6571
  busy = false;
3495
6572
  }
@@ -3547,6 +6624,10 @@ const internals = Object.freeze({
3547
6624
  decodeRsAdaptive,
3548
6625
  encodeProtectedHeader,
3549
6626
  decodeProtectedHeader,
6627
+ selectBestFrameObservation,
6628
+ combineFrameObservations,
6629
+ visibleVideoSourceRect,
6630
+ cropImageDataInset,
3550
6631
  HEADER_CODEWORD_CELLS,
3551
6632
  COMPACT_HEADER_CODEWORD_CELLS,
3552
6633
  CELLS_PER_BYTE,
@@ -3554,7 +6635,7 @@ const internals = Object.freeze({
3554
6635
  SECURE_FLAG
3555
6636
  });
3556
6637
 
3557
- 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 };
3558
6639
  })();
3559
6640
  const QuadQR = __qqr_quadqr_js;
3560
6641