ZUGFeRD mit eigenem PDF-Layout, endlich pragmatisch gelöst

Monate lang stand ich vor dem gleichen Problem: Die PHP-Bibliothek horstoeko/zugferd erzeugt zuverlässig die ZUGFeRD-XML, möchte aber idealerweise, dass man das sichtbare PDF mit FPDF neu aufbaut. Für standardisierte Rechnungen ist das akzeptabel; für gewachsene Corporate-Designs mit sauber gesetzten Typografien, Logos, farbigen Flächen und komplexeren Kopf-/Fußbereichen ist es schlicht unpraktikabel. Mein Ziel war daher, ein beliebiges, bereits perfektes Template-PDF (aus FileMaker, InDesign, Word oder einem anderen System) weiterzuverwenden und ausschließlich die ZUGFeRD-XML sauber zu integrieren – ohne das Layout neu zu programmieren.
Ausgangsproblem – warum der reine FPDF-Weg nicht reicht
Sobald es um reale Layouts geht, wird der FPDF-Weg schnell mühsam: Spaltenbreiten, Zeilenabstände, Schriften, Positionierungen, Logos, CI-Farben – alles muss mühsam nachgebaut werden, obwohl das Layout als fertige PDF längst existiert. Hinzu kommt, dass einzelne PDFs wegen Kompressionsarten, PDF-Versionen oder Producer-Spezifika von FPDI nicht immer direkt importierbar sind. Das kostet Zeit, Nerven und bringt keinen fachlichen Mehrwert, wenn das Ziel schlicht „bestehendes Layout beibehalten, ZUGFeRD einbetten“ heißt.
Die Lösung – Template-PDF nutzen, ZUGFeRD einbetten, robustes Fallback
Ich habe ein Template-System mit mehrstufigem Fallback gebaut, das vorhandene PDFs bevorzugt, FPDI nur nutzt, wenn es geht, andernfalls automatisiert konvertiert oder, falls nötig, das Template schlicht als Basis kopiert und anschließend die ZUGFeRD-XML einbettet. Die ZUGFeRD-Erzeugung bleibt sauber im ZugferdDocumentBuilder, das Einbetten erfolgt mit ZugferdDocumentPdfBuilder::fromPdfFile(…). Ergebnis: Das Layout bleibt 1:1 erhalten, und die PDF wird ZUGFeRD-konform.
Kernpunkte des Ansatzes: • Automatische Template-Erkennung im templates/-Ordner, optional mit layout_config.json für kleinere Koordinaten-/Schrift-Tweaks (falls man doch etwas übersteuern möchte). • Mehrstufiger FPDI-Fallback: Direktimport → Konvertierung (Ghostscript) → sichere Kopie. • Robustes Error-Handling & Logging: Jede Stufe wird sauber protokolliert, inklusive Dateigrößen, Rückgaben und etwaigen Ausnahmen. • Saubere Trennung der Verantwortlichkeiten: Der Builder erzeugt die XML; der PDF-Builder fügt sie einem bestehenden PDF hinzu; FPDF kommt nur noch dann zum Einsatz, wenn wirklich ein Basispapier benötigt wird.
Technischer Ablauf – von Template bis ZUGFeRD-PDF 1. Template laden oder Standard-PDF erzeugen (Upload aus FileMaker per aus URL einfügen) Im Projektverzeichnis liegt ein templates/-Ordner. Dort wird automatisch die erste passende PDF als Template ausgewählt. Deshalb wird die Rechnung immer als template.pdf hochgeladen. Möglich ist auch dort schon die Rechnugs-PDF mit eigenem Datei-Namen zu versehen. Dies ist aber nicht notwendig. Ist FPDI verfügbar und kann das Template öffnen, wird die Seite importiert. Schlägt dies wegen Kompressionsdetails fehl, versucht das System eine Konvertierung (via Ghostscript) und importiert erneut. Misslingt auch das, wird das Template als Datei kopiert und danach direkt mit XML versehen. Nur wenn überhaupt kein Template vorhanden/geeignet ist, wird minimal mit FPDF eine neutrale Seite gebaut. 2. ZUGFeRD-XML generieren Die fachlichen Rechnungsdaten kommen wie gehabt per POST (Rechnungsnummer, Datum, Positionen, Summen, Steuerblöcke, Zahlungsbedingungen etc.). Daraus wird mit ZugferdDocumentBuilder die EN16931-konforme XML erzeugt. 3. XML in PDF integrieren Mit ZugferdDocumentPdfBuilder::fromPdfFile($document, $tempPdfPath) wird die XML in das vorbereitete PDF geschrieben und als finale ZUGFeRD-PDF gespeichert.
Ausschnitt: Template-Erkennung mit mehrstufigem Fallback
Im folgenden Ausschnitt ist das Prinzip komprimiert dargestellt. Beachten: Kommentare sind bewusst ausführlich, da sie beim späteren Debuggen Gold wert sind.
// Template-PDF automatisch erkennen $templatesDir = __DIR__ . '/templates/'; $templatePdfs = glob($templatesDir . '*.pdf'); if (!empty($templatePdfs)) { $templatePdf = $templatePdfs[0]; try { // Versuch 1: FPDI nutzen (falls verfügbar) if (!class_exists('Fpdi')) { throw new Exception('FPDI-Klasse nicht verfügbar'); } $pdf = new Fpdi(); $pageCount = $pdf->setSourceFile($templatePdf); $tpl = $pdf->importPage(1); $pdf->AddPage(); $pdf->useTemplate($tpl, 0, 0); // Als temporäre Basis speichern $pdf->Output('F', $tempPdfPath); // -> ab hier geht es direkt zum Einbetten der XML } catch (Exception $e) { // Versuch 2: PDF kompatibel machen und erneut probieren $converted = $templatesDir . 'converted_template.pdf'; if (convertPdfForFpdi($templatePdf, $converted)) { try { $pdf = new Fpdi(); $pageCount = $pdf->setSourceFile($converted); $tpl = $pdf->importPage(1); $pdf->AddPage(); $pdf->useTemplate($tpl, 0, 0); $pdf->Output('F', $tempPdfPath); @unlink($converted); } catch (Exception $e2) { @unlink($converted); // Versuch 3: Direkte Kopie als Fallback copy($templatePdf, $tempPdfPath); } } else { // Falls Konvertierung nicht möglich: Direkte Kopie copy($templatePdf, $tempPdfPath); } } } else { // Kein Template gefunden -> neutrale FPDF-Seite als Minimalbasis $pdf = new FPDF(); $pdf->AddPage(); // Optional: Logo/Absender, wenn gewünscht; ansonsten blank lassen // $pdf->Image('logo.png', 20, 10, 30, 15); $pdf->Output('F', $tempPdfPath); }
Konvertierung für FPDI-Kompatibilität
Manche PDFs scheitern an der Importhürde wegen Kompressionsart oder PDF-Version. Ich reiche deshalb (falls vorhanden) eine Ghostscript-Konvertierung vor. Der Code ist so geschrieben, dass er ohne Ghostscript nicht abstürzt, sondern sauber weiter macht.
function convertPdfForFpdi($inputPdf, $outputPdf) { try { // Versuch: Ghostscript (falls exec() und gs vorhanden) if (function_exists('exec')) { $cmd = 'gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 ' . '-dPDFSETTINGS=/prepress -dNOPAUSE -dQUIET -dBATCH ' . '-sOutputFile="' . $outputPdf . '" "' . $inputPdf . '" 2>&1'; $out = []; $ret = 0; exec($cmd, $out, $ret); if ($ret === 0 && file_exists($outputPdf)) { return true; } } // Fallback: Wenn kompatibel, reicht Kopie return copy($inputPdf, $outputPdf); } catch (Exception $e) { return false; } }
XML erzeugen und in bestehendes PDF einbetten
Der entscheidende Schritt: Wir nutzen die Stärke der Bibliothek – die XML-Erzeugung – und vermeiden, das Layout neu bauen zu müssen. Dazu erstellen wir den ZugferdDocumentBuilder, setzen alle Pflicht- und optionalen Felder (Dokumenttyp, Beträge, Steuern, Zahlungsbedingungen etc.) und übergeben dieses Dokument anschließend an den PDF-Builder.
// 1) ZUGFeRD-XML vorbereiten $document = ZugferdDocumentBuilder::createNew(ZugferdProfiles::PROFILE_EN16931); $document->setDocumentInformation($invoiceNumber, $invoiceTypeCode, new DateTime($invoiceDate), $currency); // Summation/Steuerblöcke etc. setzen ... // $document->setDocumentSummation(...); // $document->addDocumentTax(...); // $document->addDocumentPaymentMeanToCreditTransfer(...); // 2) XML in das vorhandene (Template-)PDF einbetten $pdfBuilder = ZugferdDocumentPdfBuilder::fromPdfFile($document, $tempPdfPath); $pdfBuilder->generateDocument(); $pdfBuilder->saveDocument($outputPath);
Template-Konfiguration: optional, klein, hilfreich
Damit ich bei Bedarf Kleinigkeiten ohne neues Template anfassen kann, lese ich eine layout_config.json ein. Sie enthält vor allem Koordinaten und Schriftgrößen für Textbausteine, die ich optional über FPDF ergänze (z. B. Adressblock, Infospalte, Summenfeld) – praktisch, falls Kundenvarianten leichte Unterschiede verlangen, aber kein komplett eigenes Template rechtfertigen.
Beispielhafte Keys (gekürzt): page_width, margin_left, sender_start_x, right_column_label_x, document_type_y, table_start_y, default_font, header_bg_color, summary_label_x, template_pdf. Fehlt die Datei, läuft das System mit vernünftigen Standardwerten weiter – der ZUGFeRD-Teil ist davon ohnehin unabhängig.
Logging und Fehlertoleranz
Alle wesentlichen Schritte werden in uploads/zugferd_log.txt mit Zeitstempel geloggt: Welche Stufe gegriffen hat, Dateigrößen vor/nach Verarbeitung, gefundene Templates, etwaige Exceptions inklusive Trace. Damit sind Fehlersuche und spätere Betriebsbeobachtung unaufwendig. Auch wenn FPDI oder Ghostscript nicht verfügbar sind, bleibt das System funktionsfähig, da es am Ende immer auf die „Kopie + Einbettung“-Strategie zurückfällt.
Was bleibt von FPDF?
FPDF bleibt im Projekt lediglich als Minimal-Fallback oder für sehr kleine Ergänzungen (z. B. dynamischer Hinweistext) erhalten. Logos, Balken, CI-Elemente usw. kommen aus dem Template, so wie es sein soll. Fazit
Statt aufwendig ein bestehendes Corporate-Design in FPDF nachzubauen, nutze ich jetzt vorhandene PDFs die in FileMaker erzeugt werden unverändert und lasse lediglich die ZUGFeRD-XML sauber einbetten. Die Kombination aus Template-Erkennung, FPDI-Fallback, optionaler Konvertierung und konsequentem Error-Handling führt zu einem robusten, praxistauglichen Workflow: Template bzw. die aktuelle Rechnung hochladen, Daten senden, fertige ZUGFeRD-PDF erhalten. Wenn Sie bereits ein gutes Rechnungslayout in FileMaker haben und nur „ZUGFeRD dazu“ brauchen, ist dieser Ansatz die pragmatische Abkürzung.