quadqr-js 1.3.0 → 1.5.2

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.
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Small synchronous RFC 1951 raw-DEFLATE codec used by QuadQR Compression 3.0.
3
+ *
4
+ * The compressor intentionally emits fixed-Huffman blocks only. This keeps the
5
+ * implementation compact, deterministic, dependency-free, and usable in both
6
+ * browsers and Node.js while still allowing DEFLATE's 32 KiB window and
7
+ * 258-byte matches to compress repetitive payloads far better than the legacy
8
+ * QuadQR LZSS stream.
9
+ *
10
+ * The decoder accepts stored and fixed-Huffman blocks produced by this module.
11
+ * Dynamic-Huffman input is intentionally rejected because QuadQR's envelope
12
+ * only needs to decode streams created by its own compressor.
13
+ */
14
+
15
+ function assert(condition, message) {
16
+ if (!condition) throw new Error(message);
17
+ }
18
+
19
+ function asBytes(input) {
20
+ if (input instanceof Uint8Array) return input;
21
+ if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
22
+ if (input instanceof ArrayBuffer) return new Uint8Array(input);
23
+ return new Uint8Array(input);
24
+ }
25
+
26
+ export const DEFLATE_LEVEL_MIN = 1;
27
+ export const DEFLATE_LEVEL_MAX = 9;
28
+ export const DEFAULT_DEFLATE_LEVEL = 6;
29
+
30
+ function normalizeDeflateLevel(value = DEFAULT_DEFLATE_LEVEL) {
31
+ const level = Number(value);
32
+ assert(Number.isInteger(level), `DEFLATE level must be an integer ${DEFLATE_LEVEL_MIN}..${DEFLATE_LEVEL_MAX}.`);
33
+ assert(
34
+ level >= DEFLATE_LEVEL_MIN && level <= DEFLATE_LEVEL_MAX,
35
+ `DEFLATE level must be ${DEFLATE_LEVEL_MIN}..${DEFLATE_LEVEL_MAX}.`
36
+ );
37
+ return level;
38
+ }
39
+
40
+ function deflateTuning(level) {
41
+ // Higher levels spend progressively more CPU walking candidate chains and
42
+ // performing lazy-match lookahead. The RFC 1951 stream stays compatible
43
+ // regardless of level, so the decoder never needs this value.
44
+ const candidateDepth = [0, 8, 16, 28, 48, 72, 96, 144, 224, 320][level];
45
+ const lazyDepth = [0, 0, 0, 8, 12, 18, 24, 40, 64, 96][level];
46
+ const lazyGain = level <= 3 ? 3 : level <= 6 ? 2 : 1;
47
+ return { candidateDepth, lazyDepth, lazyGain };
48
+ }
49
+
50
+ function reverseBits(value, width) {
51
+ let out = 0;
52
+ for (let i = 0; i < width; i++) {
53
+ out = (out << 1) | ((value >>> i) & 1);
54
+ }
55
+ return out >>> 0;
56
+ }
57
+
58
+ class BitWriter {
59
+ constructor() {
60
+ this.bytes = [];
61
+ this.bitBuffer = 0;
62
+ this.bitCount = 0;
63
+ }
64
+
65
+ writeBits(value, count) {
66
+ let v = value >>> 0;
67
+ for (let i = 0; i < count; i++) {
68
+ this.bitBuffer |= ((v >>> i) & 1) << this.bitCount;
69
+ this.bitCount++;
70
+ if (this.bitCount === 8) {
71
+ this.bytes.push(this.bitBuffer & 0xff);
72
+ this.bitBuffer = 0;
73
+ this.bitCount = 0;
74
+ }
75
+ }
76
+ }
77
+
78
+ finish() {
79
+ if (this.bitCount) this.bytes.push(this.bitBuffer & 0xff);
80
+ return Uint8Array.from(this.bytes);
81
+ }
82
+ }
83
+
84
+ class BitReader {
85
+ constructor(bytes) {
86
+ this.bytes = asBytes(bytes);
87
+ this.bytePos = 0;
88
+ this.bitPos = 0;
89
+ }
90
+
91
+ readBits(count) {
92
+ let value = 0;
93
+ for (let i = 0; i < count; i++) {
94
+ assert(this.bytePos < this.bytes.length, "DEFLATE stream is truncated.");
95
+ const bit = (this.bytes[this.bytePos] >>> this.bitPos) & 1;
96
+ value |= bit << i;
97
+ this.bitPos++;
98
+ if (this.bitPos === 8) {
99
+ this.bitPos = 0;
100
+ this.bytePos++;
101
+ }
102
+ }
103
+ return value >>> 0;
104
+ }
105
+
106
+ alignByte() {
107
+ if (this.bitPos) {
108
+ this.bitPos = 0;
109
+ this.bytePos++;
110
+ }
111
+ }
112
+ }
113
+
114
+ function fixedLiteralCode(symbol) {
115
+ if (symbol <= 143) return { bits: reverseBits(0x30 + symbol, 8), width: 8 };
116
+ if (symbol <= 255) return { bits: reverseBits(0x190 + (symbol - 144), 9), width: 9 };
117
+ if (symbol <= 279) return { bits: reverseBits(symbol - 256, 7), width: 7 };
118
+ assert(symbol <= 287, "Invalid fixed-Huffman literal/length symbol.");
119
+ return { bits: reverseBits(0xc0 + (symbol - 280), 8), width: 8 };
120
+ }
121
+
122
+ const LENGTH_BASE = new Uint16Array([
123
+ 3, 4, 5, 6, 7, 8, 9, 10,
124
+ 11, 13, 15, 17,
125
+ 19, 23, 27, 31,
126
+ 35, 43, 51, 59,
127
+ 67, 83, 99, 115,
128
+ 131, 163, 195, 227,
129
+ 258
130
+ ]);
131
+ const LENGTH_EXTRA = new Uint8Array([
132
+ 0, 0, 0, 0, 0, 0, 0, 0,
133
+ 1, 1, 1, 1,
134
+ 2, 2, 2, 2,
135
+ 3, 3, 3, 3,
136
+ 4, 4, 4, 4,
137
+ 5, 5, 5, 5,
138
+ 0
139
+ ]);
140
+
141
+ const DIST_BASE = new Uint16Array([
142
+ 1, 2, 3, 4,
143
+ 5, 7,
144
+ 9, 13,
145
+ 17, 25,
146
+ 33, 49,
147
+ 65, 97,
148
+ 129, 193,
149
+ 257, 385,
150
+ 513, 769,
151
+ 1025, 1537,
152
+ 2049, 3073,
153
+ 4097, 6145,
154
+ 8193, 12289,
155
+ 16385, 24577
156
+ ]);
157
+ const DIST_EXTRA = new Uint8Array([
158
+ 0, 0, 0, 0,
159
+ 1, 1,
160
+ 2, 2,
161
+ 3, 3,
162
+ 4, 4,
163
+ 5, 5,
164
+ 6, 6,
165
+ 7, 7,
166
+ 8, 8,
167
+ 9, 9,
168
+ 10, 10,
169
+ 11, 11,
170
+ 12, 12,
171
+ 13, 13
172
+ ]);
173
+
174
+ function lengthSymbol(length) {
175
+ assert(length >= 3 && length <= 258, "DEFLATE match length must be 3..258.");
176
+ if (length === 258) return { symbol: 285, extraBits: 0, extraValue: 0 };
177
+ for (let i = 0; i < 28; i++) {
178
+ const base = LENGTH_BASE[i];
179
+ const extraBits = LENGTH_EXTRA[i];
180
+ const max = base + ((1 << extraBits) - 1);
181
+ if (length <= max) return { symbol: 257 + i, extraBits, extraValue: length - base };
182
+ }
183
+ throw new Error("Unable to encode DEFLATE match length.");
184
+ }
185
+
186
+ function distanceSymbol(distance) {
187
+ assert(distance >= 1 && distance <= 32768, "DEFLATE match distance must be 1..32768.");
188
+ for (let i = 0; i < DIST_BASE.length; i++) {
189
+ const base = DIST_BASE[i];
190
+ const extraBits = DIST_EXTRA[i];
191
+ const max = base + ((1 << extraBits) - 1);
192
+ if (distance <= max) return { symbol: i, extraBits, extraValue: distance - base };
193
+ }
194
+ throw new Error("Unable to encode DEFLATE match distance.");
195
+ }
196
+
197
+ function hash3(bytes, i) {
198
+ if (i + 2 >= bytes.length) return -1;
199
+ return (((bytes[i] * 251 + bytes[i + 1]) * 251 + bytes[i + 2]) >>> 0) & 0xffff;
200
+ }
201
+
202
+ function emitLiteral(writer, value) {
203
+ const code = fixedLiteralCode(value);
204
+ writer.writeBits(code.bits, code.width);
205
+ }
206
+
207
+ function emitMatch(writer, length, distance) {
208
+ const len = lengthSymbol(length);
209
+ const lenCode = fixedLiteralCode(len.symbol);
210
+ writer.writeBits(lenCode.bits, lenCode.width);
211
+ if (len.extraBits) writer.writeBits(len.extraValue, len.extraBits);
212
+
213
+ const dist = distanceSymbol(distance);
214
+ writer.writeBits(reverseBits(dist.symbol, 5), 5);
215
+ if (dist.extraBits) writer.writeBits(dist.extraValue, dist.extraBits);
216
+ }
217
+
218
+ /**
219
+ * Compress bytes as a raw RFC 1951 DEFLATE stream using one final
220
+ * fixed-Huffman block. The function is synchronous and runtime-neutral.
221
+ */
222
+ export function compressDeflatePayload(input, options = {}) {
223
+ const bytes = asBytes(input);
224
+ const level = normalizeDeflateLevel(
225
+ typeof options === "number" ? options : (options.level ?? options.compressionLevel ?? DEFAULT_DEFLATE_LEVEL)
226
+ );
227
+ const { candidateDepth, lazyDepth, lazyGain } = deflateTuning(level);
228
+ const writer = new BitWriter();
229
+
230
+ // BFINAL=1, BTYPE=01 (fixed Huffman). Bits are written LSB-first.
231
+ writer.writeBits(1, 1);
232
+ writer.writeBits(1, 2);
233
+
234
+ const recent = new Map();
235
+ const WINDOW = 32768;
236
+ const MAX_MATCH = 258;
237
+ const HISTORY_LIMIT = Math.max(candidateDepth, lazyDepth, 8);
238
+
239
+ const remember = (position) => {
240
+ const hash = hash3(bytes, position);
241
+ if (hash < 0) return;
242
+ let list = recent.get(hash);
243
+ if (!list) recent.set(hash, list = []);
244
+ list.push(position);
245
+ while (list.length > HISTORY_LIMIT) list.shift();
246
+ const minimum = position - WINDOW;
247
+ while (list.length && list[0] < minimum) list.shift();
248
+ };
249
+
250
+ const findBest = (position, maxCandidates) => {
251
+ let bestLength = 0;
252
+ let bestDistance = 0;
253
+ const hash = hash3(bytes, position);
254
+ const candidates = hash >= 0 ? (recent.get(hash) ?? []) : [];
255
+ for (let ci = candidates.length - 1, checked = 0; ci >= 0 && checked < maxCandidates; ci--, checked++) {
256
+ const candidate = candidates[ci];
257
+ const distance = position - candidate;
258
+ if (distance <= 0 || distance > WINDOW) continue;
259
+ let length = 0;
260
+ const limit = Math.min(MAX_MATCH, bytes.length - position);
261
+ while (length < limit && bytes[candidate + (length % distance)] === bytes[position + length]) length++;
262
+ if (length >= 3 && length > bestLength) {
263
+ bestLength = length;
264
+ bestDistance = distance;
265
+ if (length === limit) break;
266
+ }
267
+ }
268
+ return { length: bestLength, distance: bestDistance };
269
+ };
270
+
271
+ let pos = 0;
272
+ while (pos < bytes.length) {
273
+ let best = findBest(pos, candidateDepth);
274
+
275
+ // Levels 3+ may defer a match when the next byte starts a meaningfully
276
+ // longer one. Stronger levels search deeper and accept a smaller gain.
277
+ if (best.length >= 3 && lazyDepth > 0 && pos + 1 < bytes.length) {
278
+ const next = findBest(pos + 1, lazyDepth);
279
+ if (next.length > best.length + lazyGain) best = { length: 0, distance: 0 };
280
+ }
281
+
282
+ if (best.length >= 3) {
283
+ emitMatch(writer, best.length, best.distance);
284
+ for (let i = 0; i < best.length; i++) remember(pos + i);
285
+ pos += best.length;
286
+ } else {
287
+ emitLiteral(writer, bytes[pos]);
288
+ remember(pos);
289
+ pos++;
290
+ }
291
+ }
292
+
293
+ // End-of-block symbol.
294
+ const end = fixedLiteralCode(256);
295
+ writer.writeBits(end.bits, end.width);
296
+ return writer.finish();
297
+ }
298
+
299
+ const FIXED_DECODE = (() => {
300
+ const byLength = new Map();
301
+ for (let symbol = 0; symbol <= 287; symbol++) {
302
+ const { bits, width } = fixedLiteralCode(symbol);
303
+ let map = byLength.get(width);
304
+ if (!map) byLength.set(width, map = new Map());
305
+ map.set(bits, symbol);
306
+ }
307
+ return byLength;
308
+ })();
309
+
310
+ function readFixedSymbol(reader) {
311
+ let code = 0;
312
+ for (let width = 1; width <= 9; width++) {
313
+ code |= reader.readBits(1) << (width - 1);
314
+ const symbol = FIXED_DECODE.get(width)?.get(code);
315
+ if (symbol != null) return symbol;
316
+ }
317
+ throw new Error("Invalid fixed-Huffman code in DEFLATE stream.");
318
+ }
319
+
320
+ function readStoredBlock(reader, out) {
321
+ reader.alignByte();
322
+ assert(reader.bytePos + 4 <= reader.bytes.length, "Stored DEFLATE block is truncated.");
323
+ const len = reader.bytes[reader.bytePos] | (reader.bytes[reader.bytePos + 1] << 8);
324
+ const nlen = reader.bytes[reader.bytePos + 2] | (reader.bytes[reader.bytePos + 3] << 8);
325
+ reader.bytePos += 4;
326
+ assert(((len ^ 0xffff) & 0xffff) === nlen, "Stored DEFLATE block length checksum is invalid.");
327
+ assert(reader.bytePos + len <= reader.bytes.length, "Stored DEFLATE block payload is truncated.");
328
+ for (let i = 0; i < len; i++) out.push(reader.bytes[reader.bytePos++]);
329
+ }
330
+
331
+ /** Decode raw DEFLATE streams produced by compressDeflatePayload(). */
332
+ export function decompressDeflatePayload(input, expectedLength = null) {
333
+ const reader = new BitReader(input);
334
+ const out = [];
335
+ let finalBlock = false;
336
+
337
+ while (!finalBlock) {
338
+ finalBlock = reader.readBits(1) === 1;
339
+ const blockType = reader.readBits(2);
340
+ if (blockType === 0) {
341
+ readStoredBlock(reader, out);
342
+ continue;
343
+ }
344
+ assert(blockType === 1, "QuadQR DEFLATE decoder supports stored and fixed-Huffman blocks only.");
345
+
346
+ while (true) {
347
+ const symbol = readFixedSymbol(reader);
348
+ if (symbol < 256) {
349
+ out.push(symbol);
350
+ continue;
351
+ }
352
+ if (symbol === 256) break;
353
+ assert(symbol >= 257 && symbol <= 285, "Invalid DEFLATE length symbol.");
354
+
355
+ const lengthIndex = symbol - 257;
356
+ const lengthBase = LENGTH_BASE[lengthIndex];
357
+ const lengthExtra = LENGTH_EXTRA[lengthIndex];
358
+ const length = lengthBase + (lengthExtra ? reader.readBits(lengthExtra) : 0);
359
+
360
+ const reversedDistanceCode = reader.readBits(5);
361
+ let distanceCode = -1;
362
+ for (let code = 0; code < 30; code++) {
363
+ if (reverseBits(code, 5) === reversedDistanceCode) {
364
+ distanceCode = code;
365
+ break;
366
+ }
367
+ }
368
+ assert(distanceCode >= 0, "Invalid DEFLATE distance symbol.");
369
+ const distanceBase = DIST_BASE[distanceCode];
370
+ const distanceExtra = DIST_EXTRA[distanceCode];
371
+ const distance = distanceBase + (distanceExtra ? reader.readBits(distanceExtra) : 0);
372
+ assert(distance > 0 && distance <= out.length, "DEFLATE stream contains an invalid back-reference.");
373
+
374
+ for (let i = 0; i < length; i++) out.push(out[out.length - distance]);
375
+ if (expectedLength != null) assert(out.length <= expectedLength, "DEFLATE payload expands beyond the expected length.");
376
+ }
377
+ }
378
+
379
+ if (expectedLength != null) assert(out.length === expectedLength, "DEFLATE payload length mismatch.");
380
+ return Uint8Array.from(out);
381
+ }
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Versions 2..40 use the same alignment-pattern center schedule as standard
5
5
  * QR Code matrices. QuadQR remains its own symbology, but reuses that proven
