"""LaTeX -> OMML (Office Math) conversion for Markdown -> Word export.

Word renders equations from OMML (`<m:oMath>`), not from raw text. The previous
exporter only set the font to "Cambria Math" and emitted the LaTeX source as
literal characters, so anything with real commands (\\sum, \\Delta, \\rightarrow,
\\leq) showed up as raw text. This module performs a genuine conversion:

    LaTeX --(latex2mathml)--> presentation MathML --(owned converter)--> OMML

and returns a parsed `<m:oMath>` / `<m:oMathPara>` element ready to splice into a
python-docx paragraph.

OMML is REQUIRED, not optional. If the conversion backends are not importable
this raises loudly, so a missing dependency can never silently strip OMML from
the whole document. A *single* equation that fails to convert returns ``None``
so that one fragment falls back to text while the rest of the document keeps
real OMML.

This converter is owned (pure stdlib + latex2mathml); it does NOT use
``mathml2omml``. The 113-equation conformance suite in
``tests/test_omml_suite.py`` is the structural spec the emitted OMML matches.
"""

from __future__ import annotations

from html import escape
from xml.etree import ElementTree as ET

from docx.oxml import parse_xml
from docx.oxml.ns import nsdecls

_MATH_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math"
_MATHML_NS = "http://www.w3.org/1998/Math/MathML"

# N-ary operator characters. Big operators (∑∏⋃⋂…) and integrals (∫∮…) all
# default in this corpus to limits placed beside the glyph (limLoc=subSup),
# matching the conformance fixture.
_NARY_CHARS = {
    "∑",  # ∑ summation
    "∏",  # ∏ product
    "∐",  # ∐ coproduct
    "∫",  # ∫ integral
    "∬",  # ∬ double integral
    "∭",  # ∭ triple integral
    "⨌",  # ⨌ quadruple integral
    "∮",  # ∮ contour integral
    "∯",  # ∯ surface integral
    "∰",  # ∰ volume integral
    "⋃",  # ⋃ n-ary union
    "⋂",  # ⋂ n-ary intersection
    "⋁",  # ⋁ n-ary logical or
    "⋀",  # ⋀ n-ary logical and
    "⨁",  # ⨁ n-ary circled plus
    "⨂",  # ⨂ n-ary circled times
    "⨀",  # ⨀ n-ary circled dot
}

# Fence/delimiter open->close pairs recognised on bare mrow content.
_FENCE_PAIRS = {
    "(": ")",
    "[": "]",
    "{": "}",
    "⟨": "⟩",  # ⟨ ⟩ angle brackets
    "⌈": "⌉",  # ⌈ ⌉ ceiling
    "⌊": "⌋",  # ⌊ ⌋ floor
    "|": "|",
    "‖": "‖",  # ‖ double vertical bar
}
_FENCE_OPENERS = set(_FENCE_PAIRS)
_FENCE_CLOSERS = set(_FENCE_PAIRS.values())

# Accent over/under glyphs (both spacing and combining forms latex2mathml may
# emit) mapped to the COMBINING mark Word stores on <m:acc><m:chr>.
_ACCENT_TO_COMBINING = {
    "^": "̂",   # circumflex / hat
    "̂": "̂",
    "~": "̃",   # tilde
    "̃": "̃",
    "˜": "̃",  # small tilde
    "→": "⃗",   # vector arrow
    "⃗": "⃗",
    "˙": "̇",   # dot above
    "̇": "̇",
    "¨": "̈",   # diaeresis / ddot
    "̈": "̈",
    "ˇ": "̌",   # caron / check
    "̌": "̌",
    "˘": "̆",   # breve
    "̆": "̆",
    "´": "́",   # acute
    "́": "́",
    "`": "̀",   # grave
    "̀": "̀",
    "´": "́",  # acute accent variant
}
# Overline / bar (top) glyphs → <m:bar pos="top">.
_BAR_TOP_CHARS = {
    "¯",  # ¯ macron (spacing)
    "̄",  # ̄ combining macron
    "̅",  # ̅ combining overline
    "‾",  # ‾ overline (spacing)
}
# Underline glyph (―) is shared by overline; munder vs mover disambiguates.
_OVERLINE_CHARS = _BAR_TOP_CHARS | {"―"}
_BAR_BOT_CHARS = {
    "̲",  # ̲ combining low line
    "_",  # _ low line (spacing)
    "―",  # ― horizontal bar (latex2mathml \underline, under context)
}

# Group-character braces.
_GROUPCHR_TOP = {"⏞"}  # over-brace
_GROUPCHR_BOT = {"⏟"}  # under-brace

# Arrow glyphs (base of a decorated arrow like \xrightarrow).
_ARROW_CHARS = {"→", "←", "↔", "⇒", "⇐", "⇔", "↦", "⟶", "⟵", "⟷"}

# Known function names latex2mathml renders as a bare <mi>/<mo>. A function
# application wraps the name in <m:func> with the argument as <m:e>.
_FUNCTION_NAMES = {
    "sin", "cos", "tan", "cot", "sec", "csc",
    "sinh", "cosh", "tanh", "coth",
    "arcsin", "arccos", "arctan",
    "arsinh", "arcosh", "artanh",
    "log", "ln", "lg", "exp",
    "det", "dim", "hom", "ker", "deg", "gcd", "arg",
}
# Operator-name <mo> tokens that take an UNDER limit (limLow) when scripted.
_LIMIT_OP_NAMES = {"lim", "lim sup", "lim inf", "max", "min", "sup", "inf",
                   "limsup", "liminf", "argmax", "argmin"}

# Bold-italic / bold math-alphanumeric Unicode blocks → (ascii, style). We map
# Mathematical Bold and Bold-Italic letters/digits back to their ASCII base and
# carry the bold style as <m:sty m:val="b">. Other math-alphanumeric variants
# (blackboard, script/calligraphic) are intentionally LEFT as-is — the fixture
# keeps ℝ, 𝒩, 𝒳 verbatim.
def _bold_unmap(ch: str) -> "str | None":
    cp = ord(ch)
    # Mathematical Bold Capital A-Z: U+1D400..U+1D419
    if 0x1D400 <= cp <= 0x1D419:
        return chr(ord("A") + cp - 0x1D400)
    # Mathematical Bold Small a-z: U+1D41A..U+1D433
    if 0x1D41A <= cp <= 0x1D433:
        return chr(ord("a") + cp - 0x1D41A)
    # Mathematical Bold Digit 0-9: U+1D7CE..U+1D7D7
    if 0x1D7CE <= cp <= 0x1D7D7:
        return chr(ord("0") + cp - 0x1D7CE)
    return None


