Word-Vorlagen mit Python ausfüllen

Im Folgenden möchte ich beschreiben, wie man halbwegs zuverlässig und anwenderfreundlich Word-Vorlagen erstellen kann und diese später mit Python ausfüllen kann. Hierzu werden keine Dependencies, bzw. keine externen Bibliotheken verwendet: Das Vorgehen basiert ausschließlich auf der Python-Standardbibliothek, solange man eine halbwegs aktuelle Version von Python benutzt. Ich selber habe Python 3.12.0 verwendet, aber vermutlich funktioniert es auch mit etwas älteren Versionen, das habe ich aber nicht getestet.

Der Grund, ausschließlich die Standardbibliothek zu verwenden, liegt daran, dass ich und vielleicht auch andere in software-technisch sehr eingeschränkten Umgebungen arbeiten (müssen), in denen es nicht immer möglich ist, weitere Bibliotheken oder Software zu installieren. Außerdem unterscheidet sich der hier präsentierte Ansatz von den gänigsten auf PyPi zu findenden Alternativen, baut jedoch auf einem Ähnlichen Grundsatz wie docx-form auf, ist aber auf das wiederholte Ersetzen optimiert.

Sicherheitshinweis: Dieser Artikel geht davon aus, dass das schlussendliche Programm in einer sicheren Umgebung ausgeführt wird, dass also unter anderem die verwendeten Vorlagen vertrauenswürdig sind. Einige der aus der Standardbibliothek verwendeten Funktionalitäten operieren unter der gleichen Annahme, eine Umsetzung in einer feindlichen Umgebung wäre somit wesentlich komplexer.

Da keine externen Bibliotheken verwendet werden, müssen wir also selbst die Word-Dateien manipulieren. Um das zu erklären, werde ich zunächst darauf eingehen, wie eine solche Datei aufgebaut ist und dabei ein bisschen Kontext erklären. Dabei werden sich zum Thema Text-Ersetzung (denn nichts anderes machen wir konzeptuell in der Word-Datei) Herausforderungen ergeben, zu denen ich eine Lösung präsentieren werde. Abschließend kommen wir dann dazu, wie das ganze technisch in Python umgesetzt werden kann.

Direkt zum Quellcode

Aufbau von Word-Dateien

Wenn es um Microsoft Word-Dateien geht, könnte man ja zunächst meinen, dass das ein völlig proprietäres Format ist, mit dem man nicht weit kommt. Das stimmt aber nicht ganz (und dann würde auch dieser Artikel nicht existieren). Tatsächlich gibt es öffentlich zugängliche Standards/Spezifikationen für docx & Co. z. B. als ECMA-376. Mit diesen Standards werden mehrere Teile zusammengesetzt, um ein ganzes zu bilden:

  1. Open Packaging Convetions
  2. Extensible Markup Language (XML)
  3. WordprocessingML

Open Packaging Conventions

überspringen

Dies ist ein weiterer ECMA-Standard. Für uns ist dabei hauptsächlich relevant, dass ein "Package" bzw. Paket entsprechend diesem Standard mehr oder weniger ein ZIP-Archiv mit einem bestimmten Inhalt ist. Im Standard wird auch noch genau darauf eingegangen, wie die Dateien innerhalb des Pakets anzulegen sind und wie man sich bei automatischer Verarbeiten von der einen zu nächsten hangeln kann. Das ganze ist für diesen Anwendungszweck relativ egal, denn wir wissen, nach welcher Datei wir suchen. Aufgrund der weiteren Standardisierung ist diese immer an der gleichen Stelle zu finden und heißt word/document.xml.

Im ersten Schritt kann man jetzt also feststellen, dass man .docx-Dateien auch als ZIP-Archiv behandeln kann. Benennt man auf Windows beispielsweise eine Datei mit der Endung .docx um zur Endung .zip, wird Windows zwar erst mal protestieren, aber wenn man es geschafft hat, kann man die Datei so öffnen, wie jedes andere ZIP-Archiv auch. Darin kann man z. B. folgende Inhalte finden:

dokument.zip
│   [Content_Types].xml
│
├───docProps
│       app.xml
│       core.xml
│
├───word
│   │   document.xml
│   │   fontTable.xml
│   │   settings.xml
│   │   styles.xml
│   │   webSettings.xml
│   │
│   ├───theme
│   │       theme1.xml
│   │
│   └───_rels
│           document.xml.rels
│
└───_rels
        .rels
Beispielhafter "Inhalt" einer Word-Datei.

Und das war es auch schon mit den wesentlichen Informationen zu Open Packaging Conventions (OPC). Zunächst werde ich jetzt erklären, was es mit dem Inhalt dieser .xml-Dateien auf sich hat.

Extensible Markup Language (XML)

überspringen

