Source code for scitex_str._latex_fallback

#!/usr/bin/env python3
# Timestamp: "2025-08-21 21:37:23 (ywatanabe)"
# File: /home/ywatanabe/proj/scitex_repo/src/scitex/str/_latex_fallback.py
# ----------------------------------------
from __future__ import annotations

import os

__FILE__ = __file__
__DIR__ = os.path.dirname(__FILE__)
# ----------------------------------------

# Time-stamp: "2025-06-05 12:00:00 (ywatanabe)"


"""
LaTeX Fallback Mechanism for SciTeX

This module provides a robust fallback system for LaTeX rendering issues.
When LaTeX compilation fails (e.g., missing fonts, Node.js conflicts),
it gracefully degrades to mathtext or plain text alternatives.

Functionality:
    - Detect LaTeX rendering capabilities
    - Convert LaTeX to mathtext equivalents
    - Provide plain text fallbacks
    - Cache LaTeX capability status
Input:
    LaTeX strings and matplotlib configuration
Output:
    Fallback-compatible strings for matplotlib
Prerequisites:
    matplotlib
"""

import functools
import re
from typing import Any, Callable, Dict

# matplotlib imports moved to functions that need them


# Delay logging import to avoid circular dependency
# scitex.logging imports _Tee which imports scitex.str which imports this file
def _get_logger():
    """Get logger lazily to avoid circular import."""
    import scitex_logging as logging

    return logging.getLogger(__name__)


# Use property-like access for logger
class _LoggerProxy:
    def __getattr__(self, name):
        return getattr(_get_logger(), name)


logger = _LoggerProxy()

# Global state for LaTeX capability
_latex_available = None
_fallback_mode = "auto"  # "auto", "force_mathtext", "force_plain"


