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.
@@ -1190,6 +1190,31 @@ function detectFinderCandidates(binary, width, height, options = {}) {
1190
1190
  return clusterFinderCandidates(raw, minConfirmations);
1191
1191
  }
1192
1192
 
1193
+ function recoverFinderSetFromTwo(binary, width, height, strongFinders, detector = {}) {
1194
+ if (strongFinders.length !== 2) return strongFinders;
1195
+ const moduleMean = (strongFinders[0].moduleSize + strongFinders[1].moduleSize) / 2;
1196
+ const loose = detectFinderCandidates(binary, width, height, {
1197
+ toleranceScale: Math.max(1.45, (detector.toleranceScale ?? 1) * 1.35),
1198
+ moduleSpreadLimit: Math.max(0.72, detector.moduleSpreadLimit ?? 0.45),
1199
+ minConfirmations: 1
1200
+ });
1201
+
1202
+ const extra = loose.filter((candidate) => {
1203
+ const moduleRatio = Math.abs(candidate.moduleSize - moduleMean) / Math.max(candidate.moduleSize, moduleMean);
1204
+ if (moduleRatio > 0.62) return false;
1205
+ return strongFinders.every((known) =>
1206
+ Math.hypot(candidate.x - known.x, candidate.y - known.y) > Math.max(moduleMean, known.moduleSize) * 5
1207
+ );
1208
+ }).slice(0, 12);
1209
+
1210
+ if (!extra.length) return strongFinders;
1211
+ return strongFinders.concat(extra).sort((a, b) =>
1212
+ (b.confirmations - a.confirmations) ||
1213
+ (a.score - b.score) ||
1214
+ (b.moduleSize - a.moduleSize)
1215
+ );
1216
+ }
1217
+
1193
1218
  function dot(a, b) {
1194
1219
  return a.x * b.x + a.y * b.y;
1195
1220
  }
@@ -1272,6 +1297,14 @@ function alignmentTemplateValue(pattern, r, c) {
1272
1297
  return alignmentPatternIsBlack(pattern, r, c) ? 1 : 0;
1273
1298
  }
1274
1299
 