XML ist ein vom W3C (World Wide Web Consortium) standardisierte Auszeichnungssprache. Vereinfacht gesagt ist man mit XML in der Lage, Text bzw. Zeichenketten so zu strukturieren, dass sie einfach von Computern als komplexere Datenstrukturen lesbar sind, ein sogenanntes Serialisierungsformat. Mitunter hält XML auch her, wenn man ein Symbolbild für "Software" sucht: Vielleicht schon mal </> oder ähnliche Symbolkombinationen gesehen? Eine vollständige Beschreibung von XML werde ich hier nicht liefern (können), aber die hier wichtigsten Konzepte werde ich umreißen.

Ursprünglich für Dokumente entworfen (d.h. das Dokument wird direkt als XML geschrieben) hat es sich auch sehr in Richtung eines maschinenlesbaren Formats zum Austausch von strukturierten Daten entwickelt. Insbesondere um die Jahrtausendwende war XML auch bei Managern beliebt. Vielleicht ist das auch ein Grund, warum Microsoft die Formate für seine Office-Programme bei der Umstellung auf XML basieren lies.

Elemente und Tags

Zu den sicherlich wichtigsten Konstrukten zählt das "Element" mit seinen Tags, dabei kommen wieder die schon erwähnten Symbole wieder zum Einsatz. Ein Element hat einen öffnenden und schließenden Tag. "Tag" hat in diesem Kontext nichts mit der Zeiteinheit zu tun, sondern ist ursprünglich englisch; sprich "täg". Der öffnende Tag besteht aus < und >, dazwischen der Name. Beim schließenden Tag ergibt sich nur der kleine Unterschied, das vor dem Namen noch ein / eingefügt wird. Ein Element sieht dann beispielsweise so aus:

<Person>Johann</Person>

Für Tags ohne Inhalt gibt es noch eine Möglichkeit zur Abkürzung. Dabei werden der öffnende und schließende Tag zusammengefasst. Um das aber von einem schließenden Tag unterscheiden zu können, wird bei dieser Abkürzung das / ans Ende gesetzt:

<istMensch/>

Attribute

Ein weiteres Konzept sind Attribute, die Bestandteil eines Tags sein können. Sie sind aber auch kein Inhalt sondern eher "Metadaten". Da sie nicht zum Inhalt gehören, werden sie auch nicht dort platziert, sondern innerhalb des öffnenden Tags eingefügt. Damit man die Attribute vom Namen unterscheiden kann, werden sie mit einem Leerzeichen vom Namen und untereinander abgegrenzt.

Auch ein Attribut hat einen Namen und einen Inhalt. Der Name wird in den Tag geschrieben, gefolgt von einem = und dann der Inhalt zwischen zwei ". Das obige Beispiel mit einem zusätzlichen Attribut sieht dann so aus:

<Person lebt="ja">Johann</Person>

Namensräume

Namensräume bzw. englisch Namespaces werden insbesondere dann wichtig, wenn XML von verschiedenen Anwendungen erzeugt bzw. konsumiert wird. Um ein Element und alle darin enthaltenen Elemente einem Namensraum zuzuweisen, wird das Attribut xmlns (XML Name-Space) verwendet. Der Inhalt dieses Attributs ist ein URI (Uniform Resource Identifier). Das kann dann z. B. so aussehen:

<document xmlns="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
...
</document>

Achtung bei Begrifflichkeiten: URI ≠ URL
URLs und URIs haben das gleiche Format und sehr ähnliche Namen. Es gibt aber einen wesentlichen Unterschied im Anwendungszweck: URL (Uniform Resource Locator) wird zum auffinden verwendet. URI (Uniform Resource Identifier) wird zum identifzieren bzw. benennen verwendet.

Wenn z. B. der Namensraum http://purl.org/dc/terms/ definiert wird, muss man sich keine Gedanken darüber machen, dass nicht HTTPS verwendet wird. Denn es geht nur um den Namen, der eindeutig sein soll und von Computern erkannt werden soll.

Der Name muss auch Zeichen für Zeichen gleich sein, so wie bei Namen von Menschen auch: Johann und Johanna sind nicht die gleiche Person. Deswegen sollte man in diesem Beispiel auch nicht mit dem Gedanken der Sicherheit den Namensraum zu HTTPS verändern, denn das wäre ein ganz anderer Namensraum!

Mit dieser Methode müsste man bei jedem XML-Tag den Namespace mit xmlns explizit hinschreiben. Insbesondere wenn fast alle Tags einen Namensraum haben, wird das aber nervig, unübersichtlich und nimmt Speicherplatz in Anspruch. Deswegen gibt es eine Alternative, wie man einen Namensraum mehrfach verwenden kann, ihn aber nur einmal definieren muss: Namensraum-Präfixe. Diese werden über Attribute definiert, deren Namen mit xmlns: anfangen und mit dem gewünschten Präfix enden, also z. B. xmlns:w für das Präfix w. Um das Präfix zu verwenden, kann es dann vor den Namen von Tags oder Attributen gesetzt werden und wird dann ebenfalls mit einem Doppelpunkt getrennt. Wenn man das vorherige Beispiel so ändert, dass Präfixe verwendet werden, sieht das dann so aus:

