qr 0.5.5 → 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/src/decode.ts CHANGED
@@ -17,22 +17,20 @@ 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
 
26
- import type { EncodingType, ErrorCorrection, Image, Mask, Point } from './index.ts';
22
+ import type { EncodingType, ErrorCorrection, Image, Mask, Point, TArg, TRet } from './index.ts';
27
23
  import { Bitmap, utils } from './index.ts';
28
- const { best, bin, drawTemplate, fillArr, info, interleave, validateVersion, zigzag, popcnt } =
29
- utils;
30
24
 
31
25
  // Constants
32
26
  const MAX_BITS_ERROR = 3; // Up to 3 bit errors in version/format
27
+ // Kept at 8: the block-stat fast path reads two u32 words per row and the
28
+ // average uses `sum >>> 6`, so the current binarizer assumes 8x8 = 64 pixels.
33
29
  const GRAYSCALE_BLOCK_SIZE = 8;
34
30
  const GRAYSCALE_RANGE = 24;
35
31
  const PATTERN_VARIANCE = 2;
32
+ // Diagonal finder scans are noisier under blur/perspective than horizontal or
33
+ // vertical runs, so they use a looser ratio tolerance.
36
34
  const PATTERN_VARIANCE_DIAGONAL = 1.333;
37
35
  const PATTERN_MIN_CONFIRMATIONS = 2;
38
36
  const DETECT_MIN_ROW_SKIP = 3;
@@ -50,9 +48,12 @@ for (let i = 0; i < SUM16.length; i++) {
50
48
  }
51
49
 
52
50
  // TODO: move to index, nearby with bitmap and other graph related stuff?
51
+ // Fast truncation for values expected to be non-negative; negatives would wrap
52
+ // through uint32 because this uses `>>> 0`, not `Math.floor()`.
53
53
  const int = (n: number) => n >>> 0;
54
54
 
55
55
  type Point4 = [Point, Point, Point, Point];
56
+ /** Finder and alignment points returned by the detector. */
56
57
  export type FinderPoints = [Pattern, Pattern, Point, Pattern];
57
58
  // distance ^ 2