1300
+ const ALIGNMENT_SUBCELL_PROBES = Object.freeze([
1301
+ Object.freeze([0, 0]),
1302
+ Object.freeze([-0.32, 0]),
1303
+ Object.freeze([0.32, 0]),
1304
+ Object.freeze([0, -0.32]),
1305
+ Object.freeze([0, 0.32])
1306
+ ]);
1307
+
1275
1308
  function alignmentScore(binary, width, height, center, basisU, basisV, scale, pattern) {
1276
1309
  let matches = 0;
1277
1310
  let total = 0;
@@ -1289,7 +1322,31 @@ function alignmentScore(binary, width, height, center, basisU, basisV, scale, pa
1289
1322
  return total ? matches / total : 0;
1290
1323
  }
1291
1324
 
1292
- function searchAlignment(binary, width, height, triple, version) {
1325
+ function preciseAlignmentScore(binary, width, height, center, basisU, basisV, scale, pattern) {
1326
+ let matches = 0;
1327
+ let total = 0;
1328
+ const radius = alignmentPatternRadius(pattern);
1329
+ for (let r = -radius; r <= radius; r++) {
1330
+ for (let c = -radius; c <= radius; c++) {
1331
+ const expected = alignmentTemplateValue(pattern, r, c);
1332
+ // Centre-only alignment scoring has a broad plateau: a candidate can be
1333
+ // wrong by almost half a module while every centre still lands in the
1334
+ // correct solid tile. This denser score is reserved for the slow recovery
1335
+ // path, where Triangle16 needs sub-module geometry precision.
1336
+ for (const [du, dv] of ALIGNMENT_SUBCELL_PROBES) {
1337
+ const x = center.x + basisU.x * (c + du) * scale + basisV.x * (r + dv) * scale;
1338
+ const y = center.y + basisU.y * (c + du) * scale + basisV.y * (r + dv) * scale;
1339
+ const value = sampleBinaryAt(binary, width, height, x, y);
1340
+ if (value === null) continue;
1341
+ total++;
1342
+ if (value === expected) matches++;
1343
+ }
1344
+ }
1345
+ }
1346
+ return total ? matches / total : 0;
1347
+ }
1348
+
1349
+ function searchAlignment(binary, width, height, triple, version, options = {}) {
1293
1350
  const size = sizeForVersion(version);
1294
1351
  const separation = size - 7;
1295
1352
  const basisU = {
@@ -1309,8 +1366,12 @@ function searchAlignment(binary, width, height, triple, version) {
1309
1366
  };
1310
1367
 
1311
1368
  let best = { score: 0, center: predicted, scale: 1 };
1312
- const offsets = [-2.5, -1.5, -0.75, 0, 0.75, 1.5, 2.5];
1369
+ const precise = options.preciseAlignment === true;
1370
+ const offsets = precise
1371
+ ? [-2.5, -1.5, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1.5, 2.5]
1372
+ : [-2.5, -1.5, -0.75, 0, 0.75, 1.5, 2.5];
1313
1373
  const scales = [0.72, 0.85, 1, 1.15, 1.3];
1374
+ const scoreAlignment = precise ? preciseAlignmentScore : alignmentScore;
1314
1375
 
1315
1376
  for (const ou of offsets) {
1316
1377
  for (const ov of offsets) {
@@ -1319,7 +1380,7 @@ function searchAlignment(binary, width, height, triple, version) {
1319
1380
  y: predicted.y + basisU.y * ou + basisV.y * ov
1320
1381
  };
1321
1382
  for (const scale of scales) {
1322
- const score = alignmentScore(binary, width, height, center, basisU, basisV, scale, target);
1383
+ const score = scoreAlignment(binary, width, height, center, basisU, basisV, scale, target);
1323
1384
  if (score > best.score) best = { score, center, scale };
1324
1385
  }
1325
1386
  }
@@ -1362,6 +1423,183 @@ function scoreAlignmentGrid(binary, width, height, homography, version) {
1362
1423
  return { score, patternScores };
1363
1424
  }
1364
1425
 
1426
+ function localProjectiveBasis(homography, moduleX, moduleY) {
1427
+ const center = projectPoint(homography, moduleX, moduleY);
1428
+ const alongU = projectPoint(homography, moduleX + 1, moduleY);
1429
+ const alongV = projectPoint(homography, moduleX, moduleY + 1);
1430
+ return {
1431
+ center,
1432
+ basisU: { x: alongU.x - center.x, y: alongU.y - center.y },
1433
+ basisV: { x: alongV.x - center.x, y: alongV.y - center.y }
1434
+ };
1435
+ }
1436
+
1437
+ function searchProjectedAlignment(binary, width, height, homography, pattern) {
1438
+ const moduleX = pattern.col + 0.5;
1439
+ const moduleY = pattern.row + 0.5;
1440
+ const local = localProjectiveBasis(homography, moduleX, moduleY);
1441
+ const moduleScale = Math.max(
1442
+ 0.5,
1443
+ (Math.hypot(local.basisU.x, local.basisU.y) + Math.hypot(local.basisV.x, local.basisV.y)) / 2
1444
+ );
1445
+ const offsets = [-1, -0.5, 0, 0.5, 1];
1446
+ const scales = [0.88, 1, 1.12];
1447
+ let best = {
1448
+ score: projectedAlignmentScore(binary, width, height, homography, pattern),
1449
+ center: local.center,
1450
+ scale: 1,
1451
+ offsetU: 0,
1452
+ offsetV: 0,
1453
+ displacementModules: 0
1454
+ };
1455
+
1456
+ for (const offsetU of offsets) {
1457
+ for (const offsetV of offsets) {
1458
+ const center = {
1459
+ x: local.center.x + local.basisU.x * offsetU + local.basisV.x * offsetV,
1460
+ y: local.center.y + local.basisU.y * offsetU + local.basisV.y * offsetV
1461
+ };
1462
+ for (const scale of scales) {
1463
+ const score = alignmentScore(
1464
+ binary,
1465
+ width,
1466
+ height,
1467
+ center,
1468
+ local.basisU,
1469
+ local.basisV,
1470
+ scale,
1471
+ pattern
1472
+ );
1473
+ if (score > best.score) {
1474
+ best = {
1475
+ score,
1476
+ center,
1477
+ scale,
1478
+ offsetU,
1479
+ offsetV,
1480
+ displacementModules: Math.hypot(offsetU, offsetV),
1481
+ displacementPixels: Math.hypot(offsetU, offsetV) * moduleScale
1482
+ };
1483
+ }
1484
+ }
1485
+ }
1486
+ }
1487
+
1488
+ return best;
1489
+ }
1490
+
1491
+ function refineHomographyWithAlignmentGrid(
1492
+ binary,
1493
+ width,
1494
+ height,
1495
+ initialHomography,
1496
+ version,
1497
+ sourcePoints,
1498
+ destinationPoints,
1499
+ initialGrid,
1500
+ options = {}
1501
+ ) {
1502
+ const patterns = alignmentPatternCentersForVersion(version);
1503
+ if (patterns.length <= 1 || options.alignmentRefinement === false) {
1504
+ return {
1505
+ homography: initialHomography,
1506
+ grid: initialGrid,
1507
+ refined: false,
1508
+ points: []
1509
+ };
1510
+ }
1511
+
1512
+ const primary = patterns.find((pattern) => pattern.primary);
1513
+ const secondaries = patterns.filter((pattern) => !pattern.primary);
1514
+ const minimumScore = options.alignmentRefinePatternThreshold ?? 0.84;
1515
+ const maxPoints = Math.max(1, Math.min(18, Math.round(options.alignmentRefineMaxPoints ?? 12)));
1516
+ const candidates = [];
1517
+
1518
+ for (const pattern of secondaries) {
1519
+ const found = searchProjectedAlignment(binary, width, height, initialHomography, pattern);
1520
+ if (found.score < minimumScore) continue;
1521
+ if (found.displacementModules > (options.alignmentRefineMaxDisplacement ?? 1.45)) continue;
1522
+ candidates.push({ pattern, ...found });
1523
+ }
1524
+
1525
+ if (!candidates.length) {
1526
+ return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
1527
+ }
1528
+
1529
+ // Prefer confident references, but retain spatial distribution so dense
1530
+ // versions use alignment information across the entire matrix rather than
1531
+ // allowing a cluster of nearby markers to dominate the fit.
1532
+ candidates.sort((a, b) =>
1533
+ (b.score - a.score) ||
1534
+ ((b.pattern.row + b.pattern.col) - (a.pattern.row + a.pattern.col))
1535
+ );
1536
+ const selected = [];
1537
+ for (const candidate of candidates) {
1538
+ if (selected.length >= maxPoints) break;
1539
+ const tooClose = selected.some((item) =>
1540
+ Math.hypot(item.pattern.col - candidate.pattern.col, item.pattern.row - candidate.pattern.row) < 6
1541
+ );
1542
+ if (!tooClose || selected.length < 3) selected.push(candidate);
1543
+ }
1544
+ if (!selected.length) return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
1545
+
1546
+ const refinedSource = sourcePoints.slice();
1547
+ const refinedDestination = destinationPoints.slice();
1548
+ const weights = [4, 4, 4, 5];
1549
+ const pointInfo = [];
1550
+
1551
+ for (const candidate of selected) {
1552
+ refinedSource.push(candidate.center);
1553
+ refinedDestination.push({
1554
+ x: candidate.pattern.col + 0.5,
1555
+ y: candidate.pattern.row + 0.5
1556
+ });
1557
+ // A 3x3 marker carries less evidence than the 5x5 primary and the three
1558
+ // finder patterns, so it helps average geometry without overpowering them.
1559
+ weights.push(0.8 + candidate.score * 1.7);
1560
+ pointInfo.push({
1561
+ row: candidate.pattern.row,
1562
+ col: candidate.pattern.col,
1563
+ score: candidate.score,
1564
+ x: candidate.center.x,
1565
+ y: candidate.center.y,
1566
+ displacementModules: candidate.displacementModules
1567
+ });
1568
+ }
1569
+
1570
+ let refinedHomography;
1571
+ try {
1572
+ refinedHomography = computeHomographyLeastSquares(refinedSource, refinedDestination, weights);
1573
+ } catch {
1574
+ return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
1575
+ }
1576
+
1577
+ const refinedGrid = scoreAlignmentGrid(binary, width, height, refinedHomography, version);
1578
+ const primaryBefore = primary
1579
+ ? projectedAlignmentScore(binary, width, height, initialHomography, primary)
1580
+ : 1;
1581
+ const primaryAfter = primary
1582
+ ? projectedAlignmentScore(binary, width, height, refinedHomography, primary)
1583
+ : 1;
1584
+ const allowedGridDrop = options.alignmentRefineAllowedGridDrop ?? 0.006;
1585
+ const allowedPrimaryDrop = options.alignmentRefineAllowedPrimaryDrop ?? 0.04;
1586
+
1587
+ if (
1588
+ refinedGrid.score + allowedGridDrop < initialGrid.score ||
1589
+ primaryAfter + allowedPrimaryDrop < primaryBefore
1590
+ ) {
1591
+ return { homography: initialHomography, grid: initialGrid, refined: false, points: [] };
1592
+ }
1593
+
1594
+ return {
1595
+ homography: refinedHomography,
1596
+ grid: refinedGrid,
1597
+ refined: true,
1598
+ points: pointInfo,
1599
+ initialGridScore: initialGrid.score
1600
+ };
1601
+ }
1602
+
1365
1603
  function solveLinearSystemFloat(matrix, vector) {
1366
1604
  const n = vector.length;
1367
1605
  const a = matrix.map((row, index) => row.slice().concat([vector[index]]));
@@ -1403,6 +1641,43 @@ function computeHomography(sourcePoints, destinationPoints) {
1403
1641
  return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1];
1404
1642
  }
1405
1643
 
1644
+ function computeHomographyLeastSquares(sourcePoints, destinationPoints, weights = null) {
1645
+ // Same destination -> source convention as computeHomography(), but accepts
1646
+ // more than four correspondences and averages locator/alignment measurement
1647
+ // noise. This is used only as a refinement after a valid four-point projective
1648
+ // solution already exists.
1649
+ assert(
1650
+ sourcePoints.length === destinationPoints.length && sourcePoints.length >= 4,
1651
+ "At least four matching point pairs are required."
1652
+ );
1653
+
1654
+ const normal = Array.from({ length: 8 }, () => Array(8).fill(0));
1655
+ const rhs = Array(8).fill(0);
1656
+
1657
+ const addEquation = (row, value, weight) => {
1658
+ for (let i = 0; i < 8; i++) {
1659
+ rhs[i] += row[i] * value * weight;
1660
+ for (let j = 0; j < 8; j++) normal[i][j] += row[i] * row[j] * weight;
1661
+ }
1662
+ };
1663
+
1664
+ for (let i = 0; i < sourcePoints.length; i++) {
1665
+ const u = destinationPoints[i].x;
1666
+ const v = destinationPoints[i].y;
1667
+ const x = sourcePoints[i].x;
1668
+ const y = sourcePoints[i].y;
1669
+ const weight = Math.max(0.05, Number(weights?.[i] ?? 1));
1670
+ addEquation([u, v, 1, 0, 0, 0, -u * x, -v * x], x, weight);
1671
+ addEquation([0, 0, 0, u, v, 1, -u * y, -v * y], y, weight);
1672
+ }
1673
+
1674
+ // Tiny Tikhonov regularisation keeps near-degenerate noisy fits numerically
1675
+ // stable without moving a normal QR-sized solution in any meaningful way.
1676
+ for (let i = 0; i < 8; i++) normal[i][i] += 1e-9;
1677
+ const h = solveLinearSystemFloat(normal, rhs);
1678
+ return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1];
1679
+ }
1680
+
1406
1681
  function projectPoint(h, u, v) {
1407
1682
  const denominator = h[6] * u + h[7] * v + h[8];
1408
1683
  return {
@@ -1428,7 +1703,7 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
1428
1703
  for (const item of versions) {
1429
1704
  const version = item.version;
1430
1705
  const size = sizeForVersion(version);
1431
- const alignment = searchAlignment(binary, width, height, triple, version);
1706
+ const alignment = searchAlignment(binary, width, height, triple, version, options);
1432
1707
  if (alignment.score < (options.alignmentThreshold ?? 0.72)) continue;
1433
1708
 
1434
1709
  const alignmentTarget = alignment.target;
@@ -1452,8 +1727,34 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
1452
1727
  continue;
1453
1728
  }
1454
1729
 
1455
- const alignmentGrid = scoreAlignmentGrid(binary, width, height, homography, version);
1456
- if (alignmentGrid.score < (options.alignmentGridThreshold ?? 0.68)) continue;
1730
+ const initialAlignmentGrid = scoreAlignmentGrid(binary, width, height, homography, version);
1731
+ const alignmentGridThreshold = options.alignmentGridThreshold ?? 0.68;
1732
+ const refinementFloor = Math.max(0, alignmentGridThreshold - (options.alignmentRefineCandidateMargin ?? 0.10));
1733
+ const skipRefinementScore = options.alignmentRefineSkipScore ?? 0.985;
1734
+ const refinement = (
1735
+ initialAlignmentGrid.score >= refinementFloor &&
1736
+ initialAlignmentGrid.score < skipRefinementScore
1737
+ )
1738
+ ? refineHomographyWithAlignmentGrid(
1739
+ binary,
1740
+ width,
1741
+ height,
1742
+ homography,
1743
+ version,
1744
+ src,
1745
+ dest,
1746
+ initialAlignmentGrid,
1747
+ options
1748
+ )
1749
+ : {
1750
+ homography,
1751
+ grid: initialAlignmentGrid,
1752
+ refined: false,
1753
+ points: []
1754
+ };
1755
+ homography = refinement.homography;
1756
+ const alignmentGrid = refinement.grid;
1757
+ if (alignmentGrid.score < alignmentGridThreshold) continue;
1457
1758
 
1458
1759
  const versionPenalty = item.error / 4;
1459
1760
  const alignmentConfidence = 0.55 * alignment.score + 0.45 * alignmentGrid.score;
@@ -1472,6 +1773,9 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
1472
1773
  target: { row: alignmentTarget.row, col: alignmentTarget.col },
1473
1774
  patterns: alignmentGrid.patternScores.length,
1474
1775
  gridScore: alignmentGrid.score,
1776
+ initialGridScore: refinement.initialGridScore ?? initialAlignmentGrid.score,
1777
+ refined: refinement.refined,
1778
+ refinementPoints: refinement.points,
1475
1779
  patternScores: alignmentGrid.patternScores
1476
1780
  },
1477
1781
  threshold,
@@ -1543,19 +1847,21 @@ function detectCodeGeometry(imageData, options = {}) {
1543
1847
  const maxCandidates = options.maxCandidates ?? 8;
1544
1848
 
1545
1849
  const evaluatePass = (pass, recovery = false) => {
1546
- const finders = detectFinderCandidates(pass.binary, width, height, pass.detector ?? {});
1547
- const geometries = geometryCandidatesFromBinary(
1850
+ const detector = pass.detector ?? {};
1851
+ const finders = detectFinderCandidates(pass.binary, width, height, detector);
1852
+ const geometryOptions = {
1853
+ ...options,
1854
+ finderMethod: pass.finderMethod,
1855
+ alignmentThreshold: recovery ? 0.68 : 0.72,
1856
+ alignmentGridThreshold: recovery ? 0.64 : 0.68
1857
+ };
1858
+ let geometries = geometryCandidatesFromBinary(
1548
1859
  pass.binary,
1549
1860
  width,
1550
1861
  height,
1551
1862
  finders,
1552
1863
  pass.threshold,
1553
- {
1554
- ...options,
1555
- finderMethod: pass.finderMethod,
1556
- alignmentThreshold: recovery ? 0.68 : 0.72,
1557
- alignmentGridThreshold: recovery ? 0.64 : 0.68
1558
- }
1864
+ geometryOptions
1559
1865
  );
1560
1866
 
1561
1867
  pushFinderDiagnostic(
@@ -1565,6 +1871,35 @@ function detectCodeGeometry(imageData, options = {}) {
1565
1871
  finders,
1566
1872
  geometries
1567
1873
  );
1874
+
1875
+ // Dense codes under projective distortion can leave two finder patterns
1876
+ // perfectly strong while the third is stretched enough to miss the normal
1877
+ // 1:1:3:1:1 tolerance. Only in that very specific recovery state, run one
1878
+ // bounded looser detector and let the normal three-finder geometry checks
1879
+ // reject false data-cell lookalikes. The clean fast path is unchanged.
1880
+ if (!geometries.length && recovery && finders.length === 2) {
1881
+ const recoveredFinders = recoverFinderSetFromTwo(pass.binary, width, height, finders, detector);
1882
+ if (recoveredFinders.length > 2) {
1883
+ const finderMethod = `${pass.finderMethod}-two-finder-recovery`;
1884
+ geometries = geometryCandidatesFromBinary(
1885
+ pass.binary,
1886
+ width,
1887
+ height,
1888
+ recoveredFinders,
1889
+ pass.threshold,
1890
+ { ...geometryOptions, finderMethod }
1891
+ );
1892
+ pushFinderDiagnostic(
1893
+ { ...options, width, height, diagnosticLabel: `${options.diagnosticLabel ?? "perspective"}-two-finder` },
1894
+ finderMethod,
1895
+ pass.threshold,
1896
+ recoveredFinders,
1897
+ geometries
1898
+ );
1899
+ if (geometries.length) return { finders: recoveredFinders, geometries };
1900
+ }
1901
+ }
1902
+
1568
1903
  return { finders, geometries };
1569
1904
  };
1570
1905
 
@@ -1580,6 +1915,38 @@ function detectCodeGeometry(imageData, options = {}) {
1580
1915
  if (fast.geometries.length) return fast.geometries.slice(0, maxCandidates);
1581
1916
  if (options.finderRecovery === false) return [];
1582
1917
 
1918
+ // If the clean threshold already found exactly two strong locators, try the
1919
+ // bounded perspective-tolerant third-finder pass before any color processing.
1920
+ // This is substantially cheaper than Auto Color and targets the dense-code
1921
+ // projective failure mode directly.
1922
+ if (fast.finders.length === 2) {
1923
+ const recoveredFinders = recoverFinderSetFromTwo(valueInfo.binary, width, height, fast.finders, {});
1924
+ if (recoveredFinders.length > 2) {
1925
+ const finderMethod = "rgb-value-otsu-two-finder-recovery";
1926
+ const recoveredGeometries = geometryCandidatesFromBinary(
1927
+ valueInfo.binary,
1928
+ width,
1929
+ height,
1930
+ recoveredFinders,
1931
+ valueInfo.threshold,
1932
+ {
1933
+ ...options,
1934
+ finderMethod,
1935
+ alignmentThreshold: 0.68,
1936
+ alignmentGridThreshold: 0.64
1937
+ }
1938
+ );
1939
+ pushFinderDiagnostic(
1940
+ { ...options, width, height, diagnosticLabel: `${options.diagnosticLabel ?? "perspective"}-two-finder` },
1941
+ finderMethod,
1942
+ valueInfo.threshold,
1943
+ recoveredFinders,
1944
+ recoveredGeometries
1945
+ );
1946
+ if (recoveredGeometries.length) return recoveredGeometries.slice(0, maxCandidates);
1947
+ }
1948
+ }
1949
+
1583
1950
  // Camera recovery #1: Photoshop Auto Color-style per-channel levels before
1584
1951
  // finder thresholding. Live camera frames are usually much larger than the
1585
1952
  // QR itself, so a single global histogram can be dominated by dark room/UI
@@ -1983,6 +2350,49 @@ function samplePerspectiveMatrix(imageData, homography, size, options = {}) {
1983
2350
  return { rgbGrid };
1984
2351
  }
1985
2352
 
2353
+ /**
2354
+ * Sample the two protected regions of every Triangle16 module.
2355
+ *
2356
+ * Triangle16 uses a fixed "/" diagonal. Samples are intentionally placed well
2357
+ * inside the upper-left and lower-right triangles, away from the diagonal and
2358
+ * module borders. This makes the decoder much less sensitive to antialiasing,
2359
+ * blur, resampling and small homography errors than center sampling.
2360
+ */
2361
+ function samplePerspectiveTriangleMatrix(imageData, homography, size, options = {}) {
2362
+ const triangleGrid = Array.from({ length: size }, () => new Array(size));
2363
+ const mode = options.sampleMode ?? "cross";
2364
+ const radius = options.highDensitySampleRadius ?? options.triangleSampleRadius ??
2365
+ (mode === "median" ? 0.075 : 0.085);
2366
+ const inset = Math.max(0.18, Math.min(0.36, Number(options.highDensitySampleInset ?? options.triangleSampleInset ?? 0.28)));
2367
+ const firstOffset = inset;
2368
+ const secondOffset = 1 - inset;
2369
+ const offsetX = Number.isFinite(options.sampleOffsetX) ? options.sampleOffsetX : 0;
2370
+ const offsetY = Number.isFinite(options.sampleOffsetY) ? options.sampleOffsetY : 0;
2371
+ const sampler = mode === "median" ? robustProjectedSample : averageProjectedSample;
2372
+
2373
+ for (let r = 0; r < size; r++) {
2374
+ for (let c = 0; c < size; c++) {
2375
+ triangleGrid[r][c] = {
2376
+ first: sampler(
2377
+ imageData,
2378
+ homography,
2379
+ c + firstOffset + offsetX,
2380
+ r + firstOffset + offsetY,
2381
+ radius
2382
+ ),
2383
+ second: sampler(
2384
+ imageData,
2385
+ homography,
2386
+ c + secondOffset + offsetX,
2387
+ r + secondOffset + offsetY,
2388
+ radius
2389
+ )
2390
+ };
2391
+ }
2392
+ }
2393
+ return { triangleGrid };
2394
+ }
2395
+
1986
2396
  function meanRgb(values) {
1987
2397
  if (!values.length) throw new Error("No calibration samples available.");
1988
2398
  return values.reduce(
@@ -2179,6 +2589,34 @@ function sampleAxisAlignedGrid(imageData, bounds, size, radiusRatio = 0.18) {
2179
2589
  return { rgbGrid, moduleWidth: moduleW, moduleHeight: moduleH };
2180
2590
  }
2181
2591
 
2592
+ function sampleAxisAlignedTriangleGrid(imageData, bounds, size, radiusRatio = 0.085, insetRatio = 0.28) {
2593
+ const moduleW = bounds.width / size;
2594
+ const moduleH = bounds.height / size;
2595
+ const inset = Math.max(0.18, Math.min(0.36, insetRatio));
2596
+ const radius = Math.max(0, Math.min(moduleW, moduleH) * radiusRatio);
2597
+ const triangleGrid = Array.from({ length: size }, () => new Array(size));
2598
+
2599
+ for (let r = 0; r < size; r++) {
2600
+ for (let c = 0; c < size; c++) {
2601
+ triangleGrid[r][c] = {
2602
+ first: sampleAverageAxis(
2603
+ imageData,
2604
+ bounds.x + (c + inset) * moduleW,
2605
+ bounds.y + (r + inset) * moduleH,
2606
+ radius
2607
+ ),
2608
+ second: sampleAverageAxis(
2609
+ imageData,
2610
+ bounds.x + (c + 1 - inset) * moduleW,
2611
+ bounds.y + (r + 1 - inset) * moduleH,
2612
+ radius
2613
+ )
2614
+ };
2615
+ }
2616
+ }
2617
+ return { triangleGrid, moduleWidth: moduleW, moduleHeight: moduleH };
2618
+ }
2619
+
2182
2620
  const visionInternals = Object.freeze({
2183
2621
  buildGray,
2184
2622
  otsuThreshold,
@@ -2191,21 +2629,21 @@ const visionInternals = Object.freeze({
2191
2629
  luminance
2192
2630
  });
2193
2631
 
2194
- return { computeHomography, projectPoint, detectCodeGeometry, autoColorImageData, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, samplePerspectiveMatrix, sampleObservedPalette, spatiallyNormalizeRgbGrid, rectifyImageData, findActiveBounds, sampleAxisAlignedGrid, visionInternals };
2632
+ return { computeHomography, projectPoint, detectCodeGeometry, autoColorImageData, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, samplePerspectiveMatrix, samplePerspectiveTriangleMatrix, sampleObservedPalette, spatiallyNormalizeRgbGrid, rectifyImageData, findActiveBounds, sampleAxisAlignedGrid, sampleAxisAlignedTriangleGrid, visionInternals };
2195
2633
  })();
2196
2634
  const __qqr_quadqr_js = (function () {
2197
2635
  const { MAX_CODEWORD_SYMBOLS, rsEncode, rsDecode } = __qqr_reed_solomon_js;
2198
2636
  const { alignmentPatternCentersForVersion, alignmentPatternIsBlack, alignmentPatternRadius, sizeForVersion, versionFromSize } = __qqr_geometry_js;
2199
- const { detectCodeGeometry, computeHomography, projectPoint, samplePerspectiveMatrix, rectifyImageData, sampleObservedPalette, spatiallyNormalizeRgbGrid, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, autoColorImageData, findActiveBounds, sampleAxisAlignedGrid } = __qqr_vision_js;
2637
+ const { detectCodeGeometry, computeHomography, projectPoint, samplePerspectiveMatrix, samplePerspectiveTriangleMatrix, rectifyImageData, sampleObservedPalette, spatiallyNormalizeRgbGrid, autoToneContrastColorRgbGrid, autoToneContrastColorImageData, autoColorImageData, findActiveBounds, sampleAxisAlignedGrid, sampleAxisAlignedTriangleGrid } = __qqr_vision_js;
2200
2638
  const { decryptSecurePayload, encryptSecurePayload, inspectSecureEnvelope, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex } = __qqr_security_js;
2201
2639
  /**
2202
2640
  * QuadQR
2203
2641
  *
2204
- * Experimental four-state RGBW matrix code written in pure JavaScript.
2642
+ * Experimental RGBW / Triangle16 matrix code written in pure JavaScript.
2205
2643
  *
2206
2644
  * Core format:
2207
- * - Red / Green / Blue / White data cells
2208
- * - exactly 2 bits per data cell
2645
+ * - RGBW profile: Red / Green / Blue / White, exactly 2 bits per data cell
2646
+ * - Triangle16 profile: two fixed RGBW triangle regions, 16 states / 4 bits per body data cell
2209
2647
  * - GF(256) Reed-Solomon ECC over byte symbols
2210
2648
  * - interleaved body ECC blocks
2211
2649
  * - zero-overhead spectral-spatial cell placement
@@ -2225,6 +2663,11 @@ const RENDER_MODES = Object.freeze({
2225
2663
  PRINT: "print"
2226
2664
  });
2227
2665
 
2666
+ const CELL_ENCODINGS = Object.freeze({
2667
+ RGBW: "rgbw",
2668
+ TRIANGLE16: "triangle16"
2669
+ });
2670
+
2228
2671
  // Print-safe defaults deliberately use darker, more ink-tolerant primaries and
2229
2672
  // a true white background. Applications can still override individual colors.
2230
2673
  const PRINT_PALETTE = Object.freeze({
@@ -2282,8 +2725,10 @@ const MAGIC = new Uint8Array([0x51, 0x51, 0x52, 0x57]); // QQRW (QuadQR RGBW)
2282
2725
  const HEADER_BYTES = 10;
2283
2726
  const HEADER_RS_PARITY = 8;
2284
2727
  const HEADER_CODEWORD_BYTES = HEADER_BYTES + HEADER_RS_PARITY;
2285
- const CELLS_PER_BYTE = 4;
2286
- const HEADER_CODEWORD_CELLS = HEADER_CODEWORD_BYTES * CELLS_PER_BYTE;
2728
+ const RGBW_CELLS_PER_BYTE = 4;
2729
+ const TRIANGLE16_CELLS_PER_BYTE = 2;
2730
+ const CELLS_PER_BYTE = RGBW_CELLS_PER_BYTE; // legacy internal alias
2731
+ const HEADER_CODEWORD_CELLS = HEADER_CODEWORD_BYTES * RGBW_CELLS_PER_BYTE;
2287
2732
 
2288
2733
  // Version 1 is a deliberately compact small-symbol profile. A 21x21 matrix
2289
2734
  // cannot afford the normal 18-byte protected header plus a 24-byte M body
@@ -2295,7 +2740,7 @@ const COMPACT_HEADER_MAGIC = 0xc3;
2295
2740
  const COMPACT_HEADER_BYTES = 4;
2296
2741
  const COMPACT_HEADER_RS_PARITY = 4;
2297
2742
  const COMPACT_HEADER_CODEWORD_BYTES = COMPACT_HEADER_BYTES + COMPACT_HEADER_RS_PARITY;
2298
- const COMPACT_HEADER_CODEWORD_CELLS = COMPACT_HEADER_CODEWORD_BYTES * CELLS_PER_BYTE;
2743
+ const COMPACT_HEADER_CODEWORD_CELLS = COMPACT_HEADER_CODEWORD_BYTES * RGBW_CELLS_PER_BYTE;
2299
2744
  const COMPACT_ECC_LEVELS = Object.freeze({
2300
2745
  L: Object.freeze({ paritySymbols: 4, correctableSymbolsPerBlock: 2 }),
2301
2746
  M: Object.freeze({ paritySymbols: 8, correctableSymbolsPerBlock: 4 }),
@@ -2308,6 +2753,7 @@ const TEXT_FLAG = 1;
2308
2753
  const SECURE_FLAG = 1 << 3;
2309
2754
  const EXTENDED_PAYLOAD_FLAG = 1 << 4;
2310
2755
  const SIGNED_FLAG = 1 << 5;
2756
+ const TRIANGLE16_FLAG = 1 << 6;
2311
2757
 
2312
2758
  // Internal payload extension envelope. This is deliberately not a public
2313
2759
  // payload mode or content-type system. It exists only when compression or
@@ -2370,6 +2816,45 @@ function normalizeEccLevel(level = DEFAULT_ECC_LEVEL) {
2370
2816
  return value;
2371
2817
  }
2372
2818
 
2819
+ function normalizeCellEncoding(value = CELL_ENCODINGS.RGBW) {
2820
+ const key = String(value).toLowerCase();
2821
+ assert(
2822
+ key === CELL_ENCODINGS.RGBW || key === CELL_ENCODINGS.TRIANGLE16,
2823
+ `Internal cell encoding must be ${CELL_ENCODINGS.RGBW} or ${CELL_ENCODINGS.TRIANGLE16}.`
2824
+ );
2825
+ return key;
2826
+ }
2827
+
2828
+ // Public API presents Triangle16 as one experimental High Density Mode toggle.
2829
+ // cellEncoding remains an internal/backward-compatible detail so existing
2830
+ // experimental branch symbols and renderer metadata continue to work.
2831
+ function cellEncodingFromOptions(options = {}) {
2832
+ if (typeof options.highDensity === "boolean") {
2833
+ return options.highDensity ? CELL_ENCODINGS.TRIANGLE16 : CELL_ENCODINGS.RGBW;
2834
+ }
2835
+ return normalizeCellEncoding(options.cellEncoding ?? CELL_ENCODINGS.RGBW);
2836
+ }
2837
+
2838
+ function cellsPerByteForEncoding(cellEncoding = CELL_ENCODINGS.RGBW) {
2839
+ return normalizeCellEncoding(cellEncoding) === CELL_ENCODINGS.TRIANGLE16
2840
+ ? TRIANGLE16_CELLS_PER_BYTE
2841
+ : RGBW_CELLS_PER_BYTE;
2842
+ }
2843
+
2844
+ function packTriangleCell(first, second) {
2845
+ assert(first >= 0 && first <= 3 && second >= 0 && second <= 3, "Invalid Triangle16 color.");
2846
+ return ((first & 3) << 2) | (second & 3);
2847
+ }
2848
+
2849
+ function unpackTriangleCell(value) {
2850
+ assert(Number.isInteger(value) && value >= 0 && value <= 15, "Invalid Triangle16 data cell.");
2851
+ return { first: (value >>> 2) & 3, second: value & 3 };
2852
+ }
2853
+
2854
+ function solidTriangleCell(color) {
2855
+ return packTriangleCell(color, color);
2856
+ }
2857
+
2373
2858
  function concatBytes(...arrays) {
2374
2859
  const total = arrays.reduce((sum, item) => sum + item.length, 0);
2375
2860
  const out = new Uint8Array(total);
@@ -2796,8 +3281,19 @@ function crc32(bytes) {
2796
3281
  return (crc ^ 0xffffffff) >>> 0;
2797
3282
  }
2798
3283
 
2799
- function bytesToCells(bytes) {
2800
- const out = new Array(bytes.length * CELLS_PER_BYTE);
3284
+ function bytesToCells(bytes, cellEncoding = CELL_ENCODINGS.RGBW) {
3285
+ const encoding = normalizeCellEncoding(cellEncoding);
3286
+ if (encoding === CELL_ENCODINGS.TRIANGLE16) {
3287
+ const out = new Array(bytes.length * TRIANGLE16_CELLS_PER_BYTE);
3288
+ let cursor = 0;
3289
+ for (const byte of bytes) {
3290
+ out[cursor++] = (byte >>> 4) & 0x0f;
3291
+ out[cursor++] = byte & 0x0f;
3292
+ }
3293
+ return out;
3294
+ }
3295
+
3296
+ const out = new Array(bytes.length * RGBW_CELLS_PER_BYTE);
2801
3297
  let cursor = 0;
2802
3298
  for (const byte of bytes) {
2803
3299
  out[cursor++] = (byte >>> 6) & 0b11;
@@ -2808,11 +3304,36 @@ function bytesToCells(bytes) {
2808
3304
  return out;
2809
3305
  }
2810
3306
 
2811
- function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYTE)) {
2812
- assert(cells.length >= byteCount * CELLS_PER_BYTE, "Not enough RGBW cells to rebuild bytes.");
2813
- const out = new Uint8Array(byteCount);
2814
- for (let i = 0; i < byteCount; i++) {
2815
- const offset = i * CELLS_PER_BYTE;
3307
+ function bytesToProtectedHeaderCells(bytes, cellEncoding = CELL_ENCODINGS.RGBW) {
3308
+ const base = bytesToCells(bytes, CELL_ENCODINGS.RGBW);
3309
+ if (normalizeCellEncoding(cellEncoding) !== CELL_ENCODINGS.TRIANGLE16) return base;
3310
+ // Triangle16 deliberately keeps the protected bootstrap/header as solid-color
3311
+ // modules. This costs a few cells but makes mode detection and damaged-camera
3312
+ // recovery substantially more reliable.
3313
+ return base.map(solidTriangleCell);
3314
+ }
3315
+
3316
+ function cellsToBytes(cells, byteCount, cellEncoding = CELL_ENCODINGS.RGBW) {
3317
+ const encoding = normalizeCellEncoding(cellEncoding);
3318
+ const cellsPerByte = cellsPerByteForEncoding(encoding);
3319
+ const count = byteCount ?? Math.floor(cells.length / cellsPerByte);
3320
+ assert(cells.length >= count * cellsPerByte, "Not enough data cells to rebuild bytes.");
3321
+ const out = new Uint8Array(count);
3322
+
3323
+ if (encoding === CELL_ENCODINGS.TRIANGLE16) {
3324
+ for (let i = 0; i < count; i++) {
3325
+ const offset = i * TRIANGLE16_CELLS_PER_BYTE;
3326
+ const a = cells[offset];
3327
+ const b = cells[offset + 1];
3328
+ assert(Number.isInteger(a) && a >= 0 && a <= 15, "Invalid Triangle16 data cell.");
3329
+ assert(Number.isInteger(b) && b >= 0 && b <= 15, "Invalid Triangle16 data cell.");
3330
+ out[i] = (a << 4) | b;
3331
+ }
3332
+ return out;
3333
+ }
3334
+
3335
+ for (let i = 0; i < count; i++) {
3336
+ const offset = i * RGBW_CELLS_PER_BYTE;
2816
3337
  const a = cells[offset];
2817
3338
  const b = cells[offset + 1];
2818
3339
  const c = cells[offset + 2];
@@ -2825,14 +3346,31 @@ function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYT
2825
3346
  return out;
2826
3347
  }
2827
3348
 
2828
- function flagsFor(text, eccLevel, secure = false, extended = false, signed = false) {
3349
+ function protectedHeaderCellsToBytes(cells, byteCount, cellEncoding = CELL_ENCODINGS.RGBW) {
3350
+ if (normalizeCellEncoding(cellEncoding) !== CELL_ENCODINGS.TRIANGLE16) {
3351
+ return cellsToBytes(cells, byteCount, CELL_ENCODINGS.RGBW);
3352
+ }
3353
+ const colors = cells.map((value) => {
3354
+ const pair = unpackTriangleCell(value);
3355
+ if (pair.first !== pair.second) throw new Error("Triangle16 protected header lost solid-color integrity.");
3356
+ return pair.first;
3357
+ });
3358
+ return cellsToBytes(colors, byteCount, CELL_ENCODINGS.RGBW);
3359
+ }
3360
+
3361
+ function flagsFor(text, eccLevel, secure = false, extended = false, signed = false, cellEncoding = CELL_ENCODINGS.RGBW) {
2829
3362
  return (text ? TEXT_FLAG : 0) |
2830
3363
  (secure ? SECURE_FLAG : 0) |
2831
3364
  (extended ? EXTENDED_PAYLOAD_FLAG : 0) |
2832
3365
  (signed ? SIGNED_FLAG : 0) |
3366
+ (normalizeCellEncoding(cellEncoding) === CELL_ENCODINGS.TRIANGLE16 ? TRIANGLE16_FLAG : 0) |
2833
3367
  ((ECC_LEVELS[eccLevel].id << ECC_SHIFT) & ECC_MASK);
2834
3368
  }
2835
3369
 
3370
+ function cellEncodingFromFlags(flags) {
3371
+ return (flags & TRIANGLE16_FLAG) !== 0 ? CELL_ENCODINGS.TRIANGLE16 : CELL_ENCODINGS.RGBW;
3372
+ }
3373
+
2836
3374
  function eccFromFlags(flags) {
2837
3375
  const id = (flags & ECC_MASK) >> ECC_SHIFT;
2838
3376
  const name = ECC_BY_ID[id];
@@ -3082,7 +3620,8 @@ function maskValue(row, col, maskId) {
3082
3620
  }
3083
3621
  }
3084
3622
 
3085
- function makePaddingCells(count, seed) {
3623
+ function makePaddingCells(count, seed, cellEncoding = CELL_ENCODINGS.RGBW) {
3624
+ const encoding = normalizeCellEncoding(cellEncoding);
3086
3625
  let state = (seed >>> 0) || 0x6d2b79f5;
3087
3626
  const out = new Array(count);
3088
3627
  for (let i = 0; i < count; i++) {
@@ -3090,11 +3629,27 @@ function makePaddingCells(count, seed) {
3090
3629
  state ^= state >>> 17;
3091
3630
  state ^= state << 5;
3092
3631
  state >>>= 0;
3093
- out[i] = state & 3;
3632
+ out[i] = encoding === CELL_ENCODINGS.TRIANGLE16 ? (state & 0x0f) : (state & 3);
3094
3633
  }
3095
3634
  return out;
3096
3635
  }
3097
3636
 
3637
+ function triangleMaskValue(row, col, maskId) {
3638
+ const first = maskValue(row, col, maskId);
3639
+ const second = maskValue(row + 1, col + 2, (maskId + 1) & 3);
3640
+ return packTriangleCell(first, second);
3641
+ }
3642
+
3643
+ function applyCellMask(value, row, col, maskId, cellEncoding, protectedHeader = false) {
3644
+ if (cellEncoding !== CELL_ENCODINGS.TRIANGLE16) return value ^ maskValue(row, col, maskId);
3645
+ if (protectedHeader) {
3646
+ const pair = unpackTriangleCell(value);
3647
+ if (pair.first !== pair.second) throw new Error("Triangle16 protected header must use solid-color cells.");
3648
+ return solidTriangleCell(pair.first ^ maskValue(row, col, maskId));
3649
+ }
3650
+ return value ^ triangleMaskValue(row, col, maskId);
3651
+ }
3652
+
3098
3653
  const SPECTRAL_PERMUTATION_CACHE = new Map();
3099
3654
 
3100
3655
  function spectralPermutation(length, version) {
@@ -3123,7 +3678,15 @@ function spectralPermutation(length, version) {
3123
3678
  return permutation;
3124
3679
  }
3125
3680
 
3126
- function applyData(layout, rawCells, maskId, spectralInterleaving = true) {
3681
+ function applyData(
3682
+ layout,
3683
+ rawCells,
3684
+ maskId,
3685
+ spectralInterleaving = true,
3686
+ cellEncoding = CELL_ENCODINGS.RGBW,
3687
+ protectedHeaderCells = getHeaderPlan(layout.version).codewordCells
3688
+ ) {
3689
+ const encoding = normalizeCellEncoding(cellEncoding);
3127
3690
  const matrix = cloneMatrix(layout.matrix);
3128
3691
  const permutation = spectralInterleaving
3129
3692
  ? spectralPermutation(layout.dataPositions.length, layout.version)
@@ -3132,16 +3695,37 @@ function applyData(layout, rawCells, maskId, spectralInterleaving = true) {
3132
3695
  for (let logicalIndex = 0; logicalIndex < layout.dataPositions.length; logicalIndex++) {
3133
3696
  const physicalIndex = permutation ? permutation[logicalIndex] : logicalIndex;
3134
3697
  const [row, col] = layout.dataPositions[physicalIndex];
3135
- matrix[row][col] = rawCells[logicalIndex] ^ maskValue(row, col, maskId);
3698
+ matrix[row][col] = applyCellMask(
3699
+ rawCells[logicalIndex],
3700
+ row,
3701
+ col,
3702
+ maskId,
3703
+ encoding,
3704
+ encoding === CELL_ENCODINGS.TRIANGLE16 && logicalIndex < protectedHeaderCells
3705
+ );
3136
3706
  }
3137
3707
  return matrix;
3138
3708
  }
3139
3709
 
3140
- function quaternaryPenalty(matrix, reserved) {
3710
+ function dataCellPenalty(matrix, reserved, cellEncoding = CELL_ENCODINGS.RGBW) {
3711
+ const encoding = normalizeCellEncoding(cellEncoding);
3141
3712
  const size = matrix.length;
3142
3713
  let penalty = 0;
3143
- const counts = [0, 0, 0, 0];
3144
- let dataCount = 0;
3714
+ const colorCounts = [0, 0, 0, 0];
3715
+ let colorCount = 0;
3716
+
3717
+ const symbolAt = (r, c) => matrix[r][c];
3718
+ const addColors = (value) => {
3719
+ if (encoding === CELL_ENCODINGS.TRIANGLE16) {
3720
+ const pair = unpackTriangleCell(value);
3721
+ colorCounts[pair.first]++;
3722
+ colorCounts[pair.second]++;
3723
+ colorCount += 2;
3724
+ } else {
3725
+ colorCounts[value]++;
3726
+ colorCount++;
3727
+ }
3728
+ };
3145
3729
 
3146
3730
  for (let r = 0; r < size; r++) {
3147
3731
  let previous = null;
@@ -3152,9 +3736,8 @@ function quaternaryPenalty(matrix, reserved) {
3152
3736
  run = 0;
3153
3737
  continue;
3154
3738
  }
3155
- const value = matrix[r][c];
3156
- counts[value]++;
3157
- dataCount++;
3739
+ const value = symbolAt(r, c);
3740
+ addColors(value);
3158
3741
  if (value === previous) {
3159
3742
  run++;
3160
3743
  if (run >= 4) penalty += 2;
@@ -3174,7 +3757,7 @@ function quaternaryPenalty(matrix, reserved) {
3174
3757
  run = 0;
3175
3758
  continue;
3176
3759
  }
3177
- const value = matrix[r][c];
3760
+ const value = symbolAt(r, c);
3178
3761
  if (value === previous) {
3179
3762
  run++;
3180
3763
  if (run >= 4) penalty += 2;
@@ -3185,9 +3768,9 @@ function quaternaryPenalty(matrix, reserved) {
3185
3768
  }
3186
3769
  }
3187
3770
 
3188
- if (dataCount > 0) {
3189
- const ideal = dataCount / 4;
3190
- penalty += counts.reduce((sum, count) => sum + Math.abs(count - ideal), 0) / 2;
3771
+ if (colorCount > 0) {
3772
+ const ideal = colorCount / 4;
3773
+ penalty += colorCounts.reduce((sum, count) => sum + Math.abs(count - ideal), 0) / 2;
3191
3774
  }
3192
3775
  return penalty;
3193
3776
  }
@@ -3216,7 +3799,7 @@ function deinterleaveBlocks(stream, blockLengths) {
3216
3799
  return blocks;
3217
3800
  }
3218
3801
 
3219
- function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
3802
+ function getBodyRsPlan(payloadLength, eccLevel, version = 2, cellEncoding = CELL_ENCODINGS.RGBW) {
3220
3803
  const ecc = getEffectiveEcc(version, eccLevel);
3221
3804
  const bodyByteCount = payloadLength + CRC_BYTES;
3222
3805
  const dataSymbols = bodyByteCount;
@@ -3238,25 +3821,27 @@ function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
3238
3821
  dataBlockLengths,
3239
3822
  codewordBlockLengths,
3240
3823
  encodedSymbols,
3241
- encodedCells: encodedSymbols * CELLS_PER_BYTE
3824
+ encodedCells: encodedSymbols * cellsPerByteForEncoding(cellEncoding)
3242
3825
  };
3243
3826
  }
3244
3827
 
3245
- function streamCellCount(payloadLength, eccLevel, version) {
3246
- return getHeaderPlan(version).codewordCells + getBodyRsPlan(payloadLength, eccLevel, version).encodedCells;
3828
+ function streamCellCount(payloadLength, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
3829
+ return getHeaderPlan(version).codewordCells +
3830
+ getBodyRsPlan(payloadLength, eccLevel, version, cellEncoding).encodedCells;
3247
3831
  }
3248
3832
 
3249
- function streamFitsLayout(layout, eccLevel, payloadLength, version) {
3250
- return streamCellCount(payloadLength, eccLevel, version) <= layout.dataPositions.length;
3833
+ function streamFitsLayout(layout, eccLevel, payloadLength, version, cellEncoding = CELL_ENCODINGS.RGBW) {
3834
+ return streamCellCount(payloadLength, eccLevel, version, cellEncoding) <= layout.dataPositions.length;
3251
3835
  }
3252
3836
 
3253
- function getCapacityForLayout(layout, eccLevel, version) {
3254
- if (!streamFitsLayout(layout, eccLevel, 0, version)) return 0;
3837
+ function getCapacityForLayout(layout, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
3838
+ const encoding = normalizeCellEncoding(cellEncoding);
3839
+ if (!streamFitsLayout(layout, eccLevel, 0, version, encoding)) return 0;
3255
3840
  let low = 0;
3256
- let high = Math.floor(layout.dataPositions.length / CELLS_PER_BYTE);
3841
+ let high = Math.floor(layout.dataPositions.length / cellsPerByteForEncoding(encoding));
3257
3842
  while (low < high) {
3258
3843
  const mid = Math.ceil((low + high) / 2);
3259
- if (streamFitsLayout(layout, eccLevel, mid, version)) low = mid;
3844
+ if (streamFitsLayout(layout, eccLevel, mid, version, encoding)) low = mid;
3260
3845
  else high = mid - 1;
3261
3846
  }
3262
3847
  return low;
@@ -3265,6 +3850,7 @@ function getCapacityForLayout(layout, eccLevel, version) {
3265
3850
  function getVersionInfo(version, options = {}) {
3266
3851
  validateVersion(version);
3267
3852
  const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
3853
+ const cellEncoding = cellEncodingFromOptions(options);
3268
3854
  const layout = createLayout(version);
3269
3855
  const headerPlan = getHeaderPlan(version);
3270
3856
  const effectiveEcc = getEffectiveEcc(version, eccLevel);
@@ -3274,8 +3860,8 @@ function getVersionInfo(version, options = {}) {
3274
3860
  eccLevel,
3275
3861
  size: layout.size,
3276
3862
  dataCells: layout.dataPositions.length,
3277
- theoreticalBits: layout.dataPositions.length * 2,
3278
- capacityBytes: getCapacityForLayout(layout, eccLevel, version),
3863
+ theoreticalBits: layout.dataPositions.length * (cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2),
3864
+ capacityBytes: getCapacityForLayout(layout, eccLevel, version, cellEncoding),
3279
3865
  headerCells: headerPlan.codewordCells,
3280
3866
  headerBytes: headerPlan.headerBytes,
3281
3867
  headerParitySymbols: headerPlan.paritySymbols,
@@ -3287,8 +3873,11 @@ function getVersionInfo(version, options = {}) {
3287
3873
  hasAlignmentMarker: true,
3288
3874
  alignmentPatterns: layout.alignments.length,
3289
3875
  alignmentCenters: layout.alignments.map(({ row, col }) => [row, col]),
3290
- bitsPerDataCell: 2,
3876
+ bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
3877
+ highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
3878
+ cellEncoding,
3291
3879
  colors: 4,
3880
+ statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
3292
3881
  spectralInterleaving: true,
3293
3882
  confidenceAwareEcc: true
3294
3883
  };
@@ -3299,6 +3888,7 @@ function chooseVersion(payloadLength, options = {}) {
3299
3888
  const minVersion = options.minVersion ?? MIN_VERSION;
3300
3889
  const maxVersion = options.maxVersion ?? MAX_VERSION;
3301
3890
  const ecc = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
3891
+ const cellEncoding = cellEncodingFromOptions(options);
3302
3892
 
3303
3893
  validateVersion(minVersion);
3304
3894
  validateVersion(maxVersion);
@@ -3307,10 +3897,10 @@ function chooseVersion(payloadLength, options = {}) {
3307
3897
  if (requested !== "auto") {
3308
3898
  validateVersion(requested);
3309
3899
  assert(requested >= minVersion && requested <= maxVersion, "Requested version is outside selected bounds.");
3310
- const info = getVersionInfo(requested, { ecc });
3900
+ const info = getVersionInfo(requested, { ecc, cellEncoding });
3311
3901
  const layout = createLayout(requested);
3312
3902
  assert(
3313
- payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested),
3903
+ payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested, cellEncoding),
3314
3904
  `Payload does not fit version ${requested} with ${ecc} ECC. Maximum is ${info.capacityBytes} bytes.`
3315
3905
  );
3316
3906
  return requested;
@@ -3318,20 +3908,20 @@ function chooseVersion(payloadLength, options = {}) {
3318
3908
 
3319
3909
  for (let version = minVersion; version <= maxVersion; version++) {
3320
3910
  const layout = createLayout(version);
3321
- if (streamFitsLayout(layout, ecc, payloadLength, version)) return version;
3911
+ if (streamFitsLayout(layout, ecc, payloadLength, version, cellEncoding)) return version;
3322
3912
  }
3323
3913
  throw new Error(`Payload is too large for versions ${minVersion}..${maxVersion}.`);
3324
3914
  }
3325
3915
 
3326
- function encodeProtectedHeader(header, version) {
3916
+ function encodeProtectedHeader(header, version, cellEncoding = CELL_ENCODINGS.RGBW) {
3327
3917
  const plan = getHeaderPlan(version);
3328
3918
  const codeword = rsEncode(Array.from(header), plan.paritySymbols);
3329
3919
  assert(codeword.length === plan.codewordBytes, "Header RS symbol calculation mismatch.");
3330
- return bytesToCells(codeword);
3920
+ return bytesToProtectedHeaderCells(codeword, cellEncoding);
3331
3921
  }
3332
3922
 
3333
- function encodeProtectedBody(bodyBytes, eccLevel, version) {
3334
- const plan = getBodyRsPlan(bodyBytes.length - CRC_BYTES, eccLevel, version);
3923
+ function encodeProtectedBody(bodyBytes, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
3924
+ const plan = getBodyRsPlan(bodyBytes.length - CRC_BYTES, eccLevel, version, cellEncoding);
3335
3925
  assert(bodyBytes.length === plan.dataSymbols, "Body RS symbol calculation mismatch.");
3336
3926
 
3337
3927
  const blocks = [];
@@ -3340,7 +3930,7 @@ function encodeProtectedBody(bodyBytes, eccLevel, version) {
3340
3930
  blocks.push(rsEncode(Array.from(bodyBytes.slice(offset, offset + length)), plan.paritySymbols));
3341
3931
  offset += length;
3342
3932
  }
3343
- return { cells: bytesToCells(interleaveBlocks(blocks)), plan };
3933
+ return { cells: bytesToCells(interleaveBlocks(blocks), cellEncoding), plan };
3344
3934
  }
3345
3935
 
3346
3936
  function finalizeMatrix(layout, rawCells, meta) {
@@ -3348,9 +3938,10 @@ function finalizeMatrix(layout, rawCells, meta) {
3348
3938
  let bestMatrix = null;
3349
3939
  let bestPenalty = Infinity;
3350
3940
 
3941
+ const cellEncoding = normalizeCellEncoding(meta.cellEncoding ?? CELL_ENCODINGS.RGBW);
3351
3942
  for (let maskId = 0; maskId < 4; maskId++) {
3352
- const candidate = applyData(layout, rawCells, maskId, true);
3353
- const penalty = quaternaryPenalty(candidate, layout.reserved);
3943
+ const candidate = applyData(layout, rawCells, maskId, true, cellEncoding);
3944
+ const penalty = dataCellPenalty(candidate, layout.reserved, cellEncoding);
3354
3945
  if (penalty < bestPenalty) {
3355
3946
  bestPenalty = penalty;
3356
3947
  bestMaskId = maskId;
@@ -3358,7 +3949,7 @@ function finalizeMatrix(layout, rawCells, meta) {
3358
3949
  }
3359
3950
  }
3360
3951
 
3361
- const info = getVersionInfo(meta.version, { ecc: meta.eccLevel });
3952
+ const info = getVersionInfo(meta.version, { ecc: meta.eccLevel, cellEncoding });
3362
3953
  return {
3363
3954
  format: "QuadQR",
3364
3955
  formatVersion: FORMAT_VERSION,
@@ -3381,7 +3972,10 @@ function finalizeMatrix(layout, rawCells, meta) {
3381
3972
  capacityBytes: info.capacityBytes,
3382
3973
  alignmentPatterns: layout.alignments.length,
3383
3974
  utilization: meta.meaningfulCells / layout.dataPositions.length,
3384
- bitsPerDataCell: 2,
3975
+ bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
3976
+ highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
3977
+ cellEncoding,
3978
+ statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
3385
3979
  eccLevel: meta.eccLevel,
3386
3980
  eccParitySymbols: meta.eccParitySymbols,
3387
3981
  eccBlocks: meta.eccBlocks,
@@ -3402,23 +3996,26 @@ function encodePreparedBytes(input, options = {}) {
3402
3996
  const secure = Boolean(options.secure);
3403
3997
  const extended = Boolean(options.extended);
3404
3998
  const signed = Boolean(options.signed);
3405
- const flags = flagsFor(Boolean(options.text), eccLevel, secure, extended, signed);
3406
- const version = chooseVersion(payload.length, { ...options, ecc: eccLevel });
3999
+ const cellEncoding = cellEncodingFromOptions(options);
4000
+ const flags = flagsFor(Boolean(options.text), eccLevel, secure, extended, signed, cellEncoding);
4001
+ const version = chooseVersion(payload.length, { ...options, ecc: eccLevel, cellEncoding });
3407
4002
  const layout = createLayout(version);
3408
4003
  const header = makeHeader(payload.length, flags, version);
3409
4004
  const crc = crc32(concatBytes(header, payload));
3410
- const headerCells = encodeProtectedHeader(header, version);
3411
- const bodyEncoded = encodeProtectedBody(concatBytes(payload, u32be(crc)), eccLevel, version);
4005
+ const headerCells = encodeProtectedHeader(header, version, cellEncoding);
4006
+ const bodyEncoded = encodeProtectedBody(concatBytes(payload, u32be(crc)), eccLevel, version, cellEncoding);
3412
4007
  const meaningfulCells = headerCells.concat(bodyEncoded.cells);
3413
4008
  assert(meaningfulCells.length <= layout.dataPositions.length, "Internal QuadQR capacity calculation error.");
3414
4009
 
3415
4010
  const padding = makePaddingCells(
3416
4011
  layout.dataPositions.length - meaningfulCells.length,
3417
- crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16)
4012
+ crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16),
4013
+ cellEncoding
3418
4014
  );
3419
4015
 
3420
4016
  return finalizeMatrix(layout, meaningfulCells.concat(padding), {
3421
4017
  version,
4018
+ cellEncoding,
3422
4019
  payloadBytes: payload.length,
3423
4020
  sourcePayloadBytes: options.sourcePayloadBytes ?? payload.length,
3424
4021
  secure,
@@ -3648,12 +4245,14 @@ function rotate90(matrix) {
3648
4245
  return out;
3649
4246
  }
3650
4247
 
3651
- function extractVisibleCells(matrix, layout) {
4248
+ function extractVisibleCells(matrix, layout, cellEncoding = CELL_ENCODINGS.RGBW) {
4249
+ const encoding = normalizeCellEncoding(cellEncoding);
4250
+ const maxValue = encoding === CELL_ENCODINGS.TRIANGLE16 ? 15 : 3;
3652
4251
  const out = [];
3653
4252
  for (const [row, col] of layout.dataPositions) {
3654
4253
  const value = matrix[row][col];
3655
- if (!Number.isInteger(value) || value < 0 || value > 3) {
3656
- throw new Error("Data region contains an invalid QuadQR data cell.");
4254
+ if (!Number.isInteger(value) || value < 0 || value > maxValue) {
4255
+ throw new Error(`Data region contains an invalid ${encoding === CELL_ENCODINGS.TRIANGLE16 ? "Triangle16" : "RGBW"} data cell.`);
3657
4256
  }
3658
4257
  out.push(value);
3659
4258
  }
@@ -3667,6 +4266,34 @@ function unmaskCells(visibleCells, positions, maskId) {
3667
4266
  });
3668
4267
  }
3669
4268
 
4269
+ function unmaskAndRestoreLogicalCells(
4270
+ visibleCells,
4271
+ positions,
4272
+ maskId,
4273
+ version,
4274
+ spectralInterleaving,
4275
+ cellEncoding = CELL_ENCODINGS.RGBW
4276
+ ) {
4277
+ const encoding = normalizeCellEncoding(cellEncoding);
4278
+ const permutation = spectralInterleaving ? spectralPermutation(visibleCells.length, version) : null;
4279
+ const logical = new Array(visibleCells.length);
4280
+ const headerCells = getHeaderPlan(version).codewordCells;
4281
+
4282
+ for (let logicalIndex = 0; logicalIndex < visibleCells.length; logicalIndex++) {
4283
+ const physicalIndex = permutation ? permutation[logicalIndex] : logicalIndex;
4284
+ const [row, col] = positions[physicalIndex];
4285
+ logical[logicalIndex] = applyCellMask(
4286
+ visibleCells[physicalIndex],
4287
+ row,
4288
+ col,
4289
+ maskId,
4290
+ encoding,
4291
+ encoding === CELL_ENCODINGS.TRIANGLE16 && logicalIndex < headerCells
4292
+ );
4293
+ }
4294
+ return logical;
4295
+ }
4296
+
3670
4297
  function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
3671
4298
  if (!spectralInterleaving) return physicalValues.slice();
3672
4299
  const permutation = spectralPermutation(physicalValues.length, version);
@@ -3677,20 +4304,21 @@ function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
3677
4304
  return logical;
3678
4305
  }
3679
4306
 
3680
- function cellsToSymbolConfidences(confidences, byteCount) {
4307
+ function cellsToSymbolConfidences(
4308
+ confidences,
4309
+ byteCount,
4310
+ cellsPerByte = RGBW_CELLS_PER_BYTE
4311
+ ) {
3681
4312
  if (!confidences) return null;
3682
- if (confidences.length < byteCount * CELLS_PER_BYTE) return null;
4313
+ if (confidences.length < byteCount * cellsPerByte) return null;
3683
4314
  const out = new Array(byteCount);
3684
4315
  for (let symbolIndex = 0; symbolIndex < byteCount; symbolIndex++) {
3685
- const offset = symbolIndex * CELLS_PER_BYTE;
3686
- // A single wrong 2-bit cell changes the GF(256) symbol, so the weakest
3687
- // constituent cell is the useful confidence bound for that symbol.
3688
- out[symbolIndex] = Math.min(
3689
- confidences[offset] ?? 1,
3690
- confidences[offset + 1] ?? 1,
3691
- confidences[offset + 2] ?? 1,
3692
- confidences[offset + 3] ?? 1
3693
- );
4316
+ const offset = symbolIndex * cellsPerByte;
4317
+ let value = 1;
4318
+ for (let cellIndex = 0; cellIndex < cellsPerByte; cellIndex++) {
4319
+ value = Math.min(value, confidences[offset + cellIndex] ?? 1);
4320
+ }
4321
+ out[symbolIndex] = value;
3694
4322
  }
3695
4323
  return out;
3696
4324
  }
@@ -3727,11 +4355,11 @@ function decodeRsAdaptive(codeword, paritySymbols, symbolConfidences, options =
3727
4355
  }
3728
4356
  }
3729
4357
 
3730
- function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}) {
4358
+ function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}, cellEncoding = CELL_ENCODINGS.RGBW) {
3731
4359
  const plan = getHeaderPlan(version);
3732
4360
  const headerCells = rawCells.slice(0, plan.codewordCells);
3733
- const codeword = Array.from(cellsToBytes(headerCells, plan.codewordBytes));
3734
- const confidences = cellsToSymbolConfidences(rawConfidences?.slice(0, plan.codewordCells), plan.codewordBytes);
4361
+ const codeword = Array.from(protectedHeaderCellsToBytes(headerCells, plan.codewordBytes, cellEncoding));
4362
+ const confidences = cellsToSymbolConfidences(rawConfidences?.slice(0, plan.codewordCells), plan.codewordBytes, RGBW_CELLS_PER_BYTE);
3735
4363
  const decoded = decodeRsAdaptive(codeword, plan.paritySymbols, confidences, options);
3736
4364
  const header = new Uint8Array(decoded.data);
3737
4365
  const parsed = parseHeader(header, version);
@@ -3746,16 +4374,17 @@ function decodeProtectedHeader(rawCells, version, rawConfidences = null, options
3746
4374
  };
3747
4375
  }
3748
4376
 
3749
- function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConfidences = null, options = {}) {
3750
- const plan = getBodyRsPlan(payloadLength, eccLevel, version);
4377
+ function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConfidences = null, options = {}, cellEncoding = CELL_ENCODINGS.RGBW) {
4378
+ const plan = getBodyRsPlan(payloadLength, eccLevel, version, cellEncoding);
3751
4379
  const bodyStart = getHeaderPlan(version).codewordCells;
3752
4380
  const encodedCells = rawCells.slice(bodyStart, bodyStart + plan.encodedCells);
3753
4381
  if (encodedCells.length !== plan.encodedCells) throw new Error("Protected body is incomplete.");
3754
4382
 
3755
- const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols));
4383
+ const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols, cellEncoding));
3756
4384
  const symbolConfidences = cellsToSymbolConfidences(
3757
4385
  rawConfidences?.slice(bodyStart, bodyStart + plan.encodedCells),
3758
- plan.encodedSymbols
4386
+ plan.encodedSymbols,
4387
+ cellsPerByteForEncoding(cellEncoding)
3759
4388
  );
3760
4389
  const blocks = deinterleaveBlocks(encodedSymbols, plan.codewordBlockLengths);
3761
4390
  const confidenceBlocks = symbolConfidences
@@ -3798,96 +4427,136 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
3798
4427
  if (!validateStructure(matrix, tolerance)) throw new Error("QuadQR finder/alignment structure does not match.");
3799
4428
 
3800
4429
  const layout = createLayout(version);
3801
- const visible = extractVisibleCells(matrix, layout);
3802
- const visibleConfidences = confidenceMatrix
3803
- ? layout.dataPositions.map(([row, col]) => confidenceMatrix[row]?.[col] ?? 1)
3804
- : null;
3805
4430
  const errors = [];
4431
+ const hintedEncoding = options.cellEncodingHint != null
4432
+ ? normalizeCellEncoding(options.cellEncodingHint)
4433
+ : null;
4434
+ const hasExtendedCells = layout.dataPositions.some(([row, col]) => (matrix[row]?.[col] ?? 0) > 3);
4435
+ const encodingAttempts = hintedEncoding
4436
+ ? [hintedEncoding]
4437
+ : hasExtendedCells
4438
+ ? [CELL_ENCODINGS.TRIANGLE16, CELL_ENCODINGS.RGBW]
4439
+ : [CELL_ENCODINGS.RGBW, CELL_ENCODINGS.TRIANGLE16];
4440
+
4441
+ for (const cellEncoding of encodingAttempts) {
4442
+ let visible;
4443
+ try {
4444
+ visible = extractVisibleCells(matrix, layout, cellEncoding);
4445
+ } catch (error) {
4446
+ errors.push(`${cellEncoding}: ${error.message}`);
4447
+ continue;
4448
+ }
3806
4449
 
3807
- // New symbols use spectral-spatial interleaving. Legacy order remains a
3808
- // decode fallback so existing QuadQR images do not become unreadable.
3809
- for (const spectralInterleaving of [true, false]) {
3810
- for (let maskId = 0; maskId < 4; maskId++) {
3811
- try {
3812
- const physicalRaw = unmaskCells(visible, layout.dataPositions, maskId);
3813
- const raw = restoreLogicalOrder(physicalRaw, version, spectralInterleaving);
3814
- const rawConfidences = visibleConfidences
3815
- ? restoreLogicalOrder(visibleConfidences, version, spectralInterleaving)
3816
- : null;
3817
- const headerDecoded = decodeProtectedHeader(raw, version, rawConfidences, options);
3818
- const header = headerDecoded.header;
3819
- const flags = headerDecoded.flags;
3820
- const eccLevel = eccFromFlags(flags);
3821
- const payloadLength = headerDecoded.payloadLength;
3822
- if (streamCellCount(payloadLength, eccLevel, version) > raw.length) {
3823
- throw new Error("Declared payload exceeds matrix capacity.");
3824
- }
4450
+ const visibleConfidences = confidenceMatrix
4451
+ ? layout.dataPositions.map(([row, col]) => confidenceMatrix[row]?.[col] ?? 1)
4452
+ : null;
3825
4453
 
3826
- const bodyDecoded = decodeProtectedBody(
3827
- raw,
3828
- payloadLength,
3829
- eccLevel,
3830
- version,
3831
- rawConfidences,
3832
- options
3833
- );
3834
- const body = bodyDecoded.body;
3835
- const payload = body.slice(0, payloadLength);
3836
- const expectedCrc = readU32be(body, payloadLength);
3837
- const actualCrc = crc32(concatBytes(header, payload));
3838
- if (expectedCrc !== actualCrc) throw new Error("CRC mismatch after ECC.");
3839
-
3840
- const isText = (flags & TEXT_FLAG) !== 0;
3841
- const secure = (flags & SECURE_FLAG) !== 0;
3842
- const extendedFlag = (flags & EXTENDED_PAYLOAD_FLAG) !== 0;
3843
- const signedFlag = (flags & SIGNED_FLAG) !== 0;
3844
- const security = secure ? inspectSecureEnvelope(payload) : null;
3845
- const envelope = extendedFlag && !secure ? parsePayloadEnvelope(payload) : null;
3846
- const applicationPayload = envelope ? envelope.payload : payload;
3847
- const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
3848
- const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
3849
- return {
3850
- ok: true,
3851
- format: "QuadQR",
3852
- formatVersion: FORMAT_VERSION,
3853
- version,
3854
- size,
3855
- alignmentPatterns: layout.alignments.length,
3856
- maskId,
3857
- rotation,
3858
- flags,
3859
- eccLevel,
3860
- eccParitySymbols: bodyDecoded.plan.paritySymbols,
3861
- eccBlocks: bodyDecoded.plan.dataBlockLengths.length,
3862
- correctableSymbolsPerBlock: bodyDecoded.plan.correctableSymbolsPerBlock,
3863
- spectralInterleaving,
3864
- confidenceAwareEcc: Boolean(rawConfidences),
3865
- confidenceAssisted: headerDecoded.confidenceAssisted || bodyDecoded.confidenceAssisted,
3866
- erasureSymbols,
3867
- unknownErrorSymbols,
3868
- correctedHeaderSymbols: headerDecoded.correctedSymbols,
3869
- correctedBodySymbols: bodyDecoded.correctedSymbols,
3870
- correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
3871
- protectedPayload: envelope ? payload : undefined,
3872
- payload: secure ? payload : applicationPayload,
3873
- text: secure ? null : (isText ? getTextDecoder().decode(applicationPayload) : null),
3874
- compressed: envelope?.compressed ?? false,
3875
- compression: envelope?.compression ?? "none",
3876
- signed: envelope?.signed ?? signedFlag,
3877
- signatureVerified: envelope?.signatureVerified,
3878
- signatureTrusted: envelope?.signatureTrusted,
3879
- signingKeyId: envelope?.keyId ?? null,
3880
- hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? false,
3881
- signer: envelope?.signer ?? null,
3882
- secure,
3883
- encrypted: secure,
3884
- decrypted: false,
3885
- requiresDecryption: secure,
3886
- security,
3887
- crc32: actualCrc >>> 0
3888
- };
3889
- } catch (error) {
3890
- errors.push(`${spectralInterleaving ? "spectral" : "legacy"} mask ${maskId}: ${error.message}`);
4454
+ // New symbols use spectral-spatial interleaving. Legacy order remains a
4455
+ // decode fallback so existing RGBW QuadQR images do not become unreadable.
4456
+ for (const spectralInterleaving of [true, false]) {
4457
+ for (let maskId = 0; maskId < 4; maskId++) {
4458
+ try {
4459
+ const raw = unmaskAndRestoreLogicalCells(
4460
+ visible,
4461
+ layout.dataPositions,
4462
+ maskId,
4463
+ version,
4464
+ spectralInterleaving,
4465
+ cellEncoding
4466
+ );
4467
+ const rawConfidences = visibleConfidences
4468
+ ? restoreLogicalOrder(visibleConfidences, version, spectralInterleaving)
4469
+ : null;
4470
+ const headerDecoded = decodeProtectedHeader(
4471
+ raw,
4472
+ version,
4473
+ rawConfidences,
4474
+ options,
4475
+ cellEncoding
4476
+ );
4477
+ const header = headerDecoded.header;
4478
+ const flags = headerDecoded.flags;
4479
+ const declaredCellEncoding = cellEncodingFromFlags(flags);
4480
+ if (declaredCellEncoding !== cellEncoding) {
4481
+ throw new Error(`Header declares ${declaredCellEncoding}, not ${cellEncoding}.`);
4482
+ }
4483
+ const eccLevel = eccFromFlags(flags);
4484
+ const payloadLength = headerDecoded.payloadLength;
4485
+ if (streamCellCount(payloadLength, eccLevel, version, cellEncoding) > raw.length) {
4486
+ throw new Error("Declared payload exceeds matrix capacity.");
4487
+ }
4488
+
4489
+ const bodyDecoded = decodeProtectedBody(
4490
+ raw,
4491
+ payloadLength,
4492
+ eccLevel,
4493
+ version,
4494
+ rawConfidences,
4495
+ options,
4496
+ cellEncoding
4497
+ );
4498
+ const body = bodyDecoded.body;
4499
+ const payload = body.slice(0, payloadLength);
4500
+ const expectedCrc = readU32be(body, payloadLength);
4501
+ const actualCrc = crc32(concatBytes(header, payload));
4502
+ if (expectedCrc !== actualCrc) throw new Error("CRC mismatch after ECC.");
4503
+
4504
+ const isText = (flags & TEXT_FLAG) !== 0;
4505
+ const secure = (flags & SECURE_FLAG) !== 0;
4506
+ const extendedFlag = (flags & EXTENDED_PAYLOAD_FLAG) !== 0;
4507
+ const signedFlag = (flags & SIGNED_FLAG) !== 0;
4508
+ const security = secure ? inspectSecureEnvelope(payload) : null;
4509
+ const envelope = extendedFlag && !secure ? parsePayloadEnvelope(payload) : null;
4510
+ const applicationPayload = envelope ? envelope.payload : payload;
4511
+ const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
4512
+ const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
4513
+ return {
4514
+ ok: true,
4515
+ format: "QuadQR",
4516
+ formatVersion: FORMAT_VERSION,
4517
+ version,
4518
+ size,
4519
+ alignmentPatterns: layout.alignments.length,
4520
+ maskId,
4521
+ rotation,
4522
+ flags,
4523
+ highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
4524
+ cellEncoding,
4525
+ bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
4526
+ statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
4527
+ eccLevel,
4528
+ eccParitySymbols: bodyDecoded.plan.paritySymbols,
4529
+ eccBlocks: bodyDecoded.plan.dataBlockLengths.length,
4530
+ correctableSymbolsPerBlock: bodyDecoded.plan.correctableSymbolsPerBlock,
4531
+ spectralInterleaving,
4532
+ confidenceAwareEcc: Boolean(rawConfidences),
4533
+ confidenceAssisted: headerDecoded.confidenceAssisted || bodyDecoded.confidenceAssisted,
4534
+ erasureSymbols,
4535
+ unknownErrorSymbols,
4536
+ correctedHeaderSymbols: headerDecoded.correctedSymbols,
4537
+ correctedBodySymbols: bodyDecoded.correctedSymbols,
4538
+ correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
4539
+ protectedPayload: envelope ? payload : undefined,
4540
+ payload: secure ? payload : applicationPayload,
4541
+ text: secure ? null : (isText ? getTextDecoder().decode(applicationPayload) : null),
4542
+ compressed: envelope?.compressed ?? false,
4543
+ compression: envelope?.compression ?? "none",
4544
+ signed: envelope?.signed ?? signedFlag,
4545
+ signatureVerified: envelope?.signatureVerified,
4546
+ signatureTrusted: envelope?.signatureTrusted,
4547
+ signingKeyId: envelope?.keyId ?? null,
4548
+ hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? false,
4549
+ signer: envelope?.signer ?? null,
4550
+ secure,
4551
+ encrypted: secure,
4552
+ decrypted: false,
4553
+ requiresDecryption: secure,
4554
+ security,
4555
+ crc32: actualCrc >>> 0
4556
+ };
4557
+ } catch (error) {
4558
+ errors.push(`${cellEncoding} ${spectralInterleaving ? "spectral" : "legacy"} mask ${maskId}: ${error.message}`);
4559
+ }
3891
4560
  }
3892
4561
  }
3893
4562
  }
@@ -4002,6 +4671,79 @@ function cellRgb(cell, palette) {
4002
4671
  }
4003
4672
  }
4004
4673
 
4674
+ function inferCellEncoding(codeOrMatrix, matrix, layout = null) {
4675
+ if (!Array.isArray(codeOrMatrix) && codeOrMatrix?.cellEncoding) {
4676
+ return normalizeCellEncoding(codeOrMatrix.cellEncoding);
4677
+ }
4678
+ const activeLayout = layout ?? renderLayoutForMatrix(matrix);
4679
+ if (activeLayout) {
4680
+ for (const [row, col] of activeLayout.dataPositions) {
4681
+ if ((matrix[row]?.[col] ?? 0) > 3) return CELL_ENCODINGS.TRIANGLE16;
4682
+ }
4683
+ }
4684
+ return CELL_ENCODINGS.RGBW;
4685
+ }
4686
+
4687
+ function triangleCellColors(cell) {
4688
+ const { first, second } = unpackTriangleCell(cell);
4689
+ return { first, second };
4690
+ }
4691
+
4692
+ function drawTriangleCellCanvas(ctx, x, y, width, height, cell, palette) {
4693
+ const pair = triangleCellColors(cell);
4694
+ if (pair.first === pair.second) {
4695
+ ctx.fillStyle = cellColor(pair.first, palette);
4696
+ ctx.fillRect(x, y, width, height);
4697
+ return;
4698
+ }
4699
+
4700
+ // Fixed "/" split. first = upper-left triangle, second = lower-right.
4701
+ ctx.beginPath();
4702
+ ctx.moveTo(x, y);
4703
+ ctx.lineTo(x + width, y);
4704
+ ctx.lineTo(x, y + height);
4705
+ ctx.closePath();
4706
+ ctx.fillStyle = cellColor(pair.first, palette);
4707
+ ctx.fill();
4708
+
4709
+ ctx.beginPath();
4710
+ ctx.moveTo(x + width, y);
4711
+ ctx.lineTo(x + width, y + height);
4712
+ ctx.lineTo(x, y + height);
4713
+ ctx.closePath();
4714
+ ctx.fillStyle = cellColor(pair.second, palette);
4715
+ ctx.fill();
4716
+ }
4717
+
4718
+ function fillImageTriangleCell(data, imageWidth, x, y, width, height, cell, palette) {
4719
+ const pair = triangleCellColors(cell);
4720
+ const firstRgb = cellRgb(pair.first, palette);
4721
+ const secondRgb = cellRgb(pair.second, palette);
4722
+ for (let yy = 0; yy < height; yy++) {
4723
+ for (let xx = 0; xx < width; xx++) {
4724
+ const normalizedX = (xx + 0.5) / Math.max(1, width);
4725
+ const normalizedY = (yy + 0.5) / Math.max(1, height);
4726
+ const rgb = normalizedX + normalizedY <= 1 ? firstRgb : secondRgb;
4727
+ const p = ((y + yy) * imageWidth + (x + xx)) * 4;
4728
+ data[p] = rgb.r;
4729
+ data[p + 1] = rgb.g;
4730
+ data[p + 2] = rgb.b;
4731
+ data[p + 3] = 255;
4732
+ }
4733
+ }
4734
+ }
4735
+
4736
+ function svgTriangleCell(x, y, size, cell, palette) {
4737
+ const pair = triangleCellColors(cell);
4738
+ if (pair.first === pair.second) return svgRect(x, y, size, size, cellColor(pair.first, palette));
4739
+ const first = cellColor(pair.first, palette);
4740
+ const second = cellColor(pair.second, palette);
4741
+ return [
4742
+ `<polygon points="${x},${y} ${x + size},${y} ${x},${y + size}" fill="${first}"/>`,
4743
+ `<polygon points="${x + size},${y} ${x + size},${y + size} ${x},${y + size}" fill="${second}"/>`
4744
+ ].join("");
4745
+ }
4746
+
4005
4747
  function normalizeRenderStyle(style = RENDER_STYLES.CLASSIC) {
4006
4748
  const value = String(style).toLowerCase();
4007
4749
  const allowed = Object.values(RENDER_STYLES);
@@ -4470,6 +5212,7 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
4470
5212
  const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4471
5213
  const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
4472
5214
  const layout = renderLayoutForMatrix(matrix);
5215
+ const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
4473
5216
  const size = matrix.length;
4474
5217
  const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
4475
5218
 
@@ -4488,6 +5231,14 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
4488
5231
  const { x, y, width: cellWidth, height: cellHeight } = rect;
4489
5232
  const cellSize = Math.min(cellWidth, cellHeight);
4490
5233
 
5234
+ if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
5235
+ // Experimental Triangle16 always renders payload cells with exact,
5236
+ // hard-edged geometry. Decorative per-cell styles would contaminate the
5237
+ // two independent color samples and reduce scan reliability.
5238
+ drawTriangleCellCanvas(ctx, x, y, cellWidth, cellHeight, cell, palette);
5239
+ continue;
5240
+ }
5241
+
4491
5242
  if (style === RENDER_STYLES.CLASSIC || structural) {
4492
5243
  ctx.fillStyle = cellColor(cell, palette);
4493
5244
  ctx.fillRect(x, y, cellWidth, cellHeight);
@@ -4561,6 +5312,7 @@ function renderToImageData(codeOrMatrix, options = {}) {
4561
5312
  const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4562
5313
  const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
4563
5314
  const layout = renderLayoutForMatrix(matrix);
5315
+ const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
4564
5316
  const size = matrix.length;
4565
5317
  const data = new Uint8ClampedArray(pixelSize * pixelSize * 4);
4566
5318
  const white = palette.white;
@@ -4577,7 +5329,6 @@ function renderToImageData(codeOrMatrix, options = {}) {
4577
5329
  for (let r = 0; r < size; r++) {
4578
5330
  for (let c = 0; c < size; c++) {
4579
5331
  const cell = matrix[r][c];
4580
- const rgb = cellRgb(cell, palette);
4581
5332
  const structural = isStructuralRenderCell(layout, r, c, cell);
4582
5333
  const rect = rasterModuleRect(r, c, quietZone, moduleSize);
4583
5334
  const x0 = rect.x;
@@ -4586,6 +5337,13 @@ function renderToImageData(codeOrMatrix, options = {}) {
4586
5337
  const cellHeight = rect.height;
4587
5338
  const cellSize = Math.min(cellWidth, cellHeight);
4588
5339
 
5340
+ if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
5341
+ fillImageTriangleCell(data, pixelSize, x0, y0, cellWidth, cellHeight, cell, palette);
5342
+ continue;
5343
+ }
5344
+
5345
+ const rgb = cellRgb(cell, palette);
5346
+
4589
5347
  if (style === RENDER_STYLES.CLASSIC || structural) {
4590
5348
  fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
4591
5349
  continue;
@@ -4647,6 +5405,7 @@ function renderToSVG(codeOrMatrix, options = {}) {
4647
5405
  const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4648
5406
  const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
4649
5407
  const layout = renderLayoutForMatrix(matrix);
5408
+ const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
4650
5409
  const size = matrix.length;
4651
5410
  const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
4652
5411
  const body = [svgRect(0, 0, pixelSize, pixelSize, palette.white)];
@@ -4657,7 +5416,12 @@ function renderToSVG(codeOrMatrix, options = {}) {
4657
5416
  const structural = isStructuralRenderCell(layout, r, c, cell);
4658
5417
  const x = (c + quietZone) * moduleSize;
4659
5418
  const y = (r + quietZone) * moduleSize;
4660
- const fill = cellColor(cell, palette);
5419
+ const fill = cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural ? null : cellColor(cell, palette);
5420
+
5421
+ if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
5422
+ body.push(svgTriangleCell(x, y, moduleSize, cell, palette));
5423
+ continue;
5424
+ }
4661
5425
 
4662
5426
  if (style === RENDER_STYLES.CLASSIC || structural) {
4663
5427
  body.push(svgRect(x, y, moduleSize, moduleSize, fill));
@@ -4883,6 +5647,60 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
4883
5647
  };
4884
5648
  }
4885
5649
 
5650
+ function classifyTriangleSampledRgbGrid(rgbGrid, triangleGrid, classifier, layout) {
5651
+ const size = rgbGrid.length;
5652
+ const matrix = make2D(size, CELL.WHITE);
5653
+ const confidence = make2D(size, 1);
5654
+ const dataClassifier = {
5655
+ ...classifier,
5656
+ entries: classifier.entries.filter(({ cell }) => cell !== CELL.BLACK)
5657
+ };
5658
+ let distanceSum = 0;
5659
+ let confidenceSum = 0;
5660
+ let minimumConfidence = 1;
5661
+ let lowConfidenceCells = 0;
5662
+
5663
+ for (let r = 0; r < size; r++) {
5664
+ for (let c = 0; c < size; c++) {
5665
+ const isDataCell = layout && !layout.reserved[r][c];
5666
+ if (!isDataCell) {
5667
+ const classified = classifyRgb(rgbGrid[r][c], classifier);
5668
+ matrix[r][c] = classified.cell;
5669
+ confidence[r][c] = classified.confidence;
5670
+ distanceSum += classified.distance;
5671
+ confidenceSum += classified.confidence;
5672
+ minimumConfidence = Math.min(minimumConfidence, classified.confidence);
5673
+ if (classified.confidence < 0.4) lowConfidenceCells++;
5674
+ continue;
5675
+ }
5676
+
5677
+ const samples = triangleGrid?.[r]?.[c];
5678
+ if (!samples) throw new Error("Triangle16 sampling grid is incomplete.");
5679
+ const classifyData = (rgb) => classifier.dataMode === "hue"
5680
+ ? classifyDataHue(rgb, classifier)
5681
+ : classifyRgb(rgb, dataClassifier);
5682
+ const first = classifyData(samples.first);
5683
+ const second = classifyData(samples.second);
5684
+ const cellConfidence = Math.min(first.confidence, second.confidence);
5685
+ matrix[r][c] = packTriangleCell(first.cell, second.cell);
5686
+ confidence[r][c] = cellConfidence;
5687
+ distanceSum += (first.distance + second.distance) / 2;
5688
+ confidenceSum += cellConfidence;
5689
+ minimumConfidence = Math.min(minimumConfidence, cellConfidence);
5690
+ if (cellConfidence < 0.4) lowConfidenceCells++;
5691
+ }
5692
+ }
5693
+
5694
+ return {
5695
+ matrix,
5696
+ confidence,
5697
+ averageColorDistance: distanceSum / (size * size),
5698
+ averageCellConfidence: confidenceSum / (size * size),
5699
+ minimumCellConfidence: minimumConfidence,
5700
+ lowConfidenceCells
5701
+ };
5702
+ }
5703
+
4886
5704
  function structuralAccuracy(matrix, layout) {
4887
5705
  let matches = 0;
4888
5706
  let total = 0;
@@ -4922,6 +5740,7 @@ function tryPerspectiveScan(imageData, options) {
4922
5740
  finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
4923
5741
  finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
4924
5742
  finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
5743
+ preciseAlignment: options.preciseAlignment,
4925
5744
  diagnostics: options._visionDiagnostics,
4926
5745
  diagnosticLabel: options._diagnosticLabel ?? "normal"
4927
5746
  });
@@ -4947,9 +5766,18 @@ function tryPerspectiveScan(imageData, options) {
4947
5766
  let bestStructureScore = 0;
4948
5767
  for (const profile of sampleProfiles) {
4949
5768
  let sampled;
5769
+ let triangleSampled = null;
4950
5770
  let observedPalette;
4951
5771
  try {
4952
5772
  sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, profile);
5773
+ if (options.highDensitySampling !== false && options.triangle16 !== false) {
5774
+ triangleSampled = samplePerspectiveTriangleMatrix(
5775
+ imageData,
5776
+ geometry.homography,
5777
+ layout.size,
5778
+ profile
5779
+ );
5780
+ }
4953
5781
  observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, {
4954
5782
  robust: profile.robustCalibration
4955
5783
  });
@@ -5008,11 +5836,67 @@ function tryPerspectiveScan(imageData, options) {
5008
5836
  }
5009
5837
  };
5010
5838
 
5839
+ const tryTriangleAttempt = (attempt, metadata = {}) => {
5840
+ if (!triangleSampled?.triangleGrid) return false;
5841
+ const activeObservedPalette = metadata.observedPalette ?? observedPalette;
5842
+ const activeSamplingMode = metadata.samplingMode ?? `${profile.sampleMode}-triangle16`;
5843
+ const classified = classifyTriangleSampledRgbGrid(
5844
+ sampled.rgbGrid,
5845
+ triangleSampled.triangleGrid,
5846
+ attempt.classifier,
5847
+ layout
5848
+ );
5849
+ const structureScore = structuralAccuracy(classified.matrix, layout);
5850
+ bestStructureScore = Math.max(bestStructureScore, structureScore);
5851
+ pushObservation(options, {
5852
+ version: geometry.version,
5853
+ matrix: classified.matrix,
5854
+ confidence: classified.confidence,
5855
+ geometry,
5856
+ observedPalette: activeObservedPalette,
5857
+ samplingMode: activeSamplingMode,
5858
+ colorNormalization: attempt.colorNormalization,
5859
+ cellEncoding: CELL_ENCODINGS.TRIANGLE16,
5860
+ structureScore,
5861
+ averageCellConfidence: classified.averageCellConfidence,
5862
+ lowConfidenceCells: classified.lowConfidenceCells
5863
+ });
5864
+
5865
+ try {
5866
+ const decoded = decodeMatrix(classified.matrix, {
5867
+ structureTolerance: options.structureTolerance ?? 0.18,
5868
+ cellConfidence: classified.confidence,
5869
+ cellEncodingHint: CELL_ENCODINGS.TRIANGLE16,
5870
+ maxErasureConfidence: options.maxErasureConfidence
5871
+ });
5872
+ if (decoded.version !== geometry.version) return false;
5873
+ results.push({
5874
+ ...decoded,
5875
+ perspectiveCorrected: true,
5876
+ colorCalibrated: true,
5877
+ colorNormalization: attempt.colorNormalization,
5878
+ samplingMode: activeSamplingMode,
5879
+ geometry,
5880
+ observedPalette: activeObservedPalette,
5881
+ averageColorDistance: classified.averageColorDistance,
5882
+ averageCellConfidence: classified.averageCellConfidence,
5883
+ minimumCellConfidence: classified.minimumCellConfidence,
5884
+ lowConfidenceCells: classified.lowConfidenceCells,
5885
+ rectified: options.includeRectified
5886
+ ? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
5887
+ : undefined
5888
+ });
5889
+ return true;
5890
+ } catch {
5891
+ return false;
5892
+ }
5893
+ };
5894
+
5011
5895
  // Fast path: preserve the original observed-RGB classifier first, then
5012
5896
  // try per-channel white balancing. Most clean frames stop here without
5013
5897
  // paying for the more expensive spatial normalization fallback.
5014
5898
  for (const attempt of paletteClassifierAttempts(observedPalette)) {
5015
- if (tryAttempt(attempt, sampled.rgbGrid)) {
5899
+ if (tryAttempt(attempt, sampled.rgbGrid) || tryTriangleAttempt(attempt)) {
5016
5900
  geometryDecoded = true;
5017
5901
  break;
5018
5902
  }
@@ -5139,17 +6023,30 @@ function tryPerspectiveScan(imageData, options) {
5139
6023
  [step, -step], [step, step], [-step, -step], [-step, step]
5140
6024
  ];
5141
6025
  const refinementCandidates = [];
6026
+ const triangleRefinementCandidates = [];
5142
6027
 
5143
6028
  for (const [offsetX, offsetY] of refinementOffsets) {
5144
6029
  let sampled;
6030
+ let triangleSampled = null;
5145
6031
  let observedPalette;
5146
6032
  try {
5147
- sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, {
6033
+ const samplingOptions = {
5148
6034
  sampleMode: "cross",
5149
6035
  sampleRadius: 0,
5150
6036
  sampleOffsetX: offsetX,
5151
- sampleOffsetY: offsetY
5152
- });
6037
+ sampleOffsetY: offsetY,
6038
+ triangleSampleInset: options.highDensitySampleInset ?? options.triangleSampleInset,
6039
+ triangleSampleRadius: options.highDensitySampleRadius ?? options.triangleSampleRadius
6040
+ };
6041
+ sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, samplingOptions);
6042
+ if (options.highDensitySampling !== false && options.triangle16 !== false) {
6043
+ triangleSampled = samplePerspectiveTriangleMatrix(
6044
+ imageData,
6045
+ geometry.homography,
6046
+ layout.size,
6047
+ samplingOptions
6048
+ );
6049
+ }
5153
6050
  observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, { robust: true });
5154
6051
  } catch {
5155
6052
  continue;
@@ -5179,30 +6076,80 @@ function tryPerspectiveScan(imageData, options) {
5179
6076
 
5180
6077
  // Do not spend RS work on a refinement that made the known finder /
5181
6078
  // alignment structure worse. This keeps the recovery path bounded.
5182
- if (structureScore < (options.refinementDecodeThreshold ?? 0.95)) continue;
5183
- refinementCandidates.push({
5184
- attempt,
5185
- classified,
5186
- observedPalette,
5187
- offsetX,
5188
- offsetY,
5189
- structureScore
5190
- });
6079
+ if (structureScore >= (options.refinementDecodeThreshold ?? 0.95)) {
6080
+ refinementCandidates.push({
6081
+ attempt,
6082
+ classified,
6083
+ observedPalette,
6084
+ offsetX,
6085
+ offsetY,
6086
+ structureScore,
6087
+ cellEncoding: CELL_ENCODINGS.RGBW,
6088
+ samplingMode: "refined-center"
6089
+ });
6090
+ }
6091
+
6092
+ if (triangleSampled?.triangleGrid) {
6093
+ const triangleClassified = classifyTriangleSampledRgbGrid(
6094
+ sampled.rgbGrid,
6095
+ triangleSampled.triangleGrid,
6096
+ attempt.classifier,
6097
+ layout
6098
+ );
6099
+ const triangleStructureScore = structuralAccuracy(triangleClassified.matrix, layout);
6100
+ bestStructureScore = Math.max(bestStructureScore, triangleStructureScore);
6101
+ pushObservation(options, {
6102
+ version: geometry.version,
6103
+ matrix: triangleClassified.matrix,
6104
+ confidence: triangleClassified.confidence,
6105
+ geometry,
6106
+ observedPalette,
6107
+ samplingMode: "refined-triangle16",
6108
+ samplingOffset: { x: offsetX, y: offsetY },
6109
+ colorNormalization: attempt.colorNormalization,
6110
+ cellEncoding: CELL_ENCODINGS.TRIANGLE16,
6111
+ structureScore: triangleStructureScore,
6112
+ averageCellConfidence: triangleClassified.averageCellConfidence,
6113
+ lowConfidenceCells: triangleClassified.lowConfidenceCells
6114
+ });
6115
+ if (triangleStructureScore >= (options.refinementDecodeThreshold ?? 0.95)) {
6116
+ triangleRefinementCandidates.push({
6117
+ attempt,
6118
+ classified: triangleClassified,
6119
+ observedPalette,
6120
+ offsetX,
6121
+ offsetY,
6122
+ structureScore: triangleStructureScore,
6123
+ cellEncoding: CELL_ENCODINGS.TRIANGLE16,
6124
+ samplingMode: "refined-triangle16"
6125
+ });
6126
+ }
6127
+ }
5191
6128
  }
5192
6129
  }
5193
6130
 
5194
- refinementCandidates.sort((a, b) =>
6131
+ const sortRefinementCandidates = (items) => items.sort((a, b) =>
5195
6132
  (b.structureScore - a.structureScore) ||
5196
6133
  (b.classified.averageCellConfidence - a.classified.averageCellConfidence) ||
5197
6134
  (a.classified.averageColorDistance - b.classified.averageColorDistance)
5198
6135
  );
6136
+ sortRefinementCandidates(refinementCandidates);
6137
+ sortRefinementCandidates(triangleRefinementCandidates);
5199
6138
 
5200
6139
  const decodeLimit = Math.max(1, Math.min(8, Math.round(options.refinementDecodeCandidates ?? 4)));
5201
- for (const candidate of refinementCandidates.slice(0, decodeLimit)) {
6140
+ // Keep separate per-encoding limits. A Triangle16 image can produce very
6141
+ // confident but meaningless centre samples, and those must not crowd out
6142
+ // the dual-region candidates during the final bounded recovery pass.
6143
+ const decodeCandidates = [
6144
+ ...refinementCandidates.slice(0, decodeLimit),
6145
+ ...triangleRefinementCandidates.slice(0, decodeLimit)
6146
+ ];
6147
+ for (const candidate of decodeCandidates) {
5202
6148
  try {
5203
6149
  const decoded = decodeMatrix(candidate.classified.matrix, {
5204
6150
  structureTolerance: options.structureTolerance ?? 0.18,
5205
6151
  cellConfidence: candidate.classified.confidence,
6152
+ cellEncodingHint: candidate.cellEncoding,
5206
6153
  maxErasureConfidence: options.maxErasureConfidence
5207
6154
  });
5208
6155
  if (decoded.version !== geometry.version) continue;
@@ -5213,7 +6160,7 @@ function tryPerspectiveScan(imageData, options) {
5213
6160
  samplingOffset: { x: candidate.offsetX, y: candidate.offsetY },
5214
6161
  colorCalibrated: true,
5215
6162
  colorNormalization: candidate.attempt.colorNormalization,
5216
- samplingMode: "refined-center",
6163
+ samplingMode: candidate.samplingMode,
5217
6164
  geometry,
5218
6165
  observedPalette: candidate.observedPalette,
5219
6166
  averageColorDistance: candidate.classified.averageColorDistance,
@@ -5263,6 +6210,15 @@ function tryAxisAlignedScan(imageData, options) {
5263
6210
  for (let profileIndex = 0; profileIndex < radiusProfiles.length && !accepted; profileIndex++) {
5264
6211
  try {
5265
6212
  const sampled = sampleAxisAlignedGrid(imageData, bounds, size, radiusProfiles[profileIndex]);
6213
+ const triangleSampled = options.highDensitySampling === false || options.triangle16 === false
6214
+ ? null
6215
+ : sampleAxisAlignedTriangleGrid(
6216
+ imageData,
6217
+ bounds,
6218
+ size,
6219
+ Math.min(0.10, Math.max(0.04, radiusProfiles[profileIndex] * 0.48)),
6220
+ options.highDensitySampleInset ?? options.triangleSampleInset ?? 0.28
6221
+ );
5266
6222
  const layout = createLayout(version);
5267
6223
  const classifierAttempts = [];
5268
6224
  try {
@@ -5302,45 +6258,70 @@ function tryAxisAlignedScan(imageData, options) {
5302
6258
  });
5303
6259
 
5304
6260
  for (const attempt of classifierAttempts) {
5305
- try {
5306
- const classified = classifySampledRgbGrid(attempt.rgbGrid ?? sampled.rgbGrid, attempt.classifier, layout);
5307
- const structureScore = structuralAccuracy(classified.matrix, layout);
5308
- pushObservation(options, {
5309
- version,
5310
- matrix: classified.matrix,
5311
- confidence: classified.confidence,
5312
- bounds,
5313
- samplingMode: profileIndex === 0 ? "axis" : "axis-center",
5314
- colorNormalization: attempt.colorNormalization,
5315
- structureScore,
5316
- averageCellConfidence: classified.averageCellConfidence,
5317
- lowConfidenceCells: classified.lowConfidenceCells
6261
+ const centerGrid = attempt.rgbGrid ?? sampled.rgbGrid;
6262
+ const samplingMode = profileIndex === 0 ? "axis" : "axis-center";
6263
+ const classifiedAttempts = [{
6264
+ classified: classifySampledRgbGrid(centerGrid, attempt.classifier, layout),
6265
+ cellEncoding: CELL_ENCODINGS.RGBW,
6266
+ samplingMode
6267
+ }];
6268
+ if (triangleSampled?.triangleGrid && attempt.rgbGrid === sampled.rgbGrid) {
6269
+ classifiedAttempts.push({
6270
+ classified: classifyTriangleSampledRgbGrid(
6271
+ centerGrid,
6272
+ triangleSampled.triangleGrid,
6273
+ attempt.classifier,
6274
+ layout
6275
+ ),
6276
+ cellEncoding: CELL_ENCODINGS.TRIANGLE16,
6277
+ samplingMode: `${samplingMode}-triangle16`
5318
6278
  });
5319
- const decoded = decodeMatrix(classified.matrix, {
5320
- structureTolerance: options.structureTolerance ?? 0.12,
5321
- cellConfidence: classified.confidence,
5322
- maxErasureConfidence: options.maxErasureConfidence
5323
- });
5324
- candidates.push({
5325
- ...decoded,
5326
- bounds,
5327
- sampledVersion: version,
5328
- moduleWidth: moduleW,
5329
- moduleHeight: moduleH,
5330
- perspectiveCorrected: false,
5331
- colorCalibrated: attempt.calibrated,
5332
- colorNormalization: attempt.colorNormalization,
5333
- samplingMode: profileIndex === 0 ? "axis" : "axis-center",
5334
- averageColorDistance: classified.averageColorDistance,
5335
- averageCellConfidence: classified.averageCellConfidence,
5336
- minimumCellConfidence: classified.minimumCellConfidence,
5337
- lowConfidenceCells: classified.lowConfidenceCells
5338
- });
5339
- accepted = true;
5340
- break;
5341
- } catch {
5342
- // Try next classifier/profile.
5343
6279
  }
6280
+
6281
+ for (const classifiedAttempt of classifiedAttempts) {
6282
+ try {
6283
+ const classified = classifiedAttempt.classified;
6284
+ const structureScore = structuralAccuracy(classified.matrix, layout);
6285
+ pushObservation(options, {
6286
+ version,
6287
+ matrix: classified.matrix,
6288
+ confidence: classified.confidence,
6289
+ bounds,
6290
+ samplingMode: classifiedAttempt.samplingMode,
6291
+ colorNormalization: attempt.colorNormalization,
6292
+ cellEncoding: classifiedAttempt.cellEncoding,
6293
+ structureScore,
6294
+ averageCellConfidence: classified.averageCellConfidence,
6295
+ lowConfidenceCells: classified.lowConfidenceCells
6296
+ });
6297
+ const decoded = decodeMatrix(classified.matrix, {
6298
+ structureTolerance: options.structureTolerance ?? 0.12,
6299
+ cellConfidence: classified.confidence,
6300
+ cellEncodingHint: classifiedAttempt.cellEncoding,
6301
+ maxErasureConfidence: options.maxErasureConfidence
6302
+ });
6303
+ candidates.push({
6304
+ ...decoded,
6305
+ bounds,
6306
+ sampledVersion: version,
6307
+ moduleWidth: moduleW,
6308
+ moduleHeight: moduleH,
6309
+ perspectiveCorrected: false,
6310
+ colorCalibrated: attempt.calibrated,
6311
+ colorNormalization: attempt.colorNormalization,
6312
+ samplingMode: classifiedAttempt.samplingMode,
6313
+ averageColorDistance: classified.averageColorDistance,
6314
+ averageCellConfidence: classified.averageCellConfidence,
6315
+ minimumCellConfidence: classified.minimumCellConfidence,
6316
+ lowConfidenceCells: classified.lowConfidenceCells
6317
+ });
6318
+ accepted = true;
6319
+ break;
6320
+ } catch {
6321
+ // Try the next cell encoding/classifier/profile.
6322
+ }
6323
+ }
6324
+ if (accepted) break;
5344
6325
  }
5345
6326
  } catch {
5346
6327
  // Try a narrower centre sample, then next version.
@@ -5450,7 +6431,7 @@ function perspectiveStress(imageData, severity) {
5450
6431
  { x: width - 1 - inset * 1.0, y: height - 1 - inset * 0.35 },
5451
6432
  { x: inset * 0.20, y: height - 1 - inset * 0.95 }
5452
6433
  ];
5453
- const inverse = computeHomography(destination, source);
6434
+ const inverse = computeHomography(source, destination);
5454
6435
  const out = new Uint8ClampedArray(width * height * 4);
5455
6436
  out.fill(255);
5456
6437
  for (let y = 0; y < height; y++) {
@@ -5735,6 +6716,36 @@ function scanImageData(imageData, options = {}) {
5735
6716
  if (perspective) return decorateScanResult(perspective, debugContext, options);
5736
6717
  }
5737
6718
 
6719
+ // Triangle16 has half-cell color regions, so geometry that is adequate for
6720
+ // solid RGBW cells can still be off by a few tenths of a module and mix the
6721
+ // two triangle colors. If normal geometry was found but no payload decoded,
6722
+ // retry geometry once with finer alignment localization. This stays off the
6723
+ // clean/normal camera fast path.
6724
+ if (
6725
+ options.perspective !== false &&
6726
+ options.preciseAlignmentRecovery !== false &&
6727
+ !options._preciseAlignmentRecovery &&
6728
+ geometryCollector.length > 0
6729
+ ) {
6730
+ try {
6731
+ const precise = tryPerspectiveScan(imageData, {
6732
+ ...scanOptions,
6733
+ preciseAlignment: true,
6734
+ _preciseAlignmentRecovery: true,
6735
+ _diagnosticLabel: "precise-alignment"
6736
+ });
6737
+ if (precise) {
6738
+ return decorateScanResult({
6739
+ ...precise,
6740
+ geometryRefined: true,
6741
+ recoveryMode: precise.recoveryMode ?? "precise-alignment"
6742
+ }, debugContext, options);
6743
+ }
6744
+ } catch {
6745
+ // Continue to color recovery and axis-aligned fallback.
6746
+ }
6747
+ }
6748
+
5738
6749
  // If the normal scanner could see QuadQR geometry but could not decode the
5739
6750
  // colors, retry the same frame after a single global Auto Tone / Contrast /
5740
6751
  // Color-style pass. This is deliberately after the normal path so clean
@@ -6138,6 +7149,7 @@ async function startCameraScanner(video, options = {}) {
6138
7149
 
6139
7150
  const scanInterval = Math.max(80, options.scanInterval ?? 180);
6140
7151
  const scratchCanvas = document.createElement("canvas");
7152
+ const highResolutionCanvas = document.createElement("canvas");
6141
7153
  const multiFrameEnabled = options.multiFrame !== false;
6142
7154
  const multiFrameWindow = Math.max(2, Math.min(8, Math.round(options.multiFrameWindow ?? 4)));
6143
7155
  const multiFrameMinFrames = Math.max(2, Math.min(multiFrameWindow, Math.round(options.multiFrameMinFrames ?? 2)));
@@ -6145,6 +7157,12 @@ async function startCameraScanner(video, options = {}) {
6145
7157
  const cameraAutoColorEvery = Math.max(1, Math.round(options.cameraAutoColorEvery ?? 1));
6146
7158
  const cameraAutoEnhanceEvery = Math.max(1, Math.round(options.cameraAutoEnhanceEvery ?? 2));
6147
7159
  const cameraFinderRecoveryEvery = Math.max(1, Math.round(options.cameraFinderRecoveryEvery ?? 2));
7160
+ const cameraHighResolutionEvery = Math.max(1, Math.round(options.cameraHighResolutionEvery ?? 2));
7161
+ const baseCameraMaxDimension = Math.max(480, Math.round(options.maxDimension ?? 1080));
7162
+ const cameraHighResolutionMaxDimension = Math.max(
7163
+ baseCameraMaxDimension,
7164
+ Math.round(options.cameraHighResolutionMaxDimension ?? 1600)
7165
+ );
6148
7166
  let missStreak = 0;
6149
7167
  let stopped = false;
6150
7168
  let busy = false;
@@ -6191,7 +7209,7 @@ async function startCameraScanner(video, options = {}) {
6191
7209
 
6192
7210
  const scanNow = () => scanVideoFrame(video, {
6193
7211
  ...options,
6194
- maxDimension: options.maxDimension ?? 1080,
7212
+ maxDimension: baseCameraMaxDimension,
6195
7213
  canvas: scratchCanvas
6196
7214
  });
6197
7215
 
@@ -6258,7 +7276,7 @@ async function startCameraScanner(video, options = {}) {
6258
7276
  busy = true;
6259
7277
  frameNumber++;
6260
7278
  const observations = [];
6261
- const frameDiagnostics = diagnosticsEnabled ? {} : null;
7279
+ const frameDiagnostics = {};
6262
7280
  const capturedFrame = {};
6263
7281
  const frameStarted = nowMs();
6264
7282
  let allowAutoEnhance = false;
@@ -6286,7 +7304,7 @@ async function startCameraScanner(video, options = {}) {
6286
7304
  // If geometry exists, the rectified QR-only pixel enhancer is both
6287
7305
  // stronger and much cheaper than reprocessing the whole live frame.
6288
7306
  fullFrameAutoEnhanceRecovery: options.fullFrameAutoEnhanceRecovery ?? false,
6289
- maxDimension: options.maxDimension ?? 1080,
7307
+ maxDimension: baseCameraMaxDimension,
6290
7308
  canvas: scratchCanvas,
6291
7309
  _capturedFrame: capturedFrame,
6292
7310
  _observationCollector: observations,
@@ -6327,6 +7345,71 @@ async function startCameraScanner(video, options = {}) {
6327
7345
  ...frameDiagnostics
6328
7346
  });
6329
7347
 
7348
+ // Geometry-aware high-resolution retry. A dense QuadQR can look large
7349
+ // enough in the preview while each individual module has become too
7350
+ // small after the normal 1080 px scanner cap. If the fast pass already
7351
+ // sees at least two convincing finders, spend one bounded retry on a
7352
+ // higher-resolution copy of the visible camera ROI. Empty frames and
7353
+ // ordinary small codes never pay this cost.
7354
+ const shouldTryHighResolution = options.cameraHighResolutionRecovery !== false &&
7355
+ cameraHighResolutionMaxDimension > baseCameraMaxDimension &&
7356
+ (frameDiagnostics?.finderCount ?? 0) >= (options.cameraHighResolutionMinFinders ?? 2) &&
7357
+ ((missStreak - 1) % cameraHighResolutionEvery === 0);
7358
+ if (shouldTryHighResolution) {
7359
+ const highResolutionObservations = [];
7360
+ const highResolutionDiagnostics = {};
7361
+ const highResolutionCapturedFrame = {};
7362
+ emitDiagnostic({
7363
+ type: "method",
7364
+ state: "trying",
7365
+ method: "high-resolution-geometry-recovery",
7366
+ message: `Dense geometry detected · retrying at up to ${cameraHighResolutionMaxDimension}px`,
7367
+ ...frameDiagnostics
7368
+ });
7369
+ try {
7370
+ const recoveryStarted = nowMs();
7371
+ const recovered = scanVideoFrame(video, {
7372
+ ...options,
7373
+ _diagnosticLabel: "high-resolution-geometry-recovery",
7374
+ finderRecovery: true,
7375
+ autoEnhanceRecovery: false,
7376
+ fullFrameAutoEnhanceRecovery: false,
7377
+ maxDimension: cameraHighResolutionMaxDimension,
7378
+ canvas: highResolutionCanvas,
7379
+ _capturedFrame: highResolutionCapturedFrame,
7380
+ _observationCollector: highResolutionObservations,
7381
+ _frameDiagnostics: highResolutionDiagnostics
7382
+ });
7383
+ const recoveryElapsedMs = nowMs() - recoveryStarted;
7384
+ emitDiagnostic({
7385
+ type: "success",
7386
+ state: "decoded",
7387
+ method: "high-resolution-geometry-recovery",
7388
+ elapsedMs: recoveryElapsedMs,
7389
+ message: `High-resolution retry decoded v${recovered.version} · ${Math.round(recoveryElapsedMs)} ms`,
7390
+ ...(highResolutionDiagnostics ?? frameDiagnostics)
7391
+ });
7392
+ missStreak = 0;
7393
+ observationHistory.clear();
7394
+ if (emitResult({
7395
+ ...recovered,
7396
+ cameraHighResolutionRecovery: true,
7397
+ cameraProgressiveRecovery: true,
7398
+ recoveryMode: recovered.recoveryMode ?? "high-resolution-geometry-recovery"
7399
+ }, highResolutionCapturedFrame, highResolutionDiagnostics ?? frameDiagnostics)) return;
7400
+ } catch (highResolutionError) {
7401
+ observations.push(...highResolutionObservations);
7402
+ emitDiagnostic({
7403
+ type: "method",
7404
+ state: "failed",
7405
+ method: "high-resolution-geometry-recovery",
7406
+ message: `High-resolution geometry retry did not decode${highResolutionDiagnostics?.finderCount != null ? ` · ${highResolutionDiagnostics.finderCount} finder(s)` : ""}`,
7407
+ error: highResolutionError?.message ?? String(highResolutionError),
7408
+ ...(highResolutionDiagnostics ?? frameDiagnostics)
7409
+ });
7410
+ }
7411
+ }
7412
+
6330
7413
  // The user's real phone-camera case is dominated by color cast before
6331
7414
  // finder detection: Photoshop Auto Color alone makes the same live QR
6332
7415
  // immediately detectable. Retry the exact captured frame with a cheap
@@ -6631,11 +7714,14 @@ const internals = Object.freeze({
6631
7714
  HEADER_CODEWORD_CELLS,
6632
7715
  COMPACT_HEADER_CODEWORD_CELLS,
6633
7716
  CELLS_PER_BYTE,
7717
+ RGBW_CELLS_PER_BYTE,
7718
+ TRIANGLE16_CELLS_PER_BYTE,
7719
+ TRIANGLE16_FLAG,
6634
7720
  TEXT_FLAG,
6635
7721
  SECURE_FLAG
6636
7722
  });
6637
7723
 
6638
- return { compressPayload, decompressPayload, deriveSigningKeyId, generateSigningKeyPair, installCrc32Accelerator, crc32, getVersionInfo, encodeText, encodeBytes, encodeUint8Array, encodeSignedBytes, encodeSignedText, encodeSecureText, encodeSecureBytes, decryptDecoded, decodeMatrix, decodeUint8Array, verifyDecodedSignature, estimateSafeLogoSize, findMaxSafeLogoSize, getPrintGuidance, renderToCanvas, renderToImageData, renderToSVG, applyStressDistortion, runImageStressTest, assessScanability, debugScanImageData, scanImageData, scanFile, scanVideoFrame, startCameraScanner, rectifyDetectedCode, rotateMatrix, FORMAT_VERSION, MIN_VERSION, MAX_VERSION, DEFAULT_ECC_LEVEL, RENDER_MODES, PRINT_PALETTE, COMPRESSION_MODES, SIGNATURE_ALGORITHMS, CELL, DEFAULT_PALETTE, RENDER_STYLES, ECC_LEVELS, STRESS_PROFILES, internals, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex };
7724
+ 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 };
6639
7725
  })();
6640
7726
  const QuadQR = __qqr_quadqr_js;
6641
7727