Skip to content

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 ​

  1. /OutputIntents — an sRGB OutputIntent dictionary referencing the vendored ICC profile (artifacts/pdfa/sRGB2014.icc — see sources.md for its licence). Built with low-level pdf-lib primitives (context.obj/context.register); OutputConditionIdentifier/Info must be PDFString, not the plain-JS-string-becomes-PDFName shorthand context.obj({...}) gives you — found via a real veraPDF rejection ("Missing OutputConditionIdentifier"), not by reading the spec alone.
  2. /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 for einvoice-cii). Three rdf:Description blocks:
    • pdfaid:part/pdfaid:conformance (PDF/A identification) + dc:format/dc:title
    • pdfaExtension:schemas — the ISO 19005-3 Annex E declaration that the custom fx: namespace exists. Without this block veraPDF rejects the file under clause 6.6.2.3 even when every fx: property value is otherwise correct — the exact pitfall that spike found.
    • the actual fx:DocumentType/fx:DocumentFileName/fx:Version/fx:ConformanceLevel properties.
  3. 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):

ProfileXMP namespacefx:ConformanceLevelfx:VersionAttachment filename
EN16931urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#EN 169311.0factur-x.xml
XRECHNUNGurn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#XRECHNUNG3.0xrechnung.xml

Two non-obvious things this caught that guessing would have gotten wrong:

  • Both profiles use the fx: (Factur-X) namespace, not zf: (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, not factur-x.xml — Mustang's own getFilenameForVersion() 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-3TitleUnder the title
380Rechnung / Invoice—
381Rechnungskorrektur / Credit notethe invoice it corrects: "zu Rechnung RE-… vom …" (BT-25, BT-26)
384Rechnungskorrektur (berichtigt) / Corrected invoicethe 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's Date.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 job conformance-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 validate must exit clean (its XMP property checks included — this is what "recognizes the profile" means in practice), and --action extract must recover the embedded XML byte-for-byte. Every fixture is embedded under both ZUGFeRD profiles — XRECHNUNG and EN16931 (the one selectProfile picks 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 xpacket begin attribute is the UTF-8 byte-order mark EF 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". For EN16931 this 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 PDFcheckPdfAEligibilityWhat 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.