DEV Community

Cover image for Come convertire un PDF in Word con Python e PyMuPDF

Come convertire un PDF in Word con Python e PyMuPDF

Un PDF non ha paragrafi, titoli o tabelle. Ha glifi, ciascuno disegnato a una posizione x e y della pagina, più qualche rettangolo e qualche linea. Ogni convertitore da PDF a Word è quindi un piccolo programma di analisi del layout: raggruppa i glifi in righe, decide quali righe stanno insieme e scrive il risultato come oggetti .docx con stile. L'editor PDF dell'app desktop di osFoundry offre questa funzione come esportazione (All tools, Export a PDF, Microsoft Word). L'editor viene distribuito come modulo compilato, quindi non c'è codice sorgente da citare. Qui sotto lo stesso lavoro viene ricostruito in circa 50 righe di Python con PyMuPDF e python-docx, eseguito sullo stesso avviso di una pagina e poi confrontato con il .docx che l'app ha scritto a partire da quel PDF.

Misurato su un Apple M1 con Python 3.12, MuPDF 1.28.6 e python-docx 1.2.0. La pagina di prova è un avviso inventato di una pagina (documento di esempio): il nome di un'azienda, un titolo, due righe di introduzione, un'intestazione «Details», cinque righe di etichetta e valore e una riga a piè di pagina.

Che cosa contiene davvero il file

Un dump dei glifi della pagina mostra quanto poco ci sia. Le prime lettere del titolo:

import pymupdf
page = pymupdf.open("Holiday party 2025.pdf")[0]
for b in page.get_text("rawdict")["blocks"]:
    for line in b.get("lines", []):
        for span in line["spans"]:
            for ch in span["chars"]:
                print(ch["c"], round(ch["origin"][0], 1), round(ch["origin"][1], 1), span["size"])
Enter fullscreen mode Exit fullscreen mode
H 56.0 118.0 26.0
o 74.8 118.0 26.0
l 90.6 118.0 26.0
i 97.8 118.0 26.0
d 105.0 118.0 26.0
Enter fullscreen mode Exit fullscreen mode

L'intera pagina è fatta di 327 glifi, 277 dei quali non sono spazi, in 16 righe. Niente dice che «Holiday Party 2025» sia un titolo. È testo a 26 punti a y = 118, e questo è l'unico indizio. Non è nemmeno garantito che gli spazi esistano. Ho scritto un PDF con ogni parola posizionata da sola con uno scostamento in x, come fanno molti generatori, e il file non conteneva alcun carattere di spazio. page.get_text() ha comunque restituito Thank you all for a great year, perché MuPDF inserisce uno spazio quando il vuoto tra due glifi supera una frazione della dimensione del carattere.

La strada ingenua, page.get_text() e un paragrafo per riga, dà 16 paragrafi per questa pagina: l'etichetta «Date» e il suo valore sono due paragrafi, e lo sono anche le due righe di introduzione.

Raggruppare le righe in una struttura

Per una pagina pulita bastano tre segnali, e tutti e tre sono numeri già presenti nel dump.

La dimensione del carattere separa i titoli dal corpo del testo. La dimensione del corpo è quella più frequente nella pagina (qui 12 pt: le righe di introduzione più le dieci righe di etichetta e valore), e tutto ciò che è nettamente più grande è un titolo. Il rapporto tra le dimensioni decide il livello.

La linea di base separa le celle di tabella dal testo. Due righe di testo con lo stesso y ma un grande scarto in x sono due celle della stessa riga.

L'interlinea separa un paragrafo da una pila di righe. Due righe consecutive della stessa dimensione, più vicine del doppio della dimensione del carattere, formano un paragrafo.

import collections, statistics, pymupdf, docx

def is_cjk(ch):
    return " " <= ch <= "鿿" or "＀" <= ch <= "￯"

def join(prev, nxt):
    # a line break is a space between Latin words and nothing between Japanese characters
    return nxt if is_cjk(prev[-1]) or is_cjk(nxt[0]) else " " + nxt

def read_lines(page):
    out = []
    for b in page.get_text("dict")["blocks"]:
        for ln in b.get("lines", []):
            spans = [s for s in ln["spans"] if s["text"].strip()]
            if spans:
                out.append({"text": "".join(s["text"] for s in ln["spans"]).strip(),
                            "x": ln["bbox"][0], "y": ln["bbox"][1],
                            "size": round(spans[0]["size"], 1)})
    return sorted(out, key=lambda l: (round(l["y"]), l["x"]))

