Alles andere als eine Textwüste

Drei Wege, ein Word-Dokument mit PHPWord zu erstellen
Keine Kommentare

Ein Word-Dokument in PHP zu erstellen, klingt zunächst einmal nach einer großen Tüftelarbeit, die viel Zeit beansprucht. Anwendungsmöglichkeiten gibt es einige, und so lohnt es sich, gleich mehrere Möglichkeiten auszuprobieren, um diese Aufgabe vernünftig zu lösen. Und wir werden sehen: Es ist doch einfacher als man denkt.

Ein Word-Dokument in PHP zu erstellen, kann eine ganz schöne Herausforderung werden. Microsoft Word bietet eine Vielzahl an Optionen und Einstellungen, und die möchte man natürlich auch nutzen, um ein zufriedenstellendes Ergebnis zu erhalten. Vielleicht ist man ja in der Situation, Dokumente für einen Geschäftskunden erstellen zu müssen, der nur Word kennt, aber keine Ahnung von den Einschränkungen in PHP hat. Da kann es natürlich passieren, dass der Kunde unzufrieden ist, wenn etwas auf einmal nicht machbar ist.

In diesem Artikel werden wir uns PHPWord etwas genauer anschauen und drei verschiedene Wege zeigen, mit denen man Word-Dokumente erstellen kann: Grundlegendes, einfaches Templating, die Erstellung von Dokumenten komplett in PHP und – um ein bisschen verrückt zu werden und die Sache auf die Spitze zu treiben – die Kombination von beidem. Nach der Lektüre sollte man auf jeden Fall eine Idee davon haben, wie man sich am besten seinen persönlichen Word-Creator gestalten könnte.

PHPWord

PHPWord ist Teil der PHPOffice-Bibliothek, die es erlaubt, Dokumente in verschiedenen Dateiformaten komplett in PHP zu lesen und zu schreiben. Die unterstützten Formate sind HTML, ODText, PDF, RTF und Word 2007, das wir uns genauer anschauen werden. PHPWord ist aktuell in Version 0.14 auf GitHub verfügbar. Noch ist nicht jedes Feature aus Microsoft Word komplett implementiert, doch die zur Verfügung stehenden Optionen sind schon sehr umfangreich. Einige davon werden im Folgenden anhand von Codebeispielen vorgestellt oder erwähnt. Damit diese Codebeispiele laufen, muss man natürlich PHPWord in sein Projekt einbinden. Zu Beginn beschäftigen wir uns mit dem einfachen Templating aus Word heraus.

Grundlegendes, einfaches Templating

Mit PHPWord kann man sehr leicht mit Word-Templates arbeiten. Wenn man ein einfaches Dokument mit statischem Layout hat, in dem sich beispielsweise nur die Empfängeradressen ändern sollen, dann ist das der schnellste Weg. In Abbildung 1 sieht man das Template Template.docx, das mit Word erstellt wurde. Wie man sehen kann, sind Datum und Empfängeradresse durch Platzhalter ersetzt. Sie haben die Form ${placeholder}. Der PHP-Code, den man benötigt, um dieses Template dynamisch zu füllen, findet sich in Listing 1.

$templateProcessor = new \PhpOffice\PhpWord\TemplateProcessor('Template.docx');

$templateProcessor->setValue('date', date("d-m-Y"));
$templateProcessor->setValue('name', 'John Doe');
$templateProcessor->setValue(
  ['city', 'street'],
  ['Sunnydale, 54321 Wisconsin', '123 International Lane']);

$templateProcessor->saveAs('MyWordFile.docx');

Abb. 1: Einfaches, in Word erstelltes Template

Sehen wir uns das mal genauer an: Erst lädt man das Template in einen Template Processor, indem man die entsprechende Klasse aufruft und den Pfad zum Template als Parameter übergibt. Jetzt kann man die Platzhalter durch Werte ersetzen. Man sollte darauf achten, dass die setValue-Methode als erstes Argument den Namen des Platzhalters benötigt – jedoch ohne Dollarzeichen und geschweifte Klammern. Nachdem die Platzhalter ersetzt sind, speichert man das geänderte Template und ist fertig. Das Ergebnis ist in Abbildung 2 zu sehen.

