Factur-X / ZUGFeRD¶
A Factur-X (or ZUGFeRD 2.x) invoice is a PDF/A-3 that people can read, with the CII XML of the same invoice embedded as an associated file. euinvoice does the embedding and the extraction. It does not render the human-readable PDF; your renderer does.
uv add 'euinvoice[pdf]' # pypdf
Levels¶
| Profile | Embedded file | XMP fx:ConformanceLevel |
Write | Read |
|---|---|---|---|---|
profiles.FACTURX_EN16931 |
factur-x.xml |
EN 16931 |
yes | yes |
profiles.FACTURX_XRECHNUNG |
xrechnung.xml |
XRECHNUNG |
yes | yes |
profiles.FACTURX_MINIMUM, FACTURX_BASIC_WL, FACTURX_BASIC, FACTURX_EXTENDED |
factur-x.xml |
MINIMUM, BASIC WL, BASIC, EXTENDED |
no | yes (the EN 16931 subset) |
1. Render a PDF/A-3¶
The PDF you embed into must already be PDF/A-3 (its XMP must say pdfaid:part 3); embed raises PdfError
otherwise. WeasyPrint can render HTML straight to PDF/A-3b.
Illustration, not run
euinvoice does not depend on WeasyPrint, so this block is not executed by the documentation tests (they only check that it is valid Python). Check the WeasyPrint documentation for your version.
from weasyprint import HTML # not a dependency of euinvoice
invoice_html = "<html><body><h1>Invoice 2026-0001</h1>...</body></html>" # your template
rendered_pdf = HTML(string=invoice_html).write_pdf(pdf_variant="pdf/a-3b")
The examples below continue the Quickstart (invoice is its EN 16931 invoice). In the
documentation tests, rendered_pdf is a blank one-page PDF/A-3B made with pypdf instead of WeasyPrint's output.
2. Embed the invoice¶
facturx.embed writes the CII XML for the level, attaches it under
the level's file name and adds the Factur-X properties to the XMP. The rest of the PDF stays as it is.
>>> from euinvoice import facturx, parse, profiles
>>> hybrid_pdf = facturx.embed(rendered_pdf, invoice, profile=profiles.FACTURX_EN16931)
>>> hybrid_pdf[:5]
b'%PDF-'
embed does not run the pre-flight checks. To get them, write the XML with to_xml first and embed the bytes:
facturx.embed(rendered_pdf, to_xml(invoice, profile=profiles.FACTURX_EN16931), profile=profiles.FACTURX_EN16931).
The check below reads both PDFs back with facturx.extract,
which section 3 covers.
>>> from euinvoice import to_xml
>>> xml = to_xml(invoice, profile=profiles.FACTURX_EN16931) # CII, the only syntax of the level
>>> from_bytes = facturx.embed(rendered_pdf, xml, profile=profiles.FACTURX_EN16931)
>>> facturx.extract(from_bytes).xml == facturx.extract(hybrid_pdf).xml # same invoice embedded
True
3. Read it back¶
facturx.extract finds the invoice attachment through the XMP,
and parse accepts a PDF directly.
>>> found = facturx.extract(hybrid_pdf)
>>> found.filename, found.conformance_level, found.profile.id
('factur-x.xml', 'EN 16931', 'facturx-en16931')
>>> found.xml == xml
True
>>> parse(hybrid_pdf) == profiles.FACTURX_EN16931.prepare(invoice)
True
4. Validate the embedded XML¶
Validate the extracted XML under the level's profile. For FACTURX_EN16931 that runs the D16B XSD and the CEN
rules; the Factur-X Schematron itself is not pinned yet (#42),
so ok is the EN 16931 verdict, not a full Factur-X one:
>>> from euinvoice import validate
>>> validate(found.xml, profiles.FACTURX_EN16931).ok
True
The MINIMUM, BASIC WL, BASIC and EXTENDED levels have no pinned Schematron, so validate() raises
ArtifactsNotAvailableError for them instead of giving a verdict. The same applies when it detects the profile
from BT-24 in a ZUGFeRD 2.0 MINIMUM, BASIC or EXTENDED invoice (extract only), or in a BASIC or EXTENDED invoice
whose BT-24 is spelled with colons (urn:cen.eu:en16931:2017:compliant:factur-x.eu:1p0:basic). To get the
EN 16931 core verdict alone, pass profiles.EN16931.
Whether the PDF itself is valid PDF/A-3 is up to your renderer; euinvoice's own conformance suite checks the PDFs it writes with veraPDF.