Schöne Dokumente aus FileMaker - ohne FileMaker-Layout
Wie wir Rechnungen, Angebote und Briefe aus FileMaker als HTML-Templates rendern, im WebViewer mit TinyMCE bearbeiten lassen und am Ende ein sauberes PDF zurück in die Datenbank schreiben. Ein paar bewegliche Teile, aber das Ergebnis ist überraschend rund — und designerisch viel flexibler als der klassische FM-Layout-Druck.
—
Ausgangslage: Rechnungen aus FileMaker
Es gibt viele FileMaker-Projekte, in denen Rechnungen und Angebote über FM-Layouts gedruckt werden. Funktioniert. Sieht meistens auch okay aus. Aber irgendwann kommt einer dieser Momente:
- Der Kunde möchte den Briefkopf neu gestalten — mit echtem Logo, vernünftigen Farben, einem coolen Layout
- Die zweite Seite soll umbrechen wie in echten Geschäftsbriefen, mit Kopf-/Fußzeile, Seitennummerierung „Seite X von Y"
- Ein Reverse-Charge-Hinweis soll dynamisch eingeblendet werden, je nach Kunden-Land
- Die Texte sollen vor dem Versand schnell mal angepasst werden — ein zusätzlicher Absatz, ein anderer Schlusssatz, eine spezielle Bemerkung für diesen Kunden
In FM-Layouts wird das schnell zur Schmerz-Übung. Konditionale Formatierung, Sub-Summary-Sortierungen, Slide-Object-Tetris. Und am Ende ist die Vorlage so verschachtelt, dass keiner sie mehr anfassen will — schon gar nicht die Texte, die in einem statischen Layout-Element verklebt sind.
Die Idee: HTML-Templates + ein kleiner PHP-Dienst
Was wäre, wenn:
- Die Rechnung ein einfaches HTML-Template wäre, mit Platzhaltern wie
{{rechnung_nummer}},{{kunde_name}},{{positionen_rows}}? - FileMaker liefert die Daten als JSON an einen kleinen PHP-Dienst
- Der Dienst rendert das Template, gibt einen Editor-Link zurück
- Im FileMaker-WebViewer öffnet sich ein WYSIWYG-Editor (TinyMCE), in dem man die Rechnung nochmal bearbeiten kann
- Beim Klick auf „Fertig" holt FileMaker das fertige PDF zurück in den Datensatz-Container
Genau das haben wir gebaut. Wir nennen’s „docsystem" — ein eigener kleiner Ordner mit PHP, ein paar HTML-Templates und mPDF als PDF-Renderer.
Architektur in einer Skizze
FileMaker (Projektdatei) docsystem (PHP)
+------------------+ +------------------------+
| T_INVOICES | ─→ JSON-POST ─→ | api/create_document |
| T_ESTIMATES | | |
| T_DOCUMENTS | ←─ editor_url ── | drafts/<docId>.html |
+------------------+ | |
↓ WebViewer | editor.php (TinyMCE) |
öffnet editor.php | |
↓ | api/get_pdf_base64 |
Klick „Fertig" | |
↓ fmp:// callback | pdf/<docId>.pdf |
FM holt PDF (base64) +------------------------+
↓ (mPDF)
Container_PDF gefüllt
Drei Aufrufe von FileMaker aus:
- POST
api/create_document_json.php— mit JSON-Body:doc_id,template,data. Antwort:editor_url - WebViewer öffnet
editor_url— der TinyMCE-Editor lädt den Draft, User editiert - Klick „Fertig" im Editor löst per
fmp://ein FM-Skript aus, dasapi/get_pdf_base64.php?doc_id=…aufruft und das PDF in den Container schreibt
Wie ein Template aussieht
Hier ein gekürzter Ausschnitt aus templates/rechnung.html:
<table class="letterhead-rechnung">
<tr>
<td class="logo-cell">
<img src="{{LOGO_PFAD}}" alt="Logo" style="width: 45mm">
</td>
<td class="sender-cell">
<strong class="brand">{{absender_name}}</strong><br>
{{absender_telefon}}<br>
…
</td>
</tr>
</table>
<h1 class="rechnung-headline">RECHNUNG</h1>
<table class="meta-recipient">
<tr>
<td class="meta-cell">
<table class="meta-fields">
<tr><td>Rechnungsnummer</td><td>{{rechnung_nummer}}</td></tr>
<tr><td>Rechnungsdatum</td><td>{{rechnung_datum}}</td></tr>
<tr><td>Fälligkeitsdatum</td><td>{{faelligkeit_datum}}</td></tr>
</table>
</td>
<td class="recipient-cell">
<strong>{{kunde_name}}</strong><br>
{{kunde_strasse}}<br>
…
</td>
</tr>
</table>
<table class="positions-rechnung">
<thead>
<tr><th>Artikel</th><th>Beschreibung</th>…</tr>
</thead>
<tbody>
{{positionen_rows}}
</tbody>
</table>
<!-- weiter: Totals, Bedingungen, Bank-Daten -->
Reines HTML mit Mustache-artigen {{…}}-Platzhaltern. Kein PHP, keine Schleifen-Syntax im Template. Es gibt einen Sonderfall — die Positionen, dazu gleich mehr.
Wie FileMaker die Daten liefert
Im FM-Skript wird der JSON-Payload aufgebaut. Vereinfacht:
JSONSetElement ( "{}" ;
[ "doc_id" ; "RECHNUNG-" & T_INVOICES::ID_Invoice ; JSONString ];
[ "template" ; "rechnung" ; JSONString ];
[ "data" ; $data ; JSONObject ]
)
$data ist ein zweites JSON-Objekt, das die ganzen Platzhalter-Werte enthält:
JSONSetElement ( "{}" ;
[ "absender_name" ; "Beispiel GmbH" ; JSONString ];
[ "rechnung_nummer" ; T_INVOICES::ID_Invoice_Display ; JSONString ];
[ "rechnung_datum" ; GetAsText ( T_INVOICES::Date_Invoice ) ; JSONString ];
[ "kunde_name" ; T_INVOICES::BillTo_Company ; JSONString ];
[ "positionen_rows" ; $positionen_rows ; JSONString ];
[ "netto" ; "€" & Substitute ( T_INVOICES::Subtotal_Net ; "." ; "," ) ; JSONString ];
…
)
Ein POST per „Aus URL einfügen" mit --request POST --header "Content-Type: application/json" --data @$json — und auf der anderen Seite parst PHP das.
Im docsystem läuft die Template-Ersetzung minimalistisch:
function render_template(string $tpl, array $data): string {
return preg_replace_callback(
'/\{\{\s*([a-zA-Z0-9_\-]+)\s*\}\}/',
fn($m) => (string)($data[$m[1]] ?? ''),
$tpl
);
}
Das war’s. Kein Twig, kein Blade, kein Mustache-Paket — eine Funktion mit einem Regex.
Der Positionen-Trick: ein Calc-Feld pro Datensatz
Tabellen mit dynamischen Zeilen sind die Stelle, wo simple Template-Engines aussteigen. Statt eine Loop-Syntax ins Template einzubauen, lassen wir die Arbeit von FileMaker erledigen — über ein Calc-Feld:
Auf der Line-Items-Tabelle ein berechnetes Textfeld c_invoice_row_html:
Let ([
prod_safe = Substitute ( Product ;
[ "&" ; "&" ] ; [ "<" ; "<" ] ; [ ">" ; ">" ]
) ;
unit_fmt = "€" & Substitute ( Runden ( UnitPrice ; 2 ) ; "." ; "," ) ;
sum_fmt = "€" & Substitute ( Runden ( ExtendedPrice ; 2 ) ; "." ; "," )
];
"<tr>"
& "<td class=\"artikel\">" & prod_safe & "</td>"
& "<td>" & Description & "</td>"
& "<td class=\"num\">" & unit_fmt & "</td>"
& "<td class=\"num\">" & Quantity & "</td>"
& "<td class=\"num\">" & sum_fmt & "</td>"
& "</tr>"
)
Im FM-Skript ziehen wir alle <tr>-Zeilen über die normale Liste-Funktion über die portal-relevante Beziehung:
Variable setzen [ $positionen_rows ;
Substitute (
Liste ( T_invoices_LINE_ITEM||id_invoice|::c_invoice_row_html )
; "¶" ; ""
)
]
Liste() mit ¶-Trennzeichen sammelt alle Rohzeilen, Substitute entfernt den Trenner. Heraus kommt ein einziger String mit allen <tr>...</tr>-Tags — den schicken wir als positionen_rows ins Template. PHP setzt das stumpf in den <tbody> ein. Fertig.
Vorteile dieses Ansatzes:
- Pro-Position-Formatierung liegt im Calc-Feld — also bei den FM-Devs, nicht im PHP
- Kein PHP-Code für unterschiedliche Positions-Layouts: für Angebote, Rechnungen, Lieferscheine kann man je ein eigenes Calc-Feld definieren
- HTML-Escaping passiert dort, wo die Daten herkommen — sauberer als am PHP-Ende
Der Editor: TinyMCE im WebViewer
api/create_document_json.php legt unter drafts/<docId>.html die gerenderte Seite ab und gibt eine URL zurück:
https://maps.example.com/docsystem/editor.php?doc_id=<docId>
Diese URL landet im WebViewer eines FM-Layouts. editor.php baut um den Draft eine TinyMCE-Instanz drumherum. Der User kann frei nachbearbeiten — einen extra Absatz einfügen, eine Zeile streichen, einen freundlicheren Schlusssatz wählen. Alles direkt in der Rechnung, ohne FM-Layout-Anpassung.
Drei Buttons oben:
- Speichern — schreibt den HTML zurück nach
drafts/<docId>.html - PDF erzeugen — speichert + ruft mPDF auf, legt das PDF unter
pdf/<docId>.pdfab - Fertig (an FileMaker übergeben) — feuert
fmp://$/MyDB.fmp12?script=Dokument%20uebernehmen¶m=<docId>
Der letzte Button ist die Brücke zurück: FM bekommt einen Skript-Aufruf mit der doc_id als Parameter. Das Skript ruft dann selbst api/get_pdf_base64.php?doc_id=… auf, holt das PDF als base64, und schreibt es per Base64Decode direkt in den Container des Dokuments. Fenster schließt sich, der User landet wieder auf seinem Datensatz — der Container ist gefüllt.
mPDF macht das PDF
mPDF nimmt das gespeicherte HTML aus drafts/<docId>.html, kombiniert es mit assets/pdf.css, und schreibt eine PDF nach pdf/<docId>.pdf. Die CSS ist normales Print-CSS: A4-Format, Margins, dezente Tabellen-Borders, Markenfarbe für Headlines und Bullets.
mPDF ist nicht ohne Tücken — wir haben drei Stolperfallen erlebt, die für jede ähnliche Setup gelten:
Logo via URL, nicht via Filesystem-Pfad
Erster Versuch: Das Logo als absoluten Filesystem-Pfad in den <img src=""> schreiben. Funktioniert für mPDF, das von der Disk lesen kann. Funktioniert NICHT für den Editor-Preview im Browser — der interpretiert /var/www/... als URL-Pfad und scheitert. Lösung: eine URL nehmen, die sowohl der Browser als auch mPDF abholen können:
$data['LOGO_PFAD'] = BASE_URL . '/assets/logo.png';
mPDF holt das per HTTP, der Browser zeigt’s normal an. Funktioniert lokal und auf dem Server, weil BASE_URL aus dem Request abgeleitet wird (Auto-Detect, kein Hardcoding).
Helvetica fällt um, Bold-„f" als Tofu
mPDF bringt eine PostScript-Helvetica mit, die in den Bold-Varianten berüchtigt unvollständig ist. Konkret: das Wort „Offener Betrag" rendert als „O[]fener Betrag" mit Glyph-Tofu auf dem „f". Auf den ersten Blick ein bizarres Phänomen — bis man merkt, dass der Bold-Cut bestimmte Glyphen einfach nicht hat.
Fix: in den mPDF-Konstruktor 'default_font' => 'dejavusans' setzen, in der CSS-Body-Regel font-family: dejavusans, sans-serif. DejaVu Sans ist Unicode-vollständig, bringt saubere Bold/Italic-Varianten mit, ist mPDF-mitgeliefert. Optik etwas weniger streng, aber lesbar und korrekt.
Ein BOM in der editor_url
Der erste Echtbetrieb: WebViewer zeigt „Laden: %EF%BB%BFhttps://…". Das %EF%BB%BF ist die URL-Codierung eines UTF-8 Byte-Order-Mark, das sich vor die URL geschmuggelt hat. PHP-Response war sauber, also kam das Zeichen aus FileMaker — bekannte Quirks beim JSONGetElement mit UTF-8-Responses.
Wir hätten den BOM im Skript wegschneiden können. Eleganter ist es im WebViewer-Calc selbst:
ZeichenMitte (
DOC_Documents::g_editor_url ;
Position ( DOC_Documents::g_editor_url ; "http" ; 1 ; 1 ) ;
9999
)
Schneidet alles vor dem ersten „http" weg — egal ob BOM, Leerzeichen, Zeilenumbruch. Robust gegen alles, was FileMaker an unsichtbaren Zeichen produzieren könnte. Und der Vorteil: nur eine Stelle anpassen, nicht in jedem Skript.
Was uns überraschend gefreut hat
Wiederverwendung zwischen Templates. Rechnung und Angebot teilen sich denselben CSS-Stack — letterhead-rechnung, positions-rechnung, totals-table, alles definiert in pdf.css. Das Angebot ist eine 1:1-Kopie der Rechnung mit anderen Platzhalter-Namen und einem geänderten Bedingungen-Text. Neuer Look entstand in zehn Minuten, nicht in zehn Stunden.
Texte sind editierbar. Das war der eigentliche Trigger. Der User kann jeden Brief, jede Rechnung kurz vor dem Versand nochmal anfassen — eine Bemerkung ergänzen, einen Absatz raushauen, eine spezielle Anrede. Im FM-Layout-Druck unmöglich, im TinyMCE trivial.
Separation of Concerns. FileMaker macht Daten und Beziehungen. PHP macht Templates und PDF-Rendering. CSS macht Optik. Drei Welten, klar getrennt, jede für sich änderbar.
Multi-Instance-tauglich. Der ganze Ordner ist relocateable. Auf einem Server zwei docsystem-Instanzen parallel? Einfach beide Ordner hochladen, beide config.php haben BASE_URL als Auto-Detect — keine Pfade hardcoded. Eine Instanz für die GmbH, eine für die GbR, eine für den Kunden. Funktioniert.
Was wir noch vor uns haben
Ein paar offene Punkte, die in der nächsten Iteration drankommen:
- Pro-Kunden-Vorlagen. Aktuell hat jeder Kunde dieselbe Vorlage. Für „edge"-Kunden wäre ein eigenes Branding nett — über einen optionalen
template_variant-Parameter - Mehrsprachige Templates.
rechnung_de.html,rechnung_en.html,rechnung_fr.html— unddata.langentscheidet - Tausender-Trennzeichen für Beträge. Aktuell „€4000,00" statt „€4.000,00". Eine Custom Function in FM würde das lösen — einmal schreiben, überall verwenden
- Bilder im Editor. Der TinyMCE kann Bilder embedden; wir würden Anhänge (z.B. Produktfotos) gerne direkt im Editor einfügbar machen
Fazit
Wenn dein FileMaker-Druck irgendwann an Grenzen stößt — sei es Optik, Flexibilität oder Editierbarkeit kurz vor dem Versand — lohnt sich der Blick auf das HTML/CSS/mPDF-Stack. Du brauchst nicht viel: einen Webserver mit PHP, mPDF aus Composer, ein paar Templates, einen Editor (TinyMCE), und FileMaker auf der anderen Seite, das per JSON die Daten liefert und am Ende das PDF entgegennimmt.
Die mPDF-Quirks (Logo-URLs, Schriften, BOM) klingen alle erstmal nervig — sind aber alle einmal-und-für-immer-Probleme. Hat man sie verstanden, läuft der Stack still und zuverlässig.
Und das Beste: das ganze System ist kein Plugin. Kein Vendor-Lock-in, kein Lizenzmodell, keine Lieferanten-Abhängigkeit. Nur PHP, mPDF, HTML, CSS und etwas FileMaker-JSON. Wartbar von jedem, der diese Bausteine kennt.