Document automation in a law office fails in a particular way. The script runs. It prints “wrote motion.docx.” No exception, no warning, every assertion passes. Then somebody opens the file and the signature block is in Calibri, the placeholder is still red, the footer with the file number shows up on page one and nowhere else, and the filled PDF form is blank in Preview.
The code that made the file can’t see a single one of those bugs. Anybody looking at the page sees all of them. So here’s the rule behind everything below:
Render the output and look at it before you deliver it. “It wrote without an exception” isn’t verification. All it tells you is that one kind of problem didn’t announce itself one particular way.
These are the five ways this has actually bitten me building document automation for this firm, with the code that fixes each. Every example here is made up — fake placeholder tokens, a fictional matter number, a made-up form. No client document appears in this article.
I ran every snippet before publishing, against python-docx 1.2.0, PyMuPDF 1.26.5, and LibreOffice 26.2 on macOS. Where I couldn’t reproduce something in a test, I tell you.
1. python-docx quietly drops your formatting
This is the one that eats the most time, because the object model lies to you.
In WordprocessingML, character formatting lives in an rPr element inside each run (w:r). Paragraph formatting lives in a pPr inside the paragraph (w:p). Neither one comes from the neighbors. Both come from the style, and a firm template usually gets its look from direct formatting on the runs, not from a clean style hierarchy. So a run you create in code inside an existing paragraph starts with no rPr at all and falls back to whatever the document default is.
Here’s that happening, straight from a run I executed:
from docx import Document
from docx.oxml.ns import qn
d = Document("template.docx")
p = d.paragraphs[0] # "Dated: [[DATE]]" — Times New Roman 12, bold
naive = p.add_run(" (naive)")
print(naive._r.find(qn("w:rPr")) is not None)
# False
No rPr. In the Python object model naive.font.name is None, which reads as “not set,” which reads as “fine.” On the page, it’s the Normal style’s font at the Normal style’s size, sitting right next to text that isn’t.
The fix: clone the rPr from a sibling run in the same paragraph and attach it to the new run. copy.deepcopy on the lxml element is the right tool, because rPr is a self-contained subtree with no references out.
import copy
from docx.oxml.ns import qn
def add_run_like(paragraph, text, model_run=None):
"""Append a run to `paragraph` carrying the run properties of a sibling."""
if model_run is None:
for existing in paragraph.runs:
if existing._r.find(qn("w:rPr")) is not None:
model_run = existing
break
new_run = paragraph.add_run(text)
if model_run is not None:
model_rPr = model_run._r.find(qn("w:rPr"))
if model_rPr is not None:
existing_rPr = new_run._r.find(qn("w:rPr"))
if existing_rPr is not None:
new_run._r.remove(existing_rPr)
new_run._r.insert(0, copy.deepcopy(model_rPr)) # rPr must be first
return new_run
Two details you can’t skip. rPr has to be the first child of w:r — that’s why it’s insert(0, ...) and not append. And you have to remove any rPr python-docx already created, or you end up with two, and I don’t want to find out what Word does with two.
Same problem one level up. To insert a whole paragraph after an existing one and keep its indentation, spacing, and justification, clone the w:p, strip everything but the pPr, and splice it in with lxml’s addnext:
import copy
from docx.text.paragraph import Paragraph
from docx.oxml.ns import qn
def insert_paragraph_after(paragraph, text=""):
"""Insert a new paragraph directly after `paragraph`, cloning its pPr."""
new_p = copy.deepcopy(paragraph._p)
for child in list(new_p):
if child.tag != qn("w:pPr"):
new_p.remove(child) # keep the formatting, drop the content
paragraph._p.addnext(new_p)
new_para = Paragraph(new_p, paragraph._parent)
if text:
add_run_like(new_para, text, model_run=(paragraph.runs[0] if paragraph.runs else None))
return new_para
I ran it against a source paragraph that was centered and double-spaced. The new paragraph came back centered and double-spaced, pPr intact:
<w:pPr>
<w:spacing w:line="480" w:lineRule="auto"/>
<w:jc w:val="center"/>
</w:pPr>
Check the XML, not the object model. run._r.xml and paragraph._p.xml print the actual serialized element. That string is what Word reads. The Python attributes are a convenience layer on top of it, and on this problem the convenience layer’s None points you exactly the wrong way.
2. Red placeholders and the footer that only shows up on page one
Firm templates mark placeholder text in a color — usually red — so whoever proofreads a draft can see at a glance what hasn’t been filled. Good convention. It turns into a hazard the minute a script does the filling, because replacing the text of a run doesn’t touch the run’s color. You file something with red text in it.
The fix is to delete the color element instead of setting a new one. Setting it to black works, but removing it lets the style hierarchy decide, and that’s what you actually want:
from docx.oxml.ns import qn
def fill_placeholder(paragraph, token, value):
"""Replace `token` with `value` and drop any placeholder styling."""
hits = 0
for run in paragraph.runs:
if token in run.text:
run.text = run.text.replace(token, value)
rPr = run._r.find(qn("w:rPr"))
if rPr is not None:
for tag in ("w:color", "w:highlight"):
el = rPr.find(qn(tag))
if el is not None:
rPr.remove(el)
hits += 1
return hits
That function returns a count for a reason. A placeholder split across two runs — which happens all the time, because Word splits runs at spell-check boundaries and revision marks — won’t match, and fill_placeholder will return 0 without a peep. Assert on the count.
The footer is the same kind of bug with a bigger blast radius. A filing that has to carry a file number on every page is easy to botch, because a document with more than one section has more than one footer, and python-docx reports every section’s footer as “linked to previous” by default. In my test, looping over sections and skipping the linked ones wrote zero footers into a two-section document and raised nothing. The version that works unlinks first:
from docx.shared import Pt
def stamp_footer(document, text):
"""Put `text` in the footer of every section. Returns the number written."""
written = 0
for section in document.sections:
footer = section.footer
footer.is_linked_to_previous = False # force this section to own a footer
para = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
para.text = text
for run in para.runs:
run.font.name = "Times New Roman"
run.font.size = Pt(10)
written += 1
return written
Two sections in, two footers out, and both survived a save-and-reopen. There’s a tradeoff: once you unlink every section, later sections stop inheriting, so if you want a different footer on a later section you have to write it yourself. For a court filing where the same number goes on every page, that’s what you want. One more trap: section.first_page_footer and section.even_page_footer are separate objects, and a template with “different first page” turned on will happily show you a blank footer on page one while the rest of the document looks fine.
3. Finish with a LibreOffice round-trip
A .docx a library produces is a ZIP of XML that makes the library happy. Making Word happy is a different bar. Element order inside rPr and pPr is fixed by the schema, relationship IDs have to resolve, sectPr has to land in the right spot, and a library that lets you build the tree by hand will let you build a broken one.
Run the file through headless LibreOffice and it reparses the whole document against a real ODF/OOXML implementation and writes it back out cleaned up. Structural problems either get fixed or get loud.
soffice --headless --convert-to docx --outdir ./out ./draft.docx
soffice --headless --convert-to pdf --outdir ./out ./draft.docx
On this machine that takes about 1.7 seconds for a short document. The same command gets you a reliable PDF, and that matters because it’s the same renderer — the PDF you proof is a faithful preview of the .docx you’re about to hand over.
Three things I confirmed by testing that’ll bite you otherwise:
LibreOffice exits 0 when it fails. I fed it a file named .docx that contained the text not a docx, and separately asked it to write a .docx into the same directory as its source. Both printed an error, or quietly produced nothing, and both returned exit status 0. If your pipeline checks the return code, it isn’t checking anything.
Same directory in and out doesn’t work for the same format. --convert-to docx --outdir <dir-of-source> resolves to the source path, LibreOffice refuses to overwrite it, prints Error: Please verify input parameters..., and exits 0. Convert into a scratch directory and move the result.
Garbage in, plausible garbage out. That not a docx text file converted “successfully” to a one-page PDF containing the words not a docx. LibreOffice guessed at the format and, in fairness, guessed right. Nothing in the pipeline noticed.
So wrap it, and check the file it made instead of the exit code:
import os, subprocess
SOFFICE = "/Applications/LibreOffice.app/Contents/MacOS/soffice"
def soffice_convert(src, fmt, outdir, profile=None):
"""Convert `src` to `fmt` in `outdir`. Raises unless real output appears."""
os.makedirs(outdir, exist_ok=True)
dest = os.path.join(outdir, os.path.splitext(os.path.basename(src))[0] + "." + fmt)
if fmt == "docx" and os.path.abspath(outdir) == os.path.abspath(os.path.dirname(src)):
raise ValueError("outdir must differ from the source directory for docx->docx")
if os.path.exists(dest):
os.remove(dest) # never trust a stale artifact
cmd = [SOFFICE]
if profile:
cmd.append(f"-env:UserInstallation={profile}")
cmd += ["--headless", "--convert-to", fmt, "--outdir", outdir, src]
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
if not os.path.exists(dest) or os.path.getsize(dest) < 1024:
raise RuntimeError(
f"conversion produced nothing usable\nrc={proc.returncode}\n"
f"stdout={proc.stdout.strip()}\nstderr={proc.stderr.strip()}")
return dest
Pass the profile argument. LibreOffice keeps one user profile with a lock on it, so a headless conversion fired while you’ve got LibreOffice open on the desktop can hang or fail. Point the job at its own profile directory — -env:UserInstallation=file:///tmp/lo_profile — and it stays out of the way. I tested that flag. It works and costs nothing.
4. Government PDF forms: fill it, then bake it
Fill an AcroForm the obvious way and you get a file where the values are really there and really invisible.
The culprit is appearance streams. A form field has a value (/V) and a drawn appearance (/AP). Historically, setting the value didn’t redraw the appearance, and the usual workaround was the document-level /NeedAppearances flag. That flag doesn’t fix anything. It asks the reader to redraw the fields when the file opens. pypdf exposes this as auto_regenerate, and its documentation says the flag “indicates if the reading program should re-render the visual fields upon document launch,” and that setting it true “may trigger a ‘save changes’ dialog.” A reader that honors the flag shows your values. One that ignores it shows an empty form. That’s the whole failure: the file looks right on the machine that made it, shows up blank on somebody else’s, and the values were in there the whole time.
I tested that before repeating it, and things have changed. On pypdf 6.10.2 I built a single-page AcroForm with one text widget, filled it with update_page_form_field_values, and looked inside: the field came back with a populated /V and a 111-byte /AP normal appearance stream, and it showed up on the page. Modern pypdf draws the appearance for a simple text field itself instead of leaving it to the reader.
So don’t carry the old rule forward as gospel. And don’t assume the problem’s gone. My test was one text field on a form I made myself, which is the easy case. Real government forms bring comb fields, choice and button widgets, embedded fonts the filler can’t resolve, and now and then XFA. That same run spat out Font dictionary for /Helv not found; defaulting to Helvetica — a font lookup failing on the simplest field there is, and exactly the kind of thing that produces an appearance nobody can see. Whether the appearance gets drawn depends on the library version and on the form. So check the output. Don’t trust a version number.
Don’t negotiate with appearance streams. Write the values with PyMuPDF, then flatten them into the page with doc.bake(), which the PyMuPDF documentation describes as making “annotations / fields permanent content.” After baking there’s no form left to draw — the text is ordinary drawing operations on the page, same as any other text.
import fitz # PyMuPDF
VALUES = {"applicant_name": "Jane Q. Fictional", "county": "Nonesuch County"}
doc = fitz.open("form_blank.pdf")
filled = {}
for page in doc:
for widget in page.widgets():
if widget.field_name in VALUES:
widget.field_value = VALUES[widget.field_name]
widget.update() # required — writes the appearance
filled[widget.field_name] = widget.field_value
missing = set(VALUES) - set(filled)
if missing:
raise SystemExit(f"fields not present in this PDF: {sorted(missing)}")
doc.bake() # form state -> page content
doc.save("form_filled.pdf")
doc.close()
widget.update() is mandatory and easy to forget, because assigning field_value looks like it did something. The missing check catches the other usual failure: government forms rename fields between revisions, and a script written against last year’s version fills nothing and reports success.
Then check it twice, two different ways, because each catches things the other misses:
doc = fitz.open("form_filled.pdf")
# (a) is the text really on the page?
text = "\n".join(p.get_text() for p in doc)
for name, value in VALUES.items():
assert value in text, f"{name}: {value!r} missing from extracted text"
# (b) does it look right? render it and open the PNG.
for i, page in enumerate(doc):
page.get_pixmap(dpi=150).save(f"proof_p{i + 1}.png")
print("residual widgets after bake:", sum(len(list(p.widgets())) for p in doc))
Text extraction catches “the value is not on the page.” It won’t catch a value spilling out of its box, a date in the wrong column, or two fields stacked on top of each other. Only the render catches those, and only if a person looks at it. In my test the extraction passed, the residual widget count was 0, and the rendered page showed both values right where they belonged. That’s three separate pieces of evidence, and none of them is “the script did not crash.”
5. macOS quarantine isn’t a permissions problem
A file your tooling generates and hands to a user can carry the com.apple.quarantine extended attribute. Word responds with a message about not having permission to open or save the file.
Everybody’s first move is chmod, and chmod is the wrong diagnosis. The POSIX permission bits are fine. Quarantine is an extended attribute Gatekeeper reads, and the file is getting blocked at a different layer entirely. Look before you fix:
xattr -l generated.docx
# com.apple.quarantine: 0083;688c0000;SomeTool;
xattr -d com.apple.quarantine generated.docx
One wrinkle from testing: xattr -d exits 1 when the attribute isn’t there, which will kill a shell script running under set -e on the perfectly normal case of a clean file. Use xattr -c to clear everything, or tolerate the failure:
xattr -d com.apple.quarantine "$f" 2>/dev/null || true
6. WeasyPrint, briefly
For anything where you control the source — reports, internal briefs, a printable daily summary — HTML and CSS to PDF through WeasyPrint is a nicer way to write than anything above. Real stylesheets, real page-break control, real typography.
The price is native libraries. WeasyPrint needs Pango 1.44 or newer plus its dependency chain, and its own documentation admits the macOS failure mode: errors of the form “cannot load library,” fixed by pointing DYLD_FALLBACK_LIBRARY_PATH at the Homebrew library directory. On a Mac with system integrity protection and a messy Homebrew history, I’ve found it can get worse than that, and the debugging turns into dynamic-linker archaeology that has nothing to do with documents.
It’s worth it for reports. It isn’t worth it if all you need is a PDF of a .docx you already have. That’s the LibreOffice path, and it’s one line.
Put it all together
Line the pieces up and the last step is a person looking at a picture.
def deliver(docx_path, workdir, expect=()):
"""Normalize, proof, and clear a generated document for delivery."""
final = soffice_convert(docx_path, "docx", workdir) # 3: normalize
pdf = soffice_convert(final, "pdf", workdir) # 3: same renderer
doc = fitz.open(pdf)
text = "\n".join(p.get_text() for p in doc)
leftovers = sorted(set(PLACEHOLDER.findall(text))) # [[TOKEN]], ____, XXXX
if leftovers:
raise AssertionError(f"unfilled placeholders: {leftovers}")
for needle in expect: # file number, date, signer
if needle not in text:
raise AssertionError(f"expected text missing: {needle!r}")
pages = []
for i, page in enumerate(doc):
png = f"{workdir}/proof_p{i + 1}.png"
page.get_pixmap(dpi=110).save(png)
pages.append(png)
doc.close()
strip_quarantine(final) # 5
return final, pdf, pages # now open the PNGs
I ran that against an unfilled document and it stopped on unfilled placeholders: ['[[DATE]]']. I ran it against the filled version and it passed, and the extracted text showed the invented footer text — Matter No. 00000-Example — on both pages of a two-page document. That was exactly the thing I couldn’t see from Python.
A regex for leftover placeholders is worth ten minutes of your life. \[\[[A-Z_]+\]\]|__+|XXXX+ catches the three conventions I’ve seen templates use, and it catches them in the rendered text, so it also catches a placeholder hiding in a footer or a text box your fill code never walked.
Why bother
None of this is interesting engineering. It’s five pieces of tedium standing between a script and a document a court will accept.
Here’s why it’s worth it anyway. The reason to automate documents in a small practice isn’t that drafting is hard. It’s that assembly is expensive, and assembly cost is a big part of what sets the floor under an hour of a lawyer’s time — the floor that prices most of the people who need a lawyer out of one. A document pipeline that works really does lower that floor. One that usually works is worse than nothing, because you can’t trust what it produces without checking it by hand, and checking by hand was the cost you were trying to get rid of.
It all comes down to whether you look at the rendered page. Build the proof step first. It’s thirty lines, it runs in two seconds, and it’s the only part of the pipeline that sees what your reviewer’s going to see.
Sources
- python-docx documentation —
Document,Paragraph,Run, section footers; tested against python-docx 1.2.0 - PyMuPDF
Document.bake()— “PDF only: make annotations / fields permanent content”; andWidgetforfield_value/update(); tested against PyMuPDF 1.26.5 - pypdf forms documentation —
auto_regenerate//NeedAppearances“indicates if the reading program should re-render the visual fields upon document launch” - LibreOffice
--convert-tocommand-line reference — headless conversion and-env:UserInstallation; tested against LibreOffice 26.2 on macOS - WeasyPrint installation — Pango ≥ 1.44 requirement and the macOS
DYLD_FALLBACK_LIBRARY_PATHworkaround xattr(1)— macOS extended attribute utility; quarantine behavior confirmed by direct testing on macOS 26.3
General commentary on practice management and tooling. Not legal advice and not ethics advice. No client information appears in this article — every document, placeholder, name, form, and matter number above is invented. Questions about any of this: Send us a message or call 612-470-6529.