<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
...
</w:document>

Es gibt noch ein paar weitere Konzepte die zwar wichtig sind, aber hier nicht hinpassen. Wenn man das obige verstanden hat, sollte man in der Lage sein, eine XML-Datei halbwegs zu verstehen. Für weiteres oder vertieftes Wissen gab es schon viele Leute vor mir, die XML erklärt haben und auf die ich an dieser Stelle verweise.

WordprocessingML

überspringen

Nachdem wir nun gelernt haben, was XML ist, sollte WordprocessingML nicht mehr wie etwas komplett neues ausssehen. WordprocessingML definiert Namensräume und Namen von XML-Elementen und -Attributen und deren Bedeutung. Im Prinzip ist es mehr eine Beschreibung, welche XML-Elemente in welchen Kombinationen verwendet werden, um den Inhalt eines Dokuments abzubilden. WordprocessingML ist ein großer Teil des Inhalts des o. g. Standards ECMA-376.

Da wir ja aus dem Abschnitt Open Packaging Conventions wissen, dass Word-Dateien eigentlich ZIP-Archive von XML-Dateien sind, können wir uns darin umschauen, um einen ersten Einblick zu bekommen. Der eigentlich interessante Inhalt befindet sich immer noch in word/document.xml, also werfen wir einen Blick in diese Datei.

Da die Dateien eigentlich nicht dafür gedacht sind, von Menschen gelesen zu werden, ist der Inhalt fast komplett auf einer einzigen Zeile geschrieben. Die erste Zeile dient unter anderem dazu, die Zeichenkodierung der Datei anzugeben, das können wir erst einmal ignorieren.

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
XML-Deklaration am Beginn der XML-Datei

Danach kommt dann der eigentliche Inhalt mit der Definition des Dokuments. Um das besser lesen zu können, empfehle ich zuvor die Verwendung eines XML-Formatierers. Diese gibt es auch online und können für einheitliche Zeilenumbrüche und Einrückung sorgen. Aufgrund der Länge der XML-Datei spare ich es mir hier, die ganze Datei wiederzugeben.

WordprocessingML definiert für Text verschiedene Elemente. In einem einfachen Dokument kommen da zum Beispiel Absätze ins Spiel, die mit <w:p> repräsentiert werden. Innerhalb von Absätzen gibt es dann Text-Elemente (<w:t>) und darin dann die sogenannten Runs (<w:r>). Dazu kann es die verschiedenen "Property"-Elemente geben, in denen z. B. die Formatierung des Textes hinterlegt wird. Diese Elemente heißen dann <w:pPr> oder <w:rPr>.

Wenn in dem betreffenden Dokument schon Text stand, kann man diesen natürlich auch in der Datei wiederfinden. Dabei wird man aber unter Umständen feststellen, dass der Text über verschiedene Elemente verteilt ist. Auch wenn es dafür scheinbar keinen Grund gibt, wird von Word der Text manchmal über mehrere Runs verteilt. Beispielsweise speichert Word die letzte Cursor-Position ab; dies kann aber nicht in der Mitte eines Runs passieren, weshalb dieser unterbrochen werden müsste. Eine erneute Zusammenfassung des Runs passiert dann auch nicht unbedingt.

Natürlich gibt es auch noch weitere Elemente wie z. B. w:sectPr (section properties, Abschnittseigenschaften) für Layout und Seitenränder, aber ich möchte hier nicht auf alle Möglichkeiten eingehen. Teilweise werde ich einzelne Elemente, die ich für die Lösung verwende, im Folgenden noch genauer beschreiben. Da der Standard öffentlich ist, kann man die Definitionen und Bedeutungen der anderen Elemente gern dort nachlesen: ECMA-376 (WordprocessingML findet sich in Part 1 § 17)

Lösungs-Konzept

Damit kommen wir schon zum eigentlichen Problem. Das ist natürlich problematisch, wenn man Text ersetzen will: Wenn genau in dem Wort, das ersetzt werden soll, eine Teilung ist, wird man das mit einer einfachen Suche nicht finden.

Jetzt kommen wir schon langsam in die Richtung der eigentlichen Aufgabe. Direkt den geschriebenen Text zu ersetzen ist also keine Option. Ich habe deshalb im Standard ein bisschen gestöbert und wurde dann durch einen glücklichen Zufall auch von anderer Seite auf ein weiteres Feature hingewiesen.

Seriendruck-Felder

