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,27 @@
"""
Input/output processing module for OMML and DOCX files.
This module handles:
- DOCX file processing and equation extraction
- OMML extraction from Word documents
- LaTeX output formatting (inline, display, various delimiters)
- Result data structures
"""
from .result import ConversionResult
from .omml_extractor import OMMLExtractor
from .docx_processor import DocxProcessor
from .output_formatter import (
format_latex,
save_results,
generate_html_preview,
)
__all__ = [
"ConversionResult",
"OMMLExtractor",
"DocxProcessor",
"format_latex",
"save_results",
"generate_html_preview",
]

View File

@@ -0,0 +1,156 @@
"""
DOCX file processing and end-to-end conversion.
This module provides the DocxProcessor class which orchestrates the complete
conversion pipeline from DOCX to LaTeX, including OMML extraction, MathML
transformation, and LaTeX conversion.
"""
import logging
import time
from typing import List
from .result import ConversionResult
from .omml_extractor import OMMLExtractor
logger = logging.getLogger(__name__)
class DocxProcessor:
"""Process DOCX files and convert all OMML equations to LaTeX.
This class coordinates:
- OMML extraction from DOCX (via OMMLExtractor)
- OMML → MathML transformation (via omml_to_mathml function)
- MathML → LaTeX conversion (via MathMLToTeXConverter)
Example:
>>> from omml2tex.compat import LegacyMathMLToTeXConverter
>>> processor = DocxProcessor(
... xsl_path='OMML2MML.xsl',
... converter=MathMLToTeXConverter()
... )
>>> results = processor.process_docx('document.docx')
>>> for result in results:
... if result.success:
... print(result.latex)
"""
def __init__(self, xsl_path: str, converter, subject_mode: str = "auto"):
"""Initialize DOCX processor.
Args:
xsl_path: Path to OMML2MML.xsl transformation file
converter: MathMLToTeXConverter instance
subject_mode: Subject mode for conversion ('auto', 'math', 'chemistry', 'physics')
"""
self.extractor = OMMLExtractor()
self.xsl_path = xsl_path
self.converter = converter
self.subject_mode = subject_mode
def process_docx(self, docx_path: str, omml_to_mathml_func=None) -> List[ConversionResult]:
"""Process a DOCX file and convert all equations.
Args:
docx_path: Path to DOCX file
omml_to_mathml_func: Function to convert OMML to MathML (for dependency injection)
Returns:
List of ConversionResult objects
Example:
>>> results = processor.process_docx('test.docx')
>>> successful = [r for r in results if r.success]
>>> print(f"{len(successful)}/{len(results)} equations converted")
"""
results = []
try:
# Extract OMML equations
equations = self.extractor.extract_from_docx(docx_path)
logger.info(f"Found {len(equations)} equations in {docx_path}")
for eq_data in equations:
result = ConversionResult()
result.omml = eq_data['cleaned_omml']
result.context = eq_data['context']
result.position = eq_data['position']
result.source_file = eq_data['source_file']
result.display_mode = eq_data.get('display_mode', False)
try:
start_time = time.time()
# Convert OMML to MathML
if omml_to_mathml_func is None:
# Import here to avoid circular dependency
from ..core.xslt_transformer import omml_to_mathml
omml_to_mathml_func = omml_to_mathml
mathml_xml = omml_to_mathml_func(eq_data['cleaned_omml'], self.xsl_path)
result.mathml = mathml_xml
# Convert MathML to LaTeX
latex = self.converter.convert(mathml_xml)
result.latex = latex
result.processing_time = time.time() - start_time
result.success = True
except Exception as e:
result.error = str(e)
result.success = False
logger.error(f"Failed to convert equation {result.position}: {e}")
results.append(result)
except Exception as e:
logger.error(f"Failed to process DOCX file: {e}")
# Return empty results with error
result = ConversionResult()
result.error = f"DOCX processing failed: {e}"
results.append(result)
return results
def detect_subject_mode(self, results: List[ConversionResult]) -> str:
"""Auto-detect subject mode based on content.
Analyzes context text from equations to determine if document
is primarily chemistry, physics, or general math.
Args:
results: List of conversion results with context
Returns:
Detected subject mode: 'chemistry', 'physics', or 'math'
Example:
>>> results = processor.process_docx('chem_doc.docx')
>>> mode = processor.detect_subject_mode(results)
>>> print(f"Detected mode: {mode}")
"""
chemistry_indicators = 0
physics_indicators = 0
total_context = ""
for result in results:
if result.success and result.context:
total_context += " " + result.context.lower()
# Simple heuristic detection
chemistry_words = ['reaction', 'equation', 'chemical', 'compound', 'element', 'molecule', 'ion', 'acid', 'base']
physics_words = ['force', 'energy', 'velocity', 'acceleration', 'wave', 'field', 'quantum', 'particle']
for word in chemistry_words:
chemistry_indicators += total_context.count(word)
for word in physics_words:
physics_indicators += total_context.count(word)
if chemistry_indicators > physics_indicators and chemistry_indicators > 2:
return "chemistry"
elif physics_indicators > chemistry_indicators and physics_indicators > 2:
return "physics"
else:
return "math"

