@dotrino/qr 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 seyacat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # @dotrino/qr
2
+
3
+ > **Parte del ecosistema [Dotrino](https://dotrino.com).** Dotrino es un ecosistema de aplicaciones centradas en la privacidad de los datos: tu información es tuya, y las decisiones sobre ella también — qué compartes, con quién, cuándo y por qué. Sin anuncios, sin cookies, sin rastreo de datos, sin vender tu identidad a nadie.
4
+
5
+ El **QR del ecosistema**, en un solo sitio: dos Web Components (Shadow DOM,
6
+ bilingües es/en) que cualquier app puede usar tal cual.
7
+
8
+ - **`<dotrino-qr>`** — muestra un QR.
9
+ - **`<dotrino-qr-scan>`** — lo lee, con la cámara o desde una foto.
10
+
11
+ Todo ocurre **en el dispositivo**: el QR se dibuja en el cliente y los fotogramas
12
+ de la cámara se decodifican en la propia página. Nada sale a ninguna red — lo que
13
+ va en un QR del ecosistema suele ser del usuario (un enlace con `#fragment`, un
14
+ código de emparejamiento), así que mandarlo a una API de imágenes ajena sería
15
+ justo lo contrario de lo que promete el ecosistema.
16
+
17
+ ## Instalación
18
+
19
+ ```bash
20
+ npm i @dotrino/qr
21
+ ```
22
+
23
+ ## Uso
24
+
25
+ ```js
26
+ import '@dotrino/qr'
27
+ ```
28
+
29
+ ### Mostrar
30
+
31
+ ```html
32
+ <dotrino-qr
33
+ value="https://messenger.dotrino.com/#add=K7M2Q9"
34
+ size="200"
35
+ caption="K7M2Q9"></dotrino-qr>
36
+ ```
37
+
38
+ | Atributo / propiedad | Qué hace |
39
+ |---|---|
40
+ | `value` | lo que se codifica. Sin valor no se dibuja nada. |
41
+ | `size` | lado en píxeles (por defecto 200). |
42
+ | `ecc` | corrección de errores `L`\|`M`\|`Q`\|`H` (por defecto `M`). |
43
+ | `caption` | texto monoespaciado bajo el QR (el código legible, normalmente). |
44
+
45
+ Los módulos van **siempre negro sobre blanco** con zona de silencio: un QR
46
+ tematizado con los colores de la app se vuelve ilegible para media cámara. Lo que
47
+ sí se tematiza es la tarjeta (`--dqr-pad`, `--dqr-radius`, `--dqr-border`,
48
+ `--dqr-caption-size`, `--dqr-caption-color`).
49
+
50
+ ### Leer
51
+
52
+ ```html
53
+ <dotrino-qr-scan lang="es"></dotrino-qr-scan>
54
+ ```
55
+
56
+ ```js
57
+ const scan = document.querySelector('dotrino-qr-scan')
58
+ scan.addEventListener('dotrino-qr-scanned', (e) => { console.log(e.detail.text); scan.open = false })
59
+ scan.addEventListener('dotrino-qr-cancelled', () => { scan.open = false })
60
+ scan.open = true // pide la cámara y abre el modal
61
+ ```
62
+
63
+ | Evento | Cuándo | `detail` |
64
+ |---|---|---|
65
+ | `dotrino-qr-scanned` | leyó un QR (cámara o foto) | `{ text }` |
66
+ | `dotrino-qr-cancelled` | el usuario cerró | — |
67
+ | `dotrino-qr-error` | no hubo cámara, o la foto no tenía QR | `{ code: 'camera' \| 'no-qr' }` |
68
+
69
+ Atributos: `open` (booleano) y `lang` (`es`\|`en`). Variables de tema: `--dqr-bg`,
70
+ `--dqr-text`, `--dqr-muted`, `--dqr-border`, `--dqr-accent`, `--dqr-overlay`,
71
+ `--dqr-radius`, `--dqr-shadow`, `--dqr-z`.
72
+
73
+ **La cámara se apaga sola** al acertar, al cerrar y al sacar el elemento del DOM.
74
+ Si el navegador no da cámara (permiso denegado, contexto no seguro, un ordenador
75
+ sin ella), el modal lo dice y deja el camino de la foto abierto en vez de quedarse
76
+ en negro.
77
+
78
+ ### Sin componentes
79
+
80
+ ```js
81
+ import { qrSvg } from '@dotrino/qr/svg' // string SVG, sin tocar el DOM
82
+ import { scanImageFile } from '@dotrino/qr' // File|Blob → texto | null
83
+ ```
84
+
85
+ ## Por qué existe
86
+
87
+ Estaba escrito dos veces —la consola de `dotrino-vault` y la app
88
+ `dotrino-qrreader`— y el messenger iba a ser la tercera. Un QR mal generado o una
89
+ cámara que se queda encendida es el mismo bug repetido en tres sitios, así que
90
+ vive aquí (regla del ecosistema: si falta algo, se extiende el paquete
91
+ compartido; no se copia dentro de la app).
92
+
93
+ **Deuda declarada:** `dotrino-vault/web` y `dotrino-qrreader` siguen con su copia
94
+ propia; migran a este paquete.
95
+
96
+ ## Dependencias
97
+
98
+ `qrcode-generator` (dibujar) y `jsqr` (leer). Ambas puras, sin red.
99
+
100
+ ## Licencia
101
+
102
+ MIT
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@dotrino/qr",
3
+ "version": "0.1.0",
4
+ "description": "UI compartida de Dotrino para códigos QR: <dotrino-qr> (mostrar) y <dotrino-qr-scan> (leer con la cámara o desde una foto). Todo en el cliente, sin servicios de terceros: lo que se codifica puede ser del usuario. Shadow DOM, sin cookies, bilingüe es/en.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "module": "src/index.js",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "import": "./src/index.js"
12
+ },
13
+ "./svg": {
14
+ "import": "./src/svg.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "src",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "sideEffects": [
23
+ "./src/index.js",
24
+ "./src/show.js",
25
+ "./src/scan.js"
26
+ ],
27
+ "dependencies": {
28
+ "jsqr": "1.4.0",
29
+ "qrcode-generator": "1.4.4"
30
+ },
31
+ "keywords": [
32
+ "dotrino",
33
+ "qr",
34
+ "scanner",
35
+ "web-component",
36
+ "camera"
37
+ ],
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "https://github.com/imdotrino/dotrino-qr.git"
42
+ },
43
+ "devDependencies": {
44
+ "typescript": "5.9.3"
45
+ },
46
+ "scripts": {
47
+ "type-check": "tsc --noEmit"
48
+ }
49
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ export declare function qrSvg (text: string, opts?: { ecc?: 'L' | 'M' | 'Q' | 'H', margin?: number }): string
2
+
3
+ export declare class DotrinoQr extends HTMLElement {
4
+ value: string
5
+ size: number
6
+ caption: string
7
+ }
8
+
9
+ export declare class DotrinoQrScan extends HTMLElement {
10
+ open: boolean
11
+ lang: 'es' | 'en'
12
+ }
13
+
14
+ export declare function scanImageFile (file: Blob): Promise<string | null>
package/src/index.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @dotrino/qr — el QR del ecosistema Dotrino, en un solo sitio.
3
+ *
4
+ * import '@dotrino/qr'
5
+ * <dotrino-qr value="..."></dotrino-qr>
6
+ * <dotrino-qr-scan open></dotrino-qr-scan>
7
+ *
8
+ * Se genera y se lee EN EL DISPOSITIVO: lo que va en un QR suele ser del usuario
9
+ * (un enlace con #fragment, un código de emparejamiento), así que no toca ninguna
10
+ * API de imágenes ajena ni sube un fotograma a ninguna parte.
11
+ */
12
+ export { qrSvg } from './svg.js'
13
+ export { DotrinoQr } from './show.js'
14
+ export { DotrinoQrScan, scanImageFile } from './scan.js'
package/src/scan.js ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * <dotrino-qr-scan> — lee un QR con la cámara, o desde una foto si no hay cámara
3
+ * (o el navegador no la da). La mitad "leer" del par; la otra es <dotrino-qr>.
4
+ *
5
+ * const el = document.querySelector('dotrino-qr-scan')
6
+ * el.lang = 'es'
7
+ * el.open = true
8
+ * el.addEventListener('dotrino-qr-scanned', (e) => console.log(e.detail.text))
9
+ * el.addEventListener('dotrino-qr-cancelled', () => { el.open = false })
10
+ *
11
+ * Todo pasa en el dispositivo: los fotogramas se decodifican aquí y no sale nada
12
+ * a ninguna red. La cámara se apaga al cerrar, al acertar y al salir del DOM —
13
+ * una cámara encendida que nadie mira es un fallo, no un detalle.
14
+ *
15
+ * Eventos: 'dotrino-qr-scanned' { text }, 'dotrino-qr-cancelled',
16
+ * 'dotrino-qr-error' { code: 'camera' | 'no-qr' }.
17
+ */
18
+ import jsQR from 'jsqr'
19
+
20
+ const I18N = {
21
+ es: {
22
+ heading: 'Escanear un QR',
23
+ hint: 'Apunta la cámara al código.',
24
+ photo: '🖼 Usar una foto',
25
+ cancel: 'Cancelar',
26
+ camErr: 'No se pudo abrir la cámara. Puedes usar una foto del código.',
27
+ noQr: 'En esa imagen no hay ningún QR.'
28
+ },
29
+ en: {
30
+ heading: 'Scan a QR',
31
+ hint: 'Point the camera at the code.',
32
+ photo: '🖼 Use a photo',
33
+ cancel: 'Cancel',
34
+ camErr: 'The camera could not be opened. You can use a photo of the code instead.',
35
+ noQr: 'There is no QR in that image.'
36
+ }
37
+ }
38
+
39
+ const CSS = `
40
+ :host { font-family: var(--dqr-font-ui, system-ui, -apple-system, Segoe UI, Roboto, sans-serif); }
41
+ .overlay {
42
+ position: fixed; inset: 0; z-index: var(--dqr-z, 1000);
43
+ background: var(--dqr-overlay, rgba(0,0,0,.7));
44
+ display: flex; align-items: center; justify-content: center; padding: 1rem;
45
+ }
46
+ .modal {
47
+ background: var(--dqr-bg, #fff); color: var(--dqr-text, #1b1b1b);
48
+ border-radius: var(--dqr-radius, 14px);
49
+ box-shadow: var(--dqr-shadow, 0 18px 48px rgba(0,0,0,.35));
50
+ width: min(420px, 100%); overflow: hidden;
51
+ }
52
+ h2 { margin: 0; padding: 14px 16px; font-size: 16px; border-bottom: 1px solid var(--dqr-border, rgba(0,0,0,.12)); }
53
+ .stage { position: relative; background: #000; aspect-ratio: 1 / 1; }
54
+ video { width: 100%; height: 100%; object-fit: cover; display: block; }
55
+ .frame {
56
+ position: absolute; inset: 12%;
57
+ border: 3px solid var(--dqr-accent, #c0392b); border-radius: 12px;
58
+ box-shadow: 0 0 0 100vmax rgba(0,0,0,.25);
59
+ pointer-events: none;
60
+ }
61
+ .body { padding: 12px 16px 16px; }
62
+ .hint { margin: 0 0 10px; font-size: 13px; color: var(--dqr-muted, #6b6b6b); }
63
+ .err { margin: 0 0 10px; font-size: 13px; color: var(--dqr-accent, #c0392b); }
64
+ .row { display: flex; gap: 8px; justify-content: space-between; }
65
+ button, .file {
66
+ font: inherit; font-size: 14px; cursor: pointer;
67
+ padding: 9px 14px; border-radius: 9px;
68
+ border: 1px solid var(--dqr-border, rgba(0,0,0,.12));
69
+ background: var(--dqr-btn-bg, transparent); color: inherit;
70
+ }
71
+ button:hover, .file:hover { border-color: var(--dqr-accent, #c0392b); }
72
+ input[type=file] { display: none; }
73
+ `
74
+
75
+ /** Decodifica un QR de un archivo de imagen. Devuelve el texto o null. */
76
+ export function scanImageFile (file) {
77
+ return new Promise((resolve) => {
78
+ const img = new Image()
79
+ const url = URL.createObjectURL(file)
80
+ img.onload = () => {
81
+ try {
82
+ const c = document.createElement('canvas')
83
+ c.width = img.naturalWidth; c.height = img.naturalHeight
84
+ const ctx = c.getContext('2d')
85
+ ctx.drawImage(img, 0, 0)
86
+ const d = ctx.getImageData(0, 0, c.width, c.height)
87
+ resolve(jsQR(d.data, d.width, d.height)?.data || null)
88
+ } catch (_) { resolve(null) }
89
+ URL.revokeObjectURL(url)
90
+ }
91
+ img.onerror = () => { URL.revokeObjectURL(url); resolve(null) }
92
+ img.src = url
93
+ })
94
+ }
95
+
96
+ export class DotrinoQrScan extends HTMLElement {
97
+ static get observedAttributes () { return ['open', 'lang'] }
98
+
99
+ #stream = null
100
+ #raf = null
101
+
102
+ constructor () {
103
+ super()
104
+ this.attachShadow({ mode: 'open' })
105
+ }
106
+
107
+ get open () { return this.hasAttribute('open') }
108
+ set open (v) { if (v) this.setAttribute('open', ''); else this.removeAttribute('open') }
109
+
110
+ get lang () { return this.getAttribute('lang') === 'en' ? 'en' : 'es' }
111
+ set lang (v) { this.setAttribute('lang', v === 'en' ? 'en' : 'es') }
112
+
113
+ attributeChangedCallback (name) {
114
+ this.#render()
115
+ if (name === 'open') { if (this.open) this.#start(); else this.#stop() }
116
+ }
117
+
118
+ connectedCallback () { this.#render(); if (this.open) this.#start() }
119
+ disconnectedCallback () { this.#stop() }
120
+
121
+ #render () {
122
+ const t = I18N[this.lang]
123
+ const root = this.shadowRoot
124
+ if (!this.open) { root.innerHTML = ''; return }
125
+ // Se reconstruye solo al abrir: mientras está cerrado no hay <video> vivo.
126
+ if (root.querySelector('.overlay')) {
127
+ root.querySelector('h2').textContent = t.heading
128
+ root.querySelector('.hint').textContent = t.hint
129
+ root.querySelector('.file').textContent = t.photo
130
+ root.querySelector('.cancel').textContent = t.cancel
131
+ return
132
+ }
133
+ root.innerHTML = `
134
+ <style>${CSS}</style>
135
+ <div class="overlay" part="overlay">
136
+ <div class="modal" part="modal">
137
+ <h2>${t.heading}</h2>
138
+ <div class="stage"><video playsinline muted autoplay></video><div class="frame"></div></div>
139
+ <div class="body">
140
+ <p class="hint">${t.hint}</p>
141
+ <p class="err" hidden></p>
142
+ <div class="row">
143
+ <label class="file">${t.photo}<input type="file" accept="image/*"></label>
144
+ <button class="cancel" type="button">${t.cancel}</button>
145
+ </div>
146
+ </div>
147
+ </div>
148
+ </div>`
149
+ root.querySelector('.cancel').addEventListener('click', () => {
150
+ this.#stop()
151
+ this.dispatchEvent(new CustomEvent('dotrino-qr-cancelled', { bubbles: true, composed: true }))
152
+ })
153
+ root.querySelector('input[type=file]').addEventListener('change', async (ev) => {
154
+ const f = ev.target.files?.[0]
155
+ ev.target.value = ''
156
+ if (!f) return
157
+ const text = await scanImageFile(f)
158
+ if (text) this.#hit(text)
159
+ else this.#fail('no-qr', I18N[this.lang].noQr)
160
+ })
161
+ }
162
+
163
+ #error (msg) {
164
+ const el = this.shadowRoot?.querySelector('.err')
165
+ if (!el) return
166
+ el.textContent = msg || ''
167
+ el.hidden = !msg
168
+ }
169
+
170
+ #fail (code, msg) {
171
+ this.#error(msg)
172
+ this.dispatchEvent(new CustomEvent('dotrino-qr-error', { detail: { code }, bubbles: true, composed: true }))
173
+ }
174
+
175
+ #hit (text) {
176
+ this.#stop()
177
+ this.dispatchEvent(new CustomEvent('dotrino-qr-scanned', { detail: { text }, bubbles: true, composed: true }))
178
+ }
179
+
180
+ async #start () {
181
+ const video = this.shadowRoot?.querySelector('video')
182
+ if (!video || this.#stream) return
183
+ const canvas = document.createElement('canvas')
184
+ const ctx = canvas.getContext('2d', { willReadFrequently: true })
185
+ const tick = () => {
186
+ if (!this.#stream) return
187
+ if (video.readyState >= 2 && video.videoWidth) {
188
+ canvas.width = video.videoWidth; canvas.height = video.videoHeight
189
+ try {
190
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
191
+ const d = ctx.getImageData(0, 0, canvas.width, canvas.height)
192
+ const r = jsQR(d.data, d.width, d.height)
193
+ if (r?.data) return this.#hit(r.data)
194
+ } catch (_) {}
195
+ }
196
+ this.#raf = requestAnimationFrame(tick)
197
+ }
198
+ try {
199
+ this.#stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
200
+ // Cerrar mientras se pedía permiso deja un stream huérfano: se apaga aquí.
201
+ if (!this.open) { this.#stop(); return }
202
+ video.srcObject = this.#stream
203
+ await video.play().catch(() => {})
204
+ this.#raf = requestAnimationFrame(tick)
205
+ } catch (e) {
206
+ this.#stream = null
207
+ console.warn('[dotrino-qr-scan] camera unavailable:', e?.name || e)
208
+ this.#fail('camera', I18N[this.lang].camErr)
209
+ }
210
+ }
211
+
212
+ #stop () {
213
+ if (this.#raf) { cancelAnimationFrame(this.#raf); this.#raf = null }
214
+ if (this.#stream) { this.#stream.getTracks().forEach((tr) => tr.stop()); this.#stream = null }
215
+ const video = this.shadowRoot?.querySelector('video')
216
+ if (video) video.srcObject = null
217
+ }
218
+ }
219
+
220
+ if (typeof customElements !== 'undefined' && !customElements.get('dotrino-qr-scan')) {
221
+ customElements.define('dotrino-qr-scan', DotrinoQrScan)
222
+ }
package/src/show.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * <dotrino-qr> — enseña un QR. La mitad "mostrar" del par; la otra es
3
+ * <dotrino-qr-scan>.
4
+ *
5
+ * <dotrino-qr value="https://messenger.dotrino.com/#add=K7M2Q9" size="200"></dotrino-qr>
6
+ *
7
+ * Atributos/propiedades: value, size (px), ecc ('L'|'M'|'Q'|'H'), caption.
8
+ * El QR se pinta SIEMPRE negro sobre blanco (ver svg.js): la tarjeta se tematiza
9
+ * con las variables --dqr-*, los módulos no.
10
+ */
11
+ import { qrSvg } from './svg.js'
12
+
13
+ const CSS = `
14
+ :host { display: inline-block; }
15
+ .card {
16
+ background: #fff;
17
+ padding: var(--dqr-pad, 10px);
18
+ border-radius: var(--dqr-radius, 10px);
19
+ border: 1px solid var(--dqr-border, rgba(0,0,0,.12));
20
+ line-height: 0;
21
+ }
22
+ .card svg { display: block; width: 100%; height: 100%; }
23
+ .caption {
24
+ margin-top: 8px;
25
+ font-family: var(--dqr-font, ui-monospace, SFMono-Regular, Menlo, monospace);
26
+ font-size: var(--dqr-caption-size, 20px);
27
+ letter-spacing: 3px;
28
+ text-align: center;
29
+ color: var(--dqr-caption-color, inherit);
30
+ line-height: 1.2;
31
+ }
32
+ `
33
+
34
+ export class DotrinoQr extends HTMLElement {
35
+ static get observedAttributes () { return ['value', 'size', 'ecc', 'caption'] }
36
+
37
+ constructor () {
38
+ super()
39
+ this.attachShadow({ mode: 'open' })
40
+ this.shadowRoot.innerHTML = `<style>${CSS}</style><div class="card"></div><div class="caption" hidden></div>`
41
+ }
42
+
43
+ connectedCallback () { this.#render() }
44
+ attributeChangedCallback () { this.#render() }
45
+
46
+ get value () { return this.getAttribute('value') || '' }
47
+ set value (v) { if (v == null) this.removeAttribute('value'); else this.setAttribute('value', v) }
48
+
49
+ get size () { return Number(this.getAttribute('size')) || 200 }
50
+ set size (v) { this.setAttribute('size', String(v)) }
51
+
52
+ get caption () { return this.getAttribute('caption') || '' }
53
+ set caption (v) { if (v == null) this.removeAttribute('caption'); else this.setAttribute('caption', v) }
54
+
55
+ #render () {
56
+ const root = this.shadowRoot
57
+ if (!root) return
58
+ const card = root.querySelector('.card')
59
+ const cap = root.querySelector('.caption')
60
+ const value = this.value
61
+ // Sin valor no se dibuja un QR vacío: un QR que no lleva a ninguna parte se
62
+ // escanea igual y deja al usuario mirando un error que no es suyo.
63
+ if (!value) { card.innerHTML = ''; card.hidden = true; cap.hidden = true; return }
64
+ card.hidden = false
65
+ const px = this.size
66
+ card.style.width = px + 'px'
67
+ card.style.height = px + 'px'
68
+ const ecc = (this.getAttribute('ecc') || 'M').toUpperCase()
69
+ try {
70
+ card.innerHTML = qrSvg(value, { ecc: ['L', 'M', 'Q', 'H'].includes(ecc) ? ecc : 'M' })
71
+ } catch (e) {
72
+ // Pasa cuando el texto no cabe ni en la versión 40. Es un fallo del llamador
73
+ // y se dice, no se pinta medio QR.
74
+ card.innerHTML = ''
75
+ console.error('[dotrino-qr] no se pudo generar el QR:', e?.message || e)
76
+ }
77
+ cap.textContent = this.caption
78
+ cap.hidden = !this.caption
79
+ }
80
+ }
81
+
82
+ if (typeof customElements !== 'undefined' && !customElements.get('dotrino-qr')) {
83
+ customElements.define('dotrino-qr', DotrinoQr)
84
+ }
package/src/svg.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * qrSvg — dibuja un QR como SVG en el cliente.
3
+ *
4
+ * Sin servicios de terceros a propósito: lo que se codifica suele ser del usuario
5
+ * (un enlace con #fragment, un código de emparejamiento) y mandarlo a una API de
6
+ * imágenes lo pondría en un servidor ajeno, que es justo lo que el ecosistema evita.
7
+ *
8
+ * Negro sobre blanco con zona de silencio, pase lo que pase con el tema de la app:
9
+ * un QR con poco contraste no lo lee ninguna cámara.
10
+ */
11
+ import qrcode from 'qrcode-generator'
12
+
13
+ // La librería trae dos codificadores de texto; el de UTF-8 es el que respeta los
14
+ // acentos y la eñe. Sin esto, un enlace con tilde se lee como basura.
15
+ try { qrcode.stringToBytes = qrcode.stringToBytesFuncs['UTF-8'] } catch (_) {}
16
+
17
+ /**
18
+ * @param {string} text lo que se codifica
19
+ * @param {object} [opts]
20
+ * @param {'L'|'M'|'Q'|'H'} [opts.ecc='M'] corrección de errores
21
+ * @param {number} [opts.margin=4] zona de silencio, en módulos
22
+ * @returns {string} SVG listo para inyectar
23
+ */
24
+ export function qrSvg (text, { ecc = 'M', margin = 4 } = {}) {
25
+ const qr = qrcode(0, ecc)
26
+ qr.addData(String(text ?? ''))
27
+ qr.make()
28
+ const n = qr.getModuleCount()
29
+ const dim = n + margin * 2
30
+ let rects = ''
31
+ for (let r = 0; r < n; r++) {
32
+ for (let c = 0; c < n; c++) {
33
+ if (qr.isDark(r, c)) rects += `<rect x="${c + margin}" y="${r + margin}" width="1" height="1"/>`
34
+ }
35
+ }
36
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${dim} ${dim}" ` +
37
+ 'shape-rendering="crispEdges" role="img" aria-label="QR">' +
38
+ `<rect width="${dim}" height="${dim}" fill="#fff"/><g fill="#000">${rects}</g></svg>`
39
+ }
40
+
41
+ export default qrSvg