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

427 lines
15 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 <menclose> (box/cancel notation) MathML elements.
The <menclose> element is used to enclose mathematical content with
various notations including boxes, circles, strike-throughs, and partial frames.
This visitor handles notation attribute values like:
- box: Full box around content
- circle: Circle around content
- horizontalstrike, verticalstrike: Strike-through lines
- updiagonalstrike, downdiagonalstrike: Diagonal strike-through
- top, bottom, left, right: Partial box sides
- actuarial, longdiv, radical: Specialized notations
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MencloseVisitor(Visitor):
r"""
Handles <menclose> (box/cancel notation) MathML elements.
Converts various notation attributes to appropriate LaTeX commands.
Examples:
Box notation:
<menclose notation="box"><mi>x</mi></menclose>
→ \boxed{x}
Horizontal strike:
<menclose notation="horizontalstrike"><mi>x</mi></menclose>
→ \cancel{x}
Diagonal strike (up):
<menclose notation="updiagonalstrike"><mi>x</mi></menclose>
→ \cancel{x}
Diagonal strike (down):
<menclose notation="downdiagonalstrike"><mi>x</mi></menclose>
→ \bcancel{x}
Combined diagonal strikes:
<menclose notation="updiagonalstrike downdiagonalstrike"><mi>x</mi></menclose>
→ \xcancel{x}
Top line only:
<menclose notation="top"><mi>x</mi></menclose>
→ \overline{x}
Bottom line only:
<menclose notation="bottom"><mi>x</mi></menclose>
→ \underline{x}
Left delimiter:
<menclose notation="left"><mi>x</mi></menclose>
→ \left| x \right.
Right delimiter:
<menclose notation="right"><mi>x</mi></menclose>
→ \left. x \right|
Circle notation:
<menclose notation="circle"><mi>x</mi></menclose>
→ \circled{x}
Actuarial notation:
<menclose notation="actuarial"><mi>x</mi></menclose>
→ \overline{\underline{x}}
Long division:
<menclose notation="longdiv"><mi>x</mi></menclose>
→ \enclose{longdiv}{x}
Radical notation:
<menclose notation="radical"><mi>x</mi></menclose>
→ \sqrt{x}
"""
def __init__(self, strategy: Strategy):
"""Initialize the Menclose visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <menclose> element to LaTeX with appropriate notation.
Args:
node: The <menclose> element to convert
Returns:
LaTeX string with notation applied to content
Raises:
ValueError: If node is not a <menclose> element
"""
if not self.can_handle(node):
raise ValueError(f"MencloseVisitor cannot handle element '{node.tag}'")
notation = node.get("notation", "box")
# Special case: box containing mfrac with underlined/overlined numerator (like chemical reaction conditions)
# Pattern: <menclose notation="box"><mfrac><munder>text<mo>‾</mo></munder><empty/></mfrac></menclose>
# Remove the box but preserve the fraction structure: \dfrac{\underline{text}}{}
# Also handles: <menclose notation="box"><mrow><mfrac>...</mfrac></mrow></menclose>
mfrac_node = None
if notation == "box" and len(node) == 1:
child = node[0]
child_tag = _local(child.tag)
# Direct mfrac child
if child_tag == "mfrac":
mfrac_node = child
# mfrac wrapped in mrow (Word often does this)
elif child_tag == "mrow" and len(child) == 1 and _local(child[0].tag) == "mfrac":
mfrac_node = child[0]
if mfrac_node is not None and len(mfrac_node) >= 2:
numerator_node = mfrac_node[0]
# Check if numerator contains underlined text (munder with line character)
if self._contains_underlined_text(numerator_node):
# Convert the fraction directly without the box
# This preserves the \dfrac structure with underlined numerator
return self._convert_child(mfrac_node)
content = self._convert_children(node)
# Skip boxing for assignment expressions
if notation == "box" and self._contains_assignment(content):
return content
# Parse notation (could be space-separated multi-value)
notations = notation.split()
# === COMBINED STRIKE NOTATIONS (check these FIRST) ===
# Combined diagonal strikes → \xcancel
if "updiagonalstrike" in notations and "downdiagonalstrike" in notations:
return f"\\xcancel{{{content}}}"
# Combined horizontal and vertical strikes → \cancel
if "horizontalstrike" in notations and "verticalstrike" in notations:
return f"\\cancel{{{content}}}"
# === SIDE-SPECIFIC NOTATIONS FOR PARTIAL BOXES ===
sides = [n for n in notations if n in ["top", "bottom", "left", "right"]]
side_count = len(sides)
# Single side visible
if side_count == 1:
side = sides[0]
if side == "top":
return f"\\overline{{{content}}}"
elif side == "bottom":
return f"\\underline{{{content}}}"
elif side == "left":
return f"\\left| {content} \\right."
elif side == "right":
return f"\\left. {content} \\right|"
# Full box (4 sides or explicit "box" notation)
elif "box" in notations or side_count == 4:
return f"\\boxed{{{content}}}"
# Partial box (2-3 sides)
elif side_count >= 2:
notation_str = " ".join(sides)
return f"\\enclose{{{notation_str}}}{{{content}}}"
# === INDIVIDUAL STRIKE NOTATIONS ===
if notation == "horizontalstrike":
return f"\\cancel{{{content}}}"
if notation == "verticalstrike":
return f"\\bcancel{{{content}}}"
if notation == "updiagonalstrike":
return f"\\cancel{{{content}}}"
if notation == "downdiagonalstrike":
return f"\\bcancel{{{content}}}"
# === OTHER NOTATIONS ===
if notation == "circle":
return f"\\circled{{{content}}}"
if notation == "roundedbox":
return f"\\boxed{{{content}}}"
if notation == "actuarial":
return f"\\overline{{\\underline{{{content}}}}}"
if notation == "longdiv":
return f"\\enclose{{longdiv}}{{{content}}}"
if notation == "radical":
return f"\\sqrt{{{content}}}"
# Default: just return content without notation
return content
def _convert_children(self, node: ET.Element) -> str:
"""
Convert all child elements to LaTeX.
Args:
node: The <menclose> element
Returns:
LaTeX string with all children converted
"""
parts = []
for child in node:
parts.append(self._convert_child(child))
return ''.join(parts).strip()
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 menclose, using text fallback", file=sys.stderr)
return _text_content(child)
def _is_empty_denominator(self, node: ET.Element) -> bool:
"""
Check if denominator node is empty (no content or only whitespace).
Args:
node: MathML denominator element to check
Returns:
True if denominator is empty
"""
if node is None:
return True
tag = _local(node.tag)
# Empty <mo> tag
if tag == "mo":
text = node.text or ""
return text.strip() == ""
# Empty <mrow/> tag
if tag == "mrow":
# No children
if len(node) == 0:
return True
# Only whitespace <mo> child
if len(node) == 1 and _local(node[0].tag) == "mo":
mo_text = node[0].text or ""
return mo_text.strip() == ""
return False
def _contains_assignment(self, content: str) -> bool:
"""
Check if content contains assignment operators.
Assignment expressions should not be boxed.
Note: We only skip boxing for explicit assignment operators like :=
NOT for regular equality = which is often used in boxed theorems.
Args:
content: LaTeX content to check
Returns:
True if content contains assignment operators
"""
# Only specific assignment operators - NOT plain = which appears in boxed theorems
assignment_ops = [":=", ": =", "∶=", "∶ =", "≔", "\\coloneqq"]
return any(op in content for op in assignment_ops)
def _contains_underlined_text(self, node: ET.Element) -> bool:
"""
Check if node contains underlined text (munder with line character)
OR overlined text (mover with line character).
This is used to detect chemical reaction conditions and malformed equals.
Args:
node: MathML element to check
Returns:
True if node contains underlined or overlined text
"""
if node is None:
return False
tag = _local(node.tag)
# Check if this node itself is an underlined element (munder with line)
if tag == "munder" and len(node) > 1:
under_node = node[1]
if _local(under_node.tag) == "mo":
# Get text content
text = under_node.text or ""
if "‾" in text: # Line character
return True
# Check if this node is an overlined element (mover with line)
# This handles the "malformed equal" pattern where users put overline in fraction
if tag == "mover" and len(node) > 1:
over_node = node[1]
if _local(over_node.tag) == "mo":
# Get text content
text = over_node.text or ""
# Check for overline/bar characters
if text.strip() in ["¯", "‾", "―", "−", "⎺", "⎻", "-", "_"]:
return True
# Recursively check children
for child in node:
if self._contains_underlined_text(child):
return True
return False
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'menclose'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'menclose'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MencloseVisitor(strategy={self.strategy.name})"