r""" Visitor for (subscript + superscript) MathML elements. The element represents simultaneous subscripts and superscripts. The LaTeX conversion uses base_{subscript}^{superscript} notation. """ from lxml import etree as ET from ..core.interfaces import Visitor, Strategy from ..core.context import ConversionContext from ..utils import text_content as _text_content, local_name as _local class MsubsupVisitor(Visitor): r""" Handles (subscript + superscript) MathML elements. Converts elements with both subscript and superscript to LaTeX notation. Examples: Tensor with indices: Tij → T_{i}^{j} Integral bounds: ∫01 → \int_{0}^{1} Summation bounds: ∑i=1n → \sum_{i = 1}^{n} Variable with both scripts: x02 → x_{0}^{2} Complex base: x+1 0 2 → (x + 1)_{0}^{2} """ def __init__(self, strategy: Strategy, context: ConversionContext = None): """ Initialize the Msubsup visitor. Args: strategy: The formatting strategy to use context: Shared conversion context for state tracking """ super().__init__(strategy) self.context = context def visit(self, node: ET.Element) -> str: r""" Convert element to LaTeX. Args: node: The element to convert Returns: LaTeX string with subscript and superscript (base_{sub}^{sup}) Raises: ValueError: If node is not an element ValueError: If msubsup has fewer than 3 children """ if not self.can_handle(node): raise ValueError(f"MsubsupVisitor cannot handle element '{node.tag}'") children = list(node) # msubsup must have exactly 3 children: base, subscript, superscript if len(children) < 3: raise ValueError( f"msubsup element must have 3 children (base, subscript, superscript), " f"got {len(children)}" ) # Check for sideset pattern: base is with 3 children # Pattern: left_subleft_supbaseright_subright_sup # Result: \sideset{_{left_sub}^{left_sup}}{_{right_sub}^{right_sup}}{base} if self._is_sideset_pattern(children[0]): return self._handle_sideset(children) # Check if base is a large operator BEFORE converting is_large_op = self._is_large_operator(children[0]) # Convert base (not in script context) base = self._convert_child(children[0]) # Push script context before converting subscript and superscript if self.context: self.context.push_script_context('msubsup') try: # Convert subscript and superscript (both in script context) subscript = self._convert_child(children[1]) superscript = self._convert_child(children[2]) finally: # Always pop context, even if conversion fails if self.context: self.context.pop_script_context() # Check for large operators that need \limits if is_large_op: return f"{base}\\limits_{self._brace(subscript)}^{self._brace(superscript)}" # If base is empty (from empty ), output just the sub/superscripts # This allows them to attach to the previous element in LaTeX naturally if not base or not base.strip(): return f"_{self._brace(subscript)}^{self._brace(superscript)}" # Wrap base if needed (complex expressions or if it contains superscripts) base = self._wrap_if_needed(base) # Special case: If base contains a superscript (like v^{\prime}), # wrap it in braces to prevent syntax error with additional sub/superscripts # Example: v^{\prime} should become {v^{\prime}}_{1}^{2}, not v^{\prime}_{1}^{2} if self._contains_superscript(base): base = f"{{{base}}}" # Return base_{subscript}^{superscript} return f"{base}_{self._brace(subscript)}^{self._brace(superscript)}" def _convert_child(self, child: ET.Element) -> str: """Convert a child element to LaTeX.""" tag = _local(child.tag) # Import visitors as needed to avoid circular imports if tag == 'mi': from .mi_visitor import MiVisitor return MiVisitor(self.strategy, self.context).visit(child) elif tag == 'mo': from .mo_visitor import MoVisitor return MoVisitor(self.strategy).visit(child) elif tag == 'mn': from .mn_visitor import MnVisitor return MnVisitor(self.strategy).visit(child) elif tag == 'mfrac': from .mfrac_visitor import MfracVisitor return MfracVisitor(self.strategy, self.context).visit(child) elif tag == 'msup': from .msup_visitor import MsupVisitor return MsupVisitor(self.strategy).visit(child) elif tag == 'msub': from .msub_visitor import MsubVisitor return MsubVisitor(self.strategy).visit(child) elif tag == 'mrow': from .mrow_visitor import MrowVisitor return MrowVisitor(self.strategy, self.context).visit(child) elif tag == 'msqrt': from .msqrt_visitor import MsqrtVisitor return MsqrtVisitor(self.strategy).visit(child) elif tag == 'mroot': from .mroot_visitor import MrootVisitor return MrootVisitor(self.strategy).visit(child) elif tag == 'msubsup': from .msubsup_visitor import MsubsupVisitor return MsubsupVisitor(self.strategy).visit(child) elif tag == 'munder': from .munder_visitor import MunderVisitor return MunderVisitor(self.strategy).visit(child) elif tag == 'mover': from .mover_visitor import MoverVisitor return MoverVisitor(self.strategy).visit(child) elif tag == 'munderover': from .munderover_visitor import MunderoverVisitor return MunderoverVisitor(self.strategy).visit(child) elif tag == 'mfenced': from .mfenced_visitor import MfencedVisitor return MfencedVisitor(self.strategy).visit(child) elif tag == 'mtable': from .mtable_visitor import MtableVisitor return MtableVisitor(self.strategy).visit(child) elif tag == 'mtext': from .mtext_visitor import MtextVisitor return MtextVisitor(self.strategy).visit(child) elif tag == 'mstyle': from .mstyle_visitor import MstyleVisitor return MstyleVisitor(self.strategy).visit(child) elif tag == 'mspace': from .mspace_visitor import MspaceVisitor return MspaceVisitor(self.strategy).visit(child) elif tag == 'menclose': from .menclose_visitor import MencloseVisitor return MencloseVisitor(self.strategy).visit(child) elif tag == 'mmultiscripts': from .mmultiscripts_visitor import MmultiscriptsVisitor return MmultiscriptsVisitor(self.strategy).visit(child) elif tag == 'maction': from .maction_visitor import MactionVisitor return MactionVisitor(self.strategy).visit(child) elif tag == 'merror': from .merror_visitor import MerrorVisitor return MerrorVisitor(self.strategy).visit(child) elif tag == 'mpadded': from .mpadded_visitor import MpaddedVisitor return MpaddedVisitor(self.strategy).visit(child) elif tag == 'mphantom': from .mphantom_visitor import MphantomVisitor return MphantomVisitor(self.strategy).visit(child) elif tag == 'ms': from .ms_visitor import MsVisitor return MsVisitor(self.strategy).visit(child) elif tag == 'mglyph': from .mglyph_visitor import MglyphVisitor return MglyphVisitor(self.strategy).visit(child) elif tag == 'maligngroup': from .maligngroup_visitor import MaligngroupVisitor return MaligngroupVisitor(self.strategy).visit(child) elif tag == 'malignmark': from .malignmark_visitor import MalignmarkVisitor return MalignmarkVisitor(self.strategy).visit(child) elif tag == 'mlabeledtr': from .mlabeledtr_visitor import MlabeledtrVisitor return MlabeledtrVisitor(self.strategy).visit(child) else: # Fallback: return text content for unknown elements import sys print(f"[WARNING] Unknown element <{tag}> in msubsup, using text fallback", file=sys.stderr) return _text_content(child) def _wrap_if_needed(self, base: str) -> str: """ Wrap base in parentheses if needed. Args: base: The base content Returns: Base, optionally wrapped in parentheses Note: Complex expressions with operators should be wrapped. Simple identifiers and numbers don't need wrapping. """ # Check if base already has delimiters (starts and ends with matching delimiters) base_stripped = base.strip() if base_stripped: # Check for matching delimiters delimiter_pairs = { '(': ')', '[': ']', '{': '}', '\\left(': '\\right)', '\\left[': '\\right]', '\\left\\{': '\\right\\}', } for open_delim, close_delim in delimiter_pairs.items(): if base_stripped.startswith(open_delim) and base_stripped.endswith(close_delim): # Already has delimiters, no need to wrap return base # Check if base needs grouping for proper subscript/superscript application # In LaTeX, x_2 applies subscript to x, but "a + b_2" applies only to b # We need to brace multi-element bases: {a + b}_2 # # Use braces {} for grouping (invisible) not parentheses () (visible) # Only wrap if base contains operators that indicate a true multi-element expression math_operators = {'=', '+', '-', '/', '*', '\\times', '\\cdot', '\\pm', '\\to'} if ' ' in base and any(op in base for op in math_operators): return f"{{{base}}}" return base def _brace(self, content: str) -> str: """Wrap content in braces.""" return f"{{{content}}}" def _contains_superscript(self, base: str) -> bool: """ Check if base contains a LaTeX superscript (^{...} or ^X). This is important for proper grouping when adding additional scripts. For example: v^{\\prime} must be wrapped as {v^{\\prime}} before adding subscripts/superscripts to avoid invalid syntax. Args: base: The base LaTeX string Returns: True if base contains an unbraced superscript """ # Look for ^ character in the base # We need to be careful to not match ^ that's already inside braces at the start # For example: {v^{\prime}} should return False (already wrapped) # but v^{\prime} should return True (needs wrapping) if not base or '^' not in base: return False # If the entire base is already wrapped in braces, it's safe base_stripped = base.strip() if base_stripped.startswith('{') and base_stripped.endswith('}'): # Check if these braces wrap the entire expression # Simple heuristic: count brace depth depth = 0 for i, char in enumerate(base_stripped): if char == '{': depth += 1 elif char == '}': depth -= 1 # If depth hits 0 before the end, the braces don't wrap everything if depth == 0 and i < len(base_stripped) - 1: break # If we maintained depth > 0 until the last character, it's wrapped if i == len(base_stripped) - 1: return False # Base contains ^ and is not fully wrapped return True def _is_large_operator(self, base_node: ET.Element) -> bool: """Check if base is a large operator that uses limits by default.""" if _local(base_node.tag) != 'mo': return False base_text = _text_content(base_node).strip() # Large operators that display limits above/below in display mode large_ops = [ "∑", "\\sum", # Summation "∏", "\\prod", # Product "∐", "\\coprod", # Coproduct "∫", "\\int", # Integral "∬", "\\iint", # Double integral "∭", "\\iiint", # Triple integral "∮", "\\oint", # Contour integral "⋃", "\\bigcup", # Union "⋂", "\\bigcap", # Intersection "⋁", "\\bigvee", # Logical OR "⋀", "\\bigwedge", # Logical AND "⨁", "\\bigoplus", # Direct sum "⨂", "\\bigotimes", # Tensor product "⨄", "\\biguplus", # Multiset union ] return base_text in large_ops def _is_sideset_pattern(self, base_node: ET.Element) -> bool: """ Check if base node matches the sideset pattern. Sideset pattern: base is with at least 3 children where first 3 are - First : left subscript - Second : left superscript - Third : base symbol (first part) - Additional children: merged with base (e.g., FeMn has Fe in mrow, Mn as mi) Args: base_node: The base element to check Returns: True if this is a sideset pattern, False otherwise """ if _local(base_node.tag) != 'mrow': return False # Get direct children of the base children = list(base_node) # Must have at least 3 children if len(children) < 3: return False # First 3 children must all be elements return all(_local(children[i].tag) == 'mrow' for i in range(3)) def _handle_sideset(self, msubsup_children: list) -> str: """ Handle sideset pattern conversion. Pattern: left_sub left_sup base_part1 [optional: additional base elements...] right_sub right_sup Converts to: \\sideset{_{left_sub}^{left_sup}}{_{right_sub}^{right_sup}}{base} Args: msubsup_children: The 3 children of element Returns: LaTeX sideset command """ base_mrow = msubsup_children[0] right_sub_mrow = msubsup_children[1] right_sup_mrow = msubsup_children[2] # Extract children from base base_children = list(base_mrow) left_sub_mrow = base_children[0] left_sup_mrow = base_children[1] actual_base_mrow = base_children[2] # Additional children after the 3rd element should be merged with base # Example: FeMn → FeMn extra_base_elements = base_children[3:] if len(base_children) > 3 else [] # Push script context for converting subscripts and superscripts if self.context: self.context.push_script_context('sideset') try: # Convert all parts left_sub = self._convert_child(left_sub_mrow) left_sup = self._convert_child(left_sup_mrow) right_sub = self._convert_child(right_sub_mrow) right_sup = self._convert_child(right_sup_mrow) finally: if self.context: self.context.pop_script_context() # Convert base (not in script context) base_parts = [self._convert_child(actual_base_mrow)] # Add any extra base elements (e.g., Mn after Fe in FeMn) for extra_elem in extra_base_elements: base_parts.append(self._convert_child(extra_elem)) # Merge base parts (no spaces for chemical formulas) base = ''.join(base_parts) # Build sideset command left_scripts = f"_{{{left_sub}}}^{{{left_sup}}}" right_scripts = f"_{{{right_sub}}}^{{{right_sup}}}" return f"\\sideset{{{left_scripts}}}{{{right_scripts}}}{{{base}}}" def can_handle(self, node: ET.Element) -> bool: """Check if this visitor can handle the given node.""" return _local(node.tag) == 'msubsup' @property def element_name(self) -> str: """Get the element name this visitor handles.""" return 'msubsup' def __repr__(self) -> str: """String representation for debugging.""" return f"MsubsupVisitor(strategy={self.strategy.name})"