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,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>&#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})"