Replace text split across runs in python-docx
Replace a phrase that spans multiple Word runs without flattening the paragraph. Decide how replacement text inherits formatting and handle ambiguous matches.
On this page
A Word paragraph can display one continuous sentence while storing it in several runs. Searching each run separately misses a phrase that crosses a run boundary. Searching paragraph.text finds the phrase, but assigning back to that property replaces the paragraph's run structure.
This is a documented developer problem in python-docx issue 415. The following example demonstrates a deliberately narrow solution for a single match in a paragraph containing only ordinary text runs. It uses python-docx 1.2.0.
Map character positions back to runs
from docx import Document
from docx.oxml.ns import qn
def replace_once(paragraph, old, new):
if not old:
raise ValueError("The search text must not be empty")
# Reject hyperlinks, fields, drawings, breaks, and revision containers.
if any(child.tag not in {qn("w:pPr"), qn("w:r")}
for child in paragraph._p):
raise ValueError("Expected direct text runs only")
runs = paragraph.runs
for run in runs:
if any(child.tag not in {qn("w:rPr"), qn("w:t")}
for child in run._r):
raise ValueError("Expected ordinary text only")
text = "".join(run.text for run in runs)
start = text.find(old)
if start < 0 or text.find(old, start + 1) >= 0:
raise ValueError("Expected exactly one match")
stop = start + len(old)
offset = 0
for run in runs:
value = run.text
end = offset + len(value)
if offset < stop and end > start:
left = max(0, start - offset)
right = min(len(value), stop - offset)
replacement = new if offset <= start < end else ""
run.text = value[:left] + replacement + value[right:]
offset = end
doc = Document()
p = doc.add_paragraph()
p.add_run("Fri").bold = True
p.add_run("day deadline")
replace_once(p, "Friday", "Monday")
assert p.text == "Monday deadline"
assert p.runs[0].bold is True
Choose a formatting policy
This function gives the entire replacement the formatting of the run where the match begins. In the example, all of “Monday” becomes bold, although only “Fri” was bold originally. Unmatched text remains in its original runs. That is a policy choice, not the only correct interpretation of mixed formatting.
The two underscored XML properties are implementation details used to reject unsupported content. Pin and test your library version. This is not a complete replacement engine for arbitrary customer documents.
Move from a script to an agent
An agent also needs to establish which occurrence the user means. For Vespper, search the document, take the complete current HTML block, and edit that block with its classes intact. If the block is not unique, extend the anchor with adjacent content. The agent works with returned document structure rather than implementing run offsets itself.
Test your replacement on an absent phrase, a repeated phrase, mixed formatting, and a run containing an image. The last case should be rejected by this example. See the Python text API for the behavior of paragraph and run setters.