@sythos/js_barcode_universal 1.2.5 → 1.3.1

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.
Files changed (53) hide show
  1. package/LICENSE +8 -1
  2. package/NOTICE.md +3 -0
  3. package/README.md +548 -463
  4. package/bundle/sythos-barcode.esm.js +2377 -3
  5. package/bundle/sythos-barcode.js +2365 -3
  6. package/examples/create.html +1009 -730
  7. package/licenses/README.md +35 -58
  8. package/licenses/aztec-code.license +15 -13
  9. package/licenses/codabar.license +18 -15
  10. package/licenses/code-11.license +11 -9
  11. package/licenses/code-128.license +10 -8
  12. package/licenses/code-39.license +10 -8
  13. package/licenses/code-93.license +16 -14
  14. package/licenses/data-matrix.license +15 -13
  15. package/licenses/ean-13.license +10 -8
  16. package/licenses/ean-8.license +10 -8
  17. package/licenses/frameqr.license +84 -0
  18. package/licenses/gs1-128.license +10 -8
  19. package/licenses/isbn.license +10 -8
  20. package/licenses/itf-14.license +10 -8
  21. package/licenses/itf.license +9 -7
  22. package/licenses/micro-qr.license +79 -0
  23. package/licenses/micropdf417.license +52 -67
  24. package/licenses/msi-plessey.license +13 -11
  25. package/licenses/pdf417.license +78 -37
  26. package/licenses/pharmacode.license +14 -12
  27. package/licenses/qr-code.license +9 -7
  28. package/licenses/rmqr.license +79 -0
  29. package/licenses/upc-a.license +12 -10
  30. package/licenses/upc-e.license +10 -8
  31. package/package.json +97 -88
  32. package/src/datamatrix/decoder.js +262 -262
  33. package/src/datamatrix/detector.js +225 -225
  34. package/src/datamatrix/encoder.js +191 -191
  35. package/src/datamatrix/index.js +42 -42
  36. package/src/datamatrix/tables.js +123 -123
  37. package/src/frameqr/decoder.js +239 -0
  38. package/src/frameqr/detector.js +192 -0
  39. package/src/frameqr/encoder.js +156 -0
  40. package/src/frameqr/index.js +42 -0
  41. package/src/frameqr/tables.js +270 -0
  42. package/src/index.js +91 -2
  43. package/src/microqr/decoder.js +245 -0
  44. package/src/microqr/detector.js +355 -0
  45. package/src/microqr/encoder.js +269 -0
  46. package/src/microqr/index.js +36 -0
  47. package/src/microqr/tables.js +316 -0
  48. package/src/oned/index.js +59 -59
  49. package/src/rmqr/decoder.js +101 -0
  50. package/src/rmqr/detector.js +90 -0
  51. package/src/rmqr/encoder.js +172 -0
  52. package/src/rmqr/index.js +37 -0
  53. package/src/rmqr/tables.js +154 -0
