quadqr-js 1.0.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/quadqr.js CHANGED
@@ -1224,6 +1224,31 @@ function detectFinderCandidates(binary, width, height, options = {}) {
1224
1224
  return clusterFinderCandidates(raw, minConfirmations);
1225
1225
  }
1226
1226
 
1227
+ function recoverFinderSetFromTwo(binary, width, height, strongFinders, detector = {}) {
1228
+ if (strongFinders.length !== 2) return strongFinders;
1229
+ const moduleMean = (strongFinders[0].moduleSize + strongFinders[1].moduleSize) / 2;
1230
+ const loose = detectFinderCandidates(binary, width, height, {
1231
+ toleranceScale: Math.max(1.45, (detector.toleranceScale ?? 1) * 1.35),
1232
+ moduleSpreadLimit: Math.max(0.72, detector.moduleSpreadLimit ?? 0.45),
1233
+ minConfirmations: 1
1234
+ });
1235
+
1236
+ const extra = loose.filter((candidate) => {
1237
+ const moduleRatio = Math.abs(candidate.moduleSize - moduleMean) / Math.max(candidate.moduleSize, moduleMean);
1238
+ if (moduleRatio > 0.62) return false;
1239
+ return strongFinders.every((known) =>
1240
+ Math.hypot(candidate.x - known.x, candidate.y - known.y) > Math.max(moduleMean, known.moduleSize) * 5
1241
+ );
1242
+ }).slice(0, 12);
1243
+
1244
+ if (!extra.length) return strongFinders;
1245
+ return strongFinders.concat(extra).sort((a, b) =>
1246
+ (b.confirmations - a.confirmations) ||
1247
+ (a.score - b.score) ||
1248
+ (b.moduleSize - a.moduleSize)
1249
+ );
1250
+ }
1251
+
1227
1252
  function dot(a, b) {
1228
1253
  return a.x * b.x + a.y * b.y;
1229
1254
  }
@@ -1306,6 +1331,14 @@ function alignmentTemplateValue(pattern, r, c) {
1306
1331
  return alignmentPatternIsBlack(pattern, r, c) ? 1 : 0;
1307
1332
  }
1308
1333
 
