glu
ZUGFeRD e-invoice

Create a ZUGFeRD e-invoice

This recipe produces an EN 16931 / ZUGFeRD / Factur-X compliant electronic invoice: a typeset PDF/A-3b document with the machine-readable Cross-Industry-Invoice XML embedded as an attachment named factur-x.xml. The visible invoice and the embedded data come from the same XML source, so they cannot drift apart.

Rendered ZUGFeRD invoice

Full example: glu/markdown/zugferd-invoice

The pattern

glu has no ZUGFeRD-specific code. Everything format-specific lives in a companion Lua file that glu auto-loads next to the Markdown source. The same pattern adapts to XRechnung, PEPPOL, or any other compliance format without touching glu itself.

File Purpose
rechnung.md Visible invoice in Markdown, with {= zugferd.* =} placeholders
rechnung.lua Companion Lua: parses the XML, registers the compliance callback
rechnung.css Layout stylesheet (letterhead, address block, totals)
invoice.xml The ZUGFeRD Cross-Industry Invoice XML
AdobeRGB1998.icc Output-intent color profile required by PDF/A-3

Step 1: request PDF/A-3b in the frontmatter

The conformance level is a generic glu frontmatter key, nothing ZUGFeRD-specific:

---
title: Rechnung
author: Lieferant GmbH
lang: de
papersize: a4
format: PDF/A-3b
css: rechnung.css
---

Step 2: parse the XML into a Lua global

rechnung.lua runs before any Lua block or inline expression in the Markdown body, which makes it the right place for data preparation. It opens the XML with the cxpath module and populates a zugferd global:

local frontend = require("glu.frontend")
local cxpath = require("xml.cxpath")

local doc = cxpath.open("invoice.xml")
doc:set_namespace("rsm", "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100")
doc:set_namespace("ram", "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100")

zugferd = {
    id    = doc:eval("/rsm:CrossIndustryInvoice/rsm:ExchangedDocument/ram:ID").string,
    -- … date, currency, seller, buyer, lines, totals …
}

All values stay strings: no float rounding drift, and locale formatting remains under your control.

Step 3: use the data in the Markdown body

Inline expressions pull single fields, and a {lua} block builds the line-items table as a Markdown pipe table:

# Rechnung Nr. {= zugferd.id =}

```{lua}
local out = {
    "| Pos. | Artikel | Menge | Preis | Gesamt |",
    "| ---: | --- | ---: | ---: | ---: |",
}
for _, l in ipairs(zugferd.lines) do
    table.insert(out, string.format(
        "| %s | %s | %s | %s %s | %s %s |",
        l.pos, l.name, l.qty,
        l.price, zugferd.currency,
        l.line_total, zugferd.currency))
end
return table.concat(out, "\n")
```

Step 4: attach the XML and the XMP extension

The compliance plumbing runs in a page_init callback, because that is the first moment the live document object is available. A guard makes sure it runs exactly once:

local initialized = false

frontend.add_callback("page_init", "zugferd-compliance", function(d, _page, pagenum)
    if initialized then return end
    initialized = true

    -- PDF/A-3 requires an output intent.
    local cp = d:load_colorprofile("AdobeRGB1998.icc")
    cp.identifier = "AdobeRGB1998"
    cp.registry   = "Adobe"
    cp.condition  = "RGB"
    cp.colors     = 3

    d:attach_file({
        filename    = "invoice.xml",
        name        = "factur-x.xml",
        mimetype    = "text/xml",
        description = "Factur-X/ZUGFeRD invoice",
    })

    d:add_xmp_extension({
        schema        = "ZUGFeRD PDFA Extension Schema",
        namespace_uri = "urn:ferd:pdfa:CrossIndustryDocument:invoice:1p0#",
        prefix        = "zf",
        properties = {
            { name = "DocumentFileName", value_type = "Text", category = "external", description = "name of the embedded XML invoice file" },
            { name = "DocumentType",     value_type = "Text", category = "external", description = "INVOICE" },
            { name = "Version",          value_type = "Text", category = "external", description = "The actual version of the ZUGFeRD data" },
            { name = "ConformanceLevel", value_type = "Text", category = "external", description = "The conformance level of the ZUGFeRD data" },
        },
        values = {
            ConformanceLevel = "EN 16931",
            DocumentFileName = "factur-x.xml",
            DocumentType     = "INVOICE",
            Version          = "1.0",
        },
    })
end)

Run and verify

glu rechnung.md

Check the attachment and the XMP metadata:

pdfdetach -list rechnung.pdf
# 1 embedded files
# 1: factur-x.xml

exiftool -XMP-pdfaid:Part -XMP-zf:ConformanceLevel \
         -XMP-zf:DocumentFileName rechnung.pdf
# Part                : 3
# Conformance Level   : EN 16931
# Document File Name  : factur-x.xml

veraPDF validates the PDF/A-3b side:

verapdf --flavour 3b rechnung.pdf

Adapting for your own invoices

  1. Replace invoice.xml with the CII XML from your billing system.
  2. Adjust rechnung.md and rechnung.css to your layout.
  3. rechnung.lua needs no changes: the XPath mappings cover every EN 16931 mandatory field.

Related