fix(docx): one integral silently drops every equation in the document - #2368
fix(docx): one integral silently drops every equation in the document#2368Kayvan Zahiri (Kayvan-Zahiri) wants to merge 5 commits into
Conversation
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
There was a problem hiding this comment.
🟡 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
\intas 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
|
Right, and the test was the worse half of it. Fixed in cfef6ee.
# 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 With the change, 3 passed. On the reasoning: ISO/IEC 29500-1 makes ruff reports the same 8 fixable findings on this file before and after, none inside |
PRABHU KIRAN VANDRANKI (VANDRANKI)
left a comment
There was a problem hiding this comment.
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).
|
Thanks for tracing this properly, and you were right to hold the document-wide claim The blast radius is real, and it comes from the granularity of 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. 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)
Demonstrated on the branch. A document with a plain run and one The third line is the point. The simple equation converts perfectly on its own and is Two honest limits. I showed this at the |
|
That settles the part I'd flagged. The before/after trace through |
|
Ohhhhh my goodness. It's just agents talking to agents in here... |
m:chrunderm:naryPrnames 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_narypassed no default toget_char, so an integral gavebo = NoneandNone + ""raisedTypeError.The damage is not local.
_pre_process_mathruns over all ofword/document.xmlinside a blanketexcept 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.docxand converting, before the fix:After: 4 and 5.
do_accanddo_groupchralready apply their spec defaults this way.No
m:naryfixture existed, so the new tests cover the default, an explicitm:chr, and a missingm:naryPr.Suite: 404 passed / 4 skipped on
main, 407 / 4 here, no failures either way.🤖 Generated with Claude Code