qr 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/decode.js CHANGED
@@ -17,22 +17,35 @@ limitations under the License.
17
17
  /**
18
18
  * Methods for decoding (reading) QR code patterns.
19
19
  * @module
20
- * @example
21
- ```js
22
-
23
- ```
24
20
  */
25
21
  import { Bitmap, utils } from "./index.js";
26
- const { best, bin, drawTemplate, fillArr, info, interleave, validateVersion, zigzag, popcnt } = utils;
27
22
  // Constants
28
23
  const MAX_BITS_ERROR = 3; // Up to 3 bit errors in version/format
24
+ // Kept at 8: the block-stat fast path reads two u32 words per row and the
25
+ // average uses `sum >>> 6`, so the current binarizer assumes 8x8 = 64 pixels.
29
26
  const GRAYSCALE_BLOCK_SIZE = 8;
30
27
  const GRAYSCALE_RANGE = 24;
31
28
  const PATTERN_VARIANCE = 2;
29
+ // Diagonal finder scans are noisier under blur/perspective than horizontal or
30
+ // vertical runs, so they use a looser ratio tolerance.
32
31
  const PATTERN_VARIANCE_DIAGONAL = 1.333;
33
32
  const PATTERN_MIN_CONFIRMATIONS = 2;
34
33
  const DETECT_MIN_ROW_SKIP = 3;
34
+ // Pair LUTs for the 8x8 block-stat fast path: each 16-bit lane holds two
35
+ // brightness bytes, so we can accumulate sum/min/max four pixels at a time.
36
+ const SUM16 = new Uint16Array(1 << 16);
37
+ const MIN16 = new Uint8Array(1 << 16);
38
+ const MAX16 = new Uint8Array(1 << 16);
39
+ for (let i = 0; i < SUM16.length; i++) {
40
+ const lo = i & 0xff;
41
+ const hi = i >>> 8;
42
+ SUM16[i] = lo + hi;
43
+ MIN16[i] = lo < hi ? lo : hi;
44
+ MAX16[i] = lo > hi ? lo : hi;
45
+ }
35
46
  // TODO: move to index, nearby with bitmap and other graph related stuff?
47
+ // Fast truncation for values expected to be non-negative; negatives would wrap
48
+ // through uint32 because this uses `>>> 0`, not `Math.floor()`.
36
49
  const int = (n) => n >>> 0;
37
50
  // distance ^ 2