def _local_name(node: ET.Element) -> str:
    if node.tag.startswith("{"):
        return node.tag.rsplit("}", 1)[1]
    return node.tag


def _node_text(node: ET.Element) -> str:
    return "".join(node.itertext())


_SCRIPT_TAGS = {"msub", "msup", "msubsup", "munder", "mover", "munderover"}


# ---------------------------------------------------------------------------
# Run model — a small intermediate representation so adjacent simple text
# runs coalesce into ONE <m:r><m:t>…</m:t></m:r>, matching the fixture.
# ---------------------------------------------------------------------------

class _Run:
    """A coalescable text run: text + optional style ('b'|'p'|None)."""

    __slots__ = ("text", "style")

    def __init__(self, text: str, style: "str | None" = None) -> None:
        self.text = text
        self.style = style


class _Raw:
    """An already-serialized OMML fragment that must not be coalesced."""

    __slots__ = ("xml",)

    def __init__(self, xml: str) -> None:
        self.xml = xml


def _run_xml(text: str, style: "str | None") -> str:
    if style:
        return (
            f"<m:r><m:rPr><m:sty m:val=\"{style}\"/></m:rPr>"
            f"<m:t>{escape(text)}</m:t></m:r>"
        )
    return f"<m:r><m:t>{escape(text)}</m:t></m:r>"


def _emit(parts: "list") -> str:
    """Serialize a list of _Run/_Raw items, coalescing adjacent runs that share
    the same style into a single <m:r><m:t>."""
    out: list[str] = []
    buf_text = ""
    buf_style: "str | None" = None
    have_buf = False

    def flush() -> None:
        nonlocal have_buf, buf_text, buf_style
        if have_buf:
            out.append(_run_xml(buf_text, buf_style))
            have_buf = False
            buf_text = ""
            buf_style = None

    for item in parts:
        if isinstance(item, _Run):
            if have_buf and item.style == buf_style:
                buf_text += item.text
            else:
                flush()
                buf_text = item.text
                buf_style = item.style
                have_buf = True
        else:  # _Raw
            flush()
            out.append(item.xml)
    flush()
    return "".join(out)


# ---------------------------------------------------------------------------
# Token-level conversion: MathML node -> list of _Run/_Raw parts.
# ---------------------------------------------------------------------------

def _text_token_parts(node: ET.Element) -> "list":
    """Convert a leaf text node (mi/mn/mo/mtext) to run parts, applying style
    from mathvariant and unmapping bold math-alphanumeric characters."""
    raw = "".join(node.itertext())
    variant = node.attrib.get("mathvariant", "")
    style: "str | None" = None
    if variant in ("bold", "bold-italic"):
        style = "b"
    # NB: ``mathvariant="normal"`` (\mathrm) is NOT auto-styled upright here —
    # the fixture only marks upright runs in specific positions (a prescript
    # base, a transpose superscript), which are handled by _coerce_upright /
    # _sup_omml. Auto-styling every \mathrm run would over-apply <m:sty p>.
    # Per-character bold-alphanumeric unmap (e.g. \mathbf{F} -> 𝐅).
    mapped = []
    saw_bold = False
    for ch in raw:
        b = _bold_unmap(ch)
        if b is not None:
            mapped.append(b)
            saw_bold = True
        else:
            mapped.append(ch)
    text = _normalize_spaces("".join(mapped))
    text = text.translate(_GLYPH_ALIASES)
    if saw_bold and style is None:
        style = "b"
    return [_Run(text, style)]


# Operator glyph aliases: latex2mathml renders some relations with an ASCII
# code point; the fixture uses the proper Unicode math symbol.
_GLYPH_ALIASES = {
    ord("~"): "∼",   # \sim → U+223C
}


# Unicode spaces latex2mathml emits inside operator names / \text → normalise to
# a regular ASCII space so "lim sup" compares as "lim sup", etc.
_SPACE_CHARS = "\xa0             "
_SPACE_TABLE = {ord(c): " " for c in _SPACE_CHARS}


def _normalize_spaces(text: str) -> str:
    return text.translate(_SPACE_TABLE)


def _node_parts(node: ET.Element) -> "list":
    """Convert a single MathML node to a list of _Run/_Raw parts."""
    name = _local_name(node)
    if name in {"math", "mrow", "semantics", "mstyle", "mpadded"}:
        return _sequence_parts(list(node))
    if name in {"mi", "mn", "mo", "mtext"}:
        return _text_token_parts(node)
    if name == "mspace":
        # Math spacing commands (\, \; \! \quad …) carry no run content in the
        # fixture — drop them. (Row-break newlines are consumed upstream.)
        return []
    if name == "none":
        return []
    if name == "mphantom":
        inner = _emit(_sequence_parts(list(node)))
        return [_Raw(f"<m:phant><m:phantPr/><m:e>{inner}</m:e></m:phant>")]
    if name == "mfenced":
        return [_Raw(_mfenced_to_omml(node))]
    if name == "mmultiscripts":
        return [_Raw(_mmultiscripts_to_omml(node))]
    if name == "menclose":
        return [_Raw(_menclose_to_omml(node))]
    if name == "msup":
        kids = list(node)
        # Prime superscript (f' → f′): fold the prime glyph(s) into a text run
        # rather than a real superscript.
        if len(kids) > 1:
            sup_txt = _node_text(kids[1]).strip()
            if sup_txt and all(ch in "′″‴⁗" for ch in sup_txt):
                return _node_parts(kids[0]) + [_Run(sup_txt)]
        return [_Raw(
            "<m:sSup>"
            f"<m:e>{_child_omml(node, 0)}</m:e>"
            f"<m:sup>{_sup_omml(node, 1)}</m:sup>"
            "</m:sSup>"
        )]
    if name == "msub":
        return [_Raw(
            "<m:sSub>"
            f"<m:e>{_child_omml(node, 0)}</m:e>"
            f"<m:sub>{_child_omml(node, 1)}</m:sub>"
            "</m:sSub>"
        )]
    if name == "msubsup":
        return [_Raw(
            "<m:sSubSup>"
            f"<m:e>{_child_omml(node, 0)}</m:e>"
            f"<m:sub>{_child_omml(node, 1)}</m:sub>"
            f"<m:sup>{_child_omml(node, 2)}</m:sup>"
            "</m:sSubSup>"
        )]
    if name == "mfrac":
        return [_Raw(
            "<m:f>"
            f"<m:num>{_child_omml(node, 0)}</m:num>"
            f"<m:den>{_child_omml(node, 1)}</m:den>"
            "</m:f>"
        )]
    if name == "msqrt":
        body = _emit(_sequence_parts(list(node)))
        return [_Raw(
            "<m:rad><m:radPr><m:degHide m:val=\"1\"/></m:radPr>"
            f"<m:deg/><m:e>{body}</m:e></m:rad>"
        )]
    if name == "mroot":
        return [_Raw(
            "<m:rad><m:radPr><m:degHide m:val=\"0\"/></m:radPr>"
            f"<m:deg>{_child_omml(node, 1)}</m:deg>"
            f"<m:e>{_child_omml(node, 0)}</m:e>"
            "</m:rad>"
        )]
    if name == "mover":
        arrow = _decorated_arrow_text(node)
        if arrow is not None:
            return [_Run(arrow)]  # coalescable so "→𝒩" merges into one run
        return [_Raw(_mover_to_omml(node))]
    if name == "munder":
        return [_Raw(_munder_to_omml(node))]
    if name == "munderover":
        return [_Raw(_munderover_to_omml(node))]
    if name == "mtable":
        return [_Raw(_mtable_to_eqarr(node))]
    # Fallback: descend.
    sub = _sequence_parts(list(node))
    if sub:
        return sub
    return [_Run("".join(node.itertext()))]


