Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@

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
<https://bmmmm.github.io/epcii/> (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
pre-fill a SEPA transfer from a single scan.

## Install

Requires Go 1.26 or newer:
No install: open <https://bmmmm.github.io/epcii/>.

For the command line, requires Go 1.26 or newer:

```sh
go install github.com/bmmmm/epcii@latest
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down
5 changes: 5 additions & 0 deletions internal/render/png.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 2 additions & 6 deletions internal/webapi/webapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand All @@ -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{
Expand Down
2 changes: 1 addition & 1 deletion internal/webapi/webapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
4 changes: 1 addition & 3 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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)
}
Expand Down
17 changes: 17 additions & 0 deletions scripts/web-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
56 changes: 46 additions & 10 deletions web/app.js
Original file line number Diff line number Diff line change
@@ -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'];
Expand All @@ -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:',
Expand All @@ -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:',
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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;
Expand All @@ -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() {
Expand All @@ -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() {
Expand Down Expand Up @@ -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'));
Expand Down
47 changes: 29 additions & 18 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,50 +18,61 @@
<header>
<h1>epcii</h1>
<p data-i18n="tagline">EPC QR code (GiroCode) for SEPA credit transfers — generated in your browser, nothing leaves it.</p>
<nav aria-label="Language">
<nav id="lang-nav" aria-label="Language">
<button type="button" id="lang-en" class="lang" aria-pressed="true">EN</button>
<button type="button" id="lang-de" class="lang" aria-pressed="false">DE</button>
</nav>
</header>

<main>
<form id="form" autocomplete="off" novalidate>
<!-- No maxlength anywhere: the browser would truncate silently where the
CLI refuses. Limits are enforced by internal/epc and shown as errors. -->
<fieldset id="fields" disabled>
<label for="name"><span data-i18n="name">Beneficiary name</span> <small>*</small></label>
<!-- No maxlength anywhere: the browser would truncate silently where the
CLI refuses. Limits are enforced by internal/epc and shown as errors. -->
<input id="name" name="name" required>

<label for="iban">IBAN <small>*</small></label>
<input id="iban" name="iban" required spellcheck="false" autocapitalize="characters">

<label for="amount"><span data-i18n="amount">Amount (EUR)</span></label>
<input id="amount" name="amount" inputmode="decimal" data-i18n-placeholder="amount_ph" placeholder="e.g. 12.50 or 12,50">
<!-- Typed letters are refused by app.js (beforeinput); pasted ones reach
the validator, which names the field. -->
<input id="amount" name="amount" inputmode="decimal" pattern="[0-9 .,€]*" data-i18n-placeholder="amount_ph" placeholder="e.g. 12.50 or 12,50">

<label for="text"><span data-i18n="text">Remittance text</span></label>
<input id="text" name="text">

<label for="ref"><span data-i18n="ref">Creditor reference</span></label>
<input id="ref" name="ref" data-i18n-placeholder="ref_ph" placeholder="RF… (excludes remittance text)">
<!-- IBAN-only is the SEPA norm; everything below is rarely needed and
stays folded away (the fields still round-trip through share links). -->
<details id="more">
<summary data-i18n="more_fields">More fields (reference, BIC, purpose code, note to the payer)</summary>
<div id="more-fields">
<label for="ref"><span data-i18n="ref">Creditor reference</span></label>
<input id="ref" name="ref" data-i18n-placeholder="ref_ph" placeholder="RF… (excludes remittance text)">

<label for="bic">BIC</label>
<input id="bic" name="bic" spellcheck="false" autocapitalize="characters">
<label for="bic">BIC</label>
<input id="bic" name="bic" spellcheck="false" autocapitalize="characters" data-i18n-placeholder="bic_ph" placeholder="only for accounts outside the EEA">

<label for="purpose"><span data-i18n="purpose">Purpose code</span></label>
<input id="purpose" name="purpose" spellcheck="false" autocapitalize="characters">
<label for="purpose"><span data-i18n="purpose">Purpose code</span></label>
<input id="purpose" name="purpose" spellcheck="false" autocapitalize="characters" data-i18n-placeholder="purpose_ph" placeholder="e.g. SALA, 4 letters">

<label for="info"><span data-i18n="info">Information to the payer</span></label>
<input id="info" name="info">
<label for="info"><span data-i18n="info">Note shown to the payer</span></label>
<input id="info" name="info">
</div>
</details>
</fieldset>
</form>

<section id="result" aria-live="polite">
<p id="status">Loading generator…</p>
<p id="error" role="alert" hidden></p>
<section id="result">
<div aria-live="polite">
<p id="status">Loading generator…</p>
<p id="error" hidden></p>
</div>
<div id="qr" hidden></div>
<div id="actions" hidden>
<button type="button" id="dl-svg">SVG</button>
<button type="button" id="dl-png">PNG</button>
<button type="button" id="dl-svg" data-i18n="dl_svg">Download SVG</button>
<button type="button" id="dl-png" data-i18n="dl_png">Download PNG</button>
<button type="button" id="copy-link" data-i18n="copy_link">Copy link</button>
<button type="button" id="share" data-i18n="share" hidden>Share</button>
</div>
Expand All @@ -74,7 +85,7 @@ <h1>epcii</h1>

<footer>
<p data-i18n="privacy">No server, no cookies, no storage: the code is generated by this page alone. A shared link carries the payment data only in its #fragment, which browsers never send to any server.</p>
<p><a href="https://github.com/bmmmm/epcii#web" rel="noopener" data-i18n="more">How this works</a> · <a href="https://github.com/bmmmm/epcii" rel="noopener">Source (GPL-3.0-or-later)</a> · <span id="version"></span></p>
<p><a href="https://github.com/bmmmm/epcii#web" data-i18n="more">How this works</a> · <a href="https://github.com/bmmmm/epcii">Source (GPL-3.0-or-later)</a> · <span id="version"></span></p>
</footer>

<script src="wasm_exec.js"></script>
Expand Down
Loading
Loading