服务: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 默认密钥已清空
328 lines
12 KiB
Python
328 lines
12 KiB
Python
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})"
|