@forgesworn/qr 0.0.0-seed.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/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/SPEC.md +136 -0
- package/dist/binarise.d.ts +26 -0
- package/dist/binarise.js +115 -0
- package/dist/canvas.d.ts +29 -0
- package/dist/canvas.js +44 -0
- package/dist/decode.d.ts +42 -0
- package/dist/decode.js +98 -0
- package/dist/encode.d.ts +76 -0
- package/dist/encode.js +495 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +5 -0
- package/dist/locate.d.ts +37 -0
- package/dist/locate.js +489 -0
- package/dist/rs.d.ts +6 -0
- package/dist/rs.js +99 -0
- package/dist/svg.d.ts +17 -0
- package/dist/svg.js +35 -0
- package/dist/symbol.d.ts +25 -0
- package/dist/symbol.js +250 -0
- package/dist/tables.d.ts +8 -0
- package/dist/tables.js +93 -0
- package/dist/terminal.d.ts +18 -0
- package/dist/terminal.js +30 -0
- package/dist/text.d.ts +4 -0
- package/dist/text.js +48 -0
- package/llms.txt +37 -0
- package/package.json +68 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- First release: encoder (versions 1-40, levels L/M/Q/H, numeric,
|
|
6
|
+
alphanumeric and byte modes, UTF-8 ECI) with SVG, canvas and terminal
|
|
7
|
+
renderers.
|
|
8
|
+
- Decoder: RGBA or greyscale frames, local-threshold binarisation, finder
|
|
9
|
+
and alignment search, perspective sampling, Reed-Solomon correction,
|
|
10
|
+
inverted and mirrored codes, numeric, alphanumeric, byte, kanji (as
|
|
11
|
+
bytes), ECI, structured append and FNC1 segments. `decodeModules` for a
|
|
12
|
+
module grid.
|
|
13
|
+
- Encode vectors from ZXing core 3.5.3; jsQR round-trip tests; decoder
|
|
14
|
+
tests on synthetic distorted frames with jsQR as the bar.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TheCryptoDonkey
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# @forgesworn/qr
|
|
2
|
+
|
|
3
|
+
A QR code encoder and decoder for browsers, workers and Node, with no
|
|
4
|
+
dependencies. Versions 1-40, all four error correction levels, numeric,
|
|
5
|
+
alphanumeric and byte modes, optional UTF-8 ECI, and SVG, canvas and
|
|
6
|
+
terminal output. The decoder reads a code from camera or screenshot pixels.
|
|
7
|
+
|
|
8
|
+
The tests compare 114 symbols module for module with an independent encoder
|
|
9
|
+
(ZXing, as a test-time oracle), covering every version and mask, and have an
|
|
10
|
+
independent decoder (jsQR) read back random payloads and every renderer's
|
|
11
|
+
output. Neither ships in the package.
|
|
12
|
+
[SPEC.md](SPEC.md) defines the behaviour exactly, so ports in other
|
|
13
|
+
languages can produce identical output.
|
|
14
|
+
|
|
15
|
+
The decoder tests draw codes through synthetic camera effects (rotation,
|
|
16
|
+
perspective, small modules, blur, noise, uneven light, inversion and
|
|
17
|
+
mirroring) and require it to read at least as many of those frames as jsQR
|
|
18
|
+
does. They are synthetic; real phone photographs are not yet part of the
|
|
19
|
+
test set.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @forgesworn/qr
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
ESM only, Node.js 22.13 or later, or any modern browser.
|
|
28
|
+
|
|
29
|
+
## Use
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { drawCanvas, encode, isDark, toSvg, toTerminal } from '@forgesworn/qr'
|
|
33
|
+
|
|
34
|
+
const qr = encode('https://example.com/', { ecc: 'Q' })
|
|
35
|
+
qr.version // 2
|
|
36
|
+
qr.size // 25 modules per side, quiet zone not included
|
|
37
|
+
isDark(qr, 0, 0) // true: the top-left finder
|
|
38
|
+
|
|
39
|
+
const svg = toSvg(qr, { margin: 4, dark: '#111', light: '#fff', title: 'Link to example.com' })
|
|
40
|
+
|
|
41
|
+
drawCanvas(document.querySelector('canvas')!, qr, { width: 320 }) // or an OffscreenCanvas
|
|
42
|
+
console.log(toTerminal(qr)) // half-block text for a dark terminal; { ink: 'dark' } for a light one
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`encode(data, options)` takes a string (encoded as UTF-8) or a
|
|
46
|
+
`Uint8Array`. Options:
|
|
47
|
+
|
|
48
|
+
| Option | Default | |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `ecc` | `'M'` | `'L'`, `'M'`, `'Q'` or `'H'` |
|
|
51
|
+
| `version` | smallest that fits | 1-40; throws `QrCapacityError` if the data does not fit |
|
|
52
|
+
| `minVersion` | 1 | lower bound when choosing a version |
|
|
53
|
+
| `mask` | lowest penalty | 0-7 |
|
|
54
|
+
| `mode` | most compact | `'numeric'`, `'alphanumeric'` or `'byte'` |
|
|
55
|
+
| `eci` | `false` | in byte mode, mark the data as UTF-8 |
|
|
56
|
+
|
|
57
|
+
The result's `modules` is a row-major `Uint8Array` of `size * size`
|
|
58
|
+
entries, 1 for dark, with no quiet zone.
|
|
59
|
+
|
|
60
|
+
Renderers, each with `margin` (quiet zone in modules, default 4):
|
|
61
|
+
|
|
62
|
+
- `toSvg(qr, { dark, light, title })`: an SVG string, one path, `light: null` for no background.
|
|
63
|
+
- `drawCanvas(canvas, qr, { width, dark, light })`: sizes the canvas to `width` pixels
|
|
64
|
+
square (default 8 per module) with module edges on whole pixels.
|
|
65
|
+
- `toTerminal(qr, { ink })`: two rows per line in half blocks; `ink: 'light'` (default)
|
|
66
|
+
for dark terminals, `'dark'` for light ones.
|
|
67
|
+
|
|
68
|
+
### Reading
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { decode } from '@forgesworn/qr'
|
|
72
|
+
|
|
73
|
+
const frame = ctx.getImageData(0, 0, width, height) // RGBA, as a canvas gives
|
|
74
|
+
const found = decode(frame)
|
|
75
|
+
found?.text // the payload as text: UTF-8 when it is valid UTF-8, else Latin-1, unless an ECI says
|
|
76
|
+
found?.bytes // the raw bytes
|
|
77
|
+
found?.location // picture coordinates of the three finders
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`decode(image, options)` takes `{ data, width, height }` with RGBA or
|
|
81
|
+
greyscale bytes and returns `undefined` when it finds no readable code.
|
|
82
|
+
Options: `rowStride` (pixels a row, for padded camera planes), `channels`
|
|
83
|
+
(1 or 4; worked out from the data length by default) and `inversion`
|
|
84
|
+
(`'both'` by default, or `'normal'` or `'inverted'`). The result also says
|
|
85
|
+
whether the code was `mirrored` or `inverted`, and its `version`, `ecc` and
|
|
86
|
+
`mask`. Kanji segments are kept as bytes and show as U+FFFD in `text`. A frame with no code in it costs more than one with a code; on a
|
|
87
|
+
1280x720 frame of pure noise, expect a few hundred milliseconds on a
|
|
88
|
+
laptop, so throttle camera loops.
|
|
89
|
+
|
|
90
|
+
`decodeModules(modules, size)` reads a module grid you already have.
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npm install
|
|
96
|
+
npm run check # typecheck and tests
|
|
97
|
+
npm run build # compile to dist/
|
|
98
|
+
npm run generate-vectors # regenerate the ZXing vectors (needs a JDK and network once)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Support
|
|
102
|
+
|
|
103
|
+
For issues and feature requests, see [GitHub Issues](https://github.com/forgesworn/qr/issues).
|
|
104
|
+
|
|
105
|
+
If you find this library useful, consider sending a tip:
|
|
106
|
+
|
|
107
|
+
- **Lightning:** `profusemeat89@walletofsatoshi.com`
|
|
108
|
+
- **Nostr zaps:** `npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2`
|
|
109
|
+
|
|
110
|
+
## Licence
|
|
111
|
+
|
|
112
|
+
MIT
|
package/SPEC.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Specification
|
|
2
|
+
|
|
3
|
+
This is what every implementation of `@forgesworn/qr` (TypeScript now,
|
|
4
|
+
Kotlin and Rust later) must do, so that the same input gives the same
|
|
5
|
+
symbol byte for byte. `vectors/encode-vectors.json` is the test of it.
|
|
6
|
+
Section numbers refer to ISO/IEC 18004:2015.
|
|
7
|
+
|
|
8
|
+
## Input
|
|
9
|
+
|
|
10
|
+
- Text is encoded as UTF-8. Lone surrogates become U+FFFD.
|
|
11
|
+
- Bytes are encoded as given, always in byte mode.
|
|
12
|
+
|
|
13
|
+
## Mode
|
|
14
|
+
|
|
15
|
+
One segment per symbol. Unless the caller names a mode, text whose every
|
|
16
|
+
character is a digit is numeric; text whose every character is in
|
|
17
|
+
`0-9 A-Z space $ % * + - . / :` is alphanumeric; anything else, and the
|
|
18
|
+
empty string, is byte. Kanji mode and mixed segments are not produced.
|
|
19
|
+
|
|
20
|
+
With `eci: true`, a byte-mode symbol starts with the ECI designator
|
|
21
|
+
`0111` and assignment 26 (UTF-8) as 8 bits (section 7.4.2). Numeric and
|
|
22
|
+
alphanumeric symbols never carry one.
|
|
23
|
+
|
|
24
|
+
## Version and level
|
|
25
|
+
|
|
26
|
+
Level M unless the caller names one. The version is the smallest, from
|
|
27
|
+
`minVersion` (default 1), whose data capacity holds the header (ECI if any,
|
|
28
|
+
4-bit mode, character count in the width for that version band:
|
|
29
|
+
1-9, 10-26, 27-40) and the data, and whose count field can hold the
|
|
30
|
+
character count. A requested version that cannot hold the data is an
|
|
31
|
+
error, never a silent upgrade.
|
|
32
|
+
|
|
33
|
+
## Bit stream and error correction
|
|
34
|
+
|
|
35
|
+
Terminator of up to four zero bits, zero bits to a byte boundary, then
|
|
36
|
+
pad bytes `0xEC`, `0x11` alternately (section 7.4.10). Codewords split into
|
|
37
|
+
blocks as Table 9 gives (group 2 blocks hold one more data codeword);
|
|
38
|
+
Reed-Solomon over GF(256) with polynomial `0x11D`, generator roots
|
|
39
|
+
`α^0 .. α^(n-1)`. Data codewords are interleaved by index across blocks,
|
|
40
|
+
then error correction codewords the same way.
|
|
41
|
+
|
|
42
|
+
## Matrix
|
|
43
|
+
|
|
44
|
+
Finder patterns with separators, the dark module at (8, size - 8),
|
|
45
|
+
alignment patterns at every pair of Annex E centres except those on a
|
|
46
|
+
finder, timing patterns on row and column 6, then format and version
|
|
47
|
+
areas reserved. Data fills two-module columns from the bottom right,
|
|
48
|
+
upward first, skipping column 6 and every reserved module; remainder
|
|
49
|
+
bits are light before masking.
|
|
50
|
+
|
|
51
|
+
Format information is `(level bits << 3 | mask)` with level bits L=01,
|
|
52
|
+
M=00, Q=11, H=10, BCH(15,5) with generator `0x537`, XOR `0x5412`. Bit
|
|
53
|
+
`i` (least significant first) goes at the i-th of (8,0) (8,1) (8,2) (8,3)
|
|
54
|
+
(8,4) (8,5) (8,7) (8,8) (7,8) (5,8) (4,8) (3,8) (2,8) (1,8) (0,8), as
|
|
55
|
+
(x, y), and again at (size - 1 - i, 8) for `i < 8` or (8, size - 15 + i)
|
|
56
|
+
otherwise. Version information (version 7 and up) is BCH(18,6) with
|
|
57
|
+
generator `0x1F25`; bit `k` (least significant first) goes at
|
|
58
|
+
(k / 3, size - 11 + k % 3) and its transpose.
|
|
59
|
+
|
|
60
|
+
## Mask
|
|
61
|
+
|
|
62
|
+
Unless the caller names one, every mask 0-7 is applied to the finished
|
|
63
|
+
symbol (format and version information included) and scored with the four
|
|
64
|
+
penalty rules of section 7.8.3: N1 = 3 per run of five or more same-colour
|
|
65
|
+
modules plus one per module beyond five, rows and columns; N2 = 3 per 2x2
|
|
66
|
+
same-colour block, overlapping; N3 = 40 per `1011101` with four light
|
|
67
|
+
modules before or after, rows and columns; N4 = 10 per whole 5 per cent
|
|
68
|
+
that dark modules deviate from half, rounded down. The lowest score wins;
|
|
69
|
+
ties go to the lower mask.
|
|
70
|
+
|
|
71
|
+
One choice the standard leaves open: for N3, four light modules that would
|
|
72
|
+
lie outside the symbol do not count (the quiet zone is not scored). ZXing
|
|
73
|
+
makes the same choice, so masks chosen here match it.
|
|
74
|
+
|
|
75
|
+
## Output
|
|
76
|
+
|
|
77
|
+
A square of `17 + 4 * version` modules, row-major, 1 for dark, with no
|
|
78
|
+
quiet zone. Renderers add the quiet zone; the default is four modules.
|
|
79
|
+
|
|
80
|
+
Renderers are not covered by the vectors and need not match byte for byte
|
|
81
|
+
across ports, but must keep these properties: the quiet zone is light;
|
|
82
|
+
a canvas or bitmap renderer puts module edges on whole pixels (edge of
|
|
83
|
+
module `i` at `round(i * width / side)`); a terminal renderer packs two
|
|
84
|
+
module rows per line with U+2580 (upper half), U+2584 (lower half), U+2588
|
|
85
|
+
(full) and space, inking light modules by default.
|
|
86
|
+
|
|
87
|
+
## Vectors
|
|
88
|
+
|
|
89
|
+
`vectors/encode-vectors.json` holds module matrices produced by ZXing core
|
|
90
|
+
3.5.3 (`scripts/generate-vectors.sh`). They cover every version at full
|
|
91
|
+
capacity, every mask, every level, all three modes, both count-length band
|
|
92
|
+
edges, the empty string and UTF-8 with ECI. ZXing is a development-time
|
|
93
|
+
oracle only and is not part of the package.
|
|
94
|
+
|
|
95
|
+
## Decoder
|
|
96
|
+
|
|
97
|
+
The decoder is not bound by the vectors in the same way: ports may find
|
|
98
|
+
codes differently, but must return the same payload for any symbol they
|
|
99
|
+
read. The TypeScript pipeline, for reference:
|
|
100
|
+
|
|
101
|
+
1. Luminance: RGBA to grey as `(77 R + 150 G + 29 B) >> 8`, with alpha
|
|
102
|
+
blended onto white, so a code drawn on a transparent canvas reads.
|
|
103
|
+
2. Binarisation: 8x8 blocks; each block's threshold is the mean of the
|
|
104
|
+
block averages in the 5x5 blocks around it; a block with a range of 24
|
|
105
|
+
or less borrows its neighbours' level. Pictures under 40 pixels a side
|
|
106
|
+
use one global threshold.
|
|
107
|
+
3. Finders: row scans for 1:1:3:1:1 runs, each checked down the column,
|
|
108
|
+
back across the row and along a diagonal, with a strict and then a looser
|
|
109
|
+
ratio tolerance. Centres within a module of each other merge.
|
|
110
|
+
4. Triples: three finders of similar module size forming a near right
|
|
111
|
+
isosceles triangle; the cross product puts the top-right finder on the
|
|
112
|
+
right.
|
|
113
|
+
5. Size and alignment: size from finder distances over module size,
|
|
114
|
+
rounded to `1 (mod 4)`. From version 2, alignment candidates are found
|
|
115
|
+
near the corner the finders imply, and ranked by how well the 5x5 pattern
|
|
116
|
+
around each matches along the code's axes; the parallelogram corner is
|
|
117
|
+
the last resort. Sizes four and eight modules either side are retried.
|
|
118
|
+
6. Sampling: a perspective map from module centres to the picture, then
|
|
119
|
+
format and version information, unmasking and Reed-Solomon correction
|
|
120
|
+
(section 7.5 and annex B). A failed read is retried transposed, for
|
|
121
|
+
mirrored codes, and the whole search is repeated on the inverted
|
|
122
|
+
picture.
|
|
123
|
+
7. Segments: numeric, alphanumeric, byte, kanji (kept as Shift_JIS bytes),
|
|
124
|
+
ECI, structured append (header skipped) and FNC1 (indicators skipped).
|
|
125
|
+
Byte text is UTF-8 under ECI 26 or when it is valid UTF-8, and Latin-1
|
|
126
|
+
otherwise or under ECI 1 or 3.
|
|
127
|
+
|
|
128
|
+
## Decode fixtures
|
|
129
|
+
|
|
130
|
+
`vectors/decode-frames/` holds 36 synthetic frames as 8-bit greyscale PNG,
|
|
131
|
+
and `vectors/decode-frames.json` gives each one's exact text, the drawing
|
|
132
|
+
parameters, and whether this decoder and jsQR read it when the frames were
|
|
133
|
+
made. A port must read every frame marked `"typescript": true`. The frames
|
|
134
|
+
are drawn by `test/fixtures.test.ts` (`QR_WRITE_FIXTURES=1` regenerates
|
|
135
|
+
them); every payload in them is made up. Real photographs will join them as
|
|
136
|
+
a separate set.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** A greyscale picture: one byte per pixel, 0 black to 255 white. */
|
|
2
|
+
export interface Luminance {
|
|
3
|
+
readonly data: Uint8Array;
|
|
4
|
+
readonly width: number;
|
|
5
|
+
readonly height: number;
|
|
6
|
+
}
|
|
7
|
+
/** A black-and-white picture: 1 for dark. */
|
|
8
|
+
export interface BitImage {
|
|
9
|
+
readonly data: Uint8Array;
|
|
10
|
+
readonly width: number;
|
|
11
|
+
readonly height: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Greyscale from RGBA (4 bytes a pixel) or grey (1 byte a pixel) data, with
|
|
15
|
+
* an optional row stride in pixels for padded camera planes. Transparent
|
|
16
|
+
* pixels count as white, so a code drawn on a transparent canvas still reads.
|
|
17
|
+
*/
|
|
18
|
+
export declare function toLuminance(data: Uint8Array | Uint8ClampedArray, width: number, height: number, channels: 1 | 4, rowStride?: number): Luminance;
|
|
19
|
+
/**
|
|
20
|
+
* Local-threshold binarisation: each 8x8 block's threshold is the mean of the
|
|
21
|
+
* block averages in the 5x5 blocks around it, and a flat block borrows its
|
|
22
|
+
* neighbours' threshold, so shadows and gradients across the frame do not
|
|
23
|
+
* swallow the code. Pictures under 40 pixels a side use one global threshold.
|
|
24
|
+
*/
|
|
25
|
+
export declare function binarise(image: Luminance): BitImage;
|
|
26
|
+
export declare function invert(image: BitImage): BitImage;
|
package/dist/binarise.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Greyscale from RGBA (4 bytes a pixel) or grey (1 byte a pixel) data, with
|
|
3
|
+
* an optional row stride in pixels for padded camera planes. Transparent
|
|
4
|
+
* pixels count as white, so a code drawn on a transparent canvas still reads.
|
|
5
|
+
*/
|
|
6
|
+
export function toLuminance(data, width, height, channels, rowStride = width) {
|
|
7
|
+
const out = new Uint8Array(width * height);
|
|
8
|
+
for (let y = 0; y < height; y++) {
|
|
9
|
+
for (let x = 0; x < width; x++) {
|
|
10
|
+
const i = (y * rowStride + x) * channels;
|
|
11
|
+
if (channels === 1) {
|
|
12
|
+
out[y * width + x] = data[i];
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
const a = data[i + 3];
|
|
16
|
+
const grey = (data[i] * 77 + data[i + 1] * 150 + data[i + 2] * 29) >> 8;
|
|
17
|
+
out[y * width + x] = (grey * a + 255 * (255 - a)) / 255;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { data: out, width, height };
|
|
22
|
+
}
|
|
23
|
+
const BLOCK = 8;
|
|
24
|
+
const MIN_DYNAMIC_RANGE = 24;
|
|
25
|
+
/**
|
|
26
|
+
* Local-threshold binarisation: each 8x8 block's threshold is the mean of the
|
|
27
|
+
* block averages in the 5x5 blocks around it, and a flat block borrows its
|
|
28
|
+
* neighbours' threshold, so shadows and gradients across the frame do not
|
|
29
|
+
* swallow the code. Pictures under 40 pixels a side use one global threshold.
|
|
30
|
+
*/
|
|
31
|
+
export function binarise(image) {
|
|
32
|
+
const { data, width, height } = image;
|
|
33
|
+
const out = new Uint8Array(width * height);
|
|
34
|
+
if (width < 40 || height < 40) {
|
|
35
|
+
let sum = 0;
|
|
36
|
+
let min = 255;
|
|
37
|
+
let max = 0;
|
|
38
|
+
for (const v of data) {
|
|
39
|
+
sum += v;
|
|
40
|
+
if (v < min)
|
|
41
|
+
min = v;
|
|
42
|
+
if (v > max)
|
|
43
|
+
max = v;
|
|
44
|
+
}
|
|
45
|
+
const threshold = max - min < MIN_DYNAMIC_RANGE ? -1 : (min + max) / 2;
|
|
46
|
+
for (let i = 0; i < data.length; i++)
|
|
47
|
+
out[i] = data[i] <= threshold ? 1 : 0;
|
|
48
|
+
return { data: out, width, height };
|
|
49
|
+
}
|
|
50
|
+
const across = Math.ceil(width / BLOCK);
|
|
51
|
+
const down = Math.ceil(height / BLOCK);
|
|
52
|
+
const levels = new Float64Array(across * down);
|
|
53
|
+
for (let by = 0; by < down; by++) {
|
|
54
|
+
const top = Math.min(by * BLOCK, height - BLOCK);
|
|
55
|
+
for (let bx = 0; bx < across; bx++) {
|
|
56
|
+
const left = Math.min(bx * BLOCK, width - BLOCK);
|
|
57
|
+
let sum = 0;
|
|
58
|
+
let min = 255;
|
|
59
|
+
let max = 0;
|
|
60
|
+
for (let y = top; y < top + BLOCK; y++) {
|
|
61
|
+
for (let x = left; x < left + BLOCK; x++) {
|
|
62
|
+
const v = data[y * width + x];
|
|
63
|
+
sum += v;
|
|
64
|
+
if (v < min)
|
|
65
|
+
min = v;
|
|
66
|
+
if (v > max)
|
|
67
|
+
max = v;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
let level = sum / (BLOCK * BLOCK);
|
|
71
|
+
if (max - min <= MIN_DYNAMIC_RANGE) {
|
|
72
|
+
// A flat block: assume it is background unless its neighbours say otherwise.
|
|
73
|
+
level = min / 2;
|
|
74
|
+
if (bx > 0 && by > 0) {
|
|
75
|
+
const around = (levels[(by - 1) * across + bx] + 2 * levels[by * across + bx - 1] + levels[(by - 1) * across + bx - 1]) / 4;
|
|
76
|
+
if (min < around)
|
|
77
|
+
level = around;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
levels[by * across + bx] = level;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (let by = 0; by < down; by++) {
|
|
84
|
+
const top = Math.min(by * BLOCK, height - BLOCK);
|
|
85
|
+
const cy = Math.min(Math.max(by, 2), down - 3);
|
|
86
|
+
for (let bx = 0; bx < across; bx++) {
|
|
87
|
+
const left = Math.min(bx * BLOCK, width - BLOCK);
|
|
88
|
+
const cx = Math.min(Math.max(bx, 2), across - 3);
|
|
89
|
+
let sum = 0;
|
|
90
|
+
let n = 0;
|
|
91
|
+
for (let dy = -2; dy <= 2; dy++) {
|
|
92
|
+
for (let dx = -2; dx <= 2; dx++) {
|
|
93
|
+
const yy = cy + dy;
|
|
94
|
+
const xx = cx + dx;
|
|
95
|
+
if (yy < 0 || xx < 0 || yy >= down || xx >= across)
|
|
96
|
+
continue;
|
|
97
|
+
sum += levels[yy * across + xx];
|
|
98
|
+
n++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const threshold = sum / n;
|
|
102
|
+
for (let y = top; y < top + BLOCK; y++) {
|
|
103
|
+
for (let x = left; x < left + BLOCK; x++)
|
|
104
|
+
out[y * width + x] = data[y * width + x] <= threshold ? 1 : 0;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { data: out, width, height };
|
|
109
|
+
}
|
|
110
|
+
export function invert(image) {
|
|
111
|
+
const out = new Uint8Array(image.data.length);
|
|
112
|
+
for (let i = 0; i < out.length; i++)
|
|
113
|
+
out[i] = image.data[i] ^ 1;
|
|
114
|
+
return { data: out, width: image.width, height: image.height };
|
|
115
|
+
}
|
package/dist/canvas.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type QrCode } from './encode.js';
|
|
2
|
+
/** The slice of a 2D context the renderer uses, so it needs no DOM types. */
|
|
3
|
+
export interface Canvas2dLike {
|
|
4
|
+
fillStyle: unknown;
|
|
5
|
+
fillRect(x: number, y: number, width: number, height: number): void;
|
|
6
|
+
}
|
|
7
|
+
/** An `HTMLCanvasElement`, an `OffscreenCanvas`, or anything shaped like one. */
|
|
8
|
+
export interface CanvasLike {
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
getContext(type: '2d'): Canvas2dLike | null;
|
|
12
|
+
}
|
|
13
|
+
export interface CanvasOptions {
|
|
14
|
+
/** Width and height of the canvas in pixels. Default 8 pixels per module. */
|
|
15
|
+
width?: number;
|
|
16
|
+
/** Quiet zone in modules on every side. Default 4, the standard's minimum. */
|
|
17
|
+
margin?: number;
|
|
18
|
+
/** Colour of dark modules. Default `'#000'`. */
|
|
19
|
+
dark?: string;
|
|
20
|
+
/** Background colour, or `null` to leave the canvas transparent. Default `'#fff'`. */
|
|
21
|
+
light?: string | null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Draws a code onto a canvas, resizing it to `width` square and replacing
|
|
25
|
+
* whatever it held. Module edges fall on whole pixels, so nothing is
|
|
26
|
+
* anti-aliased; when `width` is not a multiple of the module count, modules
|
|
27
|
+
* differ in size by at most one pixel.
|
|
28
|
+
*/
|
|
29
|
+
export declare function drawCanvas(canvas: CanvasLike, qr: QrCode, options?: CanvasOptions): void;
|
package/dist/canvas.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draws a code onto a canvas, resizing it to `width` square and replacing
|
|
3
|
+
* whatever it held. Module edges fall on whole pixels, so nothing is
|
|
4
|
+
* anti-aliased; when `width` is not a multiple of the module count, modules
|
|
5
|
+
* differ in size by at most one pixel.
|
|
6
|
+
*/
|
|
7
|
+
export function drawCanvas(canvas, qr, options = {}) {
|
|
8
|
+
const margin = options.margin ?? 4;
|
|
9
|
+
if (!Number.isInteger(margin) || margin < 0)
|
|
10
|
+
throw new RangeError('Margin must be a whole number of modules');
|
|
11
|
+
const side = qr.size + margin * 2;
|
|
12
|
+
const width = options.width ?? side * 8;
|
|
13
|
+
if (!Number.isInteger(width) || width < side) {
|
|
14
|
+
throw new RangeError(`Width must be a whole number of pixels, at least ${side} for this code`);
|
|
15
|
+
}
|
|
16
|
+
canvas.width = width;
|
|
17
|
+
canvas.height = width;
|
|
18
|
+
const context = canvas.getContext('2d');
|
|
19
|
+
if (context === null)
|
|
20
|
+
throw new Error('The canvas has no 2D context');
|
|
21
|
+
const light = options.light === undefined ? '#fff' : options.light;
|
|
22
|
+
const edge = (module) => Math.round((module * width) / side);
|
|
23
|
+
if (light !== null) {
|
|
24
|
+
context.fillStyle = light;
|
|
25
|
+
context.fillRect(0, 0, width, width);
|
|
26
|
+
}
|
|
27
|
+
context.fillStyle = options.dark ?? '#000';
|
|
28
|
+
for (let y = 0; y < qr.size; y++) {
|
|
29
|
+
const top = edge(y + margin);
|
|
30
|
+
const height = edge(y + margin + 1) - top;
|
|
31
|
+
let x = 0;
|
|
32
|
+
while (x < qr.size) {
|
|
33
|
+
if (qr.modules[y * qr.size + x] !== 1) {
|
|
34
|
+
x++;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const start = x;
|
|
38
|
+
while (x < qr.size && qr.modules[y * qr.size + x] === 1)
|
|
39
|
+
x++;
|
|
40
|
+
const left = edge(start + margin);
|
|
41
|
+
context.fillRect(left, top, edge(x + margin) - left, height);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
package/dist/decode.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type Point } from './locate.js';
|
|
2
|
+
import { type Payload } from './symbol.js';
|
|
3
|
+
/** Pixels to read: RGBA (4 bytes a pixel, as ImageData gives) or greyscale (1 byte a pixel). */
|
|
4
|
+
export interface ImageLike {
|
|
5
|
+
readonly data: Uint8Array | Uint8ClampedArray;
|
|
6
|
+
readonly width: number;
|
|
7
|
+
readonly height: number;
|
|
8
|
+
}
|
|
9
|
+
export interface DecodeOptions {
|
|
10
|
+
/** Pixels from the start of one row to the next, for padded camera planes. Default: width. */
|
|
11
|
+
readonly rowStride?: number;
|
|
12
|
+
/** Bytes a pixel. Default: worked out from the data length. */
|
|
13
|
+
readonly channels?: 1 | 4;
|
|
14
|
+
/** Which way round to look for dark modules. Default 'both': dark on light first, then light on dark. */
|
|
15
|
+
readonly inversion?: 'normal' | 'inverted' | 'both';
|
|
16
|
+
}
|
|
17
|
+
/** A symbol found in a picture. */
|
|
18
|
+
export interface DecodeResult extends Payload {
|
|
19
|
+
/** The symbol was mirrored (read transposed). */
|
|
20
|
+
readonly mirrored: boolean;
|
|
21
|
+
/** The symbol was light on dark. */
|
|
22
|
+
readonly inverted: boolean;
|
|
23
|
+
/** Picture coordinates of the finder pattern centres, and of the alignment pattern if one was used. */
|
|
24
|
+
readonly location: {
|
|
25
|
+
readonly topLeft: Point;
|
|
26
|
+
readonly topRight: Point;
|
|
27
|
+
readonly bottomLeft: Point;
|
|
28
|
+
readonly alignment: Point | undefined;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Finds and reads one QR code in a picture. Returns undefined when no
|
|
33
|
+
* readable code is found.
|
|
34
|
+
*/
|
|
35
|
+
export declare function decode(image: ImageLike, options?: DecodeOptions): DecodeResult | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Reads a symbol from its module grid, `size` by `size`, row by row, truthy
|
|
38
|
+
* for dark, with no quiet zone. Mirrored grids read too.
|
|
39
|
+
*/
|
|
40
|
+
export declare function decodeModules(modules: ArrayLike<number | boolean>, size: number): (Payload & {
|
|
41
|
+
readonly mirrored: boolean;
|
|
42
|
+
}) | undefined;
|
package/dist/decode.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { binarise, invert, toLuminance } from './binarise.js';
|
|
2
|
+
import { finderTriples, findFinders, locate } from './locate.js';
|
|
3
|
+
import { readSymbol } from './symbol.js';
|
|
4
|
+
const MAX_TRIPLES = 6;
|
|
5
|
+
function channelsFor(image, stride) {
|
|
6
|
+
const { data, width, height } = image;
|
|
7
|
+
const last = stride * (height - 1) + width;
|
|
8
|
+
if (data.length >= last * 4)
|
|
9
|
+
return 4;
|
|
10
|
+
if (data.length >= last)
|
|
11
|
+
return 1;
|
|
12
|
+
throw new RangeError(`The data is too short for a ${width}x${height} picture`);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Finds and reads one QR code in a picture. Returns undefined when no
|
|
16
|
+
* readable code is found.
|
|
17
|
+
*/
|
|
18
|
+
export function decode(image, options = {}) {
|
|
19
|
+
const { width, height } = image;
|
|
20
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
|
21
|
+
throw new RangeError('Width and height must be positive integers');
|
|
22
|
+
}
|
|
23
|
+
const stride = options.rowStride ?? width;
|
|
24
|
+
if (!Number.isInteger(stride) || stride < width)
|
|
25
|
+
throw new RangeError('rowStride must be an integer of at least width');
|
|
26
|
+
const channels = options.channels ?? channelsFor(image, stride);
|
|
27
|
+
const bits = binarise(toLuminance(image.data, width, height, channels, stride));
|
|
28
|
+
const inversion = options.inversion ?? 'both';
|
|
29
|
+
if (inversion !== 'inverted') {
|
|
30
|
+
const found = search(bits, false);
|
|
31
|
+
if (found)
|
|
32
|
+
return found;
|
|
33
|
+
}
|
|
34
|
+
if (inversion !== 'normal')
|
|
35
|
+
return search(invert(bits), true);
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
function search(image, inverted) {
|
|
39
|
+
// A strict pass first; a looser one for blurred or small codes.
|
|
40
|
+
for (const tolerance of [0.5, 0.8]) {
|
|
41
|
+
const triples = finderTriples(findFinders(image, tolerance)).slice(0, MAX_TRIPLES);
|
|
42
|
+
for (const triple of triples) {
|
|
43
|
+
const first = locate(image, triple);
|
|
44
|
+
if (first.length === 0)
|
|
45
|
+
continue;
|
|
46
|
+
// The size estimate can be a version out; try the neighbours too.
|
|
47
|
+
for (const delta of [0, -4, 4, -8, 8]) {
|
|
48
|
+
for (const location of delta === 0 ? first : locate(image, triple, first[0].size + delta)) {
|
|
49
|
+
const result = read(image, location, inverted);
|
|
50
|
+
if (result)
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
function read(image, location, inverted) {
|
|
59
|
+
const { size, project } = location;
|
|
60
|
+
const modules = new Uint8Array(size * size);
|
|
61
|
+
for (let y = 0; y < size; y++) {
|
|
62
|
+
for (let x = 0; x < size; x++) {
|
|
63
|
+
const p = project(x, y);
|
|
64
|
+
const px = Math.floor(p.x);
|
|
65
|
+
const py = Math.floor(p.y);
|
|
66
|
+
if (px >= 0 && py >= 0 && px < image.width && py < image.height)
|
|
67
|
+
modules[y * size + x] = image.data[py * image.width + px];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const where = {
|
|
71
|
+
topLeft: location.topLeft,
|
|
72
|
+
topRight: location.topRight,
|
|
73
|
+
bottomLeft: location.bottomLeft,
|
|
74
|
+
alignment: location.alignment,
|
|
75
|
+
};
|
|
76
|
+
const straight = readSymbol((x, y) => modules[y * size + x] === 1, size);
|
|
77
|
+
if (straight)
|
|
78
|
+
return { ...straight, mirrored: false, inverted, location: where };
|
|
79
|
+
const mirrored = readSymbol((x, y) => modules[x * size + y] === 1, size);
|
|
80
|
+
if (mirrored)
|
|
81
|
+
return { ...mirrored, mirrored: true, inverted, location: where };
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Reads a symbol from its module grid, `size` by `size`, row by row, truthy
|
|
86
|
+
* for dark, with no quiet zone. Mirrored grids read too.
|
|
87
|
+
*/
|
|
88
|
+
export function decodeModules(modules, size) {
|
|
89
|
+
if (modules.length < size * size)
|
|
90
|
+
throw new RangeError('Fewer modules than size x size');
|
|
91
|
+
const at = (i) => Boolean(modules[i]);
|
|
92
|
+
const straightRead = (x, y) => at(y * size + x);
|
|
93
|
+
const straight = readSymbol(straightRead, size);
|
|
94
|
+
if (straight)
|
|
95
|
+
return { ...straight, mirrored: false };
|
|
96
|
+
const mirrored = readSymbol((x, y) => at(x * size + y), size);
|
|
97
|
+
return mirrored ? { ...mirrored, mirrored: true } : undefined;
|
|
98
|
+
}
|