View File

@@ -0,0 +1,273 @@
"""
OMML equation extraction from DOCX files.
This module provides the OMMLExtractor class which extracts OMML (Office Math Markup Language)
equations from Microsoft Word DOCX files, including main document, headers, and footers.
"""
import zipfile
import logging
from pathlib import Path
from typing import List, Dict, Any
from lxml import etree as ET
logger = logging.getLogger(__name__)
class OMMLExtractor:
"""Extract OMML equations from DOCX files.
Handles extraction from:
- Main document body (word/document.xml)
- Headers (word/header*.xml)
- Footers (word/footer*.xml)
Preserves:
- Non-breaking spaces (NBSP, U+00A0)
- Display mode detection (inline vs display equations)
- Context from surrounding paragraphs
- Position tracking
Example:
>>> extractor = OMMLExtractor()
>>> equations = extractor.extract_from_docx('document.docx')
>>> print(f"Found {len(equations)} equations")
>>> print(extractor.stats)
"""
# OMML and Word namespace definitions
NAMESPACES = {
'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
'm': 'http://schemas.openxmlformats.org/officeDocument/2006/math',
'wp': 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'
}
def __init__(self):
"""Initialize extractor with empty statistics."""
self.stats = {
'equations_found': 0,
'files_processed': 0,
'errors': 0
}
def extract_from_docx(self, docx_path: str) -> List[Dict[str, Any]]:
"""Extract all OMML equations from a DOCX file.
Args:
docx_path: Path to DOCX file
Returns:
List of equation dictionaries with keys:
- omml: Raw OMML XML string
- cleaned_omml: Cleaned OMML ready for transformation
- position: Position/index in document
- context: Surrounding text context
- source_file: XML file within DOCX (e.g., 'word/document.xml')
- display_mode: Boolean indicating display (True) vs inline (False)
Raises:
FileNotFoundError: If DOCX file doesn't exist
Exception: For other processing errors
Example:
>>> extractor = OMMLExtractor()
>>> equations = extractor.extract_from_docx('test.docx')
>>> for eq in equations:
... print(f"Position {eq['position']}: {eq['display_mode']}")
"""
equations = []
docx_path = Path(docx_path)
if not docx_path.exists():
raise FileNotFoundError(f"DOCX file not found: {docx_path}")
try:
with zipfile.ZipFile(docx_path, 'r') as docx:
# Extract main document
equations.extend(self._extract_from_document(docx, 'word/document.xml'))
# Check for equations in headers/footers
header_files = [f for f in docx.namelist() if f.startswith('word/header')]
footer_files = [f for f in docx.namelist() if f.startswith('word/footer')]
for header_file in header_files:
equations.extend(self._extract_from_document(docx, header_file))
for footer_file in footer_files:
equations.extend(self._extract_from_document(docx, footer_file))
self.stats['files_processed'] += 1
self.stats['equations_found'] += len(equations)
except Exception as e:
self.stats['errors'] += 1
logger.error(f"Error processing {docx_path}: {e}")
raise
return equations
def _extract_from_document(self, docx: zipfile.ZipFile, xml_path: str) -> List[Dict[str, Any]]:
"""Extract equations from a specific XML document within the DOCX.
Args:
docx: Open zipfile.ZipFile object
xml_path: Path to XML file within DOCX
Returns:
List of equation dictionaries
"""
equations = []
try:
if xml_path not in docx.namelist():
return equations
doc_xml = docx.read(xml_path)
# IMPORTANT: Preserve NBSP characters before XML parsing
# lxml normalizes U+00A0 (NBSP) to regular space, so we replace it
# with a placeholder that we'll restore later
# UTF-8 encoding of NBSP is \xc2\xa0
doc_xml = doc_xml.replace(b'\xc2\xa0', b' ') # Convert NBSP to decimal entity
# Use lxml for parsing to enable getparent() method
root = ET.fromstring(doc_xml)
# Find all OMML equations
math_elements = root.findall('.//m:oMath', self.NAMESPACES)
for i, math_elem in enumerate(math_elements):
try:
# Extract raw OMML (use lxml for consistency)
raw_omml = ET.tostring(math_elem, encoding='unicode', method='xml')
# Clean and prepare OMML
cleaned_omml = self._clean_omml(raw_omml)
# Get surrounding context
context = self._get_context(math_elem)
# Detect if this is a display equation (wrapped in oMathPara)
display_mode = self._is_display_equation(math_elem)
equation_data = {
'omml': raw_omml,
'cleaned_omml': cleaned_omml,
'position': i,
'context': context,
'source_file': xml_path,
'display_mode': display_mode
}
equations.append(equation_data)
except Exception as e:
logger.warning(f"Error extracting equation {i} from {xml_path}: {e}")
continue
except Exception as e:
logger.error(f"Error reading {xml_path}: {e}")
return equations
def _clean_omml(self, omml: str) -> str:
"""Clean and prepare OMML for transformation.
Fixes:
- Missing namespace declarations
- Word formatting properties
- Whitespace normalization between tags
Args:
omml: Raw OMML XML string
Returns:
Cleaned OMML string
"""
import re
# Add proper namespace declarations if missing
if 'xmlns:m=' not in omml:
omml = omml.replace(
'<m:oMath',
'<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"'
)
if 'xmlns:w=' not in omml and 'w:' in omml:
omml = omml.replace(
'<m:oMath',
'<m:oMath xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'
)
# Remove Word formatting properties that might interfere (but preserve font information)
omml = re.sub(r'<w:szCs[^>]*?/>', '', omml)
# Keep w:color elements for color preservation
# omml = re.sub(r'<w:color[^>]*?/>', '', omml)
# Normalize whitespace BETWEEN XML tags only (not inside text content)
# Collapse multiple whitespace chars between tags into single space
# Pattern: ">[ \t\n\r]+<" → "> <"
omml = re.sub(r'>([ \t\n\r]+)<', r'> <', omml)
omml = omml.strip()
return omml
def _get_context(self, math_elem: ET._Element) -> str:
"""Get surrounding text context for the equation.
Args:
math_elem: OMML math element
Returns:
Surrounding paragraph text (max 200 characters)
"""
try:
# Find the parent paragraph
current = math_elem
while current is not None:
if current.tag.endswith('p'): # Word paragraph
break
parent = current.find('..')
current = parent
if current is None:
return ""
# Extract all text from the paragraph
text_parts = []
for elem in current.iter():
if elem.tag.endswith('t') and elem.text: # Word text element
text_parts.append(elem.text)
context = ' '.join(text_parts).strip()
# Limit context length
if len(context) > 200:
context = context[:200] + "..."
return context
except Exception as e:
logger.debug(f"Error getting context: {e}")
return ""
def _is_display_equation(self, math_elem: ET._Element) -> bool:
"""Determine if an equation is display mode ($$) or inline mode ($).
Display equations are wrapped in <m:oMathPara>, inline equations are not.
Args:
math_elem: OMML math element
Returns:
True for display mode, False for inline mode
"""
try:
# Check if the parent is oMathPara
parent = math_elem.getparent()
if parent is not None and parent.tag.endswith('oMathPara'):
return True
return False
except Exception as e:
logger.debug(f"Error detecting display mode: {e}")
return False # Default to inline if unsure

