Skip to content

fix(docx): one integral silently drops every equation in the document - #2368

Open
Kayvan Zahiri (Kayvan-Zahiri) wants to merge 5 commits into
microsoft:mainfrom
Kayvan-Zahiri:fix-nary-default-integral
Open

fix(docx): one integral silently drops every equation in the document#2368
Kayvan Zahiri (Kayvan-Zahiri) wants to merge 5 commits into
microsoft:mainfrom
Kayvan-Zahiri:fix-nary-default-integral

Conversation

@Kayvan-Zahiri

@Kayvan-Zahiri Kayvan Zahiri (Kayvan-Zahiri) commented Sep 2, 2026

Copy link
Copy Markdown

m:chr under m:naryPr names the n-ary operator. Per ISO/IEC 29500-1 it defaults to U+222B INTEGRAL when omitted, so producers write it only for non-default operators like the summation sign. do_nary passed no default to get_char, so an integral gave bo = None and None + "" raised TypeError.

The damage is not local. _pre_process_math runs over all of word/document.xml inside a blanket except Exception, so the crash discards the OMML rewrite for the whole part, and Mammoth cannot render raw OMML.

Appending one integral to tests/test_files/equations.docx and converting, before the fix:

no_integral.docx:   4 lines containing LaTeX
with_integral.docx: 0 lines containing LaTeX

After: 4 and 5. do_acc and do_groupchr already apply their spec defaults this way.

No m:nary fixture existed, so the new tests cover the default, an explicit m:chr, and a missing m:naryPr.

Suite: 404 passed / 4 skipped on main, 407 / 4 here, no failures either way.

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQughgr95y4B9H1jfaQH8o

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

An omitted optional m:naryPr still loses the integral operator, and the new test incorrectly preserves that behavior.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds the ISO-defined integral default for DOCX n-ary math operators and regression coverage.

Changes:

  • Adds \int as the default n-ary operator.
  • Tests default, explicit, and missing property cases.
File summaries
File Description
omml.py Applies the default operator during n-ary conversion.
latex_dict.py Defines the integral default.
test_docx_math_nary.py Adds n-ary conversion tests.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

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)
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQughgr95y4B9H1jfaQH8o
@Kayvan-Zahiri

Copy link
Copy Markdown
Author

Right, and the test was the worse half of it. Fixed in cfef6ee.

m:naryPr is optional, so setting the default inside that branch only covered the case
where the element is present but m:chr is missing. With no m:naryPr at all the loop
never enters the branch, bo stays empty, and the operator disappears. bo is now
initialized before the loop, with the explicit m:chr lookup unchanged:

# m:naryPr is itself optional, so an absent element means every property
# takes its default, the operator included.
bo = CHR_DEFAULT.get("NARY_VAL", "")

The old assertion was _nary("") == "_{0}^{1}x", which pinned the operator-less output as
correct. It now expects \int_{0}^{1}x, and it fails on the previous code:

FAILED tests/test_docx_math_nary.py::test_nary_without_nary_pr_defaults_to_integral
1 failed, 2 passed

With the change, 3 passed.

On the reasoning: ISO/IEC 29500-1 makes m:chr default to U+222B, and an omitted optional
properties element is the same thing as one carrying every default, so the two cases should
not disagree. Word does not emit m:nary without m:naryPr in practice, but a
hand-built or third-party OMML tree can, and this converter should not silently drop the
operator when it does.

ruff reports the same 8 fixable findings on this file before and after, none inside
do_nary. The two collection errors in the wider suite are a missing lxml and are
unrelated.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Community review, not a merge gate.

Traced the root cause directly against the PR's own branch. get_char is defined as get_char(key, default=None, store=CHR) in omml.py. Before this fix, do_nary called it as get_char(t.chr, store=CHR_BO) with no default argument, so when m:chr is absent under m:naryPr (which per the PR's cited ISO/IEC 29500-1 reference means the operator defaults to U+222B integral, not "no operator"), t.chr is None, get_char returns the implicit default=None, and the caller then does bo + BLANK.join(res)None + str raises TypeError. I confirmed this by reading the actual get_char signature on the PR's head commit, not just inferring it from the diff context.

The fix passes default=CHR_DEFAULT.get("NARY_VAL") ("\int") explicitly, and also changes the initial bo = "" to bo = CHR_DEFAULT.get("NARY_VAL", "") for the case where m:naryPr itself is entirely absent (also spec-legal, since m:naryPr is optional) — without that second change, an absent naryPr element would still produce an empty-string operator instead of the integral sign, which would be wrong output rather than a crash, but wrong nonetheless.

The severity claim in the test docstring — that this isn't a localized bug but silently deletes every equation in the whole document — is a serious one, since it depends on pre_process_docx catching this TypeError in a blanket except Exception and reverting to unprocessed XML for the entire word/document.xml. I did not independently trace that broader claim in pre_process_docx itself, so I can't personally confirm the document-wide blast radius, but the specific TypeError root cause and its fix are directly verified. Three focused tests cover: no m:chr (integral), explicit m:chr (unaffected), and no m:naryPr at all (integral).

@Kayvan-Zahiri

Copy link
Copy Markdown
Author

Thanks for tracing this properly, and you were right to hold the document-wide claim
separate from the part you could verify. I should have shown the evidence for it in the
first place, so here it is.

The blast radius is real, and it comes from the granularity of _pre_process_math, which
takes the whole file:

def _pre_process_math(content: bytes) -> bytes:
    soup = BeautifulSoup(content.decode(), features="xml")
    for tag in soup.find_all("oMathPara"):
        _replace_equations(tag)
    for tag in soup.find_all("oMath"):
        _replace_equations(tag)
    return str(soup).encode()

One raise anywhere in that loop and the function returns nothing. pre_process_docx then
does this (pre_process.py:188-197):

updated_content = content
for pre_process_step in pre_process_enable_files.get(name, ()):
    try:
        updated_content = pre_process_step(updated_content)
    except Exception:
        pass
zip_output.writestr(name, updated_content)

updated_content keeps its pre-math value and gets written, so the whole
word/document.xml goes through unconverted.

Demonstrated on the branch. A document with a plain run and one m:chr-less integral,
each checked alone first so the second equation is known-good:

WITHOUT the fix
  simple run only  : OK, converts
  integral only    : RAISED TypeError: unsupported operand type(s) for +: 'None...
  simple + integral: RAISED TypeError

WITH the fix
  simple run only  : OK
  integral only    : OK, has \int
  simple + integral: OK, has \int AND the run, 0 raw <m:oMath> left

The third line is the point. The simple equation converts perfectly on its own and is
still lost when an integral shares the document.

Two honest limits. I showed this at the _pre_process_math level and read
pre_process_docx rather than building a real .docx and running it through Mammoth, so
the final "Mammoth renders nothing for raw OMML" step is from the code path, not an
end-to-end render. And it took me three attempts to get a valid fixture: the first died on
a missing lxml and the second on a malformed m:f of my own making, both of which failed
identically with and without the fix and proved nothing. Checking each equation alone
first is what made the comparison meaningful.

@VANDRANKI

Copy link
Copy Markdown

That settles the part I'd flagged. The before/after trace through _pre_process_math and pre_process_docx's blanket except is convincing: a plain run converts fine alone, the integral alone raises pre-fix, and the combination silently drops the run's own equation too, not just the integral's. That was the exact gap in my review, I'd verified the TypeError root cause but not the document-wide claim, and now it's shown directly rather than inferred. No further concerns from me.

@afourney

afourney commented Sep 3, 2026

Copy link
Copy Markdown
Member

Ohhhhh my goodness. It's just agents talking to agents in here...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants