r""" Visitor for (under+over script) MathML elements. The element represents content with both something above and below it: - Large operators with limits: summation, integral, product with both bounds - \sum_{i=1}^{n}, \int_{a}^{b}, \prod_{i=1}^{n} - Union/intersection with limits - General underover scripts This visitor is CRITICAL because it handles \limits properly, unlike msubsup. """ 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 MunderoverVisitor(Visitor): r""" Handles (under+over script) MathML elements. Converts under+over notation to appropriate LaTeX commands, especially for large operators with both upper and lower limits. Examples: Summation with limits: ∑ i=1 n → \sum_{i=1}^{n} Integral with bounds: ∫ a b → \int_{a}^{b} Product with limits: ∏ i=1 n → \prod_{i=1}^{n} Union with limits: ⋃ i n → \bigcup_{i}^{n} General underover: X a b → \overset{b}{\underset{a}{X}} """ def __init__(self, strategy: Strategy): """Initialize the Munderover 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 under+over notation Raises: ValueError: If node is not an element ValueError: If munderover has fewer than 3 children """ if not self.can_handle(node): raise ValueError(f"MunderoverVisitor cannot handle element '{node.tag}'") children = list(node) # munderover must have exactly 3 children: base, underscript, overscript if len(children) < 3: raise ValueError( f"munderover element must have 3 children (base, under, over), " f"got {len(children)}" ) base = self._convert_child(children[0]) under = self._convert_child(children[1]) over = self._convert_child(children[2]) # Check if base is a large operator (sum, integral, product, etc.) if self._is_large_operator(children[0]): # Use subscript/superscript notation for operator limits with \limits # \sum\limits_{i=1}^{n} return f"{base}\\limits_{self._brace(under)}^{self._brace(over)}" # Check if this should use \limits explicitly # (for operators that don't default to limit placement) if self._should_use_limits(children[0]): return f"{base}\\limits_{self._brace(under)}^{self._brace(over)}" # Check for extensible arrow patterns (arrows/equals with text above and below) # This is commonly used for chemical reactions and annotated arrows # First, unwrap mrow if the base is wrapped (Word often does this) base_node = children[0] if _local(base_node.tag) == 'mrow': # Check if mrow contains a single operator/arrow mrow_children = list(base_node) if len(mrow_children) == 1 and _local(mrow_children[0].tag) in ('mo', 'mi', 'mtext'): base_node = mrow_children[0] xarrow_cmd = self._get_xarrow_command(base_node) if xarrow_cmd: # Check if original content had \mathrm{} (indicates mathvariant="normal") # We need to preserve this semantic after cleaning over_had_mathrm = '\\mathrm{' in over under_had_mathrm = '\\mathrm{' in under # Clean up the text (remove \phantom{}, \mathrm{}, \text{}, extra spaces) over_clean = self._clean_arrow_text(over) under_clean = self._clean_arrow_text(under) # Re-wrap text in \mathrm{} if: # 1. Original content had \mathrm{} (from mathvariant="normal") # 2. Or in chemistry mode for non-command text # This preserves the mathvariant="normal" semantic from the input MathML # Skip if it's already a LaTeX command (starts with \) or empty # Also skip pure numbers (like "1", "222", "2121") - they should remain plain def should_wrap_mathrm(text: str, had_mathrm: bool) -> bool: if not text or not text.strip(): return False if text.startswith("\\"): return False # Already a LaTeX command if text.replace('.', '', 1).isdigit(): return False # Pure number # Wrap if original had \mathrm{} OR in chemistry mode if had_mathrm: return True if hasattr(self.strategy, 'name') and self.strategy.name == "chemistry": return True return False if should_wrap_mathrm(over_clean, over_had_mathrm): over_clean = f"\\mathrm{{{over_clean}}}" if should_wrap_mathrm(under_clean, under_had_mathrm): under_clean = f"\\mathrm{{{under_clean}}}" # Use \quad for empty positions (from elements) # This creates proper spacing in chemical equations if not under_clean or under_clean.strip() == "": under_clean = "\\quad" if not over_clean or over_clean.strip() == "": over_clean = "\\quad" return f"\\{xarrow_cmd}[{under_clean}]{{{over_clean}}}" # Default: general under+over using nested \overset and \underset # \overset{b}{\underset{a}{X}} return f"\\overset{self._brace(over)}{{\\underset{self._brace(under)}{self._brace(base)}}}" 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 munderover, using text fallback", file=sys.stderr) return _text_content(child) def _is_large_operator(self, base_node: ET.Element) -> bool: """ Check if base is a large operator that uses limits by default. Recursively searches through wrapper elements like , , to find the actual operator, since Word often wraps large operators in ∑. """ if base_node is None: return False tag = _local(base_node.tag) # 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 ] # Check direct or elements if tag in ('mo', 'mi'): base_text = _text_content(base_node).strip() return base_text in large_ops # Recursively search through wrapper elements (mstyle, mrow, mpadded, menclose) # This handles Word's pattern: ∑... if tag in ('mstyle', 'mrow', 'mpadded', 'menclose'): for child in base_node: if self._is_large_operator(child): return True return False def _should_use_limits(self, base_node: ET.Element) -> bool: """Check if operator should explicitly use \\limits.""" if _local(base_node.tag) != 'mo': return False base_text = _text_content(base_node).strip() # Operators that need explicit \limits to display limits above/below limits_ops = [ "lim", "\\lim", # Limit "sup", "\\sup", # Supremum "inf", "\\inf", # Infimum "max", "\\max", # Maximum "min", "\\min", # Minimum "limsup", "\\limsup", # Limit superior "liminf", "\\liminf", # Limit inferior "det", "\\det", # Determinant "gcd", "\\gcd", # Greatest common divisor ] return base_text in limits_ops def _brace(self, content: str) -> str: """Wrap content in braces.""" return f"{{{content}}}" def _get_xarrow_command(self, base_node: ET.Element) -> str: """ Get extensible arrow command for the base element. Returns command name (without backslash) if base is an arrow/equals, otherwise returns empty string. Supported arrows: - = → xlongequal - → → xrightarrow - ← → xleftarrow - ↔ → xleftrightarrow - ⇌ → xrightleftharpoons """ # Accept (operator), (identifier), and (text) elements for arrows # Word's XSLT sometimes produces for arrows instead of if _local(base_node.tag) not in ('mo', 'mi', 'mtext'): return "" base_text = _text_content(base_node).strip() # Map arrow symbols to xarrow commands arrow_map = { "=": "xlongequal", "→": "xrightarrow", "\\rightarrow": "xrightarrow", "←": "xleftarrow", "\\leftarrow": "xleftarrow", "↔": "xleftrightarrow", "\\leftrightarrow": "xleftrightarrow", "⟷": "xleftrightarrow", # U+27F7 long left-right arrow "\\longleftrightarrow": "xleftrightarrow", "⇌": "xrightleftharpoons", "\\rightleftharpoons": "xrightleftharpoons", } return arrow_map.get(base_text, "") def _clean_arrow_text(self, text: str) -> str: """ Clean up text for use in arrow labels. Removes: - \\phantom{} wrappers (Word uses these for arrow labels) - \\mathrm{} wrappers (with proper nested brace handling) - \\text{} wrappers - Extra spaces between characters """ import re # Helper function to remove LaTeX commands with balanced braces def remove_command(text: str, command: str) -> str: """Remove LaTeX command with proper brace balancing. Args: text: Input text with LaTeX commands command: Command to remove (e.g., "\\mathrm" or "mathrm") """ # Normalize command: ensure it starts with backslash if not command.startswith('\\'): command = '\\' + command while command in text: # Find the command idx = text.find(command) if idx == -1: break # Find the opening brace (should be right after command) brace_start = idx + len(command) if brace_start >= len(text) or text[brace_start] != '{': break # Malformed - no opening brace # Match balanced braces brace_count = 1 pos = brace_start + 1 while pos < len(text) and brace_count > 0: if text[pos] == '{' and (pos == 0 or text[pos-1] != '\\'): brace_count += 1 elif text[pos] == '}' and (pos == 0 or text[pos-1] != '\\'): brace_count -= 1 pos += 1 if brace_count == 0: # Successfully matched braces content = text[brace_start+1:pos-1] text = text[:idx] + content + text[pos:] else: break # Malformed braces return text # Remove \phantom{} wrappers (Word's equation editor uses these for arrow labels) text = remove_command(text, '\\phantom') # Remove \mathrm{} wrappers (with nested brace handling) text = remove_command(text, '\\mathrm') # Remove \text{} wrappers (but keep the content) # For Chinese text, we want to preserve it as-is text = remove_command(text, '\\text') # Remove extra spaces between individual characters # "高 压" → "高压", but preserve intentional spaces in phrases text = re.sub(r'(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])', '', text) # For non-Chinese text with spaces between each char, remove spaces # "a b c" → "abc", but "word1 word2" stays as-is if len(text.replace(' ', '')) > 0: # Check if it's all single chars separated by spaces parts = text.split() if all(len(p) == 1 for p in parts): text = ''.join(parts) return text.strip() def can_handle(self, node: ET.Element) -> bool: """Check if this visitor can handle the given node.""" return _local(node.tag) == 'munderover' @property def element_name(self) -> str: """Get the element name this visitor handles.""" return 'munderover' def __repr__(self) -> str: """String representation for debugging.""" return f"MunderoverVisitor(strategy={self.strategy.name})"