6
- * spatial distribution for alignment references. QuadQR uses one 5x5 primary
7
- * alignment reference at the bottom-right and compact 3x3 secondary markers.
6
+ * spatial distribution for alignment references. QuadQR format v6 uses the same
7
+ * proven 5x5 nested alignment marker shape at every scheduled alignment center.
8
+ * Legacy format v5 used one 5x5 primary marker plus compact 3x3 secondaries.
8
9
  * Version 1 keeps the legacy QuadQR bottom-right bootstrap marker because standard QR v1 has no
9
10
  * alignment pattern and the QuadQR camera scanner needs a fourth projective
10
11
  * reference point.
@@ -13,6 +14,14 @@
13
14
  export const MIN_GEOMETRY_VERSION = 1;
14
15
  export const MAX_GEOMETRY_VERSION = 40;
15
16
 
17
+ export const ALIGNMENT_PROFILE_STANDARD_5 = "standard5";
18
+ export const ALIGNMENT_PROFILE_LEGACY_3 = "compact3";
19
+
20
+ export function normalizeAlignmentProfile(profile = ALIGNMENT_PROFILE_STANDARD_5) {
21
+ if (profile === ALIGNMENT_PROFILE_STANDARD_5 || profile === ALIGNMENT_PROFILE_LEGACY_3) return profile;
22
+ throw new Error(`Unsupported alignment profile ${profile}.`);
23
+ }
24
+
16
25
  // Index by version. Entry 0 is unused. Values are module indices of alignment
