服务: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 默认密钥已清空
912 lines
40 KiB
Python
912 lines
40 KiB
Python
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})"
|