@mostajs/qrpanel 0.5.0 → 0.6.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/src/qr-png.js ADDED
@@ -0,0 +1,40 @@
1
+ // @mostajs/qrpanel — sortie PNG (data:URL) d'un QR. Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ // Pour les contextes où le SVG inline est bloqué (emails : billets, magic-link). Node (node:zlib pour l'IDAT).
3
+ import zlib from 'node:zlib';
4
+ import { qrMatrix } from './qr-svg.js';
5
+
6
+ const CRC = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); t[n] = c >>> 0; } return t; })();
7
+ const crc32 = (b) => { let c = 0xFFFFFFFF; for (let i = 0; i < b.length; i++) c = CRC[(c ^ b[i]) & 0xFF] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; };
8
+ const u32 = (n) => Uint8Array.of((n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255);
9
+ const cat = (...a) => { const o = new Uint8Array(a.reduce((s, x) => s + x.length, 0)); let p = 0; for (const x of a) { o.set(x, p); p += x.length; } return o; };
10
+ const chunk = (type, data) => { const body = cat(Uint8Array.from([...type].map((c) => c.charCodeAt(0))), data); return cat(u32(data.length), body, u32(crc32(body))); };
11
+ const toB64 = (bytes) => (typeof Buffer !== 'undefined' ? Buffer.from(bytes).toString('base64') : btoa(String.fromCharCode(...bytes)));
12
+
13
+ /** QR en PNG (niveaux de gris) → data:image/png;base64,… { scale=6, margin=4 }. */
14
+ export function qrPng(text, { scale = 6, margin = 4 } = {}) {
15
+ const { matrix, size } = qrMatrix(text);
16
+ const dim = (size + margin * 2) * scale;
17
+ const stride = dim + 1; // 1 octet de filtre par ligne
18
+ const raw = new Uint8Array(stride * dim);
19
+ for (let y = 0; y < dim; y++) {
20
+ raw[y * stride] = 0; // filtre None
21
+ const my = Math.floor(y / scale) - margin;
22
+ for (let x = 0; x < dim; x++) {
23
+ const mx = Math.floor(x / scale) - margin;
24
+ const dark = my >= 0 && my < size && mx >= 0 && mx < size && matrix[my][mx];
25
+ raw[y * stride + 1 + x] = dark ? 0 : 255;
26
+ }
27
+ }
28
+ const sig = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10);
29
+ const ihdr = cat(u32(dim), u32(dim), Uint8Array.of(8, 0, 0, 0, 0)); // 8 bits, grayscale
30
+ const idat = new Uint8Array(zlib.deflateSync(raw));
31
+ const png = cat(sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', new Uint8Array(0)));
32
+ return 'data:image/png;base64,' + toB64(png);
33
+ }
34
+
35
+ const _esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
36
+
37
+ /** Figure PNG d'un QR avec légende (pendant de qrLive pour le rendu PNG : billets/badges imprimables). { scale=5, margin=2, maxWidth=240 }. */
38
+ export function qrPngFigure(data, caption = '', { scale = 5, margin = 2, maxWidth = 240 } = {}) {
39
+ return `<figure style="margin:0;text-align:center"><img src="${qrPng(data, { scale, margin })}" alt="QR" style="max-width:${maxWidth}px;width:60vw;image-rendering:pixelated">${caption ? `<figcaption style="font-size:12px;margin-top:4px;opacity:.7">${_esc(caption)}</figcaption>` : ''}</figure>`;
40
+ }
package/src/qr-svg.js ADDED
@@ -0,0 +1,185 @@
1
+ // @mostajs/qrpanel/qr-svg — encodeur QR Code ZÉRO-DÉPENDANCE → SVG (server-side, pas de natif, pas de npm). Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ // Complète qrpanel (qr-image = navigateur via `qrcode` ; server.js = @resvg natif) par un encodeur PUR JS server-side, idéal apps zéro-dép.
3
+ // Modes numérique & octet (byte/UTF-8), niveau de correction M, versions 1→10 (auto). Reed-Solomon GF(256), 8 masques + pénalité.
4
+ // Vérifié contre le vecteur de référence publié (« 01234567 », v1-M → 10 mots de code EC connus). Sortie : matrice booléenne + SVG.
5
+
6
+ // ── GF(256) — corps de Galois, primitif 0x11d (x^8+x^4+x^3+x^2+1). ──
7
+ const EXP = new Array(512), LOG = new Array(256);
8
+ (() => { let x = 1; for (let i = 0; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; } for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255]; })();
9
+ const gmul = (a, b) => (a === 0 || b === 0) ? 0 : EXP[LOG[a] + LOG[b]];
10
+
11
+ // Polynôme générateur Reed-Solomon de degré `n`.
12
+ function rsGenPoly(n) {
13
+ let g = [1];
14
+ for (let i = 0; i < n; i++) { const ng = new Array(g.length + 1).fill(0); for (let j = 0; j < g.length; j++) { ng[j] ^= gmul(g[j], 1); ng[j + 1] ^= gmul(g[j], EXP[i]); } g = ng; }
15
+ return g;
16
+ }
17
+ // Mots de code de correction d'erreur pour `data` (octets) avec `ecLen` symboles.
18
+ function rsEncode(data, ecLen) {
19
+ const gen = rsGenPoly(ecLen); const res = new Array(ecLen).fill(0); // gen a ecLen+1 coeffs (gen[0]=1, ignoré dans la division)
20
+ for (const d of data) { const factor = d ^ res[0]; res.shift(); res.push(0); if (factor !== 0) for (let i = 0; i < ecLen; i++) res[i] ^= gmul(gen[i + 1], factor); }
21
+ return res;
22
+ }
23
+
24
+ // ── Tables niveau M, versions 1→10 : [totalDataCodewords, ecPerBlock, [ [nbBlocks, dataPerBlock], ... ] ]. ──
25
+ const VERSIONS_M = {
26
+ 1: [16, 10, [[1, 16]]], 2: [28, 16, [[1, 28]]], 3: [44, 26, [[1, 44]]], 4: [64, 18, [[2, 32]]],
27
+ 5: [86, 24, [[2, 43]]], 6: [108, 16, [[4, 27]]], 7: [124, 18, [[4, 31]]], 8: [154, 22, [[2, 38], [2, 39]]],
28
+ 9: [182, 22, [[3, 36], [2, 37]]], 10: [216, 26, [[4, 43], [1, 44]]],
29
+ };
30
+ // Positions des motifs d'alignement (centres) par version.
31
+ const ALIGN = { 1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], 6: [6, 34], 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50] };
32
+ const sizeOf = (v) => 17 + 4 * v;
33
+
34
+ const isNumeric = (s) => /^[0-9]+$/.test(s);
35
+
36
+ // ── Encodage des données (bitstream) selon le mode. ──
37
+ function encodeData(text, version, totalDataCw) {
38
+ const bits = [];
39
+ const push = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1); };
40
+ if (isNumeric(text)) {
41
+ push(0b0001, 4); // mode numérique
42
+ push(text.length, version < 10 ? 10 : 12);
43
+ for (let i = 0; i < text.length; i += 3) { const chunk = text.substr(i, 3); push(parseInt(chunk, 10), chunk.length === 3 ? 10 : chunk.length === 2 ? 7 : 4); }
44
+ } else {
45
+ const bytes = Buffer.from(text, 'utf8');
46
+ push(0b0100, 4); // mode octet
47
+ push(bytes.length, version < 10 ? 8 : 16);
48
+ for (const b of bytes) push(b, 8);
49
+ }
50
+ const cap = totalDataCw * 8;
51
+ for (let i = 0; i < 4 && bits.length < cap; i++) bits.push(0); // terminateur
52
+ while (bits.length % 8 !== 0) bits.push(0); // alignement octet
53
+ const pads = [0xec, 0x11]; let p = 0;
54
+ while (bits.length < cap) { push(pads[p % 2], 8); p++; }
55
+ const cw = []; for (let i = 0; i < bits.length; i += 8) { let b = 0; for (let j = 0; j < 8; j++) b = (b << 1) | bits[i + j]; cw.push(b); }
56
+ return cw;
57
+ }
58
+
59
+ // Choisit la plus petite version (≤10) qui contient `text`.
60
+ function pickVersion(text) {
61
+ const len = isNumeric(text) ? null : Buffer.from(text, 'utf8').length;
62
+ for (let v = 1; v <= 10; v++) {
63
+ const totalData = VERSIONS_M[v][0];
64
+ // estimation grossière de capacité : entête (~4 + count bits) puis données
65
+ const headerBits = 4 + (v < 10 ? (isNumeric(text) ? 10 : 8) : (isNumeric(text) ? 12 : 16));
66
+ const dataBits = isNumeric(text) ? Math.ceil(text.length / 3) * 10 : (len * 8);
67
+ if (headerBits + dataBits <= totalData * 8) return v;
68
+ }
69
+ throw new Error('contenu trop long pour QR v≤10 (raccourcir l\'URL/jeton)');
70
+ }
71
+
72
+ // Entrelace données + EC par blocs (spec QR).
73
+ function interleave(dataCw, version) {
74
+ const [, ecLen, groups] = VERSIONS_M[version];
75
+ const blocks = []; let idx = 0;
76
+ for (const [nb, dpb] of groups) for (let i = 0; i < nb; i++) { const d = dataCw.slice(idx, idx + dpb); idx += dpb; blocks.push({ d, e: rsEncode(d, ecLen) }); }
77
+ const maxD = Math.max(...blocks.map((b) => b.d.length));
78
+ const out = [];
79
+ for (let i = 0; i < maxD; i++) for (const b of blocks) if (i < b.d.length) out.push(b.d[i]);
80
+ for (let i = 0; i < ecLen; i++) for (const b of blocks) out.push(b.e[i]);
81
+ return out;
82
+ }
83
+
84
+ // ── Matrice : motifs de repérage, séparateurs, timing, alignement, module sombre, réservé format. ──
85
+ // Information de version (18 bits BCH) — OBLIGATOIRE dès la version 7. Sans elle, les scanners rejettent le QR.
86
+ const VERSION_INFO = { 7: 0x07C94, 8: 0x085BC, 9: 0x09A99, 10: 0x0A4D3 };
87
+
88
+ function buildMatrix(version) {
89
+ const n = sizeOf(version);
90
+ const m = Array.from({ length: n }, () => new Array(n).fill(null)); // null = libre
91
+ const fn = Array.from({ length: n }, () => new Array(n).fill(false)); // true = fonction (non masquable)
92
+ const set = (r, c, v) => { m[r][c] = v ? 1 : 0; fn[r][c] = true; };
93
+ const finder = (r, c) => { for (let i = -1; i <= 7; i++) for (let j = -1; j <= 7; j++) { const rr = r + i, cc = c + j; if (rr < 0 || rr >= n || cc < 0 || cc >= n) continue; const inb = i >= 0 && i <= 6 && j >= 0 && j <= 6; const ring = i === 0 || i === 6 || j === 0 || j === 6; const core = i >= 2 && i <= 4 && j >= 2 && j <= 4; set(rr, cc, inb && (ring || core)); } };
94
+ finder(0, 0); finder(0, n - 7); finder(n - 7, 0);
95
+ for (let i = 0; i < n; i++) { if (m[6][i] === null) set(6, i, i % 2 === 0); if (m[i][6] === null) set(i, 6, i % 2 === 0); } // timing
96
+ const ac = ALIGN[version];
97
+ for (const r of ac) for (const c of ac) { if ((r <= 7 && c <= 7) || (r <= 7 && c >= n - 8) || (r >= n - 8 && c <= 7)) continue; for (let i = -2; i <= 2; i++) for (let j = -2; j <= 2; j++) set(r + i, c + j, Math.max(Math.abs(i), Math.abs(j)) !== 1); }
98
+ set(n - 8, 8, 1); // module sombre
99
+ // Réserve les zones d'info de format (sans valeur définitive) → marquées fonction.
100
+ for (let i = 0; i < 9; i++) { if (m[8][i] === null) { m[8][i] = 0; fn[8][i] = true; } if (m[i][8] === null) { m[i][8] = 0; fn[i][8] = true; } }
101
+ for (let i = 0; i < 8; i++) { if (m[8][n - 1 - i] === null) { m[8][n - 1 - i] = 0; fn[8][n - 1 - i] = true; } if (m[n - 1 - i][8] === null) { m[n - 1 - i][8] = 0; fn[n - 1 - i][8] = true; } }
102
+ // Information de version (v≥7) : 18 bits fixes, deux blocs 3×6 près des viseurs haut-droit et bas-gauche.
103
+ if (version >= 7) { const vi = VERSION_INFO[version]; for (let i = 0; i < 18; i++) { const bit = (vi >> i) & 1; const a = n - 11 + (i % 3), b = Math.floor(i / 3); set(b, a, bit); set(a, b, bit); } }
104
+ return { m, fn, n };
105
+ }
106
+
107
+ // Place le bitstream en zigzag (colonnes de 2, de droite à gauche, en sautant la colonne 6).
108
+ function placeData(m, fn, n, bytes) {
109
+ let bitIdx = 0; const bitsArr = []; for (const b of bytes) for (let i = 7; i >= 0; i--) bitsArr.push((b >> i) & 1);
110
+ let up = true;
111
+ for (let col = n - 1; col > 0; col -= 2) {
112
+ if (col === 6) col--;
113
+ for (let k = 0; k < n; k++) { const row = up ? n - 1 - k : k; for (const c of [col, col - 1]) { if (!fn[row][c]) { m[row][c] = bitIdx < bitsArr.length ? bitsArr[bitIdx] : 0; bitIdx++; } } }
114
+ up = !up;
115
+ }
116
+ }
117
+
118
+ const MASKS = [
119
+ (r, c) => (r + c) % 2 === 0, (r) => r % 2 === 0, (r, c) => c % 3 === 0, (r, c) => (r + c) % 3 === 0,
120
+ (r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0, (r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
121
+ (r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0, (r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
122
+ ];
123
+ function applyMask(m, fn, n, maskFn) { const o = m.map((row) => row.slice()); for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) if (!fn[r][c] && maskFn(r, c)) o[r][c] ^= 1; return o; }
124
+
125
+ // Info de format (niveau M = bits 00) + masque → 15 bits BCH, placée aux deux emplacements.
126
+ function placeFormat(m, n, maskId) {
127
+ const data = (0b00 << 3) | maskId; let bch = data << 10;
128
+ for (let i = 14; i >= 10; i--) if ((bch >> i) & 1) bch ^= 0b10100110111 << (i - 10);
129
+ const bits = (((data << 10) | bch) ^ 0b101010000010010);
130
+ const get = (i) => (bits >> i) & 1;
131
+ // 1ʳᵉ copie (autour du viseur haut-gauche) : bits 0-7 → colonne 8 (haut), bits 8-14 → ligne 8 (gauche). Ordre du SPEC (ISO 18004).
132
+ for (let i = 0; i <= 5; i++) m[i][8] = get(i);
133
+ m[7][8] = get(6); m[8][8] = get(7); m[8][7] = get(8);
134
+ for (let i = 9; i <= 14; i++) m[8][14 - i] = get(i);
135
+ // 2ᵉ copie : bits 0-7 → ligne 8 (droite), bits 8-14 → colonne 8 (bas).
136
+ for (let i = 0; i <= 7; i++) m[8][n - 1 - i] = get(i);
137
+ for (let i = 8; i <= 14; i++) m[n - 15 + i][8] = get(i);
138
+ }
139
+
140
+ function penalty(m, n) {
141
+ let p = 0;
142
+ for (let r = 0; r < n; r++) for (const line of [m[r], m.map((x) => x[r])]) { let run = 1; for (let c = 1; c < n; c++) { if (line[c] === line[c - 1]) { run++; if (run === 5) p += 3; else if (run > 5) p++; } else run = 1; } }
143
+ for (let r = 0; r < n - 1; r++) for (let c = 0; c < n - 1; c++) if (m[r][c] === m[r][c + 1] && m[r][c] === m[r + 1][c] && m[r][c] === m[r + 1][c + 1]) p += 3;
144
+ let dark = 0; for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) dark += m[r][c]; const ratio = (dark * 100) / (n * n); p += Math.floor(Math.abs(ratio - 50) / 5) * 10;
145
+ return p;
146
+ }
147
+
148
+ /** Encode `text` en matrice QR (booléens true=module sombre). Niveau M, version auto ≤10. */
149
+ export function qrMatrix(text) {
150
+ const version = pickVersion(text);
151
+ const totalData = VERSIONS_M[version][0];
152
+ const dataCw = encodeData(text, version, totalData);
153
+ const finalCw = interleave(dataCw, version);
154
+ const { m, fn, n } = buildMatrix(version);
155
+ placeData(m, fn, n, finalCw);
156
+ let best = null, bestScore = Infinity, bestMask = 0;
157
+ for (let mask = 0; mask < 8; mask++) { const cand = applyMask(m, fn, n, MASKS[mask]); placeFormat(cand, n, mask); const s = penalty(cand, n); if (s < bestScore) { bestScore = s; best = cand; bestMask = mask; } }
158
+ return { matrix: best.map((row) => row.map((v) => v === 1)), size: n, version, mask: bestMask };
159
+ }
160
+
161
+ /** Rend `text` en SVG QR (scannable). `scale` px/module, `margin` modules de zone calme. */
162
+ export function qrSvg(text, { scale = 4, margin = 4, dark = '#000', light = '#fff' } = {}) {
163
+ const { matrix, size } = qrMatrix(text);
164
+ const dim = (size + margin * 2) * scale;
165
+ let rects = '';
166
+ for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) if (matrix[r][c]) rects += `<rect x="${(c + margin) * scale}" y="${(r + margin) * scale}" width="${scale}" height="${scale}"/>`;
167
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${dim}" height="${dim}" viewBox="0 0 ${dim} ${dim}" shape-rendering="crispEdges" role="img" aria-label="QR code"><rect width="${dim}" height="${dim}" fill="${light}"/><g fill="${dark}">${rects}</g></svg>`;
168
+ }
169
+
170
+ const _esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
171
+
172
+ /**
173
+ * Figure QR « scannable à l'écran » : SVG GROS (scale élevé + quiet zone large) + légende, dans un conteneur responsive.
174
+ * Pensé pour un QR qu'on affiche pour être SCANNÉ (plateau live, billet, lien public) — pas une vignette.
175
+ * Garde des URLs COURTES (≤ ~84 octets / version 6) : l'encodeur ne pose pas les « version information » des versions ≥ 7.
176
+ * qrLive(data, caption?, { scale=6, margin=3, maxWidth=240, dark, light }?)
177
+ */
178
+ export function qrLive(data, caption = '', { scale = 6, margin = 3, maxWidth = 240, dark, light } = {}) {
179
+ const svg = qrSvg(data, { scale, margin, ...(dark ? { dark } : {}), ...(light ? { light } : {}) });
180
+ return `<figure style="margin:0;text-align:center"><div style="max-width:${maxWidth}px;margin:auto">${svg}</div>${caption ? `<figcaption style="font-size:12px;margin-top:2px;opacity:.7">${_esc(caption)}</figcaption>` : ''}</figure>`;
181
+ }
182
+
183
+ // Exposés pour les tests (vérification contre le vecteur de référence publié).
184
+ export const _internal = { rsEncode, encodeData, interleave, VERSIONS_M };
185
+ export default { qrMatrix, qrSvg, qrLive };
package/src/server.ts ADDED
@@ -0,0 +1,212 @@
1
+ // @mostajs/qrpanel/server — server-side QR generation (Node)
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ //
4
+ // Wrap minimaliste de la lib `qrcode` (npm) + extension thématique
5
+ // pilotée par .qrconfig.json (chargée à process.cwd() au runtime, cache
6
+ // invalidé par mtime).
7
+ //
8
+ // Cross-OS natif (Linux, macOS, Windows) — qrcode est pure-JS, et la
9
+ // rasterization PNG composite passe par @resvg/resvg-js (rust prebuilt
10
+ // binaries cross-OS, pas de chromium ni puppeteer).
11
+ //
12
+ // API legacy 100% rétro-compatible :
13
+ // - generateQrPng/Svg/DataUrl(text, opts) marchent comme en 0.2.x
14
+ // si .qrconfig.json est absent ET opts.genimage non-précisé.
15
+ // - Le master toggle genimage=false (config ou opts) court-circuite
16
+ // toute la chaîne thématique.
17
+
18
+ import QRCode from 'qrcode'
19
+ import { signInviteToken } from '@mostajs/auth/lib/invite-token'
20
+ import {
21
+ loadQrConfig, type QrConfigDefaults,
22
+ } from './config.js'
23
+ import {
24
+ composeThemedSvg, composeThemedPng, composeThemedDataUrl, mergeComposeOpts,
25
+ type ComposeOptions,
26
+ } from './composer.js'
27
+ import type { ThemeKey } from './themes.js'
28
+
29
+ // ─── Public types ──────────────────────────────────────────────────
30
+
31
+ export interface QrOptions {
32
+ /** Largeur/hauteur de l'image en pixels. Default 600 (ou config). */
33
+ width?: number
34
+ /** Marge blanche autour du QR (en modules). Default 2 (ou config). */
35
+ margin?: number
36
+ /**
37
+ * Niveau de correction d'erreur :
38
+ * L = 7 % | M = 15 % | Q = 25 % | H = 30 % (recommandé pour overlay)
39
+ */
40
+ errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H'
41
+ /** Couleur des modules sombres. Default '#0f172a'. */
42
+ darkColor?: string
43
+ /** Couleur du fond. Default '#ffffff'. */
44
+ lightColor?: string
45
+
46
+ // ── v0.3 — extension thématique ─────────────────────────────────
47
+ /**
48
+ * Master toggle. `false` court-circuite tout le pipeline thématique
49
+ * et retombe sur le QR pur (comportement v0.2.x). Override de la
50
+ * valeur config.
51
+ */
52
+ genimage?: boolean
53
+ /**
54
+ * Thème à appliquer en cadre (4 motifs aux coins). Pris dans le
55
+ * registry natif (12 thèmes). 'random' tire dans `themePool`.
56
+ * 'none' désactive le composite ponctuellement.
57
+ * Objet `{ svg }` → motif custom inline.
58
+ */
59
+ theme?: ThemeKey | 'random' | 'none' | { svg: string; label?: string }
60
+ /** Sous-set des thèmes pour le tirage 'random'. Default = tous. */
61
+ themePool?: ThemeKey[]
62
+ /** Marge cadre image / canvas (proportion). Default 0.13. */
63
+ framePadding?: number
64
+ /** Taille du cartouche blanc central (proportion 0..1). Default 0.62. */
65
+ centerWhiteRatio?: number
66
+ /** Opacité du cadre image (0..1). Default 1. */
67
+ themeOpacity?: number
68
+ /** Couleur monochrome du cadre image. Default '#1e293b'. */
69
+ themeColor?: string
70
+ }
71
+
72
+ // ─── Internal helpers ──────────────────────────────────────────────
73
+
74
+ /**
75
+ * Décide si on bascule sur le pipeline composite ou le legacy.
76
+ * Order : opts.genimage > config.default.genimage. theme='none' force legacy.
77
+ */
78
+ function shouldComposite(opts: QrOptions, cfg: QrConfigDefaults): boolean {
79
+ if (opts.genimage === false) return false
80
+ if (opts.genimage === true) {
81
+ // explicit on : ne respecte pas theme=none ? Si l'app dit explicitement
82
+ // genimage=true mais theme=none, on respecte theme=none (QR pur ponctuel).
83
+ const theme = opts.theme ?? cfg.theme
84
+ return theme !== 'none'
85
+ }
86
+ // Pas d'override opts → suit la config
87
+ if (cfg.genimage === false) return false
88
+ const theme = opts.theme ?? cfg.theme
89
+ return theme !== 'none'
90
+ }
91
+
92
+ function composeOptsFromQrOptions(opts: QrOptions, cfg: QrConfigDefaults): ComposeOptions {
93
+ return mergeComposeOpts(cfg, {
94
+ width: opts.width,
95
+ errorCorrectionLevel: opts.errorCorrectionLevel,
96
+ darkColor: opts.darkColor,
97
+ lightColor: opts.lightColor,
98
+ theme: opts.theme,
99
+ themePool: opts.themePool,
100
+ framePadding: opts.framePadding,
101
+ centerWhiteRatio: opts.centerWhiteRatio,
102
+ themeOpacity: opts.themeOpacity,
103
+ themeColor: opts.themeColor,
104
+ })
105
+ }
106
+
107
+ // ─── Public API : 3 generators ─────────────────────────────────────
108
+
109
+ /** Génère un PNG (Buffer) d'un QR code encodant `text`. */
110
+ export async function generateQrPng(text: string, opts: QrOptions = {}): Promise<Buffer> {
111
+ const cfg = loadQrConfig().default
112
+ if (shouldComposite(opts, cfg)) {
113
+ const composed = await composeThemedPng(text, composeOptsFromQrOptions(opts, cfg))
114
+ if (composed) return composed
115
+ }
116
+ // Legacy QR pur (qrcode lib direct)
117
+ return QRCode.toBuffer(text, {
118
+ width: opts.width ?? cfg.width,
119
+ margin: opts.margin ?? 2,
120
+ errorCorrectionLevel: opts.errorCorrectionLevel ?? cfg.errorCorrectionLevel,
121
+ color: {
122
+ dark: opts.darkColor ?? cfg.darkColor,
123
+ light: opts.lightColor ?? cfg.lightColor,
124
+ },
125
+ })
126
+ }
127
+
128
+ /** Génère un SVG (string) d'un QR code encodant `text`. */
129
+ export async function generateQrSvg(text: string, opts: QrOptions = {}): Promise<string> {
130
+ const cfg = loadQrConfig().default
131
+ if (shouldComposite(opts, cfg)) {
132
+ const composed = await composeThemedSvg(text, composeOptsFromQrOptions(opts, cfg))
133
+ if (composed) return composed
134
+ }
135
+ return QRCode.toString(text, {
136
+ type: 'svg',
137
+ width: opts.width ?? cfg.width,
138
+ margin: opts.margin ?? 2,
139
+ errorCorrectionLevel: opts.errorCorrectionLevel ?? cfg.errorCorrectionLevel,
140
+ color: {
141
+ dark: opts.darkColor ?? cfg.darkColor,
142
+ light: opts.lightColor ?? cfg.lightColor,
143
+ },
144
+ })
145
+ }
146
+
147
+ /** Génère un Data URL `data:image/png;base64,...` */
148
+ export async function generateQrDataUrl(text: string, opts: QrOptions = {}): Promise<string> {
149
+ const cfg = loadQrConfig().default
150
+ if (shouldComposite(opts, cfg)) {
151
+ const composed = await composeThemedDataUrl(text, composeOptsFromQrOptions(opts, cfg))
152
+ if (composed) return composed
153
+ }
154
+ return QRCode.toDataURL(text, {
155
+ width: opts.width ?? cfg.width,
156
+ margin: opts.margin ?? 2,
157
+ errorCorrectionLevel: opts.errorCorrectionLevel ?? cfg.errorCorrectionLevel,
158
+ color: {
159
+ dark: opts.darkColor ?? cfg.darkColor,
160
+ light: opts.lightColor ?? cfg.lightColor,
161
+ },
162
+ })
163
+ }
164
+
165
+ // ─── Helpers haut-niveau (URL builder + invite-token combiné) ─────
166
+
167
+ export interface BuildInviteUrlsOptions {
168
+ /** Base absolue (https://app.example.com), sans slash final. */
169
+ baseUrl: string
170
+ /** Path public direct (ex: '/projet-x'). Sera concaténé à baseUrl. */
171
+ directPath: string
172
+ /** Secret HMAC pour signer le token invite. */
173
+ inviteSecret: string | Buffer
174
+ /** Identifiant opaque encodé dans le token (resource id). */
175
+ inviteId: string
176
+ /** TTL en ms (default 60 jours). */
177
+ ttlMs?: number
178
+ /** Path du callback invite (default '/invite'). */
179
+ invitePath?: string
180
+ /** Meta libre encodée dans le token. */
181
+ inviteMeta?: Record<string, string | number | boolean>
182
+ }
183
+
184
+ export interface InviteUrls {
185
+ /** URL absolue mode "direct" (auth standard derrière). */
186
+ directUrl: string
187
+ /** URL absolue mode "invite" (token HMAC signé). */
188
+ inviteUrl: string
189
+ /** Le token signé seul (pour storage si besoin). */
190
+ inviteToken: string
191
+ }
192
+
193
+ /**
194
+ * Helper combiné — signe un invite-token et construit la paire d'URLs
195
+ * (direct + invite) prête à passer à `<QrPanel>` côté client.
196
+ */
197
+ export function buildInviteUrls(opts: BuildInviteUrlsOptions): InviteUrls {
198
+ const base = String(opts.baseUrl).replace(/\/+$/, '')
199
+ const direct = opts.directPath.startsWith('/') ? opts.directPath : '/' + opts.directPath
200
+ const invitePath = (opts.invitePath ?? '/invite').replace(/\/+$/, '')
201
+ const inviteToken = signInviteToken(opts.inviteSecret, opts.inviteId, opts.ttlMs, opts.inviteMeta)
202
+ return {
203
+ directUrl: base + direct,
204
+ inviteUrl: `${base}${invitePath}/${inviteToken}`,
205
+ inviteToken,
206
+ }
207
+ }
208
+
209
+ // ─── Re-exports utiles côté serveur ────────────────────────────────
210
+
211
+ export { listThemes, THEME_KEYS, THEMES, getTheme, type ThemeKey, type ThemeAsset } from './themes.js'
212
+ export { loadQrConfig, ensureQrConfig, DEFAULT_CONFIG, type QrConfig } from './config.js'