def rebuild(pdf_path, out_path):
    lines = read_lines(pymupdf.open(pdf_path)[0])
    body = statistics.mode([l["size"] for l in lines])
    rows = collections.defaultdict(list)
    for l in lines:
        rows[round(l["y"])].append(l)

    doc, para, prev, table = docx.Document(), None, None, None
    for y in sorted(rows):
        cells = sorted(rows[y], key=lambda l: l["x"])
        if len(cells) >= 2:                      # label and value on one baseline
            if table is None:
                table = doc.add_table(rows=0, cols=2)
            r = table.add_row().cells
            r[0].text, r[1].text = cells[0]["text"], cells[1]["text"]
            para = prev = None
            continue
        l, table = cells[0], None
        if l["size"] > body * 1.2:               # clearly larger than body text
            doc.add_heading(l["text"], level=1 if l["size"] >= body * 1.8 else 2)
            para = None
        elif para and l["y"] - prev["y"] < 2.0 * l["size"] and l["size"] == prev["size"]:
            para.add_run(join(para.text, l["text"]))
        else:
            para = doc.add_paragraph(l["text"])
        prev = l
    doc.save(out_path)
Enter fullscreen mode Exit fullscreen mode

Eseguito sull'avviso in inglese, produce: un paragrafo Normal per il nome dell'azienda, Heading 1 per «Holiday Party 2025», un paragrafo Normal per le due righe di introduzione, Heading 2 per «Details», una tabella di 5 righe e la riga a piè di pagina come semplice paragrafo. La versione giapponese dello stesso avviso, con 149 glifi senza spazi in 15 righe, viene allo stesso modo.

Confronto con l'esportazione dell'app

Ho esportato con l'app gli stessi due PDF e riletto i file .docx con python-docx. Paragrafi e stili coincidono con i miei riga per riga: stesso testo, stessa suddivisione tra Normal, Heading 1 e Heading 2, stesso unico paragrafo per le due righe di introduzione e le stesse cinque righe di tabella.

Glifi (senza spazi) Righe Paragrafi dopo il raggruppamento Righe di tabella Dimensione dell'esportazione
Avviso in inglese 277 16 5 (2 titoli) 5 2,737 byte (app)
Avviso in giapponese 149 15 5 (2 titoli) 5 2,882 byte (app)

La ricostruzione richiede una mediana di 16.9 ms per pagina con l'avviso in inglese e 16.7 ms con quello in giapponese (50 esecuzioni ciascuno, compresa la scrittura del .docx su disco).

Dove le euristiche si rompono

Tre casi che ho provato, in cui una regola semplice sbaglia.

Una pagina scansionata non ha alcun glifo. Ho rasterizzato la pagina di prova a 150 dpi e l'ho inserita in un nuovo PDF: rawdict ha restituito 0 glifi e 1 immagine. Le regole sopra non hanno nulla su cui lavorare finché l'OCR non ha trasformato l'immagine in testo con posizioni.

Due colonne di testo sembrano una tabella. Una pagina con due colonne di testo, quattro righe ciascuna, è uscita da rebuild come tabella di 4 righe e 2 colonne e zero paragrafi, perché «stessa linea di base, grande scarto in x» è esattamente ciò che è una riga di tabella. Un convertitore vero deve cercare linee di separazione o bordi di colonna coerenti su molte righe prima di chiamare tabella qualcosa.

Unire le righe richiede di conoscere la scrittura. Con uno spazio tra tutte le righe unite, una frase giapponese che va a capo nel PDF si ritroverebbe con uno spazio a metà. La funzione join qui sopra omette lo spazio quando uno dei due lati dell'a capo è un carattere giapponese. Un paragrafo giapponese di due righe è uscito, nel mio test, come un'unica frase continua.

A che cosa serve il file ricostruito

Lo scopo della ricostruzione non è il formato del file. Con veri stili Heading 1 e Heading 2 e una vera tabella, il documento si può cercare, ristilizzare e reimpaginare, e un trova e sostituisci di un anno cambia ogni occorrenza. Il testo disposto per posizione non offre niente di tutto ciò; copiato dal PDF dà un blocco di righe da riformattare a mano. Una pagina come l'avviso qui sopra è inoltre abbastanza piccola da rileggere e verificare il risultato, il che richiede poche righe di python-docx:

d = docx.Document("Holiday party 2025.docx")
print([(p.style.name, p.text) for p in d.paragraphs if p.text.strip()])
print([[c.text for c in r.cells] for r in d.tables[0].rows])
Enter fullscreen mode Exit fullscreen mode

Il file esportato aperto nell'editor Word: titolo, paragrafo e tabella

L'esportazione si trova nell'editor PDF dell'app desktop osFoundry, che si scarica gratis dalla pagina di download e funziona sul computer stesso, in linea con l'approccio local-first su cui l'app è costruita.

Top comments (0)