""" LaTeX post-processing pipeline for cleaning up and optimizing converted equations. This module implements a 7-step post-processing pipeline that: 1. Fixes consecutive equals patterns (= → \\xlongequal) 2. Processes delimiters (adds \\left/\\right for scaling) 3. Handles unclosed set notation 4. Adds intelligent spacing after LaTeX commands 5. Performs final cleanup (various pattern fixes) 6. Merges consecutive \\mathrm{} blocks (chemical formulas) 7. Validates output quality Each step is independent and can be disabled if needed. """ import re import logging from typing import List, Dict, Any, Optional from ..utils.debug import debug_print from ..data import MATHJAX_UNSUPPORTED_TO_UNICODE , REGIONAL_SYMBOL_PREFERENCES class PostProcessor: """ LaTeX post-processor that applies cleanup and optimization steps. The pipeline is designed to fix common issues from MathML → LaTeX conversion: - Fragmented chemical formulas: \\mathrm{C}\\mathrm{u} → \\mathrm{Cu} - Missing delimiter scaling: (x) → \\left(x\\right) - Spacing issues: \\sinx → \\sin x - Consecutive equals: = \\xlongequal{} → \\xlongequal{} - Pattern-specific fixes for cleaner output """ def __init__(self, chemical_elements: set = None, common_units: set = None, subject_mode: str = 'auto'): """ Initialize post-processor with optional configuration. Args: chemical_elements: Set of chemical element symbols (for cleanup rules) common_units: Set of common unit abbreviations (for cleanup rules) subject_mode: Subject mode ('auto', 'math', 'chemistry', etc.) """ self.chemical_elements = chemical_elements or set() self.common_units = common_units or set() self.subject_mode = subject_mode # ===== EXPLICIT COMMAND CATEGORIES FOR SPACING ===== # Mathematical functions that need spacing when followed by variables # These should have: \sin x (with space) but NOT: \sin\cos (before another command) self.math_functions = [ # Trigonometric 'sin', 'cos', 'tan', 'cot', 'sec', 'csc', 'arcsin', 'arccos', 'arctan', 'arccot', 'arcsec', 'arccsc', 'sinh', 'cosh', 'tanh', 'coth', 'sech', 'csch', # Arc-hyperbolic (inverse hyperbolic functions) 'arcsinh', 'arccosh', 'arctanh', 'arccoth', 'arcsech', 'arccsch', # Also support alternative notation (asinh, etc.) 'asinh', 'acosh', 'atanh', 'acoth', 'asech', 'acsch', # Logarithmic 'log', 'ln', 'lg', 'exp', # Limits and extrema 'lim', 'limsup', 'liminf', 'sup', 'inf', 'max', 'min', # Linear algebra 'det', 'dim', 'ker', 'arg', 'deg', 'gcd', 'hom', 'Pr', ] # Large operators that need special handling # These need space before variables: \int x, \sum x # But NOT before modifiers: \int\limits, \sum\nolimits self.large_operators = [ 'int', 'iint', 'iiint', 'oint', 'oiint', 'oiiint', 'sum', 'prod', 'coprod', 'bigcup', 'bigcap', 'bigsqcup', 'bigvee', 'bigwedge', 'bigodot', 'bigoplus', 'bigotimes', 'biguplus', ] # Modifiers that should NEVER have space before them # Example: \int\limits (no space), \sum\nolimits (no space) self.modifiers_no_space_before = [ 'limits', 'nolimits', 'displaylimits', ] # Style/size commands that should NEVER have space after them # Example: \displaystyle\int (no space), \mathbf{x} (no space) self.style_commands_no_space_after = [ # Display styles 'displaystyle', 'textstyle', 'scriptstyle', 'scriptscriptstyle', # Font commands 'mathbf', 'mathit', 'mathrm', 'mathbb', 'mathcal', 'mathfrak', 'mathsf', 'mathtt', 'mathscr', 'mathbfup', 'mathbfit', 'boldsymbol', 'pmb', # Text commands 'text', 'textbf', 'textit', 'textrm', 'textsf', 'texttt', # Size commands 'big', 'Big', 'bigg', 'Bigg', 'bigl', 'Bigl', 'biggl', 'Biggl', 'bigr', 'Bigr', 'biggr', 'Biggr', 'bigm', 'Bigm', 'biggm', 'Biggm', ] # Delimiter commands that should NEVER have space after them # Example: \left( (no space), \right) (no space) self.delimiter_commands = [ 'left', 'right', 'middle', 'langle', 'rangle', 'lfloor', 'rfloor', 'lceil', 'rceil', 'lvert', 'rvert', 'lVert', 'rVert', 'lgroup', 'rgroup', 'lmoustache', 'rmoustache', ] # Composite delimiter patterns (command + delimiter) # These are multi-character patterns that should never have space in between # Example: \left( (not \left (), \right| (not \right |) self.composite_delimiters = [ # left delimiters r'\left\(', r'\left\)', r'\left\[', r'\left\]', r'\left\{', r'\left\}', r'\left\|', r'\left|', r'\left\langle', r'\left\rangle', r'\left\lfloor', r'\left\rfloor', r'\left\lceil', r'\left\rceil', r'\left\lvert', r'\left\rvert', r'\left\lVert', r'\left\rVert', r'\left.', # \left. for unpaired delimiter # right delimiters r'\right\(', r'\right\)', r'\right\[', r'\right\]', r'\right\{', r'\right\}', r'\right\|', r'\right|', r'\right\langle', r'\right\rangle', r'\right\lfloor', r'\right\rfloor', r'\right\lceil', r'\right\rceil', r'\right\lvert', r'\right\rvert', r'\right\lVert', r'\right\rVert', r'\right.', # \right. for unpaired delimiter # middle delimiters r'\middle\|', r'\middle|', ] # Comparison and relation operators that need spacing # These should have: \lt 5, \gt 10, \leq x self.comparison_operators = [ # Must be sorted by length (longest first) to avoid partial matches 'leqslant', 'geqslant', 'nleqslant', 'ngeqslant', 'nless', 'ngtr', 'nleq', 'ngeq', 'leq', 'geq', 'neq', 'lll', 'ggg', 'll', 'gg', 'lt', 'gt', 'le', 'ge', 'ne', 'approx', 'equiv', 'sim', 'simeq', 'cong', ] # Other symbols that need spacing self.symbols_need_spacing = [ # Geometric 'angle', 'parallel', 'perp', 'triangle', # Set operations 'cap', 'cup', 'subset', 'supset', 'subseteq', 'supseteq', 'notin', 'owns', # 'in' and 'ni' removed to avoid conflicts # Logic 'forall', 'exists', 'nexists', 'neg', 'land', 'lor', 'implies', 'iff', # Operators 'square', 'circ', 'cdot', 'times', 'div', 'pm', 'mp', 'ast', 'star', 'dagger', 'ddagger', # Arrows (basic and extended) 'to', 'gets', 'mapsto', 'longmapsto', 'uparrow', 'downarrow', 'updownarrow', # Single arrows 'Uparrow', 'Downarrow', 'Updownarrow', # Double arrows 'leftarrow', 'rightarrow', 'leftrightarrow', # Horizontal arrows 'Leftarrow', 'Rightarrow', 'Leftrightarrow', # Double horizontal 'longleftarrow', 'longrightarrow', 'longleftrightarrow', # Long arrows 'Longleftarrow', 'Longrightarrow', 'Longleftrightarrow', # Long double 'nearrow', 'searrow', 'swarrow', 'nwarrow', # Diagonal arrows # Arrow plurals (double arrows stacked) 'leftrightarrows', 'rightleftarrows', 'leftleftarrows', 'rightrightarrows', 'upuparrows', 'downdownarrows', 'updownarrows', 'downuparrows', # Harpoons 'leftharpoonup', 'leftharpoondown', 'rightharpoonup', 'rightharpoondown', 'leftrightharpoons', 'rightleftharpoons', 'upharpoonleft', 'upharpoonright', 'downharpoonleft', 'downharpoonright', # Hooked arrows 'hookleftarrow', 'hookrightarrow', # Curved arrows 'curvearrowleft', 'curvearrowright', 'circlearrowleft', 'circlearrowright', # Twohead arrows 'twoheadleftarrow', 'twoheadrightarrow', ] # Common symbols that should NOT be split (for protection) # These are added to prevent partial matches (e.g., \inf inside \infty, \cdot inside \cdots) self.protected_symbols = [ # ===== SPECIAL COMMANDS THAT MUST NOT BE SPLIT ===== # Protect \genfrac from being split into \ge + nfrac 'genfrac', # Protect array-related commands 'begin', 'end', # ===== GREEK LETTERS ===== 'infty', 'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 'theta', 'iota', 'kappa', 'lambda', 'mu', 'nu', 'xi', 'omicron', 'pi', 'rho', 'sigma', 'tau', 'upsilon', 'phi', 'chi', 'psi', 'omega', 'Gamma', 'Delta', 'Theta', 'Lambda', 'Xi', 'Pi', 'Sigma', 'Upsilon', 'Phi', 'Psi', 'Omega', 'varphi', 'varepsilon', 'vartheta', 'varrho', 'varsigma', # ===== SPECIAL SYMBOLS ===== 'partial', 'nabla', 'emptyset', 'varnothing', # ===== BRACKET/DELIMITER SYMBOLS (ll/parallel are prefixes) ===== 'llbracket', 'rrbracket', 'llparenthesis', 'rrparenthesis', 'parallelogram', # parallel is prefix # ===== ELLIPSIS (cdot is prefix of cdots) ===== 'cdots', 'ldots', 'vdots', 'ddots', 'iddots', # ===== TRIGONOMETRIC FUNCTIONS ===== # Hyperbolic functions (sin/cos/tan/cot/sec/csc are prefixes) 'sinh', 'cosh', 'tanh', 'coth', 'sech', 'csch', # Arc functions (arcsin/arccos/etc are prefixes of arcsinh/arccosh/etc) 'arcsin', 'arccos', 'arctan', 'arccot', 'arcsec', 'arccsc', # Arc-hyperbolic functions 'arcsinh', 'arccosh', 'arctanh', 'arccoth', 'arcsech', 'arccsch', # ===== LIMITS AND EXTREMA (lim is prefix of limsup/liminf) ===== 'limsup', 'liminf', 'limits', # ===== INTEGRALS (int is prefix of iint/iiint/oint) ===== 'iint', 'iiint', 'oint', 'oiint', 'oiiint', # ===== COMPARISON OPERATORS ===== # leq/geq are prefixes of leqslant/geqslant 'leqslant', 'geqslant', 'nleqslant', 'ngeqslant', # Other comparison variants 'leq', 'geq', 'neq', 'lll', 'ggg', # ll/gg are prefixes 'simeq', # sim is prefix # ===== SET OPERATIONS (subset/supset are prefixes) ===== 'subseteq', 'supseteq', 'subsetneq', 'supsetneq', # ===== LOGIC (ne is prefix of neg/neq/nexists) ===== 'neg', 'neq', 'nexists', # ===== ARROWS ===== # Arrow plurals (leftarrow/rightarrow/etc are prefixes) 'leftrightarrows', 'rightleftarrows', 'leftleftarrows', 'rightrightarrows', 'upuparrows', 'downdownarrows', 'updownarrows', 'downuparrows', # Arrow tails (leftarrow/rightarrow are prefixes) 'leftarrowtail', 'rightarrowtail', # Harpoons 'upharpoonleft', 'upharpoonright', 'downharpoonleft', 'downharpoonright', 'leftrightharpoons', 'rightleftharpoons', # Long arrows 'longleftrightarrow', 'longrightarrow', 'longleftarrow', 'Longleftrightarrow', 'Longrightarrow', 'Longleftarrow', # Two-head arrows 'twoheadleftarrow', 'twoheadrightarrow', # Hooked arrows 'hookleftarrow', 'hookrightarrow', # Curved arrows 'curvearrowleft', 'curvearrowright', 'circlearrowleft', 'circlearrowright', # ===== DELIMITERS ===== 'langle', 'rangle', 'lfloor', 'rfloor', 'lceil', 'rceil', 'lvert', 'rvert', 'lVert', 'rVert', 'lgroup', 'rgroup', 'lmoustache', 'rmoustache', # ===== OPERATORS ===== 'bigcap', 'bigcup', 'bigvee', 'bigwedge', 'bigodot', 'bigoplus', 'bigotimes', 'bigsqcup', 'biguplus', # ===== GEOMETRY ===== 'triangle', 'angle', 'measuredangle', 'sphericalangle', 'triangleq', 'triangleleft', 'triangleright', # ===== MISCELLANEOUS ===== 'parallel', 'nparallel', 'perp', 'models', 'vdash', 'dashv', 'propto', 'varpropto', 'therefore', 'because', ] def process(self, latex: str, set_theory_mode: bool = False, subject_mode: Optional[str] = None, skip_delimiter_processing: bool = False) -> str: # Store for use in validate_output() self._skip_delimiter_processing = skip_delimiter_processing """ Apply full 7-step post-processing pipeline. Args: latex: Raw LaTeX string from conversion set_theory_mode: Whether to enable set theory notation fixes subject_mode: Subject mode ('chemistry', 'math', 'physics', etc.) skip_delimiter_processing: Skip delimiter processing (Phase 3: use new processor instead) Returns: Cleaned and optimized LaTeX string Pipeline Steps: 1. Fix consecutive equals patterns 2. Process delimiters (\\left/\\right) - SKIPPED if skip_delimiter_processing=True 3. Handle unclosed set notation 4. Add intelligent spacing 5. Final cleanup 6. Merge consecutive \\mathrm{} 6.5. Merge fragmented chemistry elements 6.6. Format chemistry constants (K_sp, K_a, etc.) 7. Validate output """ # Step 1: Fix consecutive equals patterns split across elements latex = self.post_process_consecutive_equals(latex) # Step 2: Add \\left and \\right to delimiters for proper scaling # Phase 3 Fix: Skip this if using new delimiter_pairing_processor if not skip_delimiter_processing: latex = self.process_delimiters(latex) # Step 3: Handle unclosed set notation latex = self.post_process_unclosed_sets(latex, set_theory_mode) # Step 4: Apply intelligent spacing enhancements latex = self.add_intelligent_spacing(latex) # Step 5: Final cleanup for specific patterns latex = self.final_cleanup(latex) # Step 5.5: Merge consecutive identical formatting commands (general) # This handles cases like: \mathbf{F} \mathbf{e} → \mathbf{Fe} latex = self.merge_consecutive_formatting(latex) # Step 6: Merge consecutive \\mathrm{} blocks (chemical formulas) latex = self.merge_consecutive_mathrm(latex) # Step 6.3: Wrap ion charges in \\mathrm{} (chemistry mode only) if self.subject_mode == 'chemistry': latex = self.wrap_ion_charges_in_mathrm(latex) # Step 6.5: Merge fragmented chemistry elements with charges (safety net) latex = self.merge_fragmented_chemistry_elements(latex) # Step 6.6: Format chemistry constants (K_{sp} → K_{\mathrm{sp}}) # This is intelligent: detects chemistry context and applies formatting automatically latex = self.format_chemistry_constants(latex, subject_mode=subject_mode) # Step 6.7: Apply Unicode fallback for MathJax-unsupported commands latex = self.apply_mathjax_unicode_fallback(latex) # Step 7: Validate output (detect and fix remaining issues) latex = self.validate_output(latex) return latex 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 =). Args: latex: LaTeX string with potential consecutive equals issues Returns: LaTeX with fixed equals patterns Examples: "= \\xlongequal{text}" → "\\xlongequal{text}" "= = =" → "\\xlongequal[\\quad]{}" "\\xlongequal{text}" → "\\xlongequal[\\quad]{text}" """ # Pattern 1: equals sign followed by \\xlongequal (remove leading =) latex = re.sub(r'=\s*\\xlongequal\{([^}]*)\}', r'\\xlongequal{\1}', latex) # Pattern 2: Multiple consecutive equals signs → xlongequal latex = re.sub(r'=\s*=\s*=+', r'\\xlongequal[\\quad]{}', latex) # Pattern 3: xlongequal without brackets → add [\\quad] latex = re.sub(r'\\xlongequal(?!\[)(\{[^}]*\})', r'\\xlongequal[\\quad]\1', latex) # Pattern 4: xlongequal with empty brackets → add [\\quad] latex = re.sub(r'\\xlongequal\[\]\{([^}]*)\}', r'\\xlongequal[\\quad]{\1}', latex) return latex def _handle_set_notation(self, latex: str) -> str: """ Detect and handle set notation patterns before general delimiter processing. Set notation pattern: {content|conditions} In this pattern: - The outer { and } are kept as delimiters (will become \\left\\{...\\right\\}) - The | becomes \\mid (set separator, not a delimiter) - Inner delimiters are processed normally Paired pipe pattern: |content| In this pattern: - Both | characters should be paired delimiters (will become \\left|...\\right|) - These are absolute values or norms, NOT set notation Args: latex: LaTeX string with __DELIM__ markers Returns: LaTeX with set notation handled (| → \\mid only when in braces, preserved otherwise) Examples: Set notation: "__DELIM__{__DELIM__(x,y)__DELIM__|__DELIM__{...}__DELIM__}__DELIM__" → "__DELIM__{__DELIM__(x,y) \\mid {...}__DELIM__}__DELIM__" Absolute value: "__DELIM__|__DELIM__x__DELIM__|__DELIM__" → unchanged (will be paired) """ # Debug: Show BEFORE if '__DELIM__|__DELIM__' in latex: preview_before = latex[:200] if len(latex) > 200 else latex debug_print(f"[DEBUG SET] BEFORE: {repr(preview_before)}") # Strategy: Only convert | to \mid when it's clearly in set notation context # Detect set notation: { ... | ... } pattern # Do NOT convert paired absolute values: | ... | pattern # Find all pipe positions pipe_pattern = r'__DELIM__(?:STRETCHY::)?\|__DELIM__' pipes = list(re.finditer(pipe_pattern, latex)) if not pipes: return latex # Find all brace positions (both marked and already-converted) # Pattern 1: Marked braces __DELIM__{__DELIM__ or __DELIM__}__DELIM__ (with optional STRETCHY prefix) # Pattern 2: Already-converted braces \left\{ or \right\} brace_pattern = r'(__DELIM__(?:STRETCHY::)?([{}])__DELIM__|\\left\\{|\\right\\})' braces = list(re.finditer(brace_pattern, latex)) # Build list of pipes to convert (those between matching braces) pipes_to_convert = [] for pipe in pipes: pipe_pos = pipe.start() # Find the nearest enclosing brace pair # Look for { before pipe and } after pipe left_brace = None right_brace = None # Find nearest { before pipe (check both marked and converted forms) for brace in reversed(braces): brace_text = brace.group(1) if brace.start() < pipe_pos: # Check if it's an opening brace # Group 2 captures the brace char if it's a marked delimiter if (brace.group(2) == '{') or brace_text == '\\left\\{': left_brace = brace break elif (brace.group(2) == '}') or brace_text == '\\right\\}': # Skip closing braces continue # Find nearest } after pipe (check both marked and converted forms) for brace in braces: brace_text = brace.group(1) if brace.start() > pipe_pos: # Check if it's a closing brace if (brace.group(2) == '}') or brace_text == '\\right\\}': right_brace = brace break elif (brace.group(2) == '{') or brace_text == '\\left\\{': # Skip opening braces continue # If pipe is between { and }, it's set notation → convert to \mid if left_brace and right_brace: pipes_to_convert.append(pipe) debug_print(f"[DEBUG SET] Pipe at {pipe_pos} is between braces → convert to \\mid") else: debug_print(f"[DEBUG SET] Pipe at {pipe_pos} is NOT between braces → keep as delimiter for pairing") # Convert marked pipes to \mid (in reverse order to preserve positions) result = latex for pipe in reversed(pipes_to_convert): result = result[:pipe.start()] + ' \\mid ' + result[pipe.end():] # Debug: Check if pattern matched if result != latex: preview_after = result[:200] if len(result) > 200 else result debug_print(f"[DEBUG SET] AFTER: {repr(preview_after)}") debug_print(f"[DEBUG SET] Converted {len(pipes_to_convert)} pipes to \\mid (out of {len(pipes)} total)") return result def process_delimiters(self, latex: str) -> str: """ Post-process LaTeX to add \\left and \\right to delimiters for proper scaling. Delimiters are marked with __DELIM__ during conversion to indicate they need scaling. This method pairs them and adds \\left/\\right commands. Args: latex: LaTeX string with __DELIM__ markers Returns: LaTeX with \\left/\\right added to delimiter pairs Examples: "__DELIM__(x)__DELIM__" → "\\left(x\\right)" "__DELIM__|x|__DELIM__" → "\\left|x\\right|" """ # STEP 1: Detect and handle set notation BEFORE general delimiter processing # Pattern: {content|conditions} → content \mid conditions # The outer { is removed and | becomes \mid (not a delimiter) latex = self._handle_set_notation(latex) # Pattern to match marked delimiters (including Unicode angle brackets, ceil, floor, and LaTeX commands) # Match either: # 1. Single character delimiters: () [] {} | ⟨⟩ etc. # 2. LaTeX command delimiters: \langle \rangle \lceil \rceil \lfloor \rfloor # UPDATED: Support STRETCHY:: prefix and escaped braces delim_pattern = r'__DELIM__(?:STRETCHY::)?(\\(?:langle|rangle|lceil|rceil|lfloor|rfloor|{|})|[()[\]{}|⟨⟩〈〉⌈⌉⌊⌋])__DELIM__' # Find all delimiters and their positions delimiters = [] for match in re.finditer(delim_pattern, latex): delimiters.append({ 'char': match.group(2), 'is_stretchy': bool(match.group(1)), 'start': match.start(), 'end': match.end(), 'full_match': match.group(0) }) if not delimiters: # No __DELIM__ markers found - either already processed or not needed return latex # Dictionary of delimiter pairs delimiter_pairs = { '(': ')', '[': ']', '{': '}', '\\{': '\\}', # Escaped braces '|': '|', # Pipe pairs with itself for absolute value/norms '⟨': '⟩', # Angle brackets (Unicode) '〈': '〉', # CJK angle brackets (Unicode) '⌈': '⌉', # Ceiling (Unicode) '⌊': '⌋', # Floor (Unicode) '\\langle': '\\rangle', # Angle brackets (LaTeX) '\\lceil': '\\rceil', # Ceiling (LaTeX) '\\lfloor': '\\rfloor', # Floor (LaTeX) } # Define opening and closing delimiters opening_delims = set(['(', '[', '{', '\\{', '|', '⟨', '〈', '⌈', '⌊', '\\langle', '\\lceil', '\\lfloor']) closing_delims = set([')', ']', '}', '\\}', '|', '⟩', '〉', '⌉', '⌋', '\\rangle', '\\rceil', '\\rfloor']) # Check if this is set notation context (has \mid in the equation) is_set_notation = '\\mid' in latex # 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 OR closes angle brackets (bra-ket notation) if char == '|': # First, check if there's an angle bracket in the stack (bra notation: ⟨φ| or \langle\phi|) # This takes priority over pipe-pipe pairing langle_in_stack = False for i, stacked_delim in enumerate(stack): if stacked_delim['char'] in ('⟨', '〈', '\\langle'): # Found angle bracket - pair with this pipe as closing delimiter opening_delim = stack.pop(i) pairs.append((opening_delim, delim)) langle_in_stack = True break if not langle_in_stack: # No angle bracket found, check if there's an unpaired pipe in the stack pipe_in_stack = False for i, stacked_delim in enumerate(stack): if stacked_delim['char'] == '|': # Found matching opening pipe - pair them opening_delim = stack.pop(i) pairs.append((opening_delim, delim)) pipe_in_stack = True break if not pipe_in_stack: # No matching pipe found - this is an opening pipe stack.append(delim) elif char in opening_delims: # Opening delimiter stack.append(delim) elif char in closing_delims: # Closing delimiter if stack: # Special case: In set notation, don't pair a } with a { that precedes \begin{array} # This allows the array brace to remain unpaired (will use \right.) opening_delim = stack[-1] # Peek at top of stack # Check if opening brace has array immediately after it skip_pairing = False if is_set_notation and opening_delim['char'] == '{' and char == '}': content_after_opening = latex[opening_delim['end']:opening_delim['end']+30] if '\\begin{array}' in content_after_opening: # This opening brace should stay unpaired - don't pop it # Check if there's another brace before it on the stack if len(stack) > 1: # Pop the array brace BUT PUT IT BACK after pairing the outer one array_brace = stack.pop() # Remove temporarily opening_delim = stack.pop() # Get the outer brace pairs.append((opening_delim, delim)) stack.append(array_brace) # Put array brace back as unpaired else: # Only the array brace on stack - leave it unpaired skip_pairing = True else: opening_delim = stack.pop() pairs.append((opening_delim, delim)) else: opening_delim = stack.pop() pairs.append((opening_delim, delim)) # Handle unpaired opening delimiters (like single left brace for cases) unpaired_replacements = [] array_right_dots = [] # Track where to insert \right. for array braces for unpaired in stack: if unpaired['char'] == '{': # Check if this brace has \right. position marked (for array braces) if unpaired.get('needs_right_dot'): # This is an array brace - add \left\{ and mark \right. position replacement = '\\left\\{' unpaired_replacements.append((unpaired['full_match'], replacement)) array_right_dots.append(unpaired['right_dot_pos']) else: # Single left brace → use \\left\\{ for regular unpaired braces replacement = '\\left\\{' unpaired_replacements.append((unpaired['full_match'], replacement)) # Second pass: replace delimiter pairs with \\left/\\right # Process in reverse order to maintain position indices result = latex # DEBUG: Show pairing info latex_preview = latex[:80] if len(latex) > 80 else latex if pairs: debug_print(f"[DEBUG DELIM] Found {len(pairs)} delimiter pairs in: {repr(latex_preview)}...") for opening, closing in pairs: debug_print(f" - Pair: {opening['char']} ... {closing['char']}") elif delimiters: debug_print(f"[DEBUG DELIM] Found {len(delimiters)} delimiters but NO pairs in: {repr(latex_preview)}!") for d in delimiters: debug_print(f" - {d['char']} at pos {d['start']}") # DEBUG: Show unpaired delimiters if stack: debug_print(f"[DEBUG UNPAIRED] Found {len(stack)} unpaired delimiters:") for unpaired in stack: context = latex[unpaired['end']:unpaired['end']+40] debug_print(f" - {unpaired['char']} at {unpaired['start']}, after: {context!r}") # Handle unpaired braces that precede arrays (they need \right. after the array) for unpaired in stack: if unpaired['char'] == '{': content_after = latex[unpaired['end']:] array_match = re.search(r'\\begin\{array\}.*?\\end\{array\}', content_after, re.DOTALL) if array_match: # This is an array brace - add \right. after the array array_end_pos = unpaired['end'] + array_match.end() # Add to list for processing array_right_dots.append(array_end_pos) unpaired['needs_right_dot'] = True unpaired['right_dot_pos'] = array_end_pos debug_print(f"[DEBUG UNPAIRED] Array brace at {unpaired['start']} will get \\right. at {array_end_pos}") # Build a list of ALL replacements BEFORE applying any # This prevents position corruption all_replacements = [] # Add paired delimiter replacements for opening, closing in pairs: # CRITICAL: Check if content between delimiters needs scaling start_pos = opening['end'] end_pos = closing['start'] content = latex[start_pos:end_pos] # ALWAYS scale in these cases: # 1. Explicitly requested via stretchy attribute (STRETCHY:: prefix) # 2. Pipes (absolute value/norms) # 3. Angle brackets (⟨⟩ or \langle\rangle), ceil (⌈⌉ or \lceil\rceil), floor (⌊⌋ or \lfloor\rfloor) # 4. Set notation context - all delimiters should be scaled for consistency if opening.get('is_stretchy') or closing.get('is_stretchy'): needs_scaling = True elif opening['char'] == '|' or closing['char'] == '|': needs_scaling = True elif (opening['char'] in ('⟨', '〈', '⌈', '⌊', '\\langle', '\\lceil', '\\lfloor') or closing['char'] in ('⟩', '〉', '⌉', '⌋', '\\rangle', '\\rceil', '\\rfloor')): # Angle brackets, ceil, and floor MUST use \left\langle/\right\rangle, etc. # There's no plain LaTeX equivalent for these Unicode characters needs_scaling = True elif is_set_notation: # In set notation, scale ALL delimiters for consistent appearance needs_scaling = True else: needs_scaling = self._needs_delimiter_scaling(content) # DEBUG debug_print(f"[DEBUG SCALE] Delimiter pair {opening['char']}...{closing['char']}: content={repr(content[:50])}... needs_scaling={needs_scaling} (set_notation={is_set_notation})") # Check if content already has leading/trailing spaces has_leading_space = content.startswith(' ') has_trailing_space = content.endswith(' ') # Build \\left/\\right commands ONLY if scaling is needed if needs_scaling: # Add space after \left only if content doesn't already start with space left_space = '' if has_leading_space else ' ' # Add space before \right only if content doesn't already end with space right_space = '' if has_trailing_space else ' ' # CRITICAL: Escape braces in LaTeX (\\left{ → \\left\\{) if opening['char'] == '{' or opening['char'] == '\\{': left_cmd = f"\\left\\{{{left_space}" elif opening['char'] == '}' or opening['char'] == '\\}': left_cmd = f"\\left\\}}{left_space}" # Shouldn't happen elif opening['char'] == '|': left_cmd = f"\\left|{left_space}" elif opening['char'] in ('⟨', '〈', '⌈', '⌊', '\\langle', '\\lceil', '\\lfloor'): # Map Unicode and LaTeX to LaTeX commands delim_map = { '⟨': '\\langle', '〈': '\\langle', '⌈': '\\lceil', '⌊': '\\lfloor', '\\langle': '\\langle', '\\lceil': '\\lceil', '\\lfloor': '\\lfloor' } left_cmd = f"\\left{delim_map[opening['char']]}{left_space}" else: left_cmd = f"\\left{opening['char']}{left_space}" if closing['char'] == '}' or closing['char'] == '\\}': right_cmd = f"{right_space}\\right\\}}" elif closing['char'] == '{' or closing['char'] == '\\{': right_cmd = f"{right_space}\\right\\{{" # Shouldn't happen elif closing['char'] == '|': right_cmd = f"{right_space}\\right|" elif closing['char'] in ('⟩', '〉', '⌉', '⌋', '\\rangle', '\\rceil', '\\rfloor'): # Map Unicode and LaTeX to LaTeX commands delim_map = { '⟩': '\\rangle', '〉': '\\rangle', '⌉': '\\rceil', '⌋': '\\rfloor', '\\rangle': '\\rangle', '\\rceil': '\\rceil', '\\rfloor': '\\rfloor' } right_cmd = f"{right_space}\\right{delim_map[closing['char']]}" else: right_cmd = f"{right_space}\\right{closing['char']}" else: # No scaling needed - just use plain delimiters left_cmd = opening['char'] right_cmd = closing['char'] # Add to replacements list (closing first, then opening) all_replacements.append((closing['start'], closing['end'], right_cmd)) all_replacements.append((opening['start'], opening['end'], left_cmd)) # Add unpaired delimiter replacements for unpaired in stack: if unpaired['char'] == '{': all_replacements.append((unpaired['start'], unpaired['end'], '\\left\\{')) # Add array \right. insertions if array_right_dots: debug_print(f"[DEBUG ARRAY] Will insert \\right. at positions: {array_right_dots}") for pos in array_right_dots: all_replacements.append((pos, pos, ' \\right.')) # Insertion at position # Sort by position (reverse order) and apply ALL at once all_replacements.sort(key=lambda x: x[0], reverse=True) result = latex for start, end, replacement in all_replacements: result = result[:start] + replacement + result[end:] return result def _needs_delimiter_scaling(self, content: str) -> bool: """ Determine if content between delimiters needs scaling with \\left and \\right. Checks for indicators of tall content like fractions, large operators, etc. Args: content: LaTeX content between delimiters Returns: True if content needs scaling, False otherwise Examples: "x + y" → False (no scaling needed) "\\dfrac{1}{2}" → True (fraction needs scaling) "0 , \\dfrac{\\pi}{2}" → True (contains fraction) """ # Check for indicators that suggest tall content scaling_indicators = [ '\\frac', '\\dfrac', '\\tfrac', # Fractions '\\sum', '\\prod', '\\int', # Large operators '\\sqrt', # Square roots '\\binom', # Binomial coefficients '^{', '_{', # Scripts (especially nested) '\\overset', '\\underset', # Over/under sets '\\limits', # Limits '\\Big', '\\big', # Already scaled content ] # Check for nested delimiters (benefit from scaling) nested_delims = content.count('(') + content.count('[') + content.count('{') # Check for multiple levels (subscripts/superscripts) script_levels = content.count('_') + content.count('^') # Determine if scaling is needed needs_scaling = (any(indicator in content for indicator in scaling_indicators) or nested_delims > 0 or script_levels > 1 or len(content) > 20) # Long expressions often benefit from scaling return needs_scaling def post_process_unclosed_sets(self, latex: str, set_theory_mode: bool) -> str: r""" Handle unclosed set notation like {(x,y) \\mid conditions} without closing brace. This method adds outer braces ONLY if the set notation is implicit (no explicit braces in MathML). If the MathML had explicit braces that were converted to inner \\left\\{...\\right\\}, don't add outer braces. Args: latex: LaTeX string set_theory_mode: Whether set theory mode is active Returns: LaTeX with proper set notation formatting Examples: "(x,y) \\mid x ≠ 1" → "\\left\\{(x,y) \\mid x ≠ 1\\right." (implicit braces) "(x,y) \\mid \\left\\{...\\right\\}" → unchanged (explicit braces already present) """ if set_theory_mode: has_mid = '\\mid' in latex debug_print(f"[DEBUG SET] post_process_unclosed_sets called. mid={has_mid}, mode={set_theory_mode}") # Check if using set theory mode (debug) if set_theory_mode: # Log usage but keep cleaner for production (logging module better) pass if '\\mid' not in latex: return latex # Find position of first \mid mid_idx = latex.find('\\mid') # Check nesting level at \mid position # We need to count braces before the \mid to see if it's already inside a set text_before = latex[:mid_idx] # Count different types of opening braces # 1. Standard/escaped braces # \{ is a brace. { might be a brace or argument (but usually arguments are balanced) # Simpler approach: Count all logical opening braces # \left\{ count_left_cmd = text_before.count('\\left\\{') # __DELIM__{ count_marker = text_before.count('__DELIM__{') # \{ (escaped brace, not part of \left\{) # We subtract count of \left\{ because \{ is inside it count_escaped = text_before.count('\\{') - count_left_cmd open_count = count_left_cmd + count_marker + count_escaped # Count closing braces # \right\} count_right_cmd = text_before.count('\\right\\}') # __DELIM__} count_marker_close = text_before.count('__DELIM__}') # \} count_escaped_close = text_before.count('\\}') - count_right_cmd close_count = count_right_cmd + count_marker_close + count_escaped_close if open_count > close_count: # \mid is nested in braces - skipping wrap return latex # Check if there are already explicit braces after \\mid (from MathML) # Pattern: \\mid followed by content that includes \\left\\{ left_brace_idx = latex.find('\\left\\{', mid_idx) if left_brace_idx > mid_idx and left_brace_idx != -1: # Explicit braces present after \\mid - don't add outer braces return latex # Check if the expression already contains balanced braces (\{ and \}) # This indicates explicit set notation is already present (even if not yet \left\{...\right\}) # Example: "A = \{ x \mid ... \} , B = \{ x \mid ... \}" # Should NOT be wrapped again if '\\{' in latex and '\\}' in latex: # Re-check global balance just in case total_open = latex.count('\\{') total_close = latex.count('\\}') if total_open > 0 and total_open == total_close: return latex # No explicit braces found - add them for implicit set notation # Split on \\mid but be careful about multiple occurrences parts = latex.split('\\mid', 1) # Split only on first \\mid if len(parts) == 2: content_before_mid = parts[0].strip() content_after_mid = parts[1].strip() # Check if this looks like an unclosed set (no \\left\\{ at start) if not content_before_mid.startswith('\\left\\{'): # FIX BUG-007: Strip already-escaped braces from visitor output # When OMML text like {x|conditions} has spaces, visitors escape # the braces to \{x and \}, but we're about to add \left\{ and \right\} # So we need to strip these first to avoid double-escaping content_before_mid = re.sub(r'^\\{', '', content_before_mid) content_after_mid = re.sub(r'\\}$', '', content_after_mid) # Format as proper set notation with CORRECT f-string brace escaping # To output \left\{content\right\}: # - \\left\\{{ outputs \left\{ (backslash-left-backslash, then literal {) # - {content} for variable interpolation # - \\right\\}} outputs \right\} (backslash-right-backslash, then literal }) # CRITICAL: Only TWO closing braces at end (one pair {{), not FOUR (}}}}) return f"\\left\\{{{content_before_mid} \\mid {content_after_mid}\\right\\}}" return latex def add_intelligent_spacing(self, latex: str) -> str: """ Add conservative intelligent spacing for better typographical quality. This applies minimal spacing enhancements that don't conflict with expected outputs. Based on analysis of test failures, the current dataset expects minimal spacing. Args: latex: LaTeX string Returns: LaTeX with improved spacing """ # Clean up multiple consecutive spaces latex = re.sub(r' {3,}', ' ', latex) # Fix spacing around specific LaTeX commands latex = re.sub(r'\\left\s+\\{', r'\\left\\{', latex) latex = re.sub(r'\\right\s+\\}', r'\\right\\}', latex) # Remove spaces inside command names (common parsing error) latex = re.sub(r'\\([a-zA-Z]+)\s+\{', r'\\\1{', latex) # Fix spacing after LaTeX commands latex = self.fix_latex_command_spacing(latex) return latex def fix_latex_command_spacing(self, latex: str) -> str: """ Add proper spacing after LaTeX commands using explicit enumeration. This new implementation avoids partial command matching by: 1. Sorting commands by length (longest first) 2. Using explicit word boundaries 3. Checking for complete command matches Args: latex: LaTeX string Returns: LaTeX with proper command spacing Examples: "\\sinx" → "\\sin x" "\\intx" → "\\int x" "\\int\\limits" → "\\int\\limits" (no space before \\limits) "\\limits" → "\\limits" (not broken into \\lim its) "\\displaystyle\\int" → "\\displaystyle\\int" (no space after style commands) """ # Step 1: Combine all commands that need spacing and sort by length (longest first) # This prevents partial matches (e.g., matching \lim inside \limits) commands_need_spacing = ( self.math_functions + self.large_operators + self.comparison_operators + self.symbols_need_spacing ) # Sort by length (longest first) to avoid partial matches commands_need_spacing_sorted = sorted(set(commands_need_spacing), key=len, reverse=True) # Step 2: Create exclusion set - commands that should NEVER have space after them # Note: We exclude 'left', 'right', 'middle' from blanket no-space rule # because they need special handling (see composite delimiter fixes) delimiter_commands_no_space = [cmd for cmd in self.delimiter_commands if cmd not in ('left', 'right', 'middle')] no_space_after_commands = set( self.style_commands_no_space_after + self.modifiers_no_space_before # These shouldn't have space before them ) # Note: left/right/middle need special handling - see Step 7 for composite patterns # Step 3: Build a set of all known longer commands for each command # This helps us avoid matching prefixes (e.g., don't match \lim in \limits) # IMPORTANT: Include ALL delimiter commands here for protection, even though # some are not in no_space_after_commands (they need composite pattern handling) all_known_commands = set( commands_need_spacing_sorted + list(no_space_after_commands) + self.delimiter_commands + # Include ALL delimiters for protection self.protected_symbols ) # Step 4: Process each command that needs spacing # Use a careful approach: match complete commands only for cmd in commands_need_spacing_sorted: # Find any known longer commands that start with this command # Example: for 'lim', find 'limits', 'limsup', 'liminf' longer_commands = [ longer_cmd[len(cmd):] # Get the suffix after our command for longer_cmd in all_known_commands if longer_cmd.startswith(cmd) and longer_cmd != cmd ] # Build negative lookahead pattern to exclude these longer commands # Example: for 'lim', create (?!its|sup|inf) to avoid matching in \limits, \limsup, \liminf if longer_commands: # Sort by length (longest first) for correct regex matching longer_commands_sorted = sorted(set(longer_commands), key=len, reverse=True) negative_lookahead = '(?!' + '|'.join(re.escape(suffix) for suffix in longer_commands_sorted) + ')' else: negative_lookahead = '' # Pattern: \\cmd not followed by known suffixes, but followed by alphanumeric/( # Example: \\lim(?!its|sup|inf)(?=[a-zA-Z0-9(]) # For arrows/operators: also match when followed by +, -, =, etc. pattern = f'\\\\{re.escape(cmd)}{negative_lookahead}(?=[a-zA-Z0-9(+\\-=])' # Add space after command latex = re.sub(pattern, f'\\\\{cmd} ', latex) # Step 4: Remove spaces before LaTeX commands (backslash) # This handles cases like: \sin \dfrac → \sin\dfrac # Build pattern for all commands that need spacing all_commands_pattern = '|'.join(re.escape(cmd) for cmd in commands_need_spacing_sorted) # Remove space before any backslash command pattern_remove = f'\\\\({all_commands_pattern}) (?=\\\\)' latex = re.sub(pattern_remove, r'\\\1', latex) # Step 5: Remove spaces before modifiers specifically # Example: \int \limits → \int\limits for modifier in self.modifiers_no_space_before: # Remove any space before modifier pattern = f' \\\\{modifier}(?![a-zA-Z])' latex = re.sub(pattern, f'\\\\{modifier}', latex) # Step 6: Remove spaces after style/delimiter commands # Example: \displaystyle \int → \displaystyle\int # Example: \left ( → \left( for cmd in no_space_after_commands: # Remove space after these commands pattern = f'\\\\{re.escape(cmd)}(?![a-zA-Z]) ' latex = re.sub(pattern, f'\\\\{cmd}', latex) # Step 7: Fix composite delimiter patterns (multi-character sequences) # These are patterns like \left(, \right|, etc. that should never have spaces # Example: \left ( → \left(, \right | → \right| composite_fixes = [ # left delimiters with space (r'\\left\s+\(', r'\\left('), (r'\\left\s+\)', r'\\left)'), (r'\\left\s+\[', r'\\left['), (r'\\left\s+\]', r'\\left]'), (r'\\left\s+\\{', r'\\left\\{'), (r'\\left\s+\\}', r'\\left\\}'), (r'\\left\s+\\?\|', r'\\left|'), (r'\\left\s+\\\.', r'\\left.'), # \left \. (r'\\left\s+\.', r'\\left.'), # \left . (plain dot) # right delimiters with space (r'\\right\s+\(', r'\\right('), (r'\\right\s+\)', r'\\right)'), (r'\\right\s+\[', r'\\right['), (r'\\right\s+\]', r'\\right]'), (r'\\right\s+\\{', r'\\right\\{'), (r'\\right\s+\\}', r'\\right\\}'), (r'\\right\s+\\?\|', r'\\right|'), (r'\\right\s+\\\.', r'\\right.'), # \right \. (r'\\right\s+\.', r'\\right.'), # \right . (plain dot) # left with LaTeX delimiter commands (r'\\left\s+\\langle', r'\\left\\langle'), (r'\\left\s+\\rangle', r'\\left\\rangle'), (r'\\left\s+\\lfloor', r'\\left\\lfloor'), (r'\\left\s+\\rfloor', r'\\left\\rfloor'), (r'\\left\s+\\lceil', r'\\left\\lceil'), (r'\\left\s+\\rceil', r'\\left\\rceil'), (r'\\left\s+\\lvert', r'\\left\\lvert'), (r'\\left\s+\\rvert', r'\\left\\rvert'), (r'\\left\s+\\lVert', r'\\left\\lVert'), (r'\\left\s+\\rVert', r'\\left\\rVert'), # right with LaTeX delimiter commands (r'\\right\s+\\langle', r'\\right\\langle'), (r'\\right\s+\\rangle', r'\\right\\rangle'), (r'\\right\s+\\lfloor', r'\\right\\lfloor'), (r'\\right\s+\\rfloor', r'\\right\\rfloor'), (r'\\right\s+\\lceil', r'\\right\\rceil'), (r'\\right\s+\\rceil', r'\\right\\rceil'), (r'\\right\s+\\lvert', r'\\right\\lvert'), (r'\\right\s+\\rvert', r'\\right\\rvert'), (r'\\right\s+\\lVert', r'\\right\\lVert'), (r'\\right\s+\\rVert', r'\\right\\rVert'), # middle delimiters (r'\\middle\s+\\?\|', r'\\middle|'), ] for pattern, replacement in composite_fixes: latex = re.sub(pattern, replacement, latex) # Step 8: Add spaces AFTER opening delimiters and BEFORE closing delimiters # Example: \left(x\right) → \left( x \right) # Example: \left[10\right] → \left[ 10 \right] # Add space after opening delimiters (if not already present) opening_delimiter_patterns = [ # === \left delimiters === # Basic delimiters (r'\\left\((?! )', r'\\left( '), (r'\\left\[(?! )', r'\\left[ '), (r'\\left\\{(?! )', r'\\left\\{ '), (r'\\left\|(?! )', r'\\left| '), # Plain pipe (r'\\left\\\|(?! )', r'\\left\\| '), # Escaped pipe \left\| # LaTeX delimiter commands - left versions (r'\\left\\langle(?! )', r'\\left\\langle '), (r'\\left\\lfloor(?! )', r'\\left\\lfloor '), (r'\\left\\lceil(?! )', r'\\left\\lceil '), (r'\\left\\lvert(?! )', r'\\left\\lvert '), (r'\\left\\lVert(?! )', r'\\left\\lVert '), # LaTeX delimiter commands - right versions (sometimes used as opening) (r'\\left\\rangle(?! )', r'\\left\\rangle '), (r'\\left\\rfloor(?! )', r'\\left\\rfloor '), (r'\\left\\rceil(?! )', r'\\left\\rceil '), (r'\\left\\rvert(?! )', r'\\left\\rvert '), (r'\\left\\rVert(?! )', r'\\left\\rVert '), # Other delimiters (r'\\left\\uparrow(?! )', r'\\left\\uparrow '), (r'\\left\\downarrow(?! )', r'\\left\\downarrow '), (r'\\left\\updownarrow(?! )', r'\\left\\updownarrow '), (r'\\left\\Uparrow(?! )', r'\\left\\Uparrow '), (r'\\left\\Downarrow(?! )', r'\\left\\Downarrow '), (r'\\left\\Updownarrow(?! )', r'\\left\\Updownarrow '), (r'\\left/(?! )', r'\\left/ '), (r'\\left\\backslash(?! )', r'\\left\\backslash '), # === \bigl, \Bigl, \biggl, \Biggl (manual sizing - left) === (r'\\bigl\((?! )', r'\\bigl( '), (r'\\bigl\[(?! )', r'\\bigl[ '), (r'\\bigl\\{(?! )', r'\\bigl\\{ '), (r'\\bigl\|(?! )', r'\\bigl| '), (r'\\bigl\\langle(?! )', r'\\bigl\\langle '), (r'\\bigl\\lfloor(?! )', r'\\bigl\\lfloor '), (r'\\bigl\\lceil(?! )', r'\\bigl\\lceil '), (r'\\Bigl\((?! )', r'\\Bigl( '), (r'\\Bigl\[(?! )', r'\\Bigl[ '), (r'\\Bigl\\{(?! )', r'\\Bigl\\{ '), (r'\\Bigl\|(?! )', r'\\Bigl| '), (r'\\Bigl\\langle(?! )', r'\\Bigl\\langle '), (r'\\Bigl\\lfloor(?! )', r'\\Bigl\\lfloor '), (r'\\Bigl\\lceil(?! )', r'\\Bigl\\lceil '), (r'\\biggl\((?! )', r'\\biggl( '), (r'\\biggl\[(?! )', r'\\biggl[ '), (r'\\biggl\\{(?! )', r'\\biggl\\{ '), (r'\\biggl\|(?! )', r'\\biggl| '), (r'\\biggl\\langle(?! )', r'\\biggl\\langle '), (r'\\biggl\\lfloor(?! )', r'\\biggl\\lfloor '), (r'\\biggl\\lceil(?! )', r'\\biggl\\lceil '), (r'\\Biggl\((?! )', r'\\Biggl( '), (r'\\Biggl\[(?! )', r'\\Biggl[ '), (r'\\Biggl\\{(?! )', r'\\Biggl\\{ '), (r'\\Biggl\|(?! )', r'\\Biggl| '), (r'\\Biggl\\langle(?! )', r'\\Biggl\\langle '), (r'\\Biggl\\lfloor(?! )', r'\\Biggl\\lfloor '), (r'\\Biggl\\lceil(?! )', r'\\Biggl\\lceil '), ] # Add space before closing delimiters (if not already present) closing_delimiter_patterns = [ # === \right delimiters === # Basic delimiters (r'(? str: """ Final cleanup for specific patterns and spacing issues. This step applies various pattern-specific fixes to match expected output formats. Args: latex: LaTeX string Returns: Cleaned LaTeX string """ # Replace \dfrac with \frac in superscripts and subscripts for proper sizing # Pattern: ^{...\dfrac{...}{...}...} or _{...\dfrac{...}{...}...} def replace_dfrac_in_script(match): r"""Replace \dfrac with \frac inside a script (superscript or subscript).""" script_marker = match.group(1) # ^ or _ content = match.group(2) # everything inside braces # Replace all \dfrac with \frac in this content content = content.replace(r'\dfrac', r'\frac') return f'{script_marker}{{{content}}}' # Apply to both superscripts and subscripts # Use a simple pattern that matches ^{...} or _{...} and captures the content # This handles nested braces correctly by matching the outermost braces latex = re.sub( r'([\^_])\{((?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*)\}', replace_dfrac_in_script, latex ) # Fix assignment operator spacing: ∶ = → := latex = latex.replace("∶ =", ":=") latex = latex.replace(": =", ":=") # Fix Longleftrightarrow to iff latex = latex.replace('\\Longleftrightarrow', '\\iff ') # Replace \\lra with \\leftrightarrow (for compatibility) latex = latex.replace('\\lra', '\\leftrightarrow') # Remove \\mathrm{} from multi-letter text abbreviations # Only remove mathrm from sequences of 3+ lowercase letters that are NOT known units def should_remove_mathrm(match): content = match.group(1) # Don't remove if it's a known unit or chemical element return content if content not in self.common_units and content not in self.chemical_elements else match.group(0) latex = re.sub(r'\\mathrm\{([a-z]{3,})\}', should_remove_mathrm, latex) # Remove spaces in expressions like "x + y + z" -> "x+y+z" latex = re.sub(r'\\overset\{x \+ y \+ z\}', r'\\overset{x+y+z}', latex) # Remove space in "n 个" -> "n个" latex = latex.replace('n 个', 'n个') # Fix spacing after \\cap and \\cup commands latex = re.sub(r'\\cap(?![{])', r'\\cap ', latex) latex = re.sub(r'\\cup(?![{])', r'\\cup ', latex) # Convert \\overset{\\dot}{X} to \\dot{X} for simple variables latex = re.sub(r'\\overset\{\\dot\}\{([a-zA-Z0-9])\}', r'\\dot{\1}', latex) latex = re.sub(r'\\overset\{\.\}\{([a-zA-Z0-9])\}', r'\\dot{\1}', latex) # Fix array column specification format latex = re.sub(r'\\begin\{array\{([^}]+)\}\}', r'\\begin{array}{\1}', latex) # Fix incorrect fraction format for reaction arrows # Convert \\dfrac{\\underline{text}}{} → \\xlongequal[]{text} latex = re.sub(r'\\dfrac\{\\underline\{([^}]+)\}\}\{\}', r'\\xlongequal[\\quad]{\1}', latex) # Convert \\dfrac{\\underline{text1}}{text2} → \\xlongequal[text2]{text1} def clean_dfrac_underline(match): numerator = match.group(1) denominator = match.group(2) # Remove all spaces from both numerator and denominator numerator_clean = re.sub(r'\s+', '', numerator) denominator_clean = re.sub(r'\s+', '', denominator) return f'\\xlongequal[{denominator_clean}]{{{numerator_clean}}}' latex = re.sub(r'\\dfrac\{\\underline\{([^}]+)\}\}\{([^}]+)\}', clean_dfrac_underline, latex) # Clean up xlongequal reaction arrows from OMML conversion # Pattern: \xlongequal[\quad]{\overline{\overline{text}}} → \xlongequal[\quad]{text} # The double \overline indicates reaction conditions that should go ABOVE arrow (in braces) # Note: text (\1) already has \mathrm{} wrapper from earlier processing latex = re.sub( r'\\xlongequal\[\\quad\]\{\\overline\{\\overline\{([^}]+)\}\}\}', r'\\xlongequal[\\quad]{\1}', latex ) # Also handle \underline{\underline{}} pattern (after XSLT fix for m:bar default behavior) # Pattern: \xlongequal[\quad]{\underline{\underline{text}}} → \xlongequal[\quad]{text} # The double \underline (from m:bar without pos attribute) also indicates reaction conditions latex = re.sub( r'\\xlongequal\[\\quad\]\{\\underline\{\\underline\{([^}]+)\}\}\}', r'\\xlongequal[\\quad]{\1}', latex ) # Fix for nested array issues: Convert standalone \underline{\underline{text}} to \xlongequal[\quad]{text} # This prevents the aggressive array matching pattern below from destroying table structures # when the reaction arrow is inside an array cell. # latex = re.sub( # r'\\underline\{\\underline\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}\}', # r'\\xlongequal[\\quad]{\1}', # latex # ) # Convert array/matrix pattern with double underline/overline to reaction arrow # Pattern semantics: # - Double UNDERLINE on TOP row = reaction condition above arrow (convert) # - Double OVERLINE on BOTTOM row = reaction condition below arrow (convert) # - Double OVERLINE on TOP row = NOT reaction arrow, just decorated text (don't convert) # Result format: \xlongequal[below]{above} # # Supports: # - \begin{array}{c}, \begin{array}{l}, \begin{array}{r}, etc. (any alignment) # - \begin{array} (no alignment parameter) # - \begin{matrix} (matrix environment) # Pattern 1: Double underline on TOP row (first position) # \begin{array|matrix} + optional{alignment} + \underline{\underline{GROUP1}} + \\ + GROUP2 + \end{...} # Convert to: \xlongequal[GROUP2]{GROUP1} latex = re.sub( r'\\begin\{(?:array|matrix)\}(?:\{[^}]+\})?\s*\\underline\{\\underline\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}\}\s*\\\\\s*((?:(?!\\begin\{(?:array|matrix)\}).)+?)\s*\\end\{(?:array|matrix)\}', lambda m: '\\xlongequal[' + m.group(2).strip() + ']{' + m.group(1).strip() + '}', latex, flags=re.DOTALL ) # Pattern 2: Double overline on BOTTOM row (second position only) # \begin{array|matrix} + optional{alignment} + GROUP1 + \\ + \overline{\overline{GROUP2}} + \end{...} # Convert to: \xlongequal[GROUP2]{GROUP1} # Note: Overline on TOP row will NOT match this pattern and stays as array (correct behavior) latex = re.sub( r'\\begin\{(?:array|matrix)\}(?:\{[^}]+\})?\s*(.+?)\s*\\\\\s*\\overline\{\\overline\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}\}\s*\\end\{(?:array|matrix)\}', lambda m: '\\xlongequal[' + m.group(2).strip() + ']{' + m.group(1).strip() + '}', latex, flags=re.DOTALL ) # Fix fragmented mathematical functions (missing backslash) # Pattern: "sin\ x", "cos\ x", "tan\ x" → "\sin x", "\cos x", "\tan x" # These are functions that should have a backslash but don't fragmented_functions = [ 'sin', 'cos', 'tan', 'cot', 'sec', 'csc', 'arcsin', 'arccos', 'arctan', 'arccot', 'arcsec', 'arccsc', 'sinh', 'cosh', 'tanh', 'coth', 'sech', 'csch', 'log', 'ln', 'lg', 'exp', 'lim', 'limsup', 'liminf', 'sup', 'inf', 'max', 'min', 'det', 'dim', 'ker', 'arg', 'deg', 'gcd', 'hom', ] for func in fragmented_functions: # Pattern: func followed by backslash-space (\ ) and then a character # Example: "tan\ x" → "\tan x" # IMPORTANT: Use negative lookbehind to avoid matching \sin\ inside \\sin\ x pattern = rf'(? where command starts with a letter # Examples: \\left\{ → \left\{, \\right\} → \right\}, \\lceil → \lceil # But preserve: \\ followed by space (line break) or \\ at end of string # # Strategy: Match \\ (double backslash followed by letter) # Replace with \ (single backslash followed by letter) debug_print(f"[DEBUG CLEANUP] Before double-backslash fix: {latex[:100] if len(latex) <= 100 else latex[:100] + '...'}") latex = re.sub( r'\\\\(?=[a-zA-Z])', # Match \\ followed by a letter (lookahead) r'\\', # Replace with single backslash latex ) debug_print(f"[DEBUG CLEANUP] After double-backslash fix: {latex[:100] if len(latex) <= 100 else latex[:100] + '...'}") # Fix backslash-space (\ ) incorrectly separating LaTeX commands # New approach: Convert all \ (backslash-space) to ~ first, then fix patterns where # ~ incorrectly separates commands that should be together # Examples: "\sum\ \limits" → "\sum~\limits" → "\sum\limits" debug_print(f"[DEBUG CLEANUP] Before backslash-space to tilde conversion: {latex[:100] if len(latex) <= 100 else latex[:100] + '...'}") # Step 1: Convert all backslash-space (\ ) to tilde (~) # This normalizes spacing before we apply specific fixes latex = latex.replace('\\ ', '~') debug_print(f"[DEBUG CLEANUP] After converting \\ to ~: {latex[:100] if len(latex) <= 100 else latex[:100] + '...'}") # Step 2: Fix patterns where ~ incorrectly separates LaTeX commands # Define patterns to fix - order matters! tilde_fix_patterns = [ # 1. Large operators + limits modifiers (sum, prod, int, lim, etc.) # Pattern: \sum~\limits → \sum\limits (r'(\\(?:sum|prod|int|iint|iiint|oint|lim|liminf|limsup|coprod|bigcup|bigcap|bigwedge|bigvee|bigodot|bigoplus|bigotimes|biguplus|bigsqcup))~+(\\(?:limits|nolimits|displaylimits))', r'\1\2'), # 2. Any LaTeX command + underscore or caret (subscript/superscript) # Pattern: \alpha~_ → \alpha_, \sum~^{2} → \sum^{2} (r'(\\[a-zA-Z]+)~+([_^])', r'\1\2'), # 3. Underscore/caret + tilde + command (for nested scripts) # Pattern: _{x~\rightarrow} → _{x \rightarrow} (keep regular space) (r'([_^]\{[^}]*?)~+(\\[a-zA-Z]+)', r'\1 \2'), # 4. LaTeX command + tilde + opening brace # Pattern: \sqrt~{2} → \sqrt{2}, \frac~{1} → \frac{1} (r'(\\[a-zA-Z]+)~+(\{)', r'\1\2'), # 5. LaTeX command + tilde + another command (keep regular space) # Pattern: \mathrm~\alpha → \mathrm \alpha # But for display: \sum~\alpha → \sum \alpha (should have regular space) (r'(\\[a-zA-Z]+)~+(\\[a-zA-Z]+)', r'\1 \2'), # 6. Remove tilde before punctuation (use regular space) # Pattern: x~, → x , (r'~+([,;.])', r' \1'), # 7. Tilde between numbers and commands (common in limits) # Pattern: 1~\rightarrow → 1 \rightarrow (r'(\d)~+(\\[a-zA-Z]+)', r'\1 \2'), # 8. Tilde after closing brace and before underscore/caret # Pattern: }~_ → }_ (r'\}~+([_^])', r'}\1'), # 9. Tilde before differential notation (dx, dy, dt, etc.) # Pattern: x~dx → x dx (use regular space for differential) (r'~+(d[a-z])\b', r' \1'), # 10. Tilde between variables and letters/numbers # Pattern: x~y → x y (use regular space) # Only apply to single lowercase letters followed by tilde and another letter/number (r'([a-z])~+(?![\\])([a-z0-9])', r'\1 \2'), # 11. Tilde between relation operators and variables in expressions # Pattern: \leqslant i~\leqslant → \leqslant i \leqslant (r'(\\(?:leqslant|geqslant|leq|geq|lt|gt|ne|equiv|approx|sim|cong)) ([a-z])~+(\\(?:leqslant|geqslant|leq|geq|lt|gt|ne|equiv|approx|sim|cong))', r'\1 \2 \3'), # 12. Tilde after logical operators (convert to regular space) # Pattern: \vee~x → \vee x, \wedge~y → \wedge y (r'(\\(?:vee|wedge|land|lor|oplus|otimes|odot|cap|cup|sqcap|sqcup))~+', r'\1 '), # 13. Command + space + tilde (remove extra tilde) # Pattern: \vee ~x → \vee x (the tilde is redundant after a command with space) (r'(\\[a-zA-Z]+) ~+', r'\1 '), # 14. Variable + space + tilde (remove extra tilde for consistency) # Pattern: a ~b → a b (r'([a-z]) ~+', r'\1 '), ] for pattern, replacement in tilde_fix_patterns: latex = re.sub(pattern, replacement, latex) debug_print(f"[DEBUG CLEANUP] After fixing tilde patterns: {latex[:100] if len(latex) <= 100 else latex[:100] + '...'}") return latex def merge_consecutive_formatting(self, latex: str) -> str: r""" Merge consecutive identical formatting commands. This handles fragmented text where the same formatting command is applied to consecutive letters/words. Common with MathML conversion which may apply formatting per-character. Args: latex: LaTeX string with potentially fragmented formatting Returns: LaTeX string with consecutive identical formatting merged Examples: "\\mathbf{F} \\mathbf{e}" → "\\mathbf{Fe}" "\\mathrm{s} \\mathrm{i} \\mathrm{n}" → "\\mathrm{sin}" "\\mathit{h} \\mathit{e} \\mathit{l} \\mathit{l} \\mathit{o}" → "\\mathit{hello}" Note: This runs before merge_consecutive_mathrm() which has more specialized logic for chemical formulas (handles subscripts/superscripts). """ # List of formatting commands to merge format_commands = [ 'mathbf', 'mathit', 'mathrm', 'mathbb', 'mathcal', 'mathfrak', 'mathsf', 'mathtt', 'mathscr', 'text', 'textbf', 'textit', 'textrm', 'textsf', 'texttt', 'boldsymbol' ] for cmd in format_commands: # Pattern: \cmd{X} \cmd{Y} → \cmd{XY} # Matches: \cmd{content1} (optional whitespace) \cmd{content2} pattern = rf'\\{cmd}\{{([^}}]+)\}}\s+\\{cmd}\{{([^}}]+)\}}' max_iterations = 100 # Safety limit for _ in range(max_iterations): match = re.search(pattern, latex) if not match: break content1 = match.group(1) content2 = match.group(2) # Merge: \cmd{content1 content2} # Remove space between merged content (renders the same in LaTeX) merged = f'\\{cmd}{{{content1}{content2}}}' # Replace this occurrence latex = latex[:match.start()] + merged + latex[match.end():] return latex def merge_consecutive_mathrm(self, latex: str) -> str: r""" Merge consecutive \\mathrm{} blocks with intervening subscripts/superscripts. This is particularly useful for chemical formulas that are often fragmented. Operators like +, -, =, etc. will prevent merging. Args: latex: LaTeX string with potentially fragmented \\mathrm{} blocks Returns: LaTeX string with consecutive \\mathrm{} blocks merged Examples: "\\mathrm{C} \\mathrm{u}_{2} \\mathrm{O}" → "\\mathrm{Cu_{2}O}" "\\mathrm{H}^{2} \\mathrm{S}" → "\\mathrm{H^{2}S}" "\\mathrm{Ca}^{2+}" → "\\mathrm{Ca^{2+}}" "\\mathrm{2H_{\\mathrm{2}}}" → "\\mathrm{2H_{2}}" (remove nested) """ # Step 0: Remove nested \\mathrm{} blocks # Pattern: _{\\mathrm{X}} or ^{\\mathrm{X}} inside \\mathrm{...} # This handles cases like \\mathrm{2H_{\\mathrm{2}}} → \\mathrm{2H_{2}} # Strategy: Find _{\\mathrm{...}} or ^{\\mathrm{...}} INSIDE a \\mathrm{} block and unwrap # CRITICAL: Only unwrap if it's nested inside another \\mathrm{}, not standalone subscripts max_iterations = 100 # Safety limit for _ in range(max_iterations): # Pattern: \\mathrm{..._{\\mathrm{X}}...} or \\mathrm{...^{\\mathrm{X}}...} # This matches subscripts/superscripts with \\mathrm{} INSIDE a \\mathrm{} block # We need to find \\mathrm{ ... [_^]{\\mathrm{X}} ... } nested_mathrm_pattern = r'\\mathrm\{([^{}]*?)([_^])\{\\mathrm\{([^}]+)\}\}([^{}]*?)\}' match = re.search(nested_mathrm_pattern, latex) if not match: break # Found \\mathrm{..._{\\mathrm{X}}...} or \\mathrm{...^{\\mathrm{X}}...} before_script = match.group(1) # Content before the script script_char = match.group(2) # _ or ^ inner_content = match.group(3) # X after_script = match.group(4) # Content after the script # Unwrap: \\mathrm{2H_{\\mathrm{2}}} → \\mathrm{2H_{2}} unwrapped = f'\\mathrm{{{before_script}{script_char}{{{inner_content}}}{after_script}}}' latex = latex[:match.start()] + unwrapped + latex[match.end():] # Step 1: Merge adjacent \\mathrm{} blocks # Pattern: \\mathrm{X} (optional whitespace/subscript/superscript) \\mathrm{Y} pattern = r'\\mathrm\{([^}]+)\}((?:\s|_\{[^}]+\}|\^\{[^}]+\})*?)\\mathrm\{([^}]+)\}' max_iterations = 100 # Safety limit for _ in range(max_iterations): match = re.search(pattern, latex) if not match: break content1 = match.group(1) middle = match.group(2) # whitespace and/or scripts content2 = match.group(3) # CRITICAL FIX: Don't merge if BOTH blocks already have subscripts/superscripts # This prevents merging geometry notation like \mathrm{F}_{2} \mathrm{F}_{1} # into \mathrm{F_{2}F}_{1}, which would be incorrect. # # Pattern to check: If the middle contains a subscript (_{...}) AND # there's a following \mathrm{} block, check if we're about to merge # two independently subscripted items (like F₂ and F₁ for geometry points). # # Heuristic: If middle contains a subscript/superscript AND content2 is # a single letter (or short identifier), this is likely geometry notation. # Don't merge in this case. has_script_in_middle = bool(re.search(r'[_^]\{', middle)) content2_is_short = len(content2.strip()) <= 3 # Single letter or short identifier if has_script_in_middle and content2_is_short: # This looks like geometry notation (F₂ F₁) or similar # Check if content1 is also a single letter content1_is_short = len(content1.strip()) <= 3 if content1_is_short: # Both sides are short identifiers with script in between # This is geometry notation like F₂ F₁ - DON'T merge # Move past this match to avoid infinite loop temp_marker = f'__NOMERGE__{match.group(0)}__NOMERGE__' latex = latex[:match.start()] + temp_marker + latex[match.end():] continue # Merge into single \\mathrm{} block merged_content = content1 + middle + content2 # Remove all spaces (they don't affect LaTeX rendering in \\mathrm{}) merged_content = merged_content.replace(' ', '') merged = f'\\mathrm{{{merged_content}}}' # Replace this occurrence latex = latex[:match.start()] + merged + latex[match.end():] # Remove temporary markers latex = re.sub(r'__NOMERGE__', '', latex) # Step 2: Pull trailing subscripts/superscripts into \\mathrm{} blocks # Pattern: \\mathrm{content} followed immediately by subscript or superscript # Example: \\mathrm{Ca}^{2+} -> \\mathrm{Ca^{2+}} # # CRITICAL: In chemistry mode, DON'T pull superscripts in - they should be # wrapped separately for ion charges: \\mathrm{Cu}^{\\mathrm{2+}} # # CRITICAL: Don't pull superscripts into single-letter mathematical constants # (e, i, d, etc.) as these are often used as bases for exponentiation. # Example: \\mathrm{e}^{2x+3} should stay as-is, NOT become \\mathrm{e^{2x+3}} if self.subject_mode != 'chemistry': # Pattern allows one level of nested braces inside the script so that # constructs like \mathrm{K}_{\mathrm{a}1} are parsed correctly. trailing_pattern = r'\\mathrm\{([^{}]+)\}((?:[_^]\{(?:[^{}]|\{[^{}]*\})+\})+)' # Mathematical constants that should NOT have superscripts pulled in # These are typically used as bases for exponentiation (e^x, i^2, etc.) math_constants = {'e', 'i', 'j', 'd', 'D'} for _ in range(max_iterations): match = re.search(trailing_pattern, latex) if not match: break content = match.group(1) trailing_scripts = match.group(2) # Don't merge if content is a single-letter mathematical constant # with a superscript (e.g., e^x, i^2, d^n) if content.strip() in math_constants and trailing_scripts.startswith('^'): # Skip this match to avoid merging # Move past it by inserting a temporary marker temp_marker = f'__NOMERGE_CONST__{match.group(0)}__NOMERGE_CONST__' latex = latex[:match.start()] + temp_marker + latex[match.end():] continue # Don't pull scripts into \mathrm{} if the scripts already contain # \mathrm{} themselves. Pulling them in would create nested/duplicated # \mathrm{} blocks (e.g., \mathrm{K_{\mathrm{a}1}} instead of # \mathrm{K}_{\mathrm{a}1}). if '\\mathrm{' in trailing_scripts: temp_marker = f'__NOMERGE_NESTED__{match.group(0)}__NOMERGE_NESTED__' latex = latex[:match.start()] + temp_marker + latex[match.end():] continue # Don't pull scripts into \mathrm{} if the scripts contain bare # italic variables (letters not wrapped in formatting commands). # Example: \mathrm{C}_{k - 1}^{n - 1} should stay as-is, NOT become # \mathrm{C_{k - 1}^{n - 1}} because k and n are italic variables. # Only pull in scripts that are entirely numeric/symbolic (chemical # formulas like \mathrm{H}_{2} or \mathrm{Ca}^{2+}). scripts_cleaned = re.sub(r'\\[a-zA-Z]+\{[^{}]*\}', '', trailing_scripts) scripts_cleaned = re.sub(r'\\[a-zA-Z]+', '', scripts_cleaned) if re.search(r'[a-zA-Z]', scripts_cleaned): temp_marker = f'__NOMERGE_BARE__{match.group(0)}__NOMERGE_BARE__' latex = latex[:match.start()] + temp_marker + latex[match.end():] continue # Merge trailing scripts into \mathrm{} block merged = f'\\mathrm{{{content}{trailing_scripts}}}' # Replace this occurrence latex = latex[:match.start()] + merged + latex[match.end():] # Remove temporary markers latex = re.sub(r'__NOMERGE_CONST__|__NOMERGE_NESTED__|__NOMERGE_BARE__', '', latex) return latex def wrap_ion_charges_in_mathrm(self, latex: str) -> str: r""" Wrap ion charge superscripts in \\mathrm{} for chemistry mode. Patterns handled: - ^{2 +} → ^{\\mathrm{2+}} - ^{2 -} → ^{\\mathrm{2-}} - ^{3 +} → ^{\\mathrm{3+}} - Removes spaces between digit and +/- Args: latex: LaTeX string Returns: LaTeX with ion charges wrapped in \\mathrm{} """ # Pattern: ^{digit(s) space +/-} # Match: ^{2 +}, ^{2 -}, ^{3 +}, etc. pattern = r'\^\{(\d+)\s*([+\-])\}' def replace_charge(match): digit = match.group(1) sign = match.group(2) # Remove space and wrap in \mathrm{} return f'^{{\\mathrm{{{digit}{sign}}}}}' return re.sub(pattern, replace_charge, latex) def merge_fragmented_chemistry_elements(self, latex: str) -> str: r""" Merge fragmented chemical elements followed by charge indicators (safety net). This post-processing step catches cases where mrow_visitor couldn't merge fragmented elements during conversion. It looks for patterns like: - \mathrm{M}\mathrm{n}^{2+} → \mathrm{Mn}^{2+} - \mathrm{C}\mathrm{u}^{2-} → \mathrm{Cu}^{2-} - \mathrm{F}\mathrm{e} (followed by reaction) → \mathrm{Fe} Only merges when: 1. Two consecutive \mathrm{} blocks with single letters 2. Combined text forms known multi-char element 3. Followed by charge superscript (^{digit+/-}) OR chemistry context Args: latex: LaTeX string with potentially fragmented chemical elements Returns: LaTeX with fragmented elements merged Examples: "\mathrm{M}\mathrm{n}^{2 +}" → "\mathrm{Mn}^{2 +}" "\mathrm{C}\mathrm{u}^{2 -}" → "\mathrm{Cu}^{2 -}" "\mathrm{F}\mathrm{e} + \mathrm{O}" → "\mathrm{Fe} + \mathrm{O}" """ from ..data.chemistry_data import MULTI_CHAR_ELEMENTS # Pattern: \mathrm{X}\mathrm{Y} where X and Y are single letters # Look-ahead for charge superscript or chemistry context pattern = r'\\mathrm\{([A-Z])\}\\mathrm\{([a-z])\}(?=\^{[0-9]+\s*[+-]\})' def merge_if_element(match): letter1 = match.group(1) # First letter (capital) letter2 = match.group(2) # Second letter (lowercase) combined = letter1 + letter2 # Only merge if combined forms known multi-char element if combined in MULTI_CHAR_ELEMENTS: return f'\\mathrm{{{combined}}}' else: # Not a known element, keep fragmented return match.group(0) # Merge fragmented elements followed by charges latex = re.sub(pattern, merge_if_element, latex) # Also handle case without charge but with clear chemistry context # Pattern: \mathrm{X}\mathrm{Y} followed by chemistry indicators # (reaction arrows, more elements, subscripts) pattern_context = r'\\mathrm\{([A-Z])\}\\mathrm\{([a-z])\}(?=[\s]*(?:[\+\-\→\←\⇌]|\\mathrm\{|_\{))' latex = re.sub(pattern_context, merge_if_element, latex) return latex def format_chemistry_constants(self, latex: str, subject_mode: Optional[str] = None) -> str: r""" Format chemistry equilibrium constants with upright subscripts. INTELLIGENT MODE: Detects chemistry context from LaTeX patterns and applies formatting only when chemistry probability is high enough. Chemistry constants should have upright (roman) subscripts: - K_{sp} → K_{\mathrm{sp}} (solubility product constant) - K_{a} → K_{\mathrm{a}} (acid dissociation constant) - K_{b} → K_{\mathrm{b}} (base dissociation constant) - K_{w} → K_{\mathrm{w}} (water ionization constant) - K_{c} → K_{\mathrm{c}} (concentration equilibrium constant) - K_{p} → K_{\mathrm{p}} (pressure equilibrium constant) - K_{eq} → K_{\mathrm{eq}} (equilibrium constant) - K_{f} → K_{\mathrm{f}} (formation constant) - K_{d} → K_{\mathrm{d}} (dissociation constant) Args: latex: LaTeX string with chemistry constants subject_mode: Subject mode ('chemistry', 'auto', etc.) Returns: LaTeX with formatted chemistry constants Examples: "K_{sp}(CuS)" → "K_{\mathrm{sp}}(\mathrm{CuS})" [chemistry detected] "K_{a} + H^+" → "K_{\mathrm{a}} + \mathrm{H}^+" [chemistry detected] "K_{eq}" → "K_{\mathrm{eq}}" [chemistry mode only] """ # Check if we should apply formatting based on chemistry context should_format = False if subject_mode == 'chemistry': # Always format in chemistry mode should_format = True else: # In auto mode, detect chemistry context from LaTeX patterns chem_score = 0.0 # Chemistry indicators (same logic as calculate_chemistry_probability): # 1. Ion charges: ^{digit+/-} (+1.0) if re.search(r'\^\{[0-9]+\s*[+-]\s*\}', latex): chem_score += 1.0 # 2. Chemical formulas in \mathrm{}: multi-char elements like Cu, Fe, Mn (+0.8) from ..data.chemistry_data import MULTI_CHAR_ELEMENTS for element in MULTI_CHAR_ELEMENTS: if rf'\mathrm{{{element}' in latex: chem_score += 0.8 break # Only count once # 3. Reaction arrows: \xlongequal, \rightarrow, \leftrightharpoons (+0.6) if re.search(r'\\(xlongequal|rightarrow|leftrightharpoons|leftrightarrow)', latex): chem_score += 0.6 # 4. Chemical subscripts: _{digit} like H_2, O_2 (+0.4) if re.search(r'_\{[0-9]+\}', latex): chem_score += 0.4 # 5. State symbols: \uparrow, \downarrow (gas/precipitate) (+0.3) if r'\uparrow' in latex or r'\downarrow' in latex: chem_score += 0.3 # 6. Chemistry equilibrium constants: K_{sp}, K_{a}, K_{b}, etc. (+1.0) # These are definitive chemistry indicators chemistry_k_pattern = r'\\?mathrm\{K\}_\{(sp|a|b|w|c|p|eq|f|d|h|i|m)\}|K_\{(sp|a|b|w|c|p|eq|f|d|h|i|m)\}' if re.search(chemistry_k_pattern, latex): chem_score += 1.0 # Decision threshold: ≥1.5 for auto mode (very strong chemistry indicators) # This matches the threshold used in mrow_visitor for fragmented elements if chem_score >= 1.5: should_format = True if not should_format: return latex # Chemistry constant subscripts that should be upright # These are standard abbreviations, not variables chemistry_subscripts = { 'sp', # solubility product 'a', # acid dissociation 'b', # base dissociation 'w', # water ionization 'c', # concentration equilibrium 'p', # pressure equilibrium 'eq', # equilibrium 'f', # formation 'd', # dissociation 'h', # hydrolysis 'i', # ionization 'm', # Michaelis constant (biochemistry) } # Pattern: K_{subscript} where subscript is one of the chemistry constants # Use word boundary to avoid matching K_{alpha} or K_{x} for subscript in chemistry_subscripts: # Pattern 1: Plain K_{subscript} → K_{\mathrm{subscript}} # Match K_{subscript} but not K_{\mathrm{subscript}} (already formatted) pattern = rf'K_\{{{subscript}\}}' replacement = rf'K_{{\\mathrm{{{subscript}}}}}' # Need \\ for literal backslash latex = latex.replace(pattern, replacement) # Pattern 2: With spaces: K_{ sp } → K_{\mathrm{sp}} pattern_spaces = rf'K_\{{\s*{subscript}\s*\}}' latex = re.sub(pattern_spaces, replacement, latex) # Pattern 3: Chemistry mode wraps K in \mathrm{}: \mathrm{K}_{subscript} → \mathrm{K}_{\mathrm{subscript}} # This is critical for chemistry mode where K is detected as an element pattern_mathrm_k = rf'\\mathrm\{{K\}}_\{{{subscript}\}}' replacement_mathrm_k = rf'\\mathrm{{K}}_{{\\mathrm{{{subscript}}}}}' latex = latex.replace(pattern_mathrm_k, replacement_mathrm_k) # Pattern 4: With spaces in chemistry mode: \mathrm{K}_{ sp } → \mathrm{K}_{\mathrm{sp}} pattern_mathrm_k_spaces = rf'\\mathrm\{{K\}}_\{{\s*{subscript}\s*\}}' latex = re.sub(pattern_mathrm_k_spaces, replacement_mathrm_k, latex) return latex def apply_mathjax_unicode_fallback(self, latex: str) -> str: r""" Replace LaTeX commands not supported by MathJax with Unicode characters. Also applies regional symbol preferences (e.g., emptyset → varnothing in Chinese). This ensures symbols render correctly in MathJax by using Unicode fallback for commands that MathJax doesn't support (e.g., \llbracket → 〚). Args: latex: LaTeX string potentially containing unsupported commands Returns: LaTeX string with unsupported commands replaced by Unicode Examples: "\\llbracket x \\rrbracket" → "〚x〛" "\\parallelogram" → "▱" "\\iddots" → "⋰" "\\emptyset" → "\\varnothing" (regional preference) "\\sin x" → "\\sin x" (supported, unchanged) """ # Step 1: Apply regional symbol preferences (e.g., emptyset → varnothing) for old_cmd, new_cmd in REGIONAL_SYMBOL_PREFERENCES.items(): # Replace \old_cmd with \new_cmd pattern = f'\\\\{re.escape(old_cmd)}(?![a-zA-Z])' replacement = f'\\\\{new_cmd}' # Need double backslash for regex replacement latex = re.sub(pattern, replacement, latex) # Step 2: Replace MathJax-unsupported commands with Unicode characters for command, unicode_char in MATHJAX_UNSUPPORTED_TO_UNICODE.items(): # Replace \command with Unicode character # Use word boundary to avoid partial matches (e.g., \ll in \llbracket) pattern = f'\\\\{re.escape(command)}(?![a-zA-Z])' latex = re.sub(pattern, unicode_char, latex) return latex def validate_output(self, latex: str) -> str: """ Validate output and fix any remaining delimiter markers or issues. This is the final safety net to catch any processing issues. Args: latex: LaTeX string Returns: Validated and cleaned LaTeX string """ # Phase 3 Fix: If skip_delimiter_processing=True, PRESERVE __DELIM__ markers # They're intentionally left for the new delimiter_pairing_processor if hasattr(self, '_skip_delimiter_processing') and self._skip_delimiter_processing: return latex # Check for surviving delimiter markers (indicates processing issue) if '__DELIM' in latex or 'DELIM__' in latex or '_DELIM' in latex or 'LIM__' in latex or 'LIM_' in latex: logging.warning(f"Delimiter markers survived cleanup. Applying emergency cleanup...") # Emergency cleanup of marker remnants (most specific first) latex = re.sub(r'__DELIM[_]*\([_]*', '(', latex) # Opening paren latex = re.sub(r'[_]*\)__DELIM[_]*', ')', latex) # Closing paren latex = re.sub(r'__DELIM[_]*\[[_]*', '[', latex) # Opening bracket latex = re.sub(r'[_]*\]__DELIM[_]*', ']', latex) # Closing bracket latex = re.sub(r'__DELIM[_]*\{[_]*', '{', latex) # Opening brace latex = re.sub(r'[_]*\}__DELIM[_]*', '}', latex) # Closing brace latex = re.sub(r'__DELIM[_]*\|[_]*', '|', latex) # Pipe latex = re.sub(r'__DELIM[_]*', '', latex) # Prefix latex = re.sub(r'[_]*DELIM__', '', latex) # Suffix latex = re.sub(r'[_]*DELIM[_]*', '', latex) # Fragment latex = re.sub(r'LIM__', '', latex) # Specific fragment latex = re.sub(r'LIM_', '', latex) # Another variant return latex