Professionelle Berichte aus Word-Templates mit poi-tl

Professionelle Berichte aus Word-Templates mit poi-tl

Erfahren Sie, wie Sie mit poi-tl und Word-Templates effizient Business-Reports erstellen und in PDFs umwandeln – inklusive HTML-Integration und praktischen Tipps.

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:

  1. Datenmodell als POJO aufbauen: Mit Tools wie MapStruct wird die Modellierung sogar richtig angenehm.
  2. Word-Dokument als Template definieren: Layout, Platzhalter und Formatierungen werden dabei wie gewünscht im Template vorbereitet.
  3. 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");

Einfacher Report

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.

Template des einfachen Reports

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 und comment) 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;
        }
    }


}

Report mit alternativem Template

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);
    }

}

Vollständiger Quellcode auf Github

Demo Professional DOCX PDF Report on GitHub