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:
772
scripts/omml_mathml2latex/visitors/mi_visitor.py
Normal file
772
scripts/omml_mathml2latex/visitors/mi_visitor.py
Normal file
@@ -0,0 +1,772 @@
|
||||
"""
|
||||
Visitor for <mi> (identifier) MathML elements.
|
||||
|
||||
The <mi> element represents identifiers in MathML, including:
|
||||
- Mathematical functions (sin, cos, log)
|
||||
- Variables (x, y, z)
|
||||
- Constants (e, π)
|
||||
- Multi-letter identifiers (velocity, pressure)
|
||||
"""
|
||||
|
||||
from lxml import etree as ET
|
||||
from ..core.interfaces import Visitor, Strategy
|
||||
from ..utils import text_content as _text_content, convert_spaces_to_latex, escape_latex_special_chars
|
||||
from ..data import CHEMICAL_ELEMENTS, COMMON_UNITS, UNICODE_TO_LATEX, UNICODE_PASSTHROUGH
|
||||
from ..converters.post_processor import PostProcessor
|
||||
|
||||
|
||||
class MiVisitor(Visitor):
|
||||
"""
|
||||
Handles <mi> (identifier) MathML elements.
|
||||
|
||||
This is the most common MathML element type (~40% of all elements).
|
||||
It converts identifiers to LaTeX format using subject-specific strategies.
|
||||
|
||||
Examples:
|
||||
Math mode:
|
||||
<mi>sin</mi> → \\sin
|
||||
<mi>x</mi> → x
|
||||
<mi>α</mi> → \\alpha
|
||||
|
||||
Chemistry mode:
|
||||
<mi>Fe</mi> → \\mathrm{Fe}
|
||||
<mi>H</mi> → \\mathrm{H}
|
||||
"""
|
||||
|
||||
def __init__(self, strategy: Strategy, context=None):
|
||||
"""
|
||||
Initialize the Mi visitor.
|
||||
|
||||
Args:
|
||||
strategy: The formatting strategy to use
|
||||
context: Optional ConversionContext for script context tracking
|
||||
"""
|
||||
super().__init__(strategy)
|
||||
self._converter_context = None
|
||||
self.context = context
|
||||
|
||||
def set_converter_context(self, converter):
|
||||
"""Set converter context for color suppression checking."""
|
||||
self._converter_context = converter
|
||||
|
||||
def visit(self, node: ET.Element) -> str:
|
||||
"""
|
||||
Convert <mi> element to LaTeX.
|
||||
|
||||
Args:
|
||||
node: The <mi> element to convert
|
||||
|
||||
Returns:
|
||||
LaTeX string representation
|
||||
|
||||
Raises:
|
||||
ValueError: If node is not an <mi> element
|
||||
"""
|
||||
if not self.can_handle(node):
|
||||
raise ValueError(f"MiVisitor cannot handle element '{node.tag}'")
|
||||
|
||||
# Get text content
|
||||
content = _text_content(node)
|
||||
|
||||
# Handle empty content
|
||||
if not content:
|
||||
return ""
|
||||
|
||||
# Escape LaTeX special characters before any other processing
|
||||
content = escape_latex_special_chars(content)
|
||||
|
||||
# CRITICAL: Check for mathvariant BEFORE reclassifying as operator
|
||||
# This preserves explicit mathvariant="normal" for units like "N/m"
|
||||
mathvariant = node.get('mathvariant')
|
||||
|
||||
# PHASE 1 FIX: Detect delimiters and operators misclassified as identifiers
|
||||
# This handles malformed MathML from OMML2MML.xsl where operators are tagged as <mi>
|
||||
# instead of <mo>. Common issue with Word documents.
|
||||
# EXCEPTION: If mathvariant="normal" is present, handle it specially to preserve mathrm wrapping
|
||||
if self._contains_delimiter_or_operator(content):
|
||||
if mathvariant == 'normal':
|
||||
# Special case: mathvariant="normal" with operators (e.g., "N/m" units)
|
||||
# Split by operators but wrap identifier parts in \mathrm{}
|
||||
result = self._reclassify_with_mathrm(content, node)
|
||||
return result
|
||||
else:
|
||||
# Normal reclassification without mathrm wrapping
|
||||
result = self._reclassify_as_operator(content, node)
|
||||
return result
|
||||
|
||||
# Handle spaces (NBSP → ~, space → \ )
|
||||
# Must be done before function recognition
|
||||
if '\xa0' in content or ' ' in content:
|
||||
converted = convert_spaces_to_latex(content)
|
||||
|
||||
# If content was ONLY spaces, return directly
|
||||
if not content.strip():
|
||||
return self._wrap_with_color(converted, node)
|
||||
|
||||
# Update content with converted spaces
|
||||
content = converted
|
||||
|
||||
# Check for explicit mathvariant (overrides strategy logic)
|
||||
# Note: mathvariant already extracted earlier (line 78) for operator reclassification check
|
||||
if mathvariant:
|
||||
return self._handle_mathvariant(content, mathvariant, node)
|
||||
|
||||
# Check for explicit LaTeX font command
|
||||
latex_font_cmd = node.get('latex-font-cmd')
|
||||
if latex_font_cmd:
|
||||
return self._wrap_with_color(f"\\{latex_font_cmd}{{{content}}}", node)
|
||||
|
||||
# Delegate to strategy for subject-specific formatting
|
||||
result = self.strategy.format_identifier(content, node)
|
||||
|
||||
return self._wrap_with_color(result, node)
|
||||
|
||||
def _handle_mathvariant(self, content: str, mathvariant: str, node: ET.Element) -> str:
|
||||
"""
|
||||
Handle explicit mathvariant attributes.
|
||||
|
||||
Args:
|
||||
content: The text content
|
||||
mathvariant: The mathvariant attribute value
|
||||
node: The element node
|
||||
|
||||
Returns:
|
||||
LaTeX string with appropriate font command
|
||||
"""
|
||||
# CRITICAL: Strip whitespace and check for mathematical functions FIRST
|
||||
# Original converter checks functions before applying mathvariant wrapping
|
||||
from ..data import MATH_FUNCTIONS
|
||||
clean_content = content.strip()
|
||||
|
||||
# CRITICAL FIX: When mathvariant="normal" is present with mixed-case content,
|
||||
# do NOT treat it as a simple math function. Examples:
|
||||
# - "Asin" → should be split to "\mathrm{A}\sin" (not "\Asin")
|
||||
# - "sin" → should still be "\sin" (lowercase function names are always functions)
|
||||
# - "max" → should still be "\max" (lowercase function names are always functions)
|
||||
|
||||
# Check if content is mixed case (has both uppercase and lowercase)
|
||||
has_upper = any(c.isupper() for c in clean_content)
|
||||
has_lower = any(c.islower() for c in clean_content)
|
||||
is_mixed_case = has_upper and has_lower
|
||||
|
||||
# Skip function recognition ONLY for mixed-case content with mathvariant="normal"
|
||||
# This allows "Asin" to be handled later by _split_uppercase_and_function
|
||||
if mathvariant == 'normal' and len(clean_content) > 1 and is_mixed_case:
|
||||
# Mixed-case content like "Asin" should NOT be treated as function
|
||||
# Fall through to the mathvariant handling logic below
|
||||
pass
|
||||
else:
|
||||
# Enhanced function recognition: detect functions with inline subscripts
|
||||
# Pattern to match function + subscript (like "limn→∞" or "max0≤x≤1")
|
||||
import re
|
||||
for func in ['lim', 'limsup', 'liminf', 'sup', 'inf', 'max', 'min']:
|
||||
if clean_content.startswith(func):
|
||||
remaining = clean_content[len(func):]
|
||||
if remaining:
|
||||
# We found a function with trailing content - this should be a subscript
|
||||
# Use _convert_unicode_in_content to handle all Unicode symbols
|
||||
remaining_converted = self._convert_unicode_in_content(remaining)
|
||||
return f"\\{func}\\limits_{{{remaining_converted}}}"
|
||||
else:
|
||||
return f"\\{func}"
|
||||
|
||||
# If it's a mathematical function, ignore mathvariant and use function command
|
||||
# This handles lowercase function names like "sin", "cos", "max", "lim"
|
||||
if clean_content.lower() in MATH_FUNCTIONS:
|
||||
return f"\\{clean_content.lower()}"
|
||||
|
||||
# For other functions with trailing content
|
||||
for func in MATH_FUNCTIONS:
|
||||
if clean_content.startswith(func) and len(clean_content) > len(func):
|
||||
# Make sure it's not just a substring (e.g., 'limits' shouldn't match 'lim')
|
||||
if not clean_content[len(func)].isalpha():
|
||||
remaining = clean_content[len(func):]
|
||||
# CRITICAL FIX: Convert Unicode symbols in the remaining content
|
||||
# Example: "sin∠" → "sin" + "∠" → "\sin" + "\angle" → "\sin \angle"
|
||||
# Without this fix, "sin∠" becomes "\sin∠" (unconverted ∠)
|
||||
remaining_converted = self._convert_unicode_in_content(remaining)
|
||||
return f"\\{func} {remaining_converted}".strip()
|
||||
|
||||
# Special case: Im, Re with mathvariant="normal" → \Im, \Re
|
||||
if mathvariant == 'normal' and content in ['Im', 'Re']:
|
||||
return f"\\{content}"
|
||||
|
||||
# CRITICAL FIX: For multi-character content with mathvariant, apply same logic as original:
|
||||
# 1. Convert each character individually (Unicode → LaTeX)
|
||||
# 2. Apply mathvariant ONLY to non-LaTeX-command characters
|
||||
# 3. Join with spaces
|
||||
#
|
||||
# This handles cases like:
|
||||
# <mi mathvariant="double-struck">ℵRZCQN</mi> → \aleph \mathbb{R} \mathbb{Z} \mathbb{C} \mathbb{Q} \mathbb{N}
|
||||
# <mi mathvariant="script">ℏl</mi> → \hbar \ell
|
||||
|
||||
if len(content) > 1 and any(char in UNICODE_TO_LATEX or char in UNICODE_PASSTHROUGH or char.isalpha() for char in content):
|
||||
# Step 1: Convert each character individually (UNICODE_PASSTHROUGH first)
|
||||
converted_parts = []
|
||||
for char in content:
|
||||
if char in UNICODE_PASSTHROUGH:
|
||||
# Keep passthrough symbols as Unicode
|
||||
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)
|
||||
|
||||
# Step 2: Apply mathvariant wrapping selectively
|
||||
if mathvariant == 'double-struck':
|
||||
result_parts = []
|
||||
for part in converted_parts:
|
||||
if part.startswith('\\'):
|
||||
# Already a LaTeX command (like \aleph) - use as-is
|
||||
result_parts.append(part)
|
||||
elif part.strip(): # Skip empty strings and spaces
|
||||
# Regular letter - wrap in \mathbb{}
|
||||
result_parts.append(f"\\mathbb{{{part}}}")
|
||||
# Note: spaces are intentionally skipped - we join with spaces anyway
|
||||
return self._wrap_with_color(' '.join(result_parts), node)
|
||||
|
||||
elif mathvariant == 'script':
|
||||
result_parts = []
|
||||
for part in converted_parts:
|
||||
if part.startswith('\\'):
|
||||
# Already a LaTeX command (like \hbar) - use as-is
|
||||
result_parts.append(part)
|
||||
elif part == 'l':
|
||||
# Special case: script 'l' is \ell, not \mathscr{l}
|
||||
result_parts.append('\\ell')
|
||||
elif part.strip(): # Skip empty strings and spaces
|
||||
# Regular letter - wrap in \mathscr{}
|
||||
result_parts.append(f"\\mathscr{{{part}}}")
|
||||
# Note: spaces are intentionally skipped - we join with spaces anyway
|
||||
return self._wrap_with_color(' '.join(result_parts), node)
|
||||
|
||||
elif mathvariant in ['fraktur', 'sans-serif', 'monospace', 'bold', 'bold-italic']:
|
||||
# For other mathvariants, apply to all non-LaTeX-command parts
|
||||
result_parts = []
|
||||
font_cmd_map = {
|
||||
'fraktur': 'mathfrak',
|
||||
'sans-serif': 'mathsf',
|
||||
'monospace': 'mathtt',
|
||||
'bold': 'mathbf',
|
||||
'bold-italic': 'boldsymbol'
|
||||
}
|
||||
font_cmd = font_cmd_map[mathvariant]
|
||||
for part in converted_parts:
|
||||
if part.startswith('\\'):
|
||||
result_parts.append(part)
|
||||
elif part.strip(): # Skip empty strings and spaces
|
||||
result_parts.append(f"\\{font_cmd}{{{part}}}")
|
||||
# Note: spaces are intentionally skipped - we join with spaces anyway
|
||||
return self._wrap_with_color(' '.join(result_parts), node)
|
||||
|
||||
elif mathvariant == 'normal':
|
||||
# Multi-character content with mathvariant="normal" - always wrap in \mathrm{}
|
||||
# Combine the converted parts with intelligent spacing
|
||||
# When a LaTeX command (like \Delta) is followed by a letter, add a space
|
||||
# Otherwise, LaTeX interprets \DeltaH as a single undefined command
|
||||
combined = self._join_with_command_spacing(converted_parts)
|
||||
|
||||
# DISABLED: "Smart" logic that ignored mathvariant="normal" for simple variables
|
||||
# This was causing issues where explicit mathvariant="normal" was not respected
|
||||
# Keeping _is_simple_math_variable for potential future use
|
||||
# if self.strategy.name == 'math':
|
||||
# is_simple = self._is_simple_math_variable(combined)
|
||||
# if is_simple:
|
||||
# return self._wrap_with_color(combined, node)
|
||||
|
||||
# CRITICAL FIX: Check if content starts with uppercase letter(s) followed by a known function
|
||||
# Example: "Asin" → "\mathrm{A}\sin", "ABcos" → "\mathrm{AB}\cos"
|
||||
# This handles the case where Word's "Text Mode" is used for mixed content
|
||||
result = self._split_uppercase_and_function(content)
|
||||
if result:
|
||||
return self._wrap_with_color(result, node)
|
||||
|
||||
# Always respect mathvariant="normal" - wrap in \mathrm{}
|
||||
return self._wrap_with_color(f"\\mathrm{{{combined}}}", node)
|
||||
|
||||
else:
|
||||
# No mathvariant or unknown - just join the converted parts
|
||||
return self._wrap_with_color(' '.join(converted_parts), node)
|
||||
|
||||
# Fallback: single character or simple cases
|
||||
# IMPORTANT: Convert Unicode symbols BEFORE wrapping in font commands
|
||||
# e.g., ℵ → \aleph, ℏ → \hbar
|
||||
content_converted = self._convert_unicode_in_content(content)
|
||||
|
||||
# Map mathvariant values to LaTeX font commands
|
||||
if mathvariant == 'double-struck':
|
||||
# If already converted to LaTeX command, use as-is
|
||||
if content in UNICODE_TO_LATEX:
|
||||
return content_converted
|
||||
return f"\\mathbb{{{content_converted}}}"
|
||||
elif mathvariant == 'script':
|
||||
# Special case: script lowercase 'l' → \ell
|
||||
if content == 'l':
|
||||
return "\\ell"
|
||||
# If already converted to LaTeX command, use as-is
|
||||
if content in UNICODE_TO_LATEX:
|
||||
return content_converted
|
||||
return f"\\mathscr{{{content_converted}}}"
|
||||
elif mathvariant == 'fraktur':
|
||||
return f"\\mathfrak{{{content_converted}}}"
|
||||
elif mathvariant == 'sans-serif':
|
||||
return f"\\mathsf{{{content_converted}}}"
|
||||
elif mathvariant == 'monospace':
|
||||
return f"\\mathtt{{{content_converted}}}"
|
||||
elif mathvariant == 'bold':
|
||||
return f"\\mathbf{{{content_converted}}}"
|
||||
elif mathvariant == 'bold-italic':
|
||||
# Bold italic - use \boldsymbol for math-mode bold italic
|
||||
# Alternative: \bm from bm package, but \boldsymbol is standard
|
||||
return f"\\boldsymbol{{{content_converted}}}"
|
||||
elif mathvariant == 'italic':
|
||||
# Explicitly italic - return as-is (LaTeX default)
|
||||
return content_converted
|
||||
elif mathvariant == 'normal':
|
||||
# INTELLIGENT HANDLING of mathvariant="normal" based on subject mode and content
|
||||
# This handles the <m:nor/> tag from Word's "Text Mode" button
|
||||
|
||||
# Special cases first: Im, Re → \Im, \Re
|
||||
if content in ['Im', 'Re']:
|
||||
return f"\\{content}"
|
||||
|
||||
# CRITICAL: If content was a Unicode symbol (like π → \pi), don't wrap in \mathrm{}
|
||||
# The _convert_unicode_in_content() already converted it to LaTeX command
|
||||
if content.strip() in UNICODE_TO_LATEX:
|
||||
return content_converted # Already converted to LaTeX (e.g., \pi)
|
||||
|
||||
# DISABLED: "Smart" logic that ignored mathvariant="normal" for simple variables
|
||||
# This was causing issues where explicit mathvariant="normal" was not respected
|
||||
# Keeping _is_simple_math_variable for potential future use
|
||||
# if self.strategy.name == 'math':
|
||||
# if self._is_simple_math_variable(content_converted):
|
||||
# return content_converted
|
||||
|
||||
# Always respect mathvariant="normal" - wrap in \mathrm{}
|
||||
return f"\\mathrm{{{content_converted}}}"
|
||||
else:
|
||||
# Unknown mathvariant - return as-is
|
||||
return content_converted
|
||||
|
||||
def _is_simple_math_variable(self, content: str) -> bool:
|
||||
"""
|
||||
Check if content is a simple mathematical variable that should be italic.
|
||||
|
||||
Simple math variables include:
|
||||
- Single lowercase letters: x, y, z, a, b, etc.
|
||||
- Single uppercase letters: X, Y, Z, A, B, etc. (but NOT chemical elements)
|
||||
- Greek letters (already converted to LaTeX commands like \\alpha)
|
||||
- Common mathematical notations like single letters with primes: x', y''
|
||||
|
||||
This is used to intelligently handle mathvariant="normal" in math mode:
|
||||
- Simple variables should remain italic (ignore mathvariant="normal")
|
||||
- Complex identifiers should be upright (respect mathvariant="normal")
|
||||
|
||||
Args:
|
||||
content: The identifier content to check
|
||||
|
||||
Returns:
|
||||
True if content is a simple math variable, False otherwise
|
||||
"""
|
||||
if not content:
|
||||
return False
|
||||
|
||||
# Strip whitespace and LaTeX commands for checking
|
||||
clean_content = content.strip()
|
||||
|
||||
# If it starts with a LaTeX command (like \alpha, \beta), it's a simple variable
|
||||
if clean_content.startswith('\\'):
|
||||
return True
|
||||
|
||||
# CRITICAL: If content contains mathematical operators, it's an expression (not an identifier)
|
||||
# Examples: "x+2", "a-b", "n*m" should NOT be wrapped in \mathrm{}
|
||||
# These are parsed as single <mi> elements by XSL but should remain italic
|
||||
math_operators = {'+', '-', '*', '/', '=', '<', '>', '≤', '≥', '≠', '±'}
|
||||
if any(op in clean_content for op in math_operators):
|
||||
return True # Treat expressions as "simple" (don't wrap in \mathrm{})
|
||||
|
||||
# Single letter (a-z, A-Z) - simple variable
|
||||
if len(clean_content) == 1 and clean_content.isalpha():
|
||||
# BUT: exclude known chemical elements in non-math contexts
|
||||
if self.strategy.name == 'chemistry' and clean_content in CHEMICAL_ELEMENTS:
|
||||
return False
|
||||
return True
|
||||
|
||||
# Single letter with primes (x', y'', etc.)
|
||||
if len(clean_content) >= 2:
|
||||
base = clean_content.rstrip("'")
|
||||
primes = clean_content[len(base):]
|
||||
if len(base) == 1 and base.isalpha() and all(c == "'" for c in primes):
|
||||
return True
|
||||
|
||||
# Multi-character content that's NOT a math function is considered complex
|
||||
# Examples: "velocity", "pressure", "Mn", "Fe" - should be upright if mathvariant="normal"
|
||||
return False
|
||||
|
||||
def _convert_unicode_in_content(self, content: str) -> str:
|
||||
"""
|
||||
Convert Unicode symbols within identifier content to LaTeX equivalents.
|
||||
|
||||
For multi-character identifiers with Unicode symbols mixed in,
|
||||
we need to convert each Unicode character individually.
|
||||
|
||||
Args:
|
||||
content: Identifier text that may contain Unicode symbols
|
||||
|
||||
Returns:
|
||||
Content with Unicode symbols converted to LaTeX, preserving spaces
|
||||
"""
|
||||
if not content:
|
||||
return content
|
||||
|
||||
# Check if any Unicode symbols are present (passthrough or convertible)
|
||||
has_unicode = any(char in UNICODE_TO_LATEX for char in content)
|
||||
has_passthrough = any(char in UNICODE_PASSTHROUGH for char in content)
|
||||
if not has_unicode and not has_passthrough:
|
||||
return content
|
||||
|
||||
# Convert character by character, preserving spaces (PASSTHROUGH first)
|
||||
result = []
|
||||
for char in content:
|
||||
if char in UNICODE_PASSTHROUGH:
|
||||
# Keep passthrough symbols as Unicode
|
||||
result.append(char)
|
||||
elif char in UNICODE_TO_LATEX:
|
||||
# Add space before/after LaTeX command if needed
|
||||
latex_cmd = UNICODE_TO_LATEX[char]
|
||||
result.append(latex_cmd)
|
||||
else:
|
||||
result.append(char)
|
||||
|
||||
# Join parts with space separator for multi-symbol content
|
||||
# e.g., "ℵR" → "\aleph R" not "\alephR"
|
||||
joined = ' '.join(result)
|
||||
|
||||
# Clean up multiple spaces
|
||||
import re
|
||||
joined = re.sub(r'\s+', ' ', joined)
|
||||
|
||||
return joined
|
||||
|
||||
def _join_with_command_spacing(self, parts: list) -> str:
|
||||
"""
|
||||
Join converted parts with intelligent spacing for LaTeX commands.
|
||||
|
||||
When a LaTeX command (like \\Delta) is followed by a letter, a space must
|
||||
be inserted. Otherwise, LaTeX interprets \\DeltaH as a single undefined
|
||||
command instead of \\Delta followed by H.
|
||||
|
||||
Args:
|
||||
parts: List of converted parts (may contain LaTeX commands and letters)
|
||||
|
||||
Returns:
|
||||
Joined string with appropriate spacing between commands and letters
|
||||
|
||||
Examples:
|
||||
['\\\\Delta', 'H'] → '\\\\Delta H' (space needed)
|
||||
['a', 'b', 'c'] → 'abc' (no space needed)
|
||||
['\\\\alpha', '\\\\beta'] → '\\\\alpha\\\\beta' (commands can be adjacent)
|
||||
['\\\\Delta', '1'] → '\\\\Delta 1' (space before number)
|
||||
"""
|
||||
if not parts:
|
||||
return ''
|
||||
|
||||
result = []
|
||||
for i, part in enumerate(parts):
|
||||
result.append(part)
|
||||
|
||||
# Check if we need to add a space after this part
|
||||
if i < len(parts) - 1:
|
||||
next_part = parts[i + 1]
|
||||
|
||||
# If current part is a LaTeX command (starts with \)
|
||||
# and next part starts with a letter or digit, add space
|
||||
if part.startswith('\\') and next_part and (next_part[0].isalpha() or next_part[0].isdigit()):
|
||||
result.append(' ')
|
||||
|
||||
return ''.join(result)
|
||||
|
||||
def _wrap_with_color(self, latex: str, node: ET.Element) -> str:
|
||||
"""
|
||||
Wrap LaTeX with color command if node has color attribute.
|
||||
|
||||
Args:
|
||||
latex: The LaTeX string to wrap
|
||||
node: The element node
|
||||
|
||||
Returns:
|
||||
LaTeX string, optionally wrapped with color command
|
||||
"""
|
||||
# Use the centralized color wrapping utility with suppression support
|
||||
from ..utils.color_helpers import wrap_element_with_color
|
||||
|
||||
# Get converter context from multiple sources (for backward compatibility):
|
||||
# 1. Direct visitor context (set by legacy_adapter)
|
||||
# 2. Strategy context (shared across all visitors, including child visitors)
|
||||
context = getattr(self, '_converter_context', None)
|
||||
if context is None and hasattr(self.strategy, '_converter_context'):
|
||||
context = self.strategy._converter_context
|
||||
|
||||
return wrap_element_with_color(latex, node, context)
|
||||
|
||||
def _contains_delimiter_or_operator(self, content: str) -> bool:
|
||||
"""
|
||||
Check if content contains delimiter or operator characters that should be <mo> not <mi>.
|
||||
|
||||
This detects malformed MathML from OMML2MML.xsl where operators are misclassified
|
||||
as identifiers (e.g., <mi>(</mi> instead of <mo fence="true">(</mo>).
|
||||
|
||||
Args:
|
||||
content: Text content of the <mi> element
|
||||
|
||||
Returns:
|
||||
True if content contains delimiters or operators
|
||||
"""
|
||||
# Delimiters that should be fences
|
||||
delimiters = set('()[]{}<>')
|
||||
|
||||
# Common operators (excluding - which might be minus sign or hyphen)
|
||||
# Note: We include = and < > which are often misclassified
|
||||
operators = set('+=*/|≤≥≠≈∈∉∪∩⊂⊃∧∨¬→↔×÷±')
|
||||
|
||||
# Check if ANY character is a delimiter or operator
|
||||
return any(char in delimiters or char in operators for char in content)
|
||||
|
||||
def _reclassify_with_mathrm(self, content: str, node: ET.Element) -> str:
|
||||
"""
|
||||
Reclassify content with operators while preserving mathvariant="normal" (mathrm wrapping).
|
||||
|
||||
This handles cases like:
|
||||
- <mi mathvariant="normal">N/m</mi> → \mathrm{N}/\mathrm{m}
|
||||
- <mi mathvariant="normal">kg·m/s</mi> → \mathrm{kg}·\mathrm{m}/\mathrm{s}
|
||||
|
||||
Args:
|
||||
content: Text content with mathvariant="normal" that contains operators
|
||||
node: Original <mi> element node
|
||||
|
||||
Returns:
|
||||
LaTeX string with mathrm-wrapped identifiers and properly classified operators
|
||||
"""
|
||||
# Delimiters that should have fence="true"
|
||||
fence_chars = set('()[]{}')
|
||||
|
||||
# Operators that should be <mo> but NOT fences
|
||||
operator_chars = set('+=*/|<>≤≥≠≈∈∉∪∩⊂⊃∧∨¬→↔×÷±·')
|
||||
|
||||
# Multi-character content: split and classify each character
|
||||
# Identifier parts get wrapped in \mathrm{}
|
||||
parts = []
|
||||
for char in content:
|
||||
if char in fence_chars:
|
||||
parts.append(self._convert_as_fence(char, node))
|
||||
elif char in operator_chars:
|
||||
parts.append(self._convert_as_operator(char, node))
|
||||
elif char.isspace():
|
||||
# Preserve spaces
|
||||
from ..utils import convert_spaces_to_latex
|
||||
parts.append(convert_spaces_to_latex(char))
|
||||
else:
|
||||
# Regular identifier character - wrap in \mathrm{} for mathvariant="normal"
|
||||
parts.append(f"\\mathrm{{{char}}}")
|
||||
|
||||
return ''.join(parts)
|
||||
|
||||
def _reclassify_as_operator(self, content: str, node: ET.Element) -> str:
|
||||
"""
|
||||
Reclassify misclassified operators/delimiters and convert them properly.
|
||||
|
||||
Handles cases like:
|
||||
- <mi>(</mi> → treat as <mo fence="true">(</mo>
|
||||
- <mi>)+</mi> → treat as <mo fence="true">)</mo><mo>+</mo>
|
||||
- <mi>=</mi> → treat as <mo>=</mo>
|
||||
|
||||
Args:
|
||||
content: Text content that contains operators/delimiters
|
||||
node: Original <mi> element node
|
||||
|
||||
Returns:
|
||||
LaTeX string with properly classified operators
|
||||
"""
|
||||
# Import MoVisitor for operator handling
|
||||
from .mo_visitor import MoVisitor
|
||||
|
||||
# Delimiters that should have fence="true"
|
||||
fence_chars = set('()[]{}')
|
||||
|
||||
# Operators that should be <mo> but NOT fences
|
||||
operator_chars = set('+=*/|<>≤≥≠≈∈∉∪∩⊂⊃∧∨¬→↔×÷±')
|
||||
|
||||
# If content is a single character, handle directly
|
||||
if len(content) == 1:
|
||||
if content in fence_chars:
|
||||
return self._convert_as_fence(content, node)
|
||||
elif content in operator_chars:
|
||||
return self._convert_as_operator(content, node)
|
||||
else:
|
||||
# Shouldn't happen, but fallback to identifier
|
||||
return self.strategy.format_identifier(content, node)
|
||||
|
||||
# Multi-character content: split and classify each character
|
||||
parts = []
|
||||
for char in content:
|
||||
if char in fence_chars:
|
||||
parts.append(self._convert_as_fence(char, node))
|
||||
elif char in operator_chars:
|
||||
parts.append(self._convert_as_operator(char, node))
|
||||
elif char.isspace():
|
||||
# Preserve spaces
|
||||
from ..utils import convert_spaces_to_latex
|
||||
parts.append(convert_spaces_to_latex(char))
|
||||
else:
|
||||
# Regular identifier character
|
||||
parts.append(self.strategy.format_identifier(char, node))
|
||||
|
||||
return ''.join(parts)
|
||||
|
||||
def _convert_as_fence(self, char: str, node: ET.Element) -> str:
|
||||
"""
|
||||
Convert a character as a fence delimiter (with fence="true").
|
||||
|
||||
Fence delimiters are marked with __DELIM__ to indicate they should be paired
|
||||
by the post-processor (e.g., ( and ) become \left( and \right)).
|
||||
|
||||
Args:
|
||||
char: Single delimiter character
|
||||
node: Original element node (for color preservation)
|
||||
|
||||
Returns:
|
||||
Delimiter wrapped in __DELIM__ markers for post-processing
|
||||
"""
|
||||
# Map fence characters to LaTeX (handle escaped braces)
|
||||
fence_map = {
|
||||
'(': '(',
|
||||
')': ')',
|
||||
'[': '[',
|
||||
']': ']',
|
||||
'{': '\\{', # Must be escaped in LaTeX
|
||||
'}': '\\}', # Must be escaped in LaTeX
|
||||
}
|
||||
|
||||
latex_char = fence_map.get(char, char)
|
||||
|
||||
# Wrap in __DELIM__ markers so post-processor knows to add \left and \right
|
||||
return f"__DELIM__{latex_char}__DELIM__"
|
||||
|
||||
def _convert_as_operator(self, char: str, node: ET.Element) -> str:
|
||||
"""
|
||||
Convert a character as an operator (without fence="true").
|
||||
|
||||
Operators like +, =, |, < are NOT marked with __DELIM__ because they should NOT
|
||||
be paired as delimiters. For example, | in P(A|B) is an operator, not a delimiter.
|
||||
|
||||
Args:
|
||||
char: Single operator character
|
||||
node: Original element node (for color preservation)
|
||||
|
||||
Returns:
|
||||
LaTeX operator string (plain, no markers)
|
||||
"""
|
||||
# Map operators to LaTeX equivalents
|
||||
operator_map = {
|
||||
'+': '+',
|
||||
'-': '-',
|
||||
'*': '*',
|
||||
'/': '/',
|
||||
'=': '=',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'|': '|', # IMPORTANT: | as operator (conditional probability), NOT delimiter
|
||||
'×': '\\times ',
|
||||
'÷': '\\div ',
|
||||
'±': '\\pm ',
|
||||
'≤': '\\leqslant ',
|
||||
'≥': '\\geqslant ',
|
||||
'≠': '\\ne ',
|
||||
'≈': '\\approx ',
|
||||
'∈': '\\in ',
|
||||
'∉': '\\notin ',
|
||||
'∪': '\\cup ',
|
||||
'∩': '\\cap ',
|
||||
'⊂': '\\subset ',
|
||||
'⊃': '\\supset ',
|
||||
'∧': '\\wedge ',
|
||||
'∨': '\\vee ',
|
||||
'¬': '\\neg ',
|
||||
'→': '\\to ',
|
||||
'↔': '\\leftrightarrow ',
|
||||
}
|
||||
|
||||
latex_char = operator_map.get(char, char)
|
||||
|
||||
# Return as-is (NO __DELIM__ markers - should not be paired)
|
||||
return latex_char
|
||||
|
||||
def _split_uppercase_and_function(self, content: str) -> str:
|
||||
"""
|
||||
Split content that starts with uppercase letter(s) followed by a known function.
|
||||
|
||||
This handles cases like:
|
||||
- "Asin" → "\\mathrm{A}\\sin"
|
||||
- "ABcos" → "\\mathrm{AB}\\cos"
|
||||
- "Xlog" → "\\mathrm{X}\\log"
|
||||
|
||||
Args:
|
||||
content: The identifier content to check
|
||||
|
||||
Returns:
|
||||
LaTeX string with split content, or None if no split is needed
|
||||
"""
|
||||
from ..data import MATH_FUNCTIONS
|
||||
|
||||
if not content or len(content) < 2:
|
||||
return None
|
||||
|
||||
# Check if content starts with uppercase letter(s)
|
||||
if not content[0].isupper():
|
||||
return None
|
||||
|
||||
# Find where the uppercase letters end
|
||||
uppercase_end = 0
|
||||
for i, char in enumerate(content):
|
||||
if char.isupper():
|
||||
uppercase_end = i + 1
|
||||
else:
|
||||
break
|
||||
|
||||
if uppercase_end == 0 or uppercase_end >= len(content):
|
||||
return None
|
||||
|
||||
# Split into uppercase part and remaining part
|
||||
uppercase_part = content[:uppercase_end]
|
||||
remaining_part = content[uppercase_end:]
|
||||
|
||||
# Check if remaining part is a known math function
|
||||
if remaining_part in MATH_FUNCTIONS:
|
||||
# Split: uppercase in \mathrm{} + function as \function
|
||||
return f"\\mathrm{{{uppercase_part}}}\\{remaining_part}"
|
||||
|
||||
return None
|
||||
|
||||
def can_handle(self, node: ET.Element) -> bool:
|
||||
"""
|
||||
Check if this visitor can handle the given node.
|
||||
|
||||
Args:
|
||||
node: The MathML element to check
|
||||
|
||||
Returns:
|
||||
True if node is an <mi> element, False otherwise
|
||||
"""
|
||||
from ..utils import local_name as _local
|
||||
return _local(node.tag) == 'mi'
|
||||
|
||||
@property
|
||||
def element_name(self) -> str:
|
||||
"""Get the element name this visitor handles."""
|
||||
return 'mi'
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation for debugging."""
|
||||
return f"MiVisitor(strategy={self.strategy.name})"
|
||||
Reference in New Issue
Block a user