服务: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 默认密钥已清空
449 lines
18 KiB
Python
449 lines
18 KiB
Python
"""
|
||
Math strategy for standard mathematical notation.
|
||
|
||
This strategy handles formatting for general mathematical equations,
|
||
including function recognition, operator mapping, and number formatting.
|
||
"""
|
||
|
||
from lxml import etree as ET
|
||
from typing import Set, Optional
|
||
from ..core.interfaces import Strategy
|
||
|
||
# Import from existing data module
|
||
from ..data import MATH_FUNCTIONS, UNICODE_TO_LATEX, UNICODE_PASSTHROUGH
|
||
from ..data.chemistry_data import (
|
||
ALL_CHEMICAL_ELEMENTS,
|
||
CHEMISTRY_VARIABLES,
|
||
)
|
||
from ..utils.chemistry_detection import detect_chemistry_equation, is_chemistry_variable
|
||
|
||
|
||
class MathStrategy(Strategy):
|
||
"""
|
||
Standard mathematical notation strategy.
|
||
|
||
This strategy provides formatting for general mathematical content:
|
||
- Function recognition (sin, cos, log → \\sin, \\cos, \\log)
|
||
- Operator symbol mapping (Unicode → LaTeX)
|
||
- Standard number formatting
|
||
- Variables remain italic (LaTeX default)
|
||
- Sophisticated identifier wrapping based on context
|
||
"""
|
||
|
||
# Mathematical functions that get special LaTeX commands
|
||
FUNCTIONS: Set[str] = MATH_FUNCTIONS
|
||
|
||
# Mathematical sets that should use \mathbf{} (bold upright) - ONLY when mathvariant is set
|
||
MATH_SETS = {'R', 'Z', 'N', 'Q', 'C', 'ℝ', 'ℤ', 'ℕ', 'ℚ', 'ℂ'}
|
||
|
||
# CRITICAL: In pure math mode, NO letters are always wrapped
|
||
# Wrapping only happens with explicit mathvariant="normal" attribute
|
||
# This set is kept for compatibility but should be empty for math mode
|
||
ALWAYS_WRAPPED_LETTERS = set() # Empty - no automatic wrapping in math mode
|
||
|
||
def __init__(self, wrap_identifiers_in_mathrm: bool = False):
|
||
"""
|
||
Initialize the math strategy.
|
||
|
||
Args:
|
||
wrap_identifiers_in_mathrm: If True, wrap multi-char identifiers in \\mathrm{}
|
||
Default is False to match original converter behavior
|
||
"""
|
||
self._equation_text: Optional[str] = None
|
||
self._chemical_elements = set() # Will be populated if needed
|
||
self._wrap_identifiers_in_mathrm = wrap_identifiers_in_mathrm
|
||
self._root_element: Optional[ET.Element] = None
|
||
self._is_chemistry_cached: Optional[bool] = None
|
||
|
||
def format_identifier(self, text: str, node: ET.Element) -> str:
|
||
"""
|
||
Format an identifier (<mi> element) for mathematics.
|
||
|
||
Args:
|
||
text: The identifier text content
|
||
node: The <mi> element node
|
||
|
||
Returns:
|
||
Formatted LaTeX string
|
||
|
||
Logic:
|
||
1. Mathematical functions → \\function (e.g., "sin" → "\\sin")
|
||
2. Single letters → check wrapping rules (most stay italic)
|
||
3. Multi-letter → check wrapping rules (depends on context)
|
||
|
||
Examples:
|
||
"sin" → "\\sin"
|
||
"cos" → "\\cos"
|
||
"x" → "x" (italic)
|
||
"R" → "R" (italic in math context)
|
||
"H" → "\\mathrm{H}" (always upright)
|
||
"velocity" → "velocity" (italic if math context)
|
||
"""
|
||
# CRITICAL: Strip whitespace before checking (matches original converter behavior)
|
||
# Original converter does: clean_text = text.strip()
|
||
clean_text = text.strip()
|
||
|
||
# Check if it's a mathematical function (case-insensitive)
|
||
# Original converter does: if clean_text.lower() in MATHEMATICAL_FUNCTIONS
|
||
if clean_text.lower() in self.FUNCTIONS:
|
||
# Special handling for logarithms with bases
|
||
if clean_text == "log2":
|
||
return "\\log_{2}"
|
||
elif clean_text == "log10":
|
||
return "\\log_{10}"
|
||
# Use lowercase version for function name
|
||
return f"\\{clean_text.lower()}"
|
||
|
||
# Check for function with subscript (e.g., "limn→∞")
|
||
for func in ['lim', 'limsup', 'liminf', 'sup', 'inf', 'max', 'min']:
|
||
if clean_text.startswith(func):
|
||
remaining = clean_text[len(func):]
|
||
if remaining:
|
||
# Convert special symbols in subscript
|
||
remaining = (remaining
|
||
.replace('→', '\\to ')
|
||
.replace('∞', '\\infty ')
|
||
.replace('≤', '\\leqslant ')
|
||
.replace('≥', '\\geqslant ')
|
||
.replace('≠', '\\ne ')
|
||
.replace('±', '\\pm '))
|
||
import re
|
||
remaining = re.sub(r'\s+', ' ', remaining).strip()
|
||
return f"\\{func}\\limits_{{{remaining}}}"
|
||
else:
|
||
return f"\\{func}"
|
||
|
||
# CRITICAL: Unicode math set symbols (ℝ, ℤ, etc.) are already handled by UNICODE_TO_LATEX
|
||
# These map to \mathbb{R}, \mathbb{Z}, etc. (not \mathbf{})
|
||
# This is correct because these Unicode symbols ARE blackboard bold characters
|
||
# So we don't need special handling here - just fall through to UNICODE_TO_LATEX check below
|
||
|
||
# UNICODE_PASSTHROUGH: Keep certain symbols as Unicode for MathJax compatibility
|
||
if clean_text in UNICODE_PASSTHROUGH:
|
||
return clean_text
|
||
|
||
# Check for Unicode symbols
|
||
if clean_text in UNICODE_TO_LATEX:
|
||
return UNICODE_TO_LATEX[clean_text]
|
||
|
||
# Handle multi-character strings with Unicode symbols
|
||
if len(clean_text) > 1:
|
||
has_unicode = any(char in UNICODE_TO_LATEX for char in clean_text)
|
||
has_passthrough = any(char in UNICODE_PASSTHROUGH for char in clean_text)
|
||
if has_unicode or has_passthrough:
|
||
converted_parts = []
|
||
for char in clean_text:
|
||
if char in UNICODE_PASSTHROUGH:
|
||
converted_parts.append(char)
|
||
elif char in UNICODE_TO_LATEX:
|
||
converted_parts.append(UNICODE_TO_LATEX[char])
|
||
elif char.isspace():
|
||
converted_parts.append(' ')
|
||
else:
|
||
converted_parts.append(char)
|
||
return ' '.join(converted_parts)
|
||
|
||
# USER REQUIREMENT: "use the mathvariant to determine whether it's mathrm"
|
||
# REMOVED: Chemistry detection heuristics (lines 139-162)
|
||
# REMOVED: should_wrap_in_mathrm() call (now always returns False)
|
||
#
|
||
# Wrapping in \mathrm{} is ONLY done when mathvariant="normal" attribute exists.
|
||
# That's handled in mi_visitor.py _handle_mathvariant() method.
|
||
|
||
# Default: return as-is (variables remain italic in LaTeX)
|
||
return clean_text
|
||
|
||
def format_operator(self, text: str, node: ET.Element) -> str:
|
||
"""
|
||
Format an operator (<mo> element) for mathematics.
|
||
|
||
Args:
|
||
text: The operator text content
|
||
node: The <mo> element node
|
||
|
||
Returns:
|
||
Formatted LaTeX string
|
||
|
||
Logic:
|
||
1. Delimiters → __DELIM__ markers for later processing
|
||
2. Comparison operators < and > → \\lt and \\gt (LaTeX convention)
|
||
3. Unicode symbols → LaTeX equivalents (via UNICODE_TO_LATEX)
|
||
4. Standard operators → unchanged (+, -, *, /, =)
|
||
|
||
Examples:
|
||
"(" → "__DELIM__(__DELIM__"
|
||
")" → "__DELIM__)__DELIM__"
|
||
"+" → "+"
|
||
"<" → "\\lt"
|
||
">" → "\\gt"
|
||
"×" → "\\times"
|
||
"≤" → "\\leqslant"
|
||
"∈" → "\\in"
|
||
"""
|
||
# CRITICAL: Mark delimiters for potential scaling
|
||
# These will be processed later by _process_delimiters()
|
||
# This matches the original converter's behavior (line 3558 in omml_to_tex.py)
|
||
# Include common paired delimiters: parentheses, brackets, braces, angle brackets, ceil/floor
|
||
# BUG-012 FIX: Added "|" to delimiters to allow proper absolute value detection
|
||
import sys
|
||
delimiter_chars = ("(", ")", "[", "]", "{", "}", "⟨", "⟩", "〈", "〉", "⌈", "⌉", "⌊", "⌋", "|")
|
||
if text in delimiter_chars:
|
||
# Convert Unicode delimiters to LaTeX BEFORE marking
|
||
# This ensures the delimiter pairing processor can find and pair them
|
||
delimiter_latex_map = {
|
||
"⟨": "\\langle",
|
||
"⟩": "\\rangle",
|
||
"〈": "\\langle", # CJK angle bracket
|
||
"〉": "\\rangle", # CJK angle bracket
|
||
"⌈": "\\lceil",
|
||
"⌉": "\\rceil",
|
||
"⌊": "\\lfloor",
|
||
"⌋": "\\rfloor",
|
||
"{": "\\{", # FIX: Escape braces so unpaired ones are valid LaTeX
|
||
"}": "\\}", # FIX: Escape braces so unpaired ones are valid LaTeX
|
||
}
|
||
converted_text = delimiter_latex_map.get(text, text)
|
||
|
||
# Check for stretchy attribute - if true, use a special marker to force scaling
|
||
stretchy = node.get('stretchy')
|
||
if stretchy == 'true':
|
||
result = f"__DELIM__STRETCHY::{converted_text}__DELIM__"
|
||
else:
|
||
result = f"__DELIM__{converted_text}__DELIM__"
|
||
|
||
# Debug: print(f"[DEBUG STRATEGY] Marking delimiter {text!r} → {result!r}", file=sys.stderr)
|
||
return result
|
||
|
||
# Handle < and > specifically to match expected output format (from original converter)
|
||
if text == "<":
|
||
return "\\lt"
|
||
if text == ">":
|
||
return "\\gt"
|
||
|
||
# BUG-012 FIX: Removed forced | -> \mid conversion
|
||
# Let DelimiterPairingProcessor decide whether it's \mid (operator) or \left|\right| (abs value)
|
||
# if text == "|":
|
||
# return "\\mid"
|
||
|
||
# UNICODE_PASSTHROUGH: Keep certain symbols as Unicode for MathJax compatibility
|
||
# These symbols have LaTeX commands that MathJax doesn't support or renders poorly.
|
||
# Check this BEFORE converting to LaTeX to preserve the original Unicode.
|
||
if text in UNICODE_PASSTHROUGH:
|
||
return text
|
||
|
||
# Check for Unicode symbol mapping
|
||
if text in UNICODE_TO_LATEX:
|
||
return UNICODE_TO_LATEX[text]
|
||
|
||
# Handle multi-character operators with Unicode
|
||
if len(text) > 1:
|
||
has_unicode = any(char in UNICODE_TO_LATEX for char in text)
|
||
has_passthrough = any(char in UNICODE_PASSTHROUGH for char in text)
|
||
if has_unicode or has_passthrough:
|
||
converted_parts = []
|
||
for char in text:
|
||
# Check passthrough first - keep these as Unicode
|
||
if char in UNICODE_PASSTHROUGH:
|
||
converted_parts.append(char)
|
||
elif char in UNICODE_TO_LATEX:
|
||
converted_parts.append(UNICODE_TO_LATEX[char])
|
||
else:
|
||
converted_parts.append(char)
|
||
return ' '.join(converted_parts)
|
||
|
||
# Default: return as-is
|
||
return text
|
||
|
||
def format_number(self, text: str, node: ET.Element) -> str:
|
||
"""
|
||
Format a number (<mn> element) for mathematics.
|
||
|
||
Args:
|
||
text: The number text content
|
||
node: The <mn> element node
|
||
|
||
Returns:
|
||
Formatted LaTeX string
|
||
|
||
Logic:
|
||
Numbers are returned as-is in mathematics mode.
|
||
|
||
Examples:
|
||
"123" → "123"
|
||
"3.14" → "3.14"
|
||
"1.5×10⁻³" → "1.5×10⁻³" (Unicode handled by operator conversion)
|
||
"""
|
||
# Numbers are typically returned as-is
|
||
# Unicode processing happens at operator level
|
||
return text
|
||
|
||
def set_equation_text(self, text: str) -> None:
|
||
"""
|
||
Set the full equation text for context detection.
|
||
|
||
Args:
|
||
text: The complete equation LaTeX text
|
||
"""
|
||
self._equation_text = text
|
||
|
||
def set_wrap_identifiers_flag(self, value: bool) -> None:
|
||
"""
|
||
Set whether to wrap identifiers in \\mathrm{}.
|
||
|
||
Args:
|
||
value: True to wrap multi-char identifiers, False to leave as-is
|
||
"""
|
||
self._wrap_identifiers_in_mathrm = value
|
||
|
||
def set_root_element(self, root: ET.Element) -> None:
|
||
"""
|
||
Set the root MathML element for context detection.
|
||
|
||
Args:
|
||
root: The root <math> element
|
||
"""
|
||
self._root_element = root
|
||
self._is_chemistry_cached = None # Clear cache
|
||
|
||
def is_chemistry_equation(self) -> bool:
|
||
"""
|
||
Detect if the current equation is chemistry-related.
|
||
|
||
DISABLED: Subject detection is obsolete. We rely on mathvariant="normal" only.
|
||
|
||
Returns:
|
||
False - always (chemistry detection disabled)
|
||
"""
|
||
# DISABLED: Always return False - no chemistry detection
|
||
# Chemistry formulas are handled via mathvariant="normal" in the MathML
|
||
return False
|
||
|
||
# OLD CODE (kept for reference):
|
||
# if self._is_chemistry_cached is not None:
|
||
# return self._is_chemistry_cached
|
||
# if self._root_element is None:
|
||
# return False
|
||
# self._is_chemistry_cached = detect_chemistry_equation(self._root_element)
|
||
# return self._is_chemistry_cached
|
||
|
||
def should_wrap_in_mathrm(self, content: str) -> bool:
|
||
"""
|
||
Determine if an identifier should be wrapped in \\mathrm{}.
|
||
|
||
CRITICAL USER REQUIREMENT: "use the mathvariant to determine whether it's mathrm"
|
||
|
||
This means NO HEURISTIC WRAPPING. Only wrap when there's an explicit
|
||
mathvariant="normal" attribute, which is handled in mi_visitor.py.
|
||
|
||
This method now serves as a safety check only - by default returns False.
|
||
All wrapping decisions are made based on explicit MathML attributes.
|
||
|
||
Args:
|
||
content: The identifier text
|
||
|
||
Returns:
|
||
False - no heuristic wrapping allowed
|
||
"""
|
||
# USER REQUIREMENT: Only mathvariant="normal" should trigger \mathrm wrapping
|
||
# That's handled in mi_visitor.py _handle_mathvariant() method
|
||
# This method should NOT apply any heuristics
|
||
return False
|
||
|
||
def _is_mathematical_variable(self, content: str) -> bool:
|
||
"""
|
||
Determine if multi-letter content should be treated as mathematical variables (not wrapped).
|
||
|
||
Args:
|
||
content: The identifier text
|
||
|
||
Returns:
|
||
True if should remain italic (mathematical variable), False otherwise
|
||
"""
|
||
# Pattern: all lowercase letters (like 'ab', 'xy', 'mn') - typical for mathematical variables
|
||
if content.islower() and content.isalpha():
|
||
# Check if we're in a mathematical context (inequalities, fractions, etc.)
|
||
if self._is_mathematical_context():
|
||
return True
|
||
|
||
# Pattern: mixed case with lowercase start (like 'ab', 'pq') - likely mathematical variables
|
||
if content[0].islower() and content.isalpha() and len(content) <= 3:
|
||
if self._is_mathematical_context():
|
||
return True
|
||
|
||
return False
|
||
|
||
def _is_mathematical_context(self) -> bool:
|
||
"""Check if we're in a mathematical context with inequalities, equations, fractions, etc."""
|
||
if not self._equation_text:
|
||
return False
|
||
|
||
mathematical_indicators = [
|
||
# Inequality operators
|
||
'≤', '≥', '≠', '<', '>', '\\leqslant', '\\geqslant', '\\leq', '\\geq', '\\ne', '\\neq',
|
||
# Mathematical operations and relations
|
||
'=', '+', '-', '×', '÷', '/', '\\cdot', '\\times', '\\div', '\\pm', '\\mp',
|
||
# Fraction indicators
|
||
'\\dfrac', '\\frac', '\\tfrac',
|
||
# Function operations
|
||
'\\sin', '\\cos', '\\tan', '\\log', '\\ln', '\\exp', '\\sqrt',
|
||
# Set operations (when not chemical)
|
||
'\\cap', '\\cup', '∩', '∪',
|
||
# Mathematical symbols
|
||
'∞', '\\infty', '∂', '\\partial', '∇', '\\nabla',
|
||
# Set membership
|
||
'\\in', '\\notin', '∈', '∉'
|
||
]
|
||
|
||
return any(indicator in self._equation_text for indicator in mathematical_indicators)
|
||
|
||
def _is_clear_chemical_context(self) -> bool:
|
||
"""Check if we're in a clear chemical context (more restrictive than set theory)."""
|
||
if not self._equation_text:
|
||
return False
|
||
|
||
chemical_indicators = ['\\xlongequal', 'Cu', 'Mn', 'Fe', 'SO', 'OH', '2+', '2-', '↑', '↓']
|
||
return any(indicator in self._equation_text for indicator in chemical_indicators)
|
||
|
||
def _has_mathematical_functions(self) -> bool:
|
||
"""Check if the current equation contains mathematical functions."""
|
||
# Common mathematical functions that indicate math context, not chemistry
|
||
math_functions = ['sin', 'cos', 'tan', 'log', 'ln', 'exp', 'sqrt', 'lim', 'max', 'min',
|
||
'arcsin', 'arccos', 'arctan', 'sinh', 'cosh', 'tanh']
|
||
|
||
# Check if we have stored equation text for context analysis
|
||
if self._equation_text:
|
||
return any(func in self._equation_text for func in math_functions)
|
||
|
||
return False
|
||
|
||
def detect_context(self, equation_text: str) -> bool:
|
||
"""
|
||
Detect if an equation is mathematical content.
|
||
|
||
Args:
|
||
equation_text: The full equation text
|
||
|
||
Returns:
|
||
True if likely mathematical, False otherwise
|
||
|
||
Note:
|
||
Math is the default context, so this returns True unless
|
||
other strategies provide stronger indicators.
|
||
"""
|
||
# Store equation text for context detection
|
||
self.set_equation_text(equation_text)
|
||
|
||
# Math is the default - return True
|
||
# In practice, this is overridden by more specific strategies
|
||
# (chemistry, physics, statistics) which check for their markers
|
||
return True
|
||
|
||
@property
|
||
def name(self) -> str:
|
||
"""Get the strategy name."""
|
||
return "math"
|
||
|
||
def __repr__(self) -> str:
|
||
"""String representation for debugging."""
|
||
return f"MathStrategy(functions={len(self.FUNCTIONS)})"
|