Als erstes werden einem vielleicht Seriendruck-Felder einfallen, die ich mir auch zuerst angesehen hatte. Allerdings werden diese in einem etwas merkwürdigen Format abgespeichert. In der XML-Datei sieht ein Seriendruckfeld in etwa wie folgt aus. Im Beispiel kann man noch einmal das zuvor angeführte Problem der von Word zerhackten Runs sehen.

<w:p>
  <w:r>
    <w:fldChar w:fldCharType="begin"/>
  </w:r>
  <w:r>
    <w:instrText xml:space="preserve"> MERGEFIELD  Name  \* MERGEFORMAT </w:instrText>
  </w:r>
  <w:r>
    <w:fldChar w:fldCharType="separate"/>
  </w:r>
  <w:r>
    <w:rPr>
      <w:noProof/>
    </w:rPr>
    <w:t>«Name»</w:t>
  </w:r>
  <w:r>
    <w:fldChar w:fldCharType="end"/>
  </w:r>
</w:p>
Seriendruck-Feld in WordprocessingML

Dieses Beispiel habe ich etwas bereinigt, denn ursprünglich wurde auch hier der Text mitten im Wort MERGEFIELD geteilt, weil ich unabsichtlich meinen Cursor beim letzten Speichern dort stehen hatte. Auch das Element w:instrText ("instructional text") ist nicht vor einer wilden Aufspaltung durch Word gefeit. Somit noch ein weiterer Grund, diese Variante nicht zu wählen.

Abschließender Grund ist die etwas merkwürdige Syntax des "instructional text". Sicherlich könnte man diese parsen, wenn man es möchte, aber warum sollte man das tun, wenn es auch anders geht?

Inhaltssteuerelemente

Durch einen Zufall wurde ich in einem Dokument auf die Inhaltssteuerelemente gestoßen. Standardmäßig werden diese als ein grauer Rahmen um einen kurzen Text-Abschnitt dargestellt und haben einen kleinen Anfasser links oben. Manchmal wird zusätzlich zum Anfasser auch noch ein Name angezeigt. An dieser Stelle wurde ich dann sehr interessiert, wie denn dieser Name abgespeichert wird.

Die Inhaltssteuerelemente sind im Entwickler-Menü versteckt (welches man in den Einstellungen zum Menüband aktivieren muss). Es gibt verschiedene dieser Elemente, unter anderem als Nur-Text, Rich-Text, oder auch als Datum. Wenn man ein betreffendes Element auswählt und über den Button "Eigenschaften" ebenjene bearbeitet, bekommt man in einem Dialogfenster unter anderem zwei Text-Felder zu Gesicht: Name und Tag.

Insbesondere letzteres klingt für diesen Anwendungsfall sehr interessant. Die Betrachtung der ensprechenden Informationen als XML bestätigt diese Vermutung, wie auch im folgenden Beispiel zu sehen ist.

<w:sdt>
  <w:sdtPr>
    <w:alias w:val="Name"/>
    <w:tag w:val="Name"/>
    <w:showingPlcHdr/>
    <w:text/>
  </w:sdtPr>
  <w:sdtEndPr/>
  <w:sdtContent>
    <w:r>
      <w:rPr>
        <w:rStyle w:val="Platzhaltertext"/>
      </w:rPr>
      <w:t>Name</w:t>
    </w:r>
  </w:sdtContent>
</w:sdt>
Structured Document Tag in WordprocessingML

Nach zusätzlicher Lektüre des Standards wird auch die Element-Verschachtelung etwas deutlicher. (Wer nachlesen möchte, die Definitionen zu SDTs finden sich in ECMA-376 Part 1 § 17.5.2.) Ich habe in diesem Beispiel ein paar Elemente entfernt, die für die Zwecke dieser Erläuterung nicht relevant sind. Im folgenden gehe ich kurz auf die Bedeutung der verbleibenden, relevanten Elemente ein.

w:sdt
der gesamte Structured Document Tag (verschiedene Varianten möglich, hier § 17.5.2.31)
w:sdtPr
wie schon von anderen Konstrukten bekannt die "Properties" zum Element, welche also Metadaten enthalten (§ 17.5.2.38)
w:alias
Hierüber wird der Inhalt des Felds "Name" aus dem o. g. Eigenschaften-Dialog abgebildet, also ein menschenlesbarer Name, der in Word auch als Name des Felds angezeigt wird. Der eigentliche Wert wird nicht innerhalb des Elements sondern als Attribut angegeben, und der Text kann somit auch nicht aufgespalten werden. (§ 17.5.2.1)
w:tag
ähnliches Prinzip wie w:alias, aber mit dem Feld "Tag" aus dem Eigenschaften-Dialog, welches als maschinenlesbar gedacht ist (§ 17.5.2.42)
w:showingPlcHdr
engl. "showing placeholder", markiert also, dass in der aktuellen Darstellung ein Platzhalter angezeigt wird. Dieser kann ggf. eine andere Formatierung als der finale Text haben. (§ 17.5.2.39)
w:text
markiert, dass es sich um ein Nur-Text-Inhaltssteuerelement handelt (§ 17.5.2.44)
w:sdtContent
aktuell im Dokument gezeigter Inhalt des Inhaltssteuerelements (verschiedene Varianten wie w:sdt, hier § 17.5.2.36)

