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:
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})"
|
||||
Reference in New Issue
Block a user