r""" Visitor for (overscript/accent) MathML elements. The element represents content with something above it: - Accents: hat, tilde, bar, dot - Vector notation: arrow over variables - Chemical reactions: text over equals/arrow - General overscript: any content over 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 MoverVisitor(Visitor): r""" Handles (overscript/accent) MathML elements. Converts overscript and accent notation to appropriate LaTeX commands. Examples: Vector notation (arrow over variable): v→ → \overrightarrow{v} Bar/overline: x¯ → \overline{x} Tilde accent: x~ → \tilde{x} Hat/circumflex: x^ → \hat{x} Dot accent: x˙ → \dot{x} Double dot (ddot) accent: x¨ → \ddot{x} Triple dot (dddot) accent: x⃛ → \dddot{x} Overbrace with label: 3333⏞222 → \overbrace{3333}^{222} Chemical reaction (xlongequal): =heat → \xlongequal{\text{heat}} General overscript: xn → \overset{n}{x} """ def __init__(self, strategy: Strategy): """Initialize the Mover 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 overscript/accent notation Raises: ValueError: If node is not an element ValueError: If mover has fewer than 2 children """ if not self.can_handle(node): raise ValueError(f"MoverVisitor cannot handle element '{node.tag}'") children = list(node) # mover must have exactly 2 children: base and overscript if len(children) < 2: raise ValueError( f"mover element must have 2 children (base, overscript), " f"got {len(children)}" ) # IMPORTANT: Check raw text content BEFORE converting children # to properly detect bar/accent characters (e.g., "―" before it becomes "\textemdash") over_raw_text = _text_content(children[1]).strip() # Check for special data attributes (from OMML conversion) if node.get("data-not-slash") == "true": # \not command base = self._convert_child(children[0]) return f"\\not{self._brace(base)}" if node.get("data-chemical-reaction") == "true": # Chemical reaction with text over arrow/equals base = self._convert_child(children[0]) over = self._convert_child(children[1]) over_clean = self._strip_text_braces(over) return f"\\xlongequal{self._brace(over_clean)}" # Check for nested extensible arrow pattern: arrow/equals<...>...<...> # OR: arrow/equals<...>...<...> (Word often adds mrow wrapper) # This is Word's way of creating arrows with text above and below munder_node = None if _local(children[0].tag) == 'munder': # Direct munder child munder_node = children[0] elif _local(children[0].tag) == 'mrow': # Check if mrow contains munder as only significant child mrow_children = list(children[0]) if len(mrow_children) == 1 and _local(mrow_children[0].tag) == 'munder': munder_node = mrow_children[0] else: # Check if any child is munder (Word sometimes adds multiple mrows) for child in mrow_children: if _local(child.tag) == 'munder': munder_node = child break if munder_node is not None: munder_children = list(munder_node) if len(munder_children) >= 2: # The arrow/equals might be in an mrow wrapper base_node = munder_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 ) under = self._convert_child(munder_children[1]) over = self._convert_child(children[1]) # Clean up the text (remove \phantom{}, \mathrm{}, \text{}, extra spaces) under_clean = self._clean_arrow_text(under) over_clean = self._clean_arrow_text(over) # Re-wrap Chinese text and non-command text in \mathrm{} for chemistry mode # (since _clean_arrow_text removes it) # This is crucial for chemical reaction conditions like "加热", "控温", "电解" 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) # 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}}}" # Check for xlongequal pattern (equals sign with text above) if self._is_equals_base(children[0]): base = self._convert_child(children[0]) over = self._convert_child(children[1]) label = self._strip_text_braces(over) return f"\\xlongequal{self._brace(label)}" # Check for arc notation: arc/frown over multi-character content (line segments) if self._is_arc_notation(node): # Convert to arc notation with size modifiers base_text = _text_content(children[0]).strip() base = self._convert_child(children[0]) # Remove \mathrm{} wrapper - accent base should be italic base_clean = self._strip_mathrm_for_accents(base) # Use different sizes based on base length: \huge for 2 chars, \Huge for 3+ chars if len(base_text) == 2: return f"\\overset{{\\huge\\frown}}{self._brace(base_clean)}" else: # 3+ characters return f"\\overset{{\\Huge\\frown}}{self._brace(base_clean)}" # Check for vector notation (arrow over variables) # CRITICAL: Need to unwrap mrow wrappers around arrows # Pattern: → over_to_check = children[1] if _local(over_to_check.tag) == 'mrow': # Unwrap mrow if it contains a single element mrow_children = list(over_to_check) if len(mrow_children) == 1: over_to_check = mrow_children[0] arrow_cmd = self._get_vector_arrow_cmd(children[0], over_to_check) if arrow_cmd: # Remove any \mathrm{} wrapping (vector variables should be italic) base = self._convert_child(children[0]) base_clean = self._strip_mathrm(base) return f"\\{arrow_cmd}{self._brace(base_clean)}" # Check for extensible arrow pattern (arrow as base) # Pattern: → # This produces \xrightarrow[\quad]{content} for chemical reaction arrows # # IMPORTANT: When arrow is the BASE of mover (not the overscript), it should use # \xrightarrow format for extensible arrows with labels, NOT \overrightarrow. # \overrightarrow is for vector notation where arrow is OVER variables. 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] # Check if base is an arrow operator (extensible arrow for reaction notation) # When arrow is the base element with content above, always use xrightarrow format xarrow_cmd = self._get_xarrow_command(base_to_check) if xarrow_cmd: # Extensible arrow with content above over = self._convert_child(children[1]) over_clean = self._clean_arrow_text(over) # Use \quad for empty below position return f"\\{xarrow_cmd}[\\quad]{self._brace(over_clean)}" # Check for inverted vector notation (variables over arrow) # CRITICAL: Need to unwrap mrow wrappers around arrows # Pattern: → inv_arrow_cmd = self._get_inverted_vector_arrow_cmd(base_to_check, children[1]) if inv_arrow_cmd: # Variables are in over position over = self._convert_child(children[1]) over_clean = self._strip_mathrm(over) return f"\\{inv_arrow_cmd}{self._brace(over_clean)}" # Check for bar/overline notation (using RAW text) if self._is_bar_char(over_raw_text): base = self._convert_child(children[0]) return f"\\overline{self._brace(base)}" # Check for tilde accent (using RAW text) if self._is_tilde_char(over_raw_text): base = self._convert_child(children[0]) base_clean = self._strip_mathrm(base) # Use \tilde for single char, \widetilde for multi-char base_text = _text_content(children[0]).strip() if len(base_text) == 1: return f"\\tilde{self._brace(base_clean)}" else: return f"\\widetilde{self._brace(base_clean)}" # Check for combining arrows from XSLT transformer # U+20D7 = combining right arrow above → \overrightarrow OR \hat (depends on context) # U+20D6 = combining left arrow above → \overleftarrow if over_raw_text == "⃗": # U+20D7 combining right arrow above base = self._convert_child(children[0]) base_clean = self._strip_mathrm(base) # Check if this is a default accent (hat) or explicit vector arrow # XSLT adds data-default-accent="true" when chr is not specified in OMML # Default (no chr) → \hat, Explicit chr="⃗" → \overrightarrow if node.get('data-default-accent') == 'true': return f"\\hat{self._brace(base_clean)}" else: return f"\\overrightarrow{self._brace(base_clean)}" if over_raw_text == "⃖": # U+20D6 combining left arrow above base = self._convert_child(children[0]) base_clean = self._strip_mathrm(base) # ALWAYS use overleftarrow for combining left arrow return f"\\overleftarrow{self._brace(base_clean)}" # Check for hat/circumflex accent (using RAW text) if self._is_hat_char(over_raw_text): base = self._convert_child(children[0]) base_clean = self._strip_mathrm(base) # Use \hat for single char, \widehat for multi-char base_text = _text_content(children[0]).strip() if len(base_text) == 1: return f"\\hat{self._brace(base_clean)}" else: return f"\\widehat{self._brace(base_clean)}" # Check for dot accent (using RAW text) if self._is_dot_char(over_raw_text): base = self._convert_child(children[0]) base_clean = self._strip_mathrm(base) return f"\\dot{self._brace(base_clean)}" # Check for breve accent (using RAW text) if self._is_breve_char(over_raw_text): base = self._convert_child(children[0]) base_clean = self._strip_mathrm(base) return f"\\breve{self._brace(base_clean)}" # Check for harpoon accents (using RAW text) harpoon_cmd = self._get_harpoon_cmd(over_raw_text) if harpoon_cmd: base = self._convert_child(children[0]) base_clean = self._strip_mathrm(base) return f"\\overset{{\\{harpoon_cmd}}}{self._brace(base_clean)}" # Check for ddot (double dot) accent (using RAW text) if self._is_ddot_char(over_raw_text): base = self._convert_child(children[0]) base_clean = self._strip_mathrm(base) return f"\\ddot{self._brace(base_clean)}" # Check for dddot (triple dot) accent (using RAW text) if self._is_dddot_char(over_raw_text): base = self._convert_child(children[0]) base_clean = self._strip_mathrm(base) return f"\\dddot{self._brace(base_clean)}" # Check for overbrace notation: nested mover with brace character # Pattern 1: mover(mover(base, ⏞), label) → \overbrace{base}^{label} # Pattern 2: mover(mover(⏞, base), label) → \overbrace{base}^{label} [NEW - vertJc="bot" case] # OR: mover(mrow(mover(...)), label) with mrow wrapper (Word often wraps inner mover) # This is how Word encodes overbrace with labels inner_mover = None if len(children) >= 2: # Check if first child is mover directly if _local(children[0].tag) == 'mover': inner_mover = children[0] # Check if first child is mrow containing mover elif _local(children[0].tag) == 'mrow': mrow_children = list(children[0]) # Look for mover inside mrow (may be the only child or one of several) for child in mrow_children: if _local(child.tag) == 'mover': inner_mover = child break # Process the inner mover if found if inner_mover is not None: inner_children = list(inner_mover) if len(inner_children) >= 2: # Pattern 1: 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 character # Extract base from inner mover (first child) inner_base = self._convert_child(inner_children[0]) # Extract label from outer mover (second child) label = self._convert_child(children[1]) # Return proper overbrace with superscript label return f"\\overbrace{self._brace(inner_base)}^{self._brace(label)}" # Pattern 2: Check if inner mover's first child is the overbrace character # This happens when vertJc="bot" in OMML groupChr inner_base_text = _text_content(inner_children[0]).strip() if inner_base_text == "⏞": # U+23DE Overbrace character as base # Extract base from inner mover (second child - content above the brace) inner_base = self._convert_child(inner_children[1]) # Extract label from outer mover (second child) label = self._convert_child(children[1]) # Return proper overbrace with superscript label return f"\\overbrace{self._brace(inner_base)}^{self._brace(label)}" # Check for ALTERNATIVE overbrace pattern (Word sometimes uses this) # Pattern: mover(munder(base, ⏞), label) → \overbrace{base}^{label} # OR: mover(mrow(munder(base, ⏞)), label) with mrow wrapper # The brace character ⏞ is in the UNDER position but still represents overbrace if len(children) >= 2: # Check if first child is munder (directly or inside mrow) inner_munder = None if _local(children[0].tag) == 'munder': inner_munder = children[0] elif _local(children[0].tag) == 'mrow': # Check inside mrow wrapper for child in children[0]: if _local(child.tag) == 'munder': inner_munder = child break 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 overbrace character inner_under_text = _text_content(inner_children[1]).strip() if inner_under_text == "⏞": # U+23DE Overbrace character in under position # Extract base from inner munder (first child) inner_base = self._convert_child(inner_children[0]) # Extract label from outer mover (second child) label = self._convert_child(children[1]) # Return proper overbrace with superscript label return f"\\overbrace{self._brace(inner_base)}^{self._brace(label)}" # TODO: Additional accent types (rarely used in mathematical notation) # - Check (caron): ˇ character → \check{x} # - Acute: ´ character → \acute{x} # - Grave: ` character → \grave{x} # CRITICAL FIX: Check if base is a large operator (max, min, lim, etc.) # Word incorrectly produces for operators that should use subscripts # Example: maxcondition # Should become: \max_{condition}, NOT \overset{condition}{max} if self._is_large_operator(children[0]): # Use subscript for operator limits, treating the "over" content as "under" base = self._convert_child(children[0]) limit = self._convert_child(children[1]) return f"{base}\\limits_{self._brace(limit)}" # Default: general overscript using \overset base = self._convert_child(children[0]) over = self._convert_child(children[1]) return f"\\overset{self._brace(over)}{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 mover, using text fallback", file=sys.stderr) return _text_content(child) def _is_equals_base(self, base_node: ET.Element) -> bool: """Check if base is an equals sign.""" if _local(base_node.tag) == 'mo': return _text_content(base_node).strip() == '=' return False def _get_vector_arrow_cmd(self, base_node: ET.Element, over_node: ET.Element) -> str: """Get vector arrow command if pattern matches (arrow over variables/numbers).""" # Check if base is a variable (mi), number (mn), expression (mrow), or complex expression # Include fractions, superscripts, subscripts, roots, etc. as valid bases for vector arrows if _local(base_node.tag) not in ['mi', 'mn', 'mrow', 'mfrac', 'msup', 'msub', 'msubsup', 'msqrt', 'mroot']: return "" # Check if over is an arrow # CRITICAL FIX: OMML→MathML conversion often puts arrows in tags instead of # Must check BOTH AND tags for arrow symbols if _local(over_node.tag) not in ['mo', 'mi']: return "" over_text = _text_content(over_node).strip() # NOTE: Only single arrows (→, ←, ↔) are used for vector notation # Double arrows (⇒, ⇐, ⇔) should use overset with \Longrightarrow, not overrightarrow arrow_map = { "→": "overrightarrow", "\\rightarrow": "overrightarrow", "←": "overleftarrow", "\\leftarrow": "overleftarrow", "↔": "overleftrightarrow", "\\leftrightarrow": "overleftrightarrow", } return arrow_map.get(over_text, "") def _get_inverted_vector_arrow_cmd(self, base_node: ET.Element, over_node: ET.Element) -> str: """Get vector arrow command if pattern matches (variables over arrow).""" # Check if base is an arrow # CRITICAL FIX: OMML→MathML conversion often puts arrows in tags instead of # Must check BOTH AND tags for arrow symbols if _local(base_node.tag) not in ['mo', 'mi']: return "" base_text = _text_content(base_node).strip() # NOTE: Only single arrows (→, ←, ↔) are used for vector notation # Double arrows (⇒, ⇐, ⇔) should use overset with \Longrightarrow, not overrightarrow arrow_map = { "→": "overrightarrow", "\\rightarrow": "overrightarrow", "←": "overleftarrow", "\\leftarrow": "overleftarrow", "↔": "overleftrightarrow", "\\leftrightarrow": "overleftrightarrow", } # Check if over is variable-like content or complex expression if _local(over_node.tag) in ['mi', 'mn', 'mrow', 'mfrac', 'msup', 'msub', 'msubsup', 'msqrt', 'mroot']: return arrow_map.get(base_text, "") return "" def _is_bar_char(self, latex_str: str) -> bool: """Check if string represents a bar/overline character.""" bar_chars = [ "¯", "‾", "―", "−", "⎺", "⎻", "-", "_", "̅", # U+0305 combining overline (used in OMML m:acc) ] return latex_str.strip() in bar_chars def _is_tilde_char(self, latex_str: str) -> bool: """Check if string represents a tilde character.""" tilde_chars = [ "~", "˜", "∼", "\\tilde", "̃", # U+0303 combining tilde (used in OMML m:acc) ] return latex_str.strip() in tilde_chars def _is_hat_char(self, latex_str: str) -> bool: """Check if string represents a hat/circumflex character.""" hat_chars = [ "^", "ˆ", "︶", "∧", "\\hat", "̂", # U+0302 combining circumflex (used in OMML m:acc) ] return latex_str.strip() in hat_chars def _is_dot_char(self, latex_str: str) -> bool: """Check if string represents a dot character.""" dot_chars = [ "˙", ".", "·", "∙", "\\dot", "̇", # U+0307 combining dot above (used in OMML m:acc) ] return latex_str.strip() in dot_chars def _is_ddot_char(self, latex_str: str) -> bool: """Check if string represents a ddot (double dot/diaeresis) character.""" # Double dot characters (diaeresis/umlaut) ddot_chars = [ "¨", # U+00A8 diaeresis (standalone) "̈", # U+0308 combining diaeresis "‥", # U+2025 two dot leader "__DDOT__", # Placeholder from symbol mapping "\\ddot", ] return latex_str.strip() in ddot_chars def _is_dddot_char(self, latex_str: str) -> bool: """Check if string represents a dddot (triple dot) character.""" # Triple dot characters dddot_chars = [ "⃛", # U+20DB combining three dots above "⋮", # U+22EE vertical ellipsis (sometimes used) "︙", # U+FE19 presentation form vertical ellipsis "__DDDOT__", # Placeholder from symbol mapping "\\dddot", ] return latex_str.strip() in dddot_chars def _is_breve_char(self, latex_str: str) -> bool: """Check if string represents a breve character.""" breve_chars = ["̆", "˘", "\\breve"] # U+0306 combining breve, U+02D8 breve return latex_str.strip() in breve_chars def _get_harpoon_cmd(self, latex_str: str) -> str: """Get harpoon LaTeX command if string represents a harpoon character. Returns: The LaTeX command name (without backslash) if harpoon, empty string otherwise. """ harpoon_map = { "⃐": "leftharpoonup", # U+20D0 Combining left harpoon above "⃑": "rightharpoonup", # U+20D1 Combining right harpoon above "⇀": "rightharpoonup", # U+21C0 Right harpoon up "↼": "leftharpoonup", # U+21BC Left harpoon up "⇁": "rightharpoondown", # U+21C1 Right harpoon down "↽": "leftharpoondown", # U+21BD Left harpoon down } return harpoon_map.get(latex_str.strip(), "") def _strip_text_braces(self, latex_str: str) -> str: r"""Remove \text{...} wrapper if present.""" import re match = re.match(r'\\text\{(.+)\}', latex_str) if match: return match.group(1) return latex_str 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 _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) - \\text{} wrappers (content preserved) - Extra spaces between characters NOTE: \\mathrm{} wrappers are PRESERVED because they represent explicit styling from the source document (mathvariant="normal" in MathML). """ 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') # NOTE: Do NOT remove \mathrm{} wrappers - they represent explicit styling # from mathvariant="normal" in the source MathML, indicating the author # wanted upright text (important for chemistry formulas like NH3·H2O) # 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 _brace(self, content: str) -> str: """Wrap content in braces.""" return f"{{{content}}}" def _is_arc_notation(self, mover_node: ET.Element) -> bool: """ Check if mover represents arc notation (frown/arc over multi-char content). Arc notation is used for line segments like AB̂. Args: mover_node: The mover element to check Returns: True if this represents arc notation, False otherwise """ if len(mover_node) < 2: return False base_node = mover_node[0] over_node = mover_node[1] # Check if the over content is an arc/frown character if _local(over_node.tag) in ["mtext", "mo"]: over_text = _text_content(over_node).strip() # Arc characters (frown/smile used as arc over multiple characters) arc_characters = [ "⌢", # Frown (upward arc) - used for arc over segments "︵", # Top parenthesis "⁀", # Character tie ] if over_text in arc_characters: base_text = _text_content(base_node).strip() # Arc notation is used for multi-character bases (line segments) # For 2+ characters, use \overset{\frown} or widehat if len(base_text) >= 2: return True return False def _strip_mathrm_for_accents(self, latex: str) -> str: """ Remove \\mathrm{} wrapper from content that will be used in accent notations. Accent base content (like AB in \\widetilde{AB}) should remain italic, not upright. Args: latex: LaTeX string possibly wrapped in \\mathrm{} Returns: LaTeX string with \\mathrm{} removed if present """ import re # Match \mathrm{content} and extract just the content pattern = r'^\\mathrm\{(.+)\}$' match = re.match(pattern, latex) if match: return match.group(1) return latex def _is_large_operator(self, base_node: ET.Element) -> bool: """ Check if base is a large operator (sum, integral, product, max, min, lim, etc.). Recursively searches through wrapper elements like , , to find the actual operator, since Word often wraps large operators. This is used to detect when Word incorrectly uses for operators that should use subscripts (e.g., max, min, lim). """ if base_node is None: return False tag = _local(base_node.tag) # Define large operators list (same as in MunderVisitor) 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) if tag in ('mstyle', 'mrow', 'mpadded', 'menclose'): for child in base_node: if self._is_large_operator(child): return True return False def can_handle(self, node: ET.Element) -> bool: """Check if this visitor can handle the given node.""" return _local(node.tag) == 'mover' @property def element_name(self) -> str: """Get the element name this visitor handles.""" return 'mover' def __repr__(self) -> str: """String representation for debugging.""" return f"MoverVisitor(strategy={self.strategy.name})"