DOCX Numbered Heading Processing#
Overview#
Numbered headings in DOCX files follow a two-path design in MsWordDocumentBackend (msword_backend.py): the backend must (1) detect whether a heading paragraph uses a visibly rendered numbering scheme, then (2) synthesize the correct prefix string if so. These concerns are handled separately — incorrectly conflating them caused a silent-corruption bug in LibreOffice-generated documents (fixed in PR #3760).
Dual Numbering System#
DOCX heading paragraphs can carry a <w:numPr> element via two routes:
| Route | Meaning |
|---|---|
| w synthetic counter | <w:numPr> is present (directly or via style inheritance). The backend resolves it to a (numId, ilvl) pair and optionally synthesizes a counter-based prefix string. |
| Document-defined numbering (numbering.xml) | numbering.xml defines the actual rendering format (numFmt, lvlText, start value) for each (abstractNumId, ilvl) pair. This is the authority on whether visible output exists. |
The resolution chain for (numId, ilvl) walks: <w:num[@w:numId]> → <w:abstractNumId> → <w:abstractNum> → <w:lvl[@w:ilvl]>. This is implemented in _get_level_element().
Paragraph-level <w:numPr> is read by _get_numId_and_ilvl(): it first checks the paragraph XML directly, then falls back to the paragraph's style inheritance chain. If numId is found without ilvl, ilvl defaults to 0 .
Numbering Format Detection: _has_visible_numbering_format()#
Not every <w:numPr> reference produces a visible marker. The _has_visible_numbering_format() method determines this by:
- Calling
_get_level_element(numId, ilvl)to retrieve the<w:lvl>element fromnumbering.xml. - Reading the
<w:numFmt w:val="...">attribute from that level element. - Returning
Trueonly whennumFmtis in_VISIBLE_NUMBERING_FORMATS.
The visible formats are :
decimal, lowerRoman, upperRoman, lowerLetter, upperLetter, decimalZero
Formats that produce no visible numeric marker — notably "none" (invisible outline numbering) and "bullet" (character bullets) — are excluded. If any step fails (missing numbering.xml, missing level element, missing numFmt), the method returns False safely.
Why This Matters: The numFmt=none Bug#
LibreOffice exports headings with <w:numPr> + numFmt=none as an outline-numbering marker that Word treats as invisible. Before PR #3760, the heading detection path only checked for the presence of <w:numPr> — not its numFmt value — causing Docling to synthesize prefixes like 1 REFERENCES or 1.1 Subsection for headings that appear unnumbered in Word .
Heading Numbering Flow#
The entry point for heading processing in _handle_text_elements() is :
elif "Heading" in p_style_id:
is_numbered_style = self._is_numbered_heading(paragraph)
h1 = self._add_heading(doc, p_level, text, is_numbered_style)
_is_numbered_heading() wraps the format check:
def _is_numbered_heading(self, paragraph):
numid, ilvl = self._get_numId_and_ilvl(paragraph)
return numid is not None and self._has_visible_numbering_format(numid, ilvl or 0)
When is_numbered_style=True, _add_heading() uses a simple synthetic counter stored in self.numbered_headers: dict[int, int] — not the numbering.xml counter system. This is the "synthetic counter" path: an integer per heading level that increments and resets deeper levels, then prepends the dotted path as a string prefix .
Key distinction:
self.numbered_headersis a simple per-level counter maintained by the backend itself. Thenumbering.xml-based counter system (self.list_counters,_get_list_counter(),_build_enum_marker()) is used for list items, not headings.
Heading Level Resolution#
Heading level is determined by _get_label_and_level(), which:
- Prefers
outlineLvl: calls_get_outline_level_from_style()to read<w:outlineLvl w:val="N"/>from the style XML. OOXML uses 0-indexedoutlineLvl(0 = Heading 1), so the method returnsoutlineLvl + 1. - Falls back to style name parsing: if
outlineLvlis absent, parses the number from the style name (e.g.,"Heading 2"→ level 2) via_get_heading_and_level(). - Clamps to ≥ 1: heading level is never less than 1, guarding against custom styles like
"Heading 0".
Key Source References#
| Symbol | Location | Role |
|---|---|---|
_VISIBLE_NUMBERING_FORMATS | L153–163 | Frozenset of numFmt values that produce visible markers |
_has_visible_numbering_format() | L1062–1080 | Gate: does this (numId, ilvl) render a visible number? |
_is_numbered_heading() | L1082–1087 | Heading-specific wrapper for format check |
_get_numId_and_ilvl() | L905–943 | Reads w from paragraph or style inheritance |
_get_level_element() | L945–988 | Walks numbering.xml to find the abstractNum level element |
_add_heading() | L2215–2284 | Applies synthetic counter and adds heading to document |
_get_outline_level_from_style() | L1089–1112 | Reads outlineLvl for authoritative heading level |
| PR #3760 | — | Fix: ignore heading numPr when numFmt is none |
| PR #2916 | — | Fix: use outlineLvl for heading levels, clamp to ≥ 1 |
| Issue #3759 | — | Bug report: headings incorrectly numbered when numFmt is none |