quadqr-js 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * QuadQR
3
3
  *
4
- * Experimental four-state RGBW matrix code written in pure JavaScript.
4
+ * Experimental RGBW / Triangle16 matrix code written in pure JavaScript.
5
5
  *
6
6
  * Core format:
7
- * - Red / Green / Blue / White data cells
8
- * - exactly 2 bits per data cell
7
+ * - RGBW profile: Red / Green / Blue / White, exactly 2 bits per data cell
8
+ * - Triangle16 profile: two fixed RGBW triangle regions, 16 states / 4 bits per body data cell
9
9
  * - GF(256) Reed-Solomon ECC over byte symbols
10
10
  * - interleaved body ECC blocks
11
11
  * - zero-overhead spectral-spatial cell placement
@@ -32,6 +32,7 @@ import {
32
32
  computeHomography,
33
33
  projectPoint,
34
34
  samplePerspectiveMatrix,
35
+ samplePerspectiveTriangleMatrix,
35
36
  rectifyImageData,
36
37
  sampleObservedPalette,
37
38
  spatiallyNormalizeRgbGrid,
@@ -39,7 +40,8 @@ import {
39
40
  autoToneContrastColorImageData,
40
41
  autoColorImageData,
41
42
  findActiveBounds,
42
- sampleAxisAlignedGrid
43
+ sampleAxisAlignedGrid,
44
+ sampleAxisAlignedTriangleGrid
43
45
  } from "./vision.js";
44
46
  import {
45
47
  decryptSecurePayload,
@@ -64,6 +66,12 @@ export const RENDER_MODES = Object.freeze({
64
66
  PRINT: "print"
65
67
  });
66
68
 
69
+ export const CELL_ENCODINGS = Object.freeze({
70
+ RGBW: "rgbw",
71
+ TRIANGLE16: "triangle16"
72
+ });
73
+
74
+
67
75
  // Print-safe defaults deliberately use darker, more ink-tolerant primaries and
68
76
  // a true white background. Applications can still override individual colors.
69
77
  export const PRINT_PALETTE = Object.freeze({
@@ -121,8 +129,10 @@ const MAGIC = new Uint8Array([0x51, 0x51, 0x52, 0x57]); // QQRW (QuadQR RGBW)
121
129
  const HEADER_BYTES = 10;
122
130
  const HEADER_RS_PARITY = 8;
123
131
  const HEADER_CODEWORD_BYTES = HEADER_BYTES + HEADER_RS_PARITY;
124
- const CELLS_PER_BYTE = 4;
125
- const HEADER_CODEWORD_CELLS = HEADER_CODEWORD_BYTES * CELLS_PER_BYTE;
132
+ const RGBW_CELLS_PER_BYTE = 4;
133
+ const TRIANGLE16_CELLS_PER_BYTE = 2;
134
+ const CELLS_PER_BYTE = RGBW_CELLS_PER_BYTE; // legacy internal alias
135
+ const HEADER_CODEWORD_CELLS = HEADER_CODEWORD_BYTES * RGBW_CELLS_PER_BYTE;
126
136
 
127
137
  // Version 1 is a deliberately compact small-symbol profile. A 21x21 matrix
128
138
  // cannot afford the normal 18-byte protected header plus a 24-byte M body
@@ -134,7 +144,7 @@ const COMPACT_HEADER_MAGIC = 0xc3;
134
144
  const COMPACT_HEADER_BYTES = 4;
135
145
  const COMPACT_HEADER_RS_PARITY = 4;
136
146
  const COMPACT_HEADER_CODEWORD_BYTES = COMPACT_HEADER_BYTES + COMPACT_HEADER_RS_PARITY;
137
- const COMPACT_HEADER_CODEWORD_CELLS = COMPACT_HEADER_CODEWORD_BYTES * CELLS_PER_BYTE;
147
+ const COMPACT_HEADER_CODEWORD_CELLS = COMPACT_HEADER_CODEWORD_BYTES * RGBW_CELLS_PER_BYTE;
138
148
  const COMPACT_ECC_LEVELS = Object.freeze({
139
149
  L: Object.freeze({ paritySymbols: 4, correctableSymbolsPerBlock: 2 }),
140
150
  M: Object.freeze({ paritySymbols: 8, correctableSymbolsPerBlock: 4 }),
@@ -147,6 +157,7 @@ const TEXT_FLAG = 1;
147
157
  const SECURE_FLAG = 1 << 3;
148
158
  const EXTENDED_PAYLOAD_FLAG = 1 << 4;
149
159
  const SIGNED_FLAG = 1 << 5;
160
+ const TRIANGLE16_FLAG = 1 << 6;
150
161
 
151
162
  // Internal payload extension envelope. This is deliberately not a public
152
163
  // payload mode or content-type system. It exists only when compression or
@@ -209,6 +220,45 @@ function normalizeEccLevel(level = DEFAULT_ECC_LEVEL) {
209
220
  return value;
210
221
  }
211
222
 
223
+ function normalizeCellEncoding(value = CELL_ENCODINGS.RGBW) {
224
+ const key = String(value).toLowerCase();
225
+ assert(
226
+ key === CELL_ENCODINGS.RGBW || key === CELL_ENCODINGS.TRIANGLE16,
227
+ `Internal cell encoding must be ${CELL_ENCODINGS.RGBW} or ${CELL_ENCODINGS.TRIANGLE16}.`
228
+ );
229
+ return key;
230
+ }
231
+
232
+ // Public API presents Triangle16 as one experimental High Density Mode toggle.
233
+ // cellEncoding remains an internal/backward-compatible detail so existing
234
+ // experimental branch symbols and renderer metadata continue to work.
235
+ function cellEncodingFromOptions(options = {}) {
236
+ if (typeof options.highDensity === "boolean") {
237
+ return options.highDensity ? CELL_ENCODINGS.TRIANGLE16 : CELL_ENCODINGS.RGBW;
238
+ }
239
+ return normalizeCellEncoding(options.cellEncoding ?? CELL_ENCODINGS.RGBW);
240
+ }
241
+
242
+ function cellsPerByteForEncoding(cellEncoding = CELL_ENCODINGS.RGBW) {
243
+ return normalizeCellEncoding(cellEncoding) === CELL_ENCODINGS.TRIANGLE16
244
+ ? TRIANGLE16_CELLS_PER_BYTE
245
+ : RGBW_CELLS_PER_BYTE;
246
+ }
247
+
248
+ function packTriangleCell(first, second) {
249
+ assert(first >= 0 && first <= 3 && second >= 0 && second <= 3, "Invalid Triangle16 color.");
250
+ return ((first & 3) << 2) | (second & 3);
251
+ }
252
+
253
+ function unpackTriangleCell(value) {
254
+ assert(Number.isInteger(value) && value >= 0 && value <= 15, "Invalid Triangle16 data cell.");
255
+ return { first: (value >>> 2) & 3, second: value & 3 };
256
+ }
257
+
258
+ function solidTriangleCell(color) {
259
+ return packTriangleCell(color, color);
260
+ }
261
+
212
262
  function concatBytes(...arrays) {
213
263
  const total = arrays.reduce((sum, item) => sum + item.length, 0);
214
264
  const out = new Uint8Array(total);
@@ -636,8 +686,19 @@ export function crc32(bytes) {
636
686
  return (crc ^ 0xffffffff) >>> 0;
637
687
  }
638
688
 
639
- function bytesToCells(bytes) {
640
- const out = new Array(bytes.length * CELLS_PER_BYTE);
689
+ function bytesToCells(bytes, cellEncoding = CELL_ENCODINGS.RGBW) {
690
+ const encoding = normalizeCellEncoding(cellEncoding);
691
+ if (encoding === CELL_ENCODINGS.TRIANGLE16) {
692
+ const out = new Array(bytes.length * TRIANGLE16_CELLS_PER_BYTE);
693
+ let cursor = 0;
694
+ for (const byte of bytes) {
695
+ out[cursor++] = (byte >>> 4) & 0x0f;
696
+ out[cursor++] = byte & 0x0f;
697
+ }
698
+ return out;
699
+ }
700
+
701
+ const out = new Array(bytes.length * RGBW_CELLS_PER_BYTE);
641
702
  let cursor = 0;
642
703
  for (const byte of bytes) {
643
704
  out[cursor++] = (byte >>> 6) & 0b11;
@@ -648,11 +709,36 @@ function bytesToCells(bytes) {
648
709
  return out;
649
710
  }
650
711
 
651
- function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYTE)) {
652
- assert(cells.length >= byteCount * CELLS_PER_BYTE, "Not enough RGBW cells to rebuild bytes.");
653
- const out = new Uint8Array(byteCount);
654
- for (let i = 0; i < byteCount; i++) {
655
- const offset = i * CELLS_PER_BYTE;
712
+ function bytesToProtectedHeaderCells(bytes, cellEncoding = CELL_ENCODINGS.RGBW) {
713
+ const base = bytesToCells(bytes, CELL_ENCODINGS.RGBW);
714
+ if (normalizeCellEncoding(cellEncoding) !== CELL_ENCODINGS.TRIANGLE16) return base;
715
+ // Triangle16 deliberately keeps the protected bootstrap/header as solid-color
716
+ // modules. This costs a few cells but makes mode detection and damaged-camera
717
+ // recovery substantially more reliable.
718
+ return base.map(solidTriangleCell);
719
+ }
720
+
721
+ function cellsToBytes(cells, byteCount, cellEncoding = CELL_ENCODINGS.RGBW) {
722
+ const encoding = normalizeCellEncoding(cellEncoding);
723
+ const cellsPerByte = cellsPerByteForEncoding(encoding);
724
+ const count = byteCount ?? Math.floor(cells.length / cellsPerByte);
725
+ assert(cells.length >= count * cellsPerByte, "Not enough data cells to rebuild bytes.");
726
+ const out = new Uint8Array(count);
727
+
728
+ if (encoding === CELL_ENCODINGS.TRIANGLE16) {
729
+ for (let i = 0; i < count; i++) {
730
+ const offset = i * TRIANGLE16_CELLS_PER_BYTE;
731
+ const a = cells[offset];
732
+ const b = cells[offset + 1];
733
+ assert(Number.isInteger(a) && a >= 0 && a <= 15, "Invalid Triangle16 data cell.");
734
+ assert(Number.isInteger(b) && b >= 0 && b <= 15, "Invalid Triangle16 data cell.");
735
+ out[i] = (a << 4) | b;
736
+ }
737
+ return out;
738
+ }
739
+
740
+ for (let i = 0; i < count; i++) {
741
+ const offset = i * RGBW_CELLS_PER_BYTE;
656
742
  const a = cells[offset];
657
743
  const b = cells[offset + 1];
658
744
  const c = cells[offset + 2];
@@ -665,14 +751,31 @@ function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYT
665
751
  return out;
666
752
  }
667
753
 
668
- function flagsFor(text, eccLevel, secure = false, extended = false, signed = false) {
754
+ function protectedHeaderCellsToBytes(cells, byteCount, cellEncoding = CELL_ENCODINGS.RGBW) {
755
+ if (normalizeCellEncoding(cellEncoding) !== CELL_ENCODINGS.TRIANGLE16) {
756
+ return cellsToBytes(cells, byteCount, CELL_ENCODINGS.RGBW);
757
+ }
758
+ const colors = cells.map((value) => {
759
+ const pair = unpackTriangleCell(value);
760
+ if (pair.first !== pair.second) throw new Error("Triangle16 protected header lost solid-color integrity.");
761
+ return pair.first;
762
+ });
763
+ return cellsToBytes(colors, byteCount, CELL_ENCODINGS.RGBW);
764
+ }
765
+
766
+ function flagsFor(text, eccLevel, secure = false, extended = false, signed = false, cellEncoding = CELL_ENCODINGS.RGBW) {
669
767
  return (text ? TEXT_FLAG : 0) |
670
768
  (secure ? SECURE_FLAG : 0) |
671
769
  (extended ? EXTENDED_PAYLOAD_FLAG : 0) |
672
770
  (signed ? SIGNED_FLAG : 0) |
771
+ (normalizeCellEncoding(cellEncoding) === CELL_ENCODINGS.TRIANGLE16 ? TRIANGLE16_FLAG : 0) |
673
772
  ((ECC_LEVELS[eccLevel].id << ECC_SHIFT) & ECC_MASK);
674
773
  }
675
774
 
775
+ function cellEncodingFromFlags(flags) {
776
+ return (flags & TRIANGLE16_FLAG) !== 0 ? CELL_ENCODINGS.TRIANGLE16 : CELL_ENCODINGS.RGBW;
777
+ }
778
+
676
779
  function eccFromFlags(flags) {
677
780
  const id = (flags & ECC_MASK) >> ECC_SHIFT;
678
781
  const name = ECC_BY_ID[id];
@@ -922,7 +1025,8 @@ function maskValue(row, col, maskId) {
922
1025
  }
923
1026
  }
924
1027
 
925
- function makePaddingCells(count, seed) {
1028
+ function makePaddingCells(count, seed, cellEncoding = CELL_ENCODINGS.RGBW) {
1029
+ const encoding = normalizeCellEncoding(cellEncoding);
926
1030
  let state = (seed >>> 0) || 0x6d2b79f5;
927
1031
  const out = new Array(count);
928
1032
  for (let i = 0; i < count; i++) {
@@ -930,11 +1034,27 @@ function makePaddingCells(count, seed) {
930
1034
  state ^= state >>> 17;
931
1035
  state ^= state << 5;
932
1036
  state >>>= 0;
933
- out[i] = state & 3;
1037
+ out[i] = encoding === CELL_ENCODINGS.TRIANGLE16 ? (state & 0x0f) : (state & 3);
934
1038
  }
935
1039
  return out;
936
1040
  }
937
1041
 
1042
+ function triangleMaskValue(row, col, maskId) {
1043
+ const first = maskValue(row, col, maskId);
1044
+ const second = maskValue(row + 1, col + 2, (maskId + 1) & 3);
1045
+ return packTriangleCell(first, second);
1046
+ }
1047
+
1048
+ function applyCellMask(value, row, col, maskId, cellEncoding, protectedHeader = false) {
1049
+ if (cellEncoding !== CELL_ENCODINGS.TRIANGLE16) return value ^ maskValue(row, col, maskId);
1050
+ if (protectedHeader) {
1051
+ const pair = unpackTriangleCell(value);
1052
+ if (pair.first !== pair.second) throw new Error("Triangle16 protected header must use solid-color cells.");
1053
+ return solidTriangleCell(pair.first ^ maskValue(row, col, maskId));
1054
+ }
1055
+ return value ^ triangleMaskValue(row, col, maskId);
1056
+ }
1057
+
938
1058
  const SPECTRAL_PERMUTATION_CACHE = new Map();
939
1059
 
940
1060
  function spectralPermutation(length, version) {
@@ -963,7 +1083,15 @@ function spectralPermutation(length, version) {
963
1083
  return permutation;
964
1084
  }
965
1085
 
966
- function applyData(layout, rawCells, maskId, spectralInterleaving = true) {
1086
+ function applyData(
1087
+ layout,
1088
+ rawCells,
1089
+ maskId,
1090
+ spectralInterleaving = true,
1091
+ cellEncoding = CELL_ENCODINGS.RGBW,
1092
+ protectedHeaderCells = getHeaderPlan(layout.version).codewordCells
1093
+ ) {
1094
+ const encoding = normalizeCellEncoding(cellEncoding);
967
1095
  const matrix = cloneMatrix(layout.matrix);
968
1096
  const permutation = spectralInterleaving
969
1097
  ? spectralPermutation(layout.dataPositions.length, layout.version)
@@ -972,16 +1100,37 @@ function applyData(layout, rawCells, maskId, spectralInterleaving = true) {
972
1100
  for (let logicalIndex = 0; logicalIndex < layout.dataPositions.length; logicalIndex++) {
973
1101
  const physicalIndex = permutation ? permutation[logicalIndex] : logicalIndex;
974
1102
  const [row, col] = layout.dataPositions[physicalIndex];
975
- matrix[row][col] = rawCells[logicalIndex] ^ maskValue(row, col, maskId);
1103
+ matrix[row][col] = applyCellMask(
1104
+ rawCells[logicalIndex],
1105
+ row,
1106
+ col,
1107
+ maskId,
1108
+ encoding,
1109
+ encoding === CELL_ENCODINGS.TRIANGLE16 && logicalIndex < protectedHeaderCells
1110
+ );
976
1111
  }
977
1112
  return matrix;
978
1113
  }
979
1114
 
980
- function quaternaryPenalty(matrix, reserved) {
1115
+ function dataCellPenalty(matrix, reserved, cellEncoding = CELL_ENCODINGS.RGBW) {
1116
+ const encoding = normalizeCellEncoding(cellEncoding);
981
1117
  const size = matrix.length;
982
1118
  let penalty = 0;
983
- const counts = [0, 0, 0, 0];
984
- let dataCount = 0;
1119
+ const colorCounts = [0, 0, 0, 0];
1120
+ let colorCount = 0;
1121
+
1122
+ const symbolAt = (r, c) => matrix[r][c];
1123
+ const addColors = (value) => {
1124
+ if (encoding === CELL_ENCODINGS.TRIANGLE16) {
1125
+ const pair = unpackTriangleCell(value);
1126
+ colorCounts[pair.first]++;
1127
+ colorCounts[pair.second]++;
1128
+ colorCount += 2;
1129
+ } else {
1130
+ colorCounts[value]++;
1131
+ colorCount++;
1132
+ }
1133
+ };
985
1134
 
986
1135
  for (let r = 0; r < size; r++) {
987
1136
  let previous = null;
@@ -992,9 +1141,8 @@ function quaternaryPenalty(matrix, reserved) {
992
1141
  run = 0;
993
1142
  continue;
994
1143
  }
995
- const value = matrix[r][c];
996
- counts[value]++;
997
- dataCount++;
1144
+ const value = symbolAt(r, c);
1145
+ addColors(value);
998
1146
  if (value === previous) {
999
1147
  run++;
1000
1148
  if (run >= 4) penalty += 2;
@@ -1014,7 +1162,7 @@ function quaternaryPenalty(matrix, reserved) {
1014
1162
  run = 0;
1015
1163
  continue;
1016
1164
  }
1017
- const value = matrix[r][c];
1165
+ const value = symbolAt(r, c);
1018
1166
  if (value === previous) {
1019
1167
  run++;
1020
1168
  if (run >= 4) penalty += 2;
@@ -1025,9 +1173,9 @@ function quaternaryPenalty(matrix, reserved) {
1025
1173
  }
1026
1174
  }
1027
1175
 
1028
- if (dataCount > 0) {
1029
- const ideal = dataCount / 4;
1030
- penalty += counts.reduce((sum, count) => sum + Math.abs(count - ideal), 0) / 2;
1176
+ if (colorCount > 0) {
1177
+ const ideal = colorCount / 4;
1178
+ penalty += colorCounts.reduce((sum, count) => sum + Math.abs(count - ideal), 0) / 2;
1031
1179
  }
1032
1180
  return penalty;
1033
1181
  }
@@ -1056,7 +1204,7 @@ function deinterleaveBlocks(stream, blockLengths) {
1056
1204
  return blocks;
1057
1205
  }
1058
1206
 
1059
- function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
1207
+ function getBodyRsPlan(payloadLength, eccLevel, version = 2, cellEncoding = CELL_ENCODINGS.RGBW) {
1060
1208
  const ecc = getEffectiveEcc(version, eccLevel);
1061
1209
  const bodyByteCount = payloadLength + CRC_BYTES;
1062
1210
  const dataSymbols = bodyByteCount;
@@ -1078,25 +1226,27 @@ function getBodyRsPlan(payloadLength, eccLevel, version = 2) {
1078
1226
  dataBlockLengths,
1079
1227
  codewordBlockLengths,
1080
1228
  encodedSymbols,
1081
- encodedCells: encodedSymbols * CELLS_PER_BYTE
1229
+ encodedCells: encodedSymbols * cellsPerByteForEncoding(cellEncoding)
1082
1230
  };
1083
1231
  }
1084
1232
 
1085
- function streamCellCount(payloadLength, eccLevel, version) {
1086
- return getHeaderPlan(version).codewordCells + getBodyRsPlan(payloadLength, eccLevel, version).encodedCells;
1233
+ function streamCellCount(payloadLength, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
1234
+ return getHeaderPlan(version).codewordCells +
1235
+ getBodyRsPlan(payloadLength, eccLevel, version, cellEncoding).encodedCells;
1087
1236
  }
1088
1237
 
1089
- function streamFitsLayout(layout, eccLevel, payloadLength, version) {
1090
- return streamCellCount(payloadLength, eccLevel, version) <= layout.dataPositions.length;
1238
+ function streamFitsLayout(layout, eccLevel, payloadLength, version, cellEncoding = CELL_ENCODINGS.RGBW) {
1239
+ return streamCellCount(payloadLength, eccLevel, version, cellEncoding) <= layout.dataPositions.length;
1091
1240
  }
1092
1241
 
1093
- function getCapacityForLayout(layout, eccLevel, version) {
1094
- if (!streamFitsLayout(layout, eccLevel, 0, version)) return 0;
1242
+ function getCapacityForLayout(layout, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
1243
+ const encoding = normalizeCellEncoding(cellEncoding);
1244
+ if (!streamFitsLayout(layout, eccLevel, 0, version, encoding)) return 0;
1095
1245
  let low = 0;
1096
- let high = Math.floor(layout.dataPositions.length / CELLS_PER_BYTE);
1246
+ let high = Math.floor(layout.dataPositions.length / cellsPerByteForEncoding(encoding));
1097
1247
  while (low < high) {
1098
1248
  const mid = Math.ceil((low + high) / 2);
1099
- if (streamFitsLayout(layout, eccLevel, mid, version)) low = mid;
1249
+ if (streamFitsLayout(layout, eccLevel, mid, version, encoding)) low = mid;
1100
1250
  else high = mid - 1;
1101
1251
  }
1102
1252
  return low;
@@ -1105,6 +1255,7 @@ function getCapacityForLayout(layout, eccLevel, version) {
1105
1255
  export function getVersionInfo(version, options = {}) {
1106
1256
  validateVersion(version);
1107
1257
  const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
1258
+ const cellEncoding = cellEncodingFromOptions(options);
1108
1259
  const layout = createLayout(version);
1109
1260
  const headerPlan = getHeaderPlan(version);
1110
1261
  const effectiveEcc = getEffectiveEcc(version, eccLevel);
@@ -1114,8 +1265,8 @@ export function getVersionInfo(version, options = {}) {
1114
1265
  eccLevel,
1115
1266
  size: layout.size,
1116
1267
  dataCells: layout.dataPositions.length,
1117
- theoreticalBits: layout.dataPositions.length * 2,
1118
- capacityBytes: getCapacityForLayout(layout, eccLevel, version),
1268
+ theoreticalBits: layout.dataPositions.length * (cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2),
1269
+ capacityBytes: getCapacityForLayout(layout, eccLevel, version, cellEncoding),
1119
1270
  headerCells: headerPlan.codewordCells,
1120
1271
  headerBytes: headerPlan.headerBytes,
1121
1272
  headerParitySymbols: headerPlan.paritySymbols,
@@ -1127,8 +1278,11 @@ export function getVersionInfo(version, options = {}) {
1127
1278
  hasAlignmentMarker: true,
1128
1279
  alignmentPatterns: layout.alignments.length,
1129
1280
  alignmentCenters: layout.alignments.map(({ row, col }) => [row, col]),
1130
- bitsPerDataCell: 2,
1281
+ bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
1282
+ highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
1283
+ cellEncoding,
1131
1284
  colors: 4,
1285
+ statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
1132
1286
  spectralInterleaving: true,
1133
1287
  confidenceAwareEcc: true
1134
1288
  };
@@ -1139,6 +1293,7 @@ function chooseVersion(payloadLength, options = {}) {
1139
1293
  const minVersion = options.minVersion ?? MIN_VERSION;
1140
1294
  const maxVersion = options.maxVersion ?? MAX_VERSION;
1141
1295
  const ecc = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
1296
+ const cellEncoding = cellEncodingFromOptions(options);
1142
1297
 
1143
1298
  validateVersion(minVersion);
1144
1299
  validateVersion(maxVersion);
@@ -1147,10 +1302,10 @@ function chooseVersion(payloadLength, options = {}) {
1147
1302
  if (requested !== "auto") {
1148
1303
  validateVersion(requested);
1149
1304
  assert(requested >= minVersion && requested <= maxVersion, "Requested version is outside selected bounds.");
1150
- const info = getVersionInfo(requested, { ecc });
1305
+ const info = getVersionInfo(requested, { ecc, cellEncoding });
1151
1306
  const layout = createLayout(requested);
1152
1307
  assert(
1153
- payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested),
1308
+ payloadLength <= info.capacityBytes && streamFitsLayout(layout, ecc, payloadLength, requested, cellEncoding),
1154
1309
  `Payload does not fit version ${requested} with ${ecc} ECC. Maximum is ${info.capacityBytes} bytes.`
1155
1310
  );
1156
1311
  return requested;
@@ -1158,20 +1313,20 @@ function chooseVersion(payloadLength, options = {}) {
1158
1313
 
1159
1314
  for (let version = minVersion; version <= maxVersion; version++) {
1160
1315
  const layout = createLayout(version);
1161
- if (streamFitsLayout(layout, ecc, payloadLength, version)) return version;
1316
+ if (streamFitsLayout(layout, ecc, payloadLength, version, cellEncoding)) return version;
1162
1317
  }
1163
1318
  throw new Error(`Payload is too large for versions ${minVersion}..${maxVersion}.`);
1164
1319
  }
1165
1320
 
1166
- function encodeProtectedHeader(header, version) {
1321
+ function encodeProtectedHeader(header, version, cellEncoding = CELL_ENCODINGS.RGBW) {
1167
1322
  const plan = getHeaderPlan(version);
1168
1323
  const codeword = rsEncode(Array.from(header), plan.paritySymbols);
1169
1324
  assert(codeword.length === plan.codewordBytes, "Header RS symbol calculation mismatch.");
1170
- return bytesToCells(codeword);
1325
+ return bytesToProtectedHeaderCells(codeword, cellEncoding);
1171
1326
  }
1172
1327
 
1173
- function encodeProtectedBody(bodyBytes, eccLevel, version) {
1174
- const plan = getBodyRsPlan(bodyBytes.length - CRC_BYTES, eccLevel, version);
1328
+ function encodeProtectedBody(bodyBytes, eccLevel, version, cellEncoding = CELL_ENCODINGS.RGBW) {
1329
+ const plan = getBodyRsPlan(bodyBytes.length - CRC_BYTES, eccLevel, version, cellEncoding);
1175
1330
  assert(bodyBytes.length === plan.dataSymbols, "Body RS symbol calculation mismatch.");
1176
1331
 
1177
1332
  const blocks = [];
@@ -1180,7 +1335,7 @@ function encodeProtectedBody(bodyBytes, eccLevel, version) {
1180
1335
  blocks.push(rsEncode(Array.from(bodyBytes.slice(offset, offset + length)), plan.paritySymbols));
1181
1336
  offset += length;
1182
1337
  }
1183
- return { cells: bytesToCells(interleaveBlocks(blocks)), plan };
1338
+ return { cells: bytesToCells(interleaveBlocks(blocks), cellEncoding), plan };
1184
1339
  }
1185
1340
 
1186
1341
  function finalizeMatrix(layout, rawCells, meta) {
@@ -1188,9 +1343,10 @@ function finalizeMatrix(layout, rawCells, meta) {
1188
1343
  let bestMatrix = null;
1189
1344
  let bestPenalty = Infinity;
1190
1345
 
1346
+ const cellEncoding = normalizeCellEncoding(meta.cellEncoding ?? CELL_ENCODINGS.RGBW);
1191
1347
  for (let maskId = 0; maskId < 4; maskId++) {
1192
- const candidate = applyData(layout, rawCells, maskId, true);
1193
- const penalty = quaternaryPenalty(candidate, layout.reserved);
1348
+ const candidate = applyData(layout, rawCells, maskId, true, cellEncoding);
1349
+ const penalty = dataCellPenalty(candidate, layout.reserved, cellEncoding);
1194
1350
  if (penalty < bestPenalty) {
1195
1351
  bestPenalty = penalty;
1196
1352
  bestMaskId = maskId;
@@ -1198,7 +1354,7 @@ function finalizeMatrix(layout, rawCells, meta) {
1198
1354
  }
1199
1355
  }
1200
1356
 
1201
- const info = getVersionInfo(meta.version, { ecc: meta.eccLevel });
1357
+ const info = getVersionInfo(meta.version, { ecc: meta.eccLevel, cellEncoding });
1202
1358
  return {
1203
1359
  format: "QuadQR",
1204
1360
  formatVersion: FORMAT_VERSION,
@@ -1221,7 +1377,10 @@ function finalizeMatrix(layout, rawCells, meta) {
1221
1377
  capacityBytes: info.capacityBytes,
1222
1378
  alignmentPatterns: layout.alignments.length,
1223
1379
  utilization: meta.meaningfulCells / layout.dataPositions.length,
1224
- bitsPerDataCell: 2,
1380
+ bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
1381
+ highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
1382
+ cellEncoding,
1383
+ statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
1225
1384
  eccLevel: meta.eccLevel,
1226
1385
  eccParitySymbols: meta.eccParitySymbols,
1227
1386
  eccBlocks: meta.eccBlocks,
@@ -1242,23 +1401,26 @@ function encodePreparedBytes(input, options = {}) {
1242
1401
  const secure = Boolean(options.secure);
1243
1402
  const extended = Boolean(options.extended);
1244
1403
  const signed = Boolean(options.signed);
1245
- const flags = flagsFor(Boolean(options.text), eccLevel, secure, extended, signed);
1246
- const version = chooseVersion(payload.length, { ...options, ecc: eccLevel });
1404
+ const cellEncoding = cellEncodingFromOptions(options);
1405
+ const flags = flagsFor(Boolean(options.text), eccLevel, secure, extended, signed, cellEncoding);
1406
+ const version = chooseVersion(payload.length, { ...options, ecc: eccLevel, cellEncoding });
1247
1407
  const layout = createLayout(version);
1248
1408
  const header = makeHeader(payload.length, flags, version);
1249
1409
  const crc = crc32(concatBytes(header, payload));
1250
- const headerCells = encodeProtectedHeader(header, version);
1251
- const bodyEncoded = encodeProtectedBody(concatBytes(payload, u32be(crc)), eccLevel, version);
1410
+ const headerCells = encodeProtectedHeader(header, version, cellEncoding);
1411
+ const bodyEncoded = encodeProtectedBody(concatBytes(payload, u32be(crc)), eccLevel, version, cellEncoding);
1252
1412
  const meaningfulCells = headerCells.concat(bodyEncoded.cells);
1253
1413
  assert(meaningfulCells.length <= layout.dataPositions.length, "Internal QuadQR capacity calculation error.");
1254
1414
 
1255
1415
  const padding = makePaddingCells(
1256
1416
  layout.dataPositions.length - meaningfulCells.length,
1257
- crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16)
1417
+ crc ^ payload.length ^ (version << 24) ^ (ECC_LEVELS[eccLevel].id << 16),
1418
+ cellEncoding
1258
1419
  );
1259
1420
 
1260
1421
  return finalizeMatrix(layout, meaningfulCells.concat(padding), {
1261
1422
  version,
1423
+ cellEncoding,
1262
1424
  payloadBytes: payload.length,
1263
1425
  sourcePayloadBytes: options.sourcePayloadBytes ?? payload.length,
1264
1426
  secure,
@@ -1499,12 +1661,14 @@ function rotate90(matrix) {
1499
1661
  return out;
1500
1662
  }
1501
1663
 
1502
- function extractVisibleCells(matrix, layout) {
1664
+ function extractVisibleCells(matrix, layout, cellEncoding = CELL_ENCODINGS.RGBW) {
1665
+ const encoding = normalizeCellEncoding(cellEncoding);
1666
+ const maxValue = encoding === CELL_ENCODINGS.TRIANGLE16 ? 15 : 3;
1503
1667
  const out = [];
1504
1668
  for (const [row, col] of layout.dataPositions) {
1505
1669
  const value = matrix[row][col];
1506
- if (!Number.isInteger(value) || value < 0 || value > 3) {
1507
- throw new Error("Data region contains an invalid QuadQR data cell.");
1670
+ if (!Number.isInteger(value) || value < 0 || value > maxValue) {
1671
+ throw new Error(`Data region contains an invalid ${encoding === CELL_ENCODINGS.TRIANGLE16 ? "Triangle16" : "RGBW"} data cell.`);
1508
1672
  }
1509
1673
  out.push(value);
1510
1674
  }
@@ -1518,6 +1682,34 @@ function unmaskCells(visibleCells, positions, maskId) {
1518
1682
  });
1519
1683
  }
1520
1684
 
1685
+ function unmaskAndRestoreLogicalCells(
1686
+ visibleCells,
1687
+ positions,
1688
+ maskId,
1689
+ version,
1690
+ spectralInterleaving,
1691
+ cellEncoding = CELL_ENCODINGS.RGBW
1692
+ ) {
1693
+ const encoding = normalizeCellEncoding(cellEncoding);
1694
+ const permutation = spectralInterleaving ? spectralPermutation(visibleCells.length, version) : null;
1695
+ const logical = new Array(visibleCells.length);
1696
+ const headerCells = getHeaderPlan(version).codewordCells;
1697
+
1698
+ for (let logicalIndex = 0; logicalIndex < visibleCells.length; logicalIndex++) {
1699
+ const physicalIndex = permutation ? permutation[logicalIndex] : logicalIndex;
1700
+ const [row, col] = positions[physicalIndex];
1701
+ logical[logicalIndex] = applyCellMask(
1702
+ visibleCells[physicalIndex],
1703
+ row,
1704
+ col,
1705
+ maskId,
1706
+ encoding,
1707
+ encoding === CELL_ENCODINGS.TRIANGLE16 && logicalIndex < headerCells
1708
+ );
1709
+ }
1710
+ return logical;
1711
+ }
1712
+
1521
1713
  function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
1522
1714
  if (!spectralInterleaving) return physicalValues.slice();
1523
1715
  const permutation = spectralPermutation(physicalValues.length, version);
@@ -1528,20 +1720,21 @@ function restoreLogicalOrder(physicalValues, version, spectralInterleaving) {
1528
1720
  return logical;
1529
1721
  }
1530
1722
 
1531
- function cellsToSymbolConfidences(confidences, byteCount) {
1723
+ function cellsToSymbolConfidences(
1724
+ confidences,
1725
+ byteCount,
1726
+ cellsPerByte = RGBW_CELLS_PER_BYTE
1727
+ ) {
1532
1728
  if (!confidences) return null;
1533
- if (confidences.length < byteCount * CELLS_PER_BYTE) return null;
1729
+ if (confidences.length < byteCount * cellsPerByte) return null;
1534
1730
  const out = new Array(byteCount);
1535
1731
  for (let symbolIndex = 0; symbolIndex < byteCount; symbolIndex++) {
1536
- const offset = symbolIndex * CELLS_PER_BYTE;
1537
- // A single wrong 2-bit cell changes the GF(256) symbol, so the weakest
1538
- // constituent cell is the useful confidence bound for that symbol.
1539
- out[symbolIndex] = Math.min(
1540
- confidences[offset] ?? 1,
1541
- confidences[offset + 1] ?? 1,
1542
- confidences[offset + 2] ?? 1,
1543
- confidences[offset + 3] ?? 1
1544
- );
1732
+ const offset = symbolIndex * cellsPerByte;
1733
+ let value = 1;
1734
+ for (let cellIndex = 0; cellIndex < cellsPerByte; cellIndex++) {
1735
+ value = Math.min(value, confidences[offset + cellIndex] ?? 1);
1736
+ }
1737
+ out[symbolIndex] = value;
1545
1738
  }
1546
1739
  return out;
1547
1740
  }
@@ -1578,11 +1771,11 @@ function decodeRsAdaptive(codeword, paritySymbols, symbolConfidences, options =
1578
1771
  }
1579
1772
  }
1580
1773
 
1581
- function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}) {
1774
+ function decodeProtectedHeader(rawCells, version, rawConfidences = null, options = {}, cellEncoding = CELL_ENCODINGS.RGBW) {
1582
1775
  const plan = getHeaderPlan(version);
1583
1776
  const headerCells = rawCells.slice(0, plan.codewordCells);
1584
- const codeword = Array.from(cellsToBytes(headerCells, plan.codewordBytes));
1585
- const confidences = cellsToSymbolConfidences(rawConfidences?.slice(0, plan.codewordCells), plan.codewordBytes);
1777
+ const codeword = Array.from(protectedHeaderCellsToBytes(headerCells, plan.codewordBytes, cellEncoding));
1778
+ const confidences = cellsToSymbolConfidences(rawConfidences?.slice(0, plan.codewordCells), plan.codewordBytes, RGBW_CELLS_PER_BYTE);
1586
1779
  const decoded = decodeRsAdaptive(codeword, plan.paritySymbols, confidences, options);
1587
1780
  const header = new Uint8Array(decoded.data);
1588
1781
  const parsed = parseHeader(header, version);
@@ -1597,16 +1790,17 @@ function decodeProtectedHeader(rawCells, version, rawConfidences = null, options
1597
1790
  };
1598
1791
  }
1599
1792
 
1600
- function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConfidences = null, options = {}) {
1601
- const plan = getBodyRsPlan(payloadLength, eccLevel, version);
1793
+ function decodeProtectedBody(rawCells, payloadLength, eccLevel, version, rawConfidences = null, options = {}, cellEncoding = CELL_ENCODINGS.RGBW) {
1794
+ const plan = getBodyRsPlan(payloadLength, eccLevel, version, cellEncoding);
1602
1795
  const bodyStart = getHeaderPlan(version).codewordCells;
1603
1796
  const encodedCells = rawCells.slice(bodyStart, bodyStart + plan.encodedCells);
1604
1797
  if (encodedCells.length !== plan.encodedCells) throw new Error("Protected body is incomplete.");
1605
1798
 
1606
- const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols));
1799
+ const encodedSymbols = Array.from(cellsToBytes(encodedCells, plan.encodedSymbols, cellEncoding));
1607
1800
  const symbolConfidences = cellsToSymbolConfidences(
1608
1801
  rawConfidences?.slice(bodyStart, bodyStart + plan.encodedCells),
1609
- plan.encodedSymbols
1802
+ plan.encodedSymbols,
1803
+ cellsPerByteForEncoding(cellEncoding)
1610
1804
  );
1611
1805
  const blocks = deinterleaveBlocks(encodedSymbols, plan.codewordBlockLengths);
1612
1806
  const confidenceBlocks = symbolConfidences
@@ -1649,96 +1843,136 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
1649
1843
  if (!validateStructure(matrix, tolerance)) throw new Error("QuadQR finder/alignment structure does not match.");
1650
1844
 
1651
1845
  const layout = createLayout(version);
1652
- const visible = extractVisibleCells(matrix, layout);
1653
- const visibleConfidences = confidenceMatrix
1654
- ? layout.dataPositions.map(([row, col]) => confidenceMatrix[row]?.[col] ?? 1)
1655
- : null;
1656
1846
  const errors = [];
1847
+ const hintedEncoding = options.cellEncodingHint != null
1848
+ ? normalizeCellEncoding(options.cellEncodingHint)
1849
+ : null;
1850
+ const hasExtendedCells = layout.dataPositions.some(([row, col]) => (matrix[row]?.[col] ?? 0) > 3);
1851
+ const encodingAttempts = hintedEncoding
1852
+ ? [hintedEncoding]
1853
+ : hasExtendedCells
1854
+ ? [CELL_ENCODINGS.TRIANGLE16, CELL_ENCODINGS.RGBW]
1855
+ : [CELL_ENCODINGS.RGBW, CELL_ENCODINGS.TRIANGLE16];
1856
+
1857
+ for (const cellEncoding of encodingAttempts) {
1858
+ let visible;
1859
+ try {
1860
+ visible = extractVisibleCells(matrix, layout, cellEncoding);
1861
+ } catch (error) {
1862
+ errors.push(`${cellEncoding}: ${error.message}`);
1863
+ continue;
1864
+ }
1657
1865
 
1658
- // New symbols use spectral-spatial interleaving. Legacy order remains a
1659
- // decode fallback so existing QuadQR images do not become unreadable.
1660
- for (const spectralInterleaving of [true, false]) {
1661
- for (let maskId = 0; maskId < 4; maskId++) {
1662
- try {
1663
- const physicalRaw = unmaskCells(visible, layout.dataPositions, maskId);
1664
- const raw = restoreLogicalOrder(physicalRaw, version, spectralInterleaving);
1665
- const rawConfidences = visibleConfidences
1666
- ? restoreLogicalOrder(visibleConfidences, version, spectralInterleaving)
1667
- : null;
1668
- const headerDecoded = decodeProtectedHeader(raw, version, rawConfidences, options);
1669
- const header = headerDecoded.header;
1670
- const flags = headerDecoded.flags;
1671
- const eccLevel = eccFromFlags(flags);
1672
- const payloadLength = headerDecoded.payloadLength;
1673
- if (streamCellCount(payloadLength, eccLevel, version) > raw.length) {
1674
- throw new Error("Declared payload exceeds matrix capacity.");
1675
- }
1866
+ const visibleConfidences = confidenceMatrix
1867
+ ? layout.dataPositions.map(([row, col]) => confidenceMatrix[row]?.[col] ?? 1)
1868
+ : null;
1676
1869
 
1677
- const bodyDecoded = decodeProtectedBody(
1678
- raw,
1679
- payloadLength,
1680
- eccLevel,
1681
- version,
1682
- rawConfidences,
1683
- options
1684
- );
1685
- const body = bodyDecoded.body;
1686
- const payload = body.slice(0, payloadLength);
1687
- const expectedCrc = readU32be(body, payloadLength);
1688
- const actualCrc = crc32(concatBytes(header, payload));
1689
- if (expectedCrc !== actualCrc) throw new Error("CRC mismatch after ECC.");
1690
-
1691
- const isText = (flags & TEXT_FLAG) !== 0;
1692
- const secure = (flags & SECURE_FLAG) !== 0;
1693
- const extendedFlag = (flags & EXTENDED_PAYLOAD_FLAG) !== 0;
1694
- const signedFlag = (flags & SIGNED_FLAG) !== 0;
1695
- const security = secure ? inspectSecureEnvelope(payload) : null;
1696
- const envelope = extendedFlag && !secure ? parsePayloadEnvelope(payload) : null;
1697
- const applicationPayload = envelope ? envelope.payload : payload;
1698
- const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
1699
- const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
1700
- return {
1701
- ok: true,
1702
- format: "QuadQR",
1703
- formatVersion: FORMAT_VERSION,
1704
- version,
1705
- size,
1706
- alignmentPatterns: layout.alignments.length,
1707
- maskId,
1708
- rotation,
1709
- flags,
1710
- eccLevel,
1711
- eccParitySymbols: bodyDecoded.plan.paritySymbols,
1712
- eccBlocks: bodyDecoded.plan.dataBlockLengths.length,
1713
- correctableSymbolsPerBlock: bodyDecoded.plan.correctableSymbolsPerBlock,
1714
- spectralInterleaving,
1715
- confidenceAwareEcc: Boolean(rawConfidences),
1716
- confidenceAssisted: headerDecoded.confidenceAssisted || bodyDecoded.confidenceAssisted,
1717
- erasureSymbols,
1718
- unknownErrorSymbols,
1719
- correctedHeaderSymbols: headerDecoded.correctedSymbols,
1720
- correctedBodySymbols: bodyDecoded.correctedSymbols,
1721
- correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
1722
- protectedPayload: envelope ? payload : undefined,
1723
- payload: secure ? payload : applicationPayload,
1724
- text: secure ? null : (isText ? getTextDecoder().decode(applicationPayload) : null),
1725
- compressed: envelope?.compressed ?? false,
1726
- compression: envelope?.compression ?? "none",
1727
- signed: envelope?.signed ?? signedFlag,
1728
- signatureVerified: envelope?.signatureVerified,
1729
- signatureTrusted: envelope?.signatureTrusted,
1730
- signingKeyId: envelope?.keyId ?? null,
1731
- hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? false,
1732
- signer: envelope?.signer ?? null,
1733
- secure,
1734
- encrypted: secure,
1735
- decrypted: false,
1736
- requiresDecryption: secure,
1737
- security,
1738
- crc32: actualCrc >>> 0
1739
- };
1740
- } catch (error) {
1741
- errors.push(`${spectralInterleaving ? "spectral" : "legacy"} mask ${maskId}: ${error.message}`);
1870
+ // New symbols use spectral-spatial interleaving. Legacy order remains a
1871
+ // decode fallback so existing RGBW QuadQR images do not become unreadable.
1872
+ for (const spectralInterleaving of [true, false]) {
1873
+ for (let maskId = 0; maskId < 4; maskId++) {
1874
+ try {
1875
+ const raw = unmaskAndRestoreLogicalCells(
1876
+ visible,
1877
+ layout.dataPositions,
1878
+ maskId,
1879
+ version,
1880
+ spectralInterleaving,
1881
+ cellEncoding
1882
+ );
1883
+ const rawConfidences = visibleConfidences
1884
+ ? restoreLogicalOrder(visibleConfidences, version, spectralInterleaving)
1885
+ : null;
1886
+ const headerDecoded = decodeProtectedHeader(
1887
+ raw,
1888
+ version,
1889
+ rawConfidences,
1890
+ options,
1891
+ cellEncoding
1892
+ );
1893
+ const header = headerDecoded.header;
1894
+ const flags = headerDecoded.flags;
1895
+ const declaredCellEncoding = cellEncodingFromFlags(flags);
1896
+ if (declaredCellEncoding !== cellEncoding) {
1897
+ throw new Error(`Header declares ${declaredCellEncoding}, not ${cellEncoding}.`);
1898
+ }
1899
+ const eccLevel = eccFromFlags(flags);
1900
+ const payloadLength = headerDecoded.payloadLength;
1901
+ if (streamCellCount(payloadLength, eccLevel, version, cellEncoding) > raw.length) {
1902
+ throw new Error("Declared payload exceeds matrix capacity.");
1903
+ }
1904
+
1905
+ const bodyDecoded = decodeProtectedBody(
1906
+ raw,
1907
+ payloadLength,
1908
+ eccLevel,
1909
+ version,
1910
+ rawConfidences,
1911
+ options,
1912
+ cellEncoding
1913
+ );
1914
+ const body = bodyDecoded.body;
1915
+ const payload = body.slice(0, payloadLength);
1916
+ const expectedCrc = readU32be(body, payloadLength);
1917
+ const actualCrc = crc32(concatBytes(header, payload));
1918
+ if (expectedCrc !== actualCrc) throw new Error("CRC mismatch after ECC.");
1919
+
1920
+ const isText = (flags & TEXT_FLAG) !== 0;
1921
+ const secure = (flags & SECURE_FLAG) !== 0;
1922
+ const extendedFlag = (flags & EXTENDED_PAYLOAD_FLAG) !== 0;
1923
+ const signedFlag = (flags & SIGNED_FLAG) !== 0;
1924
+ const security = secure ? inspectSecureEnvelope(payload) : null;
1925
+ const envelope = extendedFlag && !secure ? parsePayloadEnvelope(payload) : null;
1926
+ const applicationPayload = envelope ? envelope.payload : payload;
1927
+ const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
1928
+ const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
1929
+ return {
1930
+ ok: true,
1931
+ format: "QuadQR",
1932
+ formatVersion: FORMAT_VERSION,
1933
+ version,
1934
+ size,
1935
+ alignmentPatterns: layout.alignments.length,
1936
+ maskId,
1937
+ rotation,
1938
+ flags,
1939
+ highDensity: cellEncoding === CELL_ENCODINGS.TRIANGLE16,
1940
+ cellEncoding,
1941
+ bitsPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 4 : 2,
1942
+ statesPerDataCell: cellEncoding === CELL_ENCODINGS.TRIANGLE16 ? 16 : 4,
1943
+ eccLevel,
1944
+ eccParitySymbols: bodyDecoded.plan.paritySymbols,
1945
+ eccBlocks: bodyDecoded.plan.dataBlockLengths.length,
1946
+ correctableSymbolsPerBlock: bodyDecoded.plan.correctableSymbolsPerBlock,
1947
+ spectralInterleaving,
1948
+ confidenceAwareEcc: Boolean(rawConfidences),
1949
+ confidenceAssisted: headerDecoded.confidenceAssisted || bodyDecoded.confidenceAssisted,
1950
+ erasureSymbols,
1951
+ unknownErrorSymbols,
1952
+ correctedHeaderSymbols: headerDecoded.correctedSymbols,
1953
+ correctedBodySymbols: bodyDecoded.correctedSymbols,
1954
+ correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
1955
+ protectedPayload: envelope ? payload : undefined,
1956
+ payload: secure ? payload : applicationPayload,
1957
+ text: secure ? null : (isText ? getTextDecoder().decode(applicationPayload) : null),
1958
+ compressed: envelope?.compressed ?? false,
1959
+ compression: envelope?.compression ?? "none",
1960
+ signed: envelope?.signed ?? signedFlag,
1961
+ signatureVerified: envelope?.signatureVerified,
1962
+ signatureTrusted: envelope?.signatureTrusted,
1963
+ signingKeyId: envelope?.keyId ?? null,
1964
+ hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? false,
1965
+ signer: envelope?.signer ?? null,
1966
+ secure,
1967
+ encrypted: secure,
1968
+ decrypted: false,
1969
+ requiresDecryption: secure,
1970
+ security,
1971
+ crc32: actualCrc >>> 0
1972
+ };
1973
+ } catch (error) {
1974
+ errors.push(`${cellEncoding} ${spectralInterleaving ? "spectral" : "legacy"} mask ${maskId}: ${error.message}`);
1975
+ }
1742
1976
  }
1743
1977
  }
1744
1978
  }
@@ -1855,6 +2089,80 @@ function cellRgb(cell, palette) {
1855
2089
  }
1856
2090
 
1857
2091
 
2092
+ function inferCellEncoding(codeOrMatrix, matrix, layout = null) {
2093
+ if (!Array.isArray(codeOrMatrix) && codeOrMatrix?.cellEncoding) {
2094
+ return normalizeCellEncoding(codeOrMatrix.cellEncoding);
2095
+ }
2096
+ const activeLayout = layout ?? renderLayoutForMatrix(matrix);
2097
+ if (activeLayout) {
2098
+ for (const [row, col] of activeLayout.dataPositions) {
2099
+ if ((matrix[row]?.[col] ?? 0) > 3) return CELL_ENCODINGS.TRIANGLE16;
2100
+ }
2101
+ }
2102
+ return CELL_ENCODINGS.RGBW;
2103
+ }
2104
+
2105
+ function triangleCellColors(cell) {
2106
+ const { first, second } = unpackTriangleCell(cell);
2107
+ return { first, second };
2108
+ }
2109
+
2110
+ function drawTriangleCellCanvas(ctx, x, y, width, height, cell, palette) {
2111
+ const pair = triangleCellColors(cell);
2112
+ if (pair.first === pair.second) {
2113
+ ctx.fillStyle = cellColor(pair.first, palette);
2114
+ ctx.fillRect(x, y, width, height);
2115
+ return;
2116
+ }
2117
+
2118
+ // Fixed "/" split. first = upper-left triangle, second = lower-right.
2119
+ ctx.beginPath();
2120
+ ctx.moveTo(x, y);
2121
+ ctx.lineTo(x + width, y);
2122
+ ctx.lineTo(x, y + height);
2123
+ ctx.closePath();
2124
+ ctx.fillStyle = cellColor(pair.first, palette);
2125
+ ctx.fill();
2126
+
2127
+ ctx.beginPath();
2128
+ ctx.moveTo(x + width, y);
2129
+ ctx.lineTo(x + width, y + height);
2130
+ ctx.lineTo(x, y + height);
2131
+ ctx.closePath();
2132
+ ctx.fillStyle = cellColor(pair.second, palette);
2133
+ ctx.fill();
2134
+ }
2135
+
2136
+ function fillImageTriangleCell(data, imageWidth, x, y, width, height, cell, palette) {
2137
+ const pair = triangleCellColors(cell);
2138
+ const firstRgb = cellRgb(pair.first, palette);
2139
+ const secondRgb = cellRgb(pair.second, palette);
2140
+ for (let yy = 0; yy < height; yy++) {
2141
+ for (let xx = 0; xx < width; xx++) {
2142
+ const normalizedX = (xx + 0.5) / Math.max(1, width);
2143
+ const normalizedY = (yy + 0.5) / Math.max(1, height);
2144
+ const rgb = normalizedX + normalizedY <= 1 ? firstRgb : secondRgb;
2145
+ const p = ((y + yy) * imageWidth + (x + xx)) * 4;
2146
+ data[p] = rgb.r;
2147
+ data[p + 1] = rgb.g;
2148
+ data[p + 2] = rgb.b;
2149
+ data[p + 3] = 255;
2150
+ }
2151
+ }
2152
+ }
2153
+
2154
+ function svgTriangleCell(x, y, size, cell, palette) {
2155
+ const pair = triangleCellColors(cell);
2156
+ if (pair.first === pair.second) return svgRect(x, y, size, size, cellColor(pair.first, palette));
2157
+ const first = cellColor(pair.first, palette);
2158
+ const second = cellColor(pair.second, palette);
2159
+ return [
2160
+ `<polygon points="${x},${y} ${x + size},${y} ${x},${y + size}" fill="${first}"/>`,
2161
+ `<polygon points="${x + size},${y} ${x + size},${y + size} ${x},${y + size}" fill="${second}"/>`
2162
+ ].join("");
2163
+ }
2164
+
2165
+
1858
2166
  function normalizeRenderStyle(style = RENDER_STYLES.CLASSIC) {
1859
2167
  const value = String(style).toLowerCase();
1860
2168
  const allowed = Object.values(RENDER_STYLES);
@@ -2324,6 +2632,7 @@ export function renderToCanvas(codeOrMatrix, canvas, options = {}) {
2324
2632
  const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
2325
2633
  const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
2326
2634
  const layout = renderLayoutForMatrix(matrix);
2635
+ const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
2327
2636
  const size = matrix.length;
2328
2637
  const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
2329
2638
 
@@ -2342,6 +2651,14 @@ export function renderToCanvas(codeOrMatrix, canvas, options = {}) {
2342
2651
  const { x, y, width: cellWidth, height: cellHeight } = rect;
2343
2652
  const cellSize = Math.min(cellWidth, cellHeight);
2344
2653
 
2654
+ if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
2655
+ // Experimental Triangle16 always renders payload cells with exact,
2656
+ // hard-edged geometry. Decorative per-cell styles would contaminate the
2657
+ // two independent color samples and reduce scan reliability.
2658
+ drawTriangleCellCanvas(ctx, x, y, cellWidth, cellHeight, cell, palette);
2659
+ continue;
2660
+ }
2661
+
2345
2662
  if (style === RENDER_STYLES.CLASSIC || structural) {
2346
2663
  ctx.fillStyle = cellColor(cell, palette);
2347
2664
  ctx.fillRect(x, y, cellWidth, cellHeight);
@@ -2415,6 +2732,7 @@ export function renderToImageData(codeOrMatrix, options = {}) {
2415
2732
  const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
2416
2733
  const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
2417
2734
  const layout = renderLayoutForMatrix(matrix);
2735
+ const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
2418
2736
  const size = matrix.length;
2419
2737
  const data = new Uint8ClampedArray(pixelSize * pixelSize * 4);
2420
2738
  const white = palette.white;
@@ -2431,7 +2749,6 @@ export function renderToImageData(codeOrMatrix, options = {}) {
2431
2749
  for (let r = 0; r < size; r++) {
2432
2750
  for (let c = 0; c < size; c++) {
2433
2751
  const cell = matrix[r][c];
2434
- const rgb = cellRgb(cell, palette);
2435
2752
  const structural = isStructuralRenderCell(layout, r, c, cell);
2436
2753
  const rect = rasterModuleRect(r, c, quietZone, moduleSize);
2437
2754
  const x0 = rect.x;
@@ -2440,6 +2757,13 @@ export function renderToImageData(codeOrMatrix, options = {}) {
2440
2757
  const cellHeight = rect.height;
2441
2758
  const cellSize = Math.min(cellWidth, cellHeight);
2442
2759
 
2760
+ if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
2761
+ fillImageTriangleCell(data, pixelSize, x0, y0, cellWidth, cellHeight, cell, palette);
2762
+ continue;
2763
+ }
2764
+
2765
+ const rgb = cellRgb(cell, palette);
2766
+
2443
2767
  if (style === RENDER_STYLES.CLASSIC || structural) {
2444
2768
  fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
2445
2769
  continue;
@@ -2501,6 +2825,7 @@ export function renderToSVG(codeOrMatrix, options = {}) {
2501
2825
  const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
2502
2826
  const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
2503
2827
  const layout = renderLayoutForMatrix(matrix);
2828
+ const cellEncoding = inferCellEncoding(codeOrMatrix, matrix, layout);
2504
2829
  const size = matrix.length;
2505
2830
  const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
2506
2831
  const body = [svgRect(0, 0, pixelSize, pixelSize, palette.white)];
@@ -2511,7 +2836,12 @@ export function renderToSVG(codeOrMatrix, options = {}) {
2511
2836
  const structural = isStructuralRenderCell(layout, r, c, cell);
2512
2837
  const x = (c + quietZone) * moduleSize;
2513
2838
  const y = (r + quietZone) * moduleSize;
2514
- const fill = cellColor(cell, palette);
2839
+ const fill = cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural ? null : cellColor(cell, palette);
2840
+
2841
+ if (cellEncoding === CELL_ENCODINGS.TRIANGLE16 && !structural) {
2842
+ body.push(svgTriangleCell(x, y, moduleSize, cell, palette));
2843
+ continue;
2844
+ }
2515
2845
 
2516
2846
  if (style === RENDER_STYLES.CLASSIC || structural) {
2517
2847
  body.push(svgRect(x, y, moduleSize, moduleSize, fill));
@@ -2738,6 +3068,60 @@ function classifySampledRgbGrid(rgbGrid, classifier, layout = null) {
2738
3068
  };
2739
3069
  }
2740
3070
 
3071
+ function classifyTriangleSampledRgbGrid(rgbGrid, triangleGrid, classifier, layout) {
3072
+ const size = rgbGrid.length;
3073
+ const matrix = make2D(size, CELL.WHITE);
3074
+ const confidence = make2D(size, 1);
3075
+ const dataClassifier = {
3076
+ ...classifier,
3077
+ entries: classifier.entries.filter(({ cell }) => cell !== CELL.BLACK)
3078
+ };
3079
+ let distanceSum = 0;
3080
+ let confidenceSum = 0;
3081
+ let minimumConfidence = 1;
3082
+ let lowConfidenceCells = 0;
3083
+
3084
+ for (let r = 0; r < size; r++) {
3085
+ for (let c = 0; c < size; c++) {
3086
+ const isDataCell = layout && !layout.reserved[r][c];
3087
+ if (!isDataCell) {
3088
+ const classified = classifyRgb(rgbGrid[r][c], classifier);
3089
+ matrix[r][c] = classified.cell;
3090
+ confidence[r][c] = classified.confidence;
3091
+ distanceSum += classified.distance;
3092
+ confidenceSum += classified.confidence;
3093
+ minimumConfidence = Math.min(minimumConfidence, classified.confidence);
3094
+ if (classified.confidence < 0.4) lowConfidenceCells++;
3095
+ continue;
3096
+ }
3097
+
3098
+ const samples = triangleGrid?.[r]?.[c];
3099
+ if (!samples) throw new Error("Triangle16 sampling grid is incomplete.");
3100
+ const classifyData = (rgb) => classifier.dataMode === "hue"
3101
+ ? classifyDataHue(rgb, classifier)
3102
+ : classifyRgb(rgb, dataClassifier);
3103
+ const first = classifyData(samples.first);
3104
+ const second = classifyData(samples.second);
3105
+ const cellConfidence = Math.min(first.confidence, second.confidence);
3106
+ matrix[r][c] = packTriangleCell(first.cell, second.cell);
3107
+ confidence[r][c] = cellConfidence;
3108
+ distanceSum += (first.distance + second.distance) / 2;
3109
+ confidenceSum += cellConfidence;
3110
+ minimumConfidence = Math.min(minimumConfidence, cellConfidence);
3111
+ if (cellConfidence < 0.4) lowConfidenceCells++;
3112
+ }
3113
+ }
3114
+
3115
+ return {
3116
+ matrix,
3117
+ confidence,
3118
+ averageColorDistance: distanceSum / (size * size),
3119
+ averageCellConfidence: confidenceSum / (size * size),
3120
+ minimumCellConfidence: minimumConfidence,
3121
+ lowConfidenceCells
3122
+ };
3123
+ }
3124
+
2741
3125
  function structuralAccuracy(matrix, layout) {
2742
3126
  let matches = 0;
2743
3127
  let total = 0;
@@ -2777,6 +3161,7 @@ function tryPerspectiveScan(imageData, options) {
2777
3161
  finderAutoColorAnalysisInset: options.finderAutoColorAnalysisInset,
2778
3162
  finderAutoColorMinimumInputRange: options.finderAutoColorMinimumInputRange,
2779
3163
  finderAutoColorTargetSamples: options.finderAutoColorTargetSamples,
3164
+ preciseAlignment: options.preciseAlignment,
2780
3165
  diagnostics: options._visionDiagnostics,
2781
3166
  diagnosticLabel: options._diagnosticLabel ?? "normal"
2782
3167
  });
@@ -2802,9 +3187,18 @@ function tryPerspectiveScan(imageData, options) {
2802
3187
  let bestStructureScore = 0;
2803
3188
  for (const profile of sampleProfiles) {
2804
3189
  let sampled;
3190
+ let triangleSampled = null;
2805
3191
  let observedPalette;
2806
3192
  try {
2807
3193
  sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, profile);
3194
+ if (options.highDensitySampling !== false && options.triangle16 !== false) {
3195
+ triangleSampled = samplePerspectiveTriangleMatrix(
3196
+ imageData,
3197
+ geometry.homography,
3198
+ layout.size,
3199
+ profile
3200
+ );
3201
+ }
2808
3202
  observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, {
2809
3203
  robust: profile.robustCalibration
2810
3204
  });
@@ -2863,11 +3257,67 @@ function tryPerspectiveScan(imageData, options) {
2863
3257
  }
2864
3258
  };
2865
3259
 
3260
+ const tryTriangleAttempt = (attempt, metadata = {}) => {
3261
+ if (!triangleSampled?.triangleGrid) return false;
3262
+ const activeObservedPalette = metadata.observedPalette ?? observedPalette;
3263
+ const activeSamplingMode = metadata.samplingMode ?? `${profile.sampleMode}-triangle16`;
3264
+ const classified = classifyTriangleSampledRgbGrid(
3265
+ sampled.rgbGrid,
3266
+ triangleSampled.triangleGrid,
3267
+ attempt.classifier,
3268
+ layout
3269
+ );
3270
+ const structureScore = structuralAccuracy(classified.matrix, layout);
3271
+ bestStructureScore = Math.max(bestStructureScore, structureScore);
3272
+ pushObservation(options, {
3273
+ version: geometry.version,
3274
+ matrix: classified.matrix,
3275
+ confidence: classified.confidence,
3276
+ geometry,
3277
+ observedPalette: activeObservedPalette,
3278
+ samplingMode: activeSamplingMode,
3279
+ colorNormalization: attempt.colorNormalization,
3280
+ cellEncoding: CELL_ENCODINGS.TRIANGLE16,
3281
+ structureScore,
3282
+ averageCellConfidence: classified.averageCellConfidence,
3283
+ lowConfidenceCells: classified.lowConfidenceCells
3284
+ });
3285
+
3286
+ try {
3287
+ const decoded = decodeMatrix(classified.matrix, {
3288
+ structureTolerance: options.structureTolerance ?? 0.18,
3289
+ cellConfidence: classified.confidence,
3290
+ cellEncodingHint: CELL_ENCODINGS.TRIANGLE16,
3291
+ maxErasureConfidence: options.maxErasureConfidence
3292
+ });
3293
+ if (decoded.version !== geometry.version) return false;
3294
+ results.push({
3295
+ ...decoded,
3296
+ perspectiveCorrected: true,
3297
+ colorCalibrated: true,
3298
+ colorNormalization: attempt.colorNormalization,
3299
+ samplingMode: activeSamplingMode,
3300
+ geometry,
3301
+ observedPalette: activeObservedPalette,
3302
+ averageColorDistance: classified.averageColorDistance,
3303
+ averageCellConfidence: classified.averageCellConfidence,
3304
+ minimumCellConfidence: classified.minimumCellConfidence,
3305
+ lowConfidenceCells: classified.lowConfidenceCells,
3306
+ rectified: options.includeRectified
3307
+ ? rectifyImageData(imageData, geometry.homography, layout.size, options.rectifiedModuleSize ?? 8)
3308
+ : undefined
3309
+ });
3310
+ return true;
3311
+ } catch {
3312
+ return false;
3313
+ }
3314
+ };
3315
+
2866
3316
  // Fast path: preserve the original observed-RGB classifier first, then
2867
3317
  // try per-channel white balancing. Most clean frames stop here without
2868
3318
  // paying for the more expensive spatial normalization fallback.
2869
3319
  for (const attempt of paletteClassifierAttempts(observedPalette)) {
2870
- if (tryAttempt(attempt, sampled.rgbGrid)) {
3320
+ if (tryAttempt(attempt, sampled.rgbGrid) || tryTriangleAttempt(attempt)) {
2871
3321
  geometryDecoded = true;
2872
3322
  break;
2873
3323
  }
@@ -2994,17 +3444,30 @@ function tryPerspectiveScan(imageData, options) {
2994
3444
  [step, -step], [step, step], [-step, -step], [-step, step]
2995
3445
  ];
2996
3446
  const refinementCandidates = [];
3447
+ const triangleRefinementCandidates = [];
2997
3448
 
2998
3449
  for (const [offsetX, offsetY] of refinementOffsets) {
2999
3450
  let sampled;
3451
+ let triangleSampled = null;
3000
3452
  let observedPalette;
3001
3453
  try {
3002
- sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, {
3454
+ const samplingOptions = {
3003
3455
  sampleMode: "cross",
3004
3456
  sampleRadius: 0,
3005
3457
  sampleOffsetX: offsetX,
3006
- sampleOffsetY: offsetY
3007
- });
3458
+ sampleOffsetY: offsetY,
3459
+ triangleSampleInset: options.highDensitySampleInset ?? options.triangleSampleInset,
3460
+ triangleSampleRadius: options.highDensitySampleRadius ?? options.triangleSampleRadius
3461
+ };
3462
+ sampled = samplePerspectiveMatrix(imageData, geometry.homography, layout.size, samplingOptions);
3463
+ if (options.highDensitySampling !== false && options.triangle16 !== false) {
3464
+ triangleSampled = samplePerspectiveTriangleMatrix(
3465
+ imageData,
3466
+ geometry.homography,
3467
+ layout.size,
3468
+ samplingOptions
3469
+ );
3470
+ }
3008
3471
  observedPalette = sampleObservedPalette(sampled.rgbGrid, layout.calibration, { robust: true });
3009
3472
  } catch {
3010
3473
  continue;
@@ -3034,30 +3497,80 @@ function tryPerspectiveScan(imageData, options) {
3034
3497
 
3035
3498
  // Do not spend RS work on a refinement that made the known finder /
3036
3499
  // alignment structure worse. This keeps the recovery path bounded.
3037
- if (structureScore < (options.refinementDecodeThreshold ?? 0.95)) continue;
3038
- refinementCandidates.push({
3039
- attempt,
3040
- classified,
3041
- observedPalette,
3042
- offsetX,
3043
- offsetY,
3044
- structureScore
3045
- });
3500
+ if (structureScore >= (options.refinementDecodeThreshold ?? 0.95)) {
3501
+ refinementCandidates.push({
3502
+ attempt,
3503
+ classified,
3504
+ observedPalette,
3505
+ offsetX,
3506
+ offsetY,
3507
+ structureScore,
3508
+ cellEncoding: CELL_ENCODINGS.RGBW,
3509
+ samplingMode: "refined-center"
3510
+ });
3511
+ }
3512
+
3513
+ if (triangleSampled?.triangleGrid) {
3514
+ const triangleClassified = classifyTriangleSampledRgbGrid(
3515
+ sampled.rgbGrid,
3516
+ triangleSampled.triangleGrid,
3517
+ attempt.classifier,
3518
+ layout
3519
+ );
3520
+ const triangleStructureScore = structuralAccuracy(triangleClassified.matrix, layout);
3521
+ bestStructureScore = Math.max(bestStructureScore, triangleStructureScore);
3522
+ pushObservation(options, {
3523
+ version: geometry.version,
3524
+ matrix: triangleClassified.matrix,
3525
+ confidence: triangleClassified.confidence,
3526
+ geometry,
3527
+ observedPalette,
3528
+ samplingMode: "refined-triangle16",
3529
+ samplingOffset: { x: offsetX, y: offsetY },
3530
+ colorNormalization: attempt.colorNormalization,
3531
+ cellEncoding: CELL_ENCODINGS.TRIANGLE16,
3532
+ structureScore: triangleStructureScore,
3533
+ averageCellConfidence: triangleClassified.averageCellConfidence,
3534
+ lowConfidenceCells: triangleClassified.lowConfidenceCells
3535
+ });
3536
+ if (triangleStructureScore >= (options.refinementDecodeThreshold ?? 0.95)) {
3537
+ triangleRefinementCandidates.push({
3538
+ attempt,
3539
+ classified: triangleClassified,
3540
+ observedPalette,
3541
+ offsetX,
3542
+ offsetY,
3543
+ structureScore: triangleStructureScore,
3544
+ cellEncoding: CELL_ENCODINGS.TRIANGLE16,
3545
+ samplingMode: "refined-triangle16"
3546
+ });
3547
+ }
3548
+ }
3046
3549
  }
3047
3550
  }
3048
3551
 
3049
- refinementCandidates.sort((a, b) =>
3552
+ const sortRefinementCandidates = (items) => items.sort((a, b) =>
3050
3553
  (b.structureScore - a.structureScore) ||
3051
3554
  (b.classified.averageCellConfidence - a.classified.averageCellConfidence) ||
3052
3555
  (a.classified.averageColorDistance - b.classified.averageColorDistance)
3053
3556
  );
3557
+ sortRefinementCandidates(refinementCandidates);
3558
+ sortRefinementCandidates(triangleRefinementCandidates);
3054
3559
 
3055
3560
  const decodeLimit = Math.max(1, Math.min(8, Math.round(options.refinementDecodeCandidates ?? 4)));
3056
- for (const candidate of refinementCandidates.slice(0, decodeLimit)) {
3561
+ // Keep separate per-encoding limits. A Triangle16 image can produce very
3562
+ // confident but meaningless centre samples, and those must not crowd out
3563
+ // the dual-region candidates during the final bounded recovery pass.
3564
+ const decodeCandidates = [
3565
+ ...refinementCandidates.slice(0, decodeLimit),
3566
+ ...triangleRefinementCandidates.slice(0, decodeLimit)
3567
+ ];
3568
+ for (const candidate of decodeCandidates) {
3057
3569
  try {
3058
3570
  const decoded = decodeMatrix(candidate.classified.matrix, {
3059
3571
  structureTolerance: options.structureTolerance ?? 0.18,
3060
3572
  cellConfidence: candidate.classified.confidence,
3573
+ cellEncodingHint: candidate.cellEncoding,
3061
3574
  maxErasureConfidence: options.maxErasureConfidence
3062
3575
  });
3063
3576
  if (decoded.version !== geometry.version) continue;
@@ -3068,7 +3581,7 @@ function tryPerspectiveScan(imageData, options) {
3068
3581
  samplingOffset: { x: candidate.offsetX, y: candidate.offsetY },
3069
3582
  colorCalibrated: true,
3070
3583
  colorNormalization: candidate.attempt.colorNormalization,
3071
- samplingMode: "refined-center",
3584
+ samplingMode: candidate.samplingMode,
3072
3585
  geometry,
3073
3586
  observedPalette: candidate.observedPalette,
3074
3587
  averageColorDistance: candidate.classified.averageColorDistance,
@@ -3118,6 +3631,15 @@ function tryAxisAlignedScan(imageData, options) {
3118
3631
  for (let profileIndex = 0; profileIndex < radiusProfiles.length && !accepted; profileIndex++) {
3119
3632
  try {
3120
3633
  const sampled = sampleAxisAlignedGrid(imageData, bounds, size, radiusProfiles[profileIndex]);
3634
+ const triangleSampled = options.highDensitySampling === false || options.triangle16 === false
3635
+ ? null
3636
+ : sampleAxisAlignedTriangleGrid(
3637
+ imageData,
3638
+ bounds,
3639
+ size,
3640
+ Math.min(0.10, Math.max(0.04, radiusProfiles[profileIndex] * 0.48)),
3641
+ options.highDensitySampleInset ?? options.triangleSampleInset ?? 0.28
3642
+ );
3121
3643
  const layout = createLayout(version);
3122
3644
  const classifierAttempts = [];
3123
3645
  try {
@@ -3157,45 +3679,70 @@ function tryAxisAlignedScan(imageData, options) {
3157
3679
  });
3158
3680
 
3159
3681
  for (const attempt of classifierAttempts) {
3160
- try {
3161
- const classified = classifySampledRgbGrid(attempt.rgbGrid ?? sampled.rgbGrid, attempt.classifier, layout);
3162
- const structureScore = structuralAccuracy(classified.matrix, layout);
3163
- pushObservation(options, {
3164
- version,
3165
- matrix: classified.matrix,
3166
- confidence: classified.confidence,
3167
- bounds,
3168
- samplingMode: profileIndex === 0 ? "axis" : "axis-center",
3169
- colorNormalization: attempt.colorNormalization,
3170
- structureScore,
3171
- averageCellConfidence: classified.averageCellConfidence,
3172
- lowConfidenceCells: classified.lowConfidenceCells
3173
- });
3174
- const decoded = decodeMatrix(classified.matrix, {
3175
- structureTolerance: options.structureTolerance ?? 0.12,
3176
- cellConfidence: classified.confidence,
3177
- maxErasureConfidence: options.maxErasureConfidence
3178
- });
3179
- candidates.push({
3180
- ...decoded,
3181
- bounds,
3182
- sampledVersion: version,
3183
- moduleWidth: moduleW,
3184
- moduleHeight: moduleH,
3185
- perspectiveCorrected: false,
3186
- colorCalibrated: attempt.calibrated,
3187
- colorNormalization: attempt.colorNormalization,
3188
- samplingMode: profileIndex === 0 ? "axis" : "axis-center",
3189
- averageColorDistance: classified.averageColorDistance,
3190
- averageCellConfidence: classified.averageCellConfidence,
3191
- minimumCellConfidence: classified.minimumCellConfidence,
3192
- lowConfidenceCells: classified.lowConfidenceCells
3682
+ const centerGrid = attempt.rgbGrid ?? sampled.rgbGrid;
3683
+ const samplingMode = profileIndex === 0 ? "axis" : "axis-center";
3684
+ const classifiedAttempts = [{
3685
+ classified: classifySampledRgbGrid(centerGrid, attempt.classifier, layout),
3686
+ cellEncoding: CELL_ENCODINGS.RGBW,
3687
+ samplingMode
3688
+ }];
3689
+ if (triangleSampled?.triangleGrid && attempt.rgbGrid === sampled.rgbGrid) {
3690
+ classifiedAttempts.push({
3691
+ classified: classifyTriangleSampledRgbGrid(
3692
+ centerGrid,
3693
+ triangleSampled.triangleGrid,
3694
+ attempt.classifier,
3695
+ layout
3696
+ ),
3697
+ cellEncoding: CELL_ENCODINGS.TRIANGLE16,
3698
+ samplingMode: `${samplingMode}-triangle16`
3193
3699
  });
3194
- accepted = true;
3195
- break;
3196
- } catch {
3197
- // Try next classifier/profile.
3198
3700
  }
3701
+
3702
+ for (const classifiedAttempt of classifiedAttempts) {
3703
+ try {
3704
+ const classified = classifiedAttempt.classified;
3705
+ const structureScore = structuralAccuracy(classified.matrix, layout);
3706
+ pushObservation(options, {
3707
+ version,
3708
+ matrix: classified.matrix,
3709
+ confidence: classified.confidence,
3710
+ bounds,
3711
+ samplingMode: classifiedAttempt.samplingMode,
3712
+ colorNormalization: attempt.colorNormalization,
3713
+ cellEncoding: classifiedAttempt.cellEncoding,
3714
+ structureScore,
3715
+ averageCellConfidence: classified.averageCellConfidence,
3716
+ lowConfidenceCells: classified.lowConfidenceCells
3717
+ });
3718
+ const decoded = decodeMatrix(classified.matrix, {
3719
+ structureTolerance: options.structureTolerance ?? 0.12,
3720
+ cellConfidence: classified.confidence,
3721
+ cellEncodingHint: classifiedAttempt.cellEncoding,
3722
+ maxErasureConfidence: options.maxErasureConfidence
3723
+ });
3724
+ candidates.push({
3725
+ ...decoded,
3726
+ bounds,
3727
+ sampledVersion: version,
3728
+ moduleWidth: moduleW,
3729
+ moduleHeight: moduleH,
3730
+ perspectiveCorrected: false,
3731
+ colorCalibrated: attempt.calibrated,
3732
+ colorNormalization: attempt.colorNormalization,
3733
+ samplingMode: classifiedAttempt.samplingMode,
3734
+ averageColorDistance: classified.averageColorDistance,
3735
+ averageCellConfidence: classified.averageCellConfidence,
3736
+ minimumCellConfidence: classified.minimumCellConfidence,
3737
+ lowConfidenceCells: classified.lowConfidenceCells
3738
+ });
3739
+ accepted = true;
3740
+ break;
3741
+ } catch {
3742
+ // Try the next cell encoding/classifier/profile.
3743
+ }
3744
+ }
3745
+ if (accepted) break;
3199
3746
  }
3200
3747
  } catch {
3201
3748
  // Try a narrower centre sample, then next version.
@@ -3307,7 +3854,7 @@ function perspectiveStress(imageData, severity) {
3307
3854
  { x: width - 1 - inset * 1.0, y: height - 1 - inset * 0.35 },
3308
3855
  { x: inset * 0.20, y: height - 1 - inset * 0.95 }
3309
3856
  ];
3310
- const inverse = computeHomography(destination, source);
3857
+ const inverse = computeHomography(source, destination);
3311
3858
  const out = new Uint8ClampedArray(width * height * 4);
3312
3859
  out.fill(255);
3313
3860
  for (let y = 0; y < height; y++) {
@@ -3592,6 +4139,36 @@ export function scanImageData(imageData, options = {}) {
3592
4139
  if (perspective) return decorateScanResult(perspective, debugContext, options);
3593
4140
  }
3594
4141
 
4142
+ // Triangle16 has half-cell color regions, so geometry that is adequate for
4143
+ // solid RGBW cells can still be off by a few tenths of a module and mix the
4144
+ // two triangle colors. If normal geometry was found but no payload decoded,
4145
+ // retry geometry once with finer alignment localization. This stays off the
4146
+ // clean/normal camera fast path.
4147
+ if (
4148
+ options.perspective !== false &&
4149
+ options.preciseAlignmentRecovery !== false &&
4150
+ !options._preciseAlignmentRecovery &&
4151
+ geometryCollector.length > 0
4152
+ ) {
4153
+ try {
4154
+ const precise = tryPerspectiveScan(imageData, {
4155
+ ...scanOptions,
4156
+ preciseAlignment: true,
4157
+ _preciseAlignmentRecovery: true,
4158
+ _diagnosticLabel: "precise-alignment"
4159
+ });
4160
+ if (precise) {
4161
+ return decorateScanResult({
4162
+ ...precise,
4163
+ geometryRefined: true,
4164
+ recoveryMode: precise.recoveryMode ?? "precise-alignment"
4165
+ }, debugContext, options);
4166
+ }
4167
+ } catch {
4168
+ // Continue to color recovery and axis-aligned fallback.
4169
+ }
4170
+ }
4171
+
3595
4172
  // If the normal scanner could see QuadQR geometry but could not decode the
3596
4173
  // colors, retry the same frame after a single global Auto Tone / Contrast /
3597
4174
  // Color-style pass. This is deliberately after the normal path so clean
@@ -3995,6 +4572,7 @@ export async function startCameraScanner(video, options = {}) {
3995
4572
 
3996
4573
  const scanInterval = Math.max(80, options.scanInterval ?? 180);
3997
4574
  const scratchCanvas = document.createElement("canvas");
4575
+ const highResolutionCanvas = document.createElement("canvas");
3998
4576
  const multiFrameEnabled = options.multiFrame !== false;
3999
4577
  const multiFrameWindow = Math.max(2, Math.min(8, Math.round(options.multiFrameWindow ?? 4)));
4000
4578
  const multiFrameMinFrames = Math.max(2, Math.min(multiFrameWindow, Math.round(options.multiFrameMinFrames ?? 2)));
@@ -4002,6 +4580,12 @@ export async function startCameraScanner(video, options = {}) {
4002
4580
  const cameraAutoColorEvery = Math.max(1, Math.round(options.cameraAutoColorEvery ?? 1));
4003
4581
  const cameraAutoEnhanceEvery = Math.max(1, Math.round(options.cameraAutoEnhanceEvery ?? 2));
4004
4582
  const cameraFinderRecoveryEvery = Math.max(1, Math.round(options.cameraFinderRecoveryEvery ?? 2));
4583
+ const cameraHighResolutionEvery = Math.max(1, Math.round(options.cameraHighResolutionEvery ?? 2));
4584
+ const baseCameraMaxDimension = Math.max(480, Math.round(options.maxDimension ?? 1080));
4585
+ const cameraHighResolutionMaxDimension = Math.max(
4586
+ baseCameraMaxDimension,
4587
+ Math.round(options.cameraHighResolutionMaxDimension ?? 1600)
4588
+ );
4005
4589
  let missStreak = 0;
4006
4590
  let stopped = false;
4007
4591
  let busy = false;
@@ -4049,7 +4633,7 @@ export async function startCameraScanner(video, options = {}) {
4049
4633
 
4050
4634
  const scanNow = () => scanVideoFrame(video, {
4051
4635
  ...options,
4052
- maxDimension: options.maxDimension ?? 1080,
4636
+ maxDimension: baseCameraMaxDimension,
4053
4637
  canvas: scratchCanvas
4054
4638
  });
4055
4639
 
@@ -4116,7 +4700,7 @@ export async function startCameraScanner(video, options = {}) {
4116
4700
  busy = true;
4117
4701
  frameNumber++;
4118
4702
  const observations = [];
4119
- const frameDiagnostics = diagnosticsEnabled ? {} : null;
4703
+ const frameDiagnostics = {};
4120
4704
  const capturedFrame = {};
4121
4705
  const frameStarted = nowMs();
4122
4706
  let allowAutoEnhance = false;
@@ -4144,7 +4728,7 @@ export async function startCameraScanner(video, options = {}) {
4144
4728
  // If geometry exists, the rectified QR-only pixel enhancer is both
4145
4729
  // stronger and much cheaper than reprocessing the whole live frame.
4146
4730
  fullFrameAutoEnhanceRecovery: options.fullFrameAutoEnhanceRecovery ?? false,
4147
- maxDimension: options.maxDimension ?? 1080,
4731
+ maxDimension: baseCameraMaxDimension,
4148
4732
  canvas: scratchCanvas,
4149
4733
  _capturedFrame: capturedFrame,
4150
4734
  _observationCollector: observations,
@@ -4185,6 +4769,71 @@ export async function startCameraScanner(video, options = {}) {
4185
4769
  ...frameDiagnostics
4186
4770
  });
4187
4771
 
4772
+ // Geometry-aware high-resolution retry. A dense QuadQR can look large
4773
+ // enough in the preview while each individual module has become too
4774
+ // small after the normal 1080 px scanner cap. If the fast pass already
4775
+ // sees at least two convincing finders, spend one bounded retry on a
4776
+ // higher-resolution copy of the visible camera ROI. Empty frames and
4777
+ // ordinary small codes never pay this cost.
4778
+ const shouldTryHighResolution = options.cameraHighResolutionRecovery !== false &&
4779
+ cameraHighResolutionMaxDimension > baseCameraMaxDimension &&
4780
+ (frameDiagnostics?.finderCount ?? 0) >= (options.cameraHighResolutionMinFinders ?? 2) &&
4781
+ ((missStreak - 1) % cameraHighResolutionEvery === 0);
4782
+ if (shouldTryHighResolution) {
4783
+ const highResolutionObservations = [];
4784
+ const highResolutionDiagnostics = {};
4785
+ const highResolutionCapturedFrame = {};
4786
+ emitDiagnostic({
4787
+ type: "method",
4788
+ state: "trying",
4789
+ method: "high-resolution-geometry-recovery",
4790
+ message: `Dense geometry detected · retrying at up to ${cameraHighResolutionMaxDimension}px`,
4791
+ ...frameDiagnostics
4792
+ });
4793
+ try {
4794
+ const recoveryStarted = nowMs();
4795
+ const recovered = scanVideoFrame(video, {
4796
+ ...options,
4797
+ _diagnosticLabel: "high-resolution-geometry-recovery",
4798
+ finderRecovery: true,
4799
+ autoEnhanceRecovery: false,
4800
+ fullFrameAutoEnhanceRecovery: false,
4801
+ maxDimension: cameraHighResolutionMaxDimension,
4802
+ canvas: highResolutionCanvas,
4803
+ _capturedFrame: highResolutionCapturedFrame,
4804
+ _observationCollector: highResolutionObservations,
4805
+ _frameDiagnostics: highResolutionDiagnostics
4806
+ });
4807
+ const recoveryElapsedMs = nowMs() - recoveryStarted;
4808
+ emitDiagnostic({
4809
+ type: "success",
4810
+ state: "decoded",
4811
+ method: "high-resolution-geometry-recovery",
4812
+ elapsedMs: recoveryElapsedMs,
4813
+ message: `High-resolution retry decoded v${recovered.version} · ${Math.round(recoveryElapsedMs)} ms`,
4814
+ ...(highResolutionDiagnostics ?? frameDiagnostics)
4815
+ });
4816
+ missStreak = 0;
4817
+ observationHistory.clear();
4818
+ if (emitResult({
4819
+ ...recovered,
4820
+ cameraHighResolutionRecovery: true,
4821
+ cameraProgressiveRecovery: true,
4822
+ recoveryMode: recovered.recoveryMode ?? "high-resolution-geometry-recovery"
4823
+ }, highResolutionCapturedFrame, highResolutionDiagnostics ?? frameDiagnostics)) return;
4824
+ } catch (highResolutionError) {
4825
+ observations.push(...highResolutionObservations);
4826
+ emitDiagnostic({
4827
+ type: "method",
4828
+ state: "failed",
4829
+ method: "high-resolution-geometry-recovery",
4830
+ message: `High-resolution geometry retry did not decode${highResolutionDiagnostics?.finderCount != null ? ` · ${highResolutionDiagnostics.finderCount} finder(s)` : ""}`,
4831
+ error: highResolutionError?.message ?? String(highResolutionError),
4832
+ ...(highResolutionDiagnostics ?? frameDiagnostics)
4833
+ });
4834
+ }
4835
+ }
4836
+
4188
4837
  // The user's real phone-camera case is dominated by color cast before
4189
4838
  // finder detection: Photoshop Auto Color alone makes the same live QR
4190
4839
  // immediately detectable. Retry the exact captured frame with a cheap
@@ -4489,6 +5138,9 @@ export const internals = Object.freeze({
4489
5138
  HEADER_CODEWORD_CELLS,
4490
5139
  COMPACT_HEADER_CODEWORD_CELLS,
4491
5140
  CELLS_PER_BYTE,
5141
+ RGBW_CELLS_PER_BYTE,
5142
+ TRIANGLE16_CELLS_PER_BYTE,
5143
+ TRIANGLE16_FLAG,
4492
5144
  TEXT_FLAG,
4493
5145
  SECURE_FLAG
4494
5146
  });