Fix python-docx Missing Text in Text Boxes
The contract clearly shows the client name and reference in a box at the top of page one, and the extraction output has neither:
>>> from docx import Document
>>> doc = Document("in/contract-2291.docx")
>>> any("Contract ref" in p.text for p in doc.paragraphs)
False
>>> "\n".join(p.text for p in doc.paragraphs)[:80]
'Terms and Conditions\n\n1. Scope of services\n...'
The same happens with callout shapes, sidebar boxes in reports, labels inside grouped drawings, text in SmartArt, and form answers inside content controls. No error is raised; the text simply is not there.
Root Cause
Document.paragraphs returns only the w:p elements that are direct children of the document body. A text box is a drawing object anchored inside a run of some paragraph: its own paragraphs sit several levels down, inside w:drawing, a DrawingML shape, and finally w:txbxContent. They are real paragraphs in the WordprocessingML namespace, but they are not children of the body, so python-docx's list does not include them. Content controls wrap paragraphs in w:sdt elements, which again moves them out of the body's direct children. SmartArt is stored in a separate diagram data part, not in the document XML at all. Word also writes most text boxes twice for compatibility — once as DrawingML and once as a VML v:textbox fallback — so the obvious XPath fix can return every text box's content twice.
Minimal Diagnostic
Search the raw XML for the missing string and print the chain of element names above it. The ancestor chain names the container that hides it.
# pip install "python-docx>=1.1"
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
SOURCE = Path("in/contract-2291.docx")
NEEDLE = "Contract ref"
def locate(path: Path, needle: str) -> None:
try:
doc = Document(path)
except Exception as exc:
raise SystemExit(f"cannot open {path}: {exc}")
found = False
for t in doc.element.body.iter(qn("w:t")):
if needle.lower() in (t.text or "").lower():
found = True
chain, el = [], t
while el is not None and el is not doc.element.body:
chain.append(el.tag.split("}")[-1])
el = el.getparent()
print(" < ".join(chain))
if not found:
print("not in document.xml body: check headers, footers, SmartArt or footnotes parts")
if __name__ == "__main__":
locate(SOURCE, NEEDLE)
t < r < p < txbxContent < txbx < wsp < graphicData < graphic < anchor < drawing < r < p
t < r < p < txbxContent < textbox < shape < pict < Fallback < AlternateContent < r < p
Two hits for one visible box: the DrawingML text box (wsp shape) and the VML fallback (pict/shape/textbox) inside mc:AlternateContent. Both sit below a body paragraph's run, which is why doc.paragraphs shows only that host paragraph, usually empty.
Fix: Query Text Boxes and Skip the VML Fallback
Find w:txbxContent elements, ignore those inside an mc:Fallback branch, and read their paragraphs. Changed lines carry comments.
# pip install "python-docx>=1.1"
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
from docx.text.paragraph import Paragraph
MC_FALLBACK = "{http://schemas.openxmlformats.org/markup-compatibility/2006}Fallback"
def in_fallback(element) -> bool:
parent = element.getparent()
while parent is not None:
if parent.tag == MC_FALLBACK: # changed: VML duplicate branch
return True
parent = parent.getparent()
return False
def textbox_paragraphs(doc) -> list[list[Paragraph]]:
boxes = []
for box in doc.element.body.iter(qn("w:txbxContent")): # changed: search all depths
if in_fallback(box):
continue # changed: skip the VML copy
boxes.append([Paragraph(p, doc) for p in box.iterchildren(qn("w:p"))]) # changed: real Paragraphs
return boxes
if __name__ == "__main__":
try:
doc = Document(Path("in/contract-2291.docx"))
except Exception as exc:
raise SystemExit(f"cannot open document: {exc}")
for i, paragraphs in enumerate(textbox_paragraphs(doc)):
text = "\n".join(p.text for p in paragraphs if p.text.strip())
print(f"box {i}: {text!r}")
box 0: 'Client: Northwind Traders Ltd\nContract ref: C-2291'
Wrapping the elements as python-docx Paragraph objects keeps access to styles and runs, so the same formatting-aware code you use on body paragraphs works on text box content. Documents created by older tools may contain only VML text boxes, with no DrawingML version; in that case the fallback filter removes everything. Handle it by preferring DrawingML and falling back to VML per shape:
# pip install "python-docx>=1.1"
from docx.oxml.ns import qn
def textbox_texts_any(doc) -> list[str]:
modern = [b for b in doc.element.body.iter(qn("w:txbxContent")) if not in_fallback(b)]
chosen = modern if modern else list(doc.element.body.iter(qn("w:txbxContent")))
return ["\n".join("".join(t.text or "" for t in p.iter(qn("w:t"))) for p in box.iter(qn("w:p")))
for box in chosen]
Variant Fix 1: Content Controls Hide Paragraphs Too
When the missing text is a form answer, it is usually inside a content control rather than a shape. The ancestor chain from the diagnostic shows sdtContent < sdt. Read controls by their tag to get named values:
# pip install "python-docx>=1.1"
from docx import Document
from docx.oxml.ns import qn
def sdt_values(doc) -> dict[str, str]:
values = {}
for sdt in doc.element.body.iter(qn("w:sdt")):
pr = sdt.find(qn("w:sdtPr"))
if pr is None:
continue
tag = pr.find(qn("w:tag"))
name = tag.get(qn("w:val")) if tag is not None else None
if not name:
continue
if pr.find(qn("w:showingPlcHdr")) is not None:
values[name] = "" # placeholder, not an answer
continue
content = sdt.find(qn("w:sdtContent"))
values[name] = "".join(t.text or "" for t in content.iter(qn("w:t"))).strip() if content is not None else ""
return values
Content controls can also sit inside text boxes, so run this over the whole body (as above) rather than only over top-level paragraphs.
Variant Fix 2: Grouped Shapes, SmartArt and Charts
Text in grouped shapes is still w:txbxContent, just deeper, and the fix above finds it. SmartArt and chart labels are different: their text lives in separate package parts (word/diagrams/data1.xml, word/charts/chart1.xml) using DrawingML's a:t elements. Read those parts from the ZIP when you need them:
# pip install lxml
import zipfile
from pathlib import Path
from lxml import etree
A_T = "{http://schemas.openxmlformats.org/drawingml/2006/main}t"
def smartart_and_chart_text(path: Path) -> dict[str, list[str]]:
out: dict[str, list[str]] = {}
with zipfile.ZipFile(path) as zf:
for name in zf.namelist():
if name.startswith(("word/diagrams/data", "word/charts/chart")) and name.endswith(".xml"):
root = etree.fromstring(zf.read(name))
texts = [el.text for el in root.iter(A_T) if el.text and el.text.strip()]
if texts:
out[name] = texts
return out
Chart parts also contain cached category labels and series names in c:v elements; if you need chart data, read those instead of the a:t titles.
Keeping Text Box Content in Reading Order
Appending all text boxes at the end of the extracted text loses context: a sidebar about payment terms ends up after the signature block. Emit each box's text right after the body paragraph that anchors it, which approximates where a reader sees it:
# pip install "python-docx>=1.1"
from docx import Document
from docx.oxml.ns import qn
def text_in_reading_order(doc) -> list[str]:
lines = []
for p in doc.element.body.iter(qn("w:p")):
if p.getparent() is not doc.element.body:
continue # only top-level body paragraphs here
own = "".join(t.text or "" for r in p.iterchildren(qn("w:r")) for t in r.iterchildren(qn("w:t")))
if own.strip():
lines.append(own)
for box in p.iter(qn("w:txbxContent")):
if in_fallback(box):
continue
for inner in box.iter(qn("w:p")):
text = "".join(t.text or "" for t in inner.iter(qn("w:t")))
if text.strip():
lines.append(f"[box] {text}")
return lines
When a downstream parser looks for "the reference near the title" or "the fee mentioned under payment", order decides whether it finds the right value. Anchoring is a layout hint, not a guarantee — Word lets a box anchored in paragraph 3 float to the top of the page — but for most templates the anchor paragraph is on the same page and near the box. Marking box lines with a prefix keeps them distinguishable for downstream parsing, and for search indexing you can drop the prefix.
Verification
Compare the total text in every w:t element (excluding VML fallbacks) with the text your extraction produced. Anything substantially lower means a container is still missing.
# pip install "python-docx>=1.1"
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
def verify_all_text_captured(path: Path, extracted_lines: list[str]) -> None:
doc = Document(path)
expected = "".join(t.text or "" for t in doc.element.body.iter(qn("w:t")) if not in_fallback(t))
got = "".join(line.removeprefix("[box] ") for line in extracted_lines)
squash = lambda s: "".join(s.split())
missing = len(squash(expected)) - len(squash(got))
assert missing <= 0.02 * len(squash(expected)), (
f"{missing} characters not captured; run the locate() diagnostic on a missing phrase")
print(f"{path.name}: text boxes and body captured ({len(squash(got))} chars)")
if __name__ == "__main__":
doc = Document("in/contract-2291.docx")
verify_all_text_captured(Path("in/contract-2291.docx"), text_in_reading_order(doc))
Removing whitespace before comparing avoids false alarms from line breaks and joins. The two percent allowance covers text inside content controls that you deliberately excluded, such as placeholder instructions; tighten it once your extraction includes every container you care about.
FAQ
Will a newer python-docx read text boxes automatically? Not at the time of writing. Shape support in python-docx focuses on inline pictures. The XPath approach is stable because it relies on the file format, not the library's API.
Why does doc.inline_shapes not list my text box?
Most text boxes are floating (wp:anchor), and inline_shapes lists only inline ones (wp:inline), and only pictures at that.
Can I edit text inside a text box the same way?
Yes. Once wrapped as Paragraph objects, runs can be modified and saved. Replacing text that spans runs has the same pitfalls as body text — see fix python-docx replace text split across runs.
What about text boxes in headers?
They live in the header part. Run the same queries on section.header._element instead of the body.
Related
- Extracting Data from Word Documents with Python — the full extraction workflow
- Extract Tables from Word Documents to pandas — tables, merges and nesting
- Replace Text in Headers, Footers and Tables — reaching the same hidden containers when editing
- Add Headers and Footers with python-docx — how header parts are structured