def _child_omml(node: ET.Element, index: int) -> str:
    children = list(node)
    if index >= len(children):
        return ""
    return _emit(_node_parts(children[index]))


def _sup_omml(node: ET.Element, index: int) -> str:
    """Superscript content, marking a lone uppercase Latin letter as upright
    (e.g. the transpose ``A^{T}`` — latex2mathml drops mathvariant for it)."""
    children = list(node)
    if index >= len(children):
        return ""
    sup = children[index]
    parts = _node_parts(sup)
    if (len(parts) == 1 and isinstance(parts[0], _Run)
            and parts[0].style is None
            and len(parts[0].text) == 1
            and parts[0].text.isascii()
            and parts[0].text.isupper()):
        parts = [_Run(parts[0].text, "p")]
    return _emit(parts)


# ---------------------------------------------------------------------------
# Group character (overbrace / underbrace) — mover/munder with a brace glyph.
# ---------------------------------------------------------------------------

def _try_groupchr(node: ET.Element) -> "str | None":
    """If node is an mover/munder (possibly nested for the label) whose brace
    child is ⏞/⏟, emit <m:groupChr>. The attached label (over the brace) is
    dropped, matching the fixture."""
    name = _local_name(node)
    if name not in ("mover", "munder"):
        return None
    children = list(node)
    if len(children) < 2:
        return None
    base, mark = children[0], children[1]
    mark_txt = _node_text(mark).strip()
    # Outer mover label wrapping an inner brace: \overbrace{...}^{label}
    if mark_txt not in _GROUPCHR_TOP and mark_txt not in _GROUPCHR_BOT:
        inner = _try_groupchr(base)
        return inner  # label (mark) dropped; inner brace is authoritative
    if mark_txt in _GROUPCHR_TOP:
        chr_val, pos, vert = "⏞", "top", "bot"
    else:
        chr_val, pos, vert = "⏟", "bot", "top"
    body = _emit(_node_parts(base))
    return (
        "<m:groupChr><m:groupChrPr>"
        f'<m:chr m:val="{escape(chr_val)}"/>'
        f'<m:pos m:val="{pos}"/>'
        f'<m:vertJc m:val="{vert}"/>'
        "</m:groupChrPr>"
        f"<m:e>{body}</m:e></m:groupChr>"
    )


def _decorated_arrow_text(node: ET.Element) -> "str | None":
    """If ``node`` is a decorated arrow (\\xrightarrow{d} → mover(arrow, label)),
    return the bare arrow glyph (label dropped); else None."""
    children = list(node)
    if len(children) < 2:
        return None
    base_txt = _node_text(children[0]).strip()
    over_char = _node_text(children[1]).strip()
    if (base_txt in _ARROW_CHARS
            and over_char not in _ACCENT_TO_COMBINING
            and over_char not in _OVERLINE_CHARS):
        return base_txt
    return None


def _mover_to_omml(node: ET.Element) -> str:
    grp = _try_groupchr(node)
    if grp is not None:
        return grp
    children = list(node)
    over_char = _node_text(children[1]).strip() if len(children) > 1 else "^"
    base_xml = _child_omml(node, 0)
    if over_char in _OVERLINE_CHARS:
        return (
            "<m:bar><m:barPr><m:pos m:val=\"top\"/></m:barPr>"
            f"<m:e>{base_xml}</m:e></m:bar>"
        )
    chr_val = _ACCENT_TO_COMBINING.get(over_char, over_char or "̂")
    return (
        "<m:acc>"
        f'<m:accPr><m:chr m:val="{escape(chr_val)}"/></m:accPr>'
        f"<m:e>{base_xml}</m:e></m:acc>"
    )


def _munder_to_omml(node: ET.Element) -> str:
    grp = _try_groupchr(node)
    if grp is not None:
        return grp
    children = list(node)
    under_char = _node_text(children[1]).strip() if len(children) > 1 else ""
    if under_char in _BAR_BOT_CHARS:
        return (
            "<m:bar><m:barPr><m:pos m:val=\"bot\"/></m:barPr>"
            f"<m:e>{_child_omml(node, 0)}</m:e></m:bar>"
        )
    # Otherwise a lower limit (rare in this corpus outside scripted ops).
    return (
        "<m:limLow>"
        f"<m:e>{_child_omml(node, 0)}</m:e>"
        f"<m:lim>{_child_omml(node, 1)}</m:lim>"
        "</m:limLow>"
    )


def _munderover_to_omml(node: ET.Element) -> str:
    return (
        "<m:limUpp><m:e><m:limLow>"
        f"<m:e>{_child_omml(node, 0)}</m:e>"
        f"<m:lim>{_child_omml(node, 1)}</m:lim>"
        "</m:limLow></m:e>"
        f"<m:lim>{_child_omml(node, 2)}</m:lim>"
        "</m:limUpp>"
    )


# ---------------------------------------------------------------------------
# N-ary operators.
# ---------------------------------------------------------------------------

def _is_nary_operator_node(node: ET.Element) -> "str | None":
    if _local_name(node) != "mo":
        return None
    text = _node_text(node).strip()
    return text if text in _NARY_CHARS else None


def _script_base(node: ET.Element):
    children = list(node)
    return children[0] if children else None


