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:
74
scripts/omml_mathml2latex/visitors/__init__.py
Normal file
74
scripts/omml_mathml2latex/visitors/__init__.py
Normal 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',
|
||||
]
|
||||
137
scripts/omml_mathml2latex/visitors/_convert_child_template.py
Normal file
137
scripts/omml_mathml2latex/visitors/_convert_child_template.py
Normal 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)
|
||||
215
scripts/omml_mathml2latex/visitors/maction_visitor.py
Normal file
215
scripts/omml_mathml2latex/visitors/maction_visitor.py
Normal 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})"
|
||||
88
scripts/omml_mathml2latex/visitors/maligngroup_visitor.py
Normal file
88
scripts/omml_mathml2latex/visitors/maligngroup_visitor.py
Normal 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})"
|
||||
92
scripts/omml_mathml2latex/visitors/malignmark_visitor.py
Normal file
92
scripts/omml_mathml2latex/visitors/malignmark_visitor.py
Normal 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})"
|
||||
426
scripts/omml_mathml2latex/visitors/menclose_visitor.py
Normal file
426
scripts/omml_mathml2latex/visitors/menclose_visitor.py
Normal 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})"
|
||||
194
scripts/omml_mathml2latex/visitors/merror_visitor.py
Normal file
194
scripts/omml_mathml2latex/visitors/merror_visitor.py
Normal 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})"
|
||||
532
scripts/omml_mathml2latex/visitors/mfenced_visitor.py
Normal file
532
scripts/omml_mathml2latex/visitors/mfenced_visitor.py
Normal 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})"
|
||||
608
scripts/omml_mathml2latex/visitors/mfrac_visitor.py
Normal file
608
scripts/omml_mathml2latex/visitors/mfrac_visitor.py
Normal 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})"
|
||||
105
scripts/omml_mathml2latex/visitors/mglyph_visitor.py
Normal file
105
scripts/omml_mathml2latex/visitors/mglyph_visitor.py
Normal 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})"
|
||||
772
scripts/omml_mathml2latex/visitors/mi_visitor.py
Normal file
772
scripts/omml_mathml2latex/visitors/mi_visitor.py
Normal 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})"
|
||||
200
scripts/omml_mathml2latex/visitors/mlabeledtr_visitor.py
Normal file
200
scripts/omml_mathml2latex/visitors/mlabeledtr_visitor.py
Normal 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})"
|
||||
288
scripts/omml_mathml2latex/visitors/mmultiscripts_visitor.py
Normal file
288
scripts/omml_mathml2latex/visitors/mmultiscripts_visitor.py
Normal 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})"
|
||||
136
scripts/omml_mathml2latex/visitors/mn_visitor.py
Normal file
136
scripts/omml_mathml2latex/visitors/mn_visitor.py
Normal 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})"
|
||||
388
scripts/omml_mathml2latex/visitors/mo_visitor.py
Normal file
388
scripts/omml_mathml2latex/visitors/mo_visitor.py
Normal 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})"
|
||||
911
scripts/omml_mathml2latex/visitors/mover_visitor.py
Normal file
911
scripts/omml_mathml2latex/visitors/mover_visitor.py
Normal 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})"
|
||||
198
scripts/omml_mathml2latex/visitors/mpadded_visitor.py
Normal file
198
scripts/omml_mathml2latex/visitors/mpadded_visitor.py
Normal 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})"
|
||||
204
scripts/omml_mathml2latex/visitors/mphantom_visitor.py
Normal file
204
scripts/omml_mathml2latex/visitors/mphantom_visitor.py
Normal 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})"
|
||||
201
scripts/omml_mathml2latex/visitors/mroot_visitor.py
Normal file
201
scripts/omml_mathml2latex/visitors/mroot_visitor.py
Normal 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})"
|
||||
1665
scripts/omml_mathml2latex/visitors/mrow_visitor.py
Normal file
1665
scripts/omml_mathml2latex/visitors/mrow_visitor.py
Normal file
File diff suppressed because it is too large
Load Diff
85
scripts/omml_mathml2latex/visitors/ms_visitor.py
Normal file
85
scripts/omml_mathml2latex/visitors/ms_visitor.py
Normal 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})"
|
||||
194
scripts/omml_mathml2latex/visitors/mspace_visitor.py
Normal file
194
scripts/omml_mathml2latex/visitors/mspace_visitor.py
Normal 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})"
|
||||
194
scripts/omml_mathml2latex/visitors/msqrt_visitor.py
Normal file
194
scripts/omml_mathml2latex/visitors/msqrt_visitor.py
Normal 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})"
|
||||
301
scripts/omml_mathml2latex/visitors/mstyle_visitor.py
Normal file
301
scripts/omml_mathml2latex/visitors/mstyle_visitor.py
Normal 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})"
|
||||
244
scripts/omml_mathml2latex/visitors/msub_visitor.py
Normal file
244
scripts/omml_mathml2latex/visitors/msub_visitor.py
Normal 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})"
|
||||
449
scripts/omml_mathml2latex/visitors/msubsup_visitor.py
Normal file
449
scripts/omml_mathml2latex/visitors/msubsup_visitor.py
Normal 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})"
|
||||
469
scripts/omml_mathml2latex/visitors/msup_visitor.py
Normal file
469
scripts/omml_mathml2latex/visitors/msup_visitor.py
Normal 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>′</mo></msup> → f'
|
||||
<msup><mi>f</mi><mo>″</mo></msup> → f''
|
||||
<msup><mi>y</mi><mo>‴</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})"
|
||||
1003
scripts/omml_mathml2latex/visitors/mtable_visitor.py
Normal file
1003
scripts/omml_mathml2latex/visitors/mtable_visitor.py
Normal file
File diff suppressed because it is too large
Load Diff
182
scripts/omml_mathml2latex/visitors/mtd_visitor.py
Normal file
182
scripts/omml_mathml2latex/visitors/mtd_visitor.py
Normal 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})"
|
||||
327
scripts/omml_mathml2latex/visitors/mtext_visitor.py
Normal file
327
scripts/omml_mathml2latex/visitors/mtext_visitor.py
Normal 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> </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> </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})"
|
||||
98
scripts/omml_mathml2latex/visitors/mtr_visitor.py
Normal file
98
scripts/omml_mathml2latex/visitors/mtr_visitor.py
Normal 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})"
|
||||
568
scripts/omml_mathml2latex/visitors/munder_visitor.py
Normal file
568
scripts/omml_mathml2latex/visitors/munder_visitor.py
Normal 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})"
|
||||
463
scripts/omml_mathml2latex/visitors/munderover_visitor.py
Normal file
463
scripts/omml_mathml2latex/visitors/munderover_visitor.py
Normal 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})"
|
||||
148
scripts/omml_mathml2latex/visitors/visitor_registry.py
Normal file
148
scripts/omml_mathml2latex/visitors/visitor_registry.py
Normal 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)
|
||||
Reference in New Issue
Block a user