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/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");