From df81c011d3e76358e92070e45e8f8afb5d4250e3 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Mon, 13 Jul 2026 11:48:48 +0200 Subject: [PATCH] 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 --- invoice_logic.py | 28 ++++++++++++++++++++++++++++ main.py | 47 ++++++++++++++++++++++++++++++++--------------- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/invoice_logic.py b/invoice_logic.py index 20ff525..21118f2 100644 --- a/invoice_logic.py +++ b/invoice_logic.py @@ -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. diff --git a/main.py b/main.py index 8070e76..275e817 100644 --- a/main.py +++ b/main.py @@ -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,21 +101,36 @@ 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. - pdf_bytes = facturx_generate_from_binary( - original_pdf_bytes, - cii_xml_string.encode("utf-8"), - 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) - return pdf_bytes + 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( + 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", + ) + 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)) # ==========================================