quadqr-js 1.0.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, 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
@@ -2220,6 +2658,33 @@ const MIN_VERSION = 1;
2220
2658
  const MAX_VERSION = 40;
2221
2659
  const DEFAULT_ECC_LEVEL = "M";
2222
2660
 
2661
+ const RENDER_MODES = Object.freeze({
2662
+ SCREEN: "screen",
2663
+ PRINT: "print"
2664
+ });
2665
+
2666
+ const CELL_ENCODINGS = Object.freeze({
2667
+ RGBW: "rgbw",
2668
+ TRIANGLE16: "triangle16"
2669
+ });
2670
+
2671
+ // Print-safe defaults deliberately use darker, more ink-tolerant primaries and
2672
+ // a true white background. Applications can still override individual colors.
2673
+ const PRINT_PALETTE = Object.freeze({
2674
+ black: "#000000",
2675
+ white: "#ffffff",
2676
+ red: "#d71932",
2677
+ green: "#087f3e",
2678
+ blue: "#174ea6"
2679
+ });
2680
+
2681
+ const COMPRESSION_MODES = Object.freeze({
2682
+ NONE: "none",
2683
+ AUTO: "auto",
2684
+ LZ: "lz"
2685
+ });
2686
+ const SIGNATURE_ALGORITHMS = Object.freeze({ ED25519: "Ed25519" });
2687
+
2223
2688
  // Data cells are the four values 0..3. Structural white deliberately shares
2224
2689
  // value 3 with data-white because both render and scan identically.
