Post

Comment remplacer des champs dans Microsoft Word avec une solution open-source ?

Versions utilisées pour l’exemple

  • DocumentFormat.OpenXml v3.0.0
  • .NET 8

Le besoin

Dans le cadre d’un projet, le besoin était de créer des documents au format .docx ou .pdf, qui seront remplis grâce à des données renseignées par les utilisateurs dans des dossiers numériques.

Le format de ces documents était des templates de document, construits avec Microsoft Word.

C’était tout à fait pertinent, les employés de l’entreprise connaissaient bien la suite Office et cela leur permettaient de pouvoir modifier indépendamment du logiciel, le format de ces documents.

TL;DR

J’ai utilisé la librairie DocumentFormat.OpenXml pour répondre à cette question.

Si vous n’avez pas envie de lire ce tutoriel jusqu’au bout mais que vous êtes plus intéressé par la solution, vous trouverez le code source hébergé sur GitHub par ici.

Etudes des solutions payantes

Plusieurs possibilités existent pour la mise en oeuvre.

Il y a des librairies payantes comme Aspose Word pour .NET ou Iron Word de la suite Iron Software qui y répondent très bien.

Ainsi chacun proposent des SDK avec des versions à jour et éprouvées.

Tout de même, en regardant les statistiques de téléchargement des paquets NuGet Aspose vs Iron, nous remarquerons que le premier est beaucoup plus téléchargé que le second (facteur de 50 😯).

Ces deux outils nécessitent de passer par une licence payante 💸.

Ce qui ne satisfaisait pas le contexte économique de ce projet !

Etudes des solutions open-sources

Souvent, mais pas toujours, une solution open-source est proposé gratuitement.

Il est aussi possible que si la communauté autour de l’outil est importante, un nombre important de cas d’usage, ou de problèmes déjà rencontrés, seront disponibles et documentés, librement.

Et ça, en tant que développeur, c’est du pain bénit 🙌.

En effet, nous savons très bien à quel point la prise en main de framework ou librairie externes peut s’avérer painful 😅.

Au hasard de mes recherches via un très célèbre moteur de recherche ainsi que sur GitHub, je suis tombé sur Open XML SDK.

Ce n’est pas une librairie qui permet de manipuler précisément des documents de type Word mais plutôt des documents qui utilisent la norme Open XML, c’est à dire toute la suite Office.

Choix de la librairie DocumentFormat.OpenXml

Mon choix c’est donc porté sur la librairie Open Source DocumentFormat.OpenXml.

En plus de la documentation présent sur le repo GitHub de la lib, j’ai pu apprécier un autre documentation assez fourni par ici, sur l’espace Lean Microsoft.

Pourquoi ?

  • le nombre de stars sur le repo GitHub
  • la fréquence des versions publiées
  • la licence MIT
  • les statistiques NuGet

Exemple d’implémentation un document Word

Mettre en place notre modèle

Dans un premier temps, nous allons créer un document Word, et y ajouter deux champs.

Nous pouvons faire cela via le menu Quickpart, puis Champs. Screen du bandeau Insertion avec le bouton Quickpart entouré en rouge.

Il faudra ensuite choisir VariableDoc dans la liste proposé et donner le nom que vous souhaitez à votre variable.

Il est aussi possible d’utiliser le raccourci clavier CTRL+F9, et cela ajoutera des accolades au niveau de la position de votre curseur.

Choisissez comme nom NumDossier.

Ci-dessous, un screen de ce que vous devriez obtenir en executant les deux opération mentionnés plus haut.

Screen des résultats en créant un field avec le bouton Quickpart et via le raccourci clavier CTRL+F9

(Le caractère en fin de ligne est une marque de paragraphe et n’est pas concerné par notre sujet)

On peut voir que l’affichage des champs est différente selon la manière choisie. Ce qui ne posera pas de soucis car nous allons les gérer 🙃.

Initialisation du code

Commençons par ouvrir notre document !

Je vous proposer de créer une nouvelle application console ciblant .NET 8 pour notre exemple.

Notre code d’execution principal sera dans le fichier Program.cs.

Nous allons avoir besoin de notre document nouvellement créé, que je vous propose d’embarquer comme ressource dans notre projet, et d’un chemin de fichier de sortie, pour sauvegarder notre fichier.

1
2
3
4
5
6
7
8
9
10
using DocumentFormat.OpenXml.Packaging;
using KJBConseil.WordPubliposting;
using System.Reflection;

string outputPath = Path.Combine(
    Path.GetTempPath(),
    Path.GetRandomFileName() + ".docx");

var myTemplateName = "Template.docx";
string resourceName = "KJBConseil.WordPubliposting." + myTemplateName;

Puis nous allons à présent ouvrir ce super template à l’aide de la fameuse librairie DocumentFormat.OpenXml. Il faut donc bien penser à l’installer via Nuget.

