Files
DoLuEdu-WordFileParse/scripts/omml_mathml2latex/converters/post_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

2095 lines
98 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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'(?<! )\\right\)', r' \\right)'),
(r'(?<! )\\right\]', r' \\right]'),
(r'(?<! )\\right\\}', r' \\right\\}'),
(r'(?<! )\\right\|', r' \\right|'), # Plain pipe
(r'(?<! )\\right\\\|', r' \\right\\|'), # Escaped pipe \right\|
# LaTeX delimiter commands - right versions
(r'(?<! )\\right\\rangle', r' \\right\\rangle'),
(r'(?<! )\\right\\rfloor', r' \\right\\rfloor'),
(r'(?<! )\\right\\rceil', r' \\right\\rceil'),
(r'(?<! )\\right\\rvert', r' \\right\\rvert'),
(r'(?<! )\\right\\rVert', r' \\right\\rVert'),
# LaTeX delimiter commands - left versions (sometimes used as closing)
(r'(?<! )\\right\\langle', r' \\right\\langle'),
(r'(?<! )\\right\\lfloor', r' \\right\\lfloor'),
(r'(?<! )\\right\\lceil', r' \\right\\lceil'),
(r'(?<! )\\right\\lvert', r' \\right\\lvert'),
(r'(?<! )\\right\\lVert', r' \\right\\lVert'),
# Other delimiters
(r'(?<! )\\right\\uparrow', r' \\right\\uparrow'),
(r'(?<! )\\right\\downarrow', r' \\right\\downarrow'),
(r'(?<! )\\right\\updownarrow', r' \\right\\updownarrow'),
(r'(?<! )\\right\\Uparrow', r' \\right\\Uparrow'),
(r'(?<! )\\right\\Downarrow', r' \\right\\Downarrow'),
(r'(?<! )\\right\\Updownarrow', r' \\right\\Updownarrow'),
(r'(?<! )\\right/', r' \\right/'),
(r'(?<! )\\right\\backslash', r' \\right\\backslash'),
# Unpaired delimiters
(r'(?<! )\\right\.', r' \\right.'),
# === \bigr, \Bigr, \biggr, \Biggr (manual sizing - right) ===
(r'(?<! )\\bigr\)', r' \\bigr)'),
(r'(?<! )\\bigr\]', r' \\bigr]'),
(r'(?<! )\\bigr\\}', r' \\bigr\\}'),
(r'(?<! )\\bigr\|', r' \\bigr|'),
(r'(?<! )\\bigr\\rangle', r' \\bigr\\rangle'),
(r'(?<! )\\bigr\\rfloor', r' \\bigr\\rfloor'),
(r'(?<! )\\bigr\\rceil', r' \\bigr\\rceil'),
(r'(?<! )\\Bigr\)', r' \\Bigr)'),
(r'(?<! )\\Bigr\]', r' \\Bigr]'),
(r'(?<! )\\Bigr\\}', r' \\Bigr\\}'),
(r'(?<! )\\Bigr\|', r' \\Bigr|'),
(r'(?<! )\\Bigr\\rangle', r' \\Bigr\\rangle'),
(r'(?<! )\\Bigr\\rfloor', r' \\Bigr\\rfloor'),
(r'(?<! )\\Bigr\\rceil', r' \\Bigr\\rceil'),
(r'(?<! )\\biggr\)', r' \\biggr)'),
(r'(?<! )\\biggr\]', r' \\biggr]'),
(r'(?<! )\\biggr\\}', r' \\biggr\\}'),
(r'(?<! )\\biggr\|', r' \\biggr|'),
(r'(?<! )\\biggr\\rangle', r' \\biggr\\rangle'),
(r'(?<! )\\biggr\\rfloor', r' \\biggr\\rfloor'),
(r'(?<! )\\biggr\\rceil', r' \\biggr\\rceil'),
(r'(?<! )\\Biggr\)', r' \\Biggr)'),
(r'(?<! )\\Biggr\]', r' \\Biggr]'),
(r'(?<! )\\Biggr\\}', r' \\Biggr\\}'),
(r'(?<! )\\Biggr\|', r' \\Biggr|'),
(r'(?<! )\\Biggr\\rangle', r' \\Biggr\\rangle'),
(r'(?<! )\\Biggr\\rfloor', r' \\Biggr\\rfloor'),
(r'(?<! )\\Biggr\\rceil', r' \\Biggr\\rceil'),
# === \bigm (middle delimiters with manual sizing) ===
(r'(?<! )\\bigm\|', r' \\bigm|'),
(r'(?<! )\\Bigm\|', r' \\Bigm|'),
(r'(?<! )\\biggm\|', r' \\biggm|'),
(r'(?<! )\\Biggm\|', r' \\Biggm|'),
]
# Apply opening delimiter spacing
for pattern, replacement in opening_delimiter_patterns:
latex = re.sub(pattern, replacement, latex)
# Apply closing delimiter spacing
for pattern, replacement in closing_delimiter_patterns:
latex = re.sub(pattern, replacement, latex)
return latex
def final_cleanup(self, latex: str) -> 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'(?<!\\)\b{func}\\\s+'
replacement = f'\\\\{func} '
latex = re.sub(pattern, replacement, latex)
# Fix split function names in two forms:
# 1. "si n" → "\sin" (first letters together)
# 2. "s i n" → "\sin" (all letters separate)
# This handles cases where Word splits function names with superscripts
# Note: In re.sub() replacement strings, we need to escape backslashes: \\sin
split_functions = {
# Form 1: "si n" pattern (first part together)
r'\bsi\s+n\b': '\\\\sin',
r'\bco\s+s\b': '\\\\cos',
r'\bta\s+n\b': '\\\\tan',
r'\blo\s+g\b': '\\\\log',
r'\bl\s+n\b': '\\\\ln',
r'\be\s+xp\b': '\\\\exp',
r'\bco\s+t\b': '\\\\cot',
r'\bse\s+c\b': '\\\\sec',
r'\bcs\s+c\b': '\\\\csc',
# Form 2: "s i n" pattern (all letters separate)
r'\bs\s+i\s+n\b': '\\\\sin',
r'\bc\s+o\s+s\b': '\\\\cos',
r'\bt\s+a\s+n\b': '\\\\tan',
r'\bl\s+o\s+g\b': '\\\\log',
r'\bl\s+n\b': '\\\\ln', # Already covered above
r'\be\s+x\s+p\b': '\\\\exp',
r'\bc\s+o\s+t\b': '\\\\cot',
r'\bs\s+e\s+c\b': '\\\\sec',
r'\bc\s+s\s+c\b': '\\\\csc',
}
for pattern, replacement in split_functions.items():
latex = re.sub(pattern, replacement, latex)
# Fix double-escaped LaTeX commands (e.g., "\\sin" → "\sin")
# This handles cases where backslashes were incorrectly doubled
double_escaped_functions = [
'sin', 'cos', 'tan', 'cot', 'sec', 'csc',
'arcsin', 'arccos', 'arctan', 'sinh', 'cosh', 'tanh',
'log', 'ln', 'lg', 'exp',
'lim', 'limsup', 'liminf', 'sup', 'inf', 'max', 'min',
'det', 'dim', 'ker', 'arg', 'deg', 'gcd',
'sum', 'prod', 'int', 'oint',
'frac', 'dfrac', 'sqrt', 'left', 'right',
]
for func in double_escaped_functions:
# Pattern: \\func (two backslashes) → \func (one backslash)
# Be careful: we want \\ (4 chars in pattern string) to match actual \\
pattern = f'\\\\\\\\{func}' # Four backslashes = match two literal backslashes
replacement = f'\\\\{func}' # Two backslashes = one literal backslash
latex = latex.replace(pattern, replacement)
# Fix general double-backslash in LaTeX commands
# This handles cases like \\left\{, \\right\}, \\lceil, etc.
# Rule: Double backslash should ONLY appear in line breaks "\\ " (with space after)
# Pattern: \\<command> 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 \\<letter> (double backslash followed by letter)
# Replace with \<letter> (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