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,200 @@
r"""
Visitor for <mlabeledtr> (labeled table row) MathML elements.
The <mlabeledtr> element is used in tables (<mtable>) to create a row with
a label. The first child is the label, and remaining children are table cells.
Labels are typically used for equation numbering or row identifiers.
In LaTeX conversion, the label is typically rendered separately or as part
of the row depending on context. For simplicity, we render label followed
by cells, separated by &.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MlabeledtrVisitor(Visitor):
r"""
Handles <mlabeledtr> (labeled table row) MathML elements.
Converts labeled table rows to LaTeX array/matrix rows. The first child
is treated as the label, remaining children are table cells.
Examples:
Labeled equation row:
<mlabeledtr>
<mtd><mtext>(1)</mtext></mtd>
<mtd><mi>x</mi></mtd>
<mtd><mi>y</mi></mtd>
</mlabeledtr>
→ (1) & x & y
Simple labeled row:
<mlabeledtr>
<mtd><mn>1</mn></mtd>
<mtd><mi>a</mi></mtd>
</mlabeledtr>
→ 1 & a
Three-column with label:
<mlabeledtr>
<mtd><mtext>Label</mtext></mtd>
<mtd><mi>x</mi></mtd>
<mtd><mo>=</mo></mtd>
<mtd><mn>5</mn></mtd>
</mlabeledtr>
→ \text{Label} & x & = & 5
Note:
The label (first child) is rendered as a regular cell. LaTeX doesn't
have a direct equivalent for MathML labeled rows, so we treat it as
a standard row with the label as the first column.
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mlabeledtr visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mlabeledtr> element to LaTeX array/matrix row.
Args:
node: The <mlabeledtr> element to convert
Returns:
LaTeX string with cells separated by &
Raises:
ValueError: If node is not a <mlabeledtr> element
"""
if not self.can_handle(node):
raise ValueError(f"MlabeledtrVisitor cannot handle element '{node.tag}'")
# Get all children (first is label, rest are cells)
children = list(node)
if not children:
return ""
# Convert all children (label + cells) as mtd elements
cells = []
for child in children:
cell_content = self._convert_child(child)
cells.append(cell_content)
# Join with &
return " & ".join(cells)
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 mlabeledtr, using text fallback", file=sys.stderr)
return _text_content(child)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mlabeledtr'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mlabeledtr'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MlabeledtrVisitor(strategy={self.strategy.name})"