[docs] class LaTeXFallbackError(Exception): """Raised when LaTeX fallback mechanisms fail.""" pass
[docs] def set_fallback_mode(mode: str) -> None: """ Set the global fallback mode for LaTeX rendering. Parameters ---------- mode : str Fallback mode: "auto" (detect capability), "force_mathtext", or "force_plain" """ global _fallback_mode if mode not in ["auto", "force_mathtext", "force_plain"]: raise ValueError(f"Invalid fallback mode: {mode}") _fallback_mode = mode # Reset capability cache when mode changes global _latex_available _latex_available = None
[docs] def get_fallback_mode() -> str: """Get the current fallback mode.""" return _fallback_mode
[docs] @functools.lru_cache(maxsize=1) def check_latex_capability() -> bool: """ Check if LaTeX rendering is available and working. Returns ------- bool True if LaTeX is available, False otherwise """ global _latex_available # Import matplotlib here when actually needed try: import matplotlib.pyplot as plt except ImportError: _latex_available = False return False # If forcing a mode, return accordingly if _fallback_mode == "force_mathtext" or _fallback_mode == "force_plain": _latex_available = False return False # Cache the result if already determined if _latex_available is not None: return _latex_available try: # Test if LaTeX is configured if not plt.rcParams.get("text.usetex", False): _latex_available = False return False # Try a simple LaTeX rendering test fig, ax = plt.subplots(figsize=(1, 1)) try: # Test with a simple LaTeX expression text_obj = ax.text(0.5, 0.5, r"$x^2$", usetex=True) fig.canvas.draw() # Force rendering _latex_available = True result = True except Exception as e: logger.debug(f"LaTeX capability test failed: {e}") _latex_available = False result = False finally: plt.close(fig) return result except Exception as e: logger.debug(f"LaTeX capability check failed: {e}") _latex_available = False return False
[docs] def latex_to_mathtext(latex_str: str) -> str: """ Convert LaTeX syntax to matplotlib mathtext equivalent. Parameters ---------- latex_str : str LaTeX string to convert Returns ------- str Mathtext equivalent string """ if not latex_str: return latex_str # Remove outer $ if present text = latex_str.strip() if text.startswith("$") and text.endswith("$"): text = text[1:-1] # Simple string replacements (double backslash to single) simple_conversions = { # Greek letters r"\\alpha": "\\alpha", r"\\beta": "\\beta", r"\\gamma": "\\gamma", r"\\delta": "\\delta", r"\\epsilon": "\\epsilon", r"\\theta": "\\theta", r"\\lambda": "\\lambda", r"\\mu": "\\mu", r"\\pi": "\\pi", r"\\sigma": "\\sigma", r"\\tau": "\\tau", r"\\phi": "\\phi", r"\\omega": "\\omega", # Mathematical symbols r"\\sum": "\\sum", r"\\int": "\\int", r"\\partial": "\\partial", r"\\infty": "\\infty", r"\\pm": "\\pm", r"\\times": "\\times", r"\\cdot": "\\cdot", r"\\approx": "\\approx", r"\\neq": "\\neq", r"\\leq": "\\leq", r"\\geq": "\\geq", # Functions r"\\sin": "\\sin", r"\\cos": "\\cos", r"\\tan": "\\tan", r"\\log": "\\log", r"\\ln": "\\ln", r"\\exp": "\\exp", } # Apply simple string replacements for pattern, replacement in simple_conversions.items(): text = text.replace(pattern, replacement) # Regex patterns with capture groups regex_conversions = [ # Formatting (limited mathtext support) (r"\\textbf\{([^}]+)\}", r"\\mathbf{\1}"), (r"\\mathbf\{([^}]+)\}", r"\\mathbf{\1}"), (r"\\textit\{([^}]+)\}", r"\\mathit{\1}"), (r"\\mathit\{([^}]+)\}", r"\\mathit{\1}"), # Hats and accents (r"\\hat\{([^}]+)\}", r"\\hat{\1}"), (r"\\overrightarrow\{([^}]+)\}", r"\\vec{\1}"), (r"\\vec\{([^}]+)\}", r"\\vec{\1}"), # Fractions (simple ones) (r"\\frac\{([^}]+)\}\{([^}]+)\}", r"\\frac{\1}{\2}"), ] # Apply regex conversions for pattern, replacement in regex_conversions: text = re.sub(pattern, replacement, text) # Wrap in mathtext markers return f"${text}$"
[docs] def latex_to_unicode(latex_str: str) -> str: """ Convert LaTeX to Unicode plain text equivalent. Parameters ---------- latex_str : str LaTeX string to convert Returns ------- str Unicode plain text equivalent """ if not latex_str: return latex_str # Remove outer $ if present text = latex_str.strip() if text.startswith("$") and text.endswith("$"): text = text[1:-1] # Greek letters to Unicode greek_conversions = { r"\\alpha": "α", r"\\beta": "β", r"\\gamma": "γ", r"\\delta": "δ", r"\\epsilon": "ε", r"\\zeta": "ζ", r"\\eta": "η", r"\\theta": "θ", r"\\iota": "ι", r"\\kappa": "κ", r"\\lambda": "λ", r"\\mu": "μ", r"\\nu": "ν", r"\\xi": "ξ", r"\\pi": "π", r"\\rho": "ρ", r"\\sigma": "σ", r"\\tau": "τ", r"\\upsilon": "υ", r"\\phi": "φ", r"\\chi": "χ", r"\\psi": "ψ", r"\\omega": "ω", # Capital Greek r"\\Gamma": "Γ", r"\\Delta": "Δ", r"\\Theta": "Θ", r"\\Lambda": "Λ", r"\\Xi": "Ξ", r"\\Pi": "Π", r"\\Sigma": "Σ", r"\\Upsilon": "Υ", r"\\Phi": "Φ", r"\\Psi": "Ψ", r"\\Omega": "Ω", } # Mathematical symbols to Unicode symbol_conversions = { r"\\pm": "±", r"\\times": "×", r"\\cdot": "·", r"\\div": "÷", r"\\neq": "≠", r"\\leq": "≤", r"\\geq": "≥", r"\\approx": "≈", r"\\infty": "∞", r"\\partial": "∂", r"\\sum": "∑", r"\\int": "∫", r"\\sqrt": "√", r"\\angle": "∠", r"\\degree": "°", } # Superscript numbers superscript_conversions = { r"\^0": "⁰", r"\^1": "¹", r"\^2": "²", r"\^3": "³", r"\^4": "⁴", r"\^5": "⁵", r"\^6": "⁶", r"\^7": "⁷", r"\^8": "⁸", r"\^9": "⁹", r"\^\{0\}": "⁰", r"\^\{1\}": "¹", r"\^\{2\}": "²", r"\^\{3\}": "³", r"\^\{4\}": "⁴", r"\^\{5\}": "⁵", r"\^\{6\}": "⁶", r"\^\{7\}": "⁷", r"\^\{8\}": "⁸", r"\^\{9\}": "⁹", } # Subscript numbers subscript_conversions = { r"_0": "₀", r"_1": "₁", r"_2": "₂", r"_3": "₃", r"_4": "₄", r"_5": "₅", r"_6": "₆", r"_7": "₇", r"_8": "₈", r"_9": "₉", r"_\{0\}": "₀", r"_\{1\}": "₁", r"_\{2\}": "₂", r"_\{3\}": "₃", r"_\{4\}": "₄", r"_\{5\}": "₅", r"_\{6\}": "₆", r"_\{7\}": "₇", r"_\{8\}": "₈", r"_\{9\}": "₉", } # Apply all conversions all_conversions = { **greek_conversions, **symbol_conversions, **superscript_conversions, **subscript_conversions, } for latex_pattern, unicode_char in all_conversions.items(): text = re.sub(latex_pattern, unicode_char, text) # Remove remaining LaTeX commands (simple cleanup) text = re.sub( r"\\[a-zA-Z]+\{([^}]*)\}", r"\1", text ) # \command{content} -> content text = re.sub(r"\\[a-zA-Z]+", "", text) # \command -> remove text = re.sub(r"[{}]", "", text) # Remove remaining braces return text
[docs] def safe_latex_render( text: str, fallback_strategy: str = "auto", preserve_math: bool = True ) -> str: """ Safely render LaTeX text with automatic fallback. Parameters ---------- text : str Text that may contain LaTeX fallback_strategy : str, optional Strategy when LaTeX fails: "auto", "mathtext", "unicode", "plain" preserve_math : bool, optional Whether to preserve mathematical notation in fallbacks Returns ------- str Safely rendered text """ if not text or not isinstance(text, str): return text # Import matplotlib when needed import matplotlib.pyplot as plt # Determine if we should attempt LaTeX use_latex = _fallback_mode == "auto" and check_latex_capability() if use_latex: # Try LaTeX first try: # Test rendering capability with the actual text fig, ax = plt.subplots(figsize=(1, 1)) try: ax.text(0.5, 0.5, text, usetex=True) fig.canvas.draw() plt.close(fig) return text # LaTeX works except Exception: plt.close(fig) raise LaTeXFallbackError("LaTeX rendering failed") except Exception: # Fall through to fallback strategies pass # Apply fallback strategy if fallback_strategy == "auto": # Automatically choose best fallback if preserve_math and ("$" in text or "\\" in text): try: return latex_to_mathtext(text) except Exception: return latex_to_unicode(text) else: return latex_to_unicode(text) elif fallback_strategy == "mathtext": return latex_to_mathtext(text) elif fallback_strategy == "unicode": return latex_to_unicode(text) elif fallback_strategy == "plain": # Strip all LaTeX and return plain text plain = latex_to_unicode(text) # Remove any remaining special characters plain = re.sub(r"[^\w\s\(\)\[\].,;:!?-]", "", plain) return plain else: raise ValueError(f"Unknown fallback strategy: {fallback_strategy}")
[docs] def latex_fallback_decorator( fallback_strategy: str = "auto", preserve_math: bool = True ): """ Decorator to add LaTeX fallback capability to functions. Parameters ---------- fallback_strategy : str, optional Fallback strategy to use preserve_math : bool, optional Whether to preserve mathematical notation Returns ------- callable Decorated function with LaTeX fallback """ def decorator(func: Callable) -> Callable: @functools.wraps(func) def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except Exception as e: # Check if this is a LaTeX-related error error_str = str(e).lower() latex_error_indicators = [ "latex", "tex", "dvi", "tfm", "font", "usetex", "kpathsea", "dvipng", "ghostscript", ] if any(indicator in error_str for indicator in latex_error_indicators): logger.warning(f"LaTeX error in {func.__name__}: {e}") logger.warning("Falling back to alternative text rendering") # Try to apply fallback to string arguments new_args = [] for arg in args: if isinstance(arg, str): new_args.append( safe_latex_render(arg, fallback_strategy, preserve_math) ) else: new_args.append(arg) new_kwargs = {} for key, value in kwargs.items(): if isinstance(value, str): new_kwargs[key] = safe_latex_render( value, fallback_strategy, preserve_math ) else: new_kwargs[key] = value # Import matplotlib when needed import matplotlib.pyplot as plt # Temporarily disable LaTeX for this call original_usetex = plt.rcParams.get("text.usetex", False) plt.rcParams["text.usetex"] = False try: result = func(*new_args, **new_kwargs) return result finally: plt.rcParams["text.usetex"] = original_usetex else: # Re-raise non-LaTeX errors raise return wrapper return decorator
[docs] def get_latex_status() -> Dict[str, Any]: """ Get comprehensive LaTeX status information. Returns ------- Dict[str, Any] Status information including capability, mode, and configuration """ import matplotlib.pyplot as plt return { "latex_available": check_latex_capability(), "fallback_mode": get_fallback_mode(), "usetex_enabled": plt.rcParams.get("text.usetex", False), "mathtext_fontset": plt.rcParams.get("mathtext.fontset", "cm"), "font_family": plt.rcParams.get("font.family", ["serif"]), "cache_info": check_latex_capability.cache_info()._asdict(), }
# Convenience functions
[docs] def enable_latex_fallback(mode: str = "auto") -> None: """Enable LaTeX fallback with specified mode.""" set_fallback_mode(mode)
[docs] def disable_latex_fallback() -> None: """Disable LaTeX fallback (force LaTeX usage).""" global _latex_available _latex_available = True # Force LaTeX usage
[docs] def reset_latex_cache() -> None: """Reset the LaTeX capability cache.""" check_latex_capability.cache_clear() global _latex_available _latex_available = None
# EOF