@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/CHANGELOG.md +56 -0
- package/browser/qr-scan.js +10144 -0
- package/dist/qr-image.d.ts +32 -0
- package/dist/qr-image.d.ts.map +1 -0
- package/dist/qr-image.js +68 -0
- package/dist/qr-image.js.map +1 -0
- package/dist/qr-svg.js +165 -0
- package/llms.txt +20 -0
- package/package.json +34 -38
- package/src/cli.ts +74 -0
- package/src/client.tsx +184 -0
- package/src/composer.ts +164 -0
- package/src/config.ts +213 -0
- package/src/index.js +6 -0
- package/src/index.ts +16 -0
- package/src/qr-decode.js +23 -0
- package/src/qr-figures.js +46 -0
- package/src/qr-image.tsx +105 -0
- package/src/qr-payloads.js +59 -0
- package/src/qr-png.js +40 -0
- package/src/qr-svg.js +185 -0
- package/src/server.ts +212 -0
- package/src/themes.ts +266 -0
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
|
+
}
|
package/src/composer.ts
ADDED
|
@@ -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'
|
package/src/qr-decode.js
ADDED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
}
|