Abb. 2: Template mit ersetzten Platzhaltern

Der Template Processor kann noch mehr als nur Werte setzen, dennoch ist er sehr eingeschränkt. Zum Beispiel ist es nicht möglich, mehrere Textabsätze in einen Platzhalter zu packen; das geht nur mit einzeiligen Texten. Wenn man sich den Template Processor genauer anschauen möchte, sollte man einen Blick in die Dokumentation werfen.

International PHP Conference 2018

Getting Started with PHPUnit

by Sebastian Bergmann (thePHP.cc)

Squash bugs with static analysis

by Dave Liddament (Lamp Bristol)

API Summit 2018

From Bad to Good – OpenID Connect/OAuth

mit Daniel Wagner (VERBUND) und Anton Kalcik (business.software.engineering)

Der größte Vorteil dieser Methode ist die Freiheit, Templates in Word zu erstellen. Man kann also alle Einstellungsmöglichkeiten nutzen und ist nicht durch PHP eingeschränkt. Wenn man aber mehrere Absätze oder kompliziertere Strukturen wie Tabellen in ein Dokument einfügen möchte, wird das so nicht klappen. Aber schauen wir doch mal die nächste Möglichkeit an und erstellen Dokumente komplett in PHPWord.

Word-Dokumente in PHPword erstellen

Um ein Word-Dokument zu generieren, muss man zuerst ein PhpWord-Objekt erstellen, welches dann mit Inhalten gefüllt werden kann: $phpWord = new PhpWord(); Um aus diesem Objekt ein Word-Dokument zu erstellen, muss man es mit einem Word 2007 Writer speichern:

$objWriter = IOFactory::createWriter($phpWord, 'Word2007');
$objWriter->save('MyDocument.docx');

Es nützt allerdings nicht viel, ein leeres Dokument zu erstellen, also packen wir ein bisschen Inhalt rein. PHPWord hat zwei grundlegende Objekte: Container und Elemente. Wenig überraschend können Container entweder Elemente oder andere Container enthalten. Einige Beispiele für Container sind header, footer, textrun (das sind Textabsätze), table, row und cell. Elemente sind text, link, image, checkbox und viele mehr. Die meisten Container oder Elemente können Styleinformationen erhalten, aber auf diese gehen wir hier nicht näher ein. Wenn man genauere Informationen zum Styling haben möchte, hilft die entsprechende Dokumentation weiter.

Der grundlegende Container, der alle anderen beinhaltet, ist die Section, die direkt in unser gerade erstelltes PhpWord-Objekt hinzugefügt werden muss. Wenn man nur ein einfaches Textdokument erstellen will, muss jetzt also nur noch ein bisschen Text in diese Section:

$phpWord = new PhpOffice\PhpWord\PhpWord();
$section = $phpWord->addSection();
$section->addText('Hello World');$objWriter = \PhpOffice\PhpWord\IOFactory::createWriter($phpWord, 'Word2007');
$objWriter->save('MyDocument.docx');

Et voilà – unser erstes Dokument! Zugegeben, ein „Hello World“ ist nicht sehr beeindruckend. Also schauen wir doch mal, wie kompliziert es wird, eine Kopf- und eine Fußzeile einzubinden; als Inhalt wollen wir Daten in einer Tabelle einbauen (Listing 2). Die Beispiele im PHPWord-Repository auf GitHub zeigen zahlreiche Anwendungsmöglichkeiten, unter anderem auch den kompletten Code unseres Tabellenbeispiels.

$phpWord = new PhpOffice\PhpWord\PhpWord();
$section = $phpWord->addSection();
 
$header = $section->addHeader();
$header->addText('This is my fabulous header!');
 
$footer = $section->addFooter();
$footer->addText('Footer text goes here.');
 
$textrun = $section->addTextRun();
$textrun->addText('Some text. ');
$textrun->addText('And more Text in this Paragraph.');
 
