Die Aufgabe klang leicht: Die bisher mittels Document-Schnittstelle umgesetzte Erzeugung von Word-Dokumenten aus Datenbankwerten neu implementieren im Rahmen eines WPF-Projekts. Die Learnings und meine Umsetzung findet ihr hier.

Zielsetzung: mit OpenXml SDK Word-Dokumente auf Basis eines Word-Templates erstellen

Word Dokumente erzeugt man am besten mittels OpenXml SDK, das kostet nichts und verlangt keine Word-Installation auf dem ausführenden Rechner bzw. man kommt an der Stelle nicht in das Problem von unterschiedlichen Softwareversionen. Bedingung war, dass das Template zuerst komplett in Word entworfen wird und auch für den Endnutzer dann immer noch wie ein echtes Dokument aussieht und nicht aus lauter ###Insert_Item1### Zeichenketten besteht.

Also schnell in Word ein Template mit Bookmarks(Textmarke auf deutsch) füllen und diese dann per Code ersetzen lassen, fertig. Die Idee hatte ich nicht als erstes, aber eine offene Lösung gibt es leider nicht. Direkt implementiert ist diese Funktionalität leider nicht und aufgrund der XML-Struktur von .docx-Dokumenten und der Art der Abbildung von Bookmarks ist diese Aufgabe auch alles andere als trivial. Word-Dokumente sind eigentlich Zip-Container, die wiederum viele XML-Dateien enthalten. Um als .net—Entwickler komfortabel in die Container zu blicken, nutzt man am besten die OpenXML-Tools, die hier kurz vorgestellt werden. Ein Blick in einer Word-Datei liefert z. B. folgendes Bild:

Screenshot der Productiviy Tools Die OpenXML Producivity Tools zeigen den Aufbau eines Word Dokuments und hier die Syntax für ein Bookmark

Das Bookmark hat also einen geschlossenen Tag, um den Startpunkt zu markieren und an anderer Stelle, nicht zwingend auf der gleichen Ebene im XML ein Closed-Tag. Die sehen dann so aus:

<w:bookmarkStart w:name="Beispielbookmark" w:id="0" />
<w:bookmarkEnd w:id="0" />

An dieser Stelle wird schnell klar, dass man viele Sonderfälle abfangen muss, möchte man auch Bilder und mehrere Absätze auf einmal einfügen. Versuche habe ich einige gefunden, zufriedenstellend war davon keiner. Die zweite Idee war die Verwendung von MergeFields, also der Seriendruckfunktion. Das scheiterte aber an der Anforderung, dass auch mehrere Absätze/Punkte einer Aufzählung in einem Feld stehen könnten.

Die Lösung: Content Controls (Inhaltssteuerelement) mit XML-Zuordnung

Content Controls (Inhaltssteuerelemente) lassen sich mit einem XML-Dokument verbinden, das sich direkt in das Word-Dokument eingebettet wird. Man nimmt also einfach die Vorlage, erstellt eine Kopie davon und tauscht den CustomXML-Teil aus.

Public Function CrashtestWordTemplateBefullen(replacements As Dictionary(Of String, WordInsertItem), zielFileFolder As String, templateFileFolder As String) 
    File.Copy(templateFileFolder, zielFileFolder, True)
    Using wordDoc As WordprocessingDocument = WordprocessingDocument.Open(zielFileFolder, True)
        Dim newXml = GenerateWordXML(replacements, "MeinNamespace")
        Dim main As MainDocumentPart = wordDoc.MainDocumentPart
        main.DeleteParts(Of CustomXmlPart)(main.CustomXmlParts)
        
        Dim customXml As CustomXmlPart = main.AddCustomXmlPart(CustomXmlPartType.CustomXml)  
        Using ts As StreamWriter = New StreamWriter(customXml.GetStream())
            ts.Write(newXml)
        End Using
    End Using
End Function

GenerateWordXML erzeugt den XML-Code, den schauen wir uns etwas weiter unten an. WordInsertItem ist einfach eine Hilfsklasse, die nötig ist, um eine der Eigenheiten der Content Controls auszugleichen, sie hat folgende Form:

Public Class WordInsertItem
    Public Sub New(name As String, isMultiline As Boolean)
        Me.ItemValue = name
        Me.IsMultiline = isMultiline
    End Sub
    Public Property ItemValue As String
    Public Property IsMultiline As Boolean
End Class

Inhaltssteuerelemente befinden sich in Word auf dem Entwicklertools-Tab, was man erst einblenden muss. Danach könnte man sein Template entwickeln und hinterher die XML-Datei verbinden. Für mich hat sich aber folgender Workflow als deutlich einfacher herausgestellt:

Prozess zur Templateentwicklung Mein Prozess zur Templateentwicklung

