openvibers/openvibe.tools Commits

Barcode & QR tools: QR studio, every symbology, scanner, GTIN/GS1 checker, bulk labels, image URLs (#98)

OP OpenVibers committed 19 days ago · 09718c0e08 · 24 files, +2886 −29

* Barcode & QR tools: a QR studio, every barcode symbology, a camera and picture scanner, a GTIN/GS1 checker and bulk labels, all in the browser, with image URLs and run-API engines for scripts and agents

* Tests: the codes family's 14 tools in the registry counts

Changes

ddiff --git a/apps/gateway/package-lock.json b/apps/gateway/package-lock.json
iindex 4bb9072..b5d994f 100644
---- a/apps/gateway/package-lock.json
++++ b/apps/gateway/package-lock.json
@@@ -10,6 +10,7 @@
"license": "MIT",
"dependencies": {
"@electric-sql/pglite": "^0.5.8",
++ "bwip-js": "4.11.4",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
@@@ -18,10 +19,11 @@
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.9",
-- "pg": "^8.23.0"
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.213.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.64.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.12.0",
++ "pg": "^8.23.0",
++ "zxing-wasm": "3.1.5"
}
},
"node_modules/@electric-sql/pglite": {
@@@ -36,6 +38,12 @@
"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
"license": "MIT"
},
++ "node_modules/@types/emscripten": {
++ "version": "1.41.6",
++ "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.6.tgz",
++ "integrity": "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==",
++ "license": "MIT"
++ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@@ -118,6 +126,15 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
++ "node_modules/bwip-js": {
++ "version": "4.11.4",
++ "resolved": "https://registry.npmjs.org/bwip-js/-/bwip-js-4.11.4.tgz",
++ "integrity": "sha512-FOXnOQkinxog8FwxdnPB+8k2wRqwPnqXwxX37KcXOtMNW+9CinOkGv9MtIoRC4HX/UyogT0/9lIp6FDm2DcquQ==",
++ "license": "MIT",
++ "bin": {
++ "bwip-js": "bin/bwip-js.js"
++ }
++ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@@ -893,9 +910,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.143.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
-- "integrity": "sha512-Cdbf+9RoPAUBibpM2zXQvtsiKMugb55dKubTt8pFZvAmxsLtGvX+zaC++yL1vOSub8FX3+GgqcGtoJS6tklrUg==",
++ "version": "0.213.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.213.0",
++ "integrity": "sha512-QeJamMrqMoGyvPoD80pyGZepF3GTaKJYVWBVeFsigVulZBmt8zeQOeJZ3TMwtUydT0bPje3kUt98/czwXvR7sg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -909,10 +926,13 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.44.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
-- "integrity": "sha512-8OjkIXVPUrGaOriBeHGK5GDoz4IRpjpKt7knZ5HSZnUpi0ND1Z3XD1YET4HiRydweT/rQy9/1h3oKod+tAP7Hg==",
++ "version": "0.64.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.64.0",
++ "integrity": "sha512-m7Xey0eclm7RUyEjI9NVTLXqKwgnuCYqY0poX17hDezF9vKRwBB8Xh+WqHh5aZN7QHmM16CKG8GPKS5FUcKg4w==",
"license": "MIT",
++ "bin": {
++ "openvibe-download": "bin/openvibe-download.js"
++ },
"engines": {
"node": ">=20"
},
@@@ -934,9 +954,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.1.9",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.9",
-- "integrity": "sha512-C12UmJeZMy+pugb5C/wSAu9k32lv4d6ZAln4HSh01LXp69Ufi2kWXKcrbUFbWJ89lYgolok33YGOVZJ5UynvkA==",
++ "version": "4.12.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.12.0",
++ "integrity": "sha512-k0i63sCPfHtz1KF5MJSI+CGv5VwBf4ISRzxJRQdorkeMKOTtABlAQfiH1uf9kJsd5/Wn7VUTTHlhhOLSX6ZTzw==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
@@@ -1356,6 +1376,18 @@
"node": ">= 0.8"
}
},
++ "node_modules/tagged-tag": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
++ "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=20"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@@ -1365,6 +1397,21 @@
"node": ">=0.6"
}
},
++ "node_modules/type-fest": {
++ "version": "5.10.0",
++ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz",
++ "integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==",
++ "license": "(MIT OR CC0-1.0)",
++ "dependencies": {
++ "tagged-tag": "^1.0.0"
++ },
++ "engines": {
++ "node": ">=20"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@@ -1413,6 +1460,19 @@
"engines": {
"node": ">=0.4"
}
++ },
++ "node_modules/zxing-wasm": {
++ "version": "3.1.5",
++ "resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.5.tgz",
++ "integrity": "sha512-jmxXvTCR/qZxMz2XwR6V+nL2BlDTD1aux7ZWSH9BV77TG9u4k0VyHba/NCCHBHJ60Wg6VV0QL/SMlJczKhJI4w==",
++ "license": "MIT",
++ "dependencies": {
++ "@types/emscripten": "^1.41.6",
++ "type-fest": "^5.10.0"
++ },
++ "peerDependencies": {
++ "@types/emscripten": ">=1.39.6"
++ }
}
}
}
ddiff --git a/apps/gateway/package.json b/apps/gateway/package.json
iindex 87123b4..62bf5a0 100644
---- a/apps/gateway/package.json
++++ b/apps/gateway/package.json
@@@ -23,6 +23,8 @@
"jsonwebtoken": "^9.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.213.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.64.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.12.0"
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.12.0",
++ "bwip-js": "4.11.4",
++ "zxing-wasm": "3.1.5"
}
}
ddiff --git a/apps/gateway/public/codes.html b/apps/gateway/public/codes.html
nnew file mode 100644
iindex 0000000..e607036
---- /dev/null
++++ b/apps/gateway/public/codes.html
@@@ -0,0 +1,527 @@
++<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="UTF-8">
++<meta name="viewport" content="width=device-width, initial-scale=1.0">
++<title>Barcode & QR Tools — OpenVibe</title>
++<link rel="icon" type="image/svg+xml" data-ov-icon="tools" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%22-32%20-32%2064%2064%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22ring%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2338bdf8%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%232563eb%22%2F%3E%3C%2FlinearGradient%3E%0A%3CradialGradient%20id%3D%22glow%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2338bdf8%22%20stop-opacity%3D%22.34%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%2338bdf8%22%20stop-opacity%3D%220%22%2F%3E%3C%2FradialGradient%3E%0A%3CradialGradient%20id%3D%22bg%22%20cx%3D%22.5%22%20cy%3D%22.38%22%20r%3D%22.8%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2316223a%22%2F%3E%3Cstop%20offset%3D%22.6%22%20stop-color%3D%22%230a0f1c%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23060912%22%2F%3E%3C%2FradialGradient%3E%3C%2Fdefs%3E%3Crect%20x%3D%22-32%22%20y%3D%22-32%22%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2215%22%20fill%3D%22url(%23bg)%22%2F%3E%3Cg%20transform%3D%22scale(1.22)%22%3E%0A%3Ccircle%20r%3D%2221%22%20fill%3D%22url(%23glow)%22%2F%3E%0A%3Ccircle%20r%3D%2218%22%20fill%3D%22none%22%20stroke%3D%22url(%23ring)%22%20stroke-width%3D%224%22%20opacity%3D%22.38%22%2F%3E%0A%3Cpath%20d%3D%22M0%2C-18%20A18%2C18%200%200%2C1%2017.4%2C4.6%22%20fill%3D%22none%22%20stroke%3D%22url(%23ring)%22%20stroke-width%3D%224.4%22%20stroke-linecap%3D%22round%22%2F%3E%0A%3Cpath%20d%3D%22M-9.5%2C-7%20L0%2C10%20L9.5%2C-7%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%224.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%0A%3Ccircle%20cx%3D%2217.4%22%20cy%3D%224.6%22%20r%3D%223.2%22%20fill%3D%22%23ffffff%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E">
++<meta name="theme-color" content="#0a0f1c">
++<meta name="color-scheme" content="dark light">
++<link rel="apple-touch-icon" href="/assets/logo-192.png">
++<link rel="manifest" href="/manifest.webmanifest">
++<meta name="description" content="Make QR codes, barcodes and Data Matrix, scan them with your camera or a picture, and check GTIN, ISBN and GS1 numbers. Free, in your browser.">
++<meta property="og:title" content="Barcode & QR Tools">
++<meta property="og:description" content="Make, scan and check QR codes, barcodes and Data Matrix, in your browser.">
++<meta property="og:type" content="website">
++<meta property="og:url" content="https://barcodes.openvibe.tools">
++<link rel="canonical" href="https://barcodes.openvibe.tools">
++<!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.
++ Paints the cached theme synchronously so the default palette never flashes. Reads
++ localStorage only: no network, nothing to block on. theme-loader.js still runs after
++ and reconciles with the account. -->
++<script>
++(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();
++</script>
++<style>
++/* Barcode & QR tools. Every colour comes from the OpenVibe theme tokens (theme-loader.js sets them on :root), so the
++ page follows the visitor's theme, light or dark; the fallbacks are the default Vibe theme. */
++:root{
++ --c-bg:var(--bg-primary,#0a0f1c);--c-panel:var(--bg-secondary,#101828);--c-raised:var(--bg-tertiary,#172238);--c-card:var(--bg-card,#131c2e);--c-hover:var(--bg-hover,#1c2a44);--c-input:var(--bg-input,#0d1424);
++ --c-tx:var(--text-primary,#e6edf7);--c-tx2:var(--text-secondary,#96a7c2);--c-tx3:var(--text-muted,#8192ac);
++ --c-ac:var(--accent,#3b82f6);--c-acl:var(--accent-light,#60a5fa);--c-acs:var(--accent-strong,#3472d8);--c-onac:var(--on-accent-strong,#fff);--c-glow:var(--accent-glow,rgba(59,130,246,.35));
++ --c-brd:var(--border,#1f2d47);--c-brd2:var(--border-light,#2c3d5c);
++ --c-ok:var(--success-text,#22c55e);--c-warn:var(--warning-text,#f59e0b);--c-bad:var(--danger-text,#f26262);
++ --c-shadow:var(--shadow,0 2px 12px rgba(0,0,0,.45));--c-shadow-lg:var(--shadow-lg,0 8px 32px rgba(0,0,0,.6));
++ --r:14px;--r-s:10px;--mono:'JetBrains Mono',ui-monospace,Menlo,Consolas,monospace;
++ /* the SEO block under the tool (server/seo/render.js) */
++ --tx:var(--c-tx);--tx2:var(--c-tx2);--tx3:var(--c-tx3);--bg2:var(--c-panel);--brd:var(--c-brd);--ac:var(--c-ac);
++}
++*,*::before,*::after{box-sizing:border-box}
++html{background:var(--c-bg);-webkit-text-size-adjust:100%}
++body{margin:0;min-height:100vh;font:15px/1.5 Inter,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;font-size:calc(15px * var(--ov-text-scale,1));background:var(--c-bg);color:var(--c-tx);-webkit-font-smoothing:antialiased;overflow-x:hidden}
++a{color:var(--c-acl)}
++button,input,select,textarea{font:inherit;color:inherit}
++[hidden]{display:none!important}
++.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
++#navbar-mount{min-height:52px}
++.ic{width:1em;height:1em;flex:none;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;vertical-align:-.125em}
++
++/* ── Frame: family bar, tabs, title ── */
++.cx{max-width:1180px;margin:0 auto;padding:18px max(16px,env(safe-area-inset-left)) 40px max(16px,env(safe-area-inset-right))}
++.cx-bar{display:flex;align-items:center;gap:14px;margin-bottom:14px;min-width:0}
++.cx-fam{display:inline-flex;align-items:center;gap:8px;flex:none;color:var(--c-tx);text-decoration:none;font-weight:750;font-size:.95rem;letter-spacing:-.01em}
++.cx-fam .mark{display:grid;place-items:center;width:30px;height:30px;border-radius:9px;background:var(--c-acs);color:var(--c-onac);font-size:16px}
++.cx-tabs{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none;padding:3px;border-radius:12px;background:var(--c-panel);border:1px solid var(--c-brd);min-width:0;margin-left:auto}
++.cx-tabs::-webkit-scrollbar{display:none}
++.cx-tabs a{display:inline-flex;align-items:center;gap:6px;flex:none;padding:7px 12px;border-radius:9px;color:var(--c-tx2);text-decoration:none;font-size:.86rem;font-weight:620;white-space:nowrap;transition:background .15s,color .15s}
++.cx-tabs a:hover{color:var(--c-tx);background:var(--c-hover)}
++.cx-tabs a[aria-current="page"]{background:var(--c-acs);color:var(--c-onac)}
++.cx-tabs a:focus-visible,.seg button:focus-visible,.btn:focus-visible,.chip:focus-visible{outline:2px solid var(--c-ac);outline-offset:2px}
++.cx-head{margin:10px 0 18px}
++.cx-head h1{margin:0;font-size:clamp(1.5rem,3.4vw,2.1rem);line-height:1.15;font-weight:800;letter-spacing:-.025em;text-wrap:balance}
++.cx-head p{margin:6px 0 0;color:var(--c-tx2);max-width:70ch}
++@media (max-width:760px){.cx-bar{flex-direction:column;align-items:stretch;gap:10px}.cx-tabs{margin-left:0}}
++
++/* ── Panels and controls ── */
++.panel{background:var(--c-card);border:1px solid var(--c-brd);border-radius:var(--r);padding:16px;min-width:0}
++.panel+.panel{margin-top:12px}
++.panel>h2,.panel>summary{margin:0 0 12px;font-size:.78rem;font-weight:750;letter-spacing:.07em;text-transform:uppercase;color:var(--c-tx3)}
++details.panel>summary{cursor:pointer;list-style:none;display:flex;align-items:center;justify-content:space-between;margin:0}
++details.panel>summary::-webkit-details-marker{display:none}
++details.panel>summary::after{content:'';width:8px;height:8px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .15s;margin-right:4px}
++details.panel[open]>summary{margin-bottom:12px}
++details.panel[open]>summary::after{transform:rotate(-135deg)}
++.row{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end}
++.row>*{min-width:0}
++.grow{flex:1 1 180px}
++.field{display:flex;flex-direction:column;gap:5px;margin-bottom:10px;min-width:0}
++.field>span,.lbl{font-size:.8rem;font-weight:650;color:var(--c-tx2)}
++.field small{color:var(--c-tx3);font-size:.76rem}
++input[type=text],input[type=url],input[type=email],input[type=tel],input[type=number],input[type=password],input[type=datetime-local],input[type=search],select,textarea{width:100%;min-width:0;padding:9px 11px;border-radius:var(--r-s);border:1px solid var(--c-brd2);background:var(--c-input);color:var(--c-tx);outline:none;transition:border-color .15s,box-shadow .15s}
++textarea{resize:vertical;min-height:84px;line-height:1.45}
++.mono{font-family:var(--mono);font-size:.88em}
++input:focus,select:focus,textarea:focus{border-color:var(--c-ac);box-shadow:0 0 0 3px var(--c-glow)}
++input::placeholder,textarea::placeholder{color:var(--c-tx3)}
++select{appearance:none;background-image:linear-gradient(45deg,transparent 50%,var(--c-tx2) 50%),linear-gradient(135deg,var(--c-tx2) 50%,transparent 50%);background-position:calc(100% - 16px) 55%,calc(100% - 11px) 55%;background-size:5px 5px;background-repeat:no-repeat;padding-right:30px}
++.seg{display:inline-flex;flex-wrap:wrap;gap:3px;padding:3px;border-radius:11px;background:var(--c-raised);border:1px solid var(--c-brd);max-width:100%}
++.field>.seg{align-self:flex-start}
++.seg button{border:0;background:none;padding:6px 11px;border-radius:8px;font-size:.84rem;font-weight:620;color:var(--c-tx2);cursor:pointer;white-space:nowrap}
++.seg button:hover{color:var(--c-tx)}
++.seg button[aria-pressed="true"]{background:var(--c-acs);color:var(--c-onac)}
++.chips{display:flex;flex-wrap:wrap;gap:6px}
++.chip{display:inline-flex;align-items:center;gap:7px;padding:8px 12px;border-radius:999px;border:1px solid var(--c-brd2);background:var(--c-raised);color:var(--c-tx2);font-size:.85rem;font-weight:620;cursor:pointer}
++.chip:hover{color:var(--c-tx);border-color:var(--c-ac)}
++.chip[aria-pressed="true"]{background:var(--c-acs);border-color:var(--c-acs);color:var(--c-onac)}
++.range{display:flex;align-items:center;gap:10px}
++.range input[type=range]{flex:1;accent-color:var(--c-ac);min-width:0}
++.range output{min-width:4.2em;text-align:right;font-variant-numeric:tabular-nums;font-size:.84rem;color:var(--c-tx2)}
++.color{display:flex;align-items:center;gap:8px;padding:4px 8px 4px 4px;border-radius:var(--r-s);border:1px solid var(--c-brd2);background:var(--c-input)}
++.color input[type=color]{width:30px;height:30px;padding:0;border:0;border-radius:7px;background:none;cursor:pointer;flex:none}
++.color input[type=color]::-webkit-color-swatch-wrapper{padding:0}.color input[type=color]::-webkit-color-swatch{border:1px solid rgba(127,127,127,.4);border-radius:7px}
++.color input[type=text]{border:0;padding:4px 0;background:none;box-shadow:none;font-family:var(--mono);font-size:.84rem;width:6.5em}
++.check{display:inline-flex;align-items:center;gap:8px;font-size:.88rem;color:var(--c-tx2);cursor:pointer;user-select:none}
++.check input{accent-color:var(--c-ac);width:16px;height:16px}
++.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:9px 15px;border-radius:var(--r-s);border:1px solid var(--c-brd2);background:var(--c-raised);color:var(--c-tx);font-weight:650;font-size:.88rem;cursor:pointer;text-decoration:none;white-space:nowrap;transition:filter .15s,border-color .15s,background .15s}
++.btn:hover{border-color:var(--c-ac)}
++.btn:disabled{opacity:.5;cursor:not-allowed}
++.btn.primary{background:var(--c-acs);border-color:var(--c-acs);color:var(--c-onac)}
++.btn.primary:hover{filter:brightness(1.08)}
++.btn.ghost{background:none}
++.btn.small{padding:6px 10px;font-size:.8rem}
++.note{display:flex;gap:10px;align-items:flex-start;padding:10px 12px;border-radius:var(--r-s);background:var(--c-raised);border:1px solid var(--c-brd);font-size:.86rem;color:var(--c-tx2);margin:8px 0}
++.note b{color:var(--c-tx)}
++.note.ok{border-color:color-mix(in srgb,var(--c-ok) 45%,transparent)}.note.ok .ic{color:var(--c-ok)}
++.note.warn{border-color:color-mix(in srgb,var(--c-warn) 50%,transparent)}.note.warn .ic{color:var(--c-warn)}
++.note.bad{border-color:color-mix(in srgb,var(--c-bad) 50%,transparent)}.note.bad .ic{color:var(--c-bad)}
++.note .ic{margin-top:2px;font-size:16px}
++.note .act{margin-left:auto}
++.muted{color:var(--c-tx3)}
++.kbd{font-family:var(--mono);font-size:.82em;padding:1px 6px;border-radius:6px;background:var(--c-raised);border:1px solid var(--c-brd)}
++
++/* ── Studio: controls beside a sticky preview ── */
++.studio{display:grid;grid-template-columns:minmax(0,1fr) minmax(300px,400px);gap:16px;align-items:start}
++.preview{position:sticky;top:64px;display:flex;flex-direction:column;gap:12px}
++.stage{position:relative;display:grid;place-items:center;min-height:300px;padding:22px;border-radius:var(--r);border:1px solid var(--c-brd);overflow:hidden;
++ background-color:var(--c-panel);background-image:linear-gradient(45deg,rgba(127,127,127,.07) 25%,transparent 25%,transparent 75%,rgba(127,127,127,.07) 75%),linear-gradient(45deg,rgba(127,127,127,.07) 25%,transparent 25%,transparent 75%,rgba(127,127,127,.07) 75%);background-size:18px 18px;background-position:0 0,9px 9px}
++.stage .art{max-width:100%;max-height:340px;display:block;filter:drop-shadow(0 6px 18px rgba(0,0,0,.25));transition:opacity .15s}
++.stage .art.svg{width:100%;height:auto}
++.stage .art svg{display:block;width:100%;height:auto;max-height:340px}
++.stage canvas.art{image-rendering:pixelated}
++.stage .empty{color:var(--c-tx3);text-align:center;font-size:.9rem;max-width:26ch}
++.stage .err{color:var(--c-bad);text-align:center;font-size:.9rem;max-width:34ch}
++.verdict{display:flex;align-items:center;gap:8px;font-size:.84rem;font-weight:650;color:var(--c-tx2);min-height:22px}
++.verdict .dot{width:9px;height:9px;border-radius:50%;background:var(--c-tx3);flex:none}
++.verdict.ok .dot{background:var(--c-ok);box-shadow:0 0 0 4px color-mix(in srgb,var(--c-ok) 18%,transparent)}
++.verdict.bad .dot{background:var(--c-bad)}
++.verdict.wait .dot{animation:pulse 1s ease-in-out infinite}
++@keyframes pulse{50%{opacity:.35}}
++.facts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}
++.facts div{padding:8px 10px;border-radius:var(--r-s);background:var(--c-raised);border:1px solid var(--c-brd);min-width:0}
++.facts b{display:block;font-size:.95rem;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
++.facts span{display:block;font-size:.7rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--c-tx3)}
++.dl{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px}
++@media (max-width:900px){.studio{grid-template-columns:minmax(0,1fr)}.preview{position:static;order:-1}.stage{min-height:220px}.stage .art,.stage .art svg{max-height:260px}}
++@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
++
++/* ── Hub ── */
++.smart{padding:18px;border-radius:calc(var(--r) + 4px);background:linear-gradient(135deg,color-mix(in srgb,var(--c-ac) 14%,var(--c-card)),var(--c-card));border:1px solid var(--c-brd2);display:grid;grid-template-columns:minmax(0,1fr) 220px;gap:18px;align-items:center;margin-bottom:22px}
++.smart h2{margin:0 0 4px;font-size:1.1rem;letter-spacing:-.01em}
++.smart p{margin:0 0 10px;color:var(--c-tx2);font-size:.9rem}
++.smart .stage{min-height:180px;padding:14px}
++.smart .stage .art,.smart .stage .art svg{max-height:170px}
++.smart .why{font-size:.86rem;color:var(--c-tx2);margin-top:8px;min-height:1.4em}
++.smart .why b{color:var(--c-tx)}
++@media (max-width:700px){.smart{grid-template-columns:minmax(0,1fr)}}
++.group-h{display:flex;align-items:center;gap:8px;margin:22px 0 10px;font-size:.8rem;font-weight:750;letter-spacing:.07em;text-transform:uppercase;color:var(--c-tx3)}
++.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px}
++.tile{display:flex;gap:12px;align-items:flex-start;padding:14px;border-radius:var(--r);background:var(--c-card);border:1px solid var(--c-brd);color:var(--c-tx);text-decoration:none;transition:border-color .15s,transform .15s,background .15s;min-width:0}
++.tile:hover{border-color:var(--c-ac);transform:translateY(-1px);background:var(--c-hover)}
++.tile .ti{display:grid;place-items:center;width:40px;height:40px;border-radius:11px;background:color-mix(in srgb,var(--c-ac) 16%,transparent);color:var(--c-acl);font-size:19px;flex:none}
++.tile b{display:block;font-size:.92rem}
++.tile small{display:block;color:var(--c-tx2);font-size:.8rem;margin-top:2px}
++
++/* ── Scanner ── */
++.scan{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:16px;align-items:start}
++@media (max-width:900px){.scan{grid-template-columns:minmax(0,1fr)}}
++.viewport{position:relative;aspect-ratio:4/3;border-radius:var(--r);overflow:hidden;background:#000;border:1px solid var(--c-brd);display:grid;place-items:center}
++.viewport video,.viewport img.shot{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
++.viewport canvas.overlay{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
++.viewport .aim{position:absolute;inset:18% 22%;border-radius:18px;pointer-events:none;box-shadow:0 0 0 999px rgba(0,0,0,.28);outline:2px solid rgba(255,255,255,.7);outline-offset:-2px}
++.viewport .aim::after{content:'';position:absolute;left:8%;right:8%;height:2px;top:50%;background:linear-gradient(90deg,transparent,var(--c-ac),transparent);animation:sweep 2.4s ease-in-out infinite}
++@keyframes sweep{0%,100%{transform:translateY(-60px)}50%{transform:translateY(60px)}}
++.drop{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:20px;text-align:center;color:#d6deea;background:radial-gradient(circle at 50% 40%,#1b2536,#06090f)}
++.drop.over{outline:3px dashed var(--c-ac);outline-offset:-10px}
++.drop .big{font-size:42px;color:#9fb3d1}
++.drop p{margin:0;max-width:34ch;font-size:.9rem;color:#a9b6c9}
++.scanbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:10px}
++.scanbar select{width:auto;max-width:220px}
++.results{display:flex;flex-direction:column;gap:10px}
++.result{padding:14px;border-radius:var(--r);background:var(--c-card);border:1px solid var(--c-brd);min-width:0;animation:pop .25s ease-out}
++@keyframes pop{from{transform:translateY(4px);opacity:0}}
++.result .rh{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px}
++.result .fmt{font-size:.72rem;font-weight:750;letter-spacing:.05em;text-transform:uppercase;padding:2px 8px;border-radius:999px;background:var(--c-raised);border:1px solid var(--c-brd2);color:var(--c-tx2)}
++.result .kind{font-weight:750}
++.result .when{margin-left:auto;font-size:.76rem;color:var(--c-tx3)}
++.result .raw{font-family:var(--mono);font-size:.84rem;padding:8px 10px;border-radius:8px;background:var(--c-input);border:1px solid var(--c-brd);word-break:break-all;white-space:pre-wrap;max-height:160px;overflow:auto;user-select:all}
++.result dl{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:4px 12px;margin:8px 0;font-size:.86rem}
++.result dt{color:var(--c-tx3)}.result dd{margin:0;word-break:break-word}
++.result .acts{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
++
++/* ── Check (GS1) ── */
++.big-number{font-family:var(--mono);font-size:clamp(1.3rem,4vw,2rem);letter-spacing:.06em;word-break:break-all}
++.big-number .cd{padding:0 3px;border-radius:5px}
++.big-number .cd.ok{background:color-mix(in srgb,var(--c-ok) 22%,transparent);color:var(--c-ok)}
++.big-number .cd.bad{background:color-mix(in srgb,var(--c-bad) 22%,transparent);color:var(--c-bad);text-decoration:line-through}
++table.ais{width:100%;border-collapse:collapse;font-size:.88rem}
++table.ais th,table.ais td{text-align:left;padding:8px 6px;border-bottom:1px solid var(--c-brd);vertical-align:top}
++table.ais th{font-size:.72rem;letter-spacing:.06em;text-transform:uppercase;color:var(--c-tx3);font-weight:700}
++table.ais td.v{font-family:var(--mono);word-break:break-all}
++.st-ok{color:var(--c-ok)}.st-bad{color:var(--c-bad)}
++.aibuild{display:grid;grid-template-columns:minmax(0,1.3fr) minmax(0,1fr) auto;gap:6px;margin-bottom:6px}
++
++/* ── Bulk ── */
++.sheet{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}
++.sheet figure{margin:0;padding:10px;border-radius:var(--r-s);background:#fff;border:1px solid var(--c-brd);display:flex;flex-direction:column;align-items:center;gap:6px;min-width:0}
++.sheet figure img{max-width:100%;height:auto;max-height:110px}
++.sheet figcaption{font-size:.72rem;color:#333;text-align:center;word-break:break-all;font-family:var(--mono)}
++.fails{font-size:.84rem;color:var(--c-bad);margin:8px 0 0;padding-left:18px}
++
++.toast{position:fixed;left:50%;bottom:22px;transform:translate(-50%,90px);padding:10px 18px;border-radius:12px;background:var(--c-raised);border:1px solid var(--c-brd2);color:var(--c-tx);font-weight:650;font-size:.88rem;box-shadow:var(--c-shadow-lg);opacity:0;transition:transform .25s,opacity .25s;z-index:9999;pointer-events:none;max-width:calc(100vw - 32px)}
++.toast.show{transform:translate(-50%,0);opacity:1}
++
++/* ── Printing labels: only the label sheet ── */
++#print-sheet{display:none}
++@media print{
++ body *{visibility:hidden}
++ #print-sheet,#print-sheet *{visibility:visible}
++ #print-sheet{display:block;position:absolute;left:0;top:0}
++ #print-sheet .lab{display:inline-flex;flex-direction:column;align-items:center;justify-content:center;overflow:hidden;break-inside:avoid;vertical-align:top}
++ #print-sheet .lab img{max-width:100%;max-height:100%}
++ #print-sheet .lab span{font:7pt/1.1 ui-monospace,Menlo,monospace;color:#000;margin-top:1mm;text-align:center;word-break:break-all}
++}
++</style>
++<link rel="preconnect" href="https://fonts.googleapis.com">
++<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
++<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
++</head>
++<body>
++<div id="navbar-mount"></div>
++<svg width="0" height="0" style="position:absolute" aria-hidden="true">
++ <symbol id="i-qr" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="M14 14h3v3h-3zM20 14v.01M14 20h.01M17 17h4v4h-4"/></symbol>
++ <symbol id="i-barcode" viewBox="0 0 24 24"><path d="M4 5v14M7 5v14M10 5v14M14 5v14M17 5v14M20 5v14"/><path d="M8.5 5v14M15.5 5v14" stroke-width="1"/></symbol>
++ <symbol id="i-matrix" viewBox="0 0 24 24"><path d="M4 4v16h16"/><path d="M8 4h2M12 4h2M16 4h2M20 6v2M20 10v2M20 14v2M8 8h2v2H8zM13 9h2v2h-2zM9 13h2v2H9zM14 14h2v2h-2z"/></symbol>
++ <symbol id="i-scan" viewBox="0 0 24 24"><path d="M3 8V5a2 2 0 0 1 2-2h3M16 3h3a2 2 0 0 1 2 2v3M21 16v3a2 2 0 0 1-2 2h-3M8 21H5a2 2 0 0 1-2-2v-3M7 12h10"/></symbol>
++ <symbol id="i-check" viewBox="0 0 24 24"><path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="9"/></symbol>
++ <symbol id="i-layers" viewBox="0 0 24 24"><path d="M12 3l9 5-9 5-9-5 9-5z"/><path d="M3 13l9 5 9-5"/></symbol>
++ <symbol id="i-wifi" viewBox="0 0 24 24"><path d="M2 8.8a15 15 0 0 1 20 0M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0M12 19.5h.01"/></symbol>
++ <symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></symbol>
++ <symbol id="i-book" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20V3H6.5A2.5 2.5 0 0 0 4 5.5z"/><path d="M4 19.5A2.5 2.5 0 0 0 6.5 22H20v-5"/></symbol>
++ <symbol id="i-ticket" viewBox="0 0 24 24"><path d="M3 9a3 3 0 0 0 0 6v3a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1v-3a3 3 0 0 1 0-6V6a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1z"/><path d="M13 5v2M13 11v2M13 17v2"/></symbol>
++ <symbol id="i-id" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 9h10M7 12h10M7 15h6"/></symbol>
++ <symbol id="i-download" viewBox="0 0 24 24"><path d="M12 3v12M7 10l5 5 5-5M5 21h14"/></symbol>
++ <symbol id="i-copy" viewBox="0 0 24 24"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></symbol>
++ <symbol id="i-camera" viewBox="0 0 24 24"><path d="M3 8a2 2 0 0 1 2-2h2l2-2h6l2 2h2a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><circle cx="12" cy="13" r="4"/></symbol>
++ <symbol id="i-image" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="M21 15l-5-5L5 21"/></symbol>
++ <symbol id="i-bolt" viewBox="0 0 24 24"><path d="M13 2L4 14h7l-1 8 9-12h-7z"/></symbol>
++ <symbol id="i-flip" viewBox="0 0 24 24"><path d="M17 3l4 4-4 4M21 7H9M7 21l-4-4 4-4M3 17h12"/></symbol>
++ <symbol id="i-stop" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14" rx="2"/></symbol>
++ <symbol id="i-spark" viewBox="0 0 24 24"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/></symbol>
++ <symbol id="i-warn" viewBox="0 0 24 24"><path d="M12 3l10 18H2z"/><path d="M12 10v4M12 17.5h.01"/></symbol>
++ <symbol id="i-x" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M9 9l6 6M15 9l-6 6"/></symbol>
++ <symbol id="i-link" viewBox="0 0 24 24"><path d="M10 14a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 10a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></symbol>
++ <symbol id="i-print" viewBox="0 0 24 24"><path d="M6 9V3h12v6M6 18H4a1 1 0 0 1-1-1v-6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v6a1 1 0 0 1-1 1h-2"/><rect x="6" y="14" width="12" height="7"/></symbol>
++ <symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 21V9M7 14l5-5 5 5M5 3h14"/></symbol>
++ <symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/></symbol>
++</svg>
++
++<main class="cx" id="main">
++ <div class="cx-bar">
++ <a class="cx-fam" href="https://barcodes.openvibe.tools/"><span class="mark"><svg class="ic"><use href="#i-barcode"/></svg></span>Barcode &amp; QR Tools</a>
++ <nav class="cx-tabs" id="tabs" aria-label="Barcode and QR tools"></nav>
++ </div>
++ <header class="cx-head"><h1 id="h1">Barcode &amp; QR Tools</h1><p id="lead">Make, scan and check every kind of code, in your browser.</p></header>
++
++ <!-- Hub -->
++ <section id="view-hub" hidden>
++ <div class="smart">
++ <div>
++ <h2>Type anything. Get the right code.</h2>
++ <p>A link, a product number, an ISBN, a GS1 string or plain text: the right symbology is picked, the check digit verified, and the code test-scanned.</p>
++ <input type="text" id="hub-text" class="mono" placeholder="https://… or 4006381333931 or (01)09506000134352(17)271231" autocomplete="off" spellcheck="false" aria-label="Text or number to encode">
++ <div class="why" id="hub-why" aria-live="polite"></div>
++ <div class="row" style="margin-top:10px"><a class="btn primary" id="hub-open" href="#"><svg class="ic"><use href="#i-spark"/></svg>Open in the studio</a><a class="btn" href="https://barcode-scanner.openvibe.tools/"><svg class="ic"><use href="#i-scan"/></svg>Scan a code</a></div>
++ </div>
++ <div class="stage" id="hub-stage"><div class="empty">Your code appears here</div></div>
++ </div>
++ <div id="hub-tiles"></div>
++ </section>
++
++ <!-- QR studio -->
++ <section id="view-qr" hidden>
++ <div class="studio">
++ <div class="controls">
++ <div class="panel">
++ <h2>What it does</h2>
++ <div class="chips" id="qr-types" role="group" aria-label="Content type"></div>
++ <div id="qr-fields" style="margin-top:14px"></div>
++ </div>
++ <details class="panel" open>
++ <summary>Style</summary>
++ <div class="field"><span class="lbl">Modules</span><div class="seg" id="qr-style" role="group" aria-label="Module shape"></div></div>
++ <div class="field"><span class="lbl">Eyes</span><div class="seg" id="qr-eyes" role="group" aria-label="Eye shape"></div></div>
++ <div class="row">
++ <label class="field"><span>Colour</span><span class="color"><input type="color" id="qr-color" value="#0b1220" aria-label="Colour"><input type="text" id="qr-color-t" value="#0b1220" maxlength="7" aria-label="Colour hex"></span></label>
++ <label class="field"><span>Eyes</span><span class="color"><input type="color" id="qr-eye" value="#0b1220" aria-label="Eye colour"><input type="text" id="qr-eye-t" value="#0b1220" maxlength="7" aria-label="Eye colour hex"></span></label>
++ <label class="field"><span>Background</span><span class="color"><input type="color" id="qr-bg" value="#ffffff" aria-label="Background"><input type="text" id="qr-bg-t" value="#ffffff" maxlength="7" aria-label="Background hex"></span></label>
++ </div>
++ <div class="row" style="align-items:center;gap:16px">
++ <label class="check"><input type="checkbox" id="qr-grad"> Gradient</label>
++ <label class="color" id="qr-grad-box" hidden><input type="color" id="qr-grad-to" value="#3472d8" aria-label="Gradient end colour"><input type="text" id="qr-grad-to-t" value="#3472d8" maxlength="7" aria-label="Gradient end hex"></label>
++ <label class="check"><input type="checkbox" id="qr-transparent"> Transparent background</label>
++ </div>
++ <div class="field" style="margin-top:6px"><span class="lbl">Presets</span><div class="chips" id="qr-presets"></div></div>
++ </details>
++ <details class="panel" id="qr-logo-panel">
++ <summary>Logo</summary>
++ <div class="row" style="align-items:center">
++ <label class="btn"><svg class="ic"><use href="#i-upload"/></svg>Choose a picture<input type="file" id="qr-logo" accept="image/*" class="sr"></label>
++ <button type="button" class="btn ghost" id="qr-logo-clear" hidden><svg class="ic"><use href="#i-trash"/></svg>Remove</button>
++ <span class="muted" id="qr-logo-name"></span>
++ </div>
++ <div class="field" style="margin-top:10px"><span class="lbl">Logo size</span><div class="range"><input type="range" id="qr-logo-size" min="12" max="30" value="22" aria-label="Logo size"><output id="qr-logo-size-o">22%</output></div><small>Error correction rises to H (30%) with a logo, and the code is checked after every change.</small></div>
++ </details>
++ <details class="panel">
++ <summary>Size, frame &amp; error correction</summary>
++ <div class="field"><span class="lbl">Error correction</span><div class="seg" id="qr-ecc" role="group" aria-label="Error correction"></div><small id="qr-ecc-note"></small></div>
++ <div class="row">
++ <div class="field grow"><span class="lbl">Quiet zone</span><div class="range"><input type="range" id="qr-quiet" min="0" max="10" value="4" aria-label="Quiet zone"><output id="qr-quiet-o">4 modules</output></div></div>
++ <div class="field grow"><span class="lbl">PNG size</span><div class="range"><input type="range" id="qr-px" min="256" max="4000" step="16" value="1024" aria-label="PNG size"><output id="qr-px-o">1024 px</output></div></div>
++ </div>
++ <div class="row">
++ <label class="field grow"><span>Caption under the code</span><input type="text" id="qr-caption" maxlength="40" placeholder="Scan me"></label>
++ <label class="field" style="width:130px"><span>Print at</span><select id="qr-dpi"><option value="150">150 DPI</option><option value="300" selected>300 DPI</option><option value="600">600 DPI</option></select></label>
++ </div>
++ <div class="note" id="qr-print-note"><svg class="ic"><use href="#i-print"/></svg><span></span></div>
++ </details>
++ </div>
++ <aside class="preview" aria-label="Preview">
++ <div class="stage" id="qr-stage"><div class="empty">Fill in a link or some text</div></div>
++ <div class="verdict" id="qr-verdict" aria-live="polite"><span class="dot"></span><span>—</span></div>
++ <div class="facts" id="qr-facts"></div>
++ <div class="dl">
++ <button type="button" class="btn primary" id="qr-png"><svg class="ic"><use href="#i-download"/></svg>PNG</button>
++ <button type="button" class="btn" id="qr-svg"><svg class="ic"><use href="#i-download"/></svg>SVG</button>
++ <button type="button" class="btn" id="qr-copy"><svg class="ic"><use href="#i-copy"/></svg>Copy</button>
++ </div>
++ <div id="qr-notes"></div>
++ </aside>
++ </div>
++ </section>
++
++ <!-- Barcode studio (every symbology; single-type pages are presets of it) -->
++ <section id="view-barcode" hidden>
++ <div class="studio">
++ <div class="controls">
++ <div class="panel">
++ <h2>Data</h2>
++ <label class="field"><span class="sr">Text or number to encode</span><textarea id="bc-text" class="mono" rows="2" spellcheck="false" autocomplete="off"></textarea></label>
++ <div id="bc-smart" aria-live="polite"></div>
++ </div>
++ <div class="panel">
++ <h2>Symbology</h2>
++ <div class="row">
++ <label class="field grow"><span class="sr">Find a symbology</span><input type="search" id="bc-find" placeholder="Search 100+ types: ean, gs1, postal, 2d…" autocomplete="off"></label>
++ <label class="field grow"><span class="sr">Symbology</span><select id="bc-type"></select></label>
++ </div>
++ <div class="row" style="align-items:center;justify-content:space-between"><small class="muted" id="bc-type-desc"></small><button type="button" class="btn small ghost" id="bc-sample">Try a sample</button></div>
++ <div id="bc-extra" style="margin-top:10px"></div>
++ </div>
++ <details class="panel" open>
++ <summary>Look</summary>
++ <div class="row">
++ <div class="field grow" id="bc-height-f"><span class="lbl">Bar height</span><div class="range"><input type="range" id="bc-height" min="4" max="60" step="0.5" value="15" aria-label="Bar height"><output id="bc-height-o">15 mm</output></div></div>
++ <div class="field grow"><span class="lbl">Module width (X)</span><div class="range"><input type="range" id="bc-x" min="0.17" max="1.5" step="0.01" value="0.33" aria-label="Module width"><output id="bc-x-o">0.33 mm</output></div></div>
++ </div>
++ <div class="row" style="align-items:center;gap:16px;margin-bottom:10px">
++ <label class="check" id="bc-text-f"><input type="checkbox" id="bc-showtext" checked> Human-readable text</label>
++ <div class="field" style="margin:0"><div class="seg" id="bc-rotate" role="group" aria-label="Rotation"></div></div>
++ </div>
++ <div class="row">
++ <label class="field"><span>Bars</span><span class="color"><input type="color" id="bc-color" value="#000000" aria-label="Bar colour"><input type="text" id="bc-color-t" value="#000000" maxlength="7" aria-label="Bar colour hex"></span></label>
++ <label class="field"><span>Background</span><span class="color"><input type="color" id="bc-bg" value="#ffffff" aria-label="Background"><input type="text" id="bc-bg-t" value="#ffffff" maxlength="7" aria-label="Background hex"></span></label>
++ <div class="field grow"><span class="lbl">Quiet zone</span><div class="range"><input type="range" id="bc-quiet" min="0" max="20" value="10" aria-label="Quiet zone"><output id="bc-quiet-o">10</output></div></div>
++ </div>
++ </details>
++ <details class="panel">
++ <summary>Print size</summary>
++ <div class="row">
++ <label class="field" style="width:150px"><span>Printer</span><select id="bc-dpi"><option value="96">Screen, 96 DPI</option><option value="203">Label printer, 203 DPI</option><option value="300" selected>300 DPI</option><option value="600">600 DPI</option></select></label>
++ <div class="field grow"><span class="lbl">Physical size</span><b id="bc-size" style="font-variant-numeric:tabular-nums">—</b></div>
++ </div>
++ <div class="note"><svg class="ic"><use href="#i-print"/></svg><span>The module width snaps to whole printer dots (<span id="bc-dots">—</span>), so every bar prints the same width. PNG files carry the resolution, so they print at this size.</span></div>
++ </details>
++ </div>
++ <aside class="preview" aria-label="Preview">
++ <div class="stage" id="bc-stage"><div class="empty">Type something to encode</div></div>
++ <div class="verdict" id="bc-verdict" aria-live="polite"><span class="dot"></span><span>—</span></div>
++ <div class="facts" id="bc-facts"></div>
++ <div class="dl">
++ <button type="button" class="btn primary" id="bc-png"><svg class="ic"><use href="#i-download"/></svg>PNG</button>
++ <button type="button" class="btn" id="bc-svg"><svg class="ic"><use href="#i-download"/></svg>SVG</button>
++ <button type="button" class="btn" id="bc-copy"><svg class="ic"><use href="#i-copy"/></svg>Copy</button>
++ </div>
++ </aside>
++ </div>
++ </section>
++
++ <!-- Scanner -->
++ <section id="view-scan" hidden>
++ <div class="scan">
++ <div>
++ <div class="viewport" id="sc-view">
++ <video id="sc-video" playsinline muted hidden></video>
++ <img class="shot" id="sc-shot" alt="" hidden>
++ <canvas class="overlay" id="sc-overlay"></canvas>
++ <div class="aim" id="sc-aim" hidden></div>
++ <div class="drop" id="sc-drop">
++ <svg class="ic big"><use href="#i-scan"/></svg>
++ <p>Point your camera at a code, or drop, paste (<span class="kbd">Ctrl</span>+<span class="kbd">V</span>) or choose a picture. Every code in it is read.</p>
++ <div class="row" style="justify-content:center">
++ <button type="button" class="btn primary" id="sc-start"><svg class="ic"><use href="#i-camera"/></svg>Start camera</button>
++ <label class="btn"><svg class="ic"><use href="#i-image"/></svg>Choose a picture<input type="file" id="sc-file" accept="image/*" class="sr"></label>
++ </div>
++ </div>
++ </div>
++ <div class="scanbar">
++ <button type="button" class="btn" id="sc-stop" hidden><svg class="ic"><use href="#i-stop"/></svg>Stop</button>
++ <button type="button" class="btn" id="sc-flip" hidden><svg class="ic"><use href="#i-flip"/></svg>Switch camera</button>
++ <button type="button" class="btn" id="sc-torch" hidden aria-pressed="false"><svg class="ic"><use href="#i-bolt"/></svg>Torch</button>
++ <label class="btn ghost" id="sc-again" hidden><svg class="ic"><use href="#i-image"/></svg>Another picture<input type="file" id="sc-file2" accept="image/*" class="sr"></label>
++ <label class="check" style="margin-left:auto"><input type="checkbox" id="sc-beep" checked> Sound</label>
++ </div>
++ <p class="muted" id="sc-engine" style="font-size:.8rem;margin:8px 0 0"></p>
++ </div>
++ <div>
++ <div class="row" style="justify-content:space-between;align-items:center;margin-bottom:10px"><b>Found</b><button type="button" class="btn small ghost" id="sc-clear"><svg class="ic"><use href="#i-trash"/></svg>Clear history</button></div>
++ <div class="results" id="sc-results"><div class="note"><svg class="ic"><use href="#i-scan"/></svg><span>Codes you scan appear here, with what they contain. Nothing is opened until you choose to.</span></div></div>
++ </div>
++ </div>
++ </section>
++
++ <!-- Check: GTIN / ISBN / SSCC and GS1 -->
++ <section id="view-check" hidden>
++ <div class="studio">
++ <div class="controls">
++ <div class="panel">
++ <h2>Number or GS1 data</h2>
++ <input type="text" id="ck-text" class="mono" placeholder="4006381333931, 978-0-306-40615-7, 0360002914 or (01)09506000134352(17)271231(10)A1" autocomplete="off" spellcheck="false" aria-label="Number or GS1 data">
++ <div id="ck-out" aria-live="polite" style="margin-top:14px"></div>
++ </div>
++ <details class="panel" id="ck-build-panel">
++ <summary>Build a GS1 element string</summary>
++ <div id="ck-rows"></div>
++ <div class="row"><button type="button" class="btn small" id="ck-add">Add a field</button><span class="muted" style="font-size:.82rem">Fields are checked as you type; the result goes in the preview.</span></div>
++ </details>
++ </div>
++ <aside class="preview" aria-label="Preview">
++ <div class="stage" id="ck-stage"><div class="empty">The matching barcode appears here</div></div>
++ <div class="seg" id="ck-sym" role="group" aria-label="Symbology for GS1 data" hidden></div>
++ <div class="dl">
++ <button type="button" class="btn primary" id="ck-png"><svg class="ic"><use href="#i-download"/></svg>PNG</button>
++ <button type="button" class="btn" id="ck-svg"><svg class="ic"><use href="#i-download"/></svg>SVG</button>
++ <a class="btn" id="ck-studio" href="https://barcode-generator.openvibe.tools/"><svg class="ic"><use href="#i-barcode"/></svg>Studio</a>
++ </div>
++ </aside>
++ </div>
++ </section>
++
++ <!-- Bulk -->
++ <section id="view-bulk" hidden>
++ <div class="studio">
++ <div class="controls">
++ <div class="panel">
++ <h2>Values</h2>
++ <div class="seg" id="bk-src" role="group" aria-label="Source" style="margin-bottom:10px"></div>
++ <div id="bk-list">
++ <label class="field"><span class="sr">One value per line</span><textarea id="bk-text" class="mono" rows="8" spellcheck="false" placeholder="One value per line&#10;SKU-0001&#10;SKU-0002&#10;or value,caption"></textarea></label>
++ <label class="check"><input type="checkbox" id="bk-csv"> Lines are <span class="kbd">value,caption</span> (CSV or tab-separated)</label>
++ </div>
++ <div id="bk-seq" hidden>
++ <div class="row">
++ <label class="field grow"><span>Prefix</span><input type="text" id="bk-prefix" value="SKU-" maxlength="40"></label>
++ <label class="field" style="width:110px"><span>Start</span><input type="number" id="bk-start" value="1" min="0"></label>
++ <label class="field" style="width:110px"><span>Count</span><input type="number" id="bk-count" value="24" min="1" max="2000"></label>
++ <label class="field" style="width:90px"><span>Step</span><input type="number" id="bk-step" value="1" min="1"></label>
++ <label class="field" style="width:90px"><span>Digits</span><input type="number" id="bk-pad" value="4" min="0" max="18"></label>
++ <label class="field grow"><span>Suffix</span><input type="text" id="bk-suffix" value="" maxlength="40"></label>
++ </div>
++ <label class="check"><input type="checkbox" id="bk-gtin"> Add a GS1 check digit to each number</label>
++ </div>
++ </div>
++ <div class="panel">
++ <h2>Code</h2>
++ <div class="row">
++ <label class="field grow"><span>Symbology</span><select id="bk-type"></select></label>
++ <label class="field" style="width:150px"><span>Files</span><select id="bk-fmt"><option value="png">PNG</option><option value="svg">SVG</option></select></label>
++ </div>
++ <div class="row" style="align-items:center;gap:16px"><label class="check"><input type="checkbox" id="bk-showtext" checked> Human-readable text</label><label class="check"><input type="checkbox" id="bk-caption" checked> Caption on labels</label></div>
++ </div>
++ <details class="panel">
++ <summary>Label sheet</summary>
++ <div class="row">
++ <label class="field" style="width:120px"><span>Label width</span><input type="number" id="bk-lw" value="63.5" min="10" max="300" step="0.1"></label>
++ <label class="field" style="width:120px"><span>Label height</span><input type="number" id="bk-lh" value="38.1" min="10" max="300" step="0.1"></label>
++ <label class="field" style="width:100px"><span>Columns</span><input type="number" id="bk-cols" value="3" min="1" max="10"></label>
++ <label class="field" style="width:100px"><span>Gap (mm)</span><input type="number" id="bk-gap" value="2.5" min="0" max="20" step="0.5"></label>
++ <label class="field" style="width:120px"><span>Page margin</span><input type="number" id="bk-margin" value="10" min="0" max="40" step="0.5"></label>
++ </div>
++ <small class="muted">63.5 × 38.1 mm, 3 across is the common 21-label A4 sheet; 66.7 × 25.4 mm, 3 across, the US 30-label sheet.</small>
++ </details>
++ </div>
++ <aside class="preview" aria-label="Preview">
++ <div class="panel" style="padding:12px">
++ <div class="row" style="justify-content:space-between;align-items:center"><b id="bk-count-o">0 codes</b><span class="muted" id="bk-time" style="font-size:.8rem"></span></div>
++ <ul class="fails" id="bk-fails" hidden></ul>
++ </div>
++ <div class="dl" style="grid-template-columns:1fr 1fr">
++ <button type="button" class="btn primary" id="bk-zip"><svg class="ic"><use href="#i-download"/></svg>Download ZIP</button>
++ <button type="button" class="btn" id="bk-print"><svg class="ic"><use href="#i-print"/></svg>Print labels</button>
++ </div>
++ <div class="sheet" id="bk-sheet"></div>
++ </aside>
++ </div>
++ </section>
++</main>
++<div id="print-sheet" aria-hidden="true"></div>
++<div id="ov-footer"></div>
++<div class="toast" id="toast" role="status"></div>
++<script src="/shared/navbar.js"></script>
++<script src="/shared/footer.js"></script>
++<script src="/shared/theme-loader.js" defer></script>
++<script src="/js/codes/smart.js"></script>
++<script src="/js/codes/draw.js"></script>
++<script src="/js/codes/read.js" id="codes-read" data-zxing="/vendor/[email protected]/reader.js" data-zxing-wasm="/vendor/[email protected]/zxing_reader.wasm"></script>
++<script src="/vendor/[email protected]/bwip-js.min.js" defer></script>
++<script src="/js/codes/app.js" defer></script>
++</body>
++</html>
ddiff --git a/apps/gateway/public/js/codes/app.js b/apps/gateway/public/js/codes/app.js
nnew file mode 100644
iindex 0000000..4f6df9d
---- /dev/null
++++ b/apps/gateway/public/js/codes/app.js
@@@ -0,0 +1,940 @@
++/* Barcode & QR tools (codes.html): one page for the family, a view per tool. Drawing is bwip-js (window.bwipjs) and
++ draw.js; reading is read.js (ZXing in WebAssembly, or the browser's BarcodeDetector); smart.js knows what a text is.
++ Everything happens in the browser: nothing typed, uploaded or scanned leaves it. */
++(function () {
++ 'use strict';
++ const S = window.OVCodesSmart, D = window.OVCodesDraw, R = window.OVCodesRead;
++ const $ = (id) => document.getElementById(id);
++ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++ const icon = (name) => `<svg class="ic" aria-hidden="true"><use href="#i-${name}"/></svg>`;
++ const store = { get(k, d) { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : d; } catch { return d; } }, set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* private mode */ } } };
++ const readCfg = $('codes-read');
++ if (readCfg) { R.cfg.zxing = readCfg.dataset.zxing; R.cfg.zxingWasm = readCfg.dataset.zxingWasm; }
++
++ // ── The tools: view, host, title, preset ─────────────────────────────────────────────────────────────────────────
++ const TOOLS = {
++ codes: { view: 'hub', host: 'barcodes', h1: 'Barcode & QR Tools', lead: 'Make, scan and check every kind of code, in your browser. Nothing you type or scan is uploaded.' },
++ qr: { view: 'qr', host: 'qr-code-generator', tab: 'QR code', icon: 'qr', h1: 'QR Code Generator', lead: 'Links, Wi-Fi, contacts, events and more, styled your way and test-scanned before you download.', type: 'url' },
++ wifiqr: { view: 'qr', host: 'wifi-qr-code', icon: 'wifi', h1: 'Wi-Fi QR Code', lead: 'Guests point their camera at it and join your network. The password never leaves this page.', type: 'wifi' },
++ vcardqr: { view: 'qr', host: 'vcard-qr-code', icon: 'user', h1: 'Contact QR Code', lead: 'A vCard that every phone saves in one tap: for business cards, badges and signatures.', type: 'contact' },
++ barcode: { view: 'barcode', host: 'barcode-generator', tab: 'Barcode', icon: 'barcode', h1: 'Barcode Generator', lead: 'More than a hundred symbologies. Type anything: the right one is suggested and its check digit verified.' },
++ datamatrix: { view: 'barcode', host: 'data-matrix-generator', tab: 'Data Matrix', icon: 'matrix', h1: 'Data Matrix Generator', lead: 'Square or rectangular Data Matrix, and GS1 DataMatrix with checked Application Identifiers.', sym: 'datamatrix', sample: '(01)09506000134352(17)271231(10)ABC123' },
++ code128: { view: 'barcode', host: 'code-128-generator', icon: 'barcode', h1: 'Code 128 Generator', lead: 'Dense, universal barcodes for labels, inventory and tickets.', sym: 'code128', sample: 'OV-2026-0042' },
++ ean13: { view: 'barcode', host: 'ean-13-generator', icon: 'barcode', h1: 'EAN-13 Barcode Generator', lead: 'Type 12 digits and the check digit is added, or 13 and it is verified.', sym: 'ean13', sample: '400638133393' },
++ upc: { view: 'barcode', host: 'upc-barcode-generator', icon: 'barcode', h1: 'UPC Barcode Generator', lead: 'UPC-A and UPC-E for North American retail, check digit included.', sym: 'upca', sample: '03600029145' },
++ pdf417: { view: 'barcode', host: 'pdf417-generator', icon: 'id', h1: 'PDF417 Generator', lead: 'The stacked barcode on IDs, boarding passes and shipping labels.', sym: 'pdf417', sample: 'PDF417 carries up to about 1,800 characters.' },
++ aztec: { view: 'barcode', host: 'aztec-code-generator', icon: 'ticket', h1: 'Aztec Code Generator', lead: 'The compact 2D code on train and plane tickets: no quiet zone needed.', sym: 'azteccode', sample: 'TICKET 4471 SEAT 12A' },
++ isbn: { view: 'barcode', host: 'isbn-barcode-generator', icon: 'book', h1: 'ISBN Barcode Generator', lead: 'A book\'s back-cover barcode from its ISBN, with the ISBN above and an optional price add-on.', sym: 'isbn', sample: '978-0-306-40615-7' },
++ scan: { view: 'scan', host: 'barcode-scanner', tab: 'Scan', icon: 'scan', h1: 'Barcode & QR Scanner', lead: 'Your camera, or a picture or screenshot: every code is read and explained. Nothing is opened until you choose.' },
++ gs1: { view: 'check', host: 'gtin-check-digit', tab: 'Check', icon: 'check', h1: 'GTIN Check Digit & GS1 Parser', lead: 'Name any product, book or shipping number, fix its check digit, and read GS1 labels field by field.' },
++ bulkbarcodes: { view: 'bulk', host: 'bulk-barcode-generator', tab: 'Bulk', icon: 'layers', h1: 'Bulk Barcodes & Labels', lead: 'A list, a CSV or a numbered sequence in; a ZIP of codes or a printable label sheet out.' },
++ };
++ const TABS = ['qr', 'barcode', 'datamatrix', 'scan', 'gs1', 'bulkbarcodes'];
++ const params = new URLSearchParams(location.search);
++ const META = (document.querySelector('meta[name="ov-tool"]') || {}).content || '';
++ const ID = TOOLS[META] ? META : TOOLS[params.get('tool')] ? params.get('tool') : 'codes';
++ const T = TOOLS[ID];
++ const local = !/\.openvibe\.tools$/.test(location.hostname);
++ /** A tool's address, carrying text over when there is some (?text=). */
++ function toolUrl(id, extra) {
++ const q = new URLSearchParams(extra || {});
++ if (local) { q.set('tool', id); return `${location.pathname}?${q}`; }
++ const s = q.toString();
++ return `https://${TOOLS[id].host}.openvibe.tools/${s ? `?${s}` : ''}`;
++ }
++
++ // ── Shared bits ──────────────────────────────────────────────────────────────────────────────────────────────────
++ let toastTimer = null;
++ function toast(msg) { const t = $('toast'); t.textContent = msg; t.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 2200); }
++ function download(blob, name) {
++ const a = document.createElement('a');
++ a.href = URL.createObjectURL(blob); a.download = name;
++ document.body.appendChild(a); a.click(); a.remove();
++ setTimeout(() => URL.revokeObjectURL(a.href), 4000);
++ }
++ const slug = (s) => String(s || 'code').toLowerCase().replace(/^[a-z]+:\/*/, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'code';
++ async function copyPng(blob) {
++ try { await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); toast('Copied the image'); } catch { toast('Copying images is not allowed here: use Download'); }
++ }
++ async function copyText(s) { try { await navigator.clipboard.writeText(s); toast('Copied'); } catch { toast('Copy failed'); } }
++ const canvasBlob = (c) => new Promise((res) => c.toBlob(res, 'image/png'));
++ /** An SVG string on a canvas of `w` px (white under it unless `keepAlpha`). */
++ function svgToCanvas(svg, w, keepAlpha) {
++ return new Promise((resolve, reject) => {
++ const img = new Image();
++ const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
++ img.onload = () => {
++ const h = Math.round((w * (img.naturalHeight || img.height)) / (img.naturalWidth || img.width || 1)) || w;
++ const c = document.createElement('canvas'); c.width = w; c.height = h;
++ const g = c.getContext('2d');
++ if (!keepAlpha) { g.fillStyle = '#fff'; g.fillRect(0, 0, w, h); }
++ g.imageSmoothingEnabled = false;
++ g.drawImage(img, 0, 0, w, h);
++ URL.revokeObjectURL(url);
++ resolve(c);
++ };
++ img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('The picture could not be drawn')); };
++ img.src = url;
++ });
++ }
++ /** bwip-js error text without its "bwipp.ean13badLength#2394:" prefix. */
++ const bwipError = (e) => String((e && e.message) || e).replace(/^bwip(?:p|-js)\.[\w-]+#?\d*:\s*/, '');
++ function seg(el, items, value, onPick) {
++ el.innerHTML = items.map(([v, label, title]) => `<button type="button" data-v="${esc(v)}" aria-pressed="${v === value}"${title ? ` title="${esc(title)}"` : ''}>${label}</button>`).join('');
++ el.onclick = (e) => { const b = e.target.closest('button[data-v]'); if (!b) return; el.querySelectorAll('button').forEach((x) => x.setAttribute('aria-pressed', String(x === b))); onPick(b.dataset.v); };
++ }
++ function bindColor(pickId, textId, onChange) {
++ const p = $(pickId), t = $(textId);
++ p.addEventListener('input', () => { t.value = p.value; onChange(p.value); });
++ t.addEventListener('input', () => { const v = t.value.trim(); if (/^#[0-9a-f]{6}$/i.test(v)) { p.value = v; onChange(v); } });
++ return { set(v) { p.value = v; t.value = v; } };
++ }
++ function bindRange(id, outId, fmt, onChange) {
++ const r = $(id), o = $(outId);
++ const paint = () => { o.textContent = fmt(Number(r.value)); };
++ r.addEventListener('input', () => { paint(); onChange(Number(r.value)); });
++ paint();
++ return r;
++ }
++ function debounce(fn, ms) { let t = null; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }
++ function verdict(el, state, text) { el.className = `verdict ${state}`; el.innerHTML = `<span class="dot"></span><span>${text}</span>`; }
++ function facts(el, rows) { el.innerHTML = rows.map(([v, k]) => `<div><b title="${esc(v)}">${esc(v)}</b><span>${esc(k)}</span></div>`).join(''); }
++ function note(kind, html, act) { return `<div class="note ${kind}">${icon(kind === 'ok' ? 'check' : kind === 'bad' ? 'x' : kind === 'warn' ? 'warn' : 'spark')}<span>${html}</span>${act || ''}</div>`; }
++ const norm = (s) => String(s || '').replace(/[^0-9A-Za-z]/g, '').replace(/^0+(?=\d)/, '');
++ /** Read a canvas back and compare with what was meant: { ok, label, text } or null when there is nothing to read with. */
++ async function checkScan(canvas, expected) {
++ const g = canvas.getContext('2d');
++ const data = g.getImageData(0, 0, canvas.width, canvas.height);
++ const found = await R.readZxing(data, { tryHarder: true, max: 4 });
++ if (!found.length) return { ok: false };
++ const hit = found.find((f) => f.text === expected || norm(f.text) === norm(expected)) || found[0];
++ return { ok: hit.text === expected || norm(hit.text) === norm(expected), label: hit.label, text: hit.text };
++ }
++
++ // ── Frame ────────────────────────────────────────────────────────────────────────────────────────────────────────
++ document.title = ID === 'codes' ? document.title : document.title;
++ $('h1').textContent = T.h1;
++ $('lead').textContent = T.lead;
++ const tabFor = (id) => (TOOLS[id].view === 'qr' ? 'qr' : TOOLS[id].view === 'barcode' ? (id === 'datamatrix' ? 'datamatrix' : 'barcode') : id === 'gs1' ? 'gs1' : id);
++ $('tabs').innerHTML = TABS.map((id) => `<a href="${esc(toolUrl(id))}" data-tool="${id}"${tabFor(ID) === id ? ' aria-current="page"' : ''}>${icon(TOOLS[id].icon)}${esc(TOOLS[id].tab)}</a>`).join('');
++ // A tab carries what is typed over to the next tool.
++ $('tabs').addEventListener('click', (e) => {
++ const a = e.target.closest('a[data-tool]'); if (!a) return;
++ const text = currentText();
++ if (text && text.length < 800) { e.preventDefault(); location.href = toolUrl(a.dataset.tool, { text }); }
++ });
++ let currentText = () => '';
++ $(`view-${T.view}`).hidden = false;
++
++ // ═════════════════════════════════════════════════════════════════════════════════════════════════════════════════
++ // Hub: one box that picks the code, and every tool
++ // ═════════════════════════════════════════════════════════════════════════════════════════════════════════════════
++ function hub() {
++ const groups = [
++ ['Make', ['qr', 'barcode', 'datamatrix', 'wifiqr', 'vcardqr', 'bulkbarcodes']],
++ ['Read & check', ['scan', 'gs1']],
++ ['One type, done well', ['code128', 'ean13', 'upc', 'isbn', 'pdf417', 'aztec']],
++ ];
++ $('hub-tiles').innerHTML = groups.map(([h, ids]) => `<div class="group-h">${esc(h)}</div><div class="tiles">${ids.map((id) => `<a class="tile" href="${esc(toolUrl(id))}"><span class="ti">${icon(TOOLS[id].icon)}</span><span><b>${esc(TOOLS[id].h1)}</b><small>${esc(TOOLS[id].lead)}</small></span></a>`).join('')}</div>`).join('');
++ const input = $('hub-text'), stage = $('hub-stage'), why = $('hub-why'), open = $('hub-open');
++ const run = debounce(() => {
++ const text = input.value.trim();
++ if (!text) { stage.innerHTML = '<div class="empty">Your code appears here</div>'; why.textContent = ''; open.href = toolUrl('qr'); return; }
++ const rec = S.recommend(text);
++ const sym = rec.symbology;
++ const encode = rec.encode || rec.fixed || text;
++ try {
++ if (sym === 'qrcode') {
++ stage.innerHTML = `<div class="art svg">${D.qrSvg(bwipjs.raw({ bcid: 'qrcode', text, eclevel: 'M' }), { style: 'rounded', eyes: 'rounded', color: '#0b1220' }).svg}</div>`;
++ open.href = toolUrl('qr', { text });
++ } else {
++ const c = document.createElement('canvas');
++ bwipjs.toCanvas(c, { bcid: sym, text: encode, scale: 3, ...(TWO_D.test(sym) ? {} : { height: 12, ...textOpts(sym) }), backgroundcolor: 'ffffff', paddingwidth: 8, paddingheight: 6 });
++ c.className = 'art'; stage.replaceChildren(c);
++ open.href = toolUrl(sym === 'datamatrix' || sym === 'gs1datamatrix' ? 'datamatrix' : 'barcode', { text: encode, sym });
++ }
++ const name = (bwipjs.symbolList.find((s) => s.bcid === sym) || {}).desc || sym;
++ why.innerHTML = `<b>${esc(name)}</b> · ${esc(rec.why)}${rec.fixed ? ` <span class="st-bad">Check digit corrected: ${esc(rec.fixed)}</span>` : ''}`;
++ } catch (e) {
++ stage.innerHTML = `<div class="err">${esc(bwipError(e))}</div>`;
++ why.textContent = '';
++ }
++ }, 180);
++ input.addEventListener('input', run);
++ const start = params.get('text');
++ if (start) { input.value = start; run(); }
++ currentText = () => input.value.trim();
++ }
++
++ // ═════════════════════════════════════════════════════════════════════════════════════════════════════════════════
++ // QR studio
++ // ═════════════════════════════════════════════════════════════════════════════════════════════════════════════════
++ function qr() {
++ const TYPES = [
++ ['url', 'Link', 'link'], ['text', 'Text', 'spark'], ['wifi', 'Wi-Fi', 'wifi'], ['contact', 'Contact', 'user'], ['email', 'Email', 'link'],
++ ['sms', 'SMS', 'link'], ['phone', 'Phone', 'link'], ['geo', 'Place', 'link'], ['event', 'Event', 'ticket'],
++ ];
++ const FIELDS = {
++ url: [['url', 'Link', 'url', 'https://openvibe.live']],
++ text: [['text', 'Text', 'textarea', 'Anything up to about 2,900 characters']],
++ wifi: [['ssid', 'Network name (SSID)', 'text', 'Cafe Guest'], ['password', 'Password', 'text', ''], ['security', 'Security', 'seg', [['WPA', 'WPA / WPA2 / WPA3'], ['WEP', 'WEP'], ['nopass', 'None']]], ['hidden', 'Hidden network', 'check']],
++ contact: [['first', 'First name', 'text'], ['last', 'Last name', 'text'], ['phone', 'Phone', 'tel'], ['email', 'Email', 'email'], ['org', 'Organisation', 'text'], ['title', 'Job title', 'text'], ['url', 'Website', 'url'], ['address', 'Address', 'text']],
++ email: [['to', 'To', 'email', '[email protected]'], ['subject', 'Subject', 'text'], ['body', 'Message', 'textarea']],
++ sms: [['phone', 'Phone number', 'tel', '+1 555 010 9999'], ['message', 'Message', 'textarea']],
++ phone: [['phone', 'Phone number', 'tel', '+1 555 010 9999']],
++ geo: [['lat', 'Latitude', 'number', '51.5007'], ['lng', 'Longitude', 'number', '-0.1246'], ['label', 'Label (optional)', 'text']],
++ event: [['title', 'Title', 'text', 'Launch party'], ['start', 'Starts', 'datetime-local'], ['end', 'Ends', 'datetime-local'], ['location', 'Where', 'text'], ['notes', 'Notes', 'textarea']],
++ };
++ const st = {
++ type: T.type || 'url', fields: { url: { url: '' }, wifi: { security: 'WPA' } }, style: 'rounded', eyes: 'rounded',
++ color: '#0b1220', eyeColor: '#0b1220', bg: '#ffffff', grad: false, gradTo: '#3472d8', transparent: false,
++ logo: null, logoSize: 22, ecc: 'auto', quiet: 4, px: 1024, caption: '', dpi: 300,
++ };
++ const saved = store.get('ov_codes_qr_style', null);
++ if (saved) for (const k of ['style', 'eyes', 'color', 'eyeColor', 'bg', 'grad', 'gradTo', 'transparent', 'ecc', 'quiet', 'px', 'dpi']) if (saved[k] !== undefined) st[k] = saved[k];
++ const start = params.get('text');
++ if (start) { if (/^WIFI:/i.test(start)) st.type = 'wifi'; else if (/^https?:\/\//i.test(start)) { st.type = 'url'; st.fields.url = { url: start }; } else { st.type = 'text'; st.fields.text = { text: start }; } }
++
++ const typesEl = $('qr-types');
++ typesEl.innerHTML = TYPES.map(([v, l, i]) => `<button type="button" class="chip" data-v="${v}" aria-pressed="${v === st.type}">${icon(i)}${l}</button>`).join('');
++ typesEl.onclick = (e) => { const b = e.target.closest('[data-v]'); if (!b) return; st.type = b.dataset.v; typesEl.querySelectorAll('.chip').forEach((x) => x.setAttribute('aria-pressed', String(x === b))); fields(); update(); };
++
++ function fields() {
++ const f = st.fields[st.type] || (st.fields[st.type] = {});
++ const box = $('qr-fields');
++ box.innerHTML = FIELDS[st.type].map(([k, label, kind, ph]) => {
++ if (kind === 'seg') return `<div class="field"><span class="lbl">${esc(label)}</span><div class="seg" data-k="${k}" role="group" aria-label="${esc(label)}">${ph.map(([v, l]) => `<button type="button" data-v="${v}" aria-pressed="${(f[k] || ph[0][0]) === v}">${esc(l)}</button>`).join('')}</div></div>`;
++ if (kind === 'check') return `<label class="check" style="margin:2px 0 10px"><input type="checkbox" data-k="${k}"${f[k] ? ' checked' : ''}> ${esc(label)}</label>`;
++ const v = esc(f[k] || '');
++ const input = kind === 'textarea' ? `<textarea data-k="${k}" rows="3" placeholder="${esc(ph || '')}">${v}</textarea>` : `<input type="${kind}" data-k="${k}" value="${v}" placeholder="${esc(ph || '')}"${kind === 'number' ? ' step="any"' : ''} autocomplete="off">`;
++ return `<label class="field"><span>${esc(label)}</span>${input}</label>`;
++ }).join('') + (st.type === 'geo' ? `<button type="button" class="btn small" id="qr-here">${icon('link')}Use my location</button>` : '') + (st.type === 'wifi' ? '<small class="muted">Special characters in the name or password are escaped the way phones expect.</small>' : '');
++ box.oninput = (e) => { const k = e.target.dataset.k; if (!k) return; f[k] = e.target.type === 'checkbox' ? e.target.checked : e.target.value; update(); };
++ box.onclick = (e) => {
++ const b = e.target.closest('.seg[data-k] button'); if (b) { const k = b.parentElement.dataset.k; f[k] = b.dataset.v; b.parentElement.querySelectorAll('button').forEach((x) => x.setAttribute('aria-pressed', String(x === b))); update(); }
++ if (e.target.closest('#qr-here') && navigator.geolocation) navigator.geolocation.getCurrentPosition((p) => { f.lat = p.coords.latitude.toFixed(6); f.lng = p.coords.longitude.toFixed(6); fields(); update(); }, () => toast('Location is not available'));
++ };
++ const first = box.querySelector('input,textarea');
++ if (first && document.activeElement === document.body && ID !== 'codes') first.focus({ preventScroll: true });
++ }
++
++ seg($('qr-style'), [['square', 'Square'], ['rounded', 'Rounded'], ['dots', 'Dots'], ['fluid', 'Fluid']], st.style, (v) => { st.style = v; update(); });
++ seg($('qr-eyes'), [['square', 'Square'], ['rounded', 'Rounded'], ['circle', 'Circle'], ['leaf', 'Leaf']], st.eyes, (v) => { st.eyes = v; update(); });
++ const cFg = bindColor('qr-color', 'qr-color-t', (v) => { st.color = v; update(); });
++ const cEye = bindColor('qr-eye', 'qr-eye-t', (v) => { st.eyeColor = v; update(); });
++ const cBg = bindColor('qr-bg', 'qr-bg-t', (v) => { st.bg = v; update(); });
++ const cTo = bindColor('qr-grad-to', 'qr-grad-to-t', (v) => { st.gradTo = v; update(); });
++ cFg.set(st.color); cEye.set(st.eyeColor); cBg.set(st.bg); cTo.set(st.gradTo);
++ $('qr-grad').checked = st.grad; $('qr-grad-box').hidden = !st.grad; $('qr-transparent').checked = st.transparent;
++ $('qr-grad').onchange = (e) => { st.grad = e.target.checked; $('qr-grad-box').hidden = !st.grad; update(); };
++ $('qr-transparent').onchange = (e) => { st.transparent = e.target.checked; update(); };
++ const accent = (getComputedStyle(document.documentElement).getPropertyValue('--accent-strong') || '#3472d8').trim();
++ const PRESETS = [
++ ['Classic', '#000000', '#000000', '#ffffff', null], ['Ink', '#0b1220', '#0b1220', '#ffffff', null], ['OpenVibe', /^#[0-9a-f]{6}$/i.test(accent) ? accent : '#3472d8', '#0b1220', '#ffffff', null],
++ ['Ocean', '#0b4f6c', '#0b4f6c', '#ffffff', '#0284c7'], ['Sunset', '#b91c1c', '#7c2d12', '#fffbeb', '#d97706'], ['Forest', '#14532d', '#14532d', '#f0fdf4', '#15803d'], ['Grape', '#4c1d95', '#4c1d95', '#faf5ff', '#be185d'],
++ ];
++ $('qr-presets').innerHTML = PRESETS.map(([n, c, , bg, g], i) => `<button type="button" class="chip" data-i="${i}"><span style="width:14px;height:14px;border-radius:4px;background:${g ? `linear-gradient(135deg,${c},${g})` : c};outline:2px solid ${bg};outline-offset:-1px"></span>${esc(n)}</button>`).join('');
++ $('qr-presets').onclick = (e) => {
++ const b = e.target.closest('[data-i]'); if (!b) return;
++ const [, c, eye, bg, g] = PRESETS[+b.dataset.i];
++ Object.assign(st, { color: c, eyeColor: eye, bg, grad: !!g, gradTo: g || st.gradTo, transparent: false });
++ cFg.set(c); cEye.set(eye); cBg.set(bg); cTo.set(st.gradTo); $('qr-grad').checked = st.grad; $('qr-grad-box').hidden = !st.grad; $('qr-transparent').checked = false;
++ update();
++ };
++ // Logo: read, shrunk to 512 px, kept as a data URL inside the SVG.
++ $('qr-logo').onchange = (e) => {
++ const file = e.target.files && e.target.files[0]; if (!file) return;
++ const img = new Image();
++ img.onload = () => {
++ const k = Math.min(1, 512 / Math.max(img.width, img.height));
++ const c = document.createElement('canvas'); c.width = Math.max(1, Math.round(img.width * k)); c.height = Math.max(1, Math.round(img.height * k));
++ c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
++ st.logo = c.toDataURL('image/png');
++ URL.revokeObjectURL(img.src);
++ $('qr-logo-name').textContent = file.name; $('qr-logo-clear').hidden = false;
++ update();
++ };
++ img.onerror = () => toast('That file is not a picture this browser can read');
++ img.src = URL.createObjectURL(file);
++ };
++ $('qr-logo-clear').onclick = () => { st.logo = null; $('qr-logo').value = ''; $('qr-logo-name').textContent = ''; $('qr-logo-clear').hidden = true; update(); };
++ bindRange('qr-logo-size', 'qr-logo-size-o', (v) => `${v}%`, (v) => { st.logoSize = v; update(); });
++ seg($('qr-ecc'), [['auto', 'Auto'], ['L', 'L 7%'], ['M', 'M 15%'], ['Q', 'Q 25%'], ['H', 'H 30%']], st.ecc, (v) => { st.ecc = v; update(); });
++ $('qr-quiet').value = st.quiet; $('qr-px').value = st.px; $('qr-dpi').value = String(st.dpi);
++ bindRange('qr-quiet', 'qr-quiet-o', (v) => `${v} module${v === 1 ? '' : 's'}`, (v) => { st.quiet = v; update(); });
++ bindRange('qr-px', 'qr-px-o', (v) => `${v} px`, (v) => { st.px = v; printNote(); save(); });
++ $('qr-caption').oninput = (e) => { st.caption = e.target.value; update(); };
++ $('qr-dpi').onchange = (e) => { st.dpi = +e.target.value; printNote(); save(); };
++
++ const eccFor = () => (st.ecc !== 'auto' ? st.ecc : st.logo ? 'H' : st.style === 'dots' || st.style === 'fluid' ? 'Q' : 'M');
++ const payload = () => S.qrPayload(st.type, st.fields[st.type] || {});
++ let last = null; // { svg, n, text }
++ let gen = 0;
++ const save = debounce(() => store.set('ov_codes_qr_style', { style: st.style, eyes: st.eyes, color: st.color, eyeColor: st.eyeColor, bg: st.bg, grad: st.grad, gradTo: st.gradTo, transparent: st.transparent, ecc: st.ecc, quiet: st.quiet, px: st.px, dpi: st.dpi }), 400);
++ function printNote() {
++ const mm = D.pxToMm(st.px, st.dpi);
++ $('qr-print-note').querySelector('span').innerHTML = `${st.px} px prints <b>${mm.toFixed(1)} mm</b> wide at ${st.dpi} DPI, comfortable to scan from about <b>${Math.round(mm)} cm</b> away.`;
++ }
++ function svgFor(size) {
++ const ecc = eccFor();
++ const raw = bwipjs.raw({ bcid: 'qrcode', text: last.text, eclevel: ecc });
++ return D.qrSvg(raw, { style: st.style, eyes: st.eyes, color: st.color, eyeColor: st.eyeColor !== st.color ? st.eyeColor : undefined, background: st.transparent ? 'transparent' : st.bg, gradient: st.grad ? { to: st.gradTo, angle: 45 } : null, quiet: st.quiet, logo: st.logo ? { href: st.logo, size: st.logoSize / 100 } : null, caption: st.caption, size }).svg;
++ }
++ const check = debounce(async (g, svg, text) => {
++ try {
++ const c = await svgToCanvas(svg, 720, false);
++ const r = await checkScan(c, text);
++ if (g !== gen) return;
++ if (r.ok) verdict($('qr-verdict'), 'ok', `Test-scanned: reads back exactly${st.logo ? ', logo included' : ''}`);
++ else if (r.text) verdict($('qr-verdict'), 'bad', `Reads as something else: ${esc(r.text.slice(0, 60))}`);
++ else verdict($('qr-verdict'), 'bad', 'Did not scan in the test: raise error correction, shrink the logo or add contrast');
++ } catch (e) {
++ if (g === gen) verdict($('qr-verdict'), '', `Not checked: ${esc(e.message || 'the reader did not load')}`);
++ }
++ }, 350);
++ function update() {
++ save();
++ const text = payload();
++ const stage = $('qr-stage');
++ const notes = [];
++ gen++;
++ if (!text || text === 'https://' || /^(WIFI:T:\w+;S:;|mailto:$|SMSTO::|tel:$)/.test(text)) {
++ stage.innerHTML = '<div class="empty">Fill in the fields to make your code</div>'; last = null;
++ verdict($('qr-verdict'), '', '—'); facts($('qr-facts'), []); $('qr-notes').innerHTML = '';
++ return;
++ }
++ try {
++ last = { text };
++ const ecc = eccFor();
++ const raw = bwipjs.raw({ bcid: 'qrcode', text, eclevel: ecc });
++ const n = D.matrixOf(raw).n;
++ const svg = svgFor(null);
++ stage.innerHTML = `<div class="art svg" role="img" aria-label="QR code preview">${svg}</div>`;
++ const bytes = new TextEncoder().encode(text).length;
++ facts($('qr-facts'), [[`${(n - 17) / 4}`, 'Version'], [`${n}×${n}`, 'Modules'], [`${ecc} · ${bytes} B`, 'ECC · data']]);
++ $('qr-ecc-note').textContent = st.ecc === 'auto' ? `Auto picked ${ecc}${st.logo ? ' because of the logo' : st.style === 'dots' || st.style === 'fluid' ? ' for the rounder modules' : ''}.` : '';
++ const fg = st.color, bg = st.transparent ? '#ffffff' : st.bg;
++ const ratio = S.contrast(fg, bg);
++ if (ratio < 3) notes.push(note('warn', `Low contrast (${ratio.toFixed(1)}:1). Scanners want dark modules on a light background, ideally 4:1 or more.`));
++ const lum = (h) => S.contrast(h, '#000000');
++ if (lum(fg) > lum(bg)) notes.push(note('warn', 'Light modules on a dark background: many phone cameras cannot read inverted codes.'));
++ if (st.transparent) notes.push(note('', 'Transparent background: print it on something light, with clear space around it.'));
++ if (n > 57 && st.type === 'contact') notes.push(note('warn', 'A dense code: leave out fields people do not need, or print it larger than 3 cm.'));
++ if (st.type === 'url' && /^https?:\/\//i.test(text) && text.length > 200) notes.push(note('', 'A long link makes a dense code. A shorter address scans faster.'));
++ $('qr-notes').innerHTML = notes.join('');
++ verdict($('qr-verdict'), 'wait', 'Test-scanning…');
++ check(gen, svg, text);
++ printNote();
++ } catch (e) {
++ last = null;
++ stage.innerHTML = `<div class="err">${esc(bwipError(e))}</div>`;
++ verdict($('qr-verdict'), 'bad', 'Too much for one QR code');
++ facts($('qr-facts'), []);
++ }
++ }
++ const name = () => `qr-${slug(st.type === 'url' ? (st.fields.url || {}).url : st.type)}`;
++ $('qr-svg').onclick = () => { if (!last) return toast('Nothing to download yet'); download(new Blob([svgFor(st.px)], { type: 'image/svg+xml' }), `${name()}.svg`); };
++ const png = async () => { const c = await svgToCanvas(svgFor(null), st.px, st.transparent); const b = await canvasBlob(c); return new Blob([D.pngWithDpi(new Uint8Array(await b.arrayBuffer()), st.dpi)], { type: 'image/png' }); };
++ $('qr-png').onclick = async () => { if (!last) return toast('Nothing to download yet'); download(await png(), `${name()}.png`); };
++ $('qr-copy').onclick = async () => { if (!last) return toast('Nothing to copy yet'); copyPng(await png()); };
++ fields();
++ update();
++ currentText = () => (last ? last.text : '');
++ }
++
++ // ═════════════════════════════════════════════════════════════════════════════════════════════════════════════════
++ // Barcode studio (every symbology; the single-type pages start on theirs)
++ // ═════════════════════════════════════════════════════════════════════════════════════════════════════════════════
++ const GROUPS = [
++ ['Retail (EAN / UPC / ISBN)', ['ean13', 'ean8', 'upca', 'upce', 'isbn', 'issn', 'ismn', 'ean5', 'ean2']],
++ ['Industrial & logistics', ['code128', 'code39', 'code39ext', 'code93', 'code93ext', 'interleaved2of5', 'itf14', 'code11', 'rationalizedCodabar', 'msi', 'plessey', 'telepen', 'telepennumeric', 'code2of5', 'industrial2of5', 'matrix2of5', 'iata2of5', 'datalogic2of5', 'coop2of5', 'bc412', 'channelcode', 'posicode', 'codablockf', 'code16k', 'code49']],
++ ['GS1', ['gs1-128', 'ean14', 'sscc18', 'gs1datamatrix', 'gs1datamatrixrectangular', 'gs1qrcode', 'gs1dotcode', 'databaromni', 'databarstacked', 'databarstackedomni', 'databartruncated', 'databarlimited', 'databarexpanded', 'databarexpandedstacked', 'gs1northamericancoupon', 'gs1dldatamatrix', 'gs1dlqrcode']],
++ ['2D', ['qrcode', 'microqrcode', 'rectangularmicroqrcode', 'swissqrcode', 'datamatrix', 'datamatrixrectangular', 'datamatrixrectangularextension', 'pdf417', 'pdf417compact', 'micropdf417', 'azteccode', 'azteccodecompact', 'aztecrune', 'maxicode', 'dotcode', 'hanxin', 'codeone', 'ultracode']],
++ ['Postal', ['onecode', 'postnet', 'planet', 'royalmail', 'mailmark', 'auspost', 'kix', 'japanpost', 'identcode', 'leitcode', 'daft', 'flattermarken']],
++ ['Healthcare & pharma', ['pharmacode', 'pharmacode2', 'code32', 'pzn', 'hibccode39', 'hibccode128', 'hibcdatamatrix', 'hibcdatamatrixrectangular', 'hibcpdf417', 'hibcmicropdf417', 'hibcqrcode', 'hibccodablockf', 'hibcazteccode']],
++ ];
++ const TWO_D = /qrcode|datamatrix|aztec|pdf417|maxicode|dotcode|hanxin|codeone|ultracode|codablockf|code16k|code49|mailmark|rune/;
++ const READABLE = /^(qrcode|microqrcode|rectangularmicroqrcode|swissqrcode|gs1qrcode|hibcqrcode|datamatrix|datamatrixrectangular|gs1datamatrix|gs1datamatrixrectangular|hibcdatamatrix|hibcdatamatrixrectangular|azteccode|azteccodecompact|aztecrune|hibcazteccode|pdf417|pdf417compact|micropdf417|hibcpdf417|hibcmicropdf417|maxicode|ean13|ean8|upca|upce|isbn|issn|ismn|code128|gs1-128|ean14|sscc18|hibccode128|code39|code39ext|hibccode39|code32|pzn|code93|code93ext|interleaved2of5|itf14|rationalizedCodabar|databaromni|databarstacked|databarstackedomni|databartruncated|databarlimited|databarexpanded|databarexpandedstacked|telepen|telepennumeric)$/;
++ const NO_TEXT = /pharmacode|postnet|planet|royalmail|auspost|kix|japanpost|onecode|flattermarken|mailmark|daft/;
++ const RETAIL = /^(ean13|ean8|upca|upce|isbn|ismn|issn)$/;
++ /** includetext for a barcode: retail symbologies place their digits themselves, the rest centre one line. */
++ const textOpts = (sym) => (NO_TEXT.test(sym) ? {} : RETAIL.test(sym) ? { includetext: true, guardwhitespace: true } : { includetext: true, textxalign: 'center', textyoffset: 3 });
++ function symbolOptions(select, current, filter) {
++ const all = new Map(bwipjs.symbolList.map((s) => [s.bcid, s]));
++ const used = new Set();
++ const f = String(filter || '').toLowerCase();
++ const match = (s) => !f || s.bcid.toLowerCase().includes(f) || s.desc.toLowerCase().includes(f);
++ let html = '';
++ for (const [label, ids] of GROUPS) {
++ const opts = ids.filter((id) => all.has(id) && match(all.get(id))).map((id) => { used.add(id); return `<option value="${id}"${id === current ? ' selected' : ''}>${esc(all.get(id).desc)}</option>`; });
++ if (opts.length) html += `<optgroup label="${esc(label)}">${opts.join('')}</optgroup>`;
++ }
++ const rest = [...all.values()].filter((s) => !used.has(s.bcid) && !['raw', 'symbol'].includes(s.bcid) && !/composite|gs1-cc/.test(s.bcid) && match(s));
++ if (rest.length) html += `<optgroup label="More">${rest.map((s) => `<option value="${s.bcid}"${s.bcid === current ? ' selected' : ''}>${esc(s.desc)}</option>`).join('')}</optgroup>`;
++ select.innerHTML = html || '<option disabled>No symbology matches</option>';
++ }
++
++ /** bwip-js options for the barcode studio's state (`dots` px per module). */
++ function bwipOpts(st, text, dots) {
++ const twoD = TWO_D.test(st.sym);
++ const o = { bcid: st.sym, text, scale: dots, barcolor: st.color.slice(1), backgroundcolor: st.bg.slice(1), rotate: st.rotate, paddingwidth: twoD ? Math.min(st.quiet, 10) : st.quiet, paddingheight: twoD ? Math.min(st.quiet, 10) : Math.max(4, Math.min(st.quiet, 6)) };
++ if (!twoD) {
++ // bwip-js reads height in mm at 72 dpi times the scale: this makes the bars st.height mm at st.dpi.
++ o.height = (st.height * st.dpi) / (72 * dots);
++ // Retail symbologies lay out their own digits (the first one outside the bars, then the groups).
++ const retail = RETAIL.test(st.sym);
++ if (st.showText && !NO_TEXT.test(st.sym)) { o.includetext = true; if (!retail) { o.textxalign = 'center'; o.textyoffset = 3; } }
++ if (retail) o.guardwhitespace = true;
++ }
++ Object.assign(o, st.extra);
++ if (st.sym === 'ean13' && st.isbnLine) o.extratext = st.isbnLine;
++ return o;
++ }
++
++ function barcode() {
++ const st = {
++ sym: T.sym || params.get('sym') || 'auto', color: '#000000', bg: '#ffffff', rotate: 'N', quiet: 10, height: 15, x: 0.33, dpi: 300, showText: true, extra: {}, isbnLine: null,
++ };
++ const auto = !T.sym && !params.get('sym');
++ const input = $('bc-text');
++ input.value = params.get('text') || T.sample || '';
++ if (auto && !input.value) input.value = '4006381333931';
++ const select = $('bc-type');
++ const pickSym = () => (st.sym === 'auto' ? S.recommend(input.value).symbology : st.sym);
++ if (st.sym === 'auto') st.sym = pickSym();
++ if (st.sym === 'isbn') { st.isbnPage = true; }
++ symbolOptions(select, st.sym === 'isbn' ? 'ean13' : st.sym);
++ $('bc-find').oninput = (e) => symbolOptions(select, st.sym, e.target.value);
++ select.onchange = () => { st.sym = select.value; st.isbnPage = false; extras(); update(); };
++ $('bc-sample').onclick = () => { const s = bwipjs.symbolList.find((x) => x.bcid === st.sym); if (s) { input.value = s.text; update(); } };
++ seg($('bc-rotate'), [['N', '0°'], ['R', '90°'], ['I', '180°'], ['L', '270°']], st.rotate, (v) => { st.rotate = v; update(); });
++ const cB = bindColor('bc-color', 'bc-color-t', (v) => { st.color = v; update(); });
++ const cG = bindColor('bc-bg', 'bc-bg-t', (v) => { st.bg = v; update(); });
++ cB.set(st.color); cG.set(st.bg);
++ bindRange('bc-height', 'bc-height-o', (v) => `${v} mm`, (v) => { st.height = v; update(); });
++ bindRange('bc-x', 'bc-x-o', (v) => `${v.toFixed(2)} mm`, (v) => { st.x = v; update(); });
++ bindRange('bc-quiet', 'bc-quiet-o', (v) => `${v}`, (v) => { st.quiet = v; update(); });
++ $('bc-showtext').onchange = (e) => { st.showText = e.target.checked; update(); };
++ $('bc-dpi').onchange = (e) => { st.dpi = +e.target.value; update(); };
++ input.addEventListener('input', () => { if (auto && !st.touched) { const r = S.recommend(input.value).symbology; if (r !== st.sym) { st.sym = r; symbolOptions(select, st.sym); extras(); } } update(); });
++ select.addEventListener('change', () => { st.touched = true; });
++
++ // Symbology-specific controls.
++ function extras() {
++ const box = $('bc-extra');
++ st.extra = {};
++ const s = st.isbnPage ? 'isbn' : st.sym;
++ const desc = (bwipjs.symbolList.find((x) => x.bcid === st.sym) || {}).desc || st.sym;
++ $('bc-type-desc').textContent = `${desc}${READABLE.test(st.sym) ? ' · test-scanned as you type' : ''}`;
++ $('bc-height-f').hidden = TWO_D.test(st.sym);
++ $('bc-text-f').hidden = TWO_D.test(st.sym) || NO_TEXT.test(st.sym);
++ let html = '';
++ if (/^(qrcode|microqrcode|gs1qrcode|swissqrcode|hibcqrcode)$/.test(s)) html = '<div class="field"><span class="lbl">Error correction</span><div class="seg" id="bx-ecc"></div></div>';
++ else if (/pdf417/.test(s)) html = '<div class="row"><label class="field grow"><span>Columns</span><select id="bx-cols"><option value="">Auto</option>' + Array.from({ length: 30 }, (_, i) => `<option>${i + 1}</option>`).join('') + '</select></label><label class="field grow"><span>Error correction</span><select id="bx-pecc"><option value="">Auto</option>' + Array.from({ length: 9 }, (_, i) => `<option>${i}</option>`).join('') + '</select></label></div>';
++ else if (/aztec/.test(s) && s !== 'aztecrune') html = '<div class="field"><span class="lbl">Error correction</span><div class="range"><input type="range" id="bx-aecc" min="5" max="95" value="23" aria-label="Aztec error correction"><output id="bx-aecc-o">23%</output></div></div>';
++ else if (/datamatrix/.test(s)) html = '<div class="row" style="align-items:center;gap:16px"><div class="seg" id="bx-shape"></div><label class="check"><input type="checkbox" id="bx-gs1"' + (/gs1/.test(s) ? ' checked' : '') + '> GS1 (Application Identifiers in brackets)</label></div>';
++ else if (/^(ean13|ean8|upca|upce|isbn)$/.test(s)) html = `<div class="row"><label class="field grow"><span>${s === 'isbn' ? 'Price add-on (EAN-5)' : 'Add-on (2 or 5 digits)'}</span><input type="text" id="bx-addon" inputmode="numeric" maxlength="5" placeholder="${s === 'isbn' ? '51995 = US $19.95, 90000 = no price' : 'optional'}"></label></div>`;
++ else if (/^(code39|code39ext|interleaved2of5|code11|msi)$/.test(s)) html = '<label class="check"><input type="checkbox" id="bx-check"> Add a check digit</label>';
++ box.innerHTML = html;
++ if ($('bx-ecc')) seg($('bx-ecc'), [['L', 'L'], ['M', 'M'], ['Q', 'Q'], ['H', 'H']], 'M', (v) => { st.extra.eclevel = v; update(); });
++ if ($('bx-cols')) { $('bx-cols').onchange = (e) => { st.extra.columns = e.target.value ? +e.target.value : undefined; update(); }; $('bx-pecc').onchange = (e) => { st.extra.eclevel = e.target.value === '' ? undefined : +e.target.value; update(); }; }
++ if ($('bx-aecc')) bindRange('bx-aecc', 'bx-aecc-o', (v) => `${v}%`, (v) => { st.extra.eclevel = v; update(); });
++ if ($('bx-shape')) {
++ const rect = /rectangular/.test(st.sym);
++ seg($('bx-shape'), [['square', 'Square'], ['rectangle', 'Rectangle']], rect ? 'rectangle' : 'square', (v) => { const g = $('bx-gs1').checked; st.sym = (g ? 'gs1datamatrix' : 'datamatrix') + (v === 'rectangle' ? 'rectangular' : ''); symbolOptions(select, st.sym); update(); });
++ $('bx-gs1').onchange = (e) => { const r = /rectangular/.test(st.sym); st.sym = (e.target.checked ? 'gs1datamatrix' : 'datamatrix') + (r ? 'rectangular' : ''); symbolOptions(select, st.sym); update(); };
++ }
++ if ($('bx-addon')) $('bx-addon').oninput = (e) => { const v = e.target.value.replace(/\D/g, ''); st.extra.addon = /^(\d{2}|\d{5})$/.test(v) ? v : undefined; update(); };
++ if ($('bx-check')) $('bx-check').onchange = (e) => { st.extra.includecheck = e.target.checked || undefined; update(); };
++ }
++
++ /** The text bwip-js gets, and what the smart box says about it: { text, sym, notes[] }. */
++ function prepare() {
++ const raw = input.value.replace(/\r/g, '');
++ const t = TWO_D.test(st.sym) ? raw : raw.trim();
++ const notes = [];
++ let sym = st.sym, text = t;
++ st.isbnLine = null;
++ const num = S.identifyNumber(t, { complete: true });
++ if (st.isbnPage || st.sym === 'isbn') {
++ const id = S.identifyNumber(t);
++ if (!id || !['isbn10', 'isbn13'].includes(id.kind)) { notes.push(note('', 'Type an ISBN-10 or ISBN-13. With hyphens as on the copyright page, they are printed above the bars.')); return { text: '', sym, notes }; }
++ if (!id.valid) notes.push(note('bad', `The check digit should be <b>${esc(id.expected)}</b>.`, `<button type="button" class="btn small act" data-fix="${esc(id.fixed)}">Fix</button>`));
++ const thirteen = id.kind === 'isbn10' ? id.isbn13 : id.digits;
++ const typed = t.replace(/^ISBN(?:-1[03])?:?\s*/i, '');
++ if (/^97[89]-\d+-\d+-\d+-\d$/.test(typed) && typed.length === 17) { sym = 'isbn'; text = typed; } else { sym = 'ean13'; text = thirteen; st.isbnLine = `ISBN ${thirteen}`; }
++ if (id.kind === 'isbn10') notes.push(note('ok', `ISBN-10 ${esc(id.fixed)} is ISBN-13 <b>${esc(thirteen)}</b>: books are printed with the 13-digit one.`));
++ return { text, sym, notes };
++ }
++ if (/^(ean13|upca|ean8|itf14|upce)$/.test(st.sym) && num) {
++ const want = { ean13: [12, 13], upca: [11, 12], ean8: [7, 8], itf14: [13, 14], upce: [8, 8] }[st.sym];
++ const d = t.replace(/[\s-]/g, '');
++ if (d.length === want[0] && want[0] !== want[1]) { text = d + S.gs1CheckDigit(d); notes.push(note('ok', `Check digit <b>${text.slice(-1)}</b> added: ${esc(text)}.`)); }
++ else if (d.length === want[1] && !S.gs1Valid(d) && st.sym !== 'upce') notes.push(note('bad', `The check digit is ${esc(d.slice(-1))}; it should be <b>${esc(S.gs1CheckDigit(d.slice(0, -1)))}</b>.`, `<button type="button" class="btn small act" data-fix="${esc(d.slice(0, -1) + S.gs1CheckDigit(d.slice(0, -1)))}">Fix</button>`));
++ else if (num.valid) notes.push(note('ok', `${esc(num.note)} Check digit correct.`));
++ if (st.sym !== 'upce') text = text.replace(/[\s-]/g, '');
++ }
++ if (/^gs1|^ean14|^sscc18/.test(st.sym) && t && !/^\(\d{2,4}\)/.test(t) && st.sym !== 'gs1northamericancoupon') {
++ if (/^\d{18}$/.test(t) && st.sym === 'gs1-128') { text = `(00)${t}`; notes.push(note('', `Read as an SSCC: encoded as <span class="mono">${esc(text)}</span>.`)); }
++ else notes.push(note('warn', 'GS1 symbologies take Application Identifiers in brackets, e.g. <span class="mono">(01)09506000134352(10)ABC</span>.'));
++ }
++ if (/^\(\d{2,4}\)/.test(t)) {
++ const g = S.parseGs1(t);
++ g.parts.filter((p) => p.error).forEach((p) => notes.push(note('bad', `(${esc(p.ai)}) ${esc(p.title)}: ${esc(p.error)}.`)));
++ }
++ // What the text looks like, when that is not the chosen symbology.
++ if (auto || !T.sym) {
++ const rec = S.recommend(t);
++ if (t && rec.symbology !== st.sym && !(rec.symbology === 'ean13' && st.sym === 'isbn')) {
++ const name = (bwipjs.symbolList.find((x) => x.bcid === rec.symbology) || {}).desc || rec.symbology;
++ notes.push(note('', `${esc(rec.why)}`, `<button type="button" class="btn small act" data-use="${esc(rec.symbology)}"${rec.encode ? ` data-text="${esc(rec.encode)}"` : ''}>Use ${esc(name)}</button>`));
++ }
++ }
++ return { text, sym, notes };
++ }
++
++ let gen = 0;
++ let lastPrep = null;
++ const check = debounce(async (g, canvas, text, sym) => {
++ if (!READABLE.test(sym)) { verdict($('bc-verdict'), '', 'No second reader for this symbology: drawn to its specification'); return; }
++ try {
++ const r = await checkScan(canvas, text.replace(/\s+\d{2,5}$/, ''));
++ if (g !== gen) return;
++ if (r.ok) verdict($('bc-verdict'), 'ok', `Test-scanned as ${esc(r.label)}: reads back exactly`);
++ else if (r.text) verdict($('bc-verdict'), 'ok', `Test-scanned as ${esc(r.label)}: ${esc(r.text.slice(0, 48))}`);
++ else verdict($('bc-verdict'), 'bad', 'Did not scan in the test: try a wider module, more contrast or a taller bar');
++ } catch (e) { if (g === gen) verdict($('bc-verdict'), '', `Not checked: ${esc(e.message || 'the reader did not load')}`); }
++ }, 350);
++ const stage = $('bc-stage');
++ function update() {
++ gen++;
++ const p = prepare();
++ lastPrep = p;
++ $('bc-smart').innerHTML = p.notes.join('');
++ if (!p.text) { stage.innerHTML = '<div class="empty">Type something to encode</div>'; verdict($('bc-verdict'), '', '—'); facts($('bc-facts'), []); return; }
++ const dots = Math.max(1, Math.round((st.x / 25.4) * st.dpi));
++ $('bc-dots').textContent = `${dots} dot${dots === 1 ? '' : 's'} per module = ${((dots / st.dpi) * 25.4).toFixed(3)} mm`;
++ try {
++ const prev = { ...st, sym: p.sym };
++ const c = document.createElement('canvas');
++ // On screen: a few px per module, whatever the print size.
++ bwipjs.toCanvas(c, bwipOpts({ ...prev, dpi: 72 * 3, height: st.height }, p.text, 3));
++ c.className = 'art';
++ c.setAttribute('role', 'img'); c.setAttribute('aria-label', `${p.sym} barcode preview`);
++ stage.replaceChildren(c);
++ // Print size from a render at the real resolution.
++ const real = document.createElement('canvas');
++ bwipjs.toCanvas(real, bwipOpts(prev, p.text, dots));
++ const wmm = D.pxToMm(real.width, st.dpi), hmm = D.pxToMm(real.height, st.dpi);
++ $('bc-size').textContent = `${wmm.toFixed(1)} × ${hmm.toFixed(1)} mm (${real.width} × ${real.height} px)`;
++ const desc = (bwipjs.symbolList.find((x) => x.bcid === p.sym) || {}).desc || p.sym;
++ facts($('bc-facts'), [[desc, 'Symbology'], [`${wmm.toFixed(1)} mm`, 'Width'], [`${p.text.length}`, 'Characters']]);
++ verdict($('bc-verdict'), 'wait', 'Test-scanning…');
++ const vc = document.createElement('canvas');
++ bwipjs.toCanvas(vc, { ...bwipOpts({ ...prev, rotate: 'N', color: '#000000', bg: '#ffffff' }, p.text, Math.max(2, Math.min(4, dots))), backgroundcolor: 'ffffff' });
++ check(gen, vc, p.text, p.sym);
++ } catch (e) {
++ stage.innerHTML = `<div class="err">${esc(bwipError(e))}</div>`;
++ verdict($('bc-verdict'), 'bad', 'This text cannot be drawn in this symbology');
++ facts($('bc-facts'), []);
++ }
++ }
++ $('bc-smart').addEventListener('click', (e) => {
++ const fix = e.target.closest('[data-fix]'); if (fix) { input.value = fix.dataset.fix; update(); return; }
++ const use = e.target.closest('[data-use]'); if (use) { st.sym = use.dataset.use; st.touched = true; st.isbnPage = false; if (use.dataset.text) input.value = use.dataset.text; symbolOptions(select, st.sym); extras(); update(); }
++ });
++ const fileName = () => `${lastPrep ? lastPrep.sym : 'barcode'}-${slug(lastPrep ? lastPrep.text : 'code')}`;
++ async function pngBlob() {
++ const p = lastPrep; if (!p || !p.text) return null;
++ const dots = Math.max(1, Math.round((st.x / 25.4) * st.dpi));
++ const c = document.createElement('canvas');
++ bwipjs.toCanvas(c, bwipOpts({ ...st, sym: p.sym }, p.text, dots));
++ const b = await canvasBlob(c);
++ return new Blob([D.pngWithDpi(new Uint8Array(await b.arrayBuffer()), st.dpi)], { type: 'image/png' });
++ }
++ $('bc-png').onclick = async () => { try { const b = await pngBlob(); if (!b) return toast('Nothing to download yet'); download(b, `${fileName()}.png`); } catch (e) { toast(bwipError(e)); } };
++ $('bc-copy').onclick = async () => { try { const b = await pngBlob(); if (!b) return toast('Nothing to copy yet'); copyPng(b); } catch (e) { toast(bwipError(e)); } };
++ $('bc-svg').onclick = () => {
++ const p = lastPrep; if (!p || !p.text) return toast('Nothing to download yet');
++ try {
++ const dots = Math.max(1, Math.round((st.x / 25.4) * st.dpi));
++ let svg = bwipjs.toSVG(bwipOpts({ ...st, sym: p.sym }, p.text, dots));
++ // Physical size on the root element, so it prints at the size shown.
++ const vb = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(svg);
++ if (vb) svg = svg.replace('<svg ', `<svg width="${D.pxToMm(+vb[1], st.dpi).toFixed(2)}mm" height="${D.pxToMm(+vb[2], st.dpi).toFixed(2)}mm" `);
++ download(new Blob([svg], { type: 'image/svg+xml' }), `${fileName()}.svg`);
++ } catch (e) { toast(bwipError(e)); }
++ };
++ extras();
++ update();
++ currentText = () => input.value.trim();
++ }
++
++ // ═════════════════════════════════════════════════════════════════════════════════════════════════════════════════
++ // Scanner
++ // ═════════════════════════════════════════════════════════════════════════════════════════════════════════════════
++ function scan() {
++ const video = $('sc-video'), shot = $('sc-shot'), overlay = $('sc-overlay'), drop = $('sc-drop'), list = $('sc-results');
++ let stream = null, timer = null, devices = [], deviceIdx = 0, busy = false, torch = false;
++ const seen = new Map(); // text → last time shown (ms)
++ let history = store.get('ov_codes_scans', []);
++ const engine = $('sc-engine');
++ R.native().then((d) => { engine.textContent = d ? 'Reader: this browser\'s barcode detector, with ZXing (WebAssembly) for pictures and the formats it lacks.' : 'Reader: ZXing, compiled to WebAssembly and running on this page.'; });
++
++ function beep() {
++ if (!$('sc-beep').checked) return;
++ try { const a = new (window.AudioContext || window.webkitAudioContext)(); const o = a.createOscillator(); const g = a.createGain(); o.frequency.value = 1320; g.gain.value = 0.06; o.connect(g); g.connect(a.destination); o.start(); o.stop(a.currentTime + 0.09); o.onended = () => a.close(); } catch { /* no audio */ }
++ if (navigator.vibrate && (!navigator.userActivation || navigator.userActivation.hasBeenActive)) navigator.vibrate(30);
++ }
++ function card(r, when, fresh) {
++ const d = S.describePayload(r.text, r.format);
++ const safe = d.href && /^(https?:|mailto:|tel:)/i.test(d.href) ? d.href : null;
++ const fields = d.fields.length ? `<dl>${d.fields.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl>` : '';
++ const wifiPass = d.type === 'wifi' ? (d.fields.find((f) => f[0] === 'Password') || [])[1] : null;
++ const make = toolUrl(/^(EAN|UPC|ITF|Code|Codabar|GS1 DataBar|ISBN)/.test(r.label) ? 'barcode' : r.label === 'Data Matrix' ? 'datamatrix' : 'qr', { text: r.text.slice(0, 700) });
++ const el = document.createElement('article');
++ el.className = 'result';
++ if (!fresh) el.style.animation = 'none';
++ el.innerHTML = `<div class="rh"><span class="fmt">${esc(r.label)}</span><span class="kind">${esc(d.title)}</span><span class="when">${esc(when)}</span></div>${fields}<div class="raw">${esc(r.text)}</div>
++ <div class="acts"><button type="button" class="btn small" data-copy>${icon('copy')}Copy</button>${wifiPass && wifiPass !== '(none)' ? `<button type="button" class="btn small" data-pass>${icon('wifi')}Copy password</button>` : ''}${safe ? `<a class="btn small primary" href="${esc(safe)}" target="_blank" rel="noopener noreferrer nofollow">${icon('link')}Open ${esc(d.type === 'url' ? new URL(safe).host : d.title.toLowerCase())}</a>` : ''}<a class="btn small ghost" href="${esc(make)}">${icon('spark')}Make this code</a></div>`;
++ el.querySelector('[data-copy]').onclick = () => copyText(r.text);
++ if (el.querySelector('[data-pass]')) el.querySelector('[data-pass]').onclick = () => copyText(wifiPass);
++ return el;
++ }
++ function renderHistory() {
++ list.replaceChildren();
++ if (!history.length) { list.innerHTML = note('', 'Codes you scan appear here, with what they contain. Nothing is opened until you choose to.'); return; }
++ history.slice(0, 50).forEach((h) => list.appendChild(card(h, new Date(h.at).toLocaleString([], { dateStyle: 'short', timeStyle: 'short' }), false)));
++ }
++ function found(results) {
++ const now = Date.now();
++ let added = false;
++ for (const r of results) {
++ const key = `${r.format}|${r.text}`;
++ if (now - (seen.get(key) || 0) < 4000) { seen.set(key, now); continue; }
++ seen.set(key, now);
++ history = [{ text: r.text, format: r.format, label: r.label, at: now }, ...history.filter((h) => !(h.text === r.text && h.format === r.format))].slice(0, 50);
++ if (list.querySelector('.note')) list.replaceChildren();
++ list.prepend(card(r, 'just now', true));
++ added = true;
++ }
++ if (added) { beep(); store.set('ov_codes_scans', history); }
++ }
++ /** Boxes over the picture: source coordinates mapped to the element, which shows the source with object-fit: contain. */
++ function outline(results, sw, sh) {
++ const rect = overlay.getBoundingClientRect();
++ const dpr = window.devicePixelRatio || 1;
++ overlay.width = Math.round(rect.width * dpr); overlay.height = Math.round(rect.height * dpr);
++ const g = overlay.getContext('2d');
++ g.setTransform(dpr, 0, 0, dpr, 0, 0);
++ g.clearRect(0, 0, rect.width, rect.height);
++ if (!sw || !sh) return;
++ const k = Math.min(rect.width / sw, rect.height / sh);
++ const ox = (rect.width - sw * k) / 2, oy = (rect.height - sh * k) / 2;
++ const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#3b82f6';
++ for (const r of results) {
++ if (!r.box || r.box.length < 4) continue;
++ g.beginPath();
++ r.box.forEach(([x, y], i) => { const px = ox + x * k, py = oy + y * k; if (i) g.lineTo(px, py); else g.moveTo(px, py); });
++ g.closePath();
++ g.lineWidth = 3; g.strokeStyle = accent; g.fillStyle = 'rgba(59,130,246,.18)';
++ g.fill(); g.stroke();
++ // A tag with the format over the code's top edge, kept inside the picture (the cards hold the text).
++ const xs = r.box.map((p) => ox + p[0] * k), ys = r.box.map((p) => oy + p[1] * k);
++ g.font = '700 11px Inter, system-ui, sans-serif';
++ const w = g.measureText(r.label).width + 12;
++ const tx = Math.max(0, Math.min(rect.width - w, Math.min(...xs))), ty = Math.max(0, Math.min(...ys) - 20);
++ g.fillStyle = accent; g.beginPath(); g.roundRect ? g.roundRect(tx, ty, w, 18, 5) : g.rect(tx, ty, w, 18); g.fill();
++ g.fillStyle = '#fff'; g.fillText(r.label, tx + 6, ty + 13);
++ }
++ }
++ const frame = document.createElement('canvas');
++ async function tick() {
++ if (!stream || busy || video.readyState < 2) return;
++ busy = true;
++ try {
++ let res = null;
++ if (await R.native()) res = await R.readNative(video).catch(() => null);
++ if (!res || !res.length) {
++ const vw = video.videoWidth, vh = video.videoHeight;
++ const k = Math.min(1, 1280 / Math.max(vw, vh));
++ frame.width = Math.round(vw * k); frame.height = Math.round(vh * k);
++ const g = frame.getContext('2d', { willReadFrequently: true });
++ g.drawImage(video, 0, 0, frame.width, frame.height);
++ res = (await R.readZxing(g.getImageData(0, 0, frame.width, frame.height), { tryHarder: false, max: 8 })).map((r) => ({ ...r, box: r.box && r.box.map(([x, y]) => [x / k, y / k]) }));
++ }
++ outline(res, video.videoWidth, video.videoHeight);
++ if (res.length) found(res);
++ } catch (e) { engine.textContent = `Reader problem: ${e.message}`; }
++ busy = false;
++ }
++ async function start(deviceId) {
++ stop();
++ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { toast('This browser cannot use a camera here: choose a picture instead'); return; }
++ try {
++ stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: deviceId ? { deviceId: { exact: deviceId }, width: { ideal: 1920 }, height: { ideal: 1080 } } : { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 } } });
++ } catch (e) {
++ toast(e && e.name === 'NotAllowedError' ? 'Camera permission was refused' : 'No camera is available');
++ return;
++ }
++ video.srcObject = stream; await video.play().catch(() => {});
++ video.hidden = false; shot.hidden = true; drop.hidden = true; $('sc-aim').hidden = false;
++ $('sc-stop').hidden = false; $('sc-again').hidden = true;
++ devices = (await navigator.mediaDevices.enumerateDevices()).filter((d) => d.kind === 'videoinput');
++ $('sc-flip').hidden = devices.length < 2;
++ const track = stream.getVideoTracks()[0];
++ const caps = track && track.getCapabilities ? track.getCapabilities() : {};
++ $('sc-torch').hidden = !caps.torch;
++ R.zxing().catch(() => {});
++ timer = setInterval(tick, 160);
++ }
++ function stop() {
++ clearInterval(timer); timer = null;
++ if (stream) stream.getTracks().forEach((t) => t.stop());
++ stream = null; torch = false;
++ video.hidden = true; $('sc-aim').hidden = true; $('sc-stop').hidden = true; $('sc-flip').hidden = true; $('sc-torch').hidden = true;
++ if (shot.hidden) drop.hidden = false;
++ outline([], 0, 0);
++ }
++ async function picture(file) {
++ if (!file || !/^image\//.test(file.type || 'image/')) { toast('That is not a picture'); return; }
++ stop();
++ const url = URL.createObjectURL(file);
++ shot.onload = async () => {
++ drop.hidden = true; shot.hidden = false; $('sc-again').hidden = false;
++ engine.textContent = 'Reading…';
++ try {
++ let res = await R.readZxing(file, { tryHarder: true, max: 24 });
++ if (!res.length && await R.native()) { const bmp = await createImageBitmap(file); res = await R.readNative(bmp); }
++ outline(res, shot.naturalWidth, shot.naturalHeight);
++ engine.textContent = res.length ? `${res.length} code${res.length === 1 ? '' : 's'} found in the picture.` : 'No code found. Try a sharper or closer picture, with the code flat and well lit.';
++ seen.clear();
++ found(res);
++ } catch (e) { engine.textContent = `Could not read it: ${e.message}`; }
++ };
++ shot.src = url;
++ }
++ $('sc-start').onclick = () => start();
++ $('sc-stop').onclick = () => { stop(); drop.hidden = false; shot.hidden = true; };
++ $('sc-flip').onclick = () => { if (!devices.length) return; deviceIdx = (deviceIdx + 1) % devices.length; start(devices[deviceIdx].deviceId); };
++ $('sc-torch').onclick = async () => { const t = stream && stream.getVideoTracks()[0]; if (!t) return; torch = !torch; try { await t.applyConstraints({ advanced: [{ torch }] }); $('sc-torch').setAttribute('aria-pressed', String(torch)); } catch { toast('The torch is not available'); } };
++ $('sc-file').onchange = (e) => picture(e.target.files[0]);
++ $('sc-file2').onchange = (e) => picture(e.target.files[0]);
++ $('sc-clear').onclick = () => { history = []; store.set('ov_codes_scans', []); renderHistory(); };
++ const view = $('sc-view');
++ ['dragenter', 'dragover'].forEach((ev) => view.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('over'); }));
++ ['dragleave', 'drop'].forEach((ev) => view.addEventListener(ev, () => drop.classList.remove('over')));
++ view.addEventListener('drop', (e) => { e.preventDefault(); const f = e.dataTransfer && e.dataTransfer.files[0]; if (f) picture(f); });
++ document.addEventListener('paste', (e) => { const item = [...(e.clipboardData ? e.clipboardData.items : [])].find((i) => i.type.startsWith('image/')); if (item) picture(item.getAsFile()); });
++ window.addEventListener('pagehide', stop);
++ document.addEventListener('visibilitychange', () => { if (document.hidden && stream) stop(); });
++ renderHistory();
++ }
++
++ // ═════════════════════════════════════════════════════════════════════════════════════════════════════════════════
++ // Check: GTIN / ISBN / SSCC numbers and GS1 element strings
++ // ═════════════════════════════════════════════════════════════════════════════════════════════════════════════════
++ function check() {
++ const input = $('ck-text'), out = $('ck-out'), stage = $('ck-stage');
++ input.value = params.get('text') || '';
++ let draw = null; // { sym, text }
++ let gs1Sym = 'gs1-128';
++ const KIND = { gtin8: 'GTIN-8 (EAN-8)', upce: 'UPC-E', upca: 'UPC-A (GTIN-12)', ean13: 'EAN-13 (GTIN-13)', isbn13: 'ISBN-13', isbn10: 'ISBN-10', issn: 'ISSN (EAN-13)', gtin14: 'GTIN-14', sscc: 'SSCC' };
++ function preview() {
++ if (!draw) { stage.innerHTML = '<div class="empty">The matching barcode appears here</div>'; return; }
++ try {
++ const c = document.createElement('canvas');
++ const twoD = TWO_D.test(draw.sym);
++ bwipjs.toCanvas(c, { bcid: draw.sym, text: draw.text, scale: 3, ...(twoD ? {} : { height: 14, ...textOpts(draw.sym) }), backgroundcolor: 'ffffff', paddingwidth: 10, paddingheight: 6 });
++ c.className = 'art'; stage.replaceChildren(c);
++ $('ck-studio').href = toolUrl(/datamatrix/.test(draw.sym) ? 'datamatrix' : 'barcode', { text: draw.text, sym: draw.sym });
++ } catch (e) { stage.innerHTML = `<div class="err">${esc(bwipError(e))}</div>`; }
++ }
++ function run() {
++ const t = input.value.trim();
++ draw = null; $('ck-sym').hidden = true;
++ if (!t) { out.innerHTML = note('', 'Type a product, book or shipping number, or paste GS1 data from a label or a scanner.'); preview(); return; }
++ const isGs1 = /^\(\d{2,4}\)|^\]C1|^\]d2|^\]Q3/.test(t) || (/^\d{16,}/.test(t) && !S.identifyNumber(t)) || /[\x1d|]/.test(t);
++ if (isGs1) {
++ const g = S.parseGs1(t);
++ out.innerHTML = (g.ok ? note('ok', `${g.parts.length} field${g.parts.length === 1 ? '' : 's'}, all valid.`) : note('bad', esc(g.error || 'Some fields are not valid.'))) +
++ `<table class="ais"><thead><tr><th>AI</th><th>Meaning</th><th>Value</th><th></th></tr></thead><tbody>${g.parts.map((p) => `<tr><td class="mono">(${esc(p.ai)})</td><td>${esc(p.title)}</td><td class="v">${esc(p.value)}${p.display && p.display !== p.value ? `<br><span class="muted">${esc(p.display)}</span>` : ''}</td><td class="${p.valid ? 'st-ok' : 'st-bad'}">${p.valid ? '✓' : esc(p.error)}</td></tr>`).join('')}</tbody></table>`;
++ if (g.parts.length) {
++ draw = { sym: gs1Sym, text: S.gs1Text(g.parts) };
++ $('ck-sym').hidden = false;
++ seg($('ck-sym'), [['gs1-128', 'GS1-128'], ['gs1datamatrix', 'GS1 DataMatrix'], ['gs1qrcode', 'GS1 QR']], gs1Sym, (v) => { gs1Sym = v; draw.sym = v; preview(); });
++ }
++ preview();
++ return;
++ }
++ const n = S.identifyNumber(t, { complete: true });
++ if (!n) {
++ const d = t.replace(/[\s-]/g, '');
++ out.innerHTML = /^\d+$/.test(d) ? note('', `${d.length} digits is not a GS1 key length (8, 12, 13, 14 or 18, or one less to add a check digit). The check digit for these digits would be <b>${S.gs1CheckDigit(d)}</b>.`) : note('', 'Not a GTIN, ISBN or SSCC, and no GS1 Application Identifiers found.');
++ preview();
++ return;
++ }
++ const digits = n.missing ? n.fixed : n.digits;
++ const cd = digits.slice(-1);
++ const head = n.missing ? note('ok', `Check digit <b>${esc(cd)}</b> added.`) : n.valid ? note('ok', 'Check digit correct.') : note('bad', `The check digit is ${esc(n.digits.slice(-1))}; it should be <b>${esc(n.expected)}</b>.`, `<button type="button" class="btn small act" data-fix="${esc(n.fixed)}">Fix</button>`);
++ const conv = [];
++ if (n.isbn13) conv.push(['ISBN-13', n.isbn13]);
++ if (n.isbn10) conv.push(['ISBN-10', n.isbn10]);
++ if (n.upca) conv.push(['As UPC-A', n.upca]);
++ if (n.kind === 'upca') conv.push(['As EAN-13', `0${n.fixed}`]);
++ if (/^(ean13|upca|isbn13|issn|gtin8)$/.test(n.kind)) { const base = n.fixed.padStart(13, '0').slice(0, 12); conv.push(['As GTIN-14 (case, indicator 1)', `1${base}${S.gs1CheckDigit(`1${base}`)}`]); }
++ if (n.kind === 'gtin14' || n.kind === 'sscc') conv.push(['GS1 string', n.kind === 'sscc' ? `(00)${n.fixed}` : `(01)${n.fixed}`]);
++ out.innerHTML = `<div class="lbl">${esc(KIND[n.kind] || n.kind)}</div><div class="big-number">${esc(digits.slice(0, -1))}<span class="cd ${n.valid || n.missing ? 'ok' : 'bad'}">${esc(n.valid || n.missing ? cd : n.digits.slice(-1))}</span>${!n.valid && !n.missing ? `<span class="cd ok">${esc(n.expected)}</span>` : ''}</div>${head}<p class="muted" style="margin:6px 0">${esc(n.note)}</p>` +
++ (conv.length ? `<table class="ais"><tbody>${conv.map(([k, v]) => `<tr><td>${esc(k)}</td><td class="v">${esc(v)}</td><td><button type="button" class="btn small ghost" data-copy="${esc(v)}">${icon('copy')}</button></td></tr>`).join('')}</tbody></table>` : '');
++ const fixed = n.valid || n.missing ? n.fixed : n.fixed;
++ draw = { sym: n.symbology === 'isbn' ? 'isbn' : n.symbology, text: n.symbology === 'isbn' ? n.encode : n.kind === 'sscc' ? `(00)${fixed}` : n.kind === 'isbn10' ? n.isbn13 : fixed };
++ preview();
++ }
++ out.addEventListener('click', (e) => {
++ const f = e.target.closest('[data-fix]'); if (f) { input.value = f.dataset.fix; run(); }
++ const c = e.target.closest('[data-copy]'); if (c) copyText(c.dataset.copy);
++ });
++ input.addEventListener('input', debounce(run, 120));
++ // GS1 builder: rows of AI + value → the element string in the box above.
++ const rows = $('ck-rows');
++ const opts = Object.entries(S.AI).map(([ai, d]) => `<option value="${ai}">(${ai}) ${esc(d.t)}</option>`).join('');
++ function addRow(ai = '01', value = '') {
++ const r = document.createElement('div');
++ r.className = 'aibuild';
++ r.innerHTML = `<select aria-label="Application Identifier">${opts}</select><input type="text" class="mono" aria-label="Value" value="${esc(value)}" autocomplete="off"><button type="button" class="btn small ghost" aria-label="Remove">${icon('trash')}</button>`;
++ r.querySelector('select').value = ai;
++ rows.appendChild(r);
++ }
++ function build() {
++ const parts = [...rows.querySelectorAll('.aibuild')].map((r) => ({ ai: r.querySelector('select').value, value: r.querySelector('input').value.trim() })).filter((p) => p.value);
++ if (parts.length) { input.value = S.gs1Text(parts); run(); }
++ }
++ rows.addEventListener('input', build);
++ rows.addEventListener('change', build);
++ rows.addEventListener('click', (e) => { if (e.target.closest('button')) { e.target.closest('.aibuild').remove(); build(); } });
++ $('ck-add').onclick = () => { addRow('10', ''); };
++ addRow('01', '09506000134352'); addRow('17', '271231'); addRow('10', 'ABC123');
++ if (!input.value) build();
++ $('ck-png').onclick = async () => { if (!draw) return toast('Nothing to download yet'); const c = document.createElement('canvas'); try { bwipjs.toCanvas(c, { bcid: draw.sym, text: draw.text, scale: 4, ...(TWO_D.test(draw.sym) ? {} : { height: 15, ...textOpts(draw.sym) }), backgroundcolor: 'ffffff', paddingwidth: 10, paddingheight: 8 }); download(await canvasBlob(c), `${draw.sym}-${slug(draw.text)}.png`); } catch (e) { toast(bwipError(e)); } };
++ $('ck-svg').onclick = () => { if (!draw) return toast('Nothing to download yet'); try { download(new Blob([bwipjs.toSVG({ bcid: draw.sym, text: draw.text, ...(TWO_D.test(draw.sym) ? {} : textOpts(draw.sym)), backgroundcolor: 'ffffff', paddingwidth: 10, paddingheight: 8 })], { type: 'image/svg+xml' }), `${draw.sym}-${slug(draw.text)}.svg`); } catch (e) { toast(bwipError(e)); } };
++ run();
++ currentText = () => input.value.trim();
++ }
++
++ // ═════════════════════════════════════════════════════════════════════════════════════════════════════════════════
++ // Bulk: many codes → ZIP or a label sheet
++ // ═════════════════════════════════════════════════════════════════════════════════════════════════════════════════
++ function bulk() {
++ const MAX = 2000;
++ let src = 'list';
++ let made = []; // { value, caption, blob?, svg?, url }
++ symbolOptions($('bk-type'), params.get('sym') || 'code128');
++ $('bk-text').value = params.get('text') || 'SKU-0001\nSKU-0002\nSKU-0003\nSKU-0004\nSKU-0005\nSKU-0006';
++ seg($('bk-src'), [['list', 'List or CSV'], ['seq', 'Sequence']], src, (v) => { src = v; $('bk-list').hidden = v !== 'list'; $('bk-seq').hidden = v !== 'seq'; run(); });
++ function values() {
++ if (src === 'seq') {
++ const n = Math.min(MAX, Math.max(1, +$('bk-count').value || 1)), start = +$('bk-start').value || 0, step = Math.max(1, +$('bk-step').value || 1), pad = Math.max(0, Math.min(18, +$('bk-pad').value || 0));
++ const pre = $('bk-prefix').value, suf = $('bk-suffix').value, gtin = $('bk-gtin').checked;
++ return Array.from({ length: n }, (_, i) => { let num = String(start + i * step).padStart(pad, '0'); if (gtin) num += S.gs1CheckDigit(num); return { value: `${pre}${num}${suf}`, caption: '' }; });
++ }
++ const csv = $('bk-csv').checked;
++ return $('bk-text').value.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, MAX).map((l) => {
++ if (!csv) return { value: l, caption: '' };
++ const m = /^("(?:[^"]|"")*"|[^,\t]*)[,\t](.*)$/.exec(l);
++ const unq = (s) => s.replace(/^"|"$/g, '').replace(/""/g, '"').trim();
++ return m ? { value: unq(m[1]), caption: unq(m[2]) } : { value: l, caption: '' };
++ });
++ }
++ function opts(sym, value) {
++ const twoD = TWO_D.test(sym);
++ return { bcid: sym, text: value, scale: 3, backgroundcolor: 'ffffff', paddingwidth: twoD ? 4 : 10, paddingheight: 4, ...(twoD ? {} : { height: 12, ...($('bk-showtext').checked ? textOpts(sym) : {}) }) };
++ }
++ let gen = 0;
++ async function run() {
++ const g = ++gen;
++ made.forEach((m) => m.url && URL.revokeObjectURL(m.url));
++ made = [];
++ const sym = $('bk-type').value;
++ const list = values();
++ const fails = [];
++ const sheet = $('bk-sheet');
++ sheet.replaceChildren();
++ const t0 = performance.now();
++ for (let i = 0; i < list.length; i++) {
++ if (g !== gen) return;
++ const v = list[i];
++ try {
++ const svg = bwipjs.toSVG(opts(sym, v.value));
++ const item = { ...v, svg };
++ made.push(item);
++ if (made.length <= 48) {
++ item.url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
++ const f = document.createElement('figure');
++ f.innerHTML = `<img src="${item.url}" alt="${esc(v.value)}">${v.caption || !$('bk-showtext').checked || TWO_D.test(sym) ? `<figcaption>${esc(v.caption || v.value)}</figcaption>` : ''}`;
++ sheet.appendChild(f);
++ }
++ } catch (e) { fails.push(`${v.value}: ${bwipError(e)}`); }
++ if (i % 40 === 39) { $('bk-count-o').textContent = `${made.length} of ${list.length}…`; await new Promise((r) => setTimeout(r)); }
++ }
++ $('bk-count-o').textContent = `${made.length} code${made.length === 1 ? '' : 's'}${list.length >= MAX ? ` (the first ${MAX})` : ''}`;
++ $('bk-time').textContent = made.length ? `${Math.round(performance.now() - t0)} ms${made.length > 48 ? ' · preview shows 48' : ''}` : '';
++ const fl = $('bk-fails');
++ fl.hidden = !fails.length;
++ fl.innerHTML = fails.slice(0, 20).map((f) => `<li>${esc(f)}</li>`).join('') + (fails.length > 20 ? `<li>and ${fails.length - 20} more</li>` : '');
++ }
++ const later = debounce(run, 300);
++ ['bk-text', 'bk-prefix', 'bk-start', 'bk-count', 'bk-step', 'bk-pad', 'bk-suffix'].forEach((id) => $(id).addEventListener('input', later));
++ ['bk-csv', 'bk-gtin', 'bk-type', 'bk-showtext'].forEach((id) => $(id).addEventListener('change', run));
++ $('bk-zip').onclick = async () => {
++ if (!made.length) return toast('Nothing to download yet');
++ const fmt = $('bk-fmt').value;
++ const btn = $('bk-zip'); btn.disabled = true;
++ try {
++ const files = [];
++ for (let i = 0; i < made.length; i++) {
++ const m = made[i];
++ const base = slug(m.value) || `code-${i + 1}`;
++ if (fmt === 'svg') files.push({ name: `${base}.svg`, data: m.svg });
++ else {
++ const c = document.createElement('canvas');
++ bwipjs.toCanvas(c, { ...opts($('bk-type').value, m.value), scale: 4 });
++ files.push({ name: `${base}.png`, data: D.pngWithDpi(new Uint8Array(await (await canvasBlob(c)).arrayBuffer()), 300) });
++ }
++ if (i % 25 === 24) { btn.textContent = `${i + 1} / ${made.length}…`; await new Promise((r) => setTimeout(r)); }
++ }
++ download(new Blob([D.zip(files)], { type: 'application/zip' }), `barcodes-${made.length}.zip`);
++ } catch (e) { toast(bwipError(e)); }
++ btn.disabled = false; btn.innerHTML = `${icon('download')}Download ZIP`;
++ };
++ $('bk-print').onclick = () => {
++ if (!made.length) return toast('Nothing to print yet');
++ const lw = +$('bk-lw').value || 63.5, lh = +$('bk-lh').value || 38.1, cols = Math.max(1, +$('bk-cols').value || 3), gap = +$('bk-gap').value || 0, margin = +$('bk-margin').value || 0;
++ const cap = $('bk-caption').checked;
++ const sheet = $('print-sheet');
++ sheet.style.width = `${cols * lw + (cols - 1) * gap}mm`;
++ sheet.innerHTML = `<style>@page{margin:${margin}mm}#print-sheet{display:grid;grid-template-columns:repeat(${cols},${lw}mm);gap:${gap}mm}#print-sheet .lab{width:${lw}mm;height:${lh}mm;padding:2mm}</style>` +
++ made.map((m) => `<div class="lab"><img src="data:image/svg+xml;charset=utf-8,${encodeURIComponent(m.svg)}" alt="">${cap && m.caption ? `<span>${esc(m.caption)}</span>` : ''}</div>`).join('');
++ setTimeout(() => window.print(), 100);
++ };
++ run();
++ currentText = () => '';
++ }
++
++ // ── Start ────────────────────────────────────────────────────────────────────────────────────────────────────────
++ document.addEventListener('DOMContentLoaded', () => {}, { once: true });
++ function boot() {
++ try {
++ const token = (() => { try { return localStorage.getItem('ov_token'); } catch { return null; } })();
++ if (window.OpenVibeNavbar) OpenVibeNavbar.init({ service: 'tools', apiBase: 'https://openvibe.network', token, history: { type: 'tool', title: T.h1 }, silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}' });
++ } catch { /* the page works without it */ }
++ try {
++ if (window.OpenVibeFooter) OpenVibeFooter.init({ service: 'tools', variant: ID === 'codes' ? 'full' : 'compact', mount: '#ov-footer', apiBase: 'https://openvibe.network', links: [
++ { heading: 'Barcode & QR', items: ['qr', 'barcode', 'datamatrix', 'scan', 'gs1', 'bulkbarcodes'].map((id) => ({ name: TOOLS[id].h1, url: toolUrl(id) })) },
++ { heading: 'One type', items: ['code128', 'ean13', 'upc', 'isbn', 'pdf417', 'aztec', 'wifiqr', 'vcardqr'].map((id) => ({ name: TOOLS[id].h1, url: toolUrl(id) })) },
++ ] });
++ } catch { /* */ }
++ if (!window.bwipjs && T.view !== 'scan') { $(`view-${T.view}`).insertAdjacentHTML('afterbegin', note('bad', 'The barcode library did not load. Check your connection and reload the page.')); return; }
++ ({ hub, qr, barcode, scan, check, bulk })[T.view]();
++ }
++ boot();
++})();
ddiff --git a/apps/gateway/public/js/codes/draw.js b/apps/gateway/public/js/codes/draw.js
nnew file mode 100644
iindex 0000000..9598053
---- /dev/null
++++ b/apps/gateway/public/js/codes/draw.js
@@@ -0,0 +1,214 @@
++/* Drawing for the Barcode & QR tools (codes.html), with no DOM: a styled QR code as SVG from bwip-js's module
++ matrix (module shapes, eye shapes, colours, a gradient and a logo), a PNG's print resolution (pHYs), CRC-32 and a
++ store-only ZIP for bulk downloads. The browser loads it as window.OVCodesDraw; the tests require() it. */
++(function (root, factory) {
++ 'use strict';
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ else root.OVCodesDraw = api;
++})(typeof self !== 'undefined' ? self : this, function () {
++ 'use strict';
++
++ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++ const r2 = (n) => Math.round(n * 100) / 100;
++
++ /** bwip-js raw() output → { n, dark(x, y) } for a square matrix (QR, Micro QR). */
++ function matrixOf(raw) {
++ const m = Array.isArray(raw) ? raw[0] : raw;
++ const n = m.pixx;
++ return { n, h: m.pixy, dark: (x, y) => x >= 0 && y >= 0 && x < n && y < m.pixy && m.pixs[y * n + x] === 1 };
++ }
++
++ /** The three 7×7 finder patterns of a QR code, as their top-left corners. */
++ const finders = (n) => [[0, 0], [n - 7, 0], [0, n - 7]];
++ const inFinder = (n, x, y) => finders(n).some(([fx, fy]) => x >= fx && x < fx + 7 && y >= fy && y < fy + 7);
++
++ /** A rectangle with each corner's radius (tl, tr, br, bl) as an SVG path. */
++ function roundRect(x, y, w, h, [tl, tr, br, bl]) {
++ return `M${r2(x + tl)} ${r2(y)}H${r2(x + w - tr)}${tr ? `A${tr} ${tr} 0 0 1 ${r2(x + w)} ${r2(y + tr)}` : ''}V${r2(y + h - br)}${br ? `A${br} ${br} 0 0 1 ${r2(x + w - br)} ${r2(y + h)}` : ''}H${r2(x + bl)}${bl ? `A${bl} ${bl} 0 0 1 ${r2(x)} ${r2(y + h - bl)}` : ''}V${r2(y + tl)}${tl ? `A${tl} ${tl} 0 0 1 ${r2(x + tl)} ${r2(y)}` : ''}Z`;
++ }
++
++ /** One eye (finder pattern) at (x, y) in the chosen shape: the 7×7 ring and the 3×3 pupil, as path data. */
++ function eye(x, y, shape) {
++ // The ring is the outer square with the inner one cut out (fill-rule evenodd).
++ const ring = (ro, ri) => roundRect(x, y, 7, 7, ro) + roundRect(x + 1, y + 1, 5, 5, ri);
++ switch (shape) {
++ case 'circle': return {
++ ring: `M${x + 3.5} ${y}a3.5 3.5 0 1 0 0.001 0ZM${x + 3.5} ${y + 1}a2.5 2.5 0 1 1 -0.001 0Z`,
++ pupil: `M${x + 3.5} ${y + 2}a1.5 1.5 0 1 0 0.001 0Z`,
++ };
++ case 'rounded': return { ring: ring([2.2, 2.2, 2.2, 2.2], [1.4, 1.4, 1.4, 1.4]), pupil: roundRect(x + 2, y + 2, 3, 3, [0.9, 0.9, 0.9, 0.9]) };
++ case 'leaf': return { ring: ring([3, 0, 3, 0], [2, 0, 2, 0]), pupil: roundRect(x + 2, y + 2, 3, 3, [1.4, 0, 1.4, 0]) };
++ default: return { ring: ring([0, 0, 0, 0], [0, 0, 0, 0]), pupil: roundRect(x + 2, y + 2, 3, 3, [0, 0, 0, 0]) };
++ }
++ }
++
++ /** The data modules (everything but the eyes) in one of the styles, as path data in module units. */
++ function modules(mx, style, skip) {
++ const out = [];
++ const { n, h, dark } = mx;
++ for (let y = 0; y < h; y++) {
++ if (style === 'square') {
++ // Runs of dark modules on a row as one rectangle each: fewer, crisper shapes.
++ for (let x = 0; x < n; x++) {
++ if (!dark(x, y) || skip(x, y)) continue;
++ let e = x;
++ while (e + 1 < n && dark(e + 1, y) && !skip(e + 1, y)) e++;
++ out.push(`M${x} ${y}h${e - x + 1}v1h${-(e - x + 1)}Z`);
++ x = e;
++ }
++ continue;
++ }
++ for (let x = 0; x < n; x++) {
++ if (!dark(x, y) || skip(x, y)) continue;
++ if (style === 'dots') out.push(`M${x + 0.5} ${y + 0.08}a0.42 0.42 0 1 0 0.001 0Z`);
++ else if (style === 'rounded') out.push(roundRect(x + 0.06, y + 0.06, 0.88, 0.88, [0.3, 0.3, 0.3, 0.3]));
++ else {
++ // fluid: a corner is round only where neither neighbour on that corner is dark, so modules flow into each other.
++ const d = (dx, dy) => dark(x + dx, y + dy) && !skip(x + dx, y + dy);
++ const R = 0.5;
++ out.push(roundRect(x, y, 1, 1, [!d(-1, 0) && !d(0, -1) ? R : 0, !d(1, 0) && !d(0, -1) ? R : 0, !d(1, 0) && !d(0, 1) ? R : 0, !d(-1, 0) && !d(0, 1) ? R : 0]));
++ }
++ }
++ }
++ return out.join('');
++ }
++
++ /**
++ * A styled QR code as an SVG document. `raw` is bwip-js raw({ bcid: 'qrcode', … }); `o`:
++ * style square | rounded | dots | fluid eyes square | rounded | circle | leaf
++ * color modules (#rrggbb) eyeColor eyes (defaults to color)
++ * gradient null | { to: '#rrggbb', angle: 0-360 } background '#rrggbb' or 'transparent'
++ * quiet quiet zone in modules (4) logo { href (data URL), size: share of the width 0.1-0.3 }
++ * caption text under the code, in a frame size the SVG's width/height in px (viewBox stays in modules)
++ * → { svg, modules: n, logoBox }
++ */
++ function qrSvg(raw, o = {}) {
++ const mx = matrixOf(raw);
++ const n = mx.n;
++ const q = o.quiet ?? 4;
++ const style = o.style || 'square';
++ const eyes = o.eyes || 'square';
++ const color = o.color || '#000000';
++ const eyeColor = o.eyeColor || color;
++ const bg = o.background || '#ffffff';
++ const W = n + 2 * q;
++ const capH = o.caption ? Math.max(4, Math.round(W * 0.14)) : 0;
++ const H = W + capH;
++ // A logo clears a square in the middle (whole modules, odd count so it stays centred).
++ let logoBox = null;
++ if (o.logo && o.logo.href) {
++ let k = Math.round(n * Math.min(0.3, Math.max(0.1, o.logo.size || 0.22)));
++ if (k % 2 !== n % 2) k += 1;
++ const s = (n - k) / 2;
++ logoBox = { x: s, y: s, size: k };
++ }
++ const skip = (x, y) => inFinder(n, x, y) || (logoBox && x >= logoBox.x - 0.5 && x < logoBox.x + logoBox.size + 0.5 && y >= logoBox.y - 0.5 && y < logoBox.y + logoBox.size + 0.5);
++ const fill = o.gradient && o.gradient.to ? 'url(#ovg)' : esc(color);
++ const parts = [];
++ parts.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}"${o.size ? ` width="${o.size}" height="${Math.round((o.size * H) / W)}"` : ''} shape-rendering="${style === 'square' ? 'crispEdges' : 'geometricPrecision'}">`);
++ if (o.gradient && o.gradient.to) {
++ const a = ((o.gradient.angle ?? 45) * Math.PI) / 180;
++ const [x1, y1, x2, y2] = [0.5 - Math.cos(a) / 2, 0.5 - Math.sin(a) / 2, 0.5 + Math.cos(a) / 2, 0.5 + Math.sin(a) / 2].map(r2);
++ parts.push(`<defs><linearGradient id="ovg" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"><stop offset="0" stop-color="${esc(color)}"/><stop offset="1" stop-color="${esc(o.gradient.to)}"/></linearGradient></defs>`);
++ }
++ if (bg !== 'transparent') parts.push(`<rect width="${W}" height="${H}" fill="${esc(bg)}"${o.caption ? ' rx="2"' : ''}/>`);
++ parts.push(`<g transform="translate(${q} ${q})">`);
++ parts.push(`<path fill="${fill}" d="${modules(mx, style, skip)}"/>`);
++ const eyePaths = finders(n).map(([x, y]) => eye(x, y, eyes));
++ parts.push(`<path fill="${o.gradient && o.gradient.to && !o.eyeColor ? fill : esc(eyeColor)}" fill-rule="evenodd" d="${eyePaths.map((e) => e.ring).join('')}"/>`);
++ parts.push(`<path fill="${o.gradient && o.gradient.to && !o.eyeColor ? fill : esc(eyeColor)}" d="${eyePaths.map((e) => e.pupil).join('')}"/>`);
++ if (logoBox) {
++ const pad = 0.35;
++ parts.push(`<image href="${esc(o.logo.href)}" x="${r2(logoBox.x + pad)}" y="${r2(logoBox.y + pad)}" width="${r2(logoBox.size - 2 * pad)}" height="${r2(logoBox.size - 2 * pad)}" preserveAspectRatio="xMidYMid meet"/>`);
++ }
++ parts.push('</g>');
++ if (o.caption) {
++ const fs = Math.max(2.4, capH * 0.5);
++ parts.push(`<text x="${W / 2}" y="${r2(W + capH * 0.42)}" text-anchor="middle" dominant-baseline="middle" font-family="Inter, system-ui, -apple-system, 'Segoe UI', sans-serif" font-weight="700" font-size="${r2(fs)}" fill="${fill}">${esc(String(o.caption).slice(0, 40))}</text>`);
++ }
++ parts.push('</svg>');
++ return { svg: parts.join(''), modules: n, logoBox, width: W, height: H };
++ }
++
++ // ── CRC-32, PNG resolution, ZIP ─────────────────────────────────────────────────────────────────────────────────
++ const CRC = (() => { const t = new Uint32Array(256); for (let i = 0; i < 256; i++) { let c = i; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[i] = c >>> 0; } return t; })();
++ function crc32(bytes, start = 0, end = bytes.length) {
++ let c = 0xffffffff;
++ for (let i = start; i < end; i++) c = CRC[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
++ return (c ^ 0xffffffff) >>> 0;
++ }
++
++ /** A PNG with a pHYs chunk saying its resolution, so it prints at the size it was made for. */
++ function pngWithDpi(png, dpi) {
++ const src = png instanceof Uint8Array ? png : new Uint8Array(png);
++ if (!dpi || src.length < 33) return src;
++ const ppm = Math.round(dpi / 0.0254);
++ const chunk = new Uint8Array(21);
++ const dv = new DataView(chunk.buffer);
++ dv.setUint32(0, 9);
++ chunk.set([0x70, 0x48, 0x59, 0x73], 4); // pHYs
++ dv.setUint32(8, ppm); dv.setUint32(12, ppm); chunk[16] = 1;
++ dv.setUint32(17, crc32(chunk, 4, 17));
++ // After IHDR (8-byte signature + 25-byte IHDR chunk), dropping any pHYs already there.
++ const out = [src.subarray(0, 33), chunk];
++ let o = 33;
++ while (o + 8 <= src.length) {
++ const len = new DataView(src.buffer, src.byteOffset + o, 4).getUint32(0);
++ const type = String.fromCharCode(src[o + 4], src[o + 5], src[o + 6], src[o + 7]);
++ if (type !== 'pHYs') out.push(src.subarray(o, o + 12 + len));
++ o += 12 + len;
++ }
++ const total = out.reduce((s, p) => s + p.length, 0);
++ const res = new Uint8Array(total);
++ let p = 0;
++ for (const part of out) { res.set(part, p); p += part.length; }
++ return res;
++ }
++
++ /** files: [{ name, data: Uint8Array | string }] → a ZIP (stored, no compression: PNG and SVG barely shrink). */
++ function zip(files) {
++ const enc = new TextEncoder();
++ const locals = [];
++ const centrals = [];
++ let offset = 0;
++ const seen = new Map();
++ for (const f of files) {
++ let name = String(f.name).replace(/[\\/:*?"<>|\x00-\x1f]+/g, '_').slice(0, 120) || 'code';
++ // Two values with the same file name: the second becomes name-2.png.
++ const k = name.toLowerCase();
++ if (seen.has(k)) { const i = seen.get(k) + 1; seen.set(k, i); name = name.replace(/(\.[a-z0-9]+)?$/i, `-${i}$1`); } else seen.set(k, 1);
++ const nameBytes = enc.encode(name);
++ const data = typeof f.data === 'string' ? enc.encode(f.data) : f.data;
++ const crc = crc32(data);
++ const head = new Uint8Array(30 + nameBytes.length);
++ const hv = new DataView(head.buffer);
++ hv.setUint32(0, 0x04034b50, true); hv.setUint16(4, 20, true); hv.setUint16(6, 0x0800, true); hv.setUint16(8, 0, true);
++ hv.setUint16(10, 0, true); hv.setUint16(12, 0x21, true); hv.setUint32(14, crc, true); hv.setUint32(18, data.length, true); hv.setUint32(22, data.length, true);
++ hv.setUint16(26, nameBytes.length, true); head.set(nameBytes, 30);
++ const cen = new Uint8Array(46 + nameBytes.length);
++ const cv = new DataView(cen.buffer);
++ cv.setUint32(0, 0x02014b50, true); cv.setUint16(4, 20, true); cv.setUint16(6, 20, true); cv.setUint16(8, 0x0800, true); cv.setUint16(10, 0, true);
++ cv.setUint16(12, 0, true); cv.setUint16(14, 0x21, true); cv.setUint32(16, crc, true); cv.setUint32(20, data.length, true); cv.setUint32(24, data.length, true);
++ cv.setUint16(28, nameBytes.length, true); cv.setUint32(42, offset, true); cen.set(nameBytes, 46);
++ locals.push(head, data);
++ centrals.push(cen);
++ offset += head.length + data.length;
++ }
++ const cenSize = centrals.reduce((s, c) => s + c.length, 0);
++ const end = new Uint8Array(22);
++ const ev = new DataView(end.buffer);
++ ev.setUint32(0, 0x06054b50, true); ev.setUint16(8, files.length, true); ev.setUint16(10, files.length, true); ev.setUint32(12, cenSize, true); ev.setUint32(16, offset, true);
++ const parts = [...locals, ...centrals, end];
++ const out = new Uint8Array(parts.reduce((s, p) => s + p.length, 0));
++ let p = 0;
++ for (const part of parts) { out.set(part, p); p += part.length; }
++ return out;
++ }
++
++ /** Millimetres ↔ pixels at a resolution. */
++ const mmToPx = (mm, dpi) => Math.round((mm / 25.4) * dpi);
++ const pxToMm = (px, dpi) => (px / dpi) * 25.4;
++
++ return { matrixOf, qrSvg, crc32, pngWithDpi, zip, mmToPx, pxToMm, roundRect };
++});
ddiff --git a/apps/gateway/public/js/codes/read.js b/apps/gateway/public/js/codes/read.js
nnew file mode 100644
iindex 0000000..8232db6
---- /dev/null
++++ b/apps/gateway/public/js/codes/read.js
@@@ -0,0 +1,81 @@
++/* Reading codes for the Barcode & QR tools (codes.html): the ZXing reader (zxing-wasm, served from /vendor on this
++ host, loaded on first use) and the browser's own BarcodeDetector where it has one. Both answer the same shape:
++ [{ text, format, label, box: [[x, y] × 4] }]. window.OVCodesRead. */
++(function (root) {
++ 'use strict';
++
++ // The page sets these from its <script data-*> (the versioned /vendor URLs).
++ const cfg = { zxing: '', zxingWasm: '' };
++ let zxingReady = null;
++
++ // ZXing's format names → what people call them.
++ const LABELS = {
++ QRCode: 'QR Code', MicroQRCode: 'Micro QR', RMQRCode: 'rMQR', DataMatrix: 'Data Matrix', Aztec: 'Aztec', AztecRune: 'Aztec Rune', PDF417: 'PDF417', CompactPDF417: 'Compact PDF417',
++ MicroPDF417: 'MicroPDF417', MaxiCode: 'MaxiCode', EAN13: 'EAN-13', EAN8: 'EAN-8', EAN5: 'EAN-5', EAN2: 'EAN-2', ISBN: 'ISBN', UPCA: 'UPC-A', UPCE: 'UPC-E', Code128: 'Code 128',
++ Code39: 'Code 39', Code39Ext: 'Code 39 Extended', Code32: 'Code 32', PZN: 'PZN', Code93: 'Code 93', ITF: 'ITF', ITF14: 'ITF-14', Codabar: 'Codabar', DataBar: 'GS1 DataBar',
++ DataBarOmni: 'GS1 DataBar', DataBarStk: 'GS1 DataBar Stacked', DataBarStkOmni: 'GS1 DataBar Stacked Omni', DataBarLtd: 'GS1 DataBar Limited', DataBarExp: 'GS1 DataBar Expanded',
++ DataBarExpStk: 'GS1 DataBar Expanded Stacked', DXFilmEdge: 'DX Film Edge', Telepen: 'Telepen',
++ // BarcodeDetector's names
++ qr_code: 'QR Code', micro_qr_code: 'Micro QR', data_matrix: 'Data Matrix', aztec: 'Aztec', pdf417: 'PDF417', ean_13: 'EAN-13', ean_8: 'EAN-8', upc_a: 'UPC-A', upc_e: 'UPC-E',
++ code_128: 'Code 128', code_39: 'Code 39', code_93: 'Code 93', itf: 'ITF', codabar: 'Codabar', databar: 'GS1 DataBar', databar_expanded: 'GS1 DataBar Expanded',
++ };
++ const label = (f) => LABELS[f] || String(f || '').replace(/_/g, ' ');
++
++ function loadScript(src) {
++ return new Promise((resolve, reject) => {
++ const s = document.createElement('script');
++ s.src = src; s.async = true;
++ s.onload = resolve; s.onerror = () => reject(new Error('The barcode reader did not load. Check your connection and try again.'));
++ document.head.appendChild(s);
++ });
++ }
++
++ /** Load ZXing once: its script, then its WebAssembly from this host (never a CDN). */
++ function zxing() {
++ if (!zxingReady) {
++ zxingReady = (async () => {
++ if (!root.ZXingWASM) await loadScript(cfg.zxing);
++ await root.ZXingWASM.prepareZXingModule({
++ overrides: { locateFile: (path, prefix) => (path.endsWith('.wasm') ? cfg.zxingWasm : prefix + path) },
++ fireImmediately: true,
++ });
++ return root.ZXingWASM;
++ })();
++ zxingReady.catch(() => { zxingReady = null; });
++ }
++ return zxingReady;
++ }
++
++ const boxOf = (p) => (p ? [[p.topLeft.x, p.topLeft.y], [p.topRight.x, p.topRight.y], [p.bottomRight.x, p.bottomRight.y], [p.bottomLeft.x, p.bottomLeft.y]] : null);
++
++ /** Every code in an ImageData, a Blob or an image file, with ZXing. o: { tryHarder, max } */
++ async function readZxing(input, o = {}) {
++ const Z = await zxing();
++ const res = await Z.readBarcodes(input, { tryHarder: o.tryHarder !== false, tryRotate: true, tryInvert: true, tryDownscale: true, maxNumberOfSymbols: o.max || 16, ...(o.formats ? { formats: o.formats } : {}) });
++ return res.filter((r) => r.isValid).map((r) => ({ text: r.text, format: r.format, label: label(r.format), box: boxOf(r.position), engine: 'zxing', ecLevel: r.ecLevel || '', version: r.version || '', symbologyIdentifier: r.symbologyIdentifier || '' }));
++ }
++
++ let detector = null;
++ /** The browser's BarcodeDetector, when it has one with formats (Chrome on Android and macOS, Safari 17+). */
++ async function native() {
++ if (detector !== null) return detector;
++ detector = false;
++ try {
++ if ('BarcodeDetector' in root) {
++ const formats = await root.BarcodeDetector.getSupportedFormats();
++ if (formats && formats.length) detector = new root.BarcodeDetector({ formats });
++ }
++ } catch { detector = false; }
++ return detector;
++ }
++
++ /** A video frame or a canvas, read by the native detector → same shape as readZxing. */
++ async function readNative(source) {
++ const d = await native();
++ if (!d) return null;
++ const found = await d.detect(source);
++ return found.map((r) => ({ text: r.rawValue, format: r.format, label: label(r.format), box: (r.cornerPoints || []).map((p) => [p.x, p.y]), engine: 'native' }));
++ }
++
++ root.OVCodesRead = { cfg, zxing, readZxing, native, readNative, label };
++})(typeof self !== 'undefined' ? self : this);
ddiff --git a/apps/gateway/public/js/codes/smart.js b/apps/gateway/public/js/codes/smart.js
nnew file mode 100644
iindex 0000000..1f4a53d
---- /dev/null
++++ b/apps/gateway/public/js/codes/smart.js
@@@ -0,0 +1,256 @@
++/* The intelligence of the Barcode & QR tools (codes.html), with no DOM: what a piece of text is, which symbology fits it,
++ check digits for GTIN / ISBN / SSCC / GLN, GS1 element strings, and the payloads a QR code carries (Wi-Fi, contact
++ cards, events, mail, SMS, places). The browser loads it as window.OVCodesSmart; the tests require() it. */
++(function (root, factory) {
++ 'use strict';
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ else root.OVCodesSmart = api;
++})(typeof self !== 'undefined' ? self : this, function () {
++ 'use strict';
++
++ // ── Check digits ─────────────────────────────────────────────────────────────────────────────────────────────────
++ /** The GS1 mod-10 check digit for the digits before it (GTIN-8/12/13/14, SSCC, GLN). */
++ function gs1CheckDigit(body) {
++ const d = String(body).replace(/\D/g, '');
++ let sum = 0;
++ for (let i = d.length - 1, w = 3; i >= 0; i--, w = 4 - w) sum += Number(d[i]) * w;
++ return String((10 - (sum % 10)) % 10);
++ }
++ const gs1Valid = (code) => /^\d{2,18}$/.test(code) && gs1CheckDigit(code.slice(0, -1)) === code.slice(-1);
++ /** ISBN-10's mod-11 check character (0-9 or X). */
++ function isbn10Check(body) {
++ const d = String(body).replace(/\D/g, '').slice(0, 9);
++ let sum = 0;
++ for (let i = 0; i < 9; i++) sum += Number(d[i]) * (10 - i);
++ const c = (11 - (sum % 11)) % 11;
++ return c === 10 ? 'X' : String(c);
++ }
++ const isbn10To13 = (isbn10) => { const b = `978${isbn10.slice(0, 9)}`; return b + gs1CheckDigit(b); };
++ const isbn13To10 = (isbn13) => (/^978\d{10}$/.test(isbn13) ? isbn13.slice(3, 12) + isbn10Check(isbn13.slice(3, 12)) : null);
++ /** UPC-E (8 digits, number system 0 or 1) expanded to its UPC-A. */
++ function upceToUpca(upce) {
++ if (!/^[01]\d{7}$/.test(upce)) return null;
++ const [ns, m, c] = [upce[0], upce.slice(1, 7), upce[7]];
++ const l = m[5];
++ let body;
++ if ('012'.includes(l)) body = `${m.slice(0, 2)}${l}0000${m.slice(2, 5)}`;
++ else if (l === '3') body = `${m.slice(0, 3)}00000${m.slice(3, 5)}`;
++ else if (l === '4') body = `${m.slice(0, 4)}00000${m[4]}`;
++ else body = `${m.slice(0, 5)}0000${l}`;
++ return `${ns}${body}${c}`;
++ }
++
++ // ── What a number is ─────────────────────────────────────────────────────────────────────────────────────────────
++ const NOTES = {
++ gtin8: 'GTIN-8: small packs, printed as EAN-8.', upce: 'UPC-E: the zero-suppressed form of a UPC-A, for small packs.',
++ upca: 'UPC-A (GTIN-12): retail in North America.', ean13: 'EAN-13 (GTIN-13): retail worldwide.',
++ isbn13: 'ISBN-13: a book number, printed as an EAN-13.', isbn10: 'ISBN-10: printed as its ISBN-13 (978 prefix) in an EAN-13.',
++ issn: 'ISSN in an EAN-13 (977 prefix): a magazine or journal.', gtin14: 'GTIN-14: a case of a product, printed as ITF-14 on cartons.',
++ sscc: 'SSCC: a shipping container or pallet, printed in GS1-128 as (00).',
++ };
++ /**
++ * A GS1 number in any of its forms → { kind, valid, digits, expected, fixed, symbology, encode, note }: kind gtin8 |
++ * upce | upca | ean13 | isbn13 | isbn10 | issn | gtin14 | sscc; `expected` is the right check digit, `fixed` the
++ * number carrying it, `symbology` + `encode` what to hand bwip-js. With { complete: true } a body one digit short of
++ * a GTIN or SSCC gets its check digit (`missing: true`). null when it is not a GS1 number.
++ */
++ function identifyNumber(input, opts = {}) {
++ const raw = String(input || '').trim().replace(/^ISBN(?:-1[03])?:?\s*/i, '');
++ const compact = raw.replace(/[\s-]/g, '');
++ if (/^\d{9}[\dX]$/i.test(compact)) {
++ const expected = isbn10Check(compact.slice(0, 9));
++ const valid = compact[9].toUpperCase() === expected;
++ // Ten digits that are not a valid ISBN are just a number (a phone number, an order number).
++ if (valid || /X$/i.test(compact)) {
++ const fixed = compact.slice(0, 9) + expected;
++ const isbn13 = isbn10To13(fixed);
++ return { kind: 'isbn10', valid, digits: compact.toUpperCase(), expected, fixed, isbn13, symbology: 'ean13', encode: isbn13, note: NOTES.isbn10 };
++ }
++ return null;
++ }
++ if (!/^\d+$/.test(compact)) return null;
++ const n = compact.length;
++ const by = { 8: 'gtin8', 12: 'upca', 13: 'ean13', 14: 'gtin14', 18: 'sscc' };
++ if (by[n]) {
++ const expected = gs1CheckDigit(compact.slice(0, -1));
++ const valid = expected === compact.slice(-1);
++ const fixed = compact.slice(0, -1) + expected;
++ let kind = n === 13 && /^97[89]/.test(compact) ? 'isbn13' : n === 13 && /^977/.test(compact) ? 'issn' : by[n];
++ // Eight digits from 0 or 1 may be a UPC-E: its check digit is its UPC-A's.
++ const upca = n === 8 && /^[01]/.test(compact) ? upceToUpca(compact) : null;
++ const asUpce = upca && gs1Valid(upca);
++ if (asUpce && !valid) kind = 'upce';
++ const symbology = { gtin8: 'ean8', upce: 'upce', upca: 'upca', ean13: 'ean13', isbn13: /^97[89]-\d+-\d+-\d+-\d$/.test(raw) && raw.length === 17 ? 'isbn' : 'ean13', issn: 'ean13', gtin14: 'itf14', sscc: 'gs1-128' }[kind];
++ const encode = kind === 'sscc' ? `(00)${fixed}` : symbology === 'isbn' ? raw : kind === 'upce' ? compact : fixed;
++ return {
++ kind, valid: kind === 'upce' ? true : valid, digits: compact, expected: kind === 'upce' ? compact[7] : expected, fixed: kind === 'upce' ? compact : fixed,
++ symbology, encode, note: NOTES[kind],
++ ...(kind === 'isbn13' ? { isbn10: isbn13To10(compact) } : {}), ...(asUpce ? { upca } : {}),
++ };
++ }
++ if (opts.complete && [7, 11, 17].includes(n)) {
++ const fixed = compact + gs1CheckDigit(compact);
++ const id = identifyNumber(fixed);
++ if (id) return { ...id, valid: false, missing: true, digits: compact, note: `${id.note} Check digit ${fixed.slice(-1)} added.` };
++ }
++ return null;
++ }
++
++ // ── GS1 element strings ──────────────────────────────────────────────────────────────────────────────────────────
++ // The Application Identifiers people meet on labels: { title, fixed length | max length, numeric }.
++ const AI = {
++ '00': { t: 'SSCC (serial shipping container)', n: 18, num: true }, '01': { t: 'GTIN', n: 14, num: true }, '02': { t: 'GTIN of contained items', n: 14, num: true },
++ '10': { t: 'Batch or lot', max: 20 }, '11': { t: 'Production date (YYMMDD)', n: 6, num: true, date: true }, '12': { t: 'Due date (YYMMDD)', n: 6, num: true, date: true },
++ '13': { t: 'Packaging date (YYMMDD)', n: 6, num: true, date: true }, '15': { t: 'Best before (YYMMDD)', n: 6, num: true, date: true }, '16': { t: 'Sell by (YYMMDD)', n: 6, num: true, date: true },
++ '17': { t: 'Expiry (YYMMDD)', n: 6, num: true, date: true }, '20': { t: 'Variant', n: 2, num: true }, '21': { t: 'Serial number', max: 20 }, '22': { t: 'Consumer product variant', max: 20 },
++ '30': { t: 'Count of items', max: 8, num: true }, '37': { t: 'Count of trade items', max: 8, num: true },
++ '240': { t: 'Additional product id', max: 30 }, '241': { t: 'Customer part number', max: 30 }, '250': { t: 'Secondary serial', max: 30 },
++ '400': { t: 'Customer purchase order', max: 30 }, '401': { t: 'Consignment number', max: 30 }, '402': { t: 'Shipment id', n: 17, num: true },
++ '410': { t: 'Ship to (GLN)', n: 13, num: true }, '414': { t: 'Physical location (GLN)', n: 13, num: true }, '420': { t: 'Ship to postal code', max: 20 },
++ '422': { t: 'Country of origin (ISO 3166)', n: 3, num: true }, '3103': { t: 'Net weight, kg (3 decimals)', n: 6, num: true, dec: 3 }, '3102': { t: 'Net weight, kg (2 decimals)', n: 6, num: true, dec: 2 },
++ '3922': { t: 'Price to pay (2 decimals)', max: 15, num: true, dec: 2 }, '8004': { t: 'GIAI (asset id)', max: 30 }, '8005': { t: 'Price per unit', n: 6, num: true },
++ '8200': { t: 'Product URL', max: 70 }, '90': { t: 'Company internal', max: 30 },
++ };
++ const aiOf = (s, i) => { for (const len of [4, 3, 2]) { const k = s.slice(i, i + len); if (AI[k]) return k; } return null; };
++ /**
++ * "(01)09506000134352(17)251231(10)ABC" or the raw form with GS (\x1d) or "|" separators →
++ * { ok, parts: [{ ai, title, value, valid?, display }], error? }.
++ */
++ function parseGs1(input) {
++ const s = String(input || '').trim();
++ const parts = [];
++ if (/^\(\d{2,4}\)/.test(s)) {
++ const re = /\((\d{2,4})\)([^(]*)/g; let m;
++ while ((m = re.exec(s))) parts.push({ ai: m[1], value: m[2] });
++ } else {
++ const raw = s.replace(/^\]C1|^\]d2|^\]Q3/, '');
++ let i = 0;
++ while (i < raw.length) {
++ const ai = aiOf(raw, i);
++ if (!ai) return { ok: false, parts, error: `No Application Identifier at "${raw.slice(i, i + 6)}"` };
++ i += ai.length;
++ const def = AI[ai];
++ let end;
++ if (def.n) end = i + def.n;
++ else { const gs = raw.slice(i).search(/[\x1d|]/); end = gs < 0 ? raw.length : i + gs; }
++ parts.push({ ai, value: raw.slice(i, end) });
++ i = end;
++ if (raw[i] === '\x1d' || raw[i] === '|') i++;
++ }
++ }
++ if (!parts.length) return { ok: false, parts, error: 'No Application Identifiers found' };
++ let ok = true;
++ for (const p of parts) {
++ const def = AI[p.ai];
++ p.title = def ? def.t : 'Unknown Application Identifier';
++ const errs = [];
++ if (!def) errs.push('unknown');
++ else {
++ if (def.n && p.value.length !== def.n) errs.push(`needs ${def.n} characters`);
++ if (def.max && p.value.length > def.max) errs.push(`at most ${def.max} characters`);
++ if (def.num && !/^\d*$/.test(p.value)) errs.push('digits only');
++ if ((p.ai === '01' || p.ai === '02' || p.ai === '00' || p.ai === '410' || p.ai === '414') && /^\d+$/.test(p.value) && !gs1Valid(p.value)) errs.push(`check digit should be ${gs1CheckDigit(p.value.slice(0, -1))}`);
++ if (def.date && /^\d{6}$/.test(p.value)) {
++ const [yy, mm, dd] = [p.value.slice(0, 2), p.value.slice(2, 4), p.value.slice(4, 6)];
++ if (+mm < 1 || +mm > 12 || +dd > 31) errs.push('not a date');
++ else p.display = `20${yy}-${mm}-${dd === '00' ? 'end of month' : dd}`;
++ }
++ if (def.dec && /^\d+$/.test(p.value)) p.display = (Number(p.value) / 10 ** def.dec).toFixed(def.dec);
++ }
++ p.valid = !errs.length;
++ if (errs.length) { p.error = errs.join(', '); ok = false; }
++ }
++ return { ok, parts };
++ }
++ /** Parts back into the bracketed form bwip-js takes for gs1-128, gs1datamatrix and gs1qrcode. */
++ const gs1Text = (parts) => parts.map((p) => `(${p.ai})${p.value}`).join('');
++
++ // ── QR payloads ──────────────────────────────────────────────────────────────────────────────────────────────────
++ const escWifi = (s) => String(s || '').replace(/([\\;,:"])/g, '\\$1');
++ const escV = (s) => String(s || '').replace(/([\\;,])/g, '\\$1').replace(/\r?\n/g, '\\n');
++ const pad = (n) => String(n).padStart(2, '0');
++ const icsDate = (v) => { const d = new Date(v); return Number.isNaN(d.getTime()) ? '' : `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}00Z`; };
++ /** The text a QR code carries for a content type and its fields. */
++ function qrPayload(type, f = {}) {
++ switch (type) {
++ case 'url': { const u = String(f.url || '').trim(); return u && !/^[a-z][a-z0-9+.-]*:/i.test(u) ? `https://${u}` : u; }
++ case 'wifi': return `WIFI:T:${f.security === 'nopass' ? 'nopass' : f.security || 'WPA'};S:${escWifi(f.ssid)};${f.security === 'nopass' ? '' : `P:${escWifi(f.password)};`}${f.hidden ? 'H:true;' : ''};`;
++ case 'contact': return ['BEGIN:VCARD', 'VERSION:3.0', `N:${escV(f.last)};${escV(f.first)};;;`, `FN:${escV([f.first, f.last].filter(Boolean).join(' '))}`, f.org && `ORG:${escV(f.org)}`, f.title && `TITLE:${escV(f.title)}`,
++ f.phone && `TEL;TYPE=CELL:${escV(f.phone)}`, f.email && `EMAIL:${escV(f.email)}`, f.url && `URL:${escV(f.url)}`, f.address && `ADR;TYPE=WORK:;;${escV(f.address)};;;;`, 'END:VCARD'].filter(Boolean).join('\n');
++ case 'email': { const q = [f.subject && `subject=${encodeURIComponent(f.subject)}`, f.body && `body=${encodeURIComponent(f.body)}`].filter(Boolean).join('&'); return `mailto:${String(f.to || '').trim()}${q ? `?${q}` : ''}`; }
++ case 'sms': return `SMSTO:${String(f.phone || '').replace(/[^\d+]/g, '')}:${f.message || ''}`;
++ case 'phone': return `tel:${String(f.phone || '').replace(/[^\d+]/g, '')}`;
++ case 'geo': return `geo:${Number(f.lat) || 0},${Number(f.lng) || 0}${f.label ? `?q=${encodeURIComponent(f.label)}` : ''}`;
++ case 'event': return ['BEGIN:VEVENT', `SUMMARY:${escV(f.title)}`, f.start && `DTSTART:${icsDate(f.start)}`, f.end && `DTEND:${icsDate(f.end)}`, f.location && `LOCATION:${escV(f.location)}`, f.notes && `DESCRIPTION:${escV(f.notes)}`, 'END:VEVENT'].filter(Boolean).join('\n');
++ default: return String(f.text || '');
++ }
++ }
++
++ const RETAIL = /^(EAN|UPC|ITF|ISBN|GTIN|DataBar)/i;
++ /**
++ * What a scanned code's text is, with the details worth showing: { type, title, fields, href? }. `format` is the
++ * scanner's name for the symbology (EAN-13, Code128, QRCode…): digits read from a Code 128 are not a product.
++ */
++ function describePayload(text, format) {
++ const t = String(text || '');
++ const kv = (body) => { const out = {}; const re = /([A-Z]+):((?:\\.|[^;])*);/g; let m; while ((m = re.exec(body))) out[m[1]] = m[2].replace(/\\(.)/g, '$1'); return out; };
++ if (/^WIFI:/i.test(t)) { const w = kv(t.slice(5)); return { type: 'wifi', title: 'Wi-Fi network', fields: [['Network', w.S || ''], ['Password', w.P || '(none)'], ['Security', w.T || 'WPA'], ...(w.H === 'true' ? [['Hidden', 'yes']] : [])] }; }
++ if (/^BEGIN:VCARD/i.test(t)) {
++ const line = (k) => { const m = new RegExp(`^${k}[^:\\n]*:(.*)$`, 'mi').exec(t); return m ? m[1].replace(/\\([,;\\])/g, '$1').replace(/\\n/g, ' ') : ''; };
++ return { type: 'contact', title: 'Contact card', fields: [['Name', line('FN')], ['Phone', line('TEL')], ['Email', line('EMAIL')], ['Organisation', line('ORG')], ['Website', line('URL')]].filter((f) => f[1]) };
++ }
++ if (/^BEGIN:VEVENT/i.test(t)) { const line = (k) => { const m = new RegExp(`^${k}[^:\\n]*:(.*)$`, 'mi').exec(t); return m ? m[1] : ''; }; return { type: 'event', title: 'Calendar event', fields: [['Title', line('SUMMARY')], ['Starts', line('DTSTART')], ['Ends', line('DTEND')], ['Where', line('LOCATION')]].filter((f) => f[1]) }; }
++ if (/^mailto:/i.test(t)) return { type: 'email', title: 'Email', fields: [['To', decodeURIComponent(t.slice(7).split('?')[0])]], href: t };
++ if (/^(SMSTO|smsto|sms):/.test(t)) { const [, num, msg] = t.split(':'); return { type: 'sms', title: 'Text message', fields: [['To', num || ''], ['Message', msg || '']] }; }
++ if (/^tel:/i.test(t)) return { type: 'phone', title: 'Phone number', fields: [['Number', t.slice(4)]], href: t };
++ if (/^geo:/i.test(t)) { const [lat, lng] = t.slice(4).split(/[,?]/); return { type: 'geo', title: 'Place', fields: [['Latitude', lat], ['Longitude', lng]], href: `https://www.openstreetmap.org/?mlat=${encodeURIComponent(lat)}&mlon=${encodeURIComponent(lng)}#map=16/${encodeURIComponent(lat)}/${encodeURIComponent(lng)}` }; }
++ if (/^https?:\/\/\S+$/i.test(t)) { let host = ''; try { host = new URL(t).host; } catch { host = ''; } return { type: 'url', title: 'Link', fields: [['Site', host]], href: t }; }
++ const num = !format || RETAIL.test(format) ? identifyNumber(t) : null;
++ if (num && num.valid) return { type: 'product', title: { isbn10: 'Book (ISBN-10)', isbn13: 'Book (ISBN)', issn: 'Magazine (ISSN)', sscc: 'Shipping container (SSCC)', gtin14: 'Product case (GTIN-14)' }[num.kind] || 'Product number', fields: [['Number', num.digits], ['Check digit', num.valid ? 'correct' : `wrong (should be ${num.expected})`]] };
++ if (/^\(\d{2,4}\)/.test(t) || /^\]C1|^\]d2/.test(t)) { const g = parseGs1(t); if (g.parts.length) return { type: 'gs1', title: 'GS1 data', fields: g.parts.map((p) => [`(${p.ai}) ${p.title}`, p.display || p.value]) }; }
++ return { type: 'text', title: 'Text', fields: [] };
++ }
++
++ // ── Which code for a piece of text ───────────────────────────────────────────────────────────────────────────────
++ /**
++ * Free text → { symbology, why, fixed?, warnings[] }: the barcode that fits it best, with the reason (a valid
++ * GTIN goes in its retail barcode, a GS1 string in GS1-128, digits in Code 128 or ITF, long or Unicode text in a 2D
++ * code), any correction (a wrong check digit) and warnings.
++ */
++ function recommend(text) {
++ const t = String(text || '');
++ const warnings = [];
++ if (!t.trim()) return { symbology: 'qrcode', why: 'Type something to encode.', warnings };
++ const num = identifyNumber(t);
++ // A valid GTIN, or 12-13 digits (a retail number with a typo in its check digit); other digit runs are just numbers.
++ if (num && (num.valid || ['upca', 'ean13', 'isbn13', 'issn'].includes(num.kind))) {
++ if (!num.valid) warnings.push(`The check digit is ${num.digits.slice(-1)}; for this number it should be ${num.expected}.`);
++ return { symbology: num.symbology, encode: num.valid ? num.encode : undefined, why: num.note, fixed: num.valid ? undefined : num.fixed, kind: num.kind, warnings };
++ }
++ if (/^\(\d{2,4}\)/.test(t)) {
++ const g = parseGs1(t);
++ g.parts.filter((p) => p.error).forEach((p) => warnings.push(`(${p.ai}) ${p.title}: ${p.error}.`));
++ return { symbology: t.length > 48 ? 'gs1datamatrix' : 'gs1-128', why: t.length > 48 ? 'A long GS1 element string fits best in GS1 DataMatrix.' : 'GS1 element strings (AIs in brackets) go in GS1-128 on cartons, or GS1 DataMatrix when space is tight.', warnings };
++ }
++ // eslint-disable-next-line no-control-regex
++ const ascii = /^[\x20-\x7e]*$/.test(t);
++ if (/^https?:\/\//i.test(t) || t.length > 60 || !ascii || /\n/.test(t)) {
++ return { symbology: 'qrcode', why: !ascii ? 'Accents, emoji and other scripts need a 2D code: QR handles them as UTF-8.' : /^https?:\/\//i.test(t) ? 'A link is best as a QR code: every phone camera opens it.' : 'Long text fits a 2D code; QR is the one every phone reads.', warnings };
++ }
++ if (/^\d+$/.test(t)) return t.length % 2 === 0 && t.length >= 6 ? { symbology: 'interleaved2of5', why: 'An even number of digits packs tightly in Interleaved 2 of 5 (Code 128 works too).', warnings } : { symbology: 'code128', why: 'Digits: Code 128 is compact and read by every scanner.', warnings };
++ if (/^[0-9A-Z \-.$/+%]+$/.test(t)) return { symbology: 'code39', why: 'Upper-case letters, digits and - . $ / + %: Code 39 is the classic for parts and badges (Code 128 is denser).', warnings };
++ return { symbology: 'code128', why: 'Mixed text up to 60 characters: Code 128 is the dense, universal 1D choice.', warnings };
++ }
++
++ /** How big to print a code: a 1D/2D code's width in mm for a scanning distance in cm (rule of thumb 10:1 for QR). */
++ const qrSizeFor = (distanceCm) => Math.max(10, Math.round((distanceCm * 10) / 10));
++ /** WCAG-style contrast between two hex colours (1-21): scanners want dark on light and at least ~4:1. */
++ function contrast(a, b) {
++ const lum = (h) => { const m = /^#?([0-9a-f]{6})$/i.exec(String(h).trim()); if (!m) return 0; return [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16) / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)).reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0); };
++ const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);
++ return (x + 0.05) / (y + 0.05);
++ }
++
++ return { gs1CheckDigit, gs1Valid, isbn10Check, isbn10To13, isbn13To10, upceToUpca, identifyNumber, parseGs1, gs1Text, AI, qrPayload, describePayload, recommend, qrSizeFor, contrast };
++});
ddiff --git a/apps/gateway/server/codes/descriptors.js b/apps/gateway/server/codes/descriptors.js
nnew file mode 100644
iindex 0000000..2ae8eaf
---- /dev/null
++++ b/apps/gateway/server/codes/descriptors.js
@@@ -0,0 +1,89 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Barcode & QR tools — what each tool is, for the tool registry (tools.tool@1 specs, ADR-027;
++// apps/_shared/tools/descriptor.js joins them with the catalogue's name, summary and hosts).
++//
++// The page (public/codes.html) draws and reads every code in the browser. The generators and the GS1
++// checker also have a server engine (server/codes/engines.js, the same bwip-js and smart.js) for the API:
++// api true, answered inline with an SVG. api false for the scanner (it reads a camera or a picture, which a
++// JSON run cannot carry) and the bulk generator (its answer is a ZIP or a print sheet).
++// ═══════════════════════════════════════════════════════════════
++
++const KiB = 1024;
++const ANYONE = { anonymous: true, capability: 'tools.tool.run' };
++const obj = (properties, required = ['text']) => ({ type: 'object', additionalProperties: false, required, properties });
++const text = (max, description) => ({ type: 'string', minLength: 1, maxLength: max, ...(description && { description }) });
++const COLOR = { type: 'string', pattern: '^#?[0-9a-fA-F]{6}$' };
++// The look every code takes: colours, quiet zone.
++const LOOK = { color: { ...COLOR, default: '#000000', description: 'Bars or modules' }, background: { ...COLOR, default: '#ffffff' }, quiet: { type: 'integer', minimum: 0, maximum: 40, description: 'Quiet zone, in modules (default 4 for 2D codes, 10 for barcodes)' } };
++// …and a 1D barcode's.
++const BARS = { ...LOOK, height_mm: { type: 'number', minimum: 2, maximum: 100, description: 'Bar height in millimetres' }, include_text: { type: 'boolean', default: true, description: 'Print the human-readable text' }, rotate: { enum: ['N', 'R', 'I', 'L'], default: 'N', description: 'N normal, R right, I inverted, L left' } };
++const ECC = { enum: ['L', 'M', 'Q', 'H'], default: 'M', description: 'Error correction: L 7%, M 15%, Q 25%, H 30% of the code can be lost' };
++
++const SVG_OUT = {
++ kind: 'json', schema: {
++ type: 'object', required: ['svg', 'symbology', 'text'],
++ properties: {
++ svg: { type: 'string', description: 'The code as an SVG document (viewBox in bwip-js units: 4 per module)' },
++ symbology: { type: 'string', description: 'bwip-js id of the symbology drawn' },
++ text: { type: 'string', description: 'What was encoded' },
++ width: { type: ['number', 'null'] }, height: { type: ['number', 'null'] },
++ payload: { type: 'string', description: 'QR tools: the text the code carries (a WIFI: string, a vCard)' },
++ recommended: { type: 'object', description: 'barcode without a symbology: why this one, warnings, and a corrected number', properties: { why: { type: 'string' }, warnings: { type: 'array', items: { type: 'string' } }, fixed: { type: 'string' } } },
++ isbn13: { type: 'string' }, isbn10: { type: ['string', 'null'] },
++ },
++ },
++};
++
++/** A generator with a server engine: api true, answered inline. */
++function engine(id, input, example, output = SVG_OUT) {
++ return {
++ id, execution: 'client', api: true, input, files: null, output,
++ limits: { timeoutMs: 5000, maxInputBytes: 16 * KiB },
++ auth: ANYONE, quotaClass: 'tools-run', cost: 1, egress: false,
++ engine: id, example: { input: example },
++ };
++}
++
++/** A browser-only tool: api false. */
++function page(id, output) {
++ return { id, execution: 'client', api: false, input: null, files: null, output, limits: { timeoutMs: 10000 }, auth: ANYONE, quotaClass: 'tools-run', cost: 1, egress: false };
++}
++
++const SPECS = [
++ engine('barcode', obj({ text: text(3000), symbology: { type: 'string', pattern: '^[a-z0-9-]{2,40}$', description: 'A bwip-js symbology id (ean13, code128, qrcode, pdf417…); left out, the one that fits the text is chosen' }, ...BARS,
++ eclevel: { type: ['string', 'integer'], maxLength: 2, description: 'Error correction, as the symbology names it (QR L/M/Q/H, PDF417 0-8, Aztec percent)' },
++ columns: { type: 'integer', minimum: 1, maximum: 30 }, rows: { type: 'integer', minimum: 1, maximum: 90 }, version: { type: 'string', maxLength: 12 }, format: { enum: ['square', 'rectangle', 'full', 'compact', 'micro'] }, addon: { type: 'string', pattern: '^(\\d{2}|\\d{5})$', description: 'EAN-2 or EAN-5 add-on (retail symbologies)' } }),
++ { text: '4006381333931' }),
++ engine('qr', obj({ text: text(2900), ecc: ECC, gs1: { type: 'boolean', default: false, description: 'GS1 QR: the text is a bracketed GS1 element string' }, ...LOOK }), { text: 'https://openvibe.tools', ecc: 'M' }),
++ engine('wifiqr', obj({
++ ssid: text(64, 'Network name'), password: { type: 'string', maxLength: 128 }, security: { enum: ['WPA', 'WEP', 'nopass'], default: 'WPA', description: 'WPA covers WPA2 and WPA3' },
++ hidden: { type: 'boolean', default: false }, ecc: ECC, ...LOOK,
++ }, ['ssid']), { ssid: 'Cafe Guest', password: 'espresso-2026', security: 'WPA' }),
++ engine('vcardqr', obj({
++ first: { type: 'string', maxLength: 80 }, last: { type: 'string', maxLength: 80 }, org: { type: 'string', maxLength: 120 }, title: { type: 'string', maxLength: 120 },
++ phone: { type: 'string', maxLength: 40 }, email: { type: 'string', maxLength: 160 }, url: { type: 'string', maxLength: 300 }, address: { type: 'string', maxLength: 300 }, ecc: ECC, ...LOOK,
++ }, []), { first: 'Ada', last: 'Lovelace', phone: '+44 20 7946 0000', email: '[email protected]' }),
++ engine('datamatrix', obj({ text: text(2000), gs1: { type: 'boolean', default: false, description: 'GS1 DataMatrix: the text is a bracketed GS1 element string' }, shape: { enum: ['square', 'rectangle'], default: 'square' }, ...LOOK }), { text: '(01)09506000134352(17)251231(10)ABC123', gs1: true }),
++ engine('gs1', obj({ text: text(500, 'A GTIN, UPC, EAN, ISBN, ISSN or SSCC number, or a GS1 element string ((01)…(17)… or raw)') }),
++ { text: '(01)09506000134352(17)251231(10)ABC123' },
++ {
++ kind: 'json', schema: {
++ type: 'object', required: ['number', 'gs1'],
++ properties: {
++ number: { type: ['object', 'null'], description: 'What the number is: kind, valid, expected check digit, the fixed number, conversions', properties: { kind: { type: 'string' }, valid: { type: 'boolean' }, expected: { type: 'string' }, fixed: { type: 'string' }, symbology: { type: 'string' } } },
++ gs1: { type: ['object', 'null'], description: 'A GS1 element string split into Application Identifiers', properties: { ok: { type: 'boolean' }, parts: { type: 'array', items: { type: 'object', properties: { ai: { type: 'string' }, title: { type: 'string' }, value: { type: 'string' }, valid: { type: 'boolean' }, error: { type: 'string' }, display: { type: 'string' } } } }, error: { type: 'string' } } },
++ },
++ },
++ }),
++ engine('code128', obj({ text: text(200), ...BARS }), { text: 'OV-2026-0042' }),
++ engine('ean13', obj({ text: { type: 'string', pattern: '^[0-9 -]{12,17}$', description: '12 digits (the check digit is added) or 13' }, addon: { type: 'string', pattern: '^(\\d{2}|\\d{5})$' }, ...BARS }), { text: '400638133393' }),
++ engine('upc', obj({ text: { type: 'string', pattern: '^[0-9 -]{8,15}$', description: 'UPC-A: 11 digits (the check digit is added) or 12; UPC-E: 8' }, addon: { type: 'string', pattern: '^(\\d{2}|\\d{5})$' }, ...BARS }), { text: '03600029145' }),
++ engine('pdf417', obj({ text: text(1800), columns: { type: 'integer', minimum: 1, maximum: 30 }, eclevel: { type: 'integer', minimum: 0, maximum: 8 }, ...LOOK }), { text: 'PDF417 carries up to about 1,800 characters.' }),
++ engine('aztec', obj({ text: text(3000), eclevel: { type: 'integer', minimum: 5, maximum: 95, description: 'Error correction, percent (default 23)' }, ...LOOK }), { text: 'TICKET 4471 SEAT 12A' }),
++ engine('isbn', obj({ text: { type: 'string', pattern: '^(ISBN[ :-]*)?[0-9Xx -]{10,17}$', description: 'An ISBN-10 or ISBN-13' }, addon: { type: 'string', pattern: '^\\d{5}$', description: 'EAN-5 price add-on, e.g. 51995 for US $19.95' }, ...BARS }), { text: '978-0-306-40615-7', addon: '51995' }),
++ page('scan', { kind: 'json', schema: { type: 'object', description: 'The codes found in a camera frame or a picture: format, text and position (in the browser)', properties: { codes: { type: 'array' } } } }),
++ page('bulkbarcodes', { kind: 'files', mime: ['application/zip', 'image/png', 'image/svg+xml'] }),
++];
++
++module.exports = { SPECS };
ddiff --git a/apps/gateway/server/codes/engines.js b/apps/gateway/server/codes/engines.js
nnew file mode 100644
iindex 0000000..5d324db
---- /dev/null
++++ b/apps/gateway/server/codes/engines.js
@@@ -0,0 +1,108 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Barcode & QR tools — server engines for the tools API (ADR-027).
++//
++// The pages (public/codes.html) draw and read codes in the browser. These are the same drawings, from the
++// same bwip-js and the same public/js/codes/smart.js, so a code tool whose descriptor says api: true can be
++// run by POST /api/v1/tools/:id/run and answers an SVG. Engines run in the gateway's worker pool
++// (server/run/engine-worker.js). Input is already checked against the tool's schema; text bwip-js cannot
++// encode (a letter in an EAN-13, an ISBN with the wrong number of digits) throws, and the run fails with
++// bwip-js's reason.
++// ═══════════════════════════════════════════════════════════════
++
++const bwipjs = require('bwip-js');
++const S = require('../../public/js/codes/smart.js');
++
++// Every symbology bwip-js draws, by its id: { bcid, desc, text (a sample), opts }.
++const SYMBOLS = new Map(bwipjs.symbolList.map((s) => [s.bcid, s]));
++const TWO_D = /qrcode|datamatrix|aztec|pdf417|maxicode|dotcode|hanxin|codeone|ultracode|codablockf|code16k|code49|mailmark|d3aqr|rune/;
++const hex = (c, dflt) => String(c || dflt).replace(/^#/, '').toLowerCase();
++
++/** bwip-js's errors read "bwipp.ean13badLength#2394: EAN-13 must be 12 or 13 digits": keep the sentence. */
++function failure(err) {
++ const msg = String((err && err.message) || err).replace(/^bwip(?:p|-js)\.[\w-]+#?\d*:\s*/, '');
++ return Object.assign(new Error(msg), { status: 422 });
++}
++
++/**
++ * One code as SVG → { svg, symbology, text, width, height }. `o`: height_mm (1D bar height), include_text,
++ * rotate (N R I L), color and background (#rrggbb), quiet (quiet zone in modules), plus the few symbology
++ * options the API passes through (eclevel, columns, rows, version, format, addon).
++ */
++function draw(bcid, text, o = {}) {
++ if (!SYMBOLS.has(bcid)) throw Object.assign(new Error(`Unknown symbology "${bcid}"`), { status: 422 });
++ const twoD = TWO_D.test(bcid);
++ const opts = {
++ bcid, text, scale: 4,
++ barcolor: hex(o.color, '000000'), backgroundcolor: hex(o.background, 'ffffff'),
++ rotate: o.rotate || 'N',
++ paddingwidth: o.quiet ?? (twoD ? 4 : 10), paddingheight: o.quiet ?? 4,
++ };
++ if (!twoD) {
++ if (o.height_mm) opts.height = o.height_mm;
++ // Retail symbologies lay out their own digits (the first one outside the bars, then the groups); others centre a line.
++ const retail = /^(ean13|upca|upce|ean8|isbn|ismn|issn)$/.test(bcid);
++ if (o.include_text !== false && !/pharmacode|postnet|planet|royalmail|auspost|kix|japanpost|onecode|flattermarken|mailmark|daft/.test(bcid)) { opts.includetext = true; if (!retail) { opts.textxalign = 'center'; opts.textyoffset = 3; } }
++ if (retail) opts.guardwhitespace = true;
++ }
++ for (const k of ['eclevel', 'columns', 'rows', 'version', 'format', 'addon', 'extratext']) if (o[k] !== undefined && o[k] !== '') opts[k] = o[k];
++ let svg;
++ try { svg = bwipjs.toSVG(opts); } catch (err) { throw failure(err); }
++ const vb = /viewBox="0 0 (\d+(?:\.\d+)?) (\d+(?:\.\d+)?)"/.exec(svg);
++ return { svg, symbology: bcid, text, width: vb ? Number(vb[1]) : null, height: vb ? Number(vb[2]) : null };
++}
++
++/** The general barcode tool: a chosen symbology, or the one recommend() picks for the text. */
++function barcode(input) {
++ const rec = S.recommend(input.text);
++ const bcid = input.symbology || rec.symbology;
++ const text = input.symbology ? input.text : (rec.encode || input.text);
++ const out = draw(bcid, text, input);
++ return { data: { ...out, ...(input.symbology ? {} : { recommended: { why: rec.why, warnings: rec.warnings, ...(rec.fixed ? { fixed: rec.fixed } : {}) } }) } };
++}
++
++/** A fixed symbology for the single-purpose pages (code128, ean13, upc, pdf417, aztec, isbn). */
++const fixed = (bcid, prep = (t) => t) => (input) => ({ data: draw(bcid, prep(input.text, input), input) });
++
++/** GTIN-style input: the check digit added when one digit short (12 digits → EAN-13), verified otherwise. */
++const gtin = (len) => (t) => {
++ const d = String(t).replace(/[\s-]/g, '');
++ if (/^\d+$/.test(d) && d.length === len - 1) return d + S.gs1CheckDigit(d);
++ return d;
++};
++
++const qrDraw = (payload, input) => draw(input.gs1 ? 'gs1qrcode' : 'qrcode', payload, { ...input, eclevel: input.ecc || 'M', rotate: 'N' });
++
++const ENGINES = {
++ barcode,
++ qr: (input) => ({ data: { ...qrDraw(input.text, input), payload: input.text } }),
++ wifiqr: (input) => { const payload = S.qrPayload('wifi', input); return { data: { ...qrDraw(payload, input), payload } }; },
++ vcardqr: (input) => { const payload = S.qrPayload('contact', input); return { data: { ...qrDraw(payload, input), payload } }; },
++ datamatrix: (input) => ({ data: draw(input.gs1 ? (input.shape === 'rectangle' ? 'gs1datamatrixrectangular' : 'gs1datamatrix') : 'datamatrix', input.text, { ...input, format: input.gs1 ? undefined : (input.shape === 'rectangle' ? 'rectangle' : 'square') }) }),
++ code128: fixed('code128'),
++ ean13: fixed('ean13', (t, i) => gtin(13)(t) + (i.addon ? ` ${i.addon}` : '')),
++ upc: fixed('upca', (t, i) => { const d = String(t).replace(/[\s-]/g, ''); return (d.length === 8 ? d : gtin(12)(d)) + (i.addon ? ` ${i.addon}` : ''); }),
++ pdf417: fixed('pdf417'),
++ aztec: fixed('azteccode'),
++ isbn: (input) => {
++ const id = S.identifyNumber(input.text);
++ if (!id || !['isbn10', 'isbn13'].includes(id.kind)) throw Object.assign(new Error('Not an ISBN: give 10 or 13 digits (an ISBN-13 starts with 978 or 979).'), { status: 422 });
++ if (!id.valid) throw Object.assign(new Error(`The check digit should be ${id.expected}.`), { status: 422 });
++ const thirteen = id.kind === 'isbn10' ? id.isbn13 : id.digits;
++ const addon = input.addon ? ` ${input.addon}` : '';
++ // Hyphens as printed on the copyright page go in the ISBN line above the bars (bwip-js's isbn symbol);
++ // without them the line reads the bare number: hyphen positions depend on ISBN range tables.
++ const typed = String(input.text).trim().replace(/^ISBN(?:-1[03])?:?\s*/i, '');
++ const hyphenated = /^97[89]-\d+-\d+-\d+-\d$/.test(typed) && typed.length === 17 ? typed : null;
++ const out = hyphenated ? draw('isbn', hyphenated + addon, input) : draw('ean13', thirteen + addon, { ...input, extratext: `ISBN ${thirteen}` });
++ return { data: { ...out, isbn13: thirteen, isbn10: id.kind === 'isbn10' ? id.fixed : S.isbn13To10(thirteen) } };
++ },
++ // A number or a GS1 element string, explained.
++ gs1: ({ text }) => {
++ const number = S.identifyNumber(text, { complete: true });
++ const element = /^\(\d{2,4}\)|^\]C1|^\]d2|^\]Q3|^0\d{15}/.test(String(text).trim()) ? S.parseGs1(text) : null;
++ return { data: { number, gs1: element } };
++ },
++};
++
++module.exports = { ENGINES, SYMBOLS, draw };
ddiff --git a/apps/gateway/server/codes/image.js b/apps/gateway/server/codes/image.js
nnew file mode 100644
iindex 0000000..b5142b5
---- /dev/null
++++ b/apps/gateway/server/codes/image.js
@@@ -0,0 +1,107 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Barcode & QR tools — every code as an image URL, for <img> tags, Markdown, scripts and AI agents:
++//
++// GET /api/codes/<symbology>.svg?text=… (or .png) any bwip-js symbology: qrcode, ean13, code128, datamatrix…
++// GET /api/codes/qr.png?text=…&ecc=H qr = qrcode
++// GET /api/codes/auto.svg?text=… the symbology recommend() picks (a GTIN in its retail code…)
++// GET /api/codes/wifi.svg?ssid=…&password=…&security=WPA a Wi-Fi QR code; vcard.svg?first=…&phone=… a contact card
++//
++// Options: scale (1-12, PNG px per module), height (mm, barcodes), hrt=0 (no human-readable line), color and
++// background (rrggbb, background=transparent), quiet (modules), rotate (N R I L), ecc (QR L M Q H), eclevel, addon.
++// No account: the answer depends only on the URL, so it is cached for a year (Cloudflare serves repeats) and anyone may
++// embed it (CORS *). Per address 300 a minute. Text bwip-js cannot encode answers 422 with its reason as plain text.
++// ═══════════════════════════════════════════════════════════════
++
++const express = require('express');
++const rateLimit = require('express-rate-limit');
++const bwipjs = require('bwip-js');
++const S = require('../../public/js/codes/smart.js');
++const { draw, SYMBOLS } = require('./engines');
++
++const MAX_TEXT = 2000;
++const MAX_PIXELS = 16e6; // a PNG at most 4000 × 4000
++const hexOf = (v) => (/^#?[0-9a-f]{6}$/i.test(String(v || '')) ? String(v).replace(/^#/, '') : null);
++const int = (v, lo, hi) => { const n = parseInt(v, 10); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : undefined; };
++
++/** The request → { bcid, text, opts } for engines.draw, or { error }. */
++function parse(name, q) {
++ let bcid = name === 'qr' ? 'qrcode' : name;
++ let text = typeof q.text === 'string' ? q.text : '';
++ if (bcid === 'wifi') { bcid = 'qrcode'; text = S.qrPayload('wifi', { ssid: q.ssid, password: q.password, security: q.security, hidden: q.hidden === '1' || q.hidden === 'true' }); if (!q.ssid) return { error: 'wifi needs ssid=' }; }
++ else if (bcid === 'vcard') { bcid = 'qrcode'; text = S.qrPayload('contact', q); if (!q.first && !q.last && !q.org) return { error: 'vcard needs first=, last= or org=' }; }
++ else if (bcid === 'auto') { const r = S.recommend(text); bcid = r.symbology; text = r.encode || r.fixed || text; }
++ if (!text) return { error: 'text= is required' };
++ if (text.length > MAX_TEXT) return { error: `text= is limited to ${MAX_TEXT} characters` };
++ if (!SYMBOLS.has(bcid)) return { error: `Unknown symbology "${name}". The list: https://openvibe.tools/api/codes` };
++ const bg = q.background === 'transparent' ? null : hexOf(q.background);
++ return {
++ bcid, text,
++ opts: {
++ color: hexOf(q.color) || undefined, background: bg || undefined, transparent: q.background === 'transparent',
++ quiet: int(q.quiet, 0, 40), rotate: /^[NRIL]$/.test(q.rotate || '') ? q.rotate : 'N',
++ height_mm: q.height ? Math.min(100, Math.max(2, Number(q.height) || 15)) : undefined,
++ include_text: !(q.text_line === '0' || q.hrt === '0' || q.showtext === '0'),
++ eclevel: q.ecc && /^[LMQH]$/.test(q.ecc) ? q.ecc : (q.eclevel !== undefined ? (/^\d{1,2}$/.test(q.eclevel) ? Number(q.eclevel) : String(q.eclevel).slice(0, 2)) : undefined),
++ addon: /^(\d{2}|\d{5})$/.test(q.addon || '') ? q.addon : undefined,
++ scale: int(q.scale, 1, 12) || 4,
++ },
++ };
++}
++
++function router() {
++ const r = express.Router();
++ r.use('/api/codes', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }));
++ // The symbologies, for people and programs choosing one.
++ r.get('/api/codes', (_req, res) => {
++ res.set({ 'Cache-Control': 'public, max-age=3600', 'Access-Control-Allow-Origin': '*' }).json({
++ usage: 'GET /api/codes/<symbology>.svg?text=… or .png; see https://barcodes.openvibe.tools/ and https://openvibe.tools/developers',
++ aliases: { qr: 'qrcode', auto: 'the symbology that fits the text', wifi: 'Wi-Fi QR (ssid, password, security, hidden)', vcard: 'contact QR (first, last, phone, email, org, title, url, address)' },
++ symbologies: [...SYMBOLS.values()].map((s) => ({ id: s.bcid, name: s.desc, example: `/api/codes/${s.bcid}.svg?text=${encodeURIComponent(s.text)}` })),
++ });
++ });
++ r.get('/api/codes/:name.:ext(svg|png)', async (req, res) => {
++ const p = parse(String(req.params.name).toLowerCase(), req.query);
++ const fail = (status, msg) => res.status(status).set({ 'Cache-Control': 'public, max-age=60', 'Access-Control-Allow-Origin': '*' }).type('text/plain').send(msg);
++ if (p.error) return fail(422, p.error);
++ try {
++ if (req.params.ext === 'svg') {
++ const out = draw(p.bcid, p.text, { ...p.opts, background: p.opts.transparent ? undefined : p.opts.background });
++ let svg = out.svg;
++ if (p.opts.transparent) svg = svg.replace(/<rect width="100%" height="100%" fill="#[0-9a-f]{6}" \/>\n?/i, '');
++ res.set({ 'Content-Type': 'image/svg+xml; charset=utf-8', 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'" });
++ return send(res, svg);
++ }
++ // PNG: the same drawing at `scale` pixels per module, inside the size limit.
++ const twoD = /qrcode|datamatrix|aztec|pdf417|maxicode|dotcode|hanxin|codeone|ultracode/.test(p.bcid);
++ const opts = {
++ bcid: p.bcid, text: p.text, scale: p.opts.scale, rotate: p.opts.rotate,
++ barcolor: p.opts.color || '000000', ...(p.opts.transparent ? {} : { backgroundcolor: p.opts.background || 'ffffff' }),
++ paddingwidth: p.opts.quiet ?? (twoD ? 4 : 10), paddingheight: p.opts.quiet ?? 4,
++ ...(p.opts.eclevel !== undefined ? { eclevel: p.opts.eclevel } : {}), ...(p.opts.addon ? { addon: p.opts.addon } : {}),
++ };
++ if (!twoD) {
++ if (p.opts.height_mm) opts.height = p.opts.height_mm;
++ const retail = /^(ean13|upca|upce|ean8|isbn|ismn|issn)$/.test(p.bcid);
++ if (p.opts.include_text && !/pharmacode|postnet|planet|royalmail|auspost|kix|japanpost|onecode|flattermarken|mailmark|daft/.test(p.bcid)) Object.assign(opts, retail ? { includetext: true, guardwhitespace: true } : { includetext: true, textxalign: 'center', textyoffset: 3 });
++ }
++ const raw = bwipjs.raw(opts)[0];
++ const w = ((raw.pixx || raw.sbs?.reduce?.((a, b) => a + b, 0) || 100) + 40) * p.opts.scale;
++ const h = ((raw.pixy || 60) + 40) * p.opts.scale;
++ if (w * h > MAX_PIXELS) return fail(422, 'That code at that scale is larger than 4000 × 4000 px: use a smaller scale or .svg');
++ const png = await bwipjs.toBuffer(opts);
++ res.set('Content-Type', 'image/png');
++ return send(res, png);
++ } catch (err) {
++ return fail(422, String((err && err.message) || err).replace(/^bwip(?:p|-js)\.[\w-]+#?\d*:\s*/, ''));
++ }
++ });
++ return r;
++}
++
++function send(res, body) {
++ res.set({ 'Cache-Control': 'public, max-age=31536000, immutable', 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'X-Content-Type-Options': 'nosniff' });
++ return res.send(body);
++}
++
++module.exports = { router, parse };
ddiff --git a/apps/gateway/server/codes/vendor.js b/apps/gateway/server/codes/vendor.js
nnew file mode 100644
iindex 0000000..b7924dd
---- /dev/null
++++ b/apps/gateway/server/codes/vendor.js
@@@ -0,0 +1,39 @@
++'use strict';
++// The barcode tools' browser libraries, served from this app's own node_modules (pinned to exact versions in
++// package.json, never a CDN): /vendor/bwip-js@<v>/bwip-js.min.js draws every symbology, and
++// /vendor/zxing-wasm@<v>/reader.js with its zxing_reader.wasm reads them (the page's locateFile points there).
++// A version in the path means a new pin is a new URL, so every file is cached for a year.
++const fs = require('fs');
++const path = require('path');
++const express = require('express');
++
++const MODULES = path.join(__dirname, '..', '..', 'node_modules');
++const versionOf = (name) => JSON.parse(fs.readFileSync(path.join(MODULES, name, 'package.json'), 'utf8')).version;
++
++/** { '/vendor/[email protected]/bwip-js.min.js': { file, type } … } and the URLs the page uses. */
++function files() {
++ const bwip = versionOf('bwip-js');
++ const zxing = versionOf('zxing-wasm');
++ const js = 'application/javascript; charset=utf-8';
++ return {
++ urls: { bwip: `/vendor/bwip-js@${bwip}/bwip-js.min.js`, zxing: `/vendor/zxing-wasm@${zxing}/reader.js`, zxingWasm: `/vendor/zxing-wasm@${zxing}/zxing_reader.wasm` },
++ map: {
++ [`/vendor/bwip-js@${bwip}/bwip-js.min.js`]: { file: path.join(MODULES, 'bwip-js', 'dist', 'bwip-js-min.js'), type: js },
++ [`/vendor/zxing-wasm@${zxing}/reader.js`]: { file: path.join(MODULES, 'zxing-wasm', 'dist', 'iife', 'reader', 'index.js'), type: js },
++ [`/vendor/zxing-wasm@${zxing}/zxing_reader.wasm`]: { file: path.join(MODULES, 'zxing-wasm', 'dist', 'reader', 'zxing_reader.wasm'), type: 'application/wasm' },
++ },
++ };
++}
++
++function router() {
++ const { map } = files();
++ const r = express.Router();
++ r.get(Object.keys(map), (req, res) => {
++ const f = map[req.path];
++ res.set({ 'Content-Type': f.type, 'Cache-Control': 'public, max-age=31536000, immutable', 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin' });
++ res.sendFile(f.file);
++ });
++ return r;
++}
++
++module.exports = { router, files };
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex 3045417..d729da1 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -29,6 +29,7 @@ const createNetRoutes = require('./net/routes');
const { NET_TOOL_MAP, NET_ALIASES } = require('./net/config');
const createDevRoutes = require('./dev/routes');
const { DEV_TOOL_MAP, DEV_ALIASES } = require('./dev/config');
++const { CODES } = require('./seo/catalog');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
const app = express();
@@@ -82,6 +83,7 @@ const obs = observe({ telemetry,
if (req.baseUrl === '/api/pastes') return '/api/pastes/*';
if (isNetHost(req)) return 'page:net';
if (isDevHost(req)) return 'page:dev';
++ if (isCodesHost(req)) return 'page:codes';
return null;
},
checks: [
@@@ -120,7 +122,8 @@ app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
-- scriptSrc: ["'self'", "'unsafe-inline'", "https://openvibe.network", "https://openvibe.live", "cdnjs.cloudflare.com", "cdn.jsdelivr.net", "fonts.googleapis.com", "https://static.cloudflareinsights.com"],
++ // 'wasm-unsafe-eval': the barcode scanner's ZXing reader is WebAssembly (codes.html), served from /vendor here.
++ scriptSrc: ["'self'", "'unsafe-inline'", "'wasm-unsafe-eval'", "https://openvibe.network", "https://openvibe.live", "cdnjs.cloudflare.com", "cdn.jsdelivr.net", "fonts.googleapis.com", "https://static.cloudflareinsights.com"],
styleSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "fonts.googleapis.com", "fonts.gstatic.com"],
fontSrc: ["'self'", "fonts.gstatic.com", "cdnjs.cloudflare.com"],
imgSrc: ["'self'", "data:", "blob:", "https://openvibe.network", "https://openvibe.media"],
@@@ -373,6 +376,10 @@ function isDevHost(req) {
return DEV_TOOL_MAP.has(sub) || DEV_ALIASES[sub];
}
++function isCodesHost(req) {
++ return Object.prototype.hasOwnProperty.call(CODES, subdomainOf(req));
++}
++
function isStaticPath(reqPath) {
return /\.(js|css|ico|png|svg|jpg|xml|txt|woff2?|webmanifest)$/.test(reqPath);
}
@@@ -383,6 +390,7 @@ function isStaticPath(reqPath) {
const { renderTool } = require('./seo/render');
const NET_HTML = path.join(__dirname, '..', 'public', 'net.html');
const DEV_HTML = path.join(__dirname, '..', 'public', 'dev.html');
++const CODES_HTML = path.join(__dirname, '..', 'public', 'codes.html');
function sendTool(req, res, file) {
try {
res.set('Content-Type', 'text/html; charset=utf-8');
@@@ -427,6 +435,12 @@ function sendTool(req, res, file) {
// Terms, Privacy and DMCA — the same documents on the apex and on every tool host.
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'tools', service: 'tools', host: 'openvibe.tools', name: 'OpenVibe.Tools', profile: 'tools' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }
++// The barcode tools' libraries, from this app's pinned node_modules (package.json): bwip-js draws every code, the
++// ZXing reader (zxing-wasm) reads them. The version is in the path, so the files are cached for a year.
++app.use(require('./codes/vendor').router());
++// …and every code as an image URL (GET /api/codes/<symbology>.svg|png?text=…), no account, cached for a year.
++app.use(require('./codes/image').router());
++
// The apex is server-rendered from the registry: index, families, tool pages, search,
// sitemap.xml, robots.txt, llms.txt and /api/catalog.json (server/pages/site.js).
// This site's own pinned copy of the OpenVibe Frame's browser files (/shared, openvibe-shared/serve) is
@@@ -466,6 +480,14 @@ app.use((req, res, next) => {
return sendTool(req, res, DEV_HTML);
});
++// Barcode & QR tool hosts (barcodes., qr., barcode., scan., gs1. …) → codes.html, one page for the family
++app.use((req, res, next) => {
++ if (!isCodesHost(req)) return next();
++ if (req.path.startsWith('/api/') || req.path.startsWith('/auth/') || req.path.startsWith('/vendor/')) return next();
++ if (isStaticPath(req.path)) return next();
++ return sendTool(req, res, CODES_HTML);
++});
++
// ── Web-push service worker (same-origin, scope "/") ─────────
app.get('/openvibe-sw.js', (req, res) => {
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
ddiff --git a/apps/gateway/server/openapi.js b/apps/gateway/server/openapi.js
iindex 8aa0818..1f4a0eb 100644
---- a/apps/gateway/server/openapi.js
++++ b/apps/gateway/server/openapi.js
@@@ -56,6 +56,21 @@ function openapi(snapshot, site) {
'/api/v1/jobs/{id}/files/{n}': { get: { operationId: 'jobFile', summary: 'Download result file n', tags: ['jobs'], security: [{ bearer: [] }, {}], parameters: [jobParam, { name: 'n', in: 'path', required: true, schema: { type: 'integer', minimum: 0 } }], responses: { 200: { description: 'The file' }, 404: problem } } },
'/api/v1/jobs/{id}/retry': { post: { operationId: 'retryJob', summary: 'Retry a failed job', tags: ['jobs'], security: [{ bearer: [] }, {}], parameters: [jobParam], responses: { 200: { description: 'The new job', content: { 'application/json': { schema: ref('job') } } }, 409: problem } } },
};
++ // Every code as an image URL (server/codes/image.js): no account, the same URL always draws the same image.
++ const q = (name, description, schema = { type: 'string' }) => ({ name, in: 'query', required: false, description, schema });
++ paths['/api/codes'] = { get: { operationId: 'listCodeSymbologies', summary: 'Every barcode and QR symbology the image URLs draw, with an example each', tags: ['codes'], responses: { 200: { description: '{ usage, aliases, symbologies: [{ id, name, example }] }' } } } };
++ paths['/api/codes/{symbology}.{format}'] = { get: {
++ operationId: 'codeImage', summary: 'A barcode or QR code as an SVG or PNG image (embed it in <img>, Markdown or a label)', tags: ['codes'],
++ description: 'symbology: any id from /api/codes (qrcode, ean13, code128, datamatrix, pdf417, azteccode…), or qr, auto (the symbology that fits the text), wifi (ssid, password, security, hidden) or vcard (first, last, phone, email, org, title, url, address). Cached for a year; 300 requests a minute per address.',
++ parameters: [
++ { name: 'symbology', in: 'path', required: true, schema: { type: 'string', pattern: '^[a-z0-9-]{2,40}$' } },
++ { name: 'format', in: 'path', required: true, schema: { enum: ['svg', 'png'] } },
++ q('text', 'What to encode (up to 2000 characters); not for wifi or vcard'), q('scale', 'PNG pixels per module, 1-12 (default 4)', { type: 'integer', minimum: 1, maximum: 12 }),
++ q('height', 'Bar height in mm (barcodes)', { type: 'number' }), q('hrt', '0 hides the human-readable line'), q('color', 'rrggbb'), q('background', 'rrggbb or transparent'),
++ q('quiet', 'Quiet zone in modules', { type: 'integer' }), q('rotate', 'N, R, I or L'), q('ecc', 'QR error correction: L, M, Q or H'), q('eclevel', 'Error correction as other symbologies name it'), q('addon', 'EAN-2 or EAN-5 add-on'),
++ ],
++ responses: { 200: { description: 'The image', content: { 'image/svg+xml': {}, 'image/png': {} } }, 422: { description: 'Why the text cannot be drawn (text/plain)' }, 429: { description: 'Too many requests (Retry-After)' } },
++ } };
for (const t of tools) paths[t.run.path] = runPath(t);
return {
openapi: '3.1.0',
@@@ -65,7 +80,7 @@ function openapi(snapshot, site) {
license: { name: 'MIT' }, contact: { name: 'OpenVibers', url: 'https://github.com/OpenVibers/OpenVibe.Tools', email: '[email protected]' },
},
servers: [{ url: site }],
-- tags: [{ name: 'registry' }, { name: 'jobs' }, ...[...new Set(tools.map((t) => t.family))].sort().map((f) => ({ name: f }))],
++ tags: [{ name: 'registry' }, { name: 'jobs' }, ...[...new Set([...tools.map((t) => t.family), 'codes'])].sort().map((f) => ({ name: f }))],
components: { securitySchemes: { bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'A Network service or app token for audience openvibe.tools (tools.tool.run; tools.net.probe for probes)' } } },
paths,
};
ddiff --git a/apps/gateway/server/registry/descriptors.js b/apps/gateway/server/registry/descriptors.js
iindex 7758d3d..fc471b4 100644
---- a/apps/gateway/server/registry/descriptors.js
++++ b/apps/gateway/server/registry/descriptors.js
@@@ -35,6 +35,7 @@ const PASTE = {
const SOURCES = [
{ app: 'net', specs: () => require('../net/descriptors').SPECS },
{ app: 'dev', specs: () => require('../dev/descriptors').SPECS, engines: path.join(__dirname, '..', 'dev', 'engines.js') },
++ { app: 'codes', specs: () => require('../codes/descriptors').SPECS, engines: path.join(__dirname, '..', 'codes', 'engines.js') },
{ app: 'img', specs: () => require(path.join(APPS, 'img/server/descriptors')).SPECS, satellite: 'img' },
{ app: 'audio', specs: () => require(path.join(APPS, 'audio/server/descriptors')).SPECS, satellite: 'audio' },
{ app: 'docs', specs: () => require(path.join(APPS, 'docs/server/descriptors')).SPECS, satellite: 'docs' },
ddiff --git a/apps/gateway/server/registry/families.js b/apps/gateway/server/registry/families.js
iindex c488e2f..ac539e9 100644
---- a/apps/gateway/server/registry/families.js
++++ b/apps/gateway/server/registry/families.js
@@@ -32,6 +32,17 @@ const FAMILIES = [
['Is there a size limit?', 'Browser-side tools are limited only by your device memory. Documents of a few megabytes are fine.'],
],
},
++ {
++ id: 'codes', name: 'Barcode & QR Tools', icon: 'barcode', path: '/barcode-tools', hub: 'barcodes', hubAliases: ['qrcodes', 'barcode-tools'], port: null,
++ tagline: 'Make, scan and check QR codes, barcodes and Data Matrix',
++ description: 'A QR code studio with styles and logos, a generator for more than a hundred barcode types (EAN, UPC, Code 128, GS1-128, PDF417, Aztec, Data Matrix, postal and pharmaceutical codes), a scanner for your camera or pictures, a GTIN and GS1 checker, and bulk labels.',
++ intro: 'Type what you want to encode and the tools pick the right code for it, fix a wrong check digit and test-scan the result before you download it. Everything runs in your browser: nothing you type, upload or scan is sent anywhere.',
++ faq: [
++ ['Are the codes free to use?', 'Yes, commercially too. The codes are static: what you encode is in the pattern, with no redirect service that could expire.'],
++ ['Which barcode types are supported?', 'More than a hundred: retail (EAN, UPC, ISBN), industrial (Code 128, Code 39, ITF), GS1 (GS1-128, DataBar, GS1 DataMatrix), 2D (QR, Data Matrix, PDF417, Aztec, MaxiCode, DotCode, Han Xin) and postal and pharmaceutical codes.'],
++ ['Does scanning work on a computer without a camera?', 'Yes. Drop, paste or upload a picture or a screenshot and the scanner reads every code in it.'],
++ ],
++ },
{
id: 'img', name: 'Image Tools', icon: 'image', path: '/image-tools', hub: 'img', hubAliases: [], port: 4012,
tagline: 'Convert, compress, resize and crop pictures',
ddiff --git a/apps/gateway/server/registry/hosts.js b/apps/gateway/server/registry/hosts.js
iindex 18e1181..049f4a3 100644
---- a/apps/gateway/server/registry/hosts.js
++++ b/apps/gateway/server/registry/hosts.js
@@@ -23,6 +23,21 @@ const HOST_DEFAULTS = {
jsonstringify: { canonical: 'json-stringify', short: 'jsonstringify', aliases: ["jsonescape", "json-escape", "jsonunescape", "stringifyjson"] },
html2md: { canonical: 'html-to-markdown', short: 'html2md', aliases: ["htmltomarkdown", "html2markdown", "htmltomd"] },
md2html: { canonical: 'markdown-to-html', short: 'md2html', aliases: ["markdowntohtml", "md2htm", "mdtohtml"] },
++ // Barcode & QR tools (server/codes): the job is the canonical name, the short host is what people type.
++ qr: { canonical: 'qr-code-generator', short: 'qr', aliases: ['qrcode', 'qr-code', 'qrgen', 'qr-generator', 'makeqr'] },
++ barcode: { canonical: 'barcode-generator', short: 'barcode', aliases: ['barcodegen', 'make-barcode', 'bwip'] },
++ scan: { canonical: 'barcode-scanner', short: 'scan', aliases: ['qr-scanner', 'qrscan', 'qr-reader', 'qrreader', 'barcode-reader', 'scanner', 'readqr'] },
++ datamatrix: { canonical: 'data-matrix-generator', short: 'datamatrix', aliases: ['data-matrix', 'dmtx'] },
++ gs1: { canonical: 'gtin-check-digit', short: 'gs1', aliases: ['gtin', 'checkdigit', 'check-digit', 'sscc', 'gs1-parser'] },
++ wifiqr: { canonical: 'wifi-qr-code', short: 'wifiqr', aliases: ['wifi-qr', 'qr-wifi'] },
++ vcardqr: { canonical: 'vcard-qr-code', short: 'vcardqr', aliases: ['contact-qr', 'qr-vcard', 'contactqr'] },
++ bulkbarcodes: { canonical: 'bulk-barcode-generator', short: 'bulkbarcodes', aliases: ['batch-barcodes', 'barcode-labels'] },
++ code128: { canonical: 'code-128-generator', short: 'code128' },
++ ean13: { canonical: 'ean-13-generator', short: 'ean13', aliases: ['ean', 'ean-barcode'] },
++ upc: { canonical: 'upc-barcode-generator', short: 'upc', aliases: ['upca', 'upc-a'] },
++ pdf417: { canonical: 'pdf417-generator', short: 'pdf417' },
++ aztec: { canonical: 'aztec-code-generator', short: 'aztec' },
++ isbn: { canonical: 'isbn-barcode-generator', short: 'isbn', aliases: ['book-barcode'] },
yt: { canonical: 'youtube-downloader', short: 'yt', aliases: ['youtube', 'ytdl', 'youtubedownloader', 'youtube-download'] },
};
ddiff --git a/apps/gateway/server/registry/index.js b/apps/gateway/server/registry/index.js
iindex a18c7f5..d7aae84 100644
---- a/apps/gateway/server/registry/index.js
++++ b/apps/gateway/server/registry/index.js
@@@ -22,7 +22,8 @@ const DOMAINS_URL = process.env.OV_DOMAINS_URL || 'https://openvibe.network/api/
// Two different tools must never share a display name.
const RENAME = { curl: 'HTTP Request Tester', curlconvert: 'cURL Converter' };
--const FAMILY_ICON = { net: 'dns', dev: 'code' };
++const FAMILY_ICON = { net: 'dns', dev: 'code', codes: 'barcode' };
++const CODE_ICONS = { qr: 'qr', wifiqr: 'qr', vcardqr: 'qr', scan: 'scan', gs1: 'check' };
const NET_ICONS = { ip: 'ip', myip: 'ip', dns: 'dns', whois: 'whois', ssl: 'ssl', ping: 'ping', traceroute: 'ping' };
const firstSentence = (s, max = 60) => { const t = String(s || '').split(/(?<=[.!?])\s/)[0].replace(/\.$/, ''); return t.length <= max ? t : t.slice(0, max).replace(/\s+\S*$/, ''); };
@@@ -36,7 +37,7 @@ function baseTools() {
out.push({
id: rec.sub, family: rec.family, name: rec.name, tagline: firstSentence(rec.desc), description: rec.about || rec.desc,
keywords: String(rec.kw || '').split(',').map(k => k.trim()).filter(Boolean),
-- icon: (rec.family === 'net' && NET_ICONS[rec.sub]) || (rec.sub.includes('json') ? 'json' : FAMILY_ICON[rec.family]) || 'tools',
++ icon: (rec.family === 'net' && NET_ICONS[rec.sub]) || (rec.family === 'codes' && CODE_ICONS[rec.sub]) || (rec.sub.includes('json') ? 'json' : FAMILY_ICON[rec.family]) || 'tools',
...(net && net.status === 'unavailable' && { status: 'unavailable', unavailable: net.unavailable }),
});
}
@@@ -51,7 +52,6 @@ const PLANNED = [
{ id: 'tiktok', family: 'media', name: 'TikTok Downloader', tagline: 'Save TikTok videos without the watermark dance', icon: 'download' },
{ id: 'videoconvert', family: 'media', name: 'Video Converter', tagline: 'MP4, WebM, MOV and GIF, trimmed and resized', icon: 'video' },
{ id: 'screenrecord', family: 'media', name: 'Screen Recorder', tagline: 'Record a tab or a window straight from the browser', icon: 'video' },
-- { id: 'qr', family: 'dev', name: 'QR Code Generator', tagline: 'Links, Wi-Fi logins and contact cards as QR codes', icon: 'code' },
{ id: 'password', family: 'dev', name: 'Password Generator', tagline: 'Strong passwords and passphrases, made in your browser', icon: 'ssl' },
{ id: 'speedtest', family: 'net', name: 'Speed Test', tagline: 'Download, upload and latency to the OpenVibe network', icon: 'ping' },
{ id: 'ocr', family: 'img', name: 'Image to Text (OCR)', tagline: 'Pull the text out of screenshots and scans', icon: 'text' },
ddiff --git a/apps/gateway/server/seo/catalog.js b/apps/gateway/server/seo/catalog.js
iindex 9fce8e4..3712837 100644
---- a/apps/gateway/server/seo/catalog.js
++++ b/apps/gateway/server/seo/catalog.js
@@@ -394,9 +394,136 @@ const NET = {
sitemap: { name: 'Sitemap Validator', title: 'Sitemap.xml Validator — Online XML Sitemap Checker', desc: 'Check an XML sitemap: its structure, URL count, loc and lastmod values, and sitemap index files, so search engines can read it. Instant.', kw: 'sitemap validator, sitemap.xml checker, xml sitemap test, sitemap errors, seo sitemap check', about: 'Fetches a sitemap (gzipped too) and checks it against the sitemaps.org protocol: the root element and namespace, the URL count, that every loc is an absolute URL on the same site and that lastmod dates are in W3C format. A sitemap index is followed to its first child sitemaps.', bullets: ['Structure, namespace, loc and lastmod checks', 'URL count against the 50,000 entry limit', 'Follows a sitemap index to its first three sitemaps'], faq: [['How many URLs can one sitemap hold?', '50,000 URLs or 50 MB uncompressed. Beyond that, split it and reference the parts from a sitemap index.'], ['Does lastmod matter?', 'Yes, when it is accurate. Crawlers use it to prioritise re-crawling, but they learn to ignore it if every URL claims to have changed today.']] },
};
++// ── Barcode & QR tools (hub: barcodes.openvibe.tools, the family 'codes'; public/codes.html, all in the browser) ─────────
++const API_FAQ = ['Can I make codes from a script, a spreadsheet or an AI agent?', 'Yes, with no account. https://openvibe.tools/api/codes/qrcode.svg?text=Hello is the image itself (any symbology, .svg or .png; /api/codes lists them), so it works in an <img> tag or Markdown. POST /api/v1/tools/<tool>/run answers JSON, and the OpenAPI document is at https://openvibe.tools/api/v1/openapi.json.'];
++const PRIVATE = ['Is anything I type or scan uploaded?', 'No. Codes are drawn and read inside your browser (bwip-js and the ZXing reader, compiled to WebAssembly); nothing you type, upload or point the camera at reaches our servers.'];
++const CODES = {
++ codes: {
++ name: 'Barcode & QR Tools', title: 'Barcode & QR Code Tools — Generate, Scan and Check Every Kind of Code',
++ desc: 'Make QR codes, barcodes, Data Matrix and 100+ other symbologies, scan them with your camera or a picture, and check GTIN, ISBN and GS1 numbers. Free, in your browser.',
++ kw: 'barcode generator, qr code generator, barcode scanner, qr scanner, data matrix, gtin, gs1, ean-13, upc, code 128',
++ about: 'Everything for codes in one place: a QR studio with styles and logos, a generator for every barcode in common use (retail, logistics, postal, pharma and 2D), a scanner for your camera or pictures, and a checker that fixes GTIN, ISBN and SSCC check digits and reads GS1 labels. Each tool works out what you typed and picks the right code for it.',
++ bullets: ['Over 100 symbologies, from EAN-13 and Code 128 to PDF417, Aztec, MaxiCode and DotCode', 'Every code is test-scanned as you make it, so you know it reads', 'PNG at print resolution or vector SVG, sized in pixels or millimetres', 'Nothing leaves your browser'],
++ faq: [PRIVATE, ['Do the codes expire?', 'No. The codes are static: what you encode is in the pattern itself, with no redirect service in between, so they work for as long as the printout lasts.']],
++ },
++ qr: {
++ name: 'QR Code Generator', title: 'QR Code Generator — Free, No Sign-Up, With Logo, Colours and SVG',
++ desc: 'Make a QR code for a link, Wi-Fi, a contact card, an email, a text or an event. Rounded or dotted styles, colours, a logo, PNG or SVG, test-scanned before you download.',
++ kw: 'qr code generator, free qr code, qr code with logo, custom qr code, qr code svg, wifi qr code, vcard qr code, dynamic qr alternative',
++ about: 'Pick what the code should do (open a link, join a Wi-Fi network, save a contact, start an email or a text, add an event, show a place) and fill in the fields. Style it with rounded or dotted modules, your colours and a logo in the middle; the error correction rises on its own so the logo never breaks the code. Every change is test-scanned, and the preview tells you if a colour or a logo makes it hard to read.',
++ bullets: ['Nine content types, each written in the format phone cameras understand', 'Square, rounded, dot and fluid styles; eye shapes; colours, gradients and a logo', 'Automatic error correction and a live scan check', 'PNG up to 4000 px or vector SVG; print size in millimetres'],
++ faq: [['Will this QR code stop working?', 'No. It is a static QR code: the link or text is encoded directly, with no short-link service in between that could expire or be switched off.'], ['Can I put my logo in a QR code?', 'Yes. Add a picture and the generator raises the error correction to level H and clears the space behind it, then checks the result still scans.'], ['What size should I print it?', 'A good rule is one tenth of the scanning distance: about 2 cm across for a business card read at 20 cm, 30 cm for a poster read from 3 metres. The size box converts pixels to millimetres for you.'], PRIVATE],
++ },
++ barcode: {
++ name: 'Barcode Generator', title: 'Barcode Generator — EAN, UPC, Code 128, GS1 and 100+ Types, Free',
++ desc: 'Generate any barcode: EAN-13, UPC-A, Code 128, Code 39, ITF-14, GS1-128, PDF417, Data Matrix and more. Smart type detection, check digits fixed, PNG or SVG at print size.',
++ kw: 'barcode generator, free barcode generator, online barcode maker, code 128, ean-13, upc-a, code 39, itf-14, gs1-128, barcode svg',
++ about: 'Type what you want to encode and the generator suggests the right symbology: a product number goes in EAN-13 or UPC-A with its check digit verified, a shipping container in GS1-128, digits in Interleaved 2 of 5, anything longer in a 2D code. Or choose from more than a hundred symbologies yourself. Set the bar height, the text, colours, rotation and quiet zone, and size it exactly for print at the resolution of your printer.',
++ bullets: ['More than 100 symbologies: retail, industrial, GS1, postal, pharmaceutical and 2D', 'Detects GTIN, ISBN, ISSN, SSCC and UPC-E numbers and corrects their check digits', 'Width in millimetres at your printer\'s DPI, so bars land on whole dots', 'PNG, SVG or a copy to the clipboard'],
++ faq: [['Which barcode should I use?', 'For products sold in shops, EAN-13 (or UPC-A in North America) with a GTIN from GS1. For your own labels, inventory and tickets, Code 128 is the dense, universal choice. If you need more than about 40 characters, use a 2D code such as QR or Data Matrix.'], ['Can I make a barcode for a product I sell?', 'You can draw any number, but a retail barcode only means something if the GTIN is registered to you by GS1. The generator checks the check digit; it cannot check who owns the number.'], PRIVATE],
++ },
++ scan: {
++ name: 'Barcode & QR Scanner', title: 'QR Code & Barcode Scanner Online — Camera or Picture, Free',
++ desc: 'Scan QR codes, barcodes, Data Matrix, PDF417 and more with your camera or from a picture or screenshot. Reads several codes at once and explains what each one contains.',
++ kw: 'qr code scanner, barcode scanner online, scan qr code from image, qr reader, barcode reader, scan barcode with camera, data matrix reader',
++ about: 'Point your camera at a code, or drop, paste or upload a picture or screenshot. The scanner reads every code it finds (QR, Micro QR, Data Matrix, Aztec, PDF417, MaxiCode, EAN, UPC, Code 128, Code 39, ITF, Codabar, GS1 DataBar and more), outlines each one, and says what it holds: a Wi-Fi network with its password, a contact card, a link and where it goes, a product number with its check digit, or GS1 data split into fields.',
++ bullets: ['Camera scanning with torch and camera switching on phones', 'Pictures, screenshots and pasted images, several codes at once', 'Explains Wi-Fi, contacts, events, links, GTINs and GS1 data', 'Links are shown in full before you open them'],
++ faq: [['Is it safe to scan an unknown QR code?', 'The scanner never opens anything by itself. A link is shown with its full address first, and only links starting with http or https get an Open button.'], ['Why does my code not scan?', 'Try more light and hold the code flat, filling about half the frame. For a picture, crop closer to the code; very small or blurry codes may need a sharper photo.'], PRIVATE],
++ },
++ datamatrix: {
++ name: 'Data Matrix Generator', title: 'Data Matrix Code Generator — GS1 DataMatrix, Square or Rectangular',
++ desc: 'Make Data Matrix codes for parts marking, healthcare and GS1 labels. Square or rectangular, GS1 DataMatrix with Application Identifiers, PNG or SVG.',
++ kw: 'data matrix generator, datamatrix code, gs1 datamatrix, 2d barcode generator, data matrix ecc200, dpm code, udi barcode',
++ about: 'Data Matrix packs a lot into a small square: up to 2,335 characters, and still readable when part of it is damaged. It is the code on electronic parts, medical devices (UDI) and pharmaceutical packs. Type text or switch to GS1 mode and add Application Identifiers (GTIN, batch, expiry, serial); the generator checks each field before it draws the code.',
++ bullets: ['ECC 200 Data Matrix, square or rectangular', 'GS1 DataMatrix with checked Application Identifiers', 'Module size in millimetres for laser and inkjet marking', 'PNG or SVG'],
++ faq: [['What is the difference between Data Matrix and QR?', 'Both are 2D codes. Data Matrix is smaller for the same data and is the standard for industrial and healthcare marking; QR is what phone cameras open by default.'], PRIVATE],
++ },
++ gs1: {
++ name: 'GTIN Check Digit & GS1 Parser', title: 'GTIN Check Digit Calculator — EAN, UPC, ISBN, SSCC and GS1 Parser',
++ desc: 'Calculate and verify check digits for GTIN-8, UPC-A, EAN-13, GTIN-14, SSCC, GLN and ISBN. Convert ISBN-10 to ISBN-13 and UPC-E to UPC-A, and read GS1 element strings.',
++ kw: 'gtin check digit calculator, ean check digit, upc check digit, isbn converter, isbn-10 to isbn-13, sscc check digit, gs1 parser, gs1 application identifiers, upc-e to upc-a',
++ about: 'Type a product, book or shipping number and the checker names it (GTIN-8, UPC-A, EAN-13, GTIN-14, SSCC, ISBN, ISSN, UPC-E), says whether its check digit is right and what it should be. It converts between ISBN-10 and ISBN-13 and expands UPC-E. Paste a GS1 element string, bracketed or raw from a scanner, and it splits it into Application Identifiers with dates, weights and check digits verified.',
++ bullets: ['Check digits for every GS1 key, and ISBN-10\'s mod 11', 'ISBN-10 ↔ ISBN-13, UPC-E → UPC-A, GTIN-13 → GTIN-14', 'GS1 element strings split into labelled, validated fields', 'One click to the matching barcode'],
++ faq: [['How is a GTIN check digit calculated?', 'From the right, digits are multiplied alternately by 3 and 1 and added up; the check digit is what brings the total to the next multiple of 10.'], ['What is a GS1 Application Identifier?', 'A number in brackets that says what the data after it is: (01) a GTIN, (17) an expiry date, (10) a batch, (21) a serial number. Labels on cartons, pallets and medicines use them.'], PRIVATE],
++ },
++ wifiqr: {
++ name: 'Wi-Fi QR Code Generator', title: 'Wi-Fi QR Code Generator — Guests Join Without Typing the Password',
++ desc: 'Make a QR code that joins your Wi-Fi network: phone cameras on iPhone and Android connect when they scan it. WPA, WPA3, WEP or open; print it as a card.',
++ kw: 'wifi qr code, wifi qr code generator, qr code for wifi password, share wifi qr, guest wifi qr code',
++ about: 'Enter the network name and password and print the code for guests, a café table or a rental. Scanning it with the phone camera offers to join the network; nobody has to read out or type a long password. The password never leaves your browser.',
++ bullets: ['WPA/WPA2/WPA3, WEP, open and hidden networks', 'Special characters escaped the way phones expect', 'A printable card with the network name under the code', 'Made entirely in your browser'],
++ faq: [['Does the Wi-Fi QR code work on iPhone?', 'Yes. The iPhone camera (iOS 11 and later) and Android cameras both recognise Wi-Fi codes and offer to join.'], PRIVATE],
++ },
++ vcardqr: {
++ name: 'Contact QR Code (vCard)', title: 'vCard QR Code Generator — Contact Details as a QR Code',
++ desc: 'Turn your name, phone, email, company and website into a QR code that saves as a contact when scanned. For business cards, badges and email signatures.',
++ kw: 'vcard qr code, contact qr code, business card qr code, qr code contact card, vcard generator',
++ about: 'Fill in the details you want to share and the generator writes a vCard 3.0 that every phone can save in one tap. Keep it short (name, phone, email) for a small, easy-to-scan code on a business card, or add the company, title, address and website.',
++ bullets: ['vCard 3.0, read by iPhone and Android cameras', 'Size warning when the card grows too dense to print small', 'PNG or SVG for print', 'Made in your browser'],
++ faq: [['Why is my contact QR code so dense?', 'Every field adds characters. A name, phone and email make a small code; add an address and a long title and it grows. Leave out what people do not need.'], PRIVATE],
++ },
++ bulkbarcodes: {
++ name: 'Bulk Barcode & Label Generator', title: 'Bulk Barcode Generator — Hundreds of Barcodes or QR Codes From a List',
++ desc: 'Paste a list or a CSV and get a barcode or QR code for every line: a ZIP of PNG or SVG files, or a printable sheet of labels sized in millimetres.',
++ kw: 'bulk barcode generator, batch barcode generator, barcode labels, qr code bulk, barcode from csv, print barcode labels, sequential barcodes',
++ about: 'Paste one value per line, or a CSV with a value and a caption, or generate a numbered sequence. Choose the symbology and style once, then download every code as a ZIP, or print a sheet of labels with your label size, columns and gaps. Lines that cannot be encoded are listed with the reason.',
++ bullets: ['Values from a list, a CSV or a numbered sequence with a prefix', 'Any symbology the barcode generator supports', 'ZIP of PNG or SVG files, named after each value', 'Print-ready label sheets in millimetres'],
++ faq: [['How many codes can I make at once?', 'Up to 2,000 per batch. Everything is drawn in your browser, so large batches depend on your device; a few hundred take seconds.'], PRIVATE],
++ },
++ code128: {
++ name: 'Code 128 Barcode Generator', title: 'Code 128 Barcode Generator — Free, Online, PNG and SVG',
++ desc: 'Make Code 128 barcodes for labels, inventory, shipping and tickets. Full ASCII, automatic code set switching for the shortest barcode, bar height and text options.',
++ kw: 'code 128 generator, code 128 barcode, code128, barcode for labels, inventory barcode, shipping label barcode',
++ about: 'Code 128 encodes letters, digits and punctuation densely, and every scanner reads it. The generator switches between code sets A, B and C automatically for the shortest result; digits pack two to a symbol.',
++ bullets: ['All 128 ASCII characters', 'Automatic code set optimisation', 'Bar height, human-readable text and quiet zone', 'PNG or SVG at print size'],
++ faq: [['How long can a Code 128 barcode be?', 'There is no fixed limit, but past about 20 characters a 1D barcode gets wide; for more data a 2D code such as Data Matrix or QR is easier to scan.'], PRIVATE],
++ },
++ ean13: {
++ name: 'EAN-13 Barcode Generator', title: 'EAN-13 Barcode Generator — With Check Digit and Add-On',
++ desc: 'Make EAN-13 retail barcodes with the check digit calculated and verified. Optional EAN-2 or EAN-5 add-on for magazines and books. PNG or SVG at print size.',
++ kw: 'ean-13 generator, ean13 barcode, ean barcode generator, gtin-13 barcode, ean check digit, retail barcode',
++ about: 'Type twelve digits and the check digit is added, or thirteen and it is verified. EAN-13 is the retail barcode outside North America; print it at 80 to 200 percent of its nominal 37.29 × 25.93 mm.',
++ bullets: ['Check digit added or verified', 'EAN-2 and EAN-5 add-ons', 'Nominal size presets (80% to 200%)', 'PNG or SVG'],
++ faq: [['What size should an EAN-13 be?', 'The nominal size is 37.29 mm wide by 25.93 mm high; GS1 allows 80% to 200% of it. Smaller codes need a better printer.'], PRIVATE],
++ },
++ upc: {
++ name: 'UPC Barcode Generator', title: 'UPC-A Barcode Generator — UPC Barcodes With Check Digit',
++ desc: 'Make UPC-A and UPC-E barcodes for products sold in North America. Check digit calculated and verified, UPC-E expanded, add-ons, PNG or SVG.',
++ kw: 'upc generator, upc-a barcode, upc barcode generator, upc-e, upc check digit, gtin-12',
++ about: 'Type eleven digits and the check digit is added, or twelve and it is verified. UPC-E, the short form for small packs, is accepted too and shown with the UPC-A it stands for.',
++ bullets: ['UPC-A and UPC-E', 'Check digit added or verified', 'Add-ons for periodicals', 'PNG or SVG'],
++ faq: [['Is a UPC the same as an EAN?', 'A UPC-A is a GTIN-12; put a 0 in front and it is the same number as an EAN-13. Scanners worldwide read both.'], PRIVATE],
++ },
++ pdf417: {
++ name: 'PDF417 Barcode Generator', title: 'PDF417 Barcode Generator — Stacked 2D Codes for IDs and Tickets',
++ desc: 'Make PDF417 and MicroPDF417 codes, the stacked barcode on driving licences, boarding passes and shipping labels. Columns, error correction level, PNG or SVG.',
++ kw: 'pdf417 generator, pdf417 barcode, micropdf417, boarding pass barcode, driver license barcode, 2d barcode',
++ about: 'PDF417 stacks rows of codewords into a wide rectangle that a laser scanner can read line by line. Choose the number of columns and the error correction level; MicroPDF417 makes a smaller code for short data.',
++ bullets: ['PDF417 and MicroPDF417', 'Columns and error correction level', 'Row height control', 'PNG or SVG'],
++ faq: [PRIVATE],
++ },
++ aztec: {
++ name: 'Aztec Code Generator', title: 'Aztec Code Generator — Compact 2D Codes for Tickets',
++ desc: 'Make Aztec codes, the compact 2D code on train and plane tickets. No quiet zone needed, adjustable error correction, PNG or SVG.',
++ kw: 'aztec code generator, aztec barcode, ticket barcode, 2d code generator, compact aztec',
++ about: 'An Aztec code grows from a bullseye in its centre and needs no blank margin around it, which is why rail and airline tickets use it. Set the error correction percentage and the generator picks the smallest size that fits.',
++ bullets: ['Compact and full-range Aztec', 'Error correction from 5% to 95%', 'No quiet zone required', 'PNG or SVG'],
++ faq: [PRIVATE],
++ },
++ isbn: {
++ name: 'ISBN Barcode Generator', title: 'ISBN Barcode Generator — Book Barcodes With Price Add-On',
++ desc: 'Make the barcode for a book\'s back cover from its ISBN-10 or ISBN-13, with the ISBN printed above and an optional EAN-5 price add-on.',
++ kw: 'isbn barcode generator, book barcode, isbn to barcode, isbn-13 barcode, bookland ean, price add-on',
++ about: 'Type the ISBN, with or without hyphens; an ISBN-10 is converted to its ISBN-13. The barcode is an EAN-13 with the ISBN printed above it, and the five-digit add-on can carry the price (for example 51995 for US $19.95).',
++ bullets: ['ISBN-10 or ISBN-13 in, check digit verified', 'ISBN text above the bars', 'EAN-5 price add-on', 'Print-ready PNG or SVG'],
++ faq: [['What goes in the price add-on?', 'The first digit is the currency (5 for US dollars, 6 for Canadian dollars) and the other four the price: 51995 is $19.95. Use 90000 when there is no price.'], PRIVATE],
++ },
++};
++
const CATALOG = new Map();
for (const [sub, rec] of Object.entries(DEV)) CATALOG.set(sub, { ...rec, sub, family: 'dev', hub: sub === 'dev' });
for (const [sub, rec] of Object.entries(NET)) CATALOG.set(sub, { ...rec, sub, family: 'net', hub: sub === 'net' });
++for (const [sub, rec] of Object.entries(CODES)) CATALOG.set(sub, { ...rec, faq: sub === 'scan' ? rec.faq : [...rec.faq, API_FAQ], sub, family: 'codes', hub: sub === 'codes' });
/** SEO record for a subdomain, or null when it is not a catalogued tool. */
function seoFor(sub) { return CATALOG.get(sub) || null; }
@@@ -417,4 +544,4 @@ function relatedTo(sub, limit = 6) {
return scored.slice(0, limit);
}
--module.exports = { CATALOG, DEV, NET, seoFor, relatedTo, BRAND };
++module.exports = { CATALOG, DEV, NET, CODES, seoFor, relatedTo, BRAND };
ddiff --git a/apps/gateway/server/seo/render.js b/apps/gateway/server/seo/render.js
iindex 1f07e67..0dc309a 100644
---- a/apps/gateway/server/seo/render.js
++++ b/apps/gateway/server/seo/render.js
@@@ -17,6 +17,10 @@
const fs = require('fs');
const path = require('path');
const { seoFor, relatedTo, BRAND } = require('./catalog');
++const { FAMILY_MAP } = require('../registry/families');
++
++// A family's breadcrumb name and hub page (net.openvibe.tools, barcodes.openvibe.tools…).
++const familyOf = (rec) => { const f = FAMILY_MAP.get(rec.family) || {}; return { name: f.name || 'Tools', url: f.hub ? `https://${f.hub}.openvibe.tools` : SITE, plural: String(f.name || 'tools').toLowerCase() }; };
const social = require('openvibe-shared/social');
const SITE = 'https://openvibe.tools';
@@@ -42,7 +46,7 @@ function jsonLd(rec, url, related) {
'@context': 'https://schema.org', '@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: BRAND, item: SITE },
-- { '@type': 'ListItem', position: 2, name: rec.family === 'net' ? 'Network Tools' : 'Developer Tools', item: `https://${rec.family}.openvibe.tools` },
++ { '@type': 'ListItem', position: 2, name: familyOf(rec).name, item: familyOf(rec).url },
{ '@type': 'ListItem', position: 3, name: rec.name, item: url },
],
};
@@@ -65,8 +69,7 @@ function jsonLd(rec, url, related) {
/** The visible content block appended under the tool: real copy, real internal links. */
function contentBlock(rec, related) {
-- const hubUrl = `https://${rec.family}.openvibe.tools`;
-- const hubName = rec.family === 'net' ? 'network tools' : 'developer tools';
++ const { url: hubUrl, plural: hubName } = familyOf(rec);
return `
<section class="seo-about" id="about-this-tool">
<div class="seo-about-inner">
ddiff --git a/apps/gateway/test/codes-pages.test.js b/apps/gateway/test/codes-pages.test.js
nnew file mode 100644
iindex 0000000..fc596bf
---- /dev/null
++++ b/apps/gateway/test/codes-pages.test.js
@@@ -0,0 +1,86 @@
++'use strict';
++// The Barcode & QR tools end to end, without a browser:
++// • every catalogue code tool has a view in app.js, its own <title>, description and canonical from server/seo, and
++// its family breadcrumb (Barcode & QR Tools)
++// • codes.html loads the vendor files at the URLs server/codes/vendor.js serves (the pinned versions)
++// • GET /api/codes/<symbology>.svg|png draws, caches for a year, refuses what cannot be drawn, and lists symbologies
++// • what bwip-js draws reads back with the ZXing reader the scanner uses (zxing-wasm, from this app's node_modules)
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++
++const PUB = path.join(__dirname, '..', 'public');
++const html = fs.readFileSync(path.join(PUB, 'codes.html'), 'utf8');
++const app = fs.readFileSync(path.join(PUB, 'js', 'codes', 'app.js'), 'utf8');
++const { CATALOG } = require('../server/seo/catalog');
++const { renderTool } = require('../server/seo/render');
++const vendor = require('../server/codes/vendor').files();
++const bwipjs = require('bwip-js');
++
++(async () => {
++ // ── Pages ──
++ const ids = [...CATALOG.values()].filter((r) => r.family === 'codes').map((r) => r.sub);
++ assert.strictEqual(ids.length, 15, 'the hub and 14 tools');
++ for (const id of ids) {
++ assert.match(app, new RegExp(`^\\s{8}${id}: \\{ view: '`, 'm'), `${id} has a view in app.js`);
++ const page = renderTool(path.join(PUB, 'codes.html'), id, null);
++ const rec = CATALOG.get(id);
++ assert.ok(page.includes(`<title>${rec.title.replace(/&/g, '&amp;')} | OpenVibe</title>`), `${id}: its own title`);
++ assert.ok(page.includes(`<meta name="ov-tool" content="${id}">`), `${id}: the page knows which tool it is`);
++ assert.ok(page.includes('"name":"Barcode & QR Tools","item":"https://barcodes.openvibe.tools"'), `${id}: breadcrumb to the family hub`);
++ assert.ok(page.includes('id="about-this-tool"'), `${id}: the crawlable about block`);
++ }
++ assert.ok(renderTool(path.join(PUB, 'codes.html'), 'qr', 'qr-code-generator.openvibe.tools').includes('<link rel="canonical" href="https://qr-code-generator.openvibe.tools">'), 'canonical from the registry');
++ // Every generator page tells bots and scripts about the image API.
++ assert.ok(CATALOG.get('qr').faq.some(([q, a]) => /script|AI agent/.test(q) && a.includes('/api/codes/qrcode.svg')), 'the API answer in the FAQ');
++
++ // ── Vendor files: the page asks for exactly what the server serves ──
++ for (const url of [vendor.urls.bwip, vendor.urls.zxing]) assert.ok(html.includes(`src="${url}"`) || html.includes(`data-zxing="${url}"`), `codes.html loads ${url}`);
++ assert.ok(html.includes(`data-zxing-wasm="${vendor.urls.zxingWasm}"`), 'the reader finds its WebAssembly on this host');
++ for (const f of Object.values(vendor.map)) assert.ok(fs.statSync(f.file).size > 10000, `${f.file} exists`);
++
++ // ── The image API ──
++ const srv = http.createServer(express().use(require('../server/codes/image').router()));
++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${srv.address().port}`;
++ try {
++ const svg = await fetch(`${base}/api/codes/qr.svg?text=hello`);
++ assert.strictEqual(svg.status, 200);
++ assert.match(svg.headers.get('content-type'), /^image\/svg\+xml/);
++ assert.match(svg.headers.get('cache-control'), /max-age=31536000, immutable/);
++ assert.strictEqual(svg.headers.get('access-control-allow-origin'), '*');
++ assert.match(svg.headers.get('content-security-policy'), /default-src 'none'/);
++ assert.match(await svg.text(), /^<svg /);
++ const png = await fetch(`${base}/api/codes/ean13.png?text=400638133393`);
++ assert.strictEqual(png.status, 200);
++ const bytes = Buffer.from(await png.arrayBuffer());
++ assert.strictEqual(bytes.subarray(1, 4).toString(), 'PNG');
++ assert.strictEqual((await fetch(`${base}/api/codes/wifi.svg?ssid=Cafe&password=x`)).status, 200);
++ assert.strictEqual((await fetch(`${base}/api/codes/auto.svg?text=0306406152`)).status, 200);
++ const bad = await fetch(`${base}/api/codes/ean13.svg?text=letters`);
++ assert.strictEqual(bad.status, 422);
++ assert.match(await bad.text(), /EAN-13/);
++ assert.strictEqual((await fetch(`${base}/api/codes/not-a-code.svg?text=a`)).status, 422);
++ assert.strictEqual((await fetch(`${base}/api/codes/qrcode.svg`)).status, 422, 'text is required');
++ const list = await (await fetch(`${base}/api/codes`)).json();
++ assert.ok(list.symbologies.length > 100 && list.symbologies.some((s) => s.id === 'datamatrix'));
++ } finally { srv.close(); }
++
++ // ── Round trip: drawn by bwip-js, read by ZXing ──
++ const Z = require('zxing-wasm/reader');
++ Z.prepareZXingModule({ overrides: { wasmBinary: fs.readFileSync(vendor.map[vendor.urls.zxingWasm].file) } });
++ for (const [bcid, text, want] of [
++ ['qrcode', 'https://openvibe.tools', 'https://openvibe.tools'], ['ean13', '4006381333931', '4006381333931'], ['upca', '036000291452', '036000291452'],
++ ['code128', 'OV-2026-0042', 'OV-2026-0042'], ['datamatrix', 'Data Matrix', 'Data Matrix'], ['gs1datamatrix', '(01)09506000134352(10)ABC', '(01)09506000134352(10)ABC'],
++ ['pdf417', 'PDF417 here', 'PDF417 here'], ['azteccode', 'TICKET 4471', 'TICKET 4471'], ['itf14', '09506000134352', '09506000134352'], ['code39', 'ABC-123', 'ABC-123'],
++ ]) {
++ const buf = await bwipjs.toBuffer({ bcid, text, scale: 3, paddingwidth: 10, paddingheight: 10, backgroundcolor: 'ffffff', includetext: true });
++ const found = await Z.readBarcodes(new Uint8Array(buf), { tryHarder: true });
++ const norm = (s) => String(s).replace(/^0+(?=\d{12})/, '');
++ assert.ok(found.some((f) => f.text === want || norm(f.text) === norm(want)), `${bcid}: read back ${JSON.stringify(found.map((f) => f.text))}`);
++ }
++
++ console.log(`codes pages: ${ids.length} pages, image API and 10 round trips: all checks passed`);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/apps/gateway/test/codes-smart.test.js b/apps/gateway/test/codes-smart.test.js
nnew file mode 100644
iindex 0000000..a1c7858
---- /dev/null
++++ b/apps/gateway/test/codes-smart.test.js
@@@ -0,0 +1,147 @@
++'use strict';
++// The Barcode & QR tools' logic (public/js/codes/smart.js), which runs in the browser and needs no DOM: check digits,
++// what a number is, GS1 element strings, QR payloads and how a scanned text is described, and the symbology it
++// recommends. Every barcode it recommends is then drawn by bwip-js, so a recommendation is never one bwip-js refuses.
++const assert = require('assert');
++const bwipjs = require('bwip-js');
++const S = require('../public/js/codes/smart.js');
++
++let n = 0;
++const t = (name, fn) => { fn(); n++; console.log(`ok - ${name}`); };
++
++t('GS1 check digits', () => {
++ assert.strictEqual(S.gs1CheckDigit('400638133393'), '1'); // EAN-13 4006381333931
++ assert.strictEqual(S.gs1CheckDigit('03600029145'), '2'); // UPC-A 036000291452
++ assert.strictEqual(S.gs1CheckDigit('9638507'), '4'); // EAN-8 96385074
++ assert.strictEqual(S.gs1CheckDigit('10614141123456789'), '7'); // SSCC 106141411234567897
++ assert.ok(S.gs1Valid('4006381333931'));
++ assert.ok(!S.gs1Valid('4006381333932'));
++});
++
++t('ISBN conversions', () => {
++ assert.strictEqual(S.isbn10Check('030640615'), '2');
++ assert.strictEqual(S.isbn10Check('080442957'), 'X');
++ assert.strictEqual(S.isbn10To13('0306406152'), '9780306406157');
++ assert.strictEqual(S.isbn13To10('9780306406157'), '0306406152');
++ assert.strictEqual(S.isbn13To10('9791234567896'), null); // 979 has no ISBN-10
++});
++
++t('UPC-E expands to its UPC-A', () => {
++ assert.strictEqual(S.upceToUpca('01234565'), '012345000065');
++ assert.strictEqual(S.upceToUpca('04252614'), '042100005264');
++ assert.strictEqual(S.upceToUpca('21234565'), null);
++});
++
++t('identifyNumber names every GS1 form', () => {
++ const k = (x, o) => (S.identifyNumber(x, o) || {}).kind;
++ assert.strictEqual(k('4006381333931'), 'ean13');
++ assert.strictEqual(k('036000291452'), 'upca');
++ assert.strictEqual(k('96385074'), 'gtin8');
++ assert.strictEqual(k('04252614'), 'upce'); // not a valid EAN-8, a valid UPC-E
++ assert.strictEqual(k('09506000134352'), 'gtin14');
++ assert.strictEqual(k('106141411234567897'), 'sscc');
++ assert.strictEqual(k('978-0-306-40615-7'), 'isbn13');
++ assert.strictEqual(k('ISBN 0-306-40615-2'), 'isbn10');
++ assert.strictEqual(k('9770317847001'), 'issn');
++ assert.strictEqual(k('hello'), undefined);
++ assert.strictEqual(k('5551234567'), undefined); // ten digits, not a valid ISBN: a number
++ assert.strictEqual(k('0360002914'), undefined);
++ const bad = S.identifyNumber('4006381333932');
++ assert.strictEqual(bad.valid, false);
++ assert.strictEqual(bad.expected, '1');
++ assert.strictEqual(bad.fixed, '4006381333931');
++ const isbn = S.identifyNumber('0306406152');
++ assert.strictEqual(isbn.encode, '9780306406157');
++ assert.strictEqual(S.identifyNumber('978-0-306-40615-7').symbology, 'isbn'); // hyphens as printed: the ISBN symbol
++ assert.strictEqual(S.identifyNumber('9780306406157').symbology, 'ean13');
++ assert.strictEqual(S.identifyNumber('106141411234567897').encode, '(00)106141411234567897');
++ assert.strictEqual(S.identifyNumber('03600029145'), null); // 11 digits: only with complete
++ const done = S.identifyNumber('03600029145', { complete: true });
++ assert.deepStrictEqual([done.kind, done.missing, done.fixed], ['upca', true, '036000291452']);
++});
++
++t('GS1 element strings, bracketed and raw', () => {
++ const g = S.parseGs1('(01)09506000134352(17)251231(10)ABC123');
++ assert.ok(g.ok);
++ assert.deepStrictEqual(g.parts.map((p) => p.ai), ['01', '17', '10']);
++ assert.strictEqual(g.parts[1].display, '2025-12-31');
++ const raw = S.parseGs1('0109506000134352172512311' + '0ABC123\x1d21XYZ');
++ assert.ok(raw.ok, JSON.stringify(raw));
++ assert.deepStrictEqual(raw.parts.map((p) => [p.ai, p.value]), [['01', '09506000134352'], ['17', '251231'], ['10', 'ABC123'], ['21', 'XYZ']]);
++ const sym = S.parseGs1(']d201095060001343521725123110ABC|21XYZ');
++ assert.ok(sym.ok);
++ assert.strictEqual(sym.parts.length, 4);
++ const wrong = S.parseGs1('(01)09506000134353(17)251331');
++ assert.ok(!wrong.ok);
++ assert.match(wrong.parts[0].error, /check digit should be 2/);
++ assert.match(wrong.parts[1].error, /not a date/);
++ assert.strictEqual(S.parseGs1('(3103)001250').parts[0].display, '1.250');
++ assert.strictEqual(S.gs1Text(g.parts), '(01)09506000134352(17)251231(10)ABC123');
++ assert.ok(!S.parseGs1('hello').ok);
++});
++
++t('QR payloads', () => {
++ assert.strictEqual(S.qrPayload('url', { url: 'openvibe.tools' }), 'https://openvibe.tools');
++ assert.strictEqual(S.qrPayload('url', { url: 'mailto:[email protected]' }), 'mailto:[email protected]');
++ assert.strictEqual(S.qrPayload('wifi', { ssid: 'Cafe;Net', password: 'p:w"d', security: 'WPA' }), 'WIFI:T:WPA;S:Cafe\\;Net;P:p\\:w\\"d;;');
++ assert.strictEqual(S.qrPayload('wifi', { ssid: 'Open', security: 'nopass', hidden: true }), 'WIFI:T:nopass;S:Open;H:true;;');
++ const card = S.qrPayload('contact', { first: 'Ada', last: 'Lovelace', org: 'Engines, Ltd', phone: '+44 1', email: '[email protected]' });
++ assert.match(card, /^BEGIN:VCARD\nVERSION:3\.0\nN:Lovelace;Ada;;;\nFN:Ada Lovelace\nORG:Engines\\, Ltd\n/);
++ assert.match(card, /END:VCARD$/);
++ assert.strictEqual(S.qrPayload('email', { to: '[email protected]', subject: 'Hi there' }), 'mailto:[email protected]?subject=Hi%20there');
++ assert.strictEqual(S.qrPayload('sms', { phone: '+1 (555) 010-9999', message: 'yo' }), 'SMSTO:+15550109999:yo');
++ assert.strictEqual(S.qrPayload('geo', { lat: 51.5, lng: -0.12, label: 'Here' }), 'geo:51.5,-0.12?q=Here');
++ const ev = S.qrPayload('event', { title: 'Launch', start: '2026-11-01T18:00:00Z', end: '2026-11-01T19:00:00Z' });
++ assert.match(ev, /DTSTART:20261101T180000Z\nDTEND:20261101T190000Z/);
++ assert.strictEqual(S.qrPayload('text', { text: 'plain' }), 'plain');
++});
++
++t('describePayload reads what a scan found', () => {
++ const w = S.describePayload('WIFI:T:WPA;S:Cafe\\;Net;P:secret;;');
++ assert.strictEqual(w.type, 'wifi');
++ assert.deepStrictEqual(w.fields[0], ['Network', 'Cafe;Net']);
++ assert.strictEqual(S.describePayload('https://openvibe.tools/x').fields[0][1], 'openvibe.tools');
++ assert.strictEqual(S.describePayload('BEGIN:VCARD\nFN:Ada Lovelace\nTEL:+44\nEND:VCARD').fields[0][1], 'Ada Lovelace');
++ assert.strictEqual(S.describePayload('4006381333931', 'EAN-13').type, 'product');
++ assert.strictEqual(S.describePayload('96385074', 'Code128').type, 'text'); // digits in a Code 128: not a product
++ assert.strictEqual(S.describePayload('9780306406157').title, 'Book (ISBN)');
++ assert.strictEqual(S.describePayload('(01)09506000134352(10)A1').type, 'gs1');
++ assert.match(S.describePayload('geo:51.5,-0.12').href, /^https:\/\/www\.openstreetmap\.org\//);
++ assert.strictEqual(S.describePayload('javascript:alert(1)').type, 'text'); // never a link
++ assert.strictEqual(S.describePayload('javascript:alert(1)').href, undefined);
++});
++
++t('recommend picks a symbology that fits', () => {
++ const r = (x) => S.recommend(x).symbology;
++ assert.strictEqual(r('4006381333931'), 'ean13');
++ assert.strictEqual(r('036000291452'), 'upca');
++ assert.strictEqual(r('09506000134352'), 'itf14');
++ assert.strictEqual(r('0306406152'), 'ean13');
++ assert.strictEqual(r('(01)09506000134352(10)ABC'), 'gs1-128');
++ assert.strictEqual(r('https://openvibe.tools'), 'qrcode');
++ assert.strictEqual(r('héllo'), 'qrcode');
++ assert.strictEqual(r('ABC-123'), 'code39');
++ assert.strictEqual(r('Order #42'), 'code128');
++ assert.strictEqual(r('12345'), 'code128');
++ assert.strictEqual(r('123456'), 'interleaved2of5');
++ const typo = S.recommend('4006381333932');
++ assert.strictEqual(typo.fixed, '4006381333931');
++ assert.match(typo.warnings[0], /should be 1/);
++ assert.strictEqual(S.recommend('0306406152').encode, '9780306406157');
++});
++
++t('every recommendation is a code bwip-js draws', () => {
++ for (const x of ['4006381333931', '036000291452', '96385074', '04252614', '09506000134352', '106141411234567897', '978-0-306-40615-7', '0306406152', '9770317847001',
++ '(01)09506000134352(10)ABC', 'https://openvibe.tools', 'héllo ☃', 'ABC-123', 'Order #42', '12345', '123456']) {
++ const rec = S.recommend(x);
++ const text = rec.encode || rec.fixed || x;
++ assert.doesNotThrow(() => bwipjs.toSVG({ bcid: rec.symbology, text }), `${x} → ${rec.symbology}`);
++ }
++});
++
++t('contrast', () => {
++ assert.ok(Math.abs(S.contrast('#000000', '#ffffff') - 21) < 0.01);
++ assert.ok(S.contrast('#777777', '#888888') < 1.5);
++});
++
++console.log(`codes-smart: ${n} tests passed`);
ddiff --git a/apps/gateway/test/descriptors.test.js b/apps/gateway/test/descriptors.test.js
iindex 4bc6d8a..a207887 100644
---- a/apps/gateway/test/descriptors.test.js
++++ b/apps/gateway/test/descriptors.test.js
@@@ -192,7 +192,7 @@ for (const m of ['jsonfmt', 'md', 'codediff', 'slugify', 'entities']) { assert.o
// ── Counts ──
const tally = (f) => snap.tools.reduce((o, d) => { const k = f(d); o[k] = (o[k] || 0) + 1; return o; }, {});
const fam = tally(d => d.family);
--assert.deepStrictEqual(fam, { dev: 34, net: 38, img: 15, audio: 36, docs: 10, text: 33, media: 1, places: 2, pastes: 1 });
++assert.deepStrictEqual(fam, { dev: 34, net: 38, codes: 14, img: 15, audio: 36, docs: 10, text: 33, media: 1, places: 2, pastes: 1 });
fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true });
console.log(`descriptors: ${snap.tools.length} tools valid (${Object.entries(tally(d => `${d.execution}${d.api ? '+api' : ''}`)).map(([k, n]) => `${k} ${n}`).join(', ')}); ${jobs} job tools pass their app's validation; ${syncApi} sync tools run through served routes`);
ddiff --git a/apps/gateway/test/registry-consistency.test.js b/apps/gateway/test/registry-consistency.test.js
iindex 3ba0ff6..4266d44 100644
---- a/apps/gateway/test/registry-consistency.test.js
++++ b/apps/gateway/test/registry-consistency.test.js
@@@ -5,6 +5,7 @@
// (and a default format that operation can write)
// net an endpoint in net/config.js that the router serves and net.html calls, or status 'unavailable'
// dev an entry and a processor in dev.html
++// codes an entry (a view) in public/js/codes/app.js
// text its host in the text app's host map, pointing at a page that exists
// media / places the app knows the host; pastes live on OpenVibe.Community
// merge.openvibe.tools pointed at an audio operation that did not exist for months; this test is what
@@@ -116,6 +117,12 @@ for (const t of cat.tools) {
if (!dev.processors.has(t.id)) problems.push(`dev/${t.id}: dev.html has no processor P.${t.id}`);
break;
}
++ case 'codes': {
++ // Barcode & QR tools: one page (codes.html), whose app.js names a view for every tool.
++ const app = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'codes', 'app.js'), 'utf8');
++ if (!new RegExp(`^\\s{8}${t.id}: \\{ view: '`, 'm').test(app)) problems.push(`codes/${t.id}: public/js/codes/app.js has no TOOLS entry`);
++ break;
++ }
case 'text': {
const file = TEXT_HOSTS[`${t.id}.openvibe.tools`];
if (!file) problems.push(`text/${t.id}: ${t.id}.openvibe.tools is not in apps/text/server/hosts.js`);
@@@ -138,7 +145,7 @@ for (const t of cat.tools) {
}
}
assert.deepStrictEqual(problems, [], `catalogue tools that resolve to nothing:\n ${problems.join('\n ')}`);
--for (const f of ['img', 'audio', 'docs', 'net', 'dev', 'text', 'media', 'places', 'pastes']) assert.ok(counted[f] > 0, `the catalogue has ${f} tools (checked)`);
++for (const f of ['img', 'audio', 'docs', 'net', 'dev', 'codes', 'text', 'media', 'places', 'pastes']) assert.ok(counted[f] > 0, `the catalogue has ${f} tools (checked)`);
// Planned placeholders are never listed as tools; mirrors are not tools either.
assert.ok(cat.planned.every(p => !cat.tools.some(t => t.id === p.id)));
ddiff --git a/apps/gateway/test/tools-api.test.js b/apps/gateway/test/tools-api.test.js
iindex ec4ade1..844daa4 100644
---- a/apps/gateway/test/tools-api.test.js
++++ b/apps/gateway/test/tools-api.test.js
@@@ -66,10 +66,10 @@ const get = async (url, headers = {}, method = 'GET') => {
const list = r.body;
const lc = contracts.tools.checkList(list);
assert.ok(lc.valid, JSON.stringify(lc.errors.slice(0, 5)));
-- assert.strictEqual(list.count, 170);
++ assert.strictEqual(list.count, 184);
assert.ok(list.tools.every(d => !d.input || d.input.$ref), 'inputs are $refs in the list');
assert.ok(!Number.isNaN(Date.parse(list.updated_at)));
-- assert.deepStrictEqual(list.families.map(f => f.id).sort(), ['audio', 'dev', 'docs', 'img', 'media', 'net', 'pastes', 'places', 'text']);
++ assert.deepStrictEqual(list.families.map(f => f.id).sort(), ['audio', 'codes', 'dev', 'docs', 'img', 'media', 'net', 'pastes', 'places', 'text']);
// ETag → 304 (strong, weak, in a list); HEAD; preflight.
assert.strictEqual((await get(`${G}/api/v1/tools`, { 'If-None-Match': etag })).status, 304);
@@@ -136,7 +136,7 @@ const get = async (url, headers = {}, method = 'GET') => {
assert.strictEqual((await get(`${G}/api/v1/tools/yt/schema`)).body.$defs.input, false, 'no API, no input');
// ── Not tools ──
-- for (const [id, why] of [['no-such-tool', /No tool is called/], ['qr', /planned/], ['jsonfmt', /mirror of json|second build of json/]]) {
++ for (const [id, why] of [['no-such-tool', /No tool is called/], ['password', /planned/], ['jsonfmt', /mirror of json|second build of json/]]) {
r = await get(`${G}/api/v1/tools/${id}`);
assert.strictEqual(r.status, 404, id);
assert.match(r.h.get('content-type'), /^application\/problem\+json/);
@@@ -150,7 +150,7 @@ const get = async (url, headers = {}, method = 'GET') => {
// ── /api/catalog.json is unchanged (Network reads it) ──
const catalog = (await get(`${G}/api/catalog.json`)).body;
-- assert.strictEqual(catalog.tools.length, 170);
++ assert.strictEqual(catalog.tools.length, 184);
assert.ok(catalog.tools.every(t => ['available', 'unavailable'].includes(t.status) && t.hosts && t.hosts.canonical));
assert.ok(!('api' in catalog.tools[0]) && !('execution' in catalog.tools[0]), 'no descriptor fields leak into the catalogue');