""" Math strategy for standard mathematical notation. This strategy handles formatting for general mathematical equations, including function recognition, operator mapping, and number formatting. """ from lxml import etree as ET from typing import Set, Optional from ..core.interfaces import Strategy # Import from existing data module from ..data import MATH_FUNCTIONS, UNICODE_TO_LATEX, UNICODE_PASSTHROUGH from ..data.chemistry_data import ( ALL_CHEMICAL_ELEMENTS, CHEMISTRY_VARIABLES, ) from ..utils.chemistry_detection import detect_chemistry_equation, is_chemistry_variable class MathStrategy(Strategy): """ Standard mathematical notation strategy. This strategy provides formatting for general mathematical content: - Function recognition (sin, cos, log → \\sin, \\cos, \\log) - Operator symbol mapping (Unicode → LaTeX) - Standard number formatting - Variables remain italic (LaTeX default) - Sophisticated identifier wrapping based on context """ # Mathematical functions that get special LaTeX commands FUNCTIONS: Set[str] = MATH_FUNCTIONS # Mathematical sets that should use \mathbf{} (bold upright) - ONLY when mathvariant is set MATH_SETS = {'R', 'Z', 'N', 'Q', 'C', 'ℝ', 'ℤ', 'ℕ', 'ℚ', 'ℂ'} # CRITICAL: In pure math mode, NO letters are always wrapped # Wrapping only happens with explicit mathvariant="normal" attribute # This set is kept for compatibility but should be empty for math mode ALWAYS_WRAPPED_LETTERS = set() # Empty - no automatic wrapping in math mode def __init__(self, wrap_identifiers_in_mathrm: bool = False): """ Initialize the math strategy. Args: wrap_identifiers_in_mathrm: If True, wrap multi-char identifiers in \\mathrm{} Default is False to match original converter behavior """ self._equation_text: Optional[str] = None self._chemical_elements = set() # Will be populated if needed self._wrap_identifiers_in_mathrm = wrap_identifiers_in_mathrm self._root_element: Optional[ET.Element] = None self._is_chemistry_cached: Optional[bool] = None def format_identifier(self, text: str, node: ET.Element) -> str: """ Format an identifier ( element) for mathematics. Args: text: The identifier text content node: The element node Returns: Formatted LaTeX string Logic: 1. Mathematical functions → \\function (e.g., "sin" → "\\sin") 2. Single letters → check wrapping rules (most stay italic) 3. Multi-letter → check wrapping rules (depends on context) Examples: "sin" → "\\sin" "cos" → "\\cos" "x" → "x" (italic) "R" → "R" (italic in math context) "H" → "\\mathrm{H}" (always upright) "velocity" → "velocity" (italic if math context) """ # CRITICAL: Strip whitespace before checking (matches original converter behavior) # Original converter does: clean_text = text.strip() clean_text = text.strip() # Check if it's a mathematical function (case-insensitive) # Original converter does: if clean_text.lower() in MATHEMATICAL_FUNCTIONS if clean_text.lower() in self.FUNCTIONS: # Special handling for logarithms with bases if clean_text == "log2": return "\\log_{2}" elif clean_text == "log10": return "\\log_{10}" # Use lowercase version for function name return f"\\{clean_text.lower()}" # Check for function with subscript (e.g., "limn→∞") for func in ['lim', 'limsup', 'liminf', 'sup', 'inf', 'max', 'min']: if clean_text.startswith(func): remaining = clean_text[len(func):] if remaining: # Convert special symbols in subscript remaining = (remaining .replace('→', '\\to ') .replace('∞', '\\infty ') .replace('≤', '\\leqslant ') .replace('≥', '\\geqslant ') .replace('≠', '\\ne ') .replace('±', '\\pm ')) import re remaining = re.sub(r'\s+', ' ', remaining).strip() return f"\\{func}\\limits_{{{remaining}}}" else: return f"\\{func}" # CRITICAL: Unicode math set symbols (ℝ, ℤ, etc.) are already handled by UNICODE_TO_LATEX # These map to \mathbb{R}, \mathbb{Z}, etc. (not \mathbf{}) # This is correct because these Unicode symbols ARE blackboard bold characters # So we don't need special handling here - just fall through to UNICODE_TO_LATEX check below # UNICODE_PASSTHROUGH: Keep certain symbols as Unicode for MathJax compatibility if clean_text in UNICODE_PASSTHROUGH: return clean_text # Check for Unicode symbols if clean_text in UNICODE_TO_LATEX: return UNICODE_TO_LATEX[clean_text] # Handle multi-character strings with Unicode symbols if len(clean_text) > 1: has_unicode = any(char in UNICODE_TO_LATEX for char in clean_text) has_passthrough = any(char in UNICODE_PASSTHROUGH for char in clean_text) if has_unicode or has_passthrough: converted_parts = [] for char in clean_text: if char in UNICODE_PASSTHROUGH: 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) return ' '.join(converted_parts) # USER REQUIREMENT: "use the mathvariant to determine whether it's mathrm" # REMOVED: Chemistry detection heuristics (lines 139-162) # REMOVED: should_wrap_in_mathrm() call (now always returns False) # # Wrapping in \mathrm{} is ONLY done when mathvariant="normal" attribute exists. # That's handled in mi_visitor.py _handle_mathvariant() method. # Default: return as-is (variables remain italic in LaTeX) return clean_text def format_operator(self, text: str, node: ET.Element) -> str: """ Format an operator ( element) for mathematics. Args: text: The operator text content node: The element node Returns: Formatted LaTeX string Logic: 1. Delimiters → __DELIM__ markers for later processing 2. Comparison operators < and > → \\lt and \\gt (LaTeX convention) 3. Unicode symbols → LaTeX equivalents (via UNICODE_TO_LATEX) 4. Standard operators → unchanged (+, -, *, /, =) Examples: "(" → "__DELIM__(__DELIM__" ")" → "__DELIM__)__DELIM__" "+" → "+" "<" → "\\lt" ">" → "\\gt" "×" → "\\times" "≤" → "\\leqslant" "∈" → "\\in" """ # CRITICAL: Mark delimiters for potential scaling # These will be processed later by _process_delimiters() # This matches the original converter's behavior (line 3558 in omml_to_tex.py) # Include common paired delimiters: parentheses, brackets, braces, angle brackets, ceil/floor # BUG-012 FIX: Added "|" to delimiters to allow proper absolute value detection import sys delimiter_chars = ("(", ")", "[", "]", "{", "}", "⟨", "⟩", "〈", "〉", "⌈", "⌉", "⌊", "⌋", "|") if text in delimiter_chars: # Convert Unicode delimiters to LaTeX BEFORE marking # This ensures the delimiter pairing processor can find and pair them delimiter_latex_map = { "⟨": "\\langle", "⟩": "\\rangle", "〈": "\\langle", # CJK angle bracket "〉": "\\rangle", # CJK angle bracket "⌈": "\\lceil", "⌉": "\\rceil", "⌊": "\\lfloor", "⌋": "\\rfloor", "{": "\\{", # FIX: Escape braces so unpaired ones are valid LaTeX "}": "\\}", # FIX: Escape braces so unpaired ones are valid LaTeX } converted_text = delimiter_latex_map.get(text, text) # Check for stretchy attribute - if true, use a special marker to force scaling stretchy = node.get('stretchy') if stretchy == 'true': result = f"__DELIM__STRETCHY::{converted_text}__DELIM__" else: result = f"__DELIM__{converted_text}__DELIM__" # Debug: print(f"[DEBUG STRATEGY] Marking delimiter {text!r} → {result!r}", file=sys.stderr) return result # Handle < and > specifically to match expected output format (from original converter) if text == "<": return "\\lt" if text == ">": return "\\gt" # BUG-012 FIX: Removed forced | -> \mid conversion # Let DelimiterPairingProcessor decide whether it's \mid (operator) or \left|\right| (abs value) # if text == "|": # return "\\mid" # UNICODE_PASSTHROUGH: Keep certain symbols as Unicode for MathJax compatibility # These symbols have LaTeX commands that MathJax doesn't support or renders poorly. # Check this BEFORE converting to LaTeX to preserve the original Unicode. if text in UNICODE_PASSTHROUGH: return text # Check for Unicode symbol mapping if text in UNICODE_TO_LATEX: return UNICODE_TO_LATEX[text] # Handle multi-character operators with Unicode if len(text) > 1: has_unicode = any(char in UNICODE_TO_LATEX for char in text) has_passthrough = any(char in UNICODE_PASSTHROUGH for char in text) if has_unicode or has_passthrough: converted_parts = [] for char in text: # Check passthrough first - keep these as Unicode if char in UNICODE_PASSTHROUGH: converted_parts.append(char) elif char in UNICODE_TO_LATEX: converted_parts.append(UNICODE_TO_LATEX[char]) else: converted_parts.append(char) return ' '.join(converted_parts) # Default: return as-is return text def format_number(self, text: str, node: ET.Element) -> str: """ Format a number ( element) for mathematics. Args: text: The number text content node: The element node Returns: Formatted LaTeX string Logic: Numbers are returned as-is in mathematics mode. Examples: "123" → "123" "3.14" → "3.14" "1.5×10⁻³" → "1.5×10⁻³" (Unicode handled by operator conversion) """ # Numbers are typically returned as-is # Unicode processing happens at operator level return text def set_equation_text(self, text: str) -> None: """ Set the full equation text for context detection. Args: text: The complete equation LaTeX text """ self._equation_text = text def set_wrap_identifiers_flag(self, value: bool) -> None: """ Set whether to wrap identifiers in \\mathrm{}. Args: value: True to wrap multi-char identifiers, False to leave as-is """ self._wrap_identifiers_in_mathrm = value def set_root_element(self, root: ET.Element) -> None: """ Set the root MathML element for context detection. Args: root: The root element """ self._root_element = root self._is_chemistry_cached = None # Clear cache def is_chemistry_equation(self) -> bool: """ Detect if the current equation is chemistry-related. DISABLED: Subject detection is obsolete. We rely on mathvariant="normal" only. Returns: False - always (chemistry detection disabled) """ # DISABLED: Always return False - no chemistry detection # Chemistry formulas are handled via mathvariant="normal" in the MathML return False # OLD CODE (kept for reference): # if self._is_chemistry_cached is not None: # return self._is_chemistry_cached # if self._root_element is None: # return False # self._is_chemistry_cached = detect_chemistry_equation(self._root_element) # return self._is_chemistry_cached def should_wrap_in_mathrm(self, content: str) -> bool: """ Determine if an identifier should be wrapped in \\mathrm{}. CRITICAL USER REQUIREMENT: "use the mathvariant to determine whether it's mathrm" This means NO HEURISTIC WRAPPING. Only wrap when there's an explicit mathvariant="normal" attribute, which is handled in mi_visitor.py. This method now serves as a safety check only - by default returns False. All wrapping decisions are made based on explicit MathML attributes. Args: content: The identifier text Returns: False - no heuristic wrapping allowed """ # USER REQUIREMENT: Only mathvariant="normal" should trigger \mathrm wrapping # That's handled in mi_visitor.py _handle_mathvariant() method # This method should NOT apply any heuristics return False def _is_mathematical_variable(self, content: str) -> bool: """ Determine if multi-letter content should be treated as mathematical variables (not wrapped). Args: content: The identifier text Returns: True if should remain italic (mathematical variable), False otherwise """ # Pattern: all lowercase letters (like 'ab', 'xy', 'mn') - typical for mathematical variables if content.islower() and content.isalpha(): # Check if we're in a mathematical context (inequalities, fractions, etc.) if self._is_mathematical_context(): return True # Pattern: mixed case with lowercase start (like 'ab', 'pq') - likely mathematical variables if content[0].islower() and content.isalpha() and len(content) <= 3: if self._is_mathematical_context(): return True return False def _is_mathematical_context(self) -> bool: """Check if we're in a mathematical context with inequalities, equations, fractions, etc.""" if not self._equation_text: return False mathematical_indicators = [ # Inequality operators '≤', '≥', '≠', '<', '>', '\\leqslant', '\\geqslant', '\\leq', '\\geq', '\\ne', '\\neq', # Mathematical operations and relations '=', '+', '-', '×', '÷', '/', '\\cdot', '\\times', '\\div', '\\pm', '\\mp', # Fraction indicators '\\dfrac', '\\frac', '\\tfrac', # Function operations '\\sin', '\\cos', '\\tan', '\\log', '\\ln', '\\exp', '\\sqrt', # Set operations (when not chemical) '\\cap', '\\cup', '∩', '∪', # Mathematical symbols '∞', '\\infty', '∂', '\\partial', '∇', '\\nabla', # Set membership '\\in', '\\notin', '∈', '∉' ] return any(indicator in self._equation_text for indicator in mathematical_indicators) def _is_clear_chemical_context(self) -> bool: """Check if we're in a clear chemical context (more restrictive than set theory).""" if not self._equation_text: return False chemical_indicators = ['\\xlongequal', 'Cu', 'Mn', 'Fe', 'SO', 'OH', '2+', '2-', '↑', '↓'] return any(indicator in self._equation_text for indicator in chemical_indicators) def _has_mathematical_functions(self) -> bool: """Check if the current equation contains mathematical functions.""" # Common mathematical functions that indicate math context, not chemistry math_functions = ['sin', 'cos', 'tan', 'log', 'ln', 'exp', 'sqrt', 'lim', 'max', 'min', 'arcsin', 'arccos', 'arctan', 'sinh', 'cosh', 'tanh'] # Check if we have stored equation text for context analysis if self._equation_text: return any(func in self._equation_text for func in math_functions) return False def detect_context(self, equation_text: str) -> bool: """ Detect if an equation is mathematical content. Args: equation_text: The full equation text Returns: True if likely mathematical, False otherwise Note: Math is the default context, so this returns True unless other strategies provide stronger indicators. """ # Store equation text for context detection self.set_equation_text(equation_text) # Math is the default - return True # In practice, this is overridden by more specific strategies # (chemistry, physics, statistics) which check for their markers return True @property def name(self) -> str: """Get the strategy name.""" return "math" def __repr__(self) -> str: """String representation for debugging.""" return f"MathStrategy(functions={len(self.FUNCTIONS)})"