quadqr-js 1.0.1 → 1.2.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/FORMAT.md +11 -0
- package/README.md +1222 -1057
- package/SPECIFICATION.md +332 -0
- package/bin/quadqr.js +102 -13
- package/dist/esm/benchmark.js +80 -0
- package/dist/esm/node.js +13 -1
- package/dist/esm/quadqr.js +2714 -182
- package/dist/esm/vision.js +597 -52
- package/dist/quadqr.js +3315 -244
- package/dist/quadqr.min.js +3322 -241
- package/docs/API.md +545 -309
- package/docs/BROWSER_CDN.md +187 -137
- package/docs/CLI.md +150 -76
- package/docs/GETTING_STARTED.md +247 -173
- package/docs/NODE.md +137 -123
- package/docs/README.md +59 -41
- package/docs/WASM.md +67 -67
- package/package.json +100 -95
- package/types/benchmark.d.ts +4 -3
- package/types/index.d.ts +238 -2
- package/types/node.d.ts +2 -0
package/SPECIFICATION.md
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# QuadQR Technical Specification
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
QuadQR is an experimental four-state RGBW matrix symbology. It is not ISO/IEC QR Code and is not intended to be decoded by standard QR readers.
|
|
6
|
+
|
|
7
|
+
The physical matrix format remains **QuadQR Format v5**. Normal application data is always treated simply as UTF-8 text or arbitrary bytes. Compression, signatures, encryption, rendering, and diagnostics are optional features layered around that stable matrix codec.
|
|
8
|
+
|
|
9
|
+
For exact matrix geometry and Reed-Solomon framing, see [`FORMAT.md`](./FORMAT.md).
|
|
10
|
+
|
|
11
|
+
## 1. Layer model
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
Application bytes / UTF-8 text
|
|
15
|
+
│
|
|
16
|
+
├─ optional compression metadata (internal)
|
|
17
|
+
├─ optional Ed25519 signature metadata (internal)
|
|
18
|
+
├─ optional Secure Payload v1 (AES-256-GCM)
|
|
19
|
+
│
|
|
20
|
+
└─ QuadQR Format v5
|
|
21
|
+
├─ protected header
|
|
22
|
+
├─ CRC-32
|
|
23
|
+
├─ GF(256) Reed-Solomon Spectrum ECC
|
|
24
|
+
├─ spectral-spatial interleaving
|
|
25
|
+
└─ RGBW matrix
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
There is deliberately **no public payload-type registry**. Applications do not select URL, JSON, contact, Wi-Fi, or other semantic types. They encode text or bytes and interpret that data themselves.
|
|
29
|
+
|
|
30
|
+
## 2. Physical cell alphabet
|
|
31
|
+
|
|
32
|
+
Data modules use exactly four states:
|
|
33
|
+
|
|
34
|
+
| State | Bits | Internal value |
|
|
35
|
+
|---|---|---:|
|
|
36
|
+
| Red | `00` | 0 |
|
|
37
|
+
| Green | `01` | 1 |
|
|
38
|
+
| Blue | `10` | 2 |
|
|
39
|
+
| White | `11` | 3 |
|
|
40
|
+
|
|
41
|
+
Structural black is separate from the data alphabet. One encoded byte maps to exactly four RGBW data cells.
|
|
42
|
+
|
|
43
|
+
## 3. Matrix sizing
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
size = 21 + 4 × (version - 1)
|
|
47
|
+
version = 1..40
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Three 7×7 finder patterns are always present. Version 1 has the legacy QuadQR 5×5 bottom-right alignment marker. Versions 2–40 use the distributed alignment schedule defined in `FORMAT.md`.
|
|
51
|
+
|
|
52
|
+
## 4. Format v5 header flags
|
|
53
|
+
|
|
54
|
+
The protected Format v5 header uses:
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
bit 0 UTF-8 text flag
|
|
58
|
+
bits 1..2 ECC profile id
|
|
59
|
+
bit 3 Secure Payload v1 envelope
|
|
60
|
+
bit 4 internal payload-extension metadata present
|
|
61
|
+
bit 5 signed-payload hint
|
|
62
|
+
bits 6..7 reserved
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Bit 4 is an implementation/interoperability hint used only when compression or signing requires metadata. It is not a user-selectable payload mode.
|
|
66
|
+
|
|
67
|
+
## 5. Spectrum ECC
|
|
68
|
+
|
|
69
|
+
The field remains:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
GF(2^8) = GF(256)
|
|
73
|
+
primitive polynomial = 0x11d
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Version 2+ parity profiles:
|
|
77
|
+
|
|
78
|
+
| Profile | Parity bytes/block | Correctable unknown byte errors/block |
|
|
79
|
+
|---|---:|---:|
|
|
80
|
+
| L | 12 | 6 |
|
|
81
|
+
| M | 24 | 12 |
|
|
82
|
+
| Q | 36 | 18 |
|
|
83
|
+
| H | 48 | 24 |
|
|
84
|
+
|
|
85
|
+
The decoder retains per-cell color confidence. Low-confidence bytes can be promoted to known erasures, allowing Reed-Solomon recovery to use the existing parity budget more efficiently.
|
|
86
|
+
|
|
87
|
+
## 6. Internal payload extension envelope
|
|
88
|
+
|
|
89
|
+
Compression and signing require a small amount of metadata. QuadQR stores that metadata in an **internal extension envelope** only when needed. Applications should normally use `encodeText()`, `encodeBytes()`, `encodeSignedText()`, or `encodeSignedBytes()` and never construct this envelope themselves.
|
|
90
|
+
|
|
91
|
+
Current envelope magic:
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
QPX1
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Fixed header size: **16 bytes**.
|
|
98
|
+
|
|
99
|
+
| Offset | Size | Field |
|
|
100
|
+
|---:|---:|---|
|
|
101
|
+
| 0 | 4 | ASCII magic `QPX1` |
|
|
102
|
+
| 4 | 1 | Extension version (`2`; decoder also accepts legacy `1`) |
|
|
103
|
+
| 5 | 1 | Flags |
|
|
104
|
+
| 6 | 1 | Compression ID |
|
|
105
|
+
| 7 | 1 | Signature algorithm ID |
|
|
106
|
+
| 8 | 4 | Original application payload length, big-endian |
|
|
107
|
+
| 12 | 1 | Signing key-ID length (v2); legacy signer-label length in v1 |
|
|
108
|
+
| 13 | 1 | Optional embedded public-key length |
|
|
109
|
+
| 14 | 1 | Signature length |
|
|
110
|
+
| 15 | 1 | Reserved (`0`) |
|
|
111
|
+
|
|
112
|
+
Variable bytes follow as:
|
|
113
|
+
|
|
114
|
+
```text
|
|
115
|
+
keyId || optionalPublicKey || signature || storedPayload
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Envelope flags currently use:
|
|
119
|
+
|
|
120
|
+
```text
|
|
121
|
+
bit 0 signed
|
|
122
|
+
bit 1 compressed
|
|
123
|
+
bit 2 public key embedded
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The envelope contains no semantic content type. The Format v5 text flag still determines whether the recovered application payload should be decoded as UTF-8 text.
|
|
127
|
+
|
|
128
|
+
## 7. Compression
|
|
129
|
+
|
|
130
|
+
Compression IDs:
|
|
131
|
+
|
|
132
|
+
```text
|
|
133
|
+
0 = none
|
|
134
|
+
1 = QuadQR portable LZ
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The portable LZ stream is an LZSS-style format with groups of up to eight tokens. Each group begins with one flag byte. A flag bit of `0` means a one-byte literal. A flag bit of `1` means a two-byte back-reference:
|
|
138
|
+
|
|
139
|
+
```text
|
|
140
|
+
12-bit offset: 1..4095 bytes
|
|
141
|
+
4-bit length: stored value + 3, therefore 3..18 bytes
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Public compression modes are:
|
|
145
|
+
|
|
146
|
+
```text
|
|
147
|
+
none
|
|
148
|
+
auto
|
|
149
|
+
lz
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`compression: "auto"` uses compression only when it meaningfully reduces payload size. When it does not help and the payload is not signed, QuadQR stores the original payload directly with **no extension-envelope overhead**.
|
|
153
|
+
|
|
154
|
+
Compression occurs before signing, encryption, and Format v5 ECC.
|
|
155
|
+
|
|
156
|
+
## 8. Signed QuadQR
|
|
157
|
+
|
|
158
|
+
Signature algorithm ID `1` is **Ed25519**.
|
|
159
|
+
|
|
160
|
+
Signed payloads in extension v2 store:
|
|
161
|
+
|
|
162
|
+
```text
|
|
163
|
+
optional compact key ID
|
|
164
|
+
64-byte Ed25519 signature
|
|
165
|
+
optional 32-byte raw Ed25519 public key only when explicitly requested
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The normal production profile does **not** embed the public key. The signature covers:
|
|
169
|
+
|
|
170
|
+
```text
|
|
171
|
+
16-byte extension header
|
|
172
|
+
|| key ID
|
|
173
|
+
|| optional embedded public key
|
|
174
|
+
|| stored payload bytes
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The signature field itself is excluded from the signed message.
|
|
178
|
+
|
|
179
|
+
The private Ed25519 key is used only by the issuer to create signatures and must never be embedded in a QuadQR. Verification uses a trusted public key supplied externally by the application, server, certificate, or trusted-key registry. The optional key ID is only an identifier that helps the verifier select the correct trusted public key.
|
|
180
|
+
|
|
181
|
+
For compatibility or self-contained integrity checks, implementations may set `embedPublicKey: true`. A signature verified only against a key embedded in the same QuadQR proves integrity and key possession, but does not establish a trusted signer identity. Such verification should be reported separately from verification against an external trust anchor.
|
|
182
|
+
|
|
183
|
+
Legacy extension v1 symbols used a signer label and embedded public key. Decoders may continue to read and verify those symbols for backward compatibility.
|
|
184
|
+
|
|
185
|
+
## 9. Secure + signed composition
|
|
186
|
+
|
|
187
|
+
When compression, signing, and encryption are combined, QuadQR uses:
|
|
188
|
+
|
|
189
|
+
```text
|
|
190
|
+
application payload
|
|
191
|
+
→ optional compression
|
|
192
|
+
→ optional Ed25519 signature metadata
|
|
193
|
+
→ AES-256-GCM Secure Payload v1
|
|
194
|
+
→ Format v5 ECC/matrix
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
After scanning:
|
|
198
|
+
|
|
199
|
+
```text
|
|
200
|
+
Format v5 decode
|
|
201
|
+
→ AES-GCM authentication/decryption
|
|
202
|
+
→ internal compression/signature metadata processing
|
|
203
|
+
→ application payload
|
|
204
|
+
→ optional Ed25519 signature verification
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
This keeps signature metadata confidential when encryption is enabled while preserving offline verification after decryption.
|
|
208
|
+
|
|
209
|
+
## 10. Text and binary APIs
|
|
210
|
+
|
|
211
|
+
Text and bytes are first-class without semantic payload types:
|
|
212
|
+
|
|
213
|
+
- `encodeText()` / `decodeMatrix().text` for UTF-8 text;
|
|
214
|
+
- `encodeBytes()` for arbitrary bytes;
|
|
215
|
+
- `encodeUint8Array()` / `decodeUint8Array()` as explicit byte-oriented convenience APIs.
|
|
216
|
+
|
|
217
|
+
Compression works on both text and byte payloads through the same `compression` option.
|
|
218
|
+
|
|
219
|
+
## 11. Rendering profiles
|
|
220
|
+
|
|
221
|
+
Rendering does not modify the encoded matrix.
|
|
222
|
+
|
|
223
|
+
### Screen mode
|
|
224
|
+
|
|
225
|
+
Uses the normal RGBW palette and permits:
|
|
226
|
+
|
|
227
|
+
```text
|
|
228
|
+
classic
|
|
229
|
+
soft
|
|
230
|
+
depth
|
|
231
|
+
inset
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Print mode
|
|
235
|
+
|
|
236
|
+
`mode: "print"` applies conservative defaults:
|
|
237
|
+
|
|
238
|
+
- minimum quiet zone of 4 modules unless explicitly overridden;
|
|
239
|
+
- print-safe darker RGB primaries;
|
|
240
|
+
- Classic solid-module rendering by default;
|
|
241
|
+
- physical-size guidance through `getPrintGuidance()`.
|
|
242
|
+
|
|
243
|
+
Recommended general-purpose starting module size is **0.40 mm/module**. Real printer, paper, ink/toner, lamination, lighting, and camera validation remains necessary for production deployments.
|
|
244
|
+
|
|
245
|
+
## 12. Logo safety
|
|
246
|
+
|
|
247
|
+
Logos are rendering overlays and never modify Format v5 data structures.
|
|
248
|
+
|
|
249
|
+
`size: "auto"` estimates a conservative logo ratio from:
|
|
250
|
+
|
|
251
|
+
- ECC profile;
|
|
252
|
+
- encoded utilization;
|
|
253
|
+
- version 1 compact-profile penalty;
|
|
254
|
+
- clear-background usage;
|
|
255
|
+
- screen vs print mode.
|
|
256
|
+
|
|
257
|
+
`findMaxSafeLogoSize()` can empirically search the largest decodable logo size when an ImageData-like logo is available. Generated symbols should still be verified after final rendering.
|
|
258
|
+
|
|
259
|
+
## 13. Scanner diagnostics
|
|
260
|
+
|
|
261
|
+
Successful image scans expose normalized diagnostic fields including:
|
|
262
|
+
|
|
263
|
+
```text
|
|
264
|
+
confidence
|
|
265
|
+
geometryConfidence
|
|
266
|
+
calibrationConfidence
|
|
267
|
+
structureConfidence
|
|
268
|
+
eccUtilization
|
|
269
|
+
correctedErrors
|
|
270
|
+
erasureSymbols
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
With `debug: true`, the scanner can also report stage state, geometry candidates, finder/vision passes, latest sampled matrix, confidence matrix, color-normalization method, and the failed stage when decoding does not complete.
|
|
274
|
+
|
|
275
|
+
Debug output is diagnostic evidence, not a cryptographic assurance score.
|
|
276
|
+
|
|
277
|
+
## 14. Scanability score and torture testing
|
|
278
|
+
|
|
279
|
+
`runImageStressTest()` and `assessScanability()` use deterministic synthetic distortions to estimate robustness against:
|
|
280
|
+
|
|
281
|
+
- blur;
|
|
282
|
+
- low brightness;
|
|
283
|
+
- high exposure;
|
|
284
|
+
- uneven shadow;
|
|
285
|
+
- contrast loss;
|
|
286
|
+
- perspective distortion;
|
|
287
|
+
- JPEG-like quantization/block artifacts;
|
|
288
|
+
- downscaling.
|
|
289
|
+
|
|
290
|
+
Current rating bands:
|
|
291
|
+
|
|
292
|
+
```text
|
|
293
|
+
90–100 Excellent
|
|
294
|
+
75–89 Good
|
|
295
|
+
50–74 Risky
|
|
296
|
+
0–49 Likely unscannable
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
These scores are regression/testing aids. They do not replace validation with real phone cameras, printers, displays, paper stocks, lighting conditions, and physical damage.
|
|
300
|
+
|
|
301
|
+
## 15. Capacity planning
|
|
302
|
+
|
|
303
|
+
Capacity is determined from the actual Format v5 layout, protected header, CRC, and Spectrum ECC plan. Compression can reduce stored payload bytes, while signatures and encryption add metadata bytes before the matrix codec.
|
|
304
|
+
|
|
305
|
+
The benchmark helper can report:
|
|
306
|
+
|
|
307
|
+
- minimum QuadQR version;
|
|
308
|
+
- encoded bytes;
|
|
309
|
+
- remaining capacity;
|
|
310
|
+
- utilization;
|
|
311
|
+
- approximate same-letter standard QR byte-mode version.
|
|
312
|
+
|
|
313
|
+
When only a payload byte count is known, `compression: "auto"` cannot predict the gain because compressibility depends on the actual bytes.
|
|
314
|
+
|
|
315
|
+
Standard QR comparisons use the same **nominal ECC letter only**. QuadQR and ISO QR recovery strengths are not equivalent and should not be presented as such.
|
|
316
|
+
|
|
317
|
+
## 16. Compatibility principles
|
|
318
|
+
|
|
319
|
+
Implementations should follow these rules:
|
|
320
|
+
|
|
321
|
+
1. Keep RGBW mapping exactly `R=00, G=01, B=10, W=11`.
|
|
322
|
+
2. Keep GF(256) Spectrum ECC and its errors+erasures behavior.
|
|
323
|
+
3. Preserve Format v5 decoding for normal and Secure Payload symbols.
|
|
324
|
+
4. Keep application semantics outside the QuadQR codec. Do not require a growing content-type registry.
|
|
325
|
+
5. Treat compression/signature metadata as internal transport metadata, not a separate user payload mode.
|
|
326
|
+
6. Treat `keyId` only as an identifier. Signer trust comes from an external trusted public-key binding.
|
|
327
|
+
7. Keep rendering effects out of structural finder/timing/alignment/calibration modules.
|
|
328
|
+
8. Validate public format changes with both matrix and rendered-image scanner tests.
|
|
329
|
+
|
|
330
|
+
## 17. Reference implementation
|
|
331
|
+
|
|
332
|
+
The JavaScript implementation in this repository is the current reference implementation. Public entry points are documented under `docs/` and exercised by `tests/self-test.js` and `tests/package-test.js`.
|
package/bin/quadqr.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import {
|
|
6
6
|
bytesToHex,
|
|
7
7
|
decryptDecoded,
|
|
8
8
|
encodeSecureText,
|
|
9
|
+
encodeSignedText,
|
|
9
10
|
encodeText,
|
|
10
|
-
generateRaw256Key
|
|
11
|
+
generateRaw256Key,
|
|
12
|
+
generateSigningKeyPair,
|
|
13
|
+
verifyDecodedSignature
|
|
11
14
|
} from "../dist/index.js";
|
|
12
|
-
import { savePNG, scanFile } from "../dist/node.js";
|
|
15
|
+
import { savePNG, saveSVG, scanFile } from "../dist/node.js";
|
|
13
16
|
|
|
14
17
|
function help() {
|
|
15
|
-
console.log(`QuadQR CLI\n\nUsage:\n quadqr encode <text> [-o file.png] [--ecc M] [--version auto|1..40]\n quadqr encode <text> --
|
|
18
|
+
console.log(`QuadQR CLI\n\nUsage:\n quadqr encode <text> [-o file.png|file.svg] [--ecc M] [--version auto|1..40]\n quadqr encode <text> [--compression auto]\n quadqr encode <text> --sign-key signing-key.json [--key-id issuer-main]\n quadqr encode <text> --password <password> [-o file.png|file.svg]\n quadqr decode <file.png> [--password <password> | --key <64-hex-key>] [--verify-key signing-key.json] [--debug]\n quadqr keygen\n quadqr signkeygen [-o signing-key.json]\n\nOptions:\n -o, --output <file> Output PNG/SVG path, or signing-key JSON for signkeygen\n --ecc <L|M|Q|H> ECC profile (default: M)\n --version <auto|1..40> Symbol version (default: auto)\n --compression <mode> none|auto|lz (default: auto)\n --sign-key <file> Sign using a signkeygen JSON bundle\n --key-id <id> Override the signing key ID stored in the QuadQR\n --embed-public-key Also embed the public key for untrusted/self-contained checks\n --verify-key <file> Verify a signed QuadQR with a trusted key bundle\n --password <text> Encrypt/decrypt with password mode\n --key <hex> Encrypt/decrypt with raw 256-bit key mode\n --print Use the print-safe render profile\n --image-size <px> Exact square output size (default: 720)\n --module-size <px> Legacy pixels-per-module sizing\n --quiet-zone <modules> Quiet zone in modules (default: 4)\n --debug Emit scanner diagnostics to stderr on decode\n -h, --help Show help\n`);
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
function parse(argv) {
|
|
@@ -26,6 +29,14 @@ function parse(argv) {
|
|
|
26
29
|
else if (token === "--version") flags.version = argv[++i];
|
|
27
30
|
else if (token === "--password") flags.password = argv[++i];
|
|
28
31
|
else if (token === "--key") flags.key = argv[++i];
|
|
32
|
+
else if (token === "--compression") flags.compression = argv[++i];
|
|
33
|
+
else if (token === "--sign-key") flags.signKey = argv[++i];
|
|
34
|
+
else if (token === "--key-id") flags.keyId = argv[++i];
|
|
35
|
+
else if (token === "--embed-public-key") flags.embedPublicKey = true;
|
|
36
|
+
else if (token === "--verify-key") flags.verifyKey = argv[++i];
|
|
37
|
+
else if (token === "--print") flags.print = true;
|
|
38
|
+
else if (token === "--debug") flags.debug = true;
|
|
39
|
+
else if (token === "--image-size") flags.imageSize = Number(argv[++i]);
|
|
29
40
|
else if (token === "--module-size") flags.moduleSize = Number(argv[++i]);
|
|
30
41
|
else if (token === "--quiet-zone") flags.quietZone = Number(argv[++i]);
|
|
31
42
|
else args.push(token);
|
|
@@ -33,6 +44,25 @@ function parse(argv) {
|
|
|
33
44
|
return { args, flags };
|
|
34
45
|
}
|
|
35
46
|
|
|
47
|
+
function bytesToBase64(bytes) {
|
|
48
|
+
return Buffer.from(bytes).toString("base64");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function base64ToBytes(value, label) {
|
|
52
|
+
if (typeof value !== "string" || !value) throw new Error(`${label} is missing from signing key bundle.`);
|
|
53
|
+
return new Uint8Array(Buffer.from(value, "base64"));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function readSigningBundle(filename) {
|
|
57
|
+
const parsed = JSON.parse(await readFile(filename, "utf8"));
|
|
58
|
+
if (parsed.algorithm && parsed.algorithm !== "Ed25519") throw new Error(`Unsupported signing algorithm ${parsed.algorithm}.`);
|
|
59
|
+
return {
|
|
60
|
+
privateKey: parsed.privateKeyPkcs8 ? base64ToBytes(parsed.privateKeyPkcs8, "privateKeyPkcs8") : null,
|
|
61
|
+
publicKey: base64ToBytes(parsed.publicKeyRaw || parsed.publicKeyBytes, "publicKeyRaw"),
|
|
62
|
+
keyId: parsed.keyId || null
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
36
66
|
async function main() {
|
|
37
67
|
const { args, flags } = parse(process.argv.slice(2));
|
|
38
68
|
if (flags.help || !args.length) {
|
|
@@ -46,6 +76,24 @@ async function main() {
|
|
|
46
76
|
return;
|
|
47
77
|
}
|
|
48
78
|
|
|
79
|
+
if (command === "signkeygen") {
|
|
80
|
+
const pair = await generateSigningKeyPair();
|
|
81
|
+
const bundle = JSON.stringify({
|
|
82
|
+
format: "quadqr-ed25519-key-v1",
|
|
83
|
+
algorithm: pair.algorithm,
|
|
84
|
+
privateKeyPkcs8: bytesToBase64(pair.privateKeyPkcs8),
|
|
85
|
+
publicKeyRaw: bytesToBase64(pair.publicKeyBytes),
|
|
86
|
+
keyId: pair.keyId
|
|
87
|
+
}, null, 2) + "\n";
|
|
88
|
+
if (flags.output) {
|
|
89
|
+
await writeFile(flags.output, bundle, { mode: 0o600 });
|
|
90
|
+
console.log(`Saved ${flags.output}. Keep the private key file secret.`);
|
|
91
|
+
} else {
|
|
92
|
+
process.stdout.write(bundle);
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
49
97
|
if (command === "encode") {
|
|
50
98
|
const text = args.join(" ");
|
|
51
99
|
if (!text) throw new Error("encode requires text.");
|
|
@@ -53,19 +101,43 @@ async function main() {
|
|
|
53
101
|
|
|
54
102
|
const options = {
|
|
55
103
|
ecc: flags.ecc || "M",
|
|
104
|
+
compression: flags.compression || "auto",
|
|
56
105
|
...(flags.version && flags.version !== "auto" ? { version: Number(flags.version) } : {})
|
|
57
106
|
};
|
|
58
|
-
const
|
|
59
|
-
|
|
107
|
+
const signingBundle = flags.signKey ? await readSigningBundle(flags.signKey) : null;
|
|
108
|
+
const signing = signingBundle ? {
|
|
109
|
+
privateKey: signingBundle.privateKey,
|
|
110
|
+
keyId: flags.keyId || signingBundle.keyId || undefined,
|
|
111
|
+
...(flags.embedPublicKey ? { publicKey: signingBundle.publicKey, embedPublicKey: true } : {})
|
|
112
|
+
} : null;
|
|
113
|
+
const security = flags.password
|
|
114
|
+
? { mode: "password", password: flags.password }
|
|
60
115
|
: flags.key
|
|
61
|
-
?
|
|
62
|
-
:
|
|
116
|
+
? { mode: "raw-key", key: flags.key }
|
|
117
|
+
: null;
|
|
118
|
+
|
|
119
|
+
let code;
|
|
120
|
+
if (security) {
|
|
121
|
+
code = await encodeSecureText(text, { ...options, security, ...(signing ? { signing } : {}) });
|
|
122
|
+
} else if (signing) {
|
|
123
|
+
code = await encodeSignedText(text, { ...options, ...signing });
|
|
124
|
+
} else {
|
|
125
|
+
code = encodeText(text, options);
|
|
126
|
+
}
|
|
63
127
|
|
|
64
128
|
const output = flags.output || "quadqr.png";
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
129
|
+
const renderOptions = {
|
|
130
|
+
quietZone: Number.isFinite(flags.quietZone) ? flags.quietZone : 4,
|
|
131
|
+
...(flags.print ? { mode: "print" } : {}),
|
|
132
|
+
...(Number.isFinite(flags.imageSize)
|
|
133
|
+
? { imageSize: flags.imageSize }
|
|
134
|
+
: Number.isFinite(flags.moduleSize)
|
|
135
|
+
? { moduleSize: flags.moduleSize }
|
|
136
|
+
: { imageSize: 720 })
|
|
137
|
+
};
|
|
138
|
+
const saved = output.toLowerCase().endsWith(".svg")
|
|
139
|
+
? await saveSVG(code, output, renderOptions)
|
|
140
|
+
: await savePNG(code, output, renderOptions);
|
|
69
141
|
console.log(`Saved ${output} (${saved.bytes} bytes, v${code.version}, ${code.size}x${code.size}, ECC ${code.eccLevel}).`);
|
|
70
142
|
return;
|
|
71
143
|
}
|
|
@@ -73,7 +145,7 @@ async function main() {
|
|
|
73
145
|
if (command === "decode") {
|
|
74
146
|
const filename = args[0];
|
|
75
147
|
if (!filename) throw new Error("decode requires an image filename.");
|
|
76
|
-
let result = await scanFile(filename);
|
|
148
|
+
let result = await scanFile(filename, flags.debug ? { debug: true } : {});
|
|
77
149
|
if (result.secure) {
|
|
78
150
|
if (!flags.password && !flags.key) {
|
|
79
151
|
console.log(JSON.stringify({
|
|
@@ -88,6 +160,23 @@ async function main() {
|
|
|
88
160
|
}
|
|
89
161
|
result = await decryptDecoded(result, flags.password ? { password: flags.password } : { key: flags.key });
|
|
90
162
|
}
|
|
163
|
+
if (result.signed && flags.verifyKey) {
|
|
164
|
+
const verifier = await readSigningBundle(flags.verifyKey);
|
|
165
|
+
result = await verifyDecodedSignature(result, { publicKey: verifier.publicKey });
|
|
166
|
+
}
|
|
167
|
+
if (flags.debug) {
|
|
168
|
+
console.error(JSON.stringify({
|
|
169
|
+
confidence: result.confidence ?? null,
|
|
170
|
+
geometryConfidence: result.geometryConfidence ?? null,
|
|
171
|
+
calibrationConfidence: result.calibrationConfidence ?? null,
|
|
172
|
+
eccUtilization: result.eccUtilization ?? null,
|
|
173
|
+
signed: Boolean(result.signed),
|
|
174
|
+
signatureVerified: result.signatureVerified ?? null,
|
|
175
|
+
signatureTrusted: result.signatureTrusted ?? null,
|
|
176
|
+
signingKeyId: result.signingKeyId ?? null,
|
|
177
|
+
diagnostics: result.diagnostics ?? null
|
|
178
|
+
}, null, 2));
|
|
179
|
+
}
|
|
91
180
|
if (result.text != null) console.log(result.text);
|
|
92
181
|
else process.stdout.write(Buffer.from(result.payload));
|
|
93
182
|
return;
|
package/dist/esm/benchmark.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
encodeBytes,
|
|
12
12
|
decodeMatrix,
|
|
13
13
|
getVersionInfo,
|
|
14
|
+
compressPayload,
|
|
14
15
|
MAX_VERSION
|
|
15
16
|
} from "./quadqr.js";
|
|
16
17
|
|
|
@@ -103,6 +104,85 @@ export function compareCapacity(version, ecc = "M") {
|
|
|
103
104
|
};
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
|
|
108
|
+
/** Calculate the smallest QuadQR and standard QR versions for a payload size. */
|
|
109
|
+
export function calculateCapacityPlan(options = {}) {
|
|
110
|
+
const ecc = normalizeEcc(options.ecc ?? "M");
|
|
111
|
+
let sourceBytes;
|
|
112
|
+
const hasConcretePayload = options.payload instanceof Uint8Array || typeof options.payload === "string";
|
|
113
|
+
if (options.payload instanceof Uint8Array) sourceBytes = options.payload;
|
|
114
|
+
else if (typeof options.payload === "string") sourceBytes = new TextEncoder().encode(options.payload);
|
|
115
|
+
else sourceBytes = new Uint8Array(Math.max(0, Math.floor(options.payloadBytes ?? 0)));
|
|
116
|
+
|
|
117
|
+
const requestedCompression = options.compression ?? "none";
|
|
118
|
+
const signed = Boolean(options.signed);
|
|
119
|
+
const keyIdBytes = options.keyId ? new TextEncoder().encode(String(options.keyId)).length : 0;
|
|
120
|
+
const envelopeHeaderBytes = 16;
|
|
121
|
+
const signingBytes = signed ? 64 + keyIdBytes + (options.embedPublicKey ? 32 : 0) : 0;
|
|
122
|
+
let compression = requestedCompression;
|
|
123
|
+
let storedBytes = sourceBytes.length;
|
|
124
|
+
let compressed = false;
|
|
125
|
+
|
|
126
|
+
if (requestedCompression !== "none" && hasConcretePayload) {
|
|
127
|
+
const candidate = compressPayload(sourceBytes);
|
|
128
|
+
if (requestedCompression === "lz" || candidate.length < sourceBytes.length - 2) {
|
|
129
|
+
storedBytes = candidate.length;
|
|
130
|
+
compressed = true;
|
|
131
|
+
compression = "lz";
|
|
132
|
+
} else {
|
|
133
|
+
compression = "none";
|
|
134
|
+
}
|
|
135
|
+
} else if (requestedCompression !== "none" && !hasConcretePayload) {
|
|
136
|
+
compression = "unknown";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const needsEnvelope = signed || compressed || requestedCompression === "lz";
|
|
140
|
+
const encodedBytes = storedBytes + (needsEnvelope ? envelopeHeaderBytes + signingBytes : 0);
|
|
141
|
+
const extensionOverheadBytes = encodedBytes - storedBytes;
|
|
142
|
+
|
|
143
|
+
let quadqrVersion = null;
|
|
144
|
+
let quadqrInfo = null;
|
|
145
|
+
for (let version = 1; version <= MAX_VERSION; version++) {
|
|
146
|
+
const info = getVersionInfo(version, { ecc });
|
|
147
|
+
if (encodedBytes <= info.capacityBytes) {
|
|
148
|
+
quadqrVersion = version;
|
|
149
|
+
quadqrInfo = info;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const standard = STANDARD_QR_BYTE_CAPACITY[ecc];
|
|
155
|
+
let standardQrVersion = null;
|
|
156
|
+
for (let version = 1; version <= MAX_VERSION; version++) {
|
|
157
|
+
if (sourceBytes.length <= standard[version - 1]) {
|
|
158
|
+
standardQrVersion = version;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
ecc,
|
|
165
|
+
sourceBytes: sourceBytes.length,
|
|
166
|
+
encodedBytes,
|
|
167
|
+
storedBytes,
|
|
168
|
+
extensionOverheadBytes,
|
|
169
|
+
compression,
|
|
170
|
+
compressed,
|
|
171
|
+
signed,
|
|
172
|
+
quadqrVersion,
|
|
173
|
+
quadqrSize: quadqrInfo?.size ?? null,
|
|
174
|
+
quadqrCapacityBytes: quadqrInfo?.capacityBytes ?? null,
|
|
175
|
+
remainingBytes: quadqrInfo ? quadqrInfo.capacityBytes - encodedBytes : null,
|
|
176
|
+
utilizationPercent: quadqrInfo ? encodedBytes / quadqrInfo.capacityBytes * 100 : null,
|
|
177
|
+
standardQrVersion,
|
|
178
|
+
standardQrSize: standardQrVersion ? 21 + 4 * (standardQrVersion - 1) : null,
|
|
179
|
+
matrixWidthSavingsPercent: quadqrInfo && standardQrVersion
|
|
180
|
+
? (1 - quadqrInfo.size / (21 + 4 * (standardQrVersion - 1))) * 100
|
|
181
|
+
: null,
|
|
182
|
+
note: "Compression is measured only when concrete payload bytes are provided. Byte-count-only estimates cannot predict compression gain."
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
106
186
|
export function buildCapacityComparison(options = {}) {
|
|
107
187
|
const ecc = normalizeEcc(options.ecc ?? "M");
|
|
108
188
|
const versions = options.versions ?? Array.from({ length: MAX_VERSION }, (_, i) => i + 1);
|
package/dist/esm/node.js
CHANGED
|
@@ -10,7 +10,7 @@ import { readFile, writeFile } from "node:fs/promises";
|
|
|
10
10
|
import { deflateSync, inflateSync } from "node:zlib";
|
|
11
11
|
import { webcrypto } from "node:crypto";
|
|
12
12
|
|
|
13
|
-
import { renderToImageData, scanImageData } from "./quadqr.js";
|
|
13
|
+
import { renderToImageData, renderToSVG, scanImageData } from "./quadqr.js";
|
|
14
14
|
|
|
15
15
|
if (!globalThis.crypto) globalThis.crypto = webcrypto;
|
|
16
16
|
|
|
@@ -242,6 +242,18 @@ export async function savePNG(codeOrMatrix, filename, options = {}) {
|
|
|
242
242
|
return { filename, bytes: png.length };
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
/** Render a QuadQR code/matrix directly to an SVG string. */
|
|
246
|
+
export function toSVG(codeOrMatrix, options = {}) {
|
|
247
|
+
return renderToSVG(codeOrMatrix, options);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Render a QuadQR code/matrix and save it as SVG. */
|
|
251
|
+
export async function saveSVG(codeOrMatrix, filename, options = {}) {
|
|
252
|
+
const svg = toSVG(codeOrMatrix, options);
|
|
253
|
+
await writeFile(filename, svg, "utf8");
|
|
254
|
+
return { filename, bytes: Buffer.byteLength(svg, "utf8") };
|
|
255
|
+
}
|
|
256
|
+
|
|
245
257
|
async function decodeWithOptionalSharp(buffer) {
|
|
246
258
|
try {
|
|
247
259
|
const { default: sharp } = await import("sharp");
|