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