@mostajs/qrpanel 0.4.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/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
+ }
@@ -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
+ }