2225
2690
  const CELL = Object.freeze({
@@ -2260,8 +2725,10 @@ const MAGIC = new Uint8Array([0x51, 0x51, 0x52, 0x57]); // QQRW (QuadQR RGBW)
2260
2725
  const HEADER_BYTES = 10;
2261
2726
  const HEADER_RS_PARITY = 8;
2262
2727
  const HEADER_CODEWORD_BYTES = HEADER_BYTES + HEADER_RS_PARITY;
2263
- const CELLS_PER_BYTE = 4;
2264
- 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;
2265
2732
 
2266
2733
  // Version 1 is a deliberately compact small-symbol profile. A 21x21 matrix
2267
2734
  // cannot afford the normal 18-byte protected header plus a 24-byte M body
@@ -2273,7 +2740,7 @@ const COMPACT_HEADER_MAGIC = 0xc3;
2273
2740
  const COMPACT_HEADER_BYTES = 4;
2274
2741
  const COMPACT_HEADER_RS_PARITY = 4;
2275
2742
  const COMPACT_HEADER_CODEWORD_BYTES = COMPACT_HEADER_BYTES + COMPACT_HEADER_RS_PARITY;
2276
- 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;
2277
2744
  const COMPACT_ECC_LEVELS = Object.freeze({
2278
2745
  L: Object.freeze({ paritySymbols: 4, correctableSymbolsPerBlock: 2 }),
2279
2746
  M: Object.freeze({ paritySymbols: 8, correctableSymbolsPerBlock: 4 }),
@@ -2284,6 +2751,23 @@ const COMPACT_ECC_LEVELS = Object.freeze({
2284
2751
  const CRC_BYTES = 4;
2285
2752
  const TEXT_FLAG = 1;
2286
2753
  const SECURE_FLAG = 1 << 3;
2754
+ const EXTENDED_PAYLOAD_FLAG = 1 << 4;
2755
+ const SIGNED_FLAG = 1 << 5;
2756
+ const TRIANGLE16_FLAG = 1 << 6;
2757
+
2758
+ // Internal payload extension envelope. This is deliberately not a public
2759
+ // payload mode or content-type system. It exists only when compression or
2760
+ // signing needs metadata around an otherwise normal text/binary payload.
2761
+ const PAYLOAD_ENVELOPE_VERSION = 2;
2762
+ const PAYLOAD_ENVELOPE_MAGIC = new Uint8Array([0x51, 0x50, 0x58, 0x31]); // QPX1
2763
+ const PAYLOAD_ENVELOPE_HEADER_BYTES = 16;
2764
+ const PAYLOAD_ENVELOPE_SIGNED_FLAG = 1;
2765
+ const PAYLOAD_ENVELOPE_COMPRESSED_FLAG = 1 << 1;
2766
+ const PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG = 1 << 2;
2767
+ const PAYLOAD_ENVELOPE_COMPRESSION_IDS = Object.freeze({ none: 0, lz: 1 });
2768
+ const PAYLOAD_ENVELOPE_COMPRESSION_BY_ID = Object.freeze({ 0: "none", 1: "lz" });
2769
+ const PAYLOAD_ENVELOPE_SIGNATURE_IDS = Object.freeze({ none: 0, Ed25519: 1 });
2770
+ const PAYLOAD_ENVELOPE_SIGNATURE_BY_ID = Object.freeze({ 0: null, 1: "Ed25519" });
2287
2771
  const ECC_SHIFT = 1;
2288
2772
  const ECC_MASK = 0b00000110;
2289
2773
 
@@ -2332,6 +2816,45 @@ function normalizeEccLevel(level = DEFAULT_ECC_LEVEL) {
2332
2816
  return value;
2333
2817
  }
2334
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
+
2335
2858
  function concatBytes(...arrays) {
2336
2859
  const total = arrays.reduce((sum, item) => sum + item.length, 0);
2337
2860
  const out = new Uint8Array(total);
@@ -2362,6 +2885,367 @@ function readU32be(bytes, offset = 0) {
2362
2885
  ) >>> 0;
2363
2886
  }
2364
2887
 
2888
+ function u16be(value) {
2889
+ const v = Number(value) >>> 0;
2890
+ assert(v <= 0xffff, "Value does not fit in uint16.");
2891
+ return new Uint8Array([(v >>> 8) & 0xff, v & 0xff]);
2892
+ }
2893
+
2894
+ function readU16be(bytes, offset = 0) {
2895
+ return ((bytes[offset] << 8) | bytes[offset + 1]) >>> 0;
2896
+ }
2897
+
2898
+ function normalizeCompressionMode(value = "none") {
2899
+ const key = String(value).toLowerCase();
2900
+ assert(["none", "auto", "lz"].includes(key), "compression must be none, auto, or lz.");
2901
+ return key;
2902
+ }
2903
+
2904
+ function asBytes(input) {
2905
+ if (input instanceof Uint8Array) return input;
2906
+ if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
2907
+ if (input instanceof ArrayBuffer) return new Uint8Array(input);
2908
+ return new Uint8Array(input);
2909
+ }
2910
+
2911
+ /** Portable LZSS-style compressor used by QuadQR payload compression. */
2912
+ function compressPayload(input) {
2913
+ const bytes = asBytes(input);
2914
+ if (bytes.length < 4) return bytes.slice();
2915
+ const out = [];
2916
+ const recent = new Map();
2917
+ let pos = 0;
2918
+
2919
+ const keyAt = (i) => i + 2 < bytes.length ? ((bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]) : -1;
2920
+ const remember = (i) => {
2921
+ const key = keyAt(i);
2922
+ if (key < 0) return;
2923
+ let list = recent.get(key);
2924
+ if (!list) recent.set(key, list = []);
2925
+ list.push(i);
2926
+ while (list.length > 32) list.shift();
2927
+ while (list.length && i - list[0] > 4095) list.shift();
2928
+ };
2929
+
2930
+ while (pos < bytes.length) {
2931
+ const flagIndex = out.length;
2932
+ out.push(0);
2933
+ let flags = 0;
2934
+ for (let token = 0; token < 8 && pos < bytes.length; token++) {
2935
+ let bestLength = 0;
2936
+ let bestOffset = 0;
2937
+ const key = keyAt(pos);
2938
+ const candidates = key >= 0 ? (recent.get(key) ?? []) : [];
2939
+ for (let ci = candidates.length - 1; ci >= 0; ci--) {
2940
+ const candidate = candidates[ci];
2941
+ const offset = pos - candidate;
2942
+ if (offset <= 0 || offset > 4095) continue;
2943
+ let length = 0;
2944
+ while (length < 18 && pos + length < bytes.length && bytes[candidate + length] === bytes[pos + length]) length++;
2945
+ if (length >= 3 && length > bestLength) {
2946
+ bestLength = length;
2947
+ bestOffset = offset;
2948
+ if (length === 18) break;
2949
+ }
2950
+ }
2951
+
2952
+ if (bestLength >= 3) {
2953
+ flags |= (1 << token);
2954
+ const encoded = ((bestOffset & 0x0fff) << 4) | ((bestLength - 3) & 0x0f);
2955
+ out.push((encoded >>> 8) & 0xff, encoded & 0xff);
2956
+ for (let i = 0; i < bestLength; i++) remember(pos + i);
2957
+ pos += bestLength;
2958
+ } else {
2959
+ out.push(bytes[pos]);
2960
+ remember(pos);
2961
+ pos++;
2962
+ }
2963
+ }
2964
+ out[flagIndex] = flags;
2965
+ }
2966
+ return Uint8Array.from(out);
2967
+ }
2968
+
2969
+ function decompressPayload(input, expectedLength = null) {
2970
+ const bytes = asBytes(input);
2971
+ const out = [];
2972
+ let pos = 0;
2973
+ while (pos < bytes.length) {
2974
+ const flags = bytes[pos++];
2975
+ for (let token = 0; token < 8 && pos < bytes.length; token++) {
2976
+ if (flags & (1 << token)) {
2977
+ assert(pos + 1 < bytes.length, "Compressed payload is truncated.");
2978
+ const encoded = (bytes[pos++] << 8) | bytes[pos++];
2979
+ const offset = (encoded >>> 4) & 0x0fff;
2980
+ const length = (encoded & 0x0f) + 3;
2981
+ assert(offset > 0 && offset <= out.length, "Compressed payload has an invalid back-reference.");
2982
+ for (let i = 0; i < length; i++) out.push(out[out.length - offset]);
2983
+ } else {
2984
+ out.push(bytes[pos++]);
2985
+ }
2986
+ if (expectedLength != null && out.length >= expectedLength) break;
2987
+ }
2988
+ }
2989
+ if (expectedLength != null) assert(out.length === expectedLength, "Decompressed payload length mismatch.");
2990
+ return Uint8Array.from(out);
2991
+ }
2992
+
2993
+ function prepareCompressedBody(payload, compression = "none") {
2994
+ const mode = normalizeCompressionMode(compression);
2995
+ if (mode === "none") return { body: payload, compression: "none" };
2996
+ const compressed = compressPayload(payload);
2997
+ if (mode === "auto" && compressed.length >= payload.length - 2) {
2998
+ return { body: payload, compression: "none" };
2999
+ }
3000
+ return { body: compressed, compression: "lz" };
3001
+ }
3002
+
3003
+ function normalizeSigningKeyId(value) {
3004
+ if (value == null || value === false || value === "") return new Uint8Array(0);
3005
+ const bytes = typeof value === "string" ? getTextEncoder().encode(value) : asBytes(value);
3006
+ assert(bytes.length <= 64, "Signing key ID must be 64 bytes or fewer.");
3007
+ return bytes;
3008
+ }
3009
+
3010
+ function makePayloadEnvelopeHeader({ compression, flags = 0, originalLength, keyIdLength = 0, publicKeyLength = 0, signatureLength = 0, signatureAlgorithm = null, version = PAYLOAD_ENVELOPE_VERSION }) {
3011
+ assert(keyIdLength <= 0xff, "Signing key ID is too long.");
3012
+ assert(publicKeyLength <= 0xff, "Signing public key is too large.");
3013
+ assert(signatureLength <= 0xff, "Signature is too large.");
3014
+ const header = new Uint8Array(PAYLOAD_ENVELOPE_HEADER_BYTES);
3015
+ header.set(PAYLOAD_ENVELOPE_MAGIC, 0);
3016
+ header[4] = version;
3017
+ header[5] = flags & 0xff;
3018
+ header[6] = PAYLOAD_ENVELOPE_COMPRESSION_IDS[compression];
3019
+ header[7] = PAYLOAD_ENVELOPE_SIGNATURE_IDS[signatureAlgorithm ?? "none"] ?? 0;
3020
+ header.set(u32be(originalLength), 8);
3021
+ header[12] = keyIdLength;
3022
+ header[13] = publicKeyLength;
3023
+ header[14] = signatureLength;
3024
+ header[15] = 0;
3025
+ return header;
3026
+ }
3027
+
3028
+ function isPayloadEnvelope(input) {
3029
+ const bytes = asBytes(input);
3030
+ return bytes.length >= PAYLOAD_ENVELOPE_HEADER_BYTES && PAYLOAD_ENVELOPE_MAGIC.every((value, index) => bytes[index] === value);
3031
+ }
3032
+
3033
+ function packPayloadEnvelope(input, options = {}) {
3034
+ const payload = asBytes(input);
3035
+ const prepared = prepareCompressedBody(payload, options.compression ?? "none");
3036
+ const flags = prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0;
3037
+ const header = makePayloadEnvelopeHeader({
3038
+ compression: prepared.compression,
3039
+ flags,
3040
+ originalLength: payload.length
3041
+ });
3042
+ return concatBytes(header, prepared.body);
3043
+ }
3044
+
3045
+ function parsePayloadEnvelope(input) {
3046
+ const container = asBytes(input);
3047
+ assert(isPayloadEnvelope(container), "Payload extension envelope is not recognized.");
3048
+ const version = container[4];
3049
+ assert(version === 1 || version === PAYLOAD_ENVELOPE_VERSION, `Unsupported payload extension version ${version}.`);
3050
+ const flags = container[5];
3051
+ const compression = PAYLOAD_ENVELOPE_COMPRESSION_BY_ID[container[6]];
3052
+ assert(compression != null, `Unknown payload compression id ${container[6]}.`);
3053
+ const signatureAlgorithm = PAYLOAD_ENVELOPE_SIGNATURE_BY_ID[container[7]];
3054
+ const originalLength = readU32be(container, 8);
3055
+ const metadataLength = container[12];
3056
+ const publicKeyLength = container[13];
3057
+ const signatureLength = container[14];
3058
+ let offset = PAYLOAD_ENVELOPE_HEADER_BYTES;
3059
+ const endMetadata = offset + metadataLength;
3060
+ const endPublicKey = endMetadata + publicKeyLength;
3061
+ const endSignature = endPublicKey + signatureLength;
3062
+ assert(endSignature <= container.length, "Payload extension length fields exceed the envelope size.");
3063
+ const metadataBytes = container.slice(offset, endMetadata);
3064
+ const publicKey = container.slice(endMetadata, endPublicKey);
3065
+ const signature = container.slice(endPublicKey, endSignature);
3066
+ const stored = container.slice(endSignature);
3067
+ const payload = compression === "lz" ? decompressPayload(stored, originalLength) : stored;
3068
+ assert(payload.length === originalLength, "Payload length mismatch after extension decoding.");
3069
+ const signed = Boolean(flags & PAYLOAD_ENVELOPE_SIGNED_FLAG);
3070
+ const signedBytes = signed ? concatBytes(container.slice(0, PAYLOAD_ENVELOPE_HEADER_BYTES), metadataBytes, publicKey, stored) : null;
3071
+
3072
+ // v1 used byte 12 for a human-readable signer label and always embedded
3073
+ // the public key. v2 uses the same field for a compact key ID and makes
3074
+ // public-key embedding optional.
3075
+ const legacySigner = version === 1 && metadataLength ? getTextDecoder().decode(metadataBytes) : null;
3076
+ const keyId = version >= 2 && metadataLength ? getTextDecoder().decode(metadataBytes) : null;
3077
+ const keyIdHex = version >= 2 && metadataLength
3078
+ ? Array.from(metadataBytes, (value) => value.toString(16).padStart(2, "0")).join("")
3079
+ : null;
3080
+
3081
+ return {
3082
+ container,
3083
+ version,
3084
+ compression,
3085
+ compressed: compression !== "none",
3086
+ originalBytes: originalLength,
3087
+ storedBytes: stored.length,
3088
+ savedBytes: originalLength - stored.length,
3089
+ payload,
3090
+ signed,
3091
+ signer: legacySigner,
3092
+ keyId,
3093
+ keyIdHex,
3094
+ publicKey,
3095
+ hasEmbeddedPublicKey: publicKey.length > 0,
3096
+ signature,
3097
+ signatureAlgorithm,
3098
+ signedBytes,
3099
+ signatureVerified: signed ? null : undefined,
3100
+ signatureTrusted: signed ? null : undefined
3101
+ };
3102
+ }
3103
+
3104
+ function subtleCrypto() {
3105
+ const subtle = globalThis.crypto?.subtle;
3106
+ assert(subtle, "Web Crypto is required for QuadQR signing.");
3107
+ return subtle;
3108
+ }
3109
+
3110
+ async function normalizeEd25519PublicKey(key) {
3111
+ const subtle = subtleCrypto();
3112
+ if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) return key;
3113
+ if (key && typeof key === "object" && key.type === "public" && key.algorithm?.name === "Ed25519") return key;
3114
+ const bytes = asBytes(key);
3115
+ assert(bytes.length === 32, "Ed25519 public key must be a 32-byte raw key or CryptoKey.");
3116
+ return subtle.importKey("raw", bytes, { name: "Ed25519" }, true, ["verify"]);
3117
+ }
3118
+
3119
+ async function normalizeEd25519PrivateKey(key) {
3120
+ const subtle = subtleCrypto();
3121
+ if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) return key;
3122
+ if (key && typeof key === "object" && key.type === "private" && key.algorithm?.name === "Ed25519") return key;
3123
+ const bytes = asBytes(key);
3124
+ return subtle.importKey("pkcs8", bytes, { name: "Ed25519" }, false, ["sign"]);
3125
+ }
3126
+
3127
+ async function deriveSigningKeyId(publicKey, bytes = 8) {
3128
+ const subtle = subtleCrypto();
3129
+ const normalized = await normalizeEd25519PublicKey(publicKey);
3130
+ const raw = new Uint8Array(await subtle.exportKey("raw", normalized));
3131
+ const digest = new Uint8Array(await subtle.digest("SHA-256", raw));
3132
+ const count = Math.max(4, Math.min(16, Math.floor(bytes)));
3133
+ return Array.from(digest.slice(0, count), (value) => value.toString(16).padStart(2, "0")).join("");
3134
+ }
3135
+
3136
+ async function generateSigningKeyPair() {
3137
+ const subtle = subtleCrypto();
3138
+ const pair = await subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
3139
+ const publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", pair.publicKey));
3140
+ const privateKeyPkcs8 = new Uint8Array(await subtle.exportKey("pkcs8", pair.privateKey));
3141
+ const keyId = await deriveSigningKeyId(pair.publicKey);
3142
+ return {
3143
+ privateKey: pair.privateKey,
3144
+ publicKey: pair.publicKey,
3145
+ privateKeyPkcs8,
3146
+ publicKeyBytes,
3147
+ keyId,
3148
+ algorithm: "Ed25519"
3149
+ };
3150
+ }
3151
+
3152
+ async function packSignedPayloadEnvelope(input, options = {}) {
3153
+ const payload = asBytes(input);
3154
+ assert(options.privateKey, "privateKey is required to sign a QuadQR payload.");
3155
+ const subtle = subtleCrypto();
3156
+ const prepared = prepareCompressedBody(payload, options.compression ?? "auto");
3157
+ const keyIdBytes = normalizeSigningKeyId(options.keyId);
3158
+
3159
+ let publicKeyBytes = new Uint8Array(0);
3160
+ if (options.embedPublicKey) {
3161
+ assert(options.publicKey, "publicKey is required when embedPublicKey is enabled.");
3162
+ const publicKeyObject = await normalizeEd25519PublicKey(options.publicKey);
3163
+ publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", publicKeyObject));
3164
+ }
3165
+
3166
+ const flags = PAYLOAD_ENVELOPE_SIGNED_FLAG |
3167
+ (prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0) |
3168
+ (publicKeyBytes.length ? PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG : 0);
3169
+ const header = makePayloadEnvelopeHeader({
3170
+ compression: prepared.compression,
3171
+ flags,
3172
+ originalLength: payload.length,
3173
+ keyIdLength: keyIdBytes.length,
3174
+ publicKeyLength: publicKeyBytes.length,
3175
+ signatureLength: 64,
3176
+ signatureAlgorithm: "Ed25519"
3177
+ });
3178
+ const signedBytes = concatBytes(header, keyIdBytes, publicKeyBytes, prepared.body);
3179
+ const privateKey = await normalizeEd25519PrivateKey(options.privateKey);
3180
+ const signature = new Uint8Array(await subtle.sign({ name: "Ed25519" }, privateKey, signedBytes));
3181
+ assert(signature.length === 64, "Unexpected Ed25519 signature length.");
3182
+ return concatBytes(header, keyIdBytes, publicKeyBytes, signature, prepared.body);
3183
+ }
3184
+
3185
+ function lookupTrustedSigningKey(trustedKeys, keyId) {
3186
+ if (!trustedKeys || !keyId) return null;
3187
+ if (trustedKeys instanceof Map) return trustedKeys.get(keyId) ?? null;
3188
+ if (typeof trustedKeys === "object") return trustedKeys[keyId] ?? null;
3189
+ return null;
3190
+ }
3191
+
3192
+ async function verifyPayloadEnvelopeSignature(input, options = {}) {
3193
+ const parsed = input?.container && input?.signedBytes ? input : parsePayloadEnvelope(input);
3194
+ assert(parsed.signed, "QuadQR payload is not signed.");
3195
+ assert(parsed.signatureAlgorithm === "Ed25519", "Unsupported signature algorithm.");
3196
+ const subtle = subtleCrypto();
3197
+
3198
+ let trusted = options.publicKey ?? options.trustedPublicKey ?? lookupTrustedSigningKey(options.trustedKeys, parsed.keyId);
3199
+ let trustSource = trusted ? "external" : null;
3200
+
3201
+ // Legacy v1 symbols embedded the public key by default, so preserve their
3202
+ // historical self-verification behavior. v2 requires explicit opt-in.
3203
+ if (!trusted && parsed.publicKey.length && (parsed.version === 1 || options.allowEmbeddedKey === true)) {
3204
+ trusted = parsed.publicKey;
3205
+ trustSource = "embedded";
3206
+ }
3207
+
3208
+ assert(
3209
+ trusted,
3210
+ parsed.keyId
3211
+ ? `A trusted Ed25519 public key is required to verify signing key ID "${parsed.keyId}".`
3212
+ : "A trusted Ed25519 public key is required to verify this signed QuadQR."
3213
+ );
3214
+
3215
+ const publicKey = await normalizeEd25519PublicKey(trusted);
3216
+ if (trustSource === "external" && parsed.publicKey.length) {
3217
+ const trustedBytes = new Uint8Array(await subtle.exportKey("raw", publicKey));
3218
+ assert(
3219
+ trustedBytes.length === parsed.publicKey.length && trustedBytes.every((value, index) => value === parsed.publicKey[index]),
3220
+ "Embedded signing key does not match the trusted public key."
3221
+ );
3222
+ }
3223
+
3224
+ const verified = await subtle.verify({ name: "Ed25519" }, publicKey, parsed.signature, parsed.signedBytes);
3225
+ return {
3226
+ verified,
3227
+ trusted: verified && trustSource === "external",
3228
+ trustSource,
3229
+ keyId: parsed.keyId ?? null
3230
+ };
3231
+ }
3232
+
3233
+ function prepareOptionalCompressedPayload(input, compression = "none") {
3234
+ const payload = asBytes(input);
3235
+ const mode = normalizeCompressionMode(compression);
3236
+ if (mode === "none") return { payload, extended: false, compression: "none" };
3237
+ const prepared = prepareCompressedBody(payload, mode);
3238
+ // Auto mode is truly zero-overhead when compression does not help.
3239
+ if (mode === "auto" && prepared.compression === "none") {
3240
+ return { payload, extended: false, compression: "none" };
3241
+ }
3242
+ return {
3243
+ payload: packPayloadEnvelope(payload, { compression: prepared.compression }),
3244
+ extended: true,
3245
+ compression: prepared.compression
3246
+ };
3247
+ }
3248
+
2365
3249
  let CRC_TABLE = null;
2366
3250
  let CRC32_ACCELERATOR = null;
2367
3251
 
@@ -2397,8 +3281,19 @@ function crc32(bytes) {
2397
3281
  return (crc ^ 0xffffffff) >>> 0;
2398
3282
  }
2399
3283
 
2400
- function bytesToCells(bytes) {
2401
- 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);
2402
3297
  let cursor = 0;
2403
3298
  for (const byte of bytes) {
2404
3299
  out[cursor++] = (byte >>> 6) & 0b11;
@@ -2409,11 +3304,36 @@ function bytesToCells(bytes) {
2409
3304
  return out;
2410
3305
  }
2411
3306
 
2412
- function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYTE)) {
2413
- assert(cells.length >= byteCount * CELLS_PER_BYTE, "Not enough RGBW cells to rebuild bytes.");
2414
- const out = new Uint8Array(byteCount);
2415
- for (let i = 0; i < byteCount; i++) {
2416
- 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;
2417
3337
  const a = cells[offset];
2418
3338
  const b = cells[offset + 1];
2419
3339
  const c = cells[offset + 2];
@@ -2426,12 +3346,31 @@ function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYT
2426
3346
  return out;
2427
3347
  }
2428
3348
 
2429
- function flagsFor(text, eccLevel, secure = 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) {
2430
3362
  return (text ? TEXT_FLAG : 0) |
2431
3363
  (secure ? SECURE_FLAG : 0) |
3364
+ (extended ? EXTENDED_PAYLOAD_FLAG : 0) |
3365
+ (signed ? SIGNED_FLAG : 0) |
3366
+ (normalizeCellEncoding(cellEncoding) === CELL_ENCODINGS.TRIANGLE16 ? TRIANGLE16_FLAG : 0) |
2432
3367
  ((ECC_LEVELS[eccLevel].id << ECC_SHIFT) & ECC_MASK);
2433
3368
  }
2434
3369
 
3370
+ function cellEncodingFromFlags(flags) {
3371
+ return (flags & TRIANGLE16_FLAG) !== 0 ? CELL_ENCODINGS.TRIANGLE16 : CELL_ENCODINGS.RGBW;
3372
+ }
3373
+
2435
3374
  function eccFromFlags(flags) {
2436
3375
  const id = (flags & ECC_MASK) >> ECC_SHIFT;
2437
3376
  const name = ECC_BY_ID[id];
@@ -2681,7 +3620,8 @@ function maskValue(row, col, maskId) {
2681
3620
  }
2682
3621
  }
2683
3622
 
2684
- function makePaddingCells(count, seed) {
3623
+ function makePaddingCells(count, seed, cellEncoding = CELL_ENCODINGS.RGBW) {
3624
+ const encoding = normalizeCellEncoding(cellEncoding);
2685
3625
  let state = (seed >>> 0) || 0x6d2b79f5;
2686
3626
  const out = new Array(count);
2687
3627
  for (let i = 0; i < count; i++) {
@@ -2689,11 +3629,27 @@ function makePaddingCells(count, seed) {
2689
3629
  state ^= state >>> 17;
2690
3630
  state ^= state << 5;
2691
3631
  state >>>= 0;
2692
- out[i] = state & 3;
3632
+ out[i] = encoding === CELL_ENCODINGS.TRIANGLE16 ? (state & 0x0f) : (state & 3);
2693
3633
  }
2694
3634
  return out;
2695
3635
  }
2696
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
+
2697
3653
  const SPECTRAL_PERMUTATION_CACHE = new Map();
2698
3654
 
2699
3655
  function spectralPermutation(length, version) {
@@ -2722,7 +3678,15 @@ function spectralPermutation(length, version) {
2722
3678
  return permutation;
2723
3679
  }
2724
3680
 
2725
- 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);
2726
3690
  const matrix = cloneMatrix(layout.matrix);
2727
3691
  const permutation = spectralInterleaving
2728
3692
  ? spectralPermutation(layout.dataPositions.length, layout.version)
@@ -2731,16 +3695,37 @@ function applyData(layout, rawCells, maskId, spectralInterleaving = true) {
2731
3695
  for (let logicalIndex = 0; logicalIndex < layout.dataPositions.length; logicalIndex++) {
2732
3696
  const physicalIndex = permutation ? permutation[logicalIndex] : logicalIndex;
2733
3697
  const [row, col] = layout.dataPositions[physicalIndex];
2734
- 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
+ );
2735
3706
  }
2736
3707
  return matrix;
2737
3708
  }
2738
3709
 
2739
- function quaternaryPenalty(matrix, reserved) {
3710
+ function dataCellPenalty(matrix, reserved, cellEncoding = CELL_ENCODINGS.RGBW) {
3711
+ const encoding = normalizeCellEncoding(cellEncoding);
2740
3712
  const size = matrix.length;
2741
3713
  let penalty = 0;
2742
- const counts = [0, 0, 0, 0];
2743
- 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
+ };
2744
3729
 
2745
3730
  for (let r = 0; r < size; r++) {
2746
3731
  let previous = null;
@@ -2751,9 +3736,8 @@ function quaternaryPenalty(matrix, reserved) {
2751
3736
  run = 0;
2752
3737
  continue;
2753
3738
  }
2754
- const value = matrix[r][c];
2755
- counts[value]++;
2756
- dataCount++;
3739
+ const value = symbolAt(r, c);
3740
+ addColors(value);
2757
3741
  if (value === previous) {
2758
3742
  run++;
2759
3743
  if (run >= 4) penalty += 2;
@@ -2773,7 +3757,7 @@ function quaternaryPenalty(matrix, reserved) {
2773
3757
  run = 0;
2774
3758
  continue;
2775
3759
  }
2776
- const value = matrix[r][c];
3760
+ const value = symbolAt(r, c);
2777
3761
  if (value === previous) {
2778
3762
  run++;
2779
3763
  if (run >= 4) penalty += 2;
@@ -2784,9 +3768,9 @@ function quaternaryPenalty(matrix, reserved) {
2784
3768
  }
2785
3769
  }
2786
3770
 
2787
- if (dataCount > 0) {
2788
- const ideal = dataCount / 4;
2789
- 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;
2790
3774
  }
2791
3775
  return penalty;
2792
3776
  }
@@ -2815,7 +3799,7 @@ function deinterleaveBlocks(stream, blockLengths) {
2815
3799
  return blocks;
2816
3800
  }
2817
3801
 
2818
- function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
3802
+ function getBodyRsPlan(payloadLength, eccLevel, version = 2, cellEncoding = CELL_ENCODINGS.RGBW) {
2819
3803
  const ecc = getEffectiveEcc(version, eccLevel);
2820
3804
  const bodyByteCount = payloadLength + CRC_BYTES;
2821
3805
  const dataSymbols = bodyByteCount;
@@ -2837,25 +3821,27 @@ function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
2837
3821
  dataBlockLengths,
2838
3822
  codewordBlockLengths,
2839
3823
  encodedSymbols,
2840
- encodedCells: encodedSymbols * CELLS_PER_BYTE
3824
+ encodedCells: encodedSymbols * cellsPerByteForEncoding(cellEncoding)
2841
3825
  };
2842
3826
  }
2843
3827
 
2844
- function streamCellCount(payloadLength, eccLevel, version) {
2845
- 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;
2846
3831
  }
2847
3832
 
2848
- function streamFitsLayout(layout, eccLevel, payloadLength, version) {
2849
- 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;
2850
3835
  }
2851
3836
 
2852
- function getCapacityForLayout(layout, eccLevel, version) {
2853
- 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;
2854
3840
  let low = 0;
2855
- let high = Math.floor(layout.dataPositions.length / CELLS_PER_BYTE);
3841
+ let high = Math.floor(layout.dataPositions.length / cellsPerByteForEncoding(encoding));
2856
3842
  while (low < high) {
2857
3843
  const mid = Math.ceil((low + high) / 2);
2858
- if (streamFitsLayout(layout, eccLevel, mid, version)) low = mid;
3844
+ if (streamFitsLayout(layout, eccLevel, mid, version, encoding)) low = mid;
2859
3845
  else high = mid - 1;
2860
3846
  }
2861
3847
  return low;
@@ -2864,6 +3850,7 @@ function getCapacityForLayout(layout, eccLevel, version) {
2864
3850
  function getVersionInfo(version, options = {}) {
2865
3851
  validateVersion(version);
2866
3852
  const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
3853
+ const cellEncoding = cellEncodingFromOptions(options);
2867
3854
  const layout = createLayout(version);
2868
3855
  const headerPlan = getHeaderPlan(version);
2869
3856
  const effectiveEcc = getEffectiveEcc(version, eccLevel);
@@ -2873,8 +3860,8 @@ function getVersionInfo(version, options = {}) {
2873
3860
  eccLevel,
2874
3861
  size: layout.size,
2875
3862
  dataCells: layout.dataPositions.length,
2876
- theoreticalBits: layout.dataPositions.length * 2,
2877
- capacityBytes: getCapacityForLayout(layout, eccLevel, version),
3863
+ theoreticalBits: layout.dataPositions.length * (cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2),
3864
+ capacityBytes: getCapacityForLayout(layout, eccLevel, version, cellEncoding),
2878
3865
  headerCells: headerPlan.codewordCells,
2879
3866
  headerBytes: headerPlan.headerBytes,
2880
3867
  headerParitySymbols: headerPlan.paritySymbols,
@@ -2886,8 +3873,11 @@ function getVersionInfo(version, options = {}) {
2886
3873
  hasAlignmentMarker: true,
2887
3874
  alignmentPatterns: layout.alignments.length,
2888
3875
  alignmentCenters: layout.alignments.map(({ row, col }) => [row, col]),
2889
- bitsPerDataCell: 2,
3876
+ bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
3877
+ highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
3878
+ cellEncoding,
2890
3879
  colors: 4,
3880
+ statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
2891
3881
  spectralInterleaving: true,
2892
3882
  confidenceAwareEcc: true
2893
3883
  };
@@ -2898,6 +3888,7 @@ function chooseVersion(payloadLength, options = {}) {
2898
3888
  const minVersion = options.minVersion ?? MIN_VERSION;
2899
3889
  const maxVersion = options.maxVersion ?? MAX_VERSION;
2900
3890
  const ecc = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
3891
+ const cellEncoding = cellEncodingFromOptions(options);
2901
3892
 
2902
3893
  validateVersion(minVersion);
2903
3894
  validateVersion(maxVersion);
@@ -2906,10 +3897,10 @@ function chooseVersion(payloadLength, options = {}) {
2906
3897
  if (requested !== "auto") {
2907
3898
  validateVersion(requested);
2908
3899
  assert(requested >= minVersion && requested <= maxVersion, "Requested version is outside selected bounds.");
2909
- const info = getVersionInfo(requested, { ecc });
3900
+ const info = getVersionInfo(requested, { ecc, cellEncoding });
2910
3901
  const layout = createLayout(requested);
2911
3902
  assert(
2912
- payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested),
3903
+ payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested, cellEncoding),
2913
3904
  `Payload does not fit version ${requested} with ${ecc} ECC. Maximum is ${info.capacityBytes} bytes.`
2914
3905
  );
2915
3906
  return requested;
@@ -2917,20 +3908,20 @@ function chooseVersion(payloadLength, options = {}) {
2917
3908
 
2918
3909
  for (let version = minVersion; version <= maxVersion; version++) {
2919
3910
  const layout = createLayout(version);
2920
- if (streamFitsLayout(layout, ecc, payloadLength, version)) return version;
3911
+ if (streamFitsLayout(layout, ecc, payloadLength, version, cellEncoding)) return version;
2921
3912
  }
2922
3913
  throw new Error(`Payload is too large for versions ${minVersion}..${maxVersion}.`);
2923
3914
  }
2924
3915
 
2925
- function encodeProtectedHeader(header, version) {
3916
+ function encodeProtectedHeader(header, version, cellEncoding = CELL_ENCODINGS.RGBW) {
2926
3917
  const plan = getHeaderPlan(version);
2927
3918
  const codeword = rsEncode(Array.from(header), plan.paritySymbols);
2928
3919
  assert(codeword.length === plan.codewordBytes, "Header RS symbol calculation mismatch.");
2929
- return bytesToCells(codeword);
3920
+ return bytesToProtectedHeaderCells(codeword, cellEncoding);
2930
3921
  }
2931
3922
 
2932
- function encodeProtectedBody(bodyBytes, eccLevel, version) {
2933
- 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);
2934
3925
  assert(bodyBytes.length === plan.dataSymbols, "Body RS symbol calculation mismatch.");
2935
3926
 
2936
3927
  const blocks = [];
@@ -2939,7 +3930,7 @@ function encodeProtectedBody(bodyBytes, eccLevel, version) {
2939
3930
  blocks.push(rsEncode(Array.from(bodyBytes.slice(offset, offset + length)), plan.paritySymbols));
2940
3931
  offset += length;
2941
3932
  }
2942
- return { cells: bytesToCells(interleaveBlocks(blocks)), plan };
3933
+ return { cells: bytesToCells(interleaveBlocks(blocks), cellEncoding), plan };
2943
3934
  }
2944
3935
 
2945
3936
  function finalizeMatrix(layout, rawCells, meta) {
@@ -2947,9 +3938,10 @@ function finalizeMatrix(layout, rawCells, meta) {
2947
3938
  let bestMatrix = null;
2948
3939
  let bestPenalty = Infinity;
2949
3940
 
3941
+ const cellEncoding = normalizeCellEncoding(meta.cellEncoding ?? CELL_ENCODINGS.RGBW);
2950
3942
  for (let maskId = 0; maskId < 4; maskId++) {
2951
- const candidate = applyData(layout, rawCells, maskId, true);
2952
- const penalty = quaternaryPenalty(candidate, layout.reserved);
3943
+ const candidate = applyData(layout, rawCells, maskId, true, cellEncoding);
3944
+ const penalty = dataCellPenalty(candidate, layout.reserved, cellEncoding);
2953
3945
  if (penalty < bestPenalty) {
2954
3946
  bestPenalty = penalty;
2955
3947
  bestMaskId = maskId;
@@ -2957,7 +3949,7 @@ function finalizeMatrix(layout, rawCells, meta) {
2957
3949
  }
2958
3950
  }
2959
3951
 
2960
- const info = getVersionInfo(meta.version, { ecc: meta.eccLevel });
3952
+ const info = getVersionInfo(meta.version, { ecc: meta.eccLevel, cellEncoding });
2961
3953
  return {
2962
3954
  format: "QuadQR",
2963
3955
  formatVersion: FORMAT_VERSION,
@@ -2968,6 +3960,11 @@ function finalizeMatrix(layout, rawCells, meta) {
2968
3960
  payloadBytes: meta.payloadBytes,
2969
3961
  sourcePayloadBytes: meta.sourcePayloadBytes ?? meta.payloadBytes,
2970
3962
  secure: Boolean(meta.secure),
3963
+ compressed: meta.compression != null && meta.compression !== "none",
3964
+ compression: meta.compression ?? "none",
3965
+ signed: Boolean(meta.signed),
3966
+ signingKeyId: meta.signingKeyId ?? null,
3967
+ hasEmbeddedPublicKey: Boolean(meta.hasEmbeddedPublicKey),
2971
3968
  requiresDecryption: Boolean(meta.secure),
2972
3969
  security: meta.security ?? null,
2973
3970
  meaningfulCells: meta.meaningfulCells,
@@ -2975,7 +3972,10 @@ function finalizeMatrix(layout, rawCells, meta) {
2975
3972
  capacityBytes: info.capacityBytes,
2976
3973
  alignmentPatterns: layout.alignments.length,
2977
3974
  utilization: meta.meaningfulCells / layout.dataPositions.length,
2978
- 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,
2979
3979
  eccLevel: meta.eccLevel,
2980
3980
  eccParitySymbols: meta.eccParitySymbols,
2981
3981
  eccBlocks: meta.eccBlocks,
@@ -2986,39 +3986,44 @@ function finalizeMatrix(layout, rawCells, meta) {
2986
3986
  };
2987
3987
  }
2988
3988
 
2989
- function encodeText(text, options = {}) {
2990
- assert(typeof text === "string", "encodeText expects a string.");
2991
- return encodeBytes(getTextEncoder().encode(text), { ...options, text: true });
2992
- }
2993
-
2994
- function encodeBytes(input, options = {}) {
3989
+ function encodePreparedBytes(input, options = {}) {
2995
3990
  if (options.formatVersion != null && options.formatVersion !== FORMAT_VERSION) {
2996
3991
  throw new Error(`Only QuadQR format version ${FORMAT_VERSION} is supported.`);
2997
3992
  }
2998
3993
 
2999
- const payload = input instanceof Uint8Array ? input : new Uint8Array(input);
3994
+ const payload = asBytes(input);
3000
3995
  const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
3001
3996
  const secure = Boolean(options.secure);
3002
- const flags = flagsFor(Boolean(options.text), eccLevel, secure);
3003
- const version = chooseVersion(payload.length, { ...options, ecc: eccLevel });
3997
+ const extended = Boolean(options.extended);
3998
+ const signed = Boolean(options.signed);
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 });
3004
4002
  const layout = createLayout(version);
3005
4003
  const header = makeHeader(payload.length, flags, version);
3006
4004
  const crc = crc32(concatBytes(header, payload));
3007
- const headerCells = encodeProtectedHeader(header, version);
3008
- 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);
3009
4007
  const meaningfulCells = headerCells.concat(bodyEncoded.cells);
3010
4008
  assert(meaningfulCells.length <= layout.dataPositions.length, "Internal QuadQR capacity calculation error.");
3011
4009
 
3012
4010
  const padding = makePaddingCells(
3013
4011
  layout.dataPositions.length - meaningfulCells.length,
3014
- crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16)
4012
+ crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16),
4013
+ cellEncoding
3015
4014
  );
3016
4015
 
3017
4016
  return finalizeMatrix(layout, meaningfulCells.concat(padding), {
3018
4017
  version,
4018
+ cellEncoding,
3019
4019
  payloadBytes: payload.length,
3020
4020
  sourcePayloadBytes: options.sourcePayloadBytes ?? payload.length,
3021
4021
  secure,
4022
+ extended,
4023
+ signed,
4024
+ compression: options.compressionMetadata ?? "none",
4025
+ signingKeyId: options.signingKeyId ?? null,
4026
+ hasEmbeddedPublicKey: Boolean(options.hasEmbeddedPublicKey),
3022
4027
  security: options.securityMetadata ?? null,
3023
4028
  meaningfulCells: meaningfulCells.length,
3024
4029
  eccLevel,
@@ -3029,6 +4034,58 @@ function encodeBytes(input, options = {}) {
3029
4034
  });
3030
4035
  }
3031
4036
 
4037
+ function encodeText(text, options = {}) {
4038
+ assert(typeof text === "string", "encodeText expects a string.");
4039
+ const source = getTextEncoder().encode(text);
4040
+ const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
4041
+ return encodePreparedBytes(prepared.payload, {
4042
+ ...options,
4043
+ text: true,
4044
+ extended: prepared.extended,
4045
+ sourcePayloadBytes: source.length,
4046
+ compressionMetadata: prepared.compression
4047
+ });
4048
+ }
4049
+
4050
+ function encodeBytes(input, options = {}) {
4051
+ const source = asBytes(input);
4052
+ const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
4053
+ return encodePreparedBytes(prepared.payload, {
4054
+ ...options,
4055
+ text: Boolean(options.text),
4056
+ extended: prepared.extended,
4057
+ sourcePayloadBytes: source.length,
4058
+ compressionMetadata: prepared.compression
4059
+ });
4060
+ }
4061
+
4062
+ /** Explicit Uint8Array convenience API. Equivalent to encodeBytes(). */
4063
+ function encodeUint8Array(input, options = {}) {
4064
+ return encodeBytes(asBytes(input), options);
4065
+ }
4066
+
4067
+ /** Encode and sign a normal payload using an internal Ed25519 envelope. */
4068
+ async function encodeSignedBytes(input, options = {}) {
4069
+ const source = asBytes(input);
4070
+ const envelope = await packSignedPayloadEnvelope(source, options);
4071
+ const parsed = parsePayloadEnvelope(envelope);
4072
+ return encodePreparedBytes(envelope, {
4073
+ ...options,
4074
+ text: Boolean(options.text),
4075
+ extended: true,
4076
+ signed: true,
4077
+ sourcePayloadBytes: source.length,
4078
+ compressionMetadata: parsed.compression,
4079
+ signingKeyId: parsed.keyId,
4080
+ hasEmbeddedPublicKey: parsed.hasEmbeddedPublicKey
4081
+ });
4082
+ }
4083
+
4084
+ async function encodeSignedText(text, options = {}) {
4085
+ assert(typeof text === "string", "encodeSignedText expects a string.");
4086
+ return encodeSignedBytes(getTextEncoder().encode(text), { ...options, text: true });
4087
+ }
4088
+
3032
4089
  /**
3033
4090
  * Encode text using the optional QuadQR Secure Payload v1 layer.
3034
4091
  * This API is async because Web Crypto performs AES-GCM and password KDF work asynchronously.
@@ -3040,32 +4097,75 @@ async function encodeSecureText(text, options = {}) {
3040
4097
 
3041
4098
  /** Encode arbitrary bytes using password or raw 256-bit key security. */
3042
4099
  async function encodeSecureBytes(input, options = {}) {
3043
- const sourcePayload = input instanceof Uint8Array ? input : new Uint8Array(input);
4100
+ const sourcePayload = asBytes(input);
3044
4101
  const security = options.security ?? {};
3045
- const encrypted = await encryptSecurePayload(sourcePayload, security);
3046
- return encodeBytes(encrypted.envelope, {
4102
+ let protectedPayload = sourcePayload;
4103
+ let extended = false;
4104
+ let signed = false;
4105
+ let compression = "none";
4106
+ let signingKeyId = null;
4107
+ let hasEmbeddedPublicKey = false;
4108
+
4109
+ if (options.signing) {
4110
+ protectedPayload = await packSignedPayloadEnvelope(sourcePayload, {
4111
+ ...options,
4112
+ ...options.signing,
4113
+ compression: options.compression ?? "auto"
4114
+ });
4115
+ const parsed = parsePayloadEnvelope(protectedPayload);
4116
+ extended = true;
4117
+ signed = true;
4118
+ compression = parsed.compression;
4119
+ signingKeyId = parsed.keyId;
4120
+ hasEmbeddedPublicKey = parsed.hasEmbeddedPublicKey;
4121
+ } else {
4122
+ const prepared = prepareOptionalCompressedPayload(sourcePayload, options.compression ?? "none");
4123
+ protectedPayload = prepared.payload;
4124
+ extended = prepared.extended;
4125
+ compression = prepared.compression;
4126
+ }
4127
+
4128
+ const encrypted = await encryptSecurePayload(protectedPayload, security);
4129
+ return encodePreparedBytes(encrypted.envelope, {
3047
4130
  ...options,
3048
4131
  text: Boolean(options.text),
3049
4132
  secure: true,
4133
+ extended,
4134
+ signed,
3050
4135
  sourcePayloadBytes: sourcePayload.length,
4136
+ compressionMetadata: compression,
4137
+ signingKeyId,
4138
+ hasEmbeddedPublicKey,
3051
4139
  securityMetadata: encrypted.metadata
3052
4140
  });
3053
4141
  }
3054
4142
 
3055
4143
  /**
3056
4144
  * Decrypt a result returned by decodeMatrix/scanImageData/scanFile.
3057
- * The encrypted envelope remains available as encryptedPayload.
4145
+ * Compression and signatures are automatically unwrapped after decryption.
3058
4146
  */
3059
4147
  async function decryptDecoded(result, security = {}) {
3060
4148
  assert(result?.secure && result?.payload, "decryptDecoded expects an encrypted QuadQR decode result.");
3061
4149
  const plaintext = await decryptSecurePayload(result.payload, security);
3062
4150
  const isText = (result.flags & TEXT_FLAG) !== 0;
4151
+ const extended = (result.flags & EXTENDED_PAYLOAD_FLAG) !== 0;
4152
+ const envelope = extended ? parsePayloadEnvelope(plaintext) : null;
4153
+ const payload = envelope ? envelope.payload : plaintext;
3063
4154
  return {
3064
4155
  ...result,
3065
4156
  encryptedPayload: result.payload,
3066
4157
  encryptedPayloadBytes: result.payload.length,
3067
- payload: plaintext,
3068
- text: isText ? getTextDecoder().decode(plaintext) : null,
4158
+ protectedPayload: envelope ? plaintext : undefined,
4159
+ payload,
4160
+ text: isText ? getTextDecoder().decode(payload) : null,
4161
+ compressed: envelope?.compressed ?? false,
4162
+ compression: envelope?.compression ?? "none",
4163
+ signed: envelope?.signed ?? result.signed ?? false,
4164
+ signatureVerified: envelope?.signatureVerified ?? result.signatureVerified,
4165
+ signatureTrusted: envelope?.signatureTrusted ?? result.signatureTrusted,
4166
+ signingKeyId: envelope?.keyId ?? result.signingKeyId ?? null,
4167
+ hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? result.hasEmbeddedPublicKey ?? false,
4168
+ signer: envelope?.signer ?? result.signer ?? null,
3069
4169
  decrypted: true,
3070
4170
  requiresDecryption: false,
3071
4171
  security: { ...result.security, decrypted: true }
@@ -3145,12 +4245,14 @@ function rotate90(matrix) {
3145
4245
  return out;
3146
4246
  }
3147
4247
 
3148
- 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;
3149
4251
  const out = [];
3150
4252
  for (const [row, col] of layout.dataPositions) {
3151
4253
  const value = matrix[row][col];
3152
- if (!Number.isInteger(value) || value < 0 || value > 3) {
3153
- 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.`);
3154
4256
  }
3155
4257
  out.push(value);
3156
4258
  }
@@ -3164,6 +4266,34 @@ function unmaskCells(visibleCells, positions, maskId) {
3164
4266
  });
3165
4267
  }
3166
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
+
3167
4297
  function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
3168
4298
  if (!spectralInterleaving) return physicalValues.slice();
3169
4299
  const permutation = spectralPermutation(physicalValues.length, version);
@@ -3174,20 +4304,21 @@ function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
3174
4304
  return logical;
3175
4305
  }
3176
4306
 
3177
- function cellsToSymbolConfidences(confidences, byteCount) {
4307
+ function cellsToSymbolConfidences(
4308
+ confidences,
4309
+ byteCount,
4310
+ cellsPerByte = RGBW_CELLS_PER_BYTE
4311
+ ) {
3178
4312
  if (!confidences) return null;
3179
- if (confidences.length < byteCount * CELLS_PER_BYTE) return null;
4313
+ if (confidences.length < byteCount * cellsPerByte) return null;
3180
4314
  const out = new Array(byteCount);
3181
4315
  for (let symbolIndex = 0; symbolIndex < byteCount; symbolIndex++) {
3182
- const offset = symbolIndex * CELLS_PER_BYTE;
3183
- // A single wrong 2-bit cell changes the GF(256) symbol, so the weakest
3184
- // constituent cell is the useful confidence bound for that symbol.
3185
- out[symbolIndex] = Math.min(
3186
- confidences[offset] ?? 1,
3187
- confidences[offset + 1] ?? 1,
3188
- confidences[offset + 2] ?? 1,
3189
- confidences[offset + 3] ?? 1
3190
- );
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;
3191
4322
  }
3192
4323
  return out;
3193
4324
  }
@@ -3224,11 +4355,11 @@ function decodeRsAdaptive(codeword, paritySymbols, symbolConfidences, options =
3224
4355
  }
3225
4356
  }
3226
4357
 
3227
- function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}) {
4358
+ function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}, cellEncoding = CELL_ENCODINGS.RGBW) {
3228
4359
  const plan = getHeaderPlan(version);
3229
4360
  const headerCells = rawCells.slice(0, plan.codewordCells);
3230
- const codeword = Array.from(cellsToBytes(headerCells, plan.codewordBytes));
3231
- 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);
3232
4363
  const decoded = decodeRsAdaptive(codeword, plan.paritySymbols, confidences, options);
3233
4364
  const header = new Uint8Array(decoded.data);
3234
4365
  const parsed = parseHeader(header, version);
@@ -3243,16 +4374,17 @@ function decodeProtectedHeader(rawCells, version, rawConfidences = null, options
3243
4374
  };
3244
4375
  }
3245
4376
 
3246
- function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConfidences = null, options = {}) {
3247
- 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);
3248
4379
  const bodyStart = getHeaderPlan(version).codewordCells;
3249
4380
  const encodedCells = rawCells.slice(bodyStart, bodyStart + plan.encodedCells);
3250
4381
  if (encodedCells.length !== plan.encodedCells) throw new Error("Protected body is incomplete.");
3251
4382
 
3252
- const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols));
4383
+ const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols, cellEncoding));
3253
4384
  const symbolConfidences = cellsToSymbolConfidences(
3254
4385
  rawConfidences?.slice(bodyStart, bodyStart + plan.encodedCells),
3255
- plan.encodedSymbols
4386
+ plan.encodedSymbols,
4387
+ cellsPerByteForEncoding(cellEncoding)
3256
4388
  );
3257
4389
  const blocks = deinterleaveBlocks(encodedSymbols, plan.codewordBlockLengths);
3258
4390
  const confidenceBlocks = symbolConfidences
@@ -3295,83 +4427,136 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
3295
4427
  if (!validateStructure(matrix, tolerance)) throw new Error("QuadQR finder/alignment structure does not match.");
3296
4428
 
3297
4429
  const layout = createLayout(version);
3298
- const visible = extractVisibleCells(matrix, layout);
3299
- const visibleConfidences = confidenceMatrix
3300
- ? layout.dataPositions.map(([row, col]) => confidenceMatrix[row]?.[col] ?? 1)
3301
- : null;
3302
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
+ }
3303
4449
 
3304
- // New symbols use spectral-spatial interleaving. Legacy order remains a
3305
- // decode fallback so existing QuadQR images do not become unreadable.
3306
- for (const spectralInterleaving of [true, false]) {
3307
- for (let maskId = 0; maskId < 4; maskId++) {
3308
- try {
3309
- const physicalRaw = unmaskCells(visible, layout.dataPositions, maskId);
3310
- const raw = restoreLogicalOrder(physicalRaw, version, spectralInterleaving);
3311
- const rawConfidences = visibleConfidences
3312
- ? restoreLogicalOrder(visibleConfidences, version, spectralInterleaving)
3313
- : null;
3314
- const headerDecoded = decodeProtectedHeader(raw, version, rawConfidences, options);
3315
- const header = headerDecoded.header;
3316
- const flags = headerDecoded.flags;
3317
- const eccLevel = eccFromFlags(flags);
3318
- const payloadLength = headerDecoded.payloadLength;
3319
- if (streamCellCount(payloadLength, eccLevel, version) > raw.length) {
3320
- throw new Error("Declared payload exceeds matrix capacity.");
3321
- }
4450
+ const visibleConfidences = confidenceMatrix
4451
+ ? layout.dataPositions.map(([row, col]) => confidenceMatrix[row]?.[col] ?? 1)
4452
+ : null;
3322
4453
 
3323
- const bodyDecoded = decodeProtectedBody(
3324
- raw,
3325
- payloadLength,
3326
- eccLevel,
3327
- version,
3328
- rawConfidences,
3329
- options
3330
- );
3331
- const body = bodyDecoded.body;
3332
- const payload = body.slice(0, payloadLength);
3333
- const expectedCrc = readU32be(body, payloadLength);
3334
- const actualCrc = crc32(concatBytes(header, payload));
3335
- if (expectedCrc !== actualCrc) throw new Error("CRC mismatch after ECC.");
3336
-
3337
- const isText = (flags & TEXT_FLAG) !== 0;
3338
- const secure = (flags & SECURE_FLAG) !== 0;
3339
- const security = secure ? inspectSecureEnvelope(payload) : null;
3340
- const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
3341
- const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
3342
- return {
3343
- ok: true,
3344
- format: "QuadQR",
3345
- formatVersion: FORMAT_VERSION,
3346
- version,
3347
- size,
3348
- alignmentPatterns: layout.alignments.length,
3349
- maskId,
3350
- rotation,
3351
- flags,
3352
- eccLevel,
3353
- eccParitySymbols: bodyDecoded.plan.paritySymbols,
3354
- eccBlocks: bodyDecoded.plan.dataBlockLengths.length,
3355
- correctableSymbolsPerBlock: bodyDecoded.plan.correctableSymbolsPerBlock,
3356
- spectralInterleaving,
3357
- confidenceAwareEcc: Boolean(rawConfidences),
3358
- confidenceAssisted: headerDecoded.confidenceAssisted || bodyDecoded.confidenceAssisted,
3359
- erasureSymbols,
3360
- unknownErrorSymbols,
3361
- correctedHeaderSymbols: headerDecoded.correctedSymbols,
3362
- correctedBodySymbols: bodyDecoded.correctedSymbols,
3363
- correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
3364
- payload,
3365
- text: isText && !secure ? getTextDecoder().decode(payload) : null,
3366
- secure,
3367
- encrypted: secure,
3368
- decrypted: false,
3369
- requiresDecryption: secure,
3370
- security,
3371
- crc32: actualCrc >>> 0
3372
- };
3373
- } catch (error) {
3374
- 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
+ }
3375
4560
  }
3376
4561
  }
3377
4562
  }
@@ -3407,6 +4592,26 @@ function decodeMatrix(inputMatrix, options = {}) {
3407
4592
  throw new Error(`Unable to decode matrix. ${errors.join(" || ")}`);
3408
4593
  }
3409
4594
 
4595
+ /** Decode a matrix and return only its application payload bytes. */
4596
+ function decodeUint8Array(inputMatrix, options = {}) {
4597
+ return decodeMatrix(inputMatrix, options).payload;
4598
+ }
4599
+
4600
+ /** Verify a signed decode result and return the result with verification state attached. */
4601
+ async function verifyDecodedSignature(result, options = {}) {
4602
+ assert(result?.signed, "Decode result is not signed.");
4603
+ const source = result.protectedPayload;
4604
+ assert(source, "Signed payload metadata is unavailable. Decrypt secure content first if necessary.");
4605
+ const verification = await verifyPayloadEnvelopeSignature(source, options);
4606
+ return {
4607
+ ...result,
4608
+ signatureVerified: verification.verified,
4609
+ signatureTrusted: verification.trusted,
4610
+ signingKeyId: verification.keyId ?? result.signingKeyId ?? null,
4611
+ signatureTrustSource: verification.trustSource
4612
+ };
4613
+ }
4614
+
3410
4615
  function hexToRgb(hex) {
3411
4616
  const clean = hex.replace("#", "");
3412
4617
  assert(clean.length === 6, `Invalid hex color ${hex}.`);
@@ -3421,6 +4626,18 @@ function resolvePalette(palette = {}) {
3421
4626
  return { ...DEFAULT_PALETTE, ...palette };
3422
4627
  }
3423
4628
 
4629
+ function normalizeRenderMode(mode = RENDER_MODES.SCREEN) {
4630
+ const value = String(mode).toLowerCase();
4631
+ assert(value === RENDER_MODES.SCREEN || value === RENDER_MODES.PRINT, "Render mode must be screen or print.");
4632
+ return value;
4633
+ }
4634
+
4635
+ function resolveRenderPalette(options = {}) {
4636
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4637
+ const base = mode === RENDER_MODES.PRINT ? PRINT_PALETTE : DEFAULT_PALETTE;
4638
+ return { ...base, ...(options.palette ?? {}) };
4639
+ }
4640
+
3424
4641
  function paletteRgb(palette = {}) {
3425
4642
  const p = resolvePalette(palette);
3426
4643
  return {
@@ -3454,6 +4671,79 @@ function cellRgb(cell, palette) {
3454
4671
  }
3455
4672
  }
3456
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
+
3457
4747
  function normalizeRenderStyle(style = RENDER_STYLES.CLASSIC) {
3458
4748
  const value = String(style).toLowerCase();
3459
4749
  const allowed = Object.values(RENDER_STYLES);
@@ -3584,18 +4874,347 @@ function fillImageRoundedRect(data, width, x, y, rectWidth, rectHeight, radius,
3584
4874
  }
3585
4875
  }
3586
4876
 
4877
+ /** Estimate a conservative center-logo ratio from ECC, utilization and output mode. */
4878
+ function estimateSafeLogoSize(codeOrMatrix, options = {}) {
4879
+ const code = Array.isArray(codeOrMatrix) ? null : codeOrMatrix;
4880
+ const ecc = String(code?.eccLevel ?? options.ecc ?? DEFAULT_ECC_LEVEL).toUpperCase();
4881
+ const base = { L: 0.10, M: 0.13, Q: 0.16, H: 0.19 }[ecc] ?? 0.12;
4882
+ const utilization = Number.isFinite(code?.utilization) ? code.utilization : Number(options.utilization ?? 0.65);
4883
+ const utilizationPenalty = utilization <= 0.55 ? 1 : Math.max(0.72, 1 - (utilization - 0.55) * 0.56);
4884
+ const clearBackground = Boolean(options.clearBackground ?? options.logoClearBackground ?? options.logo?.clearBackground);
4885
+ const clearPenalty = clearBackground ? 0.90 : 1;
4886
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
4887
+ const printPenalty = mode === RENDER_MODES.PRINT ? 0.88 : 1;
4888
+ const versionPenalty = code?.version === 1 ? 0.88 : 1;
4889
+ return Math.max(0.07, Math.min(0.22, base * utilizationPenalty * clearPenalty * printPenalty * versionPenalty));
4890
+ }
4891
+
4892
+ /**
4893
+ * Empirically search for the largest logo ratio that still decodes from the
4894
+ * dependency-free ImageData renderer. The logo source must therefore be ImageData-like.
4895
+ */
4896
+ function findMaxSafeLogoSize(code, options = {}) {
4897
+ assert(code?.matrix, "findMaxSafeLogoSize expects an encoded QuadQR object.");
4898
+ const logo = options.logo;
4899
+ const source = logo?.source ?? logo;
4900
+ assert(source?.data && source?.width && source?.height,
4901
+ "findMaxSafeLogoSize requires an ImageData-like logo source so it can test pixel output without DOM dependencies.");
4902
+ let low = Math.max(0.05, Number(options.minSize ?? 0.05));
4903
+ let high = Math.min(0.30, Number(options.maxSize ?? 0.28));
4904
+ let best = 0;
4905
+ const iterations = Math.max(3, Math.min(10, Math.floor(options.iterations ?? 7)));
4906
+ for (let i = 0; i < iterations; i++) {
4907
+ const probe = (low + high) / 2;
4908
+ try {
4909
+ const image = renderToImageData(code, {
4910
+ ...options,
4911
+ logo: {
4912
+ ...(typeof logo === "object" && Object.prototype.hasOwnProperty.call(logo, "source") ? logo : { source }),
4913
+ size: probe
4914
+ }
4915
+ });
4916
+ const decoded = scanImageData(image, { minVersion: code.version, maxVersion: code.version });
4917
+ if (decoded.crc32 !== code.crc32) throw new Error("Decoded a different payload.");
4918
+ best = probe;
4919
+ low = probe;
4920
+ } catch {
4921
+ high = probe;
4922
+ }
4923
+ }
4924
+ return {
4925
+ safeSize: best || estimateSafeLogoSize(code, options),
4926
+ testedMax: Number(options.maxSize ?? 0.28),
4927
+ iterations,
4928
+ empirical: best > 0
4929
+ };
4930
+ }
4931
+
4932
+ function getPrintGuidance(codeOrMatrix, options = {}) {
4933
+ const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix?.matrix;
4934
+ assert(matrix?.length, "getPrintGuidance expects a QuadQR code or matrix.");
4935
+ const quietZone = Math.max(4, Math.floor(options.quietZone ?? 4));
4936
+ const physicalSizeMm = Number(options.physicalSizeMm ?? 35);
4937
+ const totalModules = matrix.length + quietZone * 2;
4938
+ const moduleSizeMm = physicalSizeMm / totalModules;
4939
+ const dpi = Number(options.dpi ?? 300);
4940
+ const modulePixels = moduleSizeMm / 25.4 * dpi;
4941
+ const recommendedMinimumModuleMm = Number(options.minimumModuleMm ?? 0.40);
4942
+ return {
4943
+ mode: "print",
4944
+ physicalSizeMm,
4945
+ quietZone,
4946
+ moduleSizeMm,
4947
+ modulePixelsAtDpi: modulePixels,
4948
+ dpi,
4949
+ recommendedMinimumModuleMm,
4950
+ meetsRecommendedModuleSize: moduleSizeMm >= recommendedMinimumModuleMm,
4951
+ recommendedMinimumPhysicalSizeMm: totalModules * recommendedMinimumModuleMm,
4952
+ palette: PRINT_PALETTE,
4953
+ recommendations: [
4954
+ "Use classic rendering for print unless a styled output has been physically validated.",
4955
+ "Keep at least a 4-module quiet zone.",
4956
+ `Target at least ${recommendedMinimumModuleMm.toFixed(2)} mm per module for general-purpose printing.`,
4957
+ "Avoid glossy reflections and low-ink/toner modes when reliable color classification matters."
4958
+ ]
4959
+ };
4960
+ }
4961
+
4962
+ function resolveLogoRenderOptions(options, matrixSize, moduleSize, quietZone, palette, codeOrMatrix = null) {
4963
+ if (!options.logo) return null;
4964
+
4965
+ const nested = (
4966
+ options.logo &&
4967
+ typeof options.logo === "object" &&
4968
+ Object.prototype.hasOwnProperty.call(options.logo, "source")
4969
+ ) ? options.logo : null;
4970
+ const source = nested ? nested.source : options.logo;
4971
+ if (!source) return null;
4972
+
4973
+ const requestedSize = nested?.size ?? options.logoSize ?? 0.18;
4974
+ const autoSize = String(requestedSize).toLowerCase() === "auto";
4975
+ const rawSize = Number(requestedSize);
4976
+ const sizeRatio = autoSize
4977
+ ? estimateSafeLogoSize(codeOrMatrix, { ...options, clearBackground: nested?.clearBackground ?? options.logoClearBackground })
4978
+ : Math.max(0.05, Math.min(0.30, Number.isFinite(rawSize) ? rawSize : 0.18));
4979
+ const rawPadding = Number(nested?.padding ?? options.logoPadding ?? 0.65);
4980
+ const rawRadius = Number(nested?.radius ?? options.logoRadius ?? 0.8);
4981
+ const paddingModules = Math.max(0, Number.isFinite(rawPadding) ? rawPadding : 0.65);
4982
+ const radiusModules = Math.max(0, Number.isFinite(rawRadius) ? rawRadius : 0.8);
4983
+ const clearBackground = Boolean(nested?.clearBackground ?? options.logoClearBackground ?? false);
4984
+ const backgroundColor = nested?.backgroundColor ?? options.logoBackgroundColor ?? palette.white;
4985
+
4986
+ const codePixels = matrixSize * moduleSize;
4987
+ const canvasPixels = (matrixSize + quietZone * 2) * moduleSize;
4988
+ const logoPixels = Math.max(1, codePixels * sizeRatio);
4989
+ const center = canvasPixels / 2;
4990
+ const paddingPixels = paddingModules * moduleSize;
4991
+ const backgroundPixels = logoPixels + paddingPixels * 2;
4992
+
4993
+ return {
4994
+ source,
4995
+ sizeRatio,
4996
+ clearBackground,
4997
+ backgroundColor,
4998
+ logoX: center - logoPixels / 2,
4999
+ logoY: center - logoPixels / 2,
5000
+ logoSize: logoPixels,
5001
+ backgroundX: center - backgroundPixels / 2,
5002
+ backgroundY: center - backgroundPixels / 2,
5003
+ backgroundSize: backgroundPixels,
5004
+ backgroundRadius: radiusModules * moduleSize
5005
+ };
5006
+ }
5007
+
5008
+ function canvasLogoSource(source) {
5009
+ if (!source || typeof source === "string") return source;
5010
+ if (source.data && Number.isInteger(source.width) && Number.isInteger(source.height)) {
5011
+ let scratch = null;
5012
+ if (typeof OffscreenCanvas !== "undefined") {
5013
+ scratch = new OffscreenCanvas(source.width, source.height);
5014
+ } else if (typeof document !== "undefined") {
5015
+ scratch = document.createElement("canvas");
5016
+ scratch.width = source.width;
5017
+ scratch.height = source.height;
5018
+ }
5019
+ if (!scratch) return source;
5020
+ const ctx = scratch.getContext("2d");
5021
+ if (!ctx) return source;
5022
+ const pixels = source.data instanceof Uint8ClampedArray
5023
+ ? source.data
5024
+ : new Uint8ClampedArray(source.data);
5025
+ ctx.putImageData(new ImageData(pixels, source.width, source.height), 0, 0);
5026
+ return scratch;
5027
+ }
5028
+ return source;
5029
+ }
5030
+
5031
+ function logoSourceDimensions(source) {
5032
+ const width = source?.naturalWidth ?? source?.videoWidth ?? source?.width;
5033
+ const height = source?.naturalHeight ?? source?.videoHeight ?? source?.height;
5034
+ if (!(Number(width) > 0 && Number(height) > 0)) return null;
5035
+ return { width: Number(width), height: Number(height) };
5036
+ }
5037
+
5038
+ function containRect(x, y, size, dimensions) {
5039
+ if (!dimensions) return { x, y, width: size, height: size };
5040
+ const scale = Math.min(size / dimensions.width, size / dimensions.height);
5041
+ const width = dimensions.width * scale;
5042
+ const height = dimensions.height * scale;
5043
+ return {
5044
+ x: x + (size - width) / 2,
5045
+ y: y + (size - height) / 2,
5046
+ width,
5047
+ height
5048
+ };
5049
+ }
5050
+
5051
+ function drawLogoToCanvas(ctx, logo, palette) {
5052
+ if (!logo) return;
5053
+ if (logo.clearBackground) {
5054
+ ctx.fillStyle = logo.backgroundColor || palette.white;
5055
+ roundedRectPath(
5056
+ ctx,
5057
+ logo.backgroundX,
5058
+ logo.backgroundY,
5059
+ logo.backgroundSize,
5060
+ logo.backgroundSize,
5061
+ logo.backgroundRadius
5062
+ );
5063
+ ctx.fill();
5064
+ }
5065
+
5066
+ const source = canvasLogoSource(logo.source);
5067
+ assert(
5068
+ source && typeof source !== "string",
5069
+ "renderToCanvas() logo must be a loaded CanvasImageSource or ImageData-like object. Load URL/data-URL logos into an Image first."
5070
+ );
5071
+ const rect = containRect(logo.logoX, logo.logoY, logo.logoSize, logoSourceDimensions(source));
5072
+ const previousSmoothing = ctx.imageSmoothingEnabled;
5073
+ ctx.imageSmoothingEnabled = true;
5074
+ ctx.drawImage(source, rect.x, rect.y, rect.width, rect.height);
5075
+ ctx.imageSmoothingEnabled = previousSmoothing;
5076
+ }
5077
+
5078
+ function blendImagePixel(data, width, x, y, rgb, alpha) {
5079
+ if (x < 0 || y < 0 || x >= width) return;
5080
+ const height = data.length / 4 / width;
5081
+ if (y >= height) return;
5082
+ const p = (y * width + x) * 4;
5083
+ const a = Math.max(0, Math.min(1, alpha));
5084
+ data[p] = Math.round(data[p] * (1 - a) + rgb.r * a);
5085
+ data[p + 1] = Math.round(data[p + 1] * (1 - a) + rgb.g * a);
5086
+ data[p + 2] = Math.round(data[p + 2] * (1 - a) + rgb.b * a);
5087
+ data[p + 3] = 255;
5088
+ }
5089
+
5090
+ function drawLogoToImageData(data, width, logo) {
5091
+ if (!logo) return;
5092
+ if (logo.clearBackground) {
5093
+ const rgb = hexToRgb(logo.backgroundColor);
5094
+ fillImageRoundedRect(
5095
+ data,
5096
+ width,
5097
+ Math.round(logo.backgroundX),
5098
+ Math.round(logo.backgroundY),
5099
+ Math.round(logo.backgroundSize),
5100
+ Math.round(logo.backgroundSize),
5101
+ Math.round(logo.backgroundRadius),
5102
+ rgb
5103
+ );
5104
+ }
5105
+
5106
+ const source = logo.source;
5107
+ assert(
5108
+ source?.data && Number.isInteger(source.width) && Number.isInteger(source.height),
5109
+ "renderToImageData() logo must be an ImageData-like object with width, height, and RGBA data."
5110
+ );
5111
+ const dest = containRect(logo.logoX, logo.logoY, logo.logoSize, {
5112
+ width: source.width,
5113
+ height: source.height
5114
+ });
5115
+ const x0 = Math.round(dest.x);
5116
+ const y0 = Math.round(dest.y);
5117
+ const destWidth = Math.max(1, Math.round(dest.width));
5118
+ const destHeight = Math.max(1, Math.round(dest.height));
5119
+ const src = source.data;
5120
+
5121
+ for (let y = 0; y < destHeight; y++) {
5122
+ const sy = Math.min(source.height - 1, Math.floor((y + 0.5) * source.height / destHeight));
5123
+ for (let x = 0; x < destWidth; x++) {
5124
+ const sx = Math.min(source.width - 1, Math.floor((x + 0.5) * source.width / destWidth));
5125
+ const p = (sy * source.width + sx) * 4;
5126
+ const alpha = (src[p + 3] ?? 255) / 255;
5127
+ if (alpha <= 0) continue;
5128
+ blendImagePixel(data, width, x0 + x, y0 + y, {
5129
+ r: src[p],
5130
+ g: src[p + 1],
5131
+ b: src[p + 2]
5132
+ }, alpha);
5133
+ }
5134
+ }
5135
+ }
5136
+
5137
+ function escapeXmlAttribute(value) {
5138
+ return String(value)
5139
+ .replaceAll("&", "&amp;")
5140
+ .replaceAll('"', "&quot;")
5141
+ .replaceAll("<", "&lt;")
5142
+ .replaceAll(">", "&gt;");
5143
+ }
5144
+
5145
+ function svgRect(x, y, width, height, fill, radius = 0) {
5146
+ const rounded = radius > 0 ? ` rx="${radius}" ry="${radius}"` : "";
5147
+ return `<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="${escapeXmlAttribute(fill)}"${rounded}/>`;
5148
+ }
5149
+
5150
+ function svgLogoHref(source) {
5151
+ if (typeof source === "string") return source;
5152
+ if (typeof source?.src === "string" && source.src) return source.src;
5153
+ return null;
5154
+ }
5155
+
5156
+ function resolveRenderSizing(options, matrixSize) {
5157
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
5158
+ const requestedQuietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
5159
+ const quietZone = mode === RENDER_MODES.PRINT && options.allowUnsafePrintQuietZone !== true
5160
+ ? Math.max(4, requestedQuietZone)
5161
+ : requestedQuietZone;
5162
+ const totalModules = matrixSize + quietZone * 2;
5163
+ const hasImageSize = options.imageSize !== undefined && options.imageSize !== null;
5164
+ const hasModuleSize = options.moduleSize !== undefined && options.moduleSize !== null;
5165
+
5166
+ let pixelSize;
5167
+ if (hasImageSize) {
5168
+ const requested = Number(options.imageSize);
5169
+ assert(Number.isFinite(requested) && requested > 0, "imageSize must be a positive number of pixels.");
5170
+ pixelSize = Math.max(totalModules, Math.round(requested));
5171
+ } else if (hasModuleSize) {
5172
+ const requested = Number(options.moduleSize);
5173
+ assert(Number.isFinite(requested) && requested > 0, "moduleSize must be a positive number of pixels.");
5174
+ const moduleSize = Math.max(1, Math.floor(requested));
5175
+ pixelSize = totalModules * moduleSize;
5176
+ } else {
5177
+ // A practical export default that is large enough for screens/social use
5178
+ // while keeping the API independent from the encoded matrix version.
5179
+ pixelSize = Math.max(totalModules, 720);
5180
+ }
5181
+
5182
+ return {
5183
+ quietZone,
5184
+ totalModules,
5185
+ pixelSize,
5186
+ moduleSize: pixelSize / totalModules,
5187
+ mode
5188
+ };
5189
+ }
5190
+
5191
+ function rasterModuleRect(row, col, quietZone, moduleSize) {
5192
+ const x0 = Math.round((col + quietZone) * moduleSize);
5193
+ const y0 = Math.round((row + quietZone) * moduleSize);
5194
+ const x1 = Math.round((col + quietZone + 1) * moduleSize);
5195
+ const y1 = Math.round((row + quietZone + 1) * moduleSize);
5196
+ return {
5197
+ x: x0,
5198
+ y: y0,
5199
+ width: Math.max(1, x1 - x0),
5200
+ height: Math.max(1, y1 - y0)
5201
+ };
5202
+ }
5203
+
3587
5204
  function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3588
5205
  assert(canvas && typeof canvas.getContext === "function", "A canvas element is required.");
3589
5206
  const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
3590
5207
  assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
3591
- const moduleSize = Math.max(1, Math.floor(options.moduleSize ?? 12));
3592
- const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
3593
- const palette = resolvePalette(options.palette);
3594
- const paletteValues = paletteRgb(options.palette);
3595
- const style = normalizeRenderStyle(options.style);
5208
+ const sizing = resolveRenderSizing(options, matrix.length);
5209
+ const { moduleSize, quietZone, pixelSize } = sizing;
5210
+ const palette = resolveRenderPalette(options);
5211
+ const paletteValues = paletteRgb(palette);
5212
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
5213
+ const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
3596
5214
  const layout = renderLayoutForMatrix(matrix);
5215
+ const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
3597
5216
  const size = matrix.length;
3598
- const pixelSize = (size + quietZone * 2) * moduleSize;
5217
+ const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
3599
5218
 
3600
5219
  canvas.width = pixelSize;
3601
5220
  canvas.height = pixelSize;
@@ -3608,12 +5227,21 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3608
5227
  for (let c = 0; c < size; c++) {
3609
5228
  const cell = matrix[r][c];
3610
5229
  const structural = isStructuralRenderCell(layout, r, c, cell);
3611
- const x = (c + quietZone) * moduleSize;
3612
- const y = (r + quietZone) * moduleSize;
5230
+ const rect = rasterModuleRect(r, c, quietZone, moduleSize);
5231
+ const { x, y, width: cellWidth, height: cellHeight } = rect;
5232
+ const cellSize = Math.min(cellWidth, cellHeight);
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
+ }
3613
5241
 
3614
5242
  if (style === RENDER_STYLES.CLASSIC || structural) {
3615
5243
  ctx.fillStyle = cellColor(cell, palette);
3616
- ctx.fillRect(x, y, moduleSize, moduleSize);
5244
+ ctx.fillRect(x, y, cellWidth, cellHeight);
3617
5245
  continue;
3618
5246
  }
3619
5247
 
@@ -3623,28 +5251,29 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3623
5251
  const opacity = depthOpacity(r, c, cell);
3624
5252
  const base = mixRgb(paletteValues.white, rgb, opacity);
3625
5253
  ctx.fillStyle = rgbCss(base);
3626
- ctx.fillRect(x, y, moduleSize, moduleSize);
5254
+ ctx.fillRect(x, y, cellWidth, cellHeight);
3627
5255
 
3628
- if (moduleSize >= 6) {
3629
- const edge = Math.max(1, Math.floor(moduleSize * 0.08));
5256
+ if (cellSize >= 6) {
5257
+ const edge = Math.max(1, Math.floor(cellSize * 0.08));
3630
5258
  const highlight = mixRgb(base, paletteValues.white, 0.17);
3631
5259
  const shadow = mixRgb(base, paletteValues.black, 0.12);
3632
5260
  ctx.fillStyle = rgbCss(highlight);
3633
- ctx.fillRect(x, y, moduleSize, edge);
3634
- ctx.fillRect(x, y, edge, moduleSize);
5261
+ ctx.fillRect(x, y, cellWidth, edge);
5262
+ ctx.fillRect(x, y, edge, cellHeight);
3635
5263
  ctx.fillStyle = rgbCss(shadow);
3636
- ctx.fillRect(x, y + moduleSize - edge, moduleSize, edge);
3637
- ctx.fillRect(x + moduleSize - edge, y, edge, moduleSize);
5264
+ ctx.fillRect(x, y + cellHeight - edge, cellWidth, edge);
5265
+ ctx.fillRect(x + cellWidth - edge, y, edge, cellHeight);
3638
5266
  }
3639
5267
  continue;
3640
5268
  }
3641
5269
 
3642
5270
  if (style === RENDER_STYLES.SOFT) {
3643
- const inset = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
3644
- const width = moduleSize - inset * 2;
3645
- const radius = Math.max(1, Math.floor(moduleSize * 0.20));
5271
+ const inset = cellSize >= 6 ? Math.max(1, Math.floor(cellSize * 0.07)) : 0;
5272
+ const width = cellWidth - inset * 2;
5273
+ const height = cellHeight - inset * 2;
5274
+ const radius = Math.max(1, Math.floor(cellSize * 0.20));
3646
5275
  ctx.fillStyle = cellColor(cell, palette);
3647
- roundedRectPath(ctx, x + inset, y + inset, width, width, radius);
5276
+ roundedRectPath(ctx, x + inset, y + inset, width, height, radius);
3648
5277
  ctx.fill();
3649
5278
  continue;
3650
5279
  }
@@ -3653,37 +5282,41 @@ function renderToCanvas(codeOrMatrix, canvas, options = {}) {
3653
5282
  // Keep the exact encoded color across the full module, then limit the
3654
5283
  // recessed effect to a narrow edge band so center sampling stays intact.
3655
5284
  ctx.fillStyle = cellColor(cell, palette);
3656
- ctx.fillRect(x, y, moduleSize, moduleSize);
5285
+ ctx.fillRect(x, y, cellWidth, cellHeight);
3657
5286
  if (cell === CELL.WHITE) continue;
3658
5287
 
3659
- const edge = safeStyleEdge(moduleSize);
5288
+ const edge = safeStyleEdge(cellSize);
3660
5289
  if (edge > 0) {
3661
5290
  const fx = insetStyleColors(rgb, paletteValues);
3662
5291
  ctx.fillStyle = rgbCss(fx.shadow);
3663
- ctx.fillRect(x, y, moduleSize, edge);
3664
- ctx.fillRect(x, y, edge, moduleSize);
5292
+ ctx.fillRect(x, y, cellWidth, edge);
5293
+ ctx.fillRect(x, y, edge, cellHeight);
3665
5294
  ctx.fillStyle = rgbCss(fx.highlight);
3666
- ctx.fillRect(x, y + moduleSize - edge, moduleSize, edge);
3667
- ctx.fillRect(x + moduleSize - edge, y, edge, moduleSize);
5295
+ ctx.fillRect(x, y + cellHeight - edge, cellWidth, edge);
5296
+ ctx.fillRect(x + cellWidth - edge, y, edge, cellHeight);
3668
5297
  }
3669
5298
  }
3670
5299
  }
3671
5300
  }
5301
+ drawLogoToCanvas(ctx, logo, palette);
3672
5302
  return canvas;
3673
5303
  }
3674
5304
 
3675
5305
  function renderToImageData(codeOrMatrix, options = {}) {
3676
5306
  const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
3677
5307
  assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
3678
- const moduleSize = Math.max(1, Math.floor(options.moduleSize ?? 8));
3679
- const quietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
3680
- const palette = paletteRgb(options.palette);
3681
- const style = normalizeRenderStyle(options.style);
5308
+ const sizing = resolveRenderSizing(options, matrix.length);
5309
+ const { moduleSize, quietZone, pixelSize } = sizing;
5310
+ const resolvedPalette = resolveRenderPalette(options);
5311
+ const palette = paletteRgb(resolvedPalette);
5312
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
5313
+ const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
3682
5314
  const layout = renderLayoutForMatrix(matrix);
5315
+ const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
3683
5316
  const size = matrix.length;
3684
- const pixelSize = (size + quietZone * 2) * moduleSize;
3685
5317
  const data = new Uint8ClampedArray(pixelSize * pixelSize * 4);
3686
5318
  const white = palette.white;
5319
+ const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, resolvedPalette, codeOrMatrix);
3687
5320
 
3688
5321
  for (let i = 0; i < pixelSize * pixelSize; i++) {
3689
5322
  const p = i * 4;
@@ -3696,28 +5329,119 @@ function renderToImageData(codeOrMatrix, options = {}) {
3696
5329
  for (let r = 0; r < size; r++) {
3697
5330
  for (let c = 0; c < size; c++) {
3698
5331
  const cell = matrix[r][c];
3699
- const rgb = cellRgb(cell, palette);
3700
5332
  const structural = isStructuralRenderCell(layout, r, c, cell);
3701
- const y0 = (r + quietZone) * moduleSize;
3702
- const x0 = (c + quietZone) * moduleSize;
5333
+ const rect = rasterModuleRect(r, c, quietZone, moduleSize);
5334
+ const x0 = rect.x;
5335
+ const y0 = rect.y;
5336
+ const cellWidth = rect.width;
5337
+ const cellHeight = rect.height;
5338
+ const cellSize = Math.min(cellWidth, cellHeight);
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);
3703
5346
 
3704
5347
  if (style === RENDER_STYLES.CLASSIC || structural) {
3705
- fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, rgb);
5348
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
3706
5349
  continue;
3707
5350
  }
3708
5351
 
3709
5352
  if (style === RENDER_STYLES.DEPTH) {
3710
5353
  const opacity = depthOpacity(r, c, cell);
3711
5354
  const base = mixRgb(white, rgb, opacity);
3712
- fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, base);
3713
- if (moduleSize >= 6) {
3714
- const edge = Math.max(1, Math.floor(moduleSize * 0.08));
5355
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, base);
5356
+ if (cellSize >= 6) {
5357
+ const edge = Math.max(1, Math.floor(cellSize * 0.08));
3715
5358
  const highlight = mixRgb(base, white, 0.17);
3716
5359
  const shadow = mixRgb(base, palette.black, 0.12);
3717
- fillImageRect(data, pixelSize, x0, y0, moduleSize, edge, highlight);
3718
- fillImageRect(data, pixelSize, x0, y0, edge, moduleSize, highlight);
3719
- fillImageRect(data, pixelSize, x0, y0 + moduleSize - edge, moduleSize, edge, shadow);
3720
- fillImageRect(data, pixelSize, x0 + moduleSize - edge, y0, edge, moduleSize, shadow);
5360
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, edge, highlight);
5361
+ fillImageRect(data, pixelSize, x0, y0, edge, cellHeight, highlight);
5362
+ fillImageRect(data, pixelSize, x0, y0 + cellHeight - edge, cellWidth, edge, shadow);
5363
+ fillImageRect(data, pixelSize, x0 + cellWidth - edge, y0, edge, cellHeight, shadow);
5364
+ }
5365
+ continue;
5366
+ }
5367
+
5368
+ if (style === RENDER_STYLES.SOFT) {
5369
+ const inset = cellSize >= 6 ? Math.max(1, Math.floor(cellSize * 0.07)) : 0;
5370
+ const width = cellWidth - inset * 2;
5371
+ const height = cellHeight - inset * 2;
5372
+ const radius = Math.max(1, Math.floor(cellSize * 0.20));
5373
+ fillImageRoundedRect(data, pixelSize, x0 + inset, y0 + inset, width, height, radius, rgb);
5374
+ continue;
5375
+ }
5376
+
5377
+ if (style === RENDER_STYLES.INSET) {
5378
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
5379
+ if (cell === CELL.WHITE) continue;
5380
+ const edge = safeStyleEdge(cellSize);
5381
+ if (edge > 0) {
5382
+ const fx = insetStyleColors(rgb, palette);
5383
+ fillImageRect(data, pixelSize, x0, y0, cellWidth, edge, fx.shadow);
5384
+ fillImageRect(data, pixelSize, x0, y0, edge, cellHeight, fx.shadow);
5385
+ fillImageRect(data, pixelSize, x0, y0 + cellHeight - edge, cellWidth, edge, fx.highlight);
5386
+ fillImageRect(data, pixelSize, x0 + cellWidth - edge, y0, edge, cellHeight, fx.highlight);
5387
+ }
5388
+ }
5389
+ }
5390
+ }
5391
+
5392
+ drawLogoToImageData(data, pixelSize, logo);
5393
+
5394
+ return { width: pixelSize, height: pixelSize, data };
5395
+ }
5396
+
5397
+ /** Render a QuadQR code/matrix to a standalone SVG string. */
5398
+ function renderToSVG(codeOrMatrix, options = {}) {
5399
+ const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
5400
+ assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
5401
+ const sizing = resolveRenderSizing(options, matrix.length);
5402
+ const { moduleSize, quietZone, pixelSize } = sizing;
5403
+ const palette = resolveRenderPalette(options);
5404
+ const paletteValues = paletteRgb(palette);
5405
+ const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
5406
+ const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
5407
+ const layout = renderLayoutForMatrix(matrix);
5408
+ const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
5409
+ const size = matrix.length;
5410
+ const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
5411
+ const body = [svgRect(0, 0, pixelSize, pixelSize, palette.white)];
5412
+
5413
+ for (let r = 0; r < size; r++) {
5414
+ for (let c = 0; c < size; c++) {
5415
+ const cell = matrix[r][c];
5416
+ const structural = isStructuralRenderCell(layout, r, c, cell);
5417
+ const x = (c + quietZone) * moduleSize;
5418
+ const y = (r + quietZone) * moduleSize;
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
+ }
5425
+
5426
+ if (style === RENDER_STYLES.CLASSIC || structural) {
5427
+ body.push(svgRect(x, y, moduleSize, moduleSize, fill));
5428
+ continue;
5429
+ }
5430
+
5431
+ const rgb = cellRgb(cell, paletteValues);
5432
+
5433
+ if (style === RENDER_STYLES.DEPTH) {
5434
+ const opacity = depthOpacity(r, c, cell);
5435
+ const base = mixRgb(paletteValues.white, rgb, opacity);
5436
+ body.push(svgRect(x, y, moduleSize, moduleSize, rgbCss(base)));
5437
+ if (moduleSize >= 6) {
5438
+ const edge = Math.max(1, Math.floor(moduleSize * 0.08));
5439
+ const highlight = rgbCss(mixRgb(base, paletteValues.white, 0.17));
5440
+ const shadow = rgbCss(mixRgb(base, paletteValues.black, 0.12));
5441
+ body.push(svgRect(x, y, moduleSize, edge, highlight));
5442
+ body.push(svgRect(x, y, edge, moduleSize, highlight));
5443
+ body.push(svgRect(x, y + moduleSize - edge, moduleSize, edge, shadow));
5444
+ body.push(svgRect(x + moduleSize - edge, y, edge, moduleSize, shadow));
3721
5445
  }
3722
5446
  continue;
3723
5447
  }
@@ -3726,26 +5450,51 @@ function renderToImageData(codeOrMatrix, options = {}) {
3726
5450
  const inset = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
3727
5451
  const width = moduleSize - inset * 2;
3728
5452
  const radius = Math.max(1, Math.floor(moduleSize * 0.20));
3729
- fillImageRoundedRect(data, pixelSize, x0 + inset, y0 + inset, width, width, radius, rgb);
5453
+ body.push(svgRect(x + inset, y + inset, width, width, fill, radius));
3730
5454
  continue;
3731
5455
  }
3732
5456
 
3733
5457
  if (style === RENDER_STYLES.INSET) {
3734
- fillImageRect(data, pixelSize, x0, y0, moduleSize, moduleSize, rgb);
5458
+ body.push(svgRect(x, y, moduleSize, moduleSize, fill));
3735
5459
  if (cell === CELL.WHITE) continue;
3736
5460
  const edge = safeStyleEdge(moduleSize);
3737
5461
  if (edge > 0) {
3738
- const fx = insetStyleColors(rgb, palette);
3739
- fillImageRect(data, pixelSize, x0, y0, moduleSize, edge, fx.shadow);
3740
- fillImageRect(data, pixelSize, x0, y0, edge, moduleSize, fx.shadow);
3741
- fillImageRect(data, pixelSize, x0, y0 + moduleSize - edge, moduleSize, edge, fx.highlight);
3742
- fillImageRect(data, pixelSize, x0 + moduleSize - edge, y0, edge, moduleSize, fx.highlight);
5462
+ const fx = insetStyleColors(rgb, paletteValues);
5463
+ const shadow = rgbCss(fx.shadow);
5464
+ const highlight = rgbCss(fx.highlight);
5465
+ body.push(svgRect(x, y, moduleSize, edge, shadow));
5466
+ body.push(svgRect(x, y, edge, moduleSize, shadow));
5467
+ body.push(svgRect(x, y + moduleSize - edge, moduleSize, edge, highlight));
5468
+ body.push(svgRect(x + moduleSize - edge, y, edge, moduleSize, highlight));
3743
5469
  }
3744
5470
  }
3745
5471
  }
3746
5472
  }
3747
5473
 
3748
- return { width: pixelSize, height: pixelSize, data };
5474
+ if (logo) {
5475
+ if (logo.clearBackground) {
5476
+ body.push(svgRect(
5477
+ logo.backgroundX,
5478
+ logo.backgroundY,
5479
+ logo.backgroundSize,
5480
+ logo.backgroundSize,
5481
+ logo.backgroundColor,
5482
+ logo.backgroundRadius
5483
+ ));
5484
+ }
5485
+ const href = svgLogoHref(logo.source);
5486
+ assert(href, "renderToSVG() logo must be a URL/data URL string or an object with a src string.");
5487
+ body.push(
5488
+ `<image href="${escapeXmlAttribute(href)}" x="${logo.logoX}" y="${logo.logoY}" width="${logo.logoSize}" height="${logo.logoSize}" preserveAspectRatio="xMidYMid meet"/>`
5489
+ );
5490
+ }
5491
+
5492
+ return [
5493
+ '<?xml version="1.0" encoding="UTF-8"?>',
5494
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${pixelSize}" height="${pixelSize}" viewBox="0 0 ${pixelSize} ${pixelSize}" shape-rendering="crispEdges">`,
5495
+ ...body,
5496
+ "</svg>"
5497
+ ].join("\n");
3749
5498
  }
3750
5499
 
3751
5500
  function clampNumber(value, min, max) {
@@ -3898,6 +5647,60 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
3898
5647
  };
3899
5648
  }
3900
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
+
3901
5704
  function structuralAccuracy(matrix, layout) {
3902
5705
  let matches = 0;
3903
5706
  let total = 0;
@@ -3937,6 +5740,7 @@ function tryPerspectiveScan(imageData, options) {
3937
5740
  finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
3938
5741
  finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
3939
5742
  finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
5743
+ preciseAlignment: options.preciseAlignment,
3940
5744
  diagnostics: options._visionDiagnostics,
3941
5745
  diagnosticLabel: options._diagnosticLabel ?? "normal"
3942
5746
  });
@@ -3962,9 +5766,18 @@ function tryPerspectiveScan(imageData, options) {
3962
5766
  let bestStructureScore = 0;
3963
5767
  for (const profile of sampleProfiles) {
3964
5768
  let sampled;
5769
+ let triangleSampled = null;
3965
5770
  let observedPalette;
3966
5771
  try {
3967
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
+ }
3968
5781
  observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, {
3969
5782
  robust: profile.robustCalibration
3970
5783
  });
@@ -4023,11 +5836,67 @@ function tryPerspectiveScan(imageData, options) {
4023
5836
  }
4024
5837
  };
4025
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
+
4026
5895
  // Fast path: preserve the original observed-RGB classifier first, then
4027
5896
  // try per-channel white balancing. Most clean frames stop here without
4028
5897
  // paying for the more expensive spatial normalization fallback.
4029
5898
  for (const attempt of paletteClassifierAttempts(observedPalette)) {
4030
- if (tryAttempt(attempt, sampled.rgbGrid)) {
5899
+ if (tryAttempt(attempt, sampled.rgbGrid) || tryTriangleAttempt(attempt)) {
4031
5900
  geometryDecoded = true;
4032
5901
  break;
4033
5902
  }
@@ -4154,17 +6023,30 @@ function tryPerspectiveScan(imageData, options) {
4154
6023
  [step, -step], [step, step], [-step, -step], [-step, step]
4155
6024
  ];
4156
6025
  const refinementCandidates = [];
6026
+ const triangleRefinementCandidates = [];
4157
6027
 
4158
6028
  for (const [offsetX, offsetY] of refinementOffsets) {
4159
6029
  let sampled;
6030
+ let triangleSampled = null;
4160
6031
  let observedPalette;
4161
6032
  try {
4162
- sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, {
6033
+ const samplingOptions = {
4163
6034
  sampleMode: "cross",
4164
6035
  sampleRadius: 0,
4165
6036
  sampleOffsetX: offsetX,
4166
- sampleOffsetY: offsetY
4167
- });
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
+ }
4168
6050
  observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, { robust: true });
4169
6051
  } catch {
4170
6052
  continue;
@@ -4194,30 +6076,80 @@ function tryPerspectiveScan(imageData, options) {
4194
6076
 
4195
6077
  // Do not spend RS work on a refinement that made the known finder /
4196
6078
  // alignment structure worse. This keeps the recovery path bounded.
4197
- if (structureScore < (options.refinementDecodeThreshold ?? 0.95)) continue;
4198
- refinementCandidates.push({
4199
- attempt,
4200
- classified,
4201
- observedPalette,
4202
- offsetX,
4203
- offsetY,
4204
- structureScore
4205
- });
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
+ }
4206
6128
  }
4207
6129
  }
4208
6130
 
4209
- refinementCandidates.sort((a, b) =>
6131
+ const sortRefinementCandidates = (items) => items.sort((a, b) =>
4210
6132
  (b.structureScore - a.structureScore) ||
4211
6133
  (b.classified.averageCellConfidence - a.classified.averageCellConfidence) ||
4212
6134
  (a.classified.averageColorDistance - b.classified.averageColorDistance)
4213
6135
  );
6136
+ sortRefinementCandidates(refinementCandidates);
6137
+ sortRefinementCandidates(triangleRefinementCandidates);
4214
6138
 
4215
6139
  const decodeLimit = Math.max(1, Math.min(8, Math.round(options.refinementDecodeCandidates ?? 4)));
4216
- 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) {
4217
6148
  try {
4218
6149
  const decoded = decodeMatrix(candidate.classified.matrix, {
4219
6150
  structureTolerance: options.structureTolerance ?? 0.18,
4220
6151
  cellConfidence: candidate.classified.confidence,
6152
+ cellEncodingHint: candidate.cellEncoding,
4221
6153
  maxErasureConfidence: options.maxErasureConfidence
4222
6154
  });
4223
6155
  if (decoded.version !== geometry.version) continue;
@@ -4228,7 +6160,7 @@ function tryPerspectiveScan(imageData, options) {
4228
6160
  samplingOffset: { x: candidate.offsetX, y: candidate.offsetY },
4229
6161
  colorCalibrated: true,
4230
6162
  colorNormalization: candidate.attempt.colorNormalization,
4231
- samplingMode: "refined-center",
6163
+ samplingMode: candidate.samplingMode,
4232
6164
  geometry,
4233
6165
  observedPalette: candidate.observedPalette,
4234
6166
  averageColorDistance: candidate.classified.averageColorDistance,
@@ -4278,6 +6210,15 @@ function tryAxisAlignedScan(imageData, options) {
4278
6210
  for (let profileIndex = 0; profileIndex < radiusProfiles.length && !accepted; profileIndex++) {
4279
6211
  try {
4280
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
+ );
4281
6222
  const layout = createLayout(version);
4282
6223
  const classifierAttempts = [];
4283
6224
  try {
@@ -4317,45 +6258,70 @@ function tryAxisAlignedScan(imageData, options) {
4317
6258
  });
4318
6259
 
4319
6260
  for (const attempt of classifierAttempts) {
4320
- try {
4321
- const classified = classifySampledRgbGrid(attempt.rgbGrid ?? sampled.rgbGrid, attempt.classifier, layout);
4322
- const structureScore = structuralAccuracy(classified.matrix, layout);
4323
- pushObservation(options, {
4324
- version,
4325
- matrix: classified.matrix,
4326
- confidence: classified.confidence,
4327
- bounds,
4328
- samplingMode: profileIndex === 0 ? "axis" : "axis-center",
4329
- colorNormalization: attempt.colorNormalization,
4330
- structureScore,
4331
- averageCellConfidence: classified.averageCellConfidence,
4332
- lowConfidenceCells: classified.lowConfidenceCells
4333
- });
4334
- const decoded = decodeMatrix(classified.matrix, {
4335
- structureTolerance: options.structureTolerance ?? 0.12,
4336
- cellConfidence: classified.confidence,
4337
- maxErasureConfidence: options.maxErasureConfidence
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`
4338
6278
  });
4339
- candidates.push({
4340
- ...decoded,
4341
- bounds,
4342
- sampledVersion: version,
4343
- moduleWidth: moduleW,
4344
- moduleHeight: moduleH,
4345
- perspectiveCorrected: false,
4346
- colorCalibrated: attempt.calibrated,
4347
- colorNormalization: attempt.colorNormalization,
4348
- samplingMode: profileIndex === 0 ? "axis" : "axis-center",
4349
- averageColorDistance: classified.averageColorDistance,
4350
- averageCellConfidence: classified.averageCellConfidence,
4351
- minimumCellConfidence: classified.minimumCellConfidence,
4352
- lowConfidenceCells: classified.lowConfidenceCells
4353
- });
4354
- accepted = true;
4355
- break;
4356
- } catch {
4357
- // Try next classifier/profile.
4358
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;
4359
6325
  }
4360
6326
  } catch {
4361
6327
  // Try a narrower centre sample, then next version.
@@ -4369,6 +6335,364 @@ function tryAxisAlignedScan(imageData, options) {
4369
6335
  return candidates[0] ?? null;
4370
6336
  }
4371
6337
 
6338
+ const STRESS_PROFILES = Object.freeze([
6339
+ Object.freeze({ id: "clean", label: "Clean", type: "clean", severity: 0, weight: 2 }),
6340
+ Object.freeze({ id: "blur", label: "Blur", type: "blur", severity: 0.42, weight: 1 }),
6341
+ Object.freeze({ id: "dark", label: "Low brightness", type: "brightness-low", severity: 0.48, weight: 1 }),
6342
+ Object.freeze({ id: "bright", label: "High exposure", type: "brightness-high", severity: 0.34, weight: 1 }),
6343
+ Object.freeze({ id: "shadow", label: "Uneven shadow", type: "shadow", severity: 0.55, weight: 1.2 }),
6344
+ Object.freeze({ id: "contrast", label: "Contrast loss", type: "contrast-loss", severity: 0.42, weight: 1 }),
6345
+ Object.freeze({ id: "perspective", label: "Perspective", type: "perspective", severity: 0.42, weight: 1.3 }),
6346
+ Object.freeze({ id: "jpeg", label: "JPEG-like compression", type: "jpeg", severity: 0.50, weight: 0.8 }),
6347
+ Object.freeze({ id: "downscale", label: "Downscale", type: "downscale", severity: 0.52, weight: 0.9 })
6348
+ ]);
6349
+
6350
+ function cloneImageDataLike(imageData) {
6351
+ return { width: imageData.width, height: imageData.height, data: new Uint8ClampedArray(imageData.data) };
6352
+ }
6353
+
6354
+ function sampleRgbaBilinear(imageData, x, y) {
6355
+ const { width, height, data } = imageData;
6356
+ const cx = clampNumber(x, 0, width - 1);
6357
+ const cy = clampNumber(y, 0, height - 1);
6358
+ const x0 = Math.floor(cx), y0 = Math.floor(cy);
6359
+ const x1 = Math.min(width - 1, x0 + 1), y1 = Math.min(height - 1, y0 + 1);
6360
+ const tx = cx - x0, ty = cy - y0;
6361
+ const get = (xx, yy, channel) => data[(yy * width + xx) * 4 + channel];
6362
+ const out = [0, 0, 0, 255];
6363
+ for (let ch = 0; ch < 4; ch++) {
6364
+ const a = get(x0, y0, ch) * (1 - tx) + get(x1, y0, ch) * tx;
6365
+ const b = get(x0, y1, ch) * (1 - tx) + get(x1, y1, ch) * tx;
6366
+ out[ch] = a * (1 - ty) + b * ty;
6367
+ }
6368
+ return out;
6369
+ }
6370
+
6371
+ function resizeImageDataLike(imageData, width, height) {
6372
+ width = Math.max(1, Math.round(width));
6373
+ height = Math.max(1, Math.round(height));
6374
+ const out = new Uint8ClampedArray(width * height * 4);
6375
+ for (let y = 0; y < height; y++) {
6376
+ for (let x = 0; x < width; x++) {
6377
+ const sx = (x + 0.5) * imageData.width / width - 0.5;
6378
+ const sy = (y + 0.5) * imageData.height / height - 0.5;
6379
+ const rgba = sampleRgbaBilinear(imageData, sx, sy);
6380
+ const p = (y * width + x) * 4;
6381
+ out[p] = rgba[0]; out[p + 1] = rgba[1]; out[p + 2] = rgba[2]; out[p + 3] = rgba[3];
6382
+ }
6383
+ }
6384
+ return { width, height, data: out };
6385
+ }
6386
+
6387
+ function boxBlurImage(imageData, radius) {
6388
+ radius = Math.max(1, Math.round(radius));
6389
+ const { width, height, data } = imageData;
6390
+ const horizontal = new Float32Array(data.length);
6391
+ const out = new Uint8ClampedArray(data.length);
6392
+ for (let y = 0; y < height; y++) {
6393
+ for (let x = 0; x < width; x++) {
6394
+ for (let ch = 0; ch < 3; ch++) {
6395
+ let sum = 0, count = 0;
6396
+ for (let dx = -radius; dx <= radius; dx++) {
6397
+ const xx = x + dx;
6398
+ if (xx < 0 || xx >= width) continue;
6399
+ sum += data[(y * width + xx) * 4 + ch]; count++;
6400
+ }
6401
+ horizontal[(y * width + x) * 4 + ch] = sum / count;
6402
+ }
6403
+ horizontal[(y * width + x) * 4 + 3] = 255;
6404
+ }
6405
+ }
6406
+ for (let y = 0; y < height; y++) {
6407
+ for (let x = 0; x < width; x++) {
6408
+ const p = (y * width + x) * 4;
6409
+ for (let ch = 0; ch < 3; ch++) {
6410
+ let sum = 0, count = 0;
6411
+ for (let dy = -radius; dy <= radius; dy++) {
6412
+ const yy = y + dy;
6413
+ if (yy < 0 || yy >= height) continue;
6414
+ sum += horizontal[(yy * width + x) * 4 + ch]; count++;
6415
+ }
6416
+ out[p + ch] = sum / count;
6417
+ }
6418
+ out[p + 3] = 255;
6419
+ }
6420
+ }
6421
+ return { width, height, data: out };
6422
+ }
6423
+
6424
+ function perspectiveStress(imageData, severity) {
6425
+ const { width, height } = imageData;
6426
+ const inset = Math.min(width, height) * (0.025 + 0.11 * severity);
6427
+ const source = [{ x: 0, y: 0 }, { x: width - 1, y: 0 }, { x: width - 1, y: height - 1 }, { x: 0, y: height - 1 }];
6428
+ const destination = [
6429
+ { x: inset * 1.15, y: inset * 0.25 },
6430
+ { x: width - 1 - inset * 0.25, y: inset },
6431
+ { x: width - 1 - inset * 1.0, y: height - 1 - inset * 0.35 },
6432
+ { x: inset * 0.20, y: height - 1 - inset * 0.95 }
6433
+ ];
6434
+ const inverse = computeHomography(source, destination);
6435
+ const out = new Uint8ClampedArray(width * height * 4);
6436
+ out.fill(255);
6437
+ for (let y = 0; y < height; y++) {
6438
+ for (let x = 0; x < width; x++) {
6439
+ const src = projectPoint(inverse, x, y);
6440
+ if (src.x < 0 || src.y < 0 || src.x >= width || src.y >= height) continue;
6441
+ const rgba = sampleRgbaBilinear(imageData, src.x, src.y);
6442
+ const p = (y * width + x) * 4;
6443
+ out[p] = rgba[0]; out[p + 1] = rgba[1]; out[p + 2] = rgba[2]; out[p + 3] = 255;
6444
+ }
6445
+ }
6446
+ return { width, height, data: out };
6447
+ }
6448
+
6449
+ /** Apply one deterministic camera/print-style distortion to ImageData. */
6450
+ function applyStressDistortion(imageData, type, severity = 0.5, options = {}) {
6451
+ assert(imageData?.data && imageData.width && imageData.height, "applyStressDistortion requires ImageData-like input.");
6452
+ const s = clampNumber(Number(severity), 0, 1);
6453
+ if (type === "clean") return cloneImageDataLike(imageData);
6454
+ if (type === "blur") return boxBlurImage(imageData, 1 + s * 2.2);
6455
+ if (type === "perspective") return perspectiveStress(imageData, s);
6456
+ if (type === "downscale") {
6457
+ const scale = Math.max(0.20, 1 - s * 0.72);
6458
+ const small = resizeImageDataLike(imageData, imageData.width * scale, imageData.height * scale);
6459
+ return resizeImageDataLike(small, imageData.width, imageData.height);
6460
+ }
6461
+
6462
+ const out = cloneImageDataLike(imageData);
6463
+ const { width, height, data } = out;
6464
+ const quantStep = 4 + Math.round(s * 30);
6465
+ for (let y = 0; y < height; y++) {
6466
+ for (let x = 0; x < width; x++) {
6467
+ const p = (y * width + x) * 4;
6468
+ for (let ch = 0; ch < 3; ch++) {
6469
+ let value = data[p + ch];
6470
+ if (type === "brightness-low") value *= 1 - s * 0.52;
6471
+ else if (type === "brightness-high") value = value + (255 - value) * s * 0.48;
6472
+ else if (type === "contrast-loss") value = 128 + (value - 128) * (1 - s * 0.48);
6473
+ else if (type === "shadow") {
6474
+ const nx = x / Math.max(1, width - 1);
6475
+ const ny = y / Math.max(1, height - 1);
6476
+ const shadow = clampNumber((nx * 0.65 + ny * 0.35 - 0.18) / 0.82, 0, 1);
6477
+ value *= 1 - shadow * s * 0.62;
6478
+ } else if (type === "jpeg") {
6479
+ value = Math.round(value / quantStep) * quantStep;
6480
+ const block = ((Math.floor(x / 8) + Math.floor(y / 8)) & 1) ? 1 : -1;
6481
+ value += block * s * 3;
6482
+ } else if (type === "warm") {
6483
+ if (ch === 0) value *= 1 + s * 0.12;
6484
+ if (ch === 2) value *= 1 - s * 0.22;
6485
+ }
6486
+ data[p + ch] = clampNumber(Math.round(value), 0, 255);
6487
+ }
6488
+ data[p + 3] = 255;
6489
+ }
6490
+ }
6491
+ return out;
6492
+ }
6493
+
6494
+ function runImageStressTest(imageData, expected = {}, options = {}) {
6495
+ const profiles = options.profiles ?? STRESS_PROFILES;
6496
+ const results = [];
6497
+ let passedWeight = 0;
6498
+ let totalWeight = 0;
6499
+ for (const profile of profiles) {
6500
+ const weight = Number(profile.weight ?? 1);
6501
+ totalWeight += weight;
6502
+ const distorted = applyStressDistortion(imageData, profile.type ?? profile.id, profile.severity ?? 0.5, profile);
6503
+ const started = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
6504
+ try {
6505
+ const decoded = scanImageData(distorted, {
6506
+ minVersion: expected.version ?? options.minVersion ?? MIN_VERSION,
6507
+ maxVersion: expected.version ?? options.maxVersion ?? MAX_VERSION,
6508
+ debug: false
6509
+ });
6510
+ const matches = expected.crc32 == null || decoded.crc32 === expected.crc32;
6511
+ if (matches) passedWeight += weight;
6512
+ results.push({
6513
+ id: profile.id,
6514
+ label: profile.label,
6515
+ type: profile.type,
6516
+ severity: profile.severity,
6517
+ passed: matches,
6518
+ confidence: decoded.confidence ?? decoded.diagnostics?.confidence ?? null,
6519
+ correctedSymbols: decoded.correctedSymbols ?? 0,
6520
+ elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
6521
+ image: options.includeImages ? distorted : undefined,
6522
+ error: matches ? null : "Decoded payload did not match expected CRC."
6523
+ });
6524
+ } catch (error) {
6525
+ results.push({
6526
+ id: profile.id,
6527
+ label: profile.label,
6528
+ type: profile.type,
6529
+ severity: profile.severity,
6530
+ passed: false,
6531
+ confidence: 0,
6532
+ correctedSymbols: null,
6533
+ elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
6534
+ image: options.includeImages ? distorted : undefined,
6535
+ error: error.message
6536
+ });
6537
+ }
6538
+ }
6539
+ const passPercent = totalWeight ? passedWeight / totalWeight * 100 : 0;
6540
+ const averageConfidence = results.filter((r) => r.passed && Number.isFinite(r.confidence)).reduce((sum, r, _, arr) => sum + r.confidence / arr.length, 0);
6541
+ const score = clampNumber(passPercent * 0.86 + averageConfidence * 100 * 0.14, 0, 100);
6542
+ const rating = score >= 90 ? "Excellent" : score >= 75 ? "Good" : score >= 50 ? "Risky" : "Likely unscannable";
6543
+ return {
6544
+ score,
6545
+ rating,
6546
+ passPercent,
6547
+ passed: results.filter((r) => r.passed).length,
6548
+ total: results.length,
6549
+ averageConfidence,
6550
+ results
6551
+ };
6552
+ }
6553
+
6554
+ /** Render and run the standard scanability torture suite. */
6555
+ function assessScanability(code, renderOptions = {}, options = {}) {
6556
+ assert(code?.matrix, "assessScanability expects an encoded QuadQR object.");
6557
+ const testImageSize = Math.max(240, Math.min(Number(options.testImageSize ?? 480), Number(renderOptions.imageSize ?? 720)));
6558
+ const image = renderToImageData(code, { ...renderOptions, imageSize: testImageSize });
6559
+ const report = runImageStressTest(image, { version: code.version, crc32: code.crc32 }, options);
6560
+ const autoLogoSize = renderOptions.logo ? estimateSafeLogoSize(code, renderOptions) : null;
6561
+ return {
6562
+ ...report,
6563
+ version: code.version,
6564
+ eccLevel: code.eccLevel,
6565
+ utilization: code.utilization,
6566
+ testImageSize,
6567
+ autoLogoSize,
6568
+ recommendations: [
6569
+ ...(report.score < 75 ? ["Increase ECC, reduce logo size, use Classic style, or increase output/physical size."] : []),
6570
+ ...(Number(renderOptions.quietZone ?? 4) < 4 ? ["Use a quiet zone of at least 4 modules for robust camera/print scanning."] : []),
6571
+ ...(renderOptions.logo && autoLogoSize != null ? [`Auto-safe logo estimate: ${(autoLogoSize * 100).toFixed(1)}% of the symbol width.`] : []),
6572
+ ...(normalizeRenderMode(renderOptions.mode ?? renderOptions.renderMode ?? "screen") === "print" ? getPrintGuidance(code, renderOptions).recommendations : [])
6573
+ ]
6574
+ };
6575
+ }
6576
+
6577
+ function paletteConfidence(observedPalette) {
6578
+ if (!observedPalette) return 0.5;
6579
+ const values = ["black", "white", "red", "green", "blue"].map((name) => observedPalette[name]).filter(Boolean);
6580
+ if (values.length < 5) return 0.5;
6581
+ let minDistance = Infinity;
6582
+ for (let i = 0; i < values.length; i++) {
6583
+ for (let j = i + 1; j < values.length; j++) {
6584
+ const a = values[i], b = values[j];
6585
+ minDistance = Math.min(minDistance, Math.hypot(a.r - b.r, a.g - b.g, a.b - b.b));
6586
+ }
6587
+ }
6588
+ return clampNumber((minDistance - 28) / 120, 0, 1);
6589
+ }
6590
+
6591
+ function scannerDiagnostics(result, context = {}, options = {}) {
6592
+ const info = result ? getVersionInfo(result.version, { ecc: result.eccLevel }) : null;
6593
+ const correctableTotal = result && info
6594
+ ? Number(info.correctableHeaderSymbols ?? 0) + Number(result.eccBlocks ?? 0) * Number(result.correctableSymbolsPerBlock ?? 0)
6595
+ : 0;
6596
+ const eccUtilization = correctableTotal > 0 ? clampNumber((result.correctedSymbols ?? 0) / correctableTotal, 0, 1) : 0;
6597
+ const structureConfidence = clampNumber(
6598
+ result?.structureScore ?? context.observations?.at(-1)?.structureScore ?? (result ? 0.92 : 0), 0, 1
6599
+ );
6600
+ const cellConfidence = clampNumber(result?.averageCellConfidence ?? 0.75, 0, 1);
6601
+ const geometryConfidence = result
6602
+ ? clampNumber(result.perspectiveCorrected ? Math.max(0.55, structureConfidence) : Math.max(0.65, structureConfidence * 0.9), 0, 1)
6603
+ : (context.geometry?.length ? 0.45 : 0);
6604
+ const calibrationConfidence = result
6605
+ ? clampNumber(result.colorCalibrated ? paletteConfidence(result.observedPalette) : 0.55, 0, 1)
6606
+ : 0;
6607
+ const confidence = result
6608
+ ? clampNumber(cellConfidence * 0.50 + structureConfidence * 0.20 + geometryConfidence * 0.15 + calibrationConfidence * 0.15 - eccUtilization * 0.12, 0, 1)
6609
+ : 0;
6610
+ const geometryCandidates = (context.geometry ?? []).slice(0, 8).map((geometry) => ({
6611
+ version: geometry.version,
6612
+ size: geometry.size,
6613
+ score: geometry.score,
6614
+ estimatedSize: geometry.estimatedSize,
6615
+ alignmentScore: geometry.alignmentScore,
6616
+ alignmentGridScore: geometry.alignmentGridScore,
6617
+ corners: geometry.corners ?? geometry.quad ?? null,
6618
+ finders: geometry.finders ?? null
6619
+ }));
6620
+ const observations = context.observations ?? [];
6621
+ const latest = observations.at(-1) ?? null;
6622
+ const stages = {
6623
+ finderDetection: geometryCandidates.length > 0 || Boolean(result && !result.perspectiveCorrected),
6624
+ geometry: geometryCandidates.length > 0 || Boolean(result && !result.perspectiveCorrected),
6625
+ sampling: observations.length > 0 || Boolean(result),
6626
+ colorClassification: observations.length > 0 || Boolean(result),
6627
+ ecc: Boolean(result),
6628
+ crc: Boolean(result),
6629
+ payload: Boolean(result)
6630
+ };
6631
+ let failedStage = null;
6632
+ if (!result) {
6633
+ failedStage = !stages.finderDetection ? "finder-detection"
6634
+ : !stages.sampling ? "perspective-geometry"
6635
+ : "color-classification-or-ecc";
6636
+ }
6637
+ return {
6638
+ confidence,
6639
+ cellConfidence,
6640
+ structureConfidence,
6641
+ geometryConfidence,
6642
+ calibrationConfidence,
6643
+ eccUtilization,
6644
+ correctedErrors: result?.correctedSymbols ?? 0,
6645
+ erasureSymbols: result?.erasureSymbols ?? 0,
6646
+ lowConfidenceCells: result?.lowConfidenceCells ?? latest?.lowConfidenceCells ?? null,
6647
+ stages,
6648
+ failedStage,
6649
+ geometryCandidates,
6650
+ vision: context.vision ?? null,
6651
+ sampled: latest ? {
6652
+ version: latest.version,
6653
+ samplingMode: latest.samplingMode,
6654
+ colorNormalization: latest.colorNormalization,
6655
+ structureScore: latest.structureScore,
6656
+ averageCellConfidence: latest.averageCellConfidence,
6657
+ observedPalette: latest.observedPalette ?? null,
6658
+ matrix: options.debugMatrices === false ? undefined : latest.matrix,
6659
+ confidenceMatrix: options.debugMatrices === false ? undefined : latest.confidence
6660
+ } : null
6661
+ };
6662
+ }
6663
+
6664
+ function decorateScanResult(result, context, options) {
6665
+ const diagnostics = scannerDiagnostics(result, context, options);
6666
+ return {
6667
+ ...result,
6668
+ confidence: diagnostics.confidence,
6669
+ geometryConfidence: diagnostics.geometryConfidence,
6670
+ calibrationConfidence: diagnostics.calibrationConfidence,
6671
+ structureConfidence: diagnostics.structureConfidence,
6672
+ correctedErrors: result.correctedSymbols ?? 0,
6673
+ eccUtilization: diagnostics.eccUtilization,
6674
+ diagnostics: options.debug ? diagnostics : {
6675
+ confidence: diagnostics.confidence,
6676
+ geometryConfidence: diagnostics.geometryConfidence,
6677
+ calibrationConfidence: diagnostics.calibrationConfidence,
6678
+ structureConfidence: diagnostics.structureConfidence,
6679
+ eccUtilization: diagnostics.eccUtilization,
6680
+ correctedErrors: diagnostics.correctedErrors,
6681
+ erasureSymbols: diagnostics.erasureSymbols
6682
+ }
6683
+ };
6684
+ }
6685
+
6686
+ /** Non-throwing scanner debug API with detailed stage diagnostics. */
6687
+ function debugScanImageData(imageData, options = {}) {
6688
+ try {
6689
+ const result = scanImageData(imageData, { ...options, debug: true });
6690
+ return { ok: true, result, debug: result.diagnostics };
6691
+ } catch (error) {
6692
+ return { ok: false, error: error.message, debug: error.debug ?? null };
6693
+ }
6694
+ }
6695
+
4372
6696
  function scanImageData(imageData, options = {}) {
4373
6697
  assert(imageData && imageData.data && imageData.width && imageData.height, "Valid ImageData is required.");
4374
6698
  const minVersion = options.minVersion ?? MIN_VERSION;
@@ -4377,11 +6701,49 @@ function scanImageData(imageData, options = {}) {
4377
6701
  validateVersion(maxVersion);
4378
6702
 
4379
6703
  const geometryCollector = [];
4380
- const scanOptions = { ...options, _geometryCollector: geometryCollector };
6704
+ const observationCollector = options.debug ? [] : (Array.isArray(options._observationCollector) ? options._observationCollector : []);
6705
+ const visionDiagnostics = options.debug ? { passes: [] } : (options._visionDiagnostics ?? null);
6706
+ const debugContext = { geometry: geometryCollector, observations: observationCollector, vision: visionDiagnostics };
6707
+ const scanOptions = {
6708
+ ...options,
6709
+ _geometryCollector: geometryCollector,
6710
+ _observationCollector: observationCollector,
6711
+ _visionDiagnostics: visionDiagnostics
6712
+ };
4381
6713
 
4382
6714
  if (options.perspective !== false) {
4383
6715
  const perspective = tryPerspectiveScan(imageData, scanOptions);
4384
- if (perspective) return perspective;
6716
+ if (perspective) return decorateScanResult(perspective, debugContext, options);
6717
+ }
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
+ }
4385
6747
  }
4386
6748
 
4387
6749
  // If the normal scanner could see QuadQR geometry but could not decode the
@@ -4408,12 +6770,12 @@ function scanImageData(imageData, options = {}) {
4408
6770
  _geometryCollector: []
4409
6771
  });
4410
6772
  if (recovered) {
4411
- return {
6773
+ return decorateScanResult({
4412
6774
  ...recovered,
4413
6775
  autoEnhanced: true,
4414
6776
  recoveryMode: "auto-tone-contrast-color",
4415
6777
  originalColorNormalization: recovered.colorNormalization
4416
- };
6778
+ }, debugContext, options);
4417
6779
  }
4418
6780
  } catch {
4419
6781
  // Continue to the axis-aligned fallback.
@@ -4422,7 +6784,7 @@ function scanImageData(imageData, options = {}) {
4422
6784
 
4423
6785
  if (options.axisAlignedFallback !== false) {
4424
6786
  const axis = tryAxisAlignedScan(imageData, scanOptions);
4425
- if (axis) return axis;
6787
+ if (axis) return decorateScanResult(axis, debugContext, options);
4426
6788
  }
4427
6789
 
4428
6790
  // Static images sometimes have such low contrast that locator detection itself
@@ -4449,20 +6811,22 @@ function scanImageData(imageData, options = {}) {
4449
6811
  };
4450
6812
  if (options.perspective !== false) {
4451
6813
  const perspective = tryPerspectiveScan(enhanced, recoveryOptions);
4452
- if (perspective) return { ...perspective, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" };
6814
+ if (perspective) return decorateScanResult({ ...perspective, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
4453
6815
  }
4454
6816
  if (options.axisAlignedFallback !== false) {
4455
6817
  const axis = tryAxisAlignedScan(enhanced, recoveryOptions);
4456
- if (axis) return { ...axis, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" };
6818
+ if (axis) return decorateScanResult({ ...axis, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
4457
6819
  }
4458
6820
  } catch {
4459
6821
  // Fall through to the normal scan failure below.
4460
6822
  }
4461
6823
  }
4462
6824
 
4463
- throw new Error(
6825
+ const error = new Error(
4464
6826
  "No valid QuadQR code found. Try better lighting, fill more of the frame, keep all locator patterns visible, or use a less blurred image."
4465
6827
  );
6828
+ if (options.debug) error.debug = scannerDiagnostics(null, debugContext, options);
6829
+ throw error;
4466
6830
  }
4467
6831
 
4468
6832
  async function scanFile(file, options = {}) {
@@ -4785,6 +7149,7 @@ async function startCameraScanner(video, options = {}) {
4785
7149
 
4786
7150
  const scanInterval = Math.max(80, options.scanInterval ?? 180);
4787
7151
  const scratchCanvas = document.createElement("canvas");
7152
+ const highResolutionCanvas = document.createElement("canvas");
4788
7153
  const multiFrameEnabled = options.multiFrame !== false;
4789
7154
  const multiFrameWindow = Math.max(2, Math.min(8, Math.round(options.multiFrameWindow ?? 4)));
4790
7155
  const multiFrameMinFrames = Math.max(2, Math.min(multiFrameWindow, Math.round(options.multiFrameMinFrames ?? 2)));
@@ -4792,6 +7157,12 @@ async function startCameraScanner(video, options = {}) {
4792
7157
  const cameraAutoColorEvery = Math.max(1, Math.round(options.cameraAutoColorEvery ?? 1));
4793
7158
  const cameraAutoEnhanceEvery = Math.max(1, Math.round(options.cameraAutoEnhanceEvery ?? 2));
4794
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
+ );
4795
7166
  let missStreak = 0;
4796
7167
  let stopped = false;
4797
7168
  let busy = false;
@@ -4838,7 +7209,7 @@ async function startCameraScanner(video, options = {}) {
4838
7209
 
4839
7210
  const scanNow = () => scanVideoFrame(video, {
4840
7211
  ...options,
4841
- maxDimension: options.maxDimension ?? 1080,
7212
+ maxDimension: baseCameraMaxDimension,
4842
7213
  canvas: scratchCanvas
4843
7214
  });
4844
7215
 
@@ -4905,7 +7276,7 @@ async function startCameraScanner(video, options = {}) {
4905
7276
  busy = true;
4906
7277
  frameNumber++;
4907
7278
  const observations = [];
4908
- const frameDiagnostics = diagnosticsEnabled ? {} : null;
7279
+ const frameDiagnostics = {};
4909
7280
  const capturedFrame = {};
4910
7281
  const frameStarted = nowMs();
4911
7282
  let allowAutoEnhance = false;
@@ -4933,7 +7304,7 @@ async function startCameraScanner(video, options = {}) {
4933
7304
  // If geometry exists, the rectified QR-only pixel enhancer is both
4934
7305
  // stronger and much cheaper than reprocessing the whole live frame.
4935
7306
  fullFrameAutoEnhanceRecovery: options.fullFrameAutoEnhanceRecovery ?? false,
4936
- maxDimension: options.maxDimension ?? 1080,
7307
+ maxDimension: baseCameraMaxDimension,
4937
7308
  canvas: scratchCanvas,
4938
7309
  _capturedFrame: capturedFrame,
4939
7310
  _observationCollector: observations,
@@ -4974,6 +7345,71 @@ async function startCameraScanner(video, options = {}) {
4974
7345
  ...frameDiagnostics
4975
7346
  });
4976
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
+
4977
7413
  // The user's real phone-camera case is dominated by color cast before
4978
7414
  // finder detection: Photoshop Auto Color alone makes the same live QR
4979
7415
  // immediately detectable. Retry the exact captured frame with a cheap
@@ -5278,11 +7714,14 @@ const internals = Object.freeze({
5278
7714
  HEADER_CODEWORD_CELLS,
5279
7715
  COMPACT_HEADER_CODEWORD_CELLS,
5280
7716
  CELLS_PER_BYTE,
7717
+ RGBW_CELLS_PER_BYTE,
7718
+ TRIANGLE16_CELLS_PER_BYTE,
7719
+ TRIANGLE16_FLAG,
5281
7720
  TEXT_FLAG,
5282
7721
  SECURE_FLAG
5283
7722
  });
5284
7723
 
5285
- return { installCrc32Accelerator, crc32, getVersionInfo, encodeText, encodeBytes, encodeSecureText, encodeSecureBytes, decryptDecoded, decodeMatrix, renderToCanvas, renderToImageData, scanImageData, scanFile, scanVideoFrame, startCameraScanner, rectifyDetectedCode, rotateMatrix, FORMAT_VERSION, MIN_VERSION, MAX_VERSION, DEFAULT_ECC_LEVEL, CELL, DEFAULT_PALETTE, RENDER_STYLES, ECC_LEVELS, internals, SECURITY_MODES, SECURITY_ALGORITHMS, SECURE_PAYLOAD_VERSION, DEFAULT_PBKDF2_ITERATIONS, generateRaw256Key, normalizeRaw256Key, bytesToHex };
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 };
5286
7725
  })();
5287
7726
  const QuadQR = __qqr_quadqr_js;
5288
7727