""" Universal delimiter pairing post-processor. This module provides functionality to add \\left and \\right commands to ALL delimiter pairs in LaTeX output, ensuring uniform scaling regardless of content. The processor uses a smart stack-based pairing algorithm that: 1. Handles symmetric pairs: (), [], {}, || 2. Handles mismatched pairs for interval notation: [), (], etc. 3. Skips delimiters already in \\left...\\right or cases environment Author: omml2tex project Date: 2025-11-12 """ import re import sys from typing import List, Tuple, Dict, Optional from ..utils.debug import debug_print class DelimiterPairingProcessor: """ Post-processor that adds \\left and \\right to all delimiter pairs. This ensures uniform scaling of all parentheses, brackets, and braces, including interval notation with mismatched delimiters. Rules: 1. Add \\left\\right to symmetric pairs: (), [], {}, ||, ⟨⟩ 2. Add \\left\\right to mismatched pairs (intervals): [), (], etc. 3. Add \\left\\right to quantum mechanics notation: ⟨φ|, |ψ⟩, ⟨φ|ψ⟩ 4. Skip delimiters in: \\left...\\right, \\begin{array}...\\end{array} """ # Delimiter mappings # BUG-010 FIX: Added '.' to OPENING_DELIMS for invisible left delimiter (\left.) OPENING_DELIMS = {'(', '[', '{', '|', '\\langle', '\\{', '\\|', '\\lfloor', '\\lceil', '.'} CLOSING_DELIMS = {')', ']', '}', '|', '\\rangle', '\\}', '\\|', '.', '\\rfloor', '\\rceil'} # '.' for invisible delimiter ALL_DELIMS = OPENING_DELIMS | CLOSING_DELIMS # Compatible delimiter pairings (for interval notation and quantum mechanics) # Any opening can pair with any closing COMPATIBLE_PAIRS = { '(': {')', ']', '}', '\\rangle', '\\}', '.'}, # ( can close with ), ], }, ⟩, or . (invisible) '[': {')', ']', '}', '\\rangle', '\\}', '.'}, # [ can close with ), ], }, ⟩, or . (invisible) '{': {')', ']', '}', '\\rangle', '\\}', '.'}, # { can close with ), ], }, ⟩, or . (invisible) '\\{': {')', ']', '}', '\\rangle', '\\}', '.'}, # \{ can close with ), ], }, \}, or . (invisible) '|': {'|', '\\rangle', '.'}, # | can pair with |, ⟩, or . (invisible) '\\|': {'\\|', '.'}, # \| pairs with \| or . (invisible) '\\langle': {')', ']', '}', '\\rangle', '|', '\\}', '.'}, # ⟨ can pair with ⟩, |, or . (invisible) '\\lfloor': {'\\rfloor', '.'}, # ⌊ pairs with ⌋ or . (invisible) '\\lceil': {'\\rceil', '.'}, # ⌈ pairs with ⌉ or . (invisible) # BUG-010 FIX: '.' (invisible delimiter) can pair with any closing delimiter '.': {')', ']', '}', '\\rangle', '\\}', '\\|', '|', '\\rfloor', '\\rceil', '.'}, } def __init__(self, debug: bool = False): """ Initialize the delimiter pairing processor. Args: debug: Enable debug output to stderr """ self.debug = debug def process(self, latex: str) -> str: """ Add \\left and \\right to all delimiter pairs in LaTeX. This is a post-processing step that runs AFTER the visitor pattern, ensuring ALL delimiters get scaled, including interval notation. Args: latex: LaTeX string from conversion Returns: LaTeX string with \\left...\\right added to all delimiter pairs Examples: "(x + y)" → "\\left(x + y\\right)" "[a, b]" → "\\left[a, b\\right]" "[0, 1)" → "\\left[0, 1\\right)" (interval notation) "(a, b]" → "\\left(a, b\\right]" (interval notation) "\\langle \\phi | \\psi \\rangle" → "\\left\\langle \\phi | \\psi \\right\\rangle" (quantum mechanics) "|\\psi\\rangle" → "\\left|\\psi\\right\\rangle" (ket notation) "\\left\\{ \\begin{array}...\\right." → unchanged (skip) """ if not latex or not latex.strip(): return latex # Step 1: Find all plain delimiters (skip those in protected contexts) delimiters = self._find_plain_delimiters(latex) if not delimiters: if self.debug: debug_print("[DELIM PAIR] No plain delimiters found") return latex if self.debug: debug_print(f"[DELIM PAIR] Found {len(delimiters)} plain delimiters") # Step 2: Pair delimiters using smart algorithm (handles intervals) # Also identify pipes that should be \mid based on context pairs, skip_indices = self._pair_delimiters(delimiters, latex) if self.debug: debug_print(f"[DELIM PAIR] Paired {len(pairs)} delimiter pairs") for opening, closing in pairs: debug_print(f" - {opening['char']} at {opening['pos']} ... {closing['char']} at {closing['pos']}") if skip_indices: debug_print(f"[DELIM PAIR] Skipped {len(skip_indices)} pipes (will convert to \\mid)") # Step 3: Build replacement list (includes both paired delimiters and skipped pipes) replacements = self._build_replacements(pairs, delimiters, skip_indices) # Step 4: Apply replacements in reverse order result = self._apply_replacements(latex, replacements) # Step 5: Cleanup - Remove any remaining __DELIM__ markers # These are delimiters that couldn't be paired (unknown types, unmatched, etc.) # This is a fail-safe to ensure no markers leak into the final output if '__DELIM__' in result: if self.debug: debug_print("[DELIM PAIR] Cleaning up remaining __DELIM__ markers") result = result.replace('__DELIM__', '') return result def _find_plain_delimiters(self, latex: str) -> List[Dict]: """ Find all delimiters marked with __DELIM__ markers. Phase 3 Fix: Only delimiters with __DELIM__ markers are paired. This distinguishes fence delimiters (with fence="true" in MathML) from plain operators that happen to use the same character (e.g., | in P(A|B)). Args: latex: LaTeX string with __DELIM__ markers Returns: List of delimiter dictionaries with 'char', 'pos', 'end', 'marker_start', 'marker_end' keys Examples: Input: "P __DELIM__(__DELIM__ A | B __DELIM__)__DELIM__" Output: [ {'char': '(', 'pos': 11, 'end': 12, 'marker_start': 2, 'marker_end': 22}, {'char': ')', 'pos': 32, 'end': 33, 'marker_start': 23, 'marker_end': 43} ] Note: Plain '|' at position 17 is NOT in the list (no __DELIM__ markers) """ delimiters = [] marker = '__DELIM__' marker_len = len(marker) # Scan for __DELIM__ markers i = 0 while i < len(latex): if latex[i:i+marker_len] == marker: # Found opening marker # FIX BUG-007: Check if there's a leading backslash before __DELIM__ # Pattern: \__DELIM__\{__DELIM__ should be treated as a unit marker_start = i if i > 0 and latex[i-1] == '\\': # Include the leading backslash in the marker range marker_start = i - 1 i += marker_len # Extract delimiter character(s) between markers delim_start = i # Look for closing marker closing_marker_pos = latex.find(marker, i) if closing_marker_pos == -1: # No closing marker - skip this opening marker continue # Extract delimiter between markers delim_str = latex[delim_start:closing_marker_pos] # Handle STRETCHY:: prefix (from MathStrategy) if delim_str.startswith('STRETCHY::'): delim_str = delim_str[10:] # Strip prefix marker_end = closing_marker_pos + marker_len # Skip empty delimiters if not delim_str.strip(): i = marker_end continue # Add delimiter to list delimiters.append({ 'char': delim_str, 'pos': delim_start, 'end': closing_marker_pos, 'marker_start': marker_start, 'marker_end': marker_end, 'escaped': delim_str.startswith('\\'), }) # Move past closing marker i = marker_end else: i += 1 return delimiters def _find_brace_argument_regions(self, latex: str) -> List[Tuple[int, int]]: """ Find regions where braces are used as command arguments. This includes: - Command arguments: \\frac{num}{denom}, \\sqrt{x}, etc. - Subscripts: x_{sub} - Superscripts: x^{sup} Args: latex: LaTeX string Returns: List of (start, end) tuples for brace regions """ regions = [] # Simple approach: Find all {...} pairs # These are almost always command arguments, not delimiters stack = [] for i, char in enumerate(latex): if char == '{' and (i == 0 or latex[i-1] != '\\'): stack.append(i) elif char == '}' and (i == 0 or latex[i-1] != '\\'): if stack: start = stack.pop() regions.append((start, i+1)) return regions def _find_protected_regions(self, latex: str) -> List[Tuple[int, int]]: """ Find protected regions that should not have delimiters modified. Protected regions: 1. \\left...\\right pairs 2. \\begin{array}...\\end{array} (cases environment) Args: latex: LaTeX string Returns: List of (start, end) position tuples for protected regions """ protected = [] # Pattern 1: Find \\left...\\right pairs # Match \\left followed by delimiter, content, \\right followed by delimiter # Include \langle and \rangle as possible delimiters left_pattern = r'\\left(?:[(\[{\|]|\\langle)' right_pattern = r'\\right(?:[)\]}\|.]|\\rangle)' # Find all \\left positions left_positions = [] for match in re.finditer(left_pattern, latex): left_positions.append(match.start()) # Find all \\right positions right_positions = [] for match in re.finditer(right_pattern, latex): right_positions.append(match.end()) # Pair them (simple heuristic: match nearest \\right after each \\left) for left_start in left_positions: # Find the nearest \\right after this \\left for right_end in right_positions: if right_end > left_start: protected.append((left_start, right_end)) break # Pattern 2: Find \\begin{array}...\\end{array} array_pattern = r'\\begin\{array\}.*?\\end\{array\}' for match in re.finditer(array_pattern, latex, re.DOTALL): protected.append((match.start(), match.end())) return protected def _is_in_protected_region(self, pos: int, protected_regions: List[Tuple[int, int]]) -> bool: """Check if position is within any protected region.""" for start, end in protected_regions: if start <= pos < end: return True return False def _pair_delimiters(self, delimiters: List[Dict], latex: str) -> Tuple[List[Tuple[Dict, Dict]], set]: r""" Pair opening and closing delimiters using smart algorithm. Algorithm supports: 1. Symmetric pairs: (), [], {}, ||, ⟨⟩ 2. Mismatched pairs for intervals: [), (], etc. 3. Quantum mechanics: |ψ⟩ (ket), ⟨φ| (bra), ⟨φ|ψ⟩ (inner product) 4. Conditional probability: P(A|B) - pipe inside parentheses becomes \mid 5. Set notation: {x|x>0} - pipe inside braces becomes \mid Key insight: For pipes, use proximity-based pairing (pair closest pipes first) to correctly handle cases like {x | x + |x-2| < 0} where the inner pipes should be paired before the outer pipe. Args: delimiters: List of delimiter dictionaries latex: Full LaTeX string for context checking Returns: Tuple of (pairs, skip_indices) where: - pairs: List of (opening, closing) delimiter pairs - skip_indices: Set of indices for pipes that should become \mid """ pairs = [] # Phase 1: Identify operator pipes (set separators, conditional probability) and pair remaining pipes # Operator pipes should NOT be paired - they become \mid # Examples: # - Set notation: {x | condition} - first pipe is separator # - Conditional probability: P(A|B) - pipe is operator pipe_indices = [i for i, d in enumerate(delimiters) if d['char'] == '|'] operator_pipe_indices = set() # Pipes that should become \mid paired_pipe_indices = set() if pipe_indices: # First, identify pipes that are operators (not delimiters) for pipe_idx in pipe_indices: pipe_delim = delimiters[pipe_idx] pipe_start = pipe_delim['marker_start'] # Look backwards in LaTeX string search_start = max(0, pipe_start - 50) latex_before = latex[search_start:pipe_start] # Check 1: Set notation separator {x | ...} # Pattern: brace shortly before pipe, no other pipes between them # NOTE: Check for both raw braces and __LBRACE__ markers if '{' in latex_before or '\\{' in latex_before or '__LBRACE__' in latex_before: last_brace_pos = max( latex_before.rfind('{'), latex_before.rfind('\\{'), latex_before.rfind('__LBRACE__') ) content_between = latex_before[last_brace_pos:] if len(content_between) < 30: # Check no other pipes between brace and this pipe has_pipes_between = any( delimiters[other_idx]['marker_start'] > search_start + last_brace_pos and delimiters[other_idx]['marker_start'] < pipe_start for other_idx in pipe_indices if other_idx != pipe_idx ) if not has_pipes_between: # Check if there's actual content between brace and pipe # Pattern: {x | condition} → content exists → separator # Pattern: {|x|} → no content → absolute value # Strip the brace marker itself to get content after it content_after_brace = content_between for marker in ('__LBRACE__', '\\{', '{'): if content_after_brace.startswith(marker): content_after_brace = content_after_brace[len(marker):] break content_after_brace = content_after_brace.strip() if content_after_brace: # Has content between brace and pipe → set notation separator operator_pipe_indices.add(pipe_idx) continue # Skip other checks # Check 2: Conditional probability operator P(A|B) # Pattern: parenthesis shortly before pipe if '(' in latex_before or '\\left(' in latex_before: last_paren_pos = max( latex_before.rfind('('), latex_before.rfind('\\left(') ) content_between = latex_before[last_paren_pos:] if len(content_between) < 50: # Check no other pipes between parenthesis and this pipe has_pipes_between = any( delimiters[other_idx]['marker_start'] > search_start + last_paren_pos and delimiters[other_idx]['marker_start'] < pipe_start for other_idx in pipe_indices if other_idx != pipe_idx ) if not has_pipes_between: # This is likely a conditional probability operator operator_pipe_indices.add(pipe_idx) continue # Skip other checks # Remove operator pipes from pairing consideration pipe_indices_for_pairing = [idx for idx in pipe_indices if idx not in operator_pipe_indices] # Now pair the remaining pipes using proximity-based algorithm # This pairs absolute values like |x-2| correctly while len(pipe_indices_for_pairing) >= 2: # Find the pair of pipes with minimum distance min_distance = float('inf') best_pair = None for i in range(len(pipe_indices_for_pairing)): for j in range(i + 1, len(pipe_indices_for_pairing)): idx1, idx2 = pipe_indices_for_pairing[i], pipe_indices_for_pairing[j] distance = idx2 - idx1 if distance < min_distance: min_distance = distance best_pair = (i, j, idx1, idx2) if best_pair: i, j, idx1, idx2 = best_pair # Pair these pipes pairs.append((delimiters[idx1], delimiters[idx2])) paired_pipe_indices.add(idx1) paired_pipe_indices.add(idx2) # Remove from consideration pipe_indices_for_pairing.pop(j) # Remove j first (higher index) pipe_indices_for_pairing.pop(i) # Then remove i else: break # Phase 2: Pair other delimiters using stack-based algorithm stack = [] for idx, delim in enumerate(delimiters): char = delim['char'] # Skip pipes that were already paired in Phase 1 if char == '|' and idx in paired_pipe_indices: continue # Skip pipes entirely - they'll be handled in Phase 3 if char == '|': continue # Special handling for ambiguous delimiters that can be both opening AND closing # Examples: '.' (invisible delimiter), '|' (pipe) # Strategy: If there's a compatible opening on the stack, treat as closing. # Otherwise, treat as opening. is_ambiguous = char in self.OPENING_DELIMS and char in self.CLOSING_DELIMS if is_ambiguous: # Check if there's a compatible opening on the stack paired = False if stack: for i in range(len(stack) - 1, -1, -1): opening = stack[i] opening_char = opening['char'] # Check if this ambiguous delimiter can close this opening if char in self.COMPATIBLE_PAIRS.get(opening_char, set()): # Found a compatible match - treat as CLOSING paired_opening = stack.pop(i) pairs.append((paired_opening, delim)) paired = True break if not paired: # No compatible opening found - treat as OPENING stack.append(delim) elif char in self.OPENING_DELIMS: # Unambiguous opening delimiter - push to stack stack.append(delim) elif char in self.CLOSING_DELIMS: # Unambiguous closing delimiter - find compatible opening if stack: # Strategy: Find the MOST RECENT opening that's compatible # This handles both symmetric pairs and interval notation paired = False for i in range(len(stack) - 1, -1, -1): opening = stack[i] opening_char = opening['char'] # Check if this opening is compatible with closing if char in self.COMPATIBLE_PAIRS.get(opening_char, set()): # Found a compatible match! paired_opening = stack.pop(i) pairs.append((paired_opening, delim)) paired = True break if not paired and self.debug: debug_print(f"[DELIM PAIR] Warning: Unpaired closing delimiter '{char}' at {delim['pos']}") else: # Stack is empty - unpaired closing delimiter if self.debug: debug_print(f"[DELIM PAIR] Warning: Unpaired closing delimiter '{char}' at {delim['pos']}") # Phase 3: Post-process to identify UNPAIRED pipes that should be \mid operators # Pattern 1: \langle ... | ... \rangle → quantum mechanics inner product (unpaired pipe) # Pattern 2: ( ... | ... ) → conditional probability P(A|B) (unpaired pipe) # Pattern 3: [ ... | ... ] → set builder notation [x|x>0] (unpaired pipe) # Pattern 4: { ... | ... } → set notation {x|x>0} (unpaired pipe) # We mark these UNPAIRED pipes to convert to \mid skip_indices = set() for i, delim in enumerate(delimiters): if delim['char'] == '|' and i not in paired_pipe_indices: # This pipe is unpaired - check if it's inside other delimiters # Check if there's a \langle before and \rangle after this pipe has_langle_before = False has_rangle_after = False # Check if there's a ( or [ before and ) or ] after this pipe has_paren_or_bracket_before = False has_paren_or_bracket_after = False # Check if there's a { before and } after this pipe in the LaTeX string # This handles set notation where braces might not have fence="true" has_brace_before = False has_brace_after = False # Check in delimiters list first for j in range(i-1, -1, -1): if delimiters[j]['char'] == '\\langle': has_langle_before = True break elif delimiters[j]['char'] in ('(', '['): has_paren_or_bracket_before = True break elif delimiters[j]['char'] in ('{', '\\{'): has_brace_before = True break elif delimiters[j]['char'] in (')', ']', '}', '\\}'): # Other closing delims break # Stop searching for j in range(i+1, len(delimiters)): if delimiters[j]['char'] == '\\rangle': has_rangle_after = True break elif delimiters[j]['char'] in (')', ']'): has_paren_or_bracket_after = True break elif delimiters[j]['char'] in ('}', '\\}'): has_brace_after = True break elif delimiters[j]['char'] in ('(', '[', '{', '\\{', '\\langle'): # Other opening delims break # Stop searching # Also check the LaTeX string for plain braces (not in delimiters list) # Look backwards from the pipe position for a plain { or \{ or \left\{ if not has_brace_before: pipe_start = delim['marker_start'] # Search backwards in LaTeX for { or \{ or \left\{ for k in range(pipe_start - 1, max(0, pipe_start - 200), -1): # Check for \left\{ if k >= 6 and latex[k-6:k+1] == '\\left\\{': has_brace_before = True break # Check for plain { (not \{) elif latex[k] == '{' and (k == 0 or latex[k-1] != '\\'): has_brace_before = True break # Check for \{ (escaped brace) elif k >= 1 and latex[k-1:k+1] == '\\{': has_brace_before = True break # Stop at other closing delimiters elif latex[k] in (')', ']') and (k == 0 or latex[k-1] != '\\'): break # Stop at \right) or \right] elif k >= 7 and latex[k-7:k+1] in ('\\right)', '\\right]'): break # Look forwards from the pipe position for a plain } or \} or \right\} if not has_brace_after: pipe_end = delim['marker_end'] # Search forwards in LaTeX for } or \} or \right\} for k in range(pipe_end, min(len(latex), pipe_end + 200)): # Check for \right\} if k + 8 <= len(latex) and latex[k:k+8] == '\\right\\}': has_brace_after = True break # Check for plain } (not \}) elif latex[k] == '}' and (k == 0 or latex[k-1] != '\\'): has_brace_after = True break # Check for \} (escaped brace) elif k + 2 <= len(latex) and latex[k:k+2] == '\\}': has_brace_after = True break # Stop at other opening delimiters elif latex[k] in ('(', '[') and (k == 0 or latex[k-1] != '\\'): break # Stop at \left( or \left[ elif k + 6 <= len(latex) and latex[k:k+6] in ('\\left(', '\\left['): break # If unpaired pipe is between \langle and \rangle, it's an inner product operator if has_langle_before and has_rangle_after: skip_indices.add(i) # If unpaired pipe is between ( and ) or [ and ], it's a conditional probability or set builder operator # This handles P(A|B), P(A|B,C), [x|x>0], etc. elif has_paren_or_bracket_before and has_paren_or_bracket_after: skip_indices.add(i) # If unpaired pipe is between { and }, it's set notation {x|x>0} elif has_brace_before and has_brace_after: skip_indices.add(i) # Check if there's a \langle before and \rangle after this pipe has_langle_before = False has_rangle_after = False # Check if there's a ( or [ before and ) or ] after this pipe has_paren_or_bracket_before = False has_paren_or_bracket_after = False # NEW: Check if there's a { before and } after this pipe in the LaTeX string # This handles set notation where braces might not have fence="true" has_brace_before = False has_brace_after = False # Check in delimiters list first for j in range(i-1, -1, -1): if delimiters[j]['char'] == '\\langle': has_langle_before = True break elif delimiters[j]['char'] in ('(', '['): has_paren_or_bracket_before = True break elif delimiters[j]['char'] in ('{', '\\{'): has_brace_before = True break elif delimiters[j]['char'] in (')', ']', '}', '\\}'): # Other closing delims break # Stop searching for j in range(i+1, len(delimiters)): if delimiters[j]['char'] == '\\rangle': has_rangle_after = True break elif delimiters[j]['char'] in (')', ']'): has_paren_or_bracket_after = True break elif delimiters[j]['char'] in ('}', '\\}'): has_brace_after = True break elif delimiters[j]['char'] in ('(', '[', '{', '\\{', '\\langle'): # Other opening delims break # Stop searching # NEW: Also check the LaTeX string for plain braces (not in delimiters list) # Look backwards from the pipe position for a plain { or \{ or \left\{ if not has_brace_before: pipe_start = delim['marker_start'] # Search backwards in LaTeX for { or \{ or \left\{ for k in range(pipe_start - 1, max(0, pipe_start - 200), -1): # Check for \left\{ if k >= 6 and latex[k-6:k+1] == '\\left\\{': has_brace_before = True break # Check for plain { (not \{) elif latex[k] == '{' and (k == 0 or latex[k-1] != '\\'): has_brace_before = True break # Check for \{ (escaped brace) elif k >= 1 and latex[k-1:k+1] == '\\{': has_brace_before = True break # Stop at other closing delimiters elif latex[k] in (')', ']') and (k == 0 or latex[k-1] != '\\'): break # Stop at \right) or \right] elif k >= 7 and latex[k-7:k+1] in ('\\right)', '\\right]'): break # Look forwards from the pipe position for a plain } or \} or \right\} if not has_brace_after: pipe_end = delim['marker_end'] # Search forwards in LaTeX for } or \} or \right\} for k in range(pipe_end, min(len(latex), pipe_end + 200)): # Check for \right\} if k + 8 <= len(latex) and latex[k:k+8] == '\\right\\}': has_brace_after = True break # Check for plain } (not \}) elif latex[k] == '}' and (k == 0 or latex[k-1] != '\\'): has_brace_after = True break # Check for \} (escaped brace) elif k + 2 <= len(latex) and latex[k:k+2] == '\\}': has_brace_after = True break # Stop at other opening delimiters elif latex[k] in ('(', '[') and (k == 0 or latex[k-1] != '\\'): break # Stop at \left( or \left[ elif k + 6 <= len(latex) and latex[k:k+6] in ('\\left(', '\\left['): break # If pipe is between \langle and \rangle, it's an inner product operator if has_langle_before and has_rangle_after: skip_indices.add(i) # If pipe is between ( and ) or [ and ], it's a conditional probability or set builder operator # This handles P(A|B), P(A|B,C), [x|x>0], etc. elif has_paren_or_bracket_before and has_paren_or_bracket_after: skip_indices.add(i) # If unpaired pipe is between { and }, it's set notation {x|x>0} elif has_brace_before and has_brace_after: skip_indices.add(i) # Report unpaired opening delimiters if stack and self.debug: for unpaired in stack: debug_print(f"[DELIM PAIR] Warning: Unpaired opening delimiter '{unpaired['char']}' at {unpaired['pos']}") # Merge operator pipes into skip_indices so they get converted to \mid skip_indices.update(operator_pipe_indices) return pairs, skip_indices def _build_replacements(self, pairs: List[Tuple[Dict, Dict]], delimiters: List[Dict], skip_indices: set) -> List[Tuple[int, int, str]]: r""" Build replacement list for paired delimiters and skipped pipes. Phase 3 Fix: Replace __DELIM__char__DELIM__ with \left/\right commands. Also converts skipped pipes (conditional probability) to \mid. Args: pairs: List of (opening, closing) delimiter pairs with marker positions delimiters: List of all delimiter dictionaries skip_indices: Set of indices for pipes that should become \mid Returns: List of (start_pos, end_pos, replacement_text) tuples Examples: Input pair: {'char': '(', 'marker_start': 2, 'marker_end': 22} Output: (2, 22, '\\left(') # Replace __DELIM__(__DELIM__ with \left( Input skipped pipe: {'char': '|', 'marker_start': 10, 'marker_end': 30} Output: (10, 30, ' \\mid ') # Replace __DELIM__|__DELIM__ with \mid """ replacements = [] # Handle paired delimiters for opening, closing in pairs: opening_char = opening['char'] closing_char = closing['char'] # Build \\left command for opening delimiter left_cmd = self._get_left_command(opening_char) right_cmd = self._get_right_command(closing_char) if left_cmd and right_cmd: # Replace entire marker range: __DELIM__char__DELIM__ → \left/\right command replacements.append((opening['marker_start'], opening['marker_end'], left_cmd)) replacements.append((closing['marker_start'], closing['marker_end'], right_cmd)) # Handle skipped pipes (convert to \mid) for idx in skip_indices: delim = delimiters[idx] # Replace __DELIM__|__DELIM__ with \mid (with spaces for proper LaTeX spacing) replacements.append((delim['marker_start'], delim['marker_end'], ' \\mid ')) return replacements def _get_left_command(self, char: str) -> Optional[str]: """Get \\left command for opening delimiter.""" left_map = { '(': '\\left(', '[': '\\left[', '{': '\\left\\{', '\\{': '\\left\\{', # Escaped brace → \left\{ '|': '\\left|', '\\|': '\\left\\|', # Double vertical line → \left\| '\\langle': '\\left\\langle', '\\lfloor': '\\left\\lfloor', # Floor function '\\lceil': '\\left\\lceil', # Ceiling function '.': '\\left.', # BUG-010 FIX: Invisible left delimiter } return left_map.get(char) def _get_right_command(self, char: str) -> Optional[str]: """Get \\right command for closing delimiter.""" right_map = { ')': '\\right)', ']': '\\right]', '}': '\\right\\}', '\\}': '\\right\\}', # Escaped brace → \right\} '|': '\\right|', '\\|': '\\right\\|', # Double vertical line → \right\| '\\rangle': '\\right\\rangle', '.': '\\right.', # Invisible delimiter → \right. '\\rfloor': '\\right\\rfloor', # Floor function '\\rceil': '\\right\\rceil', # Ceiling function } return right_map.get(char) def _apply_replacements(self, latex: str, replacements: List[Tuple[int, int, str]]) -> str: """ Apply replacements to LaTeX string. Replacements are applied in reverse order to maintain position indices. Args: latex: Original LaTeX string replacements: List of (start_pos, end_pos, replacement_text) tuples Returns: Modified LaTeX string """ # Sort by position (reverse order) to maintain indices replacements.sort(key=lambda x: x[0], reverse=True) result = latex for start, end, replacement in replacements: result = result[:start] + replacement + result[end:] return result # Convenience function for direct use def add_universal_left_right(latex: str, debug: bool = False) -> str: """ Add \\left and \\right to all delimiter pairs in LaTeX. Convenience function that creates a DelimiterPairingProcessor and processes the LaTeX string. This function handles: - Symmetric pairs: (), [], {}, || - Interval notation: [), (], etc. - Skips protected contexts (\\left...\\right, cases environment) Args: latex: LaTeX string from conversion debug: Enable debug output to stderr Returns: LaTeX string with \\left...\\right added to all delimiter pairs Examples: >>> add_universal_left_right("(x + y)") "\\\\left(x + y\\\\right)" >>> add_universal_left_right("[0, 1)") "\\\\left[0, 1\\\\right)" >>> add_universal_left_right("\\\\left\\\\{ \\\\begin{array}...\\\\right.") "\\\\left\\\\{ \\\\begin{array}...\\\\right." (unchanged) """ processor = DelimiterPairingProcessor(debug=debug) return processor.process(latex)