""" Legacy adapter for backward compatibility. This module provides a drop-in replacement for the original MathMLToTeXConverter that internally uses the refactored architecture. Examples: Legacy API usage: >>> from omml2tex.compat import LegacyMathMLToTeXConverter >>> converter = LegacyMathMLToTeXConverter(subject_mode='chemistry') >>> latex = converter.convert('H2O') >>> latex '\\mathrm{H}_2\\mathrm{O}' """ from typing import Optional, List, Tuple from lxml import etree as ET from ..core.interfaces import Strategy from ..utils.debug import debug_print from ..strategies import ( MathStrategy, ChemistryStrategy, PhysicsStrategy, StatisticsStrategy, StrategyFactory, ) from ..visitors import ( VisitorRegistry, MiVisitor, MoVisitor, MnVisitor, MfracVisitor, MsupVisitor, MsubVisitor, MrowVisitor, MsqrtVisitor, MrootVisitor, MsubsupVisitor, MoverVisitor, MunderVisitor, MunderoverVisitor, MfencedVisitor, MtextVisitor, MtableVisitor, MtrVisitor, MtdVisitor, MspaceVisitor, MstyleVisitor, MerrorVisitor, MencloseVisitor, MmultiscriptsVisitor, MpaddedVisitor, MphantomVisitor, MactionVisitor, MsVisitor, MglyphVisitor, MlabeledtrVisitor, MaligngroupVisitor, MalignmarkVisitor, ) class LegacyMathMLToTeXConverter: """ Backward-compatible adapter for the original MathMLToTeXConverter. This class maintains the same API as the original converter but internally uses the refactored visitor/strategy architecture. Args: wrap_identifiers_in_mathrm: Whether to wrap multi-char identifiers in \\mathrm{} prefer_xlongequal: Whether to use \\xlongequal for reaction arrows subject_mode: Subject-specific formatting mode (auto, math, physics, chemistry, statistics) Examples: Basic usage: >>> converter = LegacyMathMLToTeXConverter() >>> latex = converter.convert('sinx') >>> latex '\\sin x' Chemistry mode: >>> converter = LegacyMathMLToTeXConverter(subject_mode='chemistry') >>> latex = converter.convert('Fe') >>> latex '\\mathrm{Fe}' """ def __init__( self, wrap_identifiers_in_mathrm: bool = False, # CRITICAL FIX: Default should be False like original prefer_xlongequal: bool = True, subject_mode: str = "auto", universal_delimiter_scaling: bool = True, # NEW: Add \left\right to ALL delimiters ) -> None: """Initialize the legacy converter with original API parameters.""" self.wrap_identifiers_in_mathrm = wrap_identifiers_in_mathrm self.prefer_xlongequal = prefer_xlongequal self.subject_mode = subject_mode self.universal_delimiter_scaling = universal_delimiter_scaling # Internal state (mimics original converter) self._script_context_stack = [] self._symbol_contexts = {} self._fraction_symbols = set() self._set_theory_mode = False self._equation_symbols = set() self._equation_text = "" self._uniform_equation_color = "" self._suppress_leaf_colors = False self._uniform_background_color = "" # Background/highlight color from OMML # Initialize strategy based on subject mode self._strategy = self._create_strategy() # Initialize visitor registry with all visitors self._visitor_registry = self._create_visitor_registry() def _create_strategy(self) -> Strategy: """ Create conversion strategy based on subject mode. Returns: Strategy instance for the configured subject mode """ if self.subject_mode == "auto": # Auto-detection will be done per equation in convert() return MathStrategy(wrap_identifiers_in_mathrm=self.wrap_identifiers_in_mathrm) # Default fallback elif self.subject_mode == "chemistry": return ChemistryStrategy() elif self.subject_mode == "physics": return PhysicsStrategy() elif self.subject_mode == "statistics": return StatisticsStrategy() elif self.subject_mode == "math": return MathStrategy(wrap_identifiers_in_mathrm=self.wrap_identifiers_in_mathrm) else: # Unknown mode - default to math return MathStrategy(wrap_identifiers_in_mathrm=self.wrap_identifiers_in_mathrm) def _create_visitor_registry(self, context: 'ConversionContext' = None) -> VisitorRegistry: """ Create and populate visitor registry with all known visitors. Args: context: Optional conversion context to pass to all visitors Returns: Configured VisitorRegistry instance """ registry = VisitorRegistry(context) # Register all MathML element visitors # Namespace handling: strip namespace prefix if present registry.register('mi', MiVisitor) registry.register('mo', MoVisitor) registry.register('mn', MnVisitor) registry.register('mfrac', MfracVisitor) registry.register('msup', MsupVisitor) registry.register('msub', MsubVisitor) registry.register('mrow', MrowVisitor) registry.register('msqrt', MsqrtVisitor) registry.register('mroot', MrootVisitor) registry.register('msubsup', MsubsupVisitor) registry.register('mover', MoverVisitor) registry.register('munder', MunderVisitor) registry.register('munderover', MunderoverVisitor) registry.register('mfenced', MfencedVisitor) registry.register('mtext', MtextVisitor) registry.register('mtable', MtableVisitor) registry.register('mtr', MtrVisitor) registry.register('mtd', MtdVisitor) registry.register('mspace', MspaceVisitor) registry.register('mstyle', MstyleVisitor) registry.register('merror', MerrorVisitor) registry.register('menclose', MencloseVisitor) registry.register('mmultiscripts', MmultiscriptsVisitor) registry.register('mpadded', MpaddedVisitor) registry.register('mphantom', MphantomVisitor) registry.register('maction', MactionVisitor) registry.register('ms', MsVisitor) registry.register('mglyph', MglyphVisitor) registry.register('mlabeledtr', MlabeledtrVisitor) registry.register('maligngroup', MaligngroupVisitor) registry.register('malignmark', MalignmarkVisitor) # Use mrow as default visitor (handles container elements like ) registry.set_default_visitor(MrowVisitor) return registry def convert(self, mathml_xml: str) -> str: """ Convert MathML XML to LaTeX. Args: mathml_xml: MathML XML string Returns: LaTeX string Raises: ValueError: If MathML XML is invalid Examples: >>> converter = LegacyMathMLToTeXConverter() >>> converter.convert('x') 'x' """ # Store original equation text for context analysis self._equation_text = mathml_xml try: # IMPORTANT: Preserve NBSP characters before XML parsing # lxml normalizes U+00A0 (NBSP) to regular space during parsing mathml_bytes = mathml_xml.encode("utf-8") mathml_bytes = mathml_bytes.replace(b'\xc2\xa0', b' ') doc = ET.fromstring(mathml_bytes) except ET.XMLSyntaxError as e: raise ValueError(f"Invalid MathML XML: {e}") from e # CRITICAL FIX: Word embeds "&" as column separators in equation arrays # (e.g., -x, &x<0 for piecewise functions) # The XSLT converts this to a single containing & operators # We need to split at these points to create separate table columns self._fix_eqarr_column_separators(doc) # Reset context tracking for new equation self._symbol_contexts = {} self._fraction_symbols = set() self._set_theory_mode = False self._equation_symbols = set() self._uniform_equation_color = "" self._suppress_leaf_colors = False self._uniform_background_color = "" # Detect background color from data-background attribute (set by styling_postprocessor) background_attr = doc.get('data-background') if background_attr: self._uniform_background_color = background_attr # Analyze equation context to detect set theory mode self._analyze_equation_context(doc) # Create conversion context for this equation from ..core.context import ConversionContext context = ConversionContext( input_type='mathml', subject_mode=self.subject_mode, wrap_identifiers=self.wrap_identifiers_in_mathrm, prefer_xlongequal=True ) # Update visitor registry with new context self._visitor_registry.context = context # Step 0: Detect uniform color across entire equation (optimization) from ..utils.color_helpers import detect_uniform_color uniform_color = detect_uniform_color(doc) if uniform_color: # Entire equation uses same color - suppress individual leaf colors # and wrap entire result at the end self._uniform_equation_color = uniform_color self._suppress_leaf_colors = True # Also update context context.uniform_equation_color = uniform_color context.suppress_leaf_colors = True # DISABLED: Auto-detect strategy (user wants full control via mathvariant only) # Auto-detection was overriding user's explicit mathvariant settings # if self.subject_mode == "auto": # self._strategy = StrategyFactory.auto_detect_strategy(mathml_xml) # CRITICAL: Pass wrap_identifiers flag to strategy (especially for auto-detected strategies) if hasattr(self._strategy, 'set_wrap_identifiers_flag'): self._strategy.set_wrap_identifiers_flag(self.wrap_identifiers_in_mathrm) # CRITICAL: Give strategy access to equation text for context detection if hasattr(self._strategy, 'set_equation_text'): self._strategy.set_equation_text(mathml_xml) # CRITICAL: Give strategy access to root element for chemistry detection if hasattr(self._strategy, 'set_root_element'): self._strategy.set_root_element(doc) # CRITICAL: Store converter context in strategy for color suppression # This allows all visitors (including child visitors) to access the suppression flag # since the strategy is shared across all visitors in the conversion tree self._strategy._converter_context = self # Handle root element - process its children # The element is a container element, similar to if self._get_local_name(doc.tag) == 'math': children = list(doc) if not children: # Empty element latex = "" elif len(children) == 1: # Single child - convert directly without adding spaces latex = self._convert_node(children[0]) elif len(children) == 3 and self._is_prescript_pattern(children): # Prescript pattern: subsupbase # This comes from OMML element which places scripts on LEFT side # Result: _{sub}^{sup}base (no leading {} needed for MathJax) left_sub = self._convert_node(children[0]) left_sup = self._convert_node(children[1]) base = self._convert_node(children[2]) latex = f"_{{{left_sub}}}^{{{left_sup}}}{base}" else: # Multiple children - process with intelligent spacing # IMPORTANT: Check for fragmented functions BEFORE converting each child # This handles cases like: cos → \cos parts = [] i = 0 while i < len(children): if not isinstance(children[i].tag, str): i += 1 continue # Check for fragmented function starting at position i function_name, consumed = self._check_fragmented_function_at_level(children, i) if function_name: # Found fragmented function! Add it as \funcname parts.append(f"\\{function_name}") i += consumed # Skip the consumed elements else: # Normal element - convert and add part = self._convert_node(children[i]) # Skip LaTeX space commands (backslash-space) since we join with spaces anyway # This avoids double-spacing: `\sin ` + space + `\ ` = `\sin \ ` if part and part.strip() not in ['', '\\']: # Keep the part unless it's just whitespace or lone backslash parts.append(part) i += 1 # Determine separator based on content pattern # Check if this looks like a chemical formula (no spaces) # vs regular math expression (with spaces) if self._is_chemical_formula_pattern(parts): # Chemical formula: H2O → no spaces latex = ''.join(parts) else: # Regular math: sin x → with spaces latex = ' '.join(parts) else: # Non-math root (shouldn't happen, but handle it) latex = self._convert_node(doc) # Step 2.5: Wrap entire equation with uniform color if detected # BUT: Skip wrapping if color is black (default color) if self._uniform_equation_color: from ..utils.color_helpers import wrap_with_uniform_color latex = wrap_with_uniform_color(latex, self._uniform_equation_color) # Reset suppression flag for next equation self._suppress_leaf_colors = False self._uniform_equation_color = "" # Step 2.6: Wrap entire equation with background color if detected # This uses \bbox[color,padding]{content} from amsmath if self._uniform_background_color: from ..utils.color_helpers import wrap_with_background_color latex = wrap_with_background_color(latex, self._uniform_background_color) # Reset for next equation self._uniform_background_color = "" # Step 3: Apply full 7-step post-processing pipeline # Import chemical elements and units for post-processor from ..data import CHEMICAL_ELEMENTS, COMMON_UNITS from ..converters.post_processor import PostProcessor post_processor = PostProcessor( chemical_elements=CHEMICAL_ELEMENTS, common_units=COMMON_UNITS, subject_mode=self._strategy.name # Pass actual strategy name (after auto-detection) ) latex = post_processor.process( latex, set_theory_mode=self._set_theory_mode, subject_mode=self.subject_mode, skip_delimiter_processing=self.universal_delimiter_scaling # Skip if using new processor ) # Step 4: Apply universal delimiter scaling if enabled # Phase 3 Fix: Use new delimiter_pairing_processor which respects __DELIM__ markers if self.universal_delimiter_scaling: from ..converters.delimiter_pairing_processor import add_universal_left_right latex = add_universal_left_right(latex, debug=False) # Step 4.1: Resolve brace markers (__LBRACE__/__RBRACE__) # NEW: Context-aware brace marker resolution # This converts source braces (from DOCX) to proper LaTeX delimiters # while preserving generated braces in LaTeX commands (e.g., \mathrm{...}) from ..converters.brace_marker_resolver import BraceMarkerResolver brace_resolver = BraceMarkerResolver(debug=False) latex = brace_resolver.resolve(latex) # Step 4.5: Fix double backslashes created by delimiter pairing # The delimiter pairing processor can create \\left\{ when processing escaped braces # This cleanup removes double backslashes before LaTeX commands, preserving only # line break double backslashes (\\ followed by space) # FIX BUG-007: Also match \\ before { or } (escaped braces) import re latex = re.sub( r'\\\\(?=[a-zA-Z{])', # Match \\ followed by a letter or { (lookahead) r'\\', # Replace with single backslash latex ) # Step 4.6: [DEPRECATED - Replaced by brace_marker_resolver] # Old regex-based brace handling - now handled by BraceMarkerResolver # The new marker-based system properly distinguishes source braces from # generated braces and handles pairing with context awareness # escaped_delimiter_pairs = [ # (r'(?0 \right\}) # Strategy: Only fix if \mid is at start of equation (standalone absolute value) if latex.startswith('\\mid ') and latex.endswith('|'): # Standalone \mid...| expression → convert to \left|...\right| latex = re.sub( r'^\\mid\s+(.+?)\|$', # ^match start, content, end| (no space before |) r'\\left| \1 \\right|', latex ) # Step 4.8: Universal delimiter scaling (Phase 3) # Add \left\right to LaTeX delimiter commands that don't have them # This catches remaining delimiters like \langle, \rangle, \| # that weren't created with proper OMML delimiter structures # Handle \langle...\rangle (angle brackets) if r'\langle' in latex and r'\left\langle' not in latex: latex = re.sub( r'\\langle\s*(.+?)\s*\\rangle', r'\\left\\langle \1 \\right\\rangle', latex ) # Handle \|...\| (double vertical bars for norm notation) if r'\|' in latex and r'\left\|' not in latex: latex = re.sub( r'\\\|\s*(.+?)\s*\\\|', r'\\left\\| \1 \\right\\|', latex ) # Clean up and return # Replace all newlines with spaces (important for inline equations) latex = latex.replace('\n', ' ') return latex.strip() def _post_process_consecutive_equals(self, latex: str) -> str: """ Post-process the entire LaTeX output to fix consecutive equals patterns. This handles cases where equals signs are split across multiple mtext elements, like '+3C=' followed by '\\xlongequal{\\quad}', which should become '+3C \\xlongequal{\\quad}' (remove the extra =). Also ensures all xlongequal instances have proper [\quad] brackets. Args: latex: Input LaTeX string Returns: LaTeX string with consecutive equals fixed """ import re # Pattern to match: equals sign followed by \xlongequal # This handles: "=" + space + "\xlongequal" or "=\xlongequal" pattern = r'=\s*\\xlongequal\{([^}]*)\}' # Replace with just the xlongequal (remove the leading =) result = re.sub(pattern, r'\\xlongequal{\1}', latex) # Safety net: Also catch raw consecutive equals in final output # Pattern for 2+ consecutive equals signs (with optional spaces) pattern2 = r'=\s*=\s*=+' result = re.sub(pattern2, r'\\xlongequal[\\quad]{}', result) # Fix xlongequal instances that don't have brackets (but preserve existing brackets) # Only match \xlongequal{content} where there are NO brackets at all pattern3 = r'\\xlongequal(?!\[)(\{[^}]*\})' result = re.sub(pattern3, r'\\xlongequal[\\quad]\1', result) # Fix xlongequal instances that have empty brackets pattern4 = r'\\xlongequal\[\]\{([^}]*)\}' result = re.sub(pattern4, r'\\xlongequal[\\quad]{\1}', result) return result def _process_delimiters(self, latex: str) -> str: """ Post-process LaTeX to add \\left and \\right to delimiters for proper scaling. This method processes __DELIM__ markers added during conversion and determines which delimiters need \left/\right commands based on their content. Based on original converter lines 5274-5509. """ import re # Find all marked delimiters delim_pattern = r'__DELIM__([()[\]{}|])__DELIM__' delimiters = [] for match in re.finditer(delim_pattern, latex): delimiters.append({ 'char': match.group(1), 'start': match.start(), 'end': match.end(), 'full_match': match.group(0) }) if not delimiters: return latex # Dictionary of delimiter pairs delimiter_pairs = { '(': ')', '[': ']', '{': '}', '|': '|' } # Define opening and closing delimiters opening_delims = set(['(', '[', '{', '|']) closing_delims = set([')', ']', '}', '|']) # Stack to track opening delimiters stack = [] pairs = [] # First pass: identify delimiter pairs for delim in delimiters: char = delim['char'] # Special handling for pipe - it pairs with itself if char == '|': pipe_in_stack = False for i, stacked_delim in enumerate(stack): if stacked_delim['char'] == '|': opening_delim = stack.pop(i) pairs.append((opening_delim, delim)) pipe_in_stack = True break if not pipe_in_stack: stack.append(delim) elif char in opening_delims: stack.append(delim) elif char in closing_delims: if stack: opening_delim = stack.pop() pairs.append((opening_delim, delim)) # Build replacement list result = latex all_replacements = [] for opening, closing in pairs: # Extract content between delimiters start_pos = opening['end'] end_pos = closing['start'] content = latex[start_pos:end_pos] # Check if content needs scaling if opening['char'] == '|' or closing['char'] == '|': needs_scaling = True else: needs_scaling = self._needs_delimiter_scaling(content) if needs_scaling: all_replacements.append((closing['start'], closing['end'], closing['char'], True)) all_replacements.append((opening['start'], opening['end'], opening['char'], True)) else: all_replacements.append((closing['start'], closing['end'], closing['char'], False)) all_replacements.append((opening['start'], opening['end'], opening['char'], False)) # Sort by position (descending) to process from end to beginning all_replacements.sort(key=lambda x: x[0], reverse=True) # Apply replacements for start, end, char, scale in all_replacements: if scale: if char in ['(', '[', '{']: latex_char = '\\{' if char == '{' else char replacement = f"\\left{latex_char}" elif char == '|': # Determine if opening or closing by checking position is_opening = any(opening['start'] == start for opening, closing in pairs) replacement = "\\left|" if is_opening else "\\right|" else: latex_char = '\\}' if char == '}' else char replacement = f"\\right{latex_char}" else: replacement = char result = result[:start] + replacement + result[end:] # Cleanup: Remove any remaining __DELIM__ markers result = re.sub(r'__DELIM__(.+?)__DELIM__', r'\1', result) result = re.sub(r'__DELIM__', '', result) return result def _needs_delimiter_scaling(self, content: str) -> bool: """ Determine if content between delimiters needs scaling (\left and \right). Based on original converter lines 6482-6507. """ 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 ] # 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 def _post_process_xlongequal_spacing(self, latex: str) -> str: r""" Remove extra spaces in xlongequal bracket and brace content. Converts patterns like: - \xlongequal[d e f g]{abc} → \xlongequal[defg]{abc} - \xlongequal[a b c]{x y z} → \xlongequal[abc]{xyz} Args: latex: Input LaTeX string Returns: LaTeX string with spaces removed from xlongequal content """ import re def remove_spaces_in_xlongequal(match): bracket_content = match.group(1) brace_content = match.group(2) # Remove all whitespace bracket_clean = re.sub(r'\s+', '', bracket_content) brace_clean = re.sub(r'\s+', '', brace_content) return f'\\xlongequal[{bracket_clean}]{{{brace_clean}}}' # Match \xlongequal[...]{...} and remove spaces result = re.sub(r'\\xlongequal\[([^\]]*)\]\{([^}]*)\}', remove_spaces_in_xlongequal, latex) return result def _check_fragmented_function_at_level(self, children: List, start_idx: int) -> Tuple[str, int]: """ Check if elements starting at start_idx form a fragmented function. Detects patterns like: - cos → "cos", 3 - sin → "sin", 3 - tan → "tan", 3 - sin → "sin", 3 Args: children: List of child elements start_idx: Index to start checking from Returns: Tuple of (function_name, elements_consumed) or ("", 0) if no function found """ from ..utils import text_content as _text_content from ..data import MATH_FUNCTIONS if start_idx >= len(children): return "", 0 letters = [] elements_checked = 0 for i in range(start_idx, len(children)): child = children[i] local_tag = self._get_local_name(child.tag) # Case 1: Direct element with single letter if local_tag == "mi": content = _text_content(child).strip() # Must be exactly one letter if len(content) == 1 and content.isalpha(): letters.append(content) elements_checked += 1 else: # Not a single letter, stop checking break # Case 2: with single child (MathJax pattern) elif (local_tag == "mrow" and len(list(child)) == 1 and self._get_local_name(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) elements_checked += 1 else: # Not a single letter, stop checking break else: # Not an mi or mrow-mi element, stop checking break # Need at least 2 letters to form a function if len(letters) < 2: return "", 0 # Try different combinations, starting from the longest possible for length in range(min(len(letters), 6), 1, -1): potential_function = ''.join(letters[:length]) if potential_function in MATH_FUNCTIONS: return potential_function, length return "", 0 def _is_prescript_pattern(self, children: list) -> bool: """ Detect if children match the prescript pattern from OMML element. Prescript pattern: exactly 3 consecutive elements - First : left subscript - Second : left superscript - Third : base element This pattern is generated by Microsoft's OMML→MathML transformation for (prescript) elements. Args: children: List of child elements Returns: True if this matches prescript pattern """ if len(children) != 3: return False # All 3 children must be elements return all( isinstance(child.tag, str) and self._get_local_name(child.tag) == 'mrow' for child in children ) def _is_chemical_formula_pattern(self, parts: List[str]) -> bool: """ Detect if parts represent a chemical formula pattern. Chemical formulas typically have: - Element symbols wrapped in \\mathrm{} - Numbers (stoichiometric coefficients) - Superscripts for charges (ions) Args: parts: List of LaTeX parts from converted children Returns: True if pattern looks like a chemical formula """ if not parts: return False # Heuristic: If most parts are \\mathrm{...} wrapped or plain numbers, # it's likely a chemical formula mathrm_count = sum(1 for p in parts if p.startswith('\\mathrm{')) number_count = sum(1 for p in parts if p.isdigit()) # If more than half are chemical elements or numbers, treat as chemical formula chemical_indicators = mathrm_count + number_count return chemical_indicators > len(parts) / 2 def _get_local_name(self, tag: str) -> str: """ Extract local element name from tag (strip namespace). Args: tag: Element tag (may include namespace) Returns: Local element name without namespace """ if isinstance(tag, str) and '}' in tag: return tag.split('}')[1] return tag def _convert_node(self, node: ET.Element) -> str: """ Convert a MathML node to LaTeX using visitor pattern. Args: node: MathML element to convert Returns: LaTeX string """ # Strip namespace from tag if present # e.g., "{http://www.w3.org/1998/Math/MathML}mi" → "mi" tag_name = self._get_local_name(node.tag) # Get visitor for this element type try: visitor = self._visitor_registry.get_visitor(tag_name, self._strategy) except ValueError as e: # DEBUG: Log failed lookups for critical elements if tag_name in ['mmultiscripts', 'munderover']: debug_print(f"[DEBUG] Failed to get visitor for <{tag_name}>: {e}") debug_print(f"[DEBUG] Registered visitors: {self._visitor_registry.get_registered_elements()}") visitor = None if visitor is not None: # Pass converter instance to visitor for context access (color suppression, etc.) # Visitors can check self._suppress_leaf_colors through the converter if hasattr(visitor, 'set_converter_context'): visitor.set_converter_context(self) # Use visitor to convert (visitors handle their own recursion) return visitor.visit(node) else: # Fallback: process children # DEBUG: Log when falling back for critical elements if tag_name in ['mmultiscripts', 'munderover']: from ..utils import text_content content = text_content(node) debug_print(f"[DEBUG] Falling back for <{tag_name}> content={content[:60]}") result = [] for child in node: if isinstance(child.tag, str): # Skip comments, processing instructions result.append(self._convert_node(child)) return ''.join(result) def _has_set_brace_context(self, pipe_node: ET.Element) -> bool: """ Check if a pipe symbol appears in a set brace context like {x | conditions}. This method walks up the tree to find if there's an opening brace before the pipe. Args: pipe_node: The | element to check Returns: True if pipe appears after an opening brace, False otherwise """ from ..utils import text_content as _text_content, local_name as _local current = pipe_node found_opening_brace = False # Look for opening brace before the pipe while current is not None: parent = current.getparent() if parent is None: break # Check all siblings before this node for opening brace for sibling in parent: if sibling == current: break if _local(sibling.tag) == "mo" and _text_content(sibling).strip() == "{": found_opening_brace = True break if found_opening_brace: break current = parent return found_opening_brace def _fix_eqarr_column_separators(self, doc: ET.Element) -> None: """ Fix equation array column separators by splitting cells at & operators. Word embeds "&" as column separators in equation arrays. The XSLT converter creates a single containing & operators. This method splits those cells into separate columns. Args: doc: The MathML document root element """ from ..utils import local_name as _local # Find all mtable elements MATHML_NS = "http://www.w3.org/1998/Math/MathML" # Try both with and without namespace mtables = doc.findall(f".//{{{MATHML_NS}}}mtable") if not mtables: # Try without namespace prefix mtables = [el for el in doc.iter() if _local(el.tag) == 'mtable'] for mtable in mtables: # Process each row mtrs = mtable.findall(f"{{{MATHML_NS}}}mtr") # Try without namespace if not found if not mtrs: mtrs = [el for el in mtable if _local(el.tag) == 'mtr'] for mtr in mtrs: mtds = mtr.findall(f"{{{MATHML_NS}}}mtd") if not mtds: mtds = [el for el in mtr if _local(el.tag) == 'mtd'] # Check if any mtd contains & in text content needs_splitting = False for mtd in mtds: # Look for "&" in ANY child element's text for child in mtd: if child.text and '&' in child.text: needs_splitting = True break if needs_splitting: break if needs_splitting: # Remove all existing mtd elements from the row for mtd in list(mtds): mtr.remove(mtd) # For each original mtd, split by & in text content for mtd in mtds: children = list(mtd) # Find the element containing & split_index = -1 split_element = None for i, child in enumerate(children): if child.text and '&' in child.text: split_index = i split_element = child break if split_index >= 0: # Split the text at & text_before, text_after = split_element.text.split('&', 1) # IMPORTANT: Strip trailing/leading spaces around the & separator # LaTeX array environment handles spacing automatically, and Word's # spaces before & are just for visual alignment in the editor text_before = text_before.rstrip() text_after = text_after.lstrip() # Create first column: elements before split + text_before # Skip trailing space-only elements before the split col1 = ET.Element(f"{{{MATHML_NS}}}mtd") for child in children[:split_index]: # Skip space-only mo/mi/mtext elements before the & if (child.text and child.text.strip() == '' and _local(child.tag) in ('mo', 'mi', 'mtext')): continue # Skip this space element col1.append(child) if text_before: # Create element for text_before elem = ET.Element(split_element.tag) elem.text = text_before # Copy attributes for key, val in split_element.attrib.items(): elem.set(key, val) col1.append(elem) mtr.append(col1) # Create second column: text_after + elements after split col2 = ET.Element(f"{{{MATHML_NS}}}mtd") if text_after: # Create element for text_after elem = ET.Element(split_element.tag) elem.text = text_after # Copy attributes for key, val in split_element.attrib.items(): elem.set(key, val) col2.append(elem) for child in children[split_index + 1:]: col2.append(child) mtr.append(col2) else: # No & found, keep as single cell new_mtd = ET.Element(f"{{{MATHML_NS}}}mtd") for child in children: new_mtd.append(child) mtr.append(new_mtd) def _analyze_equation_context(self, node: ET.Element) -> None: """ Analyze the equation to detect set theory mode and other context. This recursively walks the MathML tree to detect: - Set theory operators (∈, ∉, ⊆, ⊇, etc.) - Set builder notation patterns (pipe after opening brace) Args: node: MathML element to analyze """ from ..utils import text_content as _text_content, local_name as _local local_name_str = _local(node.tag) # Check for set theory symbols in operators if local_name_str == "mo": op_text = _text_content(node) set_theory_ops = ['∈', '∉', '⊆', '⊇', '⊂', '⊃', '∪', '∩', '∖', '∁', '∅'] if op_text in set_theory_ops: self._set_theory_mode = True # Also detect set builder notation patterns: look for { ... | ... } or { ... \mid ... } if op_text == '|' and self._has_set_brace_context(node): self._set_theory_mode = True # Recursively analyze children for child in node: if isinstance(child.tag, str): # Skip comments, processing instructions self._analyze_equation_context(child) def __repr__(self) -> str: """String representation for debugging.""" return ( f"LegacyMathMLToTeXConverter(" f"wrap_identifiers_in_mathrm={self.wrap_identifiers_in_mathrm}, " f"prefer_xlongequal={self.prefer_xlongequal}, " f"subject_mode={self.subject_mode!r})" )