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:
273
scripts/omml_mathml2latex/processing/omml_extractor.py
Normal file
273
scripts/omml_mathml2latex/processing/omml_extractor.py
Normal 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
|
||||
Reference in New Issue
Block a user