Dokumente programmatisch erstellen klingt simpel — ein paar Felder befüllen, als PDF exportieren, fertig. In der Praxis landet man schnell bei XML-Namespaces, kaputten Styles und Bibliotheken, die seit Jahren kein Update mehr gesehen haben.
Ich habe beides ausprobiert: Den klassischen Weg mit Apache POI in Java und den modernen Ansatz mit Puppeteer. Hier ein Vergleich — was funktioniert, was nervt, und warum ich heute nicht mehr zurückgehen würde.
Der klassische Weg: Apache POI und die Office-XML-Struktur
Was ist Apache POI?
Apache POI ist eine Java-Bibliothek zum Lesen und Schreiben von Microsoft-Office-Dateien — Word, Excel, PowerPoint. Das Projekt existiert seit über 20 Jahren und ist der De-facto-Standard in der Java-Welt.
Technisch solide, aber nicht unbedingt angenehm in der Nutzung.
Der Standard dahinter: Office Open XML (OOXML)
Um zu verstehen, warum POI so mühsam ist, lohnt sich ein Blick darauf, was eine .docx-Datei eigentlich ist.
Eine .docx-Datei ist kein einzelnes Dokument, sondern ein ZIP-Archiv mit einer ganzen Ordnerstruktur aus XML-Dateien. Der Text liegt in word/document.xml, Styles in word/styles.xml, Beziehungen zwischen den Teilen in word/_rels/document.xml.rels.
Das Format heißt Office Open XML (OOXML) und ist ein ECMA/ISO-Standard. Auf dem Papier ein offener, standardisierter Weg, Office-Dokumente darzustellen. In der Praxis sieht das so aus:
<w:p w:rsidR="00A77427" w:rsidRDefault="007F1D13">
<w:pPr>
<w:pStyle w:val="Heading1"/>
</w:pPr>
<w:r>
<w:rPr>
<w:b/>
<w:sz w:val="28"/>
</w:rPr>
<w:t>Hallo Welt</w:t>
</w:r>
</w:p>
Ein fetter “Hallo Welt”-Text als Heading. Dafür braucht es einen Paragraph (w:p), Paragraph-Properties (w:pPr), einen Run (w:r), Run-Properties (w:rPr) und dann erst den eigentlichen Text (w:t). Wer “Hallo” fett und “Welt” kursiv will, braucht zwei separate Runs.
Das ist kein Bug — so ist der Standard. Genau das macht die programmatische Arbeit damit so unintuitiv.
Warum das beim Entwickeln wehtut
-
Verschachtelte Abstraktionen: Man denkt in Absätzen und Formatierungen. OOXML denkt in Paragraphs, Runs, Properties, Numbering-Definitions und Relationship-Parts. Der Abstand zwischen “ich will eine Tabelle mit Rahmen” und dem, was man dafür im Code schreiben muss, ist enorm.
-
Inkonsistentes Verhalten: Word interpretiert OOXML großzügig. Zwei optisch identische Dokumente können völlig unterschiedliches XML haben — je nachdem, wie sie erstellt wurden.
-
Style-Vererbung: Styles erben voneinander, werden von Theme-Defaults überschrieben und verhalten sich je nach Dokumentenversion unterschiedlich. Debugging wird zum Detektivspiel.
-
Tabellen: Zellen mergen, Rahmen setzen, Spaltenbreiten definieren — jeder Schritt erfordert ein eigenes XML-Konstrukt mit eigenem Namespace. Eine einfache Tabelle mit zusammengeführten Zellen kann schnell 100 Zeilen XML werden.
-
Die API spiegelt die Komplexität: Apache POI abstrahiert das XML nur bedingt. Man arbeitet mit
XWPFParagraph,XWPFRun,CTTblPrund ähnlichen Klassen, die die XML-Struktur direkt abbilden.
Hat es funktioniert? Ja. War es wartbar? Bedingt.
Der moderne Weg: Puppeteer
Die Idee
Warum mit XML-Strukturen kämpfen, wenn man HTML und CSS schon kann?
Der Ansatz:
- Dokument als HTML/CSS bauen (oder mit einer Template-Engine wie Handlebars)
- Puppeteer starten (headless Chrome)
- HTML rendern lassen
- Als PDF exportieren
Kein XML, keine Namespaces, keine Run-Properties.
In der Praxis
const puppeteer = require('puppeteer');
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(`
<html>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
h1 { color: #333; border-bottom: 2px solid #666; }
table { width: 100%; border-collapse: collapse; }
td, th { border: 1px solid #ccc; padding: 8px; }
</style>
<body>
<h1>Angebot Nr. ${offerNumber}</h1>
<p>Sehr geehrte/r ${customerName},</p>
<table>
<tr><th>Position</th><th>Beschreibung</th><th>Preis</th></tr>
${items.map(i =>
`<tr><td>${i.pos}</td><td>${i.desc}</td><td>${i.price}</td></tr>`
).join('')}
</table>
</body>
</html>
`);
await page.pdf({ path: 'angebot.pdf', format: 'A4' });
await browser.close();
Das ist im Wesentlichen der gesamte Code. Der gleiche Output hätte mit Apache POI ein Vielfaches an Zeilen gebraucht.
Vorteile
- HTML und CSS — Technologien, die jeder Webentwickler kennt. Kein neues Format, keine kryptischen APIs.
- Vorschau im Browser — Template öffnen, F12 drücken, fertig. Kein XML-Debugging.
- CSS-Layout — Grid, Flexbox, Media Queries. Komplexe Layouts, die in OOXML extrem aufwändig wären, sind mit CSS einfach umsetzbar.
- Template-Engines — Handlebars, Nunjucks oder einfach Template-Literals. Daten einsetzen wie bei einer Website.
- Wartbarkeit — Neue Teammitglieder können sofort loslegen. HTML/CSS-Kenntnisse sind weit verbreitet, OOXML-Expertise nicht.
Einschränkungen
Auch der Puppeteer-Ansatz hat Schwächen:
- Seitenumbrüche — CSS bietet
page-break-before/afterundbreak-inside, aber das Verhalten ist bei dynamischen Inhalten nicht immer vorhersehbar. - Header und Footer — Puppeteer unterstützt sie in
page.pdf(), aber nur als separates HTML-Template. Seitenzahlen funktionieren, komplexe Header mit Bildern werden aufwändig. - Kein .docx-Output — Puppeteer erzeugt PDFs. Wer ein editierbares Word-Dokument braucht, muss auf andere Bibliotheken zurückgreifen oder beide Ansätze kombinieren.
- Ressourcenverbrauch — Ein Headless Chrome ist nicht leichtgewichtig. Bei Batch-Verarbeitung braucht man Pooling und Ressourcen-Management.
- Print-CSS —
@media printund@pagehaben eigene Regeln. Nicht alles, was am Bildschirm funktioniert, sieht im PDF genauso aus.
Der entscheidende Unterschied: Diese Probleme sind lösbar und gut dokumentiert. Bei OOXML-Problemen landet man dagegen oft in jahrzehntealten JIRA-Tickets mit dem Status “Won’t Fix”.
Der Vergleich auf einen Blick
| Aspekt | Apache POI (OOXML) | Puppeteer (HTML/CSS) |
|---|---|---|
| Lernkurve | Steil — OOXML-Verständnis nötig | Flach — HTML/CSS reicht |
| Output-Format | .docx, .xlsx nativ | PDF nativ, .docx nur über Umwege |
| Layout-Kontrolle | Mühsam, viel Boilerplate | CSS — flexibel und mächtig |
| Debugging | XML durchforsten | Browser-DevTools |
| Wartbarkeit | Gering, spezialisiertes Wissen nötig | Hoch, Standard-Webskills |
| Performance | Leichtgewichtig, schnell | Headless Browser = mehr Ressourcen |
| Editierbare Dokumente | Ja | Nein (nur PDF) |
Einsatz in der Praxis
Im Kontext von Dokumentenmanagementsystemen (DMS) wird der Ansatz besonders nützlich:
- Angebote und Rechnungen — Daten aus CRM oder ERP, festes Template, variable Werte.
- Verträge und Formulare — Standardisierte Dokumente mit individuellen Feldern. Einmal bauen, tausendfach nutzen.
- Berichte — KPI-Reports, Statusberichte, Audit-Protokolle. Daten ziehen, Template befüllen, PDF ablegen.
- Onboarding-Dokumente — Arbeitsverträge, Checklisten, Zugangsdaten. Gleiche Struktur, individuelle Inhalte.
- Archivierung — Automatisch generierte PDFs sind langzeitstabil (PDF/A) und durchsuchbar.
Ein typischer Workflow: Ein Event triggert die Generierung, Daten werden aus der Quelle gezogen, das Template befüllt und das PDF automatisch im DMS abgelegt — mit Metadaten, Versionierung und Zugriffsrechten.
Fazit
OOXML und Apache POI funktionieren, aber der Aufwand steht oft in keinem Verhältnis zum Ergebnis. Puppeteer ist keine Universallösung — aber es nutzt Technologien, die Webentwickler bereits beherrschen, und macht die Dokumentengenerierung zu einem lösbaren Problem.
Solange der Output PDF sein darf, würde ich heute immer den HTML-zu-PDF-Weg gehen.