DEV Community

Cover image for How to convert a PDF to Word with Python and PyMuPDF
Hanako H.
Hanako H.

Posted on

How to convert a PDF to Word with Python and PyMuPDF

A PDF has no paragraphs, headings or tables. It has glyphs, each drawn at an x and y on a page, and a few rectangles and lines. Every PDF-to-Word converter is therefore a small layout-analysis program: group the glyphs into lines, decide which lines belong together, and write the result as styled .docx objects. The PDF Editor in the osFoundry desktop app has this as an export (All tools, Export a PDF, Microsoft Word). The editor ships as a compiled module, so there is no source to quote here. What follows is the same job rebuilt in about 50 lines of Python with PyMuPDF and python-docx, run on the same one-page notice, and then compared against the .docx the app wrote from that PDF.

Measured on an Apple M1 with Python 3.12, MuPDF 1.28.6 and python-docx 1.2.0. The test page is a made-up one-page notice (example document): a company name, a title, two intro lines, a "Details" heading, five label and value rows, and a footer line.

What the file really stores

Dumping the glyphs of the page shows how little there is. The first letters of the title:

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

The whole page is 327 glyphs, 277 of them not spaces, in 16 lines. Nothing says that "Holiday Party 2025" is a title. It is 26-point text at y = 118, and that is the only clue. Spaces are not even guaranteed to exist. I wrote a PDF with each word placed on its own at an x offset, which is how many generators do it, and the file contained no space character. page.get_text() still returned Thank you all for a great year, because MuPDF inserts a space when the gap between two glyphs is wider than a fraction of the font size.

The naive route, page.get_text() and a paragraph per line, gives 16 paragraphs for this page: the label "Date" and its value are two paragraphs, and so is each of the two intro lines.

Group lines into structure

Three signals are enough for a clean page, and all of them are numbers already in the dump.

Font size separates headings from body. The body size is the most common size on the page (12 pt here, the intro lines plus the ten label and value lines), and anything clearly bigger is a heading. Size ratio picks the level.

Baseline separates table cells from text. Two text lines with the same y but a large gap in x are two cells of one row.

Line spacing separates a paragraph from a stack of lines. Two consecutive lines of the same size, closer than twice the font size, are one paragraph.

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

Run on the English notice, it produces: a Normal paragraph for the company name, Heading 1 for "Holiday Party 2025", one Normal paragraph for the two intro lines, Heading 2 for "Details", a 5-row table, and the footer as a plain paragraph. The Japanese version of the same notice, 149 non-space glyphs in 15 lines, comes out the same way.

Compare with the app's export

I exported the same two PDFs with the app and read the .docx files back with python-docx. The paragraphs and styles match mine line for line: same text, same Normal, Heading 1 and Heading 2 split, same one paragraph for the two intro lines, and the same five table rows.

Glyphs (non-space) Lines Paragraphs after grouping Table rows Export size
English notice 277 16 5 (2 headings) 5 2,737 bytes (app)
Japanese notice 149 15 5 (2 headings) 5 2,882 bytes (app)

The rebuild takes a median of 16.9 ms per page for the English notice and 16.7 ms for the Japanese one (50 runs each, including writing the .docx to disk).

Where the heuristics break

Three cases I ran, each of which a simple rule gets wrong.

A scanned page has no glyphs at all. I rasterised the test page at 150 dpi and wrapped it in a new PDF: rawdict returned 0 glyphs and 1 image. The rules above have nothing to work with until OCR has turned the image into text with positions.

Two text columns look like a table. A page with two columns of body text, four lines each, came out of rebuild as a 4-row, 2-column table and zero paragraphs, because "same baseline, big gap in x" is exactly what a table row is. A real converter has to check for ruling lines or consistent column edges over many rows before it calls something a table.

Joining lines needs to know the script. With a space between every joined line, a Japanese sentence that wraps in the PDF would get a space in the middle of it. The join function above skips the space when either side of the break is a Japanese character. A two-line Japanese paragraph in my test came out as one unbroken sentence.

Why the rebuilt file is worth having

The point of the rebuild is not the file format. With real Heading 1 and Heading 2 styles and a real table, the document can be searched, restyled and reflowed, and a find-and-replace for a year changes every occurrence. Text laid out by position gives none of that; copying it out of the PDF gives a block of lines to re-format by hand. A page like the notice above is also small enough to read the result back and check it, which takes a few lines of 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

The exported file opened in the Word Editor: heading, paragraph and table

The export is in the PDF Editor of the osFoundry desktop app, free from the download page, and it runs on the computer itself, in line with the local-first approach the app is built around.

Top comments (0)