quadqr-js 0.7.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 ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile } from "node:fs/promises";
4
+ import process from "node:process";
5
+ import {
6
+ bytesToHex,
7
+ decryptDecoded,
8
+ encodeSecureText,
9
+ encodeText,
10
+ generateRaw256Key
11
+ } from "../dist/index.js";
12
+ import { savePNG, scanFile } from "../dist/node.js";
13
+
14
+ 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`);
16
+ }
17
+
18
+ function parse(argv) {
19
+ const args = [];
20
+ const flags = {};
21
+ for (let i = 0; i < argv.length; i++) {
22
+ const token = argv[i];
23
+ if (token === "-h" || token === "--help") flags.help = true;
24
+ else if (token === "-o" || token === "--output") flags.output = argv[++i];
25
+ else if (token === "--ecc") flags.ecc = argv[++i];
26
+ else if (token === "--version") flags.version = argv[++i];
27
+ else if (token === "--password") flags.password = argv[++i];
28
+ else if (token === "--key") flags.key = argv[++i];
29
+ else if (token === "--module-size") flags.moduleSize = Number(argv[++i]);
30
+ else if (token === "--quiet-zone") flags.quietZone = Number(argv[++i]);
31
+ else args.push(token);
32
+ }
33
+ return { args, flags };
34
+ }
35
+
36
+ async function main() {
37
+ const { args, flags } = parse(process.argv.slice(2));
38
+ if (flags.help || !args.length) {
39
+ help();
40
+ return;
41
+ }
42
+
43
+ const command = args.shift();
44
+ if (command === "keygen") {
45
+ console.log(bytesToHex(generateRaw256Key()));
46
+ return;
47
+ }
48
+
49
+ if (command === "encode") {
50
+ const text = args.join(" ");
51
+ if (!text) throw new Error("encode requires text.");
52
+ if (flags.password && flags.key) throw new Error("Choose password mode or raw-key mode, not both.");
53
+
54
+ const options = {
55
+ ecc: flags.ecc || "M",
56
+ ...(flags.version && flags.version !== "auto" ? { version: Number(flags.version) } : {})
57
+ };
58
+ const code = flags.password
59
+ ? await encodeSecureText(text, { ...options, security: { mode: "password", password: flags.password } })
60
+ : flags.key
61
+ ? await encodeSecureText(text, { ...options, security: { mode: "raw-key", key: flags.key } })
62
+ : encodeText(text, options);
63
+
64
+ 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
+ });
69
+ console.log(`Saved ${output} (${saved.bytes} bytes, v${code.version}, ${code.size}x${code.size}, ECC ${code.eccLevel}).`);
70
+ return;
71
+ }
72
+
73
+ if (command === "decode") {
74
+ const filename = args[0];
75
+ if (!filename) throw new Error("decode requires an image filename.");
76
+ let result = await scanFile(filename);
77
+ if (result.secure) {
78
+ if (!flags.password && !flags.key) {
79
+ console.log(JSON.stringify({
80
+ secure: true,
81
+ mode: result.security?.mode,
82
+ algorithm: result.security?.algorithm,
83
+ keyId: result.security?.keyId || result.security?.keyIdHex || null,
84
+ requiresDecryption: true
85
+ }, null, 2));
86
+ process.exitCode = 2;
87
+ return;
88
+ }
89
+ result = await decryptDecoded(result, flags.password ? { password: flags.password } : { key: flags.key });
90
+ }
91
+ if (result.text != null) console.log(result.text);
92
+ else process.stdout.write(Buffer.from(result.payload));
93
+ return;
94
+ }
95
+
96
+ throw new Error(`Unknown command: ${command}`);
97
+ }
98
+
99
+ main().catch((error) => {
100
+ console.error(`QuadQR: ${error.message}`);
101
+ process.exitCode = 1;
102
+ });
@@ -0,0 +1 @@
1
+ module.exports = require("./benchmark.js");
@@ -0,0 +1 @@
1
+ export * from "./esm/benchmark.js";
@@ -0,0 +1,2 @@
1
+ export * from "./esm/quadqr.js";
2
+ export { initWasm, getWasmState, disableWasm } from "./esm/wasm.js";
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Benchmark helpers for QuadQR.
3
+ *
4
+ * Standard QR capacities below are ISO QR byte-mode payload capacities for
5
+ * versions 1..40. They are used only for same-matrix-size capacity comparison.
6
+ * The QuadQR ECC profile letters are convenience names and are NOT
7
+ * calibrated to the same recovery percentages as ISO QR L/M/Q/H.
8
+ */
9
+
10
+ import {
11
+ encodeBytes,
12
+ decodeMatrix,
13
+ getVersionInfo,
14
+ MAX_VERSION
15
+ } from "./quadqr.js";
16
+
17
+ export const STANDARD_QR_BYTE_CAPACITY = Object.freeze({
18
+ L: Object.freeze([17, 32, 53, 78, 106, 134, 154, 192, 230, 271, 321, 367, 425, 458, 520, 586, 644, 718, 792, 858, 929, 1003, 1091, 1171, 1273, 1367, 1465, 1528, 1628, 1732, 1840, 1952, 2068, 2188, 2303, 2431, 2563, 2699, 2809, 2953]),
19
+ M: Object.freeze([14, 26, 42, 62, 84, 106, 122, 152, 180, 213, 251, 287, 331, 362, 412, 450, 504, 560, 624, 666, 711, 779, 857, 911, 997, 1059, 1125, 1190, 1264, 1370, 1452, 1538, 1628, 1722, 1809, 1911, 1989, 2099, 2213, 2331]),
20
+ Q: Object.freeze([11, 20, 32, 46, 60, 74, 86, 108, 130, 151, 177, 203, 241, 258, 292, 322, 364, 394, 442, 482, 509, 565, 611, 661, 715, 751, 805, 868, 908, 982, 1030, 1112, 1168, 1228, 1283, 1351, 1423, 1499, 1579, 1663]),
21
+ H: Object.freeze([7, 14, 24, 34, 44, 58, 64, 84, 98, 119, 137, 155, 177, 194, 220, 250, 280, 310, 338, 382, 403, 439, 461, 511, 535, 593, 625, 658, 698, 742, 790, 842, 898, 958, 983, 1051, 1093, 1139, 1219, 1273])
22
+ });
23
+
24
+ function normalizeEcc(ecc = "M") {
25
+ const value = String(ecc).toUpperCase();
26
+ if (!STANDARD_QR_BYTE_CAPACITY[value]) {
27
+ throw new Error("ECC must be one of L, M, Q, H.");
28
+ }
29
+ return value;
30
+ }
31
+
32
+ function nowMs() {
33
+ if (typeof performance !== "undefined" && typeof performance.now === "function") {
34
+ return performance.now();
35
+ }
36
+ return Date.now();
37
+ }
38
+
39
+ function makePayload(length, seed = 0x51) {
40
+ const out = new Uint8Array(length);
41
+ let state = (seed ^ length) >>> 0;
42
+ for (let i = 0; i < length; i++) {
43
+ state ^= state << 13;
44
+ state ^= state >>> 17;
45
+ state ^= state << 5;
46
+ out[i] = state & 0xff;
47
+ }
48
+ return out;
49
+ }
50
+
51
+ function percentile(values, p) {
52
+ if (!values.length) return 0;
53
+ const sorted = values.slice().sort((a, b) => a - b);
54
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1));
55
+ return sorted[index];
56
+ }
57
+
58
+ function summarize(samples) {
59
+ if (!samples.length) return { meanMs: 0, medianMs: 0, p95Ms: 0, minMs: 0, maxMs: 0 };
60
+ const total = samples.reduce((sum, value) => sum + value, 0);
61
+ const sorted = samples.slice().sort((a, b) => a - b);
62
+ return {
63
+ meanMs: total / samples.length,
64
+ medianMs: percentile(sorted, 0.5),
65
+ p95Ms: percentile(sorted, 0.95),
66
+ minMs: sorted[0],
67
+ maxMs: sorted[sorted.length - 1]
68
+ };
69
+ }
70
+
71
+ export function getStandardQrByteCapacity(version, ecc = "M") {
72
+ if (!Number.isInteger(version) || version < 1 || version > MAX_VERSION) {
73
+ throw new Error(`Version must be 1..${MAX_VERSION}.`);
74
+ }
75
+ return STANDARD_QR_BYTE_CAPACITY[normalizeEcc(ecc)][version - 1];
76
+ }
77
+
78
+ export function compareCapacity(version, ecc = "M") {
79
+ const level = normalizeEcc(ecc);
80
+ const quadqr = getVersionInfo(version, { ecc: level });
81
+ const standardQrBytes = getStandardQrByteCapacity(version, level);
82
+ const quadqrBytes = quadqr.capacityBytes;
83
+ const differenceBytes = quadqrBytes - standardQrBytes;
84
+ const ratio = standardQrBytes > 0 ? quadqrBytes / standardQrBytes : null;
85
+ const gainPercent = standardQrBytes > 0 ? (differenceBytes / standardQrBytes) * 100 : null;
86
+
87
+ return {
88
+ version,
89
+ size: quadqr.size,
90
+ ecc: level,
91
+ quadqrBytes,
92
+ standardQrBytes,
93
+ differenceBytes,
94
+ ratio,
95
+ gainPercent,
96
+ quadqrBitsPerDataCell: quadqr.bitsPerDataCell,
97
+ quadqrPayloadEfficiencyPercent: quadqr.theoreticalBits > 0
98
+ ? (quadqrBytes * 8 / quadqr.theoreticalBits) * 100
99
+ : 0,
100
+ quadqrPayloadBitsPerMatrixCell: quadqrBytes * 8 / (quadqr.size * quadqr.size),
101
+ standardQrPayloadBitsPerMatrixCell: standardQrBytes * 8 / (quadqr.size * quadqr.size),
102
+ note: "ECC profile letters are nominal only; recovery strength is not equivalent to ISO QR."
103
+ };
104
+ }
105
+
106
+ export function buildCapacityComparison(options = {}) {
107
+ const ecc = normalizeEcc(options.ecc ?? "M");
108
+ const versions = options.versions ?? Array.from({ length: MAX_VERSION }, (_, i) => i + 1);
109
+ return versions.map((version) => compareCapacity(version, ecc));
110
+ }
111
+
112
+ export function benchmarkCodec(options = {}) {
113
+ const ecc = normalizeEcc(options.ecc ?? "M");
114
+ const iterations = Math.max(1, Math.floor(options.iterations ?? 30));
115
+ const warmup = Math.max(0, Math.floor(options.warmup ?? Math.min(5, iterations)));
116
+ const requestedSizes = options.payloadSizes ?? [24, 32, 128, 512, 1024, 2048];
117
+ const results = [];
118
+
119
+ for (const requestedSize of requestedSizes) {
120
+ const payloadBytes = Math.max(0, Math.floor(requestedSize));
121
+ const payload = makePayload(payloadBytes);
122
+
123
+ let probe;
124
+ try {
125
+ probe = encodeBytes(payload, { ecc });
126
+ } catch (error) {
127
+ results.push({ payloadBytes, skipped: true, reason: error.message });
128
+ continue;
129
+ }
130
+
131
+ for (let i = 0; i < warmup; i++) {
132
+ const encoded = encodeBytes(payload, { ecc, version: probe.version });
133
+ decodeMatrix(encoded.matrix);
134
+ }
135
+
136
+ const encodeSamples = [];
137
+ const decodeSamples = [];
138
+ let encoded = probe;
139
+
140
+ for (let i = 0; i < iterations; i++) {
141
+ let start = nowMs();
142
+ encoded = encodeBytes(payload, { ecc, version: probe.version });
143
+ encodeSamples.push(nowMs() - start);
144
+
145
+ start = nowMs();
146
+ const decoded = decodeMatrix(encoded.matrix);
147
+ decodeSamples.push(nowMs() - start);
148
+
149
+ if (decoded.payload.length !== payload.length) {
150
+ throw new Error(`Benchmark decode length mismatch at ${payloadBytes} bytes.`);
151
+ }
152
+ }
153
+
154
+ const versionInfo = getVersionInfo(encoded.version, { ecc });
155
+ results.push({
156
+ payloadBytes,
157
+ skipped: false,
158
+ version: encoded.version,
159
+ size: encoded.size,
160
+ capacityBytes: versionInfo.capacityBytes,
161
+ utilizationPercent: versionInfo.capacityBytes > 0
162
+ ? (payloadBytes / versionInfo.capacityBytes) * 100
163
+ : 0,
164
+ encode: summarize(encodeSamples),
165
+ decode: summarize(decodeSamples),
166
+ iterations
167
+ });
168
+ }
169
+
170
+ return {
171
+ format: "QuadQR",
172
+ ecc,
173
+ iterations,
174
+ warmup,
175
+ generatedAt: new Date().toISOString(),
176
+ results
177
+ };
178
+ }
179
+
180
+ export function benchmarkReport(options = {}) {
181
+ const ecc = normalizeEcc(options.ecc ?? "M");
182
+ const versions = options.versions ?? [1, 2, 5, 10, 20, 30, 40];
183
+ return {
184
+ capacity: buildCapacityComparison({ ecc, versions }),
185
+ performance: benchmarkCodec({
186
+ ecc,
187
+ iterations: options.iterations ?? 30,
188
+ warmup: options.warmup,
189
+ payloadSizes: options.payloadSizes
190
+ }),
191
+ caveat: "QuadQR and standard QR ECC labels are not equivalent recovery targets. Capacity rows compare equal matrix dimensions and same letter only."
192
+ };
193
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Shared QuadQR matrix geometry helpers.
3
+ *
4
+ * Versions 2..40 use the same alignment-pattern center schedule as standard
5
+ * QR Code matrices. QuadQR remains its own symbology, but reuses that proven
6
+ * spatial distribution for alignment references. QuadQR uses one 5x5 primary
7
+ * alignment reference at the bottom-right and compact 3x3 secondary markers.
8
+ * Version 1 keeps the legacy QuadQR bottom-right bootstrap marker because standard QR v1 has no
9
+ * alignment pattern and the QuadQR camera scanner needs a fourth projective
10
+ * reference point.
11
+ */
12
+
13
+ export const MIN_GEOMETRY_VERSION = 1;
14
+ export const MAX_GEOMETRY_VERSION = 40;
15
+
16
+ // Index by version. Entry 0 is unused. Values are module indices of alignment
17
+ // pattern centers, matching the standard QR Code version layout for v2..v40.
18
+ export const ALIGNMENT_PATTERN_AXES = Object.freeze([
19
+ null,
20
+ Object.freeze([]),
21
+ Object.freeze([6, 18]),
22
+ Object.freeze([6, 22]),
23
+ Object.freeze([6, 26]),
24
+ Object.freeze([6, 30]),
25
+ Object.freeze([6, 34]),
26
+ Object.freeze([6, 22, 38]),
27
+ Object.freeze([6, 24, 42]),
28
+ Object.freeze([6, 26, 46]),
29
+ Object.freeze([6, 28, 50]),
30
+ Object.freeze([6, 30, 54]),
31
+ Object.freeze([6, 32, 58]),
32
+ Object.freeze([6, 34, 62]),
33
+ Object.freeze([6, 26, 46, 66]),
34
+ Object.freeze([6, 26, 48, 70]),
35
+ Object.freeze([6, 26, 50, 74]),
36
+ Object.freeze([6, 30, 54, 78]),
37
+ Object.freeze([6, 30, 56, 82]),
38
+ Object.freeze([6, 30, 58, 86]),
39
+ Object.freeze([6, 34, 62, 90]),
40
+ Object.freeze([6, 28, 50, 72, 94]),
41
+ Object.freeze([6, 26, 50, 74, 98]),
42
+ Object.freeze([6, 30, 54, 78, 102]),
43
+ Object.freeze([6, 28, 54, 80, 106]),
44
+ Object.freeze([6, 32, 58, 84, 110]),
45
+ Object.freeze([6, 30, 58, 86, 114]),
46
+ Object.freeze([6, 34, 62, 90, 118]),
47
+ Object.freeze([6, 26, 50, 74, 98, 122]),
48
+ Object.freeze([6, 30, 54, 78, 102, 126]),
49
+ Object.freeze([6, 26, 52, 78, 104, 130]),
50
+ Object.freeze([6, 30, 56, 82, 108, 134]),
51
+ Object.freeze([6, 34, 60, 86, 112, 138]),
52
+ Object.freeze([6, 30, 58, 86, 114, 142]),
53
+ Object.freeze([6, 34, 62, 90, 118, 146]),
54
+ Object.freeze([6, 30, 54, 78, 102, 126, 150]),
55
+ Object.freeze([6, 24, 50, 76, 102, 128, 154]),
56
+ Object.freeze([6, 28, 54, 80, 106, 132, 158]),
57
+ Object.freeze([6, 32, 58, 84, 110, 136, 162]),
58
+ Object.freeze([6, 26, 54, 82, 110, 138, 166]),
59
+ Object.freeze([6, 30, 58, 86, 114, 142, 170])
60
+ ]);
61
+
62
+ function assertVersion(version) {
63
+ if (!Number.isInteger(version) || version < MIN_GEOMETRY_VERSION || version > MAX_GEOMETRY_VERSION) {
64
+ throw new Error(`Version must be ${MIN_GEOMETRY_VERSION}..${MAX_GEOMETRY_VERSION}.`);
65
+ }
66
+ }
67
+
68
+ export function sizeForVersion(version) {
69
+ assertVersion(version);
70
+ return 21 + 4 * (version - 1);
71
+ }
72
+
73
+ export function versionFromSize(size) {
74
+ const delta = size - 21;
75
+ if (delta < 0 || delta % 4 !== 0) return null;
76
+ const version = delta / 4 + 1;
77
+ return Number.isInteger(version) && version >= MIN_GEOMETRY_VERSION && version <= MAX_GEOMETRY_VERSION
78
+ ? version
79
+ : null;
80
+ }
81
+
82
+ export function alignmentPatternCentersForVersion(version) {
83
+ assertVersion(version);
84
+ const size = sizeForVersion(version);
85
+
86
+ if (version === 1) {
87
+ const center = size - 4;
88
+ return [{
89
+ row: center,
90
+ col: center,
91
+ size: 5,
92
+ primary: true,
93
+ bootstrap: true,
94
+ separator: true
95
+ }];
96
+ }
97
+
98
+ const axes = ALIGNMENT_PATTERN_AXES[version];
99
+ const last = axes[axes.length - 1];
100
+ const centers = [];
101
+
102
+ for (const row of axes) {
103
+ for (const col of axes) {
104
+ // These three locations are occupied by the primary finder patterns.
105
+ if (
106
+ (row === 6 && col === 6) ||
107
+ (row === 6 && col === last) ||
108
+ (row === last && col === 6)
109
+ ) {
110
+ continue;
111
+ }
112
+ const primary = row === last && col === last;
113
+ centers.push({
114
+ row,
115
+ col,
116
+ size: primary ? 5 : 3,
117
+ primary,
118
+ bootstrap: false,
119
+ separator: false
120
+ });
121
+ }
122
+ }
123
+
124
+ return centers;
125
+ }
126
+
127
+ export function primaryAlignmentPatternForVersion(version) {
128
+ const centers = alignmentPatternCentersForVersion(version);
129
+ return centers.find((pattern) => pattern.primary) ?? centers[centers.length - 1];
130
+ }
131
+
132
+ export function alignmentPatternRadius(pattern) {
133
+ return pattern.size === 3 ? 1 : 2;
134
+ }
135
+
136
+ export function alignmentPatternIsBlack(pattern, rowOffset, colOffset) {
137
+ const radius = alignmentPatternRadius(pattern);
138
+ if (Math.abs(rowOffset) > radius || Math.abs(colOffset) > radius) return null;
139
+
140
+ if (pattern.size === 3) {
141
+ // Compact secondary marker: black 3x3 ring with a white center.
142
+ return rowOffset !== 0 || colOffset !== 0;
143
+ }
144
+
145
+ // Primary 5x5 marker: black outer ring, white inner ring, black center.
146
+ const outer = Math.abs(rowOffset) === 2 || Math.abs(colOffset) === 2;
147
+ const center = rowOffset === 0 && colOffset === 0;
148
+ return outer || center;
149
+ }