58
59
  const distance2 = (p1: Point, p2: Point) => {
@@ -78,11 +79,18 @@ const ctz32 = (v: number) => {
78
79
  if (v === 0) return 32;
79
80
  return 31 - Math.clz32((v & -v) >>> 0);
80
81
  };
81
- function cap(value: number, min?: number, max?: number) {
82
- return Math.max(Math.min(value, max || value), min || value);
82
+ function cap(value: number, min?: number, max?: number): number {
83
+ // ISO/IEC 18004:2024 §12 h) builds the sampling grid from "module centres";
84
+ // detector callers pass `0` as a real image edge when clipping those samples
85
+ // and search windows. `|| value` treats that bound as absent.
86
+ let res = value;
87
+ if (max !== undefined) res = Math.min(res, max);
88
+ if (min !== undefined) res = Math.max(res, min);
89
+ return res;
83
90
  }
84
- function getBytesPerPixel(img: Image): number {
85
- const perPixel = img.data.length / (img.width * img.height);
91
+ function getBytesPerPixel(img: TArg<Image>): number {
92
+ const image = img as Image;
93
+ const perPixel = image.data.length / (image.width * image.height);
86
94
  if (perPixel === 3 || perPixel === 4) return perPixel; // RGB or RGBA
87
95
  throw new Error(`Unknown image format, bytes per pixel=${perPixel}`);
88
96
  }
@@ -107,10 +115,11 @@ function isBytes(data: unknown): data is Uint8Array {
107
115
  * bitmap where finder patterns survive perspective / blur / highlights while
108
116
  * keeping false dark regions low enough for downstream finder selection.
109
117
  */
110
- function toBitmap(img: Image): Bitmap {
111
- const width = img.width;
112
- const height = img.height;
113
- const data = img.data;
118
+ function toBitmap(img: TArg<Image>): TRet<Bitmap> {
119
+ const image = img as Image;
120
+ const width = image.width;
121
+ const height = image.height;
122
+ const data = image.data;
114
123
  const bytesPerPixel = getBytesPerPixel(img);
115
124
  const pixLen = height * width;
116
125
  const brightness = new Uint8Array(pixLen);
@@ -421,7 +430,7 @@ function toBitmap(img: Image): Bitmap {
421
430
  }
422
431
  }
423
432
  }
424
- return matrix;
433
+ return matrix as TRet<Bitmap>;
425
434
  }
426
435
 
427
436
  // Various utilities for pattern
@@ -456,7 +465,7 @@ type Runs = number[];
456
465
  * @returns
457
466
  */
458
467
  function pattern(p: boolean[], size?: number[]) {
459
- const _size = size || fillArr(p.length, 1);
468
+ const _size = size || utils.fillArr(p.length, 1);
460
469
  if (p.length !== _size.length) throw new Error('invalid pattern');
461
470
  if (!(p.length & 1)) throw new Error('invalid pattern, length should be odd');
462
471
  const res = {
@@ -464,7 +473,7 @@ function pattern(p: boolean[], size?: number[]) {
464
473
  length: p.length,
465
474
  pattern: p,
466
475
  size: _size,
467
- runs: () => fillArr(p.length, 0),
476
+ runs: () => utils.fillArr(p.length, 0),
468
477
  totalSize: sum(_size),
469
478
  total: (runs: Runs) => runs.reduce((acc, i) => acc + i),
470
479
  shift: (runs: Runs, n: number) => {
@@ -479,7 +488,11 @@ function pattern(p: boolean[], size?: number[]) {
479
488
  return true;
480
489
  },
481
490
  add(out: Pattern[], x: number, y: number, total: number) {
482
- const moduleSize = total / FINDER.totalSize;
491
+ // ISO/IEC 18004:2024 §5.3.3.1 gives finder runs as "1:1:3:1:1";
492
+ // §5.3.6 defines alignment as "5 x 5 dark modules", "3 x 3 light
493
+ // modules", and a central dark module. Use this pattern's own run width
494
+ // so alignment candidates are not divided by finder width 7.
495
+ const moduleSize = total / res.totalSize;
483
496
  const cur = { x, y, moduleSize, count: 1 };
484
497
  for (let idx = 0; idx < out.length; idx++) {
485
498
  const f = out[idx];
@@ -494,12 +507,13 @@ function pattern(p: boolean[], size?: number[]) {
494
507
  end -= runs[res.center] / 2;
495
508
  return end;
496
509
  },
497
- check(b: Bitmap, runs: Runs, center: Point, incr: Point, maxCount?: number) {
510
+ check(b: TArg<Bitmap>, runs: Runs, center: Point, incr: Point, maxCount?: number) {
511
+ const bm = b as Bitmap;
498
512
  let j = 0;
499
513
  let i = pointClone(center);
500
514
  const neg = pointNeg(incr);
501
515
  const check = (p: number, step: Point) => {
502
- for (; b.isInside(i) && !!b.point(i) === res.pattern[p]; pointIncr(i, step)) {
516
+ for (; bm.isInside(i) && !!bm.point(i) === res.pattern[p]; pointIncr(i, step)) {
503
517
  runs[p]++;
504
518
  j++;
505
519
  }
@@ -516,17 +530,18 @@ function pattern(p: boolean[], size?: number[]) {
516
530
  return j;
517
531
  },
518
532
  scanLine(
519
- b: Bitmap,
533
+ b: TArg<Bitmap>,
520
534
  y: number,
521
535
  xStart: number,
522
536
  xEnd: number,
523
537
  fn: (runs: Runs, x: number) => boolean | void
524
538
  ) {
539
+ const bm = b as Bitmap;
525
540
  const runs = res.runs();
526
541
  // Finder scanning also couples to Bitmap internals so it can scan packed
527
542
  // 32-bit words directly instead of re-reading one pixel bit at a time.
528
- const words = (b as unknown as { words: number }).words;
529
- const vals = (b as unknown as { value: Uint32Array }).value;
543
+ const words = (bm as unknown as { words: number }).words;
544
+ const vals = (bm as unknown as { value: Uint32Array }).value;
530
545
  const row = y * words;
531
546
  const pattern = res.pattern;
532
547
  // Scan one packed bitmap row by jumping whole equal-bit runs inside 32-bit
@@ -566,7 +581,7 @@ function pattern(p: boolean[], size?: number[]) {
566
581
  runs[pos] += n;
567
582
  x += n - 1;
568
583
  // If not last element - continue counting
569
- if (x !== b.width - 1) continue;
584
+ if (x !== bm.width - 1) continue;
570
585
  // Last element finishes run, set x outside of run
571
586
  x++;
572
587
  }
@@ -594,16 +609,17 @@ function pattern(p: boolean[], size?: number[]) {
594
609
  };
595
610
  return res;
596
611
  }
597
- // light/dark/light/dark/light in 1:1:3:1:1 ratio
598
- const FINDER = pattern([true, false, true, false, true], [1, 1, 3, 1, 1]);
599
- // dark/light/dark in 1:1:1 ratio
600
- const ALIGNMENT = pattern([false, true, false]);
612
+ // dark/light/dark/light/dark in 1:1:3:1:1 ratio
613
+ const FINDER = /* @__PURE__ */ pattern([true, false, true, false, true], [1, 1, 3, 1, 1]);
614
+ // central light/dark/light runs of an alignment pattern in 1:1:1 ratio
615
+ const ALIGNMENT = /* @__PURE__ */ pattern([false, true, false]);
601
616
 
602
- function findFinder(b: Bitmap): {
617
+ function findFinder(b: TArg<Bitmap>): {
603
618
  bl: Pattern;
604
619
  tl: Pattern;
605
620
  tr: Pattern;
606
621
  } {
622
+ const bm = b as Bitmap;
607
623
  let found: Pattern[] = [];
608
624
  function checkRuns(runs: Runs, v = 2) {
609
625
  const total = sum(runs);
@@ -614,7 +630,7 @@ function findFinder(b: Bitmap): {
614
630
  // Non-diagonal line (horizontal or vertical)
615
631
  function checkLine(center: Point, maxCount: number, total: number, incr: Point) {
616
632
  const runs = FINDER.runs();
617
- let i = FINDER.check(b, runs, center, incr, maxCount);
633
+ let i = FINDER.check(bm, runs, center, incr, maxCount);
618
634
  if (i === false) return false;
619
635
  const runsTotal = sum(runs);
620
636
  if (5 * Math.abs(runsTotal - total) >= 2 * total) return false;
@@ -636,17 +652,17 @@ function findFinder(b: Bitmap): {
636
652
  x = xx + int(x);
637
653
  // Diagonal
638
654
  const dRuns = FINDER.runs();
639
- if (!FINDER.check(b, dRuns, { x: int(x), y: int(y) }, { x: 1, y: 1 })) return false;
655
+ if (!FINDER.check(bm, dRuns, { x: int(x), y: int(y) }, { x: 1, y: 1 })) return false;
640
656
  if (!checkRuns(dRuns, PATTERN_VARIANCE_DIAGONAL)) return false;
641
657
  FINDER.add(found, x, y, total);
642
658
  return true;
643
659
  }
644
660
  let skipped = false;
645
661
  // Start with high skip lines count until we find first pattern
646
- let ySkip = cap(int((3 * b.height) / (4 * 97)), DETECT_MIN_ROW_SKIP);
662
+ let ySkip = cap(int((3 * bm.height) / (4 * 97)), DETECT_MIN_ROW_SKIP);
647
663
  let done = false;
648
- for (let y = ySkip - 1; y < b.height && !done; y += ySkip) {
649
- FINDER.scanLine(b, y, 0, b.width, (runs, x) => {
664
+ for (let y = ySkip - 1; y < bm.height && !done; y += ySkip) {
665
+ FINDER.scanLine(bm, y, 0, bm.width, (runs, x) => {
650
666
  if (!check(runs, y, x)) return;
651
667
  // Found pattern
652
668
  // Reduce row skip, since we found pattern and qr code is nearby
@@ -684,7 +700,7 @@ function findFinder(b: Bitmap): {
684
700
  const flen = found.length;
685
701
  if (flen < 3) throw new Error(`Finder: len(found) = ${flen}`);
686
702
  found.sort((i, j) => i.moduleSize - j.moduleSize);
687
- const pBest = best<[Pattern, Pattern, Pattern]>();
703
+ const pBest = utils.best<[Pattern, Pattern, Pattern]>();
688
704
  // Qubic complexity, but we stop search when we found 3 patterns, so not a problem
689
705
  for (let i = 0; i < flen - 2; i++) {
690
706
  const fi = found[i];
@@ -731,21 +747,26 @@ function findFinder(b: Bitmap): {
731
747
  return { bl, tl, tr };
732
748
  }
733
749
 
734
- function findAlignment(b: Bitmap, est: Pattern, allowanceFactor: number) {
750
+ function findAlignment(b: TArg<Bitmap>, est: Pattern, allowanceFactor: number): Pattern {
751
+ const bm = b as Bitmap;
735
752
  const { moduleSize } = est;
736
753
  const allowance = int(allowanceFactor * moduleSize);
737
754
  const leftX = cap(est.x - allowance, 0);
738
- const rightX = cap(est.x + allowance, undefined, b.width - 1);
755
+ const rightX = cap(est.x + allowance, undefined, bm.width - 1);
739
756
  const x = rightX - leftX;
740
757
  const topY = cap(est.y - allowance, 0);
741
- const bottomY = cap(est.y + allowance, undefined, b.height - 1);
758
+ const bottomY = cap(est.y + allowance, undefined, bm.height - 1);
742
759
  const y = bottomY - topY;
743
760
  if (x < moduleSize * 3 || y < moduleSize * 3)
744
761
  throw new Error(`x = ${x}, y=${y} moduleSize = ${moduleSize}`);
745
762
  const xStart = leftX;
746
763
  const yStart = topY;
747
- const width = rightX - leftX;
748
- const height = bottomY - topY;
764
+ // ISO/IEC 18004:2024 §12 h)3 scans the alignment pattern's white-square
765
+ // outline from the provisional centre. `rightX` / `bottomY` are inclusive
766
+ // clipped image coordinates; convert them to exclusive scan bounds below so
767
+ // an exact 5x5 search window still includes its final row and column.
768
+ const width = rightX - leftX + 1;
769
+ const height = bottomY - topY + 1;
749
770
  const found: Pattern[] = [];
750
771
  const xEnd = xStart + width;
751
772
  const middleY = int(yStart + height / 2);
@@ -753,13 +774,13 @@ function findAlignment(b: Bitmap, est: Pattern, allowanceFactor: number) {
753
774
  const diff = int((yGen + 1) / 2);
754
775
  const y = middleY + (yGen & 1 ? -diff : diff);
755
776
  let res;
756
- ALIGNMENT.scanLine(b, y, xStart, xEnd, (runs, x) => {
777
+ ALIGNMENT.scanLine(bm, y, xStart, xEnd, (runs, x) => {
757
778
  if (!ALIGNMENT.checkSize(runs, moduleSize)) return;
758
779
  const total = sum(runs);
759
780
  const xx = ALIGNMENT.toCenter(runs, x);
760
781
  // Vertical
761
782
  const rVert = ALIGNMENT.runs();
762
- let v = ALIGNMENT.check(b, rVert, { x: int(xx), y }, { y: 1, x: 0 }, 2 * runs[1]);
783
+ let v = ALIGNMENT.check(bm, rVert, { x: int(xx), y }, { y: 1, x: 0 }, 2 * runs[1]);
763
784
  if (v === false) return;
764
785
  v += y;
765
786
  const vTotal = sum(rVert);
@@ -776,7 +797,8 @@ function findAlignment(b: Bitmap, est: Pattern, allowanceFactor: number) {
776
797
  throw new Error('Alignment pattern not found');
777
798
  }
778
799
 
779
- function _single(b: Bitmap, from: Point, to: Point) {
800
+ function _single(b: TArg<Bitmap>, from: Point, to: Point) {
801
+ const bm = b as Bitmap;
780
802
  // http://en.wikipedia.org/wiki/Bresenham's_line_algorithm
781
803
  let steep = false;
782
804
  let d = { x: Math.abs(to.x - from.x), y: Math.abs(to.y - from.y) };
@@ -794,8 +816,10 @@ function _single(b: Bitmap, from: Point, to: Point) {
794
816
  for (let x = from.x, y = from.y; x !== xLimit; x += step.x) {
795
817
  let real = { x, y };
796
818
  if (steep) real = pointMirror(real);
797
- // Same as alignment pattern ([true, false, true])
798
- if ((runPos === 1) === !!b.point(real)) {
819
+ // Starting from a dark finder center, walk until the ray crosses
820
+ // light -> dark -> light; `BWBRunLength()` mirrors this to recover the
821
+ // full finder width around the center point.
822
+ if ((runPos === 1) === !!bm.point(real)) {
799
823
  if (runPos === 2) return distance({ x, y }, from);
800
824
  runPos++;
801
825
  }
@@ -809,12 +833,13 @@ function _single(b: Bitmap, from: Point, to: Point) {
809
833
  return NaN;
810
834
  }
811
835
 
812
- function BWBRunLength(b: Bitmap, from: Point, to: Point) {
813
- let result = _single(b, from, to);
836
+ function BWBRunLength(b: TArg<Bitmap>, from: Point, to: Point) {
837
+ const bm = b as Bitmap;
838
+ let result = _single(bm, from, to);
814
839
  let scaleY = 1.0;
815
840
  const { x: fx, y: fy } = from;
816
841
  let otherToX = fx - (to.x - fx);
817
- const bw = b.width;
842
+ const bw = bm.width;
818
843
  if (otherToX < 0) {
819
844
  scaleY = fx / (fx - otherToX);
820
845
  otherToX = 0;
@@ -822,9 +847,15 @@ function BWBRunLength(b: Bitmap, from: Point, to: Point) {
822
847
  scaleY = (bw - 1 - fx) / (otherToX - fx);
823
848
  otherToX = bw - 1;
824
849
  }
850
+ // ISO/IEC 18004:2024 §12 b) uses finder runs in the 1:1:3:1:1 ratio,
851
+ // and §12 h)1 derives module size from finder pattern width. Clipping the
852
+ // reflected ray before `int()` would avoid `>>> 0` wrapping negative near-edge
853
+ // coordinates to a huge uint32 and clamping to the wrong edge. Policy: keep
854
+ // the existing heuristic because the direct fix degraded decode performance
855
+ // on current vectors (BoofCV sweep 134/485 -> 132/485).
825
856
  let otherToY = int(fy - (to.y - fy) * scaleY);
826
857
  let scaleX = 1.0;
827
- const bh = b.height;
858
+ const bh = bm.height;
828
859
  if (otherToY < 0) {
829
860
  scaleX = fy / (fy - otherToY);
830
861
  otherToY = 0;
@@ -833,53 +864,64 @@ function BWBRunLength(b: Bitmap, from: Point, to: Point) {
833
864
  otherToY = bh - 1;
834
865
  }
835
866
  otherToX = int(fx + (otherToX - fx) * scaleX);
836
- result += _single(b, from, { x: otherToX, y: otherToY });
867
+ result += _single(bm, from, { x: otherToX, y: otherToY });
868
+ // Both mirrored rays include the center module once, so drop one module
869
+ // after summing them into the full finder-width estimate.
837
870
  return result - 1.0;
838
871
  }
839
872
 
840
- function moduleSizeAvg(b: Bitmap, p1: Point, p2: Point) {
873
+ function moduleSizeAvg(b: TArg<Bitmap>, p1: Point, p2: Point) {
841
874
  const est1 = BWBRunLength(b, pointInt(p1), pointInt(p2));
842
875
  const est2 = BWBRunLength(b, pointInt(p2), pointInt(p1));
876
+ // One ray can fail near image edges, so keep the surviving estimate
877
+ // instead of discarding the finder-width measurement outright.
843
878
  if (Number.isNaN(est1)) return est2 / FINDER.totalSize;
844
879
  if (Number.isNaN(est2)) return est1 / FINDER.totalSize;
845
880
  return (est1 + est2) / (2 * FINDER.totalSize);
846
881
  }
847
882
 
848
- function detect(b: Bitmap): {
883
+ function detect(b: TArg<Bitmap>): TRet<{
849
884
  bits: Bitmap;
850
885
  points: FinderPoints;
851
- } {
886
+ }> {
887
+ const bm = b as Bitmap;
852
888
  let bl, tl, tr;
853
889
  try {
854
- ({ bl, tl, tr } = findFinder(b));
890
+ ({ bl, tl, tr } = findFinder(bm));
855
891
  } catch (e) {
856
892
  try {
857
- b.negate();
858
- ({ bl, tl, tr } = findFinder(b));
893
+ // ISO/IEC 18004:2024 §12 b)5 says to "reverse the colouring of the
894
+ // light and dark pixels" for reflectance reversal. `detect()` works on
895
+ // the `decodeQR()`-owned scratch bitmap, so keep the retry in-place for
896
+ // performance instead of cloning before this private/test-only helper.
897
+ bm.negate();
898
+ ({ bl, tl, tr } = findFinder(bm));
859
899
  } catch (e) {
860
- b.negate(); // undo negate
900
+ bm.negate(); // undo negate
861
901
  throw e;
862
902
  }
863
903
  }
864
- const moduleSize = (moduleSizeAvg(b, tl, tr) + moduleSizeAvg(b, tl, bl)) / 2;
904
+ const moduleSize = (moduleSizeAvg(bm, tl, tr) + moduleSizeAvg(bm, tl, bl)) / 2;
865
905
  if (moduleSize < 1.0) throw new Error(`invalid moduleSize = ${moduleSize}`);
866
906
  // Estimate size
867
907
  const tltr = int(distance(tl, tr) / moduleSize + 0.5);
868
908
  const tlbl = int(distance(tl, bl) / moduleSize + 0.5);
869
909
  let size = int((tltr + tlbl) / 2 + 7);
910
+ // QR side lengths are 21 + 4 * (version - 1), so normalize the estimate
911
+ // to the nearest size that is 1 modulo 4 before decoding the version.
870
912
  const rem = size % 4;
871
913
  if (rem === 0)
872
914
  size++; // -> 1
873
915
  else if (rem === 2)
874
916
  size--; // -> 1
875
917
  else if (rem === 3) size -= 2;
876
- const version = info.size.decode(size);
877
- validateVersion(version);
918
+ const version = utils.info.size.decode(size);
919
+ utils.validateVersion(version);
878
920
  let alignmentPattern;
879
- if (info.alignmentPatterns(version).length > 0) {
921
+ if (utils.info.alignmentPatterns(version).length > 0) {
880
922
  // Bottom right estimate
881
923
  const br = { x: tr.x - tl.x + bl.x, y: tr.y - tl.y + bl.y };
882
- const c = 1.0 - 3.0 / (info.size.encode(version) - 7);
924
+ const c = 1.0 - 3.0 / (utils.info.size.encode(version) - 7);
883
925
  // Estimated alignment pattern position
884
926
  const est = {
885
927
  x: int(tl.x + c * (br.x - tl.x)),
@@ -889,7 +931,7 @@ function detect(b: Bitmap): {
889
931
  };
890
932
  for (let i = 4; i <= 16; i <<= 1) {
891
933
  try {
892
- alignmentPattern = findAlignment(b, est, i);
934
+ alignmentPattern = findAlignment(bm, est, i);
893
935
  break;
894
936
  } catch (e) {}
895
937
  }
@@ -907,14 +949,16 @@ function detect(b: Bitmap): {
907
949
  toBR = { x: size - 3.5, y: size - 3.5 };
908
950
  }
909
951
  const from: FinderPoints = [tl, tr, br, bl];
910
- const bits = transform(b, size, from, [toTL, toTR, toBR, toBL]);
911
- return { bits: bits, points: from };
952
+ const bits = transform(bm, size, from, [toTL, toTR, toBR, toBL]);
953
+ return { bits: bits as Bitmap, points: from } as TRet<{ bits: Bitmap; points: FinderPoints }>;
912
954
  }
913
955
 
914
956
  // Perspective transform by 4 points
915
957
  function squareToQuadrilateral(p: Point4) {
916
958
  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 };
917
959
  if (d3.x === 0.0 && d3.y === 0.0) {
960
+ // Parallelogram fast path: perspective terms vanish, so the homography
961
+ // reduces to an affine transform.
918
962
  return [
919
963
  [p[1].x - p[0].x, p[2].x - p[1].x, p[0].x],
920
964
  [p[1].y - p[0].y, p[2].y - p[1].y, p[0].y],
@@ -935,10 +979,13 @@ function squareToQuadrilateral(p: Point4) {
935
979
  }
936
980
 
937
981
  // Transform quadrilateral to square by 4 points
938
- function transform(b: Bitmap, size: number, from: Point4, to: Point4): Bitmap {
982
+ function transform(b: TArg<Bitmap>, size: number, from: Point4, to: Point4): TRet<Bitmap> {
983
+ const bm = b as Bitmap;
939
984
  // TODO: check
940
985
  // https://math.stackexchange.com/questions/13404/mapping-irregular-quadrilateral-to-a-rectangle
941
986
  const p = squareToQuadrilateral(to);
987
+ // Homographies are scale-invariant, so the adjugate is enough here;
988
+ // there is no need to divide by the determinant when inverting `p`.
942
989
  const qToS = [
943
990
  [
944
991
  p[1][1] * p[2][2] - p[2][1] * p[1][2],
@@ -962,7 +1009,7 @@ function transform(b: Bitmap, size: number, from: Point4, to: Point4): Bitmap {
962
1009
  );
963
1010
 
964
1011
  const res = new Bitmap(size);
965
- const points = fillArr(2 * size, 0);
1012
+ const points = utils.fillArr(2 * size, 0);
966
1013
  const pointsLength = points.length;
967
1014
  for (let y = 0; y < size; y++) {
968
1015
  const p = transform;
@@ -970,23 +1017,28 @@ function transform(b: Bitmap, size: number, from: Point4, to: Point4): Bitmap {
970
1017
  const x = i / 2 + 0.5;
971
1018
  const y2 = y + 0.5;
972
1019
  const den = p[2][0] * x + p[2][1] * y2 + p[2][2];
973
- points[i] = int((p[0][0] * x + p[0][1] * y2 + p[0][2]) / den);
974
- points[i + 1] = int((p[1][0] * x + p[1][1] * y2 + p[1][2]) / den);
1020
+ // ISO/IEC 18004:2024 §12 h) maps sampling grid intersections back to
1021
+ // image coordinates before deciding dark/light state. Clip projected
1022
+ // coordinates before `int()` because `>>> 0` wraps negative samples to a
1023
+ // huge uint32 and would clamp them to the far image edge.
1024
+ points[i] = int(cap((p[0][0] * x + p[0][1] * y2 + p[0][2]) / den, 0, bm.width - 1));
1025
+ points[i + 1] = int(cap((p[1][0] * x + p[1][1] * y2 + p[1][2]) / den, 0, bm.height - 1));
975
1026
  }
976
1027
  for (let i = 0; i < pointsLength; i += 2) {
977
- const px = cap(points[i], 0, b.width - 1);
978
- const py = cap(points[i + 1], 0, b.height - 1);
979
- if (b.get(px, py)) res.set((i / 2) | 0, y, true);
1028
+ if (bm.get(points[i], points[i + 1])) res.set((i / 2) | 0, y, true);
980
1029
  }
981
1030
  }
982
- return res;
1031
+ return res as TRet<Bitmap>;
983
1032
  }
984
1033
 
985
1034
  // Same as in drawTemplate, but reading
986
1035
  // TODO: merge in CoderType?
987
- function readInfoBits(b: Bitmap) {
988
- const readBit = (x: number, y: number, out: number) => (out << 1) | (b.get(x, y) ? 1 : 0);
989
- const size = b.height;
1036
+ function readInfoBits(b: TArg<Bitmap>) {
1037
+ const bm = b as Bitmap;
1038
+ // Walk each reserved copy from the highest-numbered module back to module 0
1039
+ // so the shift accumulator rebuilds the canonical bit string from MSB to LSB.
1040
+ const readBit = (x: number, y: number, out: number) => (out << 1) | (bm.get(x, y) ? 1 : 0);
1041
+ const size = bm.height;
990
1042
  // Version information
991
1043
  let version1 = 0;
992
1044
  for (let y = 5; y >= 0; y--)
@@ -1007,45 +1059,48 @@ function readInfoBits(b: Bitmap) {
1007
1059
  return { version1, version2, format1, format2 };
1008
1060
  }
1009
1061
 
1010
- function parseInfo(b: Bitmap) {
1062
+ function parseInfo(b: TArg<Bitmap>) {
1063
+ const bm = b as Bitmap;
1011
1064
  // Population count over xor -> hamming distance
1012
- const size = b.height;
1013
- const { version1, version2, format1, format2 } = readInfoBits(b);
1065
+ const size = bm.height;
1066
+ const { version1, version2, format1, format2 } = readInfoBits(bm);
1014
1067
  // Guess format
1015
1068
  let format;
1016
- const bestFormat = best<{ ecc: ErrorCorrection; mask: Mask }>();
1069
+ const bestFormat = utils.best<{ ecc: ErrorCorrection; mask: Mask }>();
1017
1070
  for (const ecc of ['medium', 'low', 'high', 'quartile'] as const) {
1018
1071
  for (let mask: Mask = 0; mask < 8; mask++) {
1019
- const bits = info.formatBits(ecc, mask as Mask);
1072
+ const bits = utils.info.formatBits(ecc, mask as Mask);
1020
1073
  const cur = { ecc, mask: mask as Mask };
1021
1074
  if (bits === format1 || bits === format2) {
1022
1075
  format = cur;
1023
1076
  break;
1024
1077
  }
1025
- bestFormat.add(popcnt(format1 ^ bits), cur);
1026
- if (format1 !== format2) bestFormat.add(popcnt(format2 ^ bits), cur);
1078
+ bestFormat.add(utils.popcnt(format1 ^ bits), cur);
1079
+ if (format1 !== format2) bestFormat.add(utils.popcnt(format2 ^ bits), cur);
1027
1080
  }
1028
1081
  }
1029
1082
  if (format === undefined && bestFormat.score() <= MAX_BITS_ERROR) format = bestFormat.get();
1030
1083
  if (format === undefined) throw new Error('invalid format pattern');
1031
- let version: number | undefined = info.size.decode(size); // Guess version based on bitmap size
1032
- if (version < 7) validateVersion(version);
1084
+ let version: number | undefined = utils.info.size.decode(size); // Guess version based on bitmap size
1085
+ // Versions 1-6 do not carry version-information words, so side length is
1086
+ // the only authoritative version source until version 7 adds those fields.
1087
+ if (version < 7) utils.validateVersion(version);
1033
1088
  else {
1034
1089
  version = undefined;
1035
1090
  // Guess version
1036
- const bestVer = best<number>();
1091
+ const bestVer = utils.best<number>();
1037
1092
  for (let ver = 7; ver <= 40; ver++) {
1038
- const bits = info.versionBits(ver);
1093
+ const bits = utils.info.versionBits(ver);
1039
1094
  if (bits === version1 || bits === version2) {
1040
1095
  version = ver;
1041
1096
  break;
1042
1097
  }
1043
- bestVer.add(popcnt(version1 ^ bits), ver);
1044
- if (version1 !== version2) bestVer.add(popcnt(version2 ^ bits), ver);
1098
+ bestVer.add(utils.popcnt(version1 ^ bits), ver);
1099
+ if (version1 !== version2) bestVer.add(utils.popcnt(version2 ^ bits), ver);
1045
1100
  }
1046
1101
  if (version === undefined && bestVer.score() <= MAX_BITS_ERROR) version = bestVer.get();
1047
1102
  if (version === undefined) throw new Error('invalid version pattern');
1048
- if (info.size.encode(version) !== size) throw new Error('invalid version size');
1103
+ if (utils.info.size.encode(version) !== size) throw new Error('invalid version size');
1049
1104
  }
1050
1105
  return { version, ...format };
1051
1106
  }
@@ -1054,7 +1109,10 @@ function parseInfo(b: Bitmap) {
1054
1109
  // See https://github.com/microsoft/TypeScript/issues/31535
1055
1110
  declare const TextDecoder: any;
1056
1111
 
1057
- // Common encodings, please open issue if something popular missing
1112
+ // ISO/IEC 18004:2024 §7.4.3.2 says each ECI is a "6-digit assignment
1113
+ // number"; §7.4.3.4 says invoked ECIs apply until "a change of ECI".
1114
+ // Decode through platform TextDecoder only: unsupported labels may throw on
1115
+ // some runtimes, and callers needing them can provide a custom textDecoder.
1058
1116
  const eciToEncoding: Record<number, string> = {
1059
1117
  1: 'iso-8859-1',
1060
1118
  2: 'ibm437',
@@ -1084,38 +1142,42 @@ const eciToEncoding: Record<number, string> = {
1084
1142
  30: 'euc-kr',
1085
1143
  };
1086
1144
 
1087
- function decodeWithEci(bytes: Uint8Array, eci: number = 26): string {
1145
+ function decodeWithEci(bytes: TArg<Uint8Array>, eci: number = 26): string {
1146
+ // ISO/IEC 18004:2024 §7.3.2 says QR's "default interpretation" is
1147
+ // "ECI 000003 representing the ISO/IEC 8859-1 character set". Keep UTF-8
1148
+ // here so this library's UTF-8 byte-mode encoder round-trips without ECI.
1088
1149
  const encoding = eciToEncoding[eci];
1089
1150
  if (!encoding) throw new Error(`Unsupported ECI: ${eci}`);
1090
1151
  return new TextDecoder(encoding).decode(bytes);
1091
1152
  }
1092
1153
 
1093
1154
  function decodeBitmap(
1094
- b: Bitmap,
1095
- decoder: (bytes: Uint8Array, eci: number) => string = decodeWithEci
1155
+ b: TArg<Bitmap>,
1156
+ decoder: TArg<(bytes: Uint8Array, eci: number) => string> = decodeWithEci
1096
1157
  ): string {
1097
- const size = b.height;
1098
- if (size < 21 || (size & 0b11) !== 1 || size !== b.width)
1158
+ const bm = b as Bitmap;
1159
+ const size = bm.height;
1160
+ if (size < 21 || (size & 0b11) !== 1 || size !== bm.width)
1099
1161
  throw new Error(`decode: invalid size=${size}`);
1100
- const { version, mask, ecc } = parseInfo(b);
1101
- const tpl = drawTemplate(version, ecc, mask);
1102
- const { total } = info.capacity(version, ecc);
1162
+ const { version, mask, ecc } = parseInfo(bm);
1163
+ const tpl = utils.drawTemplate(version, ecc, mask);
1164
+ const { total } = utils.info.capacity(version, ecc);
1103
1165
  const bytes = new Uint8Array(total);
1104
1166
  let pos = 0;
1105
1167
  let buf = 0;
1106
1168
  let bitPos = 0;
1107
- zigzag(tpl, mask, (x, y, m) => {
1169
+ utils.zigzag(tpl, mask, (x, y, m) => {
1108
1170
  bitPos++;
1109
1171
  buf <<= 1;
1110
- buf |= +(!!b.get(x, y) !== m);
1172
+ buf |= +(!!bm.get(x, y) !== m);
1111
1173
  if (bitPos !== 8) return;
1112
1174
  bytes[pos++] = buf;
1113
1175
  bitPos = 0;
1114
1176
  buf = 0;
1115
1177
  });
1116
1178
  if (pos !== total) throw new Error(`decode: pos=${pos}, total=${total}`);
1117
- let bits = Array.from(interleave(version, ecc).decode(bytes))
1118
- .map((i) => bin(i, 8))
1179
+ let bits = Array.from(utils.interleave(version, ecc).decode(bytes))
1180
+ .map((i) => utils.bin(i, 8))
1119
1181
  .join('');
1120
1182
  // Reverse operation of index.ts/encode working on bits
1121
1183
  const readBits = (n: number) => {
@@ -1135,6 +1197,9 @@ function decodeBitmap(
1135
1197
  '1000': 'kanji',
1136
1198
  };
1137
1199
  let res = '';
1200
+ // ISO/IEC 18004:2024 §7.3.2 defines no-ECI byte data as ECI 000003
1201
+ // / ISO-8859-1. Keep ECI 26 for compatibility with legacy no-ECI UTF-8
1202
+ // payloads and with encodeQR's UTF-8 byte-mode default.
1138
1203
  let eci: number = 26; // Default to utf-8 for compat with old behavior
1139
1204
  while (true) {
1140
1205
  if (bits.length < 4) break;
@@ -1142,7 +1207,7 @@ function decodeBitmap(
1142
1207
  const mode = modes[modeBits];
1143
1208
  if (mode === undefined) throw new Error(`Unknown modeBits=${modeBits} res="${res}"`);
1144
1209
  if (mode === 'terminator') break;
1145
- const countBits = info.lengthBits(version, mode);
1210
+ const countBits = utils.info.lengthBits(version, mode);
1146
1211
  let count = toNum(readBits(countBits));
1147
1212
  if (mode === 'numeric') {
1148
1213
  while (count >= 3) {
@@ -1163,10 +1228,10 @@ function decodeBitmap(
1163
1228
  } else if (mode === 'alphanumeric') {
1164
1229
  while (count >= 2) {
1165
1230
  const v = toNum(readBits(11));
1166
- res += info.alphabet.alphanumerc.encode([Math.floor(v / 45), v % 45]).join('');
1231
+ res += utils.info.alphabet.alphanumerc.encode([Math.floor(v / 45), v % 45]).join('');
1167
1232
  count -= 2;
1168
1233
  }
1169
- if (count === 1) res += info.alphabet.alphanumerc.encode([toNum(readBits(6))]).join('');
1234
+ if (count === 1) res += utils.info.alphabet.alphanumerc.encode([toNum(readBits(6))]).join('');
1170
1235
  } else if (mode === 'eci') {
1171
1236
  const first = toNum(readBits(8));
1172
1237
  if ((first & 0x80) === 0) eci = first;
@@ -1177,24 +1242,69 @@ function decodeBitmap(
1177
1242
  const data = new Uint8Array(count);
1178
1243
  for (let i = 0; i < count; i++) data[i] = toNum(readBits(8));
1179
1244
  res += decoder(data, eci);
1245
+ } else if (mode === 'kanji') {
1246
+ // ISO/IEC 18004:2024 §7.3.6 says Kanji mode uses Shift JIS and
1247
+ // "Each two-byte character value is compacted to a 13-bit binary
1248
+ // codeword." Keep it unsupported until a real interop fixture can verify
1249
+ // the 13-bit-to-Shift-JIS mapping rather than adding untested decoding.
1250
+ throw new Error('Kanji mode is not supported');
1180
1251
  } else throw new Error(`Unknown mode=${mode}`);
1181
1252
  }
1182
1253
  return res;
1183
1254
  }
1184
1255
 
1256
+ /** QR decoding hooks and image preprocessing options. */
1185
1257
  export type DecodeOpts = {
1258
+ /** Crop rectangular inputs to a centered square before decoding. */
1186
1259
  cropToSquare?: boolean;
1187
- textDecoder?: (bytes: Uint8Array) => string;
1260
+ /**
1261
+ * Custom byte-to-text decoder used for byte segments.
1262
+ *
1263
+ * Receives the byte segment and, when needed, the active ECI designator.
1264
+ * ISO/IEC 18004:2024 §7.4.3.4 keeps ECIs active "until the end of
1265
+ * the encoded data or a change of ECI", so callers need the active
1266
+ * designator to apply their own charset policy.
1267
+ * @param bytes - Byte segment payload to decode.
1268
+ * @param eci - Active ECI designator for the byte segment.
1269
+ * @returns Decoded text for the byte segment.
1270
+ */
1271
+ textDecoder?: TArg<(bytes: Uint8Array, eci?: number) => string>;
1272
+ /**
1273
+ * Callback invoked with finder/alignment points after detection succeeds.
1274
+ *
1275
+ * Receives finder and alignment points returned by the detector.
1276
+ * @param points - Finder and alignment points returned by the detector.
1277
+ */
1188
1278
  pointsOnDetect?: (points: FinderPoints) => void;
1279
+ /**
1280
+ * Callback invoked with the grayscale bitmap that the detector sees.
1281
+ *
1282
+ * Receives the grayscale image generated during bitmap conversion.
1283
+ * @param img - Grayscale image generated during bitmap conversion.
1284
+ */
1189
1285
  imageOnBitmap?: (img: Image) => void;
1286
+ /**
1287
+ * Callback invoked with the perspective-corrected QR image.
1288
+ *
1289
+ * Receives the perspective-corrected QR image.
1290
+ * @param img - Perspective-corrected QR image.
1291
+ */
1190
1292
  imageOnDetect?: (img: Image) => void;
1293
+ /**
1294
+ * Callback invoked with the final decoded QR image.
1295
+ *
1296
+ * Receives the final QR image used to decode the payload.
1297
+ * @param img - Final QR image used to decode the payload.
1298
+ */
1191
1299
  imageOnResult?: (img: Image) => void;
1192
1300
  };
1193
1301
 
1194
- // Creates square from rectangle
1195
- function cropToSquare(img: Image) {
1196
- const data = Array.isArray(img.data) ? new Uint8Array(img.data) : img.data;
1197
- const { height, width } = img;
1302
+ // Center-crop to a square and return the original-image offset so
1303
+ // `decodeQR()` can map detected points back into caller coordinates.
1304
+ function cropToSquare(img: TArg<Image>) {
1305
+ const image = img as Image;
1306
+ const data = Array.isArray(image.data) ? new Uint8Array(image.data) : image.data;
1307
+ const { height, width } = image;
1198
1308
  const squareSize = Math.min(height, width);
1199
1309
  const offset = {
1200
1310
  x: Math.floor((width - squareSize) / 2),
@@ -1211,46 +1321,99 @@ function cropToSquare(img: Image) {
1211
1321
  return { offset, img: { height: squareSize, width: squareSize, data: croppedData } };
1212
1322
  }
1213
1323
 
1214
- export function decodeQR(img: Image, opts: DecodeOpts = {}): string {
1324
+ /**
1325
+ * Decode text from a QR image.
1326
+ * @param img - RGB or RGBA image data that contains a QR code.
1327
+ * @param opts - Decoder hooks and image preprocessing options. See {@link DecodeOpts}.
1328
+ * @returns Decoded QR payload as a string.
1329
+ * @throws If the image, decoder options, or QR contents are invalid. {@link Error}
1330
+ * @example
1331
+ * Decode text from a QR image.
1332
+ * ```ts
1333
+ * import encodeQR, { Bitmap } from 'qr';
1334
+ * import decodeQR from 'qr/decode.js';
1335
+ * const bits = encodeQR('Hello world', 'raw', { scale: 4 });
1336
+ * const bm = new Bitmap({ width: bits[0].length, height: bits.length }, bits);
1337
+ * const text = decodeQR(bm.toImage());
1338
+ * ```
1339
+ */
1340
+ export function decodeQR(img: TArg<Image>, opts: TArg<DecodeOpts> = {}): string {
1341
+ let image = img as Image;
1342
+ const options = opts as DecodeOpts;
1215
1343
  for (const field of ['height', 'width'] as const) {
1216
- if (!Number.isSafeInteger(img[field]) || img[field] <= 0)
1217
- throw new Error(`invalid img.${field}=${img[field]} (${typeof img[field]})`);
1344
+ if (!Number.isSafeInteger(image[field]) || image[field] <= 0)
1345
+ throw new Error(`invalid img.${field}=${image[field]} (${typeof image[field]})`);
1218
1346
  }
1219
- const { data } = img;
1347
+ const { data } = image;
1220
1348
  if (!Array.isArray(data) && !isBytes(data))
1221
1349
  throw new Error(`invalid image.data=${data} (${typeof data})`);
1222
- if (opts.cropToSquare !== undefined && typeof opts.cropToSquare !== 'boolean')
1223
- throw new Error(`invalid opts.cropToSquare=${opts.cropToSquare}`);
1224
- for (const fn of ['pointsOnDetect', 'imageOnBitmap', 'imageOnDetect', 'imageOnResult'] as const) {
1225
- if (opts[fn] !== undefined && typeof opts[fn] !== 'function')
1226
- throw new Error(`invalid opts.${fn}=${opts[fn]} (${typeof opts[fn]})`);
1350
+ if (options.cropToSquare !== undefined && typeof options.cropToSquare !== 'boolean')
1351
+ throw new Error(`invalid opts.cropToSquare=${options.cropToSquare}`);
1352
+ // Validate callbacks before decoding so payload mode does not decide whether
1353
+ // an invalid public option is accepted.
1354
+ for (const fn of [
1355
+ 'textDecoder',
1356
+ 'pointsOnDetect',
1357
+ 'imageOnBitmap',
1358
+ 'imageOnDetect',
1359
+ 'imageOnResult',
1360
+ ] as const) {
1361
+ if (options[fn] !== undefined && typeof options[fn] !== 'function')
1362
+ throw new Error(`invalid opts.${fn}=${options[fn]} (${typeof options[fn]})`);
1227
1363
  }
1228
1364
  let offset = { x: 0, y: 0 };
1229
- if (opts.cropToSquare) ({ img, offset } = cropToSquare(img));
1230
- const bmp = toBitmap(img);
1231
- if (opts.imageOnBitmap) opts.imageOnBitmap(bmp.toImage());
1365
+ if (options.cropToSquare) ({ img: image, offset } = cropToSquare(image));
1366
+ const bmp = toBitmap(image) as Bitmap;
1367
+ if (options.imageOnBitmap) options.imageOnBitmap(bmp.toImage());
1232
1368
  const { bits, points } = detect(bmp);
1233
- if (opts.pointsOnDetect) {
1369
+ if (options.pointsOnDetect) {
1370
+ // Report finder points in the caller's original coordinate space after any center-crop.
1234
1371
  const p = points.map((i) => ({ ...i, ...pointAdd(i, offset) })) as FinderPoints;
1235
- opts.pointsOnDetect(p);
1372
+ options.pointsOnDetect(p);
1236
1373
  }
1237
- if (opts.imageOnDetect) opts.imageOnDetect(bits.toImage());
1238
- const res = decodeBitmap(bits, opts.textDecoder);
1239
- if (opts.imageOnResult) opts.imageOnResult(bits.toImage());
1374
+ if (options.imageOnDetect) options.imageOnDetect(bits.toImage());
1375
+ const res = decodeBitmap(bits, options.textDecoder);
1376
+ if (options.imageOnResult) options.imageOnResult(bits.toImage());
1240
1377
  return res;
1241
1378
  }
1242
1379
 
1380
+ /**
1381
+ * Default export alias for {@link decodeQR}.
1382
+ * @param img - RGB or RGBA image data that contains a QR code.
1383
+ * @param opts - Decoder hooks and image preprocessing options. See {@link DecodeOpts}.
1384
+ * @returns Decoded QR payload as a string.
1385
+ * @throws If the image, decoder options, or QR contents are invalid. {@link Error}
1386
+ * @example
1387
+ * Decode a rendered QR image via the default decoder export.
1388
+ * ```ts
1389
+ * import encodeQR, { Bitmap } from 'qr';
1390
+ * import decodeQR from 'qr/decode.js';
1391
+ * const bits = encodeQR('Hello world', 'raw', { scale: 4 });
1392
+ * const bm = new Bitmap({ width: bits[0].length, height: bits.length }, bits);
1393
+ * decodeQR(bm.toImage());
1394
+ * ```
1395
+ */
1243
1396
  export default decodeQR;
1244
1397
 
1398
+ // Additional private helpers for focused regression tests; separate from the
1399
+ // existing `_tests` object so its current keys remain stable.
1400
+ export const _TESTS: {
1401
+ findAlignment: typeof findAlignment;
1402
+ transform: typeof transform;
1403
+ } = /* @__PURE__ */ Object.freeze({
1404
+ findAlignment: findAlignment,
1405
+ transform: transform,
1406
+ });
1407
+
1245
1408
  // Unsafe API utils, exported only for tests
1246
1409
  export const _tests: {
1247
1410
  toBitmap: typeof toBitmap;
1248
1411
  decodeBitmap: typeof decodeBitmap;
1249
1412
  findFinder: typeof findFinder;
1250
1413
  detect: typeof detect;
1251
- } = {
1414
+ } = /* @__PURE__ */ Object.freeze({
1252
1415
  toBitmap,
1253
1416
  decodeBitmap,
1254
1417
  findFinder,
1255
1418
  detect,
1256
- };
1419
+ });