quadqr-js 1.0.2 → 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 +165 -11
- 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 +1435 -74
- package/dist/quadqr.js +1435 -76
- package/dist/quadqr.min.js +1429 -76
- package/docs/API.md +220 -4
- package/docs/BROWSER_CDN.md +51 -6
- package/docs/CLI.md +150 -76
- package/docs/GETTING_STARTED.md +80 -7
- package/docs/NODE.md +137 -123
- package/docs/README.md +59 -41
- package/docs/WASM.md +2 -2
- package/package.json +8 -3
- package/types/benchmark.d.ts +4 -3
- package/types/index.d.ts +115 -1
- package/types/node.d.ts +2 -0
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");
|