Files
Quentin 3271fee5cc 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 默认密钥已清空
2026-09-29 09:43:13 +08:00

328 lines
12 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>&#xA0;</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>&#xA0;</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})"