1
2
3
4
5
6
7
8
9
10
using (var documentFromTemplate = WordprocessingDocument.Open(outputPath, true))
{
    var body = documentFromTemplate.MainDocumentPart?.Document.Body ??
        throw new InvalidOperationException($"The body of the XML document is null and should not be.");

    documentFromTemplate.MainDocumentPart.Document.Save();
}

Console.WriteLine(outputPath);
Console.ReadKey();

En combinant les deux bouts de code, nous pouvons à présent ouvrir notre template via le chemin renseigné, le sauvegarder (alors qu’on a rien fait effectivement…) puis l’afficher dans notre terminal.

Ajout du remplacement des valeurs de ce document

Rentrons dans le vif du sujet 🧑‍💻.

Nous allons mettre en place la partie qui va chercher, et trouver, les noms des champs, puis les remplacer par des valeurs que nous allons donner.

Pour cela, créons une nouvelle classe et ajoutons-y une méthode qui va centraliser cette logique. J’ai fais le choix d’attendre un dictionnaire pour les valeurs.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
internal static class ReplaceVariableByValues
{
    /// <summary>
    /// Expect an OpenXML document with configured fields.
    /// </summary>
    /// <param name="body"></param>
    /// <param name="fieldsToUpdate">Key must be the name of the field used in you document; value must be the replaced value.</param>
    /// <remarks>Fields are available in the "QuickPart" menu of an Office Document.
    /// You can choose to display, or hide, them with ALT+F9 key combination.
    /// CTRL+F9 will enable you to add them quickly where you cursor will be on the document.
    /// </remarks>
    public static void Execute(Body body, Dictionary<string, string> fieldsToUpdate)
    {
        // will be filled in the next example :)
    }
}

Ensuite, nous avons besoin, pour chaque nom de champs, de le rechercher dans le document. La librairie nous facilite les choses (une fois que nous avons bien lu la documentation bien sûr…) Nous allons donc filtrer tous les enfants qui présents, en utilisant la classe typé FieldCode. Pour rappel, nous avons besoin de gérer les deux manières différentes d’ajouter ces champs !

1
2
3
4
5
6
7
8
9
10
11
12
foreach (var fieldToUpdate in fieldsToUpdate)
{
    var fieldName = fieldToUpdate.Key;
    var fieldNewValue = fieldToUpdate.Value;

    foreach (var parent in body.Descendants<FieldCode>()
        .Where(fieldCode => fieldCode.Text.Contains($"DOCVARIABLE  {fieldName}") || fieldCode.Text.Contains($"{fieldName}"))
        .Select(matchedFieldCode => matchedFieldCode.Parent))
    {
        // will be filled in the next example :)
    }
}

Nous allons ensuite supprimer tous les enfants du FieldCode qui est lié au nom du champ que l’on a trouvé. C’est aussi ici que ça se complique, en effet, les accolades affichées pour chaque champs, il faut que nous les détectons pour les supprimer.

Heureusement, la logique est la même, il faut récupérer le premier et le dernier enfant pour vérifier que c’est bien une accolade ouvrante ou fermante. Ce qui détermine qu’on est bien dans le cas d’un champs (sait-on jamais …).

Nous créons un nouveau type d’enfant Text qui va contenir notre nouvelle valeur et qui va remplacer le champ existant.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
if (parent is null)
{
    throw new InvalidOperationException($"The parent of the found '{fieldName}' field code should not be null.");
}

// to remove the doc variable declaration and replace by the targeted value
parent.RemoveAllChildren<FieldCode>();
parent.AppendChild(new Text(fieldNewValue));

// search and delete opening curly bracket when using doc variable (CTRL+F9)
var parentThatCouldHoldAnOpeningCurlyBracket =
    parent.ElementsBefore().FirstOrDefault(before => before.Descendants<FieldChar>().FirstOrDefault() != null);
var couldBeAnOpeningCurlyBracket = parentThatCouldHoldAnOpeningCurlyBracket?.GetFirstChild<FieldChar>();
if (couldBeAnOpeningCurlyBracket != null &&
    couldBeAnOpeningCurlyBracket.FieldCharType?.Value == FieldCharValues.Begin)
{
    parentThatCouldHoldAnOpeningCurlyBracket!.Remove();
}

// search and delete closing curly bracket when using doc variable (CTRL+F9)
var parentThatCouldHoldAClosingCurlyBracket =
    parent.ElementsAfter().FirstOrDefault(before => before.Descendants<FieldChar>().FirstOrDefault() != null);
var couldBeAClosingCurlyBracket = parentThatCouldHoldAClosingCurlyBracket?.GetFirstChild<FieldChar>();
if (couldBeAClosingCurlyBracket != null &&
    couldBeAClosingCurlyBracket.FieldCharType?.Value == FieldCharValues.End)
{
    parentThatCouldHoldAClosingCurlyBracket!.Remove();
}

