Skip to content

Source

This page is generated from docs/pdfa.md at the pinned revision ea6b031ccb33. Edit it there, not here.

PDF/A-3b + ZUGFeRD/Factur-X (einvoice-pdfa, T-030/T-032, W8)

@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 (Sprint 0) 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, T-032 — 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 Spike B (D-20) found.
    • the actual fx:DocumentType/fx:DocumentFileName/fx:Version/fx:ConformanceLevel properties.
  3. The attachment itselfAFRelationship: 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, T-040) 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" names in plan-v0.1 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 Spike B (D-20) 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 (plan-v0.1 §7 doesn't ask for a general-purpose invoice designer); renders every top-level BT/BG group a human reading the PDF would expect (parties, lines, VAT breakdown, totals).

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. 14/14 fixtures pass all three, for real — not simulated (AGENTS.md §8), and now against a real, font-embedded, multi-paragraph page rather than a blank one.

What this does not solve yet

Fixing an arbitrary caller-supplied PDF whose fonts aren't embedded — the "Path 2" Ghostscript re-embedding fallback D-20 already validated works, for a PDF this package didn't itself generate — is a further einvoice-pdfa continuation, not implemented here. embedInvoiceInPdfA3 still assumes its input PDF is already PDF/A-eligible in that sense; renderInvoicePdf closes that gap only for the PDF this package renders itself, not for a foreign PDF handed to it (e.g. from a PDF-generation plugin elsewhere in a host platform, T-073's "standalone mode").