@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/dist/qr-svg.js ADDED
@@ -0,0 +1,165 @@
1
+ // @mostajs/qrpanel/qr-svg — ARTEFACT GÉNÉRÉ depuis src/qr-svg.js (pur JS, copie 1:1) — ne pas éditer ici. 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
+ function buildMatrix(version) {
86
+ const n = sizeOf(version);
87
+ const m = Array.from({ length: n }, () => new Array(n).fill(null)); // null = libre
88
+ const fn = Array.from({ length: n }, () => new Array(n).fill(false)); // true = fonction (non masquable)
89
+ const set = (r, c, v) => { m[r][c] = v ? 1 : 0; fn[r][c] = true; };
90
+ 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)); } };
91
+ finder(0, 0); finder(0, n - 7); finder(n - 7, 0);
92
+ 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
93
+ const ac = ALIGN[version];
94
+ 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); }
95
+ set(n - 8, 8, 1); // module sombre
96
+ // Réserve les zones d'info de format (sans valeur définitive) → marquées fonction.
97
+ 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; } }
98
+ 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; } }
99
+ return { m, fn, n };
100
+ }
101
+
102
+ // Place le bitstream en zigzag (colonnes de 2, de droite à gauche, en sautant la colonne 6).
103
+ function placeData(m, fn, n, bytes) {
104
+ let bitIdx = 0; const bitsArr = []; for (const b of bytes) for (let i = 7; i >= 0; i--) bitsArr.push((b >> i) & 1);
105
+ let up = true;
106
+ for (let col = n - 1; col > 0; col -= 2) {
107
+ if (col === 6) col--;
108
+ 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++; } } }
109
+ up = !up;
110
+ }
111
+ }
112
+
113
+ const MASKS = [
114
+ (r, c) => (r + c) % 2 === 0, (r) => r % 2 === 0, (r, c) => c % 3 === 0, (r, c) => (r + c) % 3 === 0,
115
+ (r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0, (r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
116
+ (r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0, (r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
117
+ ];
118
+ 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; }
119
+
120
+ // Info de format (niveau M = bits 00) + masque → 15 bits BCH, placée aux deux emplacements.
121
+ function placeFormat(m, n, maskId) {
122
+ const data = (0b00 << 3) | maskId; let bch = data << 10;
123
+ for (let i = 14; i >= 10; i--) if ((bch >> i) & 1) bch ^= 0b10100110111 << (i - 10);
124
+ const bits = (((data << 10) | bch) ^ 0b101010000010010);
125
+ const get = (i) => (bits >> i) & 1;
126
+ for (let i = 0; i <= 5; i++) m[8][i] = get(i);
127
+ m[8][7] = get(6); m[8][8] = get(7); m[7][8] = get(8);
128
+ for (let i = 9; i <= 14; i++) m[14 - i][8] = get(i);
129
+ for (let i = 0; i <= 7; i++) m[n - 1 - i][8] = get(i);
130
+ for (let i = 8; i <= 14; i++) m[8][n - 15 + i] = get(i);
131
+ }
132
+
133
+ function penalty(m, n) {
134
+ let p = 0;
135
+ 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; } }
136
+ 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;
137
+ 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;
138
+ return p;
139
+ }
140
+
141
+ /** Encode `text` en matrice QR (booléens true=module sombre). Niveau M, version auto ≤10. */
142
+ export function qrMatrix(text) {
143
+ const version = pickVersion(text);
144
+ const totalData = VERSIONS_M[version][0];
145
+ const dataCw = encodeData(text, version, totalData);
146
+ const finalCw = interleave(dataCw, version);
147
+ const { m, fn, n } = buildMatrix(version);
148
+ placeData(m, fn, n, finalCw);
149
+ let best = null, bestScore = Infinity, bestMask = 0;
150
+ 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; } }
151
+ return { matrix: best.map((row) => row.map((v) => v === 1)), size: n, version, mask: bestMask };
152
+ }
153
+
154
+ /** Rend `text` en SVG QR (scannable). `scale` px/module, `margin` modules de zone calme. */
155
+ export function qrSvg(text, { scale = 4, margin = 4, dark = '#000', light = '#fff' } = {}) {
156
+ const { matrix, size } = qrMatrix(text);
157
+ const dim = (size + margin * 2) * scale;
158
+ let rects = '';
159
+ 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}"/>`;
160
+ 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>`;
161
+ }
162
+
163
+ // Exposés pour les tests (vérification contre le vecteur de référence publié).
164
+ export const _internal = { rsEncode, encodeData, interleave, VERSIONS_M };
165
+ export default { qrMatrix, qrSvg };
package/llms.txt CHANGED
@@ -1,103 +1,20 @@
1
- # @mostajs/qrpanel — fiche LLM
2
- > Panneau QR code : générateur serveur PNG/SVG à 12 thèmes (image-as-frame, ECC=H), piloté par config (.qrconfig.json), sans chromium, + composant React <QrPanel> avec copier/partager/mailto.
3
-
4
- - Version: 0.5.0 · Licence: AGPL-3.0-or-later · Auteur: Dr Hamid MADANI <drmdh@msn.com>
5
- - Chemin: mostajs/mosta-qrpanel · Statut audit: complet (dist/)
6
-
7
- ## RÔLE
8
- Génère des QR codes pour apps Node/React. Trois briques complémentaires : (1) serveur
9
- PNG/SVG/DataURL thématiques rendus côté Node via @resvg/resvg-js (pas de chromium) ;
10
- (2) client `<QrPanel>` panneau d'invitation interactif (QR + lien + copier/partager) ;
11
- (3) `<QrImage>` génération locale navigateur, COEP-safe. Inclut un CLI `qrpanel`.
12
-
13
- ## INSTALLATION
14
- npm i @mostajs/qrpanel
15
- Dépendance directe : qrcode. Peers optionnelles : react (>=18 <20) pour ./client et
16
- ./qr-image ; @mostajs/auth + @resvg/resvg-js requis uniquement par ./server.
17
-
18
- ## EXPORTS
19
- - ./server : generateQrPng, generateQrSvg, generateQrDataUrl, buildInviteUrls + (ré-export) themes & config
20
- - ./client : QrPanel
21
- - ./qr-image : QrImage (default), useQrDataUrl
22
- - ./themes : THEMES, THEME_KEYS, listThemes, getTheme, pickRandomTheme, buildThemeFrameSvg
23
- - ./config : DEFAULT_CONFIG, loadQrConfig, ensureQrConfig, clearConfigCache
24
- - "." (barrel) : ré-exporte server + client + themes + config
25
- - bin `qrpanel` → CLI
26
-
27
- ## EXPORTS PAR SOUS-CHEMIN
28
- - "." → barrel complet (server + client + themes + config) — ⚠ tire le code natif serveur
29
- - "./server" → generateQr*, buildInviteUrls, themes, config (Node uniquement)
30
- - "./client" → QrPanel (React, qrSrc fourni par l'app)
31
- - "./qr-image" → QrImage, useQrDataUrl (React, génération locale)
32
- - "./themes" → registry des 12 thèmes natifs
33
- - "./config" → chargement/écriture de .qrconfig.json
34
-
35
- ## API — SIGNATURES
36
- - function generateQrPng(text: string, opts?: QrOptions): Promise<Buffer>
37
- - function generateQrSvg(text: string, opts?: QrOptions): Promise<string>
38
- - function generateQrDataUrl(text: string, opts?: QrOptions): Promise<string> // data:image/png;base64
39
- - function buildInviteUrls(opts: BuildInviteUrlsOptions): InviteUrls
40
- - function QrPanel(props: QrPanelProps): JSX.Element | null
41
- - function QrImage(props: QrImageProps): JSX.Element
42
- - function useQrDataUrl(data: string, opts?: QrDataUrlOptions): { src: string; loading: boolean; error: string|null }
43
- - function listThemes(): ThemeKey[]
44
- - function getTheme(key: ThemeKey): ThemeAsset
45
- - function pickRandomTheme(pool?: ThemeKey[]): ThemeKey
46
- - function buildThemeFrameSvg(theme: ThemeAsset, opts: BuildFrameOpts): string
47
- - function loadQrConfig(cwd?: string): QrConfig
48
- - function ensureQrConfig(cwd?: string, overrides?: Partial<QrConfigDefaults>): string
49
- - function clearConfigCache(): void
50
-
51
- ## TYPES CLÉS
52
- - QrOptions { width?=600; margin?=2; errorCorrectionLevel?='L'|'M'|'Q'|'H'; darkColor?='#0f172a';
53
- lightColor?='#ffffff'; genimage?: boolean; theme?: ThemeKey|'random'|'none'|{ svg; label? };
54
- themePool?: ThemeKey[]; framePadding?=0.13; centerWhiteRatio?=0.62; themeOpacity?=1; themeColor?='#1e293b' }
55
- - BuildInviteUrlsOptions { baseUrl: string; directPath: string; inviteSecret: string|Buffer;
56
- inviteId: string; ttlMs?; invitePath?='/invite'; inviteMeta? }
57
- - InviteUrls { directUrl: string; inviteUrl: string; inviteToken: string }
58
- - QrPanelMode { key; label; url; qrSrc: string; description? }
59
- - QrPanelProps { modes: QrPanelMode[]; initialModeIndex?=0; title?; mailSubject?; mailBodyTemplate?;
60
- mailTo?: string[]|string; mailBcc?: string[]|string; qrSize?=260; className? }
61
- - QrImageProps extends QrDataUrlOptions { data: string; alt?; style?; className? }
62
- - QrDataUrlOptions { size?=200; margin?=2; errorCorrectionLevel?='M'; darkColor?='#0f172a'; lightColor?='#ffffff' }
63
- - ThemeKey = 'baby'|'animals'|'science'|'physics'|'chemistry'|'math'|'nature'|'tech'|'space'|'music'|'book'|'health'
64
- - ThemeAsset { key: ThemeKey; label: string; motif: string }
65
- - QrConfig { default: QrConfigDefaults; customThemes?: Record<string,{svg;label?}> }
66
- - QrFormat = 'svg'|'png'|'dataUrl' ; QrEcc = 'L'|'M'|'Q'|'H'
67
-
68
- ## PATTERN
69
- ```ts
70
- // Serveur — QR thématique en route API (Node uniquement)
71
- import { generateQrPng } from '@mostajs/qrpanel/server';
72
- const png = await generateQrPng('https://app.example.com/x', { theme: 'tech', errorCorrectionLevel: 'H' });
73
-
74
- // Client — panneau d'invitation
75
- import { QrPanel } from '@mostajs/qrpanel/client';
76
- <QrPanel modes={[{ key: 'direct', label: 'Lien', url, qrSrc: '/api/qr?u=' + url }]} />
77
-
78
- // Génération locale navigateur (COEP-safe)
79
- import QrImage from '@mostajs/qrpanel/qr-image';
80
- <QrImage data="https://example.com" size={240} />
81
- ```
82
-
83
- ## DÉPEND DE
84
- - @mostajs/auth (peer optionnel, >=3.2.0) — requis par buildInviteUrls (signature HMAC du token)
85
- - @resvg/resvg-js (peer optionnel, >=2.6.2) — requis par generateQr* (rasterisation SVG → PNG)
86
- - react (peer optionnel, >=18 <20) — pour ./client et ./qr-image
87
-
88
- ## PIÈGES
89
- - Ne PAS importer le barrel "." ni "./server" dans un bundle navigateur : @resvg/resvg-js
90
- est un binaire natif Node. Côté client, utiliser exclusivement ./client ou ./qr-image.
91
- - `<QrPanel>` NE génère PAS le QR : l'app fournit `qrSrc` (endpoint API ou data URL).
92
- - `<QrPanel>` requiert Tailwind (style compilé par l'app) ; `<QrImage>` est style-neutre.
93
- - `genimage: false` court-circuite tout le pipeline thématique (QR pur, comportement legacy).
94
- - `theme: 'H'` (ECC=30%) recommandé pour les thèmes image-as-frame ; un ECC plus bas peut
95
- rendre le QR illisible avec un cadre.
96
- - `loadQrConfig` peut auto-créer `.qrconfig.json` à `process.cwd()` ; désactiver via
97
- `QRPANEL_AUTO_ENSURE=false`. Échec d'écriture → fallback transparent sur DEFAULT_CONFIG.
98
- - `mailBcc` recommandé pour les cohortes (les destinataires ne se voient pas) vs `mailTo`.
99
-
100
- ## RÉFÉRENCES
101
- - README.md
102
- - CHANGELOG.md
103
- - src/, dist/cli.js (CLI `npx qrpanel`)
1
+ # @mostajs/qrpanel
2
+ Encodeur QR pur JS (zéro-dép) + figures + payloads typés. Versions 1→10, niveau M, v≥7 avec INFO DE VERSION (sinon QR rejeté). Reed-Solomon GF(256), 8 masques.
3
+ ## exports (. = src/index.js)
4
+ qrMatrix(text) {matrix,size}. qrSvg(text,{scale=4,margin=4,dark,light}) <svg>. qrLive(text,caption?,{scale=6,margin=3,maxWidth=240,dark,light}) <figure> scannable + légende.
5
+ ## ./payloads (chaînes scan-and-act)
6
+ qrWifi({ssid,password,security=WPA,hidden}) · qrTel(num) · qrSms(num,body) · qrEmail(to,{subject,body}) · qrGeo(lat,lng) · qrUrl(u) · qrMeCard({name,tel,email,url,note}) · qrVCard({name,org,title,tel,email,url,adr,note}) · qrEvent({title,start,end,location,description}) [VEVENT]
7
+ ## ./figures
8
+ qrLogo(text,{logo,logoScale=.2,scale,margin,dark,light}) SVG + logo central (⚠ payload court). qrCard(text,{title,subtitle,logo}) · qrBadge(text,{name,role,event}) · qrSheet([{data,caption}],{cols=3})
9
+ ## ./png (Node, node:zlib)
10
+ qrPng(text,{scale=6,margin=4}) data:image/png;base64 (emails/billets le SVG est bloqué)
11
+ ## ./decode (Node, dépend de jsqr) DÉCODAGE (qrpanel encode ET décode, v0.6)
12
+ decodeQr(imageData|{data,width,height}[, w, h]) → string|null (texte du QR depuis une image RGBA)
13
+ ## ./browser-scan (asset navigateur) — scanner caméra prêt à servir (v0.6)
14
+ Fichier JS autonome à servir tel quel (<script src>), moteur jsQR. Expose window.jsQR ET
15
+ window.QRPanel.scan(videoEl, {onResult, onStatus, onError, continuous?, debounceMs?}) {stop()}.
16
+ L'hôte fournit SON élément <video> (getUserMedia explicite démarrage fiable sur geste
17
+ utilisateur) ; décodage jsQR à CHAQUE frame (BarcodeDetector en fast-path optionnel non bloquant).
18
+ Servir avec Cache-Control: no-cache (sinon un asset périmé désynchronise avec le HTML hôte).
19
+ Décodage Node séparé : `./decode` (decodeQr, jsqr).
20
+ ## limite : versions ≤10 (~ M v10). Garder payloads raisonnables.
package/package.json CHANGED
@@ -1,53 +1,32 @@
1
1
  {
2
2
  "name": "@mostajs/qrpanel",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "QR code panel — server-side PNG/SVG generator with 12 built-in themes (image-as-frame composite, ECC=H), config-driven (.qrconfig.json), cross-OS no chromium, + React <QrPanel> client with copy/share/mailto.",
5
5
  "author": "Dr Hamid MADANI <drmdh@msn.com>",
6
6
  "license": "AGPL-3.0-or-later",
7
7
  "type": "module",
8
- "main": "dist/index.js",
8
+ "main": "src/index.js",
9
9
  "types": "dist/index.d.ts",
10
10
  "bin": {
11
11
  "qrpanel": "dist/cli.js"
12
12
  },
13
13
  "exports": {
14
- ".": {
15
- "types": "./dist/index.d.ts",
16
- "import": "./dist/index.js",
17
- "default": "./dist/index.js"
18
- },
19
- "./server": {
20
- "types": "./dist/server.d.ts",
21
- "import": "./dist/server.js",
22
- "default": "./dist/server.js"
23
- },
24
- "./client": {
25
- "types": "./dist/client.d.ts",
26
- "import": "./dist/client.js",
27
- "default": "./dist/client.js"
28
- },
29
- "./qr-image": {
30
- "types": "./dist/qr-image.d.ts",
31
- "import": "./dist/qr-image.js",
32
- "default": "./dist/qr-image.js"
33
- },
34
- "./themes": {
35
- "types": "./dist/themes.d.ts",
36
- "import": "./dist/themes.js",
37
- "default": "./dist/themes.js"
38
- },
39
- "./config": {
40
- "types": "./dist/config.d.ts",
41
- "import": "./dist/config.js",
42
- "default": "./dist/config.js"
43
- }
14
+ ".": "./src/index.js",
15
+ "./qr-svg": "./src/qr-svg.js",
16
+ "./payloads": "./src/qr-payloads.js",
17
+ "./figures": "./src/qr-figures.js",
18
+ "./png": "./src/qr-png.js",
19
+ "./decode": "./src/qr-decode.js",
20
+ "./browser-scan": "./browser/qr-scan.js"
44
21
  },
45
22
  "files": [
46
23
  "dist",
24
+ "browser",
47
25
  "LICENSE",
48
26
  "README.md",
49
27
  "CHANGELOG.md",
50
- "llms.txt"
28
+ "llms.txt",
29
+ "src"
51
30
  ],
52
31
  "keywords": [
53
32
  "qrcode",
@@ -59,13 +38,9 @@
59
38
  "magic-link",
60
39
  "config-driven"
61
40
  ],
62
- "scripts": {
63
- "build": "tsc && chmod +x dist/cli.js 2>/dev/null || true",
64
- "dev": "tsc --watch",
65
- "prepublishOnly": "npm run build"
66
- },
67
41
  "dependencies": {
68
- "qrcode": "^1.5.4"
42
+ "qrcode": "^1.5.4",
43
+ "jsqr": "^1.4.0"
69
44
  },
70
45
  "peerDependencies": {
71
46
  "react": ">=18 <20",
@@ -73,9 +48,15 @@
73
48
  "@resvg/resvg-js": ">=2.6.2"
74
49
  },
75
50
  "peerDependenciesMeta": {
76
- "react": { "optional": true },
77
- "@mostajs/auth": { "optional": true },
78
- "@resvg/resvg-js": { "optional": true }
51
+ "react": {
52
+ "optional": true
53
+ },
54
+ "@mostajs/auth": {
55
+ "optional": true
56
+ },
57
+ "@resvg/resvg-js": {
58
+ "optional": true
59
+ }
79
60
  },
80
61
  "devDependencies": {
81
62
  "@types/node": "^22.0.0",
@@ -85,5 +66,9 @@
85
66
  "@resvg/resvg-js": "^2.6.2",
86
67
  "react": "^19.0.0",
87
68
  "typescript": "^5.6.0"
69
+ },
70
+ "scripts": {
71
+ "build": "tsc && chmod +x dist/cli.js 2>/dev/null || true",
72
+ "dev": "tsc --watch"
88
73
  }
89
- }
74
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ // @mostajs/qrpanel — CLI
3
+ // Author: Dr Hamid MADANI <drmdh@msn.com>
4
+ //
5
+ // Commandes disponibles :
6
+ // qrpanel init → écrit .qrconfig.json à cwd avec les defaults
7
+ // qrpanel themes → liste les 12 thèmes natifs
8
+ // qrpanel --version → version du package
9
+
10
+ import { ensureQrConfig, DEFAULT_CONFIG } from './config.js'
11
+ import { listThemes, THEMES } from './themes.js'
12
+
13
+ const args = process.argv.slice(2)
14
+ const cmd = args[0] ?? 'help'
15
+
16
+ function help() {
17
+ console.log(`@mostajs/qrpanel — CLI
18
+
19
+ Usage:
20
+ qrpanel init [path] Generate .qrconfig.json at cwd (or [path]).
21
+ Idempotent — does not overwrite existing config.
22
+ qrpanel themes List built-in theme keys.
23
+ qrpanel --version | -v Print version.
24
+ qrpanel help | -h This help.
25
+
26
+ Config file (.qrconfig.json) drives all generators. Master toggle:
27
+ "genimage": false → bypass theme pipeline, QR-only output.
28
+ "theme": "random" → randomize from themePool at each generation.
29
+ `)
30
+ }
31
+
32
+ async function main() {
33
+ switch (cmd) {
34
+ case 'init': {
35
+ const cwd = args[1] ?? process.cwd()
36
+ const path = ensureQrConfig(cwd)
37
+ console.log(`✓ qrpanel config ready at: ${path}`)
38
+ console.log(` Edit it to customize defaults — master toggle "genimage" toggles the whole pipeline.`)
39
+ break
40
+ }
41
+ case 'themes': {
42
+ console.log(`Built-in themes (${listThemes().length}):`)
43
+ for (const key of listThemes()) {
44
+ console.log(` - ${key.padEnd(10)} (${THEMES[key].label})`)
45
+ }
46
+ console.log(`\nDefaults config :`)
47
+ console.log(` themePool : ${DEFAULT_CONFIG.default.themePool.join(', ')}`)
48
+ break
49
+ }
50
+ case '--version':
51
+ case '-v': {
52
+ // Lecture programmatique du package.json sans import JSON (cross-runtime safe)
53
+ const { readFileSync } = await import('node:fs')
54
+ const { fileURLToPath } = await import('node:url')
55
+ const { dirname, join } = await import('node:path')
56
+ const here = dirname(fileURLToPath(import.meta.url))
57
+ const pkgPath = join(here, '..', 'package.json')
58
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version: string }
59
+ console.log(pkg.version)
60
+ break
61
+ }
62
+ case 'help':
63
+ case '--help':
64
+ case '-h':
65
+ default:
66
+ help()
67
+ break
68
+ }
69
+ }
70
+
71
+ main().catch((e) => {
72
+ console.error('[qrpanel] error:', (e as Error).message)
73
+ process.exit(1)
74
+ })
package/src/client.tsx ADDED
@@ -0,0 +1,184 @@
1
+ // @mostajs/qrpanel/client — React component
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ //
4
+ // Composant `<QrPanel>` agnostique de l'app. Affiche un QR + l'URL
5
+ // textuelle copiable + 3 actions (copier, ouvrir, mailto).
6
+ // Le composant ne génère pas le QR lui-même : l'app fournit le `qrSrc`
7
+ // (URL d'un endpoint API ou data URL inline).
8
+ //
9
+ // Style : Tailwind classes — l'app les compile dans son build (le
10
+ // component reste portable car les classes sont des chaînes).
11
+ //
12
+ // Note: marqué 'use client' pour Next.js App Router. Pour SPA (Vite,
13
+ // CRA, etc.), la directive est ignorée — le component fonctionne tel
14
+ // quel.
15
+
16
+ 'use client'
17
+ import { useState, useId } from 'react'
18
+
19
+ export interface QrPanelMode {
20
+ /** Identifiant interne du mode (libre — sera passé en argument à `qrSrc`). */
21
+ key: string
22
+ /** Libellé bouton. */
23
+ label: string
24
+ /** URL absolue à afficher / copier dans ce mode. */
25
+ url: string
26
+ /** URL de l'image QR pour ce mode (endpoint API ou data URL). */
27
+ qrSrc: string
28
+ /** Description courte affichée sous l'en-tête. */
29
+ description?: string
30
+ }
31
+
32
+ export interface QrPanelProps {
33
+ /** Liste des modes proposés (1 ou plusieurs). */
34
+ modes: QrPanelMode[]
35
+ /** Mode initial — index dans `modes`. Default 0. */
36
+ initialModeIndex?: number
37
+ /** Titre du panel. Default 'QR code & lien d\'invitation'. */
38
+ title?: string
39
+ /** Pré-remplissage `mailto`. Default 'Invitation'. */
40
+ mailSubject?: string
41
+ /** Pré-remplissage `mailto` body — `{url}` est remplacé par l'URL courante. */
42
+ mailBodyTemplate?: string
43
+ /**
44
+ * Destinataires placés dans `To:` du `mailto:` *(visibles entre eux)*.
45
+ * Liste d'emails (array ou string CSV). Combinable avec `mailBcc`.
46
+ */
47
+ mailTo?: string[] | string
48
+ /**
49
+ * Destinataires placés dans `Bcc:` *(invisibles entre eux — recommandé
50
+ * pour les listes de cohort où les participants ne doivent pas voir
51
+ * les adresses des autres)*. Liste d'emails (array ou string CSV).
52
+ */
53
+ mailBcc?: string[] | string
54
+ /** Taille de l'image QR. Default 260. */
55
+ qrSize?: number
56
+ /** Classe CSS racine optionnelle. */
57
+ className?: string
58
+ }
59
+
60
+ const DEFAULT_BODY = 'Bonjour,\n\nVoici ton lien personnel :\n\n{url}\n\n'
61
+
62
+ /** Accepte une string CSV ou un array, normalise en array d'emails non-vides. */
63
+ function normaliseRecipients(input: string[] | string | undefined): string[] {
64
+ if (!input) return []
65
+ const arr = Array.isArray(input) ? input : input.split(',')
66
+ return arr.map(s => s.trim()).filter(s => s.length > 0)
67
+ }
68
+
69
+ export function QrPanel({
70
+ modes, initialModeIndex = 0, title = 'QR code & lien d\'invitation',
71
+ mailSubject = 'Invitation', mailBodyTemplate = DEFAULT_BODY,
72
+ mailTo, mailBcc,
73
+ qrSize = 260, className = '',
74
+ }: QrPanelProps) {
75
+ const [idx, setIdx] = useState(Math.min(Math.max(0, initialModeIndex), modes.length - 1))
76
+ const [copied, setCopied] = useState(false)
77
+ const textareaId = useId()
78
+ const current = modes[idx] ?? modes[0]
79
+ if (!current) return null
80
+
81
+ const toList = normaliseRecipients(mailTo)
82
+ const bccList = normaliseRecipients(mailBcc)
83
+ const parts: string[] = []
84
+ parts.push(`subject=${encodeURIComponent(mailSubject)}`)
85
+ parts.push(`body=${encodeURIComponent(mailBodyTemplate.replace('{url}', current.url))}`)
86
+ if (bccList.length > 0) parts.push(`bcc=${encodeURIComponent(bccList.join(','))}`)
87
+ // `To:` est positionné juste après `mailto:` quand fourni (les
88
+ // destinataires To: sont rendus visibles entre eux par tous les clients).
89
+ // Les Bcc: passent par query param `&bcc=`.
90
+ const toPart = toList.length > 0 ? encodeURIComponent(toList.join(',')) : ''
91
+ const mailto = `mailto:${toPart}?${parts.join('&')}`
92
+ const recipientCount = toList.length + bccList.length
93
+
94
+ async function copy() {
95
+ try {
96
+ await navigator.clipboard.writeText(current.url)
97
+ setCopied(true)
98
+ setTimeout(() => setCopied(false), 2000)
99
+ } catch {
100
+ const el = document.getElementById(textareaId) as HTMLTextAreaElement | null
101
+ el?.select()
102
+ }
103
+ }
104
+
105
+ return (
106
+ <div className={'bg-white border border-slate-200 rounded-xl p-4 space-y-3 ' + className}>
107
+ <div className="flex items-center justify-between gap-2">
108
+ <h2 className="font-semibold text-slate-900">{title}</h2>
109
+ {modes.length > 1 && (
110
+ <div className="flex items-center gap-1 text-xs">
111
+ {modes.map((m, i) => (
112
+ <button
113
+ key={m.key} type="button" onClick={() => setIdx(i)}
114
+ className={'px-2 py-1 rounded ' + (i === idx ? 'bg-blue-600 text-white' : 'bg-slate-100 hover:bg-slate-200')}
115
+ >
116
+ {m.label}
117
+ </button>
118
+ ))}
119
+ </div>
120
+ )}
121
+ </div>
122
+
123
+ {current.description && (
124
+ <p className="text-xs text-slate-500">{current.description}</p>
125
+ )}
126
+
127
+ <div className="grid md:grid-cols-2 gap-4 items-start">
128
+ <div className="flex flex-col items-center gap-2">
129
+ <img
130
+ src={current.qrSrc}
131
+ alt={`QR ${current.label}`}
132
+ width={qrSize} height={qrSize}
133
+ className="border border-slate-200 rounded-lg"
134
+ />
135
+ <a
136
+ href={current.qrSrc}
137
+ download={`qr-${current.key}.png`}
138
+ className="px-3 py-1.5 rounded text-xs bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border border-emerald-200"
139
+ >
140
+ ⬇ Télécharger le PNG
141
+ </a>
142
+ </div>
143
+
144
+ <div className="space-y-2">
145
+ <label className="block">
146
+ <span className="block text-xs font-medium text-slate-700 mb-1">
147
+ Lien direct *(à coller dans un email)*
148
+ </span>
149
+ <textarea
150
+ id={textareaId}
151
+ readOnly
152
+ value={current.url}
153
+ rows={current.url.length > 60 ? 4 : 2}
154
+ onClick={(e) => (e.currentTarget as HTMLTextAreaElement).select()}
155
+ className="w-full px-2 py-1.5 border border-slate-300 rounded text-xs font-mono bg-slate-50 break-all"
156
+ />
157
+ </label>
158
+ <div className="flex items-center gap-2 flex-wrap">
159
+ <button
160
+ type="button" onClick={copy}
161
+ className="px-3 py-1.5 rounded text-xs bg-blue-600 text-white hover:bg-blue-700"
162
+ >
163
+ {copied ? '✓ Copié' : '📋 Copier le lien'}
164
+ </button>
165
+ <a
166
+ href={current.url}
167
+ target="_blank" rel="noreferrer"
168
+ className="px-3 py-1.5 rounded text-xs bg-slate-100 hover:bg-slate-200 text-slate-700"
169
+ >
170
+ ↗ Ouvrir
171
+ </a>
172
+ <a
173
+ href={mailto}
174
+ className="px-3 py-1.5 rounded text-xs bg-amber-50 hover:bg-amber-100 text-amber-700 border border-amber-200"
175
+ title={recipientCount > 0 ? `Ouvre le client mail avec ${recipientCount} destinataire(s) pré-remplis` : 'Ouvre le client mail avec sujet + corps pré-remplis'}
176
+ >
177
+ ✉ Pré-remplir un mail{recipientCount > 0 ? ` (${recipientCount})` : ''}
178
+ </a>
179
+ </div>
180
+ </div>
181
+ </div>
182
+ </div>
183
+ )
184
+ }