PDF-Generierung aus dem Container – speedata Publisher

2 Kommentare

Nach fast fünf Jahren bei codecentric ist es nun endlich so weit, dass ich auf meine Zeit vor codecentric zurückblicke und ein Thema betrachten möchte, das immer noch viele Menschen im Rahmen von Softwareentwicklungsprojekten bewegt: die Generierung von PDFs. Noch immer versuchen wir, mithilfe von Bibliotheken wie iText oder PDFBox auf programmatische Weise diese portablen Dokumente zu erzeugen. Was mit einfachen Layouts auch einwandfrei funktioniert. Aber was passiert, wenn die Layouts und Datenlagen komplexer erscheinen und die Geschwindigkeit bei der Erstellung entscheidend ist?

Dazu möchte ich ein wenig in der Zeit zurückgehen. Während meines Studiums Anfang der 2000er und auch danach habe ich immer wieder versucht, die Erstellung von PDFs auf Basis von LaTeX und einiger Derivate wie Lua(La)TeX und ConTeXt zu automatisieren, wobei die Datenlagen grundsätzlich unterschiedlich waren. Und genau diese erfolgreichen Experimente führten dazu, dass ich mich Anfang der 2010er in die Welt des Consulting und der Entwicklung begab und in einem Team versuchte, einen TeX-Renderer in den Markt des Database Publishings zu bringen. Hierbei ging es konkret darum, Kataloge und Datenblätter in hoher Stückzahl und gleichzeitig hoher Geschwindigkeit zu erstellen. Es ergaben sich eine Menge Projekte mit vielen Learnings und vielen interessanten Einblicken in komplexe Datenstrukturen sowie entsprechenden Datenbanken und ersten Schritten hin zu Integrationen. Mitte der 2010er wurde ich dann auf ein Produkt aufmerksam, das zumindest den Aufwand hinsichtlich der Erstellung von Layouts zu reduzieren versprach: speedata Publisher. Bei diesem Renderer bilden XML-Dateien die Grundlage sowohl bezogen auf die Daten als auch auf ein mögliches Layout. Das Tool basiert ansonsten technologisch auf LuaTeX, Lua und Go. Nun aber genug der Theorie und Geschichte, hinein in die Praxis.

Installation und sonstige Voraussetzungen

Zuallererst wollen wir den Publisher auf unserem jeweiligen System installieren. Hierzu werden für das jeweilige Betriebssystem Pakete zur Verfügung gestellt. Für die nachfolgenden Betrachtungen verwende ich den Development-Release (bei Veröffentlichung des Blog-Posts in der Version 4.3.7), Visual Code Studio mit XML-Extension von Red Hat. Der speedata Publisher wird mit sp im Terminal aufgerufen, dazu muss in der Path-Variable das bin-Verzeichnis des Publishers hinterlegt werden. Um die Autovervollständigung in Visual Studio Code nutzen zu können, hinterlegen wir in den Einstellungen der XML-Extension das XML-Schema des Publishers.

Das obligatorische Beispiel

Beim Database Publishing geht es ja, wie der Begriff schon erkennen lässt, um die Veröffentlichung von Daten aus einer entsprechenden Quelle. Für den speedata Publisher müssen die Daten in Form einer XML bereitstehen. Wobei das Produkt auch in der Lage ist, entsprechende Datenlage mit vorgelagerten Filtern in XML umzuwandeln. Dies würde diesen Einführungspost inhaltlich deutlich sprengen. Für das Beispiel verwenden wir Produktdaten eines fiktiven Baumarktes, die sich in Zeiten von Corona großer Beliebtheit erfreuen.

