Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,34 @@ def _pre_process_styles(content: bytes) -> bytes:
return etree.tostring(root.getroottree(), encoding="utf-8", xml_declaration=True)


def _pre_process_numbering(content: bytes) -> bytes:
"""
Repairs DOCX numbering definitions that Mammoth cannot read properly.

In OOXML, ``w:abstractNum`` elements define list hierarchies via ``w:lvl``
child elements. The ``w:ilvl`` attribute defines the zero-based level index.
Some document exporters omit ``w:ilvl`` on ``w:lvl`` elements.

Mammoth's fallback defaults an unindexed level to "0", but only retains a
single unindexed level per abstract numbering definition, discarding any
subsequent sub-levels (levels 1, 2, etc.) and flattening or corrupting nested lists.
Assigning sequential level indices based on ordinal position restores the
intended list hierarchy.
"""
if b"lvl" not in content:
return content

soup = BeautifulSoup(content, features="xml")
modified = False
for abstract_num in soup.find_all("w:abstractNum"):
for idx, lvl in enumerate(abstract_num.find_all("w:lvl")):
if not lvl.has_attr("w:ilvl"):
lvl["w:ilvl"] = str(idx)
modified = True

return str(soup).encode() if modified else content


def pre_process_docx(input_docx: BinaryIO) -> BinaryIO:
"""
Pre-processes a DOCX file with provided steps.
Expand All @@ -271,6 +299,7 @@ def pre_process_docx(input_docx: BinaryIO) -> BinaryIO:
"word/footnotes.xml": (_pre_process_strike, _pre_process_math),
"word/endnotes.xml": (_pre_process_strike, _pre_process_math),
"word/styles.xml": (_pre_process_styles,),
"word/numbering.xml": (_pre_process_numbering,),
}
with zipfile.ZipFile(input_docx, mode="r") as zip_input:
files = {name: zip_input.read(name) for name in zip_input.namelist()}
Expand Down
28 changes: 28 additions & 0 deletions packages/markitdown/tests/test_docx.py
Original file line number Diff line number Diff line change
Expand Up @@ -1485,5 +1485,33 @@ def test_convert_docx_with_style_missing_type(tmp_path):
assert "# Abstract" in result.markdown


# Numbering definitions missing w:ilvl


def test_convert_docx_with_numbering_missing_ilvl(tmp_path):
"""DOCX conversion should not fail when numbering entries lack w:ilvl."""
source_path = os.path.join(TEST_FILES_DIR, "equations.docx")
malformed_path = tmp_path / "missing_numbering_ilvl.docx"

with zipfile.ZipFile(source_path, mode="r") as zip_input:
with zipfile.ZipFile(malformed_path, mode="w") as zip_output:
for item in zip_input.infolist():
content = zip_input.read(item.filename)
if item.filename == "word/numbering.xml":
numbering_xml = content.decode("utf-8")
numbering_xml, count = re.subn(
r'\s+w:ilvl="[^"]*"',
"",
numbering_xml,
)
assert count > 0
content = numbering_xml.encode("utf-8")
zip_output.writestr(item, content)

result = MarkItDown().convert(str(malformed_path))
assert result.markdown is not None
assert len(result.markdown) > 0


if __name__ == "__main__":
raise SystemExit(pytest.main([__file__]))