Zugegebenermaßen ist dies immer noch sehr viel XML, aber das Format ist hier eindeutig und wird nicht einfach mal irgendwo aufgeteilt. Außerdem haben wir noch einen weiteren Vorteil, wie die genaue Lektüre von § 17.5.2.44 verrät: Bei der Benutzung von Nur-Text-Elementen ist die weitere innere Struktur von w:sdtContent ziemlich eng begrenzt.

Es ergibt sich somit die von mir bevorzugte Lösung: Der Inhalt des Structured Document Tag wird jeweils durch den entsprechenden Inhalt ersetzt. Dazu muss man die w:sdt-Elemente mit dem entsprechenden w:tag finden, die vorher in Word konfiguriert wurden, dann deren w:sdtContent-Element ersetzen.

technische Umsetzung in Python

Nachdem ich nun die technischen Rahmenbedingungen und das angestrebte Ziel dargelegt habe, kann ich nun zur tatsächlichen Umsetzung übergehen. Wie eingangs erwähnt, verwende ich hierzu Python und nur die Funktionalität, die mit der Standardbibliothek mitgeliefert wird. Mit der zuvor gesammelten Kenntnis des Datei-Formats ist das keine besondere Schwierigkeit: ZIP-Archive kann man mit dem Modul zipfile verarbeiten oder mit shutil ganz ent-/verpacken. Für XML gibt es gleich mehrere Module, hier werde ich xml.etree verwenden.

Zuvor habe ich den Aufbau von Word-Dateien gezeigt, wobei anhand der Beispiele erkennbar wird, dass die Daten- bzw. Datei-Strukturen relativ komplex sind. Allerdings arbeiten wir anhand einer Vorlage, was dieses Problem erheblich vereinfacht, da wir einfach alle anderen Strukturen blind kopieren. Möglicherweise ist das nicht die ganz saubere Lösung, weil wir dann auch Dinge kopieren, die wir später durch die Bearbeitung entfernen bzw. die Referenzen auflösen, aber es ist nicht dramatisch und vor allem: Es funktioniert!

Performance-Optimierung

Ein letztes Bedenken gibt es noch hinsichtlich der Performance: Es liegt in der Natur von Serienbriefen, dass bei diesen nicht nur einmal der Inhalt ersetzt wird, sondern mehrere Kopien mit je anderen Ersetzungen erstellt werden. Wenn man anhand des naiven ansatzes für jedes neue Dokument erneut die komplette Vorlage kopiert und entpackt, entsteht so mit jedem Dokument ein Aufwand. Allerdings ist es auch möglich, diesen Aufwand etwas zu reduzieren, indem man nur am Anfang einen einmaligen Aufwand betreibt und eine entsprechend präparierte Vorlage erstellt.

Zunächst ist es verschwendete Rechen-Zeit, die gleiche XML-Datei immer wieder neu einzulesen, um innerhalb dieser die Ersetzungen vorzunehmen. Eine einfache Wiederverwendung ist aber auch nicht möglich, da das Ersetzen ja die Dokumenten-Struktur verändern kann. Als Kompromiss kann man stattdessen etwas mehr Arbeitsspeicher verwenden, um die bereits eingelesene Struktur der Datei beizubehalten.

Außerdem soll nicht immer wieder die gleiche ZIP-Datei entpackt werden müssen. Hierzu muss man noch wissen, dass innerhalb von ZIP-Archiven die enthaltenen Dateien nacheinander enthalten sind, wobei die Reihenfolge beliebig ist. Es ist deshalb einfach möglich, eine Datei an das Ende eines Archivs anzuhängen. Andererseits kann man eine bereits enthaltene Datei nur sehr schwer verändern, da sich dadurch der ganze Dateiinhalt verschiebt und ggf. auch Prüfsummen neu zu berechnen sind. Entsprechend unterstützt das zipfile-Modul nur den open-Modus zum Überschreiben von einzelnen Dateien ('w') oder zum Anhängen von neuen Dateien an das ganze Archiv.

Wie erläutert soll nur word/document.xml verändert werden. Außerdem wirssen wir schon aus der ersten Optimierung, dass diese Datei ja im Arbeitsspeicher liegen bleibt und wir sie deshalb nicht jedes Mal lesen müssen. Es ist also ausreichend, die Vorlage einmalig als ZIP-Archiv zu entpacken und die genannte Datei zu entfernen. Anschließend kann statt der Vorlage das präparierte ZIP-Archiv verwendet werden und anhand der Kopie im Arbeitsspeicher wird word/document.xml neu angefügt.

