Files
DoLuEdu-WordFileParse/scripts/omml_mathml2latex/visitors/munderover_visitor.py
Quentin 3271fee5cc 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 默认密钥已清空
2026-09-29 09:43:13 +08:00

464 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

r"""
Visitor for <munderover> (under+over script) MathML elements.
The <munderover> element represents content with both something above and below it:
- Large operators with limits: summation, integral, product with both bounds
- \sum_{i=1}^{n}, \int_{a}^{b}, \prod_{i=1}^{n}
- Union/intersection with limits
- General underover scripts
This visitor is CRITICAL because it handles \limits properly, unlike msubsup.
"""
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 MunderoverVisitor(Visitor):
r"""
Handles <munderover> (under+over script) MathML elements.
Converts under+over notation to appropriate LaTeX commands, especially
for large operators with both upper and lower limits.
Examples:
Summation with limits:
<munderover>
<mo>∑</mo>
<mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow>
<mi>n</mi>
</munderover> → \sum_{i=1}^{n}
Integral with bounds:
<munderover>
<mo>∫</mo>
<mi>a</mi>
<mi>b</mi>
</munderover> → \int_{a}^{b}
Product with limits:
<munderover>
<mo>∏</mo>
<mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow>
<mi>n</mi>
</munderover> → \prod_{i=1}^{n}
Union with limits:
<munderover>
<mo>⋃</mo>
<mi>i</mi>
<mi>n</mi>
</munderover> → \bigcup_{i}^{n}
General underover:
<munderover>
<mi>X</mi>
<mi>a</mi>
<mi>b</mi>
</munderover> → \overset{b}{\underset{a}{X}}
"""
def __init__(self, strategy: Strategy):
"""Initialize the Munderover visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <munderover> element to LaTeX.
Args:
node: The <munderover> element to convert
Returns:
LaTeX string with appropriate under+over notation
Raises:
ValueError: If node is not an <munderover> element
ValueError: If munderover has fewer than 3 children
"""
if not self.can_handle(node):
raise ValueError(f"MunderoverVisitor cannot handle element '{node.tag}'")
children = list(node)
# munderover must have exactly 3 children: base, underscript, overscript
if len(children) < 3:
raise ValueError(
f"munderover element must have 3 children (base, under, over), "
f"got {len(children)}"
)
base = self._convert_child(children[0])
under = self._convert_child(children[1])
over = self._convert_child(children[2])
# Check if base is a large operator (sum, integral, product, etc.)
if self._is_large_operator(children[0]):
# Use subscript/superscript notation for operator limits with \limits
# \sum\limits_{i=1}^{n}
return f"{base}\\limits_{self._brace(under)}^{self._brace(over)}"
# Check if this should use \limits explicitly
# (for operators that don't default to limit placement)
if self._should_use_limits(children[0]):
return f"{base}\\limits_{self._brace(under)}^{self._brace(over)}"
# Check for extensible arrow patterns (arrows/equals with text above and below)
# This is commonly used for chemical reactions and annotated arrows
# First, unwrap mrow if the base is wrapped (Word often does this)
base_node = children[0]
if _local(base_node.tag) == 'mrow':
# Check if mrow contains a single operator/arrow
mrow_children = list(base_node)
if len(mrow_children) == 1 and _local(mrow_children[0].tag) in ('mo', 'mi', 'mtext'):
base_node = mrow_children[0]
xarrow_cmd = self._get_xarrow_command(base_node)
if xarrow_cmd:
# Check if original content had \mathrm{} (indicates mathvariant="normal")
# We need to preserve this semantic after cleaning
over_had_mathrm = '\\mathrm{' in over
under_had_mathrm = '\\mathrm{' in under
# Clean up the text (remove \phantom{}, \mathrm{}, \text{}, extra spaces)
over_clean = self._clean_arrow_text(over)
under_clean = self._clean_arrow_text(under)
# Re-wrap text in \mathrm{} if:
# 1. Original content had \mathrm{} (from mathvariant="normal")
# 2. Or in chemistry mode for non-command text
# This preserves the mathvariant="normal" semantic from the input MathML
# Skip if it's already a LaTeX command (starts with \) or empty
# Also skip pure numbers (like "1", "222", "2121") - they should remain plain
def should_wrap_mathrm(text: str, had_mathrm: bool) -> bool:
if not text or not text.strip():
return False
if text.startswith("\\"):
return False # Already a LaTeX command
if text.replace('.', '', 1).isdigit():
return False # Pure number
# Wrap if original had \mathrm{} OR in chemistry mode
if had_mathrm:
return True
if hasattr(self.strategy, 'name') and self.strategy.name == "chemistry":
return True
return False
if should_wrap_mathrm(over_clean, over_had_mathrm):
over_clean = f"\\mathrm{{{over_clean}}}"
if should_wrap_mathrm(under_clean, under_had_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}}}"
# Default: general under+over using nested \overset and \underset
# \overset{b}{\underset{a}{X}}
return f"\\overset{self._brace(over)}{{\\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 munderover, using text fallback", file=sys.stderr)
return _text_content(child)
def _is_large_operator(self, base_node: ET.Element) -> bool:
"""
Check if base is a large operator that uses limits by default.
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)
# Large operators that display limits above/below in display mode
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
]
# 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: <munderover><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 _should_use_limits(self, base_node: ET.Element) -> bool:
"""Check if operator should explicitly use \\limits."""
if _local(base_node.tag) != 'mo':
return False
base_text = _text_content(base_node).strip()
# Operators that need explicit \limits to display limits above/below
limits_ops = [
"lim", "\\lim", # Limit
"sup", "\\sup", # Supremum
"inf", "\\inf", # Infimum
"max", "\\max", # Maximum
"min", "\\min", # Minimum
"limsup", "\\limsup", # Limit superior
"liminf", "\\liminf", # Limit inferior
"det", "\\det", # Determinant
"gcd", "\\gcd", # Greatest common divisor
]
return base_text in limits_ops
def _brace(self, content: str) -> str:
"""Wrap content in braces."""
return f"{{{content}}}"
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 (with proper nested brace handling)
- \\text{} wrappers
- Extra spaces between characters
"""
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')
# Remove \mathrm{} wrappers (with nested brace handling)
text = remove_command(text, '\\mathrm')
# 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 can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'munderover'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'munderover'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MunderoverVisitor(strategy={self.strategy.name})"