fix(zugferd): robust factur-x embedding for varied PDF structures

Fehler 'unable to find page tree' beim ZUGFeRD-Export mancher PDFs (z.B. von PDF-XChange
erzeugte degewo-Rechnung): factur-x bettete die XML nicht ein und zerstoerte den Seitenbaum.

- build_zugferd_pdf versucht zuerst die Original-PDF; schlaegt das fehl (Absturz ODER XML
  nicht eingebettet), wird die PDF mit pikepdf normalisiert (neu gespeichert) und erneut
  versucht. Manche PDFs funktionieren nur original, andere nur normalisiert - beide Wege
  abgedeckt, Erfolgskontrolle via pdf_has_embedded_facturx.
- Neue Helfer normalize_pdf_bytes() und pdf_has_embedded_facturx() in invoice_logic.
- Verifiziert auf beiden echten Rechnungen (degewo -> Fallback, Gemeinde -> Original).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dfuhrmann3 2026-07-13 11:48:48 +02:00
parent 29034d8501
commit df81c011d3
2 changed files with 60 additions and 15 deletions

View File

@ -912,6 +912,34 @@ def generate_compliant_zugferd_cii(data):
SRGB_ICC_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sRGB.icc")
def normalize_pdf_bytes(pdf_bytes: bytes) -> bytes:
"""Oeffnet die PDF mit pikepdf und speichert sie neu. Das erzeugt eine saubere,
standardkonforme PDF-Struktur (Seitenbaum/XRef). Manche Ursprungs-PDFs (z.B. von PDF-XChange
erzeugte, die bereits ein OutputIntent tragen) haben eine Struktur, an der factur-x sonst
scheitert: die factur-x.xml wird nicht eingebettet und der Seitenbaum geht verloren
('unable to find page tree'). Schlaegt das Normalisieren selbst fehl, werden die Original-Bytes
unveraendert zurueckgegeben, damit der Export nicht komplett blockiert."""
try:
pdf = pikepdf.open(io.BytesIO(pdf_bytes))
buf = io.BytesIO()
pdf.save(buf)
pdf.close()
return buf.getvalue()
except Exception:
return pdf_bytes
def pdf_has_embedded_facturx(pdf_bytes: bytes) -> bool:
"""True, wenn die PDF wieder lesbar ist UND die eingebettete 'factur-x.xml' enthaelt. Dient als
Erfolgskontrolle nach der ZUGFeRD-Erzeugung - factur-x kann bei manchen PDF-Strukturen die XML
still weglassen bzw. eine kaputte PDF produzieren."""
try:
with pikepdf.open(io.BytesIO(pdf_bytes)) as pdf:
return "factur-x.xml" in pdf.attachments
except Exception:
return False
def add_pdfa_output_intent(pdf_bytes: bytes, icc_path: str = SRGB_ICC_PATH) -> bytes:
"""
Fügt der PDF ein OutputIntent mit sRGB-ICC-Profil hinzu, falls noch keines vorhanden ist.

27
main.py
View File

@ -22,6 +22,8 @@ from invoice_logic import (
format_cii_date,
generate_compliant_xrechnung,
generate_compliant_zugferd_cii,
normalize_pdf_bytes,
pdf_has_embedded_facturx,
add_pdfa_output_intent,
fix_xrechnung_xmp_conformance,
compute_invoice_totals,
@ -99,22 +101,37 @@ def build_zugferd_pdf(invoice_json: str, original_pdf_bytes: bytes) -> bytes:
Rechnung tatsaechlich aendert - so kann der Export-Button direkt herunterladen, ohne bei
jedem Rerun neu zu generieren."""
invoice_dict = json.loads(invoice_json)
cii_xml_string = generate_compliant_zugferd_cii(invoice_dict)
# factur-x: XSD-Validierung der CII-XML, korrektes Einbetten und PDF/A-3-konforme XMP-Struktur.
cii_bytes = generate_compliant_zugferd_cii(invoice_dict).encode("utf-8")
def _attempt(src_pdf_bytes):
# factur-x: XSD-Validierung der CII-XML, Einbetten, PDF/A-3-konforme XMP-Struktur; danach
# OutputIntent (sRGB-ICC) und XRechnung-Konformitaets-Fix in den XMP-Metadaten.
pdf_bytes = facturx_generate_from_binary(
original_pdf_bytes,
cii_xml_string.encode("utf-8"),
src_pdf_bytes,
cii_bytes,
flavor="factur-x",
level="en16931",
check_xsd=True,
check_schematron=False, # benötigt optional 'saxonche' für Schematron-Regeln
afrelationship="data",
)
# Zusätzliches OutputIntent (sRGB-ICC) + XRechnung-Konformitaets-Fix in den XMP-Metadaten.
pdf_bytes = add_pdfa_output_intent(pdf_bytes)
pdf_bytes = fix_xrechnung_xmp_conformance(pdf_bytes)
# Erfolgskontrolle: factur-x laesst bei manchen PDF-Strukturen die XML still weg bzw.
# produziert eine kaputte PDF - dann als Fehlschlag behandeln.
if not pdf_has_embedded_facturx(pdf_bytes):
raise RuntimeError("factur-x.xml wurde nicht eingebettet")
return pdf_bytes
try:
# 1. Versuch mit der Original-PDF (funktioniert bei den meisten PDFs direkt).
return _attempt(original_pdf_bytes)
except Exception:
# 2. Fallback: Ursprungs-PDF normalisieren (mit pikepdf neu speichern) und erneut versuchen.
# Manche PDFs (z.B. von PDF-XChange) brauchen das, andere funktionieren NUR ohne - daher
# beide Wege, und wir nehmen den, der eine gueltige eingebettete factur-x.xml liefert.
return _attempt(normalize_pdf_bytes(original_pdf_bytes))
# ==========================================
# 5. STREAMLIT FRONTEND