Grobkonzept

  1. Kopie entpacken (shutil) in ein temporäres Verzeichnis (tempfile)
  2. word/document.xml als XML einlesen
  3. word/document.xml entfernen
  4. Kopie wieder einpacken (shutil) und als temporäre Datei/präparierte Vorlage beibehalten
  5. Für jede Ersetzung:
    1. Deepcopy (copy.deepcopy) des eingelesenen XML-Dokuments erstellen
    2. Ersetzungen durchführen
    3. präparierte Vorlage kopieren
    4. word/document.xml anfügen, Inhalt anhand der durchgeführten Ersetzungen
  6. Temporäre Daten/Dateien löschen

erläuterter Code

Teile des Codes wie z. B. imports, Kommentare und Docstrings werden hier nicht wiedergegeben. Sie finden sich aber in den Quellcode-Dateien.

Zunächst aber ein bisschen Boilerplate-Code, der beim Umsetzen der Optimierungen hilft. Zum einen muss ein Platz gefunden werden, wo die dauerhafte Kopie des XML-Dokuments abgespeichert wird, zum andern muss irgendwo der Pfad der temporäre Datei gespeichert werden, in der die präparierte Vorlage gespeichert ist, um diese später auch wieder zu löschen.

Für diesen Zweck eignet sich in Python ein sog. Context Manager. Das ist im wesentlichen eine Klasse, in der die speziellen Methoden __enter__ und __exit__ implementiert sind. Bei der Implementierung einer Klasse sollte natürlich auch die __init__-Methode (Konstruktor) nicht fehlen.

class MailMerge:
    def __init__(self, template_path: str):
        self._template_path = template_path

Im Konstruktur wird im wesentlichen nicht gemacht, außer den Vorlagen-Pfad für später abzuspeichern. Die erste eigentlich interessanten Schritte passieren in der __enter__-Methode, die beim Eintritt in den Kontext-Manager aufgerufen wird. (Das passiert z. B. bei der Verwendung von with.)

    def __enter__(self):
        tempdir = tempfile.mkdtemp()
        shutil.unpack_archive(self._template_path, tempdir, 'zip')

Das Entpacken von ZIP-Archiven macht die Standardbibliothek einfach. Ein kleines bisschen komplizierter wird es mit temporären Verzeichnissen, aber auch das ist machbar. Das tempfile-Modul bietet auch Kontext-Manager für Dateien und Verzeichnisse, aber diese würden beim Verlassen des Kontexts auch gleich die Datei oder das Verzeichnis mit löschen. Für diesen Anwendungszweck sind diese deshalb nicht geeignet.

In das erstellte temporäre Verzeichnis hinein wird dann die Vorlage entpackt. shutil.unpack_archive würde standardmäßíg versuchen, das Dateiformat anhand der Dateiendung zu erkennen. Hier ist aber davon auszugehen, dass die Vorlage z. B. eine .docx-Endung haben wird, weshalb das Entpacken so nicht funktionieren würde. Deshalb geben wir sicherheitshalber gleich selbst mit, dass es sich um ein Archiv im Format 'zip' handelt.

        document_path = os.path.join(tempdir, 'word', 'document.xml')

        self._template_document = ElementTree.parse(document_path)

        self._template_document.getroot().attrib.pop('{' + XMLNS['mc'] + '}Ignorable', None)

Das Archiv wurde nun zu einem Verzeichnis entpackt, innerhalb dessen das Programm normal navigieren können. Die hier entscheidende Datei word/document.xml wird lokalisiert und von XML in eine Python-Datenstruktur eingelesen, mit der weiter gearbeitet werden kann. Wie in den Optimierungen beschrieben, wird dieses Objekt für alle weiteren Schritte im Arbeitsspeicher gehalten.

Hier wird außerdem schon eine erste Gemeinheit behandelt: Standardmäßig verfügt das äußerste Element über das Attribut mc:Ignorable, in welchem nicht relevante Namespaces aufgelistet sind. Wenn die darin gelisteten Präfixe aber nicht definiert sind, liefert Word nur einen Fehler und verweigert das Einlesen der Datei. Mit der hier verwendeten XML-Bibliothek werden werden nicht benutzte Namespaces in der geschriebenen XML-Datei auch nicht erneut definiert. Um die Fehler zu umgehen, wird das Attribut einfach entfernt.

        os.remove(document_path)
        with tempfile.NamedTemporaryFile() as fp:
            self._template_zip = shutil.make_archive(fp.name, 'zip', tempdir)

        return self._merge

Die hauptsächlich wichtige Datei wurde nun eingelesen, ist jetzt aber im Weg und wird deshalb gelöscht. Danach kann die Datei wieder eingepackt werden. Dies geschieht wiederum als eine temporäre Datei.

