Skip to content

fix(docx): nest a list that is indented under an open list - #4465

Open
wenqiw777 wants to merge 2 commits into
docling-project:mainfrom
wenqiw777:fix/docx-styled-list-nesting
Open

wenqiw777 wants to merge 2 commits into
docling-project:mainfrom
wenqiw777:fix/docx-styled-list-nesting

Conversation

@wenqiw777

Copy link
Copy Markdown

Issue resolved by this Pull Request:
Resolves #4464

Word's multi-level list styles ("List Bullet 2", "List Number 2", ...) each link to their own numbering definition at w:ilvl 0 and differ from "List Bullet" / "List Number" only by a deeper indentation. The backend nested lists by w:ilvl alone, so each of these styles started a new top-level list, and the outer list did not continue after it.

When an item of another numId follows an open list's item, the backend now compares their effective left indentation: the paragraph's own w:ind, else its numbering level's, else its style's basedOn chain (w:left or w:start).

  • A deeper item opens its list under that item.
  • A later item of another numId that is indented no deeper closes the nested list and is placed as if it came right after that item, so the outer list continues.
  • Lists at the same indentation stay siblings, as before.

The nested-list state is saved and restored around table cells and reset for each header/footer part. When a blank paragraph closes a nested list, the group kept for reuse is the outer list's. _get_numId_and_ilvl is untouched, so this does not overlap with #4457.

Reproduction from the issue, before:

- apple
- banana

- banana split
- banana bread

- cherry

1. first

1. first-a

2. second

After:

- apple
- banana
    - banana split
    - banana bread
- cherry

1. first
    1. first-a
2. second

Tests: five tests in tests/test_backend_msword_lists.py cover nesting for bullets and numbers, lists at the same indentation staying siblings, returning to the outer list, a blank paragraph inside a nested list, and the indentation precedence. All five fail on main. The 36 DOCX fixtures give identical Markdown, text and JSON output, so no groundtruth changes. A 6000-paragraph list document converts about 2% slower, from one extra numbering lookup per list item.

One choice to flag: "deeper" has no tolerance, so two unrelated adjacent lists whose indents differ by a few twips will nest, the same way Word shows them. I can add a minimum difference if you prefer.

Checklist:

  • Documentation has been updated, if necessary.
  • Examples have been added, if necessary.
  • Tests have been added, if necessary.

Word's multi-level list styles ("List Bullet 2", "List Number 2", ...)
each link to their own numbering definition at w:ilvl 0 and differ from
"List Bullet" / "List Number" only by a deeper indentation. The backend
nested lists by w:ilvl alone, so each of these styles started a new
top-level list, and the outer list did not continue after it.

When an item of another numId follows an open list's item, compare their
effective left indentation: the paragraph's own w:ind, else its numbering
level's, else its style's basedOn chain (w:left or w:start). A deeper
item opens its list under that item. A later item of another numId that
is indented no deeper closes the nested list and is placed as if it came
right after that item. Lists at the same indentation stay siblings, as
before. When a blank paragraph closes a nested list, the group kept for
reuse is the outer list's.

Signed-off-by: wenqiw777 <wenqiw@umich.edu>
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

✅ DCO Check Passed

Thanks @wenqiw777, all your commits are properly signed off. 🎉

@mergify

mergify Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🟢 Merge protection satisfied — ready to merge.

Show 1 satisfied protection

🟢 Enforce conventional commit

Make sure that we follow https://www.conventionalcommits.org/en/v1.0.0/

  • title ~= ^(fix|feat|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\(.+\))?(!)?:

@codecov

codecov Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.16129% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
docling/backend/msword_backend.py 95.16% 1 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@dolfim-ibm dolfim-ibm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nesting fix looks good. One request on the style-chain walk: getattr(style, "base_style", None) probes the attribute instead of relying on python-docx's types, which AGENTS.md asks us to avoid.

In python-docx, base_style is defined on CharacterStyle. ParagraphStyle and _TableStyle inherit it; _NumberingStyle doesn't. So an isinstance check expresses exactly the "malformed chain hops to a numbering style" case the comment describes. A small shared iterator would replace the probe and the depth bookkeeping:

from collections.abc import Iterator
from docx.styles.style import BaseStyle, CharacterStyle, ParagraphStyle  # add CharacterStyle

def _iter_style_chain(self, style: BaseStyle | None) -> Iterator[CharacterStyle]:
    """Yield ``style`` and its ``basedOn`` ancestors.

    Stops at a style type without ``base_style`` (e.g. a numbering style
    reached through a malformed chain) and at ``_MAX_STYLE_INHERITANCE_DEPTH``
    to guard against cycles.
    """
    depth = 0
    while isinstance(style, CharacterStyle) and depth < self._MAX_STYLE_INHERITANCE_DEPTH:
        yield style
        style = style.base_style
        depth += 1

With that, _get_list_left_indent becomes:

left = _left(paragraph._p)
if left is None:
    left = _left(self._get_level_element(numid, ilvl))
if left is None:
    for style in self._iter_style_chain(paragraph.style):
        left = _left(style.element)
        if left is not None:
            break
return left or 0

The same walk is already copied with getattr on main (_style_numbering, _is_code_style, _effective_style_font, the bold lookup in _get_format_from_run, and the base_style check in _get_label_and_level). Those aren't introduced here. Moving them to the helper in this PR would be welcome but is optional; at minimum, the new walk shouldn't add a fourth copy.

Comment thread docling/backend/msword_backend.py Outdated
left = _left(style.element)
# A malformed basedOn chain can hop to a style type that lacks
# base_style; getattr keeps the walk safe.
style = getattr(style, "base_style", None)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of probing with getattr, iterate with an isinstance(style, CharacterStyle) guard. See the _iter_style_chain proposal in the review summary.

…iterator

_get_list_left_indent probed base_style with getattr while walking a
list paragraph's basedOn chain. In python-docx, base_style is defined on
CharacterStyle, which ParagraphStyle and _TableStyle inherit and
_NumberingStyle does not, so an isinstance check expresses the
"malformed chain hops to a numbering style" case directly, without
probing the attribute.

Add _iter_style_chain, which yields a style and its basedOn ancestors,
stops at a style type without base_style and caps the walk at
_MAX_STYLE_INHERITANCE_DEPTH, and use it for the style step of
_get_list_left_indent. Behavior is unchanged.

Signed-off-by: wenqiw777 <wenqiw@umich.edu>
@wenqiw777

Copy link
Copy Markdown
Author

Thanks, that reads much better. 314f373 adds _iter_style_chain as you sketched it and uses it for the style step of _get_list_left_indent, so the new code no longer probes base_style. I left the existing walks as they are to keep this PR focused; happy to move them onto the helper in a follow-up PR if you'd like.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DOCX: lists built with the "List Bullet 2" / "List Number 2" styles lose their nesting

2 participants