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

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

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

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

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

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

View File

@@ -0,0 +1,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})"