init: WordFileParse docx→HTML 解析服务(含 Word EQ 域公式修复)

服务:Spring Boot 3.2 + docx4j 8.3.9,POST /api/v3/server_office_file/file/parse
      接受 .docx,返回 HTML(公式 → LaTeX,图片 → RustFS 外链)

公式支持三类来源:
- OMML(Word 2007+ 公式)      → Python omml_mathml2latex
- MathType OLE 对象            → Ruby mathtype2mathml → MathML → LaTeX
- Word EQ 域(老教辅主力)      → EqToLatexUtil(本次新增)

本次改动:
1. 新增 java-svc/.../core/EqToLatexUtil.java:
   移植自 jby backend-services office-core,EQ 语法 → LaTeX
   (\f→\dfrac、\r→\sqrt、\i→\int/\sum/\prod、\s→^{}/_{}、\b→\left\right、
     \a→array、\x→\boxed/\overline、\o\al 堆叠、圈码、化学符号、极限、连等式)
   品牌标记 JBYU* → WPU*,与采集端一致
2. CtoMathFinder:接上 EQ 分支(原为 not-ported 桩),域代码经
   EqToLatexUtil.convert 转 LaTeX 写入 eqLatexMap(WPEQNUM 占位符)
3. WordParseUtil.dealPlaceholder:EQ 的 LaTeX 同样加 \( \) 包裹
   (原先因 WPEQNUM 例外不加包裹,前端/MathJax 不渲染)

修前症状:教辅 Word 里分式/方程组/上下标整段丢失(如 {−1/3, 1} 变成 {-,1})
修后实测:46 份教辅 2908 处 EQ 域全部还原,公式总数 2954 处、零未包裹残留

安全:application.yml(含 RustFS 密钥)不入库,提供 application.yml.example;
      OssService 的 @Value 默认密钥已清空
This commit is contained in:
2026-09-29 09:43:13 +08:00
commit 3271fee5cc
360 changed files with 86082 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
"""
Visitor implementations for MathML elements.
This package contains visitor classes that handle conversion of specific
MathML element types to LaTeX format.
"""
from .visitor_registry import VisitorRegistry
from .mi_visitor import MiVisitor
from .mo_visitor import MoVisitor
from .mn_visitor import MnVisitor
from .mfrac_visitor import MfracVisitor
from .msup_visitor import MsupVisitor
from .msub_visitor import MsubVisitor
from .mrow_visitor import MrowVisitor
from .msqrt_visitor import MsqrtVisitor
from .mroot_visitor import MrootVisitor
from .msubsup_visitor import MsubsupVisitor
from .mover_visitor import MoverVisitor
from .munder_visitor import MunderVisitor
from .munderover_visitor import MunderoverVisitor
from .mfenced_visitor import MfencedVisitor
from .mtext_visitor import MtextVisitor
from .mtable_visitor import MtableVisitor
from .mtr_visitor import MtrVisitor
from .mtd_visitor import MtdVisitor
from .mspace_visitor import MspaceVisitor
from .mstyle_visitor import MstyleVisitor
from .merror_visitor import MerrorVisitor
from .menclose_visitor import MencloseVisitor
from .mmultiscripts_visitor import MmultiscriptsVisitor
from .mpadded_visitor import MpaddedVisitor
from .mphantom_visitor import MphantomVisitor
from .maction_visitor import MactionVisitor
from .ms_visitor import MsVisitor
from .mglyph_visitor import MglyphVisitor
from .mlabeledtr_visitor import MlabeledtrVisitor
from .maligngroup_visitor import MaligngroupVisitor
from .malignmark_visitor import MalignmarkVisitor
__all__ = [
'VisitorRegistry',
'MiVisitor',
'MoVisitor',
'MnVisitor',
'MfracVisitor',
'MsupVisitor',
'MsubVisitor',
'MrowVisitor',
'MsqrtVisitor',
'MrootVisitor',
'MsubsupVisitor',
'MoverVisitor',
'MunderVisitor',
'MunderoverVisitor',
'MfencedVisitor',
'MtextVisitor',
'MtableVisitor',
'MtrVisitor',
'MtdVisitor',
'MspaceVisitor',
'MstyleVisitor',
'MerrorVisitor',
'MencloseVisitor',
'MmultiscriptsVisitor',
'MpaddedVisitor',
'MphantomVisitor',
'MactionVisitor',
'MsVisitor',
'MglyphVisitor',
'MlabeledtrVisitor',
'MaligngroupVisitor',
'MalignmarkVisitor',
]

View File

