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/dist/esm/quadqr.js
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
} from "./geometry.js";
|
|
30
30
|
import {
|
|
31
31
|
detectCodeGeometry,
|
|
32
|
+
computeHomography,
|
|
33
|
+
projectPoint,
|
|
32
34
|
samplePerspectiveMatrix,
|
|
33
35
|
rectifyImageData,
|
|
34
36
|
sampleObservedPalette,
|
|
@@ -57,6 +59,28 @@ export const MIN_VERSION = 1;
|
|
|
57
59
|
export const MAX_VERSION = 40;
|
|
58
60
|
export const DEFAULT_ECC_LEVEL = "M";
|
|
59
61
|
|
|
62
|
+
export const RENDER_MODES = Object.freeze({
|
|
63
|
+
SCREEN: "screen",
|
|
64
|
+
PRINT: "print"
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Print-safe defaults deliberately use darker, more ink-tolerant primaries and
|
|
68
|
+
// a true white background. Applications can still override individual colors.
|
|
69
|
+
export const PRINT_PALETTE = Object.freeze({
|
|
70
|
+
black: "#000000",
|
|
71
|
+
white: "#ffffff",
|
|
72
|
+
red: "#d71932",
|
|
73
|
+
green: "#087f3e",
|
|
74
|
+
blue: "#174ea6"
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const COMPRESSION_MODES = Object.freeze({
|
|
78
|
+
NONE: "none",
|
|
79
|
+
AUTO: "auto",
|
|
80
|
+
LZ: "lz"
|
|
81
|
+
});
|
|
82
|
+
export const SIGNATURE_ALGORITHMS = Object.freeze({ ED25519: "Ed25519" });
|
|
83
|
+
|
|
60
84
|
// Data cells are the four values 0..3. Structural white deliberately shares
|
|
61
85
|
// value 3 with data-white because both render and scan identically.
|
|
62
86
|
export const CELL = Object.freeze({
|
|
@@ -121,6 +145,22 @@ const COMPACT_ECC_LEVELS = Object.freeze({
|
|
|
121
145
|
const CRC_BYTES = 4;
|
|
122
146
|
const TEXT_FLAG = 1;
|
|
123
147
|
const SECURE_FLAG = 1 << 3;
|
|
148
|
+
const EXTENDED_PAYLOAD_FLAG = 1 << 4;
|
|
149
|
+
const SIGNED_FLAG = 1 << 5;
|
|
150
|
+
|
|
151
|
+
// Internal payload extension envelope. This is deliberately not a public
|
|
152
|
+
// payload mode or content-type system. It exists only when compression or
|
|
153
|
+
// signing needs metadata around an otherwise normal text/binary payload.
|
|
154
|
+
const PAYLOAD_ENVELOPE_VERSION = 2;
|
|
155
|
+
const PAYLOAD_ENVELOPE_MAGIC = new Uint8Array([0x51, 0x50, 0x58, 0x31]); // QPX1
|
|
156
|
+
const PAYLOAD_ENVELOPE_HEADER_BYTES = 16;
|
|
157
|
+
const PAYLOAD_ENVELOPE_SIGNED_FLAG = 1;
|
|
158
|
+
const PAYLOAD_ENVELOPE_COMPRESSED_FLAG = 1 << 1;
|
|
159
|
+
const PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG = 1 << 2;
|
|
160
|
+
const PAYLOAD_ENVELOPE_COMPRESSION_IDS = Object.freeze({ none: 0, lz: 1 });
|
|
161
|
+
const PAYLOAD_ENVELOPE_COMPRESSION_BY_ID = Object.freeze({ 0: "none", 1: "lz" });
|
|
162
|
+
const PAYLOAD_ENVELOPE_SIGNATURE_IDS = Object.freeze({ none: 0, Ed25519: 1 });
|
|
163
|
+
const PAYLOAD_ENVELOPE_SIGNATURE_BY_ID = Object.freeze({ 0: null, 1: "Ed25519" });
|
|
124
164
|
const ECC_SHIFT = 1;
|
|
125
165
|
const ECC_MASK = 0b00000110;
|
|
126
166
|
|
|
@@ -199,6 +239,368 @@ function readU32be(bytes, offset = 0) {
|
|
|
199
239
|
) >>> 0;
|
|
200
240
|
}
|
|
201
241
|
|
|
242
|
+
function u16be(value) {
|
|
243
|
+
const v = Number(value) >>> 0;
|
|
244
|
+
assert(v <= 0xffff, "Value does not fit in uint16.");
|
|
245
|
+
return new Uint8Array([(v >>> 8) & 0xff, v & 0xff]);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function readU16be(bytes, offset = 0) {
|
|
249
|
+
return ((bytes[offset] << 8) | bytes[offset + 1]) >>> 0;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function normalizeCompressionMode(value = "none") {
|
|
253
|
+
const key = String(value).toLowerCase();
|
|
254
|
+
assert(["none", "auto", "lz"].includes(key), "compression must be none, auto, or lz.");
|
|
255
|
+
return key;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function asBytes(input) {
|
|
259
|
+
if (input instanceof Uint8Array) return input;
|
|
260
|
+
if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
261
|
+
if (input instanceof ArrayBuffer) return new Uint8Array(input);
|
|
262
|
+
return new Uint8Array(input);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Portable LZSS-style compressor used by QuadQR payload compression. */
|
|
266
|
+
export function compressPayload(input) {
|
|
267
|
+
const bytes = asBytes(input);
|
|
268
|
+
if (bytes.length < 4) return bytes.slice();
|
|
269
|
+
const out = [];
|
|
270
|
+
const recent = new Map();
|
|
271
|
+
let pos = 0;
|
|
272
|
+
|
|
273
|
+
const keyAt = (i) => i + 2 < bytes.length ? ((bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]) : -1;
|
|
274
|
+
const remember = (i) => {
|
|
275
|
+
const key = keyAt(i);
|
|
276
|
+
if (key < 0) return;
|
|
277
|
+
let list = recent.get(key);
|
|
278
|
+
if (!list) recent.set(key, list = []);
|
|
279
|
+
list.push(i);
|
|
280
|
+
while (list.length > 32) list.shift();
|
|
281
|
+
while (list.length && i - list[0] > 4095) list.shift();
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
while (pos < bytes.length) {
|
|
285
|
+
const flagIndex = out.length;
|
|
286
|
+
out.push(0);
|
|
287
|
+
let flags = 0;
|
|
288
|
+
for (let token = 0; token < 8 && pos < bytes.length; token++) {
|
|
289
|
+
let bestLength = 0;
|
|
290
|
+
let bestOffset = 0;
|
|
291
|
+
const key = keyAt(pos);
|
|
292
|
+
const candidates = key >= 0 ? (recent.get(key) ?? []) : [];
|
|
293
|
+
for (let ci = candidates.length - 1; ci >= 0; ci--) {
|
|
294
|
+
const candidate = candidates[ci];
|
|
295
|
+
const offset = pos - candidate;
|
|
296
|
+
if (offset <= 0 || offset > 4095) continue;
|
|
297
|
+
let length = 0;
|
|
298
|
+
while (length < 18 && pos + length < bytes.length && bytes[candidate + length] === bytes[pos + length]) length++;
|
|
299
|
+
if (length >= 3 && length > bestLength) {
|
|
300
|
+
bestLength = length;
|
|
301
|
+
bestOffset = offset;
|
|
302
|
+
if (length === 18) break;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (bestLength >= 3) {
|
|
307
|
+
flags |= (1 << token);
|
|
308
|
+
const encoded = ((bestOffset & 0x0fff) << 4) | ((bestLength - 3) & 0x0f);
|
|
309
|
+
out.push((encoded >>> 8) & 0xff, encoded & 0xff);
|
|
310
|
+
for (let i = 0; i < bestLength; i++) remember(pos + i);
|
|
311
|
+
pos += bestLength;
|
|
312
|
+
} else {
|
|
313
|
+
out.push(bytes[pos]);
|
|
314
|
+
remember(pos);
|
|
315
|
+
pos++;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
out[flagIndex] = flags;
|
|
319
|
+
}
|
|
320
|
+
return Uint8Array.from(out);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function decompressPayload(input, expectedLength = null) {
|
|
324
|
+
const bytes = asBytes(input);
|
|
325
|
+
const out = [];
|
|
326
|
+
let pos = 0;
|
|
327
|
+
while (pos < bytes.length) {
|
|
328
|
+
const flags = bytes[pos++];
|
|
329
|
+
for (let token = 0; token < 8 && pos < bytes.length; token++) {
|
|
330
|
+
if (flags & (1 << token)) {
|
|
331
|
+
assert(pos + 1 < bytes.length, "Compressed payload is truncated.");
|
|
332
|
+
const encoded = (bytes[pos++] << 8) | bytes[pos++];
|
|
333
|
+
const offset = (encoded >>> 4) & 0x0fff;
|
|
334
|
+
const length = (encoded & 0x0f) + 3;
|
|
335
|
+
assert(offset > 0 && offset <= out.length, "Compressed payload has an invalid back-reference.");
|
|
336
|
+
for (let i = 0; i < length; i++) out.push(out[out.length - offset]);
|
|
337
|
+
} else {
|
|
338
|
+
out.push(bytes[pos++]);
|
|
339
|
+
}
|
|
340
|
+
if (expectedLength != null && out.length >= expectedLength) break;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (expectedLength != null) assert(out.length === expectedLength, "Decompressed payload length mismatch.");
|
|
344
|
+
return Uint8Array.from(out);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function prepareCompressedBody(payload, compression = "none") {
|
|
348
|
+
const mode = normalizeCompressionMode(compression);
|
|
349
|
+
if (mode === "none") return { body: payload, compression: "none" };
|
|
350
|
+
const compressed = compressPayload(payload);
|
|
351
|
+
if (mode === "auto" && compressed.length >= payload.length - 2) {
|
|
352
|
+
return { body: payload, compression: "none" };
|
|
353
|
+
}
|
|
354
|
+
return { body: compressed, compression: "lz" };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
function normalizeSigningKeyId(value) {
|
|
359
|
+
if (value == null || value === false || value === "") return new Uint8Array(0);
|
|
360
|
+
const bytes = typeof value === "string" ? getTextEncoder().encode(value) : asBytes(value);
|
|
361
|
+
assert(bytes.length <= 64, "Signing key ID must be 64 bytes or fewer.");
|
|
362
|
+
return bytes;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function makePayloadEnvelopeHeader({ compression, flags = 0, originalLength, keyIdLength = 0, publicKeyLength = 0, signatureLength = 0, signatureAlgorithm = null, version = PAYLOAD_ENVELOPE_VERSION }) {
|
|
366
|
+
assert(keyIdLength <= 0xff, "Signing key ID is too long.");
|
|
367
|
+
assert(publicKeyLength <= 0xff, "Signing public key is too large.");
|
|
368
|
+
assert(signatureLength <= 0xff, "Signature is too large.");
|
|
369
|
+
const header = new Uint8Array(PAYLOAD_ENVELOPE_HEADER_BYTES);
|
|
370
|
+
header.set(PAYLOAD_ENVELOPE_MAGIC, 0);
|
|
371
|
+
header[4] = version;
|
|
372
|
+
header[5] = flags & 0xff;
|
|
373
|
+
header[6] = PAYLOAD_ENVELOPE_COMPRESSION_IDS[compression];
|
|
374
|
+
header[7] = PAYLOAD_ENVELOPE_SIGNATURE_IDS[signatureAlgorithm ?? "none"] ?? 0;
|
|
375
|
+
header.set(u32be(originalLength), 8);
|
|
376
|
+
header[12] = keyIdLength;
|
|
377
|
+
header[13] = publicKeyLength;
|
|
378
|
+
header[14] = signatureLength;
|
|
379
|
+
header[15] = 0;
|
|
380
|
+
return header;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function isPayloadEnvelope(input) {
|
|
384
|
+
const bytes = asBytes(input);
|
|
385
|
+
return bytes.length >= PAYLOAD_ENVELOPE_HEADER_BYTES && PAYLOAD_ENVELOPE_MAGIC.every((value, index) => bytes[index] === value);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function packPayloadEnvelope(input, options = {}) {
|
|
389
|
+
const payload = asBytes(input);
|
|
390
|
+
const prepared = prepareCompressedBody(payload, options.compression ?? "none");
|
|
391
|
+
const flags = prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0;
|
|
392
|
+
const header = makePayloadEnvelopeHeader({
|
|
393
|
+
compression: prepared.compression,
|
|
394
|
+
flags,
|
|
395
|
+
originalLength: payload.length
|
|
396
|
+
});
|
|
397
|
+
return concatBytes(header, prepared.body);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function parsePayloadEnvelope(input) {
|
|
401
|
+
const container = asBytes(input);
|
|
402
|
+
assert(isPayloadEnvelope(container), "Payload extension envelope is not recognized.");
|
|
403
|
+
const version = container[4];
|
|
404
|
+
assert(version === 1 || version === PAYLOAD_ENVELOPE_VERSION, `Unsupported payload extension version ${version}.`);
|
|
405
|
+
const flags = container[5];
|
|
406
|
+
const compression = PAYLOAD_ENVELOPE_COMPRESSION_BY_ID[container[6]];
|
|
407
|
+
assert(compression != null, `Unknown payload compression id ${container[6]}.`);
|
|
408
|
+
const signatureAlgorithm = PAYLOAD_ENVELOPE_SIGNATURE_BY_ID[container[7]];
|
|
409
|
+
const originalLength = readU32be(container, 8);
|
|
410
|
+
const metadataLength = container[12];
|
|
411
|
+
const publicKeyLength = container[13];
|
|
412
|
+
const signatureLength = container[14];
|
|
413
|
+
let offset = PAYLOAD_ENVELOPE_HEADER_BYTES;
|
|
414
|
+
const endMetadata = offset + metadataLength;
|
|
415
|
+
const endPublicKey = endMetadata + publicKeyLength;
|
|
416
|
+
const endSignature = endPublicKey + signatureLength;
|
|
417
|
+
assert(endSignature <= container.length, "Payload extension length fields exceed the envelope size.");
|
|
418
|
+
const metadataBytes = container.slice(offset, endMetadata);
|
|
419
|
+
const publicKey = container.slice(endMetadata, endPublicKey);
|
|
420
|
+
const signature = container.slice(endPublicKey, endSignature);
|
|
421
|
+
const stored = container.slice(endSignature);
|
|
422
|
+
const payload = compression === "lz" ? decompressPayload(stored, originalLength) : stored;
|
|
423
|
+
assert(payload.length === originalLength, "Payload length mismatch after extension decoding.");
|
|
424
|
+
const signed = Boolean(flags & PAYLOAD_ENVELOPE_SIGNED_FLAG);
|
|
425
|
+
const signedBytes = signed ? concatBytes(container.slice(0, PAYLOAD_ENVELOPE_HEADER_BYTES), metadataBytes, publicKey, stored) : null;
|
|
426
|
+
|
|
427
|
+
// v1 used byte 12 for a human-readable signer label and always embedded
|
|
428
|
+
// the public key. v2 uses the same field for a compact key ID and makes
|
|
429
|
+
// public-key embedding optional.
|
|
430
|
+
const legacySigner = version === 1 && metadataLength ? getTextDecoder().decode(metadataBytes) : null;
|
|
431
|
+
const keyId = version >= 2 && metadataLength ? getTextDecoder().decode(metadataBytes) : null;
|
|
432
|
+
const keyIdHex = version >= 2 && metadataLength
|
|
433
|
+
? Array.from(metadataBytes, (value) => value.toString(16).padStart(2, "0")).join("")
|
|
434
|
+
: null;
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
container,
|
|
438
|
+
version,
|
|
439
|
+
compression,
|
|
440
|
+
compressed: compression !== "none",
|
|
441
|
+
originalBytes: originalLength,
|
|
442
|
+
storedBytes: stored.length,
|
|
443
|
+
savedBytes: originalLength - stored.length,
|
|
444
|
+
payload,
|
|
445
|
+
signed,
|
|
446
|
+
signer: legacySigner,
|
|
447
|
+
keyId,
|
|
448
|
+
keyIdHex,
|
|
449
|
+
publicKey,
|
|
450
|
+
hasEmbeddedPublicKey: publicKey.length > 0,
|
|
451
|
+
signature,
|
|
452
|
+
signatureAlgorithm,
|
|
453
|
+
signedBytes,
|
|
454
|
+
signatureVerified: signed ? null : undefined,
|
|
455
|
+
signatureTrusted: signed ? null : undefined
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function subtleCrypto() {
|
|
460
|
+
const subtle = globalThis.crypto?.subtle;
|
|
461
|
+
assert(subtle, "Web Crypto is required for QuadQR signing.");
|
|
462
|
+
return subtle;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function normalizeEd25519PublicKey(key) {
|
|
466
|
+
const subtle = subtleCrypto();
|
|
467
|
+
if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) return key;
|
|
468
|
+
if (key && typeof key === "object" && key.type === "public" && key.algorithm?.name === "Ed25519") return key;
|
|
469
|
+
const bytes = asBytes(key);
|
|
470
|
+
assert(bytes.length === 32, "Ed25519 public key must be a 32-byte raw key or CryptoKey.");
|
|
471
|
+
return subtle.importKey("raw", bytes, { name: "Ed25519" }, true, ["verify"]);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function normalizeEd25519PrivateKey(key) {
|
|
475
|
+
const subtle = subtleCrypto();
|
|
476
|
+
if (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) return key;
|
|
477
|
+
if (key && typeof key === "object" && key.type === "private" && key.algorithm?.name === "Ed25519") return key;
|
|
478
|
+
const bytes = asBytes(key);
|
|
479
|
+
return subtle.importKey("pkcs8", bytes, { name: "Ed25519" }, false, ["sign"]);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export async function deriveSigningKeyId(publicKey, bytes = 8) {
|
|
483
|
+
const subtle = subtleCrypto();
|
|
484
|
+
const normalized = await normalizeEd25519PublicKey(publicKey);
|
|
485
|
+
const raw = new Uint8Array(await subtle.exportKey("raw", normalized));
|
|
486
|
+
const digest = new Uint8Array(await subtle.digest("SHA-256", raw));
|
|
487
|
+
const count = Math.max(4, Math.min(16, Math.floor(bytes)));
|
|
488
|
+
return Array.from(digest.slice(0, count), (value) => value.toString(16).padStart(2, "0")).join("");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export async function generateSigningKeyPair() {
|
|
492
|
+
const subtle = subtleCrypto();
|
|
493
|
+
const pair = await subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
|
|
494
|
+
const publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", pair.publicKey));
|
|
495
|
+
const privateKeyPkcs8 = new Uint8Array(await subtle.exportKey("pkcs8", pair.privateKey));
|
|
496
|
+
const keyId = await deriveSigningKeyId(pair.publicKey);
|
|
497
|
+
return {
|
|
498
|
+
privateKey: pair.privateKey,
|
|
499
|
+
publicKey: pair.publicKey,
|
|
500
|
+
privateKeyPkcs8,
|
|
501
|
+
publicKeyBytes,
|
|
502
|
+
keyId,
|
|
503
|
+
algorithm: "Ed25519"
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function packSignedPayloadEnvelope(input, options = {}) {
|
|
508
|
+
const payload = asBytes(input);
|
|
509
|
+
assert(options.privateKey, "privateKey is required to sign a QuadQR payload.");
|
|
510
|
+
const subtle = subtleCrypto();
|
|
511
|
+
const prepared = prepareCompressedBody(payload, options.compression ?? "auto");
|
|
512
|
+
const keyIdBytes = normalizeSigningKeyId(options.keyId);
|
|
513
|
+
|
|
514
|
+
let publicKeyBytes = new Uint8Array(0);
|
|
515
|
+
if (options.embedPublicKey) {
|
|
516
|
+
assert(options.publicKey, "publicKey is required when embedPublicKey is enabled.");
|
|
517
|
+
const publicKeyObject = await normalizeEd25519PublicKey(options.publicKey);
|
|
518
|
+
publicKeyBytes = new Uint8Array(await subtle.exportKey("raw", publicKeyObject));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const flags = PAYLOAD_ENVELOPE_SIGNED_FLAG |
|
|
522
|
+
(prepared.compression !== "none" ? PAYLOAD_ENVELOPE_COMPRESSED_FLAG : 0) |
|
|
523
|
+
(publicKeyBytes.length ? PAYLOAD_ENVELOPE_EMBEDDED_KEY_FLAG : 0);
|
|
524
|
+
const header = makePayloadEnvelopeHeader({
|
|
525
|
+
compression: prepared.compression,
|
|
526
|
+
flags,
|
|
527
|
+
originalLength: payload.length,
|
|
528
|
+
keyIdLength: keyIdBytes.length,
|
|
529
|
+
publicKeyLength: publicKeyBytes.length,
|
|
530
|
+
signatureLength: 64,
|
|
531
|
+
signatureAlgorithm: "Ed25519"
|
|
532
|
+
});
|
|
533
|
+
const signedBytes = concatBytes(header, keyIdBytes, publicKeyBytes, prepared.body);
|
|
534
|
+
const privateKey = await normalizeEd25519PrivateKey(options.privateKey);
|
|
535
|
+
const signature = new Uint8Array(await subtle.sign({ name: "Ed25519" }, privateKey, signedBytes));
|
|
536
|
+
assert(signature.length === 64, "Unexpected Ed25519 signature length.");
|
|
537
|
+
return concatBytes(header, keyIdBytes, publicKeyBytes, signature, prepared.body);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function lookupTrustedSigningKey(trustedKeys, keyId) {
|
|
541
|
+
if (!trustedKeys || !keyId) return null;
|
|
542
|
+
if (trustedKeys instanceof Map) return trustedKeys.get(keyId) ?? null;
|
|
543
|
+
if (typeof trustedKeys === "object") return trustedKeys[keyId] ?? null;
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function verifyPayloadEnvelopeSignature(input, options = {}) {
|
|
548
|
+
const parsed = input?.container && input?.signedBytes ? input : parsePayloadEnvelope(input);
|
|
549
|
+
assert(parsed.signed, "QuadQR payload is not signed.");
|
|
550
|
+
assert(parsed.signatureAlgorithm === "Ed25519", "Unsupported signature algorithm.");
|
|
551
|
+
const subtle = subtleCrypto();
|
|
552
|
+
|
|
553
|
+
let trusted = options.publicKey ?? options.trustedPublicKey ?? lookupTrustedSigningKey(options.trustedKeys, parsed.keyId);
|
|
554
|
+
let trustSource = trusted ? "external" : null;
|
|
555
|
+
|
|
556
|
+
// Legacy v1 symbols embedded the public key by default, so preserve their
|
|
557
|
+
// historical self-verification behavior. v2 requires explicit opt-in.
|
|
558
|
+
if (!trusted && parsed.publicKey.length && (parsed.version === 1 || options.allowEmbeddedKey === true)) {
|
|
559
|
+
trusted = parsed.publicKey;
|
|
560
|
+
trustSource = "embedded";
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
assert(
|
|
564
|
+
trusted,
|
|
565
|
+
parsed.keyId
|
|
566
|
+
? `A trusted Ed25519 public key is required to verify signing key ID "${parsed.keyId}".`
|
|
567
|
+
: "A trusted Ed25519 public key is required to verify this signed QuadQR."
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
const publicKey = await normalizeEd25519PublicKey(trusted);
|
|
571
|
+
if (trustSource === "external" && parsed.publicKey.length) {
|
|
572
|
+
const trustedBytes = new Uint8Array(await subtle.exportKey("raw", publicKey));
|
|
573
|
+
assert(
|
|
574
|
+
trustedBytes.length === parsed.publicKey.length && trustedBytes.every((value, index) => value === parsed.publicKey[index]),
|
|
575
|
+
"Embedded signing key does not match the trusted public key."
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const verified = await subtle.verify({ name: "Ed25519" }, publicKey, parsed.signature, parsed.signedBytes);
|
|
580
|
+
return {
|
|
581
|
+
verified,
|
|
582
|
+
trusted: verified && trustSource === "external",
|
|
583
|
+
trustSource,
|
|
584
|
+
keyId: parsed.keyId ?? null
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function prepareOptionalCompressedPayload(input, compression = "none") {
|
|
589
|
+
const payload = asBytes(input);
|
|
590
|
+
const mode = normalizeCompressionMode(compression);
|
|
591
|
+
if (mode === "none") return { payload, extended: false, compression: "none" };
|
|
592
|
+
const prepared = prepareCompressedBody(payload, mode);
|
|
593
|
+
// Auto mode is truly zero-overhead when compression does not help.
|
|
594
|
+
if (mode === "auto" && prepared.compression === "none") {
|
|
595
|
+
return { payload, extended: false, compression: "none" };
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
payload: packPayloadEnvelope(payload, { compression: prepared.compression }),
|
|
599
|
+
extended: true,
|
|
600
|
+
compression: prepared.compression
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
202
604
|
let CRC_TABLE = null;
|
|
203
605
|
let CRC32_ACCELERATOR = null;
|
|
204
606
|
|
|
@@ -263,9 +665,11 @@ function cellsToBytes(cells, byteCount = Math.floor(cells.length / CELLS_PER_BYT
|
|
|
263
665
|
return out;
|
|
264
666
|
}
|
|
265
667
|
|
|
266
|
-
function flagsFor(text, eccLevel, secure = false) {
|
|
668
|
+
function flagsFor(text, eccLevel, secure = false, extended = false, signed = false) {
|
|
267
669
|
return (text ? TEXT_FLAG : 0) |
|
|
268
670
|
(secure ? SECURE_FLAG : 0) |
|
|
671
|
+
(extended ? EXTENDED_PAYLOAD_FLAG : 0) |
|
|
672
|
+
(signed ? SIGNED_FLAG : 0) |
|
|
269
673
|
((ECC_LEVELS[eccLevel].id << ECC_SHIFT) & ECC_MASK);
|
|
270
674
|
}
|
|
271
675
|
|
|
@@ -805,6 +1209,11 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
805
1209
|
payloadBytes: meta.payloadBytes,
|
|
806
1210
|
sourcePayloadBytes: meta.sourcePayloadBytes ?? meta.payloadBytes,
|
|
807
1211
|
secure: Boolean(meta.secure),
|
|
1212
|
+
compressed: meta.compression != null && meta.compression !== "none",
|
|
1213
|
+
compression: meta.compression ?? "none",
|
|
1214
|
+
signed: Boolean(meta.signed),
|
|
1215
|
+
signingKeyId: meta.signingKeyId ?? null,
|
|
1216
|
+
hasEmbeddedPublicKey: Boolean(meta.hasEmbeddedPublicKey),
|
|
808
1217
|
requiresDecryption: Boolean(meta.secure),
|
|
809
1218
|
security: meta.security ?? null,
|
|
810
1219
|
meaningfulCells: meta.meaningfulCells,
|
|
@@ -823,20 +1232,17 @@ function finalizeMatrix(layout, rawCells, meta) {
|
|
|
823
1232
|
};
|
|
824
1233
|
}
|
|
825
1234
|
|
|
826
|
-
|
|
827
|
-
assert(typeof text === "string", "encodeText expects a string.");
|
|
828
|
-
return encodeBytes(getTextEncoder().encode(text), { ...options, text: true });
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
export function encodeBytes(input, options = {}) {
|
|
1235
|
+
function encodePreparedBytes(input, options = {}) {
|
|
832
1236
|
if (options.formatVersion != null && options.formatVersion !== FORMAT_VERSION) {
|
|
833
1237
|
throw new Error(`Only QuadQR format version ${FORMAT_VERSION} is supported.`);
|
|
834
1238
|
}
|
|
835
1239
|
|
|
836
|
-
const payload =
|
|
1240
|
+
const payload = asBytes(input);
|
|
837
1241
|
const eccLevel = normalizeEccLevel(options.ecc ?? DEFAULT_ECC_LEVEL);
|
|
838
1242
|
const secure = Boolean(options.secure);
|
|
839
|
-
const
|
|
1243
|
+
const extended = Boolean(options.extended);
|
|
1244
|
+
const signed = Boolean(options.signed);
|
|
1245
|
+
const flags = flagsFor(Boolean(options.text), eccLevel, secure, extended, signed);
|
|
840
1246
|
const version = chooseVersion(payload.length, { ...options, ecc: eccLevel });
|
|
841
1247
|
const layout = createLayout(version);
|
|
842
1248
|
const header = makeHeader(payload.length, flags, version);
|
|
@@ -856,6 +1262,11 @@ export function encodeBytes(input, options = {}) {
|
|
|
856
1262
|
payloadBytes: payload.length,
|
|
857
1263
|
sourcePayloadBytes: options.sourcePayloadBytes ?? payload.length,
|
|
858
1264
|
secure,
|
|
1265
|
+
extended,
|
|
1266
|
+
signed,
|
|
1267
|
+
compression: options.compressionMetadata ?? "none",
|
|
1268
|
+
signingKeyId: options.signingKeyId ?? null,
|
|
1269
|
+
hasEmbeddedPublicKey: Boolean(options.hasEmbeddedPublicKey),
|
|
859
1270
|
security: options.securityMetadata ?? null,
|
|
860
1271
|
meaningfulCells: meaningfulCells.length,
|
|
861
1272
|
eccLevel,
|
|
@@ -866,6 +1277,59 @@ export function encodeBytes(input, options = {}) {
|
|
|
866
1277
|
});
|
|
867
1278
|
}
|
|
868
1279
|
|
|
1280
|
+
export function encodeText(text, options = {}) {
|
|
1281
|
+
assert(typeof text === "string", "encodeText expects a string.");
|
|
1282
|
+
const source = getTextEncoder().encode(text);
|
|
1283
|
+
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
|
|
1284
|
+
return encodePreparedBytes(prepared.payload, {
|
|
1285
|
+
...options,
|
|
1286
|
+
text: true,
|
|
1287
|
+
extended: prepared.extended,
|
|
1288
|
+
sourcePayloadBytes: source.length,
|
|
1289
|
+
compressionMetadata: prepared.compression
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
export function encodeBytes(input, options = {}) {
|
|
1294
|
+
const source = asBytes(input);
|
|
1295
|
+
const prepared = prepareOptionalCompressedPayload(source, options.compression ?? "none");
|
|
1296
|
+
return encodePreparedBytes(prepared.payload, {
|
|
1297
|
+
...options,
|
|
1298
|
+
text: Boolean(options.text),
|
|
1299
|
+
extended: prepared.extended,
|
|
1300
|
+
sourcePayloadBytes: source.length,
|
|
1301
|
+
compressionMetadata: prepared.compression
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/** Explicit Uint8Array convenience API. Equivalent to encodeBytes(). */
|
|
1306
|
+
export function encodeUint8Array(input, options = {}) {
|
|
1307
|
+
return encodeBytes(asBytes(input), options);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/** Encode and sign a normal payload using an internal Ed25519 envelope. */
|
|
1311
|
+
export async function encodeSignedBytes(input, options = {}) {
|
|
1312
|
+
const source = asBytes(input);
|
|
1313
|
+
const envelope = await packSignedPayloadEnvelope(source, options);
|
|
1314
|
+
const parsed = parsePayloadEnvelope(envelope);
|
|
1315
|
+
return encodePreparedBytes(envelope, {
|
|
1316
|
+
...options,
|
|
1317
|
+
text: Boolean(options.text),
|
|
1318
|
+
extended: true,
|
|
1319
|
+
signed: true,
|
|
1320
|
+
sourcePayloadBytes: source.length,
|
|
1321
|
+
compressionMetadata: parsed.compression,
|
|
1322
|
+
signingKeyId: parsed.keyId,
|
|
1323
|
+
hasEmbeddedPublicKey: parsed.hasEmbeddedPublicKey
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
export async function encodeSignedText(text, options = {}) {
|
|
1328
|
+
assert(typeof text === "string", "encodeSignedText expects a string.");
|
|
1329
|
+
return encodeSignedBytes(getTextEncoder().encode(text), { ...options, text: true });
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
|
|
869
1333
|
/**
|
|
870
1334
|
* Encode text using the optional QuadQR Secure Payload v1 layer.
|
|
871
1335
|
* This API is async because Web Crypto performs AES-GCM and password KDF work asynchronously.
|
|
@@ -877,32 +1341,75 @@ export async function encodeSecureText(text, options = {}) {
|
|
|
877
1341
|
|
|
878
1342
|
/** Encode arbitrary bytes using password or raw 256-bit key security. */
|
|
879
1343
|
export async function encodeSecureBytes(input, options = {}) {
|
|
880
|
-
const sourcePayload =
|
|
1344
|
+
const sourcePayload = asBytes(input);
|
|
881
1345
|
const security = options.security ?? {};
|
|
882
|
-
|
|
883
|
-
|
|
1346
|
+
let protectedPayload = sourcePayload;
|
|
1347
|
+
let extended = false;
|
|
1348
|
+
let signed = false;
|
|
1349
|
+
let compression = "none";
|
|
1350
|
+
let signingKeyId = null;
|
|
1351
|
+
let hasEmbeddedPublicKey = false;
|
|
1352
|
+
|
|
1353
|
+
if (options.signing) {
|
|
1354
|
+
protectedPayload = await packSignedPayloadEnvelope(sourcePayload, {
|
|
1355
|
+
...options,
|
|
1356
|
+
...options.signing,
|
|
1357
|
+
compression: options.compression ?? "auto"
|
|
1358
|
+
});
|
|
1359
|
+
const parsed = parsePayloadEnvelope(protectedPayload);
|
|
1360
|
+
extended = true;
|
|
1361
|
+
signed = true;
|
|
1362
|
+
compression = parsed.compression;
|
|
1363
|
+
signingKeyId = parsed.keyId;
|
|
1364
|
+
hasEmbeddedPublicKey = parsed.hasEmbeddedPublicKey;
|
|
1365
|
+
} else {
|
|
1366
|
+
const prepared = prepareOptionalCompressedPayload(sourcePayload, options.compression ?? "none");
|
|
1367
|
+
protectedPayload = prepared.payload;
|
|
1368
|
+
extended = prepared.extended;
|
|
1369
|
+
compression = prepared.compression;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const encrypted = await encryptSecurePayload(protectedPayload, security);
|
|
1373
|
+
return encodePreparedBytes(encrypted.envelope, {
|
|
884
1374
|
...options,
|
|
885
1375
|
text: Boolean(options.text),
|
|
886
1376
|
secure: true,
|
|
1377
|
+
extended,
|
|
1378
|
+
signed,
|
|
887
1379
|
sourcePayloadBytes: sourcePayload.length,
|
|
1380
|
+
compressionMetadata: compression,
|
|
1381
|
+
signingKeyId,
|
|
1382
|
+
hasEmbeddedPublicKey,
|
|
888
1383
|
securityMetadata: encrypted.metadata
|
|
889
1384
|
});
|
|
890
1385
|
}
|
|
891
1386
|
|
|
892
1387
|
/**
|
|
893
1388
|
* Decrypt a result returned by decodeMatrix/scanImageData/scanFile.
|
|
894
|
-
*
|
|
1389
|
+
* Compression and signatures are automatically unwrapped after decryption.
|
|
895
1390
|
*/
|
|
896
1391
|
export async function decryptDecoded(result, security = {}) {
|
|
897
1392
|
assert(result?.secure && result?.payload, "decryptDecoded expects an encrypted QuadQR decode result.");
|
|
898
1393
|
const plaintext = await decryptSecurePayload(result.payload, security);
|
|
899
1394
|
const isText = (result.flags & TEXT_FLAG) !== 0;
|
|
1395
|
+
const extended = (result.flags & EXTENDED_PAYLOAD_FLAG) !== 0;
|
|
1396
|
+
const envelope = extended ? parsePayloadEnvelope(plaintext) : null;
|
|
1397
|
+
const payload = envelope ? envelope.payload : plaintext;
|
|
900
1398
|
return {
|
|
901
1399
|
...result,
|
|
902
1400
|
encryptedPayload: result.payload,
|
|
903
1401
|
encryptedPayloadBytes: result.payload.length,
|
|
904
|
-
|
|
905
|
-
|
|
1402
|
+
protectedPayload: envelope ? plaintext : undefined,
|
|
1403
|
+
payload,
|
|
1404
|
+
text: isText ? getTextDecoder().decode(payload) : null,
|
|
1405
|
+
compressed: envelope?.compressed ?? false,
|
|
1406
|
+
compression: envelope?.compression ?? "none",
|
|
1407
|
+
signed: envelope?.signed ?? result.signed ?? false,
|
|
1408
|
+
signatureVerified: envelope?.signatureVerified ?? result.signatureVerified,
|
|
1409
|
+
signatureTrusted: envelope?.signatureTrusted ?? result.signatureTrusted,
|
|
1410
|
+
signingKeyId: envelope?.keyId ?? result.signingKeyId ?? null,
|
|
1411
|
+
hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? result.hasEmbeddedPublicKey ?? false,
|
|
1412
|
+
signer: envelope?.signer ?? result.signer ?? null,
|
|
906
1413
|
decrypted: true,
|
|
907
1414
|
requiresDecryption: false,
|
|
908
1415
|
security: { ...result.security, decrypted: true }
|
|
@@ -1183,7 +1690,11 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
|
|
|
1183
1690
|
|
|
1184
1691
|
const isText = (flags & TEXT_FLAG) !== 0;
|
|
1185
1692
|
const secure = (flags & SECURE_FLAG) !== 0;
|
|
1693
|
+
const extendedFlag = (flags & EXTENDED_PAYLOAD_FLAG) !== 0;
|
|
1694
|
+
const signedFlag = (flags & SIGNED_FLAG) !== 0;
|
|
1186
1695
|
const security = secure ? inspectSecureEnvelope(payload) : null;
|
|
1696
|
+
const envelope = extendedFlag && !secure ? parsePayloadEnvelope(payload) : null;
|
|
1697
|
+
const applicationPayload = envelope ? envelope.payload : payload;
|
|
1187
1698
|
const erasureSymbols = headerDecoded.erasureSymbols + bodyDecoded.erasureSymbols;
|
|
1188
1699
|
const unknownErrorSymbols = headerDecoded.unknownErrorSymbols + bodyDecoded.unknownErrorSymbols;
|
|
1189
1700
|
return {
|
|
@@ -1208,8 +1719,17 @@ function decodeCanonical(matrix, rotation, tolerance = 0, confidenceMatrix = nul
|
|
|
1208
1719
|
correctedHeaderSymbols: headerDecoded.correctedSymbols,
|
|
1209
1720
|
correctedBodySymbols: bodyDecoded.correctedSymbols,
|
|
1210
1721
|
correctedSymbols: headerDecoded.correctedSymbols + bodyDecoded.correctedSymbols,
|
|
1211
|
-
payload,
|
|
1212
|
-
|
|
1722
|
+
protectedPayload: envelope ? payload : undefined,
|
|
1723
|
+
payload: secure ? payload : applicationPayload,
|
|
1724
|
+
text: secure ? null : (isText ? getTextDecoder().decode(applicationPayload) : null),
|
|
1725
|
+
compressed: envelope?.compressed ?? false,
|
|
1726
|
+
compression: envelope?.compression ?? "none",
|
|
1727
|
+
signed: envelope?.signed ?? signedFlag,
|
|
1728
|
+
signatureVerified: envelope?.signatureVerified,
|
|
1729
|
+
signatureTrusted: envelope?.signatureTrusted,
|
|
1730
|
+
signingKeyId: envelope?.keyId ?? null,
|
|
1731
|
+
hasEmbeddedPublicKey: envelope?.hasEmbeddedPublicKey ?? false,
|
|
1732
|
+
signer: envelope?.signer ?? null,
|
|
1213
1733
|
secure,
|
|
1214
1734
|
encrypted: secure,
|
|
1215
1735
|
decrypted: false,
|
|
@@ -1254,6 +1774,27 @@ export function decodeMatrix(inputMatrix, options = {}) {
|
|
|
1254
1774
|
throw new Error(`Unable to decode matrix. ${errors.join(" || ")}`);
|
|
1255
1775
|
}
|
|
1256
1776
|
|
|
1777
|
+
|
|
1778
|
+
/** Decode a matrix and return only its application payload bytes. */
|
|
1779
|
+
export function decodeUint8Array(inputMatrix, options = {}) {
|
|
1780
|
+
return decodeMatrix(inputMatrix, options).payload;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
/** Verify a signed decode result and return the result with verification state attached. */
|
|
1784
|
+
export async function verifyDecodedSignature(result, options = {}) {
|
|
1785
|
+
assert(result?.signed, "Decode result is not signed.");
|
|
1786
|
+
const source = result.protectedPayload;
|
|
1787
|
+
assert(source, "Signed payload metadata is unavailable. Decrypt secure content first if necessary.");
|
|
1788
|
+
const verification = await verifyPayloadEnvelopeSignature(source, options);
|
|
1789
|
+
return {
|
|
1790
|
+
...result,
|
|
1791
|
+
signatureVerified: verification.verified,
|
|
1792
|
+
signatureTrusted: verification.trusted,
|
|
1793
|
+
signingKeyId: verification.keyId ?? result.signingKeyId ?? null,
|
|
1794
|
+
signatureTrustSource: verification.trustSource
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1257
1798
|
function hexToRgb(hex) {
|
|
1258
1799
|
const clean = hex.replace("#", "");
|
|
1259
1800
|
assert(clean.length === 6, `Invalid hex color ${hex}.`);
|
|
@@ -1268,6 +1809,18 @@ function resolvePalette(palette = {}) {
|
|
|
1268
1809
|
return { ...DEFAULT_PALETTE, ...palette };
|
|
1269
1810
|
}
|
|
1270
1811
|
|
|
1812
|
+
function normalizeRenderMode(mode = RENDER_MODES.SCREEN) {
|
|
1813
|
+
const value = String(mode).toLowerCase();
|
|
1814
|
+
assert(value === RENDER_MODES.SCREEN || value === RENDER_MODES.PRINT, "Render mode must be screen or print.");
|
|
1815
|
+
return value;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
function resolveRenderPalette(options = {}) {
|
|
1819
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
1820
|
+
const base = mode === RENDER_MODES.PRINT ? PRINT_PALETTE : DEFAULT_PALETTE;
|
|
1821
|
+
return { ...base, ...(options.palette ?? {}) };
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1271
1824
|
function paletteRgb(palette = {}) {
|
|
1272
1825
|
const p = resolvePalette(palette);
|
|
1273
1826
|
return {
|
|
@@ -1432,18 +1985,347 @@ function fillImageRoundedRect(data, width, x, y, rectWidth, rectHeight, radius,
|
|
|
1432
1985
|
}
|
|
1433
1986
|
}
|
|
1434
1987
|
|
|
1988
|
+
|
|
1989
|
+
/** Estimate a conservative center-logo ratio from ECC, utilization and output mode. */
|
|
1990
|
+
export function estimateSafeLogoSize(codeOrMatrix, options = {}) {
|
|
1991
|
+
const code = Array.isArray(codeOrMatrix) ? null : codeOrMatrix;
|
|
1992
|
+
const ecc = String(code?.eccLevel ?? options.ecc ?? DEFAULT_ECC_LEVEL).toUpperCase();
|
|
1993
|
+
const base = { L: 0.10, M: 0.13, Q: 0.16, H: 0.19 }[ecc] ?? 0.12;
|
|
1994
|
+
const utilization = Number.isFinite(code?.utilization) ? code.utilization : Number(options.utilization ?? 0.65);
|
|
1995
|
+
const utilizationPenalty = utilization <= 0.55 ? 1 : Math.max(0.72, 1 - (utilization - 0.55) * 0.56);
|
|
1996
|
+
const clearBackground = Boolean(options.clearBackground ?? options.logoClearBackground ?? options.logo?.clearBackground);
|
|
1997
|
+
const clearPenalty = clearBackground ? 0.90 : 1;
|
|
1998
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
1999
|
+
const printPenalty = mode === RENDER_MODES.PRINT ? 0.88 : 1;
|
|
2000
|
+
const versionPenalty = code?.version === 1 ? 0.88 : 1;
|
|
2001
|
+
return Math.max(0.07, Math.min(0.22, base * utilizationPenalty * clearPenalty * printPenalty * versionPenalty));
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
/**
|
|
2005
|
+
* Empirically search for the largest logo ratio that still decodes from the
|
|
2006
|
+
* dependency-free ImageData renderer. The logo source must therefore be ImageData-like.
|
|
2007
|
+
*/
|
|
2008
|
+
export function findMaxSafeLogoSize(code, options = {}) {
|
|
2009
|
+
assert(code?.matrix, "findMaxSafeLogoSize expects an encoded QuadQR object.");
|
|
2010
|
+
const logo = options.logo;
|
|
2011
|
+
const source = logo?.source ?? logo;
|
|
2012
|
+
assert(source?.data && source?.width && source?.height,
|
|
2013
|
+
"findMaxSafeLogoSize requires an ImageData-like logo source so it can test pixel output without DOM dependencies.");
|
|
2014
|
+
let low = Math.max(0.05, Number(options.minSize ?? 0.05));
|
|
2015
|
+
let high = Math.min(0.30, Number(options.maxSize ?? 0.28));
|
|
2016
|
+
let best = 0;
|
|
2017
|
+
const iterations = Math.max(3, Math.min(10, Math.floor(options.iterations ?? 7)));
|
|
2018
|
+
for (let i = 0; i < iterations; i++) {
|
|
2019
|
+
const probe = (low + high) / 2;
|
|
2020
|
+
try {
|
|
2021
|
+
const image = renderToImageData(code, {
|
|
2022
|
+
...options,
|
|
2023
|
+
logo: {
|
|
2024
|
+
...(typeof logo === "object" && Object.prototype.hasOwnProperty.call(logo, "source") ? logo : { source }),
|
|
2025
|
+
size: probe
|
|
2026
|
+
}
|
|
2027
|
+
});
|
|
2028
|
+
const decoded = scanImageData(image, { minVersion: code.version, maxVersion: code.version });
|
|
2029
|
+
if (decoded.crc32 !== code.crc32) throw new Error("Decoded a different payload.");
|
|
2030
|
+
best = probe;
|
|
2031
|
+
low = probe;
|
|
2032
|
+
} catch {
|
|
2033
|
+
high = probe;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
return {
|
|
2037
|
+
safeSize: best || estimateSafeLogoSize(code, options),
|
|
2038
|
+
testedMax: Number(options.maxSize ?? 0.28),
|
|
2039
|
+
iterations,
|
|
2040
|
+
empirical: best > 0
|
|
2041
|
+
};
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
export function getPrintGuidance(codeOrMatrix, options = {}) {
|
|
2045
|
+
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix?.matrix;
|
|
2046
|
+
assert(matrix?.length, "getPrintGuidance expects a QuadQR code or matrix.");
|
|
2047
|
+
const quietZone = Math.max(4, Math.floor(options.quietZone ?? 4));
|
|
2048
|
+
const physicalSizeMm = Number(options.physicalSizeMm ?? 35);
|
|
2049
|
+
const totalModules = matrix.length + quietZone * 2;
|
|
2050
|
+
const moduleSizeMm = physicalSizeMm / totalModules;
|
|
2051
|
+
const dpi = Number(options.dpi ?? 300);
|
|
2052
|
+
const modulePixels = moduleSizeMm / 25.4 * dpi;
|
|
2053
|
+
const recommendedMinimumModuleMm = Number(options.minimumModuleMm ?? 0.40);
|
|
2054
|
+
return {
|
|
2055
|
+
mode: "print",
|
|
2056
|
+
physicalSizeMm,
|
|
2057
|
+
quietZone,
|
|
2058
|
+
moduleSizeMm,
|
|
2059
|
+
modulePixelsAtDpi: modulePixels,
|
|
2060
|
+
dpi,
|
|
2061
|
+
recommendedMinimumModuleMm,
|
|
2062
|
+
meetsRecommendedModuleSize: moduleSizeMm >= recommendedMinimumModuleMm,
|
|
2063
|
+
recommendedMinimumPhysicalSizeMm: totalModules * recommendedMinimumModuleMm,
|
|
2064
|
+
palette: PRINT_PALETTE,
|
|
2065
|
+
recommendations: [
|
|
2066
|
+
"Use classic rendering for print unless a styled output has been physically validated.",
|
|
2067
|
+
"Keep at least a 4-module quiet zone.",
|
|
2068
|
+
`Target at least ${recommendedMinimumModuleMm.toFixed(2)} mm per module for general-purpose printing.`,
|
|
2069
|
+
"Avoid glossy reflections and low-ink/toner modes when reliable color classification matters."
|
|
2070
|
+
]
|
|
2071
|
+
};
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
function resolveLogoRenderOptions(options, matrixSize, moduleSize, quietZone, palette, codeOrMatrix = null) {
|
|
2075
|
+
if (!options.logo) return null;
|
|
2076
|
+
|
|
2077
|
+
const nested = (
|
|
2078
|
+
options.logo &&
|
|
2079
|
+
typeof options.logo === "object" &&
|
|
2080
|
+
Object.prototype.hasOwnProperty.call(options.logo, "source")
|
|
2081
|
+
) ? options.logo : null;
|
|
2082
|
+
const source = nested ? nested.source : options.logo;
|
|
2083
|
+
if (!source) return null;
|
|
2084
|
+
|
|
2085
|
+
const requestedSize = nested?.size ?? options.logoSize ?? 0.18;
|
|
2086
|
+
const autoSize = String(requestedSize).toLowerCase() === "auto";
|
|
2087
|
+
const rawSize = Number(requestedSize);
|
|
2088
|
+
const sizeRatio = autoSize
|
|
2089
|
+
? estimateSafeLogoSize(codeOrMatrix, { ...options, clearBackground: nested?.clearBackground ?? options.logoClearBackground })
|
|
2090
|
+
: Math.max(0.05, Math.min(0.30, Number.isFinite(rawSize) ? rawSize : 0.18));
|
|
2091
|
+
const rawPadding = Number(nested?.padding ?? options.logoPadding ?? 0.65);
|
|
2092
|
+
const rawRadius = Number(nested?.radius ?? options.logoRadius ?? 0.8);
|
|
2093
|
+
const paddingModules = Math.max(0, Number.isFinite(rawPadding) ? rawPadding : 0.65);
|
|
2094
|
+
const radiusModules = Math.max(0, Number.isFinite(rawRadius) ? rawRadius : 0.8);
|
|
2095
|
+
const clearBackground = Boolean(nested?.clearBackground ?? options.logoClearBackground ?? false);
|
|
2096
|
+
const backgroundColor = nested?.backgroundColor ?? options.logoBackgroundColor ?? palette.white;
|
|
2097
|
+
|
|
2098
|
+
const codePixels = matrixSize * moduleSize;
|
|
2099
|
+
const canvasPixels = (matrixSize + quietZone * 2) * moduleSize;
|
|
2100
|
+
const logoPixels = Math.max(1, codePixels * sizeRatio);
|
|
2101
|
+
const center = canvasPixels / 2;
|
|
2102
|
+
const paddingPixels = paddingModules * moduleSize;
|
|
2103
|
+
const backgroundPixels = logoPixels + paddingPixels * 2;
|
|
2104
|
+
|
|
2105
|
+
return {
|
|
2106
|
+
source,
|
|
2107
|
+
sizeRatio,
|
|
2108
|
+
clearBackground,
|
|
2109
|
+
backgroundColor,
|
|
2110
|
+
logoX: center - logoPixels / 2,
|
|
2111
|
+
logoY: center - logoPixels / 2,
|
|
2112
|
+
logoSize: logoPixels,
|
|
2113
|
+
backgroundX: center - backgroundPixels / 2,
|
|
2114
|
+
backgroundY: center - backgroundPixels / 2,
|
|
2115
|
+
backgroundSize: backgroundPixels,
|
|
2116
|
+
backgroundRadius: radiusModules * moduleSize
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
function canvasLogoSource(source) {
|
|
2121
|
+
if (!source || typeof source === "string") return source;
|
|
2122
|
+
if (source.data && Number.isInteger(source.width) && Number.isInteger(source.height)) {
|
|
2123
|
+
let scratch = null;
|
|
2124
|
+
if (typeof OffscreenCanvas !== "undefined") {
|
|
2125
|
+
scratch = new OffscreenCanvas(source.width, source.height);
|
|
2126
|
+
} else if (typeof document !== "undefined") {
|
|
2127
|
+
scratch = document.createElement("canvas");
|
|
2128
|
+
scratch.width = source.width;
|
|
2129
|
+
scratch.height = source.height;
|
|
2130
|
+
}
|
|
2131
|
+
if (!scratch) return source;
|
|
2132
|
+
const ctx = scratch.getContext("2d");
|
|
2133
|
+
if (!ctx) return source;
|
|
2134
|
+
const pixels = source.data instanceof Uint8ClampedArray
|
|
2135
|
+
? source.data
|
|
2136
|
+
: new Uint8ClampedArray(source.data);
|
|
2137
|
+
ctx.putImageData(new ImageData(pixels, source.width, source.height), 0, 0);
|
|
2138
|
+
return scratch;
|
|
2139
|
+
}
|
|
2140
|
+
return source;
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
function logoSourceDimensions(source) {
|
|
2144
|
+
const width = source?.naturalWidth ?? source?.videoWidth ?? source?.width;
|
|
2145
|
+
const height = source?.naturalHeight ?? source?.videoHeight ?? source?.height;
|
|
2146
|
+
if (!(Number(width) > 0 && Number(height) > 0)) return null;
|
|
2147
|
+
return { width: Number(width), height: Number(height) };
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
function containRect(x, y, size, dimensions) {
|
|
2151
|
+
if (!dimensions) return { x, y, width: size, height: size };
|
|
2152
|
+
const scale = Math.min(size / dimensions.width, size / dimensions.height);
|
|
2153
|
+
const width = dimensions.width * scale;
|
|
2154
|
+
const height = dimensions.height * scale;
|
|
2155
|
+
return {
|
|
2156
|
+
x: x + (size - width) / 2,
|
|
2157
|
+
y: y + (size - height) / 2,
|
|
2158
|
+
width,
|
|
2159
|
+
height
|
|
2160
|
+
};
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
function drawLogoToCanvas(ctx, logo, palette) {
|
|
2164
|
+
if (!logo) return;
|
|
2165
|
+
if (logo.clearBackground) {
|
|
2166
|
+
ctx.fillStyle = logo.backgroundColor || palette.white;
|
|
2167
|
+
roundedRectPath(
|
|
2168
|
+
ctx,
|
|
2169
|
+
logo.backgroundX,
|
|
2170
|
+
logo.backgroundY,
|
|
2171
|
+
logo.backgroundSize,
|
|
2172
|
+
logo.backgroundSize,
|
|
2173
|
+
logo.backgroundRadius
|
|
2174
|
+
);
|
|
2175
|
+
ctx.fill();
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
const source = canvasLogoSource(logo.source);
|
|
2179
|
+
assert(
|
|
2180
|
+
source && typeof source !== "string",
|
|
2181
|
+
"renderToCanvas() logo must be a loaded CanvasImageSource or ImageData-like object. Load URL/data-URL logos into an Image first."
|
|
2182
|
+
);
|
|
2183
|
+
const rect = containRect(logo.logoX, logo.logoY, logo.logoSize, logoSourceDimensions(source));
|
|
2184
|
+
const previousSmoothing = ctx.imageSmoothingEnabled;
|
|
2185
|
+
ctx.imageSmoothingEnabled = true;
|
|
2186
|
+
ctx.drawImage(source, rect.x, rect.y, rect.width, rect.height);
|
|
2187
|
+
ctx.imageSmoothingEnabled = previousSmoothing;
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
function blendImagePixel(data, width, x, y, rgb, alpha) {
|
|
2191
|
+
if (x < 0 || y < 0 || x >= width) return;
|
|
2192
|
+
const height = data.length / 4 / width;
|
|
2193
|
+
if (y >= height) return;
|
|
2194
|
+
const p = (y * width + x) * 4;
|
|
2195
|
+
const a = Math.max(0, Math.min(1, alpha));
|
|
2196
|
+
data[p] = Math.round(data[p] * (1 - a) + rgb.r * a);
|
|
2197
|
+
data[p + 1] = Math.round(data[p + 1] * (1 - a) + rgb.g * a);
|
|
2198
|
+
data[p + 2] = Math.round(data[p + 2] * (1 - a) + rgb.b * a);
|
|
2199
|
+
data[p + 3] = 255;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
function drawLogoToImageData(data, width, logo) {
|
|
2203
|
+
if (!logo) return;
|
|
2204
|
+
if (logo.clearBackground) {
|
|
2205
|
+
const rgb = hexToRgb(logo.backgroundColor);
|
|
2206
|
+
fillImageRoundedRect(
|
|
2207
|
+
data,
|
|
2208
|
+
width,
|
|
2209
|
+
Math.round(logo.backgroundX),
|
|
2210
|
+
Math.round(logo.backgroundY),
|
|
2211
|
+
Math.round(logo.backgroundSize),
|
|
2212
|
+
Math.round(logo.backgroundSize),
|
|
2213
|
+
Math.round(logo.backgroundRadius),
|
|
2214
|
+
rgb
|
|
2215
|
+
);
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
const source = logo.source;
|
|
2219
|
+
assert(
|
|
2220
|
+
source?.data && Number.isInteger(source.width) && Number.isInteger(source.height),
|
|
2221
|
+
"renderToImageData() logo must be an ImageData-like object with width, height, and RGBA data."
|
|
2222
|
+
);
|
|
2223
|
+
const dest = containRect(logo.logoX, logo.logoY, logo.logoSize, {
|
|
2224
|
+
width: source.width,
|
|
2225
|
+
height: source.height
|
|
2226
|
+
});
|
|
2227
|
+
const x0 = Math.round(dest.x);
|
|
2228
|
+
const y0 = Math.round(dest.y);
|
|
2229
|
+
const destWidth = Math.max(1, Math.round(dest.width));
|
|
2230
|
+
const destHeight = Math.max(1, Math.round(dest.height));
|
|
2231
|
+
const src = source.data;
|
|
2232
|
+
|
|
2233
|
+
for (let y = 0; y < destHeight; y++) {
|
|
2234
|
+
const sy = Math.min(source.height - 1, Math.floor((y + 0.5) * source.height / destHeight));
|
|
2235
|
+
for (let x = 0; x < destWidth; x++) {
|
|
2236
|
+
const sx = Math.min(source.width - 1, Math.floor((x + 0.5) * source.width / destWidth));
|
|
2237
|
+
const p = (sy * source.width + sx) * 4;
|
|
2238
|
+
const alpha = (src[p + 3] ?? 255) / 255;
|
|
2239
|
+
if (alpha <= 0) continue;
|
|
2240
|
+
blendImagePixel(data, width, x0 + x, y0 + y, {
|
|
2241
|
+
r: src[p],
|
|
2242
|
+
g: src[p + 1],
|
|
2243
|
+
b: src[p + 2]
|
|
2244
|
+
}, alpha);
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
function escapeXmlAttribute(value) {
|
|
2250
|
+
return String(value)
|
|
2251
|
+
.replaceAll("&", "&")
|
|
2252
|
+
.replaceAll('"', """)
|
|
2253
|
+
.replaceAll("<", "<")
|
|
2254
|
+
.replaceAll(">", ">");
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
function svgRect(x, y, width, height, fill, radius = 0) {
|
|
2258
|
+
const rounded = radius > 0 ? ` rx="${radius}" ry="${radius}"` : "";
|
|
2259
|
+
return `<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="${escapeXmlAttribute(fill)}"${rounded}/>`;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
function svgLogoHref(source) {
|
|
2263
|
+
if (typeof source === "string") return source;
|
|
2264
|
+
if (typeof source?.src === "string" && source.src) return source.src;
|
|
2265
|
+
return null;
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
function resolveRenderSizing(options, matrixSize) {
|
|
2269
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2270
|
+
const requestedQuietZone = Math.max(0, Math.floor(options.quietZone ?? 4));
|
|
2271
|
+
const quietZone = mode === RENDER_MODES.PRINT && options.allowUnsafePrintQuietZone !== true
|
|
2272
|
+
? Math.max(4, requestedQuietZone)
|
|
2273
|
+
: requestedQuietZone;
|
|
2274
|
+
const totalModules = matrixSize + quietZone * 2;
|
|
2275
|
+
const hasImageSize = options.imageSize !== undefined && options.imageSize !== null;
|
|
2276
|
+
const hasModuleSize = options.moduleSize !== undefined && options.moduleSize !== null;
|
|
2277
|
+
|
|
2278
|
+
let pixelSize;
|
|
2279
|
+
if (hasImageSize) {
|
|
2280
|
+
const requested = Number(options.imageSize);
|
|
2281
|
+
assert(Number.isFinite(requested) && requested > 0, "imageSize must be a positive number of pixels.");
|
|
2282
|
+
pixelSize = Math.max(totalModules, Math.round(requested));
|
|
2283
|
+
} else if (hasModuleSize) {
|
|
2284
|
+
const requested = Number(options.moduleSize);
|
|
2285
|
+
assert(Number.isFinite(requested) && requested > 0, "moduleSize must be a positive number of pixels.");
|
|
2286
|
+
const moduleSize = Math.max(1, Math.floor(requested));
|
|
2287
|
+
pixelSize = totalModules * moduleSize;
|
|
2288
|
+
} else {
|
|
2289
|
+
// A practical export default that is large enough for screens/social use
|
|
2290
|
+
// while keeping the API independent from the encoded matrix version.
|
|
2291
|
+
pixelSize = Math.max(totalModules, 720);
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
return {
|
|
2295
|
+
quietZone,
|
|
2296
|
+
totalModules,
|
|
2297
|
+
pixelSize,
|
|
2298
|
+
moduleSize: pixelSize / totalModules,
|
|
2299
|
+
mode
|
|
2300
|
+
};
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
function rasterModuleRect(row, col, quietZone, moduleSize) {
|
|
2304
|
+
const x0 = Math.round((col + quietZone) * moduleSize);
|
|
2305
|
+
const y0 = Math.round((row + quietZone) * moduleSize);
|
|
2306
|
+
const x1 = Math.round((col + quietZone + 1) * moduleSize);
|
|
2307
|
+
const y1 = Math.round((row + quietZone + 1) * moduleSize);
|
|
2308
|
+
return {
|
|
2309
|
+
x: x0,
|
|
2310
|
+
y: y0,
|
|
2311
|
+
width: Math.max(1, x1 - x0),
|
|
2312
|
+
height: Math.max(1, y1 - y0)
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2315
|
+
|
|
1435
2316
|
export function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
1436
2317
|
assert(canvas && typeof canvas.getContext === "function", "A canvas element is required.");
|
|
1437
2318
|
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
|
|
1438
2319
|
assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
|
|
1439
|
-
const
|
|
1440
|
-
const
|
|
1441
|
-
const palette =
|
|
1442
|
-
const paletteValues = paletteRgb(
|
|
1443
|
-
const
|
|
2320
|
+
const sizing = resolveRenderSizing(options, matrix.length);
|
|
2321
|
+
const { moduleSize, quietZone, pixelSize } = sizing;
|
|
2322
|
+
const palette = resolveRenderPalette(options);
|
|
2323
|
+
const paletteValues = paletteRgb(palette);
|
|
2324
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2325
|
+
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
1444
2326
|
const layout = renderLayoutForMatrix(matrix);
|
|
1445
2327
|
const size = matrix.length;
|
|
1446
|
-
const
|
|
2328
|
+
const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
|
|
1447
2329
|
|
|
1448
2330
|
canvas.width = pixelSize;
|
|
1449
2331
|
canvas.height = pixelSize;
|
|
@@ -1456,12 +2338,13 @@ export function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
|
1456
2338
|
for (let c = 0; c < size; c++) {
|
|
1457
2339
|
const cell = matrix[r][c];
|
|
1458
2340
|
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
1459
|
-
const
|
|
1460
|
-
const y
|
|
2341
|
+
const rect = rasterModuleRect(r, c, quietZone, moduleSize);
|
|
2342
|
+
const { x, y, width: cellWidth, height: cellHeight } = rect;
|
|
2343
|
+
const cellSize = Math.min(cellWidth, cellHeight);
|
|
1461
2344
|
|
|
1462
2345
|
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
1463
2346
|
ctx.fillStyle = cellColor(cell, palette);
|
|
1464
|
-
ctx.fillRect(x, y,
|
|
2347
|
+
ctx.fillRect(x, y, cellWidth, cellHeight);
|
|
1465
2348
|
continue;
|
|
1466
2349
|
}
|
|
1467
2350
|
|
|
@@ -1471,28 +2354,29 @@ export function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
|
1471
2354
|
const opacity = depthOpacity(r, c, cell);
|
|
1472
2355
|
const base = mixRgb(paletteValues.white, rgb, opacity);
|
|
1473
2356
|
ctx.fillStyle = rgbCss(base);
|
|
1474
|
-
ctx.fillRect(x, y,
|
|
2357
|
+
ctx.fillRect(x, y, cellWidth, cellHeight);
|
|
1475
2358
|
|
|
1476
|
-
if (
|
|
1477
|
-
const edge = Math.max(1, Math.floor(
|
|
2359
|
+
if (cellSize >= 6) {
|
|
2360
|
+
const edge = Math.max(1, Math.floor(cellSize * 0.08));
|
|
1478
2361
|
const highlight = mixRgb(base, paletteValues.white, 0.17);
|
|
1479
2362
|
const shadow = mixRgb(base, paletteValues.black, 0.12);
|
|
1480
2363
|
ctx.fillStyle = rgbCss(highlight);
|
|
1481
|
-
ctx.fillRect(x, y,
|
|
1482
|
-
ctx.fillRect(x, y, edge,
|
|
2364
|
+
ctx.fillRect(x, y, cellWidth, edge);
|
|
2365
|
+
ctx.fillRect(x, y, edge, cellHeight);
|
|
1483
2366
|
ctx.fillStyle = rgbCss(shadow);
|
|
1484
|
-
ctx.fillRect(x, y +
|
|
1485
|
-
ctx.fillRect(x +
|
|
2367
|
+
ctx.fillRect(x, y + cellHeight - edge, cellWidth, edge);
|
|
2368
|
+
ctx.fillRect(x + cellWidth - edge, y, edge, cellHeight);
|
|
1486
2369
|
}
|
|
1487
2370
|
continue;
|
|
1488
2371
|
}
|
|
1489
2372
|
|
|
1490
2373
|
if (style === RENDER_STYLES.SOFT) {
|
|
1491
|
-
const inset =
|
|
1492
|
-
const width =
|
|
1493
|
-
const
|
|
2374
|
+
const inset = cellSize >= 6 ? Math.max(1, Math.floor(cellSize * 0.07)) : 0;
|
|
2375
|
+
const width = cellWidth - inset * 2;
|
|
2376
|
+
const height = cellHeight - inset * 2;
|
|
2377
|
+
const radius = Math.max(1, Math.floor(cellSize * 0.20));
|
|
1494
2378
|
ctx.fillStyle = cellColor(cell, palette);
|
|
1495
|
-
roundedRectPath(ctx, x + inset, y + inset, width,
|
|
2379
|
+
roundedRectPath(ctx, x + inset, y + inset, width, height, radius);
|
|
1496
2380
|
ctx.fill();
|
|
1497
2381
|
continue;
|
|
1498
2382
|
}
|
|
@@ -1501,37 +2385,40 @@ export function renderToCanvas(codeOrMatrix, canvas, options = {}) {
|
|
|
1501
2385
|
// Keep the exact encoded color across the full module, then limit the
|
|
1502
2386
|
// recessed effect to a narrow edge band so center sampling stays intact.
|
|
1503
2387
|
ctx.fillStyle = cellColor(cell, palette);
|
|
1504
|
-
ctx.fillRect(x, y,
|
|
2388
|
+
ctx.fillRect(x, y, cellWidth, cellHeight);
|
|
1505
2389
|
if (cell === CELL.WHITE) continue;
|
|
1506
2390
|
|
|
1507
|
-
const edge = safeStyleEdge(
|
|
2391
|
+
const edge = safeStyleEdge(cellSize);
|
|
1508
2392
|
if (edge > 0) {
|
|
1509
2393
|
const fx = insetStyleColors(rgb, paletteValues);
|
|
1510
2394
|
ctx.fillStyle = rgbCss(fx.shadow);
|
|
1511
|
-
ctx.fillRect(x, y,
|
|
1512
|
-
ctx.fillRect(x, y, edge,
|
|
2395
|
+
ctx.fillRect(x, y, cellWidth, edge);
|
|
2396
|
+
ctx.fillRect(x, y, edge, cellHeight);
|
|
1513
2397
|
ctx.fillStyle = rgbCss(fx.highlight);
|
|
1514
|
-
ctx.fillRect(x, y +
|
|
1515
|
-
ctx.fillRect(x +
|
|
2398
|
+
ctx.fillRect(x, y + cellHeight - edge, cellWidth, edge);
|
|
2399
|
+
ctx.fillRect(x + cellWidth - edge, y, edge, cellHeight);
|
|
1516
2400
|
}
|
|
1517
2401
|
}
|
|
1518
2402
|
}
|
|
1519
2403
|
}
|
|
2404
|
+
drawLogoToCanvas(ctx, logo, palette);
|
|
1520
2405
|
return canvas;
|
|
1521
2406
|
}
|
|
1522
2407
|
|
|
1523
2408
|
export function renderToImageData(codeOrMatrix, options = {}) {
|
|
1524
2409
|
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
|
|
1525
2410
|
assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
|
|
1526
|
-
const
|
|
1527
|
-
const
|
|
1528
|
-
const
|
|
1529
|
-
const
|
|
2411
|
+
const sizing = resolveRenderSizing(options, matrix.length);
|
|
2412
|
+
const { moduleSize, quietZone, pixelSize } = sizing;
|
|
2413
|
+
const resolvedPalette = resolveRenderPalette(options);
|
|
2414
|
+
const palette = paletteRgb(resolvedPalette);
|
|
2415
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2416
|
+
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
1530
2417
|
const layout = renderLayoutForMatrix(matrix);
|
|
1531
2418
|
const size = matrix.length;
|
|
1532
|
-
const pixelSize = (size + quietZone * 2) * moduleSize;
|
|
1533
2419
|
const data = new Uint8ClampedArray(pixelSize * pixelSize * 4);
|
|
1534
2420
|
const white = palette.white;
|
|
2421
|
+
const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, resolvedPalette, codeOrMatrix);
|
|
1535
2422
|
|
|
1536
2423
|
for (let i = 0; i < pixelSize * pixelSize; i++) {
|
|
1537
2424
|
const p = i * 4;
|
|
@@ -1546,26 +2433,105 @@ export function renderToImageData(codeOrMatrix, options = {}) {
|
|
|
1546
2433
|
const cell = matrix[r][c];
|
|
1547
2434
|
const rgb = cellRgb(cell, palette);
|
|
1548
2435
|
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
1549
|
-
const
|
|
1550
|
-
const x0 =
|
|
2436
|
+
const rect = rasterModuleRect(r, c, quietZone, moduleSize);
|
|
2437
|
+
const x0 = rect.x;
|
|
2438
|
+
const y0 = rect.y;
|
|
2439
|
+
const cellWidth = rect.width;
|
|
2440
|
+
const cellHeight = rect.height;
|
|
2441
|
+
const cellSize = Math.min(cellWidth, cellHeight);
|
|
1551
2442
|
|
|
1552
2443
|
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
1553
|
-
fillImageRect(data, pixelSize, x0, y0,
|
|
2444
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
|
|
1554
2445
|
continue;
|
|
1555
2446
|
}
|
|
1556
2447
|
|
|
1557
2448
|
if (style === RENDER_STYLES.DEPTH) {
|
|
1558
2449
|
const opacity = depthOpacity(r, c, cell);
|
|
1559
2450
|
const base = mixRgb(white, rgb, opacity);
|
|
1560
|
-
fillImageRect(data, pixelSize, x0, y0,
|
|
1561
|
-
if (
|
|
1562
|
-
const edge = Math.max(1, Math.floor(
|
|
2451
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, base);
|
|
2452
|
+
if (cellSize >= 6) {
|
|
2453
|
+
const edge = Math.max(1, Math.floor(cellSize * 0.08));
|
|
1563
2454
|
const highlight = mixRgb(base, white, 0.17);
|
|
1564
2455
|
const shadow = mixRgb(base, palette.black, 0.12);
|
|
1565
|
-
fillImageRect(data, pixelSize, x0, y0,
|
|
1566
|
-
fillImageRect(data, pixelSize, x0, y0, edge,
|
|
1567
|
-
fillImageRect(data, pixelSize, x0, y0 +
|
|
1568
|
-
fillImageRect(data, pixelSize, x0 +
|
|
2456
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, edge, highlight);
|
|
2457
|
+
fillImageRect(data, pixelSize, x0, y0, edge, cellHeight, highlight);
|
|
2458
|
+
fillImageRect(data, pixelSize, x0, y0 + cellHeight - edge, cellWidth, edge, shadow);
|
|
2459
|
+
fillImageRect(data, pixelSize, x0 + cellWidth - edge, y0, edge, cellHeight, shadow);
|
|
2460
|
+
}
|
|
2461
|
+
continue;
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
if (style === RENDER_STYLES.SOFT) {
|
|
2465
|
+
const inset = cellSize >= 6 ? Math.max(1, Math.floor(cellSize * 0.07)) : 0;
|
|
2466
|
+
const width = cellWidth - inset * 2;
|
|
2467
|
+
const height = cellHeight - inset * 2;
|
|
2468
|
+
const radius = Math.max(1, Math.floor(cellSize * 0.20));
|
|
2469
|
+
fillImageRoundedRect(data, pixelSize, x0 + inset, y0 + inset, width, height, radius, rgb);
|
|
2470
|
+
continue;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
if (style === RENDER_STYLES.INSET) {
|
|
2474
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, cellHeight, rgb);
|
|
2475
|
+
if (cell === CELL.WHITE) continue;
|
|
2476
|
+
const edge = safeStyleEdge(cellSize);
|
|
2477
|
+
if (edge > 0) {
|
|
2478
|
+
const fx = insetStyleColors(rgb, palette);
|
|
2479
|
+
fillImageRect(data, pixelSize, x0, y0, cellWidth, edge, fx.shadow);
|
|
2480
|
+
fillImageRect(data, pixelSize, x0, y0, edge, cellHeight, fx.shadow);
|
|
2481
|
+
fillImageRect(data, pixelSize, x0, y0 + cellHeight - edge, cellWidth, edge, fx.highlight);
|
|
2482
|
+
fillImageRect(data, pixelSize, x0 + cellWidth - edge, y0, edge, cellHeight, fx.highlight);
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
drawLogoToImageData(data, pixelSize, logo);
|
|
2489
|
+
|
|
2490
|
+
return { width: pixelSize, height: pixelSize, data };
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
/** Render a QuadQR code/matrix to a standalone SVG string. */
|
|
2494
|
+
export function renderToSVG(codeOrMatrix, options = {}) {
|
|
2495
|
+
const matrix = Array.isArray(codeOrMatrix) ? codeOrMatrix : codeOrMatrix.matrix;
|
|
2496
|
+
assert(Array.isArray(matrix) && matrix.length > 0, "A matrix is required.");
|
|
2497
|
+
const sizing = resolveRenderSizing(options, matrix.length);
|
|
2498
|
+
const { moduleSize, quietZone, pixelSize } = sizing;
|
|
2499
|
+
const palette = resolveRenderPalette(options);
|
|
2500
|
+
const paletteValues = paletteRgb(palette);
|
|
2501
|
+
const mode = normalizeRenderMode(options.mode ?? options.renderMode ?? RENDER_MODES.SCREEN);
|
|
2502
|
+
const style = normalizeRenderStyle(mode === RENDER_MODES.PRINT && options.allowStyledPrint !== true ? RENDER_STYLES.CLASSIC : options.style);
|
|
2503
|
+
const layout = renderLayoutForMatrix(matrix);
|
|
2504
|
+
const size = matrix.length;
|
|
2505
|
+
const logo = resolveLogoRenderOptions(options, size, moduleSize, quietZone, palette, codeOrMatrix);
|
|
2506
|
+
const body = [svgRect(0, 0, pixelSize, pixelSize, palette.white)];
|
|
2507
|
+
|
|
2508
|
+
for (let r = 0; r < size; r++) {
|
|
2509
|
+
for (let c = 0; c < size; c++) {
|
|
2510
|
+
const cell = matrix[r][c];
|
|
2511
|
+
const structural = isStructuralRenderCell(layout, r, c, cell);
|
|
2512
|
+
const x = (c + quietZone) * moduleSize;
|
|
2513
|
+
const y = (r + quietZone) * moduleSize;
|
|
2514
|
+
const fill = cellColor(cell, palette);
|
|
2515
|
+
|
|
2516
|
+
if (style === RENDER_STYLES.CLASSIC || structural) {
|
|
2517
|
+
body.push(svgRect(x, y, moduleSize, moduleSize, fill));
|
|
2518
|
+
continue;
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
const rgb = cellRgb(cell, paletteValues);
|
|
2522
|
+
|
|
2523
|
+
if (style === RENDER_STYLES.DEPTH) {
|
|
2524
|
+
const opacity = depthOpacity(r, c, cell);
|
|
2525
|
+
const base = mixRgb(paletteValues.white, rgb, opacity);
|
|
2526
|
+
body.push(svgRect(x, y, moduleSize, moduleSize, rgbCss(base)));
|
|
2527
|
+
if (moduleSize >= 6) {
|
|
2528
|
+
const edge = Math.max(1, Math.floor(moduleSize * 0.08));
|
|
2529
|
+
const highlight = rgbCss(mixRgb(base, paletteValues.white, 0.17));
|
|
2530
|
+
const shadow = rgbCss(mixRgb(base, paletteValues.black, 0.12));
|
|
2531
|
+
body.push(svgRect(x, y, moduleSize, edge, highlight));
|
|
2532
|
+
body.push(svgRect(x, y, edge, moduleSize, highlight));
|
|
2533
|
+
body.push(svgRect(x, y + moduleSize - edge, moduleSize, edge, shadow));
|
|
2534
|
+
body.push(svgRect(x + moduleSize - edge, y, edge, moduleSize, shadow));
|
|
1569
2535
|
}
|
|
1570
2536
|
continue;
|
|
1571
2537
|
}
|
|
@@ -1574,26 +2540,51 @@ export function renderToImageData(codeOrMatrix, options = {}) {
|
|
|
1574
2540
|
const inset = moduleSize >= 6 ? Math.max(1, Math.floor(moduleSize * 0.07)) : 0;
|
|
1575
2541
|
const width = moduleSize - inset * 2;
|
|
1576
2542
|
const radius = Math.max(1, Math.floor(moduleSize * 0.20));
|
|
1577
|
-
|
|
2543
|
+
body.push(svgRect(x + inset, y + inset, width, width, fill, radius));
|
|
1578
2544
|
continue;
|
|
1579
2545
|
}
|
|
1580
2546
|
|
|
1581
2547
|
if (style === RENDER_STYLES.INSET) {
|
|
1582
|
-
|
|
2548
|
+
body.push(svgRect(x, y, moduleSize, moduleSize, fill));
|
|
1583
2549
|
if (cell === CELL.WHITE) continue;
|
|
1584
2550
|
const edge = safeStyleEdge(moduleSize);
|
|
1585
2551
|
if (edge > 0) {
|
|
1586
|
-
const fx = insetStyleColors(rgb,
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
2552
|
+
const fx = insetStyleColors(rgb, paletteValues);
|
|
2553
|
+
const shadow = rgbCss(fx.shadow);
|
|
2554
|
+
const highlight = rgbCss(fx.highlight);
|
|
2555
|
+
body.push(svgRect(x, y, moduleSize, edge, shadow));
|
|
2556
|
+
body.push(svgRect(x, y, edge, moduleSize, shadow));
|
|
2557
|
+
body.push(svgRect(x, y + moduleSize - edge, moduleSize, edge, highlight));
|
|
2558
|
+
body.push(svgRect(x + moduleSize - edge, y, edge, moduleSize, highlight));
|
|
1591
2559
|
}
|
|
1592
2560
|
}
|
|
1593
2561
|
}
|
|
1594
2562
|
}
|
|
1595
2563
|
|
|
1596
|
-
|
|
2564
|
+
if (logo) {
|
|
2565
|
+
if (logo.clearBackground) {
|
|
2566
|
+
body.push(svgRect(
|
|
2567
|
+
logo.backgroundX,
|
|
2568
|
+
logo.backgroundY,
|
|
2569
|
+
logo.backgroundSize,
|
|
2570
|
+
logo.backgroundSize,
|
|
2571
|
+
logo.backgroundColor,
|
|
2572
|
+
logo.backgroundRadius
|
|
2573
|
+
));
|
|
2574
|
+
}
|
|
2575
|
+
const href = svgLogoHref(logo.source);
|
|
2576
|
+
assert(href, "renderToSVG() logo must be a URL/data URL string or an object with a src string.");
|
|
2577
|
+
body.push(
|
|
2578
|
+
`<image href="${escapeXmlAttribute(href)}" x="${logo.logoX}" y="${logo.logoY}" width="${logo.logoSize}" height="${logo.logoSize}" preserveAspectRatio="xMidYMid meet"/>`
|
|
2579
|
+
);
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
return [
|
|
2583
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
2584
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${pixelSize}" height="${pixelSize}" viewBox="0 0 ${pixelSize} ${pixelSize}" shape-rendering="crispEdges">`,
|
|
2585
|
+
...body,
|
|
2586
|
+
"</svg>"
|
|
2587
|
+
].join("\n");
|
|
1597
2588
|
}
|
|
1598
2589
|
|
|
1599
2590
|
function clampNumber(value, min, max) {
|
|
@@ -2218,6 +3209,366 @@ function tryAxisAlignedScan(imageData, options) {
|
|
|
2218
3209
|
return candidates[0] ?? null;
|
|
2219
3210
|
}
|
|
2220
3211
|
|
|
3212
|
+
|
|
3213
|
+
|
|
3214
|
+
export const STRESS_PROFILES = Object.freeze([
|
|
3215
|
+
Object.freeze({ id: "clean", label: "Clean", type: "clean", severity: 0, weight: 2 }),
|
|
3216
|
+
Object.freeze({ id: "blur", label: "Blur", type: "blur", severity: 0.42, weight: 1 }),
|
|
3217
|
+
Object.freeze({ id: "dark", label: "Low brightness", type: "brightness-low", severity: 0.48, weight: 1 }),
|
|
3218
|
+
Object.freeze({ id: "bright", label: "High exposure", type: "brightness-high", severity: 0.34, weight: 1 }),
|
|
3219
|
+
Object.freeze({ id: "shadow", label: "Uneven shadow", type: "shadow", severity: 0.55, weight: 1.2 }),
|
|
3220
|
+
Object.freeze({ id: "contrast", label: "Contrast loss", type: "contrast-loss", severity: 0.42, weight: 1 }),
|
|
3221
|
+
Object.freeze({ id: "perspective", label: "Perspective", type: "perspective", severity: 0.42, weight: 1.3 }),
|
|
3222
|
+
Object.freeze({ id: "jpeg", label: "JPEG-like compression", type: "jpeg", severity: 0.50, weight: 0.8 }),
|
|
3223
|
+
Object.freeze({ id: "downscale", label: "Downscale", type: "downscale", severity: 0.52, weight: 0.9 })
|
|
3224
|
+
]);
|
|
3225
|
+
|
|
3226
|
+
function cloneImageDataLike(imageData) {
|
|
3227
|
+
return { width: imageData.width, height: imageData.height, data: new Uint8ClampedArray(imageData.data) };
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
function sampleRgbaBilinear(imageData, x, y) {
|
|
3231
|
+
const { width, height, data } = imageData;
|
|
3232
|
+
const cx = clampNumber(x, 0, width - 1);
|
|
3233
|
+
const cy = clampNumber(y, 0, height - 1);
|
|
3234
|
+
const x0 = Math.floor(cx), y0 = Math.floor(cy);
|
|
3235
|
+
const x1 = Math.min(width - 1, x0 + 1), y1 = Math.min(height - 1, y0 + 1);
|
|
3236
|
+
const tx = cx - x0, ty = cy - y0;
|
|
3237
|
+
const get = (xx, yy, channel) => data[(yy * width + xx) * 4 + channel];
|
|
3238
|
+
const out = [0, 0, 0, 255];
|
|
3239
|
+
for (let ch = 0; ch < 4; ch++) {
|
|
3240
|
+
const a = get(x0, y0, ch) * (1 - tx) + get(x1, y0, ch) * tx;
|
|
3241
|
+
const b = get(x0, y1, ch) * (1 - tx) + get(x1, y1, ch) * tx;
|
|
3242
|
+
out[ch] = a * (1 - ty) + b * ty;
|
|
3243
|
+
}
|
|
3244
|
+
return out;
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
function resizeImageDataLike(imageData, width, height) {
|
|
3248
|
+
width = Math.max(1, Math.round(width));
|
|
3249
|
+
height = Math.max(1, Math.round(height));
|
|
3250
|
+
const out = new Uint8ClampedArray(width * height * 4);
|
|
3251
|
+
for (let y = 0; y < height; y++) {
|
|
3252
|
+
for (let x = 0; x < width; x++) {
|
|
3253
|
+
const sx = (x + 0.5) * imageData.width / width - 0.5;
|
|
3254
|
+
const sy = (y + 0.5) * imageData.height / height - 0.5;
|
|
3255
|
+
const rgba = sampleRgbaBilinear(imageData, sx, sy);
|
|
3256
|
+
const p = (y * width + x) * 4;
|
|
3257
|
+
out[p] = rgba[0]; out[p + 1] = rgba[1]; out[p + 2] = rgba[2]; out[p + 3] = rgba[3];
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
return { width, height, data: out };
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
function boxBlurImage(imageData, radius) {
|
|
3264
|
+
radius = Math.max(1, Math.round(radius));
|
|
3265
|
+
const { width, height, data } = imageData;
|
|
3266
|
+
const horizontal = new Float32Array(data.length);
|
|
3267
|
+
const out = new Uint8ClampedArray(data.length);
|
|
3268
|
+
for (let y = 0; y < height; y++) {
|
|
3269
|
+
for (let x = 0; x < width; x++) {
|
|
3270
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
3271
|
+
let sum = 0, count = 0;
|
|
3272
|
+
for (let dx = -radius; dx <= radius; dx++) {
|
|
3273
|
+
const xx = x + dx;
|
|
3274
|
+
if (xx < 0 || xx >= width) continue;
|
|
3275
|
+
sum += data[(y * width + xx) * 4 + ch]; count++;
|
|
3276
|
+
}
|
|
3277
|
+
horizontal[(y * width + x) * 4 + ch] = sum / count;
|
|
3278
|
+
}
|
|
3279
|
+
horizontal[(y * width + x) * 4 + 3] = 255;
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
for (let y = 0; y < height; y++) {
|
|
3283
|
+
for (let x = 0; x < width; x++) {
|
|
3284
|
+
const p = (y * width + x) * 4;
|
|
3285
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
3286
|
+
let sum = 0, count = 0;
|
|
3287
|
+
for (let dy = -radius; dy <= radius; dy++) {
|
|
3288
|
+
const yy = y + dy;
|
|
3289
|
+
if (yy < 0 || yy >= height) continue;
|
|
3290
|
+
sum += horizontal[(yy * width + x) * 4 + ch]; count++;
|
|
3291
|
+
}
|
|
3292
|
+
out[p + ch] = sum / count;
|
|
3293
|
+
}
|
|
3294
|
+
out[p + 3] = 255;
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
return { width, height, data: out };
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
function perspectiveStress(imageData, severity) {
|
|
3301
|
+
const { width, height } = imageData;
|
|
3302
|
+
const inset = Math.min(width, height) * (0.025 + 0.11 * severity);
|
|
3303
|
+
const source = [{ x: 0, y: 0 }, { x: width - 1, y: 0 }, { x: width - 1, y: height - 1 }, { x: 0, y: height - 1 }];
|
|
3304
|
+
const destination = [
|
|
3305
|
+
{ x: inset * 1.15, y: inset * 0.25 },
|
|
3306
|
+
{ x: width - 1 - inset * 0.25, y: inset },
|
|
3307
|
+
{ x: width - 1 - inset * 1.0, y: height - 1 - inset * 0.35 },
|
|
3308
|
+
{ x: inset * 0.20, y: height - 1 - inset * 0.95 }
|
|
3309
|
+
];
|
|
3310
|
+
const inverse = computeHomography(destination, source);
|
|
3311
|
+
const out = new Uint8ClampedArray(width * height * 4);
|
|
3312
|
+
out.fill(255);
|
|
3313
|
+
for (let y = 0; y < height; y++) {
|
|
3314
|
+
for (let x = 0; x < width; x++) {
|
|
3315
|
+
const src = projectPoint(inverse, x, y);
|
|
3316
|
+
if (src.x < 0 || src.y < 0 || src.x >= width || src.y >= height) continue;
|
|
3317
|
+
const rgba = sampleRgbaBilinear(imageData, src.x, src.y);
|
|
3318
|
+
const p = (y * width + x) * 4;
|
|
3319
|
+
out[p] = rgba[0]; out[p + 1] = rgba[1]; out[p + 2] = rgba[2]; out[p + 3] = 255;
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
return { width, height, data: out };
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
/** Apply one deterministic camera/print-style distortion to ImageData. */
|
|
3326
|
+
export function applyStressDistortion(imageData, type, severity = 0.5, options = {}) {
|
|
3327
|
+
assert(imageData?.data && imageData.width && imageData.height, "applyStressDistortion requires ImageData-like input.");
|
|
3328
|
+
const s = clampNumber(Number(severity), 0, 1);
|
|
3329
|
+
if (type === "clean") return cloneImageDataLike(imageData);
|
|
3330
|
+
if (type === "blur") return boxBlurImage(imageData, 1 + s * 2.2);
|
|
3331
|
+
if (type === "perspective") return perspectiveStress(imageData, s);
|
|
3332
|
+
if (type === "downscale") {
|
|
3333
|
+
const scale = Math.max(0.20, 1 - s * 0.72);
|
|
3334
|
+
const small = resizeImageDataLike(imageData, imageData.width * scale, imageData.height * scale);
|
|
3335
|
+
return resizeImageDataLike(small, imageData.width, imageData.height);
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
const out = cloneImageDataLike(imageData);
|
|
3339
|
+
const { width, height, data } = out;
|
|
3340
|
+
const quantStep = 4 + Math.round(s * 30);
|
|
3341
|
+
for (let y = 0; y < height; y++) {
|
|
3342
|
+
for (let x = 0; x < width; x++) {
|
|
3343
|
+
const p = (y * width + x) * 4;
|
|
3344
|
+
for (let ch = 0; ch < 3; ch++) {
|
|
3345
|
+
let value = data[p + ch];
|
|
3346
|
+
if (type === "brightness-low") value *= 1 - s * 0.52;
|
|
3347
|
+
else if (type === "brightness-high") value = value + (255 - value) * s * 0.48;
|
|
3348
|
+
else if (type === "contrast-loss") value = 128 + (value - 128) * (1 - s * 0.48);
|
|
3349
|
+
else if (type === "shadow") {
|
|
3350
|
+
const nx = x / Math.max(1, width - 1);
|
|
3351
|
+
const ny = y / Math.max(1, height - 1);
|
|
3352
|
+
const shadow = clampNumber((nx * 0.65 + ny * 0.35 - 0.18) / 0.82, 0, 1);
|
|
3353
|
+
value *= 1 - shadow * s * 0.62;
|
|
3354
|
+
} else if (type === "jpeg") {
|
|
3355
|
+
value = Math.round(value / quantStep) * quantStep;
|
|
3356
|
+
const block = ((Math.floor(x / 8) + Math.floor(y / 8)) & 1) ? 1 : -1;
|
|
3357
|
+
value += block * s * 3;
|
|
3358
|
+
} else if (type === "warm") {
|
|
3359
|
+
if (ch === 0) value *= 1 + s * 0.12;
|
|
3360
|
+
if (ch === 2) value *= 1 - s * 0.22;
|
|
3361
|
+
}
|
|
3362
|
+
data[p + ch] = clampNumber(Math.round(value), 0, 255);
|
|
3363
|
+
}
|
|
3364
|
+
data[p + 3] = 255;
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
return out;
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
export function runImageStressTest(imageData, expected = {}, options = {}) {
|
|
3371
|
+
const profiles = options.profiles ?? STRESS_PROFILES;
|
|
3372
|
+
const results = [];
|
|
3373
|
+
let passedWeight = 0;
|
|
3374
|
+
let totalWeight = 0;
|
|
3375
|
+
for (const profile of profiles) {
|
|
3376
|
+
const weight = Number(profile.weight ?? 1);
|
|
3377
|
+
totalWeight += weight;
|
|
3378
|
+
const distorted = applyStressDistortion(imageData, profile.type ?? profile.id, profile.severity ?? 0.5, profile);
|
|
3379
|
+
const started = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
3380
|
+
try {
|
|
3381
|
+
const decoded = scanImageData(distorted, {
|
|
3382
|
+
minVersion: expected.version ?? options.minVersion ?? MIN_VERSION,
|
|
3383
|
+
maxVersion: expected.version ?? options.maxVersion ?? MAX_VERSION,
|
|
3384
|
+
debug: false
|
|
3385
|
+
});
|
|
3386
|
+
const matches = expected.crc32 == null || decoded.crc32 === expected.crc32;
|
|
3387
|
+
if (matches) passedWeight += weight;
|
|
3388
|
+
results.push({
|
|
3389
|
+
id: profile.id,
|
|
3390
|
+
label: profile.label,
|
|
3391
|
+
type: profile.type,
|
|
3392
|
+
severity: profile.severity,
|
|
3393
|
+
passed: matches,
|
|
3394
|
+
confidence: decoded.confidence ?? decoded.diagnostics?.confidence ?? null,
|
|
3395
|
+
correctedSymbols: decoded.correctedSymbols ?? 0,
|
|
3396
|
+
elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
|
|
3397
|
+
image: options.includeImages ? distorted : undefined,
|
|
3398
|
+
error: matches ? null : "Decoded payload did not match expected CRC."
|
|
3399
|
+
});
|
|
3400
|
+
} catch (error) {
|
|
3401
|
+
results.push({
|
|
3402
|
+
id: profile.id,
|
|
3403
|
+
label: profile.label,
|
|
3404
|
+
type: profile.type,
|
|
3405
|
+
severity: profile.severity,
|
|
3406
|
+
passed: false,
|
|
3407
|
+
confidence: 0,
|
|
3408
|
+
correctedSymbols: null,
|
|
3409
|
+
elapsedMs: (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - started,
|
|
3410
|
+
image: options.includeImages ? distorted : undefined,
|
|
3411
|
+
error: error.message
|
|
3412
|
+
});
|
|
3413
|
+
}
|
|
3414
|
+
}
|
|
3415
|
+
const passPercent = totalWeight ? passedWeight / totalWeight * 100 : 0;
|
|
3416
|
+
const averageConfidence = results.filter((r) => r.passed && Number.isFinite(r.confidence)).reduce((sum, r, _, arr) => sum + r.confidence / arr.length, 0);
|
|
3417
|
+
const score = clampNumber(passPercent * 0.86 + averageConfidence * 100 * 0.14, 0, 100);
|
|
3418
|
+
const rating = score >= 90 ? "Excellent" : score >= 75 ? "Good" : score >= 50 ? "Risky" : "Likely unscannable";
|
|
3419
|
+
return {
|
|
3420
|
+
score,
|
|
3421
|
+
rating,
|
|
3422
|
+
passPercent,
|
|
3423
|
+
passed: results.filter((r) => r.passed).length,
|
|
3424
|
+
total: results.length,
|
|
3425
|
+
averageConfidence,
|
|
3426
|
+
results
|
|
3427
|
+
};
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
/** Render and run the standard scanability torture suite. */
|
|
3431
|
+
export function assessScanability(code, renderOptions = {}, options = {}) {
|
|
3432
|
+
assert(code?.matrix, "assessScanability expects an encoded QuadQR object.");
|
|
3433
|
+
const testImageSize = Math.max(240, Math.min(Number(options.testImageSize ?? 480), Number(renderOptions.imageSize ?? 720)));
|
|
3434
|
+
const image = renderToImageData(code, { ...renderOptions, imageSize: testImageSize });
|
|
3435
|
+
const report = runImageStressTest(image, { version: code.version, crc32: code.crc32 }, options);
|
|
3436
|
+
const autoLogoSize = renderOptions.logo ? estimateSafeLogoSize(code, renderOptions) : null;
|
|
3437
|
+
return {
|
|
3438
|
+
...report,
|
|
3439
|
+
version: code.version,
|
|
3440
|
+
eccLevel: code.eccLevel,
|
|
3441
|
+
utilization: code.utilization,
|
|
3442
|
+
testImageSize,
|
|
3443
|
+
autoLogoSize,
|
|
3444
|
+
recommendations: [
|
|
3445
|
+
...(report.score < 75 ? ["Increase ECC, reduce logo size, use Classic style, or increase output/physical size."] : []),
|
|
3446
|
+
...(Number(renderOptions.quietZone ?? 4) < 4 ? ["Use a quiet zone of at least 4 modules for robust camera/print scanning."] : []),
|
|
3447
|
+
...(renderOptions.logo && autoLogoSize != null ? [`Auto-safe logo estimate: ${(autoLogoSize * 100).toFixed(1)}% of the symbol width.`] : []),
|
|
3448
|
+
...(normalizeRenderMode(renderOptions.mode ?? renderOptions.renderMode ?? "screen") === "print" ? getPrintGuidance(code, renderOptions).recommendations : [])
|
|
3449
|
+
]
|
|
3450
|
+
};
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
function paletteConfidence(observedPalette) {
|
|
3454
|
+
if (!observedPalette) return 0.5;
|
|
3455
|
+
const values = ["black", "white", "red", "green", "blue"].map((name) => observedPalette[name]).filter(Boolean);
|
|
3456
|
+
if (values.length < 5) return 0.5;
|
|
3457
|
+
let minDistance = Infinity;
|
|
3458
|
+
for (let i = 0; i < values.length; i++) {
|
|
3459
|
+
for (let j = i + 1; j < values.length; j++) {
|
|
3460
|
+
const a = values[i], b = values[j];
|
|
3461
|
+
minDistance = Math.min(minDistance, Math.hypot(a.r - b.r, a.g - b.g, a.b - b.b));
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3464
|
+
return clampNumber((minDistance - 28) / 120, 0, 1);
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
function scannerDiagnostics(result, context = {}, options = {}) {
|
|
3468
|
+
const info = result ? getVersionInfo(result.version, { ecc: result.eccLevel }) : null;
|
|
3469
|
+
const correctableTotal = result && info
|
|
3470
|
+
? Number(info.correctableHeaderSymbols ?? 0) + Number(result.eccBlocks ?? 0) * Number(result.correctableSymbolsPerBlock ?? 0)
|
|
3471
|
+
: 0;
|
|
3472
|
+
const eccUtilization = correctableTotal > 0 ? clampNumber((result.correctedSymbols ?? 0) / correctableTotal, 0, 1) : 0;
|
|
3473
|
+
const structureConfidence = clampNumber(
|
|
3474
|
+
result?.structureScore ?? context.observations?.at(-1)?.structureScore ?? (result ? 0.92 : 0), 0, 1
|
|
3475
|
+
);
|
|
3476
|
+
const cellConfidence = clampNumber(result?.averageCellConfidence ?? 0.75, 0, 1);
|
|
3477
|
+
const geometryConfidence = result
|
|
3478
|
+
? clampNumber(result.perspectiveCorrected ? Math.max(0.55, structureConfidence) : Math.max(0.65, structureConfidence * 0.9), 0, 1)
|
|
3479
|
+
: (context.geometry?.length ? 0.45 : 0);
|
|
3480
|
+
const calibrationConfidence = result
|
|
3481
|
+
? clampNumber(result.colorCalibrated ? paletteConfidence(result.observedPalette) : 0.55, 0, 1)
|
|
3482
|
+
: 0;
|
|
3483
|
+
const confidence = result
|
|
3484
|
+
? clampNumber(cellConfidence * 0.50 + structureConfidence * 0.20 + geometryConfidence * 0.15 + calibrationConfidence * 0.15 - eccUtilization * 0.12, 0, 1)
|
|
3485
|
+
: 0;
|
|
3486
|
+
const geometryCandidates = (context.geometry ?? []).slice(0, 8).map((geometry) => ({
|
|
3487
|
+
version: geometry.version,
|
|
3488
|
+
size: geometry.size,
|
|
3489
|
+
score: geometry.score,
|
|
3490
|
+
estimatedSize: geometry.estimatedSize,
|
|
3491
|
+
alignmentScore: geometry.alignmentScore,
|
|
3492
|
+
alignmentGridScore: geometry.alignmentGridScore,
|
|
3493
|
+
corners: geometry.corners ?? geometry.quad ?? null,
|
|
3494
|
+
finders: geometry.finders ?? null
|
|
3495
|
+
}));
|
|
3496
|
+
const observations = context.observations ?? [];
|
|
3497
|
+
const latest = observations.at(-1) ?? null;
|
|
3498
|
+
const stages = {
|
|
3499
|
+
finderDetection: geometryCandidates.length > 0 || Boolean(result && !result.perspectiveCorrected),
|
|
3500
|
+
geometry: geometryCandidates.length > 0 || Boolean(result && !result.perspectiveCorrected),
|
|
3501
|
+
sampling: observations.length > 0 || Boolean(result),
|
|
3502
|
+
colorClassification: observations.length > 0 || Boolean(result),
|
|
3503
|
+
ecc: Boolean(result),
|
|
3504
|
+
crc: Boolean(result),
|
|
3505
|
+
payload: Boolean(result)
|
|
3506
|
+
};
|
|
3507
|
+
let failedStage = null;
|
|
3508
|
+
if (!result) {
|
|
3509
|
+
failedStage = !stages.finderDetection ? "finder-detection"
|
|
3510
|
+
: !stages.sampling ? "perspective-geometry"
|
|
3511
|
+
: "color-classification-or-ecc";
|
|
3512
|
+
}
|
|
3513
|
+
return {
|
|
3514
|
+
confidence,
|
|
3515
|
+
cellConfidence,
|
|
3516
|
+
structureConfidence,
|
|
3517
|
+
geometryConfidence,
|
|
3518
|
+
calibrationConfidence,
|
|
3519
|
+
eccUtilization,
|
|
3520
|
+
correctedErrors: result?.correctedSymbols ?? 0,
|
|
3521
|
+
erasureSymbols: result?.erasureSymbols ?? 0,
|
|
3522
|
+
lowConfidenceCells: result?.lowConfidenceCells ?? latest?.lowConfidenceCells ?? null,
|
|
3523
|
+
stages,
|
|
3524
|
+
failedStage,
|
|
3525
|
+
geometryCandidates,
|
|
3526
|
+
vision: context.vision ?? null,
|
|
3527
|
+
sampled: latest ? {
|
|
3528
|
+
version: latest.version,
|
|
3529
|
+
samplingMode: latest.samplingMode,
|
|
3530
|
+
colorNormalization: latest.colorNormalization,
|
|
3531
|
+
structureScore: latest.structureScore,
|
|
3532
|
+
averageCellConfidence: latest.averageCellConfidence,
|
|
3533
|
+
observedPalette: latest.observedPalette ?? null,
|
|
3534
|
+
matrix: options.debugMatrices === false ? undefined : latest.matrix,
|
|
3535
|
+
confidenceMatrix: options.debugMatrices === false ? undefined : latest.confidence
|
|
3536
|
+
} : null
|
|
3537
|
+
};
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
function decorateScanResult(result, context, options) {
|
|
3541
|
+
const diagnostics = scannerDiagnostics(result, context, options);
|
|
3542
|
+
return {
|
|
3543
|
+
...result,
|
|
3544
|
+
confidence: diagnostics.confidence,
|
|
3545
|
+
geometryConfidence: diagnostics.geometryConfidence,
|
|
3546
|
+
calibrationConfidence: diagnostics.calibrationConfidence,
|
|
3547
|
+
structureConfidence: diagnostics.structureConfidence,
|
|
3548
|
+
correctedErrors: result.correctedSymbols ?? 0,
|
|
3549
|
+
eccUtilization: diagnostics.eccUtilization,
|
|
3550
|
+
diagnostics: options.debug ? diagnostics : {
|
|
3551
|
+
confidence: diagnostics.confidence,
|
|
3552
|
+
geometryConfidence: diagnostics.geometryConfidence,
|
|
3553
|
+
calibrationConfidence: diagnostics.calibrationConfidence,
|
|
3554
|
+
structureConfidence: diagnostics.structureConfidence,
|
|
3555
|
+
eccUtilization: diagnostics.eccUtilization,
|
|
3556
|
+
correctedErrors: diagnostics.correctedErrors,
|
|
3557
|
+
erasureSymbols: diagnostics.erasureSymbols
|
|
3558
|
+
}
|
|
3559
|
+
};
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
/** Non-throwing scanner debug API with detailed stage diagnostics. */
|
|
3563
|
+
export function debugScanImageData(imageData, options = {}) {
|
|
3564
|
+
try {
|
|
3565
|
+
const result = scanImageData(imageData, { ...options, debug: true });
|
|
3566
|
+
return { ok: true, result, debug: result.diagnostics };
|
|
3567
|
+
} catch (error) {
|
|
3568
|
+
return { ok: false, error: error.message, debug: error.debug ?? null };
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
|
|
2221
3572
|
export function scanImageData(imageData, options = {}) {
|
|
2222
3573
|
assert(imageData && imageData.data && imageData.width && imageData.height, "Valid ImageData is required.");
|
|
2223
3574
|
const minVersion = options.minVersion ?? MIN_VERSION;
|
|
@@ -2226,11 +3577,19 @@ export function scanImageData(imageData, options = {}) {
|
|
|
2226
3577
|
validateVersion(maxVersion);
|
|
2227
3578
|
|
|
2228
3579
|
const geometryCollector = [];
|
|
2229
|
-
const
|
|
3580
|
+
const observationCollector = options.debug ? [] : (Array.isArray(options._observationCollector) ? options._observationCollector : []);
|
|
3581
|
+
const visionDiagnostics = options.debug ? { passes: [] } : (options._visionDiagnostics ?? null);
|
|
3582
|
+
const debugContext = { geometry: geometryCollector, observations: observationCollector, vision: visionDiagnostics };
|
|
3583
|
+
const scanOptions = {
|
|
3584
|
+
...options,
|
|
3585
|
+
_geometryCollector: geometryCollector,
|
|
3586
|
+
_observationCollector: observationCollector,
|
|
3587
|
+
_visionDiagnostics: visionDiagnostics
|
|
3588
|
+
};
|
|
2230
3589
|
|
|
2231
3590
|
if (options.perspective !== false) {
|
|
2232
3591
|
const perspective = tryPerspectiveScan(imageData, scanOptions);
|
|
2233
|
-
if (perspective) return perspective;
|
|
3592
|
+
if (perspective) return decorateScanResult(perspective, debugContext, options);
|
|
2234
3593
|
}
|
|
2235
3594
|
|
|
2236
3595
|
// If the normal scanner could see QuadQR geometry but could not decode the
|
|
@@ -2257,12 +3616,12 @@ export function scanImageData(imageData, options = {}) {
|
|
|
2257
3616
|
_geometryCollector: []
|
|
2258
3617
|
});
|
|
2259
3618
|
if (recovered) {
|
|
2260
|
-
return {
|
|
3619
|
+
return decorateScanResult({
|
|
2261
3620
|
...recovered,
|
|
2262
3621
|
autoEnhanced: true,
|
|
2263
3622
|
recoveryMode: "auto-tone-contrast-color",
|
|
2264
3623
|
originalColorNormalization: recovered.colorNormalization
|
|
2265
|
-
};
|
|
3624
|
+
}, debugContext, options);
|
|
2266
3625
|
}
|
|
2267
3626
|
} catch {
|
|
2268
3627
|
// Continue to the axis-aligned fallback.
|
|
@@ -2271,7 +3630,7 @@ export function scanImageData(imageData, options = {}) {
|
|
|
2271
3630
|
|
|
2272
3631
|
if (options.axisAlignedFallback !== false) {
|
|
2273
3632
|
const axis = tryAxisAlignedScan(imageData, scanOptions);
|
|
2274
|
-
if (axis) return axis;
|
|
3633
|
+
if (axis) return decorateScanResult(axis, debugContext, options);
|
|
2275
3634
|
}
|
|
2276
3635
|
|
|
2277
3636
|
// Static images sometimes have such low contrast that locator detection itself
|
|
@@ -2298,20 +3657,22 @@ export function scanImageData(imageData, options = {}) {
|
|
|
2298
3657
|
};
|
|
2299
3658
|
if (options.perspective !== false) {
|
|
2300
3659
|
const perspective = tryPerspectiveScan(enhanced, recoveryOptions);
|
|
2301
|
-
if (perspective) return { ...perspective, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" };
|
|
3660
|
+
if (perspective) return decorateScanResult({ ...perspective, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
|
|
2302
3661
|
}
|
|
2303
3662
|
if (options.axisAlignedFallback !== false) {
|
|
2304
3663
|
const axis = tryAxisAlignedScan(enhanced, recoveryOptions);
|
|
2305
|
-
if (axis) return { ...axis, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" };
|
|
3664
|
+
if (axis) return decorateScanResult({ ...axis, autoEnhanced: true, recoveryMode: "auto-tone-contrast-color" }, debugContext, options);
|
|
2306
3665
|
}
|
|
2307
3666
|
} catch {
|
|
2308
3667
|
// Fall through to the normal scan failure below.
|
|
2309
3668
|
}
|
|
2310
3669
|
}
|
|
2311
3670
|
|
|
2312
|
-
|
|
3671
|
+
const error = new Error(
|
|
2313
3672
|
"No valid QuadQR code found. Try better lighting, fill more of the frame, keep all locator patterns visible, or use a less blurred image."
|
|
2314
3673
|
);
|
|
3674
|
+
if (options.debug) error.debug = scannerDiagnostics(null, debugContext, options);
|
|
3675
|
+
throw error;
|
|
2315
3676
|
}
|
|
2316
3677
|
|
|
2317
3678
|
export async function scanFile(file, options = {}) {
|