r""" Visitor for (underscript/underline) MathML elements. The element represents content with something below it: - Underline: line under text - Operator limits: summation/integral with lower bound only - Underbrace: brace under expression - General underscript: any content under base This implementation handles the most common cases with TODOs for advanced features. """ 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 MunderVisitor(Visitor): r""" Handles (underscript/underline) MathML elements. Converts underscript and underline notation to appropriate LaTeX commands. Examples: Underline: x_ → \underline{x} Summation with lower limit only: ∑i=1 → \sum_{i=1} Integral with lower bound: ∫a → \int_{a} Product with lower limit: ∏i → \prod_{i} Underbrace: x+y⏟ → \underbrace{x+y} General underscript: xn → \underset{n}{x} """ def __init__(self, strategy: Strategy): """Initialize the Munder 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 underscript/underline notation Raises: ValueError: If node is not an element ValueError: If munder has fewer than 2 children """ if not self.can_handle(node): raise ValueError(f"MunderVisitor cannot handle element '{node.tag}'") children = list(node) # munder must have exactly 2 children: base and underscript if len(children) < 2: raise ValueError( f"munder element must have 2 children (base, underscript), " f"got {len(children)}" ) # IMPORTANT: Check raw text content BEFORE converting children # to properly detect underline characters (e.g., "―" before it becomes "\textemdash") under_raw_text = _text_content(children[1]).strip() # Check for underline notation (underbar character) using RAW text if self._is_underline_char(under_raw_text): base = self._convert_child(children[0]) # Handle empty base (fill-in blank pattern): use \quad for visible spacing if not base or not base.strip(): base = "\\qquad" return f"\\underline{self._brace(base)}" # Check for underbrace notation using RAW text (simple case: no label) if self._is_underbrace_char(under_raw_text): base = self._convert_child(children[0]) return f"\\underbrace{self._brace(base)}" # Check for underbrace with label: nested munder with brace character # Pattern: munder(munder(base, ⏟), label) → \underbrace{base}_{label} # OR: munder(mrow(munder(base, ⏟)), label) with mrow wrapper (Word often wraps inner munder) # This is how Word encodes underbrace with labels inner_munder = None if len(children) >= 2: # Check if first child is munder directly if _local(children[0].tag) == 'munder': inner_munder = children[0] # Check if first child is mrow containing munder elif _local(children[0].tag) == 'mrow': mrow_children = list(children[0]) # Look for munder inside mrow (may be the only child or one of several) for child in mrow_children: if _local(child.tag) == 'munder': inner_munder = child break # Process the inner munder if found if inner_munder is not None: inner_children = list(inner_munder) if len(inner_children) >= 2: # Check if inner munder's second child is the underbrace character inner_under_text = _text_content(inner_children[1]).strip() if inner_under_text == "⏟": # U+23DF Underbrace character # Extract base from inner munder (first child) inner_base = self._convert_child(inner_children[0]) # Extract label from outer munder (second child) label = self._convert_child(children[1]) # Return proper underbrace with subscript label return f"\\underbrace{self._brace(inner_base)}_{self._brace(label)}" # Check for ALTERNATIVE underbrace pattern (Word sometimes uses this) # Pattern: munder(mover(base, ⏞), label) → \underbrace{base}_{label} # OR: munder(mrow(mover(base, ⏞)), label) with mrow wrapper # Word uses the overbrace character ⏞ but places it in OVER position to create underbrace if len(children) >= 2: # Check if first child is mover (directly or inside mrow) inner_mover = None if _local(children[0].tag) == 'mover': inner_mover = children[0] elif _local(children[0].tag) == 'mrow': # Check inside mrow wrapper for child in children[0]: if _local(child.tag) == 'mover': inner_mover = child break if inner_mover is not None: inner_children = list(inner_mover) if len(inner_children) >= 2: # Check if inner mover's second child is the overbrace character inner_over_text = _text_content(inner_children[1]).strip() if inner_over_text == "⏞": # U+23DE Overbrace char in over position = underbrace # Extract base from inner mover (first child) inner_base = self._convert_child(inner_children[0]) # Extract label from outer munder (second child) label = self._convert_child(children[1]) # Return proper underbrace with subscript label return f"\\underbrace{self._brace(inner_base)}_{self._brace(label)}" # Check if base is a large operator (sum, integral, product, etc.) if self._is_large_operator(children[0]): # Use subscript for operator limits with \limits base = self._convert_child(children[0]) under = self._convert_child(children[1]) return f"{base}\\limits_{self._brace(under)}" # Check for arrow UNDER content pattern (arrow in under position) # Pattern: → # This should be converted to \underrightarrow{content} under_to_check = children[1] if _local(under_to_check.tag) == 'mrow': # Unwrap mrow if it contains a single element mrow_children = list(under_to_check) if len(mrow_children) == 1: under_to_check = mrow_children[0] if _local(under_to_check.tag) in ['mo', 'mi']: under_text = _text_content(under_to_check).strip() # Map of arrows that can appear under content # NOTE: Only single arrows (→, ←, ↔) are used for under-arrow notation # Double arrows (⇒, ⇐, ⇔) should use underset with \Longrightarrow, not underrightarrow arrow_commands = { "→": "underrightarrow", "←": "underleftarrow", "↔": "underleftrightarrow", } if under_text in arrow_commands: # Check if base is suitable for arrow notation (numbers, variables, etc.) if _local(children[0].tag) in ['mi', 'mn', 'mrow', 'mfrac', 'msup', 'msub', 'msubsup']: content = self._convert_child(children[0]) # Remove mathrm wrapper for variables content_clean = self._strip_mathrm(content) arrow_cmd = arrow_commands[under_text] return f"\\{arrow_cmd}{self._brace(content_clean)}" # Check for INVERTED arrow pattern from XSLT transformer # Pattern: →content # This should be converted to \underrightarrow{content} not \underset # CRITICAL FIX: OMML→MathML conversion often puts arrows in tags instead of # Must check BOTH AND tags for arrow symbols # Also need to unwrap mrow wrappers: → base_to_check = children[0] if _local(base_to_check.tag) == 'mrow': # Unwrap mrow if it contains a single element mrow_children = list(base_to_check) if len(mrow_children) == 1: base_to_check = mrow_children[0] if _local(base_to_check.tag) in ['mo', 'mi']: base_text = _text_content(base_to_check).strip() # Map of arrows that can appear in inverted pattern # NOTE: Only single arrows (→, ←, ↔) are used for under-arrow notation # Double arrows (⇒, ⇐, ⇔) should use underset with \Longrightarrow, not underrightarrow arrow_commands = { "→": "underrightarrow", "←": "underleftarrow", "↔": "underleftrightarrow", } if base_text in arrow_commands: # The content is in the under position content = self._convert_child(children[1]) # Remove mathrm wrapper for variables content_clean = self._strip_mathrm(content) arrow_cmd = arrow_commands[base_text] return f"\\{arrow_cmd}{self._brace(content_clean)}" # Check for nested extensible arrow pattern (reversed): arrow/equals<...>...<...> # OR: arrow/equals<...>...<...> (Word often adds mrow wrapper) # This is Word's way of creating arrows with text above and below (reversed nesting) mover_node = None if _local(children[0].tag) == 'mover': # Direct mover child mover_node = children[0] elif _local(children[0].tag) == 'mrow': # Check if mrow contains mover as only significant child mrow_children = list(children[0]) if len(mrow_children) == 1 and _local(mrow_children[0].tag) == 'mover': mover_node = mrow_children[0] else: # Check if any child is mover (Word sometimes adds multiple mrows) for child in mrow_children: if _local(child.tag) == 'mover': mover_node = child break if mover_node is not None: mover_children = list(mover_node) if len(mover_children) >= 2: # The arrow/equals might be in an mrow wrapper base_node = mover_children[0] if _local(base_node.tag) == 'mrow': # Unwrap mrow to find the actual operator mrow_children = list(base_node) if mrow_children and _local(mrow_children[0].tag) in ('mo', 'mi', 'mtext'): base_node = mrow_children[0] # Check if base is an arrow or equals xarrow_cmd = self._get_xarrow_command(base_node) if xarrow_cmd: # Extract text from over and under (may be wrapped in or ) over = self._convert_child(mover_children[1]) under = self._convert_child(children[1]) # Clean up the text (remove \phantom{}, \mathrm{}, etc.) over_clean = self._clean_arrow_text(over) under_clean = self._clean_arrow_text(under) # Re-wrap Chinese text and non-command text in \mathrm{} for chemistry mode # (since _clean_arrow_text removes it) if hasattr(self.strategy, 'name') and self.strategy.name == "chemistry": # Check if text needs \mathrm{} wrapping # Skip if it's already a LaTeX command (starts with \) or empty # CRITICAL: Also skip pure numbers (like "1", "222", "2121") # Numbers should remain as plain numbers, not wrapped in \mathrm{} if over_clean and not over_clean.startswith("\\") and over_clean.strip(): # Check if it's a pure number (digits only, possibly with decimal point) if not over_clean.replace('.', '', 1).isdigit(): # Not a pure number - wrap it in \mathrm{} over_clean = f"\\mathrm{{{over_clean}}}" if under_clean and not under_clean.startswith("\\") and under_clean.strip(): # Check if it's a pure number (digits only, possibly with decimal point) if not under_clean.replace('.', '', 1).isdigit(): # Not a pure number - wrap it in \mathrm{} under_clean = f"\\mathrm{{{under_clean}}}" # Use \quad for empty positions (from elements) 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}}}" # TODO: Additional underscript types # - Underbar variants # - Custom decorations # Default: general underscript using \underset base = self._convert_child(children[0]) under = self._convert_child(children[1]) return f"\\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 munder, using text fallback", file=sys.stderr) return _text_content(child) def _is_underline_char(self, latex_str: str) -> bool: """Check if string represents an underline character.""" # Common underline/bar characters used in mathematical notation underline_chars = [ "―", # Em dash (U+2015) - commonly used for underline in Word "_", # Underscore "‾", # Overline character (also used for underline) "¯", # Macron "⎽", # Horizontal line extension "﹍", # Wavy low line "﹎", # Centreline low line "﹏", # Wavy overline "-", # Regular hyphen-minus "−", # Minus sign (U+2212) "‗", # Double low line "⎺", # Horizontal line "⎻", # Another horizontal line ] return latex_str.strip() in underline_chars def _is_underbrace_char(self, latex_str: str) -> bool: """Check if string represents an underbrace character.""" underbrace_chars = ["⏟", "︸", "\\underbrace"] return latex_str.strip() in underbrace_chars def _is_large_operator(self, base_node: ET.Element) -> bool: """ Check if base is a large operator (sum, integral, product, etc.). 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) # Define large operators list once 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 "lim", "\\lim", # Limit (often in from Word) "sup", "\\sup", # Supremum "inf", "\\inf", # Infimum "max", "\\max", # Maximum "min", "\\min", # Minimum ] # 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 _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 - \\text{} wrappers - Extra spaces between characters """ import re # Remove \phantom{} wrappers (Word's equation editor uses these for arrow labels) text = re.sub(r'\\phantom\{([^}]*)\}', r'\1', text) # Remove \mathrm{} wrappers text = re.sub(r'\\mathrm\{([^}]*)\}', r'\1', text) # Remove \text{} wrappers (but keep the content) # For Chinese text, we want to preserve it as-is text = re.sub(r'\\text\{([^}]*)\}', r'\1', 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 _strip_mathrm(self, latex_str: str) -> str: r"""Remove \mathrm{...} wrapper if present.""" import re # Remove \mathrm{} wrappers latex_str = re.sub(r'\\mathrm\{([a-zA-Z0-9]+)\}', r'\1', latex_str) # Remove spaces in vector notation (a b c -> abc) if re.match(r'^[a-zA-Z0-9](\s+[a-zA-Z0-9])+$', latex_str): latex_str = re.sub(r'\s+', '', latex_str) return latex_str def _brace(self, content: str) -> str: """Wrap content in braces.""" return f"{{{content}}}" def can_handle(self, node: ET.Element) -> bool: """Check if this visitor can handle the given node.""" return _local(node.tag) == 'munder' @property def element_name(self) -> str: """Get the element name this visitor handles.""" return 'munder' def __repr__(self) -> str: """String representation for debugging.""" return f"MunderVisitor(strategy={self.strategy.name})"