def _nary_pr(base_char: str, has_sub: bool, has_sup: bool) -> str:
    parts = [
        f'<m:chr m:val="{escape(base_char)}"/>',
        '<m:limLoc m:val="subSup"/>',
    ]
    # Only emit a Hide hint when that limit is genuinely ABSENT.
    if not has_sub:
        parts.append('<m:subHide m:val="1"/>')
    if not has_sup:
        parts.append('<m:supHide m:val="1"/>')
    return "<m:naryPr>" + "".join(parts) + "</m:naryPr>"


def _nary_from_script(node: ET.Element, base_char: str, body: str) -> str:
    name = _local_name(node)
    children = list(node)
    sub_xml = ""
    sup_xml = ""
    if name in {"msub", "munder"}:
        sub_xml = _emit(_node_parts(children[1])) if len(children) > 1 else ""
    elif name in {"msup", "mover"}:
        sup_xml = _emit(_node_parts(children[1])) if len(children) > 1 else ""
    elif name in {"msubsup", "munderover"}:
        sub_xml = _emit(_node_parts(children[1])) if len(children) > 1 else ""
        sup_xml = _emit(_node_parts(children[2])) if len(children) > 2 else ""
    pr = _nary_pr(base_char, bool(sub_xml), bool(sup_xml))
    sub_block = f"<m:sub>{sub_xml}</m:sub>" if sub_xml else "<m:sub/>"
    sup_block = f"<m:sup>{sup_xml}</m:sup>" if sup_xml else "<m:sup/>"
    return f"<m:nary>{pr}{sub_block}{sup_block}<m:e>{body}</m:e></m:nary>"


# ---------------------------------------------------------------------------
# Limit operators (lim / limsup / max …) scripted via msub/munder.
# ---------------------------------------------------------------------------

def _is_limit_op_script(node: ET.Element) -> "ET.Element | None":
    """If node is msub/munder whose base is a limit-op <mo>/<mi>, return base."""
    if _local_name(node) not in ("msub", "munder"):
        return None
    base = _script_base(node)
    if base is None:
        return None
    if _local_name(base) in ("mo", "mi"):
        if _normalize_spaces(_node_text(base)).strip() in _LIMIT_OP_NAMES:
            return base
    return None


def _limit_low_from_script(node: ET.Element) -> str:
    children = list(node)
    base_xml = _emit(_node_parts(children[0]))
    lim_xml = _emit(_node_parts(children[1])) if len(children) > 1 else ""
    return (
        "<m:limLow>"
        f"<m:e>{base_xml}</m:e>"
        f"<m:lim>{lim_xml}</m:lim>"
        "</m:limLow>"
    )


# ---------------------------------------------------------------------------
# Function detection (\sin x, \log_2 n, \ln(x+1), …).
# ---------------------------------------------------------------------------

def _function_name_node(node: ET.Element) -> "str | None":
    """Return the function name if node is a function-name token, else None."""
    if _local_name(node) in ("mi", "mo"):
        txt = _node_text(node).strip()
        if txt in _FUNCTION_NAMES:
            return txt
    if _local_name(node) == "msub":
        base = _script_base(node)
        if base is not None and _local_name(base) in ("mi", "mo"):
            if _node_text(base).strip() in _FUNCTION_NAMES:
                return _node_text(base).strip()
    return None


def _func_apply(name_xml: str, arg_xml: str) -> str:
    return (
        "<m:func><m:funcPr/>"
        f"<m:fName>{name_xml}</m:fName>"
        f"<m:e>{arg_xml}</m:e></m:func>"
    )


# ---------------------------------------------------------------------------
# Sequence conversion — the heart of run coalescing + lookahead constructs.
# ---------------------------------------------------------------------------

def _sequence_parts(children: "list") -> "list":
    """Convert a flat sequence of MathML nodes to a list of _Run/_Raw parts.

    Handles, with sibling lookahead:
      • bare fence wrapping → <m:d>
      • n-ary operator body capture → <m:nary>
      • limit-op scripts (lim…) → <m:limLow> (no argument capture)
      • function application (sin/log/…) → <m:func> capturing the argument
      • empty-base prescript pattern → <m:sPre>
    """
    # Top-level bare fence (e.g. \left( a + b \right) flattened to mo … mo).
    fence = _try_bare_fence(children)
    if fence is not None:
        return [_Raw(fence)]

    parts: "list" = []
    i = 0
    n = len(children)
    while i < n:
        child = children[i]
        cname = _local_name(child)

        # Pre-script pattern: msub/msup/msubsup with EMPTY base, followed by the
        # real base run → <m:sPre>.
        pre = _try_prescript(children, i)
        if pre is not None:
            xml, consumed = pre
            parts.append(_Raw(xml))
            i += consumed
            continue

        # N-ary operator with script container (msub/munder/…): capture body.
        if cname in _SCRIPT_TAGS:
            base = _script_base(child)
            base_char = _is_nary_operator_node(base) if base is not None else None
            if base_char:
                body_nodes, j = _capture_body(children, i + 1)
                body = _emit(_sequence_parts(body_nodes))
                parts.append(_Raw(_nary_from_script(child, base_char, body)))
                i = j
                continue
            # Limit operator (lim/limsup/max …) → limLow, no argument capture.
            if _is_limit_op_script(child) is not None:
                parts.append(_Raw(_limit_low_from_script(child)))
                i += 1
                continue
            sbase = _script_base(child)
            if (cname in ("msup", "msub") and sbase is not None
                    and _function_name_node(sbase) is not None):
                kids = list(child)
                if cname == "msub":
                    # Subscripted function NAME (e.g. \log_2 n): the whole sSub is
                    # the function name; the following operand is the argument.
                    name_xml = (
                        "<m:sSub>"
                        f"<m:e>{_emit(_node_parts(sbase))}</m:e>"
                        f"<m:sub>{_emit(_node_parts(kids[1])) if len(kids) > 1 else ''}</m:sub>"
                        "</m:sSub>"
                    )
                    arg_nodes, j = _capture_func_arg(children, i + 1)
                    parts.append(_Raw(_func_apply(
                        name_xml, _emit(_sequence_parts(arg_nodes)))))
                    i = j
                    continue
                # Superscripted function (e.g. \cos^2 θ): power of the result.
                name_xml = _emit(_node_parts(sbase))
                arg_nodes, j = _capture_func_arg(children, i + 1)
                func_xml = _func_apply(name_xml, _emit(_sequence_parts(arg_nodes)))
                script_xml = _emit(_node_parts(kids[1])) if len(kids) > 1 else ""
                parts.append(_Raw(
                    f"<m:sSup><m:e>{func_xml}</m:e>"
                    f"<m:sup>{script_xml}</m:sup></m:sSup>"
                ))
                i = j
                continue

        # Bare n-ary mo (no script container).
        bare_char = _is_nary_operator_node(child)
        if bare_char:
            body_nodes, j = _capture_body(children, i + 1)
            body = _emit(_sequence_parts(body_nodes))
            pr = _nary_pr(bare_char, False, False)
            parts.append(_Raw(
                f"<m:nary>{pr}<m:sub/><m:sup/><m:e>{body}</m:e></m:nary>"
            ))
            i = j
            continue

        # Function application: name token (sin/log_2/…) consumes its argument.
        fname = _function_name_node(child)
        if fname is not None:
            name_xml = _emit(_node_parts(child))
            arg_nodes, j = _capture_func_arg(children, i + 1)
            if arg_nodes:
                arg_xml = _emit(_sequence_parts(arg_nodes))
                parts.append(_Raw(_func_apply(name_xml, arg_xml)))
                i = j
                continue

        # Implicit function application: a single-letter operand (or scripted
        # operand) immediately followed by a parenthesised group → func, with the
        # surrounding parentheses STRIPPED (the inner content is the argument).
        # e.g. f(x) → func(f, x), f(x,y) → func(f, "x,y").
        if _is_operand_callable(child) and _next_is_paren_group(children, i + 1):
            name_xml = _emit(_node_parts(child))
            inner_nodes, j = _capture_paren_inner(children, i + 1)
            parts.append(_Raw(_func_apply(
                name_xml, _emit(_sequence_parts(inner_nodes)))))
            i = j
            continue

        # Inline fence group: a parenthesised sub-expression in function-call
        # position (immediately after an identifier/scripted operand) → wrap in
        # <m:d>. e.g. f'(x) → f′ (x). Parens after an operator stay literal text.
        prev = children[i - 1] if i > 0 else None
        grp = (_inline_fence_group(children, i)
               if prev is not None and _local_name(prev) in (
                   "mi", "msub", "msup", "msubsup", "mrow", "mover", "munder")
               else None)
        if grp is not None:
            xml, j = grp
            parts.append(_Raw(xml))
            i = j
            continue

        # Default: token / structural node.
        parts.extend(_node_parts(child))
        i += 1
    return parts


