quadqr-js 1.2.0 → 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, computeHomography, projectPoint, 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
@@ -2262,6 +2700,12 @@ const RENDER_MODES = Object.freeze({
2262
2700
  PRINT: "print"
2263
2701
  });
2264
2702
 
2703
+ const CELL_ENCODINGS = Object.freeze({
2704
+ RGBW: "rgbw",
2705
+ TRIANGLE16: "triangle16"
2706
+ });
2707
+
2708
+
2265
2709
  // Print-safe defaults deliberately use darker, more ink-tolerant primaries and
2266
2710
  // a true white background. Applications can still override individual colors.
2267
2711
  const PRINT_PALETTE = Object.freeze({
@@ -2319,8 +2763,10 @@ const MAGIC = new Uint8Array([0x51, 0x51, 0x52, 0x57]); // QQRW (QuadQR RGBW)
2319
2763
  const HEADER_BYTES = 10;
2320
2764
  const HEADER_RS_PARITY = 8;
2321
2765
  const HEADER_CODEWORD_BYTES = HEADER_BYTES + HEADER_RS_PARITY;
2322
- const CELLS_PER_BYTE = 4;
2323
- 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;
2324
2770
 
2325
2771
  // Version 1 is a deliberately compact small-symbol profile. A 21x21 matrix
2326
2772
  // cannot afford the normal 18-byte protected header plus a 24-byte M body
@@ -2332,7 +2778,7 @@ const COMPACT_HEADER_MAGIC = 0xc3;
2332
2778
  const COMPACT_HEADER_BYTES = 4;
2333
2779
  const COMPACT_HEADER_RS_PARITY = 4;
2334
2780
  const COMPACT_HEADER_CODEWORD_BYTES = COMPACT_HEADER_BYTES + COMPACT_HEADER_RS_PARITY;
2335
- 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;
2336
2782
  const COMPACT_ECC_LEVELS = Object.freeze({
2337
2783
  L: Object.freeze({ paritySymbols: 4, correctableSymbolsPerBlock: 2 }),
2338
2784
  M: Object.freeze({ paritySymbols: 8, correctableSymbolsPerBlock: 4 }),
@@ -2345,6 +2791,7 @@ const TEXT_FLAG = 1;
2345
2791
  const SECURE_FLAG = 1 << 3;
2346
2792
  const EXTENDED_PAYLOAD_FLAG = 1 << 4;
2347
2793
  const SIGNED_FLAG = 1 << 5;
2794
+ const TRIANGLE16_FLAG = 1 << 6;
2348
2795
 
2349
2796
  // Internal payload extension envelope. This is deliberately not a public
2350
2797
  // payload mode or content-type system. It exists only when compression or
@@ -2407,6 +2854,45 @@ function normalizeEccLevel(level = DEFAULT_ECC_LEVEL) {
2407
2854
  return value;
2408
2855
  }
2409
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
+
2410
2896
  function concatBytes(...arrays) {
2411
2897
  const total = arrays.reduce((sum, item) => sum + item.length, 0);
2412
2898
  const out = new Uint8Array(total);
@@ -2834,8 +3320,19 @@ function crc32(bytes) {
2834
3320
  return (crc ^ 0xffffffff) >>> 0;
2835
3321
  }
2836
3322
 
2837
- function bytesToCells(bytes) {
2838
- 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);
2839
3336
  let cursor = 0;
2840
3337
  for (const byte of bytes) {
2841
3338
  out[cursor++] = (byte >>> 6) & 0b11;
@@ -2846,11 +3343,36 @@ function bytesToCells(bytes) {
2846
3343
  return out;
2847
3344
  }
2848
3345
 
2849
- function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYTE)) {
2850
- assert(cells.length >= byteCount * CELLS_PER_BYTE, "Not enough RGBW cells to rebuild bytes.");
2851
- const out = new Uint8Array(byteCount);
2852
- for (let i = 0; i < byteCount; i++) {
2853
- const offset = i * CELLS_PER_BYTE;
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;
2854
3376
  const a = cells[offset];
2855
3377
  const b = cells[offset + 1];
2856
3378
  const c = cells[offset + 2];
@@ -2863,14 +3385,31 @@ function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYT
2863
3385
  return out;
2864
3386
  }
2865
3387
 
2866
- function flagsFor(text, eccLevel, secure = false, extended = false, signed = 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) {
2867
3401
  return (text ? TEXT_FLAG : 0) |
2868
3402
  (secure ? SECURE_FLAG : 0) |
2869
3403
  (extended ? EXTENDED_PAYLOAD_FLAG : 0) |
2870
3404
  (signed ? SIGNED_FLAG : 0) |
3405
+ (normalizeCellEncoding(cellEncoding) === CELL_ENCODINGS.TRIANGLE16 ? TRIANGLE16_FLAG : 0) |
2871
3406
  ((ECC_LEVELS[eccLevel].id << ECC_SHIFT) & ECC_MASK);
2872
3407
  }
2873
3408
 
3409
+ function cellEncodingFromFlags(flags) {
3410
+ return (flags & TRIANGLE16_FLAG) !== 0 ? CELL_ENCODINGS.TRIANGLE16 : CELL_ENCODINGS.RGBW;
3411
+ }
3412
+
2874
3413
  function eccFromFlags(flags) {
2875
3414
  const id = (flags & ECC_MASK) >> ECC_SHIFT;
2876
3415
  const name = ECC_BY_ID[id];
@@ -3120,7 +3659,8 @@ function maskValue(row, col, maskId) {
3120
3659
  }
3121
3660
  }
3122
3661
 
3123
- function makePaddingCells(count, seed) {
3662
+ function makePaddingCells(count, seed, cellEncoding = CELL_ENCODINGS.RGBW) {
3663
+ const encoding = normalizeCellEncoding(cellEncoding);
3124
3664
  let state = (seed >>> 0) || 0x6d2b79f5;
3125
3665
  const out = new Array(count);
3126
3666
  for (let i = 0; i < count; i++) {
@@ -3128,11 +3668,27 @@ function makePaddingCells(count, seed) {
3128
3668
  state ^= state >>> 17;
3129
3669
  state ^= state << 5;
3130
3670
  state >>>= 0;
3131
- out[i] = state & 3;
3671
+ out[i] = encoding === CELL_ENCODINGS.TRIANGLE16 ? (state & 0x0f) : (state & 3);
3132
3672
  }
3133
3673
  return out;
3134
3674
  }
3135
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
+
3136
3692
  const SPECTRAL_PERMUTATION_CACHE = new Map();
3137
3693
 
3138
3694
  function spectralPermutation(length, version) {
@@ -3161,7 +3717,15 @@ function spectralPermutation(length, version) {
3161
3717
  return permutation;
3162
3718
  }
3163
3719
 
3164
- 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);
3165
3729
  const matrix = cloneMatrix(layout.matrix);
3166
3730
  const permutation = spectralInterleaving
3167
3731
  ? spectralPermutation(layout.dataPositions.length, layout.version)
@@ -3170,16 +3734,37 @@ function applyData(layout, rawCells, maskId, spectralInterleaving = true) {
3170
3734
  for (let logicalIndex = 0; logicalIndex < layout.dataPositions.length; logicalIndex++) {
3171
3735
  const physicalIndex = permutation ? permutation[logicalIndex] : logicalIndex;
3172
3736
  const [row, col] = layout.dataPositions[physicalIndex];
3173
- 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
+ );
3174
3745
  }
3175
3746
  return matrix;
3176
3747
  }
3177
3748
 
3178
- function quaternaryPenalty(matrix, reserved) {
3749
+ function dataCellPenalty(matrix, reserved, cellEncoding = CELL_ENCODINGS.RGBW) {
3750
+ const encoding = normalizeCellEncoding(cellEncoding);
3179
3751
  const size = matrix.length;
3180
3752
  let penalty = 0;
3181
- const counts = [0, 0, 0, 0];
3182
- 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
+ };
3183
3768
 
3184
3769
  for (let r = 0; r < size; r++) {
3185
3770
  let previous = null;
@@ -3190,9 +3775,8 @@ function quaternaryPenalty(matrix, reserved) {
3190
3775
  run = 0;
3191
3776
  continue;
3192
3777
  }
3193
- const value = matrix[r][c];
3194
- counts[value]++;
3195
- dataCount++;
3778
+ const value = symbolAt(r, c);
3779
+ addColors(value);
3196
3780
  if (value === previous) {
3197
3781
  run++;
3198
3782
  if (run >= 4) penalty += 2;
@@ -3212,7 +3796,7 @@ function quaternaryPenalty(matrix, reserved) {
3212
3796
  run = 0;
3213
3797
  continue;
3214
3798
  }
3215
- const value = matrix[r][c];
3799
+ const value = symbolAt(r, c);
3216
3800
  if (value === previous) {
3217
3801
  run++;
3218
3802
  if (run >= 4) penalty += 2;
@@ -3223,9 +3807,9 @@ function quaternaryPenalty(matrix, reserved) {
3223
3807
  }
3224
3808
  }
3225
3809
 
3226
- if (dataCount > 0) {
3227
- const ideal = dataCount / 4;
3228
- 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;
3229
3813
  }
3230
3814
  return penalty;
3231
3815
  }
@@ -3254,7 +3838,7 @@ function deinterleaveBlocks(stream, blockLengths) {
3254
3838
  return blocks;
3255
3839
  }
3256
3840
 
3257
- function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
3841
+ function getBodyRsPlan(payloadLength, eccLevel, version = 2, cellEncoding = CELL_ENCODINGS.RGBW) {
3258
3842
  const ecc = getEffectiveEcc(version, eccLevel);
3259
3843
  const bodyByteCount = payloadLength + CRC_BYTES;
3260
3844
  const dataSymbols = bodyByteCount;
@@ -3276,25 +3860,27 @@ function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
3276
3860
  dataBlockLengths,
3277
3861
  codewordBlockLengths,
3278
3862
  encodedSymbols,
3279
- encodedCells: encodedSymbols * CELLS_PER_BYTE
3863
+ encodedCells: encodedSymbols * cellsPerByteForEncoding(cellEncoding)
3280
3864
  };
3281
3865
  }
3282
3866
 
3283
- function streamCellCount(payloadLength, eccLevel, version) {
3284
- 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;
3285
3870
  }
3286
3871
 
3287
- function streamFitsLayout(layout, eccLevel, payloadLength, version) {
3288
- 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;
3289
3874
  }
3290
3875
 
3291
- function getCapacityForLayout(layout, eccLevel, version) {
3292
- 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;
3293
3879
  let low = 0;
3294
- let high = Math.floor(layout.dataPositions.length / CELLS_PER_BYTE);
3880
+ let high = Math.floor(layout.dataPositions.length / cellsPerByteForEncoding(encoding));
3295
3881
  while (low < high) {
3296
3882
  const mid = Math.ceil((low + high) / 2);
3297
- if (streamFitsLayout(layout, eccLevel, mid, version)) low = mid;
3883
+ if (streamFitsLayout(layout, eccLevel, mid, version, encoding)) low = mid;
3298
3884
  else high = mid - 1;
3299
3885
  }
3300
3886
  return low;
@@ -3303,6 +3889,7 @@ function getCapacityForLayout(layout, eccLevel, version) {
3303
3889
  function getVersionInfo(version, options = {}) {
3304
3890
  validateVersion(version);
3305
3891
  const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
3892
+ const cellEncoding = cellEncodingFromOptions(options);
3306
3893
  const layout = createLayout(version);
3307
3894
  const headerPlan = getHeaderPlan(version);
3308
3895
  const effectiveEcc = getEffectiveEcc(version, eccLevel);
@@ -3312,8 +3899,8 @@ function getVersionInfo(version, options = {}) {
3312
3899
  eccLevel,
3313
3900
  size: layout.size,
3314
3901
  dataCells: layout.dataPositions.length,
3315
- theoreticalBits: layout.dataPositions.length * 2,
3316
- capacityBytes: getCapacityForLayout(layout, eccLevel, version),
3902
+ theoreticalBits: layout.dataPositions.length * (cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2),
3903
+ capacityBytes: getCapacityForLayout(layout, eccLevel, version, cellEncoding),
3317
3904
  headerCells: headerPlan.codewordCells,
3318
3905
  headerBytes: headerPlan.headerBytes,
3319
3906
  headerParitySymbols: headerPlan.paritySymbols,
@@ -3325,8 +3912,11 @@ function getVersionInfo(version, options = {}) {
3325
3912
  hasAlignmentMarker: true,
3326
3913
  alignmentPatterns: layout.alignments.length,
3327
3914
  alignmentCenters: layout.alignments.map(({ row, col }) => [row, col]),
3328
- bitsPerDataCell: 2,
3915
+ bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
3916
+ highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
3917
+ cellEncoding,
3329
3918
  colors: 4,
3919
+ statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
3330
3920
  spectralInterleaving: true,
3331
3921
  confidenceAwareEcc: true
3332
3922
  };
@@ -3337,6 +3927,7 @@ function chooseVersion(payloadLength, options = {}) {
3337
3927
  const minVersion = options.minVersion ?? MIN_VERSION;
3338
3928
  const maxVersion = options.maxVersion ?? MAX_VERSION;
3339
3929
  const ecc = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
3930
+ const cellEncoding = cellEncodingFromOptions(options);
3340
3931
 
3341
3932
  validateVersion(minVersion);
3342
3933
  validateVersion(maxVersion);
@@ -3345,10 +3936,10 @@ function chooseVersion(payloadLength, options = {}) {
3345
3936
  if (requested !== "auto") {
3346
3937
  validateVersion(requested);
3347
3938
  assert(requested >= minVersion && requested <= maxVersion, "Requested version is outside selected bounds.");
3348
- const info = getVersionInfo(requested, { ecc });
3939
+ const info = getVersionInfo(requested, { ecc, cellEncoding });
3349
3940
  const layout = createLayout(requested);
3350
3941
  assert(
3351
- payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested),
3942
+ payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested, cellEncoding),
3352
3943
  `Payload does not fit version ${requested} with ${ecc} ECC. Maximum is ${info.capacityBytes} bytes.`
3353
3944
  );
3354
3945
  return requested;
@@ -3356,20 +3947,20 @@ function chooseVersion(payloadLength, options = {}) {
3356
3947
 
3357
3948
  for (let version = minVersion; version <= maxVersion; version++) {
3358
3949
  const layout = createLayout(version);
3359
- if (streamFitsLayout(layout, ecc, payloadLength, version)) return version;
3950
+ if (streamFitsLayout(layout, ecc, payloadLength, version, cellEncoding)) return version;
3360
3951
  }
3361
3952
  throw new Error(`Payload is too large for versions ${minVersion}..${maxVersion}.`);
3362
3953
  }
3363
3954
 
3364
- function encodeProtectedHeader(header, version) {
3955
+ function encodeProtectedHeader(header, version, cellEncoding = CELL_ENCODINGS.RGBW) {
3365
3956
  const plan = getHeaderPlan(version);
3366
3957
  const codeword = rsEncode(Array.from(header), plan.paritySymbols);
3367
3958
  assert(codeword.length === plan.codewordBytes, "Header RS symbol calculation mismatch.");
3368
- return bytesToCells(codeword);
3959
+ return bytesToProtectedHeaderCells(codeword, cellEncoding);
3369
3960
  }
3370
3961
 
3371
- function encodeProtectedBody(bodyBytes, eccLevel, version) {
3372
- 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);
3373
3964
  assert(bodyBytes.length === plan.dataSymbols, "Body RS symbol calculation mismatch.");
3374
3965
 
3375
3966
  const blocks = [];
@@ -3378,7 +3969,7 @@ function encodeProtectedBody(bodyBytes, eccLevel, version) {
3378
3969
  blocks.push(rsEncode(Array.from(bodyBytes.slice(offset, offset + length)), plan.paritySymbols));
3379
3970
  offset += length;
3380
3971
  }
3381
- return { cells: bytesToCells(interleaveBlocks(blocks)), plan };
3972
+ return { cells: bytesToCells(interleaveBlocks(blocks), cellEncoding), plan };
3382
3973
  }
3383
3974
 
3384
3975
  function finalizeMatrix(layout, rawCells, meta) {
@@ -3386,9 +3977,10 @@ function finalizeMatrix(layout, rawCells, meta) {
3386
3977
  let bestMatrix = null;
3387
3978
  let bestPenalty = Infinity;
3388
3979
 
3980
+ const cellEncoding = normalizeCellEncoding(meta.cellEncoding ?? CELL_ENCODINGS.RGBW);
3389
3981
  for (let maskId = 0; maskId < 4; maskId++) {
3390
- const candidate = applyData(layout, rawCells, maskId, true);
3391
- const penalty = quaternaryPenalty(candidate, layout.reserved);
3982
+ const candidate = applyData(layout, rawCells, maskId, true, cellEncoding);
3983
+ const penalty = dataCellPenalty(candidate, layout.reserved, cellEncoding);
3392
3984
  if (penalty < bestPenalty) {
3393
3985
  bestPenalty = penalty;
3394
3986
  bestMaskId = maskId;
@@ -3396,7 +3988,7 @@ function finalizeMatrix(layout, rawCells, meta) {
3396
3988
  }
3397
3989
  }
3398
3990
 
3399
- const info = getVersionInfo(meta.version, { ecc: meta.eccLevel });
3991
+ const info = getVersionInfo(meta.version, { ecc: meta.eccLevel, cellEncoding });
3400
3992
  return {
3401
3993
  format: "QuadQR",
3402
3994
  formatVersion: FORMAT_VERSION,
@@ -3419,7 +4011,10 @@ function finalizeMatrix(layout, rawCells, meta) {
3419
4011
  capacityBytes: info.capacityBytes,
3420
4012
  alignmentPatterns: layout.alignments.length,
3421
4013
  utilization: meta.meaningfulCells / layout.dataPositions.length,
3422
- 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,
3423
4018
  eccLevel: meta.eccLevel,
3424
4019
  eccParitySymbols: meta.eccParitySymbols,
3425
4020
  eccBlocks: meta.eccBlocks,
@@ -3440,23 +4035,26 @@ function encodePreparedBytes(input, options = {}) {
3440
4035
  const secure = Boolean(options.secure);
3441
4036
  const extended = Boolean(options.extended);
3442
4037
  const signed = Boolean(options.signed);
3443
- const flags = flagsFor(Boolean(options.text), eccLevel, secure, extended, signed);
3444
- const version = chooseVersion(payload.length, { ...options, ecc: eccLevel });
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 });
3445
4041
  const layout = createLayout(version);
3446
4042
  const header = makeHeader(payload.length, flags, version);
3447
4043
  const crc = crc32(concatBytes(header, payload));
3448
- const headerCells = encodeProtectedHeader(header, version);
3449
- 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);
3450
4046
  const meaningfulCells = headerCells.concat(bodyEncoded.cells);
3451
4047
  assert(meaningfulCells.length <= layout.dataPositions.length, "Internal QuadQR capacity calculation error.");
3452
4048
 
3453
4049
  const padding = makePaddingCells(
3454
4050
  layout.dataPositions.length - meaningfulCells.length,
3455
- crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16)
4051
+ crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16),
4052
+ cellEncoding
3456
4053
  );
3457
4054
 
3458
4055
  return finalizeMatrix(layout, meaningfulCells.concat(padding), {
3459
4056
  version,
4057
+ cellEncoding,
3460
4058
  payloadBytes: payload.length,
3461
4059
  sourcePayloadBytes: options.sourcePayloadBytes ?? payload.length,
3462
4060
  secure,
@@ -3689,12 +4287,14 @@ function rotate90(matrix) {
3689
4287
  return out;
3690
4288
  }
3691
4289
 
3692
- 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;
3693
4293
  const out = [];
3694
4294
  for (const [row, col] of layout.dataPositions) {
3695
4295
  const value = matrix[row][col];
3696
- if (!Number.isInteger(value) || value < 0 || value > 3) {
3697
- 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.`);
3698
4298
  }
3699
4299
  out.push(value);
3700
4300
  }
@@ -3708,6 +4308,34 @@ function unmaskCells(visibleCells, positions, maskId) {
3708
4308
  });
3709
4309
  }
3710
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
+
3711
4339
  function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
3712
4340
  if (!spectralInterleaving) return physicalValues.slice();
3713
4341
  const permutation = spectralPermutation(physicalValues.length, version);
@@ -3718,20 +4346,21 @@ function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
3718
4346
  return logical;
3719
4347
  }
3720
4348
 
3721
- function cellsToSymbolConfidences(confidences, byteCount) {
4349
+ function cellsToSymbolConfidences(
4350
+ confidences,
4351
+ byteCount,
4352
+ cellsPerByte = RGBW_CELLS_PER_BYTE
4353
+ ) {
3722
4354
  if (!confidences) return null;
3723
- if (confidences.length < byteCount * CELLS_PER_BYTE) return null;
4355
+ if (confidences.length < byteCount * cellsPerByte) return null;
3724
4356
  const out = new Array(byteCount);
3725
4357
  for (let symbolIndex = 0; symbolIndex < byteCount; symbolIndex++) {
3726
- const offset = symbolIndex * CELLS_PER_BYTE;
3727
- // A single wrong 2-bit cell changes the GF(256) symbol, so the weakest
3728
- // constituent cell is the useful confidence bound for that symbol.
3729
- out[symbolIndex] = Math.min(
3730
- confidences[offset] ?? 1,
3731
- confidences[offset + 1] ?? 1,
3732
- confidences[offset + 2] ?? 1,
3733
- confidences[offset + 3] ?? 1
3734
- );
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;
3735
4364
  }
3736
4365
  return out;
3737
4366
  }
@@ -3768,11 +4397,11 @@ function decodeRsAdaptive(codeword, paritySymbols, symbolConfidences, options =
3768
4397
  }
3769
4398
  }
3770
4399
 
3771
- function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}) {
4400
+ function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}, cellEncoding = CELL_ENCODINGS.RGBW) {
3772
4401
  const plan = getHeaderPlan(version);
3773
4402
  const headerCells = rawCells.slice(0, plan.codewordCells);
3774
- const codeword = Array.from(cellsToBytes(headerCells, plan.codewordBytes));
3775
- 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);
3776
4405
  const decoded = decodeRsAdaptive(codeword, plan.paritySymbols, confidences, options);
3777
4406
  const header = new Uint8Array(decoded.data);
3778
4407
  const parsed = parseHeader(header, version);
@@ -3787,16 +4416,17 @@ function decodeProtectedHeader(rawCells, version, rawConfidences = null, options
3787
4416
  };
3788
4417
  }
3789
4418
 
3790
- function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConfidences = null, options = {}) {
3791
- 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);
3792
4421
  const bodyStart = getHeaderPlan(version).codewordCells;
3793
4422
  const encodedCells = rawCells.slice(bodyStart, bodyStart + plan.encodedCells);
3794
4423
  if (encodedCells.length !== plan.encodedCells) throw new Error("Protected body is incomplete.");
3795
4424
 
3796
- const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols));
4425
+ const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols, cellEncoding));
3797
4426
  const symbolConfidences = cellsToSymbolConfidences(
3798
4427
  rawConfidences?.slice(bodyStart, bodyStart + plan.encodedCells),
3799
- plan.encodedSymbols
4428
+ plan.encodedSymbols,
4429
+ cellsPerByteForEncoding(cellEncoding)
3800
4430
  );
3801
4431
  const blocks = deinterleaveBlocks(encodedSymbols, plan.codewordBlockLengths);
3802
4432
  const confidenceBlocks = symbolConfidences
@@ -3839,96 +4469,136 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
3839
4469
  if (!validateStructure(matrix, tolerance)) throw new Error("QuadQR finder/alignment structure does not match.");
3840
4470
 
3841
4471
  const layout = createLayout(version);
3842
- const visible = extractVisibleCells(matrix, layout);
3843
- const visibleConfidences = confidenceMatrix
3844
- ? layout.dataPositions.map(([row, col]) => confidenceMatrix[row]?.[col] ?? 1)
3845
- : null;
3846
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
+ }
3847
4491
 
3848
- // New symbols use spectral-spatial interleaving. Legacy order remains a
3849
- // decode fallback so existing QuadQR images do not become unreadable.
3850
- for (const spectralInterleaving of [true, false]) {
3851
- for (let maskId = 0; maskId < 4; maskId++) {
3852
- try {
3853
- const physicalRaw = unmaskCells(visible, layout.dataPositions, maskId);
3854
- const raw = restoreLogicalOrder(physicalRaw, version, spectralInterleaving);
3855
- const rawConfidences = visibleConfidences
3856
- ? restoreLogicalOrder(visibleConfidences, version, spectralInterleaving)
3857
- : null;
3858
- const headerDecoded = decodeProtectedHeader(raw, version, rawConfidences, options);
3859
- const header = headerDecoded.header;
3860
- const flags = headerDecoded.flags;
3861
- const eccLevel = eccFromFlags(flags);
3862
- const payloadLength = headerDecoded.payloadLength;
3863
- if (streamCellCount(payloadLength, eccLevel, version) > raw.length) {
3864
- throw new Error("Declared payload exceeds matrix capacity.");
3865
- }
4492
+ const visibleConfidences = confidenceMatrix
4493
+ ? layout.dataPositions.map(([row, col]) => confidenceMatrix[row]?.[col] ?? 1)
4494
+ : null;
3866
4495
 
3867
- const bodyDecoded = decodeProtectedBody(
3868
- raw,
3869
- payloadLength,
3870
- eccLevel,
3871
- version,
3872
- rawConfidences,
3873
- options
3874
- );
3875
- const body = bodyDecoded.body;
3876
- const payload = body.slice(0, payloadLength);
3877
- const expectedCrc = readU32be(body, payloadLength);
3878
- const actualCrc = crc32(concatBytes(header, payload));
3879
- if (expectedCrc !== actualCrc) throw new Error("CRC mismatch after ECC.");
3880
-
3881
- const isText = (flags & TEXT_FLAG) !== 0;
3882
- const secure = (flags & SECURE_FLAG) !== 0;
3883
- const extendedFlag = (flags & EXTENDED_PAYLOAD_FLAG) !== 0;
3884
- const signedFlag = (flags & SIGNED_FLAG) !== 0;
3885
- const security = secure ? inspectSecureEnvelope(payload) : null;
3886
- const envelope = extendedFlag && !secure ? parsePayloadEnvelope(payload) : null;
3887
- const applicationPayload = envelope ? envelope.payload : payload;
3888
- const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
3889
- const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
3890
- return {
3891
- ok: true,
3892
- format: "QuadQR",
3893
- formatVersion: FORMAT_VERSION,
3894
- version,
3895
- size,
3896
- alignmentPatterns: layout.alignments.length,
3897
- maskId,
3898
- rotation,
3899
- flags,
3900
- eccLevel,
3901
- eccParitySymbols: bodyDecoded.plan.paritySymbols,
3902
- eccBlocks: bodyDecoded.plan.dataBlockLengths.length,
3903
- correctableSymbolsPerBlock: bodyDecoded.plan.correctableSymbolsPerBlock,
3904
- spectralInterleaving,
3905
- confidenceAwareEcc: Boolean(rawConfidences),
3906
- confidenceAssisted: headerDecoded.confidenceAssisted || bodyDecoded.confidenceAssisted,
3907
- erasureSymbols,
3908
- unknownErrorSymbols,
3909
- correctedHeaderSymbols: headerDecoded.correctedSymbols,
3910
- correctedBodySymbols: bodyDecoded.correctedSymbols,
3911
- correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
3912
- protectedPayload: envelope ? payload : undefined,
3913
- payload: secure ? payload : applicationPayload,
3914
- text: secure ? null : (isText ? getTextDecoder().decode(applicationPayload) : null),
3915
- compressed: envelope?.compressed ?? false,
3916
- compression: envelope?.compression ?? "none",
3917
- signed: envelope?.signed ?? signedFlag,
3918
- signatureVerified: envelope?.signatureVerified,
3919
- signatureTrusted: envelope?.signatureTrusted,
3920
- signingKeyId: envelope?.keyId ?? null,
3921
- hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? false,
3922
- signer: envelope?.signer ?? null,
3923
- secure,
3924
- encrypted: secure,
3925
- decrypted: false,
3926
- requiresDecryption: secure,
3927
- security,
3928
- crc32: actualCrc >>> 0
3929
- };
3930
- } catch (error) {
3931
- 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
+ }
3932
4602
  }
3933
4603
  }
3934
4604
  }
@@ -4045,6 +4715,80 @@ function cellRgb(cell, palette) {
4045
4715
  }
4046
4716
 
4047
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
+
4048
4792
  function normalizeRenderStyle(style = RENDER_STYLES.CLASSIC) {
4049
4793
  const value = String(style).toLowerCase();
4050
4794
  const allowed = Object.values(RENDER_STYLES);
@@ -4514,6 +5258,7 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
4514
5258
  const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4515
5259
  const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
4516
5260
  const layout = renderLayoutForMatrix(matrix);
5261
+ const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
4517
5262
  const size = matrix.length;
4518
5263
  const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
4519
5264
 
@@ -4532,6 +5277,14 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
4532
5277
  const { x, y, width: cellWidth, height: cellHeight } = rect;
4533
5278
  const cellSize = Math.min(cellWidth, cellHeight);
4534
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
+ }
5287
+
4535
5288
  if (style === RENDER_STYLES.CLASSIC || structural) {
4536
5289
  ctx.fillStyle = cellColor(cell, palette);
4537
5290
  ctx.fillRect(x, y, cellWidth, cellHeight);
@@ -4605,6 +5358,7 @@ function renderToImageData(codeOrMatrix, options = {}) {
4605
5358
  const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4606
5359
  const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
4607
5360
  const layout = renderLayoutForMatrix(matrix);
5361
+ const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
4608
5362
  const size = matrix.length;
4609
5363
  const data = new Uint8ClampedArray(pixelSize * pixelSize * 4);
4610
5364
  const white = palette.white;
@@ -4621,7 +5375,6 @@ function renderToImageData(codeOrMatrix, options = {}) {
4621
5375
  for (let r = 0; r < size; r++) {
4622
5376
  for (let c = 0; c < size; c++) {
4623
5377
  const cell = matrix[r][c];
4624
- const rgb = cellRgb(cell, palette);
4625
5378
  const structural = isStructuralRenderCell(layout, r, c, cell);
4626
5379
  const rect = rasterModuleRect(r, c, quietZone, moduleSize);
4627
5380
  const x0 = rect.x;
@@ -4630,6 +5383,13 @@ function renderToImageData(codeOrMatrix, options = {}) {
4630
5383
  const cellHeight = rect.height;
4631
5384
  const cellSize = Math.min(cellWidth, cellHeight);
4632
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);
5392
+
4633
5393
  if (style === RENDER_STYLES.CLASSIC || structural) {
4634
5394
  fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
4635
5395
  continue;
@@ -4691,6 +5451,7 @@ function renderToSVG(codeOrMatrix, options = {}) {
4691
5451
  const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4692
5452
  const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
4693
5453
  const layout = renderLayoutForMatrix(matrix);
5454
+ const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
4694
5455
  const size = matrix.length;
4695
5456
  const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
4696
5457
  const body = [svgRect(0, 0, pixelSize, pixelSize, palette.white)];
@@ -4701,7 +5462,12 @@ function renderToSVG(codeOrMatrix, options = {}) {
4701
5462
  const structural = isStructuralRenderCell(layout, r, c, cell);
4702
5463
  const x = (c + quietZone) * moduleSize;
4703
5464
  const y = (r + quietZone) * moduleSize;
4704
- const fill = cellColor(cell, palette);
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
+ }
4705
5471
 
4706
5472
  if (style === RENDER_STYLES.CLASSIC || structural) {
4707
5473
  body.push(svgRect(x, y, moduleSize, moduleSize, fill));
@@ -4928,6 +5694,60 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
4928
5694
  };
4929
5695
  }
4930
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
+
4931
5751
  function structuralAccuracy(matrix, layout) {
4932
5752
  let matches = 0;
4933
5753
  let total = 0;
@@ -4967,6 +5787,7 @@ function tryPerspectiveScan(imageData, options) {
4967
5787
  finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
4968
5788
  finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
4969
5789
  finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
5790
+ preciseAlignment: options.preciseAlignment,
4970
5791
  diagnostics: options._visionDiagnostics,
4971
5792
  diagnosticLabel: options._diagnosticLabel ?? "normal"
4972
5793
  });
@@ -4992,9 +5813,18 @@ function tryPerspectiveScan(imageData, options) {
4992
5813
  let bestStructureScore = 0;
4993
5814
  for (const profile of sampleProfiles) {
4994
5815
  let sampled;
5816
+ let triangleSampled = null;
4995
5817
  let observedPalette;
4996
5818
  try {
4997
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
+ }
4998
5828
  observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, {
4999
5829
  robust: profile.robustCalibration
5000
5830
  });
@@ -5053,11 +5883,67 @@ function tryPerspectiveScan(imageData, options) {
5053
5883
  }
5054
5884
  };
5055
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
+
5056
5942
  // Fast path: preserve the original observed-RGB classifier first, then
5057
5943
  // try per-channel white balancing. Most clean frames stop here without
5058
5944
  // paying for the more expensive spatial normalization fallback.
5059
5945
  for (const attempt of paletteClassifierAttempts(observedPalette)) {
5060
- if (tryAttempt(attempt, sampled.rgbGrid)) {
5946
+ if (tryAttempt(attempt, sampled.rgbGrid) || tryTriangleAttempt(attempt)) {
5061
5947
  geometryDecoded = true;
5062
5948
  break;
5063
5949
  }
@@ -5184,17 +6070,30 @@ function tryPerspectiveScan(imageData, options) {
5184
6070
  [step, -step], [step, step], [-step, -step], [-step, step]
5185
6071
  ];
5186
6072
  const refinementCandidates = [];
6073
+ const triangleRefinementCandidates = [];
5187
6074
 
5188
6075
  for (const [offsetX, offsetY] of refinementOffsets) {
5189
6076
  let sampled;
6077
+ let triangleSampled = null;
5190
6078
  let observedPalette;
5191
6079
  try {
5192
- sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, {
6080
+ const samplingOptions = {
5193
6081
  sampleMode: "cross",
5194
6082
  sampleRadius: 0,
5195
6083
  sampleOffsetX: offsetX,
5196
- sampleOffsetY: offsetY
5197
- });
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
+ }
5198
6097
  observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, { robust: true });
5199
6098
  } catch {
5200
6099
  continue;
@@ -5224,30 +6123,80 @@ function tryPerspectiveScan(imageData, options) {
5224
6123
 
5225
6124
  // Do not spend RS work on a refinement that made the known finder /
5226
6125
  // alignment structure worse. This keeps the recovery path bounded.
5227
- if (structureScore < (options.refinementDecodeThreshold ?? 0.95)) continue;
5228
- refinementCandidates.push({
5229
- attempt,
5230
- classified,
5231
- observedPalette,
5232
- offsetX,
5233
- offsetY,
5234
- structureScore
5235
- });
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
+ }
5236
6175
  }
5237
6176
  }
5238
6177
 
5239
- refinementCandidates.sort((a, b) =>
6178
+ const sortRefinementCandidates = (items) => items.sort((a, b) =>
5240
6179
  (b.structureScore - a.structureScore) ||
5241
6180
  (b.classified.averageCellConfidence - a.classified.averageCellConfidence) ||
5242
6181
  (a.classified.averageColorDistance - b.classified.averageColorDistance)
5243
6182
  );
6183
+ sortRefinementCandidates(refinementCandidates);
6184
+ sortRefinementCandidates(triangleRefinementCandidates);
5244
6185
 
5245
6186
  const decodeLimit = Math.max(1, Math.min(8, Math.round(options.refinementDecodeCandidates ?? 4)));
5246
- 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) {
5247
6195
  try {
5248
6196
  const decoded = decodeMatrix(candidate.classified.matrix, {
5249
6197
  structureTolerance: options.structureTolerance ?? 0.18,
5250
6198
  cellConfidence: candidate.classified.confidence,
6199
+ cellEncodingHint: candidate.cellEncoding,
5251
6200
  maxErasureConfidence: options.maxErasureConfidence
5252
6201
  });
5253
6202
  if (decoded.version !== geometry.version) continue;
@@ -5258,7 +6207,7 @@ function tryPerspectiveScan(imageData, options) {
5258
6207
  samplingOffset: { x: candidate.offsetX, y: candidate.offsetY },
5259
6208
  colorCalibrated: true,
5260
6209
  colorNormalization: candidate.attempt.colorNormalization,
5261
- samplingMode: "refined-center",
6210
+ samplingMode: candidate.samplingMode,
5262
6211
  geometry,
5263
6212
  observedPalette: candidate.observedPalette,
5264
6213
  averageColorDistance: candidate.classified.averageColorDistance,
@@ -5308,6 +6257,15 @@ function tryAxisAlignedScan(imageData, options) {
5308
6257
  for (let profileIndex = 0; profileIndex < radiusProfiles.length && !accepted; profileIndex++) {
5309
6258
  try {
5310
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
+ );
5311
6269
  const layout = createLayout(version);
5312
6270
  const classifierAttempts = [];
5313
6271
  try {
@@ -5347,45 +6305,70 @@ function tryAxisAlignedScan(imageData, options) {
5347
6305
  });
5348
6306
 
5349
6307
  for (const attempt of classifierAttempts) {
5350
- try {
5351
- const classified = classifySampledRgbGrid(attempt.rgbGrid ?? sampled.rgbGrid, attempt.classifier, layout);
5352
- const structureScore = structuralAccuracy(classified.matrix, layout);
5353
- pushObservation(options, {
5354
- version,
5355
- matrix: classified.matrix,
5356
- confidence: classified.confidence,
5357
- bounds,
5358
- samplingMode: profileIndex === 0 ? "axis" : "axis-center",
5359
- colorNormalization: attempt.colorNormalization,
5360
- structureScore,
5361
- averageCellConfidence: classified.averageCellConfidence,
5362
- 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`
5363
6325
  });
5364
- const decoded = decodeMatrix(classified.matrix, {
5365
- structureTolerance: options.structureTolerance ?? 0.12,
5366
- cellConfidence: classified.confidence,
5367
- maxErasureConfidence: options.maxErasureConfidence
5368
- });
5369
- candidates.push({
5370
- ...decoded,
5371
- bounds,
5372
- sampledVersion: version,
5373
- moduleWidth: moduleW,
5374
- moduleHeight: moduleH,
5375
- perspectiveCorrected: false,
5376
- colorCalibrated: attempt.calibrated,
5377
- colorNormalization: attempt.colorNormalization,
5378
- samplingMode: profileIndex === 0 ? "axis" : "axis-center",
5379
- averageColorDistance: classified.averageColorDistance,
5380
- averageCellConfidence: classified.averageCellConfidence,
5381
- minimumCellConfidence: classified.minimumCellConfidence,
5382
- lowConfidenceCells: classified.lowConfidenceCells
5383
- });
5384
- accepted = true;
5385
- break;
5386
- } catch {
5387
- // Try next classifier/profile.
5388
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;
5389
6372
  }
5390
6373
  } catch {
5391
6374
  // Try a narrower centre sample, then next version.
@@ -5497,7 +6480,7 @@ function perspectiveStress(imageData, severity) {
5497
6480
  { x: width - 1 - inset * 1.0, y: height - 1 - inset * 0.35 },
5498
6481
  { x: inset * 0.20, y: height - 1 - inset * 0.95 }
5499
6482
  ];
5500
- const inverse = computeHomography(destination, source);
6483
+ const inverse = computeHomography(source, destination);
5501
6484
  const out = new Uint8ClampedArray(width * height * 4);
5502
6485
  out.fill(255);
5503
6486
  for (let y = 0; y < height; y++) {
@@ -5782,6 +6765,36 @@ function scanImageData(imageData, options = {}) {
5782
6765
  if (perspective) return decorateScanResult(perspective, debugContext, options);
5783
6766
  }
5784
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
+ }
6796
+ }
6797
+
5785
6798
  // If the normal scanner could see QuadQR geometry but could not decode the
5786
6799
  // colors, retry the same frame after a single global Auto Tone / Contrast /
5787
6800
  // Color-style pass. This is deliberately after the normal path so clean
@@ -6185,6 +7198,7 @@ async function startCameraScanner(video, options = {}) {
6185
7198
 
6186
7199
  const scanInterval = Math.max(80, options.scanInterval ?? 180);
6187
7200
  const scratchCanvas = document.createElement("canvas");
7201
+ const highResolutionCanvas = document.createElement("canvas");
6188
7202
  const multiFrameEnabled = options.multiFrame !== false;
6189
7203
  const multiFrameWindow = Math.max(2, Math.min(8, Math.round(options.multiFrameWindow ?? 4)));
6190
7204
  const multiFrameMinFrames = Math.max(2, Math.min(multiFrameWindow, Math.round(options.multiFrameMinFrames ?? 2)));
@@ -6192,6 +7206,12 @@ async function startCameraScanner(video, options = {}) {
6192
7206
  const cameraAutoColorEvery = Math.max(1, Math.round(options.cameraAutoColorEvery ?? 1));
6193
7207
  const cameraAutoEnhanceEvery = Math.max(1, Math.round(options.cameraAutoEnhanceEvery ?? 2));
6194
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
+ );
6195
7215
  let missStreak = 0;
6196
7216
  let stopped = false;
6197
7217
  let busy = false;
@@ -6239,7 +7259,7 @@ async function startCameraScanner(video, options = {}) {
6239
7259
 
6240
7260
  const scanNow = () => scanVideoFrame(video, {
6241
7261
  ...options,
6242
- maxDimension: options.maxDimension ?? 1080,
7262
+ maxDimension: baseCameraMaxDimension,
6243
7263
  canvas: scratchCanvas
6244
7264
  });
6245
7265
 
@@ -6306,7 +7326,7 @@ async function startCameraScanner(video, options = {}) {
6306
7326
  busy = true;
6307
7327
  frameNumber++;
6308
7328
  const observations = [];
6309
- const frameDiagnostics = diagnosticsEnabled ? {} : null;
7329
+ const frameDiagnostics = {};
6310
7330
  const capturedFrame = {};
6311
7331
  const frameStarted = nowMs();
6312
7332
  let allowAutoEnhance = false;
@@ -6334,7 +7354,7 @@ async function startCameraScanner(video, options = {}) {
6334
7354
  // If geometry exists, the rectified QR-only pixel enhancer is both
6335
7355
  // stronger and much cheaper than reprocessing the whole live frame.
6336
7356
  fullFrameAutoEnhanceRecovery: options.fullFrameAutoEnhanceRecovery ?? false,
6337
- maxDimension: options.maxDimension ?? 1080,
7357
+ maxDimension: baseCameraMaxDimension,
6338
7358
  canvas: scratchCanvas,
6339
7359
  _capturedFrame: capturedFrame,
6340
7360
  _observationCollector: observations,
@@ -6375,6 +7395,71 @@ async function startCameraScanner(video, options = {}) {
6375
7395
  ...frameDiagnostics
6376
7396
  });
6377
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
+
6378
7463
  // The user's real phone-camera case is dominated by color cast before
6379
7464
  // finder detection: Photoshop Auto Color alone makes the same live QR
6380
7465
  // immediately detectable. Retry the exact captured frame with a cheap
@@ -6679,11 +7764,14 @@ const internals = Object.freeze({
6679
7764
  HEADER_CODEWORD_CELLS,
6680
7765
  COMPACT_HEADER_CODEWORD_CELLS,
6681
7766
  CELLS_PER_BYTE,
7767
+ RGBW_CELLS_PER_BYTE,
7768
+ TRIANGLE16_CELLS_PER_BYTE,
7769
+ TRIANGLE16_FLAG,
6682
7770
  TEXT_FLAG,
6683
7771
  SECURE_FLAG
6684
7772
  });
6685
7773
 
6686
- return { compressPayload, decompressPayload, deriveSigningKeyId, generateSigningKeyPair, installCrc32Accelerator, crc32, getVersionInfo, encodeText, encodeBytes, encodeUint8Array, encodeSignedBytes, encodeSignedText, encodeSecureText, encodeSecureBytes, decryptDecoded, decodeMatrix, decodeUint8Array, verifyDecodedSignature, estimateSafeLogoSize, findMaxSafeLogoSize, getPrintGuidance, renderToCanvas, renderToImageData, renderToSVG, applyStressDistortion, runImageStressTest, assessScanability, debugScanImageData, scanImageData, scanFile, scanVideoFrame, startCameraScanner, rectifyDetectedCode, rotateMatrix, FORMAT_VERSION, MIN_VERSION, MAX_VERSION, DEFAULT_ECC_LEVEL, RENDER_MODES, PRINT_PALETTE, COMPRESSION_MODES, SIGNATURE_ALGORITHMS, CELL, DEFAULT_PALETTE, RENDER_STYLES, ECC_LEVELS, STRESS_PROFILES, internals, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex };
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 };
6687
7775
  })();
6688
7776
  const QuadQR = __qqr_quadqr_js;
6689
7777