<products>
    <product name="Schnellbauschraube">
        <image>screw.pdf</image>
        <description>
            <diameter>3,9mm</diameter>
            <details>Senkkopf, Grobgewinde</details>
            <dash>Ideal für Gipskartonplatten auf Holz</dash>
            <dash>Phosphatierte Oberfläche</dash>
            <dash>Gute Anhaftung von Gips auf dem Schraubkopf</dash>
            <dash>Vor Rost geschützt</dash>
        </description>
        <attribute type="length" value="25mm">
            <quantity unit="Stk" price="2" currency="€">200</quantity>
            <quantity unit="Stk" price="5" currency="€">500</quantity>
            <quantity unit="Stk" price="7" currency="€">1000</quantity>
        </attribute>
        <attribute type="length" value="35mm">
            <quantity unit="Stk" price="3" currency="€">200</quantity>
            <quantity unit="Stk" price="6" currency="€">500</quantity>
            <quantity unit="Stk" price="8" currency="€">1000</quantity>
        </attribute>
        <attribute type="length" value="45mm">
            <quantity unit="Stk" price="4" currency="€">200</quantity>
            <quantity unit="Stk" price="7" currency="€">500</quantity>
            <quantity unit="Stk" price="9" currency="€">1000</quantity>
        </attribute>
    </product>
    <product name="Schraubendreher-Set">
        <image>screwdrivers.pdf</image>
        <description>
            <details>Geeignet für: Schlitz und Kreuzschlitz PH, 6-tlg.</details>
            <dash>Isolierte VDE Klingen für sicheres Arbeiten</dash>
            <dash>Mehrkomponentiger Griff</dash>
            <dash>Mit Griffkennzeichnung zum leichteren Finden</dash>
            <dash>Erhöhter Korrosionsschutz</dash>
        </description>
        <attribute type="package" value="6">
            <quantity unit="Verpackung" price="11" currency="€">1</quantity>
        </attribute>
    </product>
    <product name="Schraubendreher-Set">
        <image>screwdrivers.pdf</image>
        <description>
            <details>Geeignet für: Schlitz und Kreuzschlitz PH, 6-tlg.</details>
            <dash>Isolierte VDE Klingen für sicheres Arbeiten</dash>
            <dash>Mehrkomponentiger Griff</dash>
            <dash>Mit Griffkennzeichnung zum leichteren Finden</dash>
            <dash>Erhöhter Korrosionsschutz</dash>
        </description>
        <attribute type="package" value="6">
            <quantity unit="Verpackung" price="11" currency="€">1</quantity>
        </attribute>
    </product>
    <product name="Schraubendreher-Set">
        <image>screwdrivers.pdf</image>
        <description>
            <details>Geeignet für: Schlitz und Kreuzschlitz PH, 8-tlg.</details>
            <dash>Isolierte VDE Klingen für sicheres Arbeiten</dash>
            <dash>Mehrkomponentiger Griff</dash>
            <dash>Mit Griffkennzeichnung zum leichteren Finden</dash>
            <dash>Erhöhter Korrosionsschutz</dash>
        </description>
        <attribute type="package" value="8">
            <quantity unit="Verpackung" price="13" currency="€">1</quantity>
        </attribute>
    </product>
    <product name="Schraubendreher-Set">
        <image>screwdrivers.pdf</image>
        <description>
            <details>Geeignet für: Schlitz und Kreuzschlitz PH, 10-tlg.</details>
            <dash>Isolierte VDE Klingen für sicheres Arbeiten</dash>
            <dash>Mehrkomponentiger Griff</dash>
            <dash>Mit Griffkennzeichnung zum leichteren Finden</dash>
            <dash>Erhöhter Korrosionsschutz</dash>
        </description>
        <attribute type="package" value="10">
            <quantity unit="Verpackung" price="15" currency="€">1</quantity>
        </attribute>
    </product>
    <product name="Schraubendreher-Set">
        <image>screwdrivers.pdf</image>
        <description>
            <details>Geeignet für: Schlitz und Kreuzschlitz PH, 12-tlg.</details>
            <dash>Isolierte VDE Klingen für sicheres Arbeiten</dash>
            <dash>Mehrkomponentiger Griff</dash>
            <dash>Mit Griffkennzeichnung zum leichteren Finden</dash>
            <dash>Erhöhter Korrosionsschutz</dash>
        </description>
        <attribute type="package" value="12">
            <quantity unit="Verpackung" price="17" currency="€">1</quantity>
        </attribute>
    </product>
</products>

Der Erstellungsprozess

Nun gilt es, diese Daten in ein Layout zu setzen. Hierzu wird eine layout.xml erstellt.

<Layout
  xmlns="urn:speedata.de:2009/publisher/en"
  xmlns:sd="urn:speedata:2009/publisher/functions/en">
