Skip to content

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.