LibreOffice converts a PDF form to .docx and python-docx sees no paragraphs at all
Symptom
An official form existed only as a PDF. It had to be filled in programmatically — same layout, different values each time — and delivered as a document. The obvious plan was to convert the PDF once, keep the result as a template, and write values into it with python-docx.
The conversion works. LibreOffice has an import filter for PDF, it runs headless, and it exits cleanly:
soffice --headless --infilter="writer_pdf_import" --convert-to docx form.pdf
The resulting form.docx opens in a word processor and looks right. Field labels, boxes, the signature
block at the bottom — all where they belong. Then python-docx opens the same file and finds nothing:
>>> from docx import Document
>>> doc = Document("form.docx")
>>> len(doc.paragraphs)
0
>>> len(doc.tables)
0
No exception, no warning, no corrupt-file complaint. Just an empty document as far as the API is concerned.
Iterating doc.paragraphs to locate the label you want to write next to yields nothing, because there is
nothing to iterate.
What it is not
The first instinct is to blame python-docx, and that is the wrong turn that costs the most time. It is not a
python-docx bug and the file is not damaged — the document genuinely contains no top-level paragraphs.
doc.paragraphs returns the w:p children of the document body, and this body has none.
The second instinct is to try another conversion target: --convert-to odt and then .docx, or docx:MS Word 2007 XML, or opening the PDF in LibreOffice Writer by hand and saving as .docx from the GUI. All of
them reproduce the same result, because the problem is not on the export side. It is the import side:
writer_pdf_import is what decides how the page becomes a document, and every route that starts from the
PDF’s layout lands in the same place.
The ten-second check, before you write any templating code:
from docx import Document
doc = Document("form.docx")
print(len(doc.paragraphs), len(doc.tables))
print([child.tag.rsplit("}", 1)[-1] for child in doc.element.body])
The body is not empty — it has children — but not one of them is a w:p. They are drawing elements, one per
reconstructed block of text on the page, plus the trailing section properties. That is a faithful picture of
the page and a useless template.
What I measured
The reason is inherent to the format, not to the converter’s quality. A PDF has no paragraph structure to recover. It stores glyph runs at coordinates: this string, this font, at this x/y. There is no “paragraph”, no “table”, no “heading” — those are conclusions a human draws from the positions. When the import filter has to produce a Writer document from that, it does the only lossless thing available: it wraps each block of text in an absolutely positioned frame at the coordinates it was found at, so the page renders identically.
Text inside a drawing frame is not part of the document body’s paragraph flow. That is why it is visible on
screen and invisible to doc.paragraphs — the API is telling the truth about the structure. The appearance
survived the conversion perfectly; the structure never existed to survive.
The other half of the measurement was ruling out the export format. Going through ODF instead —
--convert-to odt, then .docx from that — produced the same shape-per-block arrangement, so this is not a
.docx writer artefact. Whatever you export to, the document you are exporting was already a stack of
frames, and there was no logical structure in it to anchor an insertion to.
The fix: rebuild the form, do not convert it
Stop trying to inherit the layout. Extract the content from the PDF, then re-typeset the form as code.
First, get the wording and the ordering out with poppler, using -layout so the visual arrangement is
preserved:
pdftotext -layout form.pdf form.txt
-layout matters here. The default mode reflows text into reading order and destroys column alignment;
official forms have meaningful ordering and side-by-side fields, and -layout keeps labels next to the
boxes they belong to. The text file is your specification: exact wording, exact field order, rough column
structure.
Then build the document with python-docx. For a typical one-page official form that means A4, margins around 2.5 cm top and bottom and 2 cm left and right, Times New Roman 12, and tables for the field grid:
from docx import Document
from docx.shared import Pt, Cm, Mm
from docx.enum.text import WD_ALIGN_PARAGRAPH
doc = Document()
section = doc.sections[0]
section.page_width = Mm(210)
section.page_height = Mm(297)
section.top_margin = section.bottom_margin = Cm(2.5)
section.left_margin = section.right_margin = Cm(2)
normal = doc.styles["Normal"]
normal.font.name = "Times New Roman"
normal.font.size = Pt(12)
title = doc.add_paragraph()
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
title.add_run("Application for registration").bold = True
table = doc.add_table(rows=2, cols=2)
table.style = "Table Grid"
table.cell(0, 0).text = "Applicant"
table.cell(0, 1).text = "Jane Example"
table.cell(1, 0).text = "Registration number"
table.cell(1, 1).text = "0000/2026"
doc.save("out.docx")
Keep the headings and field labels identical to the original and in the same order — that is what the
reviewer on the other end compares against, and form.txt gives it to you verbatim.
Fill the values in the same pass. There is no reason to produce an empty template and mutate it later: you
are generating a finished document, so the data goes in while the structure is being written. That also
sidesteps the whole class of second-pass problems that come with re-opening generated office files, like
the second save() trap in openpyxl.
Finally, render and compare against the original instead of trusting the code:
soffice --headless --convert-to pdf out.docx
pdftoppm -r 150 -png out.pdf page
Put page-1.png next to the original page and look at three things: the page count, the field positions,
and whether the signature block is still on the first page. Overflow onto a second page is the failure that
gets a form rejected and the one that a word-count-based check will never catch.
Why the rebuild is cheaper than it sounds
It feels like the expensive option and it is not. A one-page form is typically a handful of tables and about
twenty labels — an hour of typing, most of it copy-paste out of form.txt. The conversion route cost me an
afternoon of filter flags and round-trips and ended with a file I could not edit programmatically at all.
The asymmetry shows up afterwards. Once the form is code, regenerating it for the next applicant, the next year, or the next entity is a function call. A converted template, even if you managed to poke text into its shapes, would have to be re-derived by hand every time the authority republishes the PDF with a moved box.
Where this is the wrong approach
Be honest about the limits. A rebuild is reasonable for short structured forms. It is not reasonable for a fifty-page document, and it is not acceptable when the output must be visually identical to the original for legal reasons — a re-typeset form is a close copy, not the same artefact.
For those cases, work on the PDF itself: fill its own AcroForm fields where the PDF has them, or overlay
text at fixed coordinates where it does not. pdftotext -layout plus a python-docx rebuild is a
document-generation strategy, not a PDF-editing strategy, and choosing between them is the actual decision
hiding behind “just convert it to Word”.
What to remember
Conversions preserve appearance, not structure. Any pipeline that consumes a converted file is betting on structure that the source format may never have carried, and the bet fails silently — you get a valid file, a clean exit code, and an empty API.
So assert on the structure you need, immediately after the conversion and before any templating code exists:
doc = Document("form.docx")
assert len(doc.paragraphs) > 0, "no paragraphs: text is probably in shapes"
assert len(doc.tables) > 0, "no tables: field grid did not survive"
print(repr(doc.paragraphs[0].text))
Three lines that fail loudly are worth more than discovering the same fact after the filling logic is written. And when they do fail, read them as an answer rather than an obstacle: the converter is telling you that the structure you wanted was never in the PDF, and that you will have to supply it yourself.