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
/OutputIntents— an sRGBOutputIntentdictionary referencing the vendored ICC profile (artifacts/pdfa/sRGB2014.icc, T-032 — 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 Spike B (D-20) 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, T-040) 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" names inplan-v0.1are 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 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'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. 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").