Source
This page is generated from docs/pdfa.md at the pinned revision 5ab878035219. Edit it there, not here.
PDF/A-3b + ZUGFeRD/Factur-X (einvoice-pdfa)
@normwerk/einvoice-pdfa embeds an EN 16931 CII invoice XML (from @normwerk/einvoice-cii) into an existing PDF, turning it into a PDF/A-3b ZUGFeRD/Factur-X document — pure pdf-lib, no Ghostscript, per the approach an internal spike validated against three synthetic PDFs and a fourth generated by headless Chrome. This file is the public record of what that spike and this package's own implementation work found.
What it adds to the PDF
/OutputIntents— an sRGBOutputIntentdictionary referencing the vendored ICC profile (artifacts/pdfa/sRGB2014.icc— seesources.mdfor its licence). Built with low-levelpdf-libprimitives (context.obj/context.register);OutputConditionIdentifier/Infomust bePDFString, not the plain-JS-string-becomes-PDFNameshorthandcontext.obj({...})gives you — found via a real veraPDF rejection ("Missing OutputConditionIdentifier"), not by reading the spec alone./Metadata— a hand-built XMP packet (src/xmp.ts; no XMP library — the structure is small and fixed, matching this repo's ADR-004 "hand-written serializer" style foreinvoice-cii). Threerdf:Descriptionblocks:pdfaid:part/pdfaid:conformance(PDF/A identification) +dc:format/dc:titlepdfaExtension:schemas— the ISO 19005-3 Annex E declaration that the customfx:namespace exists. Without this block veraPDF rejects the file under clause 6.6.2.3 even when everyfx:property value is otherwise correct — the exact pitfall that spike found.- the actual
fx:DocumentType/fx:DocumentFileName/fx:Version/fx:ConformanceLevelproperties.
- The attachment itself —
AFRelationship: Alternative(Factur-X §6.2.2: "identical content in two forms"; also the mandatory choice for Germany specifically).
Where the exact values come from
None of the ZUGFeRD/Factur-X XMP namespace/property details are in our own vendored EN 16931 artifacts — they're a PDF-embedding convention layered on top, not part of the CII binding. Rather than guess, the exact namespace URNs, the pdfaExtension:schemas RDF structure, and the accepted XMP property values were read directly from ZUGFeRD/mustangproject (Apache-2.0 — the same tool already used as this repo's own Docker conformance validator) at the pinned commit recorded in artifacts/MANIFEST.json (mustangproject-zugferd-xmp-writer, mustangproject-pdf-validator):
| Profile | XMP namespace | fx:ConformanceLevel | fx:Version | Attachment filename |
|---|---|---|---|---|
EN16931 | urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0# | EN 16931 | 1.0 | factur-x.xml |
XRECHNUNG | urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0# | XRECHNUNG | 3.0 | xrechnung.xml |
Two non-obvious things this caught that guessing would have gotten wrong:
- Both profiles use the
fx:(Factur-X) namespace, notzf:(legacy ZUGFeRD) — Mustang's own source comment: "Factur-X is now set by default since ZF 2.1, you have to disable it if you don't want it". The "ZUGFeRD 2.3 XRECHNUNG"/"EN 16931" profile names are branding, not a different XMP namespace. - XRECHNUNG's attachment filename is
xrechnung.xml, notfactur-x.xml— Mustang's owngetFilenameForVersion()special-cases the XRECHNUNG profile specifically.
fx:Version for XRECHNUNG ("3.0") is a documented judgment call, not the tool's own default (which would be "1.0" even for that profile) — chosen to match the real XRechnung 3.0.2 CIUS this repo implements; both values are in Mustang's own accepted list (PDFValidator.java).
The base PDF: a real visual layout, not a blank page
render-invoice.ts's renderInvoicePdf(invoice) lays out an Invoice (@normwerk/einvoice-model) as a real A4 page — seller/buyer blocks, a line-items table (paginating onto further pages once it overflows one), a VAT breakdown, and totals — using a real embedded, subset TrueType font: Liberation Sans (artifacts/fonts/, SIL Open Font License 1.1 — see sources.md), via pdf-lib + @pdf-lib/fontkit. This is what tools/conformance/pdfa-embed-and-validate.mjs now feeds into embedInvoiceInPdfA3 for every fixture, replacing an earlier blank-page base PDF that had sidestepped rather than exercised the classic PDF/A pitfall the internal spike diagnosed: a PDF using a non-embedded standard font (e.g. pdf-lib's StandardFonts.Helvetica) fails PDF/A regardless of anything embedInvoiceInPdfA3 does. A real, non-blank, real-font-embedded page is what actually proves this package's output is PDF/A-3b-valid for a document someone would read, not just for an empty one.
Deliberately simple — one visual style, no template system, no text-wrapping engine beyond what the fixtures need (v0.1 does not aim to be a general-purpose invoice designer); renders every top-level BT/BG group a human reading the PDF would expect (parties, lines, VAT breakdown, totals).
The title follows the document type (BT-3), German then English, so the PDF never contradicts its XML:
| BT-3 | Title | Under the title |
|---|---|---|
| 380 | Rechnung / Invoice | — |
| 381 | Rechnungskorrektur / Credit note | the invoice it corrects: "zu Rechnung RE-… vom …" (BT-25, BT-26) |
| 384 | Rechnungskorrektur (berichtigt) / Corrected invoice | the invoice it corrects, as for 381 |
A credit note is never titled "Gutschrift" — see tax-semantics.md. Any other type code is refused with an error rather than printed under a wrong title.
While implementing this, a real, previously-undetected determinism bug turned up in embedInvoiceInPdfA3 itself (index.ts): pdf-lib's PDFDocument.load() defaults to stamping the Info dictionary's ModDate (and CreationDate, once the base PDF has none) with the real new Date() at load time — silently contradicting that function's own doc comment about never touching the Info dictionary. The existing "embed twice, byte-identical" unit test had only passed by running faster than one wall-clock second. Found by actually diffing two calls a second apart, not by re-reading the source; fixed with { updateMetadata: false } on both PDFDocument.create() (render-invoice.ts) and PDFDocument.load() (index.ts) — confirmed deterministic across a real 1.5s gap, not by timing luck.
What's verified, and how
- Unit tests (
packages/einvoice-pdfa/src/index.test.ts,render-invoice.test.ts) — XMP structure/values, a real embed + Flate-decode round-trip of the attached XML, determinism (rendering/ embedding twice, with a real >1s gap in between, gives byte-identical output — matching this repo'sDate.now()-free discipline), that neither the base PDF's nor the final Info dictionary carries a timestamp, that a real subset TrueType font (not a standard font) is embedded with its program data (FontFile2), that non-ASCII text round-trips through it, and that the line-items table actually paginates onto further pages once it overflows one. - Real conformance (
tools/conformance/pdfa-embed-and-validate.mjs,pnpm conformance:pdfa, CI jobconformance-pdfa) — renders every fixture as a real visual invoice, embeds its real CII XML, and runs the actual Docker veraPDF (L3, PDF/A-3b,--flavour 3b) and Mustang validators against it: veraPDF must report the file PDF/A-3b compliant, Mustang's own--action validatemust exit clean (its XMP property checks included — this is what "recognizes the profile" means in practice), and--action extractmust recover the embedded XML byte-for-byte. Every fixture is embedded under both ZUGFeRD profiles —XRECHNUNGandEN16931(the oneselectProfilepicks for ordinary B2B invoices and every EU/CH/UK buyer) — and 28/28 fixture/profile pairs pass all three, for real (2026-09-23) — not simulated (AGENTS.md §8), against a real, font-embedded, multi-paragraph page rather than a blank one. - The XMP stream is UTF-8, checked by a unit test that decodes it with
fatal: true: the xpacketbeginattribute is the UTF-8 byte-order markEF BB BF, and a non-ASCII title survives intact. - The embedded XML always declares the XRechnung 3.0 CIUS (BT-24), whichever ZUGFeRD profile the PDF carries. Mustang 2.26.0 reads the profile from the XML, not the XMP, and reports both hybrids
status="valid". ForEN16931this is consistent rather than a mismatch: XRechnung is a CIUS of EN 16931, so a document that conforms to it also conforms to EN 16931.
A PDF rendered elsewhere: checked, not repaired
embedInvoiceInPdfA3 adds the attachment, XMP and OutputIntent, and repairs nothing. A PDF rendered elsewhere — your own template, another plugin, anything passed through the Medusa adapter's standalone.basePdf — is checked first with checkPdfAEligibility(pdfBytes) (preflight.ts, pdf-lib alone) for the two defects PDF/A does not allow and that embedding cannot fix:
| Your PDF | checkPdfAEligibility | What the Medusa adapter does |
|---|---|---|
Every font embedded (FontFile, FontFile2 or FontFile3), not encrypted | { eligible: true } | Embeds the XML: a PDF/A-3 ZUGFeRD / Factur-X hybrid |
| A font referenced by name only — typically a standard font such as Helvetica | { eligible: false, reason: "font-not-embedded", fontName } | Issues the XML alone and marks the document "XML only" with PDF_FONT_NOT_EMBEDDED, naming the font |
| Encrypted | { eligible: false, reason: "encrypted" } | Issues the XML alone and marks the document "XML only" with PDF_ENCRYPTED |
The XML is the e-invoice either way — the PDF is its human-readable companion. To get the hybrid back, embed the fonts where the PDF is rendered, or use renderInvoicePdf. Re-embedding fonts after the fact takes a font renderer such as Ghostscript (-dPDFA=3), which re-embeds a standard font with a metric-compatible one; it is AGPL-licensed and never runs inside these packages — only as a separate process of your own, before the PDF reaches the plugin.