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.
@@ -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> --password <password> [-o file.png]\n quadqr encode <text> --key <64-hex-key> [-o file.png]\n quadqr decode <file.png> [--password <password> | --key <64-hex-key>]\n quadqr keygen\n\nOptions:\n -o, --output <file> Output PNG path (default: quadqr.png)\n --ecc <L|M|Q|H> ECC profile (default: M)\n --version <auto|1..40> Symbol version (default: auto)\n --password <text> Encrypt/decrypt with password mode\n --key <hex> Encrypt/decrypt with raw 256-bit key mode\n --module-size <px> PNG module size (default: 12)\n --quiet-zone <modules> PNG quiet zone (default: 4)\n -h, --help Show help\n`);
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 code = flags.password
59
- ? await encodeSecureText(text, { ...options, security: { mode: "password", password: flags.password } })
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
- ? await encodeSecureText(text, { ...options, security: { mode: "raw-key", key: flags.key } })
62
- : encodeText(text, options);
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 saved = await savePNG(code, output, {
66
- moduleSize: flags.moduleSize || 12,
67
- quietZone: Number.isFinite(flags.quietZone) ? flags.quietZone : 4
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;
@@ -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");