Files
DoLuEdu-WordFileParse/scripts/omml_mathml2latex/visitors/mi_visitor.py
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

773 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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.

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