DOCX List Processing#
List handling in the DOCX backend (docling/backend/msword_backend.py) revolves around two concerns: correctly grouping list items into ListGroup objects (with proper numbering), and isolating list state so that lists in different table cells don't bleed into one another.
Entry Points#
| Function | Lines | Purpose |
|---|---|---|
_manage_list_structure() | 2065–2176 | Top-level dispatcher: decides whether to open, continue, or close a list based on numId and indent level |
_add_list_item() | 2178–2215 | Adds a regular list item; delegates level management to _manage_list_structure() |
_add_list_item_with_equations() | 2217–2280 | Same flow, but supports inline equations within list items |
_add_formatted_list_item() | 1937–1984 | Final step: creates the ListItem under its parent ListGroup with the computed marker |
List Group Creation and Reuse#
Each unique list in the output document is represented by a ListGroup. The backend avoids creating redundant groups when the same numbered list is interrupted (e.g., by an empty paragraph) and then resumes.
Cache variables (initialized at lines 318–322) :
self.last_numid— thenumIdof the most recently processed list paragraphself.last_list_group— the cachedListGroupobjectself.last_list_group_numid— thenumIdthat group was created forself.last_list_group_parent— the parent node the group was attached to
_can_reuse_list_group(numId, parent) returns True only when all four cached values match the current paragraph's numId and parent — i.e., the list is resuming in the same structural context .
_get_or_create_list_group() either reuses the cached group (and prunes any empty TextItems that were inserted as gap-fillers between list paragraphs) or creates a fresh ListGroup and updates the cache . Counter resets via _reset_list_counters_for_new_sequence() only fire when the numId actually changes, not on resumption.
_clear_list_group_cache() zeroes all three cache fields, forcing a fresh group on the next list paragraph .
Continuity Detection#
_manage_list_structure() detects continuity by querying the rolling self.history dict , which records the numid, indent, level, and name of each processed paragraph:
_prev_numid()/_prev_indent()— retrieve the immediately preceding paragraph's identifiers_get_level()— determines the current nesting depth
numId and ilvl are extracted from each paragraph's <w:numPr> element (with fallback to the paragraph style) by _get_numId_and_ilvl() .
Numbering Counters#
Counter state is stored in self.list_counters: dict[tuple[int, int], int], keyed by (numId, ilvl) .
_get_list_counter()— gets and increments the counter for a(numId, ilvl)pair; automatically resets all sub-level counters when a parent level advances ._get_level_element()— walks the numbering XML to resolve the abstract numbering definition for a given(numId, ilvl)._build_enum_marker()— renders thelvlTexttemplate (e.g.,"Proposal %1:") into the final marker string ._VISIBLE_NUMBERING_FORMATS— afrozensetof OOXMLnumFmtvalues (decimal,lowerRoman,upperRoman,lowerLetter,upperLetter,decimalZero) that produce visible numeric markers; other formats (bullets, etc.) are excluded .
Table Cell State Isolation#
Lists inside table cells must not share group state with lists outside the cell or in other cells, even when they share the same numId.
_isolated_list_context() (a context manager, lines 517–548) :
- Saves
history,level_at_new_list,parents, and all three list group cache variables. - Calls
_clear_list_group_cache()so no prior group bleeds into the cell. - Restores the full saved state in a
finallyblock after the cell is processed.
It is applied during rich table cell processing in _handle_tables() . For single-paragraph (plain) table cells, _clear_list_group_cache() is called directly .
Key Bug Fixes#
| PR | Issue | Fix |
|---|---|---|
| #3294 – isolate list state in table cells | Lists with the same numId in different table cells were merged into one ListGroup in the first cell, leaving other cells empty | Introduced _isolated_list_context() to save/restore history and parents around each cell's _walk_linear() call |
| #3539 – list numbering with empty paragraphs | Ordered lists interrupted by empty paragraphs reset to "1." and split into separate ListGroups | Added last_list_group cache + _get_or_create_list_group() to reuse the existing group and suppress interstitial empty TextItems; enhanced _isolated_list_context() to also save/restore the cache |
Key Source File#
| File | Purpose |
|---|---|
docling/backend/msword_backend.py | All list logic lives here: group caching (458–515), isolation (517–548), structure management (2065–2176), counter tracking (883–941) |