Vorteil dieser Methode ist, dass man beim Templatedesign die Controls nicht nachträglich auf XML-Elemente mappen muss, was aufwändig und fehleranfällig ist, sondern die XML-Elemente erst auswählt und dann sagt, durch welches Inhaltselement sie abgebildet werden sollen. Dazu bei den Entwicklertools XML-Zuordnungsbereich anwählen und den eigenen Namespace auswählen: Word Screenshot XML-Zuordnungsbereich XML Zuordnungsbereich aktivieren und danach den eigenen Namespace auswählen

In meinem Beispiel hat der Namespace folgende Struktur:

Word Screenshot XML-Struktur Die XML-Zuordnung in Word

Oder als XML:

<BeispielContent xmlns="MeinNamespace">
<Absatz><p>Lorem ipsum dolor sit amet, consetetur sadipscing elitr...</p></Absatz> 
<Absatz><p>At vero eos et accusam et justo duo dolores et …</p></Absatz> 
<EinfacherText>Kurzer Text</EinfacherText>
</BeispielContent>

Diese doppelte Verschachtelung bei den -Elementen ist leider nötig, wenn man Elemente einbinden möchte, die mehrere Absätze enthalten. Dabei ist es egal, wie das Unterelement heißt, solange es für alle Objekte der gleichen Art gleich heißt. Um also dafür zu sorgen, dass beide Absätze zusammen angezeigt werden, rechtklick auf eines der Absatz-Elemente machen und „Wiederholt“ auswählen. Anschließend einen Rechtsklick auf das p und „Nur-Text“ bzw. die entsprechende Art auswählen. Wichtig: Den Entwurfsmodus unbedingt deaktiviert lassen bei diesen Aktionen. Um zu prüfen, ob es geklappt hat, den Entwurfsmodus kurz an- und wieder ausschalten und voila:

Auch diesen Code kann man sich wieder mit dem OpenXML SDK Productivity Tool ansehen. Er liegt unter word/document.xml/customerXml/item1.xml: CustomXML im Productivity Tool Blick auf CustomXML im Productivity Tool

Möchte man eine Liste mit Punkten (Bullet-Points) befüllen, ist auch hier „Wiederholt“ zu verwenden, in das man ein „Nur-Text“-Element einfügt. Die Formatierung erfolgt aber nicht direkt, sondern muss über eine Formatvorlage erfolgen, sonst überschreibt Word die Formate direkt wieder.

Um das XML zu erzeugen, nutze ich folgende Funktion:

Private Function GenerateWordXML(replacements As Dictionary(Of String, WordInsertItem), xmlNamespace As String) As String
        Dim result As String = ""
        For Each entry In replacements
            If (entry.Value.IsMultiline AndAlso Not IsNothing(entry.Value.ItemValue)) Then
                Dim parts As String() = entry.Value.ItemValue.Split(New String({Environment.NewLine},StringSplitOptions.None)
                For Each part In parts
                    result = result & vbCrLf & $"<{entry.Key}><p>{part}</p></{entry.Key}>"
                Next
            Else
                result = result & vbCrLf & $"<{entry.Key}>{entry.Value.ItemValue}</{entry.Key}>"
            End If
        Next
        Return $"< BeispielContent xmlns=""{xmlNamespace}"">{result}</ BeispielContent >"
End Function

Ich habe mich dabei bewusst gegen ein implizites Setzen der <p>-Tags entschieden, weil das die Gefahr birgt, dass der Tag nicht gesetzt wird, falls nur ein Absatz vorhanden ist.

Jetzt sind wir fast am Ende, Bilder kann man ebenfalls direkt als XML einfügen. Dazu muss man aber in das XML das Bild als Base64-String direkt im XML-Code einfügen. Das geht z. B. mit folgender Funktion:

Public Function ImageToBase64(ByVal imageSource As String) As String
        Dim img = Drawing.Image.FromFile(imageSource)
        Using ms As MemoryStream = New MemoryStream()
            img.Save(ms, img.RawFormat)
            Dim imageBytes As Byte() = ms.ToArray()
            Dim base64String As String = Convert.ToBase64String(imageBytes)
            Return base64String
        End Using
End Function

Das Bild kann dann auch als Inhaltssteuerelement Bild direkt im Template eingefügt werden.

Offene Probleme/Grenzen bei der Nutzung von Content Controls

Ein paar Probleme mit Content Controls gibt es allerdings (noch):

  • Eine Formatierung von Zahlen ist mit Content Controls nicht möglich, möchte man bspw. nur eine Nachkommastelle angezeigt haben, muss man das im XML-Code machen
  • Ist ein Bild im aktuellen Datensatz dann nicht vorhanden, wird weiterhin das Bild aus dem Template angezeigt. Ein Workaround ist, das Bild im Template erst wie oben beschrieben einzufügen und danach das Bild (nicht das Steuerungselement!) zu löschen.

Die Bild-Probleme konnte ich in Teil 2 zu diesem Artikel lösen.