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,18 @@
"""
Input parser classes for handling different input formats.
This package contains parser implementations for DOCX, OMML, and MathML
input formats, along with a factory for auto-detection and creation.
"""
from .factory import InputParserFactory
from .docx_parser import DocxParser
from .omml_parser import OmmlParser
from .mathml_parser import MathMLParser
__all__ = [
'InputParserFactory',
'DocxParser',
'OmmlParser',
'MathMLParser',
]

View File

@@ -0,0 +1,143 @@
"""
DOCX parser for Microsoft Word documents.
This parser handles .docx files, extracting OMML equations and converting
them to MathML format using the existing DOCX processing pipeline.
"""
import os
from typing import List
from ..core.interfaces import Parser
class DocxParser(Parser):
"""
Parser for Microsoft Word .docx files.
This parser handles .docx files by extracting OMML equations and
converting them to MathML using the existing DOCX processing pipeline.
Examples:
Parse DOCX file:
>>> parser = DocxParser()
>>> results = parser.parse("equations.docx")
>>> isinstance(results, list)
True
"""
def __init__(self):
"""Initialize the DOCX parser."""
pass
def parse(self, source: str) -> List[str]:
"""
Parse DOCX file and return list of MathML equation strings.
Args:
source: Path to .docx file
Returns:
List of MathML equation strings (extracted from DOCX)
Raises:
FileNotFoundError: If DOCX file doesn't exist
ValueError: If file is not a valid .docx file
RuntimeError: If DOCX processing fails
Examples:
>>> parser = DocxParser()
>>> # Assuming test.docx exists with equations
>>> results = parser.parse("test.docx")
>>> all(isinstance(eq, str) for eq in results)
True
"""
# Validate file exists
if not os.path.exists(source):
raise FileNotFoundError(f"DOCX file not found: {source}")
# Validate file extension
if not source.lower().endswith('.docx'):
raise ValueError(f"File must have .docx extension: {source}")
# Import here to avoid circular dependencies
from ..processing import OMMLExtractor
from ..core import omml_to_mathml
from ..utils.styling_postprocessor import apply_styling_to_mathml_from_omml
# 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')
try:
# Extract OMML from DOCX using OMMLExtractor
extractor = OMMLExtractor()
equation_dicts = extractor.extract_from_docx(source)
if not equation_dicts:
# No equations found
return []
# Convert each OMML to MathML
results = []
for eq_dict in equation_dicts:
try:
# Extract the cleaned OMML string from the dictionary
omml_str = eq_dict.get('cleaned_omml', eq_dict.get('omml', ''))
if not omml_str:
continue
# Step 1: XSLT transformation
mathml_str = omml_to_mathml(omml_str, xsl_path)
# Step 2: Post-process to fix table structure and add styling
# CRITICAL: This fixes the OMML2MML.xsl bug where <mtr><mrow> should be <mtr><mtd>
mathml_str = apply_styling_to_mathml_from_omml(omml_str, mathml_str)
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 equation: {e}")
continue
return results
except Exception as e:
raise RuntimeError(f"DOCX processing failed: {e}")
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 is a .docx file, False otherwise
Examples:
>>> parser = DocxParser()
>>> parser.can_handle("equations.docx")
True
>>> parser.can_handle("<math><mi>x</mi></math>")
False
"""
# Check file extension
if source.lower().endswith('.docx'):
return True
# Check if it's an existing file with .docx extension
if os.path.exists(source) and source.lower().endswith('.docx'):
return True
return False
@property
def parser_type(self) -> str:
"""Get the parser type identifier."""
return "docx"
def __repr__(self) -> str:
"""String representation for debugging."""
return "DocxParser()"

View File

@@ -0,0 +1,144 @@
"""
Input parser factory for auto-detecting and creating appropriate parsers.
This module provides centralized parser creation with auto-detection
support for determining the most appropriate parser based on input source.
"""
import os
from typing import Union, List
from ..core.interfaces import Parser
class InputParserFactory:
"""
Factory for creating input parsers based on source content.
This factory provides centralized parser creation with support for:
- Auto-detection based on file extension or content
- Explicit parser selection by type
- Clear error messages for unsupported formats
"""
@staticmethod
def create_parser(source: str, parser_type: Union[str, None] = None) -> Parser:
"""
Create an appropriate parser for the input source.
Args:
source: Input source (file path or content string)
parser_type: Optional explicit parser type
("docx", "omml", "mathml", or None for auto-detection)
Returns:
Parser instance appropriate for the source
Raises:
ValueError: If input format cannot be determined or is unsupported
Examples:
>>> factory = InputParserFactory()
>>> parser = factory.create_parser("equations.docx")
>>> isinstance(parser, DocxParser)
True
>>> parser = factory.create_parser("<math><mi>x</mi></math>")
>>> isinstance(parser, MathMLParser)
True
>>> parser = factory.create_parser(xml_content, parser_type="omml")
>>> isinstance(parser, OmmlParser)
True
"""
# Import here to avoid circular dependencies
from .docx_parser import DocxParser
from .omml_parser import OmmlParser
from .mathml_parser import MathMLParser
# Explicit parser type specified
if parser_type:
parser_type = parser_type.lower()
if parser_type == "docx":
return DocxParser()
elif parser_type == "omml":
return OmmlParser()
elif parser_type == "mathml":
return MathMLParser()
else:
raise ValueError(f"Unknown parser type: {parser_type}")
# Auto-detection based on source
# Check file extension first (most common case)
if source.endswith('.docx') or (os.path.exists(source) and source.lower().endswith('.docx')):
return DocxParser()
# Check content for XML markers
# Strip whitespace for reliable detection
content_start = source.strip()[:200] # Check first 200 chars
# Check for OMML namespace
if '<m:oMath' in content_start or '<m:oMathPara' in content_start:
return OmmlParser()
# Check for MathML namespace
if '<math' in content_start or '<mml:math' in content_start:
return MathMLParser()
# If it's a file path but not .docx, check file content
if os.path.exists(source):
try:
with open(source, 'r', encoding='utf-8') as f:
file_content = f.read(500) # Read first 500 chars
if '<m:oMath' in file_content:
return OmmlParser()
elif '<math' in file_content or '<mml:math' in file_content:
return MathMLParser()
except (UnicodeDecodeError, IOError):
# Binary file or read error - not a supported format
pass
# Unable to determine format
preview = source if len(source) <= 100 else source[:100] + "..."
raise ValueError(
f"Unable to determine input format for source: '{preview}'. "
f"Supported formats: .docx files, OMML XML (<m:oMath), MathML XML (<math)"
)
@staticmethod
def detect_parser_type(source: str) -> str:
"""
Detect the parser type needed for the input source.
Args:
source: Input source (file path or content string)
Returns:
Parser type string ("docx", "omml", or "mathml")
Raises:
ValueError: If input format cannot be determined
Examples:
>>> factory = InputParserFactory()
>>> factory.detect_parser_type("equations.docx")
'docx'
>>> factory.detect_parser_type("<math><mi>x</mi></math>")
'mathml'
"""
parser = InputParserFactory.create_parser(source)
return parser.parser_type
@staticmethod
def get_supported_formats() -> List[str]:
"""
Get list of supported input formats.
Returns:
List of format identifiers that can be parsed
"""
return ["docx", "omml", "mathml"]
def __repr__(self) -> str:
"""String representation for debugging."""
return "InputParserFactory(docx, omml, mathml)"

View File

@@ -0,0 +1,168 @@
"""
MathML parser for direct MathML input.
This parser handles MathML XML input, performing validation and extraction
of individual math equations.
"""
import sys
from typing import List
from pathlib import Path
from lxml import etree as ET
from ..core.interfaces import Parser
# Import utilities from omml_to_tex package for HTML entity decoding and equation extraction
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from ..utils import decode_html_entities, extract_math_equations
class MathMLParser(Parser):
"""
Parser for MathML XML input.
This parser handles direct MathML XML input, performing basic validation
and extracting individual <math> elements.
Examples:
Single equation:
>>> parser = MathMLParser()
>>> mathml = "<math><mi>x</mi></math>"
>>> results = parser.parse(mathml)
>>> len(results)
1
Multiple equations:
>>> parser = MathMLParser()
>>> mathml = "<root><math><mi>x</mi></math><math><mi>y</mi></math></root>"
>>> results = parser.parse(mathml)
>>> len(results)
2
"""
def __init__(self):
"""Initialize the MathML parser."""
pass
def parse(self, source: str) -> List[str]:
"""
Parse MathML input and return list of MathML equation strings.
Args:
source: MathML XML string or file path
Returns:
List of MathML equation strings (one per <math> element)
Raises:
ValueError: If input is not valid MathML XML
RuntimeError: If parsing fails
Examples:
>>> parser = MathMLParser()
>>> mathml = "<math><mi>x</mi><mo>+</mo><mi>y</mi></math>"
>>> results = parser.parse(mathml)
>>> len(results)
1
>>> '<mi>x</mi>' in results[0]
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 MathML file: {e}")
# STRATEGY 1: Try regex-based extraction first (handles multi-equation files + HTML entities)
# This is the same approach as the original omml_to_tex.py
try:
equations = extract_math_equations(source)
if equations:
return equations
except Exception:
# If regex extraction fails, fall through to XML parsing
pass
# STRATEGY 2: Try XML parsing as fallback (single well-formed document)
try:
root = ET.fromstring(source.encode('utf-8'))
except ET.XMLSyntaxError as e:
raise ValueError(f"Invalid XML syntax: {e}")
# Find all <math> elements
math_elements = []
# Check if root itself is a <math> element
if self._is_math_element(root):
math_elements.append(root)
else:
# Search for <math> elements (with or without namespace)
math_elements.extend(root.findall('.//{http://www.w3.org/1998/Math/MathML}math'))
math_elements.extend(root.findall('.//math'))
if not math_elements:
# No <math> elements found - treat entire document as one equation if it looks like MathML
if self._looks_like_mathml(root):
math_elements.append(root)
else:
raise ValueError("No <math> elements found in MathML input")
# Convert each <math> element to string
results = []
for math_elem in math_elements:
mathml_str = ET.tostring(math_elem, encoding='unicode')
results.append(mathml_str)
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 MathML, False otherwise
Examples:
>>> parser = MathMLParser()
>>> parser.can_handle("<math><mi>x</mi></math>")
True
>>> parser.can_handle("<m:oMath>...</m:oMath>")
False
"""
# Check for MathML markers
content_start = source.strip()[:200]
return '<math' in content_start or '<mml:math' in content_start
@property
def parser_type(self) -> str:
"""Get the parser type identifier."""
return "mathml"
def _is_math_element(self, element: ET.Element) -> bool:
"""Check if element is a <math> element."""
local_name = element.tag.split('}')[-1] if '}' in element.tag else element.tag
return local_name == 'math'
def _looks_like_mathml(self, element: ET.Element) -> bool:
"""Check if element tree looks like MathML content."""
# Check for common MathML element tags
mathml_tags = {'mi', 'mo', 'mn', 'mrow', 'mfrac', 'msup', 'msub', 'mtext'}
# Get all element tags in tree
all_tags = set()
for elem in element.iter():
local_name = elem.tag.split('}')[-1] if '}' in elem.tag else elem.tag
all_tags.add(local_name)
# If at least 2 MathML tags present, consider it MathML
return len(all_tags & mathml_tags) >= 2
def __repr__(self) -> str:
"""String representation for debugging."""
return "MathMLParser()"

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()"