DEV Community

Cover image for Come funziona la stampa unione, spiegata con una trentina di righe di Python

Come funziona la stampa unione, spiegata con una trentina di righe di Python

La stampa unione sembra una funzione di Word, ma è una piccola trasformazione di dati: leggere le righe, scartare quelle che un filtro rifiuta, inserire le altre in un modello e mettere un'interruzione di pagina tra le copie. L'editor Word dell'app desktop di osFoundry lo fa dalla scheda Mailings. Questo articolo ricostruisce gli stessi passaggi con python-docx e openpyxl, li esegue sulla stessa lista di 40 soci e sulla stessa lettera di una pagina usate per provare l'app, e misura i tempi fino a 40.000 lettere. Nomi, piani e date sono dati di prova generati.

Gli input

La lista è un .xlsx con una riga di intestazioni (First_Name, Last_Name, Plan, Renews, Paid) e 40 soci, 12 dei quali con Paid = No. La lettera è un .docx di sei paragrafi; tre contengono le parole segnaposto NAME, PLAN e 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

Ogni record è un dizionario con le intestazioni come chiavi. Un "campo" non è altro: un nome che si cerca nel record corrente. Nell'app, le intestazioni della riga 1 formano l'elenco da cui si sceglie in Insert Merge Field.

Prima filtrare, poi inserire

Word salva un campo come istruzione del tipo MERGEFIELD First_Name nell'XML del documento. Lo script qui sotto salta quel livello e usa parole segnaposto, come la registrazione dell'app: mostra il meccanismo, ma non legge documenti creati in Word. Copia il corpo del modello una volta per ogni record selezionato, sostituisce i segnaposto nel testo e inserisce un paragrafo con interruzione di pagina prima di ogni copia tranne la prima.

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

Rileggere out.docx dà il controllo che conta: 12 lettere, 12 saluti diversi, 11 interruzioni di pagina (quindi 12 pagine) e nessuna parola segnaposto rimasta in alcun paragrafo. La seconda lettera inizia con "Dear Liam," e dice "Your Annual membership renews on December 21", lo stesso testo mostrato dall'app in Preview Results per la riga 2, e il documento unito nell'app diceva Page 1 of 12.

Due scelte contano. L'elemento sectPr contiene dimensione della pagina e margini, e il formato del file richiede che sia l'ultimo figlio del corpo, per questo ogni inserimento usa addprevious. E l'interruzione di pagina è un paragrafo a sé, non una proprietà del primo paragrafo di ogni lettera, così anche le lettere con un primo paragrafo diverso (logo, blocco indirizzo) iniziano su una nuova pagina.

L'insidia: un segnaposto spezzato su più run

Un paragrafo di un .docx è una lista di run, e Word ne inizia uno nuovo ogni volta che cambiano formattazione, stato del correttore o cronologia delle modifiche. Una parola scritta in un colpo solo può finire salvata in tre pezzi. La lettera di prova ha un run per paragrafo, quindi funziona, ma ecco cosa fa la stessa sostituzione quando NAME è spezzato in NA e ME:

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

La soluzione è sostituire sul testo unito del paragrafo e scrivere il risultato nel primo run, al prezzo di ridurre la formattazione interna del paragrafo a quella del primo run. I veri codici di campo evitano il problema, perché il campo è un elemento a sé; è il motivo per usare campi e non parole segnaposto in una lettera che modificheranno altri.

Quanto ci mette

Tempo per leggere il modello, costruire il documento e salvarlo, mediana di tre esecuzioni, su un Mac Apple silicon con Python 3.12 e python-docx. I 40 record sono la lista vera; le esecuzioni più grandi ripetono quelle righe aggiungendo un indice al nome perché ogni lettera sia diversa, e le tengono tutte.

Lettere Tempo Dimensione del file
12 (40 righe, filtrate) 12 ms (una sola misura) 36 KB
400 0,02 s 39 KB
4.000 0,13 s 65 KB
40.000 1,84 s 325 KB

Il costo è lineare, circa 46 microsecondi per lettera, e il file resta piccolo perché l'XML di 40.000 paragrafi quasi identici si comprime bene. Non servono server né servizi, e anche 40.000 lettere si costruiscono in meno di due secondi. Aprire o stampare un documento così lungo è un costo a parte, che qui non è stato misurato.

Dove entra l'app

La scheda Mailings fa lo stesso lavoro con un'interfaccia sopra: Select Recipients legge l'.xlsx, Edit Recipient List mostra le righe e conta quante ne tiene un filtro ("12 of 40 recipients will be merged."), e Finish & Merge scrive il risultato in un nuovo documento. L'editor è incluso nell'app desktop di osFoundry, gratuita, e i file restano sul computer (local-first).

Top comments (0)