View File

@@ -0,0 +1,221 @@
"""
Output formatting for conversion results.
This module provides functions to format conversion results in various output
formats including LaTeX, JSON, and HTML with MathJax rendering.
"""
import json
from typing import List
from .result import ConversionResult
from ..utils.text_processing import write_text
def format_latex(results: List[ConversionResult], format_type: str = "display",
delimiter: str = "dollar") -> str:
"""Format LaTeX results for output.
Args:
results: List of conversion results
format_type: 'inline', 'display', or 'both' (auto-detect)
delimiter: 'dollar', 'bracket', 'mathjax', or 'katex'
Returns:
Formatted LaTeX string with appropriate delimiters
Example:
>>> results = [ConversionResult()]
>>> results[0].success = True
>>> results[0].latex = 'x + y'
>>> results[0].display_mode = False
>>> output = format_latex(results, 'both', 'dollar')
>>> print(output)
'$x + y$'
"""
if delimiter == "dollar":
inline_start, inline_end = "$", "$"
display_start, display_end = "$$", "$$"
elif delimiter == "bracket":
inline_start, inline_end = "\\(", "\\)"
display_start, display_end = "\\[", "\\]"
elif delimiter == "mathjax":
inline_start, inline_end = "\\(", "\\)"
display_start, display_end = "$$", "$$"
elif delimiter == "katex":
inline_start, inline_end = "$", "$"
display_start, display_end = "$$", "$$"
else:
inline_start, inline_end = "$", "$"
display_start, display_end = "$$", "$$"
output_lines = []
for i, result in enumerate(results):
if not result.success:
output_lines.append(f"% Equation {i+1}: ERROR - {result.error}")
continue
# Determine if equation should be inline or display
if hasattr(result, 'display_mode') and format_type == "both":
# Use the detected display mode from OMML structure
if result.display_mode:
equation = f"{display_start}{result.latex}{display_end}"
else:
equation = f"{inline_start}{result.latex}{inline_end}"
elif format_type == "inline":
equation = f"{inline_start}{result.latex}{inline_end}"
elif format_type == "display":
equation = f"{display_start}{result.latex}{display_end}"
else: # format_type == "both" but no display_mode set
# Use heuristic: short equations inline, complex ones display
if len(result.latex) < 50 and not any(cmd in result.latex for cmd in ["\\frac", "\\sum", "\\int", "\\begin"]):
equation = f"{inline_start}{result.latex}{inline_end}"
else:
equation = f"{display_start}{result.latex}{display_end}"
output_lines.append(equation)
output_lines.append("") # Empty line between equations
return "\n".join(output_lines)
def save_results(results: List[ConversionResult], output_path: str,
format_type: str = "display", delimiter: str = "dollar",
include_context: bool = False, output_format: str = None):
"""Save conversion results in various formats.
Args:
results: List of conversion results
output_path: Output file path
format_type: For LaTeX output - 'inline', 'display', or 'both'
delimiter: For LaTeX output - delimiter style
include_context: Whether to include context in JSON output
output_format: 'latex', 'json', or 'html' (auto-detected from output_path if not provided)
Example:
>>> results = [ConversionResult()]
>>> save_results(results, 'output.json')
>>> save_results(results, 'output.tex', format_type='inline', delimiter='dollar')
"""
# Auto-detect output format from file extension if not provided
if output_format is None:
if output_path.endswith('.json'):
output_format = 'json'
elif output_path.endswith('.html'):
output_format = 'html'
else:
# Default to LaTeX for .tex, .md, and any other extension
output_format = 'latex'
if output_format == "latex":
latex_content = format_latex(results, format_type, delimiter)
write_text(output_path, latex_content)
elif output_format == "json":
json_data = {
"metadata": {
"total_equations": len(results),
"successful_conversions": sum(1 for r in results if r.success),
"failed_conversions": sum(1 for r in results if not r.success),
"format_type": format_type,
"delimiter": delimiter,
"include_context": include_context
},
"equations": [result.to_dict() for result in results]
}
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(json_data, f, indent=2, ensure_ascii=False)
elif output_format == "html":
html_content = generate_html_preview(results, format_type, delimiter)
write_text(output_path, html_content)
def generate_html_preview(results: List[ConversionResult], format_type: str, delimiter: str) -> str:
"""Generate HTML preview with MathJax rendering.
Args:
results: List of conversion results
format_type: 'inline', 'display', or 'both'
delimiter: Delimiter style
Returns:
HTML document string with embedded MathJax
Example:
>>> results = [ConversionResult()]
>>> html = generate_html_preview(results, 'display', 'dollar')
>>> '<!DOCTYPE html>' in html
True
"""
html_template = """<!DOCTYPE html>
<html>
<head>
<title>OMML to LaTeX Conversion Results</title>
<script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script>
<script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
<style>
body {{ font-family: Arial, sans-serif; margin: 40px; }}
.equation {{ margin: 20px 0; padding: 15px; border: 1px solid #ddd; background: #f9f9f9; }}
.context {{ font-style: italic; color: #666; margin-bottom: 10px; }}
.latex-code {{ font-family: monospace; background: #f0f0f0; padding: 10px; margin: 5px 0; }}
.error {{ color: red; background: #ffe6e6; }}
.success {{ border-left: 4px solid #4CAF50; }}
.failure {{ border-left: 4px solid #f44336; }}
.stats {{ background: #e3f2fd; padding: 15px; margin-bottom: 20px; }}
</style>
</head>
<body>
<h1>OMML to LaTeX Conversion Results</h1>
<div class="stats">
<h3>Conversion Statistics</h3>
<p>Total equations: {total}</p>
<p>Successful: {successful}</p>
<p>Failed: {failed}</p>
<p>Success rate: {rate:.1f}%</p>
</div>
{equations_html}
</body>
</html>"""
successful = sum(1 for r in results if r.success)
failed = len(results) - successful
success_rate = (successful / len(results)) * 100 if results else 0
equations_html = []
for i, result in enumerate(results):
equation_class = "success" if result.success else "failure"
if result.success:
latex_formatted = format_latex([result], format_type, delimiter).strip()
equation_html = f"""
<div class="equation {equation_class}">
<h4>Equation {i+1}</h4>
{f'<div class="context">Context: {result.context}</div>' if result.context else ''}
<div class="latex-code">LaTeX: {result.latex}</div>
<div>Rendered: {latex_formatted}</div>
</div>
"""
else:
equation_html = f"""
<div class="equation {equation_class}">
<h4>Equation {i+1} - ERROR</h4>
{f'<div class="context">Context: {result.context}</div>' if result.context else ''}
<div class="error">Error: {result.error}</div>
</div>
"""
equations_html.append(equation_html)
return html_template.format(
total=len(results),
successful=successful,
failed=failed,
rate=success_rate,
equations_html="\n".join(equations_html)
)

