服务: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 默认密钥已清空
2095 lines
98 KiB
Python
2095 lines
98 KiB
Python
"""
|
||
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
|