</Layout>

Ein Layout beginnt grundsätzlich auf Basis eines Layout-Nodes mit dem entsprechendem Namespace. Im Folgenden werden die Sprache des PDFs und die Schriftarten mit ihren einzelnen Definitionen beschrieben.

<Options mainlanguage="German" imagenotfound="warning"/>
  <LoadFontfile name="RubikRegular" filename="https://github.com/googlefonts/rubik/raw/main/fonts/otf/Rubik-Regular.otf"/>
  <LoadFontfile name="RubikBold" filename="https://github.com/googlefonts/rubik/raw/main/fonts/otf/Rubik-Bold.otf"/>
  <LoadFontfile name="RubikItalic" filename="https://github.com/googlefonts/rubik/raw/main/fonts/otf/Rubik-Italic.otf"/>
  <LoadFontfile name="RubikMedium" filename="https://github.com/googlefonts/rubik/raw/masin/fonts/otf/Rubik-Medium.otf"/>
  <LoadFontfile name="RubikLight" filename="https://github.com/googlefonts/rubik/raw/main/fonts/otf/Rubik-Light.otf"/>
  <LoadFontfile name="RubikBoldItalic" filename="https://github.com/googlefonts/rubik/raw/main/fonts/otf/Rubik-BoldItalic.otf"/>
  <LoadFontfile name="RubikMediumItalic" filename="https://github.com/googlefonts/rubik/raw/main/fonts/otf/Rubik-MediumItalic.otf"/>
  <LoadFontfile name="RubikLightItalic" filename="https://github.com/googlefonts/rubik/raw/main/fonts/otf/Rubik-LightItalic.otf"/>
  <DefineFontfamily fontsize="11" leading="12" name="description">
    <Regular fontface="RubikRegular"/>
    <Bold fontface="RubikBold"/>
    <Italic fontface="RubikItalic"/>
    <BoldItalic fontface="RubikBoldItalic"/>
  </DefineFontfamily>
  <DefineFontfamily fontsize="12" leading="13" name="product">
    <Regular fontface="RubikRegular"/>
    <Bold fontface="RubikBold"/>
    <Italic fontface="RubikItalic"/>
    <BoldItalic fontface="RubikBoldItalic"/>
  </DefineFontfamily>
  <DefineFontfamily fontsize="17" leading="19" name="price">
    <Regular fontface="RubikRegular"/>
    <Bold fontface="RubikBold"/>
    <Italic fontface="RubikItalic"/>
    <BoldItalic fontface="RubikBoldItalic"/>
  </DefineFontfamily>