Die Art, wie das hier passiert, mag etwas merkwürdig anmuten, da ich zuvor gesagt hatte, dass sich die Kontext-Manager-Variante von tempfile-Funktionen nicht eignet. Allerdings ist es eine Eigenheit von shutil.make_archive, dass das Archiv nicht den Namen der angegebenen Datei benutzt, sondern die Funktion selbst noch die Dateiendung anhängt. Die eigentlich erstellte temporäre Datei wird also gar nicht verwendet, kann also getrost auch wieder gelöscht werden.

Zum Ende von __enter__ wird noch self._merge zurückgegeben. Dabei handelt es sich um eine (interne) Funktion, die im Folgenden erläutert wird. Der Rückgabewert steht bei der Verwendung von with ... as ... als Wert zur Verfügung.

Funktionen, die mit einem Unterstrich beginnen, werden in Python als intern bzw. privat angesehen und sollten deshalb nicht von außen benutzt werden. Indem die Funktion nur dann verfügbar wird, wenn auch die Kontext-Funktionen benutzt wurden, wird eine versehentliche un-initialisierte Benutzung vermieden.

    def __exit__(self, exc, value, tb):
        os.remove(self._template_zip)
        del self._template_zip
        del self._template_document

Wir verlassen kurz die Reihenfolge des Grobkonzepts, um die äußere Schleife zu schließen, und die Gedanken mit den temporären Dateien usw. zu Ende zu bringen. Später, wenn es an das eigentliche Erstellen der Dokumente geht, müssen wir dann nicht mehr über diese ganzen temporären Dateien nachdenken.

Besonders komplex ist das auch nicht. Im wesentlihen wird die temporäre Datei, in der die präparierte Vorlage gespeichert ist, gelöscht. Außerdem brauchen wird die Kopien von Elementen im Arbeitsspeicher nicht mehr, diese können also gelöscht werden. Das explizite Löschen wäre hier vermutlich nicht notwendig, aber stört auch nicht besonders.

    def _merge(self, field_values: Dict[str, str], output_path: str):
        tree = copy.deepcopy(self._template_document)

Nun kommen wir zum eigentlichen Ersetzen. Warum der Name dieser Funktion mit einem Unterstrich beginnt, habe ich oben erläutert.

Zunächst wird eine sogenannte deep copy erstellt, dabei wird die Datenstruktur mitsamt darin verwendeten Objekten geklont. Es gibt auch eine Funktion zum "einfachen" Kopieren: copy.copy Diese einfachere Variante klont nur das Objekt selbst, aber nicht die darin enthaltenen bzw. verwendeten Objekte. In diesem Fall reicht das nicht aus, da sonst die Kind-Elemente zwischen den Kopien geteilt wären und somit ungewollt die ursprüngliche Datenstruktur verändert würde.

        for sdt in tree.findall('.//w:sdt', XMLNS):
            tag = sdt.find('./w:sdtPr/w:tag', XMLNS)
            if tag is None:
                continue
            tag = tag.attrib['{' + XMLNS['w'] + '}val']
            if tag not in field_values:
                continue

            content = sdt.find('./w:sdtContent', XMLNS)
            if content is None:
                continue

Das Programm sucht alle Vorkommen von w:sdt-Elementen im Dokument. Der Ausdruck .//w:sdt ist ein XPath. Die hier verwendete Bibliothek unterstützt einen beschränkten Teil von XPath, der in der Dokumentation angegeben ist. Die Syntax hat eine gewisse Ähnlichkeit zu Datei-Pfaden, es gibt z. B. auch .. zum wechseln zum übergeordneten Element. Das // erlaubt das Überspringen von beliebig vielen Verschachtelungs-Ebenen.

Innerhalb der so gefundenen Structured Document Tags wird dann der Tag-Name gesucht, anhand dessen identifiziert wird, welcher Wert eingesetzt werden soll. Wenn kein Tag-Name gefunden wird, kann das Element nicht sinnvoll weiter behandelt werden und wird übersprungen. Gleiches gilt auch, wenn zwar ein Tag-Name gefunden wurde, aber der Tag-Name nicht zur Ersetzung mitgegeben wurde.

Nachdem der Tag-Name festgestellt wurde, wird zur weiteren Bearbeitung das enthaltene w:sdtContent-Element herausgesucht.

            is_paragraph = content.find('./w:p', XMLNS) is not None

            paragraph_properties = None
            if is_paragraph:
                paragraph_properties = content.find('./w:p/w:pPr', XMLNS)

            run_properties = None

            placeholder_indicator = sdt.find('./w:sdtPr/w:showingPlcHdr', XMLNS)
            if placeholder_indicator is not None:
                sdt.find('./w:sdtPr', XMLNS).remove(placeholder_indicator)

                run_properties = sdt.find('./w:sdtPr/w:rPr', XMLNS)