38
51
  const distance2 = (p1, p2) => {
@@ -51,37 +64,145 @@ const pointMirror = (p) => ({ x: p.y, y: p.x });
51
64
  const pointClone = (p) => ({ x: p.x, y: p.y });
52
65
  const pointInt = (p) => ({ x: int(p.x), y: int(p.y) });
53
66
  const pointAdd = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
67
+ // Count trailing zeroes in a packed bitmap word so scanLine can skip whole
68
+ // runs instead of testing one bit at a time.
69
+ const ctz32 = (v) => {
70
+ v = v >>> 0;
71
+ if (v === 0)
72
+ return 32;
73
+ return 31 - Math.clz32((v & -v) >>> 0);
74
+ };
54
75
  function cap(value, min, max) {
55
- return Math.max(Math.min(value, max || value), min || value);
76
+ // ISO/IEC 18004:2024 §12 h) builds the sampling grid from "module centres";
77
+ // detector callers pass `0` as a real image edge when clipping those samples
78
+ // and search windows. `|| value` treats that bound as absent.
79
+ let res = value;
80
+ if (max !== undefined)
81
+ res = Math.min(res, max);
82
+ if (min !== undefined)
83
+ res = Math.max(res, min);
84
+ return res;
56
85
  }
57
- const getBytesPerPixel = (img) => {
58
- const perPixel = img.data.length / (img.width * img.height);
86
+ function getBytesPerPixel(img) {
87
+ const image = img;
88
+ const perPixel = image.data.length / (image.width * image.height);
59
89
  if (perPixel === 3 || perPixel === 4)
60
90
  return perPixel; // RGB or RGBA
61
91
  throw new Error(`Unknown image format, bytes per pixel=${perPixel}`);
62
- };
92
+ }
93
+ function isBytes(data) {
94
+ return data instanceof Uint8Array || data instanceof Uint8ClampedArray;
95
+ }
63
96
  /**
64
97
  * Convert to grayscale. The function is the most expensive part of decoding:
65
- * it takes up to 90% of time. TODO: check gamma correction / sqr.
98
+ * it takes up to 90% of time.
99
+ *
100
+ * Binarization pipeline:
101
+ * 1. Convert RGB/RGBA image to one luma byte per pixel.
102
+ * 2. Split the image into 8x8 blocks and collect per-block mean/min/max.
103
+ * 3. Build a 5x5 neighborhood mean over those block means.
104
+ * 4. Turn each 8x8 block into bitmap bits using a local cut derived from:
105
+ * - the neighborhood mean,
106
+ * - the current block statistics,
107
+ * - a cheap whole-image color-spread estimate,
108
+ * - and, on risky scenes, a local variance field over block means.
109
+ *
110
+ * Instead of producing "best looking" thresholding: we produce a
111
+ * bitmap where finder patterns survive perspective / blur / highlights while
112
+ * keeping false dark regions low enough for downstream finder selection.
66
113
  */
67
114
  function toBitmap(img) {
115
+ const image = img;
116
+ const width = image.width;
117
+ const height = image.height;
118
+ const data = image.data;
68
119
  const bytesPerPixel = getBytesPerPixel(img);
69
- const brightness = new Uint8Array(img.height * img.width);
70
- for (let i = 0, j = 0, d = img.data; i < d.length; i += bytesPerPixel) {
71
- const r = d[i];
72
- const g = d[i + 1];
73
- const b = d[i + 2];
74
- brightness[j++] = int((r + 2 * g + b) / 4) & 0xff;
120
+ const pixLen = height * width;
121
+ const brightness = new Uint8Array(pixLen);
122
+ if (bytesPerPixel === 4 && isBytes(data) && (data.byteOffset & 3) === 0) {
123
+ // Little-endian RGBA: compute four grayscale bytes and commit as one u32 store.
124
+ // Unaligned RGBA subarray views are still valid inputs; they fall back to
125
+ // the scalar path because Uint32Array would throw on a misaligned offset.
126
+ const pixels = new Uint32Array(data.buffer, data.byteOffset, pixLen);
127
+ const bright32 = new Uint32Array(brightness.buffer, brightness.byteOffset, brightness.length >>> 2);
128
+ const n4 = pixels.length & ~3;
129
+ for (let i = 0, j = 0; i < n4; i += 4, j++) {
130
+ const v0 = pixels[i] >>> 0;
131
+ const v1 = pixels[i + 1] >>> 0;
132
+ const v2 = pixels[i + 2] >>> 0;
133
+ const v3 = pixels[i + 3] >>> 0;
134
+ // RGBA words are little-endian here, so this is `(r + 2*g + b) / 4`
135
+ // computed from the packed byte lanes for four pixels at once.
136
+ const b0 = ((v0 & 0xff) + (((v0 >>> 8) & 0xff) << 1) + ((v0 >>> 16) & 0xff)) >>> 2;
137
+ const b1 = ((v1 & 0xff) + (((v1 >>> 8) & 0xff) << 1) + ((v1 >>> 16) & 0xff)) >>> 2;
138
+ const b2 = ((v2 & 0xff) + (((v2 >>> 8) & 0xff) << 1) + ((v2 >>> 16) & 0xff)) >>> 2;
139
+ const b3 = ((v3 & 0xff) + (((v3 >>> 8) & 0xff) << 1) + ((v3 >>> 16) & 0xff)) >>> 2;
140
+ bright32[j] = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24);
141
+ }
142
+ for (let i = n4; i < pixels.length; i++) {
143
+ const v = pixels[i] >>> 0;
144
+ brightness[i] = ((v & 0xff) + (((v >>> 8) & 0xff) << 1) + ((v >>> 16) & 0xff)) >>> 2;
145
+ }
75
146
  }
147
+ else {
148
+ for (let i = 0, j = 0, d = data; i < d.length; i += bytesPerPixel) {
149
+ const r = d[i];
150
+ const g = d[i + 1];
151
+ const b = d[i + 2];
152
+ brightness[j++] = int((r + 2 * g + b) / 4) & 0xff;
153
+ }
154
+ }
155
+ // Sampled color spread is a cheap "scene type" signal:
156
+ // grayscale / flat lighting scenes want conservative cuts, while colorful or
157
+ // high-spread scenes benefit from a slightly darker threshold.
158
+ let spreadSum = 0;
159
+ let spreadCnt = 0;
160
+ const spreadStep = bytesPerPixel * 16;
161
+ for (let i = 0; i < data.length; i += spreadStep) {
162
+ const r = data[i];
163
+ const g = data[i + 1];
164
+ const b = data[i + 2];
165
+ // hi=max(r,g,b), lo=min(r,g,b): this sampled channel spread is a cheap
166
+ // scene-level proxy for "how colorful / highlighty is this frame?".
167
+ const hi = r > g ? (r > b ? r : b) : g > b ? g : b;
168
+ const lo = r < g ? (r < b ? r : b) : g < b ? g : b;
169
+ spreadSum += hi - lo;
170
+ spreadCnt++;
171
+ }
172
+ const spreadMean = spreadSum / spreadCnt;
76
173
  // Convert to bitmap
77
174
  const block = GRAYSCALE_BLOCK_SIZE;
78
- if (img.width < block * 5 || img.height < block * 5)
175
+ if (width < block * 5 || height < block * 5)
79
176
  throw new Error('image too small');
80
- const bWidth = Math.ceil(img.width / block);
81
- const bHeight = Math.ceil(img.height / block);
82
- const maxY = img.height - block;
83
- const maxX = img.width - block;
84
- const blocks = new Uint8Array(bWidth * bHeight);
177
+ const bWidth = Math.ceil(width / block);
178
+ const bHeight = Math.ceil(height / block);
179
+ const maxY = height - block;
180
+ const maxX = width - block;
181
+ const blockLen = bWidth * bHeight;
182
+ const blockState = new Uint32Array(blockLen);
183
+ // Each 8x8 block stores packed:
184
+ // - bits 0..7: block baseline brightness used by the threshold field
185
+ // - bits 8..15: block min
186
+ // - bits 16..23: block max
187
+ let hiRangeCnt = 0;
188
+ let veryLowCnt = 0;
189
+ const padW = (width + 3) & ~3;
190
+ let statStride = width;
191
+ let stat32;
192
+ if ((width & 3) !== 0) {
193
+ const padLen = padW * height;
194
+ const brightPad = new Uint8Array(padLen);
195
+ for (let y = 0; y < height; y++) {
196
+ const src = y * width;
197
+ const dst = y * padW;
198
+ brightPad.set(brightness.subarray(src, src + width), dst);
199
+ }
200
+ // Misaligned widths are padded only for the block-stat fast path.
201
+ statStride = padW;
202
+ stat32 = new Uint32Array(brightPad.buffer, brightPad.byteOffset, (padW * height) >>> 2);
203
+ }
204
+ else
205
+ stat32 = new Uint32Array(brightness.buffer, brightness.byteOffset, brightness.length >>> 2);
85
206
  for (let y = 0; y < bHeight; y++) {
86
207
  const yPos = cap(y * block, 0, maxY);
87
208
  for (let x = 0; x < bWidth; x++) {
@@ -89,48 +210,248 @@ function toBitmap(img) {
89
210
  let sum = 0;
90
211
  let min = 0xff;
91
212
  let max = 0;
92
- for (let yy = 0, pos = yPos * img.width + xPos; yy < block; yy = yy + 1, pos = pos + img.width) {
93
- for (let xx = 0; xx < block; xx++) {
94
- const pixel = brightness[pos + xx];
95
- sum += pixel;
96
- min = Math.min(min, pixel);
97
- max = Math.max(max, pixel);
213
+ // The stat-LUT fast path needs the 8-pixel row start to be 32-bit aligned
214
+ // so each row can be read as two full u32 words without any shifts.
215
+ if ((xPos & 3) === 0) {
216
+ for (let yy = 0, pos = yPos * statStride + xPos; yy < block; yy++, pos += statStride) {
217
+ const p = pos >>> 2;
218
+ const w0 = stat32[p] >>> 0;
219
+ const w1 = stat32[p + 1] >>> 0;
220
+ const a0 = w0 & 0xffff;
221
+ const a1 = w0 >>> 16;
222
+ const b0 = w1 & 0xffff;
223
+ const b1 = w1 >>> 16;
224
+ sum += SUM16[a0] + SUM16[a1] + SUM16[b0] + SUM16[b1];
225
+ const min0 = MIN16[a0];
226
+ const min1 = MIN16[a1];
227
+ const min2 = MIN16[b0];
228
+ const min3 = MIN16[b1];
229
+ if (min0 < min)
230
+ min = min0;
231
+ if (min1 < min)
232
+ min = min1;
233
+ if (min2 < min)
234
+ min = min2;
235
+ if (min3 < min)
236
+ min = min3;
237
+ const max0 = MAX16[a0];
238
+ const max1 = MAX16[a1];
239
+ const max2 = MAX16[b0];
240
+ const max3 = MAX16[b1];
241
+ if (max0 > max)
242
+ max = max0;
243
+ if (max1 > max)
244
+ max = max1;
245
+ if (max2 > max)
246
+ max = max2;
247
+ if (max3 > max)
248
+ max = max3;
98
249
  }
99
250
  }
251
+ else {
252
+ for (let yy = 0, pos = yPos * width + xPos; yy < block; yy++, pos += width) {
253
+ for (let xx = 0; xx < block; xx++) {
254
+ const pixel = brightness[pos + xx];
255
+ sum += pixel;
256
+ if (pixel < min)
257
+ min = pixel;
258
+ if (pixel > max)
259
+ max = pixel;
260
+ }
261
+ }
262
+ }
263
+ const bIdx = bWidth * y + x;
264
+ const range = max - min;
100
265
  // Average brightness of block
101
- let average = Math.floor(sum / block ** 2);
102
- if (max - min <= GRAYSCALE_RANGE) {
266
+ let average = sum >>> 6;
267
+ if (range <= GRAYSCALE_RANGE) {
268
+ // Low-contrast blocks are unstable if we threshold from their raw mean.
269
+ // Bias toward the local dark floor, then smooth with already-seen
270
+ // neighbors so finder rings don't disappear in washed-out regions.
103
271
  average = min / 2;
104
272
  if (y > 0 && x > 0) {
105
273
  const idx = (x, y) => y * bWidth + x;
106
- const prev = (blocks[idx(x, y - 1)] + 2 * blocks[idx(x - 1, y)] + blocks[idx(x - 1, y - 1)]) / 4;
107
- if (min < prev)
108
- average = prev;
274
+ const neighborNumerator = (blockState[idx(x, y - 1)] & 0xff) +
275
+ 2 * (blockState[idx(x - 1, y)] & 0xff) +
276
+ (blockState[idx(x - 1, y - 1)] & 0xff);
277
+ if (min * 4 < neighborNumerator)
278
+ average = neighborNumerator / 4;
109
279
  }
110
280
  }
111
- blocks[bWidth * y + x] = int(average);
281
+ blockState[bIdx] = int(average) | (min << 8) | (max << 16);
282
+ if (range > 40 && average < 224)
283
+ hiRangeCnt++;
284
+ if (range <= 10)
285
+ veryLowCnt++;
112
286
  }
113
287
  }
114
- const matrix = new Bitmap({ width: img.width, height: img.height });
288
+ const hiRangeFrac = hiRangeCnt / blockLen;
289
+ const veryLowFrac = veryLowCnt / blockLen;
290
+ // These two scene gates are the main "policy" layer on top of the local cut:
291
+ // - `spotBias` darkens globally flat, slightly colorful scenes that otherwise
292
+ // miss bright-spot / washed-out QR modules.
293
+ // - `useVarField` avoids paying the variance-field cost on scenes where the
294
+ // plain 5x5 mean is already stable enough.
295
+ const spotBias = veryLowFrac > 0.55 &&
296
+ veryLowFrac < 0.66 &&
297
+ hiRangeFrac < 0.02 &&
298
+ spreadMean > 10 &&
299
+ spreadMean < 20
300
+ ? -1
301
+ : 0;
302
+ const useVarField = veryLowFrac < 0.62 || spreadMean > 30;
303
+ const iWidth = bWidth + 1;
304
+ const iHeight = bHeight + 1;
305
+ const integLen = iHeight * iWidth;
306
+ // `integ` is the standard summed-area table of block means.
307
+ const integ = new Uint32Array(integLen);
308
+ // `integSqr` is the square-integral / summed-area table of `v * v` over the
309
+ // same block means, not a u8 pixel buffer. Those prefix sums can overflow
310
+ // 32-bit integer storage on large images, and Float32 was the measured
311
+ // faster compromise vs Float64 for this heuristic field.
312
+ const integSqr = useVarField ? new Float32Array(integLen) : undefined;
313
+ for (let y = 0; y < bHeight; y++) {
314
+ let rowSum = 0;
315
+ let rowSq = 0;
316
+ const bRow = y * bWidth;
317
+ const iRow = (y + 1) * iWidth;
318
+ const iPrev = y * iWidth;
319
+ for (let x = 0; x < bWidth; x++) {
320
+ const v = blockState[bRow + x] & 0xff;
321
+ rowSum += v;
322
+ if (integSqr)
323
+ rowSq += v * v;
324
+ integ[iRow + x + 1] = integ[iPrev + x + 1] + rowSum;
325
+ if (integSqr)
326
+ integSqr[iRow + x + 1] = integSqr[iPrev + x + 1] + rowSq;
327
+ }
328
+ }
329
+ const matrix = new Bitmap({ width, height });
330
+ const rows = Math.ceil(width / 32);
331
+ // Decode intentionally writes the packed bitmap words directly here. The
332
+ // per-pixel Bitmap API is too expensive on this hot path, so this must stay
333
+ // in sync with Bitmap's internal `value` layout.
334
+ const bm = matrix.value;
335
+ const rad = 2;
336
+ const win = rad * 2 + 1;
337
+ const area = win * win;
115
338
  for (let y = 0; y < bHeight; y++) {
116
339
  const yPos = cap(y * block, 0, maxY);
117
- const top = cap(y, 2, bHeight - 3);
340
+ const top = cap(y, rad, bHeight - rad - 1);
341
+ const y0 = top - rad;
342
+ const y1 = top + rad;
343
+ const r0 = y0 * iWidth;
344
+ const r1 = (y1 + 1) * iWidth;
118
345
  for (let x = 0; x < bWidth; x++) {
119
346
  const xPos = cap(x * block, 0, maxX);
120
- const left = cap(x, 2, bWidth - 3);
347
+ const shift = xPos & 31;
348
+ const col = xPos >>> 5;
349
+ const left = cap(x, rad, bWidth - rad - 1);
350
+ const x0 = left - rad;
351
+ const x1 = left + rad;
121
352
  // 5x5 blocks average
122
- let sum = 0;
123
- for (let yy = -2; yy <= 2; yy++) {
124
- const y2 = bWidth * (top + yy) + left;
125
- for (let xx = -2; xx <= 2; xx++)
126
- sum += blocks[y2 + xx];
127
- }
128
- const average = sum / 25;
129
- for (let y = 0, pos = yPos * img.width + xPos; y < block; y += 1, pos += img.width) {
130
- for (let x = 0; x < block; x++) {
131
- if (brightness[pos + x] <= average)
132
- matrix.set(xPos + x, yPos + y, true);
353
+ const sum = integ[r1 + (x1 + 1)] - integ[r0 + (x1 + 1)] - integ[r1 + x0] + integ[r0 + x0];
354
+ // `average` is the coarse threshold surface: a 5x5 neighborhood mean of
355
+ // the 8x8 block means. The adjustments below decide when to move away
356
+ // from that surface for the current block.
357
+ const average = (sum / area) | 0;
358
+ let cut = average;
359
+ const bIdx = bWidth * y + x;
360
+ const blk = blockState[bIdx];
361
+ const blockAvg = blk & 0xff;
362
+ const min = (blk >>> 8) & 0xff;
363
+ const max = blk >>> 16;
364
+ const range = max - min;
365
+ if (average < min)
366
+ continue;
367
+ if (average >= max) {
368
+ const m = 0xff;
369
+ for (let yy = 0, row = yPos * rows + col; yy < block; yy++, row += rows) {
370
+ const lo = (m << shift) >>> 0;
371
+ bm[row] |= lo;
372
+ if (shift > 24)
373
+ bm[row + 1] |= m >>> (32 - shift);
133
374
  }
375
+ continue;
376
+ }
377
+ // `localAdj`: nudge toward the current block when it is darker than
378
+ // its neighborhood. This helps preserve dark rings / modules that are
379
+ // locally meaningful but diluted by the 5x5 field.
380
+ let localAdj = (blockAvg - average) >> 4;
381
+ if (localAdj < 0)
382
+ localAdj = 0;
383
+ if (localAdj > 1)
384
+ localAdj = 1;
385
+ // `chromaAdj`: in colorful, mid-tone blocks, slight extra darkening
386
+ // helps where luma alone underestimates QR structure.
387
+ let chromaAdj = 0;
388
+ if (range > 6 && average > 48 && average < 232) {
389
+ const spreadBoost = spreadMean > 8 ? spreadMean - 8 : 0;
390
+ const mid = 128 - Math.abs(average - 128);
391
+ chromaAdj = int((spreadBoost * (range - 6) * mid) / 2200000);
392
+ if (chromaAdj > 1)
393
+ chromaAdj = 1;
394
+ }
395
+ // `varAdj`: if the surrounding block field has real variance, darken
396
+ // more aggressively when the local mean still sits far above the
397
+ // block minimum. This is what rescues many weak finder cases.
398
+ let varAdj = 0;
399
+ if (integSqr && range >= 6 && range <= 128) {
400
+ const sq = integSqr[r1 + (x1 + 1)] - integSqr[r0 + (x1 + 1)] - integSqr[r1 + x0] + integSqr[r0 + x0];
401
+ const meanSq = sq / area;
402
+ let variance = meanSq - average * average;
403
+ if (variance < 0)
404
+ variance = 0;
405
+ const gap = average - min;
406
+ const num = gap * (variance - 196);
407
+ const den = (variance + 832) * 9;
408
+ // `variance` is clamped non-negative, so `den` stays strictly positive.
409
+ varAdj = int(num / den);
410
+ if (varAdj < -1)
411
+ varAdj = -1;
412
+ if (varAdj > 4)
413
+ varAdj = 4;
414
+ }
415
+ cut = average + localAdj + chromaAdj + varAdj;
416
+ // Small scene-level nudges are intentionally separate from the three
417
+ // local terms above: they are cheap and only target known whole-scene
418
+ // failure modes such as washed-out bright-spot images.
419
+ if (spreadMean > 10 && range >= 8 && range <= 96 && average > min + 8 && average < 192)
420
+ cut++;
421
+ if (veryLowFrac > 0.68 && veryLowFrac < 0.86 && range >= 6 && range <= 20 && average < 196)
422
+ cut++;
423
+ cut += spotBias;
424
+ if (cut < min)
425
+ cut = min;
426
+ if (cut > max)
427
+ cut = max;
428
+ // Emit one 8-pixel row of the current 8x8 block: compare against the
429
+ // block cut, pack the 8 black/white decisions into one byte, then OR
430
+ // that byte into the bitmap word(s) at the current x-bit offset.
431
+ for (let yy = 0, pos = yPos * width + xPos, row = yPos * rows + col; yy < block; yy++, pos += width, row += rows) {
432
+ let m = 0;
433
+ if (brightness[pos] <= cut)
434
+ m |= 1;
435
+ if (brightness[pos + 1] <= cut)
436
+ m |= 2;
437
+ if (brightness[pos + 2] <= cut)
438
+ m |= 4;
439
+ if (brightness[pos + 3] <= cut)
440
+ m |= 8;
441
+ if (brightness[pos + 4] <= cut)
442
+ m |= 16;
443
+ if (brightness[pos + 5] <= cut)
444
+ m |= 32;
445
+ if (brightness[pos + 6] <= cut)
446
+ m |= 64;
447
+ if (brightness[pos + 7] <= cut)
448
+ m |= 128;
449
+ if (m === 0)
450
+ continue;
451
+ const lo = (m << shift) >>> 0;
452
+ bm[row] |= lo;
453
+ if (shift > 24)
454
+ bm[row + 1] |= m >>> (32 - shift);
134
455
  }
135
456
  }
136
457
  }
@@ -161,7 +482,7 @@ const patternsConfirmed = (lst) => lst.filter((i) => i.count >= PATTERN_MIN_CONF
161
482
  * @returns
162
483
  */
163
484
  function pattern(p, size) {
164
- const _size = size || fillArr(p.length, 1);
485
+ const _size = size || utils.fillArr(p.length, 1);
165
486
  if (p.length !== _size.length)
166
487
  throw new Error('invalid pattern');
167
488
  if (!(p.length & 1))
@@ -171,7 +492,7 @@ function pattern(p, size) {
171
492
  length: p.length,
172
493
  pattern: p,
173
494
  size: _size,
174
- runs: () => fillArr(p.length, 0),
495
+ runs: () => utils.fillArr(p.length, 0),
175
496
  totalSize: sum(_size),
176
497
  total: (runs) => runs.reduce((acc, i) => acc + i),
177
498
  shift: (runs, n) => {
@@ -189,7 +510,11 @@ function pattern(p, size) {
189
510
  return true;
190
511
  },
191
512
  add(out, x, y, total) {
192
- const moduleSize = total / FINDER.totalSize;
513
+ // ISO/IEC 18004:2024 §5.3.3.1 gives finder runs as "1:1:3:1:1";
514
+ // §5.3.6 defines alignment as "5 x 5 dark modules", "3 x 3 light
515
+ // modules", and a central dark module. Use this pattern's own run width
516
+ // so alignment candidates are not divided by finder width 7.
517
+ const moduleSize = total / res.totalSize;
193
518
  const cur = { x, y, moduleSize, count: 1 };
194
519
  for (let idx = 0; idx < out.length; idx++) {
195
520
  const f = out[idx];
@@ -207,11 +532,12 @@ function pattern(p, size) {
207
532
  return end;
208
533
  },
209
534
  check(b, runs, center, incr, maxCount) {
535
+ const bm = b;
210
536
  let j = 0;
211
537
  let i = pointClone(center);
212
538
  const neg = pointNeg(incr);
213
539
  const check = (p, step) => {
214
- for (; b.isInside(i) && !!b.point(i) === res.pattern[p]; pointIncr(i, step)) {
540
+ for (; bm.isInside(i) && !!bm.point(i) === res.pattern[p]; pointIncr(i, step)) {
215
541
  runs[p]++;
216
542
  j++;
217
543
  }
@@ -234,20 +560,57 @@ function pattern(p, size) {
234
560
  return j;
235
561
  },
236
562
  scanLine(b, y, xStart, xEnd, fn) {
563
+ const bm = b;
237
564
  const runs = res.runs();
565
+ // Finder scanning also couples to Bitmap internals so it can scan packed
566
+ // 32-bit words directly instead of re-reading one pixel bit at a time.
567
+ const words = bm.words;
568
+ const vals = bm.value;
569
+ const row = y * words;
570
+ const pattern = res.pattern;
571
+ // Scan one packed bitmap row by jumping whole equal-bit runs inside 32-bit
572
+ // words; this keeps finder scanning from degenerating into per-pixel work.
573
+ const bitAt = (x) => ((vals[row + (x >>> 5)] >>> (x & 31)) & 1) === 1;
574
+ const runLen = (x, want) => {
575
+ let wi = row + (x >>> 5);
576
+ let bit = x & 31;
577
+ let w = (vals[wi] >>> bit) >>> 0;
578
+ let left = xEnd - x;
579
+ let len = 0;
580
+ while (left > 0) {
581
+ const room = 32 - bit;
582
+ let n = want ? ctz32(~w >>> 0) : ctz32(w);
583
+ if (n > room)
584
+ n = room;
585
+ if (n > left)
586
+ n = left;
587
+ len += n;
588
+ if (n < room && n < left)
589
+ break;
590
+ left -= n;
591
+ if (left <= 0)
592
+ break;
593
+ wi++;
594
+ bit = 0;
595
+ w = vals[wi] >>> 0;
596
+ }
597
+ return len;
598
+ };
238
599
  let pos = 0;
239
600
  let x = xStart;
240
601
  // If we start in middle of an image, skip first pattern run,
241
602
  // since we don't know run length of pixels from left side
242
603
  if (xStart)
243
- while (x < xEnd && !!b.get(x, y) === res.pattern[0])
244
- x++;
604
+ x += runLen(x, pattern[0]);
245
605
  for (; x < xEnd; x++) {
606
+ const cur = bitAt(x);
246
607
  // Same run, continue counting
247
- if (!!b.get(x, y) === res.pattern[pos]) {
248
- runs[pos]++;
608
+ if (cur === pattern[pos]) {
609
+ const n = runLen(x, cur);
610
+ runs[pos] += n;
611
+ x += n - 1;
249
612
  // If not last element - continue counting
250
- if (x !== b.width - 1)
613
+ if (x !== bm.width - 1)
251
614
  continue;
252
615
  // Last element finishes run, set x outside of run
253
616
  x++;
@@ -278,11 +641,12 @@ function pattern(p, size) {
278
641
  };
279
642
  return res;
280
643
  }
281
- // light/dark/light/dark/light in 1:1:3:1:1 ratio
282
- const FINDER = pattern([true, false, true, false, true], [1, 1, 3, 1, 1]);
283
- // dark/light/dark in 1:1:1 ratio
284
- const ALIGNMENT = pattern([false, true, false]);
644
+ // dark/light/dark/light/dark in 1:1:3:1:1 ratio
645
+ const FINDER = /* @__PURE__ */ pattern([true, false, true, false, true], [1, 1, 3, 1, 1]);
646
+ // central light/dark/light runs of an alignment pattern in 1:1:1 ratio
647
+ const ALIGNMENT = /* @__PURE__ */ pattern([false, true, false]);
285
648
  function findFinder(b) {
649
+ const bm = b;
286
650
  let found = [];
287
651
  function checkRuns(runs, v = 2) {
288
652
  const total = sum(runs);
@@ -294,7 +658,7 @@ function findFinder(b) {
294
658
  // Non-diagonal line (horizontal or vertical)
295
659
  function checkLine(center, maxCount, total, incr) {
296
660
  const runs = FINDER.runs();
297
- let i = FINDER.check(b, runs, center, incr, maxCount);
661
+ let i = FINDER.check(bm, runs, center, incr, maxCount);
298
662
  if (i === false)
299
663
  return false;
300
664
  const runsTotal = sum(runs);
@@ -321,7 +685,7 @@ function findFinder(b) {
321
685
  x = xx + int(x);
322
686
  // Diagonal
323
687
  const dRuns = FINDER.runs();
324
- if (!FINDER.check(b, dRuns, { x: int(x), y: int(y) }, { x: 1, y: 1 }))
688
+ if (!FINDER.check(bm, dRuns, { x: int(x), y: int(y) }, { x: 1, y: 1 }))
325
689
  return false;
326
690
  if (!checkRuns(dRuns, PATTERN_VARIANCE_DIAGONAL))
327
691
  return false;
@@ -330,10 +694,10 @@ function findFinder(b) {
330
694
  }
331
695
  let skipped = false;
332
696
  // Start with high skip lines count until we find first pattern
333
- let ySkip = cap(int((3 * b.height) / (4 * 97)), DETECT_MIN_ROW_SKIP);
697
+ let ySkip = cap(int((3 * bm.height) / (4 * 97)), DETECT_MIN_ROW_SKIP);
334
698
  let done = false;
335
- for (let y = ySkip - 1; y < b.height && !done; y += ySkip) {
336
- FINDER.scanLine(b, y, 0, b.width, (runs, x) => {
699
+ for (let y = ySkip - 1; y < bm.height && !done; y += ySkip) {
700
+ FINDER.scanLine(bm, y, 0, bm.width, (runs, x) => {
337
701
  if (!check(runs, y, x))
338
702
  return;
339
703
  // Found pattern
@@ -379,7 +743,7 @@ function findFinder(b) {
379
743
  if (flen < 3)
380
744
  throw new Error(`Finder: len(found) = ${flen}`);
381
745
  found.sort((i, j) => i.moduleSize - j.moduleSize);
382
- const pBest = best();
746
+ const pBest = utils.best();
383
747
  // Qubic complexity, but we stop search when we found 3 patterns, so not a problem
384
748
  for (let i = 0; i < flen - 2; i++) {
385
749
  const fi = found[i];
@@ -429,20 +793,25 @@ function findFinder(b) {
429
793
  return { bl, tl, tr };
430
794
  }
431
795
  function findAlignment(b, est, allowanceFactor) {
796
+ const bm = b;
432
797
  const { moduleSize } = est;
433
798
  const allowance = int(allowanceFactor * moduleSize);
434
799
  const leftX = cap(est.x - allowance, 0);
435
- const rightX = cap(est.x + allowance, undefined, b.width - 1);
800
+ const rightX = cap(est.x + allowance, undefined, bm.width - 1);
436
801
  const x = rightX - leftX;
437
802
  const topY = cap(est.y - allowance, 0);
438
- const bottomY = cap(est.y + allowance, undefined, b.height - 1);
803
+ const bottomY = cap(est.y + allowance, undefined, bm.height - 1);
439
804
  const y = bottomY - topY;
440
805
  if (x < moduleSize * 3 || y < moduleSize * 3)
441
806
  throw new Error(`x = ${x}, y=${y} moduleSize = ${moduleSize}`);
442
807
  const xStart = leftX;
443
808
  const yStart = topY;
444
- const width = rightX - leftX;
445
- const height = bottomY - topY;
809
+ // ISO/IEC 18004:2024 §12 h)3 scans the alignment pattern's white-square
810
+ // outline from the provisional centre. `rightX` / `bottomY` are inclusive
811
+ // clipped image coordinates; convert them to exclusive scan bounds below so
812
+ // an exact 5x5 search window still includes its final row and column.
813
+ const width = rightX - leftX + 1;
814
+ const height = bottomY - topY + 1;
446
815
  const found = [];
447
816
  const xEnd = xStart + width;
448
817
  const middleY = int(yStart + height / 2);
@@ -450,14 +819,14 @@ function findAlignment(b, est, allowanceFactor) {
450
819
  const diff = int((yGen + 1) / 2);
451
820
  const y = middleY + (yGen & 1 ? -diff : diff);
452
821
  let res;
453
- ALIGNMENT.scanLine(b, y, xStart, xEnd, (runs, x) => {
822
+ ALIGNMENT.scanLine(bm, y, xStart, xEnd, (runs, x) => {
454
823
  if (!ALIGNMENT.checkSize(runs, moduleSize))
455
824
  return;
456
825
  const total = sum(runs);
457
826
  const xx = ALIGNMENT.toCenter(runs, x);
458
827
  // Vertical
459
828
  const rVert = ALIGNMENT.runs();
460
- let v = ALIGNMENT.check(b, rVert, { x: int(xx), y }, { y: 1, x: 0 }, 2 * runs[1]);
829
+ let v = ALIGNMENT.check(bm, rVert, { x: int(xx), y }, { y: 1, x: 0 }, 2 * runs[1]);
461
830
  if (v === false)
462
831
  return;
463
832
  v += y;
@@ -480,6 +849,7 @@ function findAlignment(b, est, allowanceFactor) {
480
849
  throw new Error('Alignment pattern not found');
481
850
  }
482
851
  function _single(b, from, to) {
852
+ const bm = b;
483
853
  // http://en.wikipedia.org/wiki/Bresenham's_line_algorithm
484
854
  let steep = false;
485
855
  let d = { x: Math.abs(to.x - from.x), y: Math.abs(to.y - from.y) };
@@ -498,8 +868,10 @@ function _single(b, from, to) {
498
868
  let real = { x, y };
499
869
  if (steep)
500
870
  real = pointMirror(real);
501
- // Same as alignment pattern ([true, false, true])
502
- if ((runPos === 1) === !!b.point(real)) {
871
+ // Starting from a dark finder center, walk until the ray crosses
872
+ // light -> dark -> light; `BWBRunLength()` mirrors this to recover the
873
+ // full finder width around the center point.
874
+ if ((runPos === 1) === !!bm.point(real)) {
503
875
  if (runPos === 2)
504
876
  return distance({ x, y }, from);
505
877
  runPos++;
@@ -517,11 +889,12 @@ function _single(b, from, to) {
517
889
  return NaN;
518
890
  }
519
891
  function BWBRunLength(b, from, to) {
520
- let result = _single(b, from, to);
892
+ const bm = b;
893
+ let result = _single(bm, from, to);
521
894
  let scaleY = 1.0;
522
895
  const { x: fx, y: fy } = from;
523
896
  let otherToX = fx - (to.x - fx);
524
- const bw = b.width;
897
+ const bw = bm.width;
525
898
  if (otherToX < 0) {
526
899
  scaleY = fx / (fx - otherToX);
527
900
  otherToX = 0;
@@ -530,9 +903,15 @@ function BWBRunLength(b, from, to) {
530
903
  scaleY = (bw - 1 - fx) / (otherToX - fx);
531
904
  otherToX = bw - 1;
532
905
  }
906
+ // ISO/IEC 18004:2024 §12 b) uses finder runs in the 1:1:3:1:1 ratio,
907
+ // and §12 h)1 derives module size from finder pattern width. Clipping the
908
+ // reflected ray before `int()` would avoid `>>> 0` wrapping negative near-edge
909
+ // coordinates to a huge uint32 and clamping to the wrong edge. Policy: keep
910
+ // the existing heuristic because the direct fix degraded decode performance
911
+ // on current vectors (BoofCV sweep 134/485 -> 132/485).
533
912
  let otherToY = int(fy - (to.y - fy) * scaleY);
534
913
  let scaleX = 1.0;
535
- const bh = b.height;
914
+ const bh = bm.height;
536
915
  if (otherToY < 0) {
537
916
  scaleX = fy / (fy - otherToY);
538
917
  otherToY = 0;
@@ -542,12 +921,16 @@ function BWBRunLength(b, from, to) {
542
921
  otherToY = bh - 1;
543
922
  }
544
923
  otherToX = int(fx + (otherToX - fx) * scaleX);
545
- result += _single(b, from, { x: otherToX, y: otherToY });
924
+ result += _single(bm, from, { x: otherToX, y: otherToY });
925
+ // Both mirrored rays include the center module once, so drop one module
926
+ // after summing them into the full finder-width estimate.
546
927
  return result - 1.0;
547
928
  }
548
929
  function moduleSizeAvg(b, p1, p2) {
549
930
  const est1 = BWBRunLength(b, pointInt(p1), pointInt(p2));
550
931
  const est2 = BWBRunLength(b, pointInt(p2), pointInt(p1));
932
+ // One ray can fail near image edges, so keep the surviving estimate
933
+ // instead of discarding the finder-width measurement outright.
551
934
  if (Number.isNaN(est1))
552
935
  return est2 / FINDER.totalSize;
553
936
  if (Number.isNaN(est2))
@@ -555,27 +938,34 @@ function moduleSizeAvg(b, p1, p2) {
555
938
  return (est1 + est2) / (2 * FINDER.totalSize);
556
939
  }
557
940
  function detect(b) {
941
+ const bm = b;
558
942
  let bl, tl, tr;
559
943
  try {
560
- ({ bl, tl, tr } = findFinder(b));
944
+ ({ bl, tl, tr } = findFinder(bm));
561
945
  }
562
946
  catch (e) {
563
947
  try {
564
- b.negate();
565
- ({ bl, tl, tr } = findFinder(b));
948
+ // ISO/IEC 18004:2024 §12 b)5 says to "reverse the colouring of the
949
+ // light and dark pixels" for reflectance reversal. `detect()` works on
950
+ // the `decodeQR()`-owned scratch bitmap, so keep the retry in-place for
951
+ // performance instead of cloning before this private/test-only helper.
952
+ bm.negate();
953
+ ({ bl, tl, tr } = findFinder(bm));
566
954
  }
567
955
  catch (e) {
568
- b.negate(); // undo negate
956
+ bm.negate(); // undo negate
569
957
  throw e;
570
958
  }
571
959
  }
572
- const moduleSize = (moduleSizeAvg(b, tl, tr) + moduleSizeAvg(b, tl, bl)) / 2;
960
+ const moduleSize = (moduleSizeAvg(bm, tl, tr) + moduleSizeAvg(bm, tl, bl)) / 2;
573
961
  if (moduleSize < 1.0)
574
962
  throw new Error(`invalid moduleSize = ${moduleSize}`);
575
963
  // Estimate size
576
964
  const tltr = int(distance(tl, tr) / moduleSize + 0.5);
577
965
  const tlbl = int(distance(tl, bl) / moduleSize + 0.5);
578
966
  let size = int((tltr + tlbl) / 2 + 7);
967
+ // QR side lengths are 21 + 4 * (version - 1), so normalize the estimate
968
+ // to the nearest size that is 1 modulo 4 before decoding the version.
579
969
  const rem = size % 4;
580
970
  if (rem === 0)
581
971
  size++; // -> 1
@@ -583,13 +973,13 @@ function detect(b) {
583
973
  size--; // -> 1
584
974
  else if (rem === 3)
585
975
  size -= 2;
586
- const version = info.size.decode(size);
587
- validateVersion(version);
976
+ const version = utils.info.size.decode(size);
977
+ utils.validateVersion(version);
588
978
  let alignmentPattern;
589
- if (info.alignmentPatterns(version).length > 0) {
979
+ if (utils.info.alignmentPatterns(version).length > 0) {
590
980
  // Bottom right estimate
591
981
  const br = { x: tr.x - tl.x + bl.x, y: tr.y - tl.y + bl.y };
592
- const c = 1.0 - 3.0 / (info.size.encode(version) - 7);
982
+ const c = 1.0 - 3.0 / (utils.info.size.encode(version) - 7);
593
983
  // Estimated alignment pattern position
594
984
  const est = {
595
985
  x: int(tl.x + c * (br.x - tl.x)),
@@ -599,7 +989,7 @@ function detect(b) {
599
989
  };
600
990
  for (let i = 4; i <= 16; i <<= 1) {
601
991
  try {
602
- alignmentPattern = findAlignment(b, est, i);
992
+ alignmentPattern = findAlignment(bm, est, i);
603
993
  break;
604
994
  }
605
995
  catch (e) { }
@@ -619,13 +1009,15 @@ function detect(b) {
619
1009
  toBR = { x: size - 3.5, y: size - 3.5 };
620
1010
  }
621
1011
  const from = [tl, tr, br, bl];
622
- const bits = transform(b, size, from, [toTL, toTR, toBR, toBL]);
1012
+ const bits = transform(bm, size, from, [toTL, toTR, toBR, toBL]);
623
1013
  return { bits: bits, points: from };
624
1014
  }
625
1015
  // Perspective transform by 4 points
626
1016
  function squareToQuadrilateral(p) {
627
1017
  const d3 = { x: p[0].x - p[1].x + p[2].x - p[3].x, y: p[0].y - p[1].y + p[2].y - p[3].y };
628
1018
  if (d3.x === 0.0 && d3.y === 0.0) {
1019
+ // Parallelogram fast path: perspective terms vanish, so the homography
1020
+ // reduces to an affine transform.
629
1021
  return [
630
1022
  [p[1].x - p[0].x, p[2].x - p[1].x, p[0].x],
631
1023
  [p[1].y - p[0].y, p[2].y - p[1].y, p[0].y],
@@ -647,9 +1039,12 @@ function squareToQuadrilateral(p) {
647
1039
  }
648
1040
  // Transform quadrilateral to square by 4 points
649
1041
  function transform(b, size, from, to) {
1042
+ const bm = b;
650
1043
  // TODO: check
651
1044
  // https://math.stackexchange.com/questions/13404/mapping-irregular-quadrilateral-to-a-rectangle
652
1045
  const p = squareToQuadrilateral(to);
1046
+ // Homographies are scale-invariant, so the adjugate is enough here;
1047
+ // there is no need to divide by the determinant when inverting `p`.
653
1048
  const qToS = [
654
1049
  [
655
1050
  p[1][1] * p[2][2] - p[2][1] * p[1][2],
@@ -670,7 +1065,7 @@ function transform(b, size, from, to) {
670
1065
  const sToQ = squareToQuadrilateral(from);
671
1066
  const transform = sToQ.map((i) => i.map((_, qx) => i.reduce((acc, v, j) => acc + v * qToS[j][qx], 0)));
672
1067
  const res = new Bitmap(size);
673
- const points = fillArr(2 * size, 0);
1068
+ const points = utils.fillArr(2 * size, 0);
674
1069
  const pointsLength = points.length;
675
1070
  for (let y = 0; y < size; y++) {
676
1071
  const p = transform;
@@ -678,13 +1073,15 @@ function transform(b, size, from, to) {
678
1073
  const x = i / 2 + 0.5;
679
1074
  const y2 = y + 0.5;
680
1075
  const den = p[2][0] * x + p[2][1] * y2 + p[2][2];
681
- points[i] = int((p[0][0] * x + p[0][1] * y2 + p[0][2]) / den);
682
- points[i + 1] = int((p[1][0] * x + p[1][1] * y2 + p[1][2]) / den);
1076
+ // ISO/IEC 18004:2024 §12 h) maps sampling grid intersections back to
1077
+ // image coordinates before deciding dark/light state. Clip projected
1078
+ // coordinates before `int()` because `>>> 0` wraps negative samples to a
1079
+ // huge uint32 and would clamp them to the far image edge.
1080
+ points[i] = int(cap((p[0][0] * x + p[0][1] * y2 + p[0][2]) / den, 0, bm.width - 1));
1081
+ points[i + 1] = int(cap((p[1][0] * x + p[1][1] * y2 + p[1][2]) / den, 0, bm.height - 1));
683
1082
  }
684
1083
  for (let i = 0; i < pointsLength; i += 2) {
685
- const px = cap(points[i], 0, b.width - 1);
686
- const py = cap(points[i + 1], 0, b.height - 1);
687
- if (b.get(px, py))
1084
+ if (bm.get(points[i], points[i + 1]))
688
1085
  res.set((i / 2) | 0, y, true);
689
1086
  }
690
1087
  }
@@ -693,8 +1090,11 @@ function transform(b, size, from, to) {
693
1090
  // Same as in drawTemplate, but reading
694
1091
  // TODO: merge in CoderType?
695
1092
  function readInfoBits(b) {
696
- const readBit = (x, y, out) => (out << 1) | (b.get(x, y) ? 1 : 0);
697
- const size = b.height;
1093
+ const bm = b;
1094
+ // Walk each reserved copy from the highest-numbered module back to module 0
1095
+ // so the shift accumulator rebuilds the canonical bit string from MSB to LSB.
1096
+ const readBit = (x, y, out) => (out << 1) | (bm.get(x, y) ? 1 : 0);
1097
+ const size = bm.height;
698
1098
  // Version information
699
1099
  let version1 = 0;
700
1100
  for (let y = 5; y >= 0; y--)
@@ -721,56 +1121,62 @@ function readInfoBits(b) {
721
1121
  return { version1, version2, format1, format2 };
722
1122
  }
723
1123
  function parseInfo(b) {
1124
+ const bm = b;
724
1125
  // Population count over xor -> hamming distance
725
- const size = b.height;
726
- const { version1, version2, format1, format2 } = readInfoBits(b);
1126
+ const size = bm.height;
1127
+ const { version1, version2, format1, format2 } = readInfoBits(bm);
727
1128
  // Guess format
728
1129
  let format;
729
- const bestFormat = best();
1130
+ const bestFormat = utils.best();
730
1131
  for (const ecc of ['medium', 'low', 'high', 'quartile']) {
731
1132
  for (let mask = 0; mask < 8; mask++) {
732
- const bits = info.formatBits(ecc, mask);
1133
+ const bits = utils.info.formatBits(ecc, mask);
733
1134
  const cur = { ecc, mask: mask };
734
1135
  if (bits === format1 || bits === format2) {
735
1136
  format = cur;
736
1137
  break;
737
1138
  }
738
- bestFormat.add(popcnt(format1 ^ bits), cur);
1139
+ bestFormat.add(utils.popcnt(format1 ^ bits), cur);
739
1140
  if (format1 !== format2)
740
- bestFormat.add(popcnt(format2 ^ bits), cur);
1141
+ bestFormat.add(utils.popcnt(format2 ^ bits), cur);
741
1142
  }
742
1143
  }
743
1144
  if (format === undefined && bestFormat.score() <= MAX_BITS_ERROR)
744
1145
  format = bestFormat.get();
745
1146
  if (format === undefined)
746
1147
  throw new Error('invalid format pattern');
747
- let version = info.size.decode(size); // Guess version based on bitmap size
1148
+ let version = utils.info.size.decode(size); // Guess version based on bitmap size
1149
+ // Versions 1-6 do not carry version-information words, so side length is
1150
+ // the only authoritative version source until version 7 adds those fields.
748
1151
  if (version < 7)
749
- validateVersion(version);
1152
+ utils.validateVersion(version);
750
1153
  else {
751
1154
  version = undefined;
752
1155
  // Guess version
753
- const bestVer = best();
1156
+ const bestVer = utils.best();
754
1157
  for (let ver = 7; ver <= 40; ver++) {
755
- const bits = info.versionBits(ver);
1158
+ const bits = utils.info.versionBits(ver);
756
1159
  if (bits === version1 || bits === version2) {
757
1160
  version = ver;
758
1161
  break;
759
1162
  }
760
- bestVer.add(popcnt(version1 ^ bits), ver);
1163
+ bestVer.add(utils.popcnt(version1 ^ bits), ver);
761
1164
  if (version1 !== version2)
762
- bestVer.add(popcnt(version2 ^ bits), ver);
1165
+ bestVer.add(utils.popcnt(version2 ^ bits), ver);
763
1166
  }
764
1167
  if (version === undefined && bestVer.score() <= MAX_BITS_ERROR)
765
1168
  version = bestVer.get();
766
1169
  if (version === undefined)
767
1170
  throw new Error('invalid version pattern');
768
- if (info.size.encode(version) !== size)
1171
+ if (utils.info.size.encode(version) !== size)
769
1172
  throw new Error('invalid version size');
770
1173
  }
771
1174
  return { version, ...format };
772
1175
  }
773
- // Common encodings, please open issue if something popular missing
1176
+ // ISO/IEC 18004:2024 §7.4.3.2 says each ECI is a "6-digit assignment
1177
+ // number"; §7.4.3.4 says invoked ECIs apply until "a change of ECI".
1178
+ // Decode through platform TextDecoder only: unsupported labels may throw on
1179
+ // some runtimes, and callers needing them can provide a custom textDecoder.
774
1180
  const eciToEncoding = {
775
1181
  1: 'iso-8859-1',
776
1182
  2: 'ibm437',
@@ -800,26 +1206,30 @@ const eciToEncoding = {
800
1206
  30: 'euc-kr',
801
1207
  };
802
1208
  function decodeWithEci(bytes, eci = 26) {
1209
+ // ISO/IEC 18004:2024 §7.3.2 says QR's "default interpretation" is
1210
+ // "ECI 000003 representing the ISO/IEC 8859-1 character set". Keep UTF-8
1211
+ // here so this library's UTF-8 byte-mode encoder round-trips without ECI.
803
1212
  const encoding = eciToEncoding[eci];
804
1213
  if (!encoding)
805
1214
  throw new Error(`Unsupported ECI: ${eci}`);
806
1215
  return new TextDecoder(encoding).decode(bytes);
807
1216
  }
808
1217
  function decodeBitmap(b, decoder = decodeWithEci) {
809
- const size = b.height;
810
- if (size < 21 || (size & 0b11) !== 1 || size !== b.width)
1218
+ const bm = b;
1219
+ const size = bm.height;
1220
+ if (size < 21 || (size & 0b11) !== 1 || size !== bm.width)
811
1221
  throw new Error(`decode: invalid size=${size}`);
812
- const { version, mask, ecc } = parseInfo(b);
813
- const tpl = drawTemplate(version, ecc, mask);
814
- const { total } = info.capacity(version, ecc);
1222
+ const { version, mask, ecc } = parseInfo(bm);
1223
+ const tpl = utils.drawTemplate(version, ecc, mask);
1224
+ const { total } = utils.info.capacity(version, ecc);
815
1225
  const bytes = new Uint8Array(total);
816
1226
  let pos = 0;
817
1227
  let buf = 0;
818
1228
  let bitPos = 0;
819
- zigzag(tpl, mask, (x, y, m) => {
1229
+ utils.zigzag(tpl, mask, (x, y, m) => {
820
1230
  bitPos++;
821
1231
  buf <<= 1;
822
- buf |= +(!!b.get(x, y) !== m);
1232
+ buf |= +(!!bm.get(x, y) !== m);
823
1233
  if (bitPos !== 8)
824
1234
  return;
825
1235
  bytes[pos++] = buf;
@@ -828,8 +1238,8 @@ function decodeBitmap(b, decoder = decodeWithEci) {
828
1238
  });
829
1239
  if (pos !== total)
830
1240
  throw new Error(`decode: pos=${pos}, total=${total}`);
831
- let bits = Array.from(interleave(version, ecc).decode(bytes))
832
- .map((i) => bin(i, 8))
1241
+ let bits = Array.from(utils.interleave(version, ecc).decode(bytes))
1242
+ .map((i) => utils.bin(i, 8))
833
1243
  .join('');
834
1244
  // Reverse operation of index.ts/encode working on bits
835
1245
  const readBits = (n) => {
@@ -850,6 +1260,9 @@ function decodeBitmap(b, decoder = decodeWithEci) {
850
1260
  '1000': 'kanji',
851
1261
  };
852
1262
  let res = '';
1263
+ // ISO/IEC 18004:2024 §7.3.2 defines no-ECI byte data as ECI 000003
1264
+ // / ISO-8859-1. Keep ECI 26 for compatibility with legacy no-ECI UTF-8
1265
+ // payloads and with encodeQR's UTF-8 byte-mode default.
853
1266
  let eci = 26; // Default to utf-8 for compat with old behavior
854
1267
  while (true) {
855
1268
  if (bits.length < 4)
@@ -860,7 +1273,7 @@ function decodeBitmap(b, decoder = decodeWithEci) {
860
1273
  throw new Error(`Unknown modeBits=${modeBits} res="${res}"`);
861
1274
  if (mode === 'terminator')
862
1275
  break;
863
- const countBits = info.lengthBits(version, mode);
1276
+ const countBits = utils.info.lengthBits(version, mode);
864
1277
  let count = toNum(readBits(countBits));
865
1278
  if (mode === 'numeric') {
866
1279
  while (count >= 3) {
@@ -886,11 +1299,11 @@ function decodeBitmap(b, decoder = decodeWithEci) {
886
1299
  else if (mode === 'alphanumeric') {
887
1300
  while (count >= 2) {
888
1301
  const v = toNum(readBits(11));
889
- res += info.alphabet.alphanumerc.encode([Math.floor(v / 45), v % 45]).join('');
1302
+ res += utils.info.alphabet.alphanumerc.encode([Math.floor(v / 45), v % 45]).join('');
890
1303
  count -= 2;
891
1304
  }
892
1305
  if (count === 1)
893
- res += info.alphabet.alphanumerc.encode([toNum(readBits(6))]).join('');
1306
+ res += utils.info.alphabet.alphanumerc.encode([toNum(readBits(6))]).join('');
894
1307
  }
895
1308
  else if (mode === 'eci') {
896
1309
  const first = toNum(readBits(8));
@@ -908,15 +1321,24 @@ function decodeBitmap(b, decoder = decodeWithEci) {
908
1321
  data[i] = toNum(readBits(8));
909
1322
  res += decoder(data, eci);
910
1323
  }
1324
+ else if (mode === 'kanji') {
1325
+ // ISO/IEC 18004:2024 §7.3.6 says Kanji mode uses Shift JIS and
1326
+ // "Each two-byte character value is compacted to a 13-bit binary
1327
+ // codeword." Keep it unsupported until a real interop fixture can verify
1328
+ // the 13-bit-to-Shift-JIS mapping rather than adding untested decoding.
1329
+ throw new Error('Kanji mode is not supported');
1330
+ }
911
1331
  else
912
1332
  throw new Error(`Unknown mode=${mode}`);
913
1333
  }
914
1334
  return res;
915
1335
  }
916
- // Creates square from rectangle
1336
+ // Center-crop to a square and return the original-image offset so
1337
+ // `decodeQR()` can map detected points back into caller coordinates.
917
1338
  function cropToSquare(img) {
918
- const data = Array.isArray(img.data) ? new Uint8Array(img.data) : img.data;
919
- const { height, width } = img;
1339
+ const image = img;
1340
+ const data = Array.isArray(image.data) ? new Uint8Array(image.data) : image.data;
1341
+ const { height, width } = image;
920
1342
  const squareSize = Math.min(height, width);
921
1343
  const offset = {
922
1344
  x: Math.floor((width - squareSize) / 2),
@@ -932,45 +1354,93 @@ function cropToSquare(img) {
932
1354
  }
933
1355
  return { offset, img: { height: squareSize, width: squareSize, data: croppedData } };
934
1356
  }
1357
+ /**
1358
+ * Decode text from a QR image.
1359
+ * @param img - RGB or RGBA image data that contains a QR code.
1360
+ * @param opts - Decoder hooks and image preprocessing options. See {@link DecodeOpts}.
1361
+ * @returns Decoded QR payload as a string.
1362
+ * @throws If the image, decoder options, or QR contents are invalid. {@link Error}
1363
+ * @example
1364
+ * Decode text from a QR image.
1365
+ * ```ts
1366
+ * import encodeQR, { Bitmap } from 'qr';
1367
+ * import decodeQR from 'qr/decode.js';
1368
+ * const bits = encodeQR('Hello world', 'raw', { scale: 4 });
1369
+ * const bm = new Bitmap({ width: bits[0].length, height: bits.length }, bits);
1370
+ * const text = decodeQR(bm.toImage());
1371
+ * ```
1372
+ */
935
1373
  export function decodeQR(img, opts = {}) {
1374
+ let image = img;
1375
+ const options = opts;
936
1376
  for (const field of ['height', 'width']) {
937
- if (!Number.isSafeInteger(img[field]) || img[field] <= 0)
938
- throw new Error(`invalid img.${field}=${img[field]} (${typeof img[field]})`);
939
- }
940
- if (!Array.isArray(img.data) &&
941
- !(img.data instanceof Uint8Array) &&
942
- !(img.data instanceof Uint8ClampedArray))
943
- throw new Error(`invalid image.data=${img.data} (${typeof img.data})`);
944
- if (opts.cropToSquare !== undefined && typeof opts.cropToSquare !== 'boolean')
945
- throw new Error(`invalid opts.cropToSquare=${opts.cropToSquare}`);
946
- for (const fn of ['pointsOnDetect', 'imageOnBitmap', 'imageOnDetect', 'imageOnResult']) {
947
- if (opts[fn] !== undefined && typeof opts[fn] !== 'function')
948
- throw new Error(`invalid opts.${fn}=${opts[fn]} (${typeof opts[fn]})`);
1377
+ if (!Number.isSafeInteger(image[field]) || image[field] <= 0)
1378
+ throw new Error(`invalid img.${field}=${image[field]} (${typeof image[field]})`);
1379
+ }
1380
+ const { data } = image;
1381
+ if (!Array.isArray(data) && !isBytes(data))
1382
+ throw new Error(`invalid image.data=${data} (${typeof data})`);
1383
+ if (options.cropToSquare !== undefined && typeof options.cropToSquare !== 'boolean')
1384
+ throw new Error(`invalid opts.cropToSquare=${options.cropToSquare}`);
1385
+ // Validate callbacks before decoding so payload mode does not decide whether
1386
+ // an invalid public option is accepted.
1387
+ for (const fn of [
1388
+ 'textDecoder',
1389
+ 'pointsOnDetect',
1390
+ 'imageOnBitmap',
1391
+ 'imageOnDetect',
1392
+ 'imageOnResult',
1393
+ ]) {
1394
+ if (options[fn] !== undefined && typeof options[fn] !== 'function')
1395
+ throw new Error(`invalid opts.${fn}=${options[fn]} (${typeof options[fn]})`);
949
1396
  }
950
1397
  let offset = { x: 0, y: 0 };
951
- if (opts.cropToSquare)
952
- ({ img, offset } = cropToSquare(img));
953
- const bmp = toBitmap(img);
954
- if (opts.imageOnBitmap)
955
- opts.imageOnBitmap(bmp.toImage());
1398
+ if (options.cropToSquare)
1399
+ ({ img: image, offset } = cropToSquare(image));
1400
+ const bmp = toBitmap(image);
1401
+ if (options.imageOnBitmap)
1402
+ options.imageOnBitmap(bmp.toImage());
956
1403
  const { bits, points } = detect(bmp);
957
- if (opts.pointsOnDetect) {
1404
+ if (options.pointsOnDetect) {
1405
+ // Report finder points in the caller's original coordinate space after any center-crop.
958
1406
  const p = points.map((i) => ({ ...i, ...pointAdd(i, offset) }));
959
- opts.pointsOnDetect(p);
1407
+ options.pointsOnDetect(p);
960
1408
  }
961
- if (opts.imageOnDetect)
962
- opts.imageOnDetect(bits.toImage());
963
- const res = decodeBitmap(bits, opts.textDecoder);
964
- if (opts.imageOnResult)
965
- opts.imageOnResult(bits.toImage());
1409
+ if (options.imageOnDetect)
1410
+ options.imageOnDetect(bits.toImage());
1411
+ const res = decodeBitmap(bits, options.textDecoder);
1412
+ if (options.imageOnResult)
1413
+ options.imageOnResult(bits.toImage());
966
1414
  return res;
967
1415
  }
1416
+ /**
1417
+ * Default export alias for {@link decodeQR}.
1418
+ * @param img - RGB or RGBA image data that contains a QR code.
1419
+ * @param opts - Decoder hooks and image preprocessing options. See {@link DecodeOpts}.
1420
+ * @returns Decoded QR payload as a string.
1421
+ * @throws If the image, decoder options, or QR contents are invalid. {@link Error}
1422
+ * @example
1423
+ * Decode a rendered QR image via the default decoder export.
1424
+ * ```ts
1425
+ * import encodeQR, { Bitmap } from 'qr';
1426
+ * import decodeQR from 'qr/decode.js';
1427
+ * const bits = encodeQR('Hello world', 'raw', { scale: 4 });
1428
+ * const bm = new Bitmap({ width: bits[0].length, height: bits.length }, bits);
1429
+ * decodeQR(bm.toImage());
1430
+ * ```
1431
+ */
968
1432
  export default decodeQR;
1433
+ // Additional private helpers for focused regression tests; separate from the
1434
+ // existing `_tests` object so its current keys remain stable.
1435
+ export const _TESTS = /* @__PURE__ */ Object.freeze({
1436
+ findAlignment: findAlignment,
1437
+ transform: transform,
1438
+ });
969
1439
  // Unsafe API utils, exported only for tests
970
- export const _tests = {
1440
+ export const _tests = /* @__PURE__ */ Object.freeze({
971
1441
  toBitmap,
972
1442
  decodeBitmap,
973
1443
  findFinder,
974
1444
  detect,
975
- };
1445
+ });
976
1446
  //# sourceMappingURL=decode.js.map