1334
+ const ALIGNMENT_SUBCELL_PROBES = Object.freeze([
1335
+ Object.freeze([0, 0]),
1336
+ Object.freeze([-0.32, 0]),
1337
+ Object.freeze([0.32, 0]),
1338
+ Object.freeze([0, -0.32]),
1339
+ Object.freeze([0, 0.32])
1340
+ ]);
1341
+
1309
1342
  function alignmentScore(binary, width, height, center, basisU, basisV, scale, pattern) {
1310
1343
  let matches = 0;
1311
1344
  let total = 0;
@@ -1323,7 +1356,31 @@ function alignmentScore(binary, width, height, center, basisU, basisV, scale, pa
1323
1356
  return total ? matches / total : 0;
1324
1357
  }
1325
1358
 
1326
- function searchAlignment(binary, width, height, triple, version) {
1359
+ function preciseAlignmentScore(binary, width, height, center, basisU, basisV, scale, pattern) {
1360
+ let matches = 0;
1361
+ let total = 0;
1362
+ const radius = alignmentPatternRadius(pattern);
1363
+ for (let r = -radius; r <= radius; r++) {
1364
+ for (let c = -radius; c <= radius; c++) {
1365
+ const expected = alignmentTemplateValue(pattern, r, c);
1366
+ // Centre-only alignment scoring has a broad plateau: a candidate can be
1367
+ // wrong by almost half a module while every centre still lands in the
1368
+ // correct solid tile. This denser score is reserved for the slow recovery
1369
+ // path, where Triangle16 needs sub-module geometry precision.
1370
+ for (const [du, dv] of ALIGNMENT_SUBCELL_PROBES) {
1371
+ const x = center.x + basisU.x * (c + du) * scale + basisV.x * (r + dv) * scale;
1372
+ const y = center.y + basisU.y * (c + du) * scale + basisV.y * (r + dv) * scale;
1373
+ const value = sampleBinaryAt(binary, width, height, x, y);
1374
+ if (value === null) continue;
1375
+ total++;
1376
+ if (value === expected) matches++;
1377
+ }
1378
+ }
1379
+ }
1380
+ return total ? matches / total : 0;
1381
+ }
1382
+
1383
+ function searchAlignment(binary, width, height, triple, version, options = {}) {
1327
1384
  const size = sizeForVersion(version);
1328
1385
  const separation = size - 7;
1329
1386
  const basisU = {
@@ -1343,8 +1400,12 @@ function searchAlignment(binary, width, height, triple, version) {
1343
1400
  };
1344
1401
 
1345
1402
  let best = { score: 0, center: predicted, scale: 1 };
1346
- const offsets = [-2.5, -1.5, -0.75, 0, 0.75, 1.5, 2.5];
1403
+ const precise = options.preciseAlignment === true;
1404
+ const offsets = precise
1405
+ ? [-2.5, -1.5, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1.5, 2.5]
1406
+ : [-2.5, -1.5, -0.75, 0, 0.75, 1.5, 2.5];
1347
1407
  const scales = [0.72, 0.85, 1, 1.15, 1.3];
1408
+ const scoreAlignment = precise ? preciseAlignmentScore : alignmentScore;
1348
1409
 
1349
1410
  for (const ou of offsets) {
1350
1411
  for (const ov of offsets) {
@@ -1353,7 +1414,7 @@ function searchAlignment(binary, width, height, triple, version) {
1353
1414
  y: predicted.y + basisU.y * ou + basisV.y * ov
1354
1415
  };
1355
1416
  for (const scale of scales) {
1356
- const score = alignmentScore(binary, width, height, center, basisU, basisV, scale, target);
1417
+ const score = scoreAlignment(binary, width, height, center, basisU, basisV, scale, target);
1357
1418
  if (score > best.score) best = { score, center, scale };
1358
1419
  }
1359
1420
  }
@@ -1396,6 +1457,183 @@ function scoreAlignmentGrid(binary, width, height, homography, version) {
1396
1457
  return { score, patternScores };
1397
1458
  }
1398
1459
 
1460
+ function localProjectiveBasis(homography, moduleX, moduleY) {
1461
+ const center = projectPoint(homography, moduleX, moduleY);
1462
+ const alongU = projectPoint(homography, moduleX + 1, moduleY);
1463
+ const alongV = projectPoint(homography, moduleX, moduleY + 1);
1464
+ return {
1465
+ center,
1466
+ basisU: { x: alongU.x - center.x, y: alongU.y - center.y },
1467
+ basisV: { x: alongV.x - center.x, y: alongV.y - center.y }
1468
+ };
1469
+ }
1470
+
1471
+ function searchProjectedAlignment(binary, width, height, homography, pattern) {
1472
+ const moduleX = pattern.col + 0.5;
1473
+ const moduleY = pattern.row + 0.5;
1474
+ const local = localProjectiveBasis(homography, moduleX, moduleY);
1475
+ const moduleScale = Math.max(
1476
+ 0.5,
1477
+ (Math.hypot(local.basisU.x, local.basisU.y) + Math.hypot(local.basisV.x, local.basisV.y)) / 2
1478
+ );
1479
+ const offsets = [-1, -0.5, 0, 0.5, 1];
1480
+ const scales = [0.88, 1, 1.12];
1481
+ let best = {
1482
+ score: projectedAlignmentScore(binary, width, height, homography, pattern),
1483
+ center: local.center,
1484
+ scale: 1,
1485
+ offsetU: 0,
1486
+ offsetV: 0,
1487
+ displacementModules: 0
1488
+ };
1489
+
1490
+ for (const offsetU of offsets) {
1491
+ for (const offsetV of offsets) {
1492
+ const center = {
1493
+ x: local.center.x + local.basisU.x * offsetU + local.basisV.x * offsetV,
1494
+ y: local.center.y + local.basisU.y * offsetU + local.basisV.y * offsetV
1495
+ };
1496
+ for (const scale of scales) {
1497
+ const score = alignmentScore(
1498
+ binary,
1499
+ width,
1500
+ height,
1501
+ center,
1502
+ local.basisU,
1503
+ local.basisV,
1504
+ scale,
1505
+ pattern
1506
+ );
1507
+ if (score > best.score) {
1508
+ best = {
1509
+ score,
1510
+ center,
1511
+ scale,
1512
+ offsetU,
1513
+ offsetV,
1514
+ displacementModules: Math.hypot(offsetU, offsetV),
1515
+ displacementPixels: Math.hypot(offsetU, offsetV) * moduleScale
1516
+ };
1517
+ }
1518
+ }
1519
+ }
1520
+ }
1521
+
1522
+ return best;
1523
+ }
1524
+
1525
+ function refineHomographyWithAlignmentGrid(
1526
+ binary,
1527
+ width,
1528
+ height,
1529
+ initialHomography,
1530
+ version,
1531
+ sourcePoints,
1532
+ destinationPoints,
1533
+ initialGrid,
1534
+ options = {}
1535
+ ) {
1536
+ const patterns = alignmentPatternCentersForVersion(version);
1537
+ if (patterns.length <= 1 || options.alignmentRefinement === false) {
1538
+ return {
1539
+ homography: initialHomography,
1540
+ grid: initialGrid,
1541
+ refined: false,
1542
+ points: []
1543
+ };
1544
+ }
1545
+
1546
+ const primary = patterns.find((pattern) => pattern.primary);
1547
+ const secondaries = patterns.filter((pattern) => !pattern.primary);
1548
+ const minimumScore = options.alignmentRefinePatternThreshold ?? 0.84;
1549
+ const maxPoints = Math.max(1, Math.min(18, Math.round(options.alignmentRefineMaxPoints ?? 12)));
1550
+ const candidates = [];
1551
+
1552
+ for (const pattern of secondaries) {
1553
+ const found = searchProjectedAlignment(binary, width, height, initialHomography, pattern);
1554
+ if (found.score < minimumScore) continue;
1555
+ if (found.displacementModules > (options.alignmentRefineMaxDisplacement ?? 1.45)) continue;
1556
+ candidates.push({ pattern, ...found });
1557
+ }
1558
+
1559
+ if (!candidates.length) {
1560
+ return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
1561
+ }
1562
+
1563
+ // Prefer confident references, but retain spatial distribution so dense
1564
+ // versions use alignment information across the entire matrix rather than
1565
+ // allowing a cluster of nearby markers to dominate the fit.
1566
+ candidates.sort((a, b) =>
1567
+ (b.score - a.score) ||
1568
+ ((b.pattern.row + b.pattern.col) - (a.pattern.row + a.pattern.col))
1569
+ );
1570
+ const selected = [];
1571
+ for (const candidate of candidates) {
1572
+ if (selected.length >= maxPoints) break;
1573
+ const tooClose = selected.some((item) =>
1574
+ Math.hypot(item.pattern.col - candidate.pattern.col, item.pattern.row - candidate.pattern.row) < 6
1575
+ );
1576
+ if (!tooClose || selected.length < 3) selected.push(candidate);
1577
+ }
1578
+ if (!selected.length) return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
1579
+
1580
+ const refinedSource = sourcePoints.slice();
1581
+ const refinedDestination = destinationPoints.slice();
1582
+ const weights = [4, 4, 4, 5];
1583
+ const pointInfo = [];
1584
+
1585
+ for (const candidate of selected) {
1586
+ refinedSource.push(candidate.center);
1587
+ refinedDestination.push({
1588
+ x: candidate.pattern.col + 0.5,
1589
+ y: candidate.pattern.row + 0.5
1590
+ });
1591
+ // A 3x3 marker carries less evidence than the 5x5 primary and the three
1592
+ // finder patterns, so it helps average geometry without overpowering them.
1593
+ weights.push(0.8 + candidate.score * 1.7);
1594
+ pointInfo.push({
1595
+ row: candidate.pattern.row,
1596
+ col: candidate.pattern.col,
1597
+ score: candidate.score,
1598
+ x: candidate.center.x,
1599
+ y: candidate.center.y,
1600
+ displacementModules: candidate.displacementModules
1601
+ });
1602
+ }
1603
+
1604
+ let refinedHomography;
1605
+ try {
1606
+ refinedHomography = computeHomographyLeastSquares(refinedSource, refinedDestination, weights);
1607
+ } catch {
1608
+ return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
1609
+ }
1610
+
1611
+ const refinedGrid = scoreAlignmentGrid(binary, width, height, refinedHomography, version);
1612
+ const primaryBefore = primary
1613
+ ? projectedAlignmentScore(binary, width, height, initialHomography, primary)
1614
+ : 1;
1615
+ const primaryAfter = primary
1616
+ ? projectedAlignmentScore(binary, width, height, refinedHomography, primary)
1617
+ : 1;
1618
+ const allowedGridDrop = options.alignmentRefineAllowedGridDrop ?? 0.006;
1619
+ const allowedPrimaryDrop = options.alignmentRefineAllowedPrimaryDrop ?? 0.04;
1620
+
1621
+ if (
1622
+ refinedGrid.score + allowedGridDrop < initialGrid.score ||
1623
+ primaryAfter + allowedPrimaryDrop < primaryBefore
1624
+ ) {
1625
+ return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
1626
+ }
1627
+
1628
+ return {
1629
+ homography: refinedHomography,
1630
+ grid: refinedGrid,
1631
+ refined: true,
1632
+ points: pointInfo,
1633
+ initialGridScore: initialGrid.score
1634
+ };
1635
+ }
1636
+
1399
1637
  function solveLinearSystemFloat(matrix, vector) {
1400
1638
  const n = vector.length;
1401
1639
  const a = matrix.map((row, index) => row.slice().concat([vector[index]]));
@@ -1437,6 +1675,43 @@ function computeHomography(sourcePoints, destinationPoints) {
1437
1675
  return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1];
1438
1676
  }
1439
1677
 
1678
+ function computeHomographyLeastSquares(sourcePoints, destinationPoints, weights = null) {
1679
+ // Same destination -> source convention as computeHomography(), but accepts
1680
+ // more than four correspondences and averages locator/alignment measurement
1681
+ // noise. This is used only as a refinement after a valid four-point projective
1682
+ // solution already exists.
1683
+ assert(
1684
+ sourcePoints.length === destinationPoints.length && sourcePoints.length >= 4,
1685
+ "At least four matching point pairs are required."
1686
+ );
1687
+
1688
+ const normal = Array.from({ length: 8 }, () => Array(8).fill(0));
1689
+ const rhs = Array(8).fill(0);
1690
+
1691
+ const addEquation = (row, value, weight) => {
1692
+ for (let i = 0; i < 8; i++) {
1693
+ rhs[i] += row[i] * value * weight;
1694
+ for (let j = 0; j < 8; j++) normal[i][j] += row[i] * row[j] * weight;
1695
+ }
1696
+ };
1697
+
1698
+ for (let i = 0; i < sourcePoints.length; i++) {
1699
+ const u = destinationPoints[i].x;
1700
+ const v = destinationPoints[i].y;
1701
+ const x = sourcePoints[i].x;
1702
+ const y = sourcePoints[i].y;
1703
+ const weight = Math.max(0.05, Number(weights?.[i] ?? 1));
1704
+ addEquation([u, v, 1, 0, 0, 0, -u * x, -v * x], x, weight);
1705
+ addEquation([0, 0, 0, u, v, 1, -u * y, -v * y], y, weight);
1706
+ }
1707
+
1708
+ // Tiny Tikhonov regularisation keeps near-degenerate noisy fits numerically
1709
+ // stable without moving a normal QR-sized solution in any meaningful way.
1710
+ for (let i = 0; i < 8; i++) normal[i][i] += 1e-9;
1711
+ const h = solveLinearSystemFloat(normal, rhs);
1712
+ return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1];
1713
+ }
1714
+
1440
1715
  function projectPoint(h, u, v) {
1441
1716
  const denominator = h[6] * u + h[7] * v + h[8];
1442
1717
  return {
@@ -1462,7 +1737,7 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
1462
1737
  for (const item of versions) {
1463
1738
  const version = item.version;
1464
1739
  const size = sizeForVersion(version);
1465
- const alignment = searchAlignment(binary, width, height, triple, version);
1740
+ const alignment = searchAlignment(binary, width, height, triple, version, options);
1466
1741
  if (alignment.score < (options.alignmentThreshold ?? 0.72)) continue;
1467
1742
 
1468
1743
  const alignmentTarget = alignment.target;
@@ -1486,8 +1761,34 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
1486
1761
  continue;
1487
1762
  }
1488
1763
 
1489
- const alignmentGrid = scoreAlignmentGrid(binary, width, height, homography, version);
1490
- if (alignmentGrid.score < (options.alignmentGridThreshold ?? 0.68)) continue;
1764
+ const initialAlignmentGrid = scoreAlignmentGrid(binary, width, height, homography, version);
1765
+ const alignmentGridThreshold = options.alignmentGridThreshold ?? 0.68;
1766
+ const refinementFloor = Math.max(0, alignmentGridThreshold - (options.alignmentRefineCandidateMargin ?? 0.10));
1767
+ const skipRefinementScore = options.alignmentRefineSkipScore ?? 0.985;
1768
+ const refinement = (
1769
+ initialAlignmentGrid.score >= refinementFloor &&
1770
+ initialAlignmentGrid.score < skipRefinementScore
1771
+ )
1772
+ ? refineHomographyWithAlignmentGrid(
1773
+ binary,
1774
+ width,
1775
+ height,
1776
+ homography,
1777
+ version,
1778
+ src,
1779
+ dest,
1780
+ initialAlignmentGrid,
1781
+ options
1782
+ )
1783
+ : {
1784
+ homography,
1785
+ grid: initialAlignmentGrid,
1786
+ refined: false,
1787
+ points: []
1788
+ };
1789
+ homography = refinement.homography;
1790
+ const alignmentGrid = refinement.grid;
1791
+ if (alignmentGrid.score < alignmentGridThreshold) continue;
1491
1792
 
1492
1793
  const versionPenalty = item.error / 4;
1493
1794
  const alignmentConfidence = 0.55 * alignment.score + 0.45 * alignmentGrid.score;
@@ -1506,6 +1807,9 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
1506
1807
  target: { row: alignmentTarget.row, col: alignmentTarget.col },
1507
1808
  patterns: alignmentGrid.patternScores.length,
1508
1809
  gridScore: alignmentGrid.score,
1810
+ initialGridScore: refinement.initialGridScore ?? initialAlignmentGrid.score,
1811
+ refined: refinement.refined,
1812
+ refinementPoints: refinement.points,
1509
1813
  patternScores: alignmentGrid.patternScores
1510
1814
  },
1511
1815
  threshold,
@@ -1577,19 +1881,21 @@ function detectCodeGeometry(imageData, options = {}) {
1577
1881
  const maxCandidates = options.maxCandidates ?? 8;
1578
1882
 
1579
1883
  const evaluatePass = (pass, recovery = false) => {
1580
- const finders = detectFinderCandidates(pass.binary, width, height, pass.detector ?? {});
1581
- const geometries = geometryCandidatesFromBinary(
1884
+ const detector = pass.detector ?? {};
1885
+ const finders = detectFinderCandidates(pass.binary, width, height, detector);
1886
+ const geometryOptions = {
1887
+ ...options,
1888
+ finderMethod: pass.finderMethod,
1889
+ alignmentThreshold: recovery ? 0.68 : 0.72,
1890
+ alignmentGridThreshold: recovery ? 0.64 : 0.68
1891
+ };
1892
+ let geometries = geometryCandidatesFromBinary(
1582
1893
  pass.binary,
1583
1894
  width,
1584
1895
  height,
1585
1896
  finders,
1586
1897
  pass.threshold,
1587
- {
1588
- ...options,
1589
- finderMethod: pass.finderMethod,
1590
- alignmentThreshold: recovery ? 0.68 : 0.72,
1591
- alignmentGridThreshold: recovery ? 0.64 : 0.68
1592
- }
1898
+ geometryOptions
1593
1899
  );
1594
1900
 
1595
1901
  pushFinderDiagnostic(
@@ -1599,6 +1905,35 @@ function detectCodeGeometry(imageData, options = {}) {
1599
1905
  finders,
1600
1906
  geometries
1601
1907
  );
1908
+
1909
+ // Dense codes under projective distortion can leave two finder patterns
1910
+ // perfectly strong while the third is stretched enough to miss the normal
1911
+ // 1:1:3:1:1 tolerance. Only in that very specific recovery state, run one
1912
+ // bounded looser detector and let the normal three-finder geometry checks
1913
+ // reject false data-cell lookalikes. The clean fast path is unchanged.
1914
+ if (!geometries.length && recovery && finders.length === 2) {
1915
+ const recoveredFinders = recoverFinderSetFromTwo(pass.binary, width, height, finders, detector);
1916
+ if (recoveredFinders.length > 2) {
1917
+ const finderMethod = `${pass.finderMethod}-two-finder-recovery`;
1918
+ geometries = geometryCandidatesFromBinary(
1919
+ pass.binary,
1920
+ width,
1921
+ height,
1922
+ recoveredFinders,
1923
+ pass.threshold,
1924
+ { ...geometryOptions, finderMethod }
1925
+ );
1926
+ pushFinderDiagnostic(
1927
+ { ...options, width, height, diagnosticLabel: `${options.diagnosticLabel ?? "perspective"}-two-finder` },
1928
+ finderMethod,
1929
+ pass.threshold,
1930
+ recoveredFinders,
1931
+ geometries
1932
+ );
1933
+ if (geometries.length) return { finders: recoveredFinders, geometries };
1934
+ }
1935
+ }
1936
+
1602
1937
  return { finders, geometries };
1603
1938
  };
1604
1939
 
@@ -1614,6 +1949,38 @@ function detectCodeGeometry(imageData, options = {}) {
1614
1949
  if (fast.geometries.length) return fast.geometries.slice(0, maxCandidates);
1615
1950
  if (options.finderRecovery === false) return [];
1616
1951
 
1952
+ // If the clean threshold already found exactly two strong locators, try the
1953
+ // bounded perspective-tolerant third-finder pass before any color processing.
1954
+ // This is substantially cheaper than Auto Color and targets the dense-code
1955
+ // projective failure mode directly.
1956
+ if (fast.finders.length === 2) {
1957
+ const recoveredFinders = recoverFinderSetFromTwo(valueInfo.binary, width, height, fast.finders, {});
1958
+ if (recoveredFinders.length > 2) {
1959
+ const finderMethod = "rgb-value-otsu-two-finder-recovery";
1960
+ const recoveredGeometries = geometryCandidatesFromBinary(
1961
+ valueInfo.binary,
1962
+ width,
1963
+ height,
1964
+ recoveredFinders,
1965
+ valueInfo.threshold,
1966
+ {
1967
+ ...options,
1968
+ finderMethod,
1969
+ alignmentThreshold: 0.68,
1970
+ alignmentGridThreshold: 0.64
1971
+ }
1972
+ );
1973
+ pushFinderDiagnostic(
1974
+ { ...options, width, height, diagnosticLabel: `${options.diagnosticLabel ?? "perspective"}-two-finder` },
1975
+ finderMethod,
1976
+ valueInfo.threshold,
1977
+ recoveredFinders,
1978
+ recoveredGeometries
1979
+ );
1980
+ if (recoveredGeometries.length) return recoveredGeometries.slice(0, maxCandidates);
1981
+ }
1982
+ }
1983
+
1617
1984
  // Camera recovery #1: Photoshop Auto Color-style per-channel levels before
1618
1985
  // finder thresholding. Live camera frames are usually much larger than the
1619
1986
  // QR itself, so a single global histogram can be dominated by dark room/UI
@@ -2020,6 +2387,49 @@ function samplePerspectiveMatrix(imageData, homography, size, options = {}) {
2020
2387
  return { rgbGrid };
2021
2388
  }
2022
2389
 
2390
+ /**
2391
+ * Sample the two protected regions of every Triangle16 module.
2392
+ *
2393
+ * Triangle16 uses a fixed "/" diagonal. Samples are intentionally placed well
2394
+ * inside the upper-left and lower-right triangles, away from the diagonal and
2395
+ * module borders. This makes the decoder much less sensitive to antialiasing,
2396
+ * blur, resampling and small homography errors than center sampling.
2397
+ */
2398
+ function samplePerspectiveTriangleMatrix(imageData, homography, size, options = {}) {
2399
+ const triangleGrid = Array.from({ length: size }, () => new Array(size));
2400
+ const mode = options.sampleMode ?? "cross";
2401
+ const radius = options.highDensitySampleRadius ?? options.triangleSampleRadius ??
2402
+ (mode === "median" ? 0.075 : 0.085);
2403
+ const inset = Math.max(0.18, Math.min(0.36, Number(options.highDensitySampleInset ?? options.triangleSampleInset ?? 0.28)));
2404
+ const firstOffset = inset;
2405
+ const secondOffset = 1 - inset;
2406
+ const offsetX = Number.isFinite(options.sampleOffsetX) ? options.sampleOffsetX : 0;
2407
+ const offsetY = Number.isFinite(options.sampleOffsetY) ? options.sampleOffsetY : 0;
2408
+ const sampler = mode === "median" ? robustProjectedSample : averageProjectedSample;
2409
+
2410
+ for (let r = 0; r < size; r++) {
2411
+ for (let c = 0; c < size; c++) {
2412
+ triangleGrid[r][c] = {
2413
+ first: sampler(
2414
+ imageData,
2415
+ homography,
2416
+ c + firstOffset + offsetX,
2417
+ r + firstOffset + offsetY,
2418
+ radius
2419
+ ),
2420
+ second: sampler(
2421
+ imageData,
2422
+ homography,
2423
+ c + secondOffset + offsetX,
2424
+ r + secondOffset + offsetY,
2425
+ radius
2426
+ )
2427
+ };
2428
+ }
2429
+ }
2430
+ return { triangleGrid };
2431
+ }
2432
+
2023
2433
  function meanRgb(values) {
2024
2434
  if (!values.length) throw new Error("No calibration samples available.");
2025
2435
  return values.reduce(
@@ -2216,6 +2626,34 @@ function sampleAxisAlignedGrid(imageData, bounds, size, radiusRatio = 0.18) {
2216
2626
  return { rgbGrid, moduleWidth: moduleW, moduleHeight: moduleH };
2217
2627
  }
2218
2628
 
2629
+ function sampleAxisAlignedTriangleGrid(imageData, bounds, size, radiusRatio = 0.085, insetRatio = 0.28) {
2630
+ const moduleW = bounds.width / size;
2631
+ const moduleH = bounds.height / size;
2632
+ const inset = Math.max(0.18, Math.min(0.36, insetRatio));
2633
+ const radius = Math.max(0, Math.min(moduleW, moduleH) * radiusRatio);
2634
+ const triangleGrid = Array.from({ length: size }, () => new Array(size));
2635
+
2636
+ for (let r = 0; r < size; r++) {
2637
+ for (let c = 0; c < size; c++) {
2638
+ triangleGrid[r][c] = {
2639
+ first: sampleAverageAxis(
2640
+ imageData,
2641
+ bounds.x + (c + inset) * moduleW,
2642
+ bounds.y + (r + inset) * moduleH,
2643
+ radius
2644
+ ),
2645
+ second: sampleAverageAxis(
2646
+ imageData,
2647
+ bounds.x + (c + 1 - inset) * moduleW,
2648
+ bounds.y + (r + 1 - inset) * moduleH,
2649
+ radius
2650
+ )
2651
+ };
2652
+ }
2653
+ }
2654
+ return { triangleGrid, moduleWidth: moduleW, moduleHeight: moduleH };
2655
+ }
2656
+
2219
2657
  const visionInternals = Object.freeze({
2220
2658
  buildGray,
2221
2659
  otsuThreshold,
@@ -2228,21 +2666,21 @@ const visionInternals = Object.freeze({
2228
2666
  luminance
2229
2667
  });
2230
2668
 
2231
- return { computeHomography, projectPoint, detectCodeGeometry, autoColorImageData, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, samplePerspectiveMatrix, sampleObservedPalette, spatiallyNormalizeRgbGrid, rectifyImageData, findActiveBounds, sampleAxisAlignedGrid, visionInternals };
2669
+ return { computeHomography, projectPoint, detectCodeGeometry, autoColorImageData, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, samplePerspectiveMatrix, samplePerspectiveTriangleMatrix, sampleObservedPalette, spatiallyNormalizeRgbGrid, rectifyImageData, findActiveBounds, sampleAxisAlignedGrid, sampleAxisAlignedTriangleGrid, visionInternals };
2232
2670
  })();
2233
2671
  const __qqr_quadqr_js = (function () {
2234
2672
  const { MAX_CODEWORD_SYMBOLS, rsEncode, rsDecode } = __qqr_reed_solomon_js;
2235
2673
  const { alignmentPatternCentersForVersion, alignmentPatternIsBlack, alignmentPatternRadius, sizeForVersion, versionFromSize } = __qqr_geometry_js;
2236
- const { detectCodeGeometry, samplePerspectiveMatrix, rectifyImageData, sampleObservedPalette, spatiallyNormalizeRgbGrid, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, autoColorImageData, findActiveBounds, sampleAxisAlignedGrid } = __qqr_vision_js;
2674
+ const { detectCodeGeometry, computeHomography, projectPoint, samplePerspectiveMatrix, samplePerspectiveTriangleMatrix, rectifyImageData, sampleObservedPalette, spatiallyNormalizeRgbGrid, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, autoColorImageData, findActiveBounds, sampleAxisAlignedGrid, sampleAxisAlignedTriangleGrid } = __qqr_vision_js;
2237
2675
  const { decryptSecurePayload, encryptSecurePayload, inspectSecureEnvelope, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex } = __qqr_security_js;
2238
2676
  /**
2239
2677
  * QuadQR
2240
2678
  *
2241
- * Experimental four-state RGBW matrix code written in pure JavaScript.
2679
+ * Experimental RGBW / Triangle16 matrix code written in pure JavaScript.
2242
2680
  *
2243
2681
  * Core format:
2244
- * - Red / Green / Blue / White data cells
2245
- * - exactly 2 bits per data cell
2682
+ * - RGBW profile: Red / Green / Blue / White, exactly 2 bits per data cell
2683
+ * - Triangle16 profile: two fixed RGBW triangle regions, 16 states / 4 bits per body data cell
2246
2684
  * - GF(256) Reed-Solomon ECC over byte symbols
2247
2685
  * - interleaved body ECC blocks
2248
2686
  * - zero-overhead spectral-spatial cell placement
@@ -2257,6 +2695,34 @@ const MIN_VERSION = 1;
2257
2695
  const MAX_VERSION = 40;
2258
2696
  const DEFAULT_ECC_LEVEL = "M";
2259
2697
 
2698
+ const RENDER_MODES = Object.freeze({
2699
+ SCREEN: "screen",
2700
+ PRINT: "print"
2701
+ });
2702
+
2703
+ const CELL_ENCODINGS = Object.freeze({
2704
+ RGBW: "rgbw",
2705
+ TRIANGLE16: "triangle16"
2706
+ });
2707
+
2708
+
2709
+ // Print-safe defaults deliberately use darker, more ink-tolerant primaries and
2710
+ // a true white background. Applications can still override individual colors.
2711
+ const PRINT_PALETTE = Object.freeze({
2712
+ black: "#000000",
2713
+ white: "#ffffff",
2714
+ red: "#d71932",
2715
+ green: "#087f3e",
2716
+ blue: "#174ea6"
2717
+ });
2718
+
2719
+ const COMPRESSION_MODES = Object.freeze({
2720
+ NONE: "none",
2721
+ AUTO: "auto",
2722
+ LZ: "lz"
2723
+ });
2724
+ const SIGNATURE_ALGORITHMS = Object.freeze({ ED25519: "Ed25519" });
2725
+
2260
2726
  // Data cells are the four values 0..3. Structural white deliberately shares
2261
2727
  // value 3 with data-white because both render and scan identically.
2262
2728
  const CELL = Object.freeze({
@@ -2297,8 +2763,10 @@ const MAGIC = new Uint8Array([0x51, 0x51, 0x52, 0x57]); // QQRW (QuadQR RGBW)
2297
2763
  const HEADER_BYTES = 10;
2298
2764
  const HEADER_RS_PARITY = 8;
2299
2765
  const HEADER_CODEWORD_BYTES = HEADER_BYTES + HEADER_RS_PARITY;
2300
- const CELLS_PER_BYTE = 4;
2301
- const HEADER_CODEWORD_CELLS = HEADER_CODEWORD_BYTES * CELLS_PER_BYTE;
2766
+ const RGBW_CELLS_PER_BYTE = 4;
2767
+ const TRIANGLE16_CELLS_PER_BYTE = 2;
2768
+ const CELLS_PER_BYTE = RGBW_CELLS_PER_BYTE; // legacy internal alias
2769
+ const HEADER_CODEWORD_CELLS = HEADER_CODEWORD_BYTES * RGBW_CELLS_PER_BYTE;
2302
2770
 
2303
2771
  // Version 1 is a deliberately compact small-symbol profile. A 21x21 matrix
2304
2772
  // cannot afford the normal 18-byte protected header plus a 24-byte M body
@@ -2310,7 +2778,7 @@ const COMPACT_HEADER_MAGIC = 0xc3;
2310
2778
  const COMPACT_HEADER_BYTES = 4;
2311
2779
  const COMPACT_HEADER_RS_PARITY = 4;
2312
2780
  const COMPACT_HEADER_CODEWORD_BYTES = COMPACT_HEADER_BYTES + COMPACT_HEADER_RS_PARITY;
2313
- const COMPACT_HEADER_CODEWORD_CELLS = COMPACT_HEADER_CODEWORD_BYTES * CELLS_PER_BYTE;
2781
+ const COMPACT_HEADER_CODEWORD_CELLS = COMPACT_HEADER_CODEWORD_BYTES * RGBW_CELLS_PER_BYTE;
2314
2782
  const COMPACT_ECC_LEVELS = Object.freeze({
2315
2783
  L: Object.freeze({ paritySymbols: 4, correctableSymbolsPerBlock: 2 }),
2316
2784
  M: Object.freeze({ paritySymbols: 8, correctableSymbolsPerBlock: 4 }),
@@ -2321,6 +2789,23 @@ const COMPACT_ECC_LEVELS = Object.freeze({
2321
2789
  const CRC_BYTES = 4;
2322
2790
  const TEXT_FLAG = 1;
2323
2791
  const SECURE_FLAG = 1 << 3;
2792
+ const EXTENDED_PAYLOAD_FLAG = 1 << 4;
2793
+ const SIGNED_FLAG = 1 << 5;
2794
+ const TRIANGLE16_FLAG = 1 << 6;
2795
+
2796
+ // Internal payload extension envelope. This is deliberately not a public
2797
+ // payload mode or content-type system. It exists only when compression or
2798
+ // signing needs metadata around an otherwise normal text/binary payload.
2799
+ const PAYLOAD_ENVELOPE_VERSION = 2;
2800
+ const PAYLOAD_ENVELOPE_MAGIC = new Uint8Array([0x51, 0x50, 0x58, 0x31]); // QPX1
2801
+ const PAYLOAD_ENVELOPE_HEADER_BYTES = 16;
2802
+ const PAYLOAD_ENVELOPE_SIGNED_FLAG = 1;
2803
+ const PAYLOAD_ENVELOPE_COMPRESSED_FLAG = 1 << 1;
2804
+ const PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG = 1 << 2;
2805
+ const PAYLOAD_ENVELOPE_COMPRESSION_IDS = Object.freeze({ none: 0, lz: 1 });
2806
+ const PAYLOAD_ENVELOPE_COMPRESSION_BY_ID = Object.freeze({ 0: "none", 1: "lz" });
2807
+ const PAYLOAD_ENVELOPE_SIGNATURE_IDS = Object.freeze({ none: 0, Ed25519: 1 });
2808
+ const PAYLOAD_ENVELOPE_SIGNATURE_BY_ID = Object.freeze({ 0: null, 1: "Ed25519" });
2324
2809
  const ECC_SHIFT = 1;
2325
2810
  const ECC_MASK = 0b00000110;
2326
2811
 
@@ -2369,6 +2854,45 @@ function normalizeEccLevel(level = DEFAULT_ECC_LEVEL) {
2369
2854
  return value;
2370
2855
  }
2371
2856
 
2857
+ function normalizeCellEncoding(value = CELL_ENCODINGS.RGBW) {
2858
+ const key = String(value).toLowerCase();
2859
+ assert(
2860
+ key === CELL_ENCODINGS.RGBW || key === CELL_ENCODINGS.TRIANGLE16,
2861
+ `Internal cell encoding must be ${CELL_ENCODINGS.RGBW} or ${CELL_ENCODINGS.TRIANGLE16}.`
2862
+ );
2863
+ return key;
2864
+ }
2865
+
2866
+ // Public API presents Triangle16 as one experimental High Density Mode toggle.
2867
+ // cellEncoding remains an internal/backward-compatible detail so existing
2868
+ // experimental branch symbols and renderer metadata continue to work.
2869
+ function cellEncodingFromOptions(options = {}) {
2870
+ if (typeof options.highDensity === "boolean") {
2871
+ return options.highDensity ? CELL_ENCODINGS.TRIANGLE16 : CELL_ENCODINGS.RGBW;
2872
+ }
2873
+ return normalizeCellEncoding(options.cellEncoding ?? CELL_ENCODINGS.RGBW);
2874
+ }
2875
+
2876
+ function cellsPerByteForEncoding(cellEncoding = CELL_ENCODINGS.RGBW) {
2877
+ return normalizeCellEncoding(cellEncoding) === CELL_ENCODINGS.TRIANGLE16
2878
+ ? TRIANGLE16_CELLS_PER_BYTE
2879
+ : RGBW_CELLS_PER_BYTE;
2880
+ }
2881
+
2882
+ function packTriangleCell(first, second) {
2883
+ assert(first >= 0 && first <= 3 && second >= 0 && second <= 3, "Invalid Triangle16 color.");
2884
+ return ((first & 3) << 2) | (second & 3);
2885
+ }
2886
+
2887
+ function unpackTriangleCell(value) {
2888
+ assert(Number.isInteger(value) && value >= 0 && value <= 15, "Invalid Triangle16 data cell.");
2889
+ return { first: (value >>> 2) & 3, second: value & 3 };
2890
+ }
2891
+
2892
+ function solidTriangleCell(color) {
2893
+ return packTriangleCell(color, color);
2894
+ }
2895
+
2372
2896
  function concatBytes(...arrays) {
2373
2897
  const total = arrays.reduce((sum, item) => sum + item.length, 0);
2374
2898
  const out = new Uint8Array(total);
@@ -2399,6 +2923,368 @@ function readU32be(bytes, offset = 0) {
2399
2923
  ) >>> 0;
2400
2924
  }
2401
2925
 
2926
+ function u16be(value) {
2927
+ const v = Number(value) >>> 0;
2928
+ assert(v <= 0xffff, "Value does not fit in uint16.");
2929
+ return new Uint8Array([(v >>> 8) & 0xff, v & 0xff]);
2930
+ }
2931
+
2932
+ function readU16be(bytes, offset = 0) {
2933
+ return ((bytes[offset] << 8) | bytes[offset + 1]) >>> 0;
2934
+ }
2935
+
2936
+ function normalizeCompressionMode(value = "none") {
2937
+ const key = String(value).toLowerCase();
2938
+ assert(["none", "auto", "lz"].includes(key), "compression must be none, auto, or lz.");
2939
+ return key;
2940
+ }
2941
+
2942
+ function asBytes(input) {
2943
+ if (input instanceof Uint8Array) return input;
2944
+ if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
2945
+ if (input instanceof ArrayBuffer) return new Uint8Array(input);
2946
+ return new Uint8Array(input);
2947
+ }
2948
+
2949
+ /** Portable LZSS-style compressor used by QuadQR payload compression. */
2950
+ function compressPayload(input) {
2951
+ const bytes = asBytes(input);
2952
+ if (bytes.length < 4) return bytes.slice();
2953
+ const out = [];
2954
+ const recent = new Map();
2955
+ let pos = 0;
2956
+
2957
+ const keyAt = (i) => i + 2 < bytes.length ? ((bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]) : -1;
2958
+ const remember = (i) => {
2959
+ const key = keyAt(i);
2960
+ if (key < 0) return;
2961
+ let list = recent.get(key);
2962
+ if (!list) recent.set(key, list = []);
2963
+ list.push(i);
2964
+ while (list.length > 32) list.shift();
2965
+ while (list.length && i - list[0] > 4095) list.shift();
2966
+ };
2967
+
2968
+ while (pos < bytes.length) {
2969
+ const flagIndex = out.length;
2970
+ out.push(0);
2971
+ let flags = 0;
2972
+ for (let token = 0; token < 8 && pos < bytes.length; token++) {
2973
+ let bestLength = 0;
2974
+ let bestOffset = 0;
2975
+ const key = keyAt(pos);
2976
+ const candidates = key >= 0 ? (recent.get(key) ?? []) : [];
2977
+ for (let ci = candidates.length - 1; ci >= 0; ci--) {
2978
+ const candidate = candidates[ci];
2979
+ const offset = pos - candidate;
2980
+ if (offset <= 0 || offset > 4095) continue;
2981
+ let length = 0;
2982
+ while (length < 18 && pos + length < bytes.length && bytes[candidate + length] === bytes[pos + length]) length++;
2983
+ if (length >= 3 && length > bestLength) {
2984
+ bestLength = length;
2985
+ bestOffset = offset;
2986
+ if (length === 18) break;
2987
+ }
2988
+ }
2989
+
2990
+ if (bestLength >= 3) {
2991
+ flags |= (1 << token);
2992
+ const encoded = ((bestOffset & 0x0fff) << 4) | ((bestLength - 3) & 0x0f);
2993
+ out.push((encoded >>> 8) & 0xff, encoded & 0xff);
2994
+ for (let i = 0; i < bestLength; i++) remember(pos + i);
2995
+ pos += bestLength;
2996
+ } else {
2997
+ out.push(bytes[pos]);
2998
+ remember(pos);
2999
+ pos++;
3000
+ }
3001
+ }
3002
+ out[flagIndex] = flags;
3003
+ }
3004
+ return Uint8Array.from(out);
3005
+ }
3006
+
3007
+ function decompressPayload(input, expectedLength = null) {
3008
+ const bytes = asBytes(input);
3009
+ const out = [];
3010
+ let pos = 0;
3011
+ while (pos < bytes.length) {
3012
+ const flags = bytes[pos++];
3013
+ for (let token = 0; token < 8 && pos < bytes.length; token++) {
3014
+ if (flags & (1 << token)) {
3015
+ assert(pos + 1 < bytes.length, "Compressed payload is truncated.");
3016
+ const encoded = (bytes[pos++] << 8) | bytes[pos++];
3017
+ const offset = (encoded >>> 4) & 0x0fff;
3018
+ const length = (encoded & 0x0f) + 3;
3019
+ assert(offset > 0 && offset <= out.length, "Compressed payload has an invalid back-reference.");
3020
+ for (let i = 0; i < length; i++) out.push(out[out.length - offset]);
3021
+ } else {
3022
+ out.push(bytes[pos++]);
3023
+ }
3024
+ if (expectedLength != null && out.length >= expectedLength) break;
3025
+ }
3026
+ }
3027
+ if (expectedLength != null) assert(out.length === expectedLength, "Decompressed payload length mismatch.");
3028
+ return Uint8Array.from(out);
3029
+ }
3030
+
3031
+ function prepareCompressedBody(payload, compression = "none") {
3032
+ const mode = normalizeCompressionMode(compression);
3033
+ if (mode === "none") return { body: payload, compression: "none" };
3034
+ const compressed = compressPayload(payload);
3035
+ if (mode === "auto" && compressed.length >= payload.length - 2) {
3036
+ return { body: payload, compression: "none" };
3037
+ }
3038
+ return { body: compressed, compression: "lz" };
3039
+ }
3040
+
3041
+
3042
+ function normalizeSigningKeyId(value) {
3043
+ if (value == null || value === false || value === "") return new Uint8Array(0);
3044
+ const bytes = typeof value === "string" ? getTextEncoder().encode(value) : asBytes(value);
3045
+ assert(bytes.length <= 64, "Signing key ID must be 64 bytes or fewer.");
3046
+ return bytes;
3047
+ }
3048
+
3049
+ function makePayloadEnvelopeHeader({ compression, flags = 0, originalLength, keyIdLength = 0, publicKeyLength = 0, signatureLength = 0, signatureAlgorithm = null, version = PAYLOAD_ENVELOPE_VERSION }) {
3050
+ assert(keyIdLength <= 0xff, "Signing key ID is too long.");
3051
+ assert(publicKeyLength <= 0xff, "Signing public key is too large.");
3052
+ assert(signatureLength <= 0xff, "Signature is too large.");
3053
+ const header = new Uint8Array(PAYLOAD_ENVELOPE_HEADER_BYTES);
3054
+ header.set(PAYLOAD_ENVELOPE_MAGIC, 0);
3055
+ header[4] = version;
3056
+ header[5] = flags & 0xff;
3057
+ header[6] = PAYLOAD_ENVELOPE_COMPRESSION_IDS[compression];
3058
+ header[7] = PAYLOAD_ENVELOPE_SIGNATURE_IDS[signatureAlgorithm ?? "none"] ?? 0;
3059
+ header.set(u32be(originalLength), 8);
3060
+ header[12] = keyIdLength;
3061
+ header[13] = publicKeyLength;
3062
+ header[14] = signatureLength;
3063
+ header[15] = 0;
3064
+ return header;
3065
+ }
3066
+
3067
+ function isPayloadEnvelope(input) {
3068
+ const bytes = asBytes(input);
3069
+ return bytes.length >= PAYLOAD_ENVELOPE_HEADER_BYTES && PAYLOAD_ENVELOPE_MAGIC.every((value, index) => bytes[index] === value);
3070
+ }
3071
+
3072
+ function packPayloadEnvelope(input, options = {}) {
3073
+ const payload = asBytes(input);
3074
+ const prepared = prepareCompressedBody(payload, options.compression ?? "none");
3075
+ const flags = prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0;
3076
+ const header = makePayloadEnvelopeHeader({
3077
+ compression: prepared.compression,
3078
+ flags,
3079
+ originalLength: payload.length
3080
+ });
3081
+ return concatBytes(header, prepared.body);
3082
+ }
3083
+
3084
+ function parsePayloadEnvelope(input) {
3085
+ const container = asBytes(input);
3086
+ assert(isPayloadEnvelope(container), "Payload extension envelope is not recognized.");
3087
+ const version = container[4];
3088
+ assert(version === 1 || version === PAYLOAD_ENVELOPE_VERSION, `Unsupported payload extension version ${version}.`);
3089
+ const flags = container[5];
3090
+ const compression = PAYLOAD_ENVELOPE_COMPRESSION_BY_ID[container[6]];
3091
+ assert(compression != null, `Unknown payload compression id ${container[6]}.`);
3092
+ const signatureAlgorithm = PAYLOAD_ENVELOPE_SIGNATURE_BY_ID[container[7]];
3093
+ const originalLength = readU32be(container, 8);
3094
+ const metadataLength = container[12];
3095
+ const publicKeyLength = container[13];
3096
+ const signatureLength = container[14];
3097
+ let offset = PAYLOAD_ENVELOPE_HEADER_BYTES;
3098
+ const endMetadata = offset + metadataLength;
3099
+ const endPublicKey = endMetadata + publicKeyLength;
3100
+ const endSignature = endPublicKey + signatureLength;
3101
+ assert(endSignature <= container.length, "Payload extension length fields exceed the envelope size.");
3102
+ const metadataBytes = container.slice(offset, endMetadata);
3103
+ const publicKey = container.slice(endMetadata, endPublicKey);
3104
+ const signature = container.slice(endPublicKey, endSignature);
3105
+ const stored = container.slice(endSignature);
3106
+ const payload = compression === "lz" ? decompressPayload(stored, originalLength) : stored;
3107
+ assert(payload.length === originalLength, "Payload length mismatch after extension decoding.");
3108
+ const signed = Boolean(flags & PAYLOAD_ENVELOPE_SIGNED_FLAG);
3109
+ const signedBytes = signed ? concatBytes(container.slice(0, PAYLOAD_ENVELOPE_HEADER_BYTES), metadataBytes, publicKey, stored) : null;
3110
+
3111
+ // v1 used byte 12 for a human-readable signer label and always embedded
3112
+ // the public key. v2 uses the same field for a compact key ID and makes
3113
+ // public-key embedding optional.
3114
+ const legacySigner = version === 1 && metadataLength ? getTextDecoder().decode(metadataBytes) : null;
3115
+ const keyId = version >= 2 && metadataLength ? getTextDecoder().decode(metadataBytes) : null;
3116
+ const keyIdHex = version >= 2 && metadataLength
3117
+ ? Array.from(metadataBytes, (value) => value.toString(16).padStart(2, "0")).join("")
3118
+ : null;
3119
+
3120
+ return {
3121
+ container,
3122
+ version,
3123
+ compression,
3124
+ compressed: compression !== "none",
3125
+ originalBytes: originalLength,
3126
+ storedBytes: stored.length,
3127
+ savedBytes: originalLength - stored.length,
3128
+ payload,
3129
+ signed,
3130
+ signer: legacySigner,
3131
+ keyId,
3132
+ keyIdHex,
3133
+ publicKey,
3134
+ hasEmbeddedPublicKey: publicKey.length > 0,
3135
+ signature,
3136
+ signatureAlgorithm,
3137
+ signedBytes,
3138
+ signatureVerified: signed ? null : undefined,
3139
+ signatureTrusted: signed ? null : undefined
3140
+ };
3141
+ }
3142
+
3143
+ function subtleCrypto() {
3144
+ const subtle = globalThis.crypto?.subtle;
3145
+ assert(subtle, "Web Crypto is required for QuadQR signing.");
3146
+ return subtle;
3147
+ }
3148
+
3149
+ async function normalizeEd25519PublicKey(key) {
3150
+ const subtle = subtleCrypto();
3151
+ if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) return key;
3152
+ if (key && typeof key === "object" && key.type === "public" && key.algorithm?.name === "Ed25519") return key;
3153
+ const bytes = asBytes(key);
3154
+ assert(bytes.length === 32, "Ed25519 public key must be a 32-byte raw key or CryptoKey.");
3155
+ return subtle.importKey("raw", bytes, { name: "Ed25519" }, true, ["verify"]);
3156
+ }
3157
+
3158
+ async function normalizeEd25519PrivateKey(key) {
3159
+ const subtle = subtleCrypto();
3160
+ if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) return key;
3161
+ if (key && typeof key === "object" && key.type === "private" && key.algorithm?.name === "Ed25519") return key;
3162
+ const bytes = asBytes(key);
3163
+ return subtle.importKey("pkcs8", bytes, { name: "Ed25519" }, false, ["sign"]);
3164
+ }
3165
+
3166
+ async function deriveSigningKeyId(publicKey, bytes = 8) {
3167
+ const subtle = subtleCrypto();
3168
+ const normalized = await normalizeEd25519PublicKey(publicKey);
3169
+ const raw = new Uint8Array(await subtle.exportKey("raw", normalized));
3170
+ const digest = new Uint8Array(await subtle.digest("SHA-256", raw));
3171
+ const count = Math.max(4, Math.min(16, Math.floor(bytes)));
3172
+ return Array.from(digest.slice(0, count), (value) => value.toString(16).padStart(2, "0")).join("");
3173
+ }
3174
+
3175
+ async function generateSigningKeyPair() {
3176
+ const subtle = subtleCrypto();
3177
+ const pair = await subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
3178
+ const publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", pair.publicKey));
3179
+ const privateKeyPkcs8 = new Uint8Array(await subtle.exportKey("pkcs8", pair.privateKey));
3180
+ const keyId = await deriveSigningKeyId(pair.publicKey);
3181
+ return {
3182
+ privateKey: pair.privateKey,
3183
+ publicKey: pair.publicKey,
3184
+ privateKeyPkcs8,
3185
+ publicKeyBytes,
3186
+ keyId,
3187
+ algorithm: "Ed25519"
3188
+ };
3189
+ }
3190
+
3191
+ async function packSignedPayloadEnvelope(input, options = {}) {
3192
+ const payload = asBytes(input);
3193
+ assert(options.privateKey, "privateKey is required to sign a QuadQR payload.");
3194
+ const subtle = subtleCrypto();
3195
+ const prepared = prepareCompressedBody(payload, options.compression ?? "auto");
3196
+ const keyIdBytes = normalizeSigningKeyId(options.keyId);
3197
+
3198
+ let publicKeyBytes = new Uint8Array(0);
3199
+ if (options.embedPublicKey) {
3200
+ assert(options.publicKey, "publicKey is required when embedPublicKey is enabled.");
3201
+ const publicKeyObject = await normalizeEd25519PublicKey(options.publicKey);
3202
+ publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", publicKeyObject));
3203
+ }
3204
+
3205
+ const flags = PAYLOAD_ENVELOPE_SIGNED_FLAG |
3206
+ (prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0) |
3207
+ (publicKeyBytes.length ? PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG : 0);
3208
+ const header = makePayloadEnvelopeHeader({
3209
+ compression: prepared.compression,
3210
+ flags,
3211
+ originalLength: payload.length,
3212
+ keyIdLength: keyIdBytes.length,
3213
+ publicKeyLength: publicKeyBytes.length,
3214
+ signatureLength: 64,
3215
+ signatureAlgorithm: "Ed25519"
3216
+ });
3217
+ const signedBytes = concatBytes(header, keyIdBytes, publicKeyBytes, prepared.body);
3218
+ const privateKey = await normalizeEd25519PrivateKey(options.privateKey);
3219
+ const signature = new Uint8Array(await subtle.sign({ name: "Ed25519" }, privateKey, signedBytes));
3220
+ assert(signature.length === 64, "Unexpected Ed25519 signature length.");
3221
+ return concatBytes(header, keyIdBytes, publicKeyBytes, signature, prepared.body);
3222
+ }
3223
+
3224
+ function lookupTrustedSigningKey(trustedKeys, keyId) {
3225
+ if (!trustedKeys || !keyId) return null;
3226
+ if (trustedKeys instanceof Map) return trustedKeys.get(keyId) ?? null;
3227
+ if (typeof trustedKeys === "object") return trustedKeys[keyId] ?? null;
3228
+ return null;
3229
+ }
3230
+
3231
+ async function verifyPayloadEnvelopeSignature(input, options = {}) {
3232
+ const parsed = input?.container && input?.signedBytes ? input : parsePayloadEnvelope(input);
3233
+ assert(parsed.signed, "QuadQR payload is not signed.");
3234
+ assert(parsed.signatureAlgorithm === "Ed25519", "Unsupported signature algorithm.");
3235
+ const subtle = subtleCrypto();
3236
+
3237
+ let trusted = options.publicKey ?? options.trustedPublicKey ?? lookupTrustedSigningKey(options.trustedKeys, parsed.keyId);
3238
+ let trustSource = trusted ? "external" : null;
3239
+
3240
+ // Legacy v1 symbols embedded the public key by default, so preserve their
3241
+ // historical self-verification behavior. v2 requires explicit opt-in.
3242
+ if (!trusted && parsed.publicKey.length && (parsed.version === 1 || options.allowEmbeddedKey === true)) {
3243
+ trusted = parsed.publicKey;
3244
+ trustSource = "embedded";
3245
+ }
3246
+
3247
+ assert(
3248
+ trusted,
3249
+ parsed.keyId
3250
+ ? `A trusted Ed25519 public key is required to verify signing key ID "${parsed.keyId}".`
3251
+ : "A trusted Ed25519 public key is required to verify this signed QuadQR."
3252
+ );
3253
+
3254
+ const publicKey = await normalizeEd25519PublicKey(trusted);
3255
+ if (trustSource === "external" && parsed.publicKey.length) {
3256
+ const trustedBytes = new Uint8Array(await subtle.exportKey("raw", publicKey));
3257
+ assert(
3258
+ trustedBytes.length === parsed.publicKey.length && trustedBytes.every((value, index) => value === parsed.publicKey[index]),
3259
+ "Embedded signing key does not match the trusted public key."
3260
+ );
3261
+ }
3262
+
3263
+ const verified = await subtle.verify({ name: "Ed25519" }, publicKey, parsed.signature, parsed.signedBytes);
3264
+ return {
3265
+ verified,
3266
+ trusted: verified && trustSource === "external",
3267
+ trustSource,
3268
+ keyId: parsed.keyId ?? null
3269
+ };
3270
+ }
3271
+
3272
+ function prepareOptionalCompressedPayload(input, compression = "none") {
3273
+ const payload = asBytes(input);
3274
+ const mode = normalizeCompressionMode(compression);
3275
+ if (mode === "none") return { payload, extended: false, compression: "none" };
3276
+ const prepared = prepareCompressedBody(payload, mode);
3277
+ // Auto mode is truly zero-overhead when compression does not help.
3278
+ if (mode === "auto" && prepared.compression === "none") {
3279
+ return { payload, extended: false, compression: "none" };
3280
+ }
3281
+ return {
3282
+ payload: packPayloadEnvelope(payload, { compression: prepared.compression }),
3283
+ extended: true,
3284
+ compression: prepared.compression
3285
+ };
3286
+ }
3287
+
2402
3288
  let CRC_TABLE = null;
2403
3289
  let CRC32_ACCELERATOR = null;
2404
3290
 
@@ -2434,8 +3320,19 @@ function crc32(bytes) {
2434
3320
  return (crc ^ 0xffffffff) >>> 0;
2435
3321
  }
2436
3322
 
2437
- function bytesToCells(bytes) {
2438
- const out = new Array(bytes.length * CELLS_PER_BYTE);
3323
+ function bytesToCells(bytes, cellEncoding = CELL_ENCODINGS.RGBW) {
3324
+ const encoding = normalizeCellEncoding(cellEncoding);
3325
+ if (encoding === CELL_ENCODINGS.TRIANGLE16) {
3326
+ const out = new Array(bytes.length * TRIANGLE16_CELLS_PER_BYTE);
3327
+ let cursor = 0;
3328
+ for (const byte of bytes) {
3329
+ out[cursor++] = (byte >>> 4) & 0x0f;
3330
+ out[cursor++] = byte & 0x0f;
3331
+ }
3332
+ return out;
3333
+ }
3334
+
3335
+ const out = new Array(bytes.length * RGBW_CELLS_PER_BYTE);
2439
3336
  let cursor = 0;
2440
3337
  for (const byte of bytes) {
2441
3338
  out[cursor++] = (byte >>> 6) & 0b11;
@@ -2446,13 +3343,38 @@ function bytesToCells(bytes) {
2446
3343
  return out;
2447
3344
  }
2448
3345
 
2449
- function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYTE)) {
2450
- assert(cells.length >= byteCount * CELLS_PER_BYTE, "Not enough RGBW cells to rebuild bytes.");
2451
- const out = new Uint8Array(byteCount);
2452
- for (let i = 0; i < byteCount; i++) {
2453
- const offset = i * CELLS_PER_BYTE;
2454
- const a = cells[offset];
2455
- const b = cells[offset + 1];
3346
+ function bytesToProtectedHeaderCells(bytes, cellEncoding = CELL_ENCODINGS.RGBW) {
3347
+ const base = bytesToCells(bytes, CELL_ENCODINGS.RGBW);
3348
+ if (normalizeCellEncoding(cellEncoding) !== CELL_ENCODINGS.TRIANGLE16) return base;
3349
+ // Triangle16 deliberately keeps the protected bootstrap/header as solid-color
3350
+ // modules. This costs a few cells but makes mode detection and damaged-camera
3351
+ // recovery substantially more reliable.
3352
+ return base.map(solidTriangleCell);
3353
+ }
3354
+
3355
+ function cellsToBytes(cells, byteCount, cellEncoding = CELL_ENCODINGS.RGBW) {
3356
+ const encoding = normalizeCellEncoding(cellEncoding);
3357
+ const cellsPerByte = cellsPerByteForEncoding(encoding);
3358
+ const count = byteCount ?? Math.floor(cells.length / cellsPerByte);
3359
+ assert(cells.length >= count * cellsPerByte, "Not enough data cells to rebuild bytes.");
3360
+ const out = new Uint8Array(count);
3361
+
3362
+ if (encoding === CELL_ENCODINGS.TRIANGLE16) {
3363
+ for (let i = 0; i < count; i++) {
3364
+ const offset = i * TRIANGLE16_CELLS_PER_BYTE;
3365
+ const a = cells[offset];
3366
+ const b = cells[offset + 1];
3367
+ assert(Number.isInteger(a) && a >= 0 && a <= 15, "Invalid Triangle16 data cell.");
3368
+ assert(Number.isInteger(b) && b >= 0 && b <= 15, "Invalid Triangle16 data cell.");
3369
+ out[i] = (a << 4) | b;
3370
+ }
3371
+ return out;
3372
+ }
3373
+
3374
+ for (let i = 0; i < count; i++) {
3375
+ const offset = i * RGBW_CELLS_PER_BYTE;
3376
+ const a = cells[offset];
3377
+ const b = cells[offset + 1];
2456
3378
  const c = cells[offset + 2];
2457
3379
  const d = cells[offset + 3];
2458
3380
  for (const value of [a, b, c, d]) {
@@ -2463,12 +3385,31 @@ function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYT
2463
3385
  return out;
2464
3386
  }
2465
3387
 
2466
- function flagsFor(text, eccLevel, secure = false) {
3388
+ function protectedHeaderCellsToBytes(cells, byteCount, cellEncoding = CELL_ENCODINGS.RGBW) {
3389
+ if (normalizeCellEncoding(cellEncoding) !== CELL_ENCODINGS.TRIANGLE16) {
3390
+ return cellsToBytes(cells, byteCount, CELL_ENCODINGS.RGBW);
3391
+ }
3392
+ const colors = cells.map((value) => {
3393
+ const pair = unpackTriangleCell(value);
3394
+ if (pair.first !== pair.second) throw new Error("Triangle16 protected header lost solid-color integrity.");
3395
+ return pair.first;
3396
+ });
3397
+ return cellsToBytes(colors, byteCount, CELL_ENCODINGS.RGBW);
3398
+ }
3399
+
3400
+ function flagsFor(text, eccLevel, secure = false, extended = false, signed = false, cellEncoding = CELL_ENCODINGS.RGBW) {
2467
3401
  return (text ? TEXT_FLAG : 0) |
2468
3402
  (secure ? SECURE_FLAG : 0) |
3403
+ (extended ? EXTENDED_PAYLOAD_FLAG : 0) |
3404
+ (signed ? SIGNED_FLAG : 0) |
3405
+ (normalizeCellEncoding(cellEncoding) === CELL_ENCODINGS.TRIANGLE16 ? TRIANGLE16_FLAG : 0) |
2469
3406
  ((ECC_LEVELS[eccLevel].id << ECC_SHIFT) & ECC_MASK);
2470
3407
  }
2471
3408
 
3409
+ function cellEncodingFromFlags(flags) {
3410
+ return (flags & TRIANGLE16_FLAG) !== 0 ? CELL_ENCODINGS.TRIANGLE16 : CELL_ENCODINGS.RGBW;
3411
+ }
3412
+
2472
3413
  function eccFromFlags(flags) {
2473
3414
  const id = (flags & ECC_MASK) >> ECC_SHIFT;
2474
3415
  const name = ECC_BY_ID[id];
@@ -2718,7 +3659,8 @@ function maskValue(row, col, maskId) {
2718
3659
  }
2719
3660
  }
2720
3661
 
2721
- function makePaddingCells(count, seed) {
3662
+ function makePaddingCells(count, seed, cellEncoding = CELL_ENCODINGS.RGBW) {
3663
+ const encoding = normalizeCellEncoding(cellEncoding);
2722
3664
  let state = (seed >>> 0) || 0x6d2b79f5;
2723
3665
  const out = new Array(count);
2724
3666
  for (let i = 0; i < count; i++) {
@@ -2726,11 +3668,27 @@ function makePaddingCells(count, seed) {
2726
3668
  state ^= state >>> 17;
2727
3669
  state ^= state << 5;
2728
3670
  state >>>= 0;
2729
- out[i] = state & 3;
3671
+ out[i] = encoding === CELL_ENCODINGS.TRIANGLE16 ? (state & 0x0f) : (state & 3);
2730
3672
  }
2731
3673
  return out;
2732
3674
  }
2733
3675
 
3676
+ function triangleMaskValue(row, col, maskId) {
3677
+ const first = maskValue(row, col, maskId);
3678
+ const second = maskValue(row + 1, col + 2, (maskId + 1) & 3);
3679
+ return packTriangleCell(first, second);
3680
+ }
3681
+
3682
+ function applyCellMask(value, row, col, maskId, cellEncoding, protectedHeader = false) {
3683
+ if (cellEncoding !== CELL_ENCODINGS.TRIANGLE16) return value ^ maskValue(row, col, maskId);
3684
+ if (protectedHeader) {
3685
+ const pair = unpackTriangleCell(value);
3686
+ if (pair.first !== pair.second) throw new Error("Triangle16 protected header must use solid-color cells.");
3687
+ return solidTriangleCell(pair.first ^ maskValue(row, col, maskId));
3688
+ }
3689
+ return value ^ triangleMaskValue(row, col, maskId);
3690
+ }
3691
+
2734
3692
  const SPECTRAL_PERMUTATION_CACHE = new Map();
2735
3693
 
2736
3694
  function spectralPermutation(length, version) {
@@ -2759,7 +3717,15 @@ function spectralPermutation(length, version) {
2759
3717
  return permutation;
2760
3718
  }
2761
3719
 
2762
- function applyData(layout, rawCells, maskId, spectralInterleaving = true) {
3720
+ function applyData(
3721
+ layout,
3722
+ rawCells,
3723
+ maskId,
3724
+ spectralInterleaving = true,
3725
+ cellEncoding = CELL_ENCODINGS.RGBW,
3726
+ protectedHeaderCells = getHeaderPlan(layout.version).codewordCells
3727
+ ) {
3728
+ const encoding = normalizeCellEncoding(cellEncoding);
2763
3729
  const matrix = cloneMatrix(layout.matrix);
2764
3730
  const permutation = spectralInterleaving
2765
3731
  ? spectralPermutation(layout.dataPositions.length, layout.version)
@@ -2768,16 +3734,37 @@ function applyData(layout, rawCells, maskId, spectralInterleaving = true) {
2768
3734
  for (let logicalIndex = 0; logicalIndex < layout.dataPositions.length; logicalIndex++) {
2769
3735
  const physicalIndex = permutation ? permutation[logicalIndex] : logicalIndex;
2770
3736
  const [row, col] = layout.dataPositions[physicalIndex];
2771
- matrix[row][col] = rawCells[logicalIndex] ^ maskValue(row, col, maskId);
3737
+ matrix[row][col] = applyCellMask(
3738
+ rawCells[logicalIndex],
3739
+ row,
3740
+ col,
3741
+ maskId,
3742
+ encoding,
3743
+ encoding === CELL_ENCODINGS.TRIANGLE16 && logicalIndex < protectedHeaderCells
3744
+ );
2772
3745
  }
2773
3746
  return matrix;
2774
3747
  }
2775
3748
 
2776
- function quaternaryPenalty(matrix, reserved) {
3749
+ function dataCellPenalty(matrix, reserved, cellEncoding = CELL_ENCODINGS.RGBW) {
3750
+ const encoding = normalizeCellEncoding(cellEncoding);
2777
3751
  const size = matrix.length;
2778
3752
  let penalty = 0;
2779
- const counts = [0, 0, 0, 0];
2780
- let dataCount = 0;
3753
+ const colorCounts = [0, 0, 0, 0];
3754
+ let colorCount = 0;
3755
+
3756
+ const symbolAt = (r, c) => matrix[r][c];
3757
+ const addColors = (value) => {
3758
+ if (encoding === CELL_ENCODINGS.TRIANGLE16) {
3759
+ const pair = unpackTriangleCell(value);
3760
+ colorCounts[pair.first]++;
3761
+ colorCounts[pair.second]++;
3762
+ colorCount += 2;
3763
+ } else {
3764
+ colorCounts[value]++;
3765
+ colorCount++;
3766
+ }
3767
+ };
2781
3768
 
2782
3769
  for (let r = 0; r < size; r++) {
2783
3770
  let previous = null;
@@ -2788,9 +3775,8 @@ function quaternaryPenalty(matrix, reserved) {
2788
3775
  run = 0;
2789
3776
  continue;
2790
3777
  }
2791
- const value = matrix[r][c];
2792
- counts[value]++;
2793
- dataCount++;
3778
+ const value = symbolAt(r, c);
3779
+ addColors(value);
2794
3780
  if (value === previous) {
2795
3781
  run++;
2796
3782
  if (run >= 4) penalty += 2;
@@ -2810,7 +3796,7 @@ function quaternaryPenalty(matrix, reserved) {
2810
3796
  run = 0;
2811
3797
  continue;
2812
3798
  }
2813
- const value = matrix[r][c];
3799
+ const value = symbolAt(r, c);
2814
3800
  if (value === previous) {
2815
3801
  run++;
2816
3802
  if (run >= 4) penalty += 2;
@@ -2821,9 +3807,9 @@ function quaternaryPenalty(matrix, reserved) {
2821
3807
  }
2822
3808
  }
2823
3809
 
2824
- if (dataCount > 0) {
2825
- const ideal = dataCount / 4;
2826
- penalty += counts.reduce((sum, count) => sum + Math.abs(count - ideal), 0) / 2;
3810
+ if (colorCount > 0) {
3811
+ const ideal = colorCount / 4;
3812
+ penalty += colorCounts.reduce((sum, count) => sum + Math.abs(count - ideal), 0) / 2;
2827
3813
  }
2828
3814
  return penalty;
2829
3815
  }
@@ -2852,7 +3838,7 @@ function deinterleaveBlocks(stream, blockLengths) {
2852
3838
  return blocks;
2853
3839
  }
2854
3840
 
2855
- function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
3841
+ function getBodyRsPlan(payloadLength, eccLevel, version = 2, cellEncoding = CELL_ENCODINGS.RGBW) {
2856
3842
  const ecc = getEffectiveEcc(version, eccLevel);
2857
3843
  const bodyByteCount = payloadLength + CRC_BYTES;
2858
3844
  const dataSymbols = bodyByteCount;
@@ -2874,25 +3860,27 @@ function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
2874
3860
  dataBlockLengths,
2875
3861
  codewordBlockLengths,
2876
3862
  encodedSymbols,
2877
- encodedCells: encodedSymbols * CELLS_PER_BYTE
3863
+ encodedCells: encodedSymbols * cellsPerByteForEncoding(cellEncoding)
2878
3864
  };
2879
3865
  }
2880
3866
 
2881
- function streamCellCount(payloadLength, eccLevel, version) {
2882
- return getHeaderPlan(version).codewordCells + getBodyRsPlan(payloadLength, eccLevel, version).encodedCells;
3867
+ function streamCellCount(payloadLength, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
3868
+ return getHeaderPlan(version).codewordCells +
3869
+ getBodyRsPlan(payloadLength, eccLevel, version, cellEncoding).encodedCells;
2883
3870
  }
2884
3871
 
2885
- function streamFitsLayout(layout, eccLevel, payloadLength, version) {
2886
- return streamCellCount(payloadLength, eccLevel, version) <= layout.dataPositions.length;
3872
+ function streamFitsLayout(layout, eccLevel, payloadLength, version, cellEncoding = CELL_ENCODINGS.RGBW) {
3873
+ return streamCellCount(payloadLength, eccLevel, version, cellEncoding) <= layout.dataPositions.length;
2887
3874
  }
2888
3875
 
2889
- function getCapacityForLayout(layout, eccLevel, version) {
2890
- if (!streamFitsLayout(layout, eccLevel, 0, version)) return 0;
3876
+ function getCapacityForLayout(layout, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
3877
+ const encoding = normalizeCellEncoding(cellEncoding);
3878
+ if (!streamFitsLayout(layout, eccLevel, 0, version, encoding)) return 0;
2891
3879
  let low = 0;
2892
- let high = Math.floor(layout.dataPositions.length / CELLS_PER_BYTE);
3880
+ let high = Math.floor(layout.dataPositions.length / cellsPerByteForEncoding(encoding));
2893
3881
  while (low < high) {
2894
3882
  const mid = Math.ceil((low + high) / 2);
2895
- if (streamFitsLayout(layout, eccLevel, mid, version)) low = mid;
3883
+ if (streamFitsLayout(layout, eccLevel, mid, version, encoding)) low = mid;
2896
3884
  else high = mid - 1;
2897
3885
  }
2898
3886
  return low;
@@ -2901,6 +3889,7 @@ function getCapacityForLayout(layout, eccLevel, version) {
2901
3889
  function getVersionInfo(version, options = {}) {
2902
3890
  validateVersion(version);
2903
3891
  const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
3892
+ const cellEncoding = cellEncodingFromOptions(options);
2904
3893
  const layout = createLayout(version);
2905
3894
  const headerPlan = getHeaderPlan(version);
2906
3895
  const effectiveEcc = getEffectiveEcc(version, eccLevel);
@@ -2910,8 +3899,8 @@ function getVersionInfo(version, options = {}) {
2910
3899
  eccLevel,
2911
3900
  size: layout.size,
2912
3901
  dataCells: layout.dataPositions.length,
2913
- theoreticalBits: layout.dataPositions.length * 2,
2914
- capacityBytes: getCapacityForLayout(layout, eccLevel, version),
3902
+ theoreticalBits: layout.dataPositions.length * (cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2),
3903
+ capacityBytes: getCapacityForLayout(layout, eccLevel, version, cellEncoding),
2915
3904
  headerCells: headerPlan.codewordCells,
2916
3905
  headerBytes: headerPlan.headerBytes,
2917
3906
  headerParitySymbols: headerPlan.paritySymbols,
@@ -2923,8 +3912,11 @@ function getVersionInfo(version, options = {}) {
2923
3912
  hasAlignmentMarker: true,
2924
3913
  alignmentPatterns: layout.alignments.length,
2925
3914
  alignmentCenters: layout.alignments.map(({ row, col }) => [row, col]),
2926
- bitsPerDataCell: 2,
3915
+ bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
3916
+ highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
3917
+ cellEncoding,
2927
3918
  colors: 4,
3919
+ statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
2928
3920
  spectralInterleaving: true,
2929
3921
  confidenceAwareEcc: true
2930
3922
  };
@@ -2935,6 +3927,7 @@ function chooseVersion(payloadLength, options = {}) {
2935
3927
  const minVersion = options.minVersion ?? MIN_VERSION;
2936
3928
  const maxVersion = options.maxVersion ?? MAX_VERSION;
2937
3929
  const ecc = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
3930
+ const cellEncoding = cellEncodingFromOptions(options);
2938
3931
 
2939
3932
  validateVersion(minVersion);
2940
3933
  validateVersion(maxVersion);
@@ -2943,10 +3936,10 @@ function chooseVersion(payloadLength, options = {}) {
2943
3936
  if (requested !== "auto") {
2944
3937
  validateVersion(requested);
2945
3938
  assert(requested >= minVersion && requested <= maxVersion, "Requested version is outside selected bounds.");
2946
- const info = getVersionInfo(requested, { ecc });
3939
+ const info = getVersionInfo(requested, { ecc, cellEncoding });
2947
3940
  const layout = createLayout(requested);
2948
3941
  assert(
2949
- payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested),
3942
+ payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested, cellEncoding),
2950
3943
  `Payload does not fit version ${requested} with ${ecc} ECC. Maximum is ${info.capacityBytes} bytes.`
2951
3944
  );
2952
3945
  return requested;
@@ -2954,20 +3947,20 @@ function chooseVersion(payloadLength, options = {}) {
2954
3947
 
2955
3948
  for (let version = minVersion; version <= maxVersion; version++) {
2956
3949
  const layout = createLayout(version);
2957
- if (streamFitsLayout(layout, ecc, payloadLength, version)) return version;
3950
+ if (streamFitsLayout(layout, ecc, payloadLength, version, cellEncoding)) return version;
2958
3951
  }
2959
3952
  throw new Error(`Payload is too large for versions ${minVersion}..${maxVersion}.`);
2960
3953
  }
2961
3954
 
2962
- function encodeProtectedHeader(header, version) {
3955
+ function encodeProtectedHeader(header, version, cellEncoding = CELL_ENCODINGS.RGBW) {
2963
3956
  const plan = getHeaderPlan(version);
2964
3957
  const codeword = rsEncode(Array.from(header), plan.paritySymbols);
2965
3958
  assert(codeword.length === plan.codewordBytes, "Header RS symbol calculation mismatch.");
2966
- return bytesToCells(codeword);
3959
+ return bytesToProtectedHeaderCells(codeword, cellEncoding);
2967
3960
  }
2968
3961
 
2969
- function encodeProtectedBody(bodyBytes, eccLevel, version) {
2970
- const plan = getBodyRsPlan(bodyBytes.length - CRC_BYTES, eccLevel, version);
3962
+ function encodeProtectedBody(bodyBytes, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
3963
+ const plan = getBodyRsPlan(bodyBytes.length - CRC_BYTES, eccLevel, version, cellEncoding);
2971
3964
  assert(bodyBytes.length === plan.dataSymbols, "Body RS symbol calculation mismatch.");
2972
3965
 
2973
3966
  const blocks = [];
@@ -2976,7 +3969,7 @@ function encodeProtectedBody(bodyBytes, eccLevel, version) {
2976
3969
  blocks.push(rsEncode(Array.from(bodyBytes.slice(offset, offset + length)), plan.paritySymbols));
2977
3970
  offset += length;
2978
3971
  }
2979
- return { cells: bytesToCells(interleaveBlocks(blocks)), plan };
3972
+ return { cells: bytesToCells(interleaveBlocks(blocks), cellEncoding), plan };
2980
3973
  }
2981
3974
 
2982
3975
  function finalizeMatrix(layout, rawCells, meta) {
@@ -2984,9 +3977,10 @@ function finalizeMatrix(layout, rawCells, meta) {
2984
3977
  let bestMatrix = null;
2985
3978
  let bestPenalty = Infinity;
2986
3979
 
3980
+ const cellEncoding = normalizeCellEncoding(meta.cellEncoding ?? CELL_ENCODINGS.RGBW);
2987
3981
  for (let maskId = 0; maskId < 4; maskId++) {
2988
- const candidate = applyData(layout, rawCells, maskId, true);
2989
- const penalty = quaternaryPenalty(candidate, layout.reserved);
3982
+ const candidate = applyData(layout, rawCells, maskId, true, cellEncoding);
3983
+ const penalty = dataCellPenalty(candidate, layout.reserved, cellEncoding);
2990
3984
  if (penalty < bestPenalty) {
2991
3985
  bestPenalty = penalty;
2992
3986
  bestMaskId = maskId;
@@ -2994,7 +3988,7 @@ function finalizeMatrix(layout, rawCells, meta) {
2994
3988
  }
2995
3989
  }
2996
3990
 
2997
- const info = getVersionInfo(meta.version, { ecc: meta.eccLevel });
3991
+ const info = getVersionInfo(meta.version, { ecc: meta.eccLevel, cellEncoding });
2998
3992
  return {
2999
3993
  format: "QuadQR",
3000
3994
  formatVersion: FORMAT_VERSION,
@@ -3005,6 +3999,11 @@ function finalizeMatrix(layout, rawCells, meta) {
3005
3999
  payloadBytes: meta.payloadBytes,
3006
4000
  sourcePayloadBytes: meta.sourcePayloadBytes ?? meta.payloadBytes,
3007
4001
  secure: Boolean(meta.secure),
4002
+ compressed: meta.compression != null && meta.compression !== "none",
4003
+ compression: meta.compression ?? "none",
4004
+ signed: Boolean(meta.signed),
4005
+ signingKeyId: meta.signingKeyId ?? null,
4006
+ hasEmbeddedPublicKey: Boolean(meta.hasEmbeddedPublicKey),
3008
4007
  requiresDecryption: Boolean(meta.secure),
3009
4008
  security: meta.security ?? null,
3010
4009
  meaningfulCells: meta.meaningfulCells,
@@ -3012,7 +4011,10 @@ function finalizeMatrix(layout, rawCells, meta) {
3012
4011
  capacityBytes: info.capacityBytes,
3013
4012
  alignmentPatterns: layout.alignments.length,
3014
4013
  utilization: meta.meaningfulCells / layout.dataPositions.length,
3015
- bitsPerDataCell: 2,
4014
+ bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
4015
+ highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
4016
+ cellEncoding,
4017
+ statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
3016
4018
  eccLevel: meta.eccLevel,
3017
4019
  eccParitySymbols: meta.eccParitySymbols,
3018
4020
  eccBlocks: meta.eccBlocks,
@@ -3023,39 +4025,44 @@ function finalizeMatrix(layout, rawCells, meta) {
3023
4025
  };
3024
4026
  }
3025
4027
 
3026
- function encodeText(text, options = {}) {
3027
- assert(typeof text === "string", "encodeText expects a string.");
3028
- return encodeBytes(getTextEncoder().encode(text), { ...options, text: true });
3029
- }
3030
-
3031
- function encodeBytes(input, options = {}) {
4028
+ function encodePreparedBytes(input, options = {}) {
3032
4029
  if (options.formatVersion != null && options.formatVersion !== FORMAT_VERSION) {
3033
4030
  throw new Error(`Only QuadQR format version ${FORMAT_VERSION} is supported.`);
3034
4031
  }
3035
4032
 
3036
- const payload = input instanceof Uint8Array ? input : new Uint8Array(input);
4033
+ const payload = asBytes(input);
3037
4034
  const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
3038
4035
  const secure = Boolean(options.secure);
3039
- const flags = flagsFor(Boolean(options.text), eccLevel, secure);
3040
- const version = chooseVersion(payload.length, { ...options, ecc: eccLevel });
4036
+ const extended = Boolean(options.extended);
4037
+ const signed = Boolean(options.signed);
4038
+ const cellEncoding = cellEncodingFromOptions(options);
4039
+ const flags = flagsFor(Boolean(options.text), eccLevel, secure, extended, signed, cellEncoding);
4040
+ const version = chooseVersion(payload.length, { ...options, ecc: eccLevel, cellEncoding });
3041
4041
  const layout = createLayout(version);
3042
4042
  const header = makeHeader(payload.length, flags, version);
3043
4043
  const crc = crc32(concatBytes(header, payload));
3044
- const headerCells = encodeProtectedHeader(header, version);
3045
- const bodyEncoded = encodeProtectedBody(concatBytes(payload, u32be(crc)), eccLevel, version);
4044
+ const headerCells = encodeProtectedHeader(header, version, cellEncoding);
4045
+ const bodyEncoded = encodeProtectedBody(concatBytes(payload, u32be(crc)), eccLevel, version, cellEncoding);
3046
4046
  const meaningfulCells = headerCells.concat(bodyEncoded.cells);
3047
4047
  assert(meaningfulCells.length <= layout.dataPositions.length, "Internal QuadQR capacity calculation error.");
3048
4048
 
3049
4049
  const padding = makePaddingCells(
3050
4050
  layout.dataPositions.length - meaningfulCells.length,
3051
- crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16)
4051
+ crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16),
4052
+ cellEncoding
3052
4053
  );
3053
4054
 
3054
4055
  return finalizeMatrix(layout, meaningfulCells.concat(padding), {
3055
4056
  version,
4057
+ cellEncoding,
3056
4058
  payloadBytes: payload.length,
3057
4059
  sourcePayloadBytes: options.sourcePayloadBytes ?? payload.length,
3058
4060
  secure,
4061
+ extended,
4062
+ signed,
4063
+ compression: options.compressionMetadata ?? "none",
4064
+ signingKeyId: options.signingKeyId ?? null,
4065
+ hasEmbeddedPublicKey: Boolean(options.hasEmbeddedPublicKey),
3059
4066
  security: options.securityMetadata ?? null,
3060
4067
  meaningfulCells: meaningfulCells.length,
3061
4068
  eccLevel,
@@ -3066,6 +4073,59 @@ function encodeBytes(input, options = {}) {
3066
4073
  });
3067
4074
  }
3068
4075
 
4076
+ function encodeText(text, options = {}) {
4077
+ assert(typeof text === "string", "encodeText expects a string.");
4078
+ const source = getTextEncoder().encode(text);
4079
+ const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
4080
+ return encodePreparedBytes(prepared.payload, {
4081
+ ...options,
4082
+ text: true,
4083
+ extended: prepared.extended,
4084
+ sourcePayloadBytes: source.length,
4085
+ compressionMetadata: prepared.compression
4086
+ });
4087
+ }
4088
+
4089
+ function encodeBytes(input, options = {}) {
4090
+ const source = asBytes(input);
4091
+ const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
4092
+ return encodePreparedBytes(prepared.payload, {
4093
+ ...options,
4094
+ text: Boolean(options.text),
4095
+ extended: prepared.extended,
4096
+ sourcePayloadBytes: source.length,
4097
+ compressionMetadata: prepared.compression
4098
+ });
4099
+ }
4100
+
4101
+ /** Explicit Uint8Array convenience API. Equivalent to encodeBytes(). */
4102
+ function encodeUint8Array(input, options = {}) {
4103
+ return encodeBytes(asBytes(input), options);
4104
+ }
4105
+
4106
+ /** Encode and sign a normal payload using an internal Ed25519 envelope. */
4107
+ async function encodeSignedBytes(input, options = {}) {
4108
+ const source = asBytes(input);
4109
+ const envelope = await packSignedPayloadEnvelope(source, options);
4110
+ const parsed = parsePayloadEnvelope(envelope);
4111
+ return encodePreparedBytes(envelope, {
4112
+ ...options,
4113
+ text: Boolean(options.text),
4114
+ extended: true,
4115
+ signed: true,
4116
+ sourcePayloadBytes: source.length,
4117
+ compressionMetadata: parsed.compression,
4118
+ signingKeyId: parsed.keyId,
4119
+ hasEmbeddedPublicKey: parsed.hasEmbeddedPublicKey
4120
+ });
4121
+ }
4122
+
4123
+ async function encodeSignedText(text, options = {}) {
4124
+ assert(typeof text === "string", "encodeSignedText expects a string.");
4125
+ return encodeSignedBytes(getTextEncoder().encode(text), { ...options, text: true });
4126
+ }
4127
+
4128
+
3069
4129
  /**
3070
4130
  * Encode text using the optional QuadQR Secure Payload v1 layer.
3071
4131
  * This API is async because Web Crypto performs AES-GCM and password KDF work asynchronously.
@@ -3077,32 +4137,75 @@ async function encodeSecureText(text, options = {}) {
3077
4137
 
3078
4138
  /** Encode arbitrary bytes using password or raw 256-bit key security. */
3079
4139
  async function encodeSecureBytes(input, options = {}) {
3080
- const sourcePayload = input instanceof Uint8Array ? input : new Uint8Array(input);
4140
+ const sourcePayload = asBytes(input);
3081
4141
  const security = options.security ?? {};
3082
- const encrypted = await encryptSecurePayload(sourcePayload, security);
3083
- return encodeBytes(encrypted.envelope, {
4142
+ let protectedPayload = sourcePayload;
4143
+ let extended = false;
4144
+ let signed = false;
4145
+ let compression = "none";
4146
+ let signingKeyId = null;
4147
+ let hasEmbeddedPublicKey = false;
4148
+
4149
+ if (options.signing) {
4150
+ protectedPayload = await packSignedPayloadEnvelope(sourcePayload, {
4151
+ ...options,
4152
+ ...options.signing,
4153
+ compression: options.compression ?? "auto"
4154
+ });
4155
+ const parsed = parsePayloadEnvelope(protectedPayload);
4156
+ extended = true;
4157
+ signed = true;
4158
+ compression = parsed.compression;
4159
+ signingKeyId = parsed.keyId;
4160
+ hasEmbeddedPublicKey = parsed.hasEmbeddedPublicKey;
4161
+ } else {
4162
+ const prepared = prepareOptionalCompressedPayload(sourcePayload, options.compression ?? "none");
4163
+ protectedPayload = prepared.payload;
4164
+ extended = prepared.extended;
4165
+ compression = prepared.compression;
4166
+ }
4167
+
4168
+ const encrypted = await encryptSecurePayload(protectedPayload, security);
4169
+ return encodePreparedBytes(encrypted.envelope, {
3084
4170
  ...options,
3085
4171
  text: Boolean(options.text),
3086
4172
  secure: true,
4173
+ extended,
4174
+ signed,
3087
4175
  sourcePayloadBytes: sourcePayload.length,
4176
+ compressionMetadata: compression,
4177
+ signingKeyId,
4178
+ hasEmbeddedPublicKey,
3088
4179
  securityMetadata: encrypted.metadata
3089
4180
  });
3090
4181
  }
3091
4182
 
3092
4183
  /**
3093
4184
  * Decrypt a result returned by decodeMatrix/scanImageData/scanFile.
3094
- * The encrypted envelope remains available as encryptedPayload.
4185
+ * Compression and signatures are automatically unwrapped after decryption.
3095
4186
  */
3096
4187
  async function decryptDecoded(result, security = {}) {
3097
4188
  assert(result?.secure && result?.payload, "decryptDecoded expects an encrypted QuadQR decode result.");
3098
4189
  const plaintext = await decryptSecurePayload(result.payload, security);
3099
4190
  const isText = (result.flags & TEXT_FLAG) !== 0;
4191
+ const extended = (result.flags & EXTENDED_PAYLOAD_FLAG) !== 0;
4192
+ const envelope = extended ? parsePayloadEnvelope(plaintext) : null;
4193
+ const payload = envelope ? envelope.payload : plaintext;
3100
4194
  return {
3101
4195
  ...result,
3102
4196
  encryptedPayload: result.payload,
3103
4197
  encryptedPayloadBytes: result.payload.length,
3104
- payload: plaintext,
3105
- text: isText ? getTextDecoder().decode(plaintext) : null,
4198
+ protectedPayload: envelope ? plaintext : undefined,
4199
+ payload,
4200
+ text: isText ? getTextDecoder().decode(payload) : null,
4201
+ compressed: envelope?.compressed ?? false,
4202
+ compression: envelope?.compression ?? "none",
4203
+ signed: envelope?.signed ?? result.signed ?? false,
4204
+ signatureVerified: envelope?.signatureVerified ?? result.signatureVerified,
4205
+ signatureTrusted: envelope?.signatureTrusted ?? result.signatureTrusted,
4206
+ signingKeyId: envelope?.keyId ?? result.signingKeyId ?? null,
4207
+ hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? result.hasEmbeddedPublicKey ?? false,
4208
+ signer: envelope?.signer ?? result.signer ?? null,
3106
4209
  decrypted: true,
3107
4210
  requiresDecryption: false,
3108
4211
  security: { ...result.security, decrypted: true }
@@ -3184,12 +4287,14 @@ function rotate90(matrix) {
3184
4287
  return out;
3185
4288
  }
3186
4289
 
3187
- function extractVisibleCells(matrix, layout) {
4290
+ function extractVisibleCells(matrix, layout, cellEncoding = CELL_ENCODINGS.RGBW) {
4291
+ const encoding = normalizeCellEncoding(cellEncoding);
4292
+ const maxValue = encoding === CELL_ENCODINGS.TRIANGLE16 ? 15 : 3;
3188
4293
  const out = [];
3189
4294
  for (const [row, col] of layout.dataPositions) {
3190
4295
  const value = matrix[row][col];
3191
- if (!Number.isInteger(value) || value < 0 || value > 3) {
3192
- throw new Error("Data region contains an invalid QuadQR data cell.");
4296
+ if (!Number.isInteger(value) || value < 0 || value > maxValue) {
4297
+ throw new Error(`Data region contains an invalid ${encoding === CELL_ENCODINGS.TRIANGLE16 ? "Triangle16" : "RGBW"} data cell.`);
3193
4298
  }
3194
4299
  out.push(value);
3195
4300
  }
@@ -3203,6 +4308,34 @@ function unmaskCells(visibleCells, positions, maskId) {
3203
4308
  });
3204
4309
  }
3205
4310
 
4311
+ function unmaskAndRestoreLogicalCells(
4312
+ visibleCells,
4313
+ positions,
4314
+ maskId,
4315
+ version,
4316
+ spectralInterleaving,
4317
+ cellEncoding = CELL_ENCODINGS.RGBW
4318
+ ) {
4319
+ const encoding = normalizeCellEncoding(cellEncoding);
4320
+ const permutation = spectralInterleaving ? spectralPermutation(visibleCells.length, version) : null;
4321
+ const logical = new Array(visibleCells.length);
4322
+ const headerCells = getHeaderPlan(version).codewordCells;
4323
+
4324
+ for (let logicalIndex = 0; logicalIndex < visibleCells.length; logicalIndex++) {
4325
+ const physicalIndex = permutation ? permutation[logicalIndex] : logicalIndex;
4326
+ const [row, col] = positions[physicalIndex];
4327
+ logical[logicalIndex] = applyCellMask(
4328
+ visibleCells[physicalIndex],
4329
+ row,
4330
+ col,
4331
+ maskId,
4332
+ encoding,
4333
+ encoding === CELL_ENCODINGS.TRIANGLE16 && logicalIndex < headerCells
4334
+ );
4335
+ }
4336
+ return logical;
4337
+ }
4338
+
3206
4339
  function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
3207
4340
  if (!spectralInterleaving) return physicalValues.slice();
3208
4341
  const permutation = spectralPermutation(physicalValues.length, version);
@@ -3213,20 +4346,21 @@ function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
3213
4346
  return logical;
3214
4347
  }
3215
4348
 
3216
- function cellsToSymbolConfidences(confidences, byteCount) {
4349
+ function cellsToSymbolConfidences(
4350
+ confidences,
4351
+ byteCount,
4352
+ cellsPerByte = RGBW_CELLS_PER_BYTE
4353
+ ) {
3217
4354
  if (!confidences) return null;
3218
- if (confidences.length < byteCount * CELLS_PER_BYTE) return null;
4355
+ if (confidences.length < byteCount * cellsPerByte) return null;
3219
4356
  const out = new Array(byteCount);
3220
4357
  for (let symbolIndex = 0; symbolIndex < byteCount; symbolIndex++) {
3221
- const offset = symbolIndex * CELLS_PER_BYTE;
3222
- // A single wrong 2-bit cell changes the GF(256) symbol, so the weakest
3223
- // constituent cell is the useful confidence bound for that symbol.
3224
- out[symbolIndex] = Math.min(
3225
- confidences[offset] ?? 1,
3226
- confidences[offset + 1] ?? 1,
3227
- confidences[offset + 2] ?? 1,
3228
- confidences[offset + 3] ?? 1
3229
- );
4358
+ const offset = symbolIndex * cellsPerByte;
4359
+ let value = 1;
4360
+ for (let cellIndex = 0; cellIndex < cellsPerByte; cellIndex++) {
4361
+ value = Math.min(value, confidences[offset + cellIndex] ?? 1);
4362
+ }
4363
+ out[symbolIndex] = value;
3230
4364
  }
3231
4365
  return out;
3232
4366
  }
@@ -3263,11 +4397,11 @@ function decodeRsAdaptive(codeword, paritySymbols, symbolConfidences, options =
3263
4397
  }
3264
4398
  }
3265
4399
 
3266
- function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}) {
4400
+ function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}, cellEncoding = CELL_ENCODINGS.RGBW) {
3267
4401
  const plan = getHeaderPlan(version);
3268
4402
  const headerCells = rawCells.slice(0, plan.codewordCells);
3269
- const codeword = Array.from(cellsToBytes(headerCells, plan.codewordBytes));
3270
- const confidences = cellsToSymbolConfidences(rawConfidences?.slice(0, plan.codewordCells), plan.codewordBytes);
4403
+ const codeword = Array.from(protectedHeaderCellsToBytes(headerCells, plan.codewordBytes, cellEncoding));
4404
+ const confidences = cellsToSymbolConfidences(rawConfidences?.slice(0, plan.codewordCells), plan.codewordBytes, RGBW_CELLS_PER_BYTE);
3271
4405
  const decoded = decodeRsAdaptive(codeword, plan.paritySymbols, confidences, options);
3272
4406
  const header = new Uint8Array(decoded.data);
3273
4407
  const parsed = parseHeader(header, version);
@@ -3282,16 +4416,17 @@ function decodeProtectedHeader(rawCells, version, rawConfidences = null, options
3282
4416
  };
3283
4417
  }
3284
4418
 
3285
- function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConfidences = null, options = {}) {
3286
- const plan = getBodyRsPlan(payloadLength, eccLevel, version);
4419
+ function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConfidences = null, options = {}, cellEncoding = CELL_ENCODINGS.RGBW) {
4420
+ const plan = getBodyRsPlan(payloadLength, eccLevel, version, cellEncoding);
3287
4421
  const bodyStart = getHeaderPlan(version).codewordCells;
3288
4422
  const encodedCells = rawCells.slice(bodyStart, bodyStart + plan.encodedCells);
3289
4423
  if (encodedCells.length !== plan.encodedCells) throw new Error("Protected body is incomplete.");
3290
4424
 
3291
- const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols));
4425
+ const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols, cellEncoding));
3292
4426
  const symbolConfidences = cellsToSymbolConfidences(
3293
4427
  rawConfidences?.slice(bodyStart, bodyStart + plan.encodedCells),
3294
- plan.encodedSymbols
4428
+ plan.encodedSymbols,
4429
+ cellsPerByteForEncoding(cellEncoding)
3295
4430
  );
3296
4431
  const blocks = deinterleaveBlocks(encodedSymbols, plan.codewordBlockLengths);
3297
4432
  const confidenceBlocks = symbolConfidences
@@ -3334,83 +4469,136 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
3334
4469
  if (!validateStructure(matrix, tolerance)) throw new Error("QuadQR finder/alignment structure does not match.");
3335
4470
 
3336
4471
  const layout = createLayout(version);
3337
- const visible = extractVisibleCells(matrix, layout);
3338
- const visibleConfidences = confidenceMatrix
3339
- ? layout.dataPositions.map(([row, col]) => confidenceMatrix[row]?.[col] ?? 1)
3340
- : null;
3341
4472
  const errors = [];
4473
+ const hintedEncoding = options.cellEncodingHint != null
4474
+ ? normalizeCellEncoding(options.cellEncodingHint)
4475
+ : null;
4476
+ const hasExtendedCells = layout.dataPositions.some(([row, col]) => (matrix[row]?.[col] ?? 0) > 3);
4477
+ const encodingAttempts = hintedEncoding
4478
+ ? [hintedEncoding]
4479
+ : hasExtendedCells
4480
+ ? [CELL_ENCODINGS.TRIANGLE16, CELL_ENCODINGS.RGBW]
4481
+ : [CELL_ENCODINGS.RGBW, CELL_ENCODINGS.TRIANGLE16];
4482
+
4483
+ for (const cellEncoding of encodingAttempts) {
4484
+ let visible;
4485
+ try {
4486
+ visible = extractVisibleCells(matrix, layout, cellEncoding);
4487
+ } catch (error) {
4488
+ errors.push(`${cellEncoding}: ${error.message}`);
4489
+ continue;
4490
+ }
3342
4491
 
3343
- // New symbols use spectral-spatial interleaving. Legacy order remains a
3344
- // decode fallback so existing QuadQR images do not become unreadable.
3345
- for (const spectralInterleaving of [true, false]) {
3346
- for (let maskId = 0; maskId < 4; maskId++) {
3347
- try {
3348
- const physicalRaw = unmaskCells(visible, layout.dataPositions, maskId);
3349
- const raw = restoreLogicalOrder(physicalRaw, version, spectralInterleaving);
3350
- const rawConfidences = visibleConfidences
3351
- ? restoreLogicalOrder(visibleConfidences, version, spectralInterleaving)
3352
- : null;
3353
- const headerDecoded = decodeProtectedHeader(raw, version, rawConfidences, options);
3354
- const header = headerDecoded.header;
3355
- const flags = headerDecoded.flags;
3356
- const eccLevel = eccFromFlags(flags);
3357
- const payloadLength = headerDecoded.payloadLength;
3358
- if (streamCellCount(payloadLength, eccLevel, version) > raw.length) {
3359
- throw new Error("Declared payload exceeds matrix capacity.");
3360
- }
4492
+ const visibleConfidences = confidenceMatrix
4493
+ ? layout.dataPositions.map(([row, col]) => confidenceMatrix[row]?.[col] ?? 1)
4494
+ : null;
3361
4495
 
3362
- const bodyDecoded = decodeProtectedBody(
3363
- raw,
3364
- payloadLength,
3365
- eccLevel,
3366
- version,
3367
- rawConfidences,
3368
- options
3369
- );
3370
- const body = bodyDecoded.body;
3371
- const payload = body.slice(0, payloadLength);
3372
- const expectedCrc = readU32be(body, payloadLength);
3373
- const actualCrc = crc32(concatBytes(header, payload));
3374
- if (expectedCrc !== actualCrc) throw new Error("CRC mismatch after ECC.");
3375
-
3376
- const isText = (flags & TEXT_FLAG) !== 0;
3377
- const secure = (flags & SECURE_FLAG) !== 0;
3378
- const security = secure ? inspectSecureEnvelope(payload) : null;
3379
- const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
3380
- const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
3381
- return {
3382
- ok: true,
3383
- format: "QuadQR",
3384
- formatVersion: FORMAT_VERSION,
3385
- version,
3386
- size,
3387
- alignmentPatterns: layout.alignments.length,
3388
- maskId,
3389
- rotation,
3390
- flags,
3391
- eccLevel,
3392
- eccParitySymbols: bodyDecoded.plan.paritySymbols,
3393
- eccBlocks: bodyDecoded.plan.dataBlockLengths.length,
3394
- correctableSymbolsPerBlock: bodyDecoded.plan.correctableSymbolsPerBlock,
3395
- spectralInterleaving,
3396
- confidenceAwareEcc: Boolean(rawConfidences),
3397
- confidenceAssisted: headerDecoded.confidenceAssisted || bodyDecoded.confidenceAssisted,
3398
- erasureSymbols,
3399
- unknownErrorSymbols,
3400
- correctedHeaderSymbols: headerDecoded.correctedSymbols,
3401
- correctedBodySymbols: bodyDecoded.correctedSymbols,
3402
- correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
3403
- payload,
3404
- text: isText && !secure ? getTextDecoder().decode(payload) : null,
3405
- secure,
3406
- encrypted: secure,
3407
- decrypted: false,
3408
- requiresDecryption: secure,
3409
- security,
3410
- crc32: actualCrc >>> 0
3411
- };
3412
- } catch (error) {
3413
- errors.push(`${spectralInterleaving ? "spectral" : "legacy"} mask ${maskId}: ${error.message}`);
4496
+ // New symbols use spectral-spatial interleaving. Legacy order remains a
4497
+ // decode fallback so existing RGBW QuadQR images do not become unreadable.
4498
+ for (const spectralInterleaving of [true, false]) {
4499
+ for (let maskId = 0; maskId < 4; maskId++) {
4500
+ try {
4501
+ const raw = unmaskAndRestoreLogicalCells(
4502
+ visible,
4503
+ layout.dataPositions,
4504
+ maskId,
4505
+ version,
4506
+ spectralInterleaving,
4507
+ cellEncoding
4508
+ );
4509
+ const rawConfidences = visibleConfidences
4510
+ ? restoreLogicalOrder(visibleConfidences, version, spectralInterleaving)
4511
+ : null;
4512
+ const headerDecoded = decodeProtectedHeader(
4513
+ raw,
4514
+ version,
4515
+ rawConfidences,
4516
+ options,
4517
+ cellEncoding
4518
+ );
4519
+ const header = headerDecoded.header;
4520
+ const flags = headerDecoded.flags;
4521
+ const declaredCellEncoding = cellEncodingFromFlags(flags);
4522
+ if (declaredCellEncoding !== cellEncoding) {
4523
+ throw new Error(`Header declares ${declaredCellEncoding}, not ${cellEncoding}.`);
4524
+ }
4525
+ const eccLevel = eccFromFlags(flags);
4526
+ const payloadLength = headerDecoded.payloadLength;
4527
+ if (streamCellCount(payloadLength, eccLevel, version, cellEncoding) > raw.length) {
4528
+ throw new Error("Declared payload exceeds matrix capacity.");
4529
+ }
4530
+
4531
+ const bodyDecoded = decodeProtectedBody(
4532
+ raw,
4533
+ payloadLength,
4534
+ eccLevel,
4535
+ version,
4536
+ rawConfidences,
4537
+ options,
4538
+ cellEncoding
4539
+ );
4540
+ const body = bodyDecoded.body;
4541
+ const payload = body.slice(0, payloadLength);
4542
+ const expectedCrc = readU32be(body, payloadLength);
4543
+ const actualCrc = crc32(concatBytes(header, payload));
4544
+ if (expectedCrc !== actualCrc) throw new Error("CRC mismatch after ECC.");
4545
+
4546
+ const isText = (flags & TEXT_FLAG) !== 0;
4547
+ const secure = (flags & SECURE_FLAG) !== 0;
4548
+ const extendedFlag = (flags & EXTENDED_PAYLOAD_FLAG) !== 0;
4549
+ const signedFlag = (flags & SIGNED_FLAG) !== 0;
4550
+ const security = secure ? inspectSecureEnvelope(payload) : null;
4551
+ const envelope = extendedFlag && !secure ? parsePayloadEnvelope(payload) : null;
4552
+ const applicationPayload = envelope ? envelope.payload : payload;
4553
+ const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
4554
+ const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
4555
+ return {
4556
+ ok: true,
4557
+ format: "QuadQR",
4558
+ formatVersion: FORMAT_VERSION,
4559
+ version,
4560
+ size,
4561
+ alignmentPatterns: layout.alignments.length,
4562
+ maskId,
4563
+ rotation,
4564
+ flags,
4565
+ highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
4566
+ cellEncoding,
4567
+ bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
4568
+ statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
4569
+ eccLevel,
4570
+ eccParitySymbols: bodyDecoded.plan.paritySymbols,
4571
+ eccBlocks: bodyDecoded.plan.dataBlockLengths.length,
4572
+ correctableSymbolsPerBlock: bodyDecoded.plan.correctableSymbolsPerBlock,
4573
+ spectralInterleaving,
4574
+ confidenceAwareEcc: Boolean(rawConfidences),
4575
+ confidenceAssisted: headerDecoded.confidenceAssisted || bodyDecoded.confidenceAssisted,
4576
+ erasureSymbols,
4577
+ unknownErrorSymbols,
4578
+ correctedHeaderSymbols: headerDecoded.correctedSymbols,
4579
+ correctedBodySymbols: bodyDecoded.correctedSymbols,
4580
+ correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
4581
+ protectedPayload: envelope ? payload : undefined,
4582
+ payload: secure ? payload : applicationPayload,
4583
+ text: secure ? null : (isText ? getTextDecoder().decode(applicationPayload) : null),
4584
+ compressed: envelope?.compressed ?? false,
4585
+ compression: envelope?.compression ?? "none",
4586
+ signed: envelope?.signed ?? signedFlag,
4587
+ signatureVerified: envelope?.signatureVerified,
4588
+ signatureTrusted: envelope?.signatureTrusted,
4589
+ signingKeyId: envelope?.keyId ?? null,
4590
+ hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? false,
4591
+ signer: envelope?.signer ?? null,
4592
+ secure,
4593
+ encrypted: secure,
4594
+ decrypted: false,
4595
+ requiresDecryption: secure,
4596
+ security,
4597
+ crc32: actualCrc >>> 0
4598
+ };
4599
+ } catch (error) {
4600
+ errors.push(`${cellEncoding} ${spectralInterleaving ? "spectral" : "legacy"} mask ${maskId}: ${error.message}`);
4601
+ }
3414
4602
  }
3415
4603
  }
3416
4604
  }
@@ -3446,6 +4634,27 @@ function decodeMatrix(inputMatrix, options = {}) {
3446
4634
  throw new Error(`Unable to decode matrix. ${errors.join(" || ")}`);
3447
4635
  }
3448
4636
 
4637
+
4638
+ /** Decode a matrix and return only its application payload bytes. */
4639
+ function decodeUint8Array(inputMatrix, options = {}) {
4640
+ return decodeMatrix(inputMatrix, options).payload;
4641
+ }
4642
+
4643
+ /** Verify a signed decode result and return the result with verification state attached. */
4644
+ async function verifyDecodedSignature(result, options = {}) {
4645
+ assert(result?.signed, "Decode result is not signed.");
4646
+ const source = result.protectedPayload;
4647
+ assert(source, "Signed payload metadata is unavailable. Decrypt secure content first if necessary.");
4648
+ const verification = await verifyPayloadEnvelopeSignature(source, options);
4649
+ return {
4650
+ ...result,
4651
+ signatureVerified: verification.verified,
4652
+ signatureTrusted: verification.trusted,
4653
+ signingKeyId: verification.keyId ?? result.signingKeyId ?? null,
4654
+ signatureTrustSource: verification.trustSource
4655
+ };
4656
+ }
4657
+
3449
4658
  function hexToRgb(hex) {
3450
4659
  const clean = hex.replace("#", "");
3451
4660
  assert(clean.length === 6, `Invalid hex color ${hex}.`);
@@ -3460,6 +4669,18 @@ function resolvePalette(palette = {}) {
3460
4669
  return { ...DEFAULT_PALETTE, ...palette };
3461
4670
  }
3462
4671
 
4672
+ function normalizeRenderMode(mode = RENDER_MODES.SCREEN) {
4673
+ const value = String(mode).toLowerCase();
4674
+ assert(value === RENDER_MODES.SCREEN || value === RENDER_MODES.PRINT, "Render mode must be screen or print.");
4675
+ return value;
4676
+ }
4677
+
4678
+ function resolveRenderPalette(options = {}) {
4679
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4680
+ const base = mode === RENDER_MODES.PRINT ? PRINT_PALETTE : DEFAULT_PALETTE;
4681
+ return { ...base, ...(options.palette ?? {}) };
4682
+ }
4683
+
3463
4684
  function paletteRgb(palette = {}) {
3464
4685
  const p = resolvePalette(palette);
3465
4686
  return {
@@ -3494,6 +4715,80 @@ function cellRgb(cell, palette) {
3494
4715
  }
3495
4716
 
3496
4717
 
4718
+ function inferCellEncoding(codeOrMatrix, matrix, layout = null) {
4719
+ if (!Array.isArray(codeOrMatrix) && codeOrMatrix?.cellEncoding) {
4720
+ return normalizeCellEncoding(codeOrMatrix.cellEncoding);
4721
+ }
4722
+ const activeLayout = layout ?? renderLayoutForMatrix(matrix);
4723
+ if (activeLayout) {
4724
+ for (const [row, col] of activeLayout.dataPositions) {
4725
+ if ((matrix[row]?.[col] ?? 0) > 3) return CELL_ENCODINGS.TRIANGLE16;
4726
+ }
4727
+ }
4728
+ return CELL_ENCODINGS.RGBW;
4729
+ }
4730
+
4731
+ function triangleCellColors(cell) {
4732
+ const { first, second } = unpackTriangleCell(cell);
4733
+ return { first, second };
4734
+ }
4735
+
4736
+ function drawTriangleCellCanvas(ctx, x, y, width, height, cell, palette) {
4737
+ const pair = triangleCellColors(cell);
4738
+ if (pair.first === pair.second) {
4739
+ ctx.fillStyle = cellColor(pair.first, palette);
4740
+ ctx.fillRect(x, y, width, height);
4741
+ return;
4742
+ }
4743
+
4744
+ // Fixed "/" split. first = upper-left triangle, second = lower-right.
4745
+ ctx.beginPath();
4746
+ ctx.moveTo(x, y);
4747
+ ctx.lineTo(x + width, y);
4748
+ ctx.lineTo(x, y + height);
4749
+ ctx.closePath();
4750
+ ctx.fillStyle = cellColor(pair.first, palette);
4751
+ ctx.fill();
4752
+
4753
+ ctx.beginPath();
4754
+ ctx.moveTo(x + width, y);
4755
+ ctx.lineTo(x + width, y + height);
4756
+ ctx.lineTo(x, y + height);
4757
+ ctx.closePath();
4758
+ ctx.fillStyle = cellColor(pair.second, palette);
4759
+ ctx.fill();
4760
+ }
4761
+
4762
+ function fillImageTriangleCell(data, imageWidth, x, y, width, height, cell, palette) {
4763
+ const pair = triangleCellColors(cell);
4764
+ const firstRgb = cellRgb(pair.first, palette);
4765
+ const secondRgb = cellRgb(pair.second, palette);
4766
+ for (let yy = 0; yy < height; yy++) {
4767
+ for (let xx = 0; xx < width; xx++) {
4768
+ const normalizedX = (xx + 0.5) / Math.max(1, width);
4769
+ const normalizedY = (yy + 0.5) / Math.max(1, height);
4770
+ const rgb = normalizedX + normalizedY <= 1 ? firstRgb : secondRgb;
4771
+ const p = ((y + yy) * imageWidth + (x + xx)) * 4;
4772
+ data[p] = rgb.r;
4773
+ data[p + 1] = rgb.g;
4774
+ data[p + 2] = rgb.b;
4775
+ data[p + 3] = 255;
4776
+ }
4777
+ }
4778
+ }
4779
+
4780
+ function svgTriangleCell(x, y, size, cell, palette) {
4781
+ const pair = triangleCellColors(cell);
4782
+ if (pair.first === pair.second) return svgRect(x, y, size, size, cellColor(pair.first, palette));
4783
+ const first = cellColor(pair.first, palette);
4784
+ const second = cellColor(pair.second, palette);
4785
+ return [
4786
+ `<polygon points="${x},${y} ${x + size},${y} ${x},${y + size}" fill="${first}"/>`,
4787
+ `<polygon points="${x + size},${y} ${x + size},${y + size} ${x},${y + size}" fill="${second}"/>`
4788
+ ].join("");
4789
+ }
4790
+
4791
+
3497
4792
  function normalizeRenderStyle(style = RENDER_STYLES.CLASSIC) {
3498
4793
  const value = String(style).toLowerCase();
3499
4794
  const allowed = Object.values(RENDER_STYLES);
@@ -3624,18 +4919,348 @@ function fillImageRoundedRect(data, width, x, y, rectWidth, rectHeight, radius,
3624
4919
  }
3625
4920
  }
3626
4921
 
4922
+
4923
+ /** Estimate a conservative center-logo ratio from ECC, utilization and output mode. */
4924
+ function estimateSafeLogoSize(codeOrMatrix, options = {}) {
4925
+ const code = Array.isArray(codeOrMatrix) ? null : codeOrMatrix;
4926
+ const ecc = String(code?.eccLevel ?? options.ecc ?? DEFAULT_ECC_LEVEL).toUpperCase();
4927
+ const base = { L: 0.10, M: 0.13, Q: 0.16, H: 0.19 }[ecc] ?? 0.12;
4928
+ const utilization = Number.isFinite(code?.utilization) ? code.utilization : Number(options.utilization ?? 0.65);
4929
+ const utilizationPenalty = utilization <= 0.55 ? 1 : Math.max(0.72, 1 - (utilization - 0.55) * 0.56);
4930
+ const clearBackground = Boolean(options.clearBackground ?? options.logoClearBackground ?? options.logo?.clearBackground);
4931
+ const clearPenalty = clearBackground ? 0.90 : 1;
4932
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4933
+ const printPenalty = mode === RENDER_MODES.PRINT ? 0.88 : 1;
4934
+ const versionPenalty = code?.version === 1 ? 0.88 : 1;
4935
+ return Math.max(0.07, Math.min(0.22, base * utilizationPenalty * clearPenalty * printPenalty * versionPenalty));
4936
+ }
4937
+
4938
+ /**
4939
+ * Empirically search for the largest logo ratio that still decodes from the
4940
+ * dependency-free ImageData renderer. The logo source must therefore be ImageData-like.
4941
+ */
4942
+ function findMaxSafeLogoSize(code, options = {}) {
4943
+ assert(code?.matrix, "findMaxSafeLogoSize expects an encoded QuadQR object.");
4944
+ const logo = options.logo;
4945
+ const source = logo?.source ?? logo;
4946
+ assert(source?.data && source?.width && source?.height,
4947
+ "findMaxSafeLogoSize requires an ImageData-like logo source so it can test pixel output without DOM dependencies.");
4948
+ let low = Math.max(0.05, Number(options.minSize ?? 0.05));
4949
+ let high = Math.min(0.30, Number(options.maxSize ?? 0.28));
4950
+ let best = 0;
4951
+ const iterations = Math.max(3, Math.min(10, Math.floor(options.iterations ?? 7)));
4952
+ for (let i = 0; i < iterations; i++) {
4953
+ const probe = (low + high) / 2;
4954
+ try {
4955
+ const image = renderToImageData(code, {
4956
+ ...options,
4957
+ logo: {
4958
+ ...(typeof logo === "object" && Object.prototype.hasOwnProperty.call(logo, "source") ? logo : { source }),
4959
+ size: probe
4960
+ }
4961
+ });
4962
+ const decoded = scanImageData(image, { minVersion: code.version, maxVersion: code.version });
4963
+ if (decoded.crc32 !== code.crc32) throw new Error("Decoded a different payload.");
4964
+ best = probe;
4965
+ low = probe;
4966
+ } catch {
4967
+ high = probe;
4968
+ }
4969
+ }
4970
+ return {
4971
+ safeSize: best || estimateSafeLogoSize(code, options),
4972
+ testedMax: Number(options.maxSize ?? 0.28),
4973
+ iterations,
4974
+ empirical: best > 0
4975
+ };
4976
+ }
4977
+
4978
+ function getPrintGuidance(codeOrMatrix, options = {}) {
4979
+ const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix?.matrix;
4980
+ assert(matrix?.length, "getPrintGuidance expects a QuadQR code or matrix.");
4981
+ const quietZone = Math.max(4, Math.floor(options.quietZone ?? 4));
4982
+ const physicalSizeMm = Number(options.physicalSizeMm ?? 35);
4983
+ const totalModules = matrix.length + quietZone * 2;
4984
+ const moduleSizeMm = physicalSizeMm / totalModules;
4985
+ const dpi = Number(options.dpi ?? 300);
4986
+ const modulePixels = moduleSizeMm / 25.4 * dpi;
4987
+ const recommendedMinimumModuleMm = Number(options.minimumModuleMm ?? 0.40);
4988
+ return {
4989
+ mode: "print",
4990
+ physicalSizeMm,
4991
+ quietZone,
4992
+ moduleSizeMm,
4993
+ modulePixelsAtDpi: modulePixels,
4994
+ dpi,
4995
+ recommendedMinimumModuleMm,
4996
+ meetsRecommendedModuleSize: moduleSizeMm >= recommendedMinimumModuleMm,
4997
+ recommendedMinimumPhysicalSizeMm: totalModules * recommendedMinimumModuleMm,
4998
+ palette: PRINT_PALETTE,
4999
+ recommendations: [
5000
+ "Use classic rendering for print unless a styled output has been physically validated.",
5001
+ "Keep at least a 4-module quiet zone.",
5002
+ `Target at least ${recommendedMinimumModuleMm.toFixed(2)} mm per module for general-purpose printing.`,
5003
+ "Avoid glossy reflections and low-ink/toner modes when reliable color classification matters."
5004
+ ]
5005
+ };
5006
+ }
5007
+
5008
+ function resolveLogoRenderOptions(options, matrixSize, moduleSize, quietZone, palette, codeOrMatrix = null) {
5009
+ if (!options.logo) return null;
5010
+
5011
+ const nested = (
5012
+ options.logo &&
5013
+ typeof options.logo === "object" &&
5014
+ Object.prototype.hasOwnProperty.call(options.logo, "source")
5015
+ ) ? options.logo : null;
5016
+ const source = nested ? nested.source : options.logo;
5017
+ if (!source) return null;
5018
+
5019
+ const requestedSize = nested?.size ?? options.logoSize ?? 0.18;
5020
+ const autoSize = String(requestedSize).toLowerCase() === "auto";
5021
+ const rawSize = Number(requestedSize);
5022
+ const sizeRatio = autoSize
5023
+ ? estimateSafeLogoSize(codeOrMatrix, { ...options, clearBackground: nested?.clearBackground ?? options.logoClearBackground })
5024
+ : Math.max(0.05, Math.min(0.30, Number.isFinite(rawSize) ? rawSize : 0.18));
5025
+ const rawPadding = Number(nested?.padding ?? options.logoPadding ?? 0.65);
5026
+ const rawRadius = Number(nested?.radius ?? options.logoRadius ?? 0.8);
5027
+ const paddingModules = Math.max(0, Number.isFinite(rawPadding) ? rawPadding : 0.65);
5028
+ const radiusModules = Math.max(0, Number.isFinite(rawRadius) ? rawRadius : 0.8);
5029
+ const clearBackground = Boolean(nested?.clearBackground ?? options.logoClearBackground ?? false);
5030
+ const backgroundColor = nested?.backgroundColor ?? options.logoBackgroundColor ?? palette.white;
5031
+
5032
+ const codePixels = matrixSize * moduleSize;
5033
+ const canvasPixels = (matrixSize + quietZone * 2) * moduleSize;
5034
+ const logoPixels = Math.max(1, codePixels * sizeRatio);
5035
+ const center = canvasPixels / 2;
5036
+ const paddingPixels = paddingModules * moduleSize;
5037
+ const backgroundPixels = logoPixels + paddingPixels * 2;
5038
+
5039
+ return {
5040
+ source,
5041
+ sizeRatio,
5042
+ clearBackground,
5043
+ backgroundColor,
5044
+ logoX: center - logoPixels / 2,
5045
+ logoY: center - logoPixels / 2,
5046
+ logoSize: logoPixels,
5047
+ backgroundX: center - backgroundPixels / 2,
5048
+ backgroundY: center - backgroundPixels / 2,
5049
+ backgroundSize: backgroundPixels,
5050
+ backgroundRadius: radiusModules * moduleSize
5051
+ };
5052
+ }
5053
+
5054
+ function canvasLogoSource(source) {
5055
+ if (!source || typeof source === "string") return source;
5056
+ if (source.data && Number.isInteger(source.width) && Number.isInteger(source.height)) {
5057
+ let scratch = null;
5058
+ if (typeof OffscreenCanvas !== "undefined") {
5059
+ scratch = new OffscreenCanvas(source.width, source.height);
5060
+ } else if (typeof document !== "undefined") {
5061
+ scratch = document.createElement("canvas");
5062
+ scratch.width = source.width;
5063
+ scratch.height = source.height;
5064
+ }
5065
+ if (!scratch) return source;
5066
+ const ctx = scratch.getContext("2d");
5067
+ if (!ctx) return source;
5068
+ const pixels = source.data instanceof Uint8ClampedArray
5069
+ ? source.data
5070
+ : new Uint8ClampedArray(source.data);
5071
+ ctx.putImageData(new ImageData(pixels, source.width, source.height), 0, 0);
5072
+ return scratch;
5073
+ }
5074
+ return source;
5075
+ }
5076
+
5077
+ function logoSourceDimensions(source) {
5078
+ const width = source?.naturalWidth ?? source?.videoWidth ?? source?.width;
5079
+ const height = source?.naturalHeight ?? source?.videoHeight ?? source?.height;
5080
+ if (!(Number(width) > 0 && Number(height) > 0)) return null;
5081
+ return { width: Number(width), height: Number(height) };
5082
+ }
5083
+
5084
+ function containRect(x, y, size, dimensions) {
5085
+ if (!dimensions) return { x, y, width: size, height: size };
5086
+ const scale = Math.min(size / dimensions.width, size / dimensions.height);
5087
+ const width = dimensions.width * scale;
5088
+ const height = dimensions.height * scale;
5089
+ return {
5090
+ x: x + (size - width) / 2,
5091
+ y: y + (size - height) / 2,
5092
+ width,
5093
+ height
5094
+ };
5095
+ }
5096
+
5097
+ function drawLogoToCanvas(ctx, logo, palette) {
5098
+ if (!logo) return;
5099
+ if (logo.clearBackground) {
5100
+ ctx.fillStyle = logo.backgroundColor || palette.white;
5101
+ roundedRectPath(
5102
+ ctx,
5103
+ logo.backgroundX,
5104
+ logo.backgroundY,
5105
+ logo.backgroundSize,
5106
+ logo.backgroundSize,
5107
+ logo.backgroundRadius
5108
+ );
5109
+ ctx.fill();
5110
+ }
5111
+
5112
+ const source = canvasLogoSource(logo.source);
5113
+ assert(
5114
+ source && typeof source !== "string",
5115
+ "renderToCanvas() logo must be a loaded CanvasImageSource or ImageData-like object. Load URL/data-URL logos into an Image first."
5116
+ );
5117
+ const rect = containRect(logo.logoX, logo.logoY, logo.logoSize, logoSourceDimensions(source));
5118
+ const previousSmoothing = ctx.imageSmoothingEnabled;
5119
+ ctx.imageSmoothingEnabled = true;
5120
+ ctx.drawImage(source, rect.x, rect.y, rect.width, rect.height);
5121
+ ctx.imageSmoothingEnabled = previousSmoothing;
5122
+ }
5123
+
5124
+ function blendImagePixel(data, width, x, y, rgb, alpha) {
5125
+ if (x < 0 || y < 0 || x >= width) return;
5126
+ const height = data.length / 4 / width;
5127
+ if (y >= height) return;
5128
+ const p = (y * width + x) * 4;
5129
+ const a = Math.max(0, Math.min(1, alpha));
5130
+ data[p] = Math.round(data[p] * (1 - a) + rgb.r * a);
5131
+ data[p + 1] = Math.round(data[p + 1] * (1 - a) + rgb.g * a);
5132
+ data[p + 2] = Math.round(data[p + 2] * (1 - a) + rgb.b * a);
5133
+ data[p + 3] = 255;
5134
+ }
5135
+
5136
+ function drawLogoToImageData(data, width, logo) {
5137
+ if (!logo) return;
5138
+ if (logo.clearBackground) {
5139
+ const rgb = hexToRgb(logo.backgroundColor);
5140
+ fillImageRoundedRect(
5141
+ data,
5142
+ width,
5143
+ Math.round(logo.backgroundX),
5144
+ Math.round(logo.backgroundY),
5145
+ Math.round(logo.backgroundSize),
5146
+ Math.round(logo.backgroundSize),
5147
+ Math.round(logo.backgroundRadius),
5148
+ rgb
5149
+ );
5150
+ }
5151
+
5152
+ const source = logo.source;
5153
+ assert(
5154
+ source?.data && Number.isInteger(source.width) && Number.isInteger(source.height),
5155
+ "renderToImageData() logo must be an ImageData-like object with width, height, and RGBA data."
5156
+ );
5157
+ const dest = containRect(logo.logoX, logo.logoY, logo.logoSize, {
5158
+ width: source.width,
5159
+ height: source.height
5160
+ });
5161
+ const x0 = Math.round(dest.x);
5162
+ const y0 = Math.round(dest.y);
5163
+ const destWidth = Math.max(1, Math.round(dest.width));
5164
+ const destHeight = Math.max(1, Math.round(dest.height));
5165
+ const src = source.data;
5166
+
5167
+ for (let y = 0; y < destHeight; y++) {
5168
+ const sy = Math.min(source.height - 1, Math.floor((y + 0.5) * source.height / destHeight));
5169
+ for (let x = 0; x < destWidth; x++) {
5170
+ const sx = Math.min(source.width - 1, Math.floor((x + 0.5) * source.width / destWidth));
5171
+ const p = (sy * source.width + sx) * 4;
5172
+ const alpha = (src[p + 3] ?? 255) / 255;
5173
+ if (alpha <= 0) continue;
5174
+ blendImagePixel(data, width, x0 + x, y0 + y, {
5175
+ r: src[p],
5176
+ g: src[p + 1],
5177
+ b: src[p + 2]
5178
+ }, alpha);
5179
+ }
5180
+ }
5181
+ }
5182
+
5183
+ function escapeXmlAttribute(value) {
5184
+ return String(value)
5185
+ .replaceAll("&", "&amp;")
5186
+ .replaceAll('"', "&quot;")
5187
+ .replaceAll("<", "&lt;")
5188
+ .replaceAll(">", "&gt;");
5189
+ }
5190
+
5191
+ function svgRect(x, y, width, height, fill, radius = 0) {
5192
+ const rounded = radius > 0 ? ` rx="${radius}" ry="${radius}"` : "";
5193
+ return `<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="${escapeXmlAttribute(fill)}"${rounded}/>`;
5194
+ }
5195
+
5196
+ function svgLogoHref(source) {
5197
+ if (typeof source === "string") return source;
5198
+ if (typeof source?.src === "string" && source.src) return source.src;
5199
+ return null;
5200
+ }
5201
+
5202
+ function resolveRenderSizing(options, matrixSize) {
5203
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
5204
+ const requestedQuietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
5205
+ const quietZone = mode === RENDER_MODES.PRINT && options.allowUnsafePrintQuietZone !== true
5206
+ ? Math.max(4, requestedQuietZone)
5207
+ : requestedQuietZone;
5208
+ const totalModules = matrixSize + quietZone * 2;
5209
+ const hasImageSize = options.imageSize !== undefined && options.imageSize !== null;
5210
+ const hasModuleSize = options.moduleSize !== undefined && options.moduleSize !== null;
5211
+
5212
+ let pixelSize;
5213
+ if (hasImageSize) {
5214
+ const requested = Number(options.imageSize);
5215
+ assert(Number.isFinite(requested) && requested > 0, "imageSize must be a positive number of pixels.");
5216
+ pixelSize = Math.max(totalModules, Math.round(requested));
5217
+ } else if (hasModuleSize) {
5218
+ const requested = Number(options.moduleSize);
5219
+ assert(Number.isFinite(requested) && requested > 0, "moduleSize must be a positive number of pixels.");
5220
+ const moduleSize = Math.max(1, Math.floor(requested));
5221
+ pixelSize = totalModules * moduleSize;
5222
+ } else {
5223
+ // A practical export default that is large enough for screens/social use
5224
+ // while keeping the API independent from the encoded matrix version.
5225
+ pixelSize = Math.max(totalModules, 720);
5226
+ }
5227
+
5228
+ return {
5229
+ quietZone,
5230
+ totalModules,
5231
+ pixelSize,
5232
+ moduleSize: pixelSize / totalModules,
5233
+ mode
5234
+ };
5235
+ }
5236
+
5237
+ function rasterModuleRect(row, col, quietZone, moduleSize) {
5238
+ const x0 = Math.round((col + quietZone) * moduleSize);
5239
+ const y0 = Math.round((row + quietZone) * moduleSize);
5240
+ const x1 = Math.round((col + quietZone + 1) * moduleSize);
5241
+ const y1 = Math.round((row + quietZone + 1) * moduleSize);
5242
+ return {
5243
+ x: x0,
5244
+ y: y0,
5245
+ width: Math.max(1, x1 - x0),
5246
+ height: Math.max(1, y1 - y0)
5247
+ };
5248
+ }
5249
+
3627
5250
  function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3628
5251
  assert(canvas && typeof canvas.getContext === "function", "A canvas element is required.");
3629
5252
  const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
3630
5253
  assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
3631
- const moduleSize = Math.max(1, Math.floor(options.moduleSize ?? 12));
3632
- const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
3633
- const palette = resolvePalette(options.palette);
3634
- const paletteValues = paletteRgb(options.palette);
3635
- const style = normalizeRenderStyle(options.style);
5254
+ const sizing = resolveRenderSizing(options, matrix.length);
5255
+ const { moduleSize, quietZone, pixelSize } = sizing;
5256
+ const palette = resolveRenderPalette(options);
5257
+ const paletteValues = paletteRgb(palette);
5258
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
5259
+ const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
3636
5260
  const layout = renderLayoutForMatrix(matrix);
5261
+ const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
3637
5262
  const size = matrix.length;
3638
- const pixelSize = (size + quietZone * 2) * moduleSize;
5263
+ const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
3639
5264
 
3640
5265
  canvas.width = pixelSize;
3641
5266
  canvas.height = pixelSize;
@@ -3648,12 +5273,21 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3648
5273
  for (let c = 0; c < size; c++) {
3649
5274
  const cell = matrix[r][c];
3650
5275
  const structural = isStructuralRenderCell(layout, r, c, cell);
3651
- const x = (c + quietZone) * moduleSize;
3652
- const y = (r + quietZone) * moduleSize;
5276
+ const rect = rasterModuleRect(r, c, quietZone, moduleSize);
5277
+ const { x, y, width: cellWidth, height: cellHeight } = rect;
5278
+ const cellSize = Math.min(cellWidth, cellHeight);
5279
+
5280
+ if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
5281
+ // Experimental Triangle16 always renders payload cells with exact,
5282
+ // hard-edged geometry. Decorative per-cell styles would contaminate the
5283
+ // two independent color samples and reduce scan reliability.
5284
+ drawTriangleCellCanvas(ctx, x, y, cellWidth, cellHeight, cell, palette);
5285
+ continue;
5286
+ }
3653
5287
 
3654
5288
  if (style === RENDER_STYLES.CLASSIC || structural) {
3655
5289
  ctx.fillStyle = cellColor(cell, palette);
3656
- ctx.fillRect(x, y, moduleSize, moduleSize);
5290
+ ctx.fillRect(x, y, cellWidth, cellHeight);
3657
5291
  continue;
3658
5292
  }
3659
5293
 
@@ -3663,28 +5297,29 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3663
5297
  const opacity = depthOpacity(r, c, cell);
3664
5298
  const base = mixRgb(paletteValues.white, rgb, opacity);
3665
5299
  ctx.fillStyle = rgbCss(base);
3666
- ctx.fillRect(x, y, moduleSize, moduleSize);
5300
+ ctx.fillRect(x, y, cellWidth, cellHeight);
3667
5301
 
3668
- if (moduleSize >= 6) {
3669
- const edge = Math.max(1, Math.floor(moduleSize * 0.08));
5302
+ if (cellSize >= 6) {
5303
+ const edge = Math.max(1, Math.floor(cellSize * 0.08));
3670
5304
  const highlight = mixRgb(base, paletteValues.white, 0.17);
3671
5305
  const shadow = mixRgb(base, paletteValues.black, 0.12);
3672
5306
  ctx.fillStyle = rgbCss(highlight);
3673
- ctx.fillRect(x, y, moduleSize, edge);
3674
- ctx.fillRect(x, y, edge, moduleSize);
5307
+ ctx.fillRect(x, y, cellWidth, edge);
5308
+ ctx.fillRect(x, y, edge, cellHeight);
3675
5309
  ctx.fillStyle = rgbCss(shadow);
3676
- ctx.fillRect(x, y + moduleSize - edge, moduleSize, edge);
3677
- ctx.fillRect(x + moduleSize - edge, y, edge, moduleSize);
5310
+ ctx.fillRect(x, y + cellHeight - edge, cellWidth, edge);
5311
+ ctx.fillRect(x + cellWidth - edge, y, edge, cellHeight);
3678
5312
  }
3679
5313
  continue;
3680
5314
  }
3681
5315
 
3682
5316
  if (style === RENDER_STYLES.SOFT) {
3683
- const inset = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
3684
- const width = moduleSize - inset * 2;
3685
- const radius = Math.max(1, Math.floor(moduleSize * 0.20));
5317
+ const inset = cellSize >= 6 ? Math.max(1, Math.floor(cellSize * 0.07)) : 0;
5318
+ const width = cellWidth - inset * 2;
5319
+ const height = cellHeight - inset * 2;
5320
+ const radius = Math.max(1, Math.floor(cellSize * 0.20));
3686
5321
  ctx.fillStyle = cellColor(cell, palette);
3687
- roundedRectPath(ctx, x + inset, y + inset, width, width, radius);
5322
+ roundedRectPath(ctx, x + inset, y + inset, width, height, radius);
3688
5323
  ctx.fill();
3689
5324
  continue;
3690
5325
  }
@@ -3693,37 +5328,41 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3693
5328
  // Keep the exact encoded color across the full module, then limit the
3694
5329
  // recessed effect to a narrow edge band so center sampling stays intact.
3695
5330
  ctx.fillStyle = cellColor(cell, palette);
3696
- ctx.fillRect(x, y, moduleSize, moduleSize);
5331
+ ctx.fillRect(x, y, cellWidth, cellHeight);
3697
5332
  if (cell === CELL.WHITE) continue;
3698
5333
 
3699
- const edge = safeStyleEdge(moduleSize);
5334
+ const edge = safeStyleEdge(cellSize);
3700
5335
  if (edge > 0) {
3701
5336
  const fx = insetStyleColors(rgb, paletteValues);
3702
5337
  ctx.fillStyle = rgbCss(fx.shadow);
3703
- ctx.fillRect(x, y, moduleSize, edge);
3704
- ctx.fillRect(x, y, edge, moduleSize);
5338
+ ctx.fillRect(x, y, cellWidth, edge);
5339
+ ctx.fillRect(x, y, edge, cellHeight);
3705
5340
  ctx.fillStyle = rgbCss(fx.highlight);
3706
- ctx.fillRect(x, y + moduleSize - edge, moduleSize, edge);
3707
- ctx.fillRect(x + moduleSize - edge, y, edge, moduleSize);
5341
+ ctx.fillRect(x, y + cellHeight - edge, cellWidth, edge);
5342
+ ctx.fillRect(x + cellWidth - edge, y, edge, cellHeight);
3708
5343
  }
3709
5344
  }
3710
5345
  }
3711
5346
  }
5347
+ drawLogoToCanvas(ctx, logo, palette);
3712
5348
  return canvas;
3713
5349
  }
3714
5350
 
3715
5351
  function renderToImageData(codeOrMatrix, options = {}) {
3716
5352
  const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
3717
5353
  assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
3718
- const moduleSize = Math.max(1, Math.floor(options.moduleSize ?? 8));
3719
- const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
3720
- const palette = paletteRgb(options.palette);
3721
- const style = normalizeRenderStyle(options.style);
5354
+ const sizing = resolveRenderSizing(options, matrix.length);
5355
+ const { moduleSize, quietZone, pixelSize } = sizing;
5356
+ const resolvedPalette = resolveRenderPalette(options);
5357
+ const palette = paletteRgb(resolvedPalette);
5358
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
5359
+ const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
3722
5360
  const layout = renderLayoutForMatrix(matrix);
5361
+ const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
3723
5362
  const size = matrix.length;
3724
- const pixelSize = (size + quietZone * 2) * moduleSize;
3725
5363
  const data = new Uint8ClampedArray(pixelSize * pixelSize * 4);
3726
5364
  const white = palette.white;
5365
+ const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, resolvedPalette, codeOrMatrix);
3727
5366
 
3728
5367
  for (let i = 0; i < pixelSize * pixelSize; i++) {
3729
5368
  const p = i * 4;
@@ -3736,28 +5375,119 @@ function renderToImageData(codeOrMatrix, options = {}) {
3736
5375
  for (let r = 0; r < size; r++) {
3737
5376
  for (let c = 0; c < size; c++) {
3738
5377
  const cell = matrix[r][c];
3739
- const rgb = cellRgb(cell, palette);
3740
5378
  const structural = isStructuralRenderCell(layout, r, c, cell);
3741
- const y0 = (r + quietZone) * moduleSize;
3742
- const x0 = (c + quietZone) * moduleSize;
5379
+ const rect = rasterModuleRect(r, c, quietZone, moduleSize);
5380
+ const x0 = rect.x;
5381
+ const y0 = rect.y;
5382
+ const cellWidth = rect.width;
5383
+ const cellHeight = rect.height;
5384
+ const cellSize = Math.min(cellWidth, cellHeight);
5385
+
5386
+ if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
5387
+ fillImageTriangleCell(data, pixelSize, x0, y0, cellWidth, cellHeight, cell, palette);
5388
+ continue;
5389
+ }
5390
+
5391
+ const rgb = cellRgb(cell, palette);
3743
5392
 
3744
5393
  if (style === RENDER_STYLES.CLASSIC || structural) {
3745
- fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, rgb);
5394
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
3746
5395
  continue;
3747
5396
  }
3748
5397
 
3749
5398
  if (style === RENDER_STYLES.DEPTH) {
3750
5399
  const opacity = depthOpacity(r, c, cell);
3751
5400
  const base = mixRgb(white, rgb, opacity);
3752
- fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, base);
3753
- if (moduleSize >= 6) {
3754
- const edge = Math.max(1, Math.floor(moduleSize * 0.08));
5401
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, base);
5402
+ if (cellSize >= 6) {
5403
+ const edge = Math.max(1, Math.floor(cellSize * 0.08));
3755
5404
  const highlight = mixRgb(base, white, 0.17);
3756
5405
  const shadow = mixRgb(base, palette.black, 0.12);
3757
- fillImageRect(data, pixelSize, x0, y0, moduleSize, edge, highlight);
3758
- fillImageRect(data, pixelSize, x0, y0, edge, moduleSize, highlight);
3759
- fillImageRect(data, pixelSize, x0, y0 + moduleSize - edge, moduleSize, edge, shadow);
3760
- fillImageRect(data, pixelSize, x0 + moduleSize - edge, y0, edge, moduleSize, shadow);
5406
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, edge, highlight);
5407
+ fillImageRect(data, pixelSize, x0, y0, edge, cellHeight, highlight);
5408
+ fillImageRect(data, pixelSize, x0, y0 + cellHeight - edge, cellWidth, edge, shadow);
5409
+ fillImageRect(data, pixelSize, x0 + cellWidth - edge, y0, edge, cellHeight, shadow);
5410
+ }
5411
+ continue;
5412
+ }
5413
+
5414
+ if (style === RENDER_STYLES.SOFT) {
5415
+ const inset = cellSize >= 6 ? Math.max(1, Math.floor(cellSize * 0.07)) : 0;
5416
+ const width = cellWidth - inset * 2;
5417
+ const height = cellHeight - inset * 2;
5418
+ const radius = Math.max(1, Math.floor(cellSize * 0.20));
5419
+ fillImageRoundedRect(data, pixelSize, x0 + inset, y0 + inset, width, height, radius, rgb);
5420
+ continue;
5421
+ }
5422
+
5423
+ if (style === RENDER_STYLES.INSET) {
5424
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
5425
+ if (cell === CELL.WHITE) continue;
5426
+ const edge = safeStyleEdge(cellSize);
5427
+ if (edge > 0) {
5428
+ const fx = insetStyleColors(rgb, palette);
5429
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, edge, fx.shadow);
5430
+ fillImageRect(data, pixelSize, x0, y0, edge, cellHeight, fx.shadow);
5431
+ fillImageRect(data, pixelSize, x0, y0 + cellHeight - edge, cellWidth, edge, fx.highlight);
5432
+ fillImageRect(data, pixelSize, x0 + cellWidth - edge, y0, edge, cellHeight, fx.highlight);
5433
+ }
5434
+ }
5435
+ }
5436
+ }
5437
+
5438
+ drawLogoToImageData(data, pixelSize, logo);
5439
+
5440
+ return { width: pixelSize, height: pixelSize, data };
5441
+ }
5442
+
5443
+ /** Render a QuadQR code/matrix to a standalone SVG string. */
5444
+ function renderToSVG(codeOrMatrix, options = {}) {
5445
+ const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
5446
+ assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
5447
+ const sizing = resolveRenderSizing(options, matrix.length);
5448
+ const { moduleSize, quietZone, pixelSize } = sizing;
5449
+ const palette = resolveRenderPalette(options);
5450
+ const paletteValues = paletteRgb(palette);
5451
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
5452
+ const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
5453
+ const layout = renderLayoutForMatrix(matrix);
5454
+ const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
5455
+ const size = matrix.length;
5456
+ const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
5457
+ const body = [svgRect(0, 0, pixelSize, pixelSize, palette.white)];
5458
+
5459
+ for (let r = 0; r < size; r++) {
5460
+ for (let c = 0; c < size; c++) {
5461
+ const cell = matrix[r][c];
5462
+ const structural = isStructuralRenderCell(layout, r, c, cell);
5463
+ const x = (c + quietZone) * moduleSize;
5464
+ const y = (r + quietZone) * moduleSize;
5465
+ const fill = cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural ? null : cellColor(cell, palette);
5466
+
5467
+ if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
5468
+ body.push(svgTriangleCell(x, y, moduleSize, cell, palette));
5469
+ continue;
5470
+ }
5471
+
5472
+ if (style === RENDER_STYLES.CLASSIC || structural) {
5473
+ body.push(svgRect(x, y, moduleSize, moduleSize, fill));
5474
+ continue;
5475
+ }
5476
+
5477
+ const rgb = cellRgb(cell, paletteValues);
5478
+
5479
+ if (style === RENDER_STYLES.DEPTH) {
5480
+ const opacity = depthOpacity(r, c, cell);
5481
+ const base = mixRgb(paletteValues.white, rgb, opacity);
5482
+ body.push(svgRect(x, y, moduleSize, moduleSize, rgbCss(base)));
5483
+ if (moduleSize >= 6) {
5484
+ const edge = Math.max(1, Math.floor(moduleSize * 0.08));
5485
+ const highlight = rgbCss(mixRgb(base, paletteValues.white, 0.17));
5486
+ const shadow = rgbCss(mixRgb(base, paletteValues.black, 0.12));
5487
+ body.push(svgRect(x, y, moduleSize, edge, highlight));
5488
+ body.push(svgRect(x, y, edge, moduleSize, highlight));
5489
+ body.push(svgRect(x, y + moduleSize - edge, moduleSize, edge, shadow));
5490
+ body.push(svgRect(x + moduleSize - edge, y, edge, moduleSize, shadow));
3761
5491
  }
3762
5492
  continue;
3763
5493
  }
@@ -3766,26 +5496,51 @@ function renderToImageData(codeOrMatrix, options = {}) {
3766
5496
  const inset = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
3767
5497
  const width = moduleSize - inset * 2;
3768
5498
  const radius = Math.max(1, Math.floor(moduleSize * 0.20));
3769
- fillImageRoundedRect(data, pixelSize, x0 + inset, y0 + inset, width, width, radius, rgb);
5499
+ body.push(svgRect(x + inset, y + inset, width, width, fill, radius));
3770
5500
  continue;
3771
5501
  }
3772
5502
 
3773
5503
  if (style === RENDER_STYLES.INSET) {
3774
- fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, rgb);
5504
+ body.push(svgRect(x, y, moduleSize, moduleSize, fill));
3775
5505
  if (cell === CELL.WHITE) continue;
3776
5506
  const edge = safeStyleEdge(moduleSize);
3777
5507
  if (edge > 0) {
3778
- const fx = insetStyleColors(rgb, palette);
3779
- fillImageRect(data, pixelSize, x0, y0, moduleSize, edge, fx.shadow);
3780
- fillImageRect(data, pixelSize, x0, y0, edge, moduleSize, fx.shadow);
3781
- fillImageRect(data, pixelSize, x0, y0 + moduleSize - edge, moduleSize, edge, fx.highlight);
3782
- fillImageRect(data, pixelSize, x0 + moduleSize - edge, y0, edge, moduleSize, fx.highlight);
5508
+ const fx = insetStyleColors(rgb, paletteValues);
5509
+ const shadow = rgbCss(fx.shadow);
5510
+ const highlight = rgbCss(fx.highlight);
5511
+ body.push(svgRect(x, y, moduleSize, edge, shadow));
5512
+ body.push(svgRect(x, y, edge, moduleSize, shadow));
5513
+ body.push(svgRect(x, y + moduleSize - edge, moduleSize, edge, highlight));
5514
+ body.push(svgRect(x + moduleSize - edge, y, edge, moduleSize, highlight));
3783
5515
  }
3784
5516
  }
3785
5517
  }
3786
5518
  }
3787
5519
 
3788
- return { width: pixelSize, height: pixelSize, data };
5520
+ if (logo) {
5521
+ if (logo.clearBackground) {
5522
+ body.push(svgRect(
5523
+ logo.backgroundX,
5524
+ logo.backgroundY,
5525
+ logo.backgroundSize,
5526
+ logo.backgroundSize,
5527
+ logo.backgroundColor,
5528
+ logo.backgroundRadius
5529
+ ));
5530
+ }
5531
+ const href = svgLogoHref(logo.source);
5532
+ assert(href, "renderToSVG() logo must be a URL/data URL string or an object with a src string.");
5533
+ body.push(
5534
+ `<image href="${escapeXmlAttribute(href)}" x="${logo.logoX}" y="${logo.logoY}" width="${logo.logoSize}" height="${logo.logoSize}" preserveAspectRatio="xMidYMid meet"/>`
5535
+ );
5536
+ }
5537
+
5538
+ return [
5539
+ '<?xml version="1.0" encoding="UTF-8"?>',
5540
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${pixelSize}" height="${pixelSize}" viewBox="0 0 ${pixelSize} ${pixelSize}" shape-rendering="crispEdges">`,
5541
+ ...body,
5542
+ "</svg>"
5543
+ ].join("\n");
3789
5544
  }
3790
5545
 
3791
5546
  function clampNumber(value, min, max) {
@@ -3939,6 +5694,60 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
3939
5694
  };
3940
5695
  }
3941
5696
 
5697
+ function classifyTriangleSampledRgbGrid(rgbGrid, triangleGrid, classifier, layout) {
5698
+ const size = rgbGrid.length;
5699
+ const matrix = make2D(size, CELL.WHITE);
5700
+ const confidence = make2D(size, 1);
5701
+ const dataClassifier = {
5702
+ ...classifier,
5703
+ entries: classifier.entries.filter(({ cell }) => cell !== CELL.BLACK)
5704
+ };
5705
+ let distanceSum = 0;
5706
+ let confidenceSum = 0;
5707
+ let minimumConfidence = 1;
5708
+ let lowConfidenceCells = 0;
5709
+
5710
+ for (let r = 0; r < size; r++) {
5711
+ for (let c = 0; c < size; c++) {
5712
+ const isDataCell = layout && !layout.reserved[r][c];
5713
+ if (!isDataCell) {
5714
+ const classified = classifyRgb(rgbGrid[r][c], classifier);
5715
+ matrix[r][c] = classified.cell;
5716
+ confidence[r][c] = classified.confidence;
5717
+ distanceSum += classified.distance;
5718
+ confidenceSum += classified.confidence;
5719
+ minimumConfidence = Math.min(minimumConfidence, classified.confidence);
5720
+ if (classified.confidence < 0.4) lowConfidenceCells++;
5721
+ continue;
5722
+ }
5723
+
5724
+ const samples = triangleGrid?.[r]?.[c];
5725
+ if (!samples) throw new Error("Triangle16 sampling grid is incomplete.");
5726
+ const classifyData = (rgb) => classifier.dataMode === "hue"
5727
+ ? classifyDataHue(rgb, classifier)
5728
+ : classifyRgb(rgb, dataClassifier);
5729
+ const first = classifyData(samples.first);
5730
+ const second = classifyData(samples.second);
5731
+ const cellConfidence = Math.min(first.confidence, second.confidence);
5732
+ matrix[r][c] = packTriangleCell(first.cell, second.cell);
5733
+ confidence[r][c] = cellConfidence;
5734
+ distanceSum += (first.distance + second.distance) / 2;
5735
+ confidenceSum += cellConfidence;
5736
+ minimumConfidence = Math.min(minimumConfidence, cellConfidence);
5737
+ if (cellConfidence < 0.4) lowConfidenceCells++;
5738
+ }
5739
+ }
5740
+
5741
+ return {
5742
+ matrix,
5743
+ confidence,
5744
+ averageColorDistance: distanceSum / (size * size),
5745
+ averageCellConfidence: confidenceSum / (size * size),
5746
+ minimumCellConfidence: minimumConfidence,
5747
+ lowConfidenceCells
5748
+ };
5749
+ }
5750
+
3942
5751
  function structuralAccuracy(matrix, layout) {
3943
5752
  let matches = 0;
3944
5753
  let total = 0;
@@ -3978,6 +5787,7 @@ function tryPerspectiveScan(imageData, options) {
3978
5787
  finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
3979
5788
  finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
3980
5789
  finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
5790
+ preciseAlignment: options.preciseAlignment,
3981
5791
  diagnostics: options._visionDiagnostics,
3982
5792
  diagnosticLabel: options._diagnosticLabel ?? "normal"
3983
5793
  });
@@ -4003,9 +5813,18 @@ function tryPerspectiveScan(imageData, options) {
4003
5813
  let bestStructureScore = 0;
4004
5814
  for (const profile of sampleProfiles) {
4005
5815
  let sampled;
5816
+ let triangleSampled = null;
4006
5817
  let observedPalette;
4007
5818
  try {
4008
5819
  sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, profile);
5820
+ if (options.highDensitySampling !== false && options.triangle16 !== false) {
5821
+ triangleSampled = samplePerspectiveTriangleMatrix(
5822
+ imageData,
5823
+ geometry.homography,
5824
+ layout.size,
5825
+ profile
5826
+ );
5827
+ }
4009
5828
  observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, {
4010
5829
  robust: profile.robustCalibration
4011
5830
  });
@@ -4064,11 +5883,67 @@ function tryPerspectiveScan(imageData, options) {
4064
5883
  }
4065
5884
  };
4066
5885
 
5886
+ const tryTriangleAttempt = (attempt, metadata = {}) => {
5887
+ if (!triangleSampled?.triangleGrid) return false;
5888
+ const activeObservedPalette = metadata.observedPalette ?? observedPalette;
5889
+ const activeSamplingMode = metadata.samplingMode ?? `${profile.sampleMode}-triangle16`;
5890
+ const classified = classifyTriangleSampledRgbGrid(
5891
+ sampled.rgbGrid,
5892
+ triangleSampled.triangleGrid,
5893
+ attempt.classifier,
5894
+ layout
5895
+ );
5896
+ const structureScore = structuralAccuracy(classified.matrix, layout);
5897
+ bestStructureScore = Math.max(bestStructureScore, structureScore);
5898
+ pushObservation(options, {
5899
+ version: geometry.version,
5900
+ matrix: classified.matrix,
5901
+ confidence: classified.confidence,
5902
+ geometry,
5903
+ observedPalette: activeObservedPalette,
5904
+ samplingMode: activeSamplingMode,
5905
+ colorNormalization: attempt.colorNormalization,
5906
+ cellEncoding: CELL_ENCODINGS.TRIANGLE16,
5907
+ structureScore,
5908
+ averageCellConfidence: classified.averageCellConfidence,
5909
+ lowConfidenceCells: classified.lowConfidenceCells
5910
+ });
5911
+
5912
+ try {
5913
+ const decoded = decodeMatrix(classified.matrix, {
5914
+ structureTolerance: options.structureTolerance ?? 0.18,
5915
+ cellConfidence: classified.confidence,
5916
+ cellEncodingHint: CELL_ENCODINGS.TRIANGLE16,
5917
+ maxErasureConfidence: options.maxErasureConfidence
5918
+ });
5919
+ if (decoded.version !== geometry.version) return false;
5920
+ results.push({
5921
+ ...decoded,
5922
+ perspectiveCorrected: true,
5923
+ colorCalibrated: true,
5924
+ colorNormalization: attempt.colorNormalization,
5925
+ samplingMode: activeSamplingMode,
5926
+ geometry,
5927
+ observedPalette: activeObservedPalette,
5928
+ averageColorDistance: classified.averageColorDistance,
5929
+ averageCellConfidence: classified.averageCellConfidence,
5930
+ minimumCellConfidence: classified.minimumCellConfidence,
5931
+ lowConfidenceCells: classified.lowConfidenceCells,
5932
+ rectified: options.includeRectified
5933
+ ? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
5934
+ : undefined
5935
+ });
5936
+ return true;
5937
+ } catch {
5938
+ return false;
5939
+ }
5940
+ };
5941
+
4067
5942
  // Fast path: preserve the original observed-RGB classifier first, then
4068
5943
  // try per-channel white balancing. Most clean frames stop here without
4069
5944
  // paying for the more expensive spatial normalization fallback.
4070
5945
  for (const attempt of paletteClassifierAttempts(observedPalette)) {
4071
- if (tryAttempt(attempt, sampled.rgbGrid)) {
5946
+ if (tryAttempt(attempt, sampled.rgbGrid) || tryTriangleAttempt(attempt)) {
4072
5947
  geometryDecoded = true;
4073
5948
  break;
4074
5949
  }
@@ -4195,17 +6070,30 @@ function tryPerspectiveScan(imageData, options) {
4195
6070
  [step, -step], [step, step], [-step, -step], [-step, step]
4196
6071
  ];
4197
6072
  const refinementCandidates = [];
6073
+ const triangleRefinementCandidates = [];
4198
6074
 
4199
6075
  for (const [offsetX, offsetY] of refinementOffsets) {
4200
6076
  let sampled;
6077
+ let triangleSampled = null;
4201
6078
  let observedPalette;
4202
6079
  try {
4203
- sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, {
6080
+ const samplingOptions = {
4204
6081
  sampleMode: "cross",
4205
6082
  sampleRadius: 0,
4206
6083
  sampleOffsetX: offsetX,
4207
- sampleOffsetY: offsetY
4208
- });
6084
+ sampleOffsetY: offsetY,
6085
+ triangleSampleInset: options.highDensitySampleInset ?? options.triangleSampleInset,
6086
+ triangleSampleRadius: options.highDensitySampleRadius ?? options.triangleSampleRadius
6087
+ };
6088
+ sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, samplingOptions);
6089
+ if (options.highDensitySampling !== false && options.triangle16 !== false) {
6090
+ triangleSampled = samplePerspectiveTriangleMatrix(
6091
+ imageData,
6092
+ geometry.homography,
6093
+ layout.size,
6094
+ samplingOptions
6095
+ );
6096
+ }
4209
6097
  observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, { robust: true });
4210
6098
  } catch {
4211
6099
  continue;
@@ -4235,30 +6123,80 @@ function tryPerspectiveScan(imageData, options) {
4235
6123
 
4236
6124
  // Do not spend RS work on a refinement that made the known finder /
4237
6125
  // alignment structure worse. This keeps the recovery path bounded.
4238
- if (structureScore < (options.refinementDecodeThreshold ?? 0.95)) continue;
4239
- refinementCandidates.push({
4240
- attempt,
4241
- classified,
4242
- observedPalette,
4243
- offsetX,
4244
- offsetY,
4245
- structureScore
4246
- });
6126
+ if (structureScore >= (options.refinementDecodeThreshold ?? 0.95)) {
6127
+ refinementCandidates.push({
6128
+ attempt,
6129
+ classified,
6130
+ observedPalette,
6131
+ offsetX,
6132
+ offsetY,
6133
+ structureScore,
6134
+ cellEncoding: CELL_ENCODINGS.RGBW,
6135
+ samplingMode: "refined-center"
6136
+ });
6137
+ }
6138
+
6139
+ if (triangleSampled?.triangleGrid) {
6140
+ const triangleClassified = classifyTriangleSampledRgbGrid(
6141
+ sampled.rgbGrid,
6142
+ triangleSampled.triangleGrid,
6143
+ attempt.classifier,
6144
+ layout
6145
+ );
6146
+ const triangleStructureScore = structuralAccuracy(triangleClassified.matrix, layout);
6147
+ bestStructureScore = Math.max(bestStructureScore, triangleStructureScore);
6148
+ pushObservation(options, {
6149
+ version: geometry.version,
6150
+ matrix: triangleClassified.matrix,
6151
+ confidence: triangleClassified.confidence,
6152
+ geometry,
6153
+ observedPalette,
6154
+ samplingMode: "refined-triangle16",
6155
+ samplingOffset: { x: offsetX, y: offsetY },
6156
+ colorNormalization: attempt.colorNormalization,
6157
+ cellEncoding: CELL_ENCODINGS.TRIANGLE16,
6158
+ structureScore: triangleStructureScore,
6159
+ averageCellConfidence: triangleClassified.averageCellConfidence,
6160
+ lowConfidenceCells: triangleClassified.lowConfidenceCells
6161
+ });
6162
+ if (triangleStructureScore >= (options.refinementDecodeThreshold ?? 0.95)) {
6163
+ triangleRefinementCandidates.push({
6164
+ attempt,
6165
+ classified: triangleClassified,
6166
+ observedPalette,
6167
+ offsetX,
6168
+ offsetY,
6169
+ structureScore: triangleStructureScore,
6170
+ cellEncoding: CELL_ENCODINGS.TRIANGLE16,
6171
+ samplingMode: "refined-triangle16"
6172
+ });
6173
+ }
6174
+ }
4247
6175
  }
4248
6176
  }
4249
6177
 
4250
- refinementCandidates.sort((a, b) =>
6178
+ const sortRefinementCandidates = (items) => items.sort((a, b) =>
4251
6179
  (b.structureScore - a.structureScore) ||
4252
6180
  (b.classified.averageCellConfidence - a.classified.averageCellConfidence) ||
4253
6181
  (a.classified.averageColorDistance - b.classified.averageColorDistance)
4254
6182
  );
6183
+ sortRefinementCandidates(refinementCandidates);
6184
+ sortRefinementCandidates(triangleRefinementCandidates);
4255
6185
 
4256
6186
  const decodeLimit = Math.max(1, Math.min(8, Math.round(options.refinementDecodeCandidates ?? 4)));
4257
- for (const candidate of refinementCandidates.slice(0, decodeLimit)) {
6187
+ // Keep separate per-encoding limits. A Triangle16 image can produce very
6188
+ // confident but meaningless centre samples, and those must not crowd out
6189
+ // the dual-region candidates during the final bounded recovery pass.
6190
+ const decodeCandidates = [
6191
+ ...refinementCandidates.slice(0, decodeLimit),
6192
+ ...triangleRefinementCandidates.slice(0, decodeLimit)
6193
+ ];
6194
+ for (const candidate of decodeCandidates) {
4258
6195
  try {
4259
6196
  const decoded = decodeMatrix(candidate.classified.matrix, {
4260
6197
  structureTolerance: options.structureTolerance ?? 0.18,
4261
6198
  cellConfidence: candidate.classified.confidence,
6199
+ cellEncodingHint: candidate.cellEncoding,
4262
6200
  maxErasureConfidence: options.maxErasureConfidence
4263
6201
  });
4264
6202
  if (decoded.version !== geometry.version) continue;
@@ -4269,7 +6207,7 @@ function tryPerspectiveScan(imageData, options) {
4269
6207
  samplingOffset: { x: candidate.offsetX, y: candidate.offsetY },
4270
6208
  colorCalibrated: true,
4271
6209
  colorNormalization: candidate.attempt.colorNormalization,
4272
- samplingMode: "refined-center",
6210
+ samplingMode: candidate.samplingMode,
4273
6211
  geometry,
4274
6212
  observedPalette: candidate.observedPalette,
4275
6213
  averageColorDistance: candidate.classified.averageColorDistance,
@@ -4319,6 +6257,15 @@ function tryAxisAlignedScan(imageData, options) {
4319
6257
  for (let profileIndex = 0; profileIndex < radiusProfiles.length && !accepted; profileIndex++) {
4320
6258
  try {
4321
6259
  const sampled = sampleAxisAlignedGrid(imageData, bounds, size, radiusProfiles[profileIndex]);
6260
+ const triangleSampled = options.highDensitySampling === false || options.triangle16 === false
6261
+ ? null
6262
+ : sampleAxisAlignedTriangleGrid(
6263
+ imageData,
6264
+ bounds,
6265
+ size,
6266
+ Math.min(0.10, Math.max(0.04, radiusProfiles[profileIndex] * 0.48)),
6267
+ options.highDensitySampleInset ?? options.triangleSampleInset ?? 0.28
6268
+ );
4322
6269
  const layout = createLayout(version);
4323
6270
  const classifierAttempts = [];
4324
6271
  try {
@@ -4358,45 +6305,70 @@ function tryAxisAlignedScan(imageData, options) {
4358
6305
  });
4359
6306
 
4360
6307
  for (const attempt of classifierAttempts) {
4361
- try {
4362
- const classified = classifySampledRgbGrid(attempt.rgbGrid ?? sampled.rgbGrid, attempt.classifier, layout);
4363
- const structureScore = structuralAccuracy(classified.matrix, layout);
4364
- pushObservation(options, {
4365
- version,
4366
- matrix: classified.matrix,
4367
- confidence: classified.confidence,
4368
- bounds,
4369
- samplingMode: profileIndex === 0 ? "axis" : "axis-center",
4370
- colorNormalization: attempt.colorNormalization,
4371
- structureScore,
4372
- averageCellConfidence: classified.averageCellConfidence,
4373
- lowConfidenceCells: classified.lowConfidenceCells
4374
- });
4375
- const decoded = decodeMatrix(classified.matrix, {
4376
- structureTolerance: options.structureTolerance ?? 0.12,
4377
- cellConfidence: classified.confidence,
4378
- maxErasureConfidence: options.maxErasureConfidence
4379
- });
4380
- candidates.push({
4381
- ...decoded,
4382
- bounds,
4383
- sampledVersion: version,
4384
- moduleWidth: moduleW,
4385
- moduleHeight: moduleH,
4386
- perspectiveCorrected: false,
4387
- colorCalibrated: attempt.calibrated,
4388
- colorNormalization: attempt.colorNormalization,
4389
- samplingMode: profileIndex === 0 ? "axis" : "axis-center",
4390
- averageColorDistance: classified.averageColorDistance,
4391
- averageCellConfidence: classified.averageCellConfidence,
4392
- minimumCellConfidence: classified.minimumCellConfidence,
4393
- lowConfidenceCells: classified.lowConfidenceCells
6308
+ const centerGrid = attempt.rgbGrid ?? sampled.rgbGrid;
6309
+ const samplingMode = profileIndex === 0 ? "axis" : "axis-center";
6310
+ const classifiedAttempts = [{
6311
+ classified: classifySampledRgbGrid(centerGrid, attempt.classifier, layout),
6312
+ cellEncoding: CELL_ENCODINGS.RGBW,
6313
+ samplingMode
6314
+ }];
6315
+ if (triangleSampled?.triangleGrid && attempt.rgbGrid === sampled.rgbGrid) {
6316
+ classifiedAttempts.push({
6317
+ classified: classifyTriangleSampledRgbGrid(
6318
+ centerGrid,
6319
+ triangleSampled.triangleGrid,
6320
+ attempt.classifier,
6321
+ layout
6322
+ ),
6323
+ cellEncoding: CELL_ENCODINGS.TRIANGLE16,
6324
+ samplingMode: `${samplingMode}-triangle16`
4394
6325
  });
4395
- accepted = true;
4396
- break;
4397
- } catch {
4398
- // Try next classifier/profile.
4399
6326
  }
6327
+
6328
+ for (const classifiedAttempt of classifiedAttempts) {
6329
+ try {
6330
+ const classified = classifiedAttempt.classified;
6331
+ const structureScore = structuralAccuracy(classified.matrix, layout);
6332
+ pushObservation(options, {
6333
+ version,
6334
+ matrix: classified.matrix,
6335
+ confidence: classified.confidence,
6336
+ bounds,
6337
+ samplingMode: classifiedAttempt.samplingMode,
6338
+ colorNormalization: attempt.colorNormalization,
6339
+ cellEncoding: classifiedAttempt.cellEncoding,
6340
+ structureScore,
6341
+ averageCellConfidence: classified.averageCellConfidence,
6342
+ lowConfidenceCells: classified.lowConfidenceCells
6343
+ });
6344
+ const decoded = decodeMatrix(classified.matrix, {
6345
+ structureTolerance: options.structureTolerance ?? 0.12,
6346
+ cellConfidence: classified.confidence,
6347
+ cellEncodingHint: classifiedAttempt.cellEncoding,
6348
+ maxErasureConfidence: options.maxErasureConfidence
6349
+ });
6350
+ candidates.push({
6351
+ ...decoded,
6352
+ bounds,
6353
+ sampledVersion: version,
6354
+ moduleWidth: moduleW,
6355
+ moduleHeight: moduleH,
6356
+ perspectiveCorrected: false,
6357
+ colorCalibrated: attempt.calibrated,
6358
+ colorNormalization: attempt.colorNormalization,
6359
+ samplingMode: classifiedAttempt.samplingMode,
6360
+ averageColorDistance: classified.averageColorDistance,
6361
+ averageCellConfidence: classified.averageCellConfidence,
6362
+ minimumCellConfidence: classified.minimumCellConfidence,
6363
+ lowConfidenceCells: classified.lowConfidenceCells
6364
+ });
6365
+ accepted = true;
6366
+ break;
6367
+ } catch {
6368
+ // Try the next cell encoding/classifier/profile.
6369
+ }
6370
+ }
6371
+ if (accepted) break;
4400
6372
  }
4401
6373
  } catch {
4402
6374
  // Try a narrower centre sample, then next version.
@@ -4410,6 +6382,366 @@ function tryAxisAlignedScan(imageData, options) {
4410
6382
  return candidates[0] ?? null;
4411
6383
  }
4412
6384
 
6385
+
6386
+
6387
+ const STRESS_PROFILES = Object.freeze([
6388
+ Object.freeze({ id: "clean", label: "Clean", type: "clean", severity: 0, weight: 2 }),
6389
+ Object.freeze({ id: "blur", label: "Blur", type: "blur", severity: 0.42, weight: 1 }),
6390
+ Object.freeze({ id: "dark", label: "Low brightness", type: "brightness-low", severity: 0.48, weight: 1 }),
6391
+ Object.freeze({ id: "bright", label: "High exposure", type: "brightness-high", severity: 0.34, weight: 1 }),
6392
+ Object.freeze({ id: "shadow", label: "Uneven shadow", type: "shadow", severity: 0.55, weight: 1.2 }),
6393
+ Object.freeze({ id: "contrast", label: "Contrast loss", type: "contrast-loss", severity: 0.42, weight: 1 }),
6394
+ Object.freeze({ id: "perspective", label: "Perspective", type: "perspective", severity: 0.42, weight: 1.3 }),
6395
+ Object.freeze({ id: "jpeg", label: "JPEG-like compression", type: "jpeg", severity: 0.50, weight: 0.8 }),
6396
+ Object.freeze({ id: "downscale", label: "Downscale", type: "downscale", severity: 0.52, weight: 0.9 })
6397
+ ]);
6398
+
6399
+ function cloneImageDataLike(imageData) {
6400
+ return { width: imageData.width, height: imageData.height, data: new Uint8ClampedArray(imageData.data) };
6401
+ }
6402
+
6403
+ function sampleRgbaBilinear(imageData, x, y) {
6404
+ const { width, height, data } = imageData;
6405
+ const cx = clampNumber(x, 0, width - 1);
6406
+ const cy = clampNumber(y, 0, height - 1);
6407
+ const x0 = Math.floor(cx), y0 = Math.floor(cy);
6408
+ const x1 = Math.min(width - 1, x0 + 1), y1 = Math.min(height - 1, y0 + 1);
6409
+ const tx = cx - x0, ty = cy - y0;
6410
+ const get = (xx, yy, channel) => data[(yy * width + xx) * 4 + channel];
6411
+ const out = [0, 0, 0, 255];
6412
+ for (let ch = 0; ch < 4; ch++) {
6413
+ const a = get(x0, y0, ch) * (1 - tx) + get(x1, y0, ch) * tx;
6414
+ const b = get(x0, y1, ch) * (1 - tx) + get(x1, y1, ch) * tx;
6415
+ out[ch] = a * (1 - ty) + b * ty;
6416
+ }
6417
+ return out;
6418
+ }
6419
+
6420
+ function resizeImageDataLike(imageData, width, height) {
6421
+ width = Math.max(1, Math.round(width));
6422
+ height = Math.max(1, Math.round(height));
6423
+ const out = new Uint8ClampedArray(width * height * 4);
6424
+ for (let y = 0; y < height; y++) {
6425
+ for (let x = 0; x < width; x++) {
6426
+ const sx = (x + 0.5) * imageData.width / width - 0.5;
6427
+ const sy = (y + 0.5) * imageData.height / height - 0.5;
6428
+ const rgba = sampleRgbaBilinear(imageData, sx, sy);
6429
+ const p = (y * width + x) * 4;
6430
+ out[p] = rgba[0]; out[p + 1] = rgba[1]; out[p + 2] = rgba[2]; out[p + 3] = rgba[3];
6431
+ }
6432
+ }
6433
+ return { width, height, data: out };
6434
+ }
6435
+
6436
+ function boxBlurImage(imageData, radius) {
6437
+ radius = Math.max(1, Math.round(radius));
6438
+ const { width, height, data } = imageData;
6439
+ const horizontal = new Float32Array(data.length);
6440
+ const out = new Uint8ClampedArray(data.length);
6441
+ for (let y = 0; y < height; y++) {
6442
+ for (let x = 0; x < width; x++) {
6443
+ for (let ch = 0; ch < 3; ch++) {
6444
+ let sum = 0, count = 0;
6445
+ for (let dx = -radius; dx <= radius; dx++) {
6446
+ const xx = x + dx;
6447
+ if (xx < 0 || xx >= width) continue;
6448
+ sum += data[(y * width + xx) * 4 + ch]; count++;
6449
+ }
6450
+ horizontal[(y * width + x) * 4 + ch] = sum / count;
6451
+ }
6452
+ horizontal[(y * width + x) * 4 + 3] = 255;
6453
+ }
6454
+ }
6455
+ for (let y = 0; y < height; y++) {
6456
+ for (let x = 0; x < width; x++) {
6457
+ const p = (y * width + x) * 4;
6458
+ for (let ch = 0; ch < 3; ch++) {
6459
+ let sum = 0, count = 0;
6460
+ for (let dy = -radius; dy <= radius; dy++) {
6461
+ const yy = y + dy;
6462
+ if (yy < 0 || yy >= height) continue;
6463
+ sum += horizontal[(yy * width + x) * 4 + ch]; count++;
6464
+ }
6465
+ out[p + ch] = sum / count;
6466
+ }
6467
+ out[p + 3] = 255;
6468
+ }
6469
+ }
6470
+ return { width, height, data: out };
6471
+ }
6472
+
6473
+ function perspectiveStress(imageData, severity) {
6474
+ const { width, height } = imageData;
6475
+ const inset = Math.min(width, height) * (0.025 + 0.11 * severity);
6476
+ const source = [{ x: 0, y: 0 }, { x: width - 1, y: 0 }, { x: width - 1, y: height - 1 }, { x: 0, y: height - 1 }];
6477
+ const destination = [
6478
+ { x: inset * 1.15, y: inset * 0.25 },
6479
+ { x: width - 1 - inset * 0.25, y: inset },
6480
+ { x: width - 1 - inset * 1.0, y: height - 1 - inset * 0.35 },
6481
+ { x: inset * 0.20, y: height - 1 - inset * 0.95 }
6482
+ ];
6483
+ const inverse = computeHomography(source, destination);
6484
+ const out = new Uint8ClampedArray(width * height * 4);
6485
+ out.fill(255);
6486
+ for (let y = 0; y < height; y++) {
6487
+ for (let x = 0; x < width; x++) {
6488
+ const src = projectPoint(inverse, x, y);
6489
+ if (src.x < 0 || src.y < 0 || src.x >= width || src.y >= height) continue;
6490
+ const rgba = sampleRgbaBilinear(imageData, src.x, src.y);
6491
+ const p = (y * width + x) * 4;
6492
+ out[p] = rgba[0]; out[p + 1] = rgba[1]; out[p + 2] = rgba[2]; out[p + 3] = 255;
6493
+ }
6494
+ }
6495
+ return { width, height, data: out };
6496
+ }
6497
+
6498
+ /** Apply one deterministic camera/print-style distortion to ImageData. */
6499
+ function applyStressDistortion(imageData, type, severity = 0.5, options = {}) {
6500
+ assert(imageData?.data && imageData.width && imageData.height, "applyStressDistortion requires ImageData-like input.");
6501
+ const s = clampNumber(Number(severity), 0, 1);
6502
+ if (type === "clean") return cloneImageDataLike(imageData);
6503
+ if (type === "blur") return boxBlurImage(imageData, 1 + s * 2.2);
6504
+ if (type === "perspective") return perspectiveStress(imageData, s);
6505
+ if (type === "downscale") {
6506
+ const scale = Math.max(0.20, 1 - s * 0.72);
6507
+ const small = resizeImageDataLike(imageData, imageData.width * scale, imageData.height * scale);
6508
+ return resizeImageDataLike(small, imageData.width, imageData.height);
6509
+ }
6510
+
6511
+ const out = cloneImageDataLike(imageData);
6512
+ const { width, height, data } = out;
6513
+ const quantStep = 4 + Math.round(s * 30);
6514
+ for (let y = 0; y < height; y++) {
6515
+ for (let x = 0; x < width; x++) {
6516
+ const p = (y * width + x) * 4;
6517
+ for (let ch = 0; ch < 3; ch++) {
6518
+ let value = data[p + ch];
6519
+ if (type === "brightness-low") value *= 1 - s * 0.52;
6520
+ else if (type === "brightness-high") value = value + (255 - value) * s * 0.48;
6521
+ else if (type === "contrast-loss") value = 128 + (value - 128) * (1 - s * 0.48);
6522
+ else if (type === "shadow") {
6523
+ const nx = x / Math.max(1, width - 1);
6524
+ const ny = y / Math.max(1, height - 1);
6525
+ const shadow = clampNumber((nx * 0.65 + ny * 0.35 - 0.18) / 0.82, 0, 1);
6526
+ value *= 1 - shadow * s * 0.62;
6527
+ } else if (type === "jpeg") {
6528
+ value = Math.round(value / quantStep) * quantStep;
6529
+ const block = ((Math.floor(x / 8) + Math.floor(y / 8)) & 1) ? 1 : -1;
6530
+ value += block * s * 3;
6531
+ } else if (type === "warm") {
6532
+ if (ch === 0) value *= 1 + s * 0.12;
6533
+ if (ch === 2) value *= 1 - s * 0.22;
6534
+ }
6535
+ data[p + ch] = clampNumber(Math.round(value), 0, 255);
6536
+ }
6537
+ data[p + 3] = 255;
6538
+ }
6539
+ }
6540
+ return out;
6541
+ }
6542
+
6543
+ function runImageStressTest(imageData, expected = {}, options = {}) {
6544
+ const profiles = options.profiles ?? STRESS_PROFILES;
6545
+ const results = [];
6546
+ let passedWeight = 0;
6547
+ let totalWeight = 0;
6548
+ for (const profile of profiles) {
6549
+ const weight = Number(profile.weight ?? 1);
6550
+ totalWeight += weight;
6551
+ const distorted = applyStressDistortion(imageData, profile.type ?? profile.id, profile.severity ?? 0.5, profile);
6552
+ const started = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
6553
+ try {
6554
+ const decoded = scanImageData(distorted, {
6555
+ minVersion: expected.version ?? options.minVersion ?? MIN_VERSION,
6556
+ maxVersion: expected.version ?? options.maxVersion ?? MAX_VERSION,
6557
+ debug: false
6558
+ });
6559
+ const matches = expected.crc32 == null || decoded.crc32 === expected.crc32;
6560
+ if (matches) passedWeight += weight;
6561
+ results.push({
6562
+ id: profile.id,
6563
+ label: profile.label,
6564
+ type: profile.type,
6565
+ severity: profile.severity,
6566
+ passed: matches,
6567
+ confidence: decoded.confidence ?? decoded.diagnostics?.confidence ?? null,
6568
+ correctedSymbols: decoded.correctedSymbols ?? 0,
6569
+ elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
6570
+ image: options.includeImages ? distorted : undefined,
6571
+ error: matches ? null : "Decoded payload did not match expected CRC."
6572
+ });
6573
+ } catch (error) {
6574
+ results.push({
6575
+ id: profile.id,
6576
+ label: profile.label,
6577
+ type: profile.type,
6578
+ severity: profile.severity,
6579
+ passed: false,
6580
+ confidence: 0,
6581
+ correctedSymbols: null,
6582
+ elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
6583
+ image: options.includeImages ? distorted : undefined,
6584
+ error: error.message
6585
+ });
6586
+ }
6587
+ }
6588
+ const passPercent = totalWeight ? passedWeight / totalWeight * 100 : 0;
6589
+ const averageConfidence = results.filter((r) => r.passed && Number.isFinite(r.confidence)).reduce((sum, r, _, arr) => sum + r.confidence / arr.length, 0);
6590
+ const score = clampNumber(passPercent * 0.86 + averageConfidence * 100 * 0.14, 0, 100);
6591
+ const rating = score >= 90 ? "Excellent" : score >= 75 ? "Good" : score >= 50 ? "Risky" : "Likely unscannable";
6592
+ return {
6593
+ score,
6594
+ rating,
6595
+ passPercent,
6596
+ passed: results.filter((r) => r.passed).length,
6597
+ total: results.length,
6598
+ averageConfidence,
6599
+ results
6600
+ };
6601
+ }
6602
+
6603
+ /** Render and run the standard scanability torture suite. */
6604
+ function assessScanability(code, renderOptions = {}, options = {}) {
6605
+ assert(code?.matrix, "assessScanability expects an encoded QuadQR object.");
6606
+ const testImageSize = Math.max(240, Math.min(Number(options.testImageSize ?? 480), Number(renderOptions.imageSize ?? 720)));
6607
+ const image = renderToImageData(code, { ...renderOptions, imageSize: testImageSize });
6608
+ const report = runImageStressTest(image, { version: code.version, crc32: code.crc32 }, options);
6609
+ const autoLogoSize = renderOptions.logo ? estimateSafeLogoSize(code, renderOptions) : null;
6610
+ return {
6611
+ ...report,
6612
+ version: code.version,
6613
+ eccLevel: code.eccLevel,
6614
+ utilization: code.utilization,
6615
+ testImageSize,
6616
+ autoLogoSize,
6617
+ recommendations: [
6618
+ ...(report.score < 75 ? ["Increase ECC, reduce logo size, use Classic style, or increase output/physical size."] : []),
6619
+ ...(Number(renderOptions.quietZone ?? 4) < 4 ? ["Use a quiet zone of at least 4 modules for robust camera/print scanning."] : []),
6620
+ ...(renderOptions.logo && autoLogoSize != null ? [`Auto-safe logo estimate: ${(autoLogoSize * 100).toFixed(1)}% of the symbol width.`] : []),
6621
+ ...(normalizeRenderMode(renderOptions.mode ?? renderOptions.renderMode ?? "screen") === "print" ? getPrintGuidance(code, renderOptions).recommendations : [])
6622
+ ]
6623
+ };
6624
+ }
6625
+
6626
+ function paletteConfidence(observedPalette) {
6627
+ if (!observedPalette) return 0.5;
6628
+ const values = ["black", "white", "red", "green", "blue"].map((name) => observedPalette[name]).filter(Boolean);
6629
+ if (values.length < 5) return 0.5;
6630
+ let minDistance = Infinity;
6631
+ for (let i = 0; i < values.length; i++) {
6632
+ for (let j = i + 1; j < values.length; j++) {
6633
+ const a = values[i], b = values[j];
6634
+ minDistance = Math.min(minDistance, Math.hypot(a.r - b.r, a.g - b.g, a.b - b.b));
6635
+ }
6636
+ }
6637
+ return clampNumber((minDistance - 28) / 120, 0, 1);
6638
+ }
6639
+
6640
+ function scannerDiagnostics(result, context = {}, options = {}) {
6641
+ const info = result ? getVersionInfo(result.version, { ecc: result.eccLevel }) : null;
6642
+ const correctableTotal = result && info
6643
+ ? Number(info.correctableHeaderSymbols ?? 0) + Number(result.eccBlocks ?? 0) * Number(result.correctableSymbolsPerBlock ?? 0)
6644
+ : 0;
6645
+ const eccUtilization = correctableTotal > 0 ? clampNumber((result.correctedSymbols ?? 0) / correctableTotal, 0, 1) : 0;
6646
+ const structureConfidence = clampNumber(
6647
+ result?.structureScore ?? context.observations?.at(-1)?.structureScore ?? (result ? 0.92 : 0), 0, 1
6648
+ );
6649
+ const cellConfidence = clampNumber(result?.averageCellConfidence ?? 0.75, 0, 1);
6650
+ const geometryConfidence = result
6651
+ ? clampNumber(result.perspectiveCorrected ? Math.max(0.55, structureConfidence) : Math.max(0.65, structureConfidence * 0.9), 0, 1)
6652
+ : (context.geometry?.length ? 0.45 : 0);
6653
+ const calibrationConfidence = result
6654
+ ? clampNumber(result.colorCalibrated ? paletteConfidence(result.observedPalette) : 0.55, 0, 1)
6655
+ : 0;
6656
+ const confidence = result
6657
+ ? clampNumber(cellConfidence * 0.50 + structureConfidence * 0.20 + geometryConfidence * 0.15 + calibrationConfidence * 0.15 - eccUtilization * 0.12, 0, 1)
6658
+ : 0;
6659
+ const geometryCandidates = (context.geometry ?? []).slice(0, 8).map((geometry) => ({
6660
+ version: geometry.version,
6661
+ size: geometry.size,
6662
+ score: geometry.score,
6663
+ estimatedSize: geometry.estimatedSize,
6664
+ alignmentScore: geometry.alignmentScore,
6665
+ alignmentGridScore: geometry.alignmentGridScore,
6666
+ corners: geometry.corners ?? geometry.quad ?? null,
6667
+ finders: geometry.finders ?? null
6668
+ }));
6669
+ const observations = context.observations ?? [];
6670
+ const latest = observations.at(-1) ?? null;
6671
+ const stages = {
6672
+ finderDetection: geometryCandidates.length > 0 || Boolean(result && !result.perspectiveCorrected),
6673
+ geometry: geometryCandidates.length > 0 || Boolean(result && !result.perspectiveCorrected),
6674
+ sampling: observations.length > 0 || Boolean(result),
6675
+ colorClassification: observations.length > 0 || Boolean(result),
6676
+ ecc: Boolean(result),
6677
+ crc: Boolean(result),
6678
+ payload: Boolean(result)
6679
+ };
6680
+ let failedStage = null;
6681
+ if (!result) {
6682
+ failedStage = !stages.finderDetection ? "finder-detection"
6683
+ : !stages.sampling ? "perspective-geometry"
6684
+ : "color-classification-or-ecc";
6685
+ }
6686
+ return {
6687
+ confidence,
6688
+ cellConfidence,
6689
+ structureConfidence,
6690
+ geometryConfidence,
6691
+ calibrationConfidence,
6692
+ eccUtilization,
6693
+ correctedErrors: result?.correctedSymbols ?? 0,
6694
+ erasureSymbols: result?.erasureSymbols ?? 0,
6695
+ lowConfidenceCells: result?.lowConfidenceCells ?? latest?.lowConfidenceCells ?? null,
6696
+ stages,
6697
+ failedStage,
6698
+ geometryCandidates,
6699
+ vision: context.vision ?? null,
6700
+ sampled: latest ? {
6701
+ version: latest.version,
6702
+ samplingMode: latest.samplingMode,
6703
+ colorNormalization: latest.colorNormalization,
6704
+ structureScore: latest.structureScore,
6705
+ averageCellConfidence: latest.averageCellConfidence,
6706
+ observedPalette: latest.observedPalette ?? null,
6707
+ matrix: options.debugMatrices === false ? undefined : latest.matrix,
6708
+ confidenceMatrix: options.debugMatrices === false ? undefined : latest.confidence
6709
+ } : null
6710
+ };
6711
+ }
6712
+
6713
+ function decorateScanResult(result, context, options) {
6714
+ const diagnostics = scannerDiagnostics(result, context, options);
6715
+ return {
6716
+ ...result,
6717
+ confidence: diagnostics.confidence,
6718
+ geometryConfidence: diagnostics.geometryConfidence,
6719
+ calibrationConfidence: diagnostics.calibrationConfidence,
6720
+ structureConfidence: diagnostics.structureConfidence,
6721
+ correctedErrors: result.correctedSymbols ?? 0,
6722
+ eccUtilization: diagnostics.eccUtilization,
6723
+ diagnostics: options.debug ? diagnostics : {
6724
+ confidence: diagnostics.confidence,
6725
+ geometryConfidence: diagnostics.geometryConfidence,
6726
+ calibrationConfidence: diagnostics.calibrationConfidence,
6727
+ structureConfidence: diagnostics.structureConfidence,
6728
+ eccUtilization: diagnostics.eccUtilization,
6729
+ correctedErrors: diagnostics.correctedErrors,
6730
+ erasureSymbols: diagnostics.erasureSymbols
6731
+ }
6732
+ };
6733
+ }
6734
+
6735
+ /** Non-throwing scanner debug API with detailed stage diagnostics. */
6736
+ function debugScanImageData(imageData, options = {}) {
6737
+ try {
6738
+ const result = scanImageData(imageData, { ...options, debug: true });
6739
+ return { ok: true, result, debug: result.diagnostics };
6740
+ } catch (error) {
6741
+ return { ok: false, error: error.message, debug: error.debug ?? null };
6742
+ }
6743
+ }
6744
+
4413
6745
  function scanImageData(imageData, options = {}) {
4414
6746
  assert(imageData && imageData.data && imageData.width && imageData.height, "Valid ImageData is required.");
4415
6747
  const minVersion = options.minVersion ?? MIN_VERSION;
@@ -4418,11 +6750,49 @@ function scanImageData(imageData, options = {}) {
4418
6750
  validateVersion(maxVersion);
4419
6751
 
4420
6752
  const geometryCollector = [];
4421
- const scanOptions = { ...options, _geometryCollector: geometryCollector };
6753
+ const observationCollector = options.debug ? [] : (Array.isArray(options._observationCollector) ? options._observationCollector : []);
6754
+ const visionDiagnostics = options.debug ? { passes: [] } : (options._visionDiagnostics ?? null);
6755
+ const debugContext = { geometry: geometryCollector, observations: observationCollector, vision: visionDiagnostics };
6756
+ const scanOptions = {
6757
+ ...options,
6758
+ _geometryCollector: geometryCollector,
6759
+ _observationCollector: observationCollector,
6760
+ _visionDiagnostics: visionDiagnostics
6761
+ };
4422
6762
 
4423
6763
  if (options.perspective !== false) {
4424
6764
  const perspective = tryPerspectiveScan(imageData, scanOptions);
4425
- if (perspective) return perspective;
6765
+ if (perspective) return decorateScanResult(perspective, debugContext, options);
6766
+ }
6767
+
6768
+ // Triangle16 has half-cell color regions, so geometry that is adequate for
6769
+ // solid RGBW cells can still be off by a few tenths of a module and mix the
6770
+ // two triangle colors. If normal geometry was found but no payload decoded,
6771
+ // retry geometry once with finer alignment localization. This stays off the
6772
+ // clean/normal camera fast path.
6773
+ if (
6774
+ options.perspective !== false &&
6775
+ options.preciseAlignmentRecovery !== false &&
6776
+ !options._preciseAlignmentRecovery &&
6777
+ geometryCollector.length > 0
6778
+ ) {
6779
+ try {
6780
+ const precise = tryPerspectiveScan(imageData, {
6781
+ ...scanOptions,
6782
+ preciseAlignment: true,
6783
+ _preciseAlignmentRecovery: true,
6784
+ _diagnosticLabel: "precise-alignment"
6785
+ });
6786
+ if (precise) {
6787
+ return decorateScanResult({
6788
+ ...precise,
6789
+ geometryRefined: true,
6790
+ recoveryMode: precise.recoveryMode ?? "precise-alignment"
6791
+ }, debugContext, options);
6792
+ }
6793
+ } catch {
6794
+ // Continue to color recovery and axis-aligned fallback.
6795
+ }
4426
6796
  }
4427
6797
 
4428
6798
  // If the normal scanner could see QuadQR geometry but could not decode the
@@ -4449,12 +6819,12 @@ function scanImageData(imageData, options = {}) {
4449
6819
  _geometryCollector: []
4450
6820
  });
4451
6821
  if (recovered) {
4452
- return {
6822
+ return decorateScanResult({
4453
6823
  ...recovered,
4454
6824
  autoEnhanced: true,
4455
6825
  recoveryMode: "auto-tone-contrast-color",
4456
6826
  originalColorNormalization: recovered.colorNormalization
4457
- };
6827
+ }, debugContext, options);
4458
6828
  }
4459
6829
  } catch {
4460
6830
  // Continue to the axis-aligned fallback.
@@ -4463,7 +6833,7 @@ function scanImageData(imageData, options = {}) {
4463
6833
 
4464
6834
  if (options.axisAlignedFallback !== false) {
4465
6835
  const axis = tryAxisAlignedScan(imageData, scanOptions);
4466
- if (axis) return axis;
6836
+ if (axis) return decorateScanResult(axis, debugContext, options);
4467
6837
  }
4468
6838
 
4469
6839
  // Static images sometimes have such low contrast that locator detection itself
@@ -4490,20 +6860,22 @@ function scanImageData(imageData, options = {}) {
4490
6860
  };
4491
6861
  if (options.perspective !== false) {
4492
6862
  const perspective = tryPerspectiveScan(enhanced, recoveryOptions);
4493
- if (perspective) return { ...perspective, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" };
6863
+ if (perspective) return decorateScanResult({ ...perspective, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
4494
6864
  }
4495
6865
  if (options.axisAlignedFallback !== false) {
4496
6866
  const axis = tryAxisAlignedScan(enhanced, recoveryOptions);
4497
- if (axis) return { ...axis, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" };
6867
+ if (axis) return decorateScanResult({ ...axis, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
4498
6868
  }
4499
6869
  } catch {
4500
6870
  // Fall through to the normal scan failure below.
4501
6871
  }
4502
6872
  }
4503
6873
 
4504
- throw new Error(
6874
+ const error = new Error(
4505
6875
  "No valid QuadQR code found. Try better lighting, fill more of the frame, keep all locator patterns visible, or use a less blurred image."
4506
6876
  );
6877
+ if (options.debug) error.debug = scannerDiagnostics(null, debugContext, options);
6878
+ throw error;
4507
6879
  }
4508
6880
 
4509
6881
  async function scanFile(file, options = {}) {
@@ -4826,6 +7198,7 @@ async function startCameraScanner(video, options = {}) {
4826
7198
 
4827
7199
  const scanInterval = Math.max(80, options.scanInterval ?? 180);
4828
7200
  const scratchCanvas = document.createElement("canvas");
7201
+ const highResolutionCanvas = document.createElement("canvas");
4829
7202
  const multiFrameEnabled = options.multiFrame !== false;
4830
7203
  const multiFrameWindow = Math.max(2, Math.min(8, Math.round(options.multiFrameWindow ?? 4)));
4831
7204
  const multiFrameMinFrames = Math.max(2, Math.min(multiFrameWindow, Math.round(options.multiFrameMinFrames ?? 2)));
@@ -4833,6 +7206,12 @@ async function startCameraScanner(video, options = {}) {
4833
7206
  const cameraAutoColorEvery = Math.max(1, Math.round(options.cameraAutoColorEvery ?? 1));
4834
7207
  const cameraAutoEnhanceEvery = Math.max(1, Math.round(options.cameraAutoEnhanceEvery ?? 2));
4835
7208
  const cameraFinderRecoveryEvery = Math.max(1, Math.round(options.cameraFinderRecoveryEvery ?? 2));
7209
+ const cameraHighResolutionEvery = Math.max(1, Math.round(options.cameraHighResolutionEvery ?? 2));
7210
+ const baseCameraMaxDimension = Math.max(480, Math.round(options.maxDimension ?? 1080));
7211
+ const cameraHighResolutionMaxDimension = Math.max(
7212
+ baseCameraMaxDimension,
7213
+ Math.round(options.cameraHighResolutionMaxDimension ?? 1600)
7214
+ );
4836
7215
  let missStreak = 0;
4837
7216
  let stopped = false;
4838
7217
  let busy = false;
@@ -4880,7 +7259,7 @@ async function startCameraScanner(video, options = {}) {
4880
7259
 
4881
7260
  const scanNow = () => scanVideoFrame(video, {
4882
7261
  ...options,
4883
- maxDimension: options.maxDimension ?? 1080,
7262
+ maxDimension: baseCameraMaxDimension,
4884
7263
  canvas: scratchCanvas
4885
7264
  });
4886
7265
 
@@ -4947,7 +7326,7 @@ async function startCameraScanner(video, options = {}) {
4947
7326
  busy = true;
4948
7327
  frameNumber++;
4949
7328
  const observations = [];
4950
- const frameDiagnostics = diagnosticsEnabled ? {} : null;
7329
+ const frameDiagnostics = {};
4951
7330
  const capturedFrame = {};
4952
7331
  const frameStarted = nowMs();
4953
7332
  let allowAutoEnhance = false;
@@ -4975,7 +7354,7 @@ async function startCameraScanner(video, options = {}) {
4975
7354
  // If geometry exists, the rectified QR-only pixel enhancer is both
4976
7355
  // stronger and much cheaper than reprocessing the whole live frame.
4977
7356
  fullFrameAutoEnhanceRecovery: options.fullFrameAutoEnhanceRecovery ?? false,
4978
- maxDimension: options.maxDimension ?? 1080,
7357
+ maxDimension: baseCameraMaxDimension,
4979
7358
  canvas: scratchCanvas,
4980
7359
  _capturedFrame: capturedFrame,
4981
7360
  _observationCollector: observations,
@@ -5016,6 +7395,71 @@ async function startCameraScanner(video, options = {}) {
5016
7395
  ...frameDiagnostics
5017
7396
  });
5018
7397
 
7398
+ // Geometry-aware high-resolution retry. A dense QuadQR can look large
7399
+ // enough in the preview while each individual module has become too
7400
+ // small after the normal 1080 px scanner cap. If the fast pass already
7401
+ // sees at least two convincing finders, spend one bounded retry on a
7402
+ // higher-resolution copy of the visible camera ROI. Empty frames and
7403
+ // ordinary small codes never pay this cost.
7404
+ const shouldTryHighResolution = options.cameraHighResolutionRecovery !== false &&
7405
+ cameraHighResolutionMaxDimension > baseCameraMaxDimension &&
7406
+ (frameDiagnostics?.finderCount ?? 0) >= (options.cameraHighResolutionMinFinders ?? 2) &&
7407
+ ((missStreak - 1) % cameraHighResolutionEvery === 0);
7408
+ if (shouldTryHighResolution) {
7409
+ const highResolutionObservations = [];
7410
+ const highResolutionDiagnostics = {};
7411
+ const highResolutionCapturedFrame = {};
7412
+ emitDiagnostic({
7413
+ type: "method",
7414
+ state: "trying",
7415
+ method: "high-resolution-geometry-recovery",
7416
+ message: `Dense geometry detected · retrying at up to ${cameraHighResolutionMaxDimension}px`,
7417
+ ...frameDiagnostics
7418
+ });
7419
+ try {
7420
+ const recoveryStarted = nowMs();
7421
+ const recovered = scanVideoFrame(video, {
7422
+ ...options,
7423
+ _diagnosticLabel: "high-resolution-geometry-recovery",
7424
+ finderRecovery: true,
7425
+ autoEnhanceRecovery: false,
7426
+ fullFrameAutoEnhanceRecovery: false,
7427
+ maxDimension: cameraHighResolutionMaxDimension,
7428
+ canvas: highResolutionCanvas,
7429
+ _capturedFrame: highResolutionCapturedFrame,
7430
+ _observationCollector: highResolutionObservations,
7431
+ _frameDiagnostics: highResolutionDiagnostics
7432
+ });
7433
+ const recoveryElapsedMs = nowMs() - recoveryStarted;
7434
+ emitDiagnostic({
7435
+ type: "success",
7436
+ state: "decoded",
7437
+ method: "high-resolution-geometry-recovery",
7438
+ elapsedMs: recoveryElapsedMs,
7439
+ message: `High-resolution retry decoded v${recovered.version} · ${Math.round(recoveryElapsedMs)} ms`,
7440
+ ...(highResolutionDiagnostics ?? frameDiagnostics)
7441
+ });
7442
+ missStreak = 0;
7443
+ observationHistory.clear();
7444
+ if (emitResult({
7445
+ ...recovered,
7446
+ cameraHighResolutionRecovery: true,
7447
+ cameraProgressiveRecovery: true,
7448
+ recoveryMode: recovered.recoveryMode ?? "high-resolution-geometry-recovery"
7449
+ }, highResolutionCapturedFrame, highResolutionDiagnostics ?? frameDiagnostics)) return;
7450
+ } catch (highResolutionError) {
7451
+ observations.push(...highResolutionObservations);
7452
+ emitDiagnostic({
7453
+ type: "method",
7454
+ state: "failed",
7455
+ method: "high-resolution-geometry-recovery",
7456
+ message: `High-resolution geometry retry did not decode${highResolutionDiagnostics?.finderCount != null ? ` · ${highResolutionDiagnostics.finderCount} finder(s)` : ""}`,
7457
+ error: highResolutionError?.message ?? String(highResolutionError),
7458
+ ...(highResolutionDiagnostics ?? frameDiagnostics)
7459
+ });
7460
+ }
7461
+ }
7462
+
5019
7463
  // The user's real phone-camera case is dominated by color cast before
5020
7464
  // finder detection: Photoshop Auto Color alone makes the same live QR
5021
7465
  // immediately detectable. Retry the exact captured frame with a cheap
@@ -5320,11 +7764,14 @@ const internals = Object.freeze({
5320
7764
  HEADER_CODEWORD_CELLS,
5321
7765
  COMPACT_HEADER_CODEWORD_CELLS,
5322
7766
  CELLS_PER_BYTE,
7767
+ RGBW_CELLS_PER_BYTE,
7768
+ TRIANGLE16_CELLS_PER_BYTE,
7769
+ TRIANGLE16_FLAG,
5323
7770
  TEXT_FLAG,
5324
7771
  SECURE_FLAG
5325
7772
  });
5326
7773
 
5327
- return { installCrc32Accelerator, crc32, getVersionInfo, encodeText, encodeBytes, encodeSecureText, encodeSecureBytes, decryptDecoded, decodeMatrix, renderToCanvas, renderToImageData, scanImageData, scanFile, scanVideoFrame, startCameraScanner, rectifyDetectedCode, rotateMatrix, FORMAT_VERSION, MIN_VERSION, MAX_VERSION, DEFAULT_ECC_LEVEL, CELL, DEFAULT_PALETTE, RENDER_STYLES, ECC_LEVELS, internals, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex };
7774
+ 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, CELL_ENCODINGS, PRINT_PALETTE, COMPRESSION_MODES, SIGNATURE_ALGORITHMS, CELL, DEFAULT_PALETTE, RENDER_STYLES, ECC_LEVELS, STRESS_PROFILES, internals, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex };
5328
7775
  })();
5329
7776
  const QuadQR = __qqr_quadqr_js;
5330
7777