Die Struktur des Inhalts eines Structured Document Tags (also des w:sdtContent-Elements) kann verschieden sein. Wenn die ausgefüllte Datei später wieder eingelesen werden soll, muss unbedingt das XML-Schema eingehalten werden, dass im Standard definiert ist. Das heißt praktisch, dass davon ausgeangen werden muss, dass die Struktur innerhalb von w:sdtContent davor und danach grob gleich aussehen muss.

Andererseits wollen wir den Platzhalter-Inhalt auch vollständig entfernen, bevor der neue Inhalt eingefügt wird. Dazu gehört auch, dass die Formatierung des Platzhalters entfernt wird, usw. Alles zu entfernen, ist weniger das Problem, aber das Schema halbwegs beizubehalten, benötigt etwas mehr überlegung.

Hier gehe ich etwas vereinfachend davon aus, dass es sich um ein Nur-Text-Feld handelt, ohne das geprüft zu haben. Somit ergibt sich, dass der Structured Document Tag entweder nur einen Run oder Absätze enthält. (ECMA-376 Part 1 § 17.5.2.44)

Bevor alles entfernt wird, werden noch die Eigenschaften des (ersten) Absatzes abgespeichert, wenn ein Absatz enthalten ist. Außerdem wird eine Markierung des Structured Document Tags entfernt, die anzeigt, dass Platzhalter-Inhalt angezeigt wird.

Die ursprünglichen Eigenschaften des Runs werden nicht gespeichert, da damit ja der Stil des Platzhalter-Text kopiert würde. Stattdessen gibt es optional einen vorgegebenen Stil für eingefügten Text, der hier gespeichert wird. (Diesen konnte man auch im Eigenschaften-Dialog auswählen!)

            content.clear()

            if is_paragraph:
                content = SubElementNS(content, 'w', 'p')
                if paragraph_properties is not None:
                    content.append(paragraph_properties)

            run = SubElementNS(content, 'w', 'r')
            if run_properties is not None:
                run.append(run_properties)

Nun kann tatsächlich der Inhalt vollständig entfernt werden. Mit Element.clear werden alle Attribute und enthaltenen Elemente entfernt.

Anhand der vorher erfassten Informationen wird anschließen die grobe Struktur des Inhalts wiederhergestellt, wie bereits diskutiert. Dabei werden ggf. auch die Eigenschaften des Absatzes bzw. des Runs eingefügt.

            text = SubElementNS(run, 'w', 't')
            text.text = field_values[tag]
            text.set('xml:space', 'preserve')

Nachdem das ganze Theater mit "Schema" erledigt ist, kommt es nun endlich zu dem Punkt, an dem der tatsächliche Text-Inhalt ersetzt wird. Der Inhalt bestimmt sich anhand des zuvor ermittelten Tag-Namen aus dem übergebenen dict.

        shutil.copyfile(self._template_zip, output_path)

Nun haben wir endlich die innere Schleife über die Structured Document Tags verlassen. Das bedeutet auch, dass alle Ersetzungen - im Arbeitsspeicher - vorgenommen wurden.

Die Repräsentation im Arbeitsspeicher muss nun aber noch in die Datei wieder eingefügt werden. Bisher wurde die Datei, in die das eingefügt werden soll, aber noch gar nicht angelegt. Da wir bereits vorher die Vorlage präpariert haben, muss nun einfach nur die temporäre Datei kopiert werden. Die hier verwendete Funktion shutil.copyfile kopiert die Datei ohne die Metadaten auf Dateisystemebene, wie z. B. die Erstellungszeit der Datei.

        with zipfile.ZipFile(output_path, 'a') as docx:
            with docx.open('word/document.xml', 'w') as document:
                document.write(b'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n')
                tree.write(document, encoding='UTF-8', xml_declaration=False)

Im letzten Schritt kommt die Bedeutung der Präparierung des ZIP-Archivs zum Vorschein: Die betreffende Datei kann nun zum Bearbeiten geöffnet werden und die word/document.xml-Datei angehangen werden.

Als kleiner Umweg wird dabei noch manuell die XML-Deklaration in die Datei geschrieben, damit sie genau so aussieht, wie im ursprünglichen Dokument. Wenn nur das standardmäßige ElementTree.write verwendet wird, fehlt hier das standalone="yes". Nach meinen Versuchen scheint es zwar nicht unbedingt notwendig, aber es ist einfach wieder einzufügen, deswegen schadet es auch nicht wirklich.

Damit bin ich am Ende angekommen. Es bleibt nur noch, auf eine vollständige Quellcode-Datei zu verweisen: mail_merge.py Der Quellcode steht unter der UNLICENSE. Der Inhalt dieser Seite steht sonst unter CC0.