r""" Visitor for (fenced/delimited expressions) MathML elements. The element represents expressions enclosed by delimiters: - Auto-scaling parentheses: \left( ... \right) - Custom brackets: \left[ ... \right] - Custom braces: \left\{ ... \right\} - Cases environment: single left brace - Separators: commas, semicolons between elements This visitor handles delimiter scaling and separator insertion. """ from typing import Optional, List from lxml import etree as ET from ..core.interfaces import Visitor, Strategy from ..utils import text_content as _text_content, local_name as _local class MfencedVisitor(Visitor): r""" Handles (fenced/delimited) MathML elements. Converts fenced expressions to LaTeX with auto-scaling delimiters. Examples: Default parentheses: xy → \left(x,y\right) Custom brackets: a → \left[a\right] Custom braces: x → \left\{x\right\} Angle brackets: v → \left\langle v\right\rangle Single left brace (piecewise/cases): ... → \left\{\begin{array}{...}...\end{array}\right. Custom separator (semicolon): ab → \left(a;b\right) Multiple separators: abc → \left(a;b c\right) """ def __init__(self, strategy: Strategy): """Initialize the Mfenced visitor.""" super().__init__(strategy) def visit(self, node: ET.Element) -> str: r""" Convert element to LaTeX. Args: node: The element to convert Returns: LaTeX string with auto-scaling delimiters Raises: ValueError: If node is not an element """ if not self.can_handle(node): raise ValueError(f"MfencedVisitor cannot handle element '{node.tag}'") # Get opening and closing delimiters (default: parentheses) open_delim = node.get('open', '(') close_delim = node.get('close', ')') # Get separator (default: comma) separators = node.get('separators', ',') # Convert children children = list(node) if not children: # Empty mfenced left = self._get_left_delimiter(open_delim) right = self._get_right_delimiter(close_delim) return f"{left}{right}" # NOTE: We NO LONGER use cases environment for better compatibility # Instead, we use explicit \left\{ and \right. notation # (Cases environment is not universally supported) # Check if this is set notation: { pattern | condition } if open_delim == '{' and close_delim == '}': set_notation_result = self._try_convert_as_set_notation(node, children) if set_notation_result: return set_notation_result # Check if this is an interval with nested mfenced for the closing delimiter # Pattern: ... # This should merge into a single interval: \left( ... \right] if self._is_split_interval(node, children, open_delim, close_delim): return self._merge_split_interval(node, children, open_delim) # Convert each child, tracking which are operators converted_children = [] child_types = [] for child in children: converted = self._convert_child(child) converted_children.append(converted) child_types.append(_local(child.tag)) # Determine if this is a continuous expression or a list # If there are explicit separator operators (mo elements with separator chars), # treat as continuous expression (join with spaces) # Otherwise, use the separators attribute for list formatting content = self._smart_join(converted_children, child_types, separators) # Get LaTeX delimiters left = self._get_left_delimiter(open_delim) right = self._get_right_delimiter(close_delim) return f"{left}{content}{right}" def _try_convert_as_set_notation(self, node: ET.Element, children: List) -> Optional[str]: r""" Try to convert as set notation: { pattern | condition }. Set notation has the pattern: - { (x, y) | x+y < 2 } → \left\{ (x, y) \mid x+y < 2 \right\} - { x | x > 0 } → \left\{ x \mid x > 0 \right\} Detection heuristics: 1. First child (or first few children) form the "pattern" (variable/tuple) 2. There's a nested that's really a separator OR there are exactly 2 distinct groups of children 3. Last children form the "condition" Args: node: The outer mfenced element with open="{" close="}" children: List of child elements Returns: LaTeX string if this is set notation, None otherwise """ if len(children) < 2: return None # Strategy 1: Look for nested mfenced with | delimiters # If the second or third child is , # and there's content after it OR before it, treat as set notation # BUT keep the |...| as absolute value brackets in the output for i, child in enumerate(children): if _local(child.tag) == 'mfenced': child_open = child.get('open', '(') child_close = child.get('close', ')') if child_open == '|' and child_close == '|': # Found nested | delimiters # Check if there's content before this (the pattern) or after (condition) has_content_before = i >= 1 has_content_after = i < len(children) - 1 if has_content_before or has_content_after: # This looks like set notation! # Split into: pattern (before), |...|, and rest pattern_parts = children[:i] pipe_element = child # The entire element condition_parts = children[i+1:] # Convert pattern pattern_latex = ' '.join(self._convert_child(c) for c in pattern_parts if c is not None) # Convert the |...| element as absolute value (keep the delimiters!) pipe_latex = self._convert_child(pipe_element) # Convert remaining condition condition_latex = ' '.join(self._convert_child(c) for c in condition_parts if c is not None) # Assemble: { pattern \mid |pipe_content| condition } parts = [] if pattern_latex: parts.append(pattern_latex) parts.append('\\mid') if pipe_latex: parts.append(pipe_latex) if condition_latex: parts.append(condition_latex) content = ' '.join(parts) return f"\\left\\{{{content}\\right\\}}" # Strategy 2: Check if there are exactly 2 "groups" of elements # where the first group is a single identifier/tuple, suggesting { x | condition } # This handles cases like { x x+1<2 } where x and x+1<2 should be separated by \mid if len(children) >= 2: first_child_type = _local(children[0].tag) # If first child is a single identifier (mi) or a tuple (mfenced with parens) if first_child_type in ['mi', 'mfenced']: # Check if this looks like a pattern followed by a condition # Heuristic: if first child is simple (mi or mfenced), and there are more elements, # and those elements form an expression/condition, insert \mid # For now, check if we have exactly these patterns: # 1. First child is (tuple) # 2. OR first child is (variable) # 3. Rest form a condition if first_child_type == 'mi': # Check if second element is also mi (likely the start of condition) if len(children) >= 2 and _local(children[1].tag) == 'mi': # Likely: { x x+1<2 } should be { x | x+1<2 } pattern_latex = self._convert_child(children[0]) condition_latex = ' '.join(self._convert_child(c) for c in children[1:]) return f"\\left\\{{{pattern_latex} \\mid {condition_latex}\\right\\}}" elif first_child_type == 'mfenced': first_child_open = children[0].get('open', '(') first_child_close = children[0].get('close', ')') # Check if it's a tuple (parentheses) if first_child_open == '(' and first_child_close == ')': # Pattern: { (x,y) ... } - check if ... is a condition pattern_latex = self._convert_child(children[0]) # Check if remaining children don't start with a comma # (which would indicate list notation, not set notation) if len(children) >= 2: second_type = _local(children[1].tag) if second_type != 'mo' or (second_type == 'mo' and _text_content(children[1]).strip() != ','): # Not a list, likely set notation condition_latex = ' '.join(self._convert_child(c) for c in children[1:]) return f"\\left\\{{{pattern_latex} \\mid {condition_latex}\\right\\}}" return None 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).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).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).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 mfenced, using text fallback", file=sys.stderr) return _text_content(child) def _smart_join(self, items: list, types: list, separators: str) -> str: """ Intelligently join items based on whether they form a continuous expression. Args: items: Converted LaTeX strings types: Element types (mi, mo, mn, etc.) separators: Separator string from mfenced attribute Returns: Joined LaTeX string """ if len(items) == 0: return '' if len(items) == 1: return items[0] # Check if this looks like a continuous expression: # - Contains 'mo' (operator) elements # - Separators not explicitly set (or default comma) has_operators = 'mo' in types if has_operators and separators == ',': # Continuous expression: join with spaces, no extra separators # Filter out empty items return ' '.join(item for item in items if item) else: # List of items: use traditional separator insertion return self._insert_separators(items, separators) def _insert_separators(self, items: list, separators: str) -> str: """Insert separators between items for list-style formatting.""" if len(items) == 0: return '' if len(items) == 1: return items[0] # Parse separator string # If multiple characters, each is used for successive separators # If fewer separators than gaps, last separator is reused sep_list = list(separators) if separators else [','] result = [items[0]] for i, item in enumerate(items[1:], start=0): # Get appropriate separator (reuse last if not enough) sep_idx = min(i, len(sep_list) - 1) separator = sep_list[sep_idx] result.append(separator) result.append(item) return ''.join(result) def _get_left_delimiter(self, delim: str) -> str: """Map opening delimiter to LaTeX \\left command.""" delimiter_map = { '(': '\\left(', '[': '\\left[', '{': '\\left\\{', '⟨': '\\left\\langle', '⌈': '\\left\\lceil', '⌊': '\\left\\lfloor', '|': '\\left|', '‖': '\\left\\|', '': '\\left.', # Invisible left delimiter } return delimiter_map.get(delim, f'\\left{delim}') def _get_right_delimiter(self, delim: str) -> str: """Map closing delimiter to LaTeX \\right command.""" delimiter_map = { ')': '\\right)', ']': '\\right]', '}': '\\right\\}', '⟩': '\\right\\rangle', '⌉': '\\right\\rceil', '⌋': '\\right\\rfloor', '|': '\\right|', '‖': '\\right\\|', '': '\\right.', # Invisible right delimiter } return delimiter_map.get(delim, f'\\right{delim}') def _is_split_interval(self, node: ET.Element, children: list, open_delim: str, close_delim: str) -> bool: """ Detect if this is a split interval notation pattern. Pattern: ... This represents an interval like (0, 1/4] split across two mfenced elements. Also handles case where content is wrapped in mrow: ... ... Returns True if: 1. Outer has opening delimiter but NO closing delimiter (close="") 2. Last child is with NO opening delimiter (open="") but HAS closing delimiter 3. There are other children before the last mfenced (the interval content) Args: node: The outer mfenced element children: List of child elements open_delim: Opening delimiter of outer mfenced close_delim: Closing delimiter of outer mfenced Returns: True if this is a split interval pattern """ # Check pattern: outer has open delim but no close delim if not (open_delim in '([{' and close_delim == ''): return False # Handle case where content is wrapped in a single mrow actual_children = children if len(children) == 1 and _local(children[0].tag) == 'mrow': actual_children = list(children[0]) # Need at least 2 children (content + nested mfenced) if len(actual_children) < 2: return False # Check if last child is mfenced with complementary delimiters last_child = actual_children[-1] if _local(last_child.tag) != 'mfenced': return False # Last mfenced should have no open delimiter but have close delimiter last_open = last_child.get('open', '(') last_close = last_child.get('close', ')') if last_open == '' and last_close in ')]}\u232A\u2309\u230B': # Closing delimiters return True return False def _merge_split_interval(self, node: ET.Element, children: list, open_delim: str) -> str: """ Merge split interval into a single LaTeX interval. Pattern: Output: \\left( \\right] Also handles case where content is wrapped in mrow: ... ... Args: node: The outer mfenced element children: List of child elements open_delim: Opening delimiter (e.g., '(', '[') Returns: LaTeX string for merged interval """ # Handle case where content is wrapped in a single mrow actual_children = children if len(children) == 1 and _local(children[0].tag) == 'mrow': actual_children = list(children[0]) # Get the closing delimiter from the last child (which is the nested mfenced) last_child = actual_children[-1] close_delim = last_child.get('close', ')') # Convert all children except the last (which is the nested mfenced) content_children = actual_children[:-1] # Also convert the CONTENT of the last mfenced (not the mfenced itself) last_mfenced_children = list(last_child) # Combine content from both outer and inner mfenced all_content_children = content_children + last_mfenced_children # Convert all content converted_parts = [] for child in all_content_children: part = self._convert_child(child) if part: converted_parts.append(part) # Join with spaces (standard interval notation) content = ' '.join(converted_parts) # Build result with merged delimiters left = self._get_left_delimiter(open_delim) right = self._get_right_delimiter(close_delim) return f"{left}{content}{right}" def can_handle(self, node: ET.Element) -> bool: """Check if this visitor can handle the given node.""" return _local(node.tag) == 'mfenced' @property def element_name(self) -> str: """Get the element name this visitor handles.""" return 'mfenced' def __repr__(self) -> str: """String representation for debugging.""" return f"MfencedVisitor(strategy={self.strategy.name})"