DEV Community

Cover image for Comment fonctionne le publipostage, expliqué en une trentaine de lignes de Python

Comment fonctionne le publipostage, expliqué en une trentaine de lignes de Python

Le publipostage ressemble à une fonction de Word, mais c'est une petite transformation de données : lire des lignes, écarter celles que refuse un filtre, insérer le reste dans un modèle et mettre un saut de page entre les copies. L'éditeur Word de l'application de bureau osFoundry le fait depuis l'onglet Mailings. Cet article reconstruit les mêmes étapes avec python-docx et openpyxl, les exécute sur la même liste de 40 adhérents et la même lettre d'une page que celles utilisées pour essayer l'application, et mesure le temps jusqu'à 40 000 lettres. Les noms, formules et dates sont des données de test générées.

Les entrées

La liste est un .xlsx avec une ligne d'en-têtes (First_Name, Last_Name, Plan, Renews, Paid) et 40 adhérents, dont 12 avec Paid = No. La lettre est un .docx de six paragraphes ; trois contiennent les mots repères NAME, PLAN et DATE.

import openpyxl

ws = openpyxl.load_workbook("members.xlsx", read_only=True).active
rows = [[str(c) for c in r] for r in ws.iter_rows(values_only=True)]
records = [dict(zip(rows[0], r)) for r in rows[1:]]
Enter fullscreen mode Exit fullscreen mode

Chaque enregistrement est un dictionnaire dont les clés sont les en-têtes. Un « champ » n'est rien de plus : un nom que l'on cherche dans l'enregistrement courant. Dans l'application, les en-têtes de la ligne 1 forment la liste où l'on choisit dans Insert Merge Field.

Filtrer, puis insérer

Word enregistre un champ sous forme d'instruction du type MERGEFIELD First_Name dans le XML du document. Le script ci-dessous saute cette couche et utilise des mots repères, comme l'enregistrement de l'application : il montre le mécanisme, mais ne lit pas les documents créés dans Word. Il copie le corps du modèle une fois par enregistrement retenu, remplace les repères dans le texte et insère un paragraphe de saut de page avant chaque copie sauf la première.

import copy, docx
W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
FIELDS = {"NAME": "First_Name", "PLAN": "Plan", "DATE": "Renews"}

def merge(template, records, keep):
    out = docx.Document(template)          # keeps styles and page setup
    body = out.element.body
    sect = body[-1]                         # sectPr: must stay last
    tmpl = [copy.deepcopy(e) for e in body if e is not sect]
    for e in [e for e in body if e is not sect]:
        body.remove(e)
    first = True
    for rec in filter(keep, records):
        if not first:
            sect.addprevious(docx.oxml.parser.parse_xml(
                '<w:p xmlns:w="http://schemas.openxmlformats.org/'
                'wordprocessingml/2006/main"><w:r><w:br w:type="page"/></w:r></w:p>'))
        for el in tmpl:
            el = copy.deepcopy(el)
            for t in el.iter(W + "t"):
                for ph, col in FIELDS.items():
                    t.text = t.text.replace(ph, rec[col])
            sect.addprevious(el)
        first = False
    return out

merge("letter.docx", records, lambda r: r["Paid"] == "No").save("out.docx")
Enter fullscreen mode Exit fullscreen mode

Relire out.docx donne la vérification qui compte : 12 lettres, 12 formules d'appel différentes, 11 sauts de page (donc 12 pages) et aucun mot repère restant dans les paragraphes. La deuxième lettre commence par « Dear Liam, » et dit « Your Annual membership renews on December 21 », le même texte que l'application affichait dans Preview Results pour la ligne 2, et le document fusionné dans l'application indiquait Page 1 of 12.

Deux choix comptent. L'élément sectPr contient la taille de page et les marges, et le format de fichier exige qu'il soit le dernier enfant du corps, d'où addprevious pour chaque insertion. Et le saut de page est un paragraphe à part, pas une propriété du premier paragraphe de chaque lettre, si bien que les lettres dont le premier paragraphe est autre (logo, bloc d'adresse) commencent aussi sur une nouvelle page.

Le piège : un repère coupé sur plusieurs runs

Un paragraphe d'un .docx est une liste de runs, et Word en démarre un nouveau chaque fois que la mise en forme, l'état du correcteur ou l'historique de modification change. Un mot tapé d'un trait peut être stocké en trois morceaux. La lettre de test a un run par paragraphe, donc elle fonctionne, mais voici ce que donne le même remplacement quand NAME est coupé en NA et ME :

naive per-run replace  -> Dear NAME,
joined-text replace    -> Dear Maya,
Enter fullscreen mode Exit fullscreen mode

La correction consiste à remplacer sur le texte joint du paragraphe et à écrire le résultat dans le premier run, au prix d'une mise en forme interne réduite à celle du premier run. Les vrais codes de champ évitent le problème, puisque le champ est un élément à part ; c'est la raison d'utiliser des champs plutôt que des mots repères dans une lettre que d'autres vont modifier.

Combien de temps ça prend

Temps pour lire le modèle, construire le document et l'enregistrer, médiane de trois exécutions, sur un Mac Apple silicon avec Python 3.12 et python-docx. Les 40 enregistrements sont la vraie liste ; les exécutions plus grandes répètent ces lignes en ajoutant un indice au prénom pour que chaque lettre soit différente, et les gardent toutes.

Lettres Temps Taille du fichier
12 (40 lignes, filtrées) 12 ms (une seule mesure) 36 KB
400 0,02 s 39 KB
4 000 0,13 s 65 KB
40 000 1,84 s 325 KB

Le coût est linéaire, environ 46 microsecondes par lettre, et le fichier reste petit parce que le XML de 40 000 paragraphes presque identiques se compresse bien. Aucun serveur ni service n'est nécessaire, et même 40 000 lettres se construisent en moins de deux secondes. Ouvrir ou imprimer un document aussi long est un coût à part, qui n'a pas été mesuré ici.

Où se place l'application

L'onglet Mailings fait le même travail avec une interface par-dessus : Select Recipients lit le .xlsx, Edit Recipient List affiche les lignes et compte ce que garde un filtre (« 12 of 40 recipients will be merged. »), et Finish & Merge écrit le résultat dans un nouveau document. L'éditeur est inclus dans l'application de bureau osFoundry, gratuite, et les fichiers restent sur l'ordinateur (local-first).

Top comments (0)