服务: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 默认密钥已清空
847 lines
38 KiB
Python
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)
|