def _inline_fence_group(children: "list", i: int) -> "tuple[str, int] | None":
    child = children[i]
    if _local_name(child) != "mo":
        return None
    open_ch = _node_text(child).strip()
    # Only parenthesis groups are wrapped inline. Brackets/braces/bars appear in
    # the corpus as literal text inside limits and sets, so wrapping them would
    # over-fire; the whole-row fence handler still covers \left[ … \right].
    if open_ch != "(":
        return None
    close_ch = _FENCE_PAIRS[open_ch]
    depth = 0
    n = len(children)
    j = i
    while j < n:
        node = children[j]
        if _local_name(node) == "mo":
            t = _node_text(node).strip()
            if t == open_ch:
                depth += 1
            elif t == close_ch:
                depth -= 1
                if depth == 0:
                    inner = children[i + 1:j]
                    return _delimiter(
                        open_ch, close_ch,
                        [_emit(_sequence_parts(inner))]), j + 1
        j += 1
    return None


def _is_operand_callable(node: ET.Element) -> bool:
    """A node that can stand as a function head when followed by '(': a single
    identifier letter, or a script whose base is such a letter (f', T_k, …)."""
    name = _local_name(node)
    if name == "mi":
        txt = _node_text(node).strip()
        return len(txt) == 1 and txt.isalpha()
    return False


def _capture_paren_inner(children: "list", start: int) -> "tuple[list, int]":
    """Capture the INNER nodes of the following ``(`` … ``)`` group (parens
    stripped). Returns (inner_nodes, index_after_close)."""
    n = len(children)
    while start < n and _local_name(children[start]) == "mspace":
        start += 1
    if start >= n or _local_name(children[start]) != "mo" \
            or _node_text(children[start]).strip() != "(":
        return [children[start]] if start < n else [], min(start + 1, n)
    depth = 0
    j = start
    while j < n:
        node = children[j]
        if _local_name(node) == "mo":
            t = _node_text(node).strip()
            if t == "(":
                depth += 1
            elif t == ")":
                depth -= 1
                if depth == 0:
                    return children[start + 1:j], j + 1
        j += 1
    return children[start + 1:], n


def _next_is_paren_group(children: "list", start: int) -> bool:
    # Skip spacing.
    while start < len(children) and _local_name(children[start]) == "mspace":
        start += 1
    if start >= len(children):
        return False
    nxt = children[start]
    if _local_name(nxt) == "mo" and _node_text(nxt).strip() == "(":
        return True
    if _local_name(nxt) == "mfenced":
        return True
    return False


_RELATION_OPS = {
    "=", "≠", "≈", "≡", "∼", "≅", "≃", "∝",
    "<", ">", "≤", "≥", "≪", "≫",
    "→", "←", "↔", "⇒", "⇐", "⇔", "↦", "⟶",
    "∈", "∉", "⊂", "⊆", "⊃", "⊇",
}


def _capture_body(children: "list", start: int) -> "tuple[list, int]":
    """Capture nodes for an n-ary body: the summand/integrand.

    A following n-ary operator is INCLUDED so adjacent operators nest (matching
    ``\\sum_i \\sum_j a_{ij}`` → an outer nary whose body is the inner nary). The
    body ends at a top-level relation operator (=, ∼, <, →, ∈, …), which marks
    the end of the operand and the start of a new clause."""
    body: "list" = []
    j = start
    n = len(children)
    while j < n:
        nxt = children[j]
        if _local_name(nxt) in ("mo", "mi"):
            t = _node_text(nxt).strip().translate(_GLYPH_ALIASES)
            if t in _RELATION_OPS:
                break
        body.append(nxt)
        j += 1
    return body, j


def _capture_func_arg(children: "list", start: int) -> "tuple[list, int]":
    """Capture the argument of a function application.

    The argument is the immediately-following operand:
      • a single delimiter group (mo '(' … mo ')'), or a node that already is a
        delimiter / fenced / row, OR
      • a single simple operand token.
    We deliberately keep the argument tight so trailing terms stay outside.
    """
    n = len(children)
    # Skip a spacing token (e.g. \! negative thin space between \exp and the
    # parenthesised argument).
    while start < n and _local_name(children[start]) == "mspace":
        start += 1
    if start >= n:
        return [], start
    first = children[start]
    fname_local = _local_name(first)
    # Parenthesised argument starting with an opening fence mo: take through the
    # matching close fence.
    if fname_local == "mo":
        open_ch = _node_text(first).strip()
        if open_ch in _FENCE_OPENERS:
            close_ch = _FENCE_PAIRS[open_ch]
            depth = 0
            j = start
            while j < n:
                node = children[j]
                if _local_name(node) == "mo":
                    t = _node_text(node).strip()
                    if t == open_ch:
                        depth += 1
                    elif t == close_ch:
                        depth -= 1
                        if depth == 0:
                            return children[start:j + 1], j + 1
                j += 1
            # Unbalanced — fall through to single token.
    # A node that is itself a complete group (mfenced / row / delimiter) — take
    # just it.
    if fname_local in {"mfenced", "mrow"}:
        return [first], start + 1
    # Otherwise the single following operand token.
    return [first], start + 1


