Engineering
Two syntaxes, one PDF
EN 16931 can arrive as UBL or as CII. A human reading the rendered invoice should never be able to tell which. That sounds obvious, and it is the kind of obvious thing that silently stops being true.
The European standard defines a semantic model — BT-1 is the invoice
number, BT-112 is the total including VAT — and then blesses two completely
different XML syntaxes to carry it. OASIS UBL 2.1 and UN/CEFACT Cross Industry
Invoice. They share no element names, no structure, and no design philosophy.
The same commercial fact appears in UBL as
cbc:PayableAmount, two levels below the root, and in CII as
ram:DuePayableAmount, four levels down a different tree.
Which one you receive is an accident of your counterparty's software. It says nothing about the invoice. So the rendered document must not betray it — not in the wording, not in the rounding, not in a stray blank line where an optional element was absent in one syntax and empty in the other.
Why it drifts
The failure mode is not dramatic. Nobody writes a renderer that produces two obviously different documents. What happens is that you fix something for one syntax, in the place where that syntax is handled, and the other path keeps its old behaviour. A date format normalised in one branch. A payment term that only the UBL mapping trims. A tax category that CII spells differently and the renderer half-recognises.
Each is a one-line difference. None of them fails validation, because both documents are still perfectly valid EN 16931. You find out when a customer sends the same invoice through two channels and gets two different PDFs.
The architectural answer, and why it is not enough
Faktorei renders in two phases. Normalizers map each syntax onto one semantic vocabulary; the renderer consumes only that vocabulary and has never heard of UBL or CII. Structurally, the renderer cannot treat them differently, because by the time it runs the distinction is gone.
This is a good design and it is not a proof. The distinction is gone from the renderer, but it is very much alive in the two normalizers, and nothing about the architecture stops those from disagreeing. The contract has to be tested, not merely arranged for.
The gate
So the corpus carries twins: pairs of fixtures that are the same commercial document expressed once in UBL and once in CII. Same parties, same lines, same totals, same dates. Fixture 001 and fixture 101 are one invoice written twice.
A CI gate normalizes both and compares the semantic XML after canonicalization.
Not "similar". Not "equivalent modulo whitespace". Identical,
excluding only a meta element that records which syntax it came
from — which exists precisely so that the rest can be compared without it.
[equivalence] 001-base-multivat.xml == 101-base-multivat.xml: IDENTICAL
[equivalence] 007-xrechnung-leitweg.xml == 107-xrechnung-leitweg.xml: IDENTICAL
[equivalence] 008-exempt-e.xml == 108-exempt-e.xml: IDENTICAL
[equivalence] 013-rounding.xml == 113-rounding.xml: IDENTICAL Comparing at the semantic stage rather than at the PDF is deliberate. It is the earliest point where the two paths have converged, so a failure names the normalizer that drifted instead of leaving you diffing rasterised pages. It also needs no PDF formatter and no rasteriser, so it runs in seconds.
What the rendered output actually does
The gate proves the inputs to the renderer match. The stronger claim is
about what comes out, so here it is, measured by posting each twin to
/render on ghcr.io/faktorei/render:2025.11.2 in
evaluation mode and hashing the result:
| Twin pair | UBL | CII | Result |
|---|---|---|---|
| 001 / 101 · multi-rate VAT | eff2acae… | eff2acae… | identical |
| 007 / 107 · XRechnung | 1a6923a2… | 1a6923a2… | identical |
| 008 / 108 · exempt (category E) | b5324b22… | b5324b22… | identical |
| 013 / 113 · rounding (BT-114) | cd6afb90… | cd6afb90… | identical |
Those are SHA-256 prefixes of the finished PDFs. Not visually identical — byte-identical. The same document arriving in either syntax produces the same file, down to the last byte of the compressed content stream.
You can reproduce this. Both fixtures of every pair are in the public corpus and
the image is public; post one to /render, post the other, and hash
them. The hashes are version-bound — a later release changes them, and the point
is that within one version the two syntaxes never diverge.
What it caught
The gate earns its place by failing. CII carries dates as
format="102", which is CCYYMMDD — the twin above carries
its issue date as 20260701, no separators. UBL writes the same day as
ISO 2026-07-01. Convert in the
normalizer and the renderer never learns that dates have formats. Forget, and the
renderer grows a branch that knows about syntax, which is exactly the coupling
the architecture exists to prevent — and the twins stop matching immediately.
The rule that follows is narrow and worth stating plainly: a normalizer may map, and may convert a format, but may not compute. The moment a normalizer does arithmetic, the two syntaxes have two implementations of it, and they will disagree about rounding before they disagree about anything else.
Why publish this
"We support both syntaxes" is on every e-invoicing product page in Europe, and it is nearly always true in the sense that both are accepted. It is much less often true in the sense that matters to whoever receives the PDF — that the choice made by someone else's ERP left no trace on the document you have to file.
The difference between the two meanings is a test that fails when someone breaks it. That is the whole post.
The corpus, the normalizers and the equivalence gate are Apache-2.0:
github.com/faktorei/stylesheets.
The pairs above are corpus/fixtures/ubl/ and
corpus/fixtures/cii/; the gate is
tools/test_equivalence.py.