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