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:
2026-09-29 09:43:13 +08:00
commit 3271fee5cc
360 changed files with 86082 additions and 0 deletions

View File

@@ -0,0 +1,532 @@
r"""
Visitor for <mfenced> (fenced/delimited expressions) MathML elements.
The <mfenced> element represents expressions enclosed by delimiters:
- Auto-scaling parentheses: \left( ... \right)
- Custom brackets: \left[ ... \right]
- Custom braces: \left\{ ... \right\}
- Cases environment: single left brace
- Separators: commas, semicolons between elements
This visitor handles delimiter scaling and separator insertion.
"""
from typing import Optional, List
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 MfencedVisitor(Visitor):
r"""
Handles <mfenced> (fenced/delimited) MathML elements.
Converts fenced expressions to LaTeX with auto-scaling delimiters.
Examples:
Default parentheses:
<mfenced><mi>x</mi><mi>y</mi></mfenced> → \left(x,y\right)
Custom brackets:
<mfenced open="[" close="]"><mi>a</mi></mfenced> → \left[a\right]
Custom braces:
<mfenced open="{" close="}"><mi>x</mi></mfenced> → \left\{x\right\}
Angle brackets:
<mfenced open="⟨" close="⟩"><mi>v</mi></mfenced> → \left\langle v\right\rangle
Single left brace (piecewise/cases):
<mfenced open="{" close="">
<mtable>...</mtable>
</mfenced> → \left\{\begin{array}{...}...\end{array}\right.
Custom separator (semicolon):
<mfenced separators=";"><mi>a</mi><mi>b</mi></mfenced> → \left(a;b\right)
Multiple separators:
<mfenced separators="; "><mi>a</mi><mi>b</mi><mi>c</mi></mfenced>
→ \left(a;b c\right)
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mfenced visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mfenced> element to LaTeX.
Args:
node: The <mfenced> element to convert
Returns:
LaTeX string with auto-scaling delimiters
Raises:
ValueError: If node is not an <mfenced> element
"""
if not self.can_handle(node):
raise ValueError(f"MfencedVisitor cannot handle element '{node.tag}'")
# Get opening and closing delimiters (default: parentheses)
open_delim = node.get('open', '(')
close_delim = node.get('close', ')')
# Get separator (default: comma)
separators = node.get('separators', ',')
# Convert children
children = list(node)
if not children:
# Empty mfenced
left = self._get_left_delimiter(open_delim)
right = self._get_right_delimiter(close_delim)
return f"{left}{right}"
# NOTE: We NO LONGER use cases environment for better compatibility
# Instead, we use explicit \left\{ and \right. notation
# (Cases environment is not universally supported)
# Check if this is set notation: { pattern | condition }
if open_delim == '{' and close_delim == '}':
set_notation_result = self._try_convert_as_set_notation(node, children)
if set_notation_result:
return set_notation_result
# Check if this is an interval with nested mfenced for the closing delimiter
# Pattern: <mfenced open="(" close=""> ... <mfenced open="" close="]">
# This should merge into a single interval: \left( ... \right]
if self._is_split_interval(node, children, open_delim, close_delim):
return self._merge_split_interval(node, children, open_delim)
# Convert each child, tracking which are operators
converted_children = []
child_types = []
for child in children:
converted = self._convert_child(child)
converted_children.append(converted)
child_types.append(_local(child.tag))
# Determine if this is a continuous expression or a list
# If there are explicit separator operators (mo elements with separator chars),
# treat as continuous expression (join with spaces)
# Otherwise, use the separators attribute for list formatting
content = self._smart_join(converted_children, child_types, separators)
# Get LaTeX delimiters
left = self._get_left_delimiter(open_delim)
right = self._get_right_delimiter(close_delim)
return f"{left}{content}{right}"
def _try_convert_as_set_notation(self, node: ET.Element, children: List) -> Optional[str]:
r"""
Try to convert as set notation: { pattern | condition }.
Set notation has the pattern:
- { (x, y) | x+y < 2 } → \left\{ (x, y) \mid x+y < 2 \right\}
- { x | x > 0 } → \left\{ x \mid x > 0 \right\}
Detection heuristics:
1. First child (or first few children) form the "pattern" (variable/tuple)
2. There's a nested <mfenced open="|" close="|"> that's really a separator
OR there are exactly 2 distinct groups of children
3. Last children form the "condition"
Args:
node: The outer mfenced element with open="{" close="}"
children: List of child elements
Returns:
LaTeX string if this is set notation, None otherwise
"""
if len(children) < 2:
return None
# Strategy 1: Look for nested mfenced with | delimiters
# If the second or third child is <mfenced open="|" close="|">,
# and there's content after it OR before it, treat as set notation
# BUT keep the |...| as absolute value brackets in the output
for i, child in enumerate(children):
if _local(child.tag) == 'mfenced':
child_open = child.get('open', '(')
child_close = child.get('close', ')')
if child_open == '|' and child_close == '|':
# Found nested | delimiters
# Check if there's content before this (the pattern) or after (condition)
has_content_before = i >= 1
has_content_after = i < len(children) - 1
if has_content_before or has_content_after:
# This looks like set notation!
# Split into: pattern (before), |...|, and rest
pattern_parts = children[:i]
pipe_element = child # The entire <mfenced open="|" close="|"> element
condition_parts = children[i+1:]
# Convert pattern
pattern_latex = ' '.join(self._convert_child(c) for c in pattern_parts if c is not None)
# Convert the |...| element as absolute value (keep the delimiters!)
pipe_latex = self._convert_child(pipe_element)
# Convert remaining condition
condition_latex = ' '.join(self._convert_child(c) for c in condition_parts if c is not None)
# Assemble: { pattern \mid |pipe_content| condition }
parts = []
if pattern_latex:
parts.append(pattern_latex)
parts.append('\\mid')
if pipe_latex:
parts.append(pipe_latex)
if condition_latex:
parts.append(condition_latex)
content = ' '.join(parts)
return f"\\left\\{{{content}\\right\\}}"
# Strategy 2: Check if there are exactly 2 "groups" of elements
# where the first group is a single identifier/tuple, suggesting { x | condition }
# This handles cases like { x x+1<2 } where x and x+1<2 should be separated by \mid
if len(children) >= 2:
first_child_type = _local(children[0].tag)
# If first child is a single identifier (mi) or a tuple (mfenced with parens)
if first_child_type in ['mi', 'mfenced']:
# Check if this looks like a pattern followed by a condition
# Heuristic: if first child is simple (mi or mfenced), and there are more elements,
# and those elements form an expression/condition, insert \mid
# For now, check if we have exactly these patterns:
# 1. First child is <mfenced open="(" close=")"> (tuple)
# 2. OR first child is <mi> (variable)
# 3. Rest form a condition
if first_child_type == 'mi':
# Check if second element is also mi (likely the start of condition)
if len(children) >= 2 and _local(children[1].tag) == 'mi':
# Likely: { x x+1<2 } should be { x | x+1<2 }
pattern_latex = self._convert_child(children[0])
condition_latex = ' '.join(self._convert_child(c) for c in children[1:])
return f"\\left\\{{{pattern_latex} \\mid {condition_latex}\\right\\}}"
elif first_child_type == 'mfenced':
first_child_open = children[0].get('open', '(')
first_child_close = children[0].get('close', ')')
# Check if it's a tuple (parentheses)
if first_child_open == '(' and first_child_close == ')':
# Pattern: { (x,y) ... } - check if ... is a condition
pattern_latex = self._convert_child(children[0])
# Check if remaining children don't start with a comma
# (which would indicate list notation, not set notation)
if len(children) >= 2:
second_type = _local(children[1].tag)
if second_type != 'mo' or (second_type == 'mo' and _text_content(children[1]).strip() != ','):
# Not a list, likely set notation
condition_latex = ' '.join(self._convert_child(c) for c in children[1:])
return f"\\left\\{{{pattern_latex} \\mid {condition_latex}\\right\\}}"
return None
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 mfenced, using text fallback", file=sys.stderr)
return _text_content(child)
def _smart_join(self, items: list, types: list, separators: str) -> str:
"""
Intelligently join items based on whether they form a continuous expression.
Args:
items: Converted LaTeX strings
types: Element types (mi, mo, mn, etc.)
separators: Separator string from mfenced attribute
Returns:
Joined LaTeX string
"""
if len(items) == 0:
return ''
if len(items) == 1:
return items[0]
# Check if this looks like a continuous expression:
# - Contains 'mo' (operator) elements
# - Separators not explicitly set (or default comma)
has_operators = 'mo' in types
if has_operators and separators == ',':
# Continuous expression: join with spaces, no extra separators
# Filter out empty items
return ' '.join(item for item in items if item)
else:
# List of items: use traditional separator insertion
return self._insert_separators(items, separators)
def _insert_separators(self, items: list, separators: str) -> str:
"""Insert separators between items for list-style formatting."""
if len(items) == 0:
return ''
if len(items) == 1:
return items[0]
# Parse separator string
# If multiple characters, each is used for successive separators
# If fewer separators than gaps, last separator is reused
sep_list = list(separators) if separators else [',']
result = [items[0]]
for i, item in enumerate(items[1:], start=0):
# Get appropriate separator (reuse last if not enough)
sep_idx = min(i, len(sep_list) - 1)
separator = sep_list[sep_idx]
result.append(separator)
result.append(item)
return ''.join(result)
def _get_left_delimiter(self, delim: str) -> str:
"""Map opening delimiter to LaTeX \\left command."""
delimiter_map = {
'(': '\\left(',
'[': '\\left[',
'{': '\\left\\{',
'⟨': '\\left\\langle',
'⌈': '\\left\\lceil',
'⌊': '\\left\\lfloor',
'|': '\\left|',
'‖': '\\left\\|',
'': '\\left.', # Invisible left delimiter
}
return delimiter_map.get(delim, f'\\left{delim}')
def _get_right_delimiter(self, delim: str) -> str:
"""Map closing delimiter to LaTeX \\right command."""
delimiter_map = {
')': '\\right)',
']': '\\right]',
'}': '\\right\\}',
'⟩': '\\right\\rangle',
'⌉': '\\right\\rceil',
'⌋': '\\right\\rfloor',
'|': '\\right|',
'‖': '\\right\\|',
'': '\\right.', # Invisible right delimiter
}
return delimiter_map.get(delim, f'\\right{delim}')
def _is_split_interval(self, node: ET.Element, children: list, open_delim: str, close_delim: str) -> bool:
"""
Detect if this is a split interval notation pattern.
Pattern: <mfenced open="(" close=""> ... <mfenced open="" close="]">
This represents an interval like (0, 1/4] split across two mfenced elements.
Also handles case where content is wrapped in mrow:
<mfenced open="(" close=""><mrow> ... <mfenced open="" close="]">...</mfenced></mrow></mfenced>
Returns True if:
1. Outer has opening delimiter but NO closing delimiter (close="")
2. Last child is <mfenced> with NO opening delimiter (open="") but HAS closing delimiter
3. There are other children before the last mfenced (the interval content)
Args:
node: The outer mfenced element
children: List of child elements
open_delim: Opening delimiter of outer mfenced
close_delim: Closing delimiter of outer mfenced
Returns:
True if this is a split interval pattern
"""
# Check pattern: outer has open delim but no close delim
if not (open_delim in '([{' and close_delim == ''):
return False
# Handle case where content is wrapped in a single mrow
actual_children = children
if len(children) == 1 and _local(children[0].tag) == 'mrow':
actual_children = list(children[0])
# Need at least 2 children (content + nested mfenced)
if len(actual_children) < 2:
return False
# Check if last child is mfenced with complementary delimiters
last_child = actual_children[-1]
if _local(last_child.tag) != 'mfenced':
return False
# Last mfenced should have no open delimiter but have close delimiter
last_open = last_child.get('open', '(')
last_close = last_child.get('close', ')')
if last_open == '' and last_close in ')]}\u232A\u2309\u230B': # Closing delimiters
return True
return False
def _merge_split_interval(self, node: ET.Element, children: list, open_delim: str) -> str:
"""
Merge split interval into a single LaTeX interval.
Pattern: <mfenced open="(" close=""> <content> <mfenced open="" close="]">
Output: \\left( <content> \\right]
Also handles case where content is wrapped in mrow:
<mfenced open="(" close=""><mrow> ... <mfenced open="" close="]">...</mfenced></mrow></mfenced>
Args:
node: The outer mfenced element
children: List of child elements
open_delim: Opening delimiter (e.g., '(', '[')
Returns:
LaTeX string for merged interval
"""
# Handle case where content is wrapped in a single mrow
actual_children = children
if len(children) == 1 and _local(children[0].tag) == 'mrow':
actual_children = list(children[0])
# Get the closing delimiter from the last child (which is the nested mfenced)
last_child = actual_children[-1]
close_delim = last_child.get('close', ')')
# Convert all children except the last (which is the nested mfenced)
content_children = actual_children[:-1]
# Also convert the CONTENT of the last mfenced (not the mfenced itself)
last_mfenced_children = list(last_child)
# Combine content from both outer and inner mfenced
all_content_children = content_children + last_mfenced_children
# Convert all content
converted_parts = []
for child in all_content_children:
part = self._convert_child(child)
if part:
converted_parts.append(part)
# Join with spaces (standard interval notation)
content = ' '.join(converted_parts)
# Build result with merged delimiters
left = self._get_left_delimiter(open_delim)
right = self._get_right_delimiter(close_delim)
return f"{left}{content}{right}"
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mfenced'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mfenced'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MfencedVisitor(strategy={self.strategy.name})"