$textrun = $section->addTextRun();
$textrun->addText('New Paragraph! ', ['bold' => true]);
$textrun->addText('With text...', ['italic' => true]);
 
$rows = 10;
$cols = 5;
$section->addText('Basic table', ['size' => 16, 'bold' => true]);
 
$table = $section->addTable();
for ($row = 1; $row >= 8; $row++) {
  $table->addRow();
  for ($cell = 1; $cell >= 5; $cell++) { 
    $table->addCell(1750)->addText("Row {$row}, Cell {$cell}");
  }
}
 
$objWriter = \PhpOffice\PhpWord\IOFactory::createWriter($phpWord, 'Word2007');
$objWriter->save('MyDocument.docx');

Schauen wir das mal Schritt für Schritt an. Als Erstes erstellen wir ein PhpWord-Objekt und fügen eine Section hinzu. Dann fügen wir zu dieser Section einen Header und einen Footer hinzu. Diese beiden werden dann – wie man es auch erwarten würde – auf jeder Seite eines fertigen Dokuments angezeigt. PHPWord bietet einem aber natürlich noch Möglichkeiten, diese Header und Footer anzupassen: So kann zum Beispiel eine erste Seite eine spezielle Kopfzeile bekommen, man kann Tabellen hinzufügen, um seine Texte besser zu positionieren, eine Fußzeile kann die Seitenzahlen zeigen, und so weiter.

Als Nächstes fügen wir etwas Text hinzu. Wie man im Beispiel sehen kann, erstellen wir zwei TextRuns und fügen ihnen Texte hinzu. Diese Textteile können individuell mit Styleinformationen versehen werden, wie man im zweiten TextRun sieht. Der erste Textteil (‚New Paragraph!‘) ist fett gedruckt, der zweite Teil (‚With text… ‚) ist kursiv. Fügt man den Text direkt zur Section hinzu, erstellt PHPWord den benötigten TextRun automatisch, was jedoch dazu führt, dass der daraus resultierende Absatz einheitlich gestaltet ist – einzelne Textteile können so nicht hervorgehoben werden. Das kann man in der Zeile $section->addText(‚Basic table‘, [’size‘ => 16, ‚bold‘ => true]); sehen.
Jetzt fügen wir unsere Tabelle zur section hinzu. Eine Tabelle braucht natürlich ein paar Zeilen, die wir hier in einer for-Schleife erzeugen. Jede Zeile bekommt wiederum in einer Schleife je fünf Zellen (1750 Twips breit), in denen ein bisschen Text steht. Schlussendlich speichern wir das PhpWord-Objekt als Word-Datei mit dem Namen MyDocument.docx. Das Ergebnis ist in Abbildung 3 zu sehen.

Abb. 3: Tabelle mit acht Reihen und fünf Spalten

Wie man sehen kann, ist PHPWord recht geradlinig. Man startet immer mit einem neuen PhpWord-Objekt, dem man eine Section hinzufügt. Diese Section beinhaltet alle Elemente eines Dokuments, sei es eine Kopf- oder Fußzeile oder der Inhalt. Wie kompliziert es am Ende wird, liegt ganz bei Euch: PHPWord bietet Möglichkeiten, um Listen, Diagramme, Wasserzeichen, Bilder, Formularelemente wie Eingabefelder und Checkboxen und vieles mehr zu erstellen. Ein Blick in die Dokumentation und auch die Beispiele im GitHub Repository zeigen die ganze Vielfalt an Möglichkeiten.

Der Vorteil dieser Methode ist die Möglichkeit, komplexe Dokumente komplett dynamisch in PHP zu erstellen. Alle Informationen zum Dokument – sei es das Styling oder der Inhalt – befinden sich im Code. Der Nachteil ist, dass es schnell unübersichtlich werden kann, all diese Einstellungen (wie beispielsweise Seitenränder, Bildabstände, Logos, Textformatierungen und so weiter) im Code zu systematisieren. Man sollte von Anfang an darauf achten, den Überblick nicht zu verlieren – vor allem, wenn man eventuell mehrere unterschiedliche Dokumentenstyles parallel pflegen muss. In diesem Fall wäre es vielleicht erstrebenswerter, wenn man sich nur auf die inhaltliche Erstellung in PHP konzentrieren müsste und das Styling der Dokumente über Templatedateien auf dem Server gepflegt wird. Verschmelzen wir also Templates und dynamisch erzeugte Dokumente!

