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,183 @@
"""
OMML parser for Office Math Markup Language input.
This parser handles OMML XML input from Microsoft Office, converting it
to MathML format using the existing OMML processing pipeline.
"""
from typing import List
from lxml import etree as ET
from ..core.interfaces import Parser
class OmmlParser(Parser):
"""
Parser for OMML (Office Math Markup Language) XML input.
This parser handles OMML XML from Microsoft Office documents, converting
it to MathML using the existing OMML transformation pipeline.
Examples:
Single OMML equation:
>>> parser = OmmlParser()
>>> omml = '<m:oMath xmlns:m="http://..."><m:r><m:t>x</m:t></m:r></m:oMath>'
>>> results = parser.parse(omml)
>>> len(results) >= 1
True
"""
def __init__(self):
"""Initialize the OMML parser."""
pass
def parse(self, source: str) -> List[str]:
"""
Parse OMML input and return list of MathML equation strings.
Args:
source: OMML XML string or file path
Returns:
List of MathML equation strings (converted from OMML)
Raises:
ValueError: If input is not valid OMML XML
RuntimeError: If OMML to MathML conversion fails
Examples:
>>> parser = OmmlParser()
>>> omml = '<m:oMath xmlns:m="http://...">...</m:oMath>'
>>> results = parser.parse(omml)
>>> isinstance(results, list)
True
"""
import os
# Check if source is a file path
if os.path.exists(source):
try:
with open(source, 'r', encoding='utf-8') as f:
source = f.read()
except (IOError, UnicodeDecodeError) as e:
raise ValueError(f"Failed to read OMML file: {e}")
# Parse XML
try:
root = ET.fromstring(source.encode('utf-8'))
except ET.XMLSyntaxError as e:
raise ValueError(f"Invalid XML syntax: {e}")
# Find all OMML math elements
# OMML uses <m:oMath> or <m:oMathPara> elements
omml_ns = {'m': 'http://schemas.openxmlformats.org/officeDocument/2006/math'}
# Find oMath elements (with or without namespace)
omath_elements = []
omath_elements.extend(root.findall('.//m:oMath', omml_ns))
omath_elements.extend(root.findall('.//m:oMathPara', omml_ns))
# Try without namespace (in case it's not declared)
if not omath_elements:
for elem in root.iter():
local_name = elem.tag.split('}')[-1] if '}' in elem.tag else elem.tag
if local_name in ('oMath', 'oMathPara'):
omath_elements.append(elem)
if not omath_elements:
# Check if root itself is an OMML element
root_local = root.tag.split('}')[-1] if '}' in root.tag else root.tag
if root_local in ('oMath', 'oMathPara'):
omath_elements.append(root)
else:
raise ValueError("No OMML math elements (<m:oMath> or <m:oMathPara>) found in input")
# Convert each OMML element to MathML
results = []
for omml_elem in omath_elements:
try:
mathml_str = self._omml_to_mathml(omml_elem)
if mathml_str and mathml_str.strip():
results.append(mathml_str)
except Exception as e:
# Log error but continue with other equations
print(f"Warning: Failed to convert OMML element: {e}")
continue
if not results:
raise RuntimeError("Failed to convert any OMML elements to MathML")
return results
def can_handle(self, source: str) -> bool:
"""
Check if this parser can handle the given input source.
Args:
source: Input source to check
Returns:
True if source appears to be OMML, False otherwise
Examples:
>>> parser = OmmlParser()
>>> parser.can_handle("<m:oMath>...</m:oMath>")
True
>>> parser.can_handle("<math><mi>x</mi></math>")
False
"""
# Check for OMML markers
content_start = source.strip()[:200]
return '<m:oMath' in content_start or '<m:oMathPara' in content_start
@property
def parser_type(self) -> str:
"""Get the parser type identifier."""
return "omml"
def _omml_to_mathml(self, omml_element: ET.Element) -> str:
"""
Convert OMML element to MathML string with styling preservation.
This wraps the existing OMML to MathML conversion from the core module,
and adds post-processing to preserve styling (bold, fonts, colors) that
the XSLT stylesheet doesn't preserve.
Args:
omml_element: OMML XML element
Returns:
MathML string with styling attributes
Raises:
RuntimeError: If conversion fails
"""
# Import here to avoid circular dependencies
import os
from ..core import omml_to_mathml
from ..utils.styling_postprocessor import apply_styling_to_mathml_from_omml
try:
# Convert OMML element to string
omml_str = ET.tostring(omml_element, encoding='unicode')
# Get XSL path (in core module)
current_dir = os.path.dirname(os.path.abspath(__file__))
core_dir = os.path.join(os.path.dirname(current_dir), 'core')
xsl_path = os.path.join(core_dir, 'OMML2MML.xsl')
# Step 1: Use XSLT to convert OMML to MathML
mathml_str = omml_to_mathml(omml_str, xsl_path)
# Step 2: Post-process to add styling (bold, fonts, colors)
# The XSLT doesn't preserve m:sty, m:scr, w:color, etc.
# This step extracts styling from OMML and applies to MathML
mathml_str = apply_styling_to_mathml_from_omml(omml_str, mathml_str)
return mathml_str
except Exception as e:
raise RuntimeError(f"OMML to MathML conversion failed: {e}")
def __repr__(self) -> str:
"""String representation for debugging."""
return "OmmlParser()"