Files
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

569 lines
26 KiB
Python
Raw Permalink 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 <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})"