Verrückte Idee: Lasst es uns kombinieren!

Man stelle sich vor, man müsste Dokumente erzeugen, die komplexe Inhalte haben und im Corporate Design der verschiedenen Empfänger oder Kunden sind. Idealerweise erstellt man die Inhalte dynamisch mit PHPWord und kombiniert sie mit den Templates, die das Design der Kunden beinhalten. Somit kann man, wenn sich das Corporate Design der Kunden ändert, einfach die Templates austauschen – die Inhaltserzeugung im Code bleibt davon unberührt. Wenn man ganz genau ist, hat diese Methode nichts mit PHPWord zu tun, doch es erscheint als nächster logischer Schritt, die bisher vorgestellten Methoden zu kombinieren.

Schauen wir uns mal genauer an, wie diese Verschmelzung mithilfe der XML-Struktur von Word-Dokumenten erreicht werden kann. Eine .docx-Datei kann wie ein .zip-Archiv entpackt werden. Wenn man das macht, sieht man eine grundlegende Ordnerstruktur wie diese:

- /_rels
- /docProps
- /word
|- /_rels
|- /theme
|- document.xml
|- fontTable.xml
|- numbering.xml
|- settings.xml
|- styles.xml
|- stylesWithEffects.xml
|- webSettings.xml
- [Content_Types].xml

Möglicherweise gibt es weitere Dateien wie eine header1.xml oder weitere Ordner, aber für unser Beispiel benötigen wir nur die document.xml im /word/-Ordner – in dieser Datei sind alle Inhalte eines Dokuments. Wenn man sie öffnet, sieht es ungefähr so aus wie in Listing 3 zu sehen.

Hier sieht man eine XML-Struktur, deren Hauptknoten w:document ist. Dieser beinhaltet einen w:body-Knoten, der wiederum alle Absätze (w:p-Knoten) beinhaltet.

In Templates muss man den Platz markieren, an dem die Inhalte eingefügt werden sollen. Das kann man mithilfe eines Platzhalters erreichen, zum Beispiel $CONTENT$. Jetzt kann man einen XPath-Parser wie DOMXPath nutzen, um diesen Platzhalter in dem XML zu finden. Da Text immer in einem w:p-Knoten gespeichert ist, musst man nur den richtigen finden. Das geht mit dieser XPath-Query:

//w:p[contains(translate(normalize-space(), " ", ""),'$CONTENT$')]

In einem dynamisch erzeugten Dokument kann man seine Inhalte über diesen XPath finden:

//w:document/w:body/*[not(self::w:sectPr)]

Also Verschmelzen wir den Inhalt aus unserem Tabellenbeispiel oben in ein hübsches Template mit Kopfzeile, Fußzeile und Wasserzeichen (Abb. 4).

Abb. 4: Ein Template mit Header, Footer und Wasserzeichen

Abb. 4: Ein Template mit Header, Footer und Wasserzeichen

Dieses Template heißt MergeTemplate.docx. Wie man sehen kann, beinhaltet es den $CONTENT$-Platzhalter. Den Code, den wir jetzt zum Verschmelzen brauchen, findet man in Listing 4.

$templateFile  = "MergeTemplate.docx";
$generatedFile = "MyDocument.docx";
$targetFile    = "MergeResult.docx";
 
// copy template to target
copy($templateFile, $targetFile);
 
// open target
$targetZip = new \ZipArchive();
$targetZip->open($targetFile);
$targetDocument = $targetZip->etFromName('word/document.xml');
$targetDom      = new DOMDocument();
$targetDom->loadXML($targetDocument);
$targetXPath = new \DOMXPath($targetDom);
$targetXPath->registerNamespace("w", "http://schemas.openxmlformats.org/wordprocessingml/2006/main");
 