View File

@@ -0,0 +1,96 @@
"""
Conversion result data structure.
This module provides the ConversionResult class which represents the result
of an OMML to LaTeX conversion operation.
"""
from typing import Optional, List, Dict, Any
class ConversionResult:
"""Result of OMML conversion process.
Attributes:
success: Whether conversion was successful
omml: Original OMML XML string
mathml: Intermediate MathML XML string
latex: Final LaTeX output
context: Surrounding text context from source document
position: Position/index of equation in source document
source_file: Source file name or path
error: Error message if conversion failed
warnings: List of warning messages
processing_time: Time taken for conversion in seconds
display_mode: True for display equations ($$), False for inline ($)
Example:
>>> result = ConversionResult()
>>> result.success = True
>>> result.latex = '\\\\frac{1}{2}'
>>> result.display_mode = False
>>> print(result.to_dict())
{'success': True, 'latex': '\\\\frac{1}{2}', ...}
"""
def __init__(self):
"""Initialize conversion result with default values."""
self.success: bool = False
self.omml: str = ""
self.mathml: str = ""
self.latex: str = ""
self.context: str = ""
self.position: int = 0
self.source_file: str = ""
self.error: Optional[str] = None
self.warnings: List[str] = []
self.processing_time: float = 0.0
self.display_mode: bool = False # True for display equations ($$), False for inline ($)
def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary for JSON serialization.
Returns:
Dictionary representation of conversion result
Example:
>>> result = ConversionResult()
>>> result.success = True
>>> result.latex = 'x + y'
>>> data = result.to_dict()
>>> print(data['latex'])
'x + y'
"""
return {
'success': self.success,
'omml': self.omml,
'mathml': self.mathml,
'latex': self.latex,
'context': self.context,
'position': self.position,
'source_file': self.source_file,
'error': self.error,
'warnings': self.warnings,
'processing_time': self.processing_time,
'display_mode': self.display_mode
}
def __repr__(self) -> str:
"""Return detailed string representation.
Returns:
String representation showing key attributes
"""
status = "Success" if self.success else f"Failed: {self.error}"
return f"ConversionResult(position={self.position}, status={status}, latex='{self.latex[:50]}...')"
def __str__(self) -> str:
"""Return human-readable string representation.
Returns:
User-friendly string representation
"""
if self.success:
return f"Equation {self.position}: {self.latex}"
else:
return f"Equation {self.position}: ERROR - {self.error}"