Source code for mathxlab.utils.plotting
"""Shared plotting utilities for py-mathx-lab.
This module centralizes Matplotlib configuration so experiments can remain
small and consistent.
A key feature is optional *true LaTeX* rendering in generated figures:
- Default: Matplotlib's built-in mathtext engine (portable; no TeX required)
with a LaTeX-like global style (Computer Modern math + serif font stack).
- Optional: enable `text.usetex = True` if a LaTeX toolchain is available.
Recommended usage (early in an experiment's `run()`):
from mathxlab.utils.plotting import configure_matplotlib
configure_matplotlib() # portable defaults
# or: MATHXLAB_USETEX=1 make run EXP=e012
The environment variable `MATHXLAB_USETEX` controls LaTeX usage:
- unset / "0" -> mathtext
- "1" -> try LaTeX; fall back to mathtext if toolchain is missing
Notes:
- Matplotlib's `usetex` path normally uses `latex` + `dvipng` (or `dvisvgm`)
behind the scenes. This is independent from Sphinx `latexmk` runs.
"""
from __future__ import annotations
import os
import shutil
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
import matplotlib as mpl
__all__ = [
"LatexToolchainStatus",
"configure_matplotlib",
"detect_latex_toolchain",
"make_math_label",
]
# ------------------------------------------------------------------------------
# A LaTeX-ish serif font preference stack.
# Matplotlib will pick the first installed font available at runtime.
_LATEXISH_SERIF_STACK: list[str] = [
# Best if installed:
"CMU Serif", # Computer Modern Unicode
"Computer Modern Roman",
"Latin Modern Roman",
# Good widely-available fallback with TeX-like proportions:
"STIXGeneral",
# Always available with Matplotlib:
"DejaVu Serif",
]
# ------------------------------------------------------------------------------
[docs]
@dataclass(frozen=True, slots=True)
class LatexToolchainStatus:
"""
Represents the availability of an external LaTeX toolchain.
Args:
latex: True if `latex` (or an alternative TeX engine) is available.
dvipng: True if `dvipng` is available.
dvisvgm: True if `dvisvgm` is available.
Examples:
>>> from mathxlab.utils.plotting import LatexToolchainStatus
>>> LatexToolchainStatus # doctest: +SKIP
"""
latex: bool
dvipng: bool
dvisvgm: bool
# ------------------------------------------------------------------------------
[docs]
def detect_latex_toolchain() -> LatexToolchainStatus:
"""
Detect whether an external LaTeX toolchain is available.
Matplotlib's `text.usetex = True` typically requires:
- `latex`
- and one of `dvipng` or `dvisvgm`
Returns:
A `LatexToolchainStatus` instance describing which tools were found.
Examples:
>>> from mathxlab.utils.plotting import detect_latex_toolchain
>>> detect_latex_toolchain # doctest: +SKIP
"""
latex = shutil.which("latex") is not None
dvipng = shutil.which("dvipng") is not None
dvisvgm = shutil.which("dvisvgm") is not None
return LatexToolchainStatus(latex=latex, dvipng=dvipng, dvisvgm=dvisvgm)
# ------------------------------------------------------------------------------
def _env_flag(name: str, default: str = "0") -> bool:
"""Parse a boolean-like environment variable.
Args:
name: Environment variable name.
default: Default value if the variable is not set.
Returns:
True if the variable is set to a truthy value ("1", "true", "yes", "on"),
otherwise False.
"""
raw = os.getenv(name, default).strip().lower()
return raw in {"1", "true", "yes", "on"}
# ------------------------------------------------------------------------------
[docs]
def configure_matplotlib(
*,
use_tex: bool | None = None,
tex_preamble: Sequence[str] | None = None,
font_family: str = "serif",
rcparams: Mapping[str, object] | None = None,
) -> bool:
"""
Configure Matplotlib defaults for experiments.
This sets stable, portable defaults and optionally enables true LaTeX
rendering for math labels.
Selection logic:
- If `use_tex` is given, it is respected.
- Otherwise, `MATHXLAB_USETEX=1` triggers an attempt to enable `usetex`.
- If LaTeX tools are missing, configuration falls back to mathtext.
Global LaTeX-like look without TeX:
- `mathtext.fontset = "cm"` uses Computer Modern for `$...$`.
- `font.serif` prefers CMU/Computer Modern/Latin Modern (falls back cleanly).
- `axes.formatter.use_mathtext = True` makes tick formatting use mathtext.
Args:
use_tex: Whether to enable Matplotlib's `text.usetex`. If None, uses
`MATHXLAB_USETEX` env var.
tex_preamble: Optional LaTeX preamble lines (e.g. packages). Only used
when LaTeX is enabled.
font_family: Matplotlib font family to prefer (defaults to serif).
rcparams: Extra rcParams to apply after base configuration.
Returns:
True if LaTeX rendering was enabled; False if mathtext is used.
Notes:
Call this once near the start of an experiment to keep all figures consistent across HTML and PDF builds.
Examples:
>>> from mathxlab.utils.plotting import configure_matplotlib
>>> configure_matplotlib() # portable defaults for docs/CI
"""
# Stable defaults that work well across platforms.
#
# Important: we configure a LaTeX-ish mathtext + font stack by default,
# even when TeX is not installed, so `$...$` and surrounding text look consistent.
base: dict[str, object] = {
# Text (non-math) font configuration
"font.family": font_family,
"font.serif": list(_LATEXISH_SERIF_STACK),
# MathText configuration (TeX-free, but LaTeX-like)
"mathtext.fontset": "cm", # Computer Modern math look
"axes.formatter.use_mathtext": True, # tick formatter uses mathtext too
# General figure defaults
"axes.grid": True,
"figure.constrained_layout.use": True,
"savefig.bbox": "tight",
"savefig.pad_inches": 0.05,
}
# Apply base first so later values can override.
mpl.rcParams.update(base)
if use_tex is None:
use_tex = _env_flag("MATHXLAB_USETEX", default="0")
latex_enabled = False
if use_tex:
status = detect_latex_toolchain()
if status.latex and (status.dvipng or status.dvisvgm):
mpl.rcParams.update({"text.usetex": True})
latex_enabled = True
if tex_preamble:
# Matplotlib expects a single string for the preamble.
mpl.rcParams.update({"text.latex.preamble": "\n".join(tex_preamble)})
else:
# Fall back cleanly.
mpl.rcParams.update({"text.usetex": False})
latex_enabled = False
else:
mpl.rcParams.update({"text.usetex": False})
latex_enabled = False
if rcparams:
mpl.rcParams.update(dict(rcparams))
return latex_enabled
# ------------------------------------------------------------------------------
[docs]
def make_math_label(expr: str) -> str:
"""
Wrap an expression in `$...$` for math rendering.
Args:
expr: A LaTeX/mathtext expression (without surrounding `$`).
Returns:
A string like `"$\\pi(x) \\sim x/\\log x$"`.
Notes:
Use raw strings in calling code where convenient:
ax.set_title(rf"Prime counting: {make_math_label('\\pi(x)')}")
This helper keeps figure code consistent and avoids duplicated `$`.
Examples:
>>> from mathxlab.utils.plotting import make_math_label
>>> make_math_label(r"\\pi(x) \\sim x/\\log x")
'$\\pi(x) \\sim x/\\log x$'
"""
inner = expr.strip()
if inner.startswith("$") and inner.endswith("$"):
return inner
return f"${inner}$"