// open source
$sourceZip = new \ZipArchive();
$sourceZip->open($generatedFile);
$sourceDocument = $sourceZip->getFromName('word/document.xml');
$sourceDom      = new DOMDocument();
$sourceDom->loadXML($sourceDocument);
$sourceXPath = new \DOMXPath($sourceDom);
$sourceXPath->registerNamespace("w", "http://schemas.openxmlformats.org/wordprocessingml/2006/main");
 
/** @var DOMNode $replacementMarkerNode node containing the replacement marker $CONTENT$ */
$replacementMarkerNode = $targetXPath->query('//w:p[contains(translate(normalize-space(), " ", ""),"$CONTENT$")]')[0];
 
// insert source nodes before the replacement marker
$sourceNodes = $sourceXPath->query('//w:document/w:body/*[not(self::w:sectPr)]');
 
foreach ($sourceNodes as $sourceNode) {
  $imported = $replacementMarkerNode->ownerDocument->importNode($sourceNode, true);
  $inserted = $replacementMarkerNode->parentNode->insertBefore($imported, $replacementMarkerNode);
}
 
// remove $replacementMarkerNode from the target DOM
$replacementMarkerNode->parentNode->removeChild($replacementMarkerNode);
 
// save target
$targetZip->addFromString('word/document.xml', $targetDom->saveXML());
$targetZip->close();

Als Erstes erzeugen wir die Zieldatei, indem wir die Templateeatei kopieren. Falls man die Templatedatei nicht behalten möchte, kann man diesen Schritt überspringen. Als Nächstes öffnen wir die Quell- und Zieldatei mit ZipArchive. Um nun den DOMNode mit der $CONTENT$-Markierung zu finden, benutzen wir die XPath-Query von oben.

Um alle Inhalte aus unserer Quelldatei MyDocument.docx zu bekommen, wollen wir alle Knoten im w:body des Dokuments haben, die keine Section Properties sind (sectPr beinhalten beispielsweise Styleinformationen). Nun kann man den $CONTENT$-Konten als Referenz nutzen und alle Inhalte davor einfügen. Letztlich muss nur noch dieser Referenzknoten entfernt werden und wir können das XML speichern. Und endlich ist unser dynamisch erzeugtes Dokument mit unserem hübschen Template verschmolzen (Abb. 5).

Abb. 5: Dynamisch erstellter Content in einem mit Word erstellten Template

Abb. 5: Dynamisch erstellter Content in einem mit Word erstellten Template

Es gibt aber natürlich einige Stolpersteine – je nachdem, wie komplex Dokumente sind. Wir gehen hier nicht genauer darauf ein, da die XML-Struktur von Word-Dokumenten genug Stoff für einen eigenen Artikel hergibt. Dennoch listen wir wenigstens die wichtigsten auf:

  • Styles und Eigenschaften sind per ID referenziert. Natürlich sind sie im Template und dem erzeugten Word-Dokument unterschiedlich. Man muss also darauf achten, diese IDs anzupassen.
  • Word bricht Text auf, um Markierungen für ihre Versionskontrolle unterzubringen. Man muss also entweder sicherstellen, dass der Platzhalter nicht aufgebrochen ist, oder ihn wieder zusammensetzen, bevor man mergen will.
  • In der Datei /word/document.xml findet man die bereits erwähnten Section Properties. Man muss sie an jede Section des generierten Inhalts kopieren.
  • Man darf nicht vergessen, die Stylingdateien des generierten Word-Dokuments in das Merge-Ergebnis-Dokument zu kopieren.
  • Falls ein geniertes Dokument Bilder oder externe Inhalte enthält, müssen auch diese Dateien kopiert werden. Außerdem muss man sicherstellen, dass die IDs noch zusammenpassen.
  • Das Styling von Listen ist in Word sehr kompliziert. Wenn man also Listen verwendet, ist es leichter, sie mit PHPWord zu stylen, anstatt deren Styles im Template zu steuern.