def _try_prescript(children: "list", i: int) -> "tuple[str, int] | None":
    """Detect the empty-base prescript pattern emitted by latex2mathml for
    ``{}^{a}_{b}\\mathrm{X}``: an msubsup/msup/msub whose BASE is an empty
    <mrow/>, immediately followed by the real base operand. Emits <m:sPre>.
    """
    node = children[i]
    name = _local_name(node)
    if name not in ("msubsup", "msup", "msub"):
        return None
    kids = list(node)
    if not kids:
        return None
    base = kids[0]
    # Empty base = an mrow/mi/mo with no text and no children.
    if _node_text(base).strip() != "" or list(base):
        return None
    # Need a following operand to act as the real base.
    if i + 1 >= len(children):
        return None
    sub_xml = ""
    sup_xml = ""
    if name == "msubsup":
        sub_xml = _emit(_node_parts(kids[1])) if len(kids) > 1 else ""
        sup_xml = _emit(_node_parts(kids[2])) if len(kids) > 2 else ""
    elif name == "msub":
        sub_xml = _emit(_node_parts(kids[1])) if len(kids) > 1 else ""
    elif name == "msup":
        sup_xml = _emit(_node_parts(kids[1])) if len(kids) > 1 else ""
    real_base = children[i + 1]
    base_xml = _emit(_coerce_upright(_node_parts(real_base)))
    sub_block = f"<m:sub>{sub_xml}</m:sub>" if sub_xml else "<m:sub/>"
    sup_block = f"<m:sup>{sup_xml}</m:sup>" if sup_xml else "<m:sup/>"
    xml = (
        "<m:sPre><m:sPrePr/>"
        f"{sub_block}{sup_block}"
        f"<m:e>{base_xml}</m:e></m:sPre>"
    )
    return xml, 2


def _coerce_upright(parts: "list") -> "list":
    """Mark plain text runs in ``parts`` as upright (sty='p'). Used for the base
    of a prescript (a chemical element symbol like \\mathrm{X}), which
    latex2mathml strips ``mathvariant=normal`` from for single letters."""
    out: "list" = []
    for item in parts:
        if isinstance(item, _Run) and item.style is None:
            out.append(_Run(item.text, "p"))
        else:
            out.append(item)
    return out


# ---------------------------------------------------------------------------
# Delimiters / fences.
# ---------------------------------------------------------------------------

def _delimiter(begin: str, end: str, inner_es: "list[str]", sep: "str | None" = None) -> str:
    pr_parts = [f'<m:begChr m:val="{escape(begin)}"/>', f'<m:endChr m:val="{escape(end)}"/>']
    if sep is not None:
        pr_parts.append(f'<m:sepChr m:val="{escape(sep)}"/>')
    pr = "<m:dPr>" + "".join(pr_parts) + "</m:dPr>"
    es = "".join(f"<m:e>{e}</m:e>" for e in inner_es) or "<m:e/>"
    return f"<m:d>{pr}{es}</m:d>"


def _is_fence_mo(node: ET.Element) -> bool:
    return _local_name(node) == "mo" and (
        node.attrib.get("fence") == "true"
        or _node_text(node).strip() in _FENCE_OPENERS
        or _node_text(node).strip() in _FENCE_CLOSERS
    )


def _try_bare_fence(children: "list") -> "str | None":
    """If ``children`` open with a fence ``mo`` and close with a matching fence
    ``mo``, wrap the inner content in <m:d>. Handles inner ``\\middle|`` bars by
    splitting into nested <m:d> with | … | when fence bars appear mid-row."""
    if len(children) < 2:
        return None
    first, last = children[0], children[-1]
    if _local_name(first) != "mo" or _local_name(last) != "mo":
        return None
    open_ch = _node_text(first).strip()
    close_ch = _node_text(last).strip()
    if open_ch not in _FENCE_OPENERS:
        return None
    # The close must be a recognised closer; allow asymmetric pairs that the
    # fixture uses (e.g. ( … ] and | … ⟩).
    if close_ch not in _FENCE_CLOSERS:
        return None
    inner = children[1:-1]
    # Detect inner "middle" fence bars (lspace/rspace stretchy fence | ) that
    # split the content into nested delimiters: ⟨ a | b | c ⟩.
    mids = [
        idx for idx, node in enumerate(inner)
        if _local_name(node) == "mo"
        and node.attrib.get("fence") == "true"
        and node.attrib.get("lspace") is not None
        and _node_text(node).strip() in ("|", "‖")
    ]
    if mids:
        segs: "list[list]" = [[]]
        bar_chars: "list[str]" = []
        for node in inner:
            if (_local_name(node) == "mo"
                    and node.attrib.get("fence") == "true"
                    and node.attrib.get("lspace") is not None
                    and _node_text(node).strip() in ("|", "‖")):
                bar_chars.append(_node_text(node).strip())
                segs.append([])
            else:
                segs[-1].append(node)
        # Middle bars come in PAIRS: the content between bar[0] and bar[1] is
        # wrapped in its own <m:d>|…|, the content after bar[1] is plain, etc.
        # ⟨ ϕ | X | ψ ⟩ → ϕ, <m:d>|X|</m:d>, ψ.
        es_parts: "list[str]" = []
        for k, seg in enumerate(segs):
            seg_xml = _emit(_sequence_parts(seg))
            if k >= 1 and k % 2 == 1:
                bar = bar_chars[k - 1] if k - 1 < len(bar_chars) else "|"
                es_parts.append(_delimiter(bar, bar, [seg_xml]))
            else:
                es_parts.append(seg_xml)
        body = "".join(es_parts)
        return _delimiter(open_ch, close_ch, [body])
    # Plain fence: the whole inner content is a single <m:e> (commas stay as
    # literal text — the fixture does not split argument lists into cells).
    return _delimiter(open_ch, close_ch, [_emit(_sequence_parts(inner))])


