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