r""" Visitor for (table/matrix) MathML elements. The element represents tables and matrices in mathematical expressions: - Matrix environments: pmatrix, bmatrix, vmatrix, Vmatrix - Generic arrays with column specifications - Cases environment (piecewise functions) - Binomial coefficients - Chemical reaction conditions This visitor coordinates with MtrVisitor and MtdVisitor to handle table structure. """ from lxml import etree as ET from ..core.interfaces import Visitor, Strategy from ..utils import local_name as _local class MtableVisitor(Visitor): r""" Handles (table/matrix) MathML elements. Converts tables to appropriate LaTeX environments based on context and attributes. Examples: Matrix (parentheses): 12 34 → \begin{pmatrix}1 & 2 \\ 3 & 4\end{pmatrix} Matrix (brackets): ... → \begin{bmatrix}...\end{bmatrix} Matrix (vertical bars): ... → \begin{vmatrix}...\end{vmatrix} Piecewise functions (legacy data-cases attribute, now ignored): x y → \left\{\begin{array}{l}x \\ y\end{array}\right. Generic array: ab → \begin{array}{cc}a & b \\\end{array} """ def __init__(self, strategy: Strategy): """Initialize the Mtable 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 appropriate matrix/table environment Raises: ValueError: If node is not an element """ if not self.can_handle(node): raise ValueError(f"MtableVisitor cannot handle element '{node.tag}'") # Check for special table types # 1. Cases-like structures (piecewise functions) # NOTE: We NO LONGER use cases environment for better compatibility # Instead, these are wrapped by parent mfenced with \left\{ and \right. # If we see data-cases="true", we should ignore it and render as a regular array # (the wrapping delimiters will be added by the parent mfenced element) # 2. Binomial coefficients if node.get("data-binomial") == "true": return self._create_binomial(node) # 3. Chemical reaction conditions (double underline pattern) # CRITICAL: Check BEFORE rendering as array # Pattern: 2-row mtable with nested munder elements if self._is_chemical_reaction_condition(node): upper_text = self._extract_reaction_condition_text(node) lower_text = self._extract_reaction_condition_lower_text(node) if not lower_text: lower_text = "\\quad" return f"\\xlongequal[{lower_text}]{{{upper_text}}}" # 4. Large operator with limits (vertical stack pattern) # Pattern: 3-row mtable with large operator in middle row # Example: n / ∑ / i=1 → \sum^{n}_{i=1} if self._is_large_operator_stack(node): return self._convert_large_operator_stack(node) # Get table attributes columnalign = node.get("columnalign", "center") align = node.get("align", "") # Vertical alignment (top, bottom, center, axis, baseline) frame = node.get("frame", "none") # Determine environment type based on parent mfenced delimiters env = self._determine_environment(node, frame) # Get all rows rows = self._convert_rows(node) if not rows: return f"\\begin{{{env}}}\\end{{{env}}}" # Remove trailing \\ from last row (not needed in LaTeX) if rows and rows[-1].endswith(" \\\\"): rows[-1] = rows[-1][:-3] # Remove " \\" # Determine strict alignment requirements # If the environment is a matrix type (pmatrix, etc.) but we have specific alignment # (e.g., left aligned columns), we must switch to 'array' with manual delimiters # because pmatrix/bmatrix etc. do not support column specs (always centered). explicit_alignment = columnalign and columnalign != "center" and columnalign != "mean" # Matrix to delimiter mapping matrix_delims = { "pmatrix": ("(", ")"), "bmatrix": ("[", "]"), "vmatrix": ("|", "|"), "Vmatrix": (r"\|", r"\|"), } force_array_with_delims = False open_delim, close_delim = "", "" if env in matrix_delims and explicit_alignment: # Switch to array but wrap in delimiters force_array_with_delims = True open_delim, close_delim = matrix_delims[env] env = "array" # Add column specification for array environment if env == "array": col_count = rows[0].count('&') + 1 if rows else 1 col_spec = self._get_column_spec(columnalign, col_count) # Was: Forced left for cases-style. # Removed to respect source alignment (e.g. Center) even for single-brace tables. # Helper for position argument (vertical alignment) # MathXML align: top, bottom, center, axis, baseline # LaTeX array: t (top), b (bottom), c (center/default) pos_arg = "" if align == "top": pos_arg = "[t]" elif align == "bottom": pos_arg = "[b]" # center/axis/baseline -> default (center) # Format as: \begin{array}[pos]{cc}...\end{array} rows_str = '\n'.join(rows) array_latex = f"\\begin{{{env}}}{pos_arg}{{{col_spec}}}\n{rows_str}\n\\end{{{env}}}" if force_array_with_delims: # Wrap in \left...\right # Handle special case for braces/bars in LaTeX if needed (but map uses raw chars or commands) # open_delim is raw char usually. # If it's { or } it needs escaping, but Vmatrix uses \|. # However, \left( is valid. \left\| is valid. # Only need to be careful if delim is complex. # Check if we need to escape { or } for built-in text # But here we are generating LaTeX code. left_cmd = f"\\left{open_delim}" right_cmd = f"\\right{close_delim}" # Specific fix for brace which might be passed as { if open_delim == "{": left_cmd = r"\left\{" if close_delim == "}": right_cmd = r"\right\}" return f"{left_cmd}{array_latex}{right_cmd}" else: return array_latex else: # Format as: \begin{pmatrix}...\end{pmatrix} # Note: Standard matrix envs don't support vertical alignment arg easily rows_str = '\n'.join(rows) return f"\\begin{{{env}}}\n{rows_str}\n\\end{{{env}}}" def _is_cases_style(self, node: ET.Element) -> bool: """ Check if mtable is inside a cases-style structure (left brace only). Cases-style includes: - Piecewise functions: f(x) = { case1 \\ case2 } - Equation systems: { eq1 \\ eq2 } - Inequality systems Per company convention, these should use left alignment ('ll'). Args: node: The element Returns: True if this is a cases-style table """ from ..utils import text_content as _text_content parent = node.getparent() # Pattern 1: Direct mfenced parent if parent is not None and _local(parent.tag) == "mfenced": open_delim = parent.get("open", "(") close_delim = parent.get("close", ")") # Cases-style: left brace with empty or "." closing if open_delim == "{" and close_delim in ("", "."): return True # Pattern 2: mfenced -> mrow -> mtable if parent is not None and _local(parent.tag) == "mrow": grandparent = parent.getparent() if grandparent is not None and _local(grandparent.tag) == "mfenced": open_delim = grandparent.get("open", "(") close_delim = grandparent.get("close", ")") if open_delim == "{" and close_delim in ("", "."): return True # Pattern 3: { sibling pattern (MathML without mfenced) # Structure: {... # The mtable is in mrow -> mrow, and sibling mo has fence=true with { if parent is not None and _local(parent.tag) == "mrow": grandparent = parent.getparent() if grandparent is not None and _local(grandparent.tag) == "mrow": # Check siblings of parent for { for sibling in grandparent: if _local(sibling.tag) == "mo" and sibling.get("fence") == "true": sibling_text = _text_content(sibling).strip() if sibling_text == "{": # Found left brace fence, check if closing is empty # Look for an empty or . for other in grandparent: if other is sibling: continue if _local(other.tag) == "mo" and other.get("fence") == "true": other_text = _text_content(other).strip() if other_text in ("", "."): return True # If only one fence found with {, still consider it cases-style # (closing might be implicit) return True return False def _determine_environment(self, node: ET.Element, frame: str) -> str: """ Determine the LaTeX environment based on context. Args: node: The element frame: Frame attribute value Returns: Environment name (pmatrix, bmatrix, vmatrix, Vmatrix, array, matrix) """ # Check for delimiter attributes on parent mfenced parent = node.getparent() if parent is not None and _local(parent.tag) == "mfenced": open_delim = parent.get("open", "(") close_delim = parent.get("close", ")") # Match delimiters to matrix types if open_delim == "(" and close_delim == ")": return "pmatrix" elif open_delim == "[" and close_delim == "]": return "bmatrix" elif open_delim == "|" and close_delim == "|": return "vmatrix" elif open_delim == "||" and close_delim == "||": return "Vmatrix" # Default based on frame attribute if frame == "solid": return "matrix" else: return "array" def _get_column_spec(self, columnalign: str, col_count: int) -> str: """ Generate column specification for array environment. Args: columnalign: Column alignment attribute col_count: Number of columns Returns: Column specification string (e.g., "ccc" for 3 centered columns) """ # Parse columnalign attribute # Can be: "left", "center", "right", or space-separated list alignments = columnalign.split() if len(alignments) == 1: # Same alignment for all columns align_char = self._align_to_char(alignments[0]) return align_char * col_count else: # Different alignments per column # Repeat last alignment if not enough specified spec = [] for i in range(col_count): if i < len(alignments): spec.append(self._align_to_char(alignments[i])) else: # Repeat last alignment spec.append(self._align_to_char(alignments[-1])) return ''.join(spec) def _align_to_char(self, align: str) -> str: """ Convert alignment name to LaTeX column spec character. Args: align: Alignment name (left, center, right) Returns: Column spec character (l, c, r) """ align_map = { 'left': 'l', 'center': 'c', 'right': 'r', } return align_map.get(align, 'c') # Default to center def _convert_rows(self, node: ET.Element) -> list: """ Convert all child elements to LaTeX row strings. Args: node: The element Returns: List of LaTeX row strings """ rows = [] for child in node: if _local(child.tag) == "mtr": row_latex = self._convert_row(child) rows.append(row_latex) return rows def _convert_row(self, node: ET.Element) -> str: """ Convert a single (table row) element. Args: node: The element Returns: LaTeX row string with cells separated by & """ # Use MtrVisitor for proper row conversion from .mtr_visitor import MtrVisitor return MtrVisitor(self.strategy).visit(node) # NOTE: _create_cases_environment is no longer used # We now use explicit \left\{ and \right. notation via mfenced wrapper # for better compatibility (cases environment is not universally supported) # def _create_cases_environment(self, node: ET.Element) -> str: # """ # Create a cases environment for piecewise functions. # # Args: # node: The element with data-cases="true" # # Returns: # LaTeX cases environment # """ # from .mtd_visitor import MtdVisitor # # rows = [] # for child in node: # if _local(child.tag) == "mtr": # # For cases, we only use the first cell of each row # if len(child) > 0: # first_cell = child[0] # if _local(first_cell.tag) == "mtd": # cell_content = MtdVisitor(self.strategy).visit(first_cell) # rows.append(cell_content) # # if not rows: # return "\\begin{cases}\\end{cases}" # # # Join rows with \\ and add space after each line break (except last) # # This matches the original converter's output format # cases_content = '\\\\ '.join(rows) # return f"\\begin{{cases}}{cases_content}\\end{{cases}}" def _create_binomial(self, node: ET.Element) -> str: """ Create binomial coefficient notation. Args: node: The element with data-binomial="true" Returns: LaTeX array for binomial coefficient """ from .mtd_visitor import MtdVisitor rows = [child for child in node if _local(child.tag) == "mtr"] if len(rows) == 2: # Get numerator from first row num_cell = rows[0][0] if len(rows[0]) > 0 else None num_content = MtdVisitor(self.strategy).visit(num_cell) if num_cell is not None else "" # Get denominator from second row den_cell = rows[1][0] if len(rows[1]) > 0 else None den_content = MtdVisitor(self.strategy).visit(den_cell) if den_cell is not None else "" return f"\\begin{{array}}{num_content}\\\\{den_content}\\end{{array}}" # Fallback to regular array return self.visit(node) def _is_chemical_reaction_condition(self, mtable_node: ET.Element) -> bool: """ Check if an mtable represents chemical reaction conditions. Pattern: 2-row mtable with: - Row 1: Contains double munder pattern (nested munder with underscore operators) - Row 2: Empty or whitespace only This is how Word/OMML encodes reaction arrows like: A + B --[condition]--> C + D Args: mtable_node: The element to check Returns: True if this is a chemical reaction condition table """ from ..utils import text_content as _text_content rows = [child for child in mtable_node if _local(child.tag) == "mtr"] # Must be exactly 2 rows (condition text + empty row) if len(rows) != 2: return False # First row should have content, second row should be empty first_row = rows[0] second_row = rows[1] # Check first row has exactly one cell first_cells = [child for child in first_row if _local(child.tag) == "mtd"] if len(first_cells) != 1: return False # CRITICAL: Check for malformed "double munder" pattern # Pattern: mtd → munder → munder → content first_cell = first_cells[0] if self._has_double_munder_pattern(first_cell): return True # Alternative: Check if content looks like a reaction condition # (For cases where double munder isn't present but it's clearly a condition) first_cell_text = _text_content(first_cell).strip() if not first_cell_text: return False # Check second row is empty or has only whitespace second_cells = [child for child in second_row if _local(child.tag) == "mtd"] if len(second_cells) != 1: return False second_cell_text = _text_content(second_cells[0]).strip() # Allow nbsp (U+00A0) and other whitespace if second_cell_text and second_cell_text != "\xa0" and second_cell_text != " ": return False # Check for typical reaction conditions if self._contains_chinese_text(first_cell_text): return True # Check for standalone chemical condition symbols like Δ (delta/heat) if first_cell_text in ["Δ", "δ", "\\Delta"]: return True return False def _has_double_munder_pattern(self, cell_node: ET.Element) -> bool: """ Detect nested munder elements with underscore operators. Pattern: mtd → [mrow →] munder → munder → content Both munders have underscore (_) as the operator, creating double underline. Args: cell_node: The cell element Returns: True if double munder pattern detected """ from ..utils import text_content as _text_content # Check if cell contains munder as direct child munder_children = [child for child in cell_node if _local(child.tag) == "munder"] # If no direct munder, check inside mrow wrapper if len(munder_children) == 0: mrow_children = [child for child in cell_node if _local(child.tag) == "mrow"] if len(mrow_children) == 1: # Look for munder inside the mrow munder_children = [child for child in mrow_children[0] if _local(child.tag) == "munder"] if len(munder_children) != 1: return False outer_munder = munder_children[0] # Check if outer munder has underaccent or accentunder attribute if not (outer_munder.get("accentunder") or outer_munder.get("underaccent")): return False # Outer munder should have 2 children: content (nested munder) and operator (_) if len(outer_munder) < 2: return False # Check if the operator is underscore outer_operator = outer_munder[1] if _local(outer_operator.tag) == "mo": outer_op_text = _text_content(outer_operator).strip() if outer_op_text != "_": return False else: return False # Check if first child is another munder (nested) inner_content = outer_munder[0] inner_munder = None if _local(inner_content.tag) == "munder": inner_munder = inner_content elif _local(inner_content.tag) == "mrow": # Inner munder might be inside mrow for child in inner_content: if _local(child.tag) == "munder": inner_munder = child break if inner_munder is None: return False # Check if inner munder also has underaccent or accentunder attribute if not (inner_munder.get("accentunder") or inner_munder.get("underaccent")): return False # Inner munder should also have underscore operator if len(inner_munder) < 2: return False inner_operator = inner_munder[1] if _local(inner_operator.tag) == "mo": inner_op_text = _text_content(inner_operator).strip() if inner_op_text != "_": return False else: return False # If we got here, we have the double munder pattern! return True def _extract_reaction_condition_text(self, mtable_node: ET.Element) -> str: """ Extract the reaction condition text from the mtable. Args: mtable_node: The element Returns: Condition text (e.g., "电解", "\\Delta", etc.) """ from ..utils import text_content as _text_content rows = [child for child in mtable_node if _local(child.tag) == "mtr"] if len(rows) < 1: return "" first_row = rows[0] cells = [child for child in first_row if _local(child.tag) == "mtd"] if len(cells) < 1: return "" first_cell = cells[0] # CRITICAL: Handle malformed double munder pattern if self._has_double_munder_pattern(first_cell): # Extract text from inside the nested munder structure text = self._extract_text_from_double_munder(first_cell) if text: # Convert common symbols if text == "Δ" or text == "\\Delta": return "\\Delta" # For Chinese text or other content, wrap in \mathrm{} if self._contains_chinese_text(text): return f"\\mathrm{{{text}}}" return text # If extraction failed or content is empty, return empty string return "" # Fallback: Convert the cell normally from .mtd_visitor import MtdVisitor content = MtdVisitor(self.strategy).visit(first_cell).strip() # Clean up extra wrappers import re # Remove \text{} wrappers content = re.sub(r'\\text\{([^}]*)\}', r'\1', content) # Preserve \mathrm{} wrappers return content def _extract_reaction_condition_lower_text(self, mtable_node: ET.Element) -> str: """ Extract lower (below-arrow) reaction condition text from second row. Args: mtable_node: The element Returns: Lower condition text for xlongequal bracket (empty if missing) """ from ..utils import text_content as _text_content from .mtd_visitor import MtdVisitor import re rows = [child for child in mtable_node if _local(child.tag) == "mtr"] if len(rows) < 2: return "" second_row = rows[1] cells = [child for child in second_row if _local(child.tag) == "mtd"] if len(cells) < 1: return "" second_cell = cells[0] raw_text = _text_content(second_cell).strip() # Treat empty/whitespace-only second row as no lower condition if not raw_text or raw_text in ("\xa0", " "): return "" # Normalize common symbol cases if raw_text in ("Δ", "\\Delta"): return "\\Delta" # Prefer direct text extraction for Chinese to avoid spacing artifacts if self._contains_chinese_text(raw_text): compact_text = re.sub(r'(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])', '', raw_text) return f"\\mathrm{{{compact_text}}}" # Fallback to full conversion for non-textual/math-rich content content = MtdVisitor(self.strategy).visit(second_cell).strip() content = re.sub(r'\\text\{([^}]*)\}', r'\1', content) return content def _extract_text_from_double_munder(self, cell_node: ET.Element) -> str: """ Extract actual text content from double munder pattern. Structure: mtd → [mrow →] munder → munder → actual_content Args: cell_node: The cell element Returns: Extracted text content """ from ..utils import text_content as _text_content # Navigate to outer munder outer_munder = None for child in cell_node: if _local(child.tag) == "munder": outer_munder = child break elif _local(child.tag) == "mrow": # Check inside mrow for grandchild in child: if _local(grandchild.tag) == "munder": outer_munder = grandchild break if outer_munder is None: return "" # Get first child of outer munder (should be inner munder or mrow containing it) if len(outer_munder) < 1: return "" inner_content = outer_munder[0] # Find inner munder inner_munder = None if _local(inner_content.tag) == "munder": inner_munder = inner_content elif _local(inner_content.tag) == "mrow": for child in inner_content: if _local(child.tag) == "munder": inner_munder = child break if inner_munder is None: return "" # Get first child of inner munder (the actual content) if len(inner_munder) < 1: return "" actual_content = inner_munder[0] simple_label = self._extract_simple_chemical_label(actual_content) if simple_label: return simple_label # Extract content while preserving MathML structure (e.g., subscripts) # instead of flattening to plain text. from .mrow_visitor import MrowVisitor try: rendered = MrowVisitor(self.strategy)._convert_child(actual_content) return rendered.strip() except Exception: # Fallback for unexpected structures text = _text_content(actual_content).strip() return text def _extract_simple_chemical_label(self, node: ET.Element) -> str: """ Extract simple chemical labels like FeCl₃ as \mathrm{F e C l}_{3}. This is used for reaction-condition annotations in malformed double-munder structures where preserving chemistry-style spacing is desirable. """ if _local(node.tag) != "mrow": return "" children = list(node) if not children: return "" roman_parts: list[str] = [] trailing_subscript = "" for index, child in enumerate(children): tag = _local(child.tag) if tag in {"mi", "mn"}: token = self._extract_single_normal_variant_token(child) if token: roman_parts.append(token) else: return "" continue if tag == "msub": if index != len(children) - 1 or len(child) < 2: return "" base_text = self._extract_single_normal_variant_token(child[0]) sub_text = self._extract_single_normal_variant_token(child[1]) if not base_text or not sub_text: return "" roman_parts.append(base_text) trailing_subscript = sub_text continue return "" if not roman_parts: return "" roman_text = " ".join(roman_parts) if trailing_subscript: return f"\\mathrm{{{roman_text}}}_{{{trailing_subscript}}}" return f"\\mathrm{{{roman_text}}}" def _extract_single_normal_variant_token(self, node: ET.Element) -> str: """ Extract a single token only when it explicitly carries mathvariant="normal". Returns empty string if the node is not a simple single-token wrapper. """ from ..utils import text_content as _text_content tag = _local(node.tag) if tag in {"mi", "mn"}: if node.get("mathvariant") != "normal": return "" return _text_content(node).strip() if tag == "mrow" and len(node) == 1: return self._extract_single_normal_variant_token(node[0]) return "" def _contains_chinese_text(self, text: str) -> bool: """ Check if text contains Chinese characters. Args: text: Text to check Returns: True if text contains Chinese characters """ for char in text: if '\u4e00' <= char <= '\u9fff': # CJK Unified Ideographs range return True return False def _is_large_operator_stack(self, mtable_node: ET.Element) -> bool: """ Check if mtable represents a vertical stack with large operator. Pattern: 3-row mtable with: - Row 1: Upper limit (e.g., "n") - Row 2: Large operator (e.g., "∑") - Row 3: Lower limit (e.g., "i=1") This is how Word/OMML encodes operators with limits when NOT using munderover. Args: mtable_node: The element to check Returns: True if this is a large operator stack """ from ..utils import text_content as _text_content rows = [child for child in mtable_node if _local(child.tag) == "mtr"] # Must be exactly 3 rows (upper limit, operator, lower limit) # or 2 rows (operator, lower limit) for simple cases if len(rows) < 2 or len(rows) > 3: return False # Each row should have exactly 1 cell for row in rows: cells = [child for child in row if _local(child.tag) == "mtd"] if len(cells) != 1: return False # Find which row contains the large operator operator_row_idx = -1 for idx, row in enumerate(rows): cell = [child for child in row if _local(child.tag) == "mtd"][0] # Get first child of cell (might be wrapped in mstyle/mrow) if self._cell_contains_large_operator(cell): operator_row_idx = idx break if operator_row_idx == -1: return False # For 3-row stacks: operator should be in middle row (idx 1) # For 2-row stacks: operator should be in first row (idx 0) - integral/sum with lower limit only if len(rows) == 3 and operator_row_idx != 1: return False if len(rows) == 2 and operator_row_idx != 0 and operator_row_idx != 1: return False return True def _cell_contains_large_operator(self, cell_node: ET.Element) -> bool: """ Check if a cell contains a large operator. Recursively searches through wrapper elements to find operator. Args: cell_node: The cell element Returns: True if cell contains a large operator """ from ..utils import text_content as _text_content # Large operators 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 ] # Check cell content directly cell_text = _text_content(cell_node).strip() if cell_text in large_ops: return True # Recursively check children for child in cell_node: tag = _local(child.tag) if tag in ('mi', 'mo'): child_text = _text_content(child).strip() if child_text in large_ops: return True elif tag in ('mstyle', 'mrow', 'mpadded', 'menclose'): # Recursively check wrapper elements if self._cell_contains_large_operator(child): return True return False def _convert_large_operator_stack(self, mtable_node: ET.Element) -> str: """ Convert vertical stack mtable to proper large operator notation. Args: mtable_node: The element Returns: LaTeX string with proper subscript/superscript notation """ from .mtd_visitor import MtdVisitor rows = [child for child in mtable_node if _local(child.tag) == "mtr"] # Get cells cells = [] for row in rows: cell = [child for child in row if _local(child.tag) == "mtd"][0] cells.append(cell) # Find operator cell operator_idx = -1 for idx, cell in enumerate(cells): if self._cell_contains_large_operator(cell): operator_idx = idx break if operator_idx == -1: # Fallback: shouldn't happen return self.visit(mtable_node) # Convert operator operator_latex = MtdVisitor(self.strategy).visit(cells[operator_idx]).strip() if len(cells) == 3: # 3-row stack: upper / operator / lower upper_latex = MtdVisitor(self.strategy).visit(cells[0]).strip() lower_latex = MtdVisitor(self.strategy).visit(cells[2]).strip() return f"{operator_latex}\\limits^{{{upper_latex}}}_{{{lower_latex}}}" elif len(cells) == 2: if operator_idx == 0: # 2-row stack: operator / lower lower_latex = MtdVisitor(self.strategy).visit(cells[1]).strip() return f"{operator_latex}\\limits_{{{lower_latex}}}" else: # 2-row stack: upper / operator upper_latex = MtdVisitor(self.strategy).visit(cells[0]).strip() return f"{operator_latex}\\limits^{{{upper_latex}}}" else: # Fallback return self.visit(mtable_node) def can_handle(self, node: ET.Element) -> bool: """Check if this visitor can handle the given node.""" return _local(node.tag) == 'mtable' @property def element_name(self) -> str: """Get the element name this visitor handles.""" return 'mtable' def __repr__(self) -> str: """String representation for debugging.""" return f"MtableVisitor(strategy={self.strategy.name})"