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:
327
scripts/omml_mathml2latex/visitors/mtext_visitor.py
Normal file
327
scripts/omml_mathml2latex/visitors/mtext_visitor.py
Normal file
@@ -0,0 +1,327 @@
|
||||
r"""
|
||||
Visitor for <mtext> (text content) MathML elements.
|
||||
|
||||
The <mtext> element represents text content within mathematical expressions:
|
||||
- Descriptive text: "and", "or", "if", "then"
|
||||
- Unit labels: "kg", "mol", "Hz", "Pa", "m/s"
|
||||
- Chinese/Unicode text: "电解", "加热", "催化剂"
|
||||
- Words in equations: "min", "max", "for all"
|
||||
|
||||
This visitor wraps text in \text{} or \mathrm{} depending on context.
|
||||
"""
|
||||
|
||||
from lxml import etree as ET
|
||||
from ..core.interfaces import Visitor, Strategy
|
||||
from ..utils import text_content as _text_content, local_name as _local, escape_latex_special_chars
|
||||
from ..data import UNICODE_TO_LATEX, UNICODE_PASSTHROUGH
|
||||
|
||||
|
||||
class MtextVisitor(Visitor):
|
||||
r"""
|
||||
Handles <mtext> (text content) MathML elements.
|
||||
|
||||
Converts text content to LaTeX using appropriate text commands.
|
||||
|
||||
Examples:
|
||||
Simple text:
|
||||
<mtext>and</mtext> → \text{and}
|
||||
|
||||
Units:
|
||||
<mtext>kg</mtext> → \text{kg}
|
||||
|
||||
Chinese text:
|
||||
<mtext>电解</mtext> → \text{电解}
|
||||
|
||||
Multi-word text:
|
||||
<mtext>for all</mtext> → \text{for all}
|
||||
|
||||
Empty text:
|
||||
<mtext></mtext> → (empty string)
|
||||
|
||||
Text with spaces:
|
||||
<mtext> spaced </mtext> → \text{ spaced }
|
||||
"""
|
||||
|
||||
# Common SI units (should use \mathrm for units in some contexts)
|
||||
SI_UNITS = {
|
||||
# Base units
|
||||
'm', 'kg', 'g', 's', 'A', 'K', 'mol', 'cd',
|
||||
# Derived units
|
||||
'Hz', 'N', 'Pa', 'J', 'W', 'C', 'V', 'F', 'Ω',
|
||||
'S', 'Wb', 'T', 'H', 'lm', 'lx', 'Bq', 'Gy', 'Sv',
|
||||
# Common combinations
|
||||
'km', 'cm', 'mm', 'nm', 'pm',
|
||||
'km/h', 'm/s', 'm/s²', 'cm³', 'm³',
|
||||
'kJ', 'MJ', 'kW', 'MW',
|
||||
'mA', 'kA', 'mV', 'kV',
|
||||
# Other common units
|
||||
'L', 'mL', 'atm', 'bar', 'mmHg',
|
||||
'eV', 'kcal', 'cal',
|
||||
'°C', '°F', 'K', # Temperature
|
||||
}
|
||||
|
||||
# Common mathematical/logical text words
|
||||
TEXT_WORDS = {
|
||||
'and', 'or', 'not', 'if', 'then', 'else',
|
||||
'for', 'all', 'such', 'that', 'where',
|
||||
'with', 'when', 'otherwise',
|
||||
'let', 'be', 'is', 'are',
|
||||
}
|
||||
|
||||
def __init__(self, strategy: Strategy):
|
||||
"""Initialize the Mtext visitor."""
|
||||
super().__init__(strategy)
|
||||
self._converter_context = None
|
||||
|
||||
def set_converter_context(self, converter):
|
||||
"""Set converter context for color suppression checking."""
|
||||
self._converter_context = converter
|
||||
|
||||
def visit(self, node: ET.Element) -> str:
|
||||
r"""
|
||||
Convert <mtext> element to LaTeX.
|
||||
|
||||
Args:
|
||||
node: The <mtext> element to convert
|
||||
|
||||
Returns:
|
||||
LaTeX string with text wrapped in \text{} or \mathrm{}
|
||||
|
||||
Raises:
|
||||
ValueError: If node is not an <mtext> element
|
||||
"""
|
||||
if not self.can_handle(node):
|
||||
raise ValueError(f"MtextVisitor cannot handle element '{node.tag}'")
|
||||
|
||||
# Get text content
|
||||
text = _text_content(node)
|
||||
|
||||
# Handle empty text
|
||||
if not text:
|
||||
return ''
|
||||
|
||||
# Escape LaTeX special characters before any other processing
|
||||
text = escape_latex_special_chars(text)
|
||||
|
||||
# Handle invisible function application operator (U+2061)
|
||||
# This is used to indicate function application but should not be rendered
|
||||
if text == "": # U+2061 FUNCTION APPLICATION
|
||||
return "" # Return empty string (invisible)
|
||||
|
||||
# CRITICAL: Check if text is a mathematical function BEFORE other processing
|
||||
# This handles cases like <mtext>sin</mtext> → \sin (not \mathrm{sin})
|
||||
from ..data import MATH_FUNCTIONS
|
||||
text_stripped = text.strip()
|
||||
if text_stripped.lower() in MATH_FUNCTIONS:
|
||||
return f"\\{text_stripped.lower()}"
|
||||
|
||||
# IMPORTANT: Convert Unicode symbols to LaTeX BEFORE wrapping in \text{}
|
||||
# But first check UNICODE_PASSTHROUGH - keep these as-is for MathJax compatibility
|
||||
text_stripped = text.strip()
|
||||
if len(text_stripped) == 1 and text_stripped in UNICODE_PASSTHROUGH:
|
||||
# Single symbol that should be kept as Unicode
|
||||
return text_stripped
|
||||
|
||||
if len(text_stripped) == 1 and text_stripped in UNICODE_TO_LATEX:
|
||||
# Single symbol with direct LaTeX mapping - return LaTeX command directly
|
||||
# (don't wrap in \text{})
|
||||
return UNICODE_TO_LATEX[text_stripped]
|
||||
|
||||
# CRITICAL FIX: For multi-symbol Unicode strings, check if result after conversion
|
||||
# is ALL LaTeX commands or passthrough symbols. If so, don't wrap in \text{}
|
||||
#
|
||||
# This handles cases like:
|
||||
# <mtext>℧◊</mtext> → \mho\lozenge (not \text{\mho\lozenge})
|
||||
# <mtext>∠∢⦞</mtext> → \measuredangle\sphericalangle (not \text{...})
|
||||
# <mtext>∯∰</mtext> → ∯∰ (kept as Unicode for MathJax)
|
||||
if len(text_stripped) > 1 and all(char in UNICODE_TO_LATEX or char in UNICODE_PASSTHROUGH for char in text_stripped):
|
||||
# All characters are Unicode symbols
|
||||
# Convert to LaTeX or keep as Unicode based on PASSTHROUGH set
|
||||
result_parts = []
|
||||
for char in text_stripped:
|
||||
if char in UNICODE_PASSTHROUGH:
|
||||
result_parts.append(char)
|
||||
else:
|
||||
result_parts.append(UNICODE_TO_LATEX[char])
|
||||
return ''.join(result_parts) # Join without spaces for symbols
|
||||
|
||||
# Check for color attributes
|
||||
from ..utils import is_default_color
|
||||
|
||||
color = node.get('mathcolor')
|
||||
if color and not is_default_color(color):
|
||||
# Apply color to text (after Unicode conversion if needed)
|
||||
# Only add textcolor if it's not the default black color
|
||||
text_converted = self._convert_unicode_in_text(text)
|
||||
return f"\\textcolor{{{color}}}{{\\text{{{text_converted}}}}}"
|
||||
|
||||
# Determine whether to use \text{} or \mathrm{}
|
||||
# CRITICAL: The default should be \mathrm{} to match wrap_identifiers_in_mathrm=True
|
||||
# This ensures chemical formulas, units, and identifiers render upright (not italic)
|
||||
|
||||
# CRITICAL FIX: Check for operators in text (like "2NaCl+2H")
|
||||
# If text contains operators, split and process separately
|
||||
# This prevents wrapping operators in \mathrm{} and avoids nested \mathrm{}
|
||||
if any(op in text for op in ['+', '-', '=', '<', '>', '×', '·', '/']):
|
||||
return self._process_complex_mtext(text)
|
||||
|
||||
# Check if we should use \mathrm{} (default for most math/chemistry content)
|
||||
# Use \text{} only for actual prose text (multi-word descriptions)
|
||||
|
||||
# If it contains spaces and looks like prose, use \text{}
|
||||
if ' ' in text and any(word in text_stripped.lower() for word in self.TEXT_WORDS):
|
||||
# Multi-word prose text
|
||||
text_converted = self._convert_unicode_in_text(text)
|
||||
return f"\\text{{{text_converted}}}"
|
||||
|
||||
# If it's Chinese text (common in chemistry for reaction conditions), use \mathrm{}
|
||||
# Chinese text needs upright rendering for chemical equations
|
||||
if self._contains_chinese(text):
|
||||
text_converted = self._convert_unicode_in_text(text)
|
||||
return f"\\mathrm{{{text_converted}}}"
|
||||
|
||||
# Default: use \mathrm{} for chemical formulas, units, and identifiers
|
||||
# This matches the original converter's wrap_identifiers_in_mathrm=True behavior
|
||||
text_converted = self._convert_unicode_in_text(text)
|
||||
|
||||
# CRITICAL FIX: If text_converted is ONLY LaTeX space commands (like ~, \ , \quad, etc.),
|
||||
# return it directly WITHOUT wrapping in \mathrm{}
|
||||
# This fixes the issue where <mtext> </mtext> was becoming \mathrm{~} instead of ~
|
||||
stripped = text_converted.strip()
|
||||
if stripped in ('~', '\\ ', '\\quad', '\\qquad', '\\!', '\\,', '\\:', '\\;'):
|
||||
return text_converted # Return space command directly
|
||||
elif stripped.replace('~', '').replace('\\ ', '').strip() == '':
|
||||
# Pure spaces (multiple ~ or \ )
|
||||
return text_converted
|
||||
|
||||
return f"\\mathrm{{{text_converted}}}"
|
||||
|
||||
def _process_complex_mtext(self, text: str) -> str:
|
||||
"""
|
||||
Process complex mtext that contains operators.
|
||||
|
||||
Splits text at operators and wraps chemical parts in \\mathrm{}
|
||||
while keeping operators unwrapped.
|
||||
|
||||
Example:
|
||||
"2NaCl+2H" → "\\mathrm{2NaCl}+\\mathrm{2H}"
|
||||
|
||||
Args:
|
||||
text: Text containing operators
|
||||
|
||||
Returns:
|
||||
Processed LaTeX with operators unwrapped
|
||||
"""
|
||||
import re
|
||||
|
||||
# Pattern for mathematical operators (including Unicode)
|
||||
operator_pattern = r'([+\-=<>±∓×·/∩∪∈∉⊆⊇∁∅∧∨¬→←↔⇒⇐⇔≡≠≤≥∞])'
|
||||
|
||||
# Split text at operators (operators are preserved in the result)
|
||||
parts = re.split(operator_pattern, text)
|
||||
|
||||
result_parts = []
|
||||
for part in parts:
|
||||
part_stripped = part.strip()
|
||||
if not part_stripped:
|
||||
continue
|
||||
|
||||
# Check if this part is an operator
|
||||
if re.match(operator_pattern, part_stripped):
|
||||
# Operator - keep as-is, DON'T wrap in \mathrm{}
|
||||
result_parts.append(part_stripped)
|
||||
else:
|
||||
# Not an operator - wrap in \mathrm{}
|
||||
# First convert any Unicode symbols
|
||||
part_converted = self._convert_unicode_in_text(part)
|
||||
result_parts.append(f"\\mathrm{{{part_converted}}}")
|
||||
|
||||
# Join parts without spaces (chemical formulas should be compact)
|
||||
return ''.join(result_parts)
|
||||
|
||||
def _contains_chinese(self, text: str) -> bool:
|
||||
"""
|
||||
Check if text contains Chinese (CJK) characters.
|
||||
|
||||
Args:
|
||||
text: Text to check
|
||||
|
||||
Returns:
|
||||
True if text contains Chinese characters, False otherwise
|
||||
"""
|
||||
for char in text:
|
||||
# CJK Unified Ideographs range
|
||||
if '\u4e00' <= char <= '\u9fff':
|
||||
return True
|
||||
return False
|
||||
|
||||
def _convert_unicode_in_text(self, text: str) -> str:
|
||||
"""
|
||||
Convert Unicode symbols within text to LaTeX equivalents.
|
||||
|
||||
For multi-character text with Unicode symbols mixed in,
|
||||
we need to convert each Unicode character individually.
|
||||
|
||||
IMPORTANT: Also converts spaces to LaTeX space commands:
|
||||
- \\xa0 (NBSP) → ~ (non-breaking space in LaTeX)
|
||||
- Regular space (when standalone) → \\ (backslash-space)
|
||||
|
||||
Args:
|
||||
text: Text that may contain Unicode symbols
|
||||
|
||||
Returns:
|
||||
Text with Unicode symbols converted to LaTeX
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
# CRITICAL FIX: Convert non-breaking spaces (\xa0) to ~ BEFORE other processing
|
||||
# This fixes the issue where <mtext> </mtext> was becoming \mathrm{ } instead of ~
|
||||
result = text
|
||||
|
||||
# Step 1: Convert NBSP to LaTeX non-breaking space
|
||||
if '\xa0' in result:
|
||||
result = result.replace('\xa0', '~')
|
||||
|
||||
# Step 2: Convert regular spaces to LaTeX space (but NOT inside prose text)
|
||||
# Only convert standalone spaces, not spaces within multi-word text
|
||||
# Check if this is a single space or contains only spaces
|
||||
if result.strip() == '' and result:
|
||||
# Pure space(s) → convert to ~
|
||||
result = '~' * len(result)
|
||||
elif result in [' ']:
|
||||
# Single space → ~
|
||||
result = '~'
|
||||
|
||||
# Step 3: Convert Unicode symbols (except PASSTHROUGH symbols)
|
||||
# Check if any Unicode symbols are present
|
||||
has_unicode = any(char in UNICODE_TO_LATEX for char in result)
|
||||
has_passthrough = any(char in UNICODE_PASSTHROUGH for char in result)
|
||||
if not has_unicode and not has_passthrough:
|
||||
return result
|
||||
|
||||
# Convert character by character (PASSTHROUGH symbols kept as-is)
|
||||
converted = []
|
||||
for char in result:
|
||||
if char in UNICODE_PASSTHROUGH:
|
||||
# Keep passthrough symbols as Unicode for MathJax
|
||||
converted.append(char)
|
||||
elif char in UNICODE_TO_LATEX:
|
||||
converted.append(UNICODE_TO_LATEX[char])
|
||||
else:
|
||||
converted.append(char)
|
||||
|
||||
return ''.join(converted)
|
||||
|
||||
def can_handle(self, node: ET.Element) -> bool:
|
||||
"""Check if this visitor can handle the given node."""
|
||||
return _local(node.tag) == 'mtext'
|
||||
|
||||
@property
|
||||
def element_name(self) -> str:
|
||||
"""Get the element name this visitor handles."""
|
||||
return 'mtext'
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation for debugging."""
|
||||
return f"MtextVisitor(strategy={self.strategy.name})"
|
||||
Reference in New Issue
Block a user