Gerade in Zeiten, in denen Automatisierung und Effizienzsteigerung in Unternehmen eine immer größere Rolle spielen, gewinnen flexible Dokumentenerstellungsprozesse zunehmend an Bedeutung.
Im vorangegangenen Artikel habe ich dargelegt, wie moderne Business-Reports auf Basis von Word-Templates umgesetzt werden können. Dabei standen vor allem folgende Anforderungen im Fokus:
- Erstellung von Reports mittels Word-Templates und komfortable Anpassungen im WYSIWYG-Modus
- Wiederverwendung bestehender Vorlagen unter Beibehaltung des unternehmensspezifischen CD
- Integration von HTML-Snippets aus Rich-Text-Editoren (RTE)
- Ausgabe des Berichts im PDF-Format
- Minimaler programmatischer Aufwand bei der Umsetzung
Um die Leistungsfähigkeit von poi-tl (poi-template-language) zu evaluieren, habe ich einen umfassenden Praxistest durchgeführt. Die im Test gewonnenen Erkenntnisse und praxisnahen Hinweise zur Entwicklung und Fehlersuche werden in diesem Artikel ausführlich dokumentiert.
Den vollständigen Quellcode gibt es auf Github.
In meinem Code und den Beispielen nutze ich grundsätzlich lombok, um Boilerplate-Code zu minimieren.
poi-tl (poi-template-language)
Das Tool definiert sich wie folgt:
poi-tl is a Word template engine that generates new documents based on Word template and data. The Word template has rich styles. Poi-tl will perfectly retain the styles in the template in the generated documents. You can also set styles for the tags. The styles of the tags will be applied to the replaced text, so you can focus on the template design.
Damit bietet poi-tl genau die einfache, aber leistungsfähige Lösung, um professionelle Berichte zu erstellen.
Installation und Konfiguration
Die Dokumentation von poi-tl ist sehr umfangreich – auch wenn sie primär auf Chinesisch vorliegt, lässt sie sich mit Google Translate gut nutzen. Besonders hilfreich sind zudem die Beispiele im GitHub-Repository.
Aus diesem Grund verzichte ich auf eine detaillierte Beschreibung der Installation und verweise auf die offizielle Dokumentation.
Erfahrungen
Mit poi-tl lassen sich in kurzer Zeit beeindruckende Ergebnisse erzielen. Der grundlegende Workflow besteht aus drei einfachen Schritten:
- Datenmodell als POJO aufbauen: Mit Tools wie MapStruct wird die Modellierung sogar richtig angenehm.
- Word-Dokument als Template definieren: Layout, Platzhalter und Formatierungen werden dabei wie gewünscht im Template vorbereitet.
- Dokument auf Basis des Templates und der Daten erzeugen: Die fertige Instanz wird dann unkompliziert zusammengeführt.
Fertig!
Configure config = Configure.builder()
.useSpringEL(false)
.build();
XWPFTemplate template = XWPFTemplate.compile("template.docx", config).render(viewData);
template.writeToFile("out_example.docx");
Wichtige Tipps
Basierend auf meinen Erfahrungen möchte ich folgende Hinweise geben:
Best Practices bei Template-Anpassungen
- Template-Anpassungen regelmäßig testen: Selbst kleine Änderungen können Fehler verursachen. Dokumente können dadurch beschädigt werden, bleiben aber meist reparierbar.
- Häufige Commits: Änderungen am Template sollten oft und kleinteilig committet werden, um Probleme leichter nachvollziehen zu können.
Saubere Formatierung und Bedingungsausdrücke
- Formatvorlagen verwenden: Saubere Nutzung von Formatvorlagen, insbesondere in Tabellen, sorgt für ein konsistentes Erscheinungsbild.
- Conditions vs. Blocks: Achtung bei Bedingungsausdrücken und Blöcken – beide beginnen mit
?
, unterscheiden sich jedoch im End-Tag.
Technische Hinweise
- Readonly-Attribute nutzen: Dokumente können gegen ungewollte Änderungen geschützt werden (über Apache POI).
- Inhaltsverzeichnis / Table of Contents (TOC):
- Eine automatische Aktualisierung des TOC ist in poi-tl/POI nicht zuverlässig möglich.
- Inhaltsverzeichnisse müssen daher manuell gepflegt oder alternativ selbst generiert werden.
- Beim eigenen Generieren eines TOC: auf die englischen Bezeichnungen der Formatvorlagen achten (
^Heading
).
Herausforderung: HTML-Texte
Viele moderne Anwendungen speichern Inhalte in Rich-Text-Editoren (RTEs). Damit diese HTML-Daten sauber in Word-Templates integriert werden können, müssen sie zunächst korrekt in Word-Absätze und Formatierungen überführt werden.
Glücklicherweise existiert hierfür ein praktisches Plugin.
HTML Plugin
Das Plugin poi-tl-ext erweitert poi-tl um die Fähigkeit, HTML-Inhalte zu rendern. Die Integration läuft folgendermaßen ab:
- Zunächst die Dependency installieren.
<dependency> <groupId>io.github.draco1023</groupId> <artifactId>poi-tl-ext</artifactId> <version>0.4.18-poi5</version> </dependency>
- Anschließend die Konfiguration anpassen und die entsprechenden Datenfelder (z.B.
description
undcomment
) binden:HtmlRenderConfig htmlRenderConfig = new HtmlRenderConfig(); HtmlRenderPolicy htmlRenderPolicy = new HtmlRenderPolicy(htmlRenderConfig); Configure config = Configure.builder() .bind(htmlRenderPolicy, "description", "comment") .useSpringEL(false) .build();
Thema Leerzeilen
Aktuell gibt es noch kleinere Herausforderungen mit Absätzen hinter HTML-Texten. Dieses Problem konnte ich bislang nocht lösen.
Erzeugtes DOCX in PDF umwandeln
Für die Konvertierung des fertigen Word-Dokuments in ein PDF bieten sich mehrere Ansätze:
- LibreOffice Wrapper: Über das Projekt Docx To PDF kann eine einfache lokale Umwandlung realisiert werden.
(Spannend ist hierbei auch, warum das technisch nicht ganz trivial ist – ein Blick in die Readme lohnt sich!) - Microsoft Graph API: Über die Graph API lässt sich eine nahezu perfekte PDF-Generierung erreichen.
LibreOffice via Docker
Ein einfacher Compose-Stack zur Nutzung des Wrappers:
services:
docx2pdf:
image: moalhaddar/docx-to-pdf:2.1.0-12
environment:
- POOL_SIZE=1
ports:
- "7700:8080"
@Service
@RequiredArgsConstructor
public class DocxToPdfService {
final ApplicationConfigurationProperties config;
final PdfLibreOfficeService pdfLibreOfficeService;
final PdfOneDrivePersonalService pdfOneDrivePersonalService;
public ByteArrayOutputStream convertDocxToPdf(byte[] docxData) throws InvalidConfigurationException, PdfConversionException, IOException {
try {
Objects.requireNonNull(config, "config is required");
Objects.requireNonNull(config.getExport(), "config.export is required");
Objects.requireNonNull(config.getExport().getPdfConversion(), "config.export.pdf-conversion is required");
ApplicationConfigurationProperties.PdfConversionConfig pdfConversion = config.getExport().getPdfConversion();
if (pdfConversion.getPdfConversion() == ApplicationConfigurationProperties.PdfConversionConfig.PdfConverter.LIBREOFFICE) {
return pdfLibreOfficeService.convert(docxData, config.getExport().getPdfConversion());
} else if (pdfConversion.getPdfConversion() == ApplicationConfigurationProperties.PdfConversionConfig.PdfConverter.GRAPH_API) {
return pdfOneDrivePersonalService.convert(docxData, config.getExport().getPdfConversion());
} else {
throw new InvalidConfigurationException("Unknown pdf conversion method " + pdfConversion.getPdfConversion());
}
} catch (NullPointerException e) {
throw new InvalidConfigurationException("docx2pdf conversion - Configuration is missing required properties. " + e.getMessage(), e);
}
}
}
package rocks.m2x.demo.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.Resource;
@ConfigurationProperties(prefix = "m2x.demo")
@Getter
@Setter
public class ApplicationConfigurationProperties {
CorsConfig cors = new CorsConfig();
Export export = new Export();
@Getter
@Setter
public static class CorsConfig {
String[] allowedOrigins = new String[0];
}
@Getter
@Setter
public static class Export {
Resource template = new DefaultResourceLoader().getResource("classpath:templates/template.docx");
boolean readonly = true;
boolean enforceUpdateFields = false;
PdfConversionConfig pdfConversion = new PdfConversionConfig();
}
@Getter
@Setter
public static class PdfConversionConfig {
PdfConverter pdfConversion = PdfConverter.LIBREOFFICE;
PdfConversionLibreOfficeConfig libreOffice = new PdfConversionLibreOfficeConfig();
PdfConversionGraphApiConfig graphApi = new PdfConversionGraphApiConfig();
public enum PdfConverter {
LIBREOFFICE,
GRAPH_API
}
@Getter
@Setter
public static class PdfConversionLibreOfficeConfig {
String url = "http://localhost:7700/pdf";
}
@Getter
@Setter
public static class PdfConversionGraphApiConfig {
String tenantId = "";
String clientId = "";
String clientSecret = "";
String userPrincipalNameOrId;
boolean deleteAfterConversion = true;
}
}
}
Appendix
Im folgenden Anhang dokumentiere ich weiterführende Anpassungen und Optimierungen, die über die Standardfunktionalitäten von poi-tl hinausgehen. Diese Hinweise sind besonders nützlich für Projekte mit speziellen Layout- oder Darstellungsanforderungen.
Rendering von HTML-Listen anpassen (verschönern)
Das Standard-Rendering von Listen aus HTML-Texten in Word empfinde ich als optisch wenig ansprechend. Glücklicherweise lässt sich das Verhalten dank Open Source leicht anpassen.
Hier ein Ausschnitt meiner Konfiguration, um kleinere und schönere Listen-Bullets zu erzeugen:
pom.xml
<!-- add poi-tl -->
<dependency>
<groupId>com.deepoove</groupId>
<artifactId>poi-tl</artifactId>
<version>1.12.2</version>
</dependency>
<dependency>
<groupId>io.github.draco1023</groupId>
<artifactId>poi-tl-ext</artifactId>
<version>0.4.18-poi5</version>
</dependency>
<!-- needed for custom html listrender customhtmlrender.NicerListRenderer -->
<dependency>
<groupId>net.sourceforge.cssparser</groupId>
<artifactId>cssparser</artifactId>
<version>0.9.29</version>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.18.1</version>
</dependency>
HtmlRenderConfig htmlRenderConfig = new HtmlRenderConfig();
htmlRenderConfig.setNumberingSpacing(STLevelSuffix.Enum.forString("tab"));
int listLeftAndIndent = 125;
htmlRenderConfig.setNumberingHanging(listLeftAndIndent);
htmlRenderConfig.setNumberingIndent(listLeftAndIndent);
htmlRenderConfig.setCustomRenderers(List.of(new NicerListRenderer(NicerListStyleType.NicerUnordered.DISC).setLeft(listLeftAndIndent)));
HtmlRenderPolicy htmlRenderPolicy = new HtmlRenderPolicy(htmlRenderConfig);
@Service
@RequiredArgsConstructor
@Slf4j
public class RenderDocxReportService {
String DATE_FORMAT = "yyyy-MM-dd";
final ApplicationConfigurationProperties config;
public ByteArrayOutputStream renderSoa(SoA i) throws IOException {
ApplicationConfigurationProperties.Export exportConfig = config.getExport();
try (InputStream templateIs = exportConfig.getTemplate().getInputStream()) {
i.setCreated(LocalDate.now().format(DateTimeFormatter.ofPattern(DATE_FORMAT)));
// numbering controls by concatenating group nr and control nr
i.getGroups().forEach(group -> {
AtomicInteger cn = new AtomicInteger(1);
List<Control> controls = group.getControls();
if (controls != null) {
controls.forEach(c -> {
c.setNr(group.getNr() + "." + c.getNr());
cn.getAndIncrement();
});
}
});
HtmlRenderConfig htmlRenderConfig = new HtmlRenderConfig();
htmlRenderConfig.setNumberingSpacing(STLevelSuffix.Enum.forString("tab"));
int listLeftAndIndent = 125;
htmlRenderConfig.setNumberingHanging(listLeftAndIndent);
htmlRenderConfig.setNumberingIndent(listLeftAndIndent);
htmlRenderConfig.setCustomRenderers(List.of(new NicerListRenderer(NicerListStyleType.NicerUnordered.DISC).setLeft(listLeftAndIndent)));
HtmlRenderPolicy htmlRenderPolicy = new HtmlRenderPolicy(htmlRenderConfig);
Configure config = Configure.builder()
.useSpringEL(false)
.bind(htmlRenderPolicy, "description", "company")
.build();
try (XWPFTemplate t = XWPFTemplate.compile(templateIs, config)) {
XWPFTemplate d = t.render(i);
NiceXWPFDocument xwpfDocument = d.getXWPFDocument();
if (exportConfig.isReadonly()) {
// read only protection
xwpfDocument.enforceReadonlyProtection();
}
if (exportConfig.isEnforceUpdateFields()) {
// enforce update fields on next open.
xwpfDocument.enforceUpdateFields();
}
if (i.isDraft()) {
try {
addWatermatermark(xwpfDocument, "!!! DRAFT !!!");
} catch (InvalidFormatException e) {
throw new IOException(e);
}
}
log.debug("Rendering docx report for SoA {} done.", i.getVersion());
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
d.write(byteArrayOutputStream);
return byteArrayOutputStream;
}
}
}
void addWatermatermark(XWPFDocument document, String text) throws InvalidFormatException {
// Kopfzeile hinzufügen
XWPFHeaderFooterPolicy headerFooterPolicy = document.getHeaderFooterPolicy();
if (headerFooterPolicy == null) {
headerFooterPolicy = document.createHeaderFooterPolicy();
}
// Kopfzeile der ersten Seite erstellen
XWPFHeader firstPageHeader = headerFooterPolicy.getFirstPageHeader();
if (firstPageHeader == null) {
firstPageHeader = headerFooterPolicy.createHeader(XWPFHeaderFooterPolicy.FIRST);
}
createHeader(firstPageHeader, text);
// Kopfzeile auf anderen Seiten erstellen
XWPFHeader header = headerFooterPolicy.getDefaultHeader();
if (header == null) {
header = headerFooterPolicy.createHeader(XWPFHeaderFooterPolicy.DEFAULT);
}
createHeader(header, text);
}
private static void createHeader(XWPFHeader header, String text) {
XWPFParagraph paragraph = header.getParagraphArray(0);
if (paragraph == null) {
paragraph = header.createParagraph();
}
paragraph.setStyle("IntensivesZitat"); // Verwende die Formatvorlage "IntensivesZitat"
// Text in der Kopfzeile erstellen
XWPFRun run = paragraph.createRun();
run.setText(text);
}
}