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