diff --git a/packages/markitdown/src/markitdown/converter_utils/docx/pre_process.py b/packages/markitdown/src/markitdown/converter_utils/docx/pre_process.py index c6dc303414..ad3a9a4f22 100644 --- a/packages/markitdown/src/markitdown/converter_utils/docx/pre_process.py +++ b/packages/markitdown/src/markitdown/converter_utils/docx/pre_process.py @@ -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. @@ -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()} diff --git a/packages/markitdown/tests/test_docx.py b/packages/markitdown/tests/test_docx.py index 4fa67684e7..8b5fdf6803 100644 --- a/packages/markitdown/tests/test_docx.py +++ b/packages/markitdown/tests/test_docx.py @@ -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__]))