@@ -1,734 +1,1013 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Sythos Barcode Suite — Create</title>
7
- <!--
8
- Sythos Barcode Suite — example
9
- Copyright (c) 2026 Sythos
10
- SPDX-License-Identifier: MIT
11
-
12
- Loads the IIFE bundle with a plain <script> tag, so this page works when
13
- opened straight from disk (file://). No server, no build step, no modules.
14
- -->
15
- <style>
16
- :root {
17
- --bg: #10131a;
18
- --panel: #181c26;
19
- --line: #262c3a;
20
- --ink: #e6e9f0;
21
- --muted: #8b93a7;
22
- --accent: #5ac8fa;
23
- --danger: #ff6b6b;
24
- color-scheme: dark;
25
- }
26
- @media (prefers-color-scheme: light) {
27
- :root {
28
- --bg: #f5f6f8; --panel: #ffffff; --line: #dfe3ea;
29
- --ink: #171a21; --muted: #666e80; --accent: #0a84c4;
30
- color-scheme: light;
31
- }
32
- }
33
- * { box-sizing: border-box; }
34
- body {
35
- margin: 0; padding: 24px;
36
- font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
37
- background: var(--bg); color: var(--ink);
38
- }
39
- header { max-width: 1100px; margin: 0 auto 20px; }
40
- h1 { font-size: 20px; margin: 0 0 4px; letter-spacing: -0.01em; }
41
- .sub { color: var(--muted); font-size: 13px; }
42
- .sub a { color: var(--accent); }
43
- .layout {
44
- max-width: 1100px; margin: 0 auto;
45
- display: grid; grid-template-columns: 340px 1fr; gap: 20px;
46
- align-items: start;
47
- }
48
- @media (max-width: 800px) { .layout { grid-template-columns: 1fr; } }
49
- .panel {
50
- background: var(--panel); border: 1px solid var(--line);
51
- border-radius: 12px; padding: 18px;
52
- }
53
- label { display: block; font-size: 12px; color: var(--muted); margin: 14px 0 5px;
54
- text-transform: uppercase; letter-spacing: 0.05em; }
55
- label:first-child { margin-top: 0; }
56
- input, select, textarea {
57
- width: 100%; padding: 9px 11px; font: inherit; font-size: 14px;
58
- background: var(--bg); color: var(--ink);
59
- border: 1px solid var(--line); border-radius: 8px;
60
- }
61
- input[type=color] { padding: 3px; height: 38px; }
62
- input[type=checkbox] { width: auto; height: 18px; padding: 0; margin: 4px 0 0;
63
- accent-color: var(--accent); }
64
- textarea { resize: vertical; min-height: 74px; font-family: ui-monospace, monospace; font-size: 13px; }
65
- .row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
66
- .btns { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 18px; }
67
- button {
68
- flex: 1; min-width: 108px; padding: 10px 14px; font: inherit; font-weight: 600;
69
- font-size: 13px; cursor: pointer; border-radius: 8px;
70
- border: 1px solid var(--line); background: var(--bg); color: var(--ink);
71
- }
72
- button.primary { background: var(--accent); border-color: var(--accent); color: #051019; }
73
- button:hover { filter: brightness(1.12); }
74
- #stage {
75
- min-height: 300px; display: flex; align-items: center; justify-content: center;
76
- padding: 24px; background: #fff; border-radius: 8px; overflow: auto;
77
- }
78
- #stage canvas { max-width: 100%; height: auto; image-rendering: pixelated; }
79
- .meta {
80
- margin-top: 14px; font-family: ui-monospace, monospace; font-size: 12px;
81
- color: var(--muted); display: flex; gap: 16px; flex-wrap: wrap;
82
- }
83
- .meta b { color: var(--ink); font-weight: 600; }
84
- .error {
85
- margin-top: 14px; padding: 10px 12px; border-radius: 8px; font-size: 13px;
86
- background: rgba(255,107,107,0.12); border: 1px solid var(--danger); color: var(--danger);
87
- }
88
- .hint { font-size: 12px; color: var(--muted); margin-top: 6px; }
89
-
90
- /* Content-type builder. The generated fields live in #ctFields; the wrappers
91
- below would otherwise trip `label:first-child` and lose their top margin. */
92
- #plainWrap > label:first-child,
93
- #ctWrap > label:first-child,
94
- #ctFields > label:first-child,
95
- #payloadWrap > label:first-child { margin-top: 14px; }
96
- #ctFields > .row { margin-top: 14px; }
97
- .payload {
98
- font-family: ui-monospace, monospace; font-size: 12px; line-height: 1.45;
99
- white-space: pre-wrap; word-break: break-all;
100
- padding: 9px 11px; min-height: 38px; max-height: 168px; overflow: auto;
101
- background: var(--bg); color: var(--ink);
102
- border: 1px solid var(--line); border-radius: 8px;
103
- }
104
- .payload:empty::before { content: "(empty)"; color: var(--muted); }
105
- </style>
106
- </head>
107
- <body>
108
-
109
- <header>
110
- <h1>Create a barcode</h1>
111
- <p class="sub">
112
- Sythos Barcode Suite &mdash; 100% original JavaScript, zero dependencies, MIT.
113
- See also <a href="read.html">read.html</a>.
114
- </p>
115
- </header>
116
-
117
- <div class="layout">
118
- <div class="panel">
119
- <label for="format">Format</label>
120
- <select id="format"></select>
121
- <p class="hint" id="formatHint"></p>
122
-
123
- <div id="ctWrap">
124
- <label for="ctype">Content type</label>
125
- <select id="ctype"></select>
126
- </div>
127
-
128
- <div id="ctFields"></div>
129
-
130
- <div id="plainWrap">
131
- <label for="text">Content</label>
132
- <textarea id="text">https://example.com</textarea>
133
- </div>
134
-
135
- <div id="payloadWrap">
136
- <label for="payloadOut">Encoded payload</label>
137
- <div class="payload" id="payloadOut" tabindex="0"></div>
138
- <p class="hint" id="payloadLen"></p>
139
- </div>
140
-
141
- <div id="qrOpts">
142
- <div class="row">
143
- <div>
144
- <label for="ecc">Error correction</label>
145
- <select id="ecc">
146
- <option value="L">L &mdash; 7%</option>
147
- <option value="M" selected>M &mdash; 15%</option>
148
- <option value="Q">Q &mdash; 25%</option>
149
- <option value="H">H &mdash; 30%</option>
150
- </select>
151
- </div>
152
- <div>
153
- <label for="version">Version</label>
154
- <select id="version"><option value="">Auto</option></select>
155
- </div>
156
- </div>
157
- </div>
158
-
159
- <div class="row">
160
- <div>
161
- <label for="scale">Scale (px/module)</label>
162
- <input type="number" id="scale" value="6" min="1" max="40">
163
- </div>
164
- <div>
165
- <label for="margin">Quiet zone</label>
166
- <input type="number" id="margin" value="4" min="0" max="20">
167
- </div>
168
- </div>
169
-
170
- <div class="row">
171
- <div>
172
- <label for="dark">Dark</label>
173
- <input type="color" id="dark" value="#000000">
174
- </div>
175
- <div>
176
- <label for="light">Light</label>
177
- <input type="color" id="light" value="#ffffff">
178
- </div>
179
- </div>
180
-
181
- <div class="btns">
182
- <button class="primary" id="download-png">Download PNG</button>
183
- <button id="download-svg">Download SVG</button>
184
- </div>
185
- </div>
186
-
187
- <div class="panel">
188
- <div id="stage"><canvas id="canvas"></canvas></div>
189
- <div class="meta" id="meta"></div>
190
- <div id="err"></div>
191
- </div>
192
- </div>
193
-
194
- <script src="../bundle/sythos-barcode.js"></script>
195
- <script>
196
- (function () {
197
- 'use strict';
198
- var B = window.SythosBarcode;
199
-
200
- var el = function (id) { return document.getElementById(id); };
201
- var formatSel = el('format');
202
- var canvas = el('canvas');
203
-
204
- // Sensible sample content per format, so switching format never lands the
205
- // user on an "invalid payload" error they have to reverse-engineer.
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Sythos Barcode Suite — Create</title>
7
+ <!--
8
+ Sythos Barcode Suite — example
9
+ Copyright (c) 2026 Sythos
10
+ SPDX-License-Identifier: MIT
11
+
12
+ Loads the IIFE bundle with a plain <script> tag, so this page works when
13
+ opened straight from disk (file://). No server, no build step, no modules.
14
+ -->
15
+ <style>
16
+ :root {
17
+ --bg: #10131a;
18
+ --panel: #181c26;
19
+ --line: #262c3a;
20
+ --ink: #e6e9f0;
21
+ --muted: #8b93a7;
22
+ --accent: #5ac8fa;
23
+ --danger: #ff6b6b;
24
+ color-scheme: dark;
25
+ }
26
+ @media (prefers-color-scheme: light) {
27
+ :root {
28
+ --bg: #f5f6f8; --panel: #ffffff; --line: #dfe3ea;
29
+ --ink: #171a21; --muted: #666e80; --accent: #0a84c4;
30
+ color-scheme: light;
31
+ }
32
+ }
33
+ * { box-sizing: border-box; }
34
+ body {
35
+ margin: 0; padding: 24px;
36
+ font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
37
+ background: var(--bg); color: var(--ink);
38
+ }
39
+ header { max-width: 1100px; margin: 0 auto 20px; }
40
+ h1 { font-size: 20px; margin: 0 0 4px; letter-spacing: -0.01em; }
41
+ .sub { color: var(--muted); font-size: 13px; }
42
+ .sub a { color: var(--accent); }
43
+ .layout {
44
+ max-width: 1100px; margin: 0 auto;
45
+ display: grid; grid-template-columns: 340px 1fr; gap: 20px;
46
+ align-items: start;
47
+ }
48
+ @media (max-width: 800px) { .layout { grid-template-columns: 1fr; } }
49
+ .panel {
50
+ background: var(--panel); border: 1px solid var(--line);
51
+ border-radius: 12px; padding: 18px;
52
+ }
53
+ label { display: block; font-size: 12px; color: var(--muted); margin: 14px 0 5px;
54
+ text-transform: uppercase; letter-spacing: 0.05em; }
55
+ label:first-child { margin-top: 0; }
56
+ input, select, textarea {
57
+ width: 100%; padding: 9px 11px; font: inherit; font-size: 14px;
58
+ background: var(--bg); color: var(--ink);
59
+ border: 1px solid var(--line); border-radius: 8px;
60
+ }
61
+ input[type=color] { padding: 3px; height: 38px; }
62
+ input[type=checkbox] { width: auto; height: 18px; padding: 0; margin: 4px 0 0;
63
+ accent-color: var(--accent); }
64
+ textarea { resize: vertical; min-height: 74px; font-family: ui-monospace, monospace; font-size: 13px; }
65
+ .row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
66
+ .btns { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 18px; }
67
+ button {
68
+ flex: 1; min-width: 108px; padding: 10px 14px; font: inherit; font-weight: 600;
69
+ font-size: 13px; cursor: pointer; border-radius: 8px;
70
+ border: 1px solid var(--line); background: var(--bg); color: var(--ink);
71
+ }
72
+ button.primary { background: var(--accent); border-color: var(--accent); color: #051019; }
73
+ button:hover { filter: brightness(1.12); }
74
+ #stage {
75
+ min-height: 300px; display: flex; align-items: center; justify-content: center;
76
+ position: relative; padding: 24px; background: #fff; border-radius: 8px; overflow: auto;
77
+ }
78
+ #stage canvas { max-width: 100%; height: auto; image-rendering: pixelated; }
79
+ #stage .frame-art-preview {
80
+ position: absolute; z-index: 2; display: none; pointer-events: none;
81
+ object-fit: cover; object-position: center; transform-origin: center;
82
+ }
83
+ .meta {
84
+ margin-top: 14px; font-family: ui-monospace, monospace; font-size: 12px;
85
+ color: var(--muted); display: flex; gap: 16px; flex-wrap: wrap;
86
+ }
87
+ .meta b { color: var(--ink); font-weight: 600; }
88
+ .error {
89
+ margin-top: 14px; padding: 10px 12px; border-radius: 8px; font-size: 13px;
90
+ background: rgba(255,107,107,0.12); border: 1px solid var(--danger); color: var(--danger);
91
+ }
92
+ .hint { font-size: 12px; color: var(--muted); margin-top: 6px; }
93
+
94
+ /* Content-type builder. The generated fields live in #ctFields; the wrappers
95
+ below would otherwise trip `label:first-child` and lose their top margin. */
96
+ #plainWrap > label:first-child,
97
+ #ctWrap > label:first-child,
98
+ #ctFields > label:first-child,
99
+ #payloadWrap > label:first-child { margin-top: 14px; }
100
+ #ctFields > .row { margin-top: 14px; }
101
+ .payload {
102
+ font-family: ui-monospace, monospace; font-size: 12px; line-height: 1.45;
103
+ white-space: pre-wrap; word-break: break-all;
104
+ padding: 9px 11px; min-height: 38px; max-height: 168px; overflow: auto;
105
+ background: var(--bg); color: var(--ink);
106
+ border: 1px solid var(--line); border-radius: 8px;
107
+ }
108
+ .payload:empty::before { content: "(empty)"; color: var(--muted); }
109
+ </style>
110
+ </head>
111
+ <body>
112
+
113
+ <header>
114
+ <h1>Create a barcode</h1>
115
+ <p class="sub">
116
+ Sythos Barcode Suite &mdash; 100% original JavaScript, zero dependencies, MIT.
117
+ See also <a href="read.html">read.html</a>.
118
+ </p>
119
+ </header>
120
+
121
+ <div class="layout">
122
+ <div class="panel">
123
+ <label for="format">Format</label>
124
+ <select id="format"></select>
125
+ <p class="hint" id="formatHint"></p>
126
+
127
+ <div id="ctWrap">
128
+ <label for="ctype">Content type</label>
129
+ <select id="ctype"></select>
130
+ </div>
131
+
132
+ <div id="ctFields"></div>
133
+
134
+ <div id="plainWrap">
135
+ <label for="text">Content</label>
136
+ <textarea id="text">https://www.sythos.net/</textarea>
137
+ </div>
138
+
139
+ <div id="payloadWrap">
140
+ <label for="payloadOut">Encoded payload</label>
141
+ <div class="payload" id="payloadOut" tabindex="0"></div>
142
+ <p class="hint" id="payloadLen"></p>
143
+ </div>
144
+
145
+ <div id="qrOpts">
146
+ <div class="row">
147
+ <div>
148
+ <label for="ecc">Error correction</label>
149
+ <select id="ecc">
150
+ <option value="L">L &mdash; 7%</option>
151
+ <option value="M" selected>M &mdash; 15%</option>
152
+ <option value="Q">Q &mdash; 25%</option>
153
+ <option value="H">H &mdash; 30%</option>
154
+ </select>
155
+ </div>
156
+ <div>
157
+ <label for="version">Version</label>
158
+ <select id="version"><option value="">Auto</option></select>
159
+ </div>
160
+ </div>
161
+ </div>
162
+
163
+ <div id="frameOpts" style="display:none">
164
+ <p class="hint">Sythos Canvas QR profile: this is not certified DENSO FrameQR interoperability.</p>
165
+ <label for="frameCanvasShape">Canvas shape</label>
166
+ <select id="frameCanvasShape">
167
+ <option value="square" selected>Square</option>
168
+ <option value="circle">Circle</option>
169
+ <option value="diamond">Diamond</option>
170
+ </select>
171
+ <label for="frameCanvasSize">Canvas size (QR modules)</label>
172
+ <input type="number" id="frameCanvasSize" value="5" min="1" step="2">
173
+ <label for="frameCanvasUrl">Remote canvas image</label>
174
+ <input type="url" id="frameCanvasUrl" value="https://www.sythos.net/favicon.ico">
175
+ <p class="hint" id="frameArtworkStatus">
176
+ Fallback: https://www.sythos.net/apple-touch-icon.png. The asset is never copied locally.
177
+ CORS is required to include remote artwork in PNG output.
178
+ </p>
179
+ </div>
180
+
181
+ <div class="row">
182
+ <div>
183
+ <label for="scale">Scale (px/module)</label>
184
+ <input type="number" id="scale" value="6" min="1" max="40">
185
+ </div>
186
+ <div>
187
+ <label for="margin">Quiet zone</label>
188
+ <input type="number" id="margin" value="4" min="0" max="20">
189
+ </div>
190
+ </div>
191
+
192
+ <div class="row">
193
+ <div>
194
+ <label for="dark">Dark</label>
195
+ <input type="color" id="dark" value="#000000">
196
+ </div>
197
+ <div>
198
+ <label for="light">Light</label>
199
+ <input type="color" id="light" value="#ffffff">
200
+ </div>
201
+ </div>
202
+
203
+ <div class="btns">
204
+ <button class="primary" id="download-png">Download PNG</button>
205
+ <button id="download-svg">Download SVG</button>
206
+ </div>
207
+ </div>
208
+
209
+ <div class="panel">
210
+ <div id="stage">
211
+ <canvas id="canvas"></canvas>
212
+ <img id="frameArtworkPreview" class="frame-art-preview" alt="" aria-hidden="true">
213
+ </div>
214
+ <div class="meta" id="meta"></div>
215
+ <div id="err"></div>
216
+ </div>
217
+ </div>
218
+
219
+ <script src="../bundle/sythos-barcode.js"></script>
220
+ <script>
221
+ (function () {
222
+ 'use strict';
223
+ var B = window.SythosBarcode;
224
+
225
+ var el = function (id) { return document.getElementById(id); };
226
+ var formatSel = el('format');
227
+ var canvas = el('canvas');
228
+ var stage = el('stage');
229
+ var frameArtworkPreview = el('frameArtworkPreview');
230
+ var frameArtworkState = {
231
+ image: null,
232
+ url: '',
233
+ cors: false,
234
+ status: 'idle',
235
+ request: ''
236
+ };
237
+
238
+ // Sensible sample content per format, so switching format never lands the
239
+ // user on an "invalid payload" error they have to reverse-engineer.
206
240
  var SAMPLES = {
207
- qr: 'https://example.com',
241
+ qr: 'https://www.sythos.net/',
208
242
  datamatrix: 'Data Matrix ECC 200',
209
243
  aztec: 'Aztec Code — UTF-8 👋',
210
244
  pdf417: 'PDF417 sample payload',
211
- ean13: '5901234123457',
212
- ean8: '96385074',
213
- upca: '036000291452',
214
- upce: '01234565',
215
- code128: 'ABC-123456',
216
- gs1128: '0101234567890128',
217
- code39: 'SYTHOS-39',
218
- code93: 'SYTHOS93',
219
- itf: '12345678',
220
- itf14: '12345678901231',
221
- codabar: 'A12345A',
222
- code11: '123456',
223
- msi: '1234567',
224
- pharmacode: '1234'
225
- };
226
-
227
- /* ---8<--- payload builders --- (unit-tested from a scratch Node script that
228
- extracts this exact block out of the file, so the tests never drift from
229
- the shipped code). Everything between the sentinels must stay dependency
230
- free: no DOM, no closures over outer state.
231
-
232
- The escaping conventions genuinely differ and must not be shared:
233
- - WIFI / MECARD backslash-escape \ ; , : "
234
- - vCard / vEvent backslash-escape \ ; , and newline -> \n, but NOT ':'
235
- (escaping the colon there would corrupt every "URL:https://..." value).
236
- */
237
-
238
- function ctTrim(s) {
239
- return String(s === null || s === undefined ? '' : s).replace(/^\s+|\s+$/g, '');
240
- }
241
-
242
- function ctStr(s) {
243
- return String(s === null || s === undefined ? '' : s);
244
- }
245
-
246
- /** WIFI: / MECARD: value escaping backslash before \ ; , : and " */
247
- function escWifi(s) {
248
- return ctStr(s).replace(/([\\;,:"])/g, '\\$1');
249
- }
250
-
251
- /** vCard 3.0 / iCalendar TEXT value escaping — \ ; , and newlines. No colon. */
252
- function escVCardText(s) {
253
- var out = ctStr(s);
254
- out = out.replace(/\\/g, '\\\\');
255
- out = out.replace(/;/g, '\\;');
256
- out = out.replace(/,/g, '\\,');
257
- out = out.replace(/\r\n|\r|\n/g, '\\n');
258
- return out;
259
- }
260
-
261
- /** "2026-09-01T14:00" (or with seconds) -> "20260901T140000" */
262
- function icalStamp(s) {
263
- var raw = ctTrim(s);
264
- if (!raw) return '';
265
- var cleaned = raw.replace(/[^0-9T]/g, '');
266
- var parts = cleaned.split('T');
267
- var date = parts[0] || '';
268
- var time = parts.length > 1 ? parts[1] : '';
269
- if (!date) return '';
270
- while (time.length < 6) { time += '0'; }
271
- return date + 'T' + time.substring(0, 6);
272
- }
273
-
274
- function buildPlain(v) {
275
- return ctStr(v.text);
276
- }
277
-
278
- function buildUrl(v) {
279
- var u = ctTrim(v.url);
280
- if (!u) return '';
281
- // Scheme test, not a "://" test: mailto:, tel: and geo: have no slashes.
282
- if (!/^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(u)) u = 'https://' + u;
283
- return u;
284
- }
285
-
286
- function buildEmail(v) {
287
- var to = ctTrim(v.to);
288
- var subject = ctStr(v.subject);
289
- var body = ctStr(v.body);
290
- var params = [];
291
- if (ctTrim(subject)) params.push('subject=' + encodeURIComponent(subject));
292
- if (ctTrim(body)) params.push('body=' + encodeURIComponent(body));
293
- // The address itself is not percent-encoded: "@" must survive.
294
- return 'mailto:' + to + (params.length ? '?' + params.join('&') : '');
295
- }
296
-
297
- function buildPhone(v) {
298
- return 'tel:' + ctTrim(v.number);
299
- }
300
-
301
- function buildSms(v) {
302
- return 'SMSTO:' + ctTrim(v.number) + ':' + ctStr(v.message);
303
- }
304
-
305
- function buildWifi(v) {
306
- var enc = ctTrim(v.encryption) || 'WPA';
307
- var out = 'WIFI:T:' + enc + ';S:' + escWifi(v.ssid) + ';';
308
- if (enc !== 'nopass') out += 'P:' + escWifi(v.password) + ';';
309
- if (v.hidden === true || v.hidden === 'true') out += 'H:true;';
310
- return out + ';';
311
- }
312
-
313
- function buildVCard(v) {
314
- var first = ctTrim(v.first);
315
- var last = ctTrim(v.last);
316
- var lines = ['BEGIN:VCARD', 'VERSION:3.0'];
317
- // The ";" in N: is structural — escape each component, then join.
318
- lines.push('N:' + escVCardText(last) + ';' + escVCardText(first));
319
- var fnParts = [];
320
- if (first) fnParts.push(first);
321
- if (last) fnParts.push(last);
322
- lines.push('FN:' + escVCardText(fnParts.join(' ')));
323
- if (ctTrim(v.org)) lines.push('ORG:' + escVCardText(ctTrim(v.org)));
324
- if (ctTrim(v.title)) lines.push('TITLE:' + escVCardText(ctTrim(v.title)));
325
- if (ctTrim(v.phone)) lines.push('TEL:' + escVCardText(ctTrim(v.phone)));
326
- if (ctTrim(v.email)) lines.push('EMAIL:' + escVCardText(ctTrim(v.email)));
327
- if (ctTrim(v.url)) lines.push('URL:' + escVCardText(ctTrim(v.url)));
328
- // ADR is 7 structural components; a free-text address goes in "street".
329
- if (ctTrim(v.address)) lines.push('ADR:;;' + escVCardText(ctTrim(v.address)) + ';;;;');
330
- lines.push('END:VCARD');
331
- return lines.join('\r\n');
332
- }
333
-
334
- function buildMecard(v) {
335
- var segs = [];
336
- if (ctTrim(v.name)) segs.push('N:' + escWifi(ctTrim(v.name)));
337
- if (ctTrim(v.phone)) segs.push('TEL:' + escWifi(ctTrim(v.phone)));
338
- if (ctTrim(v.email)) segs.push('EMAIL:' + escWifi(ctTrim(v.email)));
339
- if (ctTrim(v.url)) segs.push('URL:' + escWifi(ctTrim(v.url)));
340
- var out = 'MECARD:';
341
- for (var i = 0; i < segs.length; i++) { out += segs[i] + ';'; }
342
- return out + ';';
343
- }
344
-
345
- function buildGeo(v) {
346
- var lat = ctTrim(v.lat);
347
- var lon = ctTrim(v.lon);
348
- var alt = ctTrim(v.alt);
349
- var out = 'geo:' + lat + ',' + lon;
350
- if (alt) out += ',' + alt;
351
- return out;
352
- }
353
-
354
- function buildEvent(v) {
355
- var lines = ['BEGIN:VEVENT'];
356
- if (ctTrim(v.summary)) lines.push('SUMMARY:' + escVCardText(ctTrim(v.summary)));
357
- if (ctTrim(v.location)) lines.push('LOCATION:' + escVCardText(ctTrim(v.location)));
358
- var dtStart = icalStamp(v.start);
359
- var dtEnd = icalStamp(v.end);
360
- if (dtStart) lines.push('DTSTART:' + dtStart);
361
- if (dtEnd) lines.push('DTEND:' + dtEnd);
362
- lines.push('END:VEVENT');
363
- return lines.join('\r\n');
364
- }
365
-
366
- var PAYLOAD_BUILDERS = {
367
- text: buildPlain,
368
- url: buildUrl,
369
- email: buildEmail,
370
- phone: buildPhone,
371
- sms: buildSms,
372
- wifi: buildWifi,
373
- vcard: buildVCard,
374
- mecard: buildMecard,
375
- geo: buildGeo,
376
- event: buildEvent
377
- };
378
-
379
- /* ---8<--- end payload builders --- */
380
-
381
- // Data-driven field definitions. `kind` maps straight onto the control that
382
- // gets created; `half` pairs a field with the next one in a .row grid.
383
- // Every default is a literal (never derived from `new Date()`) so the page
384
- // and its unit tests agree, and so no type ever opens in an error state.
385
- var CONTENT_TYPES = [
386
- {
387
- type: 'text', label: 'Plain text',
388
- fields: [
389
- { id: 'text', label: 'Text', kind: 'textarea', value: 'https://example.com' }
390
- ]
391
- },
392
- {
393
- type: 'url', label: 'URL',
394
- fields: [
395
- { id: 'url', label: 'URL', kind: 'url', value: 'https://sythos.net',
396
- placeholder: 'example.com (https:// added if missing)' }
397
- ]
398
- },
399
- {
400
- type: 'email', label: 'Email',
401
- fields: [
402
- { id: 'to', label: 'To', kind: 'email', value: 'hello@example.com' },
403
- { id: 'subject', label: 'Subject', kind: 'text', value: 'Hello from a QR code' },
404
- { id: 'body', label: 'Body', kind: 'textarea', value: 'Scanned your code — let’s talk.' }
405
- ]
406
- },
407
- {
408
- type: 'phone', label: 'Phone',
409
- fields: [
410
- { id: 'number', label: 'Phone number', kind: 'tel', value: '+390212345678' }
411
- ]
412
- },
413
- {
414
- type: 'sms', label: 'SMS',
415
- fields: [
416
- { id: 'number', label: 'Phone number', kind: 'tel', value: '+390212345678' },
417
- { id: 'message', label: 'Message', kind: 'textarea', value: 'Hello!' }
418
- ]
419
- },
420
- {
421
- type: 'wifi', label: 'Wi-Fi network',
422
- fields: [
423
- { id: 'ssid', label: 'Network name (SSID)', kind: 'text', value: 'Sythos-Guest' },
424
- { id: 'password', label: 'Password', kind: 'text', value: 'correct horse' },
425
- { id: 'encryption', label: 'Encryption', kind: 'select', value: 'WPA', options: [
426
- { value: 'WPA', label: 'WPA / WPA2 / WPA3' },
427
- { value: 'WEP', label: 'WEP' },
428
- { value: 'nopass', label: 'None (open)' }
429
- ] },
430
- { id: 'hidden', label: 'Hidden network', kind: 'checkbox', value: false }
431
- ]
432
- },
433
- {
434
- type: 'vcard', label: 'Contact (vCard)',
435
- fields: [
436
- { id: 'first', label: 'First name', kind: 'text', value: 'Ada', half: true },
437
- { id: 'last', label: 'Last name', kind: 'text', value: 'Lovelace', half: true },
438
- { id: 'org', label: 'Organisation', kind: 'text', value: 'Analytical Engines' },
439
- { id: 'title', label: 'Title', kind: 'text', value: 'Mathematician' },
440
- { id: 'phone', label: 'Phone', kind: 'tel', value: '+390212345678' },
441
- { id: 'email', label: 'Email', kind: 'email', value: 'ada@example.com' },
442
- { id: 'url', label: 'Website', kind: 'url', value: 'https://example.com' },
443
- { id: 'address', label: 'Address', kind: 'text', value: '12 Baker Street, London' }
444
- ]
445
- },
446
- {
447
- type: 'mecard', label: 'Contact (MeCard)',
448
- fields: [
449
- { id: 'name', label: 'Name', kind: 'text', value: 'Lovelace,Ada' },
450
- { id: 'phone', label: 'Phone', kind: 'tel', value: '+390212345678' },
451
- { id: 'email', label: 'Email', kind: 'email', value: 'ada@example.com' },
452
- { id: 'url', label: 'Website', kind: 'url', value: 'https://example.com' }
453
- ]
454
- },
455
- {
456
- type: 'geo', label: 'Geo location',
457
- fields: [
458
- { id: 'lat', label: 'Latitude', kind: 'number', step: 'any', value: '45.4642', half: true },
459
- { id: 'lon', label: 'Longitude', kind: 'number', step: 'any', value: '9.1900', half: true },
460
- { id: 'alt', label: 'Altitude (m, optional)', kind: 'number', step: 'any', value: '' }
461
- ]
462
- },
463
- {
464
- type: 'event', label: 'Calendar event',
465
- fields: [
466
- { id: 'summary', label: 'Summary', kind: 'text', value: 'Sythos Barcode Suite 1.0' },
467
- { id: 'location', label: 'Location', kind: 'text', value: 'Milano, Italy' },
468
- { id: 'start', label: 'Start', kind: 'datetime-local', value: '2026-09-01T14:00', half: true },
469
- { id: 'end', label: 'End', kind: 'datetime-local', value: '2026-09-01T15:30', half: true }
470
- ]
471
- }
472
- ];
473
-
474
- var ctypeSel = el('ctype');
475
- var ctFieldsEl = el('ctFields');
476
- var ctType = CONTENT_TYPES[0].type;
477
-
478
- // One value bag per content type, seeded from the defaults, so switching
479
- // away and back inside a session never loses what was typed.
480
- var ctValues = {};
481
- CONTENT_TYPES.forEach(function (def) {
482
- var bag = {};
483
- def.fields.forEach(function (f) {
484
- bag[f.id] = (f.kind === 'checkbox')
485
- ? (f.value === true)
486
- : (f.value === null || f.value === undefined ? '' : String(f.value));
487
- });
488
- ctValues[def.type] = bag;
489
-
490
- var o = document.createElement('option');
491
- o.value = def.type;
492
- o.textContent = def.label;
493
- ctypeSel.appendChild(o);
494
- });
495
- ctypeSel.value = ctType;
496
-
497
- function ctDef(typeId) {
498
- var found = CONTENT_TYPES.filter(function (d) { return d.type === typeId; });
499
- return found.length ? found[0] : CONTENT_TYPES[0];
500
- }
501
-
502
- function ctFieldId(typeId, fieldId) {
503
- // Prefixed so generated ids can never collide with the page's own
504
- // (#text, #scale, #margin, #version, …).
505
- return 'ct-' + typeId + '-' + fieldId;
506
- }
507
-
508
- function ctLabel(typeId, f) {
509
- var lab = document.createElement('label');
510
- lab.setAttribute('for', ctFieldId(typeId, f.id));
511
- lab.textContent = f.label;
512
- return lab;
513
- }
514
-
515
- function ctControl(typeId, f, bag) {
516
- var input;
517
- if (f.kind === 'textarea') {
518
- input = document.createElement('textarea');
519
- input.rows = f.rows || 3;
520
- } else if (f.kind === 'select') {
521
- input = document.createElement('select');
522
- (f.options || []).forEach(function (opt) {
523
- var o = document.createElement('option');
524
- o.value = opt.value;
525
- o.textContent = opt.label;
526
- input.appendChild(o);
527
- });
528
- } else {
529
- input = document.createElement('input');
530
- // Unknown types (datetime-local on ancient engines) degrade to text.
531
- try { input.type = f.kind; } catch (e) { input.type = 'text'; }
532
- if (f.step) input.setAttribute('step', f.step);
533
- }
534
- input.id = ctFieldId(typeId, f.id);
535
- if (f.placeholder) input.setAttribute('placeholder', f.placeholder);
536
- if (f.kind === 'checkbox') {
537
- input.checked = bag[f.id] === true;
538
- } else {
539
- input.value = bag[f.id] === null || bag[f.id] === undefined ? '' : String(bag[f.id]);
540
- }
541
- return input;
542
- }
543
-
544
- function ctCell(typeId, f, bag) {
545
- var cell = document.createElement('div');
546
- cell.appendChild(ctLabel(typeId, f));
547
- cell.appendChild(ctControl(typeId, f, bag));
548
- return cell;
549
- }
550
-
551
- function ctRender() {
552
- var def = ctDef(ctType);
553
- var bag = ctValues[def.type];
554
- while (ctFieldsEl.firstChild) ctFieldsEl.removeChild(ctFieldsEl.firstChild);
555
-
556
- var fields = def.fields;
557
- var i = 0;
558
- while (i < fields.length) {
559
- var f = fields[i];
560
- var next = (i + 1 < fields.length) ? fields[i + 1] : null;
561
- if (f.half && next && next.half) {
562
- var row = document.createElement('div');
563
- row.className = 'row';
564
- row.appendChild(ctCell(def.type, f, bag));
565
- row.appendChild(ctCell(def.type, next, bag));
566
- ctFieldsEl.appendChild(row);
567
- i += 2;
568
- } else {
569
- ctFieldsEl.appendChild(ctLabel(def.type, f));
570
- ctFieldsEl.appendChild(ctControl(def.type, f, bag));
571
- i += 1;
572
- }
573
- }
574
- }
575
-
576
- function ctSync() {
577
- var def = ctDef(ctType);
578
- var bag = ctValues[def.type];
579
- def.fields.forEach(function (f) {
580
- var node = document.getElementById(ctFieldId(def.type, f.id));
581
- if (!node) return;
582
- bag[f.id] = (f.kind === 'checkbox') ? !!node.checked : node.value;
583
- });
584
- }
585
-
586
- function currentPayload() {
587
- if (formatSel.value !== 'qr') return el('text').value;
588
- var build = PAYLOAD_BUILDERS[ctType];
589
- return build ? build(ctValues[ctType]) : '';
590
- }
591
-
592
- var formats = B.listFormats();
593
- formats.forEach(function (f) {
594
- var o = document.createElement('option');
595
- o.value = f.id;
596
- o.textContent = f.label + (f.canWrite ? '' : ' (unavailable)');
597
- o.disabled = !f.canWrite;
598
- formatSel.appendChild(o);
599
- });
600
- formatSel.value = formats.some(function (f) { return f.id === 'qr' && f.canWrite; })
601
- ? 'qr' : 'ean13';
602
-
603
- var versionSel = el('version');
604
- for (var v = 1; v <= 40; v++) {
605
- var o = document.createElement('option');
606
- o.value = String(v);
607
- o.textContent = 'Version ' + v + ' (' + (17 + 4 * v) + '×' + (17 + 4 * v) + ')';
608
- versionSel.appendChild(o);
609
- }
610
-
611
- var lastMatrix = null;
612
-
613
- function currentOptions() {
614
- return {
615
- format: formatSel.value,
616
- ecc: el('ecc').value,
617
- version: el('version').value ? Number(el('version').value) : undefined,
618
- checkDigit: true
619
- };
620
- }
621
-
622
- function renderOptions() {
623
- return {
624
- scale: Math.max(1, Number(el('scale').value) || 6),
625
- margin: Math.max(0, Number(el('margin').value) || 0),
626
- dark: el('dark').value,
627
- light: el('light').value
628
- };
629
- }
630
-
631
- // User-supplied strings never go through innerHTML.
632
- function showError(msg) {
633
- var box = el('err');
634
- while (box.firstChild) box.removeChild(box.firstChild);
635
- if (!msg) return;
636
- var d = document.createElement('div');
637
- d.className = 'error';
638
- d.textContent = msg;
639
- box.appendChild(d);
640
- }
641
-
642
- function update() {
643
- var isQR = formatSel.value === 'qr';
644
- el('qrOpts').style.display = isQR ? '' : 'none';
645
- el('ctWrap').style.display = isQR ? '' : 'none';
646
- ctFieldsEl.style.display = isQR ? '' : 'none';
647
- el('payloadWrap').style.display = isQR ? '' : 'none';
648
- el('plainWrap').style.display = isQR ? 'none' : '';
649
-
650
- var info = formats.filter(function (f) { return f.id === formatSel.value; })[0];
651
- el('formatHint').textContent = info
652
- ? (info.kind + ' · ' + (info.canRead ? 'this suite can also read it' : 'write-only in this build'))
653
- : '';
654
-
655
- var payload = currentPayload();
656
- el('payloadOut').textContent = payload;
657
- el('payloadLen').textContent = payload.length + ' character' + (payload.length === 1 ? '' : 's');
658
-
659
- showError('');
660
- try {
661
- lastMatrix = B.encode(payload, currentOptions());
662
- } catch (e) {
663
- lastMatrix = null;
664
- showError(String(e && e.message ? e.message : e));
665
- el('meta').innerHTML = '';
666
- var ctx = canvas.getContext('2d');
667
- canvas.width = 10; canvas.height = 10;
668
- if (ctx) ctx.clearRect(0, 0, 10, 10);
669
- return;
670
- }
671
-
672
- // renderToCanvasAuto tries WebGL2 first and falls back to the 2D context,
673
- // reporting which one actually ran.
674
- var out = B.renderToCanvasAuto(lastMatrix, canvas, renderOptions());
675
-
676
- el('meta').innerHTML =
677
- '<span>modules <b>' + lastMatrix.width + '×' + lastMatrix.height + '</b></span>' +
678
- '<span>pixels <b>' + canvas.width + '×' + canvas.height + '</b></span>' +
679
- '<span>backend <b>' + out.backend + '</b></span>' +
680
- '<span>bytes in <b>' + payload.length + '</b></span>';
681
- }
682
-
683
- function saveBlob(blob, name) {
684
- var url = URL.createObjectURL(blob);
685
- var a = document.createElement('a');
686
- a.href = url;
687
- a.download = name;
688
- document.body.appendChild(a);
689
- a.click();
690
- document.body.removeChild(a);
691
- setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
692
- }
693
-
694
- el('download-svg').addEventListener('click', function () {
695
- if (!lastMatrix) return;
696
- var svg = B.toSVG(lastMatrix, renderOptions());
697
- saveBlob(new Blob([svg], { type: 'image/svg+xml' }), formatSel.value + '.svg');
698
- });
699
-
700
- el('download-png').addEventListener('click', function () {
701
- if (!lastMatrix) return;
702
- B.toPNG(lastMatrix, renderOptions()).then(function (bytes) {
703
- saveBlob(new Blob([bytes], { type: 'image/png' }), formatSel.value + '.png');
704
- });
705
- });
706
-
707
- formatSel.addEventListener('change', function () {
708
- if (SAMPLES[formatSel.value]) el('text').value = SAMPLES[formatSel.value];
709
- update();
710
- });
711
-
712
- ctypeSel.addEventListener('change', function () {
713
- ctSync(); // keep whatever was typed under the old type
714
- ctType = ctypeSel.value;
715
- ctRender();
716
- update();
717
- });
718
-
719
- // Delegated: the generated controls are replaced wholesale on every type
720
- // switch, so listeners live on the container instead. Both events bubble.
721
- ctFieldsEl.addEventListener('input', function () { ctSync(); update(); });
722
- ctFieldsEl.addEventListener('change', function () { ctSync(); update(); });
723
-
724
- ['text', 'ecc', 'version', 'scale', 'margin', 'dark', 'light'].forEach(function (id) {
725
- el(id).addEventListener('input', update);
726
- el(id).addEventListener('change', update);
727
- });
728
-
729
- ctRender();
730
- update();
731
- })();
732
- </script>
733
- </body>
734
- </html>
245
+ microqr: '12345',
246
+ rmqr: 'rMQR sample payload',
247
+ frameqr: 'https://www.sythos.net/',
248
+ ean13: '5901234123457',
249
+ ean8: '96385074',
250
+ upca: '036000291452',
251
+ upce: '01234565',
252
+ code128: 'ABC-123456',
253
+ gs1128: '0101234567890128',
254
+ code39: 'SYTHOS-39',
255
+ code93: 'SYTHOS93',
256
+ itf: '12345678',
257
+ itf14: '12345678901231',
258
+ codabar: 'A12345A',
259
+ code11: '123456',
260
+ msi: '1234567',
261
+ pharmacode: '1234'
262
+ };
263
+
264
+ /* ---8<--- payload builders --- (unit-tested from a scratch Node script that
265
+ extracts this exact block out of the file, so the tests never drift from
266
+ the shipped code). Everything between the sentinels must stay dependency
267
+ free: no DOM, no closures over outer state.
268
+
269
+ The escaping conventions genuinely differ and must not be shared:
270
+ - WIFI / MECARD backslash-escape \ ; , : "
271
+ - vCard / vEvent backslash-escape \ ; , and newline -> \n, but NOT ':'
272
+ (escaping the colon there would corrupt every "URL:https://..." value).
273
+ */
274
+
275
+ function ctTrim(s) {
276
+ return String(s === null || s === undefined ? '' : s).replace(/^\s+|\s+$/g, '');
277
+ }
278
+
279
+ function ctStr(s) {
280
+ return String(s === null || s === undefined ? '' : s);
281
+ }
282
+
283
+ /** WIFI: / MECARD: value escaping — backslash before \ ; , : and " */
284
+ function escWifi(s) {
285
+ return ctStr(s).replace(/([\\;,:"])/g, '\\$1');
286
+ }
287
+
288
+ /** vCard 3.0 / iCalendar TEXT value escaping — \ ; , and newlines. No colon. */
289
+ function escVCardText(s) {
290
+ var out = ctStr(s);
291
+ out = out.replace(/\\/g, '\\\\');
292
+ out = out.replace(/;/g, '\\;');
293
+ out = out.replace(/,/g, '\\,');
294
+ out = out.replace(/\r\n|\r|\n/g, '\\n');
295
+ return out;
296
+ }
297
+
298
+ /** "2026-09-01T14:00" (or with seconds) -> "20260901T140000" */
299
+ function icalStamp(s) {
300
+ var raw = ctTrim(s);
301
+ if (!raw) return '';
302
+ var cleaned = raw.replace(/[^0-9T]/g, '');
303
+ var parts = cleaned.split('T');
304
+ var date = parts[0] || '';
305
+ var time = parts.length > 1 ? parts[1] : '';
306
+ if (!date) return '';
307
+ while (time.length < 6) { time += '0'; }
308
+ return date + 'T' + time.substring(0, 6);
309
+ }
310
+
311
+ function buildPlain(v) {
312
+ return ctStr(v.text);
313
+ }
314
+
315
+ function buildUrl(v) {
316
+ var u = ctTrim(v.url);
317
+ if (!u) return '';
318
+ // Scheme test, not a "://" test: mailto:, tel: and geo: have no slashes.
319
+ if (!/^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(u)) u = 'https://' + u;
320
+ return u;
321
+ }
322
+
323
+ function buildEmail(v) {
324
+ var to = ctTrim(v.to);
325
+ var subject = ctStr(v.subject);
326
+ var body = ctStr(v.body);
327
+ var params = [];
328
+ if (ctTrim(subject)) params.push('subject=' + encodeURIComponent(subject));
329
+ if (ctTrim(body)) params.push('body=' + encodeURIComponent(body));
330
+ // The address itself is not percent-encoded: "@" must survive.
331
+ return 'mailto:' + to + (params.length ? '?' + params.join('&') : '');
332
+ }
333
+
334
+ function buildPhone(v) {
335
+ return 'tel:' + ctTrim(v.number);
336
+ }
337
+
338
+ function buildSms(v) {
339
+ return 'SMSTO:' + ctTrim(v.number) + ':' + ctStr(v.message);
340
+ }
341
+
342
+ function buildWifi(v) {
343
+ var enc = ctTrim(v.encryption) || 'WPA';
344
+ var out = 'WIFI:T:' + enc + ';S:' + escWifi(v.ssid) + ';';
345
+ if (enc !== 'nopass') out += 'P:' + escWifi(v.password) + ';';
346
+ if (v.hidden === true || v.hidden === 'true') out += 'H:true;';
347
+ return out + ';';
348
+ }
349
+
350
+ function buildVCard(v) {
351
+ var first = ctTrim(v.first);
352
+ var last = ctTrim(v.last);
353
+ var lines = ['BEGIN:VCARD', 'VERSION:3.0'];
354
+ // The ";" in N: is structural — escape each component, then join.
355
+ lines.push('N:' + escVCardText(last) + ';' + escVCardText(first));
356
+ var fnParts = [];
357
+ if (first) fnParts.push(first);
358
+ if (last) fnParts.push(last);
359
+ lines.push('FN:' + escVCardText(fnParts.join(' ')));
360
+ if (ctTrim(v.org)) lines.push('ORG:' + escVCardText(ctTrim(v.org)));
361
+ if (ctTrim(v.title)) lines.push('TITLE:' + escVCardText(ctTrim(v.title)));
362
+ if (ctTrim(v.phone)) lines.push('TEL:' + escVCardText(ctTrim(v.phone)));
363
+ if (ctTrim(v.email)) lines.push('EMAIL:' + escVCardText(ctTrim(v.email)));
364
+ if (ctTrim(v.url)) lines.push('URL:' + escVCardText(ctTrim(v.url)));
365
+ // ADR is 7 structural components; a free-text address goes in "street".
366
+ if (ctTrim(v.address)) lines.push('ADR:;;' + escVCardText(ctTrim(v.address)) + ';;;;');
367
+ lines.push('END:VCARD');
368
+ return lines.join('\r\n');
369
+ }
370
+
371
+ function buildMecard(v) {
372
+ var segs = [];
373
+ if (ctTrim(v.name)) segs.push('N:' + escWifi(ctTrim(v.name)));
374
+ if (ctTrim(v.phone)) segs.push('TEL:' + escWifi(ctTrim(v.phone)));
375
+ if (ctTrim(v.email)) segs.push('EMAIL:' + escWifi(ctTrim(v.email)));
376
+ if (ctTrim(v.url)) segs.push('URL:' + escWifi(ctTrim(v.url)));
377
+ var out = 'MECARD:';
378
+ for (var i = 0; i < segs.length; i++) { out += segs[i] + ';'; }
379
+ return out + ';';
380
+ }
381
+
382
+ function buildGeo(v) {
383
+ var lat = ctTrim(v.lat);
384
+ var lon = ctTrim(v.lon);
385
+ var alt = ctTrim(v.alt);
386
+ var out = 'geo:' + lat + ',' + lon;
387
+ if (alt) out += ',' + alt;
388
+ return out;
389
+ }
390
+
391
+ function buildEvent(v) {
392
+ var lines = ['BEGIN:VEVENT'];
393
+ if (ctTrim(v.summary)) lines.push('SUMMARY:' + escVCardText(ctTrim(v.summary)));
394
+ if (ctTrim(v.location)) lines.push('LOCATION:' + escVCardText(ctTrim(v.location)));
395
+ var dtStart = icalStamp(v.start);
396
+ var dtEnd = icalStamp(v.end);
397
+ if (dtStart) lines.push('DTSTART:' + dtStart);
398
+ if (dtEnd) lines.push('DTEND:' + dtEnd);
399
+ lines.push('END:VEVENT');
400
+ return lines.join('\r\n');
401
+ }
402
+
403
+ var PAYLOAD_BUILDERS = {
404
+ text: buildPlain,
405
+ url: buildUrl,
406
+ email: buildEmail,
407
+ phone: buildPhone,
408
+ sms: buildSms,
409
+ wifi: buildWifi,
410
+ vcard: buildVCard,
411
+ mecard: buildMecard,
412
+ geo: buildGeo,
413
+ event: buildEvent
414
+ };
415
+
416
+ /* ---8<--- end payload builders --- */
417
+
418
+ // Data-driven field definitions. `kind` maps straight onto the control that
419
+ // gets created; `half` pairs a field with the next one in a .row grid.
420
+ // Every default is a literal (never derived from `new Date()`) so the page
421
+ // and its unit tests agree, and so no type ever opens in an error state.
422
+ var CONTENT_TYPES = [
423
+ {
424
+ type: 'text', label: 'Plain text',
425
+ fields: [
426
+ { id: 'text', label: 'Text', kind: 'textarea', value: 'https://www.sythos.net/' }
427
+ ]
428
+ },
429
+ {
430
+ type: 'url', label: 'URL',
431
+ fields: [
432
+ { id: 'url', label: 'URL', kind: 'url', value: 'https://www.sythos.net/',
433
+ placeholder: 'example.com (https:// added if missing)' }
434
+ ]
435
+ },
436
+ {
437
+ type: 'email', label: 'Email',
438
+ fields: [
439
+ { id: 'to', label: 'To', kind: 'email', value: 'hello@example.com' },
440
+ { id: 'subject', label: 'Subject', kind: 'text', value: 'Hello from a QR code' },
441
+ { id: 'body', label: 'Body', kind: 'textarea', value: 'Scanned your code — let’s talk.' }
442
+ ]
443
+ },
444
+ {
445
+ type: 'phone', label: 'Phone',
446
+ fields: [
447
+ { id: 'number', label: 'Phone number', kind: 'tel', value: '+390212345678' }
448
+ ]
449
+ },
450
+ {
451
+ type: 'sms', label: 'SMS',
452
+ fields: [
453
+ { id: 'number', label: 'Phone number', kind: 'tel', value: '+390212345678' },
454
+ { id: 'message', label: 'Message', kind: 'textarea', value: 'Hello!' }
455
+ ]
456
+ },
457
+ {
458
+ type: 'wifi', label: 'Wi-Fi network',
459
+ fields: [
460
+ { id: 'ssid', label: 'Network name (SSID)', kind: 'text', value: 'Sythos-Guest' },
461
+ { id: 'password', label: 'Password', kind: 'text', value: 'correct horse' },
462
+ { id: 'encryption', label: 'Encryption', kind: 'select', value: 'WPA', options: [
463
+ { value: 'WPA', label: 'WPA / WPA2 / WPA3' },
464
+ { value: 'WEP', label: 'WEP' },
465
+ { value: 'nopass', label: 'None (open)' }
466
+ ] },
467
+ { id: 'hidden', label: 'Hidden network', kind: 'checkbox', value: false }
468
+ ]
469
+ },
470
+ {
471
+ type: 'vcard', label: 'Contact (vCard)',
472
+ fields: [
473
+ { id: 'first', label: 'First name', kind: 'text', value: 'Ada', half: true },
474
+ { id: 'last', label: 'Last name', kind: 'text', value: 'Lovelace', half: true },
475
+ { id: 'org', label: 'Organisation', kind: 'text', value: 'Analytical Engines' },
476
+ { id: 'title', label: 'Title', kind: 'text', value: 'Mathematician' },
477
+ { id: 'phone', label: 'Phone', kind: 'tel', value: '+390212345678' },
478
+ { id: 'email', label: 'Email', kind: 'email', value: 'ada@example.com' },
479
+ { id: 'url', label: 'Website', kind: 'url', value: 'https://example.com' },
480
+ { id: 'address', label: 'Address', kind: 'text', value: '12 Baker Street, London' }
481
+ ]
482
+ },
483
+ {
484
+ type: 'mecard', label: 'Contact (MeCard)',
485
+ fields: [
486
+ { id: 'name', label: 'Name', kind: 'text', value: 'Lovelace,Ada' },
487
+ { id: 'phone', label: 'Phone', kind: 'tel', value: '+390212345678' },
488
+ { id: 'email', label: 'Email', kind: 'email', value: 'ada@example.com' },
489
+ { id: 'url', label: 'Website', kind: 'url', value: 'https://example.com' }
490
+ ]
491
+ },
492
+ {
493
+ type: 'geo', label: 'Geo location',
494
+ fields: [
495
+ { id: 'lat', label: 'Latitude', kind: 'number', step: 'any', value: '45.4642', half: true },
496
+ { id: 'lon', label: 'Longitude', kind: 'number', step: 'any', value: '9.1900', half: true },
497
+ { id: 'alt', label: 'Altitude (m, optional)', kind: 'number', step: 'any', value: '' }
498
+ ]
499
+ },
500
+ {
501
+ type: 'event', label: 'Calendar event',
502
+ fields: [
503
+ { id: 'summary', label: 'Summary', kind: 'text', value: 'Sythos Barcode Suite 1.0' },
504
+ { id: 'location', label: 'Location', kind: 'text', value: 'Milano, Italy' },
505
+ { id: 'start', label: 'Start', kind: 'datetime-local', value: '2026-09-01T14:00', half: true },
506
+ { id: 'end', label: 'End', kind: 'datetime-local', value: '2026-09-01T15:30', half: true }
507
+ ]
508
+ }
509
+ ];
510
+
511
+ var ctypeSel = el('ctype');
512
+ var ctFieldsEl = el('ctFields');
513
+ var ctType = CONTENT_TYPES[0].type;
514
+
515
+ // One value bag per content type, seeded from the defaults, so switching
516
+ // away and back inside a session never loses what was typed.
517
+ var ctValues = {};
518
+ CONTENT_TYPES.forEach(function (def) {
519
+ var bag = {};
520
+ def.fields.forEach(function (f) {
521
+ bag[f.id] = (f.kind === 'checkbox')
522
+ ? (f.value === true)
523
+ : (f.value === null || f.value === undefined ? '' : String(f.value));
524
+ });
525
+ ctValues[def.type] = bag;
526
+
527
+ var o = document.createElement('option');
528
+ o.value = def.type;
529
+ o.textContent = def.label;
530
+ ctypeSel.appendChild(o);
531
+ });
532
+ ctypeSel.value = ctType;
533
+
534
+ function ctDef(typeId) {
535
+ var found = CONTENT_TYPES.filter(function (d) { return d.type === typeId; });
536
+ return found.length ? found[0] : CONTENT_TYPES[0];
537
+ }
538
+
539
+ function ctFieldId(typeId, fieldId) {
540
+ // Prefixed so generated ids can never collide with the page's own
541
+ // (#text, #scale, #margin, #version, …).
542
+ return 'ct-' + typeId + '-' + fieldId;
543
+ }
544
+
545
+ function ctLabel(typeId, f) {
546
+ var lab = document.createElement('label');
547
+ lab.setAttribute('for', ctFieldId(typeId, f.id));
548
+ lab.textContent = f.label;
549
+ return lab;
550
+ }
551
+
552
+ function ctControl(typeId, f, bag) {
553
+ var input;
554
+ if (f.kind === 'textarea') {
555
+ input = document.createElement('textarea');
556
+ input.rows = f.rows || 3;
557
+ } else if (f.kind === 'select') {
558
+ input = document.createElement('select');
559
+ (f.options || []).forEach(function (opt) {
560
+ var o = document.createElement('option');
561
+ o.value = opt.value;
562
+ o.textContent = opt.label;
563
+ input.appendChild(o);
564
+ });
565
+ } else {
566
+ input = document.createElement('input');
567
+ // Unknown types (datetime-local on ancient engines) degrade to text.
568
+ try { input.type = f.kind; } catch (e) { input.type = 'text'; }
569
+ if (f.step) input.setAttribute('step', f.step);
570
+ }
571
+ input.id = ctFieldId(typeId, f.id);
572
+ if (f.placeholder) input.setAttribute('placeholder', f.placeholder);
573
+ if (f.kind === 'checkbox') {
574
+ input.checked = bag[f.id] === true;
575
+ } else {
576
+ input.value = bag[f.id] === null || bag[f.id] === undefined ? '' : String(bag[f.id]);
577
+ }
578
+ return input;
579
+ }
580
+
581
+ function ctCell(typeId, f, bag) {
582
+ var cell = document.createElement('div');
583
+ cell.appendChild(ctLabel(typeId, f));
584
+ cell.appendChild(ctControl(typeId, f, bag));
585
+ return cell;
586
+ }
587
+
588
+ function ctRender() {
589
+ var def = ctDef(ctType);
590
+ var bag = ctValues[def.type];
591
+ while (ctFieldsEl.firstChild) ctFieldsEl.removeChild(ctFieldsEl.firstChild);
592
+
593
+ var fields = def.fields;
594
+ var i = 0;
595
+ while (i < fields.length) {
596
+ var f = fields[i];
597
+ var next = (i + 1 < fields.length) ? fields[i + 1] : null;
598
+ if (f.half && next && next.half) {
599
+ var row = document.createElement('div');
600
+ row.className = 'row';
601
+ row.appendChild(ctCell(def.type, f, bag));
602
+ row.appendChild(ctCell(def.type, next, bag));
603
+ ctFieldsEl.appendChild(row);
604
+ i += 2;
605
+ } else {
606
+ ctFieldsEl.appendChild(ctLabel(def.type, f));
607
+ ctFieldsEl.appendChild(ctControl(def.type, f, bag));
608
+ i += 1;
609
+ }
610
+ }
611
+ }
612
+
613
+ function ctSync() {
614
+ var def = ctDef(ctType);
615
+ var bag = ctValues[def.type];
616
+ def.fields.forEach(function (f) {
617
+ var node = document.getElementById(ctFieldId(def.type, f.id));
618
+ if (!node) return;
619
+ bag[f.id] = (f.kind === 'checkbox') ? !!node.checked : node.value;
620
+ });
621
+ }
622
+
623
+ function currentPayload() {
624
+ var isQRLike = formatSel.value === 'qr' || formatSel.value === 'frameqr';
625
+ if (!isQRLike) return el('text').value;
626
+ if (ctType === 'text') return el('text').value;
627
+ var build = PAYLOAD_BUILDERS[ctType];
628
+ return build ? build(ctValues[ctType]) : '';
629
+ }
630
+
631
+ var formats = B.listFormats();
632
+ formats.forEach(function (f) {
633
+ var o = document.createElement('option');
634
+ o.value = f.id;
635
+ o.textContent = f.label + (f.canWrite ? '' : ' (unavailable)');
636
+ o.disabled = !f.canWrite;
637
+ formatSel.appendChild(o);
638
+ });
639
+ formatSel.value = formats.some(function (f) { return f.id === 'qr' && f.canWrite; })
640
+ ? 'qr' : 'ean13';
641
+
642
+ var versionSel = el('version');
643
+ for (var v = 1; v <= 40; v++) {
644
+ var o = document.createElement('option');
645
+ o.value = String(v);
646
+ o.textContent = 'Version ' + v + ' (' + (17 + 4 * v) + '×' + (17 + 4 * v) + ')';
647
+ versionSel.appendChild(o);
648
+ }
649
+
650
+ var lastMatrix = null;
651
+
652
+ function currentOptions() {
653
+ var options = {
654
+ format: formatSel.value,
655
+ ecc: el('ecc').value,
656
+ version: el('version').value ? Number(el('version').value) : undefined,
657
+ checkDigit: true
658
+ };
659
+ if (formatSel.value === 'frameqr') {
660
+ options.ecc = 'H';
661
+ options.canvas = {
662
+ shape: el('frameCanvasShape').value,
663
+ size: Math.max(1, Number(el('frameCanvasSize').value) || 5)
664
+ };
665
+ }
666
+ return options;
667
+ }
668
+
669
+ function frameArtworkOptions() {
670
+ var primary = String(el('frameCanvasUrl').value || '').trim();
671
+ return {
672
+ primary: primary || 'https://www.sythos.net/favicon.ico',
673
+ fallback: 'https://www.sythos.net/apple-touch-icon.png'
674
+ };
675
+ }
676
+
677
+ function frameArtworkStatus(text) {
678
+ el('frameArtworkStatus').textContent = text;
679
+ }
680
+
681
+ function loadFrameImage() {
682
+ if (formatSel.value !== 'frameqr') {
683
+ frameArtworkState.image = null;
684
+ frameArtworkState.status = 'idle';
685
+ frameArtworkPreview.style.display = 'none';
686
+ return;
687
+ }
688
+
689
+ var urls = frameArtworkOptions();
690
+ var request = urls.primary + '\n' + urls.fallback;
691
+ if (frameArtworkState.request === request && frameArtworkState.image) return;
692
+ frameArtworkState.request = request;
693
+ frameArtworkState.image = null;
694
+ frameArtworkState.url = '';
695
+ frameArtworkState.cors = false;
696
+ frameArtworkState.status = 'loading';
697
+ frameArtworkStatus('Loading remote artwork (CORS first; preview fallback if CORS is unavailable)…');
698
+
699
+ var candidates = [urls.primary, urls.fallback]
700
+ .filter(function (url, index, all) { return url && all.indexOf(url) === index; });
701
+ var candidateIndex = 0;
702
+
703
+ function attempt(url, cors) {
704
+ var image = new Image();
705
+ if (cors) image.crossOrigin = 'anonymous';
706
+ image.onload = function () {
707
+ if (request !== frameArtworkState.request) return;
708
+ frameArtworkState.image = image;
709
+ frameArtworkState.url = url;
710
+ frameArtworkState.cors = cors;
711
+ frameArtworkState.status = cors ? 'ready' : 'preview-only';
712
+ frameArtworkStatus(cors
713
+ ? 'Remote artwork loaded with CORS and will be embedded in PNG output.'
714
+ : 'Remote artwork is preview-only because the server did not allow CORS; PNG/SVG downloads keep the QR modules.');
715
+ renderFrameArtwork();
716
+ };
717
+ image.onerror = function () {
718
+ if (request !== frameArtworkState.request) return;
719
+ if (cors) {
720
+ // A no-CORS retry still permits an in-page preview without tainting
721
+ // the QR canvas, while keeping export behaviour deterministic.
722
+ attempt(url, false);
723
+ return;
724
+ }
725
+ candidateIndex += 1;
726
+ if (candidateIndex < candidates.length) {
727
+ attempt(candidates[candidateIndex], true);
728
+ return;
729
+ }
730
+ frameArtworkState.status = 'missing';
731
+ frameArtworkStatus('Remote artwork could not be loaded; the QR canvas remains usable without a local asset.');
732
+ renderFrameArtwork();
733
+ };
734
+ image.src = url;
735
+ }
736
+
737
+ if (candidates.length) attempt(candidates[0], true);
738
+ else frameArtworkState.status = 'missing';
739
+ }
740
+
741
+ function renderOptions() {
742
+ return {
743
+ scale: Math.max(1, Number(el('scale').value) || 6),
744
+ margin: Math.max(0, Number(el('margin').value) || 0),
745
+ dark: el('dark').value,
746
+ light: el('light').value
747
+ };
748
+ }
749
+
750
+ function frameArtworkGeometry(matrix, options) {
751
+ var frame = matrix && matrix.frameqr && matrix.frameqr.canvas;
752
+ if (!frame) return null;
753
+ var scale = options.scale;
754
+ var margin = options.margin;
755
+ var rotated = frame.angle === 90 || frame.angle === 270;
756
+ var width = (rotated ? frame.height : frame.width) * scale;
757
+ var height = (rotated ? frame.width : frame.height) * scale;
758
+ return {
759
+ frame: frame,
760
+ centerX: (margin + frame.centerX + 0.5) * scale,
761
+ centerY: (margin + frame.centerY + 0.5) * scale,
762
+ width: width,
763
+ height: height,
764
+ angle: frame.angle || 0
765
+ };
766
+ }
767
+
768
+ function clipFrameShape(ctx, geometry) {
769
+ var frame = geometry.frame;
770
+ var halfWidth = geometry.width / 2;
771
+ var halfHeight = geometry.height / 2;
772
+ ctx.beginPath();
773
+ if (frame.shape === 'circle') {
774
+ ctx.ellipse(0, 0, halfWidth, halfHeight, 0, 0, Math.PI * 2);
775
+ } else if (frame.shape === 'diamond') {
776
+ ctx.moveTo(0, -halfHeight);
777
+ ctx.lineTo(halfWidth, 0);
778
+ ctx.lineTo(0, halfHeight);
779
+ ctx.lineTo(-halfWidth, 0);
780
+ ctx.closePath();
781
+ } else {
782
+ ctx.rect(-halfWidth, -halfHeight, geometry.width, geometry.height);
783
+ }
784
+ ctx.clip();
785
+ }
786
+
787
+ function imageIsCorsSafe(image) {
788
+ try {
789
+ var probe = document.createElement('canvas');
790
+ probe.width = 1;
791
+ probe.height = 1;
792
+ var ctx = probe.getContext('2d');
793
+ if (!ctx) return false;
794
+ ctx.drawImage(image, 0, 0, 1, 1);
795
+ ctx.getImageData(0, 0, 1, 1);
796
+ return true;
797
+ } catch (e) {
798
+ return false;
799
+ }
800
+ }
801
+
802
+ function positionFramePreview(geometry) {
803
+ var image = frameArtworkState.image;
804
+ if (!image || !geometry) {
805
+ frameArtworkPreview.style.display = 'none';
806
+ return;
807
+ }
808
+ var stageRect = stage.getBoundingClientRect();
809
+ var canvasRect = canvas.getBoundingClientRect();
810
+ var scaleX = canvas.width ? canvasRect.width / canvas.width : 1;
811
+ var scaleY = canvas.height ? canvasRect.height / canvas.height : 1;
812
+ var left = canvasRect.left - stageRect.left + (geometry.centerX - geometry.width / 2) * scaleX;
813
+ var top = canvasRect.top - stageRect.top + (geometry.centerY - geometry.height / 2) * scaleY;
814
+ frameArtworkPreview.src = image.src;
815
+ frameArtworkPreview.alt = 'Remote canvas artwork';
816
+ frameArtworkPreview.style.left = left + 'px';
817
+ frameArtworkPreview.style.top = top + 'px';
818
+ frameArtworkPreview.style.width = geometry.width * scaleX + 'px';
819
+ frameArtworkPreview.style.height = geometry.height * scaleY + 'px';
820
+ frameArtworkPreview.style.clipPath = geometry.frame.shape === 'circle'
821
+ ? 'ellipse(50% 50% at 50% 50%)'
822
+ : geometry.frame.shape === 'diamond'
823
+ ? 'polygon(50% 0, 100% 50%, 50% 100%, 0 50%)'
824
+ : 'inset(0)';
825
+ frameArtworkPreview.style.transform = 'rotate(' + geometry.angle + 'deg)';
826
+ frameArtworkPreview.style.display = 'block';
827
+ }
828
+
829
+ function renderFrameArtwork() {
830
+ var isFrameQR = formatSel.value === 'frameqr';
831
+ var geometry = frameArtworkGeometry(lastMatrix, renderOptions());
832
+ if (!isFrameQR || !geometry || !frameArtworkState.image) {
833
+ frameArtworkPreview.style.display = 'none';
834
+ return;
835
+ }
836
+
837
+ if (!frameArtworkState.cors || !imageIsCorsSafe(frameArtworkState.image)) {
838
+ frameArtworkState.cors = false;
839
+ if (frameArtworkState.status !== 'preview-only') {
840
+ frameArtworkState.status = 'preview-only';
841
+ frameArtworkStatus('Remote artwork is preview-only because the server did not allow CORS; PNG/SVG downloads keep the QR modules.');
842
+ }
843
+ positionFramePreview(geometry);
844
+ return;
845
+ }
846
+
847
+ frameArtworkPreview.style.display = 'none';
848
+ var ctx = canvas.getContext('2d');
849
+ if (!ctx) return;
850
+ var image = frameArtworkState.image;
851
+ var imageWidth = image.naturalWidth || image.width || geometry.width;
852
+ var imageHeight = image.naturalHeight || image.height || geometry.height;
853
+ var coverScale = Math.max(geometry.width / imageWidth, geometry.height / imageHeight);
854
+ ctx.save();
855
+ ctx.translate(geometry.centerX, geometry.centerY);
856
+ ctx.rotate((geometry.angle * Math.PI) / 180);
857
+ clipFrameShape(ctx, geometry);
858
+ ctx.drawImage(
859
+ image,
860
+ -imageWidth * coverScale / 2,
861
+ -imageHeight * coverScale / 2,
862
+ imageWidth * coverScale,
863
+ imageHeight * coverScale
864
+ );
865
+ ctx.restore();
866
+ }
867
+
868
+ // User-supplied strings never go through innerHTML.
869
+ function showError(msg) {
870
+ var box = el('err');
871
+ while (box.firstChild) box.removeChild(box.firstChild);
872
+ if (!msg) return;
873
+ var d = document.createElement('div');
874
+ d.className = 'error';
875
+ d.textContent = msg;
876
+ box.appendChild(d);
877
+ }
878
+
879
+ function update() {
880
+ var isQR = formatSel.value === 'qr' || formatSel.value === 'frameqr';
881
+ var isFrameQR = formatSel.value === 'frameqr';
882
+ el('qrOpts').style.display = isQR ? '' : 'none';
883
+ el('frameOpts').style.display = isFrameQR ? '' : 'none';
884
+ el('ctWrap').style.display = isQR ? '' : 'none';
885
+ ctFieldsEl.style.display = isQR ? '' : 'none';
886
+ el('payloadWrap').style.display = isQR ? '' : 'none';
887
+ el('plainWrap').style.display = isQR ? 'none' : '';
888
+ el('ecc').disabled = isFrameQR;
889
+ if (isFrameQR) el('ecc').value = 'H';
890
+
891
+ var info = formats.filter(function (f) { return f.id === formatSel.value; })[0];
892
+ el('formatHint').textContent = info
893
+ ? (info.kind + ' · ' + (info.canRead ? 'this suite can also read it' : 'write-only in this build'))
894
+ : '';
895
+
896
+ var payload = currentPayload();
897
+ el('payloadOut').textContent = payload;
898
+ el('payloadLen').textContent = payload.length + ' character' + (payload.length === 1 ? '' : 's');
899
+
900
+ showError('');
901
+ try {
902
+ lastMatrix = B.encode(payload, currentOptions());
903
+ } catch (e) {
904
+ lastMatrix = null;
905
+ showError(String(e && e.message ? e.message : e));
906
+ el('meta').innerHTML = '';
907
+ frameArtworkPreview.style.display = 'none';
908
+ var ctx = canvas.getContext('2d');
909
+ canvas.width = 10; canvas.height = 10;
910
+ if (ctx) ctx.clearRect(0, 0, 10, 10);
911
+ return;
912
+ }
913
+
914
+ // Frame artwork is composited through a 2D context; do not commit the
915
+ // canvas to WebGL first because a canvas can own only one context type.
916
+ var renderOpts = renderOptions();
917
+ var out = isFrameQR
918
+ ? { backend: B.toCanvas(lastMatrix, canvas, renderOpts) ? '2d' : 'none' }
919
+ : B.renderToCanvasAuto(lastMatrix, canvas, renderOpts);
920
+
921
+ if (isFrameQR) {
922
+ loadFrameImage();
923
+ renderFrameArtwork();
924
+ } else {
925
+ frameArtworkPreview.style.display = 'none';
926
+ }
927
+
928
+ el('meta').innerHTML =
929
+ '<span>modules <b>' + lastMatrix.width + '×' + lastMatrix.height + '</b></span>' +
930
+ '<span>pixels <b>' + canvas.width + '×' + canvas.height + '</b></span>' +
931
+ '<span>backend <b>' + out.backend + '</b></span>' +
932
+ '<span>bytes in <b>' + payload.length + '</b></span>';
933
+ }
934
+
935
+ function saveBlob(blob, name) {
936
+ var url = URL.createObjectURL(blob);
937
+ var a = document.createElement('a');
938
+ a.href = url;
939
+ a.download = name;
940
+ document.body.appendChild(a);
941
+ a.click();
942
+ document.body.removeChild(a);
943
+ setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
944
+ }
945
+
946
+ el('download-svg').addEventListener('click', function () {
947
+ if (!lastMatrix) return;
948
+ var svg = B.toSVG(lastMatrix, renderOptions());
949
+ if (formatSel.value === 'frameqr' && frameArtworkState.image) {
950
+ frameArtworkStatus('SVG export contains the QR profile only; external artwork remains a remote preview and is not copied locally.');
951
+ }
952
+ saveBlob(new Blob([svg], { type: 'image/svg+xml' }), formatSel.value + '.svg');
953
+ });
954
+
955
+ el('download-png').addEventListener('click', function () {
956
+ if (!lastMatrix) return;
957
+ if (formatSel.value === 'frameqr' && frameArtworkState.cors) {
958
+ try {
959
+ canvas.toBlob(function (blob) {
960
+ if (blob) {
961
+ saveBlob(blob, formatSel.value + '.png');
962
+ return;
963
+ }
964
+ frameArtworkStatus('PNG export could not include remote artwork; exporting the QR profile without a local asset.');
965
+ B.toPNG(lastMatrix, renderOptions()).then(function (bytes) {
966
+ saveBlob(new Blob([bytes], { type: 'image/png' }), formatSel.value + '.png');
967
+ });
968
+ }, 'image/png');
969
+ return;
970
+ } catch (e) {
971
+ frameArtworkStatus('PNG export could not include remote artwork; exporting the QR profile without a local asset.');
972
+ }
973
+ }
974
+ B.toPNG(lastMatrix, renderOptions()).then(function (bytes) {
975
+ saveBlob(new Blob([bytes], { type: 'image/png' }), formatSel.value + '.png');
976
+ });
977
+ });
978
+
979
+ formatSel.addEventListener('change', function () {
980
+ if (SAMPLES[formatSel.value]) el('text').value = SAMPLES[formatSel.value];
981
+ update();
982
+ });
983
+
984
+ ctypeSel.addEventListener('change', function () {
985
+ ctSync(); // keep whatever was typed under the old type
986
+ ctType = ctypeSel.value;
987
+ ctRender();
988
+ update();
989
+ });
990
+
991
+ // Delegated: the generated controls are replaced wholesale on every type
992
+ // switch, so listeners live on the container instead. Both events bubble.
993
+ ctFieldsEl.addEventListener('input', function () { ctSync(); update(); });
994
+ ctFieldsEl.addEventListener('change', function () { ctSync(); update(); });
995
+
996
+ ['text', 'ecc', 'version', 'scale', 'margin', 'dark', 'light'].forEach(function (id) {
997
+ el(id).addEventListener('input', update);
998
+ el(id).addEventListener('change', update);
999
+ });
1000
+ ['frameCanvasShape', 'frameCanvasSize', 'frameCanvasUrl'].forEach(function (id) {
1001
+ el(id).addEventListener('input', update);
1002
+ el(id).addEventListener('change', update);
1003
+ });
1004
+ window.addEventListener('resize', function () {
1005
+ if (formatSel.value === 'frameqr' && !frameArtworkState.cors) renderFrameArtwork();
1006
+ });
1007
+
1008
+ ctRender();
1009
+ update();
1010
+ })();
1011
+ </script>
1012
+ </body>
1013
+ </html>