""" Visitor for (identifier) MathML elements. The 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 (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: sin → \\sin x → x α → \\alpha Chemistry mode: Fe → \\mathrm{Fe} H → \\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 element to LaTeX. Args: node: The element to convert Returns: LaTeX string representation Raises: ValueError: If node is not an 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 # instead of . 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: # ℵRZCQN → \aleph \mathbb{R} \mathbb{Z} \mathbb{C} \mathbb{Q} \mathbb{N} # ℏl → \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 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 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 not . This detects malformed MathML from OMML2MML.xsl where operators are misclassified as identifiers (e.g., ( instead of (). Args: content: Text content of the 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: - N/m → \mathrm{N}/\mathrm{m} - kg·m/s → \mathrm{kg}·\mathrm{m}/\mathrm{s} Args: content: Text content with mathvariant="normal" that contains operators node: Original 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 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: - ( → treat as ( - )+ → treat as )+ - = → treat as = Args: content: Text content that contains operators/delimiters node: Original 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 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 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})"