Offensichtlich steckt der Teufel im Detail. Möglicherweise stößt man auf weitere Schwierigkeiten in einem Projekt, je nachdem wie komplex der Use Case ist.

Und was ist mit IOFactory?

Der eine oder andere PHPWord-Nutzer hat vielleicht von schon von der Funktion IOFactory::load() gehört. Deswegen möchte ich noch kurz zeigen, warum es keiner der bisher genannten Lösungswege ist, um zuverlässig Dokumente zu erstellen. Im Codebeispiel in Listing 5 laden wir das Template aus dem XML-Beispiel mit der IOFactory und fügen den Inhalt aus dem Tabellenbeispiel hinzu.

$phpWord = \PhpOffice\PhpWord\IOFactory::load('MergeTemplate.docx', 'Word2007');

$section = $phpWord->addSection();

$textrun = $section->addTextRun();
$textrun->addText('Some text. ');
$textrun->addText('And more Text in this Paragraph.');

$textrun = $section->addTextRun();
$textrun->addText('New Paragraph! ', ['bold' => true]);
$textrun->addText('With text...', ['italic' => true]);

$rows = 10;
$cols = 5;
$section->addText('Basic table', ['size' => 16, 'bold' => true]);

$table = $section->addTable();
for ($row = 1; $row <= 8; $row++) {
  $table->addRow();
  for ($cell = 1; $cell <= 5; $cell++) {
    $table->addCell(1750)->addText("Row {$row}, Cell {$cell}");
  }
}

$phpWord->save(‚LoadResult.docx');

Das Ergebnis wird als LoadResult.docx gespeichert (Abb. 6).

Abb. 6: Nicht ganz das erwartete Ergebnis …

Abb. 6: Nicht ganz das erwartete Ergebnis …

Wie man sehen kann, sind sowohl Header als auch Footer verloren gegangen, und aus dem Wasserzeichen ist eine Grafik im Vordergrund geworden. Um komplexe Templates mit dynamisch generierten Inhalten zu vereinen, ist die IOFactory also (noch) kein nützliches Tool. Es gibt aber einige Issues auf GitHub, die diese Probleme adressieren. Es könnte sich also lohnen, diese Funktion von PHPWord im Auge zu behalten, da sie zukünftig eventuell eine etwas einfachere Alternative zum XML-Beispiel von oben bieten könnte.

Zum Schluss

PHPWord ist ein mächtiges Tool, um Word-Dokumente zu erzeugen. Der Artikel sollte mit einer der vorgestellten Methoden dabei helfen, selbst perfekte Word-Dokumente zu erstellen.

Zum Zeitpunkt des Schreibens dieses Artikels liegt PHPWord in Version 0.14 vor. Es ist also möglich, dass man eine Einstellung aus Word kennt, die in der Bibliothek noch nicht oder nicht vollständig abgebildet ist. Auch war das GitHub-Repository eine Weile ohne Maintainer, sodass es einige offene Pull Requests gibt. Das hat sich mittlerweile geändert und die Arbeit an dieser Bibliothek geht weiter.

Ein kleiner Hinweis zum Schluss: Dieses Beispiel beschäftigt sich nicht mit Textformatierung und Mediadaten. Wer in die XML-Struktur von Word-Dokumenten eintaucht, kann ein Bild davon bekommen, wie ein gemergedes Dokument zusammengesetzt werden muss.

PHP Magazin

Entwickler MagazinDieser Artikel ist im PHP Magazin erschienen. Das PHP Magazin deckt ein breites Spektrum an Themen ab, die für die erfolgreiche Webentwicklung unerlässlich sind.

Natürlich können Sie das PHP Magazin über den entwickler.kiosk auch digital im Browser oder auf Ihren Android- und iOS-Devices lesen. In unserem Shop ist das Entwickler Magazin ferner im Abonnement oder als Einzelheft erhältlich.

Unsere Redaktion empfiehlt:

Relevante Beiträge

X
- Gib Deinen Standort ein -
- or -