Files
DoLuEdu-WordFileParse/scripts/omml_mathml2latex/converters/delimiter_pairing_processor.py
Quentin 3271fee5cc init: WordFileParse docx→HTML 解析服务(含 Word EQ 域公式修复)
服务:Spring Boot 3.2 + docx4j 8.3.9,POST /api/v3/server_office_file/file/parse
      接受 .docx,返回 HTML(公式 → LaTeX,图片 → RustFS 外链)

公式支持三类来源:
- OMML(Word 2007+ 公式)      → Python omml_mathml2latex
- MathType OLE 对象            → Ruby mathtype2mathml → MathML → LaTeX
- Word EQ 域(老教辅主力)      → EqToLatexUtil(本次新增)

本次改动:
1. 新增 java-svc/.../core/EqToLatexUtil.java:
   移植自 jby backend-services office-core,EQ 语法 → LaTeX
   (\f→\dfrac、\r→\sqrt、\i→\int/\sum/\prod、\s→^{}/_{}、\b→\left\right、
     \a→array、\x→\boxed/\overline、\o\al 堆叠、圈码、化学符号、极限、连等式)
   品牌标记 JBYU* → WPU*,与采集端一致
2. CtoMathFinder:接上 EQ 分支(原为 not-ported 桩),域代码经
   EqToLatexUtil.convert 转 LaTeX 写入 eqLatexMap(WPEQNUM 占位符)
3. WordParseUtil.dealPlaceholder:EQ 的 LaTeX 同样加 \( \) 包裹
   (原先因 WPEQNUM 例外不加包裹,前端/MathJax 不渲染)

修前症状:教辅 Word 里分式/方程组/上下标整段丢失(如 {−1/3, 1} 变成 {-,1})
修后实测:46 份教辅 2908 处 EQ 域全部还原,公式总数 2954 处、零未包裹残留

安全:application.yml(含 RustFS 密钥)不入库,提供 application.yml.example;
      OssService 的 @Value 默认密钥已清空
2026-09-29 09:43:13 +08:00

847 lines
38 KiB
Python

"""
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)