diff --git a/CHANGELOG.md b/CHANGELOG.md
index aef8c7e..a4079c2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,18 @@ what to check.
resources; share links carry the fields only in the URL fragment. SVG and
PNG downloads are byte-identical to the CLI, enforced by
`scripts/web-smoke.mjs` in CI. See README "Web".
+- Web form: name, IBAN, amount and remittance text up front; reference,
+ BIC, purpose code and the note to the payer fold under "More fields"
+ (IBAN-only is the SEPA norm). Typed letters never land in the amount
+ field; pasted ones still reach the validator. A share link from a newer
+ format version is refused instead of guessed. `web-smoke.mjs` now also
+ greps the page sources for storage APIs, address-bar writes and external
+ resources.
+
+### Changed
+
+- The PNG scale (8 px per module) is one constant, `render.DefaultPNGScale`,
+ shared by `--png` and the web download; behaviour is unchanged.
## [0.2.0] - 2026-09-04
diff --git a/README.md b/README.md
index e40b136..b067405 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,8 @@
Generate EPC QR codes ("GiroCode") for SEPA credit transfers — a single
static binary with zero runtime dependencies, including its own QR encoder
-core.
+core. The same code runs in the browser at
+ (see [Web](#web)): no server, no storage.
An EPC QR code ([EPC069-12](https://www.europeanpaymentscouncil.eu/document-library/guidance-documents/quick-response-code-guidelines-enable-data-capture-initiation))
encodes recipient, IBAN, amount, and remittance text so a banking app can
@@ -10,7 +11,9 @@ pre-fill a SEPA transfer from a single scan.
## Install
-Requires Go 1.26 or newer:
+No install: open .
+
+For the command line, requires Go 1.26 or newer:
```sh
go install github.com/bmmmm/epcii@latest
@@ -71,6 +74,12 @@ The same generator runs in the browser at
and the SVG and PNG you download are byte-identical to the CLI's output —
`scripts/web-smoke.mjs` proves that in CI on every change.
+The form shows name, IBAN, amount and remittance text; reference, BIC,
+purpose code and the note to the payer are folded under "More fields", since
+IBAN-only is the SEPA norm (a BIC is only needed for accounts outside the
+EEA). Every field of the flag table above is available and travels in share
+links. The page is in English and German; the switch keeps nothing.
+
What the page does not do:
- **No server, no storage.** GitHub Pages serves a handful of static files;
@@ -115,8 +124,15 @@ after `go build -o epcii .`.
go build -o epcii . # build
go test ./... # unit, golden, and round-trip tests
go vet ./... && gofmt -l .
+scripts/build-web.sh && node scripts/web-smoke.mjs # web build + CLI-identity gate
```
+The browser build lives in `cmd/epcii-wasm` (a `js && wasm` entry point
+over `internal/webapi`, the CLI pipeline as one function) and `web/`
+(static page, no framework). `scripts/build-web.sh` assembles `web/dist/`;
+`pages.yml` deploys it to GitHub Pages on every push to `main`, after the
+same smoke gate CI runs on pull requests.
+
`--version` reports whatever the build info carries: the module version for
`go install`, a VCS pseudo-version for a plain `go build` in a checkout.
Release builds stamp it explicitly:
@@ -132,7 +148,10 @@ segno's reference implementation (regenerate via
reconstruction test that rebuilds the module matrix from the emitted path,
and matrix fingerprints for every payload length generated from the
upstream piglig/go-qr encoder (`go run -C scripts/qrfixtures .`, a separate
-module so upstream never enters `go.mod`).
+module so upstream never enters `go.mod`). The web build adds a fifth:
+`scripts/web-smoke.mjs` runs the wasm through Go's `wasm_exec.js` and
+compares its SVG and PNG with the CLI byte for byte, then greps the page
+sources for storage APIs, address-bar writes and external resources.
## Contributing
diff --git a/internal/render/png.go b/internal/render/png.go
index 12f94f4..feb4fa9 100644
--- a/internal/render/png.go
+++ b/internal/render/png.go
@@ -7,6 +7,11 @@ import (
"io"
)
+// DefaultPNGScale is the pixels-per-module scale the CLI (--png) and the web
+// version share, so both produce the same file: 69 modules + quiet zone
+// => 616 px at most.
+const DefaultPNGScale = 8
+
// PNG renders the module matrix as a PNG with the given pixels-per-module
// scale, including the quiet zone.
func PNG(w io.Writer, modules [][]bool, scale int) error {
diff --git a/internal/webapi/webapi.go b/internal/webapi/webapi.go
index 1b9ca8d..40a1ea2 100644
--- a/internal/webapi/webapi.go
+++ b/internal/webapi/webapi.go
@@ -12,10 +12,6 @@ import (
"github.com/bmmmm/epcii/internal/render"
)
-// pngScale is pixels per module for the PNG download. Keep equal to
-// pngScale in main.go so the web PNG matches `epcii --png` byte for byte.
-const pngScale = 8
-
// Input mirrors the CLI flags (see the flag table in main.go).
type Input struct {
Name, IBAN, BIC, Amount, Purpose, Ref, Text, Info string
@@ -28,7 +24,7 @@ type Output struct {
Version int // QR symbol version
Size int // modules per side, without quiet zone
SVG string // identical to the CLI's stdout
- PNG []byte // identical to the CLI's --png file
+ PNG []byte // identical to the CLI's --png file (render.DefaultPNGScale)
Error string // validation/encoding error text, without the "epcii:" prefix
}
@@ -48,7 +44,7 @@ func Generate(in Input) Output {
}
matrix := code.Matrix()
var png bytes.Buffer
- if err := render.PNG(&png, matrix, pngScale); err != nil {
+ if err := render.PNG(&png, matrix, render.DefaultPNGScale); err != nil {
return Output{Error: err.Error()}
}
return Output{
diff --git a/internal/webapi/webapi_test.go b/internal/webapi/webapi_test.go
index a17deb3..af6dd61 100644
--- a/internal/webapi/webapi_test.go
+++ b/internal/webapi/webapi_test.go
@@ -33,7 +33,7 @@ func TestGenerateMatchesCLIPipeline(t *testing.T) {
t.Error("SVG differs from render.SVG")
}
var png bytes.Buffer
- if err := render.PNG(&png, code.Matrix(), 8); err != nil {
+ if err := render.PNG(&png, code.Matrix(), render.DefaultPNGScale); err != nil {
t.Fatal(err)
}
if !bytes.Equal(out.PNG, png.Bytes()) {
diff --git a/main.go b/main.go
index 7510d87..c13cf4f 100644
--- a/main.go
+++ b/main.go
@@ -19,8 +19,6 @@ import (
var version = "dev"
-const pngScale = 8 // pixels per module; 69 modules + quiet zone => 616 px max
-
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
@@ -184,7 +182,7 @@ func writePNG(path string, matrix [][]bool) error {
os.Remove(name)
return err
}
- if err := encodePNG(f, matrix, pngScale); err != nil {
+ if err := encodePNG(f, matrix, render.DefaultPNGScale); err != nil {
f.Close()
return fail(err)
}
diff --git a/scripts/web-smoke.mjs b/scripts/web-smoke.mjs
index d41283c..a12d0d1 100644
--- a/scripts/web-smoke.mjs
+++ b/scripts/web-smoke.mjs
@@ -78,4 +78,21 @@ else console.log(`ok invalid IBAN → "${bad.error}"`);
if (typeof globalThis.epcii.version !== 'string' || !globalThis.epcii.version) fail('version string missing');
else console.log(`ok version ${globalThis.epcii.version}`);
+// --- zero-storage contract (CONTRIBUTING "The web version is the CLI"):
+// the page sources must not name a storage API, write the address bar, or
+// pull anything from another origin. A grep gate, but one that can go red.
+const forbidden = [
+ [/localStorage|sessionStorage|indexedDB|document\.cookie|serviceWorker|caches\./, 'storage API'],
+ [/history\.(pushState|replaceState|go|back|forward)|location\.(hash|href|search|assign|replace)\s*[=(]/, 'address bar write'],
+ [/<(script|link|img|iframe)[^>]+(src|href)=["']https?:/i, 'external resource tag'],
+ [/@import|url\(\s*["']?https?:/i, 'external stylesheet resource'],
+ [/\bimport\s*\(|\bfetch\(\s*["']https?:/, 'dynamic import / cross-origin fetch'],
+];
+for (const name of ['index.html', 'app.js', 'style.css']) {
+ const src = readFileSync(join(root, 'web', name), 'utf8');
+ const hits = forbidden.filter(([re]) => re.test(src)).map(([, what]) => what);
+ if (hits.length) fail(`web/${name} violates the zero-storage contract: ${hits.join(', ')}`);
+ else console.log(`ok web/${name} names no storage, address-bar write or external resource`);
+}
+
process.exit(failures ? 1 : 0);
diff --git a/web/app.js b/web/app.js
index 9a69ecc..8aa37ef 100644
--- a/web/app.js
+++ b/web/app.js
@@ -1,8 +1,9 @@
// epcii web: form → globalThis.epcii.generate (Go/WASM) → inline SVG.
//
-// Zero-storage contract: this file never touches localStorage, sessionStorage,
-// cookies, IndexedDB, history or location.hash. The only state that can leave
-// the page is a share link the user asks for, and it lives in the #fragment.
+// Zero-storage contract: this file never touches a storage API, cookies, the
+// history or the address bar (scripts/web-smoke.mjs greps for that). The only
+// state that can leave the page is a share link the user asks for, and it
+// lives in the #fragment.
'use strict';
const FIELDS = ['name', 'iban', 'amount', 'text', 'ref', 'bic', 'purpose', 'info'];
@@ -20,13 +21,20 @@ const STR = {
amount: 'Amount (EUR)',
amount_ph: 'e.g. 12.50 or 12,50',
text: 'Remittance text',
+ more_fields: 'More fields (reference, BIC, purpose code, note to the payer)',
ref: 'Creditor reference',
ref_ph: 'RF… (excludes remittance text)',
+ bic_ph: 'only for accounts outside the EEA',
purpose: 'Purpose code',
- info: 'Information to the payer',
+ purpose_ph: 'e.g. SALA, 4 letters',
+ info: 'Note shown to the payer',
+ language: 'Language',
loading: 'Loading generator…',
load_failed: 'The generator could not be loaded. Your browser needs WebAssembly.',
empty: 'Enter at least a name and an IBAN.',
+ bad_link: 'This link was made by a newer version of the page and was not loaded.',
+ dl_svg: 'Download SVG',
+ dl_png: 'Download PNG',
copy_link: 'Copy link',
copied: 'Link copied',
copy_failed: 'Copying failed — the link is:',
@@ -43,13 +51,20 @@ const STR = {
amount: 'Betrag (EUR)',
amount_ph: 'z. B. 12,50 oder 12.50',
text: 'Verwendungszweck',
+ more_fields: 'Weitere Felder (Referenz, BIC, Verwendungscode, Hinweis an die zahlende Person)',
ref: 'Strukturierte Referenz',
ref_ph: 'RF… (schließt Verwendungszweck aus)',
- purpose: 'Purpose-Code',
- info: 'Hinweis an die zahlende Person',
+ bic_ph: 'nur für Konten außerhalb des EWR',
+ purpose: 'Verwendungscode',
+ purpose_ph: 'z. B. SALA, 4 Zeichen',
+ info: 'Hinweis, der der zahlenden Person angezeigt wird',
+ language: 'Sprache',
loading: 'Generator wird geladen…',
load_failed: 'Der Generator konnte nicht geladen werden. Der Browser braucht WebAssembly.',
empty: 'Mindestens Name und IBAN eingeben.',
+ bad_link: 'Dieser Link stammt von einer neueren Version der Seite und wurde nicht geladen.',
+ dl_svg: 'SVG herunterladen',
+ dl_png: 'PNG herunterladen',
copy_link: 'Link kopieren',
copied: 'Link kopiert',
copy_failed: 'Kopieren fehlgeschlagen — der Link lautet:',
@@ -93,6 +108,7 @@ function applyLang(code) {
}
$('lang-en').setAttribute('aria-pressed', String(lang === 'en'));
$('lang-de').setAttribute('aria-pressed', String(lang === 'de'));
+ $('lang-nav').setAttribute('aria-label', t.language);
if (!ready) showStatus();
else render(); // details header and the page's own messages are localized
}
@@ -155,7 +171,7 @@ function scheduleRender() {
function shareParams() {
const p = new URLSearchParams();
- p.set('v', '1');
+ p.set('v', LINK_VERSION);
const fields = readForm();
for (const k of FIELDS) if (fields[k] !== '') p.set(k, fields[k]);
return p;
@@ -165,18 +181,36 @@ function shareURL() {
return location.origin + location.pathname + '#' + shareParams().toString();
}
+const LINK_VERSION = '1';
+
+// loadFragment fills the form from a share link. Returns true when fields
+// were filled; a link from a newer format version is refused, not guessed.
function loadFragment() {
const raw = location.hash.slice(1);
if (!raw) return false;
const p = new URLSearchParams(raw);
+ if ((p.get('v') || LINK_VERSION) !== LINK_VERSION) {
+ showError(STR[lang].bad_link);
+ return false;
+ }
let any = false;
for (const k of FIELDS) {
const v = p.get(k);
- if (v !== null) { $(k).value = v; any = true; }
+ if (v !== null) {
+ $(k).value = v;
+ any = true;
+ if (v !== '' && $('more').contains($(k))) $('more').open = true;
+ }
}
return any;
}
+// Typed characters outside the amount alphabet are dropped before they land;
+// a paste is left alone so the validator can name what is wrong with it.
+function guardAmountInput(e) {
+ if (e.inputType === 'insertText' && e.data && /[^0-9 .,€]/.test(e.data)) e.preventDefault();
+}
+
// --- downloads
function fileStem() {
@@ -199,10 +233,11 @@ function svgBlob() { return new Blob([last.svg], { type: 'image/svg+xml' }); }
function pngBlob() { return new Blob([last.png], { type: 'image/png' }); }
function flash(button, text) {
- const old = button.textContent;
button.textContent = text;
button.disabled = true;
- setTimeout(() => { button.textContent = old; button.disabled = false; }, 1500);
+ // Restore from the i18n table, not from a captured string: the language
+ // may have changed in the meantime.
+ setTimeout(() => { button.textContent = STR[lang][button.dataset.i18n]; button.disabled = false; }, 1500);
}
async function copyLink() {
@@ -265,6 +300,7 @@ async function main() {
els.fields.disabled = false;
$('form').addEventListener('input', scheduleRender);
+ $('amount').addEventListener('beforeinput', guardAmountInput);
$('form').addEventListener('submit', (e) => { e.preventDefault(); render(); });
els.dlSvg.addEventListener('click', () => download(svgBlob(), fileStem() + '.svg'));
els.dlPng.addEventListener('click', () => download(pngBlob(), fileStem() + '.png'));
diff --git a/web/index.html b/web/index.html
index 913360b..fc8702d 100644
--- a/web/index.html
+++ b/web/index.html
@@ -18,7 +18,7 @@
epcii
EPC QR code (GiroCode) for SEPA credit transfers — generated in your browser, nothing leaves it.