Document automation in a law office fails in a particular way. The script runs. It prints “wrote motion.docx.” No exception is raised, no warning appears, 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 appears on page one and nowhere else, and the filled PDF form is blank in Preview.
Every one of those bugs is invisible to the generating code and obvious to a human eye on a rendered page. That is the whole thesis of this article, and the operating principle behind everything below:
Render the output and look at it before you deliver it. “It wrote without an exception” is not verification. It is the absence of one kind of evidence about one kind of problem.
What follows are the five failure modes I have actually hit building document automation for this firm, with the code that fixes each. Every example in this article is invented — fake placeholder tokens, a fictional matter number, a made-up form. No client document appears here.
I ran every snippet in this article before publishing it, against python-docx 1.2.0, PyMuPDF 1.26.5, and LibreOffice 26.2 on macOS. Where I am reporting something I could not reproduce in a test, I say so.
1. python-docx silently drops run properties
This is the one that costs 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 is inherited from the neighbors — they are inherited from the style, and a firm template usually gets its look from direct formatting on the runs rather than from a clean style hierarchy. So a run you create programmatically inside an existing paragraph starts with no rPr at all and falls back to whatever the document default is.
Here is that happening, verbatim 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 is the Normal style’s font at the Normal style’s size, sitting next to text that is not.
The fix is to 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; 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 that are not optional. rPr must be the first child of w:r — hence insert(0, ...) rather than append. And you have to remove any rPr python-docx already created, or you get two, and Word’s behavior with two is not something I want to depend on.
The same problem exists 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 except 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
Run against a source paragraph that was centered and double-spaced, the inserted paragraph came back centered and double-spaced, with the pPr intact:
<w:pPr>
<w:spacing w:line="480" w:lineRule="auto"/>
<w:jc w:val="center"/>
</w:pPr>
Verify the XML, not the object model. run._r.xml and paragraph._p.xml print the actual serialized element. That string is what Word will read. The Python attributes are a convenience layer over it, and on this specific problem the convenience layer’s None is ambiguous in exactly the wrong direction.
2. Red placeholders and the footer that only exists on page one
Firm templates carry placeholder text in a marker color — red, usually — so a human proofreading a draft can see at a glance what has not been filled. That is a good convention and it becomes a hazard the moment a script does the filling, because replacing the text of a run leaves the run’s color alone. You ship a filing with red text in it.
The fix is to delete the color element rather than set a new one. Setting it to black works, but removing it lets the style hierarchy decide, which is 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 constantly, because Word splits runs at spell-check boundaries and revision marks — will not match, and fill_placeholder will return 0 while raising nothing. Assert on the count.
The footer is the same class of bug with a worse blast radius. A filing that must carry a file number on every page is easy to get wrong, 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, iterating sections and skipping the linked ones wrote zero footers into a two-section document and raised nothing at all. The working version 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, both surviving a save-and-reopen. Note the tradeoff: unlinking every section means later sections stop inheriting, so if you want a different footer on a later section you now have to write it. For a court filing where the same number belongs on every page, that is the behavior you want. Also worth knowing that section.first_page_footer and section.even_page_footer are separate objects, and a template with “different first page” enabled will happily show you a blank footer on page one while the rest of the document is fine.
3. Finish with a LibreOffice round-trip
A .docx produced by a library is a ZIP archive of XML that satisfies the library. That is not the same as satisfying Word. Element ordering inside rPr and pPr is schema-constrained, relationship IDs have to resolve, sectPr has to land in the right place, and a library that lets you build the tree by hand will let you build an invalid one.
Converting the file with headless LibreOffice reparses the whole document against a real ODF/OOXML implementation and writes it back out normalized. 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 is about 1.7 seconds for a short document. The same command gives you a reliable PDF path, which matters because it is the same renderer — the PDF you proof is a faithful preview of the .docx you are about to hand over.
Three things I confirmed by testing that will otherwise bite you:
LibreOffice exits 0 when it fails. I fed it a file named .docx containing 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 silently produced nothing, and both returned exit status 0. If your pipeline checks the return code, your pipeline does not check anything.
Same directory in and out does not 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 produces plausible output. 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 was, in a sense, right. Nothing in the pipeline noticed.
So wrap it, and validate the artifact rather than 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
The profile argument is worth passing. LibreOffice keeps a single user profile with a lock, so a headless conversion fired while you have LibreOffice open on the desktop can hang or fail. Pointing the job at its own profile directory — -env:UserInstallation=file:///tmp/lo_profile — isolates it. I tested that flag; it works and costs nothing.
4. Government PDF forms: set the value, then bake it
Filling an AcroForm the obvious way produces a file where the values are genuinely present and genuinely invisible.
The mechanism is appearance streams. A form field has a value (/V) and a rendered appearance (/AP). Historically, setting the value did not regenerate the appearance, and the usual workaround was the document-level /NeedAppearances flag — which is not a fix, it is a request to the reader to re-render the fields on open. pypdf exposes this as auto_regenerate, and its documentation notes 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 is the whole failure: the file looks correct on the machine that made it, arrives blank on someone else’s, and the values are in there the entire time.
Before repeating that as current advice, I tested it, and the picture has 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 inspected the result: the field came back with a populated /V and a 111-byte /AP normal appearance stream, and it rendered visibly. Modern pypdf generates appearances for a simple text field rather than leaving the job to the reader.
So do not carry the old rule forward as gospel — but do not conclude the hazard is gone either. My test is one text field on a form I generated myself, which is the easy case. Real government forms bring comb fields, choice and button widgets, embedded fonts the filler cannot resolve, and occasionally XFA. The very run above emitted Font dictionary for /Helv not found; defaulting to Helvetica — a resolution failure on the simplest possible field, and exactly the class of thing that produces an appearance nobody can see. The honest statement is that appearance generation is version-dependent and form-dependent, which is a reason to verify the output rather than a reason to trust a particular library version.
Do not negotiate with appearance streams. Write the values with PyMuPDF, then flatten them into page content with doc.bake(), which the PyMuPDF documentation describes as making “annotations / fields permanent content.” After baking there is no form left to render — the text is drawing operations on the page, the 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 not optional and is easy to leave out, because assigning field_value looks like it did something. The missing check catches the other common failure: government forms rename fields between revisions, and a script written against last year’s version fills nothing and reports success.
Then verify — twice, in two different ways, because they catch different things:
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 does not catch a value overflowing its box, a date landing in the wrong column, or two fields overlapping. Only the render catches those, and only if a person looks at it. On my test the extraction passed, residual widget count was 0, and the rendered page showed both values sitting where they belonged — which is three separate pieces of evidence, none of which is “the script did not crash.”
5. macOS quarantine is not a permissions problem
A file your tooling generates and hands to a user can carry the com.apple.quarantine extended attribute. Word’s response is a message about not having permission to open or save the file.
Everyone’s first move is chmod, and chmod is the wrong diagnosis. The POSIX permission bits are fine. Quarantine is an extended attribute that Gatekeeper reads, and the file is being 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 is not there, which will fail 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 you control the source of — reports, internal briefs, a printable daily summary — HTML and CSS to PDF via WeasyPrint is a better authoring experience than any of the above. Real stylesheets, real page-break control, real typography.
The cost is native libraries. WeasyPrint needs Pango 1.44 or newer and its dependency chain, and its own documentation acknowledges the macOS failure mode: errors of the form “cannot load library,” fixed by pointing DYLD_FALLBACK_LIBRARY_PATH at the Homebrew library directory. In my experience it can be worse than that on a Mac with system integrity protection and a mixed Homebrew history, and the debugging is dynamic-linker archaeology rather than anything to do with documents.
It is worth it for report generation. It is not worth it if all you need is a PDF of a .docx you already have — that is the LibreOffice path, and it is one line.
The pipeline, assembled
Put the pieces in order and the last step is a human 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 confirmed the invented footer text — Matter No. 00000-Example — appearing on both pages of a two-page document, which was the specific thing I could not see from Python.
A regex for leftover placeholders is worth ten minutes of your life. \[\[[A-Z_]+\]\]|__+|XXXX+ catches the three conventions I have seen templates use, and it catches them in the rendered text, which means it also catches a placeholder that lives in a footer or a text box your fill code never walked.
Why this is worth the trouble
None of this is interesting engineering. It is five pieces of tedium standing between a generating script and a document a court will accept.
But the reason to automate documents in a small practice is not that drafting is hard. It is that assembly is expensive, and assembly cost is a large part of what sets the floor under an hour of a lawyer’s time — the floor that prices out most of the people who need one. A document pipeline that works is a real reduction in that floor. A document pipeline that usually works is worse than none, because you cannot trust its output without checking it by hand, and checking by hand was the cost you were trying to remove.
The difference between the two is entirely whether you look at the rendered page. Build the proof step first. It is thirty lines, it runs in two seconds, and it is the only part of the pipeline that sees what your reviewer will 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.