From 063bc101b29b46832a416f34e890d5e6d3ed82c8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=9E=97SO?=
<142557582+Linxiushen@users.noreply.github.com>
Date: Wed, 4 Nov 2026 07:53:52 +0800
Subject: [PATCH] Fix hidden n-ary limits in DOCX math
---
.../converter_utils/docx/math/omml.py | 14 ++-
.../tests/test_docx_nary_visibility.py | 99 +++++++++++++++++++
2 files changed, 112 insertions(+), 1 deletion(-)
create mode 100644 packages/markitdown/tests/test_docx_nary_visibility.py
diff --git a/packages/markitdown/src/markitdown/converter_utils/docx/math/omml.py b/packages/markitdown/src/markitdown/converter_utils/docx/math/omml.py
index 5a014384a7..92676b343b 100644
--- a/packages/markitdown/src/markitdown/converter_utils/docx/math/omml.py
+++ b/packages/markitdown/src/markitdown/converter_utils/docx/math/omml.py
@@ -374,9 +374,21 @@ def do_nary(self, elm):
"""
the n-ary object
"""
+ include = {"naryPr", "sub", "sup", "e"}
+ properties = elm.find(OMML_NS + "naryPr")
+ if properties is not None:
+ for bound in ("sub", "sup"):
+ hide = properties.find(OMML_NS + bound + "Hide")
+ # A present on/off property with no value means enabled.
+ if hide is not None and hide.get(OMML_NS + "val", "1") in (
+ "1",
+ "true",
+ "on",
+ ):
+ include.remove(bound)
res = []
bo = ""
- for stag, t, e in self.process_children_list(elm):
+ for stag, t, e in self.process_children_list(elm, include=include):
if stag == "naryPr":
bo = get_char(t.chr, store=CHR_BO)
else:
diff --git a/packages/markitdown/tests/test_docx_nary_visibility.py b/packages/markitdown/tests/test_docx_nary_visibility.py
new file mode 100644
index 0000000000..bbfbe7a5ee
--- /dev/null
+++ b/packages/markitdown/tests/test_docx_nary_visibility.py
@@ -0,0 +1,99 @@
+"""Stored hidden n-ary limits must not become visible DOCX math."""
+
+import io
+import zipfile
+from xml.etree import ElementTree as ET
+
+import pytest
+
+from markitdown import MarkItDown, StreamInfo
+from markitdown.converter_utils.docx.math.omml import OMML_NS, oMath2Latex
+
+
+def _nary_visibility_math(properties, lower="lower", upper="upper"):
+ return (
+ f''
+ ''
+ f"{properties}"
+ f"{lower}"
+ f"{upper}"
+ "x"
+ ""
+ )
+
+
+@pytest.mark.parametrize("bound", ["sub", "sup"])
+@pytest.mark.parametrize("value", [None, "1", "true", "on", "0", "false", "off"])
+def test_nary_limit_visibility(bound, value):
+ attribute = "" if value is None else f' m:val="{value}"'
+ properties = f""
+ hidden = value in (None, "1", "true", "on")
+ expected = r"\sum"
+ if not (bound == "sub" and hidden):
+ expected += "_{lower}"
+ if not (bound == "sup" and hidden):
+ expected += "^{upper}"
+ expected += "x"
+ assert (
+ oMath2Latex(ET.fromstring(_nary_visibility_math(properties))).latex == expected
+ )
+
+
+def test_nary_limits_are_visible_without_hide_properties():
+ assert (
+ oMath2Latex(ET.fromstring(_nary_visibility_math(""))).latex
+ == r"\sum_{lower}^{upper}x"
+ )
+
+
+def _nary_visibility_docx(properties):
+ document = (
+ ''
+ + _nary_visibility_math(properties)
+ + "After equation"
+ )
+ stream = io.BytesIO()
+ with zipfile.ZipFile(stream, "w") as archive:
+ archive.writestr(
+ "[Content_Types].xml",
+ ''
+ ''
+ ''
+ "",
+ )
+ archive.writestr(
+ "_rels/.rels",
+ ''
+ ''
+ "",
+ )
+ archive.writestr("word/document.xml", document)
+ stream.seek(0)
+ return stream
+
+
+@pytest.mark.parametrize(
+ "properties,visible,hidden",
+ [
+ ('', "upper", "lower"),
+ ("", "lower", "upper"),
+ ('', None, "lower"),
+ ],
+)
+def test_nary_visibility_through_public_docx_conversion(properties, visible, hidden):
+ markdown = (
+ MarkItDown()
+ .convert_stream(
+ _nary_visibility_docx(properties), stream_info=StreamInfo(extension=".docx")
+ )
+ .markdown
+ )
+ assert hidden not in markdown
+ if visible is not None:
+ assert visible in markdown
+ else:
+ assert "upper" not in markdown
+ assert r"\sum" in markdown
+ assert "x$" in markdown
+ assert "After equation" in markdown