Nach diesen Fontregeln gilt es, ein einfaches Tabellenlayout in das PDF zu schreiben. Hierzu werden mittels <Record> die einzelnen Nodes der Daten-XML angesprochen, wobei eine erste Tabelle erst auf Basis des <product> -Node erstellt wird. Mittels <Switch> wird auf Basis von Variablen ein dynamisches Layout in Bezug auf die Produktbeschreibung erzeugt. Für die Nodes <dash>,<attribute>und <quantity> werden die Elemente im PDF per Loops über <ForAll> erzeugt.

  <Record element="products">
    <ProcessNode select="product"/>
  </Record>
  <Record element="product">
    <SetVariable variable="diameter" select="description/diameter"/>
    <SetVariable variable="type" select="attribute/@type"/>
    <PlaceObject>
      <Table stretch="max">
        <Columns>
          <Column width="1*"/>
          <Column width="2*"/> 
        </Columns>
        <Tr>
          <Td>
            <Image file="{image}"/>
          </Td>
          <Td align="left" valign="top">
            <Paragraph fontfamily="product"><B><Value select="@name"/></B></Paragraph>
            <Paragraph fontfamily="description">
              <Switch>
                <Case test="not(empty($diameter))">
                  <Value select="$diameter"/>
                  <Value> Durchmesser, </Value>
                  <Value select="description/details"/>
                </Case>
                <Otherwise>
                  <Value select="description/details"/>
                </Otherwise>
              </Switch>
            </Paragraph>
            <ForAll select="description/dash">
              <Table max="stretch" fontfamily="description">
                <Columns>
                  <Column width="5mm"/>
                  <Column width="5mm"/>
                  <Column width="1*"/>
                </Columns>
                <Tr valign="top">
                  <Td></Td>
                  <Td>
                    <Paragraph>
                      <Value>• </Value>
                    </Paragraph>
                  </Td>
                  <Td>
                    <Paragraph textformat="justified">
                      <Value select="."/>
                    </Paragraph>
                  </Td>
                </Tr>
              </Table>
            </ForAll>
            <ForAll select="attribute">
              <Switch>
                <Case test="$type != 'package'">
                  <Table>
                    <Tr>
                      <Td></Td>
                      <Td><Paragraph><Value select="@value"/>:</Paragraph></Td>
                    </Tr>
                  </Table>
                  <Table width="5" stretch="max">
                    <Tablehead>
                      <Tr backgroundcolor="gray">
                        <Td><Paragraph><Value>Menge</Value></Paragraph></Td>
                        <Td><Paragraph><Value>Einheit</Value></Paragraph></Td>
                        <Td><Paragraph><Value>Preis</Value></Paragraph></Td>
                      </Tr>
                    </Tablehead>
                    <ForAll select="quantity">
                      <Tr>
                        <Td><Paragraph><Value select="."/></Paragraph></Td>
                        <Td><Paragraph><Value select="@unit"/></Paragraph></Td>
                        <Td><Paragraph><Value select="concat(@price,@currency"/></Paragraph></Td>
                      </Tr>
                    </ForAll>
                  </Table>
                </Case>
                <Otherwise>
                  <Table width="10" stretch="max">
                    <Tr>
                      <Td align="right" height="30"><Paragraph fontface="price"><Value select="concat(quantity/@price,quantity/@currency)"/></Paragraph></Td>
                    </Tr>
                  </Table>
                </Otherwise>
              </Switch>
            </ForAll>
          </Td>
        </Tr>
      </Table>
    </PlaceObject>
  </Record>

Das Ergebnis stellt nun einen Produktkatalog mit einem einfachen Tabellenlayout, inklusive Logik, dar. Da bei Erstellung des PDFs die entsprechenden Produktbilder nicht vorhanden waren, wurde diese direkt durch einen Platzhalter ersetzt. Die Erstellung des PDFs dauerte auf einem MacBook Pro (2,3 GHz 8-Core Intel Core i9, 32 GB RAM) genau 0.579982 Sekunden.
produktkatalog im speedata publisher
Wie wir sehen konnten, ist es mithilfe des speedata Publishers recht einfach, auf Basis von Datenstrukturen ein entsprechendes PDF in hoher Geschwindigkeit zu erstellen. Zum Abschluss gilt es nun, die lokale Umgebung in einen Container zu packen.

PDFs aus dem Container

Leider gibt es noch keinen fertigen Docker-Container, sodass wir direkt loslegen könnten. Daher gilt es, diesen nun zu erstellen.

FROM ubuntu:latest

ENV DEBIAN_FRONTEND=noninteractive
RUN apt update && apt install -y gnupg openjdk-13-jre-headless inkscape

COPY files/speedata.list /etc/apt/sources.list.d
COPY files/gpgkey-speedata.txt /tmp/gpgkey-speedata.txt
RUN  APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 apt-key add /tmp/gpgkey-speedata.txt

RUN apt update
RUN apt install -y speedata-publisher

WORKDIR /server
ADD files/publisher.cfg /server

ENTRYPOINT [ "sp", "server" ]

Im Ordner files befindet sich neben speedata.list und gpgkey-speedata.txt noch die Datei publisher.cfg. Hier wird über Key-/Value-Parameter die Konfiguration des speedata Publishers vorgenommen. Wenn das Image nun mit docker build -t pdf-container und der Container anschließend mittelsdocker run --rm -p 5266:5266 pdf-container gestartet wurde, verfügt dieser über die nachfolgenden Endpunkte.