17
26
  // pattern centers, matching the standard QR Code version layout for v2..v40.
18
27
  export const ALIGNMENT_PATTERN_AXES = Object.freeze([
@@ -79,9 +88,11 @@ export function versionFromSize(size) {
79
88
  : null;
80
89
  }
81
90
 
82
- export function alignmentPatternCentersForVersion(version) {
91
+ export function alignmentPatternCentersForVersion(version, options = {}) {
83
92
  assertVersion(version);
84
93
  const size = sizeForVersion(version);
94
+ const requestedProfile = typeof options === "string" ? options : options.profile;
95
+ const profile = normalizeAlignmentProfile(requestedProfile ?? ALIGNMENT_PROFILE_STANDARD_5);
85
96
 
86
97
  if (version === 1) {
87
98
  const center = size - 4;
@@ -91,7 +102,8 @@ export function alignmentPatternCentersForVersion(version) {
91
102
  size: 5,
92
103
  primary: true,
93
104
  bootstrap: true,
94
- separator: true
105
+ separator: true,
106
+ profile
95
107
  }];
96
108
  }
97
109
 
@@ -113,10 +125,13 @@ export function alignmentPatternCentersForVersion(version) {
113
125
  centers.push({
114
126
  row,
115
127
  col,
116
- size: primary ? 5 : 3,
128
+ // Format v6 uses a real QR-style 5x5 black/white/black alignment eye
129
+ // everywhere. Legacy v5 kept compact 3x3 secondaries to save cells.
130
+ size: profile === ALIGNMENT_PROFILE_LEGACY_3 && !primary ? 3 : 5,
117
131
  primary,
118
132
  bootstrap: false,
119
- separator: false
133
+ separator: false,
134
+ profile
120
135
  });
121
136
  }
122
137
  }
@@ -124,8 +139,8 @@ export function alignmentPatternCentersForVersion(version) {
124
139
  return centers;
125
140
  }
126
141
 
127
- export function primaryAlignmentPatternForVersion(version) {
128
- const centers = alignmentPatternCentersForVersion(version);
142
+ export function primaryAlignmentPatternForVersion(version, options = {}) {
143
+ const centers = alignmentPatternCentersForVersion(version, options);
129
144
  return centers.find((pattern) => pattern.primary) ?? centers[centers.length - 1];
130
145
  }
131
146
 
@@ -138,11 +153,11 @@ export function alignmentPatternIsBlack(pattern, rowOffset, colOffset) {
138
153
  if (Math.abs(rowOffset) > radius || Math.abs(colOffset) > radius) return null;
139
154
 
140
155
  if (pattern.size === 3) {
141
- // Compact secondary marker: black 3x3 ring with a white center.
156
+ // Legacy format-v5 secondary marker: black 3x3 ring with a white center.
142
157
  return rowOffset !== 0 || colOffset !== 0;
143
158
  }
144
159
 
145
- // Primary 5x5 marker: black outer ring, white inner ring, black center.
160
+ // QR-style 5x5 marker: black outer ring, white inner ring, black center.
146
161
  const outer = Math.abs(rowOffset) === 2 || Math.abs(colOffset) === 2;
147
162
  const center = rowOffset === 0 && colOffset === 0;
148
163
  return outer || center;