DOCX Text Formatting#
Overview#
DOCX text formatting extraction is handled by MsWordDocumentBackend in docling/backend/msword_backend.py. The backend reads OOXML <w:r> (run) elements from each paragraph, extracts per-run Formatting objects, groups consecutive runs with identical formatting, and emits (text, Formatting, hyperlink) tuples that are later stored on TextItem nodes in the DoclingDocument.
The data model for formatting (Formatting, Script) is defined in docling-core — see the DoclingDocument Text Formatting knowledge article for the model layer. This article focuses only on the extraction path in the DOCX backend.
Key Functions#
_get_format_from_run() — run-level extraction#
_get_format_from_run(run, paragraph) is a @classmethod that converts a python-docx Run into a Formatting instance. It reads:
- Bold — first checks
run.bold, then falls back to raw<w:b>/<w:bCs>XML tags on the run element, then the paragraph'spPr/rPr, then recursively climbs the paragraph's style chain . - Italic —
run.italic. - Strikethrough —
run.font.strike. - Underline — any non-
Nonerun.underlinevalue is coerced toTrue. - Script —
run.font.subscript/run.font.superscript, mapped toScript.SUB/Script.SUPER/Script.BASELINE.
The function always returns a Formatting object (never None) .
_iter_paragraph_content() — run iteration#
_iter_paragraph_content(paragraph) walks the raw OOXML children of a paragraph's <w:p> element, skipping wrapper tags (smartTag, customXml, ins, fldSimple), and yields (text, Formatting|None, hyperlink) tuples for <w:r> (run), <w:hyperlink>, and <w:sdt> (Structured Document Tag) children.
SDT content is collapsed to a single text string, using the formatting of the first run found inside it .
_get_paragraph_elements() — format grouping#
_get_paragraph_elements(paragraph) groups runs with the same Formatting into combined text chunks. The grouping loop uses two tracking variables :
previous_format— theFormattingof the run that opened the current group. Only updated whenlen(text.strip()) > 0AND a format change is detected.last_format(buggy — see below) — updated unconditionally on every run, including whitespace-only ones.
Known Bug: Whitespace-Only Trailing Runs Strip Formatting#
Symptom#
A paragraph whose last run contains only whitespace (spaces, tabs) will have the formatting of all preceding text silently discarded — e.g., a fully-bold paragraph exports with bold=False .
This affects any DOCX where Microsoft Word has emitted a trailing space run (common), or any run that fails len(text.strip()) > 0. The bug is bidirectional: a bold whitespace-only trailing run also incorrectly makes plain text appear bold .
Root Cause#
The final flush at line 1215–1216 uses last_format instead of previous_format:
# Buggy (current code at line 1216):
paragraph_elements.append((group_text.strip(), last_format, None))
# Should be:
paragraph_elements.append((group_text.strip(), previous_format, None))
last_format is updated unconditionally on every iteration , so when the last run is whitespace-only, last_format holds the whitespace run's format while previous_format (guarded by len(text.strip())) correctly retains the format of the last content run .
Origin#
Introduced in PR #3280 ("fix(docx): preserve inline SDT references"), which added last_format to support SDT content extraction.
Fix#
PR #3799 ("fix(msword): keep paragraph formatting when a trailing run is whitespace-only") removes last_format entirely and changes the final flush to use previous_format. The change is a 4-line diff to _get_paragraph_elements(). A regression test test_trailing_whitespace_run_keeps_paragraph_formatting is added in tests/test_backend_msword.py covering bold, italic, and the reverse-direction (plain text should not inherit formatting from a bold whitespace run) .
Call Chain#
convert()
└─ _walk_linear()
└─ _handle_text_elements()
├─ _get_paragraph_elements() ← format grouping (bug lives here)
│ └─ _iter_paragraph_content()
│ └─ _get_format_from_run()
└─ doc.add_text(..., formatting=format)
Formatted TextItem objects receive the Formatting instance directly via doc.add_text(formatting=...) . List items follow the same path via _add_list_item() → _add_formatted_list_item() .
Related Articles#
| Topic | Article |
|---|---|
Formatting / Script data model and JSON/DocLang serialization | DoclingDocument Text Formatting |
| Per-cell formatting in DOCX tables | Table Cell Formatting |