r""" Visitor for (text content) MathML elements. The element represents text content within mathematical expressions: - Descriptive text: "and", "or", "if", "then" - Unit labels: "kg", "mol", "Hz", "Pa", "m/s" - Chinese/Unicode text: "电解", "加热", "催化剂" - Words in equations: "min", "max", "for all" This visitor wraps text in \text{} or \mathrm{} depending on context. """ from lxml import etree as ET from ..core.interfaces import Visitor, Strategy from ..utils import text_content as _text_content, local_name as _local, escape_latex_special_chars from ..data import UNICODE_TO_LATEX, UNICODE_PASSTHROUGH class MtextVisitor(Visitor): r""" Handles (text content) MathML elements. Converts text content to LaTeX using appropriate text commands. Examples: Simple text: and → \text{and} Units: kg → \text{kg} Chinese text: 电解 → \text{电解} Multi-word text: for all → \text{for all} Empty text: → (empty string) Text with spaces: spaced → \text{ spaced } """ # Common SI units (should use \mathrm for units in some contexts) SI_UNITS = { # Base units 'm', 'kg', 'g', 's', 'A', 'K', 'mol', 'cd', # Derived units 'Hz', 'N', 'Pa', 'J', 'W', 'C', 'V', 'F', 'Ω', 'S', 'Wb', 'T', 'H', 'lm', 'lx', 'Bq', 'Gy', 'Sv', # Common combinations 'km', 'cm', 'mm', 'nm', 'pm', 'km/h', 'm/s', 'm/s²', 'cm³', 'm³', 'kJ', 'MJ', 'kW', 'MW', 'mA', 'kA', 'mV', 'kV', # Other common units 'L', 'mL', 'atm', 'bar', 'mmHg', 'eV', 'kcal', 'cal', '°C', '°F', 'K', # Temperature } # Common mathematical/logical text words TEXT_WORDS = { 'and', 'or', 'not', 'if', 'then', 'else', 'for', 'all', 'such', 'that', 'where', 'with', 'when', 'otherwise', 'let', 'be', 'is', 'are', } def __init__(self, strategy: Strategy): """Initialize the Mtext visitor.""" super().__init__(strategy) self._converter_context = None def set_converter_context(self, converter): """Set converter context for color suppression checking.""" self._converter_context = converter def visit(self, node: ET.Element) -> str: r""" Convert element to LaTeX. Args: node: The element to convert Returns: LaTeX string with text wrapped in \text{} or \mathrm{} Raises: ValueError: If node is not an element """ if not self.can_handle(node): raise ValueError(f"MtextVisitor cannot handle element '{node.tag}'") # Get text content text = _text_content(node) # Handle empty text if not text: return '' # Escape LaTeX special characters before any other processing text = escape_latex_special_chars(text) # Handle invisible function application operator (U+2061) # This is used to indicate function application but should not be rendered if text == "⁡": # U+2061 FUNCTION APPLICATION return "" # Return empty string (invisible) # CRITICAL: Check if text is a mathematical function BEFORE other processing # This handles cases like sin → \sin (not \mathrm{sin}) from ..data import MATH_FUNCTIONS text_stripped = text.strip() if text_stripped.lower() in MATH_FUNCTIONS: return f"\\{text_stripped.lower()}" # IMPORTANT: Convert Unicode symbols to LaTeX BEFORE wrapping in \text{} # But first check UNICODE_PASSTHROUGH - keep these as-is for MathJax compatibility text_stripped = text.strip() if len(text_stripped) == 1 and text_stripped in UNICODE_PASSTHROUGH: # Single symbol that should be kept as Unicode return text_stripped if len(text_stripped) == 1 and text_stripped in UNICODE_TO_LATEX: # Single symbol with direct LaTeX mapping - return LaTeX command directly # (don't wrap in \text{}) return UNICODE_TO_LATEX[text_stripped] # CRITICAL FIX: For multi-symbol Unicode strings, check if result after conversion # is ALL LaTeX commands or passthrough symbols. If so, don't wrap in \text{} # # This handles cases like: # ℧◊ → \mho\lozenge (not \text{\mho\lozenge}) # ∠∢⦞ → \measuredangle\sphericalangle (not \text{...}) # ∯∰ → ∯∰ (kept as Unicode for MathJax) if len(text_stripped) > 1 and all(char in UNICODE_TO_LATEX or char in UNICODE_PASSTHROUGH for char in text_stripped): # All characters are Unicode symbols # Convert to LaTeX or keep as Unicode based on PASSTHROUGH set result_parts = [] for char in text_stripped: if char in UNICODE_PASSTHROUGH: result_parts.append(char) else: result_parts.append(UNICODE_TO_LATEX[char]) return ''.join(result_parts) # Join without spaces for symbols # Check for color attributes from ..utils import is_default_color color = node.get('mathcolor') if color and not is_default_color(color): # Apply color to text (after Unicode conversion if needed) # Only add textcolor if it's not the default black color text_converted = self._convert_unicode_in_text(text) return f"\\textcolor{{{color}}}{{\\text{{{text_converted}}}}}" # Determine whether to use \text{} or \mathrm{} # CRITICAL: The default should be \mathrm{} to match wrap_identifiers_in_mathrm=True # This ensures chemical formulas, units, and identifiers render upright (not italic) # CRITICAL FIX: Check for operators in text (like "2NaCl+2H") # If text contains operators, split and process separately # This prevents wrapping operators in \mathrm{} and avoids nested \mathrm{} if any(op in text for op in ['+', '-', '=', '<', '>', '×', '·', '/']): return self._process_complex_mtext(text) # Check if we should use \mathrm{} (default for most math/chemistry content) # Use \text{} only for actual prose text (multi-word descriptions) # If it contains spaces and looks like prose, use \text{} if ' ' in text and any(word in text_stripped.lower() for word in self.TEXT_WORDS): # Multi-word prose text text_converted = self._convert_unicode_in_text(text) return f"\\text{{{text_converted}}}" # If it's Chinese text (common in chemistry for reaction conditions), use \mathrm{} # Chinese text needs upright rendering for chemical equations if self._contains_chinese(text): text_converted = self._convert_unicode_in_text(text) return f"\\mathrm{{{text_converted}}}" # Default: use \mathrm{} for chemical formulas, units, and identifiers # This matches the original converter's wrap_identifiers_in_mathrm=True behavior text_converted = self._convert_unicode_in_text(text) # CRITICAL FIX: If text_converted is ONLY LaTeX space commands (like ~, \ , \quad, etc.), # return it directly WITHOUT wrapping in \mathrm{} # This fixes the issue where   was becoming \mathrm{~} instead of ~ stripped = text_converted.strip() if stripped in ('~', '\\ ', '\\quad', '\\qquad', '\\!', '\\,', '\\:', '\\;'): return text_converted # Return space command directly elif stripped.replace('~', '').replace('\\ ', '').strip() == '': # Pure spaces (multiple ~ or \ ) return text_converted return f"\\mathrm{{{text_converted}}}" def _process_complex_mtext(self, text: str) -> str: """ Process complex mtext that contains operators. Splits text at operators and wraps chemical parts in \\mathrm{} while keeping operators unwrapped. Example: "2NaCl+2H" → "\\mathrm{2NaCl}+\\mathrm{2H}" Args: text: Text containing operators Returns: Processed LaTeX with operators unwrapped """ import re # Pattern for mathematical operators (including Unicode) operator_pattern = r'([+\-=<>±∓×·/∩∪∈∉⊆⊇∁∅∧∨¬→←↔⇒⇐⇔≡≠≤≥∞])' # Split text at operators (operators are preserved in the result) parts = re.split(operator_pattern, text) result_parts = [] for part in parts: part_stripped = part.strip() if not part_stripped: continue # Check if this part is an operator if re.match(operator_pattern, part_stripped): # Operator - keep as-is, DON'T wrap in \mathrm{} result_parts.append(part_stripped) else: # Not an operator - wrap in \mathrm{} # First convert any Unicode symbols part_converted = self._convert_unicode_in_text(part) result_parts.append(f"\\mathrm{{{part_converted}}}") # Join parts without spaces (chemical formulas should be compact) return ''.join(result_parts) def _contains_chinese(self, text: str) -> bool: """ Check if text contains Chinese (CJK) characters. Args: text: Text to check Returns: True if text contains Chinese characters, False otherwise """ for char in text: # CJK Unified Ideographs range if '\u4e00' <= char <= '\u9fff': return True return False def _convert_unicode_in_text(self, text: str) -> str: """ Convert Unicode symbols within text to LaTeX equivalents. For multi-character text with Unicode symbols mixed in, we need to convert each Unicode character individually. IMPORTANT: Also converts spaces to LaTeX space commands: - \\xa0 (NBSP) → ~ (non-breaking space in LaTeX) - Regular space (when standalone) → \\ (backslash-space) Args: text: Text that may contain Unicode symbols Returns: Text with Unicode symbols converted to LaTeX """ if not text: return text # CRITICAL FIX: Convert non-breaking spaces (\xa0) to ~ BEFORE other processing # This fixes the issue where   was becoming \mathrm{ } instead of ~ result = text # Step 1: Convert NBSP to LaTeX non-breaking space if '\xa0' in result: result = result.replace('\xa0', '~') # Step 2: Convert regular spaces to LaTeX space (but NOT inside prose text) # Only convert standalone spaces, not spaces within multi-word text # Check if this is a single space or contains only spaces if result.strip() == '' and result: # Pure space(s) → convert to ~ result = '~' * len(result) elif result in [' ']: # Single space → ~ result = '~' # Step 3: Convert Unicode symbols (except PASSTHROUGH symbols) # Check if any Unicode symbols are present has_unicode = any(char in UNICODE_TO_LATEX for char in result) has_passthrough = any(char in UNICODE_PASSTHROUGH for char in result) if not has_unicode and not has_passthrough: return result # Convert character by character (PASSTHROUGH symbols kept as-is) converted = [] for char in result: if char in UNICODE_PASSTHROUGH: # Keep passthrough symbols as Unicode for MathJax converted.append(char) elif char in UNICODE_TO_LATEX: converted.append(UNICODE_TO_LATEX[char]) else: converted.append(char) return ''.join(converted) def can_handle(self, node: ET.Element) -> bool: """Check if this visitor can handle the given node.""" return _local(node.tag) == 'mtext' @property def element_name(self) -> str: """Get the element name this visitor handles.""" return 'mtext' def __repr__(self) -> str: """String representation for debugging.""" return f"MtextVisitor(strategy={self.strategy.name})"