""" Visitor for (row/container) MathML elements. The element is a container that groups mathematical expressions. It's the most fundamental structural element in MathML, used to: - Group operands and operators - Delimit expressions with parentheses/brackets - Contain complex expressions (numerators, exponents, etc.) Basic behavior: Convert all children and join with spaces. """ from typing import Tuple, Optional from lxml import etree as ET from ..core.interfaces import Visitor, Strategy from ..core.context import ConversionContext from ..utils import text_content as _text_content, local_name as _local class MrowVisitor(Visitor): r""" Handles (row/container) MathML elements. The mrow element is a container that groups mathematical content. Basic behavior is to convert all children and join them with spaces. In script contexts (subscripts/superscripts), multiple consecutive identifiers/numbers are joined without spaces, since they usually form a single composite index like \(x_{ij}\) or \(K_{a1}\). Examples: Simple grouping: x+1 → x + 1 In fractions: x+1 2 → \dfrac{x + 1}{2} With parentheses: ( x+1 ) → ( x + 1 ) TODO (Future Enhancements): - Fragmented function detection (sin split as s-i-n) - Chemical formula pattern detection - Absolute value notation (|x|) - Mixed delimiter handling - Delimiter-only rows (preserve spacing) - Cross-mrow fragmented units - Numeric mtext merging """ def __init__(self, strategy: Strategy, context: ConversionContext = None): """Initialize the Mrow visitor.""" super().__init__(strategy) self.context = context def visit(self, node: ET.Element) -> str: """ Convert element to LaTeX. Args: node: The element to convert Returns: LaTeX string with children joined by spaces Raises: ValueError: If node is not an element """ if not self.can_handle(node): raise ValueError(f"MrowVisitor cannot handle element '{node.tag}'") children = list(node) # Empty mrow returns empty string if not children: return "" # Pattern 1: Fragmented function detection (sin split as s-i-n) fragmented_function = self._is_fragmented_function_row(node) if fragmented_function: return self._handle_fragmented_function(node, fragmented_function) # Pattern 2: Chemical formula pattern - DISABLED # REASON: Always adding spaces is safer. Even H2O with mathvariant="normal" # will render correctly as \mathrm{H} 2 \mathrm{O}. No need for heuristic merging. # if self._detect_chemical_formula_pattern(node): # return self._merge_chemical_formula(node) # Pattern 2.5: Fragmented chemical elements with charge - DISABLED # REASON: Same as Pattern 2. Spaces are safe; heuristics cause false positives. # if self._detect_fragmented_element_with_charge(node): # return self._merge_fragmented_element_with_charge(node) # Pattern 3: Cross-mrow fragmented units if self._has_cross_mrow_fragmented_units(node): return self._merge_cross_mrow_fragmented_units(node) # Pattern 4: Set notation {x | condition} if self._is_set_notation_row(node): return self._handle_set_notation_row(node) # Pattern 5: Absolute value notation |x| if self._is_absolute_value_row(node): return self._handle_absolute_value_row(node) # Pattern 6: Mixed delimiter row if self._is_mixed_delimiter_row(node): return self._handle_mixed_delimiter_row(node) # Pattern 7: Delimiter-only row (preserve spacing) if self._is_delimiter_only_row(node): return self._convert_children_no_space(node) # Pattern 8: Chemical formula row - DISABLED # REASON: Subject detection is obsolete. We now rely solely on mathvariant="normal" # to determine when to use \mathrm{}. Chemical formulas with mathvariant="normal" # will be properly formatted. Always adding spaces is safer than heuristic removal. # if self._is_chemical_formula_row(node): # return self._convert_children_no_space(node) # Pattern 9: Numeric mtext merging ("1" + "0 mol" → "10 mol") if self._should_merge_numeric_mtext(node): return self._merge_numeric_mtext_elements(node) # Pattern 10: Fragmented units (m-o-l → mol) if self._should_merge_fragmented_units(node): return self._merge_fragmented_units(node) # Check if this mrow has delimiters that need scaling # This matches the original converter's behavior of adding \left and \right # when parentheses contain fractions delimiter_info = self._detect_delimiters(children) if delimiter_info: return self._handle_delimited_content(children, delimiter_info) # Default: Convert all children and join with spaces # IMPORTANT: Use _convert_children_with_spaces() which checks for # fragmented functions at each position (not just at the start of mrow) # # EXCEPTION: Inside subscripts/superscripts, consecutive identifiers # normally form a single composite index (e.g., x_{ij}, K_{a1}). # Join them without spaces to preserve that semantic grouping. if self.context and self.context.in_script_context(): result = self._convert_children_no_space(node) else: result = self._convert_children_with_spaces(node) # Don't strip if result ends with LaTeX space command '\ ' # The trailing space in '\ ' is semantically significant if result.endswith('\\ '): return result else: return result.strip() def _convert_child(self, child: ET.Element) -> str: """ Convert a child element to LaTeX. This method instantiates the appropriate visitor for each child type. """ 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': # Recursive: mrow can contain other mrow elements # Pass context so nested mrows also respect script context return MrowVisitor(self.strategy, self.context).visit(child) elif tag == 'msqrt': from .msqrt_visitor import MsqrtVisitor return MsqrtVisitor(self.strategy).visit(child) elif tag == 'mover': from .mover_visitor import MoverVisitor return MoverVisitor(self.strategy).visit(child) elif tag == 'munder': from .munder_visitor import MunderVisitor return MunderVisitor(self.strategy).visit(child) elif tag == 'munderover': from .munderover_visitor import MunderoverVisitor return MunderoverVisitor(self.strategy).visit(child) elif tag == 'msubsup': from .msubsup_visitor import MsubsupVisitor return MsubsupVisitor(self.strategy).visit(child) elif tag == 'mroot': from .mroot_visitor import MrootVisitor return MrootVisitor(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 # This should rarely happen now that most MathML elements are covered import sys print(f"[WARNING] Unknown element <{tag}> in mrow, using text fallback", file=sys.stderr) return _text_content(child) def _detect_delimiters(self, children: list) -> dict: """ Detect if this mrow contains delimiters (parentheses, brackets, etc.). Returns dict with delimiter info if found, None otherwise. Pattern: ( ... content ... ) NOTE: Skips elements to let them be handled by mo_visitor, which will add __DELIM__ markers for the delimiter pairing processor. """ if len(children) < 3: return None # Check if first child is an opening delimiter first_child = children[0] if _local(first_child.tag) != 'mo': return None # CRITICAL FIX (Phase 2): Skip fence delimiters - let mo_visitor handle them # This ensures XSLT-generated elements get __DELIM__ markers # and are processed by the universal delimiter pairing processor if first_child.get('fence') == 'true': return None # CRITICAL FIX (Phase 3): Skip sticky delimiters - let mo_visitor handle them # This supports which should force scaling via PostProcessor if first_child.get('stretchy') == 'true': return None first_text = _text_content(first_child).strip() if first_text not in '([{': return None # Check if last child is a closing delimiter last_child = children[-1] if _local(last_child.tag) != 'mo': return None # Also check last child for fence attribute if last_child.get('fence') == 'true': return None # Also check last child for stretchy attribute if last_child.get('stretchy') == 'true': return None last_text = _text_content(last_child).strip() if last_text not in ')]}': return None # Verify delimiter pairing pairs = {'(': ')', '[': ']', '{': '}'} if pairs.get(first_text) != last_text: # Mismatched delimiters - treat as regular mrow return None return { 'open': first_text, 'close': last_text, 'content_children': children[1:-1] } def _handle_delimited_content(self, children: list, delimiter_info: dict) -> str: r""" Handle mrow with delimiters, adding \left and \right if needed. This matches the original converter's behavior: - If content contains fractions, use \left( and \right) - Otherwise, use plain ( and ) """ # Convert content between delimiters content_parts = [] has_fraction = False for child in delimiter_info['content_children']: part = self._convert_child(child) if part: content_parts.append(part) # Check if this child is a fraction if _local(child.tag) == 'mfrac': has_fraction = True # Check if converted content contains fraction commands if '\\frac' in part or '\\dfrac' in part or '\\tfrac' in part: has_fraction = True content = ' '.join(content_parts) # Check if content needs delimiter scaling needs_scaling = has_fraction or self._needs_delimiter_scaling(content) # Build result with appropriate delimiters open_delim = delimiter_info['open'] close_delim = delimiter_info['close'] if needs_scaling: # Map delimiters for LaTeX if open_delim == '{': left = '\\left\\{' else: left = f'\\left{open_delim}' if close_delim == '}': right = '\\right\\}' else: right = f'\\right{close_delim}' return f'{left} {content} {right}' else: # Plain delimiters (must escape braces) left = '\\{' if open_delim == '{' else open_delim right = '\\}' if close_delim == '}' else close_delim return f'{left} {content} {right}' def _needs_delimiter_scaling(self, content: str) -> bool: r""" Determine if content needs delimiter scaling (\left and \right). Based on the original converter's logic at line 6482-6507. Content needs scaling if it contains: - Fractions (\frac, \dfrac, \tfrac) - Large operators (\sum, \prod, \int) - Square roots (\sqrt) - Nested delimiters - Multiple scripts """ scaling_indicators = [ '\\frac', '\\dfrac', '\\tfrac', # Fractions '\\sum', '\\prod', '\\int', # Large operators '\\sqrt', # Square roots '\\binom', # Binomial coefficients '\\overset', '\\underset', # Over/under sets '\\limits', # Limits '\\Big', '\\big', # Already scaled content ] # CRITICAL FIX: Check universal_delimiter_scaling flag from LegacyAdapter # This allows forcing \left and \right on all delimiters regardless of content size if hasattr(self.strategy, '_converter_context'): converter = self.strategy._converter_context if getattr(converter, 'universal_delimiter_scaling', False): return True # Check for scaling indicators if any(indicator in content for indicator in scaling_indicators): return True # Check for nested delimiters nested_delims = content.count('(') + content.count('[') + content.count('{') if nested_delims > 0: return True # Check for multiple script levels script_levels = content.count('_') + content.count('^') if script_levels > 1: return True # Long expressions often benefit from scaling if len(content) > 20: return True return False # =================================================================== # Pattern Detection Methods (10 patterns) # =================================================================== def _check_fragmented_function_at_position( self, children: list, start_idx: int ) -> Tuple[str, int, Optional[ET.Element]]: """ Check if elements starting at start_idx form a fragmented function. Returns: (function_latex, consumed_count, last_sub_or_sup_element) - function_latex: LaTeX for the function (e.g., "\\log_{2}") or empty string - consumed_count: Number of children consumed - last_sub_or_sup_element: The last msub/msup element if any, None otherwise Examples: sin → ("\\sin", 3, None) log2 → ("\\log_{2}", 3, msub_element) """ if start_idx >= len(children): return ("", 0, None) letters = [] consumed_count = 0 last_sub_or_sup = None for i in range(start_idx, len(children)): child = children[i] tag = _local(child.tag) # Case 1: Direct element if tag == "mi": content = _text_content(child).strip() if len(content) == 1 and content.isalpha(): letters.append(content) consumed_count += 1 else: break # Stop at first non-single-letter mi # Case 2: containing a single element (MathJax pattern) elif (tag == "mrow" and len(list(child)) == 1 and _local(list(child)[0].tag) == "mi"): mi_element = list(child)[0] content = _text_content(mi_element).strip() if len(content) == 1 and content.isalpha(): letters.append(content) consumed_count += 1 else: break # Case 3: or where the base is a single-letter # Also handles s2 elif tag in ("msub", "msup"): base_children = list(child) if len(base_children) >= 1: base = base_children[0] base_tag = _local(base.tag) if base_tag == "mi": content = _text_content(base).strip() if len(content) == 1 and content.isalpha(): letters.append(content) consumed_count += 1 last_sub_or_sup = child break # msup/msub should be the last element else: break elif base_tag == "mrow": # Handle both single and nested structures # Pattern 1: s (single mi) # Pattern 2: s (nested mrow) base_children_list = list(base) if len(base_children_list) == 1: inner_child = base_children_list[0] inner_tag = _local(inner_child.tag) if inner_tag == "mi": # Pattern 1: direct mi content = _text_content(inner_child).strip() if len(content) == 1 and content.isalpha(): letters.append(content) consumed_count += 1 last_sub_or_sup = child break # msup/msub should be the last element in fragmented function else: break elif inner_tag == "mrow" and len(list(inner_child)) == 1: # Pattern 2: nested mrow with mi deepest_mi = list(inner_child)[0] if _local(deepest_mi.tag) == "mi": content = _text_content(deepest_mi).strip() if len(content) == 1 and content.isalpha(): letters.append(content) consumed_count += 1 last_sub_or_sup = child break # msup/msub should be the last element else: break else: break else: break else: break else: break else: break else: break # Stop at first non-matching element if len(letters) < 2: return ("", 0, None) # Check if the sequence forms a known function from ..data import MATH_FUNCTIONS potential_function = ''.join(letters) if potential_function not in MATH_FUNCTIONS: return ("", 0, None) # Build the function LaTeX function_latex = f"\\{potential_function}" # If the last consumed element was msub/msup, add the subscript/superscript if last_sub_or_sup is not None: tag = _local(last_sub_or_sup.tag) children_of_sub = list(last_sub_or_sup) if len(children_of_sub) >= 2: # Get the subscript/superscript (second child) script = children_of_sub[1] script_latex = self._convert_child(script) if tag == "msub": function_latex += f"_{{{script_latex}}}" elif tag == "msup": function_latex += f"^{{{script_latex}}}" return (function_latex, consumed_count, last_sub_or_sup) def _is_fragmented_function_row(self, node: ET.Element) -> str: """ Check if this mrow contains a fragmented mathematical function. Returns the function name if found, empty string otherwise. Examples: sin → "sin" cos → "cos" max → "max" log2 → "log" """ children = list(node) if len(children) < 2: return "" # Extract consecutive mi elements with single characters # Also handle nested X patterns (MathJax style) # AND handle / where the base is a single-letter (e.g., log_2) letters = [] for child in children: tag = _local(child.tag) # Case 1: Direct element if tag == "mi": content = _text_content(child).strip() if len(content) == 1 and content.isalpha(): letters.append(content) else: break # Stop at first non-single-letter mi # Case 2: containing a single element (MathJax pattern) elif (tag == "mrow" and len(list(child)) == 1 and _local(list(child)[0].tag) == "mi"): mi_element = list(child)[0] content = _text_content(mi_element).strip() if len(content) == 1 and content.isalpha(): letters.append(content) else: break # Stop at first non-single-letter mi # Case 3: or where the base is a single-letter # This handles patterns like log2 → "log" # Also handles s2 elif tag in ("msub", "msup"): # Get the base element (first child of msub/msup) base_children = list(child) if len(base_children) >= 1: base = base_children[0] # Check if base is or X base_tag = _local(base.tag) if base_tag == "mi": content = _text_content(base).strip() if len(content) == 1 and content.isalpha(): letters.append(content) else: break # Stop at first non-single-letter base elif base_tag == "mrow": # Handle both single and nested structures base_children_list = list(base) if len(base_children_list) == 1: inner_child = base_children_list[0] inner_tag = _local(inner_child.tag) if inner_tag == "mi": # Pattern 1: s content = _text_content(inner_child).strip() if len(content) == 1 and content.isalpha(): letters.append(content) else: break elif inner_tag == "mrow" and len(list(inner_child)) == 1: # Pattern 2: s deepest_mi = list(inner_child)[0] if _local(deepest_mi.tag) == "mi": content = _text_content(deepest_mi).strip() if len(content) == 1 and content.isalpha(): letters.append(content) else: break else: break else: break else: break else: break # Base is not a simple else: break # msub/msup has no children else: break # Stop at first non-matching element if len(letters) < 2: return "" # Check if the sequence forms a known function from ..data import MATH_FUNCTIONS potential_function = ''.join(letters) if potential_function in MATH_FUNCTIONS: return potential_function return "" def _handle_fragmented_function(self, node: ET.Element, function_name: str) -> str: """ Handle fragmented function by merging letters and processing remaining content. Args: node: The mrow element function_name: The detected function name (e.g., "sin") Returns: LaTeX with function and remaining content Examples: sinx → "\\sin x" log2x → "\\log_{2} x" """ children = list(node) function_length = len(function_name) # Track which children were consumed for the function # This is trickier when the last letter is in an msub/msup consumed_count = 0 letters_found = 0 last_was_sub_or_sup = False last_sub_or_sup_element = None for child in children: if letters_found >= function_length: break tag = _local(child.tag) # Count letters consumed (same logic as _is_fragmented_function_row) if tag == "mi": letters_found += 1 consumed_count += 1 elif (tag == "mrow" and len(list(child)) == 1 and _local(list(child)[0].tag) == "mi"): letters_found += 1 consumed_count += 1 elif tag in ("msub", "msup"): letters_found += 1 consumed_count += 1 last_was_sub_or_sup = True last_sub_or_sup_element = child else: break # Build the function LaTeX function_latex = f"\\{function_name}" # If the last consumed element was msub/msup, we need to add the subscript/superscript if last_was_sub_or_sup and last_sub_or_sup_element is not None: tag = _local(last_sub_or_sup_element.tag) children_of_sub = list(last_sub_or_sup_element) if len(children_of_sub) >= 2: # Get the subscript/superscript (second child) script = children_of_sub[1] script_latex = self._convert_child(script) if tag == "msub": function_latex += f"_{{{script_latex}}}" elif tag == "msup": function_latex += f"^{{{script_latex}}}" # Convert remaining elements after the function remaining_parts = [] for child in children[consumed_count:]: part = self._convert_child(child) if part: remaining_parts.append(part) remaining_content = " ".join(remaining_parts).strip() if remaining_content: return f"{function_latex} {remaining_content}" else: return function_latex def _detect_chemical_formula_pattern(self, node: ET.Element) -> bool: """ Detect if consecutive mi elements form a chemical formula like HCl, KClO3, etc. Pattern: consecutive mi elements with mathvariant="normal" that together form a chemical formula starting with known element. """ children = list(node) if len(children) < 2: return False # Check for pattern: consecutive mi elements with mathvariant="normal" consecutive_mi = [] for child in children: if (_local(child.tag) == "mi" and child.get("mathvariant") == "normal"): text = _text_content(child) if text and (text.isalpha() or text.isdigit()): consecutive_mi.append(text) else: break else: break # Check if combined text forms a chemical formula if len(consecutive_mi) >= 2: combined = "".join(consecutive_mi) # Pattern: Starts with capital letter, contains mix of letters/numbers import re if re.match(r'^[A-Z][A-Za-z0-9]+$', combined): # Additional check: starts with known element symbols from ..data import CHEMICAL_ELEMENTS for elem in CHEMICAL_ELEMENTS: if combined.startswith(elem): return True return False def _merge_chemical_formula(self, node: ET.Element) -> str: """ Merge consecutive mi elements that form a chemical formula. Example: HCl → \\mathrm{HCl} """ children = list(node) formula_parts = [] formula_consumed = 0 # Collect consecutive mi elements that form the formula for child in children: if (_local(child.tag) == "mi" and child.get("mathvariant") == "normal"): text = _text_content(child) if text and (text.isalpha() or text.isdigit()): formula_parts.append(text) formula_consumed += 1 else: break else: break # Process remaining children after the formula remaining_children = children[formula_consumed:] # Combine formula parts and wrap in \mathrm{} if formula_parts: formula = "".join(formula_parts) result = f"\\mathrm{{{formula}}}" # Process remaining elements if remaining_children: remaining_parts = [] for child in remaining_children: remaining_parts.append(self._convert_child(child)) remaining_content = " ".join([p for p in remaining_parts if p]) if remaining_content: result += " " + remaining_content return result # Fallback to normal processing return self._convert_children_with_spaces(node) def _detect_fragmented_element_with_charge(self, node: ET.Element) -> bool: """ Detect fragmented chemical elements with charge indicators. Pattern: M n ... This handles cases like "M n^{2+}" which should be "\mathrm{Mn}^{2+}" The key difference from _detect_chemical_formula_pattern is that this allows interruptions by or elements when they contain charges. Returns True if: 1. Found 2+ consecutive elements (may be interrupted by msup/msub) 2. Combined text forms known multi-char element (Mn, Cu, Fe, etc.) 3. Chemistry probability score meets threshold for current mode """ children = list(node) if len(children) < 2: return False # Import here to avoid circular dependency from ..data.chemistry_data import MULTI_CHAR_ELEMENTS from ..utils.chemistry_detection import calculate_chemistry_probability # Look for pattern: mi mi [msup/msub with charge] i = 0 while i < len(children) - 1: # Check if we have consecutive mi elements if _local(children[i].tag) != "mi": i += 1 continue # Collect consecutive mi elements (allowing msup/msub interruptions) mi_elements = [] mi_texts = [] j = i while j < len(children): child = children[j] tag = _local(child.tag) # Accept ANY element (not just mathvariant="normal") # Chemistry probability scoring will decide whether to merge if tag == "mi": text = _text_content(child).strip() # Only consider single letters (M, n) or 2-letter elements (Mn) if text and len(text) <= 2 and text.isalpha(): mi_elements.append((j, text)) mi_texts.append(text) j += 1 else: break elif tag in ("msup", "msub") and len(mi_elements) >= 1: # Check if this interruption contains a charge indicator if tag == "msup" and len(child) >= 2: sup_node = child[1] sup_text = _text_content(sup_node).strip() # Check for charge pattern has_charge = False if sup_text and ('+' in sup_text or '-' in sup_text): if len(sup_text) <= 3 and (sup_text.endswith('+') or sup_text.endswith('-')): has_charge = True # Format: "2 +" or "2 -" parts = sup_text.split() if len(parts) == 2 and parts[0].isdigit() and parts[1] in ('+', '-'): has_charge = True if has_charge: # Found charge! Check if collected mi elements form known element if len(mi_texts) >= 2: combined = "".join(mi_texts) if combined in MULTI_CHAR_ELEMENTS: # Calculate chemistry probability to decide if we should merge # Get the root element for full context math_root = node while math_root is not None and _local(math_root.tag) != 'math': math_root = math_root.getparent() if math_root is not None: subject_mode = self.strategy.subject_mode if hasattr(self.strategy, 'subject_mode') else None chem_prob = calculate_chemistry_probability(math_root, subject_mode) # Decision thresholds: # - Chemistry mode: ≥0.8 # - Auto-mode: ≥1.5 (very strong indicators) threshold = 0.8 if subject_mode == 'chemistry' else 1.5 if chem_prob >= threshold: return True break else: break i += 1 return False def _merge_fragmented_element_with_charge(self, node: ET.Element) -> str: """ Merge fragmented chemical elements that are separated with charge indicators. Example: Mn...2+ → \mathrm{Mn}^{2 +} """ from ..data.chemistry_data import MULTI_CHAR_ELEMENTS from ..utils.chemistry_detection import calculate_chemistry_probability children = list(node) result_parts = [] i = 0 while i < len(children): # Try to find and merge fragmented element pattern # Accept ANY element (not just mathvariant="normal") if _local(children[i].tag) == "mi": # Collect consecutive mi elements mi_elements = [] mi_texts = [] j = i while j < len(children): child = children[j] tag = _local(child.tag) # Accept ANY element if tag == "mi": text = _text_content(child).strip() # Only consider single letters or 2-letter elements if text and len(text) <= 2 and text.isalpha(): mi_elements.append((j, child)) mi_texts.append(text) j += 1 else: break elif tag == "msup" and len(mi_elements) >= 2: # Check if this is a charge superscript if len(child) >= 2: sup_node = child[1] sup_text = _text_content(sup_node).strip() has_charge = False if sup_text and ('+' in sup_text or '-' in sup_text): if len(sup_text) <= 3 and (sup_text.endswith('+') or sup_text.endswith('-')): has_charge = True parts = sup_text.split() if len(parts) == 2 and parts[0].isdigit() and parts[1] in ('+', '-'): has_charge = True if has_charge: # Check if combined forms known element combined = "".join(mi_texts) if combined in MULTI_CHAR_ELEMENTS: # Merge the mi elements merged_latex = f"\\mathrm{{{combined}}}" # Add the superscript sup_latex = self._convert_child(child) result_parts.append(merged_latex + sup_latex) # Skip past all consumed elements i = j + 1 break # If we get here, didn't merge, fall through to normal processing break else: break # If we didn't merge anything, process normally if j == i + len(mi_elements): # Didn't find charge pattern, process first element normally result_parts.append(self._convert_child(children[i])) i += 1 else: # Not an mi element, process normally result_parts.append(self._convert_child(children[i])) i += 1 return " ".join([p for p in result_parts if p]) def _has_cross_mrow_fragmented_units(self, node: ET.Element) -> bool: """ Check if this element has fragmented units across multiple mrow children. Example: mol → mol """ children = list(node) if len(children) < 2: return False # Collect all letters from consecutive mrow elements letters = [] for child in children: if _local(child.tag) == "mrow": mrow_children = list(child) mrow_letters = [] for grandchild in mrow_children: if (_local(grandchild.tag) == "mi" and grandchild.get('mathvariant') == 'normal'): text = _text_content(grandchild).strip() if text and len(text) == 1 and text.isalpha(): mrow_letters.append(text) else: mrow_letters = [] break elif _local(grandchild.tag) not in ['mrow']: mrow_letters = [] break if mrow_letters: letters.extend(mrow_letters) else: break # Check if collected letters form a unit if len(letters) >= 2: concatenated = "".join(letters) from ..data import COMMON_UNITS if concatenated in COMMON_UNITS: return True return False def _merge_cross_mrow_fragmented_units(self, node: ET.Element) -> str: """ Merge fragmented units that are split across multiple mrow elements. This is complex - needs to be imported from original converter. For now, fallback to normal processing. """ # TODO: Implement full cross-mrow merging logic # This is a complex method that requires careful handling # For now, return normal processing to avoid breaking tests return self._convert_children_with_spaces(node) def _is_set_notation_row(self, node: ET.Element) -> bool: r""" Check if this mrow represents set notation {x | condition}. Pattern: { ... | ... } Also handles nested: { ... | ... } The vertical bar | should be converted to \mid (set separator), not \left| \right| (absolute value). Examples: {x | x > 0} - set builder notation {x | x ∈ R} - set membership {(x,y) | x+y < 2} - set with condition Returns: True if this is set notation, False otherwise """ children = list(node) if len(children) < 3: # Need at least {, content, } return False # Check if first child is { and last child is } first_child = children[0] last_child = children[-1] if not (_local(first_child.tag) == "mo" and _text_content(first_child).strip() == "{" and _local(last_child.tag) == "mo" and _text_content(last_child).strip() == "}"): return False # Check if there's a | somewhere in the middle (recursively search nested mrows) # Support both | (correct) and | (malformed but common) def has_pipe_recursive(elem): """Recursively search for | in element and its children""" elem_tag = _local(elem.tag) elem_text = _text_content(elem).strip() # Check this element if elem_tag in ("mo", "mn") and elem_text == "|": return True # Recursively check children (only for mrow, not for other containers) if elem_tag == "mrow": for child in elem: if has_pipe_recursive(child): return True return False # Search in children between { and } for child in children[1:-1]: # Skip first { and last } if has_pipe_recursive(child): return True return False def _handle_set_notation_row(self, node: ET.Element) -> str: r""" Convert set notation to LaTeX with \mid separator. Converts: {x | condition} → \left\{ x \mid condition \right\} The key difference from absolute value: - Set notation: | becomes \mid (relational operator) - Absolute value: | becomes \left| \right| (delimiters) Special case: {(x,y) | |x+y| < 2} - First | after (x,y) → \mid (set separator) - Paired || around x+y → \left| \right| (absolute value) Args: node: The mrow element containing set notation Returns: LaTeX string with proper set notation """ children = list(node) # Track if we've converted the first pipe self._first_pipe_converted = False def convert_with_pipe_replacement(elem): """Recursively convert element, replacing first | with \mid""" elem_tag = _local(elem.tag) elem_text = _text_content(elem).strip() # Check if this is a pipe that should be converted to \mid if elem_tag in ("mo", "mn") and elem_text == "|": if not self._first_pipe_converted: # Convert first pipe to \mid self._first_pipe_converted = True return "\\mid" else: # Keep subsequent pipes - let normal conversion handle them return self._convert_child(elem) # For mrow elements, we need to handle them specially if elem_tag == "mrow": # CRITICAL FIX: After the set separator | has been converted to \mid, # any nested mrow that looks like |x| should be treated as absolute value, # not as more set separators. # Example: {x | |y| > 0} → \left\{ x \mid \left|y\right| > 0 \right\} if self._first_pipe_converted and self._is_absolute_value_row(elem): # This nested mrow is an absolute value, handle it properly return self._handle_absolute_value_row(elem) # Otherwise, process mrow children and convert nested pipes inner_parts = [] for child in elem: part = convert_with_pipe_replacement(child) if part: inner_parts.append(part) return " ".join(inner_parts).strip() # For all other elements, use normal conversion return self._convert_child(elem) # Process all children between { and } parts = [] for child in children[1:-1]: # Skip first { and last } part = convert_with_pipe_replacement(child) if part: parts.append(part) content = " ".join(parts).strip() return f"\\left\\{{{content}\\right\\}}" def _is_absolute_value_row(self, node: ET.Element) -> bool: """ Check if this mrow represents absolute value notation |...|. Pattern: | ... content ... | IMPORTANT: Only match if there are exactly 2 pipe operators. For multiple absolute values like |AB||n|, there are 4 pipes, which should NOT be treated as a single absolute value. """ children = list(node) if len(children) < 3: # Need at least |, content, | return False # Check if first and last children are | operators first_child = children[0] last_child = children[-1] if not (_local(first_child.tag) == "mo" and _text_content(first_child).strip() == "|" and _local(last_child.tag) == "mo" and _text_content(last_child).strip() == "|"): return False # Count total | operators in the row # Only match if there are exactly 2 (one opening, one closing) pipe_count = 0 for child in children: if _local(child.tag) == "mo" and _text_content(child).strip() == "|": pipe_count += 1 # If more than 2 pipes, this is multiple absolute values, not a single one # e.g., |AB||n| has 4 pipes and should NOT be treated as \left|...\right| return pipe_count == 2 def _handle_absolute_value_row(self, node: ET.Element) -> str: """ Convert absolute value notation to \\left|...\\right| for dynamic scaling. Example: |x| → \\left|x\\right| """ children = list(node) # Convert the content between the first and last | operators content_parts = [] for child in children[1:-1]: # Skip first and last | part = self._convert_child(child) if part: content_parts.append(part) content = " ".join(content_parts).strip() return f"\\left|{content}\\right|" def _is_mixed_delimiter_row(self, node: ET.Element) -> bool: """ Check if this mrow contains delimiters mixed with other content. Returns True if row has both delimiters and non-delimiter content. Note: This should NOT match the case where delimiters are at the beginning and end as matching pairs (e.g., ( ... )), as that should be handled by the proper delimiter detection logic later. """ children = list(node) if len(children) <= 1: return False # Check if this is a properly delimited expression (first and last are matching delimiters) # If so, return False to let the delimiter handling logic deal with it delimiter_info = self._detect_delimiters(children) if delimiter_info: # This is a properly delimited expression, not a mixed delimiter row return False has_delimiters = False has_other_content = False delimiter_chars = set("()[]{}") for child in children: if _local(child.tag) == "mo": content = _text_content(child).strip() if content in delimiter_chars: has_delimiters = True else: has_other_content = True else: has_other_content = True return has_delimiters and has_other_content def _handle_mixed_delimiter_row(self, node: ET.Element) -> str: """ Handle mrow with mixed delimiters and content. For now, use normal processing with spaces. """ # TODO: Implement specialized mixed delimiter handling if needed return self._convert_children_with_spaces(node) def _is_delimiter_only_row(self, node: ET.Element) -> bool: """ Check if this mrow contains only delimiter operators. Returns True if all children are mo elements with delimiter characters. """ children = list(node) if len(children) == 0: return False delimiter_chars = set("()[]{}") for child in children: if _local(child.tag) == "mo": content = _text_content(child).strip() if content not in delimiter_chars: return False else: return False return True def _is_chemical_formula_row(self, node: ET.Element) -> bool: """ Check if mrow contains chemical formula elements that should be joined without spaces. Returns True if more than half the elements are chemical. IMPORTANT: Subscripted elements (msub) with single-letter bases are treated as geometry/math notation (e.g., F₁, F₂ for points), NOT chemical formulas. Only count as chemical if they are part of a clear chemical formula pattern. """ children = list(node) if len(children) < 2: return False from ..data import CHEMICAL_ELEMENTS # CRITICAL: Check for mathematical binary operators that indicate this is NOT a chemical formula # Binary operators like ⊙, ⊕, ⊗, ⊖, ⊘ clearly indicate mathematical context # This set should include all operators from SKIP_SPACE_AFTER in mo_visitor.py mathematical_binary_operators = { # Relation operators '→', '←', '⇒', '⇐', '↔', '⇔', '⟷', '\\rightarrow', '\\leftarrow', '\\to', '\\Longrightarrow', '\\Longleftarrow', '\\leftrightarrow', '\\Longleftrightarrow', '=', '≈', '≅', '<', '>', '≤', '≥', '≠', '\\equiv', '\\approx', '\\cong', '\\leq', '\\geq', '\\neq', '⊆', '⊇', '∈', '∉', '⊥', '∥', '∼', '≃', '∝', '≺', '≻', '⪯', '⪰', '\\subseteq', '\\supseteq', '\\in', '\\notin', '\\perp', '\\parallel', '\\sim', '\\simeq', '\\propto', '\\prec', '\\succ', '\\preceq', '\\succeq', '⊂', '⊃', '⊑', '⊒', '\\subset', '\\supset', '\\sqsubseteq', '\\sqsupseteq', # Binary operators '+', '-', '×', '÷', '·', '±', '∓', '*', '/', '\\times', '\\div', '\\cdot', '\\pm', '\\mp', '∨', '∧', '¬', '∩', '∪', '⊓', '⊔', '⊎', '\\lor', '\\land', '\\wedge', '\\vee', '\\neg', '\\cap', '\\cup', '\\sqcap', '\\sqcup', '\\uplus', '⊕', '⊗', '⊙', '⊖', '⊘', '⋏', '⋎', '\\oplus', '\\otimes', '\\odot', '\\ominus', '\\oslash', '\\curlywedge', '\\curlyvee', '∖', '∗', '⋆', '∘', '•', '⋄', '△', '▽', '◃', '▹', '⊲', '⊳', '⊴', '⊵', '\\setminus', '\\ast', '\\star', '\\circ', '\\bullet', '\\diamond', '\\bigtriangleup', '\\bigtriangledown', '\\triangleleft', '\\triangleright', '\\lhd', '\\rhd', '\\unlhd', '\\unrhd', } for child in children: if _local(child.tag) == "mo": content = _text_content(child).strip() if content in mathematical_binary_operators: # Found a mathematical binary operator - this is NOT a chemical formula return False chemical_indicators = 0 total_relevant = 0 # CRITICAL FIX: If ALL children are msub with single-letter bases, # this is likely geometry notation (F₁, F₂, P₁, P₂), not chemistry. # Don't treat it as a chemical formula. all_are_single_letter_msub = True for child in children: child_name = _local(child.tag) if child_name != "msub": all_are_single_letter_msub = False break # Check if base is a single letter if len(child) > 0: base_elem = child[0] # Handle both direct and wrapped if _local(base_elem.tag) == "mi": base_content = _text_content(base_elem).strip() if len(base_content) != 1: all_are_single_letter_msub = False break elif _local(base_elem.tag) == "mrow": mrow_children = list(base_elem) if (len(mrow_children) == 1 and _local(mrow_children[0].tag) == "mi"): base_content = _text_content(mrow_children[0]).strip() if len(base_content) != 1: all_are_single_letter_msub = False break else: all_are_single_letter_msub = False break else: all_are_single_letter_msub = False break # If all children are single-letter msub elements, NOT a chemical formula if all_are_single_letter_msub: return False for child in children: child_name = _local(child.tag) if child_name == "mi": total_relevant += 1 content = _text_content(child) if content in CHEMICAL_ELEMENTS: chemical_indicators += 1 elif child_name == "msub": total_relevant += 1 # Check if base element is chemical if len(child) > 0: base_elem = child[0] if _local(base_elem.tag) == "mi": base_content = _text_content(base_elem) if base_content in CHEMICAL_ELEMENTS: chemical_indicators += 1 elif _local(base_elem.tag) == "mrow": # Handle wrapped X pattern mrow_children = list(base_elem) if (len(mrow_children) == 1 and _local(mrow_children[0].tag) == "mi"): base_content = _text_content(mrow_children[0]) if base_content in CHEMICAL_ELEMENTS: chemical_indicators += 1 # If more than half the elements are chemical, treat as chemical formula return total_relevant > 0 and chemical_indicators / total_relevant > 0.5 def _should_merge_numeric_mtext(self, node: ET.Element) -> bool: """ Check if this mrow contains consecutive mtext elements that should be merged. Pattern: mtext with single digit + mtext starting with digit and containing units Example: "1" + "0 mol" → "10 mol" """ children = list(node) if len(children) < 2: return False # Look for pattern: mtext with single digit + mtext starting with digit for i in range(len(children) - 1): current = children[i] next_elem = children[i + 1] if (_local(current.tag) == "mtext" and _local(next_elem.tag) == "mtext"): current_text = _text_content(current).strip() next_text = _text_content(next_elem).strip() # Check if current is single digit and next starts with digit if (current_text.isdigit() and len(current_text) == 1 and next_text and next_text[0].isdigit()): return True return False def _merge_numeric_mtext_elements(self, node: ET.Element) -> str: """ Merge consecutive numeric mtext elements intelligently. Example: 10 mol → 10 mol """ children = list(node) result_parts = [] i = 0 while i < len(children): current = children[i] if _local(current.tag) == "mtext": current_text = _text_content(current).strip() # Check if we can merge with next element if (i + 1 < len(children) and _local(children[i + 1].tag) == "mtext" and current_text.isdigit() and len(current_text) == 1): next_elem = children[i + 1] next_text = _text_content(next_elem).strip() if next_text and next_text[0].isdigit(): # Merge: remove space between digits merged_text = current_text + next_text # Import mtext visitor to process merged content from .mtext_visitor import MtextVisitor visitor = MtextVisitor(self.strategy) # Create a temporary element with merged text temp_elem = ET.Element('mtext') temp_elem.text = merged_text processed = visitor.visit(temp_elem) result_parts.append(processed) i += 2 # Skip next element as it's been merged continue # No merge possible, process normally from .mtext_visitor import MtextVisitor visitor = MtextVisitor(self.strategy) processed = visitor.visit(current) result_parts.append(processed) else: # Non-mtext element, convert normally converted = self._convert_child(current) result_parts.append(converted) i += 1 return " ".join([p for p in result_parts if p]).strip() def _should_merge_fragmented_units(self, node: ET.Element) -> bool: """ Check if this mrow contains fragmented units (mi elements that spell out unit names). Example: mol → mol """ children = list(node) if len(children) < 2: return False # Collect all mi elements with mathvariant="normal" mi_elements = [] for child in children: if _local(child.tag) == "mi": mathvariant = child.get('mathvariant') if mathvariant == 'normal': text = _text_content(child).strip() if text and len(text) == 1 and text.isalpha(): mi_elements.append(text) # Check if concatenated text forms a unit if len(mi_elements) >= 2: concatenated = "".join(mi_elements) from ..data import COMMON_UNITS if concatenated in COMMON_UNITS: return True return False def _merge_fragmented_units(self, node: ET.Element) -> str: """ Merge fragmented unit elements into proper unit formatting. Example: mol → \\mathrm{mol} """ children = list(node) result_parts = [] i = 0 from ..data import COMMON_UNITS while i < len(children): current = children[i] if _local(current.tag) == "mi": mathvariant = current.get('mathvariant') if mathvariant == 'normal': # Start collecting consecutive mi elements unit_letters = [_text_content(current).strip()] j = i + 1 # Collect consecutive mi elements with mathvariant="normal" while j < len(children): next_elem = children[j] if (_local(next_elem.tag) == "mi" and next_elem.get('mathvariant') == 'normal'): text = _text_content(next_elem).strip() if text and len(text) == 1 and text.isalpha(): unit_letters.append(text) j += 1 else: break else: break # Check if collected letters form a unit if len(unit_letters) >= 2: concatenated = "".join(unit_letters) if concatenated in COMMON_UNITS: # Format as unit with \mathrm{} formatted_unit = f"\\mathrm{{{concatenated}}}" result_parts.append(formatted_unit) i = j # Skip all consumed elements continue # If not part of a unit, process normally converted = self._convert_child(current) result_parts.append(converted) else: # Non-mi element, convert normally converted = self._convert_child(current) result_parts.append(converted) i += 1 return " ".join([p for p in result_parts if p]).strip() # =================================================================== # Helper Methods # =================================================================== def _convert_children_with_spaces(self, node: ET.Element) -> str: """ Convert all children and join with spaces. IMPORTANT: Also checks for fragmented functions at each position. This allows detection of fragmented functions in the middle of an mrow, not just at the beginning. """ children = list(node) parts = [] i = 0 while i < len(children): # Check for fragmented function starting at position i function_name, consumed_count, last_sub_or_sup = self._check_fragmented_function_at_position( children, i ) if function_name: # Found a fragmented function - add it to parts parts.append(function_name) i += consumed_count else: # No fragmented function - convert child normally part = self._convert_child(children[i]) if part: parts.append(part) i += 1 return ' '.join(parts).strip() def _convert_children_no_space(self, node: ET.Element) -> str: """Convert all children and join without spaces.""" parts = [] for child in list(node): part = self._convert_child(child) if part: parts.append(part) return ''.join(parts).strip() def can_handle(self, node: ET.Element) -> bool: """Check if this visitor can handle the given node.""" return _local(node.tag) == 'mrow' @property def element_name(self) -> str: """Get the element name this visitor handles.""" return 'mrow' def __repr__(self) -> str: """String representation for debugging.""" return f"MrowVisitor(strategy={self.strategy.name})"