À ce stade, nous avons un bout de code fonctionnel 👏.

Le code final devrait à présent ressembler à ceci :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
internal static class ReplaceVariableByValues
{
    /// <summary>
    /// Expect an OpenXML document with configured fields.
    /// </summary>
    /// <param name="body"></param>
    /// <param name="fieldsToUpdate">Key must be the name of the field used in you document; value must be the replaced value.</param>
    /// <remarks>Fields are available in the "QuickPart" menu of an Office Document.
    /// You can choose to display, or hide, them with ALT+F9 key combination.
    /// CTRL+F9 will enable you to add them quickly where you cursor will be on the document.
    /// </remarks>
    /// <exception cref="InvalidOperationException"></exception>
    public static void Execute(Body body, Dictionary<string, string> fieldsToUpdate)
    {
        foreach (var fieldToUpdate in fieldsToUpdate)
        {
            var fieldName = fieldToUpdate.Key;
            var fieldNewValue = fieldToUpdate.Value;

            foreach (var parent in body.Descendants<FieldCode>()
                .Where(fieldCode => fieldCode.Text.Contains($"DOCVARIABLE  {fieldName}") || fieldCode.Text.Contains($"{fieldName}"))
                .Select(matchedFieldCode => matchedFieldCode.Parent))
            {
                if (parent is null)
                {
                    throw new InvalidOperationException($"The parent of the found '{fieldName}' field code should not be null.");
                }

                // to remove the doc variable declaration and replace by the targeted value
                parent.RemoveAllChildren<FieldCode>();
                parent.AppendChild(new Text(fieldNewValue));

                // search and delete opening curly bracket when using doc variable (CTRL+F9)
                var parentThatCouldHoldAnOpeningCurlyBracket =
                    parent.ElementsBefore().FirstOrDefault(before => before.Descendants<FieldChar>().FirstOrDefault() != null);
                var couldBeAnOpeningCurlyBracket = parentThatCouldHoldAnOpeningCurlyBracket?.GetFirstChild<FieldChar>();
                if (couldBeAnOpeningCurlyBracket != null &&
                    couldBeAnOpeningCurlyBracket.FieldCharType?.Value == FieldCharValues.Begin)
                {
                    parentThatCouldHoldAnOpeningCurlyBracket!.Remove();
                }

                // search and delete closing curly bracket when using doc variable (CTRL+F9)
                var parentThatCouldHoldAClosingCurlyBracket =
                    parent.ElementsAfter().FirstOrDefault(before => before.Descendants<FieldChar>().FirstOrDefault() != null);
                var couldBeAClosingCurlyBracket = parentThatCouldHoldAClosingCurlyBracket?.GetFirstChild<FieldChar>();
                if (couldBeAClosingCurlyBracket != null &&
                    couldBeAClosingCurlyBracket.FieldCharType?.Value == FieldCharValues.End)
                {
                    parentThatCouldHoldAClosingCurlyBracket!.Remove();
                }
            }
        }
    }
}

Finalisation

Retournons dans notre Program.cs afin d’y ajouter les derniers éléments pour faire fonctionner tout cela !

Dans le block qui contient l’ouverture de notre fichier Word, nous allons ajouter un dictionnaire qui contiendra nos valeurs.

Ainsi que l’appel à la méthode que nous avons créé précédemment.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using (var documentFromTemplate =
            WordprocessingDocument.Open(outputPath, true))
{
    //...
    Dictionary<string, string> fieldsWithValues = new()
    {
        { "NumDossier", "0012" },
        { "Name", "Harry Potter" },
    };

    ReplaceVariableByValues.Execute(body, fieldsWithValues);

    //...
}

Une fois cette petite application lancée, vous devriez lire dans la console, le chemin absolu vers votre fichier modifié. Il vous suffira de l’ouvrir pour voir que les valeurs ont bien été remplacés.

Résultat sur le template de l'execution du code

Comme vous pouvez le voir, j’ai un peu customisé le document Word.

Conclusion

Nous avons pu implémenter une solution basique pour remplacer des valeurs dynamiquement définis dans un document Word.

Sous le capot d’un document Word, il y a toute une arborescence de fichier XML.

D’ailleurs si vous souhaitez voir un peu comment c’est conçu, il vous suffit de remplacer l’extension de votre template en .zip.

Puis de l’extraire avec votre outil préféré d’archive.

Cela vous permettra de découvrir les noeuds XML que nous parcourons dans la méthode Execute que nous avons créé.

Tout le code de cette solution est disponible sur GitHub.


Je vous ai déjà parlé de ma règle de développement favorite ?

you SHOULD apply the famous boy scout rule : you SHOULD leave the code cleaner than you found it.

Cet article est sous licence CC BY 4.0 par l'auteur.