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

450 lines
17 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 <msubsup> (subscript + superscript) MathML elements.
The <msubsup> element represents simultaneous subscripts and superscripts.
The LaTeX conversion uses base_{subscript}^{superscript} notation.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..core.context import ConversionContext
from ..utils import text_content as _text_content, local_name as _local
class MsubsupVisitor(Visitor):
r"""
Handles <msubsup> (subscript + superscript) MathML elements.
Converts elements with both subscript and superscript to LaTeX notation.
Examples:
Tensor with indices:
<msubsup><mi>T</mi><mi>i</mi><mi>j</mi></msubsup> → T_{i}^{j}
Integral bounds:
<msubsup><mo>∫</mo><mn>0</mn><mn>1</mn></msubsup> → \int_{0}^{1}
Summation bounds:
<msubsup><mo>∑</mo><mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow><mi>n</mi></msubsup>
→ \sum_{i = 1}^{n}
Variable with both scripts:
<msubsup><mi>x</mi><mn>0</mn><mn>2</mn></msubsup> → x_{0}^{2}
Complex base:
<msubsup>
<mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow>
<mn>0</mn>
<mn>2</mn>
</msubsup>
→ (x + 1)_{0}^{2}
"""
def __init__(self, strategy: Strategy, context: ConversionContext = None):
"""
Initialize the Msubsup visitor.
Args:
strategy: The formatting strategy to use
context: Shared conversion context for state tracking
"""
super().__init__(strategy)
self.context = context
def visit(self, node: ET.Element) -> str:
r"""
Convert <msubsup> element to LaTeX.
Args:
node: The <msubsup> element to convert
Returns:
LaTeX string with subscript and superscript (base_{sub}^{sup})
Raises:
ValueError: If node is not an <msubsup> element
ValueError: If msubsup has fewer than 3 children
"""
if not self.can_handle(node):
raise ValueError(f"MsubsupVisitor cannot handle element '{node.tag}'")
children = list(node)
# msubsup must have exactly 3 children: base, subscript, superscript
if len(children) < 3:
raise ValueError(
f"msubsup element must have 3 children (base, subscript, superscript), "
f"got {len(children)}"
)
# Check for sideset pattern: base is <mrow> with 3 <mrow> children
# Pattern: <msubsup><mrow><mrow>left_sub</mrow><mrow>left_sup</mrow><mrow>base</mrow></mrow><mrow>right_sub</mrow><mrow>right_sup</mrow></msubsup>
# Result: \sideset{_{left_sub}^{left_sup}}{_{right_sub}^{right_sup}}{base}
if self._is_sideset_pattern(children[0]):
return self._handle_sideset(children)
# Check if base is a large operator BEFORE converting
is_large_op = self._is_large_operator(children[0])
# Convert base (not in script context)
base = self._convert_child(children[0])
# Push script context before converting subscript and superscript
if self.context:
self.context.push_script_context('msubsup')
try:
# Convert subscript and superscript (both in script context)
subscript = self._convert_child(children[1])
superscript = self._convert_child(children[2])
finally:
# Always pop context, even if conversion fails
if self.context:
self.context.pop_script_context()
# Check for large operators that need \limits
if is_large_op:
return f"{base}\\limits_{self._brace(subscript)}^{self._brace(superscript)}"
# If base is empty (from empty <mrow/>), output just the sub/superscripts
# This allows them to attach to the previous element in LaTeX naturally
if not base or not base.strip():
return f"_{self._brace(subscript)}^{self._brace(superscript)}"
# Wrap base if needed (complex expressions or if it contains superscripts)
base = self._wrap_if_needed(base)
# Special case: If base contains a superscript (like v^{\prime}),
# wrap it in braces to prevent syntax error with additional sub/superscripts
# Example: v^{\prime} should become {v^{\prime}}_{1}^{2}, not v^{\prime}_{1}^{2}
if self._contains_superscript(base):
base = f"{{{base}}}"
# Return base_{subscript}^{superscript}
return f"{base}_{self._brace(subscript)}^{self._brace(superscript)}"
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, self.context).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, self.context).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, self.context).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 msubsup, using text fallback", file=sys.stderr)
return _text_content(child)
def _wrap_if_needed(self, base: str) -> str:
"""
Wrap base in parentheses if needed.
Args:
base: The base content
Returns:
Base, optionally wrapped in parentheses
Note:
Complex expressions with operators should be wrapped.
Simple identifiers and numbers don't need wrapping.
"""
# Check if base already has delimiters (starts and ends with matching delimiters)
base_stripped = base.strip()
if base_stripped:
# Check for matching delimiters
delimiter_pairs = {
'(': ')',
'[': ']',
'{': '}',
'\\left(': '\\right)',
'\\left[': '\\right]',
'\\left\\{': '\\right\\}',
}
for open_delim, close_delim in delimiter_pairs.items():
if base_stripped.startswith(open_delim) and base_stripped.endswith(close_delim):
# Already has delimiters, no need to wrap
return base
# Check if base needs grouping for proper subscript/superscript application
# In LaTeX, x_2 applies subscript to x, but "a + b_2" applies only to b
# We need to brace multi-element bases: {a + b}_2
#
# Use braces {} for grouping (invisible) not parentheses () (visible)
# Only wrap if base contains operators that indicate a true multi-element expression
math_operators = {'=', '+', '-', '/', '*', '\\times', '\\cdot', '\\pm', '\\to'}
if ' ' in base and any(op in base for op in math_operators):
return f"{{{base}}}"
return base
def _brace(self, content: str) -> str:
"""Wrap content in braces."""
return f"{{{content}}}"
def _contains_superscript(self, base: str) -> bool:
"""
Check if base contains a LaTeX superscript (^{...} or ^X).
This is important for proper grouping when adding additional scripts.
For example: v^{\\prime} must be wrapped as {v^{\\prime}} before
adding subscripts/superscripts to avoid invalid syntax.
Args:
base: The base LaTeX string
Returns:
True if base contains an unbraced superscript
"""
# Look for ^ character in the base
# We need to be careful to not match ^ that's already inside braces at the start
# For example: {v^{\prime}} should return False (already wrapped)
# but v^{\prime} should return True (needs wrapping)
if not base or '^' not in base:
return False
# If the entire base is already wrapped in braces, it's safe
base_stripped = base.strip()
if base_stripped.startswith('{') and base_stripped.endswith('}'):
# Check if these braces wrap the entire expression
# Simple heuristic: count brace depth
depth = 0
for i, char in enumerate(base_stripped):
if char == '{':
depth += 1
elif char == '}':
depth -= 1
# If depth hits 0 before the end, the braces don't wrap everything
if depth == 0 and i < len(base_stripped) - 1:
break
# If we maintained depth > 0 until the last character, it's wrapped
if i == len(base_stripped) - 1:
return False
# Base contains ^ and is not fully wrapped
return True
def _is_large_operator(self, base_node: ET.Element) -> bool:
"""Check if base is a large operator that uses limits by default."""
if _local(base_node.tag) != 'mo':
return False
base_text = _text_content(base_node).strip()
# 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
]
return base_text in large_ops
def _is_sideset_pattern(self, base_node: ET.Element) -> bool:
"""
Check if base node matches the sideset pattern.
Sideset pattern: base is <mrow> with at least 3 children where first 3 are <mrow>
- First <mrow>: left subscript
- Second <mrow>: left superscript
- Third <mrow>: base symbol (first part)
- Additional children: merged with base (e.g., FeMn has Fe in mrow, Mn as mi)
Args:
base_node: The base element to check
Returns:
True if this is a sideset pattern, False otherwise
"""
if _local(base_node.tag) != 'mrow':
return False
# Get direct children of the base <mrow>
children = list(base_node)
# Must have at least 3 children
if len(children) < 3:
return False
# First 3 children must all be <mrow> elements
return all(_local(children[i].tag) == 'mrow' for i in range(3))
def _handle_sideset(self, msubsup_children: list) -> str:
"""
Handle sideset pattern conversion.
Pattern:
<msubsup>
<mrow>
<mrow>left_sub</mrow>
<mrow>left_sup</mrow>
<mrow>base_part1</mrow>
[optional: additional base elements...]
</mrow>
<mrow>right_sub</mrow>
<mrow>right_sup</mrow>
</msubsup>
Converts to: \\sideset{_{left_sub}^{left_sup}}{_{right_sub}^{right_sup}}{base}
Args:
msubsup_children: The 3 children of <msubsup> element
Returns:
LaTeX sideset command
"""
base_mrow = msubsup_children[0]
right_sub_mrow = msubsup_children[1]
right_sup_mrow = msubsup_children[2]
# Extract children from base <mrow>
base_children = list(base_mrow)
left_sub_mrow = base_children[0]
left_sup_mrow = base_children[1]
actual_base_mrow = base_children[2]
# Additional children after the 3rd element should be merged with base
# Example: <mrow>Fe</mrow><mi>Mn</mi> → FeMn
extra_base_elements = base_children[3:] if len(base_children) > 3 else []
# Push script context for converting subscripts and superscripts
if self.context:
self.context.push_script_context('sideset')
try:
# Convert all parts
left_sub = self._convert_child(left_sub_mrow)
left_sup = self._convert_child(left_sup_mrow)
right_sub = self._convert_child(right_sub_mrow)
right_sup = self._convert_child(right_sup_mrow)
finally:
if self.context:
self.context.pop_script_context()
# Convert base (not in script context)
base_parts = [self._convert_child(actual_base_mrow)]
# Add any extra base elements (e.g., Mn after Fe in FeMn)
for extra_elem in extra_base_elements:
base_parts.append(self._convert_child(extra_elem))
# Merge base parts (no spaces for chemical formulas)
base = ''.join(base_parts)
# Build sideset command
left_scripts = f"_{{{left_sub}}}^{{{left_sup}}}"
right_scripts = f"_{{{right_sub}}}^{{{right_sup}}}"
return f"\\sideset{{{left_scripts}}}{{{right_scripts}}}{{{base}}}"
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'msubsup'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'msubsup'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MsubsupVisitor(strategy={self.strategy.name})"