@@ -0,0 +1,137 @@
"""
Template for standardized _convert_child() method.
This provides a complete, comprehensive _convert_child implementation that all visitors can use.
It handles all MathML element types and provides proper warnings for unknown elements.
"""
from lxml import etree as ET
from ..utils import text_content as _text_content, local_name as _local
def convert_child_standard(child: ET.Element, strategy, visitor_name: str = "unknown") -> str:
"""
Standard _convert_child implementation for all visitors.
Args:
child: The child element to convert
strategy: The conversion strategy to use
visitor_name: Name of the calling visitor (for debug messages)
Returns:
LaTeX string representation of the child element
"""
tag = _local(child.tag)
# Token elements (leaf nodes)
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(strategy).visit(child)
# Layout elements (structural)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(strategy).visit(child)
# Script and limit elements
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(strategy).visit(child)
# Table elements
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(strategy).visit(child)
elif tag == 'mtr':
from .mtr_visitor import MtrVisitor
return MtrVisitor(strategy).visit(child)
elif tag == 'mtd':
from .mtd_visitor import MtdVisitor
return MtdVisitor(strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(strategy).visit(child)
# Style and presentation elements
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(strategy).visit(child)
# Action elements
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(strategy).visit(child)
# Glyph elements
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(strategy).visit(child)
else:
# Fallback for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in {visitor_name}, using text fallback", file=sys.stderr)
return _text_content(child)

View File

@@ -0,0 +1,215 @@
r"""
Visitor for <maction> (action/interactive) MathML elements.
The <maction> element binds actions (like tooltips, highlighting, or toggling)
to mathematical expressions. It typically has an actiontype attribute that
specifies the kind of action.
In LaTeX conversion, maction is treated as a transparent wrapper since LaTeX
is a static typesetting system without interactive capabilities. Only the
selected child (typically the first) is converted.
Common action types:
- toggle: Toggle between alternative expressions
- statusline: Display expression in status line
- tooltip: Show tooltip on hover
- highlight: Highlight on mouseover
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MactionVisitor(Visitor):
r"""
Handles <maction> (action/interactive) MathML elements.
Treats maction as a transparent wrapper, converting only the selected
child (typically the first one) since LaTeX doesn't support interactivity.
Examples:
Toggle action (shows first alternative):
<maction actiontype="toggle">
<mi>x</mi>
<mi>y</mi>
</maction>
→ x
Tooltip action (shows content, ignores tooltip):
<maction actiontype="tooltip">
<mi>a</mi>
<mtext>variable a</mtext>
</maction>
→ a
Highlight action (shows content without highlight):
<maction actiontype="highlight">
<mfrac><mi>a</mi><mi>b</mi></mfrac>
</maction>
→ \frac{a}{b}
Selection attribute (selects specific child):
<maction actiontype="toggle" selection="2">
<mi>x</mi>
<mi>y</mi>
<mi>z</mi>
</maction>
→ y (second child, 1-indexed)
Note:
LaTeX has no interactive capabilities, so:
- Only one child is rendered (the selected one)
- Action types are ignored
- Default: first child is selected
"""
def __init__(self, strategy: Strategy):
"""Initialize the Maction visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <maction> element to LaTeX (renders selected child only).
Args:
node: The <maction> element to convert
Returns:
LaTeX string with selected child converted
Raises:
ValueError: If node is not a <maction> element
"""
if not self.can_handle(node):
raise ValueError(f"MactionVisitor cannot handle element '{node.tag}'")
# Get selection attribute (1-indexed, defaults to 1)
selection = node.get("selection", "1")
try:
selection_idx = int(selection) - 1 # Convert to 0-indexed
except ValueError:
selection_idx = 0 # Default to first child if invalid
# Get children
children = list(node)
if not children:
return ""
# Ensure selection is within bounds
if selection_idx < 0 or selection_idx >= len(children):
selection_idx = 0
# Convert only the selected child
return self._convert_child(children[selection_idx])
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in maction, using text fallback", file=sys.stderr)
return _text_content(child)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'maction'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'maction'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MactionVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,88 @@
r"""
Visitor for <maligngroup> (alignment group marker) MathML elements.
The <maligngroup/> element is an empty element used within table cells (<mtd>)
to mark alignment groups. It's used to control horizontal alignment across
table columns for complex multi-line expressions.
In LaTeX conversion, alignment groups are handled implicitly by the array/matrix
environment's column specifications (e.g., {lll} for left-aligned columns).
Therefore, maligngroup markers are treated as transparent (no output).
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MaligngroupVisitor(Visitor):
r"""
Handles <maligngroup/> (alignment group marker) MathML elements.
Treats maligngroup as an invisible marker since LaTeX handles alignment
through column specifications in array/matrix environments. The element
is completely ignored in conversion.
Examples:
Alignment group in cell:
<mtd>
<maligngroup/>
<mi>x</mi>
</mtd>
→ x (maligngroup ignored)
Multiple alignment groups:
<mtd>
<maligngroup groupalign="left"/>
<mi>a</mi>
<maligngroup groupalign="right"/>
<mi>b</mi>
</mtd>
→ a b (both maligngroup markers ignored)
Group with alignment attribute:
<maligngroup groupalign="center"/>
→ (empty - no output)
Note:
LaTeX doesn't support MathML's alignment group concept. Alignment
is controlled by column specifications in the array/matrix preamble
(e.g., {ccc} for center-aligned, {lll} for left-aligned).
Therefore, maligngroup elements produce no output.
"""
def __init__(self, strategy: Strategy):
"""Initialize the Maligngroup visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <maligngroup/> element (returns empty string).
Args:
node: The <maligngroup/> element to convert
Returns:
Empty string (maligngroup is invisible in LaTeX)
Raises:
ValueError: If node is not a <maligngroup/> element
"""
if not self.can_handle(node):
raise ValueError(f"MaligngroupVisitor cannot handle element '{node.tag}'")
# Maligngroup is an invisible alignment marker - no output
return ""
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'maligngroup'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'maligngroup'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MaligngroupVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,92 @@
r"""
Visitor for <malignmark> (alignment point marker) MathML elements.
The <malignmark/> element is an empty element used within table cells (<mtd>)
to mark specific alignment points for fine-grained horizontal alignment control.
It differs from <maligngroup/> in that it marks a specific point within content
rather than grouping alignment zones.
In LaTeX conversion, alignment points are handled implicitly by the array/matrix
environment's column specifications and & separators. Therefore, malignmark
markers are treated as transparent (no output).
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MalignmarkVisitor(Visitor):
r"""
Handles <malignmark/> (alignment point marker) MathML elements.
Treats malignmark as an invisible marker since LaTeX handles alignment
through column specifications and & separators in array/matrix environments.
The element is completely ignored in conversion.
Examples:
Alignment point in cell:
<mtd>
<mi>x</mi>
<malignmark/>
<mo>=</mo>
<mn>5</mn>
</mtd>
→ x = 5 (malignmark ignored)
Multiple alignment marks:
<mtd>
<malignmark edge="left"/>
<mi>a</mi>
<malignmark edge="right"/>
<mi>b</mi>
</mtd>
→ a b (both malignmark markers ignored)
Mark with edge attribute:
<malignmark edge="center"/>
→ (empty - no output)
Note:
LaTeX doesn't support MathML's alignment mark concept. Alignment
is controlled by column specifications in the array/matrix preamble
(e.g., {ccc} for center-aligned, {lll} for left-aligned) and by
& separators between cells. Therefore, malignmark elements produce
no output.
"""
def __init__(self, strategy: Strategy):
"""Initialize the Malignmark visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <malignmark/> element (returns empty string).
Args:
node: The <malignmark/> element to convert
Returns:
Empty string (malignmark is invisible in LaTeX)
Raises:
ValueError: If node is not a <malignmark/> element
"""
if not self.can_handle(node):
raise ValueError(f"MalignmarkVisitor cannot handle element '{node.tag}'")
# Malignmark is an invisible alignment point marker - no output
return ""
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'malignmark'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'malignmark'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MalignmarkVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,426 @@
r"""
Visitor for <menclose> (box/cancel notation) MathML elements.
The <menclose> element is used to enclose mathematical content with
various notations including boxes, circles, strike-throughs, and partial frames.
This visitor handles notation attribute values like:
- box: Full box around content
- circle: Circle around content
- horizontalstrike, verticalstrike: Strike-through lines
- updiagonalstrike, downdiagonalstrike: Diagonal strike-through
- top, bottom, left, right: Partial box sides
- actuarial, longdiv, radical: Specialized notations
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MencloseVisitor(Visitor):
r"""
Handles <menclose> (box/cancel notation) MathML elements.
Converts various notation attributes to appropriate LaTeX commands.
Examples:
Box notation:
<menclose notation="box"><mi>x</mi></menclose>
→ \boxed{x}
Horizontal strike:
<menclose notation="horizontalstrike"><mi>x</mi></menclose>
→ \cancel{x}
Diagonal strike (up):
<menclose notation="updiagonalstrike"><mi>x</mi></menclose>
→ \cancel{x}
Diagonal strike (down):
<menclose notation="downdiagonalstrike"><mi>x</mi></menclose>
→ \bcancel{x}
Combined diagonal strikes:
<menclose notation="updiagonalstrike downdiagonalstrike"><mi>x</mi></menclose>
→ \xcancel{x}
Top line only:
<menclose notation="top"><mi>x</mi></menclose>
→ \overline{x}
Bottom line only:
<menclose notation="bottom"><mi>x</mi></menclose>
→ \underline{x}
Left delimiter:
<menclose notation="left"><mi>x</mi></menclose>
→ \left| x \right.
Right delimiter:
<menclose notation="right"><mi>x</mi></menclose>
→ \left. x \right|
Circle notation:
<menclose notation="circle"><mi>x</mi></menclose>
→ \circled{x}
Actuarial notation:
<menclose notation="actuarial"><mi>x</mi></menclose>
→ \overline{\underline{x}}
Long division:
<menclose notation="longdiv"><mi>x</mi></menclose>
→ \enclose{longdiv}{x}
Radical notation:
<menclose notation="radical"><mi>x</mi></menclose>
→ \sqrt{x}
"""
def __init__(self, strategy: Strategy):
"""Initialize the Menclose visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <menclose> element to LaTeX with appropriate notation.
Args:
node: The <menclose> element to convert
Returns:
LaTeX string with notation applied to content
Raises:
ValueError: If node is not a <menclose> element
"""
if not self.can_handle(node):
raise ValueError(f"MencloseVisitor cannot handle element '{node.tag}'")
notation = node.get("notation", "box")
# Special case: box containing mfrac with underlined/overlined numerator (like chemical reaction conditions)
# Pattern: <menclose notation="box"><mfrac><munder>text<mo>‾</mo></munder><empty/></mfrac></menclose>
# Remove the box but preserve the fraction structure: \dfrac{\underline{text}}{}
# Also handles: <menclose notation="box"><mrow><mfrac>...</mfrac></mrow></menclose>
mfrac_node = None
if notation == "box" and len(node) == 1:
child = node[0]
child_tag = _local(child.tag)
# Direct mfrac child
if child_tag == "mfrac":
mfrac_node = child
# mfrac wrapped in mrow (Word often does this)
elif child_tag == "mrow" and len(child) == 1 and _local(child[0].tag) == "mfrac":
mfrac_node = child[0]
if mfrac_node is not None and len(mfrac_node) >= 2:
numerator_node = mfrac_node[0]
# Check if numerator contains underlined text (munder with line character)
if self._contains_underlined_text(numerator_node):
# Convert the fraction directly without the box
# This preserves the \dfrac structure with underlined numerator
return self._convert_child(mfrac_node)
content = self._convert_children(node)
# Skip boxing for assignment expressions
if notation == "box" and self._contains_assignment(content):
return content
# Parse notation (could be space-separated multi-value)
notations = notation.split()
# === COMBINED STRIKE NOTATIONS (check these FIRST) ===
# Combined diagonal strikes → \xcancel
if "updiagonalstrike" in notations and "downdiagonalstrike" in notations:
return f"\\xcancel{{{content}}}"
# Combined horizontal and vertical strikes → \cancel
if "horizontalstrike" in notations and "verticalstrike" in notations:
return f"\\cancel{{{content}}}"
# === SIDE-SPECIFIC NOTATIONS FOR PARTIAL BOXES ===
sides = [n for n in notations if n in ["top", "bottom", "left", "right"]]
side_count = len(sides)
# Single side visible
if side_count == 1:
side = sides[0]
if side == "top":
return f"\\overline{{{content}}}"
elif side == "bottom":
return f"\\underline{{{content}}}"
elif side == "left":
return f"\\left| {content} \\right."
elif side == "right":
return f"\\left. {content} \\right|"
# Full box (4 sides or explicit "box" notation)
elif "box" in notations or side_count == 4:
return f"\\boxed{{{content}}}"
# Partial box (2-3 sides)
elif side_count >= 2:
notation_str = " ".join(sides)
return f"\\enclose{{{notation_str}}}{{{content}}}"
# === INDIVIDUAL STRIKE NOTATIONS ===
if notation == "horizontalstrike":
return f"\\cancel{{{content}}}"
if notation == "verticalstrike":
return f"\\bcancel{{{content}}}"
if notation == "updiagonalstrike":
return f"\\cancel{{{content}}}"
if notation == "downdiagonalstrike":
return f"\\bcancel{{{content}}}"
# === OTHER NOTATIONS ===
if notation == "circle":
return f"\\circled{{{content}}}"
if notation == "roundedbox":
return f"\\boxed{{{content}}}"
if notation == "actuarial":
return f"\\overline{{\\underline{{{content}}}}}"
if notation == "longdiv":
return f"\\enclose{{longdiv}}{{{content}}}"
if notation == "radical":
return f"\\sqrt{{{content}}}"
# Default: just return content without notation
return content
def _convert_children(self, node: ET.Element) -> str:
"""
Convert all child elements to LaTeX.
Args:
node: The <menclose> element
Returns:
LaTeX string with all children converted
"""
parts = []
for child in node:
parts.append(self._convert_child(child))
return ''.join(parts).strip()
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in menclose, using text fallback", file=sys.stderr)
return _text_content(child)
def _is_empty_denominator(self, node: ET.Element) -> bool:
"""
Check if denominator node is empty (no content or only whitespace).
Args:
node: MathML denominator element to check
Returns:
True if denominator is empty
"""
if node is None:
return True
tag = _local(node.tag)
# Empty <mo> tag
if tag == "mo":
text = node.text or ""
return text.strip() == ""
# Empty <mrow/> tag
if tag == "mrow":
# No children
if len(node) == 0:
return True
# Only whitespace <mo> child
if len(node) == 1 and _local(node[0].tag) == "mo":
mo_text = node[0].text or ""
return mo_text.strip() == ""
return False
def _contains_assignment(self, content: str) -> bool:
"""
Check if content contains assignment operators.
Assignment expressions should not be boxed.
Note: We only skip boxing for explicit assignment operators like :=
NOT for regular equality = which is often used in boxed theorems.
Args:
content: LaTeX content to check
Returns:
True if content contains assignment operators
"""
# Only specific assignment operators - NOT plain = which appears in boxed theorems
assignment_ops = [":=", ": =", "∶=", "∶ =", "≔", "\\coloneqq"]
return any(op in content for op in assignment_ops)
def _contains_underlined_text(self, node: ET.Element) -> bool:
"""
Check if node contains underlined text (munder with line character)
OR overlined text (mover with line character).
This is used to detect chemical reaction conditions and malformed equals.
Args:
node: MathML element to check
Returns:
True if node contains underlined or overlined text
"""
if node is None:
return False
tag = _local(node.tag)
# Check if this node itself is an underlined element (munder with line)
if tag == "munder" and len(node) > 1:
under_node = node[1]
if _local(under_node.tag) == "mo":
# Get text content
text = under_node.text or ""
if "‾" in text: # Line character
return True
# Check if this node is an overlined element (mover with line)
# This handles the "malformed equal" pattern where users put overline in fraction
if tag == "mover" and len(node) > 1:
over_node = node[1]
if _local(over_node.tag) == "mo":
# Get text content
text = over_node.text or ""
# Check for overline/bar characters
if text.strip() in ["¯", "‾", "―", "−", "⎺", "⎻", "-", "_"]:
return True
# Recursively check children
for child in node:
if self._contains_underlined_text(child):
return True
return False
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'menclose'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'menclose'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MencloseVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,194 @@
r"""
Visitor for <merror> (error markup) MathML elements.
The <merror> element is used to display error messages or invalid markup
within mathematical expressions. It typically indicates a parsing error,
invalid content, or unsupported features.
This visitor wraps error content with visual indication using color or boxes.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MerrorVisitor(Visitor):
r"""
Handles <merror> (error markup) MathML elements.
Wraps error content with visual indication to highlight issues.
Examples:
Simple error:
<merror><mtext>Undefined</mtext></merror>
→ \textcolor{red}{\text{Undefined}}
Error with expression:
<merror><mi>x</mi><mo>/</mo><mn>0</mn></merror>
→ \textcolor{red}{x/0}
Empty error:
<merror></merror>
→ \textcolor{red}{\text{Error}}
Nested content:
<merror><mfrac><mi>a</mi><mn>0</mn></mfrac></merror>
→ \textcolor{red}{\frac{a}{0}}
"""
def __init__(self, strategy: Strategy):
"""Initialize the Merror visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <merror> element to LaTeX with error indication.
Args:
node: The <merror> element to convert
Returns:
LaTeX string with error content wrapped in red color
Raises:
ValueError: If node is not an <merror> element
"""
if not self.can_handle(node):
raise ValueError(f"MerrorVisitor cannot handle element '{node.tag}'")
# Convert all children
content = self._convert_children(node)
# If empty, use default error message
if not content or not content.strip():
content = "\\text{Error}"
# Wrap in red color to indicate error
return f"\\textcolor{{red}}{{{content}}}"
def _convert_children(self, node: ET.Element) -> str:
"""
Convert all child elements to LaTeX.
Args:
node: The <merror> element
Returns:
LaTeX string with all children converted
"""
parts = []
for child in node:
parts.append(self._convert_child(child))
return ''.join(parts).strip()
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in merror, using text fallback", file=sys.stderr)
return _text_content(child)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'merror'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'merror'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MerrorVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,532 @@
r"""
Visitor for <mfenced> (fenced/delimited expressions) MathML elements.
The <mfenced> element represents expressions enclosed by delimiters:
- Auto-scaling parentheses: \left( ... \right)
- Custom brackets: \left[ ... \right]
- Custom braces: \left\{ ... \right\}
- Cases environment: single left brace
- Separators: commas, semicolons between elements
This visitor handles delimiter scaling and separator insertion.
"""
from typing import Optional, List
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, local_name as _local
class MfencedVisitor(Visitor):
r"""
Handles <mfenced> (fenced/delimited) MathML elements.
Converts fenced expressions to LaTeX with auto-scaling delimiters.
Examples:
Default parentheses:
<mfenced><mi>x</mi><mi>y</mi></mfenced> → \left(x,y\right)
Custom brackets:
<mfenced open="[" close="]"><mi>a</mi></mfenced> → \left[a\right]
Custom braces:
<mfenced open="{" close="}"><mi>x</mi></mfenced> → \left\{x\right\}
Angle brackets:
<mfenced open="⟨" close="⟩"><mi>v</mi></mfenced> → \left\langle v\right\rangle
Single left brace (piecewise/cases):
<mfenced open="{" close="">
<mtable>...</mtable>
</mfenced> → \left\{\begin{array}{...}...\end{array}\right.
Custom separator (semicolon):
<mfenced separators=";"><mi>a</mi><mi>b</mi></mfenced> → \left(a;b\right)
Multiple separators:
<mfenced separators="; "><mi>a</mi><mi>b</mi><mi>c</mi></mfenced>
→ \left(a;b c\right)
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mfenced visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mfenced> element to LaTeX.
Args:
node: The <mfenced> element to convert
Returns:
LaTeX string with auto-scaling delimiters
Raises:
ValueError: If node is not an <mfenced> element
"""
if not self.can_handle(node):
raise ValueError(f"MfencedVisitor cannot handle element '{node.tag}'")
# Get opening and closing delimiters (default: parentheses)
open_delim = node.get('open', '(')
close_delim = node.get('close', ')')
# Get separator (default: comma)
separators = node.get('separators', ',')
# Convert children
children = list(node)
if not children:
# Empty mfenced
left = self._get_left_delimiter(open_delim)
right = self._get_right_delimiter(close_delim)
return f"{left}{right}"
# NOTE: We NO LONGER use cases environment for better compatibility
# Instead, we use explicit \left\{ and \right. notation
# (Cases environment is not universally supported)
# Check if this is set notation: { pattern | condition }
if open_delim == '{' and close_delim == '}':
set_notation_result = self._try_convert_as_set_notation(node, children)
if set_notation_result:
return set_notation_result
# Check if this is an interval with nested mfenced for the closing delimiter
# Pattern: <mfenced open="(" close=""> ... <mfenced open="" close="]">
# This should merge into a single interval: \left( ... \right]
if self._is_split_interval(node, children, open_delim, close_delim):
return self._merge_split_interval(node, children, open_delim)
# Convert each child, tracking which are operators
converted_children = []
child_types = []
for child in children:
converted = self._convert_child(child)
converted_children.append(converted)
child_types.append(_local(child.tag))
# Determine if this is a continuous expression or a list
# If there are explicit separator operators (mo elements with separator chars),
# treat as continuous expression (join with spaces)
# Otherwise, use the separators attribute for list formatting
content = self._smart_join(converted_children, child_types, separators)
# Get LaTeX delimiters
left = self._get_left_delimiter(open_delim)
right = self._get_right_delimiter(close_delim)
return f"{left}{content}{right}"
def _try_convert_as_set_notation(self, node: ET.Element, children: List) -> Optional[str]:
r"""
Try to convert as set notation: { pattern | condition }.
Set notation has the pattern:
- { (x, y) | x+y < 2 } → \left\{ (x, y) \mid x+y < 2 \right\}
- { x | x > 0 } → \left\{ x \mid x > 0 \right\}
Detection heuristics:
1. First child (or first few children) form the "pattern" (variable/tuple)
2. There's a nested <mfenced open="|" close="|"> that's really a separator
OR there are exactly 2 distinct groups of children
3. Last children form the "condition"
Args:
node: The outer mfenced element with open="{" close="}"
children: List of child elements
Returns:
LaTeX string if this is set notation, None otherwise
"""
if len(children) < 2:
return None
# Strategy 1: Look for nested mfenced with | delimiters
# If the second or third child is <mfenced open="|" close="|">,
# and there's content after it OR before it, treat as set notation
# BUT keep the |...| as absolute value brackets in the output
for i, child in enumerate(children):
if _local(child.tag) == 'mfenced':
child_open = child.get('open', '(')
child_close = child.get('close', ')')
if child_open == '|' and child_close == '|':
# Found nested | delimiters
# Check if there's content before this (the pattern) or after (condition)
has_content_before = i >= 1
has_content_after = i < len(children) - 1
if has_content_before or has_content_after:
# This looks like set notation!
# Split into: pattern (before), |...|, and rest
pattern_parts = children[:i]
pipe_element = child # The entire <mfenced open="|" close="|"> element
condition_parts = children[i+1:]
# Convert pattern
pattern_latex = ' '.join(self._convert_child(c) for c in pattern_parts if c is not None)
# Convert the |...| element as absolute value (keep the delimiters!)
pipe_latex = self._convert_child(pipe_element)
# Convert remaining condition
condition_latex = ' '.join(self._convert_child(c) for c in condition_parts if c is not None)
# Assemble: { pattern \mid |pipe_content| condition }
parts = []
if pattern_latex:
parts.append(pattern_latex)
parts.append('\\mid')
if pipe_latex:
parts.append(pipe_latex)
if condition_latex:
parts.append(condition_latex)
content = ' '.join(parts)
return f"\\left\\{{{content}\\right\\}}"
# Strategy 2: Check if there are exactly 2 "groups" of elements
# where the first group is a single identifier/tuple, suggesting { x | condition }
# This handles cases like { x x+1<2 } where x and x+1<2 should be separated by \mid
if len(children) >= 2:
first_child_type = _local(children[0].tag)
# If first child is a single identifier (mi) or a tuple (mfenced with parens)
if first_child_type in ['mi', 'mfenced']:
# Check if this looks like a pattern followed by a condition
# Heuristic: if first child is simple (mi or mfenced), and there are more elements,
# and those elements form an expression/condition, insert \mid
# For now, check if we have exactly these patterns:
# 1. First child is <mfenced open="(" close=")"> (tuple)
# 2. OR first child is <mi> (variable)
# 3. Rest form a condition
if first_child_type == 'mi':
# Check if second element is also mi (likely the start of condition)
if len(children) >= 2 and _local(children[1].tag) == 'mi':
# Likely: { x x+1<2 } should be { x | x+1<2 }
pattern_latex = self._convert_child(children[0])
condition_latex = ' '.join(self._convert_child(c) for c in children[1:])
return f"\\left\\{{{pattern_latex} \\mid {condition_latex}\\right\\}}"
elif first_child_type == 'mfenced':
first_child_open = children[0].get('open', '(')
first_child_close = children[0].get('close', ')')
# Check if it's a tuple (parentheses)
if first_child_open == '(' and first_child_close == ')':
# Pattern: { (x,y) ... } - check if ... is a condition
pattern_latex = self._convert_child(children[0])
# Check if remaining children don't start with a comma
# (which would indicate list notation, not set notation)
if len(children) >= 2:
second_type = _local(children[1].tag)
if second_type != 'mo' or (second_type == 'mo' and _text_content(children[1]).strip() != ','):
# Not a list, likely set notation
condition_latex = ' '.join(self._convert_child(c) for c in children[1:])
return f"\\left\\{{{pattern_latex} \\mid {condition_latex}\\right\\}}"
return None
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in mfenced, using text fallback", file=sys.stderr)
return _text_content(child)
def _smart_join(self, items: list, types: list, separators: str) -> str:
"""
Intelligently join items based on whether they form a continuous expression.
Args:
items: Converted LaTeX strings
types: Element types (mi, mo, mn, etc.)
separators: Separator string from mfenced attribute
Returns:
Joined LaTeX string
"""
if len(items) == 0:
return ''
if len(items) == 1:
return items[0]
# Check if this looks like a continuous expression:
# - Contains 'mo' (operator) elements
# - Separators not explicitly set (or default comma)
has_operators = 'mo' in types
if has_operators and separators == ',':
# Continuous expression: join with spaces, no extra separators
# Filter out empty items
return ' '.join(item for item in items if item)
else:
# List of items: use traditional separator insertion
return self._insert_separators(items, separators)
def _insert_separators(self, items: list, separators: str) -> str:
"""Insert separators between items for list-style formatting."""
if len(items) == 0:
return ''
if len(items) == 1:
return items[0]
# Parse separator string
# If multiple characters, each is used for successive separators
# If fewer separators than gaps, last separator is reused
sep_list = list(separators) if separators else [',']
result = [items[0]]
for i, item in enumerate(items[1:], start=0):
# Get appropriate separator (reuse last if not enough)
sep_idx = min(i, len(sep_list) - 1)
separator = sep_list[sep_idx]
result.append(separator)
result.append(item)
return ''.join(result)
def _get_left_delimiter(self, delim: str) -> str:
"""Map opening delimiter to LaTeX \\left command."""
delimiter_map = {
'(': '\\left(',
'[': '\\left[',
'{': '\\left\\{',
'⟨': '\\left\\langle',
'⌈': '\\left\\lceil',
'⌊': '\\left\\lfloor',
'|': '\\left|',
'‖': '\\left\\|',
'': '\\left.', # Invisible left delimiter
}
return delimiter_map.get(delim, f'\\left{delim}')
def _get_right_delimiter(self, delim: str) -> str:
"""Map closing delimiter to LaTeX \\right command."""
delimiter_map = {
')': '\\right)',
']': '\\right]',
'}': '\\right\\}',
'⟩': '\\right\\rangle',
'⌉': '\\right\\rceil',
'⌋': '\\right\\rfloor',
'|': '\\right|',
'‖': '\\right\\|',
'': '\\right.', # Invisible right delimiter
}
return delimiter_map.get(delim, f'\\right{delim}')
def _is_split_interval(self, node: ET.Element, children: list, open_delim: str, close_delim: str) -> bool:
"""
Detect if this is a split interval notation pattern.
Pattern: <mfenced open="(" close=""> ... <mfenced open="" close="]">
This represents an interval like (0, 1/4] split across two mfenced elements.
Also handles case where content is wrapped in mrow:
<mfenced open="(" close=""><mrow> ... <mfenced open="" close="]">...</mfenced></mrow></mfenced>
Returns True if:
1. Outer has opening delimiter but NO closing delimiter (close="")
2. Last child is <mfenced> with NO opening delimiter (open="") but HAS closing delimiter
3. There are other children before the last mfenced (the interval content)
Args:
node: The outer mfenced element
children: List of child elements
open_delim: Opening delimiter of outer mfenced
close_delim: Closing delimiter of outer mfenced
Returns:
True if this is a split interval pattern
"""
# Check pattern: outer has open delim but no close delim
if not (open_delim in '([{' and close_delim == ''):
return False
# Handle case where content is wrapped in a single mrow
actual_children = children
if len(children) == 1 and _local(children[0].tag) == 'mrow':
actual_children = list(children[0])
# Need at least 2 children (content + nested mfenced)
if len(actual_children) < 2:
return False
# Check if last child is mfenced with complementary delimiters
last_child = actual_children[-1]
if _local(last_child.tag) != 'mfenced':
return False
# Last mfenced should have no open delimiter but have close delimiter
last_open = last_child.get('open', '(')
last_close = last_child.get('close', ')')
if last_open == '' and last_close in ')]}\u232A\u2309\u230B': # Closing delimiters
return True
return False
def _merge_split_interval(self, node: ET.Element, children: list, open_delim: str) -> str:
"""
Merge split interval into a single LaTeX interval.
Pattern: <mfenced open="(" close=""> <content> <mfenced open="" close="]">
Output: \\left( <content> \\right]
Also handles case where content is wrapped in mrow:
<mfenced open="(" close=""><mrow> ... <mfenced open="" close="]">...</mfenced></mrow></mfenced>
Args:
node: The outer mfenced element
children: List of child elements
open_delim: Opening delimiter (e.g., '(', '[')
Returns:
LaTeX string for merged interval
"""
# Handle case where content is wrapped in a single mrow
actual_children = children
if len(children) == 1 and _local(children[0].tag) == 'mrow':
actual_children = list(children[0])
# Get the closing delimiter from the last child (which is the nested mfenced)
last_child = actual_children[-1]
close_delim = last_child.get('close', ')')
# Convert all children except the last (which is the nested mfenced)
content_children = actual_children[:-1]
# Also convert the CONTENT of the last mfenced (not the mfenced itself)
last_mfenced_children = list(last_child)
# Combine content from both outer and inner mfenced
all_content_children = content_children + last_mfenced_children
# Convert all content
converted_parts = []
for child in all_content_children:
part = self._convert_child(child)
if part:
converted_parts.append(part)
# Join with spaces (standard interval notation)
content = ' '.join(converted_parts)
# Build result with merged delimiters
left = self._get_left_delimiter(open_delim)
right = self._get_right_delimiter(close_delim)
return f"{left}{content}{right}"
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mfenced'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mfenced'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MfencedVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,608 @@
"""
Visitor for <mfrac> (fraction) MathML elements.
The <mfrac> element represents fractions in MathML:
- Standard fractions: \\frac{numerator}{denominator}
- Display fractions: \\dfrac{numerator}{denominator}
- Bevelled fractions (line fractions): numerator/denominator
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..core.context import ConversionContext
from ..utils import text_content as _text_content, local_name as _local
class MfracVisitor(Visitor):
"""
Handles <mfrac> (fraction) MathML elements.
This visitor converts fractions to LaTeX format using \\frac or \\dfrac
commands depending on context.
Examples:
Standard fraction:
<mfrac><mn>1</mn><mn>2</mn></mfrac> → \\dfrac{1}{2}
Algebraic fraction:
<mfrac><mi>x</mi><mi>y</mi></mfrac> → \\dfrac{x}{y}
Complex fraction:
<mfrac>
<mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow>
<mrow><mi>y</mi><mo>-</mo><mn>1</mn></mrow>
</mfrac>
→ \\dfrac{x + 1}{y - 1}
Bevelled fraction (bevelled="true"):
<mfrac bevelled="true"><mn>1</mn><mn>2</mn></mfrac> → 1/2
No-bar stacked fraction (linethickness="0"):
<mfrac linethickness="0"><mi>n</mi><mi>k</mi></mfrac> → \genfrac{}{}{0pt}{}{n}{k}
Note:
For full compatibility with the original converter, we need to:
1. Detect xlongequal pattern (special chemistry notation)
2. Handle script context (use \\frac in superscripts/subscripts)
3. Handle display style context (use \\dfrac in display mode)
Currently implements basic functionality. Advanced features TODO.
"""
def __init__(self, strategy: Strategy, context: ConversionContext = None):
"""
Initialize the Mfrac visitor.
Args:
strategy: The formatting strategy to use
context: Shared conversion context for state tracking
"""
super().__init__(strategy)
self.context = context
# We'll need a reference to the visitor registry to convert children
# This will be set by the conversion stage
self._visitor_registry = None
def set_visitor_registry(self, registry):
"""
Set the visitor registry for converting child elements.
Args:
registry: The VisitorRegistry instance
Note:
This is called by the conversion stage to enable child conversion.
"""
self._visitor_registry = registry
def visit(self, node: ET.Element) -> str:
"""
Convert <mfrac> element to LaTeX.
Args:
node: The <mfrac> element to convert
Returns:
LaTeX fraction string
Raises:
ValueError: If node is not an <mfrac> element
ValueError: If mfrac has fewer than 2 children
"""
if not self.can_handle(node):
raise ValueError(f"MfracVisitor cannot handle element '{node.tag}'")
# mfrac should have exactly 2 children: numerator and denominator
children = list(node)
if len(children) < 2:
# Malformed mfrac - handle gracefully for MathJax compatibility
# \frac{} or \frac{x}{} should work; \frac without braces won't
if len(children) == 0:
return "\\dfrac{}{}"
elif len(children) == 1:
# Only numerator provided
numerator = self._convert_child(children[0])
return f"\\dfrac{{{numerator}}}{{}}"
# Check for xlongequal pattern with === in denominator FIRST (BUG-009 fix)
# This MUST come before linethickness check because noBar fractions
# with === in denominator should produce \xlongequal, not bevelled fraction
# Pattern: <mfrac linethickness="0"><text>高温</text><mo>===</mo></mfrac>
if self._is_xlongequal_with_equals_denominator(children):
# Extract text from numerator (goes in braces)
numerator = self._convert_child(children[0])
return f"\\xlongequal[\\quad]{{{numerator}}}"
# Check for xlongequal pattern WITH both upper and lower text
# Pattern: <mfrac><munder><text1><mo>‾</mo></munder><text2></mfrac>
# This represents \xlongequal[text2]{text1} (text above and below equals)
if self._is_xlongequal_with_both_texts(children):
# Extract text from numerator (goes in braces) and denominator (goes in brackets)
upper_text = self._extract_reaction_condition_text(children[0])
lower_text = self._convert_child(children[1])
# Strip any \mathrm{} or \text{} wrappers from both texts
# Note: The post-processing will handle space removal
lower_clean = self._strip_latex_wrappers(lower_text)
upper_clean = self._strip_latex_wrappers(upper_text)
return f"\\xlongequal[{lower_clean}]{{{upper_clean}}}"
# Check for xlongequal pattern (chemistry reaction condition)
# Pattern: <mfrac><munder><text><mo>‾</mo></munder><empty/></mfrac>
# This is Word's way of representing reaction conditions like "点燃" above an arrow
if self._is_chemical_reaction_condition(children):
# Extract the text from the underlined numerator
condition_text = self._extract_reaction_condition_text(children[0])
return f"\\xlongequal[\\quad]{{{condition_text}}}"
# Check for bevelled fraction (bevelled="true" attribute triggers inline fraction)
# Note: bevelled="true" means inline fraction with slash (a/b)
bevelled = node.get('bevelled')
if bevelled == 'true':
return self._convert_bevelled_fraction(children)
# Check for no-bar stacking (linethickness="0")
# Note: linethickness="0" means no-bar stacking (like binomial), NOT bevelled
# This is for binomial-style stacking without a fraction bar
linethickness = node.get('linethickness')
if linethickness == '0':
return self._convert_nobar_stack(children)
# Convert numerator and denominator
numerator = self._convert_child(children[0])
denominator = self._convert_child(children[1])
# Context-aware fraction command selection
# Priority: Script context > display style context > default
if self.context and self.context.in_script_context():
# Use \frac in script contexts (superscript/subscript) for proper sizing
frac_command = "\\frac"
elif self.context and self.context.display_style_context:
# Use \dfrac if in display style context (from mstyle)
frac_command = "\\dfrac"
else:
# Default to \dfrac for main equations
frac_command = "\\dfrac"
# Wrap in braces and return
return f"{frac_command}{self._brace(numerator)}{self._brace(denominator)}"
def _convert_bevelled_fraction(self, children: list) -> str:
"""
Convert bevelled (inline) fraction.
Bevelled fractions display as numerator/denominator in a single line.
This is triggered by bevelled="true" attribute.
Note: This is different from linethickness="0" which produces stacking
without a fraction bar (like binomial coefficients).
Args:
children: List of child elements [numerator, denominator]
Returns:
LaTeX string with / separator
Examples:
1/2, x/y, (x+1)/(y-1)
"""
numerator = self._convert_child(children[0])
denominator = self._convert_child(children[1])
# For bevelled fractions, use simple / separator
# May need parentheses for complex expressions
# TODO: Add intelligent parenthesization
return f"{numerator}/{denominator}"
def _convert_nobar_stack(self, children: list) -> str:
"""
Convert no-bar stacked fraction (linethickness="0").
This is for stacking like binomial coefficients where numerator
is above denominator but without a fraction bar.
Args:
children: List of child elements [numerator, denominator]
Returns:
LaTeX string for stacked notation
Note:
We use \\begin{array}{c}...\\end{array} for proper stacking.
This is more user-friendly and widely understood than \\genfrac syntax.
Examples:
<mfrac linethickness="0"><mi>n</mi><mi>k</mi></mfrac>
→ \\begin{array}{c} n \\\\ k \\end{array}
"""
numerator = self._convert_child(children[0])
denominator = self._convert_child(children[1])
# Use array environment for proper no-bar stacking
# Format: \begin{array}{c} top \\ bottom \end{array}
# Center-aligned single column with line break between elements
# Note: No space after \\ to avoid it being converted to \~
return f"\\begin{{array}}{{c}} {numerator} \\\\\\\\{denominator} \\end{{array}}"
def _convert_child(self, child: ET.Element) -> str:
"""
Convert a child element to LaTeX.
Args:
child: The child element to convert
Returns:
LaTeX string
Note:
For now, we directly instantiate the appropriate visitor.
In the future, this should use the visitor registry.
"""
from ..utils import local_name as _local
tag = _local(child.tag)
if tag == 'mi':
from .mi_visitor import MiVisitor
visitor = MiVisitor(self.strategy)
return visitor.visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
visitor = MoVisitor(self.strategy)
return visitor.visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
visitor = MnVisitor(self.strategy)
return visitor.visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback for unknown elements: return text content
import sys
print(f"[WARNING] Unknown element <{tag}> in mfrac, using text fallback", file=sys.stderr)
return _text_content(child)
def _brace(self, content: str) -> str:
"""
Wrap content in braces if needed.
Args:
content: The content to wrap
Returns:
Content wrapped in braces
Note:
Always wraps in braces for fractions to ensure proper LaTeX parsing.
"""
return f"{{{content}}}"
def _is_chemical_reaction_condition(self, children: list) -> bool:
"""
Check if this fraction represents a chemical reaction condition.
Pattern: numerator is munder with underline OR mover with overline,
denominator is empty/whitespace
Args:
children: [numerator, denominator] child elements
Returns:
True if this is a chemical reaction condition pattern
"""
if len(children) != 2:
return False
numerator = children[0]
denominator = children[1]
# Unwrap mrow if present (Word often wraps in mrow)
numerator_tag = _local(numerator.tag)
if numerator_tag == "mrow" and len(numerator) == 1:
numerator = numerator[0]
numerator_tag = _local(numerator.tag)
# Check if numerator is munder with underline OR mover with overline
if numerator_tag in ("munder", "mover") and len(numerator) >= 2:
line_node = numerator[1] # Second child is the line (under or over)
if _local(line_node.tag) == "mo":
text = line_node.text or ""
# Check for line character (same character used for both over and under)
if "‾" in text or text.strip() in ["¯", "―", "−", "_", "-"]:
# Check if denominator is empty
denom_tag = _local(denominator.tag)
# Empty <mo> tag
if denom_tag == "mo":
denom_text = denominator.text or ""
if denom_text.strip() == "":
return True
# Empty <mrow/> tag
elif denom_tag == "mrow":
# Check if mrow has no children or only whitespace
if len(denominator) == 0:
return True
# Check if mrow has only whitespace text nodes
if len(denominator) == 1 and _local(denominator[0].tag) == "mo":
mo_text = denominator[0].text or ""
if mo_text.strip() == "":
return True
return False
def _extract_reaction_condition_text(self, numerator_node: ET.Element) -> str:
"""
Extract text from underlined/overlined numerator for reaction condition.
Args:
numerator_node: The munder or mover element (or mrow wrapping it) containing the condition text
Returns:
LaTeX content with user-specified formatting preserved (e.g., \\mathbf{})
but subject-determined formatting removed (e.g., \\mathrm{})
"""
numerator_tag = _local(numerator_node.tag)
# Unwrap mrow if present
if numerator_tag == "mrow" and len(numerator_node) == 1:
numerator_node = numerator_node[0]
numerator_tag = _local(numerator_node.tag)
# Handle both munder and mover
if numerator_tag not in ("munder", "mover"):
return ""
# The first child contains the actual content (same for both munder and mover)
if len(numerator_node) > 0:
content_node = numerator_node[0]
# Convert to LaTeX, preserving user-specified formatting (like \mathbf{})
content = self._convert_child(content_node)
# Only strip subject-determined formatting (\mathrm{} for chemistry mode)
# Preserve user-specified formatting like \mathbf{}, \textbf{}, \textit{}, etc.
if content.startswith("\\mathrm{") and content.endswith("}"):
content = content[8:-1] # Remove \mathrm{ and }
# Strip \text{} wrapper if present
elif content.startswith("\\text{") and content.endswith("}"):
content = content[6:-1] # Remove \text{ and }
# Clean up extra spaces
return content.strip()
return ""
def _is_xlongequal_with_equals_denominator(self, children: list) -> bool:
"""
Check if this fraction represents xlongequal with === in denominator.
Pattern: numerator has content, denominator contains only consecutive equals signs
This pattern is used in chemistry for reaction arrows with conditions above.
Args:
children: [numerator, denominator] child elements
Returns:
True if denominator contains only === (or ==, ====, etc.)
Examples:
<mfrac>
<mi>高温</mi>
<mo>===</mo>
</mfrac>
→ \\xlongequal[\\quad]{高温}
<mfrac>
<mtext>heat</mtext>
<mo>==</mo>
</mfrac>
→ \\xlongequal[\\quad]{heat}
"""
if len(children) != 2:
return False
numerator = children[0]
denominator = children[1]
# Get text content from denominator
denom_text = _text_content(denominator).strip()
# Check if denominator contains only equals signs (2 or more)
# Pattern: ==, ===, ====, = = =, etc. (with or without spaces)
import re
# Remove all spaces to check if it's only equals signs
denom_text_no_spaces = denom_text.replace(' ', '')
if denom_text_no_spaces and re.match(r'^=+$', denom_text_no_spaces) and len(denom_text_no_spaces) >= 2:
# Check if numerator has actual content
num_text = _text_content(numerator).strip()
if num_text and num_text != "":
return True
return False
def _is_xlongequal_with_both_texts(self, children: list) -> bool:
"""
Check if this fraction represents xlongequal with both upper and lower text.
Pattern: numerator is munder with underline OR mover with overline,
denominator has actual content
Args:
children: [numerator, denominator] child elements
Returns:
True if this is xlongequal with both texts pattern
Examples:
<mfrac>
<munder><mi>abc</mi><mo>‾</mo></munder>
<mi>defg</mi>
</mfrac>
→ \\xlongequal[defg]{abc}
<mfrac>
<mover><mi>abc</mi><mo>‾</mo></mover>
<mi>defg</mi>
</mfrac>
→ \\xlongequal[defg]{abc}
<mfrac>
<mrow><mover><mrow><mi>abc</mi></mrow><mo>‾</mo></mover></mrow>
<mrow><mi>defg</mi></mrow>
</mfrac>
→ \\xlongequal[defg]{abc}
"""
if len(children) != 2:
return False
numerator = children[0]
denominator = children[1]
# Unwrap mrow if present (Word often wraps in mrow)
numerator_tag = _local(numerator.tag)
if numerator_tag == "mrow" and len(numerator) == 1:
numerator = numerator[0]
numerator_tag = _local(numerator.tag)
# Check if numerator is munder with underline OR mover with overline
if numerator_tag in ("munder", "mover") and len(numerator) >= 2:
line_node = numerator[1] # Second child is the line (under or over)
if _local(line_node.tag) == "mo":
text = line_node.text or ""
# Check for line character (same character used for both over and under)
if "‾" in text or text.strip() in ["¯", "―", "−", "_", "-"]:
# Check if denominator is NOT empty (has actual content)
denom_text = _text_content(denominator).strip()
if denom_text and denom_text != "":
return True
return False
def _strip_latex_wrappers(self, latex: str) -> str:
"""
Strip LaTeX wrapper commands like \\mathrm{}, \\text{}, etc.
Args:
latex: LaTeX string that might have wrappers
Returns:
Clean text without wrappers
Examples:
\\mathrm{abc} → abc
\\text{hello} → hello
\\mathrm{d e f g} → d e f g (spaces preserved)
"""
latex = latex.strip()
# Strip \mathrm{} wrapper
if latex.startswith("\\mathrm{") and latex.endswith("}"):
latex = latex[8:-1]
# Strip \text{} wrapper
if latex.startswith("\\text{") and latex.endswith("}"):
latex = latex[6:-1]
# Strip \mathbf{} wrapper
if latex.startswith("\\mathbf{") and latex.endswith("}"):
latex = latex[8:-1]
return latex.strip()
def can_handle(self, node: ET.Element) -> bool:
"""
Check if this visitor can handle the given node.
Args:
node: The MathML element to check
Returns:
True if node is an <mfrac> element, False otherwise
"""
return _local(node.tag) == 'mfrac'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mfrac'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MfracVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,105 @@
r"""
Visitor for <mglyph> (custom glyph) MathML elements.
The <mglyph> element is used to display non-standard symbols or images
within mathematical expressions. It references external images via the
'src' attribute or font glyphs via 'fontfamily' and 'index' attributes.
In LaTeX conversion, mglyph is converted to text representations or
fallback Unicode characters when available. For image-based glyphs,
alt text is used if provided.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local, text_content as _text_content
class MglyphVisitor(Visitor):
r"""
Handles <mglyph> (custom glyph) MathML elements.
Converts custom glyphs to LaTeX text or fallback Unicode. Since LaTeX
doesn't support arbitrary image references, we use alt text or text
content as fallback.
Examples:
Glyph with alt text:
<mglyph src="myGlyph.png" alt="custom symbol"/>
→ \text{custom symbol}
Glyph with font reference:
<mglyph fontfamily="MyFont" index="42" alt="α"/>
→ \text{α}
Glyph without alt (uses src filename):
<mglyph src="alpha.png"/>
→ \text{alpha}
Glyph with width/height (ignored in LaTeX):
<mglyph src="beta.png" width="2em" height="2em" alt="β"/>
→ \text{β}
Note:
LaTeX doesn't support external image references in math mode,
so we convert to text representations using alt attribute or
derive from src filename.
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mglyph visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mglyph> element to LaTeX text.
Args:
node: The <mglyph> element to convert
Returns:
LaTeX string with \text{...} fallback
Raises:
ValueError: If node is not a <mglyph> element
"""
if not self.can_handle(node):
raise ValueError(f"MglyphVisitor cannot handle element '{node.tag}'")
# Try to get alt text (primary fallback)
alt_text = node.get("alt", "")
if alt_text:
# Use alt text if provided
return f"\\text{{{alt_text}}}"
# Fallback: try to derive from src attribute
src = node.get("src", "")
if src:
# Extract filename without extension as fallback
import os
filename = os.path.splitext(os.path.basename(src))[0]
if filename:
return f"\\text{{{filename}}}"
# Last resort: fontfamily reference or empty
fontfamily = node.get("fontfamily", "")
if fontfamily:
# Can't do much with font references in LaTeX
return f"\\text{{{fontfamily}}}"
# Completely empty - render as empty
return ""
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mglyph'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mglyph'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MglyphVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,772 @@
"""
Visitor for <mi> (identifier) MathML elements.
The <mi> element represents identifiers in MathML, including:
- Mathematical functions (sin, cos, log)
- Variables (x, y, z)
- Constants (e, π)
- Multi-letter identifiers (velocity, pressure)
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, convert_spaces_to_latex, escape_latex_special_chars
from ..data import CHEMICAL_ELEMENTS, COMMON_UNITS, UNICODE_TO_LATEX, UNICODE_PASSTHROUGH
from ..converters.post_processor import PostProcessor
class MiVisitor(Visitor):
"""
Handles <mi> (identifier) MathML elements.
This is the most common MathML element type (~40% of all elements).
It converts identifiers to LaTeX format using subject-specific strategies.
Examples:
Math mode:
<mi>sin</mi> → \\sin
<mi>x</mi> → x
<mi>α</mi> → \\alpha
Chemistry mode:
<mi>Fe</mi> → \\mathrm{Fe}
<mi>H</mi> → \\mathrm{H}
"""
def __init__(self, strategy: Strategy, context=None):
"""
Initialize the Mi visitor.
Args:
strategy: The formatting strategy to use
context: Optional ConversionContext for script context tracking
"""
super().__init__(strategy)
self._converter_context = None
self.context = context
def set_converter_context(self, converter):
"""Set converter context for color suppression checking."""
self._converter_context = converter
def visit(self, node: ET.Element) -> str:
"""
Convert <mi> element to LaTeX.
Args:
node: The <mi> element to convert
Returns:
LaTeX string representation
Raises:
ValueError: If node is not an <mi> element
"""
if not self.can_handle(node):
raise ValueError(f"MiVisitor cannot handle element '{node.tag}'")
# Get text content
content = _text_content(node)
# Handle empty content
if not content:
return ""
# Escape LaTeX special characters before any other processing
content = escape_latex_special_chars(content)
# CRITICAL: Check for mathvariant BEFORE reclassifying as operator
# This preserves explicit mathvariant="normal" for units like "N/m"
mathvariant = node.get('mathvariant')
# PHASE 1 FIX: Detect delimiters and operators misclassified as identifiers
# This handles malformed MathML from OMML2MML.xsl where operators are tagged as <mi>
# instead of <mo>. Common issue with Word documents.
# EXCEPTION: If mathvariant="normal" is present, handle it specially to preserve mathrm wrapping
if self._contains_delimiter_or_operator(content):
if mathvariant == 'normal':
# Special case: mathvariant="normal" with operators (e.g., "N/m" units)
# Split by operators but wrap identifier parts in \mathrm{}
result = self._reclassify_with_mathrm(content, node)
return result
else:
# Normal reclassification without mathrm wrapping
result = self._reclassify_as_operator(content, node)
return result
# Handle spaces (NBSP → ~, space → \ )
# Must be done before function recognition
if '\xa0' in content or ' ' in content:
converted = convert_spaces_to_latex(content)
# If content was ONLY spaces, return directly
if not content.strip():
return self._wrap_with_color(converted, node)
# Update content with converted spaces
content = converted
# Check for explicit mathvariant (overrides strategy logic)
# Note: mathvariant already extracted earlier (line 78) for operator reclassification check
if mathvariant:
return self._handle_mathvariant(content, mathvariant, node)
# Check for explicit LaTeX font command
latex_font_cmd = node.get('latex-font-cmd')
if latex_font_cmd:
return self._wrap_with_color(f"\\{latex_font_cmd}{{{content}}}", node)
# Delegate to strategy for subject-specific formatting
result = self.strategy.format_identifier(content, node)
return self._wrap_with_color(result, node)
def _handle_mathvariant(self, content: str, mathvariant: str, node: ET.Element) -> str:
"""
Handle explicit mathvariant attributes.
Args:
content: The text content
mathvariant: The mathvariant attribute value
node: The element node
Returns:
LaTeX string with appropriate font command
"""
# CRITICAL: Strip whitespace and check for mathematical functions FIRST
# Original converter checks functions before applying mathvariant wrapping
from ..data import MATH_FUNCTIONS
clean_content = content.strip()
# CRITICAL FIX: When mathvariant="normal" is present with mixed-case content,
# do NOT treat it as a simple math function. Examples:
# - "Asin" → should be split to "\mathrm{A}\sin" (not "\Asin")
# - "sin" → should still be "\sin" (lowercase function names are always functions)
# - "max" → should still be "\max" (lowercase function names are always functions)
# Check if content is mixed case (has both uppercase and lowercase)
has_upper = any(c.isupper() for c in clean_content)
has_lower = any(c.islower() for c in clean_content)
is_mixed_case = has_upper and has_lower
# Skip function recognition ONLY for mixed-case content with mathvariant="normal"
# This allows "Asin" to be handled later by _split_uppercase_and_function
if mathvariant == 'normal' and len(clean_content) > 1 and is_mixed_case:
# Mixed-case content like "Asin" should NOT be treated as function
# Fall through to the mathvariant handling logic below
pass
else:
# Enhanced function recognition: detect functions with inline subscripts
# Pattern to match function + subscript (like "limn→∞" or "max0≤x≤1")
import re
for func in ['lim', 'limsup', 'liminf', 'sup', 'inf', 'max', 'min']:
if clean_content.startswith(func):
remaining = clean_content[len(func):]
if remaining:
# We found a function with trailing content - this should be a subscript
# Use _convert_unicode_in_content to handle all Unicode symbols
remaining_converted = self._convert_unicode_in_content(remaining)
return f"\\{func}\\limits_{{{remaining_converted}}}"
else:
return f"\\{func}"
# If it's a mathematical function, ignore mathvariant and use function command
# This handles lowercase function names like "sin", "cos", "max", "lim"
if clean_content.lower() in MATH_FUNCTIONS:
return f"\\{clean_content.lower()}"
# For other functions with trailing content
for func in MATH_FUNCTIONS:
if clean_content.startswith(func) and len(clean_content) > len(func):
# Make sure it's not just a substring (e.g., 'limits' shouldn't match 'lim')
if not clean_content[len(func)].isalpha():
remaining = clean_content[len(func):]
# CRITICAL FIX: Convert Unicode symbols in the remaining content
# Example: "sin∠" → "sin" + "∠" → "\sin" + "\angle" → "\sin \angle"
# Without this fix, "sin∠" becomes "\sin∠" (unconverted ∠)
remaining_converted = self._convert_unicode_in_content(remaining)
return f"\\{func} {remaining_converted}".strip()
# Special case: Im, Re with mathvariant="normal" → \Im, \Re
if mathvariant == 'normal' and content in ['Im', 'Re']:
return f"\\{content}"
# CRITICAL FIX: For multi-character content with mathvariant, apply same logic as original:
# 1. Convert each character individually (Unicode → LaTeX)
# 2. Apply mathvariant ONLY to non-LaTeX-command characters
# 3. Join with spaces
#
# This handles cases like:
# <mi mathvariant="double-struck">ℵRZCQN</mi> → \aleph \mathbb{R} \mathbb{Z} \mathbb{C} \mathbb{Q} \mathbb{N}
# <mi mathvariant="script">ℏl</mi> → \hbar \ell
if len(content) > 1 and any(char in UNICODE_TO_LATEX or char in UNICODE_PASSTHROUGH or char.isalpha() for char in content):
# Step 1: Convert each character individually (UNICODE_PASSTHROUGH first)
converted_parts = []
for char in content:
if char in UNICODE_PASSTHROUGH:
# Keep passthrough symbols as Unicode
converted_parts.append(char)
elif char in UNICODE_TO_LATEX:
converted_parts.append(UNICODE_TO_LATEX[char])
elif char.isspace():
converted_parts.append(' ')
else:
converted_parts.append(char)
# Step 2: Apply mathvariant wrapping selectively
if mathvariant == 'double-struck':
result_parts = []
for part in converted_parts:
if part.startswith('\\'):
# Already a LaTeX command (like \aleph) - use as-is
result_parts.append(part)
elif part.strip(): # Skip empty strings and spaces
# Regular letter - wrap in \mathbb{}
result_parts.append(f"\\mathbb{{{part}}}")
# Note: spaces are intentionally skipped - we join with spaces anyway
return self._wrap_with_color(' '.join(result_parts), node)
elif mathvariant == 'script':
result_parts = []
for part in converted_parts:
if part.startswith('\\'):
# Already a LaTeX command (like \hbar) - use as-is
result_parts.append(part)
elif part == 'l':
# Special case: script 'l' is \ell, not \mathscr{l}
result_parts.append('\\ell')
elif part.strip(): # Skip empty strings and spaces
# Regular letter - wrap in \mathscr{}
result_parts.append(f"\\mathscr{{{part}}}")
# Note: spaces are intentionally skipped - we join with spaces anyway
return self._wrap_with_color(' '.join(result_parts), node)
elif mathvariant in ['fraktur', 'sans-serif', 'monospace', 'bold', 'bold-italic']:
# For other mathvariants, apply to all non-LaTeX-command parts
result_parts = []
font_cmd_map = {
'fraktur': 'mathfrak',
'sans-serif': 'mathsf',
'monospace': 'mathtt',
'bold': 'mathbf',
'bold-italic': 'boldsymbol'
}
font_cmd = font_cmd_map[mathvariant]
for part in converted_parts:
if part.startswith('\\'):
result_parts.append(part)
elif part.strip(): # Skip empty strings and spaces
result_parts.append(f"\\{font_cmd}{{{part}}}")
# Note: spaces are intentionally skipped - we join with spaces anyway
return self._wrap_with_color(' '.join(result_parts), node)
elif mathvariant == 'normal':
# Multi-character content with mathvariant="normal" - always wrap in \mathrm{}
# Combine the converted parts with intelligent spacing
# When a LaTeX command (like \Delta) is followed by a letter, add a space
# Otherwise, LaTeX interprets \DeltaH as a single undefined command
combined = self._join_with_command_spacing(converted_parts)
# DISABLED: "Smart" logic that ignored mathvariant="normal" for simple variables
# This was causing issues where explicit mathvariant="normal" was not respected
# Keeping _is_simple_math_variable for potential future use
# if self.strategy.name == 'math':
# is_simple = self._is_simple_math_variable(combined)
# if is_simple:
# return self._wrap_with_color(combined, node)
# CRITICAL FIX: Check if content starts with uppercase letter(s) followed by a known function
# Example: "Asin" → "\mathrm{A}\sin", "ABcos" → "\mathrm{AB}\cos"
# This handles the case where Word's "Text Mode" is used for mixed content
result = self._split_uppercase_and_function(content)
if result:
return self._wrap_with_color(result, node)
# Always respect mathvariant="normal" - wrap in \mathrm{}
return self._wrap_with_color(f"\\mathrm{{{combined}}}", node)
else:
# No mathvariant or unknown - just join the converted parts
return self._wrap_with_color(' '.join(converted_parts), node)
# Fallback: single character or simple cases
# IMPORTANT: Convert Unicode symbols BEFORE wrapping in font commands
# e.g., ℵ → \aleph, ℏ → \hbar
content_converted = self._convert_unicode_in_content(content)
# Map mathvariant values to LaTeX font commands
if mathvariant == 'double-struck':
# If already converted to LaTeX command, use as-is
if content in UNICODE_TO_LATEX:
return content_converted
return f"\\mathbb{{{content_converted}}}"
elif mathvariant == 'script':
# Special case: script lowercase 'l' → \ell
if content == 'l':
return "\\ell"
# If already converted to LaTeX command, use as-is
if content in UNICODE_TO_LATEX:
return content_converted
return f"\\mathscr{{{content_converted}}}"
elif mathvariant == 'fraktur':
return f"\\mathfrak{{{content_converted}}}"
elif mathvariant == 'sans-serif':
return f"\\mathsf{{{content_converted}}}"
elif mathvariant == 'monospace':
return f"\\mathtt{{{content_converted}}}"
elif mathvariant == 'bold':
return f"\\mathbf{{{content_converted}}}"
elif mathvariant == 'bold-italic':
# Bold italic - use \boldsymbol for math-mode bold italic
# Alternative: \bm from bm package, but \boldsymbol is standard
return f"\\boldsymbol{{{content_converted}}}"
elif mathvariant == 'italic':
# Explicitly italic - return as-is (LaTeX default)
return content_converted
elif mathvariant == 'normal':
# INTELLIGENT HANDLING of mathvariant="normal" based on subject mode and content
# This handles the <m:nor/> tag from Word's "Text Mode" button
# Special cases first: Im, Re → \Im, \Re
if content in ['Im', 'Re']:
return f"\\{content}"
# CRITICAL: If content was a Unicode symbol (like π → \pi), don't wrap in \mathrm{}
# The _convert_unicode_in_content() already converted it to LaTeX command
if content.strip() in UNICODE_TO_LATEX:
return content_converted # Already converted to LaTeX (e.g., \pi)
# DISABLED: "Smart" logic that ignored mathvariant="normal" for simple variables
# This was causing issues where explicit mathvariant="normal" was not respected
# Keeping _is_simple_math_variable for potential future use
# if self.strategy.name == 'math':
# if self._is_simple_math_variable(content_converted):
# return content_converted
# Always respect mathvariant="normal" - wrap in \mathrm{}
return f"\\mathrm{{{content_converted}}}"
else:
# Unknown mathvariant - return as-is
return content_converted
def _is_simple_math_variable(self, content: str) -> bool:
"""
Check if content is a simple mathematical variable that should be italic.
Simple math variables include:
- Single lowercase letters: x, y, z, a, b, etc.
- Single uppercase letters: X, Y, Z, A, B, etc. (but NOT chemical elements)
- Greek letters (already converted to LaTeX commands like \\alpha)
- Common mathematical notations like single letters with primes: x', y''
This is used to intelligently handle mathvariant="normal" in math mode:
- Simple variables should remain italic (ignore mathvariant="normal")
- Complex identifiers should be upright (respect mathvariant="normal")
Args:
content: The identifier content to check
Returns:
True if content is a simple math variable, False otherwise
"""
if not content:
return False
# Strip whitespace and LaTeX commands for checking
clean_content = content.strip()
# If it starts with a LaTeX command (like \alpha, \beta), it's a simple variable
if clean_content.startswith('\\'):
return True
# CRITICAL: If content contains mathematical operators, it's an expression (not an identifier)
# Examples: "x+2", "a-b", "n*m" should NOT be wrapped in \mathrm{}
# These are parsed as single <mi> elements by XSL but should remain italic
math_operators = {'+', '-', '*', '/', '=', '<', '>', '≤', '≥', '≠', '±'}
if any(op in clean_content for op in math_operators):
return True # Treat expressions as "simple" (don't wrap in \mathrm{})
# Single letter (a-z, A-Z) - simple variable
if len(clean_content) == 1 and clean_content.isalpha():
# BUT: exclude known chemical elements in non-math contexts
if self.strategy.name == 'chemistry' and clean_content in CHEMICAL_ELEMENTS:
return False
return True
# Single letter with primes (x', y'', etc.)
if len(clean_content) >= 2:
base = clean_content.rstrip("'")
primes = clean_content[len(base):]
if len(base) == 1 and base.isalpha() and all(c == "'" for c in primes):
return True
# Multi-character content that's NOT a math function is considered complex
# Examples: "velocity", "pressure", "Mn", "Fe" - should be upright if mathvariant="normal"
return False
def _convert_unicode_in_content(self, content: str) -> str:
"""
Convert Unicode symbols within identifier content to LaTeX equivalents.
For multi-character identifiers with Unicode symbols mixed in,
we need to convert each Unicode character individually.
Args:
content: Identifier text that may contain Unicode symbols
Returns:
Content with Unicode symbols converted to LaTeX, preserving spaces
"""
if not content:
return content
# Check if any Unicode symbols are present (passthrough or convertible)
has_unicode = any(char in UNICODE_TO_LATEX for char in content)
has_passthrough = any(char in UNICODE_PASSTHROUGH for char in content)
if not has_unicode and not has_passthrough:
return content
# Convert character by character, preserving spaces (PASSTHROUGH first)
result = []
for char in content:
if char in UNICODE_PASSTHROUGH:
# Keep passthrough symbols as Unicode
result.append(char)
elif char in UNICODE_TO_LATEX:
# Add space before/after LaTeX command if needed
latex_cmd = UNICODE_TO_LATEX[char]
result.append(latex_cmd)
else:
result.append(char)
# Join parts with space separator for multi-symbol content
# e.g., "ℵR" → "\aleph R" not "\alephR"
joined = ' '.join(result)
# Clean up multiple spaces
import re
joined = re.sub(r'\s+', ' ', joined)
return joined
def _join_with_command_spacing(self, parts: list) -> str:
"""
Join converted parts with intelligent spacing for LaTeX commands.
When a LaTeX command (like \\Delta) is followed by a letter, a space must
be inserted. Otherwise, LaTeX interprets \\DeltaH as a single undefined
command instead of \\Delta followed by H.
Args:
parts: List of converted parts (may contain LaTeX commands and letters)
Returns:
Joined string with appropriate spacing between commands and letters
Examples:
['\\\\Delta', 'H'] → '\\\\Delta H' (space needed)
['a', 'b', 'c'] → 'abc' (no space needed)
['\\\\alpha', '\\\\beta'] → '\\\\alpha\\\\beta' (commands can be adjacent)
['\\\\Delta', '1'] → '\\\\Delta 1' (space before number)
"""
if not parts:
return ''
result = []
for i, part in enumerate(parts):
result.append(part)
# Check if we need to add a space after this part
if i < len(parts) - 1:
next_part = parts[i + 1]
# If current part is a LaTeX command (starts with \)
# and next part starts with a letter or digit, add space
if part.startswith('\\') and next_part and (next_part[0].isalpha() or next_part[0].isdigit()):
result.append(' ')
return ''.join(result)
def _wrap_with_color(self, latex: str, node: ET.Element) -> str:
"""
Wrap LaTeX with color command if node has color attribute.
Args:
latex: The LaTeX string to wrap
node: The element node
Returns:
LaTeX string, optionally wrapped with color command
"""
# Use the centralized color wrapping utility with suppression support
from ..utils.color_helpers import wrap_element_with_color
# Get converter context from multiple sources (for backward compatibility):
# 1. Direct visitor context (set by legacy_adapter)
# 2. Strategy context (shared across all visitors, including child visitors)
context = getattr(self, '_converter_context', None)
if context is None and hasattr(self.strategy, '_converter_context'):
context = self.strategy._converter_context
return wrap_element_with_color(latex, node, context)
def _contains_delimiter_or_operator(self, content: str) -> bool:
"""
Check if content contains delimiter or operator characters that should be <mo> not <mi>.
This detects malformed MathML from OMML2MML.xsl where operators are misclassified
as identifiers (e.g., <mi>(</mi> instead of <mo fence="true">(</mo>).
Args:
content: Text content of the <mi> element
Returns:
True if content contains delimiters or operators
"""
# Delimiters that should be fences
delimiters = set('()[]{}<>')
# Common operators (excluding - which might be minus sign or hyphen)
# Note: We include = and < > which are often misclassified
operators = set('+=*/|≤≥≠≈∈∉∪∩⊂⊃∧∨¬→↔×÷±')
# Check if ANY character is a delimiter or operator
return any(char in delimiters or char in operators for char in content)
def _reclassify_with_mathrm(self, content: str, node: ET.Element) -> str:
"""
Reclassify content with operators while preserving mathvariant="normal" (mathrm wrapping).
This handles cases like:
- <mi mathvariant="normal">N/m</mi> → \mathrm{N}/\mathrm{m}
- <mi mathvariant="normal">kg·m/s</mi> → \mathrm{kg}·\mathrm{m}/\mathrm{s}
Args:
content: Text content with mathvariant="normal" that contains operators
node: Original <mi> element node
Returns:
LaTeX string with mathrm-wrapped identifiers and properly classified operators
"""
# Delimiters that should have fence="true"
fence_chars = set('()[]{}')
# Operators that should be <mo> but NOT fences
operator_chars = set('+=*/|<>≤≥≠≈∈∉∪∩⊂⊃∧∨¬→↔×÷±·')
# Multi-character content: split and classify each character
# Identifier parts get wrapped in \mathrm{}
parts = []
for char in content:
if char in fence_chars:
parts.append(self._convert_as_fence(char, node))
elif char in operator_chars:
parts.append(self._convert_as_operator(char, node))
elif char.isspace():
# Preserve spaces
from ..utils import convert_spaces_to_latex
parts.append(convert_spaces_to_latex(char))
else:
# Regular identifier character - wrap in \mathrm{} for mathvariant="normal"
parts.append(f"\\mathrm{{{char}}}")
return ''.join(parts)
def _reclassify_as_operator(self, content: str, node: ET.Element) -> str:
"""
Reclassify misclassified operators/delimiters and convert them properly.
Handles cases like:
- <mi>(</mi> → treat as <mo fence="true">(</mo>
- <mi>)+</mi> → treat as <mo fence="true">)</mo><mo>+</mo>
- <mi>=</mi> → treat as <mo>=</mo>
Args:
content: Text content that contains operators/delimiters
node: Original <mi> element node
Returns:
LaTeX string with properly classified operators
"""
# Import MoVisitor for operator handling
from .mo_visitor import MoVisitor
# Delimiters that should have fence="true"
fence_chars = set('()[]{}')
# Operators that should be <mo> but NOT fences
operator_chars = set('+=*/|<>≤≥≠≈∈∉∪∩⊂⊃∧∨¬→↔×÷±')
# If content is a single character, handle directly
if len(content) == 1:
if content in fence_chars:
return self._convert_as_fence(content, node)
elif content in operator_chars:
return self._convert_as_operator(content, node)
else:
# Shouldn't happen, but fallback to identifier
return self.strategy.format_identifier(content, node)
# Multi-character content: split and classify each character
parts = []
for char in content:
if char in fence_chars:
parts.append(self._convert_as_fence(char, node))
elif char in operator_chars:
parts.append(self._convert_as_operator(char, node))
elif char.isspace():
# Preserve spaces
from ..utils import convert_spaces_to_latex
parts.append(convert_spaces_to_latex(char))
else:
# Regular identifier character
parts.append(self.strategy.format_identifier(char, node))
return ''.join(parts)
def _convert_as_fence(self, char: str, node: ET.Element) -> str:
"""
Convert a character as a fence delimiter (with fence="true").
Fence delimiters are marked with __DELIM__ to indicate they should be paired
by the post-processor (e.g., ( and ) become \left( and \right)).
Args:
char: Single delimiter character
node: Original element node (for color preservation)
Returns:
Delimiter wrapped in __DELIM__ markers for post-processing
"""
# Map fence characters to LaTeX (handle escaped braces)
fence_map = {
'(': '(',
')': ')',
'[': '[',
']': ']',
'{': '\\{', # Must be escaped in LaTeX
'}': '\\}', # Must be escaped in LaTeX
}
latex_char = fence_map.get(char, char)
# Wrap in __DELIM__ markers so post-processor knows to add \left and \right
return f"__DELIM__{latex_char}__DELIM__"
def _convert_as_operator(self, char: str, node: ET.Element) -> str:
"""
Convert a character as an operator (without fence="true").
Operators like +, =, |, < are NOT marked with __DELIM__ because they should NOT
be paired as delimiters. For example, | in P(A|B) is an operator, not a delimiter.
Args:
char: Single operator character
node: Original element node (for color preservation)
Returns:
LaTeX operator string (plain, no markers)
"""
# Map operators to LaTeX equivalents
operator_map = {
'+': '+',
'-': '-',
'*': '*',
'/': '/',
'=': '=',
'<': '<',
'>': '>',
'|': '|', # IMPORTANT: | as operator (conditional probability), NOT delimiter
'×': '\\times ',
'÷': '\\div ',
'±': '\\pm ',
'≤': '\\leqslant ',
'≥': '\\geqslant ',
'≠': '\\ne ',
'≈': '\\approx ',
'∈': '\\in ',
'∉': '\\notin ',
'∪': '\\cup ',
'∩': '\\cap ',
'⊂': '\\subset ',
'⊃': '\\supset ',
'∧': '\\wedge ',
'∨': '\\vee ',
'¬': '\\neg ',
'→': '\\to ',
'↔': '\\leftrightarrow ',
}
latex_char = operator_map.get(char, char)
# Return as-is (NO __DELIM__ markers - should not be paired)
return latex_char
def _split_uppercase_and_function(self, content: str) -> str:
"""
Split content that starts with uppercase letter(s) followed by a known function.
This handles cases like:
- "Asin" → "\\mathrm{A}\\sin"
- "ABcos" → "\\mathrm{AB}\\cos"
- "Xlog" → "\\mathrm{X}\\log"
Args:
content: The identifier content to check
Returns:
LaTeX string with split content, or None if no split is needed
"""
from ..data import MATH_FUNCTIONS
if not content or len(content) < 2:
return None
# Check if content starts with uppercase letter(s)
if not content[0].isupper():
return None
# Find where the uppercase letters end
uppercase_end = 0
for i, char in enumerate(content):
if char.isupper():
uppercase_end = i + 1
else:
break
if uppercase_end == 0 or uppercase_end >= len(content):
return None
# Split into uppercase part and remaining part
uppercase_part = content[:uppercase_end]
remaining_part = content[uppercase_end:]
# Check if remaining part is a known math function
if remaining_part in MATH_FUNCTIONS:
# Split: uppercase in \mathrm{} + function as \function
return f"\\mathrm{{{uppercase_part}}}\\{remaining_part}"
return None
def can_handle(self, node: ET.Element) -> bool:
"""
Check if this visitor can handle the given node.
Args:
node: The MathML element to check
Returns:
True if node is an <mi> element, False otherwise
"""
from ..utils import local_name as _local
return _local(node.tag) == 'mi'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mi'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MiVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,200 @@
r"""
Visitor for <mlabeledtr> (labeled table row) MathML elements.
The <mlabeledtr> element is used in tables (<mtable>) to create a row with
a label. The first child is the label, and remaining children are table cells.
Labels are typically used for equation numbering or row identifiers.
In LaTeX conversion, the label is typically rendered separately or as part
of the row depending on context. For simplicity, we render label followed
by cells, separated by &.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MlabeledtrVisitor(Visitor):
r"""
Handles <mlabeledtr> (labeled table row) MathML elements.
Converts labeled table rows to LaTeX array/matrix rows. The first child
is treated as the label, remaining children are table cells.
Examples:
Labeled equation row:
<mlabeledtr>
<mtd><mtext>(1)</mtext></mtd>
<mtd><mi>x</mi></mtd>
<mtd><mi>y</mi></mtd>
</mlabeledtr>
→ (1) & x & y
Simple labeled row:
<mlabeledtr>
<mtd><mn>1</mn></mtd>
<mtd><mi>a</mi></mtd>
</mlabeledtr>
→ 1 & a
Three-column with label:
<mlabeledtr>
<mtd><mtext>Label</mtext></mtd>
<mtd><mi>x</mi></mtd>
<mtd><mo>=</mo></mtd>
<mtd><mn>5</mn></mtd>
</mlabeledtr>
→ \text{Label} & x & = & 5
Note:
The label (first child) is rendered as a regular cell. LaTeX doesn't
have a direct equivalent for MathML labeled rows, so we treat it as
a standard row with the label as the first column.
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mlabeledtr visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mlabeledtr> element to LaTeX array/matrix row.
Args:
node: The <mlabeledtr> element to convert
Returns:
LaTeX string with cells separated by &
Raises:
ValueError: If node is not a <mlabeledtr> element
"""
if not self.can_handle(node):
raise ValueError(f"MlabeledtrVisitor cannot handle element '{node.tag}'")
# Get all children (first is label, rest are cells)
children = list(node)
if not children:
return ""
# Convert all children (label + cells) as mtd elements
cells = []
for child in children:
cell_content = self._convert_child(child)
cells.append(cell_content)
# Join with &
return " & ".join(cells)
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in mlabeledtr, using text fallback", file=sys.stderr)
return _text_content(child)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mlabeledtr'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mlabeledtr'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MlabeledtrVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,288 @@
r"""
Visitor for <mmultiscripts> (tensor notation) MathML elements.
The <mmultiscripts> element is used to attach prescripts and postscripts to a base element.
It's commonly used in physics and chemistry for tensor notation, nuclear notation, etc.
Structure:
<mmultiscripts>
<base/> <!-- Base element -->
<sub1/> <sup1/> <!-- First postscript pair -->
<sub2/> <sup2/> <!-- Second postscript pair -->
<mprescripts/> <!-- Separator -->
<presub1/> <presup1/> <!-- First prescript pair -->
<presub2/> <presup2/> <!-- Second prescript pair -->
</mmultiscripts>
This visitor handles both prescripts (left side) and postscripts (right side) with \sideset.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MmultiscriptsVisitor(Visitor):
r"""
Handles <mmultiscripts> (tensor notation) MathML elements.
Converts prescripts and postscripts to LaTeX using \sideset for bidirectional scripts.
Examples:
Only postscripts:
<mmultiscripts>
<mi>R</mi>
<mn>1</mn><mn>2</mn>
</mmultiscripts>
→ R_{1}^{2}
Only prescripts:
<mmultiscripts>
<mi>R</mi>
<mprescripts/>
<mn>1</mn><mn>2</mn>
</mmultiscripts>
→ _{1}^{2}R
Both prescripts and postscripts:
<mmultiscripts>
<mi>R</mi>
<mn>3</mn><mn>4</mn>
<mprescripts/>
<mn>1</mn><mn>2</mn>
</mmultiscripts>
→ \sideset{_{1}^{2}}{_{3}^{4}}{R}
Multiple postscripts:
<mmultiscripts>
<mi>R</mi>
<mi>i</mi><mi>j</mi>
<mi>k</mi><mi>l</mi>
</mmultiscripts>
→ R_{i}^{j}_{k}^{l}
None elements (empty script positions):
<mmultiscripts>
<mi>R</mi>
<none/><mn>2</mn>
<mprescripts/>
<mn>1</mn><none/>
</mmultiscripts>
→ \sideset{_{1}}{^{2}}{R}
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mmultiscripts visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mmultiscripts> element to LaTeX with tensor notation.
Args:
node: The <mmultiscripts> element to convert
Returns:
LaTeX string with prescripts and postscripts
Raises:
ValueError: If node is not a <mmultiscripts> element
"""
if not self.can_handle(node):
raise ValueError(f"MmultiscriptsVisitor cannot handle element '{node.tag}'")
if len(node) == 0:
return ""
# First child is the base element
base = self._convert_child(node[0])
scripts = list(node)[1:] # Skip base element
# Find mprescripts separator
prescript_idx = None
for i, child in enumerate(scripts):
if _local(child.tag) == "mprescripts":
prescript_idx = i
break
if prescript_idx is None:
# Only postscripts
postscripts = scripts
prescripts = []
else:
postscripts = scripts[:prescript_idx]
prescripts = scripts[prescript_idx + 1:]
# Process postscripts (pairs of sub/sup)
postscript_parts = self._process_script_pairs(postscripts)
# Process prescripts (pairs of sub/sup)
prescript_parts = self._process_script_pairs(prescripts)
# Use \sideset when there are scripts on both sides
if prescript_parts and postscript_parts:
left_scripts = "".join(prescript_parts)
right_scripts = "".join(postscript_parts)
return f"\\sideset{{{left_scripts}}}{{{right_scripts}}}{{{base}}}"
elif prescript_parts:
# Only left scripts - direct positioning
return f"{''.join(prescript_parts)}{base}"
elif postscript_parts:
# Only right scripts
return f"{base}{''.join(postscript_parts)}"
else:
return base
def _process_script_pairs(self, scripts: list) -> list:
"""
Process script pairs (sub/sup) from the scripts list.
Scripts come in pairs: subscript, superscript, subscript, superscript, ...
<none/> elements represent empty positions.
Args:
scripts: List of script elements (pairs of sub/sup)
Returns:
List of LaTeX script strings
"""
parts = []
for i in range(0, len(scripts), 2):
sub = self._convert_script(scripts[i]) if i < len(scripts) else ""
sup = self._convert_script(scripts[i + 1]) if i + 1 < len(scripts) else ""
if sub and sup:
parts.append(f"_{{{sub}}}^{{{sup}}}")
elif sub:
parts.append(f"_{{{sub}}}")
elif sup:
parts.append(f"^{{{sup}}}")
return parts
def _convert_script(self, child: ET.Element) -> str:
"""
Convert a script element to LaTeX.
Handles <none/> elements (empty script positions).
Args:
child: Script element to convert
Returns:
LaTeX string for the script (empty for <none/>)
"""
if _local(child.tag) == "none":
return ""
return self._convert_child(child)
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in mmultiscripts, using text fallback", file=sys.stderr)
return _text_content(child)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mmultiscripts'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mmultiscripts'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MmultiscriptsVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,136 @@
"""
Visitor for <mn> (number) MathML elements.
The <mn> element represents numeric literals in MathML, including:
- Integers (1, 2, 3, ...)
- Decimals (3.14, 2.718, ...)
- Scientific notation (1.5×10⁻³)
- Fractions in decimal form (0.5)
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, convert_spaces_to_latex, escape_latex_special_chars
from ..data import CHEMICAL_ELEMENTS, COMMON_UNITS
from ..converters.post_processor import PostProcessor
class MnVisitor(Visitor):
"""
Handles <mn> (number) MathML elements.
This visitor converts numbers to LaTeX format. Most numbers
are returned as-is, but the strategy can customize formatting
for specific subjects (e.g., chemistry, physics).
Examples:
Standard numbers:
<mn>123</mn> → 123
<mn>3.14</mn> → 3.14
<mn>0.5</mn> → 0.5
Scientific notation (handled at operator level):
<mn>1.5</mn><mo>×</mo><mn>10</mn><msup><mn>-3</mn></msup>
→ 1.5 \\times 10^{-3}
"""
def __init__(self, strategy: Strategy):
"""
Initialize the Mn visitor.
Args:
strategy: The formatting strategy to use
"""
super().__init__(strategy)
self._converter_context = None
def set_converter_context(self, converter):
"""Set converter context for color suppression checking."""
self._converter_context = converter
def visit(self, node: ET.Element) -> str:
"""
Convert <mn> element to LaTeX.
Args:
node: The <mn> element to convert
Returns:
LaTeX string representation
Raises:
ValueError: If node is not an <mn> element
"""
if not self.can_handle(node):
raise ValueError(f"MnVisitor cannot handle element '{node.tag}'")
# Get text content
content = _text_content(node)
# Handle empty content
if not content:
return ""
# Escape LaTeX special characters before any other processing
content = escape_latex_special_chars(content)
# Handle spaces (NBSP → ~, space → \ )
if '\xa0' in content or ' ' in content:
converted = convert_spaces_to_latex(content)
# If content was ONLY spaces, return directly
if not content.strip():
return self._wrap_with_color(converted, node)
# Update content with converted spaces
content = converted
# Delegate to strategy for subject-specific number formatting
result = self.strategy.format_number(content, node)
return self._wrap_with_color(result, node)
def _wrap_with_color(self, latex: str, node: ET.Element) -> str:
"""
Wrap LaTeX with color command if node has color attribute.
Args:
latex: The LaTeX string to wrap
node: The element node
Returns:
LaTeX string, optionally wrapped with color command
"""
# Use the centralized color wrapping utility with suppression support
from ..utils.color_helpers import wrap_element_with_color
# Get converter context from multiple sources (for backward compatibility):
# 1. Direct visitor context (set by legacy_adapter)
# 2. Strategy context (shared across all visitors, including child visitors)
context = getattr(self, '_converter_context', None)
if context is None and hasattr(self.strategy, '_converter_context'):
context = self.strategy._converter_context
return wrap_element_with_color(latex, node, context)
def can_handle(self, node: ET.Element) -> bool:
"""
Check if this visitor can handle the given node.
Args:
node: The MathML element to check
Returns:
True if node is an <mn> element, False otherwise
"""
from ..utils import local_name as _local
return _local(node.tag) == 'mn'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mn'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MnVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,388 @@
"""
Visitor for <mo> (operator) MathML elements.
The <mo> element represents operators in MathML, including:
- Binary operators (+, -, ×, ÷)
- Relational operators (=, <, >, ≤, ≥)
- Delimiters (parentheses, brackets, braces)
- Special operators (∫, ∑, ∏, ∇)
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, local_name as _local, convert_spaces_to_latex, escape_latex_special_chars
from ..utils.debug import debug_print
from ..data import CHEMICAL_ELEMENTS, COMMON_UNITS
from ..converters.post_processor import PostProcessor
# Operators with built-in LaTeX spacing - skip manual spaces after these
SKIP_SPACE_AFTER = {
# Relation operators (thick spacing in LaTeX)
'\\rightarrow', '\\leftarrow', '\\to', '→', '←',
'\\Longrightarrow', '\\Longleftarrow', '⇒', '⇐',
'\\leftrightarrow', '\\Longleftrightarrow', '↔', '⇔', '⟷',
'=', '\\equiv', '\\approx', '≈', '\\cong', '≅',
'<', '>', '\\leq', '\\geq', '≤', '≥', '\\neq', '≠',
'\\subseteq', '⊆', '\\supseteq', '⊇',
'\\in', '∈', '\\notin', '∉',
'\\perp', '⊥', '\\parallel', '∥',
'\\sim', '∼', '\\simeq', '≃', '\\propto', '∝',
'\\prec', '≺', '\\succ', '≻', '\\preceq', '⪯', '\\succeq', '⪰',
'\\subset', '⊂', '\\supset', '⊃', '\\sqsubseteq', '⊑', '\\sqsupseteq', '⊒',
# Binary operators (medium spacing in LaTeX)
'+', '-', '\\times', '×', '\\div', '÷', '\\cdot', '·',
'\\pm', '±', '\\mp', '∓',
'\\lor', '∨', '\\land', '∧', '\\wedge', '∧', '\\vee', '∨', '\\neg', '¬',
'\\cap', '∩', '\\cup', '∪',
'\\sqcap', '⊓', '\\sqcup', '⊔', '\\uplus', '⊎',
'\\oplus', '⊕', '\\otimes', '⊗', '\\odot', '⊙', '\\ominus', '⊖', '\\oslash', '⊘',
'\\curlywedge', '⋏', '\\curlyvee', '⋎',
'\\setminus', '∖', '\\ast', '∗', '\\star', '⋆', '\\circ', '∘',
'\\bullet', '•', '\\diamond', '⋄', '\\bigtriangleup', '△', '\\bigtriangledown', '▽',
'\\triangleleft', '◃', '\\triangleright', '▹',
'\\lhd', '⊲', '\\rhd', '⊳', '\\unlhd', '⊴', '\\unrhd', '⊵',
# Punctuation with spacing
',', ';', ':',
}
# Preserve spaces before these (differentials need explicit spacing)
PRESERVE_SPACE_BEFORE = {
'dx', 'dy', 'dz', 'dt', 'du', 'dv', 'dw',
'dr', 'ds', 'da', 'db', 'dc',
'd\\theta', 'd\\phi', 'd\\psi', 'd\\alpha', 'd\\beta',
'dxdy', 'dydz', 'dxdz', # Multi-variable differentials
}
class MoVisitor(Visitor):
"""
Handles <mo> (operator) MathML elements.
This visitor converts operators to LaTeX format, handling:
- Unicode symbol mapping (× → \\times, ≤ → \\leqslant)
- Delimiter recognition (fence operators)
- Spacing control
- Subject-specific operator formatting
Examples:
Standard operators:
<mo>+</mo> → +
<mo>×</mo> → \\times
<mo>≤</mo> → \\leqslant
Delimiters:
<mo fence="true">(</mo> → \\left(
<mo fence="true">)</mo> → \\right)
Chemistry:
<mo>→</mo> → \\rightarrow (or \\xlongequal{condition})
"""
def __init__(self, strategy: Strategy):
"""
Initialize the Mo visitor.
Args:
strategy: The formatting strategy to use
"""
super().__init__(strategy)
self._converter_context = None
def set_converter_context(self, converter):
"""Set converter context for color suppression checking."""
self._converter_context = converter
def visit(self, node: ET.Element) -> str:
"""
Convert <mo> element to LaTeX.
Args:
node: The <mo> element to convert
Returns:
LaTeX string representation
Raises:
ValueError: If node is not an <mo> element
"""
if not self.can_handle(node):
raise ValueError(f"MoVisitor cannot handle element '{node.tag}'")
# Get text content
content = _text_content(node)
# Check for fence attribute FIRST (delimiter handling)
# Empty fence delimiters (like closing brace in cases) need special handling
fence = node.get('fence')
if fence == 'true':
# For fence delimiters, allow empty content (will be handled by _handle_fence)
if not content:
return self._handle_fence("", node)
# Escape LaTeX special characters before fence handling
content = escape_latex_special_chars(content)
return self._handle_fence(content, node)
# Handle empty content (non-fence operators)
if not content:
return ""
# Escape LaTeX special characters before any other processing
content = escape_latex_special_chars(content)
# Handle spaces (NBSP → ~, space → \ )
if '\xa0' in content or ' ' in content:
# If content is ONLY spaces, use context-aware filtering
if not content.strip():
# Check if this space should be preserved based on context
if self._should_preserve_space(node):
converted = convert_spaces_to_latex(content)
return self._wrap_with_color(converted, node)
else:
# Skip this space - LaTeX will handle spacing
return ""
# Content has spaces mixed with other characters
# CRITICAL FIX: Strip surrounding whitespace from operators!
# MathType sometimes outputs "<mo> < </mo>" which fails to match "<" in strategy
# and gets converted to "\ < \ " by convert_spaces_to_latex.
content = content.strip()
# If strip() resulted in empty string (should catch above, but safety)
if not content:
return ""
# Use convert_spaces_to_latex ONLY if there are still spaces INSIDE the operator
# (e.g. "mod " -> "mod" (stripped), but "a b" -> "a~b"?)
if '\xa0' in content or ' ' in content:
content = convert_spaces_to_latex(content)
# Check for stretchy attribute (affects some operators like arrows)
stretchy = node.get('stretchy')
# Delegate to strategy for subject-specific operator formatting
result = self.strategy.format_operator(content, node)
# DEBUG
if content in '()':
debug_print(f"[DEBUG MO] Operator {content!r} → {result!r}")
return self._wrap_with_color(result, node)
def _handle_fence(self, content: str, node: ET.Element) -> str:
"""
Handle fence (delimiter) operators with fence="true" attribute.
Args:
content: The delimiter character (can be empty for invisible delimiters)
node: The element node
Returns:
LaTeX delimiter wrapped in __DELIM__ markers for post-processing
Note:
Fences are marked with __DELIM__ markers to indicate they should be
paired by the delimiter pairing processor. This distinguishes them from
plain operators that happen to use the same character (e.g., | in P(A|B)
is an operator, not a fence delimiter).
Empty content is converted to "." (invisible delimiter) for cases like
piecewise functions: \left\{...\right.
"""
# Handle empty or whitespace-only delimiter
# Empty: invisible delimiter in LaTeX (e.g., piecewise functions: \left\{...\right.)
# Whitespace-only: OMML endChr/begChr with space value (e.g., m:val=" ")
# → return empty string (no delimiter), NOT a dot, to prevent extra \right.
if not content or content.strip() == "":
if not content:
# Truly empty → invisible delimiter (for cases environment etc.)
result = "__DELIM__.__DELIM__"
debug_print(f"[DEBUG MO FENCE] Empty delimiter → {result!r}")
return result
else:
# Whitespace-only → skip entirely (no delimiter needed)
debug_print(f"[DEBUG MO FENCE] Whitespace-only fence content → skip (no delimiter)")
return ""
# Map common fences to LaTeX
fence_map = {
'(': '(',
')': ')',
'[': '[',
']': ']',
'{': '\\{',
'}': '\\}',
'|': '|',
'‖': '\\|', # Double vertical bar
'⟨': '\\langle',
'⟩': '\\rangle',
'⌈': '\\lceil',
'⌉': '\\rceil',
'⌊': '\\lfloor',
'⌋': '\\rfloor',
}
latex_char = fence_map.get(content, content)
# Wrap in __DELIM__ markers so post-processor knows to add \left and \right
# ONLY delimiters with fence="true" get these markers
stretchy = node.get('stretchy')
if stretchy == 'true':
result = f"__DELIM__STRETCHY::{latex_char}__DELIM__"
else:
result = f"__DELIM__{latex_char}__DELIM__"
# Debug output
debug_print(f"[DEBUG MO FENCE] {content!r} → {result!r}")
return result
def _wrap_with_color(self, latex: str, node: ET.Element) -> str:
"""
Wrap LaTeX with color command if node has color attribute.
Args:
latex: The LaTeX string to wrap
node: The element node
Returns:
LaTeX string, optionally wrapped with color command
"""
# Use the centralized color wrapping utility with suppression support
from ..utils.color_helpers import wrap_element_with_color
# Get converter context from multiple sources (for backward compatibility):
# 1. Direct visitor context (set by legacy_adapter)
# 2. Strategy context (shared across all visitors, including child visitors)
context = getattr(self, '_converter_context', None)
if context is None and hasattr(self.strategy, '_converter_context'):
context = self.strategy._converter_context
return wrap_element_with_color(latex, node, context)
def can_handle(self, node: ET.Element) -> bool:
"""
Check if this visitor can handle the given node.
Args:
node: The MathML element to check
Returns:
True if node is an <mo> element, False otherwise
"""
from ..utils import local_name as _local
return _local(node.tag) == 'mo'
def _should_preserve_space(self, node: ET.Element) -> bool:
"""
Context-aware decision: should this space operator be preserved?
Analyzes the previous and next sibling elements to determine if the space
is redundant (LaTeX will add it automatically) or necessary.
Args:
node: The <mo> space element
Returns:
True if space should be preserved, False if it should be skipped
"""
parent = node.getparent()
if parent is None:
return False # No context, skip
siblings = list(parent)
try:
idx = siblings.index(node)
except ValueError:
return False # Can't find in parent, skip
# Rule 1: Skip trailing spaces (no next element)
if idx >= len(siblings) - 1:
return False
# Get previous and next elements
prev_elem = siblings[idx - 1] if idx > 0 else None
next_elem = siblings[idx + 1]
# Rule 2: Get LaTeX representation of previous element
if prev_elem is not None:
prev_latex = self._peek_sibling_latex(prev_elem)
# Skip after operators with built-in spacing
if prev_latex.strip() in SKIP_SPACE_AFTER:
return False
# Skip after LaTeX functions (\sin, \cos, etc.)
# Check if converted from plain text to LaTeX command
if prev_latex.startswith('\\'):
func_name = prev_latex.lstrip('\\').split()[0] if prev_latex else ''
if func_name in {'sin', 'cos', 'tan', 'log', 'ln', 'exp',
'lim', 'max', 'min', 'sup', 'inf',
'arcsin', 'arccos', 'arctan', 'sinh', 'cosh', 'tanh'}:
return False
# Rule 3: Preserve before differentials
next_latex = self._peek_sibling_latex(next_elem)
if next_latex.strip() in PRESERVE_SPACE_BEFORE:
return True
# Rule 4: Default - skip (let LaTeX handle spacing)
return False
def _peek_sibling_latex(self, elem: ET.Element) -> str:
"""
Convert a sibling element to LaTeX without full recursion.
This is a lightweight peek to check what LaTeX representation
the element would produce, used for context decisions.
Args:
elem: The sibling element to peek at
Returns:
LaTeX representation (simplified)
"""
if elem is None:
return ""
tag = _local(elem.tag)
# For simple elements, just get text content
if tag == 'mi':
text = _text_content(elem).strip()
# Check if it's a function name
if text in {'sin', 'cos', 'tan', 'log', 'ln', 'exp',
'lim', 'max', 'min', 'sup', 'inf'}:
return f'\\{text}'
return text
elif tag == 'mo':
text = _text_content(elem).strip()
# Common operator conversions
op_map = {
'×': '\\times', '÷': '\\div', '·': '\\cdot',
'≤': '\\leq', '≥': '\\geq', '≠': '\\neq',
'→': '\\rightarrow', '←': '\\leftarrow', '↔': '\\leftrightarrow',
'∨': '\\lor', '∧': '\\land', '¬': '\\neg',
'∩': '\\cap', '∪': '\\cup',
'⊥': '\\perp', '∥': '\\parallel',
'∈': '\\in', '⊆': '\\subseteq', '⊇': '\\supseteq',
}
return op_map.get(text, text)
elif tag == 'mn':
return _text_content(elem).strip()
else:
# For complex elements, return empty (conservative)
return ""
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mo'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MoVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,911 @@
r"""
Visitor for <mover> (overscript/accent) MathML elements.
The <mover> element represents content with something above it:
- Accents: hat, tilde, bar, dot
- Vector notation: arrow over variables
- Chemical reactions: text over equals/arrow
- General overscript: any content over base
This implementation handles the most common cases with TODOs for advanced features.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, local_name as _local
class MoverVisitor(Visitor):
r"""
Handles <mover> (overscript/accent) MathML elements.
Converts overscript and accent notation to appropriate LaTeX commands.
Examples:
Vector notation (arrow over variable):
<mover><mi>v</mi><mo>→</mo></mover> → \overrightarrow{v}
Bar/overline:
<mover><mi>x</mi><mo>¯</mo></mover> → \overline{x}
Tilde accent:
<mover><mi>x</mi><mo>~</mo></mover> → \tilde{x}
Hat/circumflex:
<mover><mi>x</mi><mo>^</mo></mover> → \hat{x}
Dot accent:
<mover><mi>x</mi><mo>˙</mo></mover> → \dot{x}
Double dot (ddot) accent:
<mover><mi>x</mi><mo>¨</mo></mover> → \ddot{x}
Triple dot (dddot) accent:
<mover><mi>x</mi><mo>⃛</mo></mover> → \dddot{x}
Overbrace with label:
<mover><mover><mn>3333</mn><mo>⏞</mo></mover><mn>222</mn></mover>
→ \overbrace{3333}^{222}
Chemical reaction (xlongequal):
<mover accent="false"><mo>=</mo><mtext>heat</mtext></mover>
→ \xlongequal{\text{heat}}
General overscript:
<mover accent="false"><mi>x</mi><mi>n</mi></mover> → \overset{n}{x}
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mover visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mover> element to LaTeX.
Args:
node: The <mover> element to convert
Returns:
LaTeX string with appropriate overscript/accent notation
Raises:
ValueError: If node is not an <mover> element
ValueError: If mover has fewer than 2 children
"""
if not self.can_handle(node):
raise ValueError(f"MoverVisitor cannot handle element '{node.tag}'")
children = list(node)
# mover must have exactly 2 children: base and overscript
if len(children) < 2:
raise ValueError(
f"mover element must have 2 children (base, overscript), "
f"got {len(children)}"
)
# IMPORTANT: Check raw text content BEFORE converting children
# to properly detect bar/accent characters (e.g., "―" before it becomes "\textemdash")
over_raw_text = _text_content(children[1]).strip()
# Check for special data attributes (from OMML conversion)
if node.get("data-not-slash") == "true":
# \not command
base = self._convert_child(children[0])
return f"\\not{self._brace(base)}"
if node.get("data-chemical-reaction") == "true":
# Chemical reaction with text over arrow/equals
base = self._convert_child(children[0])
over = self._convert_child(children[1])
over_clean = self._strip_text_braces(over)
return f"\\xlongequal{self._brace(over_clean)}"
# Check for nested extensible arrow pattern: <mover><munder>arrow/equals<...>...</munder><...></mover>
# OR: <mover><mrow><munder>arrow/equals<...>...</munder></mrow><...></mover> (Word often adds mrow wrapper)
# This is Word's way of creating arrows with text above and below
munder_node = None
if _local(children[0].tag) == 'munder':
# Direct munder child
munder_node = children[0]
elif _local(children[0].tag) == 'mrow':
# Check if mrow contains munder as only significant child
mrow_children = list(children[0])
if len(mrow_children) == 1 and _local(mrow_children[0].tag) == 'munder':
munder_node = mrow_children[0]
else:
# Check if any child is munder (Word sometimes adds multiple mrows)
for child in mrow_children:
if _local(child.tag) == 'munder':
munder_node = child
break
if munder_node is not None:
munder_children = list(munder_node)
if len(munder_children) >= 2:
# The arrow/equals might be in an mrow wrapper
base_node = munder_children[0]
if _local(base_node.tag) == 'mrow':
# Unwrap mrow to find the actual operator
mrow_children = list(base_node)
if mrow_children and _local(mrow_children[0].tag) in ('mo', 'mi', 'mtext'):
base_node = mrow_children[0]
# Check if base is an arrow or equals
xarrow_cmd = self._get_xarrow_command(base_node)
if xarrow_cmd:
# Extract text from over and under (may be wrapped in <mphantom> or <mrow>)
under = self._convert_child(munder_children[1])
over = self._convert_child(children[1])
# Clean up the text (remove \phantom{}, \mathrm{}, \text{}, extra spaces)
under_clean = self._clean_arrow_text(under)
over_clean = self._clean_arrow_text(over)
# Re-wrap Chinese text and non-command text in \mathrm{} for chemistry mode
# (since _clean_arrow_text removes it)
# This is crucial for chemical reaction conditions like "加热", "控温", "电解"
if hasattr(self.strategy, 'name') and self.strategy.name == "chemistry":
# Check if text needs \mathrm{} wrapping
# Skip if it's already a LaTeX command (starts with \) or empty
# CRITICAL: Also skip pure numbers (like "1", "222", "2121")
# Numbers should remain as plain numbers, not wrapped in \mathrm{}
if over_clean and not over_clean.startswith("\\") and over_clean.strip():
# Check if it's a pure number (digits only, possibly with decimal point)
if not over_clean.replace('.', '', 1).isdigit():
# Not a pure number - wrap it in \mathrm{}
over_clean = f"\\mathrm{{{over_clean}}}"
if under_clean and not under_clean.startswith("\\") and under_clean.strip():
# Check if it's a pure number (digits only, possibly with decimal point)
if not under_clean.replace('.', '', 1).isdigit():
# Not a pure number - wrap it in \mathrm{}
under_clean = f"\\mathrm{{{under_clean}}}"
# Use \quad for empty positions (from <mphantom> elements)
# This creates proper spacing in chemical equations
if not under_clean or under_clean.strip() == "":
under_clean = "\\quad"
if not over_clean or over_clean.strip() == "":
over_clean = "\\quad"
return f"\\{xarrow_cmd}[{under_clean}]{{{over_clean}}}"
# Check for xlongequal pattern (equals sign with text above)
if self._is_equals_base(children[0]):
base = self._convert_child(children[0])
over = self._convert_child(children[1])
label = self._strip_text_braces(over)
return f"\\xlongequal{self._brace(label)}"
# Check for arc notation: arc/frown over multi-character content (line segments)
if self._is_arc_notation(node):
# Convert to arc notation with size modifiers
base_text = _text_content(children[0]).strip()
base = self._convert_child(children[0])
# Remove \mathrm{} wrapper - accent base should be italic
base_clean = self._strip_mathrm_for_accents(base)
# Use different sizes based on base length: \huge for 2 chars, \Huge for 3+ chars
if len(base_text) == 2:
return f"\\overset{{\\huge\\frown}}{self._brace(base_clean)}"
else: # 3+ characters
return f"\\overset{{\\Huge\\frown}}{self._brace(base_clean)}"
# Check for vector notation (arrow over variables)
# CRITICAL: Need to unwrap mrow wrappers around arrows
# Pattern: <mover><base><mrow><mi>→</mi></mrow></mover>
over_to_check = children[1]
if _local(over_to_check.tag) == 'mrow':
# Unwrap mrow if it contains a single element
mrow_children = list(over_to_check)
if len(mrow_children) == 1:
over_to_check = mrow_children[0]
arrow_cmd = self._get_vector_arrow_cmd(children[0], over_to_check)
if arrow_cmd:
# Remove any \mathrm{} wrapping (vector variables should be italic)
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
return f"\\{arrow_cmd}{self._brace(base_clean)}"
# Check for extensible arrow pattern (arrow as base)
# Pattern: <mover><mo>→</mo><content></mover>
# This produces \xrightarrow[\quad]{content} for chemical reaction arrows
#
# IMPORTANT: When arrow is the BASE of mover (not the overscript), it should use
# \xrightarrow format for extensible arrows with labels, NOT \overrightarrow.
# \overrightarrow is for vector notation where arrow is OVER variables.
base_to_check = children[0]
if _local(base_to_check.tag) == 'mrow':
# Unwrap mrow if it contains a single element
mrow_children = list(base_to_check)
if len(mrow_children) == 1:
base_to_check = mrow_children[0]
# Check if base is an arrow operator (extensible arrow for reaction notation)
# When arrow is the base element with content above, always use xrightarrow format
xarrow_cmd = self._get_xarrow_command(base_to_check)
if xarrow_cmd:
# Extensible arrow with content above
over = self._convert_child(children[1])
over_clean = self._clean_arrow_text(over)
# Use \quad for empty below position
return f"\\{xarrow_cmd}[\\quad]{self._brace(over_clean)}"
# Check for inverted vector notation (variables over arrow)
# CRITICAL: Need to unwrap mrow wrappers around arrows
# Pattern: <mover><mrow><mi>→</mi></mrow><content></mover>
inv_arrow_cmd = self._get_inverted_vector_arrow_cmd(base_to_check, children[1])
if inv_arrow_cmd:
# Variables are in over position
over = self._convert_child(children[1])
over_clean = self._strip_mathrm(over)
return f"\\{inv_arrow_cmd}{self._brace(over_clean)}"
# Check for bar/overline notation (using RAW text)
if self._is_bar_char(over_raw_text):
base = self._convert_child(children[0])
return f"\\overline{self._brace(base)}"
# Check for tilde accent (using RAW text)
if self._is_tilde_char(over_raw_text):
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
# Use \tilde for single char, \widetilde for multi-char
base_text = _text_content(children[0]).strip()
if len(base_text) == 1:
return f"\\tilde{self._brace(base_clean)}"
else:
return f"\\widetilde{self._brace(base_clean)}"
# Check for combining arrows from XSLT transformer
# U+20D7 = combining right arrow above → \overrightarrow OR \hat (depends on context)
# U+20D6 = combining left arrow above → \overleftarrow
if over_raw_text == "⃗": # U+20D7 combining right arrow above
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
# Check if this is a default accent (hat) or explicit vector arrow
# XSLT adds data-default-accent="true" when chr is not specified in OMML
# Default (no chr) → \hat, Explicit chr="⃗" → \overrightarrow
if node.get('data-default-accent') == 'true':
return f"\\hat{self._brace(base_clean)}"
else:
return f"\\overrightarrow{self._brace(base_clean)}"
if over_raw_text == "⃖": # U+20D6 combining left arrow above
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
# ALWAYS use overleftarrow for combining left arrow
return f"\\overleftarrow{self._brace(base_clean)}"
# Check for hat/circumflex accent (using RAW text)
if self._is_hat_char(over_raw_text):
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
# Use \hat for single char, \widehat for multi-char
base_text = _text_content(children[0]).strip()
if len(base_text) == 1:
return f"\\hat{self._brace(base_clean)}"
else:
return f"\\widehat{self._brace(base_clean)}"
# Check for dot accent (using RAW text)
if self._is_dot_char(over_raw_text):
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
return f"\\dot{self._brace(base_clean)}"
# Check for breve accent (using RAW text)
if self._is_breve_char(over_raw_text):
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
return f"\\breve{self._brace(base_clean)}"
# Check for harpoon accents (using RAW text)
harpoon_cmd = self._get_harpoon_cmd(over_raw_text)
if harpoon_cmd:
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
return f"\\overset{{\\{harpoon_cmd}}}{self._brace(base_clean)}"
# Check for ddot (double dot) accent (using RAW text)
if self._is_ddot_char(over_raw_text):
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
return f"\\ddot{self._brace(base_clean)}"
# Check for dddot (triple dot) accent (using RAW text)
if self._is_dddot_char(over_raw_text):
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
return f"\\dddot{self._brace(base_clean)}"
# Check for overbrace notation: nested mover with brace character
# Pattern 1: mover(mover(base, ⏞), label) → \overbrace{base}^{label}
# Pattern 2: mover(mover(⏞, base), label) → \overbrace{base}^{label} [NEW - vertJc="bot" case]
# OR: mover(mrow(mover(...)), label) with mrow wrapper (Word often wraps inner mover)
# This is how Word encodes overbrace with labels
inner_mover = None
if len(children) >= 2:
# Check if first child is mover directly
if _local(children[0].tag) == 'mover':
inner_mover = children[0]
# Check if first child is mrow containing mover
elif _local(children[0].tag) == 'mrow':
mrow_children = list(children[0])
# Look for mover inside mrow (may be the only child or one of several)
for child in mrow_children:
if _local(child.tag) == 'mover':
inner_mover = child
break
# Process the inner mover if found
if inner_mover is not None:
inner_children = list(inner_mover)
if len(inner_children) >= 2:
# Pattern 1: Check if inner mover's second child is the overbrace character
inner_over_text = _text_content(inner_children[1]).strip()
if inner_over_text == "⏞": # U+23DE Overbrace character
# Extract base from inner mover (first child)
inner_base = self._convert_child(inner_children[0])
# Extract label from outer mover (second child)
label = self._convert_child(children[1])
# Return proper overbrace with superscript label
return f"\\overbrace{self._brace(inner_base)}^{self._brace(label)}"
# Pattern 2: Check if inner mover's first child is the overbrace character
# This happens when vertJc="bot" in OMML groupChr
inner_base_text = _text_content(inner_children[0]).strip()
if inner_base_text == "⏞": # U+23DE Overbrace character as base
# Extract base from inner mover (second child - content above the brace)
inner_base = self._convert_child(inner_children[1])
# Extract label from outer mover (second child)
label = self._convert_child(children[1])
# Return proper overbrace with superscript label
return f"\\overbrace{self._brace(inner_base)}^{self._brace(label)}"
# Check for ALTERNATIVE overbrace pattern (Word sometimes uses this)
# Pattern: mover(munder(base, ⏞), label) → \overbrace{base}^{label}
# OR: mover(mrow(munder(base, ⏞)), label) with mrow wrapper
# The brace character ⏞ is in the UNDER position but still represents overbrace
if len(children) >= 2:
# Check if first child is munder (directly or inside mrow)
inner_munder = None
if _local(children[0].tag) == 'munder':
inner_munder = children[0]
elif _local(children[0].tag) == 'mrow':
# Check inside mrow wrapper
for child in children[0]:
if _local(child.tag) == 'munder':
inner_munder = child
break
if inner_munder is not None:
inner_children = list(inner_munder)
if len(inner_children) >= 2:
# Check if inner munder's second child is the overbrace character
inner_under_text = _text_content(inner_children[1]).strip()
if inner_under_text == "⏞": # U+23DE Overbrace character in under position
# Extract base from inner munder (first child)
inner_base = self._convert_child(inner_children[0])
# Extract label from outer mover (second child)
label = self._convert_child(children[1])
# Return proper overbrace with superscript label
return f"\\overbrace{self._brace(inner_base)}^{self._brace(label)}"
# TODO: Additional accent types (rarely used in mathematical notation)
# - Check (caron): ˇ character → \check{x}
# - Acute: ´ character → \acute{x}
# - Grave: ` character → \grave{x}
# CRITICAL FIX: Check if base is a large operator (max, min, lim, etc.)
# Word incorrectly produces <mover> for operators that should use subscripts
# Example: <mover><mi>max</mi><mrow>condition</mrow></mover>
# Should become: \max_{condition}, NOT \overset{condition}{max}
if self._is_large_operator(children[0]):
# Use subscript for operator limits, treating the "over" content as "under"
base = self._convert_child(children[0])
limit = self._convert_child(children[1])
return f"{base}\\limits_{self._brace(limit)}"
# Default: general overscript using \overset
base = self._convert_child(children[0])
over = self._convert_child(children[1])
return f"\\overset{self._brace(over)}{self._brace(base)}"
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in mover, using text fallback", file=sys.stderr)
return _text_content(child)
def _is_equals_base(self, base_node: ET.Element) -> bool:
"""Check if base is an equals sign."""
if _local(base_node.tag) == 'mo':
return _text_content(base_node).strip() == '='
return False
def _get_vector_arrow_cmd(self, base_node: ET.Element, over_node: ET.Element) -> str:
"""Get vector arrow command if pattern matches (arrow over variables/numbers)."""
# Check if base is a variable (mi), number (mn), expression (mrow), or complex expression
# Include fractions, superscripts, subscripts, roots, etc. as valid bases for vector arrows
if _local(base_node.tag) not in ['mi', 'mn', 'mrow', 'mfrac', 'msup', 'msub', 'msubsup', 'msqrt', 'mroot']:
return ""
# Check if over is an arrow
# CRITICAL FIX: OMML→MathML conversion often puts arrows in <mi> tags instead of <mo>
# Must check BOTH <mo> AND <mi> tags for arrow symbols
if _local(over_node.tag) not in ['mo', 'mi']:
return ""
over_text = _text_content(over_node).strip()
# NOTE: Only single arrows (→, ←, ↔) are used for vector notation
# Double arrows (⇒, ⇐, ⇔) should use overset with \Longrightarrow, not overrightarrow
arrow_map = {
"→": "overrightarrow",
"\\rightarrow": "overrightarrow",
"←": "overleftarrow",
"\\leftarrow": "overleftarrow",
"↔": "overleftrightarrow",
"\\leftrightarrow": "overleftrightarrow",
}
return arrow_map.get(over_text, "")
def _get_inverted_vector_arrow_cmd(self, base_node: ET.Element, over_node: ET.Element) -> str:
"""Get vector arrow command if pattern matches (variables over arrow)."""
# Check if base is an arrow
# CRITICAL FIX: OMML→MathML conversion often puts arrows in <mi> tags instead of <mo>
# Must check BOTH <mo> AND <mi> tags for arrow symbols
if _local(base_node.tag) not in ['mo', 'mi']:
return ""
base_text = _text_content(base_node).strip()
# NOTE: Only single arrows (→, ←, ↔) are used for vector notation
# Double arrows (⇒, ⇐, ⇔) should use overset with \Longrightarrow, not overrightarrow
arrow_map = {
"→": "overrightarrow",
"\\rightarrow": "overrightarrow",
"←": "overleftarrow",
"\\leftarrow": "overleftarrow",
"↔": "overleftrightarrow",
"\\leftrightarrow": "overleftrightarrow",
}
# Check if over is variable-like content or complex expression
if _local(over_node.tag) in ['mi', 'mn', 'mrow', 'mfrac', 'msup', 'msub', 'msubsup', 'msqrt', 'mroot']:
return arrow_map.get(base_text, "")
return ""
def _is_bar_char(self, latex_str: str) -> bool:
"""Check if string represents a bar/overline character."""
bar_chars = [
"¯", "‾", "―", "−", "⎺", "⎻", "-", "_",
"̅", # U+0305 combining overline (used in OMML m:acc)
]
return latex_str.strip() in bar_chars
def _is_tilde_char(self, latex_str: str) -> bool:
"""Check if string represents a tilde character."""
tilde_chars = [
"~", "˜", "∼", "\\tilde",
"̃", # U+0303 combining tilde (used in OMML m:acc)
]
return latex_str.strip() in tilde_chars
def _is_hat_char(self, latex_str: str) -> bool:
"""Check if string represents a hat/circumflex character."""
hat_chars = [
"^", "ˆ", "︶", "∧", "\\hat",
"̂", # U+0302 combining circumflex (used in OMML m:acc)
]
return latex_str.strip() in hat_chars
def _is_dot_char(self, latex_str: str) -> bool:
"""Check if string represents a dot character."""
dot_chars = [
"˙", ".", "·", "∙", "\\dot",
"̇", # U+0307 combining dot above (used in OMML m:acc)
]
return latex_str.strip() in dot_chars
def _is_ddot_char(self, latex_str: str) -> bool:
"""Check if string represents a ddot (double dot/diaeresis) character."""
# Double dot characters (diaeresis/umlaut)
ddot_chars = [
"¨", # U+00A8 diaeresis (standalone)
"̈", # U+0308 combining diaeresis
"‥", # U+2025 two dot leader
"__DDOT__", # Placeholder from symbol mapping
"\\ddot",
]
return latex_str.strip() in ddot_chars
def _is_dddot_char(self, latex_str: str) -> bool:
"""Check if string represents a dddot (triple dot) character."""
# Triple dot characters
dddot_chars = [
"⃛", # U+20DB combining three dots above
"⋮", # U+22EE vertical ellipsis (sometimes used)
"︙", # U+FE19 presentation form vertical ellipsis
"__DDDOT__", # Placeholder from symbol mapping
"\\dddot",
]
return latex_str.strip() in dddot_chars
def _is_breve_char(self, latex_str: str) -> bool:
"""Check if string represents a breve character."""
breve_chars = ["̆", "˘", "\\breve"] # U+0306 combining breve, U+02D8 breve
return latex_str.strip() in breve_chars
def _get_harpoon_cmd(self, latex_str: str) -> str:
"""Get harpoon LaTeX command if string represents a harpoon character.
Returns:
The LaTeX command name (without backslash) if harpoon, empty string otherwise.
"""
harpoon_map = {
"⃐": "leftharpoonup", # U+20D0 Combining left harpoon above
"⃑": "rightharpoonup", # U+20D1 Combining right harpoon above
"⇀": "rightharpoonup", # U+21C0 Right harpoon up
"↼": "leftharpoonup", # U+21BC Left harpoon up
"⇁": "rightharpoondown", # U+21C1 Right harpoon down
"↽": "leftharpoondown", # U+21BD Left harpoon down
}
return harpoon_map.get(latex_str.strip(), "")
def _strip_text_braces(self, latex_str: str) -> str:
r"""Remove \text{...} wrapper if present."""
import re
match = re.match(r'\\text\{(.+)\}', latex_str)
if match:
return match.group(1)
return latex_str
def _strip_mathrm(self, latex_str: str) -> str:
r"""Remove \mathrm{...} wrapper if present."""
import re
# Remove \mathrm{} wrappers
latex_str = re.sub(r'\\mathrm\{([a-zA-Z0-9]+)\}', r'\1', latex_str)
# Remove spaces in vector notation (a b c -> abc)
if re.match(r'^[a-zA-Z0-9](\s+[a-zA-Z0-9])+$', latex_str):
latex_str = re.sub(r'\s+', '', latex_str)
return latex_str
def _get_xarrow_command(self, base_node: ET.Element) -> str:
"""
Get extensible arrow command for the base element.
Returns command name (without backslash) if base is an arrow/equals,
otherwise returns empty string.
Supported arrows:
- = → xlongequal
- → → xrightarrow
- ← → xleftarrow
- ↔ → xleftrightarrow
- ⇌ → xrightleftharpoons
"""
# Accept <mo> (operator), <mi> (identifier), and <mtext> (text) elements for arrows
# Word's XSLT sometimes produces <mi> for arrows instead of <mo>
if _local(base_node.tag) not in ('mo', 'mi', 'mtext'):
return ""
base_text = _text_content(base_node).strip()
# Map arrow symbols to xarrow commands
arrow_map = {
"=": "xlongequal",
"→": "xrightarrow",
"\\rightarrow": "xrightarrow",
"←": "xleftarrow",
"\\leftarrow": "xleftarrow",
"↔": "xleftrightarrow",
"\\leftrightarrow": "xleftrightarrow",
"⟷": "xleftrightarrow", # U+27F7 long left-right arrow
"\\longleftrightarrow": "xleftrightarrow",
"⇌": "xrightleftharpoons",
"\\rightleftharpoons": "xrightleftharpoons",
}
return arrow_map.get(base_text, "")
def _clean_arrow_text(self, text: str) -> str:
"""
Clean up text for use in arrow labels.
Removes:
- \\phantom{} wrappers (Word uses these for arrow labels)
- \\text{} wrappers (content preserved)
- Extra spaces between characters
NOTE: \\mathrm{} wrappers are PRESERVED because they represent explicit
styling from the source document (mathvariant="normal" in MathML).
"""
import re
# Helper function to remove LaTeX commands with balanced braces
def remove_command(text: str, command: str) -> str:
"""Remove LaTeX command with proper brace balancing.
Args:
text: Input text with LaTeX commands
command: Command to remove (e.g., "\\mathrm" or "mathrm")
"""
# Normalize command: ensure it starts with backslash
if not command.startswith('\\'):
command = '\\' + command
while command in text:
# Find the command
idx = text.find(command)
if idx == -1:
break
# Find the opening brace (should be right after command)
brace_start = idx + len(command)
if brace_start >= len(text) or text[brace_start] != '{':
break # Malformed - no opening brace
# Match balanced braces
brace_count = 1
pos = brace_start + 1
while pos < len(text) and brace_count > 0:
if text[pos] == '{' and (pos == 0 or text[pos-1] != '\\'):
brace_count += 1
elif text[pos] == '}' and (pos == 0 or text[pos-1] != '\\'):
brace_count -= 1
pos += 1
if brace_count == 0:
# Successfully matched braces
content = text[brace_start+1:pos-1]
text = text[:idx] + content + text[pos:]
else:
break # Malformed braces
return text
# Remove \phantom{} wrappers (Word's equation editor uses these for arrow labels)
text = remove_command(text, '\\phantom')
# NOTE: Do NOT remove \mathrm{} wrappers - they represent explicit styling
# from mathvariant="normal" in the source MathML, indicating the author
# wanted upright text (important for chemistry formulas like NH3·H2O)
# Remove \text{} wrappers (but keep the content)
# For Chinese text, we want to preserve it as-is
text = remove_command(text, '\\text')
# Remove extra spaces between individual characters
# "高 压" → "高压", but preserve intentional spaces in phrases
text = re.sub(r'(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])', '', text)
# For non-Chinese text with spaces between each char, remove spaces
# "a b c" → "abc", but "word1 word2" stays as-is
if len(text.replace(' ', '')) > 0:
# Check if it's all single chars separated by spaces
parts = text.split()
if all(len(p) == 1 for p in parts):
text = ''.join(parts)
return text.strip()
def _brace(self, content: str) -> str:
"""Wrap content in braces."""
return f"{{{content}}}"
def _is_arc_notation(self, mover_node: ET.Element) -> bool:
"""
Check if mover represents arc notation (frown/arc over multi-char content).
Arc notation is used for line segments like AB̂.
Args:
mover_node: The mover element to check
Returns:
True if this represents arc notation, False otherwise
"""
if len(mover_node) < 2:
return False
base_node = mover_node[0]
over_node = mover_node[1]
# Check if the over content is an arc/frown character
if _local(over_node.tag) in ["mtext", "mo"]:
over_text = _text_content(over_node).strip()
# Arc characters (frown/smile used as arc over multiple characters)
arc_characters = [
"⌢", # Frown (upward arc) - used for arc over segments
"︵", # Top parenthesis
"⁀", # Character tie
]
if over_text in arc_characters:
base_text = _text_content(base_node).strip()
# Arc notation is used for multi-character bases (line segments)
# For 2+ characters, use \overset{\frown} or widehat
if len(base_text) >= 2:
return True
return False
def _strip_mathrm_for_accents(self, latex: str) -> str:
"""
Remove \\mathrm{} wrapper from content that will be used in accent notations.
Accent base content (like AB in \\widetilde{AB}) should remain italic, not upright.
Args:
latex: LaTeX string possibly wrapped in \\mathrm{}
Returns:
LaTeX string with \\mathrm{} removed if present
"""
import re
# Match \mathrm{content} and extract just the content
pattern = r'^\\mathrm\{(.+)\}$'
match = re.match(pattern, latex)
if match:
return match.group(1)
return latex
def _is_large_operator(self, base_node: ET.Element) -> bool:
"""
Check if base is a large operator (sum, integral, product, max, min, lim, etc.).
Recursively searches through wrapper elements like <mstyle>, <mrow>, <mpadded>
to find the actual operator, since Word often wraps large operators.
This is used to detect when Word incorrectly uses <mover> for operators
that should use subscripts (e.g., max, min, lim).
"""
if base_node is None:
return False
tag = _local(base_node.tag)
# Define large operators list (same as in MunderVisitor)
large_ops = [
"∑", "\\sum", # Summation
"∏", "\\prod", # Product
"∐", "\\coprod", # Coproduct
"∫", "\\int", # Integral
"∬", "\\iint", # Double integral
"∭", "\\iiint", # Triple integral
"∮", "\\oint", # Contour integral
"⋃", "\\bigcup", # Union
"⋂", "\\bigcap", # Intersection
"⋁", "\\bigvee", # Logical OR
"⋀", "\\bigwedge", # Logical AND
"⨁", "\\bigoplus", # Direct sum
"⨂", "\\bigotimes", # Tensor product
"⨄", "\\biguplus", # Multiset union
"lim", "\\lim", # Limit (often in <mi> from Word)
"sup", "\\sup", # Supremum
"inf", "\\inf", # Infimum
"max", "\\max", # Maximum
"min", "\\min", # Minimum
]
# Check direct <mo> or <mi> elements
if tag in ('mo', 'mi'):
base_text = _text_content(base_node).strip()
return base_text in large_ops
# Recursively search through wrapper elements (mstyle, mrow, mpadded, menclose)
if tag in ('mstyle', 'mrow', 'mpadded', 'menclose'):
for child in base_node:
if self._is_large_operator(child):
return True
return False
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mover'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mover'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MoverVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,198 @@
r"""
Visitor for <mpadded> (padded content) MathML elements.
The <mpadded> element is used to adjust the spacing and positioning of its content
by adding padding. It supports attributes like width, height, depth, lwidth, and voffset.
In LaTeX conversion, mpadded is typically treated as a transparent wrapper since
LaTeX doesn't have a direct equivalent for fine-grained spacing control. The padding
attributes are generally ignored, and only the content is converted.
This visitor acts as a transparent container, similar to <mrow>.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MpaddedVisitor(Visitor):
r"""
Handles <mpadded> (padded content) MathML elements.
Acts as a transparent wrapper, converting only the content without
applying padding attributes (LaTeX doesn't have direct equivalents).
Examples:
Simple padding (transparent):
<mpadded width="+1em"><mi>x</mi></mpadded>
→ x
Padded expression:
<mpadded lspace="0.5em"><mrow><mi>a</mi><mo>+</mo><mi>b</mi></mrow></mpadded>
→ a+b
Padded fraction:
<mpadded height="+1em"><mfrac><mi>a</mi><mi>b</mi></mfrac></mpadded>
→ \frac{a}{b}
Empty padding:
<mpadded width="0em"><mi>x</mi></mpadded>
→ x
Note:
MathML padding attributes (width, height, depth, lwidth, voffset) are
intentionally ignored in LaTeX conversion as there's no direct equivalent.
For precise spacing control in LaTeX, use \hspace{}, \vspace{}, or \phantom{}.
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mpadded visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mpadded> element to LaTeX (transparent wrapper).
Padding attributes are ignored in conversion.
Args:
node: The <mpadded> element to convert
Returns:
LaTeX string with content only (no padding applied)
Raises:
ValueError: If node is not a <mpadded> element
"""
if not self.can_handle(node):
raise ValueError(f"MpaddedVisitor cannot handle element '{node.tag}'")
# Simply convert children, ignoring padding attributes
return self._convert_children(node)
def _convert_children(self, node: ET.Element) -> str:
"""
Convert all child elements to LaTeX.
Args:
node: The <mpadded> element
Returns:
LaTeX string with all children converted
"""
parts = []
for child in node:
parts.append(self._convert_child(child))
return ''.join(parts)
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in mpadded, using text fallback", file=sys.stderr)
return _text_content(child)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mpadded'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mpadded'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MpaddedVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,204 @@
r"""
Visitor for <mphantom> (phantom/invisible content) MathML elements.
The <mphantom> element renders its content invisibly but reserves the space
it would normally occupy. This is useful for alignment and spacing purposes.
In LaTeX, this is converted using the \phantom{} command.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MphantomVisitor(Visitor):
r"""
Handles <mphantom> (phantom/invisible content) MathML elements.
Converts phantom content to LaTeX \phantom{} command, which creates
an invisible placeholder that reserves space.
Examples:
Simple phantom:
<mphantom><mi>x</mi></mphantom>
→ \phantom{x}
Phantom expression:
<mphantom><mrow><mi>a</mi><mo>+</mo><mi>b</mi></mrow></mphantom>
→ \phantom{a+b}
Phantom for alignment:
<mrow>
<mi>x</mi>
<mphantom><mo>+</mo></mphantom>
<mi>y</mi>
</mrow>
→ x\phantom{+}y
Phantom fraction:
<mphantom><mfrac><mi>a</mi><mi>b</mi></mfrac></mphantom>
→ \phantom{\frac{a}{b}}
Note:
The phantom command is useful for:
- Vertical alignment across equations
- Horizontal spacing that matches specific content
- Creating symmetric layouts
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mphantom visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mphantom> element to LaTeX \phantom{} command.
Args:
node: The <mphantom> element to convert
Returns:
LaTeX string with \phantom{content}
Raises:
ValueError: If node is not a <mphantom> element
"""
if not self.can_handle(node):
raise ValueError(f"MphantomVisitor cannot handle element '{node.tag}'")
# Convert children to get the content
content = self._convert_children(node)
# If empty, return empty phantom
if not content or not content.strip():
return "\\phantom{}"
# Wrap content in \phantom{}
return f"\\phantom{{{content}}}"
def _convert_children(self, node: ET.Element) -> str:
"""
Convert all child elements to LaTeX.
Args:
node: The <mphantom> element
Returns:
LaTeX string with all children converted
"""
parts = []
for child in node:
parts.append(self._convert_child(child))
return ''.join(parts)
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in mphantom, using text fallback", file=sys.stderr)
return _text_content(child)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mphantom'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mphantom'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MphantomVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,201 @@
r"""
Visitor for <mroot> (nth root) MathML elements.
The <mroot> element represents nth roots with explicit index.
The LaTeX conversion uses \sqrt[n]{...} notation.
Note: When index is 2, optimizes to \sqrt{...} (standard square root).
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, local_name as _local
class MrootVisitor(Visitor):
r"""
Handles <mroot> (nth root) MathML elements.
Converts nth root expressions to LaTeX \sqrt[n]{...} notation.
Optimizes square roots (index=2) to \sqrt{...}.
Examples:
Cube root:
<mroot><mi>x</mi><mn>3</mn></mroot> → \sqrt[3]{x}
Fourth root:
<mroot><mn>16</mn><mn>4</mn></mroot> → \sqrt[4]{16}
Square root (optimized):
<mroot><mi>x</mi><mn>2</mn></mroot> → \sqrt{x}
Variable index:
<mroot><mi>x</mi><mi>n</mi></mroot> → \sqrt[n]{x}
Complex base:
<mroot>
<mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow>
<mn>3</mn>
</mroot>
→ \sqrt[3]{x + 1}
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mroot visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mroot> element to LaTeX.
Args:
node: The <mroot> element to convert
Returns:
LaTeX nth root string (\sqrt[n]{content} or \sqrt{content})
Raises:
ValueError: If node is not an <mroot> element
ValueError: If mroot has fewer than 2 children
"""
if not self.can_handle(node):
raise ValueError(f"MrootVisitor cannot handle element '{node.tag}'")
children = list(node)
# mroot must have exactly 2 children: base and index
# Handle malformed input gracefully for MathJax compatibility
if len(children) < 2:
if len(children) == 0:
return "\\sqrt{}"
elif len(children) == 1:
# Only base provided, assume square root
base = self._convert_child(children[0])
return f"\\sqrt{self._brace(base)}"
# Convert base (radicand) and index (root degree)
base = self._convert_child(children[0])
index = self._convert_child(children[1])
# Optimize: omit [2] for square roots (standard mathematical notation)
# Also omit empty brackets [] when index is empty
index_stripped = index.strip()
if index_stripped == "2" or not index_stripped:
return f"\\sqrt{self._brace(base)}"
else:
return f"\\sqrt[{index}]{self._brace(base)}"
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in mroot, using text fallback", file=sys.stderr)
return _text_content(child)
def _brace(self, content: str) -> str:
"""Wrap content in braces."""
return f"{{{content}}}"
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mroot'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mroot'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MrootVisitor(strategy={self.strategy.name})"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
r"""
Visitor for <ms> (string literal) MathML elements.
The <ms> element represents a string literal, typically displayed with
surrounding quotes. It's used for text that should be treated as a literal
string rather than mathematical notation.
In LaTeX conversion, ms is converted to \text{} with quotes, similar to mtext.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local, text_content as _text_content
class MsVisitor(Visitor):
r"""
Handles <ms> (string literal) MathML elements.
Converts string literals to LaTeX \text{} with optional quotes.
Examples:
Simple string:
<ms>hello</ms>
→ \text{"hello"}
String without lquote/rquote attributes:
<ms lquote="" rquote="">world</ms>
→ \text{world}
Custom quotes:
<ms lquote="[" rquote="]">text</ms>
→ \text{[text]}
Empty string:
<ms></ms>
→ \text{""}
Note:
Default quotes are double quotes (") unless overridden by
lquote and rquote attributes.
"""
def __init__(self, strategy: Strategy):
"""Initialize the Ms visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <ms> element to LaTeX \text{} with quotes.
Args:
node: The <ms> element to convert
Returns:
LaTeX string with \text{...} and quotes
Raises:
ValueError: If node is not a <ms> element
"""
if not self.can_handle(node):
raise ValueError(f"MsVisitor cannot handle element '{node.tag}'")
# Get quote attributes (default to double quotes)
lquote = node.get("lquote", '"')
rquote = node.get("rquote", '"')
# Get text content
content = _text_content(node)
# Wrap in \text{} with quotes
return f"\\text{{{lquote}{content}{rquote}}}"
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'ms'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'ms'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MsVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,194 @@
r"""
Visitor for <mspace> (spacing) MathML elements.
The <mspace> element represents horizontal spacing in mathematical expressions.
It uses the width attribute to specify the amount of space.
Common spacing types:
- Named spaces: thinmathspace, mediummathspace, thickmathspace
- Negative spaces: negativethinmathspace, etc.
- Explicit sizes: 0.1em, 1em, 2em
This visitor maps MathML spacing to LaTeX spacing commands.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MspaceVisitor(Visitor):
r"""
Handles <mspace> (spacing) MathML elements.
Converts spacing specifications to LaTeX spacing commands.
Examples:
Thin space:
<mspace width="thinmathspace"/>
→ \,
Medium space:
<mspace width="mediummathspace"/>
→ \:
Thick space:
<mspace width="thickmathspace"/>
→ \;
Quad space:
<mspace width="1em"/>
→ \quad
Qquad space:
<mspace width="2em"/>
→ \qquad
Negative space:
<mspace width="negativethinmathspace"/>
→ \!
Default (no width):
<mspace/>
→ \, (thin space)
"""
# Mapping from MathML spacing names to LaTeX commands
SPACING_MAP = {
# Named mathematical spaces
"thinmathspace": "\\,",
"mediummathspace": "\\:",
"thickmathspace": "\\;",
"verythinmathspace": "\\!",
"veryverythinmathspace": "\\!",
# Negative spaces (for fine-tuning)
"negativethinmathspace": "\\!",
"negativemediummathspace": "\\!",
"negativethickmathspace": "\\!",
# Explicit em-based sizes
"0em": "", # No space
"0.1em": "\\,",
"0.2em": "\\:",
"0.3em": "\\;",
"0.5em": "\\;",
"1em": "\\quad",
"2em": "\\qquad",
# Alternative common values
"thin": "\\,",
"medium": "\\:",
"thick": "\\;",
}
def __init__(self, strategy: Strategy):
"""Initialize the Mspace visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mspace> element to LaTeX spacing.
Args:
node: The <mspace> element to convert
Returns:
LaTeX spacing command (e.g., \,, \:, \;, \quad, \qquad)
Raises:
ValueError: If node is not an <mspace> element
"""
if not self.can_handle(node):
raise ValueError(f"MspaceVisitor cannot handle element '{node.tag}'")
# Get width attribute
width = node.get("width", "")
# Handle empty width (default to thin space)
if not width:
return "\\,"
# Check if width is in our mapping
if width in self.SPACING_MAP:
return self.SPACING_MAP[width]
# Try to parse numeric width (e.g., "0.5em", "1.5em")
spacing_cmd = self._parse_numeric_width(width)
if spacing_cmd:
return spacing_cmd
# Default: thin space
return "\\,"
def _parse_numeric_width(self, width: str) -> str:
"""
Parse numeric width values and convert to LaTeX spacing.
Args:
width: Width string (e.g., "0.5em", "1.5em")
Returns:
LaTeX spacing command, or empty string if cannot parse
"""
# Remove whitespace
width = width.strip()
# Try to extract numeric value from em-based widths
if width.endswith("em"):
try:
value = float(width[:-2])
# Map numeric values to spacing commands
if value <= 0:
return ""
elif value < 0.15:
return "\\," # thin
elif value < 0.25:
return "\\:" # medium
elif value < 0.6:
return "\\;" # thick
elif value < 1.5:
return "\\quad"
else:
return "\\qquad"
except ValueError:
pass
# Try to extract numeric value from pt-based widths
if width.endswith("pt"):
try:
value = float(width[:-2])
# Map pt values to spacing (rough approximation)
# 1em ≈ 10pt in typical math fonts
if value <= 0:
return ""
elif value < 2:
return "\\,"
elif value < 4:
return "\\:"
elif value < 6:
return "\\;"
elif value < 12:
return "\\quad"
else:
return "\\qquad"
except ValueError:
pass
# Could not parse
return ""
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mspace'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mspace'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MspaceVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,194 @@
r"""
Visitor for <msqrt> (square root) MathML elements.
The <msqrt> element represents square roots (√).
The LaTeX conversion uses \sqrt{...} notation.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, local_name as _local
class MsqrtVisitor(Visitor):
r"""
Handles <msqrt> (square root) MathML elements.
Converts square root expressions to LaTeX \sqrt notation.
Examples:
Simple number:
<msqrt><mn>2</mn></msqrt> → \sqrt{2}
Variable:
<msqrt><mi>x</mi></msqrt> → \sqrt{x}
Complex expression:
<msqrt>
<mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow>
</msqrt>
→ \sqrt{x + 1}
Nested root:
<msqrt><msqrt><mi>x</mi></msqrt></msqrt> → \sqrt{\sqrt{x}}
Fraction under root:
<msqrt><mfrac><mn>1</mn><mn>2</mn></mfrac></msqrt> → \sqrt{\dfrac{1}{2}}
"""
def __init__(self, strategy: Strategy):
"""Initialize the Msqrt visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <msqrt> element to LaTeX.
Args:
node: The <msqrt> element to convert
Returns:
LaTeX square root string (\sqrt{content})
Raises:
ValueError: If node is not an <msqrt> element
"""
if not self.can_handle(node):
raise ValueError(f"MsqrtVisitor cannot handle element '{node.tag}'")
children = list(node)
# Empty msqrt (edge case)
if not children:
return "\\sqrt{}"
# Convert all children and join with spaces
parts = []
for child in children:
part = self._convert_child(child)
if part: # Only add non-empty parts
parts.append(part)
radicand = ' '.join(parts).strip()
# Return \sqrt{radicand}
return f"\\sqrt{self._brace(radicand)}"
def _convert_child(self, child: ET.Element) -> str:
"""
Convert a child element to LaTeX.
This method instantiates the appropriate visitor for each child type.
"""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
# Recursive: msqrt can contain other msqrt elements
return self.visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in msqrt, using text fallback", file=sys.stderr)
return _text_content(child)
def _brace(self, content: str) -> str:
"""Wrap content in braces."""
return f"{{{content}}}"
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'msqrt'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'msqrt'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MsqrtVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,301 @@
r"""
Visitor for <mstyle> (style attributes) MathML elements.
The <mstyle> element is a wrapper that applies style attributes to its children:
- mathvariant: Font variants (bold, italic, script, fraktur, etc.)
- mathcolor: Text color
- displaystyle: Display vs inline math mode
This visitor is a transparent container that wraps converted children with
appropriate LaTeX style commands.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MstyleVisitor(Visitor):
r"""
Handles <mstyle> (style attributes) MathML elements.
Applies style attributes to child content through LaTeX wrappers.
Examples:
Bold text:
<mstyle mathvariant="bold"><mi>x</mi></mstyle>
→ \mathbf{x}
Colored text:
<mstyle mathcolor="red"><mi>f</mi></mstyle>
→ \textcolor{red}{f}
Script font:
<mstyle mathvariant="script"><mi>L</mi></mstyle>
→ \mathscr{L}
Fraktur font:
<mstyle mathvariant="fraktur"><mi>g</mi></mstyle>
→ \mathfrak{g}
Double-struck (blackboard bold):
<mstyle mathvariant="double-struck"><mi>R</mi></mstyle>
→ \mathbb{R}
Normal (upright) text:
<mstyle mathvariant="normal"><mi>x</mi></mstyle>
→ \mathrm{x}
Display style (affects fractions):
<mstyle displaystyle="true"><mfrac>...</mfrac></mstyle>
→ (context passed to children)
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mstyle visitor."""
super().__init__(strategy)
self._converter_context = None
def set_converter_context(self, converter):
"""Set converter context for color suppression checking."""
self._converter_context = converter
def visit(self, node: ET.Element) -> str:
r"""
Convert <mstyle> element to LaTeX.
Args:
node: The <mstyle> element to convert
Returns:
LaTeX string with style wrappers applied to children
Raises:
ValueError: If node is not an <mstyle> element
"""
if not self.can_handle(node):
raise ValueError(f"MstyleVisitor cannot handle element '{node.tag}'")
# Get style attributes
mathvariant = node.get("mathvariant", "")
displaystyle = node.get("displaystyle", "")
mathcolor = node.get("mathcolor", "")
# Convert all children
content = self._convert_children(node)
# Apply font variants
content = self._apply_mathvariant(content, mathvariant)
# Apply color (if not black, which is default)
content = self._apply_color(content, mathcolor)
# Display style is contextual and doesn't produce direct LaTeX output
# It would be handled by context/strategy if needed
# (In the original implementation, it affects fraction rendering)
return content
def _convert_children(self, node: ET.Element) -> str:
"""
Convert all child elements to LaTeX.
Args:
node: The <mstyle> element
Returns:
LaTeX string with all children converted
"""
parts = []
for child in node:
parts.append(self._convert_child(child))
return ' '.join(parts).strip()
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in mstyle, using text fallback", file=sys.stderr)
return _text_content(child)
def _apply_mathvariant(self, content: str, mathvariant: str) -> str:
"""
Apply mathvariant font styling to content.
Args:
content: The LaTeX content to wrap
mathvariant: The mathvariant attribute value
Returns:
LaTeX content with font variant wrapper
"""
if not mathvariant or not content:
return content
# Map mathvariant values to LaTeX commands
variant_map = {
"bold": "\\mathbf",
"bold-italic": "\\boldsymbol", # Bold italic
"italic": None, # Italic is default in math mode
"normal": "\\mathrm", # Upright/roman text
"script": "\\mathscr",
"fraktur": "\\mathfrak",
"double-struck": "\\mathbb", # Blackboard bold
"monospace": "\\mathtt",
"sans-serif": "\\mathsf",
}
latex_cmd = variant_map.get(mathvariant)
if latex_cmd:
return f"{latex_cmd}{{{content}}}"
else:
# Italic (default) or unknown variant - no wrapper needed
return content
def _apply_color(self, content: str, mathcolor: str) -> str:
"""
Apply color styling to content.
Args:
content: The LaTeX content to wrap
mathcolor: The mathcolor attribute value
Returns:
LaTeX content with color wrapper
"""
# Use centralized color handling with RGB support and suppression
from ..utils.color_helpers import hex_to_latex_color
from ..utils import is_default_color
if not mathcolor or not content:
return content
# Check if suppression is active (uniform color optimization)
# Get context from visitor or strategy (strategy is shared across all visitors)
context = getattr(self, '_converter_context', None)
if context is None and hasattr(self.strategy, '_converter_context'):
context = self.strategy._converter_context
if context and getattr(context, '_suppress_leaf_colors', False):
return content
# Skip default black color
if is_default_color(mathcolor):
return content
# Convert hex color to LaTeX format (with RGB mode support)
color_model, color_spec = hex_to_latex_color(mathcolor)
# Skip if conversion resulted in black
if color_spec == 'black':
return content
# Wrap with appropriate format
if color_model:
return f"\\textcolor{color_model}{{{color_spec}}}{{{content}}}"
else:
return f"\\textcolor{{{color_spec}}}{{{content}}}"
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mstyle'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mstyle'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MstyleVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,244 @@
"""Visitor for <msub> (subscript) MathML elements."""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..core.context import ConversionContext
from ..utils import text_content as _text_content, local_name as _local
class MsubVisitor(Visitor):
"""Handles <msub> (subscript) MathML elements.
Examples:
Simple: <msub><mi>x</mi><mn>1</mn></msub> → x_{1}
Index: <msub><mi>a</mi><mi>i</mi></msub> → a_{i}
"""
def __init__(self, strategy: Strategy, context: ConversionContext = None):
"""
Initialize the Msub visitor.
Args:
strategy: The formatting strategy to use
context: Shared conversion context for state tracking
"""
super().__init__(strategy)
self.context = context
def visit(self, node: ET.Element) -> str:
if not self.can_handle(node):
raise ValueError(f"MsubVisitor cannot handle element '{node.tag}'")
children = list(node)
if len(children) < 2:
raise ValueError(f"msub element must have 2 children, got {len(children)}")
# Convert base (not in script context)
base = self._convert_child(children[0])
# Push script context before converting subscript
if self.context:
self.context.push_script_context('msub')
try:
# Convert subscript (in script context)
subscript = self._convert_child(children[1])
finally:
# Always pop context, even if conversion fails
if self.context:
self.context.pop_script_context()
# Special case: Empty subscript - just return the base
if not subscript or not subscript.strip():
base = self._wrap_if_needed(base)
return base
base = self._wrap_if_needed(base)
# Special case: If base contains a superscript (like f^{\prime}),
# wrap it in braces to prevent syntax errors
# Example: f^{\prime} should become {f^{\prime}}_{x}, not f^{\prime}_{x}
if self._contains_superscript(base):
base = f"{{{base}}}"
return f"{base}_{self._brace(subscript)}"
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy, self.context).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy, self.context).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
# Recursive: msub can contain other msub elements
return self.visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy, self.context).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in msub, using text fallback", file=sys.stderr)
return _text_content(child)
def _wrap_if_needed(self, base: str) -> str:
"""Wrap base in parentheses if needed, avoiding double-wrapping."""
# Check if base already has delimiters (starts and ends with matching delimiters)
base_stripped = base.strip()
if base_stripped:
# Check for matching delimiters
delimiter_pairs = {
'(': ')',
'[': ']',
'{': '}',
'\\left(': '\\right)',
'\\left[': '\\right]',
'\\left\\{': '\\right\\}',
}
for open_delim, close_delim in delimiter_pairs.items():
if base_stripped.startswith(open_delim) and base_stripped.endswith(close_delim):
# Already has delimiters, no need to wrap
return base
# Check if base needs grouping for proper subscript application
# In LaTeX, x_2 applies subscript to x, but "a + b_2" applies only to b
# We need to brace multi-element bases: {a + b}_2
#
# Use braces {} for grouping (invisible) not parentheses () (visible)
# Only wrap if base contains operators that indicate a true multi-element expression
# Don't wrap for spaces alone (e.g., "\Delta H" has space but is semantically single)
math_operators = {'=', '+', '-', '/', '*', '\\times', '\\cdot', '\\pm', '\\to'}
if ' ' in base and any(op in base for op in math_operators):
return f"{{{base}}}"
return base
def _brace(self, content: str) -> str:
return f"{{{content}}}"
def _contains_superscript(self, base: str) -> bool:
"""
Check if base contains a LaTeX superscript (^{...} or ^X).
This is important for proper grouping when adding subscripts.
For example: f^{\\prime} must be wrapped as {f^{\\prime}} before
adding subscripts to avoid invalid syntax.
Args:
base: The base LaTeX string
Returns:
True if base contains an unbraced superscript
"""
if not base or '^' not in base:
return False
# If the entire base is already wrapped in braces, it's safe
base_stripped = base.strip()
if base_stripped.startswith('{') and base_stripped.endswith('}'):
# Check if these braces wrap the entire expression
depth = 0
for i, char in enumerate(base_stripped):
if char == '{':
depth += 1
elif char == '}':
depth -= 1
# If depth hits 0 before the end, the braces don't wrap everything
if depth == 0 and i < len(base_stripped) - 1:
break
# If we maintained depth > 0 until the last character, it's wrapped
if i == len(base_stripped) - 1:
return False
# Base contains ^ and is not fully wrapped
return True
def can_handle(self, node: ET.Element) -> bool:
return _local(node.tag) == 'msub'
@property
def element_name(self) -> str:
return 'msub'
def __repr__(self) -> str:
return f"MsubVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,449 @@
r"""
Visitor for <msubsup> (subscript + superscript) MathML elements.
The <msubsup> element represents simultaneous subscripts and superscripts.
The LaTeX conversion uses base_{subscript}^{superscript} notation.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..core.context import ConversionContext
from ..utils import text_content as _text_content, local_name as _local
class MsubsupVisitor(Visitor):
r"""
Handles <msubsup> (subscript + superscript) MathML elements.
Converts elements with both subscript and superscript to LaTeX notation.
Examples:
Tensor with indices:
<msubsup><mi>T</mi><mi>i</mi><mi>j</mi></msubsup> → T_{i}^{j}
Integral bounds:
<msubsup><mo>∫</mo><mn>0</mn><mn>1</mn></msubsup> → \int_{0}^{1}
Summation bounds:
<msubsup><mo>∑</mo><mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow><mi>n</mi></msubsup>
→ \sum_{i = 1}^{n}
Variable with both scripts:
<msubsup><mi>x</mi><mn>0</mn><mn>2</mn></msubsup> → x_{0}^{2}
Complex base:
<msubsup>
<mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow>
<mn>0</mn>
<mn>2</mn>
</msubsup>
→ (x + 1)_{0}^{2}
"""
def __init__(self, strategy: Strategy, context: ConversionContext = None):
"""
Initialize the Msubsup visitor.
Args:
strategy: The formatting strategy to use
context: Shared conversion context for state tracking
"""
super().__init__(strategy)
self.context = context
def visit(self, node: ET.Element) -> str:
r"""
Convert <msubsup> element to LaTeX.
Args:
node: The <msubsup> element to convert
Returns:
LaTeX string with subscript and superscript (base_{sub}^{sup})
Raises:
ValueError: If node is not an <msubsup> element
ValueError: If msubsup has fewer than 3 children
"""
if not self.can_handle(node):
raise ValueError(f"MsubsupVisitor cannot handle element '{node.tag}'")
children = list(node)
# msubsup must have exactly 3 children: base, subscript, superscript
if len(children) < 3:
raise ValueError(
f"msubsup element must have 3 children (base, subscript, superscript), "
f"got {len(children)}"
)
# Check for sideset pattern: base is <mrow> with 3 <mrow> children
# Pattern: <msubsup><mrow><mrow>left_sub</mrow><mrow>left_sup</mrow><mrow>base</mrow></mrow><mrow>right_sub</mrow><mrow>right_sup</mrow></msubsup>
# Result: \sideset{_{left_sub}^{left_sup}}{_{right_sub}^{right_sup}}{base}
if self._is_sideset_pattern(children[0]):
return self._handle_sideset(children)
# Check if base is a large operator BEFORE converting
is_large_op = self._is_large_operator(children[0])
# Convert base (not in script context)
base = self._convert_child(children[0])
# Push script context before converting subscript and superscript
if self.context:
self.context.push_script_context('msubsup')
try:
# Convert subscript and superscript (both in script context)
subscript = self._convert_child(children[1])
superscript = self._convert_child(children[2])
finally:
# Always pop context, even if conversion fails
if self.context:
self.context.pop_script_context()
# Check for large operators that need \limits
if is_large_op:
return f"{base}\\limits_{self._brace(subscript)}^{self._brace(superscript)}"
# If base is empty (from empty <mrow/>), output just the sub/superscripts
# This allows them to attach to the previous element in LaTeX naturally
if not base or not base.strip():
return f"_{self._brace(subscript)}^{self._brace(superscript)}"
# Wrap base if needed (complex expressions or if it contains superscripts)
base = self._wrap_if_needed(base)
# Special case: If base contains a superscript (like v^{\prime}),
# wrap it in braces to prevent syntax error with additional sub/superscripts
# Example: v^{\prime} should become {v^{\prime}}_{1}^{2}, not v^{\prime}_{1}^{2}
if self._contains_superscript(base):
base = f"{{{base}}}"
# Return base_{subscript}^{superscript}
return f"{base}_{self._brace(subscript)}^{self._brace(superscript)}"
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy, self.context).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy, self.context).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy, self.context).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in msubsup, using text fallback", file=sys.stderr)
return _text_content(child)
def _wrap_if_needed(self, base: str) -> str:
"""
Wrap base in parentheses if needed.
Args:
base: The base content
Returns:
Base, optionally wrapped in parentheses
Note:
Complex expressions with operators should be wrapped.
Simple identifiers and numbers don't need wrapping.
"""
# Check if base already has delimiters (starts and ends with matching delimiters)
base_stripped = base.strip()
if base_stripped:
# Check for matching delimiters
delimiter_pairs = {
'(': ')',
'[': ']',
'{': '}',
'\\left(': '\\right)',
'\\left[': '\\right]',
'\\left\\{': '\\right\\}',
}
for open_delim, close_delim in delimiter_pairs.items():
if base_stripped.startswith(open_delim) and base_stripped.endswith(close_delim):
# Already has delimiters, no need to wrap
return base
# Check if base needs grouping for proper subscript/superscript application
# In LaTeX, x_2 applies subscript to x, but "a + b_2" applies only to b
# We need to brace multi-element bases: {a + b}_2
#
# Use braces {} for grouping (invisible) not parentheses () (visible)
# Only wrap if base contains operators that indicate a true multi-element expression
math_operators = {'=', '+', '-', '/', '*', '\\times', '\\cdot', '\\pm', '\\to'}
if ' ' in base and any(op in base for op in math_operators):
return f"{{{base}}}"
return base
def _brace(self, content: str) -> str:
"""Wrap content in braces."""
return f"{{{content}}}"
def _contains_superscript(self, base: str) -> bool:
"""
Check if base contains a LaTeX superscript (^{...} or ^X).
This is important for proper grouping when adding additional scripts.
For example: v^{\\prime} must be wrapped as {v^{\\prime}} before
adding subscripts/superscripts to avoid invalid syntax.
Args:
base: The base LaTeX string
Returns:
True if base contains an unbraced superscript
"""
# Look for ^ character in the base
# We need to be careful to not match ^ that's already inside braces at the start
# For example: {v^{\prime}} should return False (already wrapped)
# but v^{\prime} should return True (needs wrapping)
if not base or '^' not in base:
return False
# If the entire base is already wrapped in braces, it's safe
base_stripped = base.strip()
if base_stripped.startswith('{') and base_stripped.endswith('}'):
# Check if these braces wrap the entire expression
# Simple heuristic: count brace depth
depth = 0
for i, char in enumerate(base_stripped):
if char == '{':
depth += 1
elif char == '}':
depth -= 1
# If depth hits 0 before the end, the braces don't wrap everything
if depth == 0 and i < len(base_stripped) - 1:
break
# If we maintained depth > 0 until the last character, it's wrapped
if i == len(base_stripped) - 1:
return False
# Base contains ^ and is not fully wrapped
return True
def _is_large_operator(self, base_node: ET.Element) -> bool:
"""Check if base is a large operator that uses limits by default."""
if _local(base_node.tag) != 'mo':
return False
base_text = _text_content(base_node).strip()
# Large operators that display limits above/below in display mode
large_ops = [
"∑", "\\sum", # Summation
"∏", "\\prod", # Product
"∐", "\\coprod", # Coproduct
"∫", "\\int", # Integral
"∬", "\\iint", # Double integral
"∭", "\\iiint", # Triple integral
"∮", "\\oint", # Contour integral
"⋃", "\\bigcup", # Union
"⋂", "\\bigcap", # Intersection
"⋁", "\\bigvee", # Logical OR
"⋀", "\\bigwedge", # Logical AND
"⨁", "\\bigoplus", # Direct sum
"⨂", "\\bigotimes", # Tensor product
"⨄", "\\biguplus", # Multiset union
]
return base_text in large_ops
def _is_sideset_pattern(self, base_node: ET.Element) -> bool:
"""
Check if base node matches the sideset pattern.
Sideset pattern: base is <mrow> with at least 3 children where first 3 are <mrow>
- First <mrow>: left subscript
- Second <mrow>: left superscript
- Third <mrow>: base symbol (first part)
- Additional children: merged with base (e.g., FeMn has Fe in mrow, Mn as mi)
Args:
base_node: The base element to check
Returns:
True if this is a sideset pattern, False otherwise
"""
if _local(base_node.tag) != 'mrow':
return False
# Get direct children of the base <mrow>
children = list(base_node)
# Must have at least 3 children
if len(children) < 3:
return False
# First 3 children must all be <mrow> elements
return all(_local(children[i].tag) == 'mrow' for i in range(3))
def _handle_sideset(self, msubsup_children: list) -> str:
"""
Handle sideset pattern conversion.
Pattern:
<msubsup>
<mrow>
<mrow>left_sub</mrow>
<mrow>left_sup</mrow>
<mrow>base_part1</mrow>
[optional: additional base elements...]
</mrow>
<mrow>right_sub</mrow>
<mrow>right_sup</mrow>
</msubsup>
Converts to: \\sideset{_{left_sub}^{left_sup}}{_{right_sub}^{right_sup}}{base}
Args:
msubsup_children: The 3 children of <msubsup> element
Returns:
LaTeX sideset command
"""
base_mrow = msubsup_children[0]
right_sub_mrow = msubsup_children[1]
right_sup_mrow = msubsup_children[2]
# Extract children from base <mrow>
base_children = list(base_mrow)
left_sub_mrow = base_children[0]
left_sup_mrow = base_children[1]
actual_base_mrow = base_children[2]
# Additional children after the 3rd element should be merged with base
# Example: <mrow>Fe</mrow><mi>Mn</mi> → FeMn
extra_base_elements = base_children[3:] if len(base_children) > 3 else []
# Push script context for converting subscripts and superscripts
if self.context:
self.context.push_script_context('sideset')
try:
# Convert all parts
left_sub = self._convert_child(left_sub_mrow)
left_sup = self._convert_child(left_sup_mrow)
right_sub = self._convert_child(right_sub_mrow)
right_sup = self._convert_child(right_sup_mrow)
finally:
if self.context:
self.context.pop_script_context()
# Convert base (not in script context)
base_parts = [self._convert_child(actual_base_mrow)]
# Add any extra base elements (e.g., Mn after Fe in FeMn)
for extra_elem in extra_base_elements:
base_parts.append(self._convert_child(extra_elem))
# Merge base parts (no spaces for chemical formulas)
base = ''.join(base_parts)
# Build sideset command
left_scripts = f"_{{{left_sub}}}^{{{left_sup}}}"
right_scripts = f"_{{{right_sub}}}^{{{right_sup}}}"
return f"\\sideset{{{left_scripts}}}{{{right_scripts}}}{{{base}}}"
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'msubsup'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'msubsup'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MsubsupVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,469 @@
"""
Visitor for <msup> (superscript) MathML elements.
The <msup> element represents superscripts in MathML:
- Powers: x^2, x^n
- Exponents: e^x, 10^{-3}
- Indices: a^i, T^{ij}
- Prime notation: f', f'', f''' (derivatives)
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..core.context import ConversionContext
from ..utils import text_content as _text_content, local_name as _local
class MsupVisitor(Visitor):
"""
Handles <msup> (superscript) MathML elements.
This visitor converts superscripts to LaTeX format using ^ notation.
Special handling for prime notation (derivatives).
Examples:
Simple power:
<msup><mi>x</mi><mn>2</mn></msup> → x^{2}
Exponent:
<msup><mi>e</mi><mi>x</mi></msup> → e^{x}
Complex base:
<msup>
<mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow>
<mn>2</mn>
</msup>
→ (x + 1)^{2}
Complex exponent:
<msup><mi>x</mi><mrow><mn>2</mn><mi>n</mi></mrow></msup>
→ x^{2 n}
Prime notation (derivatives):
<msup><mi>f</mi><mo>&#x2032;</mo></msup> → f'
<msup><mi>f</mi><mo>&#x2033;</mo></msup> → f''
<msup><mi>y</mi><mo>&#x2034;</mo></msup> → y'''
"""
def __init__(self, strategy: Strategy, context: ConversionContext = None):
"""
Initialize the Msup visitor.
Args:
strategy: The formatting strategy to use
context: Shared conversion context for state tracking
"""
super().__init__(strategy)
self.context = context
def visit(self, node: ET.Element) -> str:
"""
Convert <msup> element to LaTeX.
Args:
node: The <msup> element to convert
Returns:
LaTeX superscript string
Raises:
ValueError: If node is not an <msup> element
ValueError: If msup has fewer than 2 children
"""
if not self.can_handle(node):
raise ValueError(f"MsupVisitor cannot handle element '{node.tag}'")
children = list(node)
if len(children) < 2:
raise ValueError(
f"msup element must have 2 children (base, superscript), "
f"got {len(children)}"
)
# NEW: Check for nested msup and merge if needed
# Pattern: <msup><mrow><msup>base<sup1></msup></mrow><sup2></msup>
# Should become: base^{sup1sup2} instead of base^{sup1}^{sup2}
nested_msup = self._detect_nested_msup(children[0])
if nested_msup is not None:
result = self._merge_nested_superscripts(nested_msup, children[1])
if result:
base_latex, merged_sup_latex = result
base_latex = self._wrap_if_needed(base_latex)
return f"{base_latex}^{self._brace(merged_sup_latex)}"
# Convert base (not in script context)
base = self._convert_child(children[0])
# Push script context before converting superscript
if self.context:
self.context.push_script_context('msup')
try:
# Convert superscript (in script context)
superscript = self._convert_child(children[1])
finally:
# Always pop context, even if conversion fails
if self.context:
self.context.pop_script_context()
# Special case: Empty superscript - just return the base
# This can happen when OMML has <m:sSup> with an empty <m:sup>
if not superscript or not superscript.strip():
base = self._wrap_if_needed(base)
return base
# Special case: Prime notation (\prime → ^{\prime}, \prime\prime → ^{\prime\prime}, etc.)
# If superscript contains only \prime symbols, convert to proper LaTeX prime syntax
if self._is_prime_notation(superscript):
# Wrap base if needed (complex expressions)
base = self._wrap_if_needed(base)
# Remove spaces from prime notation
clean_primes = superscript.replace(' ', '').replace('\xa0', '')
# Convert to proper superscript form: ^{\prime}, ^{\prime\prime}, etc.
return f"{base}^{{{clean_primes}}}"
# Special case: cancelto pattern (menclose with updiagonalstrike + superscript)
# Pattern: <msup><menclose notation="updiagonalstrike">content</menclose><sup>target</sup></msup>
# Should become: \cancelto{target}{content} instead of \cancel{content}^{target}
if self._is_cancel_with_target(children[0], base):
# Extract content from \cancel{content}
content = self._extract_cancel_content(base)
if content:
return f"\\cancelto{self._brace(superscript)}{self._brace(content)}"
# TODO: More special cases
# - Empty base (just return ^{sup})
# - Omit ^{1} for simple variables
# - Large operators with \limits
# Wrap base if needed (complex expressions)
base = self._wrap_if_needed(base)
# Return base^{superscript}
return f"{base}^{self._brace(superscript)}"
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy, self.context).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy, self.context).visit(child)
elif tag == 'msup':
# Recursive: msup can contain other msup elements
return self.visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy, self.context).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in msup, using text fallback", file=sys.stderr)
return _text_content(child)
def _wrap_if_needed(self, base: str) -> str:
"""
Wrap base in parentheses if needed.
Args:
base: The base content
Returns:
Base, optionally wrapped in parentheses
Note:
Complex expressions with operators should be wrapped.
Simple identifiers and numbers don't need wrapping.
"""
# Check if base already has delimiters (starts and ends with matching delimiters)
base_stripped = base.strip()
if base_stripped:
# Check for __DELIM__ markers (used by delimiter pairing processor)
# Pattern: __DELIM__X__DELIM__ ... __DELIM__Y__DELIM__
# These will be converted to \left...\right by post-processor
if base_stripped.startswith('__DELIM__') and '__DELIM__' in base_stripped[9:]:
# Already has delimiter markers, no need to wrap
return base
# Check for matching delimiters
delimiter_pairs = {
'(': ')',
'[': ']',
'{': '}',
'\\left(': '\\right)',
'\\left[': '\\right]',
'\\left\\{': '\\right\\}',
}
for open_delim, close_delim in delimiter_pairs.items():
if base_stripped.startswith(open_delim) and base_stripped.endswith(close_delim):
# Already has delimiters, no need to wrap
return base
# Check if base needs grouping for proper superscript application
# In LaTeX, x^2 applies superscript to x, but "a + b^2" applies only to b
# We need to brace multi-element bases: {a + b}^2
#
# Use braces {} for grouping (invisible) not parentheses () (visible)
# Only wrap if base contains operators that indicate a true multi-element expression
math_operators = {'=', '+', '-', '/', '*', '\\times', '\\cdot', '\\pm', '\\to'}
if ' ' in base and any(op in base for op in math_operators):
return f"{{{base}}}"
return base
def _brace(self, content: str) -> str:
"""Wrap content in braces."""
return f"{{{content}}}"
def _is_prime_notation(self, superscript: str) -> bool:
"""
Check if superscript is prime notation (only \\prime symbols).
Prime notation in LaTeX:
- x^{\\prime} (single prime)
- x^{\\prime\\prime} (double prime)
- x^{\\prime\\prime\\prime} (triple prime)
- x^{\\prime\\prime\\prime\\prime} (quadruple prime)
Args:
superscript: The converted superscript LaTeX string
Returns:
True if superscript contains only \\prime symbols
Note:
Handles spaced primes from mrow conversion (e.g., "\\prime \\prime")
which should become "\\prime\\prime"
"""
# Remove ALL whitespace
no_spaces = superscript.replace(' ', '').replace('\xa0', '')
if not no_spaces:
return False
# Check if content is only \prime symbols (possibly repeated)
# Valid patterns: \prime, \prime\prime, \prime\prime\prime, etc.
# The string should be exactly \prime repeated N times
if not no_spaces.startswith('\\prime'):
return False
# Remove all \prime occurrences and check if anything remains
test = no_spaces.replace('\\prime', '')
return len(test) == 0
def _is_cancel_with_target(self, base_node: ET.Element, base_latex: str) -> bool:
"""
Check if base is a cancel/strike-through that should use cancelto.
Args:
base_node: The base element (first child of msup)
base_latex: The converted LaTeX for the base
Returns:
True if this is a cancel pattern that should use \\cancelto
"""
# Check if base_node is menclose with updiagonalstrike notation
if _local(base_node.tag) == 'menclose':
notation = base_node.get('notation', '')
return 'updiagonalstrike' in notation
# Check if base_node is mrow containing a single menclose
if _local(base_node.tag) == 'mrow':
children = list(base_node)
# If mrow contains exactly one child and it's a menclose
if len(children) == 1 and _local(children[0].tag) == 'menclose':
notation = children[0].get('notation', '')
return 'updiagonalstrike' in notation
return False
def _extract_cancel_content(self, base_latex: str) -> str:
"""
Extract content from \\cancel{content}, \\xcancel{content}, etc.
Args:
base_latex: LaTeX string containing cancel command
Returns:
The content inside the cancel braces, or empty string if not a cancel pattern
Examples:
\\cancel{x} → x
\\xcancel{abc} → abc
\\bcancel{123} → 123
"""
import re
# Pattern: \cancel{content}, \xcancel{content}, \bcancel{content}
match = re.match(r'\\(?:x|b)?cancel\{(.+?)\}$', base_latex)
if match:
return match.group(1)
return ''
def _detect_nested_msup(self, base_node: ET.Element):
"""
Detect if base contains a nested msup element.
Handles two patterns:
1. Direct nested: <msup><msup>...</msup>...</msup>
2. Wrapped in mrow: <msup><mrow><msup>...</msup></mrow>...</msup>
Args:
base_node: The base element (first child of outer msup)
Returns:
The nested msup element if found, None otherwise
"""
# Check direct nested msup
if _local(base_node.tag) == 'msup':
return base_node
# Check msup wrapped in mrow (most common pattern)
if _local(base_node.tag) == 'mrow':
children = list(base_node)
# If mrow contains exactly one child and it's an msup
if len(children) == 1 and _local(children[0].tag) == 'msup':
return children[0]
return None
def _merge_nested_superscripts(self, inner_msup: ET.Element, outer_sup_node: ET.Element):
"""
Merge nested msup with outer superscript.
This handles the pattern:
<msup>
<msup><base><sup1></msup>
<sup2>
</msup>
Which should become: base^{sup1sup2} instead of base^{sup1}^{sup2}
Args:
inner_msup: The nested <msup> element
outer_sup_node: The outer superscript element
Returns:
Tuple of (base_latex, merged_superscript_latex) or None if invalid
Examples:
<msup><msup><mi>n</mi><mn>2</mn></msup><mo>+</mo></msup>
→ ("\\mathrm{n}", "2+")
"""
# Extract inner msup children
inner_children = list(inner_msup)
if len(inner_children) < 2:
return None # Invalid structure
inner_base = inner_children[0]
inner_sup = inner_children[1]
# Convert base (not in script context)
base_latex = self._convert_child(inner_base)
# Convert both superscripts in script context
if self.context:
self.context.push_script_context('msup')
try:
inner_sup_latex = self._convert_child(inner_sup)
outer_sup_latex = self._convert_child(outer_sup_node)
finally:
if self.context:
self.context.pop_script_context()
# Merge superscripts: {inner}{outer}
# Example: inner="2", outer="+" → "2+"
merged_sup = f"{inner_sup_latex}{outer_sup_latex}"
return (base_latex, merged_sup)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'msup'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'msup'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MsupVisitor(strategy={self.strategy.name})"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,182 @@
r"""
Visitor for <mtd> (table cell) MathML elements.
The <mtd> element represents a cell in a table/matrix row. It is a helper
element that contains the actual mathematical content.
This visitor is primarily used by MtrVisitor to convert table cells.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local, text_content as _text_content
class MtdVisitor(Visitor):
r"""
Handles <mtd> (table cell) MathML elements.
Converts table cells to LaTeX format by rendering all child content.
Examples:
Simple number:
<mtd><mn>5</mn></mtd>
→ 5
Variable:
<mtd><mi>x</mi></mtd>
→ x
Expression:
<mtd><mi>x</mi><mo>+</mo><mn>1</mn></mtd>
→ x+1
Fraction:
<mtd><mfrac><mn>1</mn><mn>2</mn></mfrac></mtd>
→ \frac{1}{2}
Empty cell:
<mtd></mtd>
→ (empty string)
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mtd visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mtd> element to LaTeX.
Args:
node: The <mtd> element to convert
Returns:
LaTeX string for cell content
Raises:
ValueError: If node is not an <mtd> element
"""
if not self.can_handle(node):
raise ValueError(f"MtdVisitor cannot handle element '{node.tag}'")
# Convert all children of the cell
# Cells are transparent containers, so just render children
# Join with spaces (like mrow) to ensure proper spacing between elements
parts = []
for child in node:
part = self._convert_child(child)
if part: # Only add non-empty parts
parts.append(part)
return ' '.join(parts).strip()
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in mtd, using text fallback", file=sys.stderr)
return _text_content(child)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mtd'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mtd'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MtdVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,327 @@
r"""
Visitor for <mtext> (text content) MathML elements.
The <mtext> element represents text content within mathematical expressions:
- Descriptive text: "and", "or", "if", "then"
- Unit labels: "kg", "mol", "Hz", "Pa", "m/s"
- Chinese/Unicode text: "电解", "加热", "催化剂"
- Words in equations: "min", "max", "for all"
This visitor wraps text in \text{} or \mathrm{} depending on context.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, local_name as _local, escape_latex_special_chars
from ..data import UNICODE_TO_LATEX, UNICODE_PASSTHROUGH
class MtextVisitor(Visitor):
r"""
Handles <mtext> (text content) MathML elements.
Converts text content to LaTeX using appropriate text commands.
Examples:
Simple text:
<mtext>and</mtext> → \text{and}
Units:
<mtext>kg</mtext> → \text{kg}
Chinese text:
<mtext>电解</mtext> → \text{电解}
Multi-word text:
<mtext>for all</mtext> → \text{for all}
Empty text:
<mtext></mtext> → (empty string)
Text with spaces:
<mtext> spaced </mtext> → \text{ spaced }
"""
# Common SI units (should use \mathrm for units in some contexts)
SI_UNITS = {
# Base units
'm', 'kg', 'g', 's', 'A', 'K', 'mol', 'cd',
# Derived units
'Hz', 'N', 'Pa', 'J', 'W', 'C', 'V', 'F', 'Ω',
'S', 'Wb', 'T', 'H', 'lm', 'lx', 'Bq', 'Gy', 'Sv',
# Common combinations
'km', 'cm', 'mm', 'nm', 'pm',
'km/h', 'm/s', 'm/s²', 'cm³', 'm³',
'kJ', 'MJ', 'kW', 'MW',
'mA', 'kA', 'mV', 'kV',
# Other common units
'L', 'mL', 'atm', 'bar', 'mmHg',
'eV', 'kcal', 'cal',
'°C', '°F', 'K', # Temperature
}
# Common mathematical/logical text words
TEXT_WORDS = {
'and', 'or', 'not', 'if', 'then', 'else',
'for', 'all', 'such', 'that', 'where',
'with', 'when', 'otherwise',
'let', 'be', 'is', 'are',
}
def __init__(self, strategy: Strategy):
"""Initialize the Mtext visitor."""
super().__init__(strategy)
self._converter_context = None
def set_converter_context(self, converter):
"""Set converter context for color suppression checking."""
self._converter_context = converter
def visit(self, node: ET.Element) -> str:
r"""
Convert <mtext> element to LaTeX.
Args:
node: The <mtext> element to convert
Returns:
LaTeX string with text wrapped in \text{} or \mathrm{}
Raises:
ValueError: If node is not an <mtext> element
"""
if not self.can_handle(node):
raise ValueError(f"MtextVisitor cannot handle element '{node.tag}'")
# Get text content
text = _text_content(node)
# Handle empty text
if not text:
return ''
# Escape LaTeX special characters before any other processing
text = escape_latex_special_chars(text)
# Handle invisible function application operator (U+2061)
# This is used to indicate function application but should not be rendered
if text == "⁡": # U+2061 FUNCTION APPLICATION
return "" # Return empty string (invisible)
# CRITICAL: Check if text is a mathematical function BEFORE other processing
# This handles cases like <mtext>sin</mtext> → \sin (not \mathrm{sin})
from ..data import MATH_FUNCTIONS
text_stripped = text.strip()
if text_stripped.lower() in MATH_FUNCTIONS:
return f"\\{text_stripped.lower()}"
# IMPORTANT: Convert Unicode symbols to LaTeX BEFORE wrapping in \text{}
# But first check UNICODE_PASSTHROUGH - keep these as-is for MathJax compatibility
text_stripped = text.strip()
if len(text_stripped) == 1 and text_stripped in UNICODE_PASSTHROUGH:
# Single symbol that should be kept as Unicode
return text_stripped
if len(text_stripped) == 1 and text_stripped in UNICODE_TO_LATEX:
# Single symbol with direct LaTeX mapping - return LaTeX command directly
# (don't wrap in \text{})
return UNICODE_TO_LATEX[text_stripped]
# CRITICAL FIX: For multi-symbol Unicode strings, check if result after conversion
# is ALL LaTeX commands or passthrough symbols. If so, don't wrap in \text{}
#
# This handles cases like:
# <mtext>℧◊</mtext> → \mho\lozenge (not \text{\mho\lozenge})
# <mtext>∠∢⦞</mtext> → \measuredangle\sphericalangle (not \text{...})
# <mtext>∯∰</mtext> → ∯∰ (kept as Unicode for MathJax)
if len(text_stripped) > 1 and all(char in UNICODE_TO_LATEX or char in UNICODE_PASSTHROUGH for char in text_stripped):
# All characters are Unicode symbols
# Convert to LaTeX or keep as Unicode based on PASSTHROUGH set
result_parts = []
for char in text_stripped:
if char in UNICODE_PASSTHROUGH:
result_parts.append(char)
else:
result_parts.append(UNICODE_TO_LATEX[char])
return ''.join(result_parts) # Join without spaces for symbols
# Check for color attributes
from ..utils import is_default_color
color = node.get('mathcolor')
if color and not is_default_color(color):
# Apply color to text (after Unicode conversion if needed)
# Only add textcolor if it's not the default black color
text_converted = self._convert_unicode_in_text(text)
return f"\\textcolor{{{color}}}{{\\text{{{text_converted}}}}}"
# Determine whether to use \text{} or \mathrm{}
# CRITICAL: The default should be \mathrm{} to match wrap_identifiers_in_mathrm=True
# This ensures chemical formulas, units, and identifiers render upright (not italic)
# CRITICAL FIX: Check for operators in text (like "2NaCl+2H")
# If text contains operators, split and process separately
# This prevents wrapping operators in \mathrm{} and avoids nested \mathrm{}
if any(op in text for op in ['+', '-', '=', '<', '>', '×', '·', '/']):
return self._process_complex_mtext(text)
# Check if we should use \mathrm{} (default for most math/chemistry content)
# Use \text{} only for actual prose text (multi-word descriptions)
# If it contains spaces and looks like prose, use \text{}
if ' ' in text and any(word in text_stripped.lower() for word in self.TEXT_WORDS):
# Multi-word prose text
text_converted = self._convert_unicode_in_text(text)
return f"\\text{{{text_converted}}}"
# If it's Chinese text (common in chemistry for reaction conditions), use \mathrm{}
# Chinese text needs upright rendering for chemical equations
if self._contains_chinese(text):
text_converted = self._convert_unicode_in_text(text)
return f"\\mathrm{{{text_converted}}}"
# Default: use \mathrm{} for chemical formulas, units, and identifiers
# This matches the original converter's wrap_identifiers_in_mathrm=True behavior
text_converted = self._convert_unicode_in_text(text)
# CRITICAL FIX: If text_converted is ONLY LaTeX space commands (like ~, \ , \quad, etc.),
# return it directly WITHOUT wrapping in \mathrm{}
# This fixes the issue where <mtext>&#xA0;</mtext> was becoming \mathrm{~} instead of ~
stripped = text_converted.strip()
if stripped in ('~', '\\ ', '\\quad', '\\qquad', '\\!', '\\,', '\\:', '\\;'):
return text_converted # Return space command directly
elif stripped.replace('~', '').replace('\\ ', '').strip() == '':
# Pure spaces (multiple ~ or \ )
return text_converted
return f"\\mathrm{{{text_converted}}}"
def _process_complex_mtext(self, text: str) -> str:
"""
Process complex mtext that contains operators.
Splits text at operators and wraps chemical parts in \\mathrm{}
while keeping operators unwrapped.
Example:
"2NaCl+2H" → "\\mathrm{2NaCl}+\\mathrm{2H}"
Args:
text: Text containing operators
Returns:
Processed LaTeX with operators unwrapped
"""
import re
# Pattern for mathematical operators (including Unicode)
operator_pattern = r'([+\-=<>±∓×·/∩∪∈∉⊆⊇∁∅∧∨¬→←↔⇒⇐⇔≡≠≤≥∞])'
# Split text at operators (operators are preserved in the result)
parts = re.split(operator_pattern, text)
result_parts = []
for part in parts:
part_stripped = part.strip()
if not part_stripped:
continue
# Check if this part is an operator
if re.match(operator_pattern, part_stripped):
# Operator - keep as-is, DON'T wrap in \mathrm{}
result_parts.append(part_stripped)
else:
# Not an operator - wrap in \mathrm{}
# First convert any Unicode symbols
part_converted = self._convert_unicode_in_text(part)
result_parts.append(f"\\mathrm{{{part_converted}}}")
# Join parts without spaces (chemical formulas should be compact)
return ''.join(result_parts)
def _contains_chinese(self, text: str) -> bool:
"""
Check if text contains Chinese (CJK) characters.
Args:
text: Text to check
Returns:
True if text contains Chinese characters, False otherwise
"""
for char in text:
# CJK Unified Ideographs range
if '\u4e00' <= char <= '\u9fff':
return True
return False
def _convert_unicode_in_text(self, text: str) -> str:
"""
Convert Unicode symbols within text to LaTeX equivalents.
For multi-character text with Unicode symbols mixed in,
we need to convert each Unicode character individually.
IMPORTANT: Also converts spaces to LaTeX space commands:
- \\xa0 (NBSP) → ~ (non-breaking space in LaTeX)
- Regular space (when standalone) → \\ (backslash-space)
Args:
text: Text that may contain Unicode symbols
Returns:
Text with Unicode symbols converted to LaTeX
"""
if not text:
return text
# CRITICAL FIX: Convert non-breaking spaces (\xa0) to ~ BEFORE other processing
# This fixes the issue where <mtext>&#xA0;</mtext> was becoming \mathrm{ } instead of ~
result = text
# Step 1: Convert NBSP to LaTeX non-breaking space
if '\xa0' in result:
result = result.replace('\xa0', '~')
# Step 2: Convert regular spaces to LaTeX space (but NOT inside prose text)
# Only convert standalone spaces, not spaces within multi-word text
# Check if this is a single space or contains only spaces
if result.strip() == '' and result:
# Pure space(s) → convert to ~
result = '~' * len(result)
elif result in [' ']:
# Single space → ~
result = '~'
# Step 3: Convert Unicode symbols (except PASSTHROUGH symbols)
# Check if any Unicode symbols are present
has_unicode = any(char in UNICODE_TO_LATEX for char in result)
has_passthrough = any(char in UNICODE_PASSTHROUGH for char in result)
if not has_unicode and not has_passthrough:
return result
# Convert character by character (PASSTHROUGH symbols kept as-is)
converted = []
for char in result:
if char in UNICODE_PASSTHROUGH:
# Keep passthrough symbols as Unicode for MathJax
converted.append(char)
elif char in UNICODE_TO_LATEX:
converted.append(UNICODE_TO_LATEX[char])
else:
converted.append(char)
return ''.join(converted)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mtext'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mtext'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MtextVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,98 @@
r"""
Visitor for <mtr> (table row) MathML elements.
The <mtr> element represents a row in a table/matrix. It is a helper element
that contains <mtd> (table cell) children.
This visitor is primarily used by MtableVisitor to convert table rows.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MtrVisitor(Visitor):
r"""
Handles <mtr> (table row) MathML elements.
Converts table rows to LaTeX format with cells separated by & and
terminated with \\.
Examples:
Simple row:
<mtr><mtd><mn>1</mn></mtd><mtd><mn>2</mn></mtd></mtr>
→ 1 & 2 \\
Row with expressions:
<mtr><mtd><mi>x</mi></mtd><mtd><mi>y</mi></mtd></mtr>
→ x & y \\
Single cell row:
<mtr><mtd><mi>a</mi></mtd></mtr>
→ a \\
Empty row:
<mtr></mtr>
→ \\
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mtr visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mtr> element to LaTeX row.
Args:
node: The <mtr> element to convert
Returns:
LaTeX string with cells separated by & and terminated with \\
Raises:
ValueError: If node is not an <mtr> element
"""
if not self.can_handle(node):
raise ValueError(f"MtrVisitor cannot handle element '{node.tag}'")
# Convert all <mtd> children to cells
cells = []
for child in node:
if _local(child.tag) == "mtd":
cell_latex = self._convert_cell(child)
cells.append(cell_latex)
# Join cells with & and add row terminator
if not cells:
return "\\\\"
return " & ".join(cells) + " \\\\"
def _convert_cell(self, node: ET.Element) -> str:
"""
Convert a single <mtd> (table cell) element.
Args:
node: The <mtd> element
Returns:
LaTeX string for cell content
"""
# Use MtdVisitor for proper cell conversion
from .mtd_visitor import MtdVisitor
return MtdVisitor(self.strategy).visit(node)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mtr'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mtr'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MtrVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,568 @@
r"""
Visitor for <munder> (underscript/underline) MathML elements.
The <munder> element represents content with something below it:
- Underline: line under text
- Operator limits: summation/integral with lower bound only
- Underbrace: brace under expression
- General underscript: any content under base
This implementation handles the most common cases with TODOs for advanced features.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, local_name as _local
class MunderVisitor(Visitor):
r"""
Handles <munder> (underscript/underline) MathML elements.
Converts underscript and underline notation to appropriate LaTeX commands.
Examples:
Underline:
<munder><mi>x</mi><mo>_</mo></munder> → \underline{x}
Summation with lower limit only:
<munder><mo>∑</mo><mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow></munder>
→ \sum_{i=1}
Integral with lower bound:
<munder><mo>∫</mo><mi>a</mi></munder> → \int_{a}
Product with lower limit:
<munder><mo>∏</mo><mi>i</mi></munder> → \prod_{i}
Underbrace:
<munder><mrow><mi>x</mi><mo>+</mo><mi>y</mi></mrow><mo>⏟</mo></munder>
→ \underbrace{x+y}
General underscript:
<munder accent="false"><mi>x</mi><mi>n</mi></munder> → \underset{n}{x}
"""
def __init__(self, strategy: Strategy):
"""Initialize the Munder visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <munder> element to LaTeX.
Args:
node: The <munder> element to convert
Returns:
LaTeX string with appropriate underscript/underline notation
Raises:
ValueError: If node is not an <munder> element
ValueError: If munder has fewer than 2 children
"""
if not self.can_handle(node):
raise ValueError(f"MunderVisitor cannot handle element '{node.tag}'")
children = list(node)
# munder must have exactly 2 children: base and underscript
if len(children) < 2:
raise ValueError(
f"munder element must have 2 children (base, underscript), "
f"got {len(children)}"
)
# IMPORTANT: Check raw text content BEFORE converting children
# to properly detect underline characters (e.g., "―" before it becomes "\textemdash")
under_raw_text = _text_content(children[1]).strip()
# Check for underline notation (underbar character) using RAW text
if self._is_underline_char(under_raw_text):
base = self._convert_child(children[0])
# Handle empty base (fill-in blank pattern): use \quad for visible spacing
if not base or not base.strip():
base = "\\qquad"
return f"\\underline{self._brace(base)}"
# Check for underbrace notation using RAW text (simple case: no label)
if self._is_underbrace_char(under_raw_text):
base = self._convert_child(children[0])
return f"\\underbrace{self._brace(base)}"
# Check for underbrace with label: nested munder with brace character
# Pattern: munder(munder(base, ⏟), label) → \underbrace{base}_{label}
# OR: munder(mrow(munder(base, ⏟)), label) with mrow wrapper (Word often wraps inner munder)
# This is how Word encodes underbrace with labels
inner_munder = None
if len(children) >= 2:
# Check if first child is munder directly
if _local(children[0].tag) == 'munder':
inner_munder = children[0]
# Check if first child is mrow containing munder
elif _local(children[0].tag) == 'mrow':
mrow_children = list(children[0])
# Look for munder inside mrow (may be the only child or one of several)
for child in mrow_children:
if _local(child.tag) == 'munder':
inner_munder = child
break
# Process the inner munder if found
if inner_munder is not None:
inner_children = list(inner_munder)
if len(inner_children) >= 2:
# Check if inner munder's second child is the underbrace character
inner_under_text = _text_content(inner_children[1]).strip()
if inner_under_text == "⏟": # U+23DF Underbrace character
# Extract base from inner munder (first child)
inner_base = self._convert_child(inner_children[0])
# Extract label from outer munder (second child)
label = self._convert_child(children[1])
# Return proper underbrace with subscript label
return f"\\underbrace{self._brace(inner_base)}_{self._brace(label)}"
# Check for ALTERNATIVE underbrace pattern (Word sometimes uses this)
# Pattern: munder(mover(base, ⏞), label) → \underbrace{base}_{label}
# OR: munder(mrow(mover(base, ⏞)), label) with mrow wrapper
# Word uses the overbrace character ⏞ but places it in OVER position to create underbrace
if len(children) >= 2:
# Check if first child is mover (directly or inside mrow)
inner_mover = None
if _local(children[0].tag) == 'mover':
inner_mover = children[0]
elif _local(children[0].tag) == 'mrow':
# Check inside mrow wrapper
for child in children[0]:
if _local(child.tag) == 'mover':
inner_mover = child
break
if inner_mover is not None:
inner_children = list(inner_mover)
if len(inner_children) >= 2:
# Check if inner mover's second child is the overbrace character
inner_over_text = _text_content(inner_children[1]).strip()
if inner_over_text == "⏞": # U+23DE Overbrace char in over position = underbrace
# Extract base from inner mover (first child)
inner_base = self._convert_child(inner_children[0])
# Extract label from outer munder (second child)
label = self._convert_child(children[1])
# Return proper underbrace with subscript label
return f"\\underbrace{self._brace(inner_base)}_{self._brace(label)}"
# Check if base is a large operator (sum, integral, product, etc.)
if self._is_large_operator(children[0]):
# Use subscript for operator limits with \limits
base = self._convert_child(children[0])
under = self._convert_child(children[1])
return f"{base}\\limits_{self._brace(under)}"
# Check for arrow UNDER content pattern (arrow in under position)
# Pattern: <munder><content><mrow><mi>→</mi></mrow></munder>
# This should be converted to \underrightarrow{content}
under_to_check = children[1]
if _local(under_to_check.tag) == 'mrow':
# Unwrap mrow if it contains a single element
mrow_children = list(under_to_check)
if len(mrow_children) == 1:
under_to_check = mrow_children[0]
if _local(under_to_check.tag) in ['mo', 'mi']:
under_text = _text_content(under_to_check).strip()
# Map of arrows that can appear under content
# NOTE: Only single arrows (→, ←, ↔) are used for under-arrow notation
# Double arrows (⇒, ⇐, ⇔) should use underset with \Longrightarrow, not underrightarrow
arrow_commands = {
"→": "underrightarrow",
"←": "underleftarrow",
"↔": "underleftrightarrow",
}
if under_text in arrow_commands:
# Check if base is suitable for arrow notation (numbers, variables, etc.)
if _local(children[0].tag) in ['mi', 'mn', 'mrow', 'mfrac', 'msup', 'msub', 'msubsup']:
content = self._convert_child(children[0])
# Remove mathrm wrapper for variables
content_clean = self._strip_mathrm(content)
arrow_cmd = arrow_commands[under_text]
return f"\\{arrow_cmd}{self._brace(content_clean)}"
# Check for INVERTED arrow pattern from XSLT transformer
# Pattern: <munder><mo>→</mo><mrow>content</mrow></munder>
# This should be converted to \underrightarrow{content} not \underset
# CRITICAL FIX: OMML→MathML conversion often puts arrows in <mi> tags instead of <mo>
# Must check BOTH <mo> AND <mi> tags for arrow symbols
# Also need to unwrap mrow wrappers: <munder><mrow><mi>→</mi></mrow><content></munder>
base_to_check = children[0]
if _local(base_to_check.tag) == 'mrow':
# Unwrap mrow if it contains a single element
mrow_children = list(base_to_check)
if len(mrow_children) == 1:
base_to_check = mrow_children[0]
if _local(base_to_check.tag) in ['mo', 'mi']:
base_text = _text_content(base_to_check).strip()
# Map of arrows that can appear in inverted pattern
# NOTE: Only single arrows (→, ←, ↔) are used for under-arrow notation
# Double arrows (⇒, ⇐, ⇔) should use underset with \Longrightarrow, not underrightarrow
arrow_commands = {
"→": "underrightarrow",
"←": "underleftarrow",
"↔": "underleftrightarrow",
}
if base_text in arrow_commands:
# The content is in the under position
content = self._convert_child(children[1])
# Remove mathrm wrapper for variables
content_clean = self._strip_mathrm(content)
arrow_cmd = arrow_commands[base_text]
return f"\\{arrow_cmd}{self._brace(content_clean)}"
# Check for nested extensible arrow pattern (reversed): <munder><mover>arrow/equals<...>...</mover><...></munder>
# OR: <munder><mrow><mover>arrow/equals<...>...</mover></mrow><...></munder> (Word often adds mrow wrapper)
# This is Word's way of creating arrows with text above and below (reversed nesting)
mover_node = None
if _local(children[0].tag) == 'mover':
# Direct mover child
mover_node = children[0]
elif _local(children[0].tag) == 'mrow':
# Check if mrow contains mover as only significant child
mrow_children = list(children[0])
if len(mrow_children) == 1 and _local(mrow_children[0].tag) == 'mover':
mover_node = mrow_children[0]
else:
# Check if any child is mover (Word sometimes adds multiple mrows)
for child in mrow_children:
if _local(child.tag) == 'mover':
mover_node = child
break
if mover_node is not None:
mover_children = list(mover_node)
if len(mover_children) >= 2:
# The arrow/equals might be in an mrow wrapper
base_node = mover_children[0]
if _local(base_node.tag) == 'mrow':
# Unwrap mrow to find the actual operator
mrow_children = list(base_node)
if mrow_children and _local(mrow_children[0].tag) in ('mo', 'mi', 'mtext'):
base_node = mrow_children[0]
# Check if base is an arrow or equals
xarrow_cmd = self._get_xarrow_command(base_node)
if xarrow_cmd:
# Extract text from over and under (may be wrapped in <mphantom> or <mrow>)
over = self._convert_child(mover_children[1])
under = self._convert_child(children[1])
# Clean up the text (remove \phantom{}, \mathrm{}, etc.)
over_clean = self._clean_arrow_text(over)
under_clean = self._clean_arrow_text(under)
# Re-wrap Chinese text and non-command text in \mathrm{} for chemistry mode
# (since _clean_arrow_text removes it)
if hasattr(self.strategy, 'name') and self.strategy.name == "chemistry":
# Check if text needs \mathrm{} wrapping
# Skip if it's already a LaTeX command (starts with \) or empty
# CRITICAL: Also skip pure numbers (like "1", "222", "2121")
# Numbers should remain as plain numbers, not wrapped in \mathrm{}
if over_clean and not over_clean.startswith("\\") and over_clean.strip():
# Check if it's a pure number (digits only, possibly with decimal point)
if not over_clean.replace('.', '', 1).isdigit():
# Not a pure number - wrap it in \mathrm{}
over_clean = f"\\mathrm{{{over_clean}}}"
if under_clean and not under_clean.startswith("\\") and under_clean.strip():
# Check if it's a pure number (digits only, possibly with decimal point)
if not under_clean.replace('.', '', 1).isdigit():
# Not a pure number - wrap it in \mathrm{}
under_clean = f"\\mathrm{{{under_clean}}}"
# Use \quad for empty positions (from <mphantom> elements)
if not under_clean or under_clean.strip() == "":
under_clean = "\\quad"
if not over_clean or over_clean.strip() == "":
over_clean = "\\quad"
return f"\\{xarrow_cmd}[{under_clean}]{{{over_clean}}}"
# TODO: Additional underscript types
# - Underbar variants
# - Custom decorations
# Default: general underscript using \underset
base = self._convert_child(children[0])
under = self._convert_child(children[1])
return f"\\underset{self._brace(under)}{self._brace(base)}"
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in munder, using text fallback", file=sys.stderr)
return _text_content(child)
def _is_underline_char(self, latex_str: str) -> bool:
"""Check if string represents an underline character."""
# Common underline/bar characters used in mathematical notation
underline_chars = [
"―", # Em dash (U+2015) - commonly used for underline in Word
"_", # Underscore
"‾", # Overline character (also used for underline)
"¯", # Macron
"⎽", # Horizontal line extension
"﹍", # Wavy low line
"﹎", # Centreline low line
"﹏", # Wavy overline
"-", # Regular hyphen-minus
"−", # Minus sign (U+2212)
"‗", # Double low line
"⎺", # Horizontal line
"⎻", # Another horizontal line
]
return latex_str.strip() in underline_chars
def _is_underbrace_char(self, latex_str: str) -> bool:
"""Check if string represents an underbrace character."""
underbrace_chars = ["⏟", "︸", "\\underbrace"]
return latex_str.strip() in underbrace_chars
def _is_large_operator(self, base_node: ET.Element) -> bool:
"""
Check if base is a large operator (sum, integral, product, etc.).
Recursively searches through wrapper elements like <mstyle>, <mrow>, <mpadded>
to find the actual operator, since Word often wraps large operators in
<mstyle mathsize="140%"><mrow><mo>∑</mo></mrow></mstyle>.
"""
if base_node is None:
return False
tag = _local(base_node.tag)
# Define large operators list once
large_ops = [
"∑", "\\sum", # Summation
"∏", "\\prod", # Product
"∐", "\\coprod", # Coproduct
"∫", "\\int", # Integral
"∬", "\\iint", # Double integral
"∭", "\\iiint", # Triple integral
"∮", "\\oint", # Contour integral
"⋃", "\\bigcup", # Union
"⋂", "\\bigcap", # Intersection
"⋁", "\\bigvee", # Logical OR
"⋀", "\\bigwedge", # Logical AND
"⨁", "\\bigoplus", # Direct sum
"⨂", "\\bigotimes", # Tensor product
"⨄", "\\biguplus", # Multiset union
"lim", "\\lim", # Limit (often in <mi> from Word)
"sup", "\\sup", # Supremum
"inf", "\\inf", # Infimum
"max", "\\max", # Maximum
"min", "\\min", # Minimum
]
# Check direct <mo> or <mi> elements
if tag in ('mo', 'mi'):
base_text = _text_content(base_node).strip()
return base_text in large_ops
# Recursively search through wrapper elements (mstyle, mrow, mpadded, menclose)
# This handles Word's pattern: <munder><mstyle><mrow><mo>∑</mo></mrow></mstyle>...
if tag in ('mstyle', 'mrow', 'mpadded', 'menclose'):
for child in base_node:
if self._is_large_operator(child):
return True
return False
def _get_xarrow_command(self, base_node: ET.Element) -> str:
"""
Get extensible arrow command for the base element.
Returns command name (without backslash) if base is an arrow/equals,
otherwise returns empty string.
Supported arrows:
- = → xlongequal
- → → xrightarrow
- ← → xleftarrow
- ↔ → xleftrightarrow
- ⇌ → xrightleftharpoons
"""
# Accept <mo> (operator), <mi> (identifier), and <mtext> (text) elements for arrows
# Word's XSLT sometimes produces <mi> for arrows instead of <mo>
if _local(base_node.tag) not in ('mo', 'mi', 'mtext'):
return ""
base_text = _text_content(base_node).strip()
# Map arrow symbols to xarrow commands
arrow_map = {
"=": "xlongequal",
"→": "xrightarrow",
"\\rightarrow": "xrightarrow",
"←": "xleftarrow",
"\\leftarrow": "xleftarrow",
"↔": "xleftrightarrow",
"\\leftrightarrow": "xleftrightarrow",
"⟷": "xleftrightarrow", # U+27F7 long left-right arrow
"\\longleftrightarrow": "xleftrightarrow",
"⇌": "xrightleftharpoons",
"\\rightleftharpoons": "xrightleftharpoons",
}
return arrow_map.get(base_text, "")
def _clean_arrow_text(self, text: str) -> str:
"""
Clean up text for use in arrow labels.
Removes:
- \\phantom{} wrappers (Word uses these for arrow labels)
- \\mathrm{} wrappers
- \\text{} wrappers
- Extra spaces between characters
"""
import re
# Remove \phantom{} wrappers (Word's equation editor uses these for arrow labels)
text = re.sub(r'\\phantom\{([^}]*)\}', r'\1', text)
# Remove \mathrm{} wrappers
text = re.sub(r'\\mathrm\{([^}]*)\}', r'\1', text)
# Remove \text{} wrappers (but keep the content)
# For Chinese text, we want to preserve it as-is
text = re.sub(r'\\text\{([^}]*)\}', r'\1', text)
# Remove extra spaces between individual characters
# "高 压" → "高压", but preserve intentional spaces in phrases
text = re.sub(r'(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])', '', text)
# For non-Chinese text with spaces between each char, remove spaces
# "a b c" → "abc", but "word1 word2" stays as-is
if len(text.replace(' ', '')) > 0:
# Check if it's all single chars separated by spaces
parts = text.split()
if all(len(p) == 1 for p in parts):
text = ''.join(parts)
return text.strip()
def _strip_mathrm(self, latex_str: str) -> str:
r"""Remove \mathrm{...} wrapper if present."""
import re
# Remove \mathrm{} wrappers
latex_str = re.sub(r'\\mathrm\{([a-zA-Z0-9]+)\}', r'\1', latex_str)
# Remove spaces in vector notation (a b c -> abc)
if re.match(r'^[a-zA-Z0-9](\s+[a-zA-Z0-9])+$', latex_str):
latex_str = re.sub(r'\s+', '', latex_str)
return latex_str
def _brace(self, content: str) -> str:
"""Wrap content in braces."""
return f"{{{content}}}"
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'munder'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'munder'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MunderVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,463 @@
r"""
Visitor for <munderover> (under+over script) MathML elements.
The <munderover> element represents content with both something above and below it:
- Large operators with limits: summation, integral, product with both bounds
- \sum_{i=1}^{n}, \int_{a}^{b}, \prod_{i=1}^{n}
- Union/intersection with limits
- General underover scripts
This visitor is CRITICAL because it handles \limits properly, unlike msubsup.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, local_name as _local
class MunderoverVisitor(Visitor):
r"""
Handles <munderover> (under+over script) MathML elements.
Converts under+over notation to appropriate LaTeX commands, especially
for large operators with both upper and lower limits.
Examples:
Summation with limits:
<munderover>
<mo>∑</mo>
<mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow>
<mi>n</mi>
</munderover> → \sum_{i=1}^{n}
Integral with bounds:
<munderover>
<mo>∫</mo>
<mi>a</mi>
<mi>b</mi>
</munderover> → \int_{a}^{b}
Product with limits:
<munderover>
<mo>∏</mo>
<mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow>
<mi>n</mi>
</munderover> → \prod_{i=1}^{n}
Union with limits:
<munderover>
<mo>⋃</mo>
<mi>i</mi>
<mi>n</mi>
</munderover> → \bigcup_{i}^{n}
General underover:
<munderover>
<mi>X</mi>
<mi>a</mi>
<mi>b</mi>
</munderover> → \overset{b}{\underset{a}{X}}
"""
def __init__(self, strategy: Strategy):
"""Initialize the Munderover visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <munderover> element to LaTeX.
Args:
node: The <munderover> element to convert
Returns:
LaTeX string with appropriate under+over notation
Raises:
ValueError: If node is not an <munderover> element
ValueError: If munderover has fewer than 3 children
"""
if not self.can_handle(node):
raise ValueError(f"MunderoverVisitor cannot handle element '{node.tag}'")
children = list(node)
# munderover must have exactly 3 children: base, underscript, overscript
if len(children) < 3:
raise ValueError(
f"munderover element must have 3 children (base, under, over), "
f"got {len(children)}"
)
base = self._convert_child(children[0])
under = self._convert_child(children[1])
over = self._convert_child(children[2])
# Check if base is a large operator (sum, integral, product, etc.)
if self._is_large_operator(children[0]):
# Use subscript/superscript notation for operator limits with \limits
# \sum\limits_{i=1}^{n}
return f"{base}\\limits_{self._brace(under)}^{self._brace(over)}"
# Check if this should use \limits explicitly
# (for operators that don't default to limit placement)
if self._should_use_limits(children[0]):
return f"{base}\\limits_{self._brace(under)}^{self._brace(over)}"
# Check for extensible arrow patterns (arrows/equals with text above and below)
# This is commonly used for chemical reactions and annotated arrows
# First, unwrap mrow if the base is wrapped (Word often does this)
base_node = children[0]
if _local(base_node.tag) == 'mrow':
# Check if mrow contains a single operator/arrow
mrow_children = list(base_node)
if len(mrow_children) == 1 and _local(mrow_children[0].tag) in ('mo', 'mi', 'mtext'):
base_node = mrow_children[0]
xarrow_cmd = self._get_xarrow_command(base_node)
if xarrow_cmd:
# Check if original content had \mathrm{} (indicates mathvariant="normal")
# We need to preserve this semantic after cleaning
over_had_mathrm = '\\mathrm{' in over
under_had_mathrm = '\\mathrm{' in under
# Clean up the text (remove \phantom{}, \mathrm{}, \text{}, extra spaces)
over_clean = self._clean_arrow_text(over)
under_clean = self._clean_arrow_text(under)
# Re-wrap text in \mathrm{} if:
# 1. Original content had \mathrm{} (from mathvariant="normal")
# 2. Or in chemistry mode for non-command text
# This preserves the mathvariant="normal" semantic from the input MathML
# Skip if it's already a LaTeX command (starts with \) or empty
# Also skip pure numbers (like "1", "222", "2121") - they should remain plain
def should_wrap_mathrm(text: str, had_mathrm: bool) -> bool:
if not text or not text.strip():
return False
if text.startswith("\\"):
return False # Already a LaTeX command
if text.replace('.', '', 1).isdigit():
return False # Pure number
# Wrap if original had \mathrm{} OR in chemistry mode
if had_mathrm:
return True
if hasattr(self.strategy, 'name') and self.strategy.name == "chemistry":
return True
return False
if should_wrap_mathrm(over_clean, over_had_mathrm):
over_clean = f"\\mathrm{{{over_clean}}}"
if should_wrap_mathrm(under_clean, under_had_mathrm):
under_clean = f"\\mathrm{{{under_clean}}}"
# Use \quad for empty positions (from <mphantom> elements)
# This creates proper spacing in chemical equations
if not under_clean or under_clean.strip() == "":
under_clean = "\\quad"
if not over_clean or over_clean.strip() == "":
over_clean = "\\quad"
return f"\\{xarrow_cmd}[{under_clean}]{{{over_clean}}}"
# Default: general under+over using nested \overset and \underset
# \overset{b}{\underset{a}{X}}
return f"\\overset{self._brace(over)}{{\\underset{self._brace(under)}{self._brace(base)}}}"
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in munderover, using text fallback", file=sys.stderr)
return _text_content(child)
def _is_large_operator(self, base_node: ET.Element) -> bool:
"""
Check if base is a large operator that uses limits by default.
Recursively searches through wrapper elements like <mstyle>, <mrow>, <mpadded>
to find the actual operator, since Word often wraps large operators in
<mstyle mathsize="140%"><mrow><mo>∑</mo></mrow></mstyle>.
"""
if base_node is None:
return False
tag = _local(base_node.tag)
# Large operators that display limits above/below in display mode
large_ops = [
"∑", "\\sum", # Summation
"∏", "\\prod", # Product
"∐", "\\coprod", # Coproduct
"∫", "\\int", # Integral
"∬", "\\iint", # Double integral
"∭", "\\iiint", # Triple integral
"∮", "\\oint", # Contour integral
"⋃", "\\bigcup", # Union
"⋂", "\\bigcap", # Intersection
"⋁", "\\bigvee", # Logical OR
"⋀", "\\bigwedge", # Logical AND
"⨁", "\\bigoplus", # Direct sum
"⨂", "\\bigotimes", # Tensor product
"⨄", "\\biguplus", # Multiset union
]
# Check direct <mo> or <mi> elements
if tag in ('mo', 'mi'):
base_text = _text_content(base_node).strip()
return base_text in large_ops
# Recursively search through wrapper elements (mstyle, mrow, mpadded, menclose)
# This handles Word's pattern: <munderover><mstyle><mrow><mo>∑</mo></mrow></mstyle>...
if tag in ('mstyle', 'mrow', 'mpadded', 'menclose'):
for child in base_node:
if self._is_large_operator(child):
return True
return False
def _should_use_limits(self, base_node: ET.Element) -> bool:
"""Check if operator should explicitly use \\limits."""
if _local(base_node.tag) != 'mo':
return False
base_text = _text_content(base_node).strip()
# Operators that need explicit \limits to display limits above/below
limits_ops = [
"lim", "\\lim", # Limit
"sup", "\\sup", # Supremum
"inf", "\\inf", # Infimum
"max", "\\max", # Maximum
"min", "\\min", # Minimum
"limsup", "\\limsup", # Limit superior
"liminf", "\\liminf", # Limit inferior
"det", "\\det", # Determinant
"gcd", "\\gcd", # Greatest common divisor
]
return base_text in limits_ops
def _brace(self, content: str) -> str:
"""Wrap content in braces."""
return f"{{{content}}}"
def _get_xarrow_command(self, base_node: ET.Element) -> str:
"""
Get extensible arrow command for the base element.
Returns command name (without backslash) if base is an arrow/equals,
otherwise returns empty string.
Supported arrows:
- = → xlongequal
- → → xrightarrow
- ← → xleftarrow
- ↔ → xleftrightarrow
- ⇌ → xrightleftharpoons
"""
# Accept <mo> (operator), <mi> (identifier), and <mtext> (text) elements for arrows
# Word's XSLT sometimes produces <mi> for arrows instead of <mo>
if _local(base_node.tag) not in ('mo', 'mi', 'mtext'):
return ""
base_text = _text_content(base_node).strip()
# Map arrow symbols to xarrow commands
arrow_map = {
"=": "xlongequal",
"→": "xrightarrow",
"\\rightarrow": "xrightarrow",
"←": "xleftarrow",
"\\leftarrow": "xleftarrow",
"↔": "xleftrightarrow",
"\\leftrightarrow": "xleftrightarrow",
"⟷": "xleftrightarrow", # U+27F7 long left-right arrow
"\\longleftrightarrow": "xleftrightarrow",
"⇌": "xrightleftharpoons",
"\\rightleftharpoons": "xrightleftharpoons",
}
return arrow_map.get(base_text, "")
def _clean_arrow_text(self, text: str) -> str:
"""
Clean up text for use in arrow labels.
Removes:
- \\phantom{} wrappers (Word uses these for arrow labels)
- \\mathrm{} wrappers (with proper nested brace handling)
- \\text{} wrappers
- Extra spaces between characters
"""
import re
# Helper function to remove LaTeX commands with balanced braces
def remove_command(text: str, command: str) -> str:
"""Remove LaTeX command with proper brace balancing.
Args:
text: Input text with LaTeX commands
command: Command to remove (e.g., "\\mathrm" or "mathrm")
"""
# Normalize command: ensure it starts with backslash
if not command.startswith('\\'):
command = '\\' + command
while command in text:
# Find the command
idx = text.find(command)
if idx == -1:
break
# Find the opening brace (should be right after command)
brace_start = idx + len(command)
if brace_start >= len(text) or text[brace_start] != '{':
break # Malformed - no opening brace
# Match balanced braces
brace_count = 1
pos = brace_start + 1
while pos < len(text) and brace_count > 0:
if text[pos] == '{' and (pos == 0 or text[pos-1] != '\\'):
brace_count += 1
elif text[pos] == '}' and (pos == 0 or text[pos-1] != '\\'):
brace_count -= 1
pos += 1
if brace_count == 0:
# Successfully matched braces
content = text[brace_start+1:pos-1]
text = text[:idx] + content + text[pos:]
else:
break # Malformed braces
return text
# Remove \phantom{} wrappers (Word's equation editor uses these for arrow labels)
text = remove_command(text, '\\phantom')
# Remove \mathrm{} wrappers (with nested brace handling)
text = remove_command(text, '\\mathrm')
# Remove \text{} wrappers (but keep the content)
# For Chinese text, we want to preserve it as-is
text = remove_command(text, '\\text')
# Remove extra spaces between individual characters
# "高 压" → "高压", but preserve intentional spaces in phrases
text = re.sub(r'(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])', '', text)
# For non-Chinese text with spaces between each char, remove spaces
# "a b c" → "abc", but "word1 word2" stays as-is
if len(text.replace(' ', '')) > 0:
# Check if it's all single chars separated by spaces
parts = text.split()
if all(len(p) == 1 for p in parts):
text = ''.join(parts)
return text.strip()
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'munderover'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'munderover'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MunderoverVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,148 @@
"""
Visitor registry/factory for MathML element visitors.
This module provides a centralized registry for mapping MathML element
names to their corresponding visitor classes.
"""
from typing import Dict, Type, Optional, List
from ..core.interfaces import Visitor, Strategy, VisitorRegistry as IVisitorRegistry
from ..core.context import ConversionContext
class VisitorRegistry(IVisitorRegistry):
"""
Registry for MathML element visitors.
This class maintains a mapping of element names (e.g., 'mi', 'mo', 'mn')
to their corresponding visitor classes. It provides visitor instances
on demand with the appropriate strategy and context injected.
Example:
>>> registry = VisitorRegistry()
>>> registry.register('mi', MiVisitor)
>>> registry.register('mo', MoVisitor)
>>> strategy = MathStrategy()
>>> context = ConversionContext(input_type='mathml')
>>> mi_visitor = registry.get_visitor('mi', strategy, context)
"""
def __init__(self, context: Optional[ConversionContext] = None):
"""
Initialize the registry with empty mappings.
Args:
context: Optional shared conversion context for all visitors
"""
self._visitors: Dict[str, Type[Visitor]] = {}
self._default_visitor: Type[Visitor] | None = None
self.context = context
def register(self, element_name: str, visitor_class: type) -> None:
"""
Register a visitor class for a specific element type.
Args:
element_name: MathML element name (e.g., 'mi', 'mo', 'mn')
visitor_class: The visitor class to handle this element
Raises:
TypeError: If visitor_class is not a subclass of Visitor
"""
if not issubclass(visitor_class, Visitor):
raise TypeError(
f"visitor_class must be a subclass of Visitor, "
f"got {type(visitor_class)}"
)
self._visitors[element_name] = visitor_class
def get_visitor(self, element_name: str, strategy: Strategy, context: Optional[ConversionContext] = None) -> Visitor:
"""
Get a visitor instance for the given element type.
Args:
element_name: MathML element name (e.g., 'mi', 'mo')
strategy: The conversion strategy to inject into the visitor
context: Optional conversion context (uses registry's context if not provided)
Returns:
Visitor instance configured with the strategy and context
Raises:
ValueError: If no visitor registered for element_name and no default set
"""
visitor_class = self._visitors.get(element_name)
if visitor_class is None:
if self._default_visitor is not None:
visitor_class = self._default_visitor
else:
raise ValueError(
f"No visitor registered for element '{element_name}' "
f"and no default visitor configured"
)
# Use provided context or fall back to registry's context
ctx = context if context is not None else self.context
# Try to instantiate with context, fall back to strategy-only if needed
try:
return visitor_class(strategy, ctx)
except TypeError:
# Visitor doesn't accept context parameter yet
return visitor_class(strategy)
def has_visitor(self, element_name: str) -> bool:
"""
Check if a visitor is registered for the element type.
Args:
element_name: MathML element name
Returns:
True if visitor is registered or default exists, False otherwise
"""
return element_name in self._visitors or self._default_visitor is not None
def set_default_visitor(self, visitor_class: Type[Visitor]) -> None:
"""
Set a default visitor for unregistered element types.
Args:
visitor_class: The visitor class to use as default
Raises:
TypeError: If visitor_class is not a subclass of Visitor
"""
if not issubclass(visitor_class, Visitor):
raise TypeError(
f"visitor_class must be a subclass of Visitor, "
f"got {type(visitor_class)}"
)
self._default_visitor = visitor_class
def get_registered_elements(self) -> List[str]:
"""
Get list of all registered element names.
Returns:
List of element names with registered visitors
"""
return list(self._visitors.keys())
def clear(self) -> None:
"""Clear all registered visitors."""
self._visitors.clear()
self._default_visitor = None
def __repr__(self) -> str:
"""String representation for debugging."""
elements = ', '.join(self._visitors.keys()) if self._visitors else 'none'
default = f", default={self._default_visitor.__name__}" if self._default_visitor else ""
return f"VisitorRegistry(registered=[{elements}]{default})"
def __len__(self) -> int:
"""Get number of registered visitors."""
return len(self._visitors)