Skip to content

fix: nested DOCX sub-lists are being converted to un-nested lists in MarkDown - #2324

Open
org30h wants to merge 1 commit into
microsoft:mainfrom
org30h:fix/docx-nested-list-numbering
Open

fix: nested DOCX sub-lists are being converted to un-nested lists in MarkDown#2324
org30h wants to merge 1 commit into
microsoft:mainfrom
org30h:fix/docx-nested-list-numbering

Conversation

@org30h

@org30h org30h commented Aug 21, 2026

Copy link
Copy Markdown

Fixes #2323

Root cause

MarkItDown converts DOCX through mammoth, and mammoth decides list nesting from the w:ilvl value on each paragraph. Word does not always use w:ilvl to express nesting. Indenting a list in Word will sometimes start a brand new numbering definition instead, giving the sub-list its own w:numId at w:ilvl 0 and setting it apart from its parent only by indentation.

Both forms look identical in Word. mammoth understands only the first one. In the second form the sub-list carries the same w:ilvl as its parent, so mammoth reads the items as siblings, flattens them into the parent list and renumbers them.

In the document attached to #2323, items 3.1 and 3.2 use numId 3 at ilvl 0 while item 3 uses numId 1 at ilvl 0. They came out as top level items 4 and 5.

  before                    after
  1. Item 1                 1. Item 1
  2. Item 2                 2. Item 2
     * Item 2.1                * Item 2.1
     * Item 2.2                * Item 2.2
  3. Item 3                 3. Item 3
  4. Item 3.1                  1. Item 3.1
  5. Item 3.2                  2. Item 3.2

What this changes

Everything happens inside pre_process_docx, which already rewrites word/document.xml before mammoth reads it. The converter is untouched and so is mammoth. The diff against the existing function is a 14 line insertion, and the rest of the change is new helper functions.

The new step reads the effective indentation of every (numId, ilvl) pair from word/numbering.xml, then walks the document in order while tracking which list levels are currently open. A paragraph indented further than the level above it is a nested item regardless of what its declared w:ilvl says. Body text closes any open levels, which matches how mammoth already ends a list.

Three details carry most of the correctness.

Inside a single numId the declared w:ilvl stays authoritative and indentation is consulted only across different numId values. This matters because some numbering definitions give two levels the same indentation, and comparing indentation alone would flatten lists that are already correct today.

Indentation is only ever used to add nesting that the declared levels missed. It never removes nesting that a document states outright. That property is what keeps every document mammoth already handles from changing at all.

Remapped paragraphs are not simply given a higher w:ilvl. Doing that would make the paragraph resolve against whatever unrelated level its numbering happens to define at that index, so a bulleted sub-list whose numbering defines level 1 as decimal would quietly become a numbered list. Instead each remapped combination gets a small generated w:abstractNum that carries the paragraph's original w:numFmt to the new depth.

Depth is capped at the fifth level because mammoth's default style map stops there. A paragraph promoted past it matches no rule and falls out of the list entirely.

The whole step is wrapped in try/except. If anything goes wrong the original XML is used and the output is exactly what it is today.

Tests

Added packages/markitdown/tests/test_docx_lists.py with seven tests, and the file from #2323 as the multilevel_lists.docx fixture. Tests build small DOCX files in memory and assert on the output of the public MarkItDown API, so they do not depend on how the fix is implemented.

Three of them fail on main and pass with this change:

Test What it covers
test_docx_multilevel_lists the fixture from #2323, end to end
test_docx_sub_list_as_new_num_id a sub-list that is a separate numId at ilvl 0
test_docx_sub_list_preserves_bullets a bulleted sub-list whose own numbering defines ilvl 1 as decimal

Four pass both with and without the change. They are there to show the fix does not disturb lists that already convert correctly:

Test What it guards
test_docx_nesting_from_declared_levels_is_unchanged conventional w:ilvl nesting still converts the same way
test_docx_equal_level_indents_are_not_flattened two levels sharing one indentation are not collapsed
test_docx_list_interrupted_by_paragraph body text between two lists still ends the first
test_docx_deep_nesting_keeps_every_item seven levels of indentation, no item dropped

Verification

Run on Python 3.12 with packages/markitdown[all] installed, which matches what CI uses.

The full suite was run twice, once on main and once with this branch, and the set of failing tests is byte for byte identical. Every failure in that set is environmental and pre-existing, from Windows path handling in test_file_uris and from the CLI tests resolving a subprocess interpreter that has no markitdown installed. None of them involve DOCX conversion.

With remote tests skipped the way CI skips them, and excluding that CLI subprocess group, the result is 272 passed and 22 skipped, with the single Windows only path failure left. file_uri_to_path was checked directly under Linux and returns the POSIX paths that test expects, so it passes on the ubuntu-latest runner.

The seven new tests were run on Python 3.12 and 3.14 and pass on both. pre-commit run --all-files passes.

…inition

Word can express a nested list either as a deeper w:ilvl within the parent's
w:numId, or as a new w:numId at w:ilvl 0 that is set apart only by its
indentation. Both render identically in Word, but mammoth derives nesting
from w:ilvl alone, so the second form was flattened into the parent list and
its items were renumbered as siblings.

Extend the existing pre_process_docx step to resolve each level's effective
indentation from numbering.xml and walk the document tracking the open list
levels, so nesting implied by indentation is restored before mammoth reads
the file.

Within one w:numId the declared w:ilvl stays authoritative, since some
numbering definitions give several levels the same indentation. Indentation
only ever adds nesting that the declared levels missed and never removes
nesting a document states outright, which leaves documents that already
convert correctly untouched. Remapped paragraphs are pointed at a generated
w:abstractNum carrying their original w:numFmt, so a bulleted sub-list is
not silently converted into a numbered one, and depth is capped at the last
level mammoth's default style map defines.

Fixes microsoft#2323
@org30h org30h changed the title fix: nest DOCX sub-lists that Word stores as a separate numbering definition fix: nested DOCX sub-lists are being converted to un-nested lists in MarkDown Aug 21, 2026
@org30h

org30h commented Aug 21, 2026

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

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.

Nested list in docx is not properly parsed in Markdown

2 participants