def _mfenced_to_omml(node: ET.Element) -> str:
    open_ch = node.attrib.get("open", "(")
    close_ch = node.attrib.get("close", ")")
    seps = node.attrib.get("separators", ",")
    children = list(node)
    es = [_emit(_node_parts(child)) for child in children]
    sep = seps[0] if seps else None
    return _delimiter(open_ch, close_ch, es, sep=sep if len(es) > 1 else None)


# ---------------------------------------------------------------------------
# Multiscripts (mmultiscripts) — pre/post scripts.
# ---------------------------------------------------------------------------

def _mmultiscripts_to_omml(node: ET.Element) -> str:
    children = list(node)
    if not children:
        return ""
    base = children[0]
    base_xml = _emit(_node_parts(base))
    post: "list[ET.Element]" = []
    pre: "list[ET.Element]" = []
    in_pre = False
    for child in children[1:]:
        if _local_name(child) == "mprescripts":
            in_pre = True
            continue
        (pre if in_pre else post).append(child)

    def _sx(nodes: "list[ET.Element]", idx: int) -> str:
        if idx >= len(nodes):
            return ""
        nd = nodes[idx]
        if _local_name(nd) == "none":
            return ""
        return _emit(_node_parts(nd))

    result = base_xml
    if post:
        sub_xml = _sx(post, 0)
        sup_xml = _sx(post, 1)
        if sub_xml and sup_xml:
            result = (
                "<m:sSubSup>"
                f"<m:e>{result}</m:e>"
                f"<m:sub>{sub_xml}</m:sub>"
                f"<m:sup>{sup_xml}</m:sup>"
                "</m:sSubSup>"
            )
        elif sub_xml:
            result = f"<m:sSub><m:e>{result}</m:e><m:sub>{sub_xml}</m:sub></m:sSub>"
        elif sup_xml:
            result = f"<m:sSup><m:e>{result}</m:e><m:sup>{sup_xml}</m:sup></m:sSup>"
    if pre:
        pre_sub = _sx(pre, 0)
        pre_sup = _sx(pre, 1)
        sub_block = f"<m:sub>{pre_sub}</m:sub>" if pre_sub else "<m:sub/>"
        sup_block = f"<m:sup>{pre_sup}</m:sup>" if pre_sup else "<m:sup/>"
        result = (
            "<m:sPre><m:sPrePr/>"
            f"{sub_block}{sup_block}"
            f"<m:e>{result}</m:e></m:sPre>"
        )
    return result


# ---------------------------------------------------------------------------
# Enclosures (menclose) — boxed / cancel / bcancel.
# ---------------------------------------------------------------------------

def _menclose_to_omml(node: ET.Element) -> str:
    notation = node.attrib.get("notation", "").strip().lower()
    inner = _emit(_sequence_parts(list(node)))
    if notation == "updiagonalstrike":
        pr = (
            "<m:borderBoxPr>"
            '<m:hideTop m:val="1"/><m:hideBot m:val="1"/>'
            '<m:hideLeft m:val="1"/><m:hideRight m:val="1"/>'
            '<m:strikeTLBR m:val="1"/>'
            "</m:borderBoxPr>"
        )
        return f"<m:borderBox>{pr}<m:e>{inner}</m:e></m:borderBox>"
    if notation == "downdiagonalstrike":
        pr = (
            "<m:borderBoxPr>"
            '<m:hideTop m:val="1"/><m:hideBot m:val="1"/>'
            '<m:hideLeft m:val="1"/><m:hideRight m:val="1"/>'
            '<m:strikeBLTR m:val="1"/>'
            "</m:borderBoxPr>"
        )
        return f"<m:borderBox>{pr}<m:e>{inner}</m:e></m:borderBox>"
    # box / roundedbox / actuarial / everything else → plain bordered box.
    return f"<m:borderBox><m:borderBoxPr/><m:e>{inner}</m:e></m:borderBox>"


# ---------------------------------------------------------------------------
# mtable → eqArr (aligned / cases).  latex2mathml flattens \aligned to a single
# mrow with raw '&' separators (handled in preprocessing), and \cases to an
# mtable inside a prefix-fence mrow. Both become <m:eqArr> with '&' stripped.
# ---------------------------------------------------------------------------

# Synthetic newline marker (an mspace[linebreak=newline]) used to insert an
# explicit row break at <mtr> boundaries.
_NEWLINE_MARK = ET.Element("mspace", {"linebreak": "newline"})


def _mtable_rows(node: ET.Element) -> "list[list]":
    """Flatten an mtable into a list of token-rows, splitting on the nbsp/newline
    ``\\`` break markers. Used for eqArr (aligned/cases) where columns collapse
    into a single per-row sequence (the '&' align markers are stripped)."""
    flat: "list" = []
    for tr in [c for c in list(node) if _local_name(c) == "mtr"]:
        for cell in [c for c in list(tr) if _local_name(c) == "mtd"]:
            flat.extend(list(cell))
        flat.append(_NEWLINE_MARK)  # mtr boundary is also a row break
    out: "list[list]" = []
    for seg in _split_rows_on_nbsp(flat):
        out.append([nd for nd in seg if not _is_align_marker(nd)])
    return [r for r in out if r]


def _mtable_to_eqarr(node: ET.Element) -> str:
    rows = _mtable_rows(node)
    es = "".join(
        f"<m:e>{_emit(_sequence_parts(seg))}</m:e>" for seg in rows
    )
    return f"<m:eqArr>{es}</m:eqArr>"


def _mtable_grid(node: ET.Element) -> "list[list[list]]":
    """Reconstruct a 2-D cell grid from latex2mathml's degenerate matrix mtable.

    latex2mathml drops '&' column markers in matrix environments and packs the
    table into one <mtr> with split <mtd>s plus a newline/nbsp ``\\`` break. We
    treat each <mtd> boundary as a column separator and each break as a new row.
    """
    rows: "list[list[list]]" = [[]]
    for tr in [c for c in list(node) if _local_name(c) == "mtr"]:
        for cell in [c for c in list(tr) if _local_name(c) == "mtd"]:
            segments = _split_rows_on_nbsp(list(cell)) or [[]]
            for k, seg in enumerate(segments):
                seg = [nd for nd in seg if not _is_align_marker(nd)]
                if k == 0:
                    rows[-1].append(seg)
                else:
                    rows.append([seg])
        rows.append([])  # mtr boundary ends a row
    return [r for r in rows if r]


def _mtable_to_matrix(node: ET.Element) -> str:
    grid = _mtable_grid(node)
    ncols = max((len(r) for r in grid), default=0)
    mrows = []
    for row in grid:
        es = "".join(
            f"<m:e>{_emit(_sequence_parts(cell))}</m:e>" for cell in row
        )
        mrows.append(f"<m:mr>{es}</m:mr>")
    mpr = (
        "<m:mPr><m:mcs><m:mc><m:mcPr>"
        f'<m:count m:val="{ncols}"/>'
        "</m:mcPr></m:mc></m:mcs></m:mPr>"
    )
    return f"<m:m>{mpr}{''.join(mrows)}</m:m>"


# Private-use sentinel char standing in for the alignment '&' that
# latex2mathml emits as invalid XML inside aligned environments.
_ALIGN_SENTINEL = ""


def _is_align_marker(node: ET.Element) -> bool:
    if _local_name(node) not in ("mi", "mo"):
        return False
    t = _node_text(node).strip()
    return t == "&" or t == _ALIGN_SENTINEL


def _is_nbsp_break(node: ET.Element) -> bool:
    # Newline mspace (latex2mathml emits this for "\\" in aligned/matrix).
    if _local_name(node) == "mspace" and node.attrib.get("linebreak") == "newline":
        return True
    # Whitespace/nbsp mtext (alternate "\\" rendering).
    if _local_name(node) == "mtext":
        txt = _node_text(node)
        return txt != "" and txt.strip("  	") == ""
    return False


def _split_rows_on_nbsp(nodes: "list") -> "list[list]":
    rows: "list[list]" = [[]]
    for nd in nodes:
        if _is_nbsp_break(nd):
            rows.append([])
        else:
            rows[-1].append(nd)
    return [r for r in rows if r]


# ---------------------------------------------------------------------------
# Top-level mrow special cases: aligned (flattened) and cases (fence+mtable).
# ---------------------------------------------------------------------------

def _try_aligned_or_cases(root_children: "list") -> "str | None":
    """If the top-level content is an aligned/cases environment, emit eqArr.

    • aligned → a single mrow flattened to tokens with raw '&' align markers and
      nbsp '\\' breaks (no mtable). Detect by presence of '&' align markers.
    • cases  → an mrow whose children are [fence-mo '{', mtable]. Detect the
      mtable and convert it.
    """
    # Unwrap a single wrapping mrow.
    nodes = root_children
    while len(nodes) == 1 and _local_name(nodes[0]) in ("mrow", "mstyle"):
        nodes = list(nodes[0])

    # matrix: fence-open + mtable + fence-close → <m:d><m:m>.
    if (len(nodes) == 3
            and _local_name(nodes[0]) == "mo"
            and _local_name(nodes[1]) == "mtable"
            and _local_name(nodes[2]) == "mo"):
        open_ch = _node_text(nodes[0]).strip()
        close_ch = _node_text(nodes[2]).strip()
        if open_ch in _FENCE_OPENERS and close_ch in _FENCE_CLOSERS:
            return _delimiter(open_ch, close_ch, [_mtable_to_matrix(nodes[1])])
    # cases: prefix fence '{' followed by mtable → eqArr (no fence).
    if (len(nodes) == 2
            and _local_name(nodes[0]) == "mo"
            and _node_text(nodes[0]).strip() == "{"
            and _local_name(nodes[1]) == "mtable"):
        return _mtable_to_eqarr(nodes[1])
    # A bare mtable (\begin{matrix} / \begin{array}, no fence) → matrix <m:m>.
    if len(nodes) == 1 and _local_name(nodes[0]) == "mtable":
        return _mtable_to_matrix(nodes[0])

    # aligned: flattened tokens containing '&' align markers.
    if any(_is_align_marker(nd) for nd in nodes):
        rows: "list[str]" = []
        for seg in _split_rows_on_nbsp(nodes):
            seg = [nd for nd in seg if not _is_align_marker(nd)]
            rows.append(_emit(_sequence_parts(seg)))
        es = "".join(f"<m:e>{r}</m:e>" for r in rows)
        return f"<m:eqArr>{es}</m:eqArr>"
    return None


# ---------------------------------------------------------------------------
# MathML preprocessing — repair latex2mathml's invalid '&' in aligned output.
# ---------------------------------------------------------------------------

def _sanitize_mathml(mathml: str) -> str:
    """latex2mathml emits a literal ``<mi>&</mi>`` for the alignment ``&`` in
    ``aligned`` environments, which is invalid XML. Replace it with a parseable
    sentinel token we can recognise downstream as an align marker."""
    return mathml.replace("<mi>&</mi>", "<mi></mi>")



def _mathml_to_omml(mathml: str) -> "str | None":
    mathml = _sanitize_mathml(mathml)
    try:
        root = ET.fromstring(mathml)
    except ET.ParseError:
        return None
    if _local_name(root) != "math" and root.tag != f"{{{_MATHML_NS}}}math":
        return None
    children = list(root)
    aligned = _try_aligned_or_cases(children)
    if aligned is not None:
        return f"<m:oMath>{aligned}</m:oMath>"
    body = _emit(_sequence_parts(children))
    if not body:
        return None
    return f"<m:oMath>{body}</m:oMath>"


def _latex_to_omml_string(latex: str) -> "str | None":
    """Convert a LaTeX fragment to an OMML `<m:oMath>` XML string, or None."""
    latex = latex.strip()
    if not latex:
        return None
    try:
        import latex2mathml.converter as _l2m
    except ImportError as exc:
        raise RuntimeError(
            "OMML conversion requires 'latex2mathml'; "
            "install the build-corpus dependencies (pip install -r requirements.txt)"
        ) from exc
    try:
        mathml = _l2m.convert(latex)
        omml = _mathml_to_omml(mathml)
    except Exception:
        return None
    if not omml or "oMath" not in omml:
        return None
    return omml


def _parse_with_namespaces(omml: str):
    decls = nsdecls("m", "w")
    for tag in ("<m:oMath>", "<m:oMathPara>"):
        if omml.startswith(tag):
            opener = tag[:-1] + f" {decls}>"
            omml = opener + omml[len(tag):]
            break
    else:
        return None
    try:
        return parse_xml(omml)
    except Exception:
        return None


def latex_to_omath(latex: str):
    """Return an inline `<m:oMath>` element for ``latex``, or None on failure."""
    omml = _latex_to_omml_string(latex)
    if omml is None:
        return None
    return _parse_with_namespaces(omml)


def latex_to_omath_para(latex: str):
    """Return a display `<m:oMathPara>` element for ``latex``, or None."""
    omml = _latex_to_omml_string(latex)
    if omml is None:
        return None
    if omml.startswith("<m:oMath>"):
        omml = "<m:oMathPara>" + omml + "</m:oMathPara>"
    return _parse_with_namespaces(omml)
