From bbc99b7534ab3ed3daec19e3533f993e61f72d3a Mon Sep 17 00:00:00 2001 From: AndrewAvery7 Date: Wed, 5 Aug 2026 21:26:44 -0500 Subject: [PATCH] fix: remove extra closing brace from caron and ring-above accents `CHR["\u030c"]` and `CHR["\u030a"]` each carried a fourth `}`, so the templates read `\check{{{0}}}}` and `\ocirc{{{0}}}}`. Every other accent in the table is `\name{{{0}}}`. `oMath2Latex.do_acc` applies these with `latex_s.format(c_dict["e"])`, and an unbalanced template raises `ValueError: Single '}' encountered in format string`. The damage is not limited to the accented 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 a single caron or ring-above accent silently removes **every** equation in the document, with no error and a zero exit code. Both are standard entries in Word's Equation > Accent gallery (U+030C COMBINING CARON, U+030A COMBINING RING ABOVE). Measured on the repo's own `equations.docx` fixture: 2 equations recovered normally, 0 after appending one caron-accented equation, 3 with this fix applied. Adds `test_docx_math_accents.py`: a table-wide guard asserting every `{0}` template survives `.format()`, plus direct coverage of the two affected accents and an unaffected control. Co-Authored-By: Claude Opus 5 --- .../converter_utils/docx/math/latex_dict.py | 4 +- .../tests/test_docx_math_accents.py | 80 +++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 packages/markitdown/tests/test_docx_math_accents.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 4c196f50a5..6a76cfab0c 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 @@ -26,8 +26,8 @@ "\u0307": "\\dot{{{0}}}", "\u0308": "\\ddot{{{0}}}", "\u0309": "\\ovhook{{{0}}}", - "\u030a": "\\ocirc{{{0}}}}", - "\u030c": "\\check{{{0}}}}", + "\u030a": "\\ocirc{{{0}}}", + "\u030c": "\\check{{{0}}}", "\u0310": "\\candra{{{0}}}", "\u0312": "\\oturnedcomma{{{0}}}", "\u0315": "\\ocommatopright{{{0}}}", diff --git a/packages/markitdown/tests/test_docx_math_accents.py b/packages/markitdown/tests/test_docx_math_accents.py new file mode 100644 index 0000000000..821d057bc5 --- /dev/null +++ b/packages/markitdown/tests/test_docx_math_accents.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 -m pytest +"""Tests for DOCX math accent templates. + +Every accent in ``latex_dict.CHR`` is a ``str.format`` template applied by +``oMath2Latex.do_acc`` as ``latex_s.format(c_dict["e"])``. A template whose +braces are unbalanced raises ``ValueError`` at that call. + +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 a single unformattable accent silently removes +every equation in the document -- with no error and a zero exit code. + +``test_accent_templates_are_formattable`` guards the whole table against that +class of typo; ``test_caron_and_ring_accents`` covers the two entries that +carried an extra closing brace. +""" + +from markitdown.converter_utils.docx.math.latex_dict import ( + CHR, + CHR_BO, + CHR_DEFAULT, + POS, + POS_DEFAULT, +) +from markitdown.converter_utils.docx.math.omml import load_string + +# Accent characters, as Word writes them into . +CARON = "\u030c" # COMBINING CARON -> \check +RING_ABOVE = "\u030a" # COMBINING RING ABOVE -> \ocirc +CIRCUMFLEX = "\u0302" # COMBINING CIRCUMFLEX -> \hat + +_OMML_DOC = ( + "' + "" + '' + "x" + "" +) + + +def _latex_for_accent(accent_char: str) -> str: + """Convert a single accented variable to LaTeX, as pre_process_docx would.""" + return next(load_string(_OMML_DOC.format(chr=accent_char))).latex + + +def test_accent_templates_are_formattable() -> None: + """Every {0} template must survive .format(); do_acc calls it unguarded.""" + unformattable = [] + for name, table in ( + ("CHR", CHR), + ("CHR_BO", CHR_BO), + ("CHR_DEFAULT", CHR_DEFAULT), + ("POS", POS), + ("POS_DEFAULT", POS_DEFAULT), + ): + for key, template in table.items(): + if not isinstance(template, str) or "{0}" not in template: + continue + try: + template.format("x") + except ValueError as exc: + unformattable.append(f"{name}[{key!r}] = {template!r}: {exc}") + + assert not unformattable, "unformattable accent templates: " + "; ".join( + unformattable + ) + + +def test_caron_and_ring_accents() -> None: + """U+030C and U+030A each carried an extra '}' and raised ValueError.""" + assert _latex_for_accent(CARON) == "\\check{x}" + assert _latex_for_accent(RING_ABOVE) == "\\ocirc{x}" + + +def test_unmodified_accent_still_converts() -> None: + """Control: a neighbouring accent that was never broken.""" + assert _latex_for_accent(CIRCUMFLEX) == "\\hat{x}"