MethodeEndpunktBeschreibung
GET/availableEs wird ein Statuscode 200 zurückgegeben, wenn der Publishing Server verfügbar ist.
POST/v0/publishEs werden Daten an den Server gesendet, um einen Publishing-Lauf zu starten. Eine ID des Publishing-Laufs wird als Rückgabewert geliefert.
GET/v0/publish/Es wird geprüft, ob ein Publishing Prozess beendet ist.
GET/v0/pdf/Es wird auf die Fertigstellung eines PDFs gewartet und das PDF wird heruntergeladen.
GETGET/v0/data/Es wird die data.xml des jeweiligen Publishing-Laufs in Referenz zur angegebenen ID geladen.
GET/v0/layout/Es wird die layout.xml des jeweiligen Publishing-Laufs in Referenz zur angegebenen ID geladen.
GET/v0/statusfile/Es wird die Statusdatei (publisher.status) des jeweiligen Publishing-Laufs in Referenz zur angegebenen ID geladen.
GET/v0/statusEs wird eine Übersicht über die laufenden Publishing-Prozesse zurückgegeben.
GET/v0/status/Es wird eine Übersicht über einen laufenden Publishing-Prozess in Referenz zur angegebenen ID zurückgegeben.
POST/v0/delete/Es wird ein Publishing-Lauf in Referenz zur angegebenen ID gelöscht.

Mit /v0/publish können wir nun Daten und das Layout an den „PDF-Container“ senden. Diese müssen in einer JSON-Datei base64-kodiert hinterlegt werden. Was bezogen auf unser Beispiel folgendermaßen aussehen würde.

{
    "layout.xml": "...",
    "data.xml": "..."
}

Als Rückgabe erhalten wir eine ID mit Statuscode 201 zurück. Mithilfe dieser ID können wir nun dem GET-Request v0/pdf/<id> das PDF vom Server laden. Durch Setzen des Parameters delete auf den Wert false wird das PDF nicht vom Server gelöscht.

Fazit

Mit dem speedata Publisher können wir auf recht einfache Art und Weise PDFs erzeugen und diesen Erstellungsprozess, vor allem aufgrund eines Containers, in bestehende Entwicklungsprojekte einbinden. Das Beispiel und das Dockerfile sind in GitHub zu finden.

Daniel Kocot ist der erste Kong Champion (Experte für das API Gateway Kong) Deutschlands und seit 2016 bei codecentric. Schon seit Anfang der 2000er Jahre widmet er sich dem Thema der „Digitalen Transformation“. Neben den aktuellen Schwerpunkten API Design und Management, Application Lifecycle Management Tooling und VoiceUI, ist er auch Experte für den Einsatz von Produktinformationssystemen (PIM) und Database-Publishing mit Hilfe von Rendering-Technologien.

Über 1.000 Abonnenten sind up to date!

Die neuesten Tipps, Tricks, Tools und Technologien.
Jede Woche direkt in deine Inbox.

Kostenfrei anmelden und immer auf dem neuesten Stand bleiben!
(Keine Sorge, du kannst dich jederzeit abmelden.)

Kommentare

  • Bernhard

    20. April 2021 von Bernhard

    Hallo,
    die Anforderung PDFs zu erstellen ist mir auch schon oft untergekommen und ich habe schon einige Lösungen dafür gesehen.

    Noch einfacher als diese Lösung finde ich direkt HTML + CSS Paged Media zu verwenden. Dazu gibt es mehrere Tools (OpenSource, Kommerziell, Cloud, Frontend, Backend) um aus HTML+CSS die PDFs zu generieren.
    Der Vorteil ist, dass HTML + CSS weit verbreitet ist und man muss nur die Erweiterungen für Paged Media dazulernen, Vorlagen können mit beliebigen HTML Template Tools erstellt werden.

    Infos:
    https://www.w3.org/TR/css-page-3/
    https://print-css.rocks/
    https://print-css.rocks/

    • Daniel Kocot

      7. Juni 2021 von Daniel Kocot

      Hallo Bernhard,

      danke Dir für den Hinweis auf Print-CSS. In meinen Augen eine sehr komplexe Lösung, da sie aus vielen einzelnen Komponenten besteht. Mit Hinblick auf Geschwindigkeit & Qualität erscheint mir speedata publisher die leichtgewichtigere Lösung zu sein, die sich auch sehr gut in bestehende Prozesse integrieren lässt.

      Viele Grüße,
      Daniel

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.