From aae15f96dba87873ed033d1b76ffde66139a9c9b Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Mon, 6 Jul 2026 09:23:01 +0200 Subject: [PATCH] Test --- main.py | 174 ++++++++++++++++++----------------------------- requirements.txt | 3 +- sRGB.icc | Bin 0 -> 3268 bytes 3 files changed, 70 insertions(+), 107 deletions(-) create mode 100644 sRGB.icc diff --git a/main.py b/main.py index a3df05c..f5f8274 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,5 @@ import os +import io import streamlit as st import json import re @@ -9,6 +10,8 @@ from decimal import Decimal, ROUND_HALF_UP import fitz import requests from pydantic import BaseModel, Field +import pikepdf +from facturx import generate_from_binary as facturx_generate_from_binary # ========================================== # 0. SITZUNGS-STATUS INITIALISIEREN (ZÄHLER GEGEN WIDGET-CACHING) @@ -351,7 +354,7 @@ def generate_compliant_zugferd_cii(data): root = ET.Element(f"{{{RSM}}}CrossIndustryInvoice") - ex_doc_ctx = ET.SubElement(root, f"{{{RAM}}}ExchangedDocumentContext") + ex_doc_ctx = ET.SubElement(root, f"{{{RSM}}}ExchangedDocumentContext") guideline = ET.SubElement(ex_doc_ctx, f"{{{RAM}}}GuidelineSpecifiedDocumentContextParameter") ET.SubElement(guideline, f"{{{RAM}}}ID").text = "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0" @@ -383,7 +386,7 @@ def generate_compliant_zugferd_cii(data): ET.SubElement(trade_prod, f"{{{RAM}}}Description").text = str(item["description"]).strip() line_agree = ET.SubElement(line_item, f"{{{RAM}}}SpecifiedLineTradeAgreement") - net_price = ET.SubElement(line_agree, f"{{{RAM}}}GrossPriceProductTradePrice") + net_price = ET.SubElement(line_agree, f"{{{RAM}}}NetPriceProductTradePrice") ET.SubElement(net_price, f"{{{RAM}}}ChargeAmount").text = f"{item['unit_price']:.2f}" line_deliv = ET.SubElement(line_item, f"{{{RAM}}}SpecifiedLineTradeDelivery") @@ -447,7 +450,7 @@ def generate_compliant_zugferd_cii(data): header_settle = ET.SubElement(trade_trans, f"{{{RAM}}}ApplicableHeaderTradeSettlement") ET.SubElement(header_settle, f"{{{RAM}}}InvoiceCurrencyCode").text = "EUR" - pay_means = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradePaymentMeans") + pay_means = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradeSettlementPaymentMeans") ET.SubElement(pay_means, f"{{{RAM}}}TypeCode").text = "30" payee_acc = ET.SubElement(pay_means, f"{{{RAM}}}PayeePartyCreditorFinancialAccount") ET.SubElement(payee_acc, f"{{{RAM}}}IBANID").text = str(data["iban"]).replace(" ", "") @@ -470,7 +473,6 @@ def generate_compliant_zugferd_cii(data): ET.SubElement(header_sum, f"{{{RAM}}}LineTotalAmount").text = f"{data['total_net_amount']:.2f}" ET.SubElement(header_sum, f"{{{RAM}}}TaxBasisTotalAmount").text = f"{data['total_net_amount']:.2f}" ET.SubElement(header_sum, f"{{{RAM}}}TaxTotalAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}" - header_sum.find(f"{{{RAM}}}TaxTotalAmount").set("currencyID", "EUR") ET.SubElement(header_sum, f"{{{RAM}}}GrandTotalAmount").text = f"{data['total_gross_amount']:.2f}" ET.SubElement(header_sum, f"{{{RAM}}}DuePayableAmount").text = f"{data['total_gross_amount']:.2f}" @@ -478,7 +480,43 @@ def generate_compliant_zugferd_cii(data): return minidom.parseString(xml_str).toprettyxml(indent=" ") -# ========================================== +# Pfad zum sRGB-ICC-Profil, das für ein PDF/A-3-konformes OutputIntent benötigt wird. +# (PDF/A verlangt zwingend ein eingebettetes Farbprofil - ohne das gilt die PDF nicht als PDF/A, +# selbst wenn XMP-Metadaten und eingebettetes XML korrekt sind.) +SRGB_ICC_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sRGB.icc") + + +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. + Das ist zwingend erforderlich, damit eine PDF als PDF/A-3(B) gilt (ZUGFeRD-Pflicht). + """ + pdf = pikepdf.open(io.BytesIO(pdf_bytes)) + try: + if "/OutputIntents" not in pdf.Root or len(pdf.Root.get("/OutputIntents", [])) == 0: + if not os.path.isfile(icc_path): + raise FileNotFoundError( + f"sRGB-ICC-Profil nicht gefunden unter '{icc_path}'. " + "Für echte PDF/A-3-Konformität muss diese Datei mit ausgeliefert werden." + ) + with open(icc_path, "rb") as f: + icc_bytes = f.read() + icc_stream = pikepdf.Stream(pdf, icc_bytes) + icc_stream["/N"] = 3 + icc_stream["/Alternate"] = pikepdf.Name("/DeviceRGB") + output_intent = pdf.make_indirect(pikepdf.Dictionary({ + "/Type": pikepdf.Name("/OutputIntent"), + "/S": pikepdf.Name("/GTS_PDFA1"), + "/OutputConditionIdentifier": pikepdf.String("sRGB IEC61966-2.1"), + "/Info": pikepdf.String("sRGB IEC61966-2.1"), + "/DestOutputProfile": icc_stream, + })) + pdf.Root["/OutputIntents"] = pikepdf.Array([output_intent]) + buf = io.BytesIO() + pdf.save(buf) + return buf.getvalue() + finally: + pdf.close()# ========================================== # 4. API CLIENT INITIALISIERUNG # ========================================== def get_cached_llm(): @@ -727,116 +765,40 @@ with col2: if st.button("🔒 ZUGFeRD PDF exportieren", type="primary", width="stretch"): try: cii_xml_string = generate_compliant_zugferd_cii(final_invoice_dict) - + uploaded_file.seek(0) original_pdf_bytes = uploaded_file.read() - doc = fitz.open(stream=original_pdf_bytes, filetype="pdf") - - # 1. XML-Datei einbetten nach ZUGFeRD Standard - doc.embfile_add( - "factur-x.xml", + + # 1) factur-x übernimmt: XSD-Validierung der CII-XML, korrektes Einbetten + # (Filespec mit AFRelationship=/Data, /F + /UF, Subtype text/xml) und die + # komplette PDF/A-3-konforme XMP-Metadatenstruktur (pdfaid:part/conformance, + # fx:-Schema-Erweiterung). Bricht die XML-Validierung fehl, wird hier ein + # klarer Fehler geworfen statt einer stillen, ungültigen PDF. + zugferd_pdf_bytes = facturx_generate_from_binary( + original_pdf_bytes, cii_xml_string.encode("utf-8"), - filename="factur-x.xml", - desc="Factur-X/ZUGFeRD E-Invoice" + flavor="factur-x", + level="en16931", + check_xsd=True, + check_schematron=False, # benötigt optional 'saxonche' für Schematron-Regeln + afrelationship="data", ) - - # 2. XMP-Metadaten inkl. PDF/A-3-Identifikation & ZUGFeRD-Schema aufbauen - xmp_xml = """ - - - - 3 - B - - - - - - Factur-X PDFA Extension Schema - fx - urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0# - - - - DocumentType - Text - external - The type of the document, usually INVOICE - - - DocumentFileName - Text - external - The name of the embedded file - - - Version - Text - external - The version of the document type schema - - - ConformanceLevel - Text - external - The conformance level of the document - - - - - - - - - INVOICE - factur-x.xml - 1.0 - XRECHNUNG - - - -""" - - doc.set_metadata(doc.metadata, xmp=xmp_xml) - - # 3. Low-Level XREF-Injektion für strikte PDF/A-3 & ZUGFeRD Konformität - filespec_xref = None - for xref in range(1, doc.xref_length()): - obj_str = doc.xref_object(xref) - if "/Type /Filespec" in obj_str and "factur-x.xml" in obj_str: - filespec_xref = xref - break - - if filespec_xref: - # 3.1 Erzwinge das /AFRelationship Attribut am eingebetteten FileSpec - doc.xref_set_key(filespec_xref, "AFRelationship", "/Alternative") - - # 3.2 Ermittle den Catalog-Knoten und verknüpfe das XML im globalen /AF Array - cat_xref = doc.pdf_catalog() - doc.xref_set_key(cat_xref, "AF", f"[{filespec_xref} 0 R]") - - # 3.3 Standard-sRGB OutputIntent erstellen & injizieren (Farbprofil-Marker für PDF/A) - oi_xref = doc.xref_create() - oi_dict = ( - "<< /Type /OutputIntent " - "/S /GTS_PDFA1 " - "/OutputConditionIdentifier (sRGB) " - "/RegistryName (http://www.color.org) " - "/Info (sRGB IEC61966-2.1) >>" - ) - doc.xref_update(oi_xref, oi_dict) - doc.xref_set_key(cat_xref, "OutputIntents", f"[{oi_xref} 0 R]") - - # PDF finalisieren und umwandeln - zugferd_pdf_bytes = doc.tobytes() - doc.close() - + + # 2) Zusätzliches OutputIntent (sRGB-ICC-Profil) ergänzen. Das ist für echte + # PDF/A-3-Konformität zwingend, wird von factur-x aber nicht selbst gesetzt. + zugferd_pdf_bytes = add_pdfa_output_intent(zugferd_pdf_bytes) + st.download_button( label="📥 Download ZUGFeRD Hybrid-PDF", data=zugferd_pdf_bytes, file_name=f"ZUGFeRD_{inv_num}.pdf", mime="application/pdf" ) + st.info( + "ℹ️ Hinweis: Die eingebettete CII-XML wurde gegen das offizielle EN16931-XSD-Schema " + "validiert. Für eine vollständige PDF/A-3-Zertifizierung (z. B. Schriftarten-Einbettung " + "der Ursprungs-PDF) empfiehlt sich zusätzlich eine Prüfung mit veraPDF." + ) except Exception as e: st.error(f"Fehler bei der ZUGFeRD-Generierung: {e}") @@ -847,7 +809,7 @@ st.markdown("---") st.caption( "📝 **Hinweise:**\n\n" "**Human-in-the-Loop:** " - "Das Tool dient als Automations-Assistent. Vor dem finalen XML-Export ist eine visuelle Endkontrolle der extrahierten Daten " + "Das Tool dient as Automations-Assistent. Vor dem finalen XML-Export ist eine visuelle Endkontrolle der extrahierten Daten " "(insbesondere Rechnungsnummer und Empfängeradresse) durch den Sachbearbeiter zwingend erforderlich.\n\n" "**GoBD-Konformität:** " "Das Tool generiert ausschließlich das strukturierte XML-Format. Für die Einhaltung der GoBD-Richtlinien " diff --git a/requirements.txt b/requirements.txt index f044702..2158e8f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ streamlit==1.58.0 pydantic==2.13.4 pymupdf==1.28.0 requests==2.32.3 -factur-x>=3.8 \ No newline at end of file +factur-x==5.1 +pikepdf==10.5.1 \ No newline at end of file diff --git a/sRGB.icc b/sRGB.icc new file mode 100644 index 0000000000000000000000000000000000000000..59b4507045831e10007301bab6a8b6e3c0c47ac3 GIT binary patch literal 3268 zcmb`J2T&7f8^_<>O(&rxL^?}v5}H5&k={h4NEelmgb+vqgoI)*94c_4h=`z~$f<}2 zo}Pf56;Ui4f(5%5O^IGj??mt|w|aaKw_HS-J4RI(J?G^R%?Sun}U zn2gj(F8Jb!iT1}lWdM+gevyMnfXeY-v`SP{g3lR>o+=V@QC^mqBb2bv zxmjpDM=Iq=WQEhw*jzr!k)efIaw(dXFF~ck+)Ne#Q*yvm)=s)imhEA0uiUMW&t)lJ z_@aL4;xF{|1eaq7e|-+Tiu%&)5VPl-_SFo&X<54fV2@!vxbd5ox)p%7 zwE$?}{-&AL0iad{Kx-FQF3p>aq-<4y2Na+N7(fRY0#jfG?0^$+1D?PK1cKQh0>pqs zun=%TIuHXX$OlWoDo_kc!A7tJ)POqB02)CvI1Jjrac~Bl2bVz~xB>101sDd;z$kbF z#vuqILNtgD=|D!1Ib;htL2PIi6bQ|MqM<}61rk6KNDeK9)a^(NHfxbbR*Xg1@atuk0apdI231x z^TdVVVsTtt4sJQF3|E8Ok88zs;jZBZaih4;cp6?GZ;SW9hvF0P0(?Hc7+;0oi$97# zhrfXz#=jvD2uy+{!Hp0?NF-zsmJrGaI|wa=(}aG)5Mhi+B1*f>^mFuK27zJ82xVk2N*O;gdKsfynp!io z;WhldYP1U-HP$x{ zF_s(eHtsbZH?cH{HCb)aVshV#4;`*g|l`sp|AsCK?~d3H^9gDfU%4r?{5jrGFb%s$C}lYO`SX9pJt zkwb&ST}MsF*^a9nI~@OVvT@=%)jC~wra1>WuW)X6emTQ-27kuR8Mj>MF5xa~T{>Mp zxw^SZU7KA;+|1lk+_t;jWHZ>2>@xNRcf7lw`wI8t?(aQZJ!Bq-J^u8x^UU--;Q3^x zn=-@|{I|LFiEAUL2Tpf^x6FgCCz@IjDCkRYfjXf)U*cuDZ-5PV2j$fl5+p$4Jc z(8kcw*>1C!&F%`LhRqAB341ulW{z}DM>rflJA8Bay}9OdC3D*%KtyQ7=7{@|mXSG; zzeEwDBBN@f9z{DwFOBY=N1wN7-hp{NP4?X=U<)?weVTU`9u#YtVZ2t51qx7RCtx#)W>u6hgo1#6jz5m$kW1StI z9Y=p*{j%@4@$uRdS|`d+Qcf0~oH(`k)Y$3l)6dTc&J1-f>b!e4;q3LUsIJ~~VdpNK z4?2Ig+qe7F1+NRoFM3=&c8Ptd?Xv6T)*hFhqrbZRdbHQIxAltKmG(aOzK;Hx{U@(_ zUp;dz;M)1?q1P|pn0uptAa>x^&E%VkTfAFOZi{Zeypwll{O-zo_ zoAqxk3RlI6!GOV@hw~oZ9pVl>`#tyfkHbZeXpgEM8$E7(;`HSBNYF^%)A>(_pJhKA ze_s5D#vj{XSiSi9PoF<~MiWMdUrJw2{8jc!=heQ~GhTO&MUFjqBYrdfw&b18yZ!Ik z@4G+5e|S8e|B?8y`jhpij?ZDA?@fp&CUVo9ohH8phRp#8aGsbLXqlMsehr}3{Q!{p P*fBOS@uC1g#LB+`$e<%Z literal 0 HcmV?d00001