@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.
@@ -0,0 +1,164 @@
1
+ // @mostajs/qrpanel/composer — themed composite generator
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ //
4
+ // Compose un SVG global :
5
+ // 1. background blanc full canvas
6
+ // 2. cadre thématique (4 motifs aux coins) via themes.buildThemeFrameSvg
7
+ // 3. cartouche blanc central (rect)
8
+ // 4. QR centré dans le cartouche, ECC élevé recommandé
9
+ //
10
+ // Le PNG composite est obtenu en rasterisant le SVG via @resvg/resvg-js
11
+ // (rust prebuilt-binaries cross-OS, no chromium, no node-gyp).
12
+
13
+ import QRCode from 'qrcode'
14
+ import { Resvg } from '@resvg/resvg-js'
15
+ import {
16
+ type ThemeKey, type ThemeAsset, getTheme, pickRandomTheme,
17
+ buildThemeFrameSvg,
18
+ } from './themes.js'
19
+ import type { QrConfigDefaults } from './config.js'
20
+
21
+ export interface ComposeOptions {
22
+ width: number
23
+ errorCorrectionLevel: 'L' | 'M' | 'Q' | 'H'
24
+ darkColor: string
25
+ lightColor: string
26
+ theme: ThemeKey | 'random' | 'none' | { svg: string; label?: string }
27
+ themePool: ThemeKey[]
28
+ framePadding: number
29
+ centerWhiteRatio: number
30
+ themeOpacity: number
31
+ themeColor: string
32
+ }
33
+
34
+ /**
35
+ * Résout la prop `theme` en thème concret.
36
+ * - 'random' → tire dans themePool
37
+ * - 'none' → null (caller doit fallback sur QR pur)
38
+ * - { svg } → thème inline custom
39
+ * - clé → thème natif via registry
40
+ */
41
+ export function resolveTheme(
42
+ theme: ComposeOptions['theme'],
43
+ pool: ThemeKey[],
44
+ ): ThemeAsset | null {
45
+ if (theme === 'none') return null
46
+ if (typeof theme === 'object' && theme && 'svg' in theme) {
47
+ return { key: 'custom' as ThemeKey, label: theme.label ?? 'Custom', motif: theme.svg }
48
+ }
49
+ const key: ThemeKey = theme === 'random' ? pickRandomTheme(pool) : theme
50
+ return getTheme(key)
51
+ }
52
+
53
+ /**
54
+ * Génère le SVG composite (theme frame + center white card + QR).
55
+ * Si theme='none', retourne null — le caller doit fallback sur le QR pur.
56
+ */
57
+ export async function composeThemedSvg(
58
+ text: string,
59
+ opts: ComposeOptions,
60
+ ): Promise<string | null> {
61
+ const themeAsset = resolveTheme(opts.theme, opts.themePool)
62
+ if (!themeAsset) return null
63
+
64
+ const w = opts.width
65
+
66
+ // 1. QR SVG inline — margin=0, on lui donne sa propre marge via le cartouche
67
+ const qrRawSvg = await QRCode.toString(text, {
68
+ type: 'svg',
69
+ margin: 0,
70
+ errorCorrectionLevel: opts.errorCorrectionLevel,
71
+ color: { dark: opts.darkColor, light: opts.lightColor },
72
+ })
73
+
74
+ // 2. Extrait le viewBox et le contenu interne du QR SVG
75
+ const vbMatch = qrRawSvg.match(/viewBox="([^"]+)"/)
76
+ const qrViewBox = vbMatch?.[1] ?? '0 0 21 21'
77
+ const qrInner = qrRawSvg
78
+ .replace(/<\?xml[^>]*\?>\s*/, '')
79
+ .replace(/<svg[^>]*>/, '')
80
+ .replace(/<\/svg>\s*$/, '')
81
+
82
+ // 3. Géométrie : cartouche blanc central + QR à l'intérieur (avec
83
+ // une marge de respiration entre le QR et le bord du cartouche)
84
+ const centerSize = w * opts.centerWhiteRatio
85
+ const centerX = (w - centerSize) / 2
86
+ const qrInsideMargin = centerSize * 0.06 // 6% de la taille cartouche
87
+ const qrSize = centerSize - 2 * qrInsideMargin
88
+ const qrX = centerX + qrInsideMargin
89
+ const qrY = centerX + qrInsideMargin
90
+
91
+ // 4. Cadre thématique (4 coins) — scale et positions auto-calculés
92
+ // depuis centerWhiteRatio (zone marge entre cartouche et bord).
93
+ const frame = buildThemeFrameSvg(themeAsset, {
94
+ width: w,
95
+ color: opts.themeColor,
96
+ opacity: opts.themeOpacity,
97
+ framePadding: opts.framePadding,
98
+ centerWhiteRatio: opts.centerWhiteRatio,
99
+ })
100
+
101
+ // 5. SVG composite final
102
+ const svg = `<?xml version="1.0" encoding="UTF-8"?>
103
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${w}" width="${w}" height="${w}">
104
+ <rect width="${w}" height="${w}" fill="${opts.lightColor}"/>
105
+ ${frame}
106
+ <rect x="${centerX}" y="${centerX}" width="${centerSize}" height="${centerSize}" fill="${opts.lightColor}"/>
107
+ <svg x="${qrX}" y="${qrY}" width="${qrSize}" height="${qrSize}" viewBox="${qrViewBox}" shape-rendering="crispEdges">
108
+ ${qrInner}
109
+ </svg>
110
+ </svg>`
111
+
112
+ return svg
113
+ }
114
+
115
+ /**
116
+ * Génère le PNG composite via @resvg/resvg-js.
117
+ * Si theme='none', retourne null — le caller doit fallback.
118
+ */
119
+ export async function composeThemedPng(
120
+ text: string,
121
+ opts: ComposeOptions,
122
+ ): Promise<Buffer | null> {
123
+ const svg = await composeThemedSvg(text, opts)
124
+ if (!svg) return null
125
+
126
+ const resvg = new Resvg(svg, {
127
+ fitTo: { mode: 'width', value: opts.width },
128
+ background: opts.lightColor,
129
+ })
130
+ const rendered = resvg.render()
131
+ return Buffer.from(rendered.asPng())
132
+ }
133
+
134
+ /**
135
+ * Génère un Data URL PNG du composite.
136
+ * Si theme='none', retourne null — le caller doit fallback.
137
+ */
138
+ export async function composeThemedDataUrl(
139
+ text: string,
140
+ opts: ComposeOptions,
141
+ ): Promise<string | null> {
142
+ const png = await composeThemedPng(text, opts)
143
+ if (!png) return null
144
+ return `data:image/png;base64,${png.toString('base64')}`
145
+ }
146
+
147
+ /** Helper de merge config + opts pour les fonctions de génération. */
148
+ export function mergeComposeOpts(
149
+ cfg: QrConfigDefaults,
150
+ opts: Partial<ComposeOptions> = {},
151
+ ): ComposeOptions {
152
+ return {
153
+ width: opts.width ?? cfg.width,
154
+ errorCorrectionLevel: opts.errorCorrectionLevel ?? cfg.errorCorrectionLevel,
155
+ darkColor: opts.darkColor ?? cfg.darkColor,
156
+ lightColor: opts.lightColor ?? cfg.lightColor,
157
+ theme: opts.theme ?? cfg.theme,
158
+ themePool: opts.themePool ?? cfg.themePool,
159
+ framePadding: opts.framePadding ?? cfg.framePadding,
160
+ centerWhiteRatio: opts.centerWhiteRatio ?? cfg.centerWhiteRatio,
161
+ themeOpacity: opts.themeOpacity ?? cfg.themeOpacity,
162
+ themeColor: opts.themeColor ?? cfg.themeColor,
163
+ }
164
+ }
package/src/config.ts ADDED
@@ -0,0 +1,213 @@
1
+ // @mostajs/qrpanel/config — runtime config layer
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ //
4
+ // Cascade :
5
+ // 1. defaults compilés (ce fichier)
6
+ // 2. .qrconfig.json (ou .qrconfig.js / .qrconfig) à process.cwd()
7
+ // 3. options passées explicitement à l'appel generateQr* (highest)
8
+ //
9
+ // Cache : la lecture du fichier est cachée en mémoire avec invalidation
10
+ // par mtime — édite le fichier, le prochain appel le relit. Pas de
11
+ // watcher (overkill pour ce cas d'usage).
12
+
13
+ import { readFileSync, existsSync, statSync, writeFileSync } from 'node:fs'
14
+ import { join, resolve } from 'node:path'
15
+ import type { ThemeKey } from './themes.js'
16
+
17
+ // ─── Types ─────────────────────────────────────────────────────────
18
+
19
+ export type QrFormat = 'svg' | 'png' | 'dataUrl'
20
+ export type QrEcc = 'L' | 'M' | 'Q' | 'H'
21
+
22
+ /** Valeurs par défaut éditables dans .qrconfig.json. */
23
+ export interface QrConfigDefaults {
24
+ /** Master toggle — false = QR pur (legacy), bypass tout le pipeline thématique. */
25
+ genimage: boolean
26
+ /** Format préféré quand l'app n'en spécifie pas un. */
27
+ format: QrFormat
28
+ /** Largeur/hauteur du canvas SVG/PNG en pixels. */
29
+ width: number
30
+ /** Marge blanche autour du QR (en modules). */
31
+ margin: number
32
+ /** Niveau de correction d'erreur. 'H' recommandé pour composite (ECC=30%). */
33
+ errorCorrectionLevel: QrEcc
34
+ /** Couleur des modules sombres du QR. */
35
+ darkColor: string
36
+ /** Couleur du fond (cartouche central). */
37
+ lightColor: string
38
+ /** 'random' = tirage dans themePool, ou clé thème, ou 'none' (= image off ponctuel). */
39
+ theme: ThemeKey | 'random' | 'none'
40
+ /** Sous-set des thèmes utilisés quand theme='random'. */
41
+ themePool: ThemeKey[]
42
+ /**
43
+ * Position du motif dans la zone-marge (0..1) :
44
+ * 0 = collé au bord
45
+ * 0.5 = centre de la marge (optimal — default)
46
+ * 1 = collé contre le cartouche
47
+ */
48
+ framePadding: number
49
+ /** Taille du cartouche blanc central (proportion du canvas, 0..1). */
50
+ centerWhiteRatio: number
51
+ /** Opacité du cadre image (0..1). */
52
+ themeOpacity: number
53
+ /** Couleur monochrome du cadre image (CSS color). */
54
+ themeColor: string
55
+ }
56
+
57
+ export interface QrConfig {
58
+ default: QrConfigDefaults
59
+ /** Thèmes custom — clé arbitraire, override ou ajout. */
60
+ customThemes?: Record<string, { svg: string; label?: string }>
61
+ }
62
+
63
+ // ─── Defaults ──────────────────────────────────────────────────────
64
+
65
+ export const DEFAULT_CONFIG: QrConfig = {
66
+ default: {
67
+ genimage: true,
68
+ format: 'svg',
69
+ width: 600,
70
+ margin: 2,
71
+ errorCorrectionLevel: 'H',
72
+ darkColor: '#0f172a',
73
+ lightColor: '#ffffff',
74
+ theme: 'random',
75
+ themePool: [
76
+ 'baby', 'animals', 'science', 'physics', 'chemistry', 'math',
77
+ 'nature', 'tech', 'space', 'music', 'book', 'health',
78
+ ],
79
+ framePadding: 0.5,
80
+ centerWhiteRatio: 0.62,
81
+ themeOpacity: 1.0,
82
+ themeColor: '#1e293b',
83
+ },
84
+ }
85
+
86
+ // ─── Lookup file paths ─────────────────────────────────────────────
87
+
88
+ const CONFIG_FILES = ['.qrconfig.json', '.qrconfig.js', '.qrconfig'] as const
89
+
90
+ /** Cherche le fichier config existant à `cwd`, retourne le path absolu ou null. */
91
+ function findConfigFile(cwd: string): string | null {
92
+ for (const name of CONFIG_FILES) {
93
+ const p = join(cwd, name)
94
+ if (existsSync(p)) return p
95
+ }
96
+ return null
97
+ }
98
+
99
+ // ─── Cache ─────────────────────────────────────────────────────────
100
+
101
+ interface CacheEntry {
102
+ mtimeMs: number
103
+ config: QrConfig
104
+ }
105
+ const _cache = new Map<string, CacheEntry>()
106
+
107
+ // ─── Readers ───────────────────────────────────────────────────────
108
+
109
+ /**
110
+ * Décide si l'auto-création silencieuse est activée.
111
+ * Désactivable via `process.env.QRPANEL_AUTO_ENSURE=false` ou `=0` ou `=off`.
112
+ * Activée par défaut (depuis 0.3.2).
113
+ */
114
+ function isAutoEnsureEnabled(): boolean {
115
+ const v = process.env.QRPANEL_AUTO_ENSURE
116
+ if (v == null) return true
117
+ const lower = String(v).toLowerCase().trim()
118
+ return !(lower === 'false' || lower === '0' || lower === 'off' || lower === 'no')
119
+ }
120
+
121
+ // Mémoire pour ne pas re-tenter writeFileSync à chaque appel sur un FS readonly
122
+ const _autoEnsureFailedFor = new Set<string>()
123
+
124
+ /**
125
+ * Lit la config depuis `cwd` (default `process.cwd()`).
126
+ *
127
+ * Comportement (depuis 0.3.2) :
128
+ * - Si `.qrconfig.json/.js/.qrconfig` trouvé → lecture + cache mtime.
129
+ * - Si absent ET auto-ensure activé → tente writeFileSync silencieux.
130
+ * - Si writeFileSync échoue (readonly fs, perm denied) → fallback transparent
131
+ * sur DEFAULT_CONFIG, l'échec est mémorisé pour ne pas retenter.
132
+ *
133
+ * Désactiver l'auto-ensure : `QRPANEL_AUTO_ENSURE=false`.
134
+ */
135
+ export function loadQrConfig(cwd: string = process.cwd()): QrConfig {
136
+ let path = findConfigFile(cwd)
137
+
138
+ // Auto-création (0.3.2+) : si pas de fichier et pas déjà échoué une fois ici
139
+ if (!path && isAutoEnsureEnabled() && !_autoEnsureFailedFor.has(cwd)) {
140
+ try {
141
+ path = ensureQrConfig(cwd)
142
+ } catch {
143
+ _autoEnsureFailedFor.add(cwd) // FS readonly / perm denied → on retombe sur defaults sans bruit
144
+ }
145
+ }
146
+
147
+ if (!path) return DEFAULT_CONFIG
148
+
149
+ const stat = statSync(path)
150
+ const cached = _cache.get(path)
151
+ if (cached && cached.mtimeMs === stat.mtimeMs) {
152
+ return cached.config
153
+ }
154
+
155
+ let parsed: Partial<QrConfig>
156
+ try {
157
+ if (path.endsWith('.js')) {
158
+ // require() interop pour .js — sync, pas d'import dynamique
159
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
160
+ const mod = (require)(path)
161
+ parsed = (mod.default ?? mod) as Partial<QrConfig>
162
+ } else {
163
+ const raw = readFileSync(path, 'utf-8')
164
+ parsed = JSON.parse(raw) as Partial<QrConfig>
165
+ }
166
+ } catch (e) {
167
+ throw new Error(`[qrpanel] failed to parse ${path}: ${(e as Error).message}`)
168
+ }
169
+
170
+ const merged: QrConfig = {
171
+ default: { ...DEFAULT_CONFIG.default, ...(parsed.default ?? {}) },
172
+ customThemes: parsed.customThemes,
173
+ }
174
+
175
+ _cache.set(path, { mtimeMs: stat.mtimeMs, config: merged })
176
+ return merged
177
+ }
178
+
179
+ /**
180
+ * Crée `.qrconfig.json` à `cwd` s'il n'existe pas. Idempotent.
181
+ * Retourne le path écrit (ou path existant si déjà là).
182
+ *
183
+ * @param overrides — valeurs par défaut à patcher dans le fichier généré.
184
+ */
185
+ export function ensureQrConfig(
186
+ cwd: string = process.cwd(),
187
+ overrides: Partial<QrConfigDefaults> = {},
188
+ ): string {
189
+ const existing = findConfigFile(cwd)
190
+ if (existing) return existing
191
+
192
+ const path = join(resolve(cwd), '.qrconfig.json')
193
+ const config: QrConfig = {
194
+ default: { ...DEFAULT_CONFIG.default, ...overrides },
195
+ }
196
+
197
+ const header = '// @mostajs/qrpanel — édite ce fichier pour piloter la génération QR.\n'
198
+ + '// Le master toggle "genimage": false bypass tout le pipeline thématique.\n'
199
+ + '// "theme": "random" tire dans themePool ; figer un thème = "theme": "science" par exemple.\n'
200
+ const body = JSON.stringify(config, null, 2) + '\n'
201
+
202
+ // JSON pur (pas de commentaire dans .qrconfig.json) — header en commentaire
203
+ // de fichier .json est invalide, on l'ignore. On garde juste le JSON.
204
+ void header
205
+
206
+ writeFileSync(path, body, { mode: 0o644 })
207
+ return path
208
+ }
209
+
210
+ /** Vide le cache mémoire (utile pour les tests). */
211
+ export function clearConfigCache(): void {
212
+ _cache.clear()
213
+ }
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ // @mostajs/qrpanel — point d'entrée. Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ // Encodeur QR pur JS (versions 1→10, niveau M, v≥7 avec info de version) + figures + payloads typés.
3
+ // NB : qrPng (sortie PNG) est sous le sous-chemin ./png (dépend de node:zlib) → import séparé pour rester browser-safe ici.
4
+ export * from './qr-svg.js'; // qrMatrix, qrSvg, qrLive
5
+ export * from './qr-payloads.js'; // qrWifi, qrTel, qrSms, qrEmail, qrGeo, qrUrl, qrMeCard, qrVCard, qrEvent
6
+ export * from './qr-figures.js'; // qrLogo, qrCard, qrBadge, qrSheet
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ // @mostajs/qrpanel — barrel
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ //
4
+ // Re-export complet pour les consumers qui ne séparent pas client/server.
5
+ // Préfère `@mostajs/qrpanel/server` ou `@mostajs/qrpanel/client` pour
6
+ // éviter d'inclure React côté serveur ou qrcode côté client.
7
+
8
+ export * from './server.js'
9
+ export * from './client.js'
10
+ export {
11
+ type ThemeKey, type ThemeAsset, THEME_KEYS, listThemes, getTheme,
12
+ } from './themes.js'
13
+ export {
14
+ type QrConfig, type QrConfigDefaults, type QrFormat, type QrEcc,
15
+ DEFAULT_CONFIG, loadQrConfig, ensureQrConfig, clearConfigCache,
16
+ } from './config.js'
@@ -0,0 +1,23 @@
1
+ // @mostajs/qrpanel — DÉCODEUR QR Node (moteur jsQR). Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ // Décodage côté Node/serveur (tests, traitement d'images). Pour la CAMÉRA navigateur,
3
+ // utiliser l'asset `@mostajs/qrpanel/browser-scan` (window.QRPanel.scan, moteur html5-qrcode).
4
+ // Sous-chemin dédié `@mostajs/qrpanel/decode` (dépend de `jsqr`) pour garder le point
5
+ // d'entrée principal browser-safe et sans dépendance (comme ./png pour node:zlib).
6
+ import jsQR from 'jsqr';
7
+
8
+ /**
9
+ * Décode une image RGBA en texte de QR.
10
+ * @param {{data:Uint8ClampedArray,width:number,height:number}|Uint8ClampedArray} image
11
+ * ImageData (ou tableau RGBA brut, alors fournir width/height).
12
+ * @param {number} [width] largeur si `image` est un tableau brut.
13
+ * @param {number} [height] hauteur si `image` est un tableau brut.
14
+ * @returns {string|null} texte encodé, ou null si aucun QR lisible.
15
+ */
16
+ export function decodeQr(image, width, height) {
17
+ const data = image && image.data ? image.data : image;
18
+ const w = image && image.width != null ? image.width : width;
19
+ const h = image && image.height != null ? image.height : height;
20
+ if (!data || !w || !h) return null;
21
+ const res = jsQR(data, w, h);
22
+ return res ? res.data : null;
23
+ }
@@ -0,0 +1,46 @@
1
+ // @mostajs/qrpanel — figures composées : logo central, carte/badge imprimable, planche. Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ import { qrSvg, qrLive } from './qr-svg.js';
3
+
4
+ const _esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
5
+
6
+ /**
7
+ * QR avec LOGO au centre (branding). ⚠️ occulte le centre → garder un payload COURT (basse version) et un logo PETIT.
8
+ * qrLogo(text, { logo (URL/dataURL), logoScale=0.2, scale=6, margin=4, dark, light })
9
+ */
10
+ export function qrLogo(text, { logo = '', logoScale = 0.2, scale = 6, margin = 4, dark, light } = {}) {
11
+ const svg = qrSvg(text, { scale, margin, ...(dark ? { dark } : {}), ...(light ? { light } : {}) });
12
+ const dim = Number((svg.match(/width="(\d+)"/) || [])[1]) || 0;
13
+ const ls = Math.round(dim * logoScale), pad = Math.max(2, Math.round(ls * 0.12));
14
+ const x = Math.round((dim - ls) / 2);
15
+ const overlay = `<rect x="${x - pad}" y="${x - pad}" width="${ls + 2 * pad}" height="${ls + 2 * pad}" rx="${pad}" fill="${light || '#fff'}"/>` +
16
+ (logo ? `<image href="${_esc(logo)}" x="${x}" y="${x}" width="${ls}" height="${ls}" preserveAspectRatio="xMidYMid meet"/>` : '');
17
+ return svg.replace('</svg>', overlay + '</svg>');
18
+ }
19
+
20
+ /** Carte imprimable : QR + titre + sous-titre (billet, lien public). */
21
+ export function qrCard(text, { title = '', subtitle = '', logo = '', scale = 6, margin = 3, maxWidth = 240 } = {}) {
22
+ const qr = logo ? qrLogo(text, { logo, scale, margin }) : qrSvg(text, { scale, margin });
23
+ return `<div style="display:inline-block;border:1px solid #dfe3ea;border-radius:12px;padding:14px;text-align:center;font-family:system-ui,sans-serif;background:#fff">
24
+ ${title ? `<div style="font-weight:700;font-size:15px;color:#13294b;margin-bottom:6px">${_esc(title)}</div>` : ''}
25
+ <div style="max-width:${maxWidth}px;margin:auto">${qr}</div>
26
+ ${subtitle ? `<div style="font-size:12px;color:#667085;margin-top:6px">${_esc(subtitle)}</div>` : ''}
27
+ </div>`;
28
+ }
29
+
30
+ /** Badge événement : bandeau événement + nom/rôle + QR (contrôle d'accès). */
31
+ export function qrBadge(text, { name = '', role = '', event = '', scale = 5, margin = 3 } = {}) {
32
+ return `<div style="display:inline-block;width:260px;border:1px solid #cfd5df;border-radius:14px;overflow:hidden;font-family:system-ui,sans-serif;background:#fff;text-align:center">
33
+ ${event ? `<div style="background:#13294b;color:#fff;padding:8px;font-weight:700;font-size:13px">${_esc(event)}</div>` : ''}
34
+ <div style="padding:12px">
35
+ <div style="font-size:18px;font-weight:800;color:#13294b">${_esc(name)}</div>
36
+ ${role ? `<div style="font-size:12px;color:#667085;margin-bottom:8px">${_esc(role)}</div>` : ''}
37
+ <div style="width:208px;margin:0 auto;line-height:0">${qrSvg(text, { scale, margin }).replace('<svg ', '<svg style="display:block;width:100%;height:auto" ')}</div>
38
+ </div>
39
+ </div>`;
40
+ }
41
+
42
+ /** Planche de QR (impression en lot) : items = [{ data, caption? }]. */
43
+ export function qrSheet(items = [], { cols = 3, scale = 4, margin = 2, maxWidth = 200 } = {}) {
44
+ const cells = (items || []).map((it) => `<div style="break-inside:avoid;padding:8px">${qrLive(it.data, it.caption || '', { scale, margin, maxWidth })}</div>`).join('');
45
+ return `<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:10px;font-family:system-ui,sans-serif">${cells}</div>`;
46
+ }
@@ -0,0 +1,105 @@
1
+ // @mostajs/qrpanel/qr-image — composant QR généré côté navigateur
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ //
4
+ // QR code généré 100% dans le navigateur (data URL via `qrcode`), sans
5
+ // appel réseau ni round-trip serveur. Complète les deux briques
6
+ // existantes de qrpanel :
7
+ // - `<QrPanel>` (./client) attend un `qrSrc` déjà fourni par l'app.
8
+ // - `generateQr*` (./server) génère côté Node uniquement (@resvg natif).
9
+ // `<QrImage>` couvre le cas manquant : génération locale, côté client.
10
+ //
11
+ // Cas d'usage clé — COEP `require-corp` : sous cross-origin isolation
12
+ // (requise par ex. par ffmpeg.wasm / SharedArrayBuffer), une image QR
13
+ // servie par un service externe est bloquée par la politique. La
14
+ // génération locale est alors la seule option côté client.
15
+ //
16
+ // Sub-export dédié (`@mostajs/qrpanel/qr-image`) : `qrcode` n'est
17
+ // embarqué dans le bundle client que pour les consumers qui l'importent.
18
+
19
+ 'use client'
20
+
21
+ import { useEffect, useState } from 'react'
22
+ import QRCode from 'qrcode'
23
+
24
+ export interface QrDataUrlOptions {
25
+ /** Côté de l'image en pixels. Défaut 200. */
26
+ size?: number
27
+ /** Marge blanche autour du QR (en modules). Défaut 2. */
28
+ margin?: number
29
+ /** Correction d'erreur : L 7 % | M 15 % | Q 25 % | H 30 %. Défaut 'M'. */
30
+ errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H'
31
+ /** Couleur des modules sombres. Défaut '#0f172a'. */
32
+ darkColor?: string
33
+ /** Couleur du fond. Défaut '#ffffff'. */
34
+ lightColor?: string
35
+ }
36
+
37
+ export interface UseQrDataUrlResult {
38
+ /** Data URL `data:image/png;base64,…` du QR, ou '' tant que non prêt. */
39
+ src: string
40
+ /** True pendant la génération. */
41
+ loading: boolean
42
+ /** Message d'erreur si la génération a échoué, sinon null. */
43
+ error: string | null
44
+ }
45
+
46
+ /** Hook : génère le data URL d'un QR localement (navigateur). */
47
+ export function useQrDataUrl(data: string, opts: QrDataUrlOptions = {}): UseQrDataUrlResult {
48
+ const {
49
+ size = 200, margin = 2, errorCorrectionLevel = 'M',
50
+ darkColor = '#0f172a', lightColor = '#ffffff',
51
+ } = opts
52
+ const [src, setSrc] = useState('')
53
+ const [loading, setLoading] = useState(true)
54
+ const [error, setError] = useState<string | null>(null)
55
+
56
+ useEffect(() => {
57
+ let alive = true
58
+ setLoading(true); setError(null); setSrc('')
59
+ QRCode.toDataURL(data, {
60
+ width: size, margin, errorCorrectionLevel,
61
+ color: { dark: darkColor, light: lightColor },
62
+ })
63
+ .then((url) => { if (alive) { setSrc(url); setLoading(false) } })
64
+ .catch((e) => {
65
+ if (alive) { setError(e?.message || 'QR generation failed'); setLoading(false) }
66
+ })
67
+ return () => { alive = false }
68
+ }, [data, size, margin, errorCorrectionLevel, darkColor, lightColor])
69
+
70
+ return { src, loading, error }
71
+ }
72
+
73
+ export interface QrImageProps extends QrDataUrlOptions {
74
+ /** Donnée encodée dans le QR (URL, texte…). */
75
+ data: string
76
+ alt?: string
77
+ style?: React.CSSProperties
78
+ className?: string
79
+ }
80
+
81
+ /** QR code généré localement et rendu en `<img>` data-URL (COEP-safe). */
82
+ export default function QrImage({
83
+ data, alt = 'QR code', style, className, ...opts
84
+ }: QrImageProps) {
85
+ const size = opts.size ?? 200
86
+ const { src, loading, error } = useQrDataUrl(data, opts)
87
+
88
+ const box: React.CSSProperties = {
89
+ width: size, height: size, display: 'flex',
90
+ alignItems: 'center', justifyContent: 'center',
91
+ background: '#fff', borderRadius: 6, border: '1px solid #e2e8f0',
92
+ fontSize: 11, color: '#94a3b8', textAlign: 'center',
93
+ ...style,
94
+ }
95
+
96
+ if (error) return <div style={box} className={className}>QR indisponible</div>
97
+ if (loading || !src) return <div style={box} className={className} aria-busy="true">…</div>
98
+ return (
99
+ <img
100
+ src={src} alt={alt} width={size} height={size}
101
+ style={{ background: '#fff', borderRadius: 6, display: 'block', ...style }}
102
+ className={className}
103
+ />
104
+ )
105
+ }
@@ -0,0 +1,59 @@
1
+ // @mostajs/qrpanel — formats de charge utile QR « scan-and-act ». Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ // Chaque helper renvoie la CHAÎNE normalisée à encoder (via qrSvg / qrLive / qrPng). Pur, zéro dépendance.
3
+
4
+ const escSep = (s) => String(s ?? '').replace(/([\\;,:"])/g, '\\$1'); // échappe les séparateurs des formats compacts
5
+
6
+ /** WiFi : scan = rejoindre le réseau. security ∈ WPA|WEP|nopass. */
7
+ export function qrWifi({ ssid, password = '', security = 'WPA', hidden = false } = {}) {
8
+ return `WIFI:T:${security};S:${escSep(ssid)};P:${escSep(password)};${hidden ? 'H:true;' : ''};`;
9
+ }
10
+ /** Téléphone : tel:+213… */
11
+ export const qrTel = (num) => `tel:${String(num ?? '').replace(/\s+/g, '')}`;
12
+ /** SMS pré-rempli : SMSTO:num:message */
13
+ export const qrSms = (num, body = '') => `SMSTO:${String(num ?? '').replace(/\s+/g, '')}:${body}`;
14
+ /** Email pré-rempli : mailto:adresse?subject=&body= */
15
+ export function qrEmail(to, { subject = '', body = '' } = {}) {
16
+ const q = []; if (subject) q.push('subject=' + encodeURIComponent(subject)); if (body) q.push('body=' + encodeURIComponent(body));
17
+ return `mailto:${to ?? ''}${q.length ? '?' + q.join('&') : ''}`;
18
+ }
19
+ /** Géolocalisation : geo:lat,lng (lieu d'un salon/atelier). */
20
+ export const qrGeo = (lat, lng) => `geo:${lat},${lng}`;
21
+ /** URL (normalise : ajoute https:// si absent). */
22
+ export const qrUrl = (u) => (/^[a-z]+:\/\//i.test(u) ? u : `https://${u}`);
23
+
24
+ /** MeCard (compact, très lisible par les scanners) : carte de contact. */
25
+ export function qrMeCard({ name, tel, email, url, note } = {}) {
26
+ let o = 'MECARD:';
27
+ if (name) o += `N:${escSep(name)};`;
28
+ if (tel) o += `TEL:${escSep(tel)};`;
29
+ if (email) o += `EMAIL:${escSep(email)};`;
30
+ if (url) o += `URL:${escSep(url)};`;
31
+ if (note) o += `NOTE:${escSep(note)};`;
32
+ return o + ';';
33
+ }
34
+ /** vCard 3.0 (riche) : carte de contact d'un sponsor/annonceur. */
35
+ export function qrVCard({ name, org, title, tel, email, url, adr, note } = {}) {
36
+ const L = ['BEGIN:VCARD', 'VERSION:3.0'];
37
+ if (name) L.push(`FN:${name}`, `N:${name};;;;`);
38
+ if (org) L.push(`ORG:${org}`);
39
+ if (title) L.push(`TITLE:${title}`);
40
+ if (tel) L.push(`TEL:${tel}`);
41
+ if (email) L.push(`EMAIL:${email}`);
42
+ if (url) L.push(`URL:${url}`);
43
+ if (adr) L.push(`ADR:;;${adr};;;;`);
44
+ if (note) L.push(`NOTE:${note}`);
45
+ L.push('END:VCARD');
46
+ return L.join('\n');
47
+ }
48
+ /** Événement calendrier (VEVENT) : « ajouter ce live/atelier à mon agenda ». start/end = Date | ISO. */
49
+ export function qrEvent({ title, start, end, location = '', description = '' } = {}) {
50
+ const fmt = (d) => (d instanceof Date ? d : new Date(d)).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
51
+ const L = ['BEGIN:VEVENT'];
52
+ if (title) L.push(`SUMMARY:${title}`);
53
+ if (start) L.push(`DTSTART:${fmt(start)}`);
54
+ if (end) L.push(`DTEND:${fmt(end)}`);
55
+ if (location) L.push(`LOCATION:${location}`);
56
+ if (description) L.push(`DESCRIPTION:${description}`);
57
+ L.push('END:VEVENT');
58
+ return L.join('\n');
59
+ }