@sythos/js_barcode_universal 0.1.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.
Files changed (53) hide show
  1. package/LICENSE +215 -0
  2. package/NOTICE.md +106 -0
  3. package/README.md +433 -0
  4. package/bundle/sythos-barcode.esm.js +7998 -0
  5. package/bundle/sythos-barcode.js +7948 -0
  6. package/examples/create.html +731 -0
  7. package/examples/read.html +341 -0
  8. package/licenses/README.md +42 -0
  9. package/licenses/codabar.license +74 -0
  10. package/licenses/code-11.license +69 -0
  11. package/licenses/code-128.license +69 -0
  12. package/licenses/code-39.license +70 -0
  13. package/licenses/code-93.license +71 -0
  14. package/licenses/ean-13.license +70 -0
  15. package/licenses/ean-8.license +70 -0
  16. package/licenses/gs1-128.license +71 -0
  17. package/licenses/isbn.license +76 -0
  18. package/licenses/itf-14.license +69 -0
  19. package/licenses/itf.license +70 -0
  20. package/licenses/msi-plessey.license +72 -0
  21. package/licenses/pharmacode.license +71 -0
  22. package/licenses/qr-code.license +75 -0
  23. package/licenses/upc-a.license +72 -0
  24. package/licenses/upc-e.license +69 -0
  25. package/package.json +89 -0
  26. package/src/core/bit-buffer.js +174 -0
  27. package/src/core/bit-matrix.js +241 -0
  28. package/src/core/errors.js +61 -0
  29. package/src/core/galois-field.js +204 -0
  30. package/src/core/index.js +56 -0
  31. package/src/core/reed-solomon.js +313 -0
  32. package/src/image/binarizer.js +270 -0
  33. package/src/image/grid-sampler.js +164 -0
  34. package/src/image/index.js +40 -0
  35. package/src/image/luminance.js +196 -0
  36. package/src/image/perspective.js +195 -0
  37. package/src/index.js +240 -0
  38. package/src/oned/index.js +89 -0
  39. package/src/oned/patterns.js +384 -0
  40. package/src/oned/reader.js +918 -0
  41. package/src/oned/writers.js +741 -0
  42. package/src/qr/decoder.js +575 -0
  43. package/src/qr/detector.js +630 -0
  44. package/src/qr/encoder.js +958 -0
  45. package/src/qr/index.js +44 -0
  46. package/src/qr/tables.js +737 -0
  47. package/src/render/image-data.js +125 -0
  48. package/src/render/index.js +130 -0
  49. package/src/render/options.js +160 -0
  50. package/src/render/png.js +295 -0
  51. package/src/render/svg.js +120 -0
  52. package/src/render/webgl.js +206 -0
  53. package/src/render/webgpu.js +369 -0
@@ -0,0 +1,630 @@
1
+ /*!
2
+ * Sythos Barcode Suite
3
+ *
4
+ * MIT License
5
+ *
6
+ * Copyright (c) 2026 Sythos
7
+ *
8
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ * of this software and associated documentation files (the "Software"), to deal
10
+ * in the Software without restriction, including without limitation the rights
11
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ * copies of the Software, and to permit persons to whom the Software is
13
+ * furnished to do so, subject to the following conditions:
14
+ *
15
+ * The above copyright notice and this permission notice shall be included in all
16
+ * copies or substantial portions of the Software.
17
+ *
18
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ * SOFTWARE.
25
+ *
26
+ * SPDX-License-Identifier: MIT
27
+ *
28
+ * Original work. No code from any other barcode implementation.
29
+ */
30
+
31
+ /**
32
+ * QR Code detection — finding symbols in a binarized image.
33
+ *
34
+ * The whole thing hangs off one property of the finder pattern: along any line
35
+ * through its centre, in any direction, the dark and light runs are in the
36
+ * ratio 1:1:3:1:1. That is true horizontally, vertically and diagonally, it is
37
+ * scale-independent, and no ordinary printed matter reproduces it by accident.
38
+ * So the search is: scan rows for that ratio, confirm each hit by scanning the
39
+ * column through it, cluster what survives, and look for three clusters
40
+ * arranged in a right isoceles triangle.
41
+ *
42
+ * Three finders give three corners. The fourth is the problem: extrapolating
43
+ * `topRight + bottomLeft - topLeft` assumes the symbol is a parallelogram,
44
+ * which is only true if it was photographed square-on. For version 2 and up the
45
+ * bottom-right alignment pattern pins that corner down properly, which is what
46
+ * makes a tilted symbol readable. When the alignment pattern cannot be found,
47
+ * detection degrades to the parallelogram estimate rather than failing — a
48
+ * slightly wrong corner still decodes on a flat image, and Reed-Solomon absorbs
49
+ * the rest.
50
+ *
51
+ * @module qr/detector
52
+ */
53
+
54
+ import { NotFoundError } from '../core/errors.js';
55
+ import { PerspectiveTransform } from '../image/perspective.js';
56
+ import { sampleQuad } from '../image/grid-sampler.js';
57
+ import { decodeQR } from './decoder.js';
58
+
59
+ /** Finder pattern run ratios, centre run first in the array's own order. */
60
+ const FINDER_RATIOS = [1, 1, 3, 1, 1];
61
+
62
+ /** Alignment pattern run ratios. */
63
+ const ALIGNMENT_RATIOS = [1, 1, 1, 1, 1];
64
+
65
+ /** Smallest and largest legal symbol dimensions, in modules. */
66
+ const MIN_DIMENSION = 21;
67
+ const MAX_DIMENSION = 177;
68
+
69
+ /**
70
+ * Do five alternating runs match the expected ratios?
71
+ *
72
+ * Tolerance is half a module, scaled by the ratio, which is the widest band
73
+ * that still rejects ordinary text and rules while accepting the blur and
74
+ * rounding of a real scan.
75
+ *
76
+ * @param {number[]} counts Five run lengths, dark first.
77
+ * @param {number[]} ratios
78
+ * @returns {number} The implied module size, or 0 if the ratios do not match.
79
+ */
80
+ function matchRatios(counts, ratios) {
81
+ let total = 0;
82
+ let units = 0;
83
+ for (let i = 0; i < 5; i++) {
84
+ if (counts[i] === 0) return 0;
85
+ total += counts[i];
86
+ units += ratios[i];
87
+ }
88
+ if (total < units) return 0;
89
+
90
+ const moduleSize = total / units;
91
+ const tolerance = moduleSize / 2;
92
+ for (let i = 0; i < 5; i++) {
93
+ if (Math.abs(counts[i] - moduleSize * ratios[i]) > tolerance * ratios[i]) return 0;
94
+ }
95
+ return moduleSize;
96
+ }
97
+
98
+ /**
99
+ * Scan one row for the finder run ratio.
100
+ *
101
+ * @param {import('../core/bit-matrix.js').BitMatrix} image
102
+ * @param {number} y
103
+ * @param {number[]} ratios
104
+ * @param {(centreX: number, moduleSize: number) => void} onHit
105
+ */
106
+ function scanRow(image, y, ratios, onHit) {
107
+ const width = image.width;
108
+ const counts = [0, 0, 0, 0, 0];
109
+ let state = 0;
110
+
111
+ for (let x = 0; x < width; x++) {
112
+ const dark = image.get(x, y);
113
+
114
+ // Even states are dark runs, odd states light.
115
+ if (dark === ((state & 1) === 0)) {
116
+ counts[state]++;
117
+ continue;
118
+ }
119
+
120
+ // A leading light margin is not part of any pattern.
121
+ if (state === 0 && counts[0] === 0) continue;
122
+
123
+ if (state < 4) {
124
+ state++;
125
+ counts[state] = 1;
126
+ continue;
127
+ }
128
+
129
+ // Five runs complete, and the sixth has begun.
130
+ const moduleSize = matchRatios(counts, ratios);
131
+ if (moduleSize > 0) {
132
+ onHit(x - counts[4] - counts[3] - counts[2] / 2, moduleSize);
133
+ }
134
+
135
+ // Slide the window on by two runs: the trailing dark run of a rejected
136
+ // candidate is often the leading dark run of the real one.
137
+ counts[0] = counts[2];
138
+ counts[1] = counts[3];
139
+ counts[2] = counts[4];
140
+ counts[3] = 1;
141
+ counts[4] = 0;
142
+ state = 3;
143
+ }
144
+
145
+ if (state === 4) {
146
+ const moduleSize = matchRatios(counts, ratios);
147
+ if (moduleSize > 0) {
148
+ onHit(width - counts[4] - counts[3] - counts[2] / 2, moduleSize);
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Walk a line through a candidate centre and confirm the ratio holds there too.
155
+ *
156
+ * @param {import('../core/bit-matrix.js').BitMatrix} image
157
+ * @param {number} x @param {number} y
158
+ * @param {number} dx @param {number} dy Unit step defining the line.
159
+ * @param {number[]} ratios
160
+ * @param {number} maxRun Guard against running the length of a dark image.
161
+ * @returns {number} Refined centre offset along the line, or NaN.
162
+ */
163
+ function crossCheck(image, x, y, dx, dy, ratios, maxRun) {
164
+ const width = image.width;
165
+ const height = image.height;
166
+
167
+ /** @returns {boolean | null} Null when the sample falls outside the image. */
168
+ const at = (i) => {
169
+ const px = x + dx * i;
170
+ const py = y + dy * i;
171
+ if (px < 0 || py < 0 || px >= width || py >= height) return null;
172
+ return image.get(px, py);
173
+ };
174
+
175
+ if (at(0) !== true) return NaN;
176
+
177
+ const counts = [0, 0, 0, 0, 0];
178
+ let i = 0;
179
+
180
+ // Forward from the centre: rest of the centre run, then light, then dark.
181
+ while (at(i) === true && counts[2] < maxRun) { counts[2]++; i++; }
182
+ if (at(i) === null) return NaN;
183
+ const centreForward = counts[2];
184
+
185
+ while (at(i) === false && counts[3] < maxRun) { counts[3]++; i++; }
186
+ if (at(i) === null || counts[3] === 0) return NaN;
187
+
188
+ while (at(i) === true && counts[4] < maxRun) { counts[4]++; i++; }
189
+ if (counts[4] === 0) return NaN;
190
+
191
+ // Backward from the centre.
192
+ i = -1;
193
+ while (at(i) === true && counts[2] < maxRun * 2) { counts[2]++; i--; }
194
+ if (at(i) === null) return NaN;
195
+ const centreBackward = counts[2] - centreForward;
196
+
197
+ while (at(i) === false && counts[1] < maxRun) { counts[1]++; i--; }
198
+ if (at(i) === null || counts[1] === 0) return NaN;
199
+
200
+ while (at(i) === true && counts[0] < maxRun) { counts[0]++; i--; }
201
+ if (counts[0] === 0) return NaN;
202
+
203
+ if (matchRatios(counts, ratios) === 0) return NaN;
204
+
205
+ // The centre run spans offsets [-centreBackward, centreForward - 1].
206
+ return (centreForward - 1 - centreBackward) / 2;
207
+ }
208
+
209
+ /**
210
+ * @typedef {object} Candidate
211
+ * @property {number} x
212
+ * @property {number} y
213
+ * @property {number} moduleSize
214
+ * @property {number} hits
215
+ */
216
+
217
+ /**
218
+ * Locate pattern centres of a given run ratio.
219
+ *
220
+ * @param {import('../core/bit-matrix.js').BitMatrix} image
221
+ * @param {number[]} ratios
222
+ * @param {number} unitsWide Modules the pattern spans (7 or 5).
223
+ * @param {{x0: number, y0: number, x1: number, y1: number}} [region]
224
+ * @returns {Candidate[]}
225
+ */
226
+ function findPatterns(image, ratios, unitsWide, region) {
227
+ /** @type {Candidate[]} */
228
+ const found = [];
229
+
230
+ const y0 = region ? Math.max(0, region.y0) : 0;
231
+ const y1 = region ? Math.min(image.height, region.y1) : image.height;
232
+
233
+ for (let y = y0; y < y1; y++) {
234
+ scanRow(image, y, ratios, (centreX, moduleSize) => {
235
+ if (region && (centreX < region.x0 || centreX > region.x1)) return;
236
+
237
+ const px = Math.floor(centreX);
238
+ const maxRun = Math.ceil(moduleSize * unitsWide);
239
+ const offsetY = crossCheck(image, px, y, 0, 1, ratios, maxRun);
240
+ if (Number.isNaN(offsetY)) return;
241
+
242
+ const cy = y + offsetY;
243
+ // Re-check horizontally at the refined row, which both confirms the hit
244
+ // and gives a better x than the original scan line did.
245
+ const offsetX = crossCheck(image, px, Math.round(cy), 1, 0, ratios, maxRun);
246
+ if (Number.isNaN(offsetX)) return;
247
+
248
+ const cx = px + offsetX;
249
+
250
+ // Merge with an existing centre when they describe the same pattern.
251
+ for (let i = 0; i < found.length; i++) {
252
+ const c = found[i];
253
+ if (
254
+ Math.abs(c.x - cx) <= c.moduleSize &&
255
+ Math.abs(c.y - cy) <= c.moduleSize &&
256
+ Math.abs(c.moduleSize - moduleSize) <= Math.max(1, c.moduleSize / 2)
257
+ ) {
258
+ const n = c.hits + 1;
259
+ c.x = (c.x * c.hits + cx) / n;
260
+ c.y = (c.y * c.hits + cy) / n;
261
+ c.moduleSize = (c.moduleSize * c.hits + moduleSize) / n;
262
+ c.hits = n;
263
+ return;
264
+ }
265
+ }
266
+
267
+ found.push({ x: cx, y: cy, moduleSize, hits: 1 });
268
+ });
269
+ }
270
+
271
+ return found;
272
+ }
273
+
274
+ /**
275
+ * @param {{x: number, y: number}} a @param {{x: number, y: number}} b
276
+ * @returns {number}
277
+ */
278
+ function distance(a, b) {
279
+ return Math.hypot(a.x - b.x, a.y - b.y);
280
+ }
281
+
282
+ /**
283
+ * Order three finder centres as top-left, top-right, bottom-left.
284
+ *
285
+ * The corner is the centre opposite the longest side. Which of the remaining
286
+ * two is "top right" follows from the sign of the cross product: in image
287
+ * coordinates, with y increasing downward, a symbol the right way round has
288
+ * (topRight - topLeft) x (bottomLeft - topLeft) positive.
289
+ *
290
+ * @param {Candidate[]} three
291
+ * @returns {{tl: Candidate, tr: Candidate, bl: Candidate} | null}
292
+ */
293
+ function orientFinders(three) {
294
+ const [a, b, c] = three;
295
+ const ab = distance(a, b);
296
+ const bc = distance(b, c);
297
+ const ca = distance(c, a);
298
+
299
+ let tl, p, q, hypotenuse, leg1, leg2;
300
+ if (ab >= bc && ab >= ca) {
301
+ tl = c; p = a; q = b; hypotenuse = ab; leg1 = ca; leg2 = bc;
302
+ } else if (bc >= ab && bc >= ca) {
303
+ tl = a; p = b; q = c; hypotenuse = bc; leg1 = ab; leg2 = ca;
304
+ } else {
305
+ tl = b; p = c; q = a; hypotenuse = ca; leg1 = bc; leg2 = ab;
306
+ }
307
+
308
+ if (leg1 === 0 || leg2 === 0) return null;
309
+
310
+ // The two legs must be near enough equal, and Pythagoras must hold: this is
311
+ // what rejects three unrelated finder-lookalikes that happen to co-occur.
312
+ const ratio = leg1 / leg2;
313
+ if (ratio < 0.7 || ratio > 1.4) return null;
314
+ const expected = Math.hypot(leg1, leg2);
315
+ if (Math.abs(hypotenuse - expected) > expected * 0.25) return null;
316
+
317
+ const cross = (p.x - tl.x) * (q.y - tl.y) - (p.y - tl.y) * (q.x - tl.x);
318
+ return cross >= 0 ? { tl, tr: p, bl: q } : { tl, tr: q, bl: p };
319
+ }
320
+
321
+ /**
322
+ * Snap a measured dimension to a legal symbol size.
323
+ *
324
+ * Every QR dimension is 17 + 4v, so `dimension % 4 === 1`. A measurement one
325
+ * off is rounding; two off means the module size estimate is wrong and the
326
+ * candidate is not worth pursuing.
327
+ *
328
+ * @param {number} raw
329
+ * @returns {number} 0 if it cannot be reconciled.
330
+ */
331
+ function snapDimension(raw) {
332
+ let d = Math.round(raw);
333
+ switch (d & 3) {
334
+ case 0: d--; break;
335
+ case 2: d++; break;
336
+ case 3: return 0;
337
+ default: break;
338
+ }
339
+ if (d < MIN_DIMENSION || d > MAX_DIMENSION) return 0;
340
+ return d;
341
+ }
342
+
343
+ /** The alignment pattern, as modules. 1 is dark. */
344
+ const ALIGNMENT_MODULES = [
345
+ [1, 1, 1, 1, 1],
346
+ [1, 0, 0, 0, 1],
347
+ [1, 0, 1, 0, 1],
348
+ [1, 0, 0, 0, 1],
349
+ [1, 1, 1, 1, 1],
350
+ ];
351
+
352
+ /**
353
+ * Confirm a candidate by reading the 5x5 module block it claims to be.
354
+ *
355
+ * The run-ratio scan alone is not enough here. A finder pattern's 1:1:3:1:1 is
356
+ * rare enough to stand on its own, but an alignment pattern's 1:1:1:1:1 occurs
357
+ * constantly in ordinary data modules, so an unverified match near the expected
358
+ * position is more likely to be payload than pattern — and a false match drags
359
+ * the fourth corner off by several modules, which is worse than having no
360
+ * alignment pattern at all.
361
+ *
362
+ * @param {import('../core/bit-matrix.js').BitMatrix} image
363
+ * @param {number} cx @param {number} cy @param {number} moduleSize
364
+ * @returns {boolean}
365
+ */
366
+ function verifyAlignment(image, cx, cy, moduleSize) {
367
+ let good = 0;
368
+ for (let j = 0; j < 5; j++) {
369
+ for (let i = 0; i < 5; i++) {
370
+ const px = Math.round(cx + (i - 2) * moduleSize);
371
+ const py = Math.round(cy + (j - 2) * moduleSize);
372
+ if (px < 0 || py < 0 || px >= image.width || py >= image.height) return false;
373
+ if (image.get(px, py) === (ALIGNMENT_MODULES[j][i] === 1)) good++;
374
+ }
375
+ }
376
+ // Allow two modules of slop for blur and sampling, but no more.
377
+ return good >= 23;
378
+ }
379
+
380
+ /**
381
+ * Look for the bottom-right alignment pattern near where the geometry predicts.
382
+ *
383
+ * @param {import('../core/bit-matrix.js').BitMatrix} image
384
+ * @param {{x: number, y: number}} expected
385
+ * @param {number} moduleSize
386
+ * @returns {{x: number, y: number} | null}
387
+ */
388
+ function findAlignment(image, expected, moduleSize) {
389
+ // Three modules of slack. The parallelogram estimate is good to well under
390
+ // that on any symbol flat enough to decode, and a wider net only admits more
391
+ // data modules as candidates.
392
+ const radius = Math.max(3, Math.ceil(moduleSize * 3));
393
+ const region = {
394
+ x0: expected.x - radius,
395
+ x1: expected.x + radius,
396
+ y0: Math.floor(expected.y - radius),
397
+ y1: Math.ceil(expected.y + radius),
398
+ };
399
+
400
+ const found = findPatterns(image, ALIGNMENT_RATIOS, 5, region);
401
+
402
+ let best = null;
403
+ let bestDistance = Infinity;
404
+ for (let i = 0; i < found.length; i++) {
405
+ // The alignment pattern is 5 modules across, so its implied module size
406
+ // should agree with the one the finders reported.
407
+ if (found[i].moduleSize > moduleSize * 1.5 || found[i].moduleSize < moduleSize / 1.5) continue;
408
+ if (!verifyAlignment(image, found[i].x, found[i].y, moduleSize)) continue;
409
+ const d = distance(found[i], expected);
410
+ if (d < bestDistance) {
411
+ bestDistance = d;
412
+ best = found[i];
413
+ }
414
+ }
415
+
416
+ // Last resort: the pattern is exactly where predicted but its runs were
417
+ // mangled by blur. Reading the modules directly still confirms it.
418
+ if (!best && verifyAlignment(image, expected.x, expected.y, moduleSize)) {
419
+ best = { x: expected.x, y: expected.y };
420
+ }
421
+
422
+ return best;
423
+ }
424
+
425
+ /**
426
+ * @typedef {object} Detection
427
+ * @property {Array<{x: number, y: number}>} corners Outer corners of the
428
+ * symbol, ordered top-left, top-right, bottom-right, bottom-left.
429
+ * @property {number} dimension Modules per side.
430
+ * @property {number} version
431
+ * @property {number} moduleSize Estimated pixels per module.
432
+ * @property {boolean} alignmentFound
433
+ */
434
+
435
+ /**
436
+ * Find QR Code symbols in a binarized image.
437
+ *
438
+ * @param {import('../core/bit-matrix.js').BitMatrix} binaryImage Set bit = dark.
439
+ * @returns {Detection[]} Possibly empty; ordered by descending module size, so
440
+ * the most prominent symbol comes first.
441
+ */
442
+ export function detectQR(binaryImage) {
443
+ if (!binaryImage || !binaryImage.width) {
444
+ throw new NotFoundError('detectQR: no image supplied');
445
+ }
446
+
447
+ const finders = findPatterns(binaryImage, FINDER_RATIOS, 7);
448
+ // A single stray row hit is noise; a real finder is crossed many times.
449
+ const solid = finders.filter((f) => f.hits >= 2);
450
+ const pool = solid.length >= 3 ? solid : finders;
451
+ if (pool.length < 3) return [];
452
+
453
+ // Prefer the largest patterns, and cap the combinatorics on noisy images.
454
+ pool.sort((a, b) => b.moduleSize - a.moduleSize || b.hits - a.hits);
455
+ const limit = Math.min(pool.length, 12);
456
+
457
+ /** @type {Detection[]} */
458
+ const detections = [];
459
+ const used = new Set();
460
+
461
+ for (let i = 0; i < limit; i++) {
462
+ for (let j = i + 1; j < limit; j++) {
463
+ for (let k = j + 1; k < limit; k++) {
464
+ const three = [pool[i], pool[j], pool[k]];
465
+
466
+ // All three finders belong to one symbol, so they share a module size.
467
+ const sizes = three.map((f) => f.moduleSize);
468
+ if (Math.max(...sizes) > Math.min(...sizes) * 1.6) continue;
469
+
470
+ const oriented = orientFinders(three);
471
+ if (!oriented) continue;
472
+
473
+ const { tl, tr, bl } = oriented;
474
+ const moduleSize = (tl.moduleSize + tr.moduleSize + bl.moduleSize) / 3;
475
+ if (moduleSize <= 0) continue;
476
+
477
+ // Centre-to-centre spans dimension - 7 modules.
478
+ const across = (distance(tl, tr) + distance(tl, bl)) / 2;
479
+ const dimension = snapDimension(across / moduleSize + 7);
480
+ if (dimension === 0) continue;
481
+
482
+ const version = (dimension - 17) / 4;
483
+ if (version < 1 || version > 40) continue;
484
+
485
+ const key = `${Math.round(tl.x)},${Math.round(tl.y)},${dimension}`;
486
+ if (used.has(key)) continue;
487
+ used.add(key);
488
+
489
+ detections.push(
490
+ buildDetection(binaryImage, tl, tr, bl, dimension, version, moduleSize)
491
+ );
492
+ }
493
+ }
494
+ }
495
+
496
+ detections.sort((a, b) => b.moduleSize - a.moduleSize);
497
+ return detections;
498
+ }
499
+
500
+ /**
501
+ * Turn three finder centres into four symbol corners.
502
+ *
503
+ * @param {import('../core/bit-matrix.js').BitMatrix} image
504
+ * @param {Candidate} tl @param {Candidate} tr @param {Candidate} bl
505
+ * @param {number} dimension @param {number} version @param {number} moduleSize
506
+ * @returns {Detection}
507
+ */
508
+ function buildDetection(image, tl, tr, bl, dimension, version, moduleSize) {
509
+ const d = dimension;
510
+ // Finder centres sit on module (3, 3) and friends, so at grid coordinate 3.5.
511
+ const gridTl = [3.5, 3.5];
512
+ const gridTr = [d - 3.5, 3.5];
513
+ const gridBl = [3.5, d - 3.5];
514
+
515
+ // Parallelogram estimate of the far corner, used both as the search seed for
516
+ // the alignment pattern and as the fallback when it is not there.
517
+ const guess = { x: tr.x + bl.x - tl.x, y: tr.y + bl.y - tl.y };
518
+
519
+ /** @type {PerspectiveTransform | null} */
520
+ let transform = null;
521
+ let alignmentFound = false;
522
+
523
+ if (version >= 2) {
524
+ // The bottom-right alignment pattern is centred on module (d - 7, d - 7).
525
+ const gridAlign = [d - 6.5, d - 6.5];
526
+ // Where that module lands under the parallelogram assumption.
527
+ const seed = {
528
+ x: tl.x + ((gridAlign[0] - 3.5) / (d - 7)) * (tr.x - tl.x) +
529
+ ((gridAlign[1] - 3.5) / (d - 7)) * (bl.x - tl.x),
530
+ y: tl.y + ((gridAlign[0] - 3.5) / (d - 7)) * (tr.y - tl.y) +
531
+ ((gridAlign[1] - 3.5) / (d - 7)) * (bl.y - tl.y),
532
+ };
533
+
534
+ const align = findAlignment(image, seed, moduleSize);
535
+ if (align) {
536
+ alignmentFound = true;
537
+ transform = PerspectiveTransform.quadToQuad(
538
+ gridTl[0], gridTl[1], gridTr[0], gridTr[1], gridAlign[0], gridAlign[1], gridBl[0], gridBl[1],
539
+ tl.x, tl.y, tr.x, tr.y, align.x, align.y, bl.x, bl.y
540
+ );
541
+ }
542
+ }
543
+
544
+ const plain = PerspectiveTransform.quadToQuad(
545
+ gridTl[0], gridTl[1], gridTr[0], gridTr[1], d - 3.5, d - 3.5, gridBl[0], gridBl[1],
546
+ tl.x, tl.y, tr.x, tr.y, guess.x, guess.y, bl.x, bl.y
547
+ );
548
+
549
+ const corners = cornersOf(transform ?? plain, d);
550
+ // Keep the parallelogram corners as a second opinion whenever an alignment
551
+ // pattern steered the first set. Verification makes a false match unlikely,
552
+ // not impossible, and one extra sampling attempt is far cheaper than losing
553
+ // a symbol to it.
554
+ const altCorners = transform ? cornersOf(plain, d) : null;
555
+
556
+ return { corners, altCorners, dimension, version, moduleSize, alignmentFound };
557
+ }
558
+
559
+ /**
560
+ * The four outer corners of the symbol under a grid-to-image transform.
561
+ *
562
+ * @param {PerspectiveTransform} transform @param {number} d
563
+ * @returns {Array<{x: number, y: number}>}
564
+ */
565
+ function cornersOf(transform, d) {
566
+ return [
567
+ transform.transformPoint(0, 0),
568
+ transform.transformPoint(d, 0),
569
+ transform.transformPoint(d, d),
570
+ transform.transformPoint(0, d),
571
+ ];
572
+ }
573
+
574
+ /**
575
+ * Find and decode every QR Code in a binarized image.
576
+ *
577
+ * Each candidate gets up to four attempts: a plain centre sample, a 3x3
578
+ * majority vote for noisy input, and both of those rotated 180 degrees. The
579
+ * rotation retry matters because three finders in a right isoceles triangle
580
+ * look identical to the same three rotated half a turn — the orientation is
581
+ * only settled once the format information reads cleanly, which is to say once
582
+ * the decode succeeds.
583
+ *
584
+ * @param {import('../core/bit-matrix.js').BitMatrix} binaryImage
585
+ * @returns {Array<import('./decoder.js').DecodeResult & {corners: Array<{x: number, y: number}>}>}
586
+ * Empty when nothing decodes; never throws for "no symbol here".
587
+ */
588
+ export function detectAndDecodeQR(binaryImage) {
589
+ let detections;
590
+ try {
591
+ detections = detectQR(binaryImage);
592
+ } catch (e) {
593
+ return [];
594
+ }
595
+
596
+ const results = [];
597
+ const seen = new Set();
598
+
599
+ for (let i = 0; i < detections.length; i++) {
600
+ const det = detections[i];
601
+
602
+ for (let attempt = 0; attempt < 4; attempt++) {
603
+ const voting = attempt === 1 || attempt === 3;
604
+ const rotated = attempt >= 2;
605
+
606
+ let matrix;
607
+ try {
608
+ matrix = sampleQuad(binaryImage, det.dimension, det.corners, voting);
609
+ } catch (e) {
610
+ continue;
611
+ }
612
+ if (rotated) matrix.rotate180();
613
+
614
+ try {
615
+ const result = decodeQR(matrix);
616
+ // The same symbol can be detected through more than one finder triple.
617
+ const key = `${result.version}|${result.text}`;
618
+ if (!seen.has(key)) {
619
+ seen.add(key);
620
+ results.push(Object.assign({ corners: det.corners }, result));
621
+ }
622
+ break;
623
+ } catch (e) {
624
+ /* Try the next sampling strategy. */
625
+ }
626
+ }
627
+ }
628
+
629
+ return results;
630
+ }