From 33b4c45f35e47804962df51038177c1d45bdff36 Mon Sep 17 00:00:00 2001 From: kzahiri1 Date: Wed, 2 Sep 2026 12:50:41 -0700 Subject: [PATCH 1/2] fix(docx): default the n-ary operator to the integral sign m:chr under m:naryPr names the n-ary operator and, per ISO/IEC 29500-1, defaults to U+222B INTEGRAL when omitted. Producers therefore write it only for non-default operators such as the summation sign. do_nary passed no default to get_char, so an integral gave bo = None and `None + ""` raised TypeError. _pre_process_math runs over the whole of word/document.xml inside a blanket `except Exception`, so the crash discards the OMML rewrite for the entire part. Mammoth cannot render raw OMML, so one integral silently removed every equation in the document. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DQughgr95y4B9H1jfaQH8o --- .../converter_utils/docx/math/latex_dict.py | 2 + .../converter_utils/docx/math/omml.py | 2 +- .../markitdown/tests/test_docx_math_nary.py | 50 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 packages/markitdown/tests/test_docx_math_nary.py diff --git a/packages/markitdown/src/markitdown/converter_utils/docx/math/latex_dict.py b/packages/markitdown/src/markitdown/converter_utils/docx/math/latex_dict.py index 5657a6007..a25466d15 100644 --- a/packages/markitdown/src/markitdown/converter_utils/docx/math/latex_dict.py +++ b/packages/markitdown/src/markitdown/converter_utils/docx/math/latex_dict.py @@ -225,6 +225,8 @@ CHR_DEFAULT = { "ACC_VAL": "\\hat{{{0}}}", "GROUP_CHR_VAL": "\\underbrace{{{0}}}", + # Omitting m:chr under m:naryPr means U+222B INTEGRAL (ISO/IEC 29500-1). + "NARY_VAL": "\\int", } POS = { 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 f566f7d3b..c8ef0a2a0 100644 --- a/packages/markitdown/src/markitdown/converter_utils/docx/math/omml.py +++ b/packages/markitdown/src/markitdown/converter_utils/docx/math/omml.py @@ -366,7 +366,7 @@ def do_nary(self, elm): bo = "" for stag, t, e in self.process_children_list(elm): if stag == "naryPr": - bo = get_char(t.chr, store=CHR_BO) + bo = get_char(t.chr, default=CHR_DEFAULT.get("NARY_VAL"), store=CHR_BO) else: res.append(t) return bo + BLANK.join(res) diff --git a/packages/markitdown/tests/test_docx_math_nary.py b/packages/markitdown/tests/test_docx_math_nary.py new file mode 100644 index 000000000..a2cc8563c --- /dev/null +++ b/packages/markitdown/tests/test_docx_math_nary.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 -m pytest +"""Tests for the default n-ary operator in DOCX math conversion. + +``m:chr`` under ``m:naryPr`` names the n-ary operator. ISO/IEC 29500-1 states +that when the element is omitted the operator is U+222B INTEGRAL, so producers +write ``m:chr`` only for the non-default operators such as the summation sign. +``do_nary`` passed no default to ``get_char``, so an integral yielded ``None`` +and ``None + ""`` raised ``TypeError``. + +That failure is not local to the equation. ``pre_process_docx`` runs +``_pre_process_math`` over the whole of ``word/document.xml`` inside a blanket +``except Exception`` and, on error, writes the *original* unprocessed XML back. +Mammoth does not render OMML, so one integral silently removes every equation +in the document. +""" + +from xml.etree import ElementTree as ET + +from markitdown.converter_utils.docx.math.omml import OMML_NS, oMath2Latex + +MATH_NS_DECL = f'xmlns:m="{OMML_NS[1:-1]}"' + +SUMMATION = "∑" # N-ARY SUMMATION, written out by Word as it is not the default + + +def _nary(nary_pr: str): + xml = ( + f"" + f"{nary_pr}" + "0" + "1" + "x" + "" + ) + return oMath2Latex(ET.fromstring(xml)).latex + + +def test_nary_without_chr_defaults_to_integral(): + # No m:chr, so the operator is U+222B. Previously raised TypeError. + latex = _nary('') + assert latex == "\\int_{0}^{1}x" + + +def test_nary_with_explicit_chr_is_unchanged(): + latex = _nary(f'') + assert latex == "\\sum_{0}^{1}x" + + +def test_nary_without_nary_pr_does_not_crash(): + assert _nary("") == "_{0}^{1}x" From cfef6ee7db2aaf77bc664e9941323810e16111e8 Mon Sep 17 00:00:00 2001 From: Kayvan Zahiri Date: Wed, 2 Sep 2026 16:20:30 -0700 Subject: [PATCH 2/2] Apply the n-ary default when m:naryPr is absent entirely m:naryPr is optional, so setting the default only inside the naryPr branch still lost the operator when the element was omitted. An absent m:naryPr means every property takes its default, the operator included, so initialize bo before the loop and keep the explicit m:chr lookup. The missing-naryPr test asserted the operator-less output, which locked in the wrong result. It now expects \int_{0}^{1}x and fails without this change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DQughgr95y4B9H1jfaQH8o --- .../src/markitdown/converter_utils/docx/math/omml.py | 4 +++- packages/markitdown/tests/test_docx_math_nary.py | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) 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 c8ef0a2a0..3b068e4f2 100644 --- a/packages/markitdown/src/markitdown/converter_utils/docx/math/omml.py +++ b/packages/markitdown/src/markitdown/converter_utils/docx/math/omml.py @@ -363,7 +363,9 @@ def do_nary(self, elm): the n-ary object """ res = [] - bo = "" + # m:naryPr is itself optional, so an absent element means every property + # takes its default, the operator included. + bo = CHR_DEFAULT.get("NARY_VAL", "") for stag, t, e in self.process_children_list(elm): if stag == "naryPr": bo = get_char(t.chr, default=CHR_DEFAULT.get("NARY_VAL"), store=CHR_BO) diff --git a/packages/markitdown/tests/test_docx_math_nary.py b/packages/markitdown/tests/test_docx_math_nary.py index a2cc8563c..d64abb987 100644 --- a/packages/markitdown/tests/test_docx_math_nary.py +++ b/packages/markitdown/tests/test_docx_math_nary.py @@ -46,5 +46,7 @@ def test_nary_with_explicit_chr_is_unchanged(): assert latex == "\\sum_{0}^{1}x" -def test_nary_without_nary_pr_does_not_crash(): - assert _nary("") == "_{0}^{1}x" +def test_nary_without_nary_pr_defaults_to_integral(): + # m:naryPr is optional. Absent, every property takes its default, so the + # operator is still U+222B rather than nothing. + assert _nary("") == "\\int_{0}^{1}x"