From 28dfb50f07cdab09a14e2a51dbb6499249a47911 Mon Sep 17 00:00:00 2001 From: k3n Date: Fri, 11 Sep 2026 11:40:27 +0200 Subject: [PATCH] fix(docx): repair unindexed numbering definitions in docx preprocessor When converting DOCX files generated by tools such as Lark/Feishu or third-party exporters, multi-level lists lose their hierarchy because elements omit w:ilvl attributes. While Mammoth attempts a fallback to "0", its internal dictionary only retains a single unindexed level per abstractNum, discarding subsequent sub-levels (1, 2, etc.). This adds _pre_process_numbering() to pre_process.py to assign sequential level indices (str(idx)) to unindexed tags within each abstractNum before passing the document to Mammoth. Tested against sample files and added a unit test in test_docx.py. --- .../converter_utils/docx/pre_process.py | 29 +++++++++++++++++++ packages/markitdown/tests/test_docx.py | 28 ++++++++++++++++++ 2 files changed, 57 insertions(+) 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__]))