Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/fonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,31 @@
# :rcraw:`mathtext.fontset` back to one of matplotlib's math-specialized font sets
# (e.g., ``'stixsans'`` or ``'dejavusans'``).
#
# If you want to retain UltraPlot's active alphabet and number fonts while using
# Computer Modern for selected LaTeX-style symbols, set :rcraw:`mathtext.cm` to
# ``True``. This routes ``\mathcal`` through ``cmsy10`` and selected large
# operators like ``\sum``, ``\prod``, ``\coprod``, ``\int``, and ``\oint``
# through ``cmex10``. This does not affect ordinary letters and numbers, and it
# does not provide a Computer Modern ``\mathfrak`` font because matplotlib's
# bundled mathtext fonts do not include one.

# %%
import ultraplot as uplt

fig, axs = uplt.subplots(nrows=2, refwidth=6, refheight=1.4, share=False, span=False)
expr = r"$\mathcal{ABC}\quad \sum_i x_i \quad \int_a^b f(x)\,dx$"

for ax, cm, title in zip(
axs,
(False, True),
("Default custom math text", "Selected Computer Modern math text"),
):
with uplt.rc.context({"mathtext.cm": cm}):
ax.text(0.02, 0.5, expr, transform="axes", va="center", fontsize=20)
ax.format(title=title, titleloc="left", xlocator="null", ylocator="null")

# %% [raw] raw_mimetype="text/restructuredtext"
#
# A table of math text containing the sans-serif fonts packaged with UltraPlot is shown
# below. The dummy glyph "¤" is shown where a given math character is unavailable
# for a particular font (in practice, the fallback font :rc:`mathtext.fallback` is used
Expand Down
39 changes: 38 additions & 1 deletion ultraplot/internals/fonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,23 @@
from . import warnings

try: # newer versions
from matplotlib._mathtext import UnicodeFonts
from matplotlib._mathtext import BakomaFonts, UnicodeFonts
except ImportError: # older versions
from matplotlib.mathtext import UnicodeFonts

BakomaFonts = None

# Global constant
WARN_MATHPARSER = True
_CM_OPERATOR_SYMBOLS = frozenset((r"\sum", r"\prod", r"\coprod", r"\int", r"\oint"))


def _is_cm_mathtext_enabled():
try:
from ..config import rc
except ImportError:
return False
return bool(rc["mathtext.cm"])


class _UnicodeFonts(UnicodeFonts):
Expand All @@ -41,8 +52,14 @@ def __init__(self, *args, **kwargs):
ctx, regular = self._collect_replacements()
with mpl.rc_context(ctx):
super().__init__(*args, **kwargs)
self._init_computer_modern_fonts(*args, **kwargs)
self._replace_fonts(regular)

def _init_computer_modern_fonts(self, *args, **kwargs):
self._cm_font = BakomaFonts(*args, **kwargs) if BakomaFonts else None
if _is_cm_mathtext_enabled() and self._cm_font is not None:
self.fontmap["cal"] = self._cm_font.fontmap["cal"]

def _collect_replacements(self) -> tuple[dict, dict]:
ctx = {} # rc context
regular = {} # styles
Expand Down Expand Up @@ -72,6 +89,26 @@ def _replace_fonts(self, regular: dict):
warnings._warn_ultraplot("Failed to update the math text parser.")
WARN_MATHPARSER = False

def _get_glyph(self, fontname: str, font_class: str, sym: str):
cm_font = getattr(self, "_cm_font", None)
if (
_is_cm_mathtext_enabled()
and cm_font is not None
and sym in _CM_OPERATOR_SYMBOLS
):
return cm_font._get_glyph(fontname, font_class, sym)
return super()._get_glyph(fontname, font_class, sym)

def get_sized_alternatives_for_symbol(self, fontname: str, sym: str):
cm_font = getattr(self, "_cm_font", None)
if (
_is_cm_mathtext_enabled()
and cm_font is not None
and sym in _CM_OPERATOR_SYMBOLS
):
return cm_font.get_sized_alternatives_for_symbol(fontname, sym)
return super().get_sized_alternatives_for_symbol(fontname, sym)


# Replace the parser
try:
Expand Down
7 changes: 7 additions & 0 deletions ultraplot/internals/rcsetup.py
Original file line number Diff line number Diff line change
Expand Up @@ -1714,6 +1714,13 @@ def _validator_accepts(validator, value):
_validate_bool,
"Alias for :rcraw:`axes.formatter.useOffset`.",
),
# Math text settings
"mathtext.cm": (
False,
_validate_bool,
"Whether to route selected math text glyphs through Computer Modern "
"while preserving the active font for ordinary letters and numbers.",
),
# Geographic axes settings
"geo.backend": (
"cartopy",
Expand Down
108 changes: 108 additions & 0 deletions ultraplot/tests/test_fonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,57 @@ def test_replacement():
pass


def _mathtext_postscript_names(text):
parsed = MathTextParser("path").parse(text, dpi=72)
return [glyph[0].postscript_name for glyph in parsed.glyphs]


def test_mathtext_letters_and_numbers_keep_active_font():
"""Test that plain math text still uses the active non-CM math font."""
with uplt.rc.context({}):
names = _mathtext_postscript_names(r"$ABC123$")

assert names
assert all(name.lower() not in {"cmex10", "cmsy10"} for name in names)


def test_mathtext_keeps_default_mathcal_font():
"""Test that mathcal does not use Computer Modern unless requested."""
with uplt.rc.context({}):
names = _mathtext_postscript_names(r"$\mathcal{ABC}$")

assert names
assert all(name != "Cmsy10" for name in names)


def test_mathtext_cm_routes_mathcal_to_computer_modern():
"""Test that mathcal uses Computer Modern script glyphs by default."""
with uplt.rc.context({"mathtext.cm": True}):
names = _mathtext_postscript_names(r"$\mathcal{ABC}$")

assert names == ["Cmsy10", "Cmsy10", "Cmsy10"]


def test_mathtext_keeps_default_operator_fonts():
"""Test that selected operators use default fonts unless requested."""
with uplt.rc.context({}):
names = _mathtext_postscript_names(r"$\sum x \int y$")

assert names[0] != "Cmex10"
assert names[2] != "Cmex10"


def test_mathtext_cm_routes_selected_operators_to_computer_modern():
"""Test that selected large operators use Computer Modern glyphs."""
with uplt.rc.context({"mathtext.cm": True}):
names = _mathtext_postscript_names(r"$\sum x \int y$")

assert names[0] == "Cmex10"
assert names[2] == "Cmex10"
assert names[1].lower() not in {"cmex10", "cmsy10"}
assert names[3].lower() not in {"cmex10", "cmsy10"}


def test_warning_on_missing_attributes(monkeypatch):
"""Test warning is raised when font instance is missing required attributes."""
# Create a mock instance without initialization
Expand Down Expand Up @@ -80,6 +131,7 @@ def test_init_method(self, monkeypatch):
return_value=(mock_ctx, mock_regular),
) as mock_collect,
patch.object(ufonts._UnicodeFonts, "_replace_fonts") as mock_replace,
patch.object(ufonts._UnicodeFonts, "_init_computer_modern_fonts"),
patch.object(
ufonts.UnicodeFonts, "__init__", return_value=None
) as mock_super_init,
Expand All @@ -106,6 +158,62 @@ def test_init_method(self, monkeypatch):
# Verify rc_context was called with the correct arguments
mock_rc_context.assert_called_once_with(mock_ctx)

def test_get_glyph_routes_operator_to_computer_modern(self):
"""Test selected operators are delegated to the Computer Modern handler."""
expected = object()
self.font_instance._cm_font = MagicMock()
self.font_instance._cm_font._get_glyph.return_value = expected

with uplt.rc.context({"mathtext.cm": True}):
result = self.font_instance._get_glyph("it", "it", r"\sum")

assert result is expected
self.font_instance._cm_font._get_glyph.assert_called_once_with(
"it", "it", r"\sum"
)

def test_get_glyph_routes_non_operator_to_parent(self):
"""Test non-operator glyphs still use the parent mathtext handler."""
expected = object()
self.font_instance._cm_font = MagicMock()

with patch.object(ufonts.UnicodeFonts, "_get_glyph", return_value=expected):
result = self.font_instance._get_glyph("it", "it", "x")

assert result is expected
self.font_instance._cm_font._get_glyph.assert_not_called()

def test_sized_alternatives_routes_operator_to_computer_modern(self):
"""Test selected operator sizing is delegated to Computer Modern."""
expected = [("ex", r"\sum")]
self.font_instance._cm_font = MagicMock()
self.font_instance._cm_font.get_sized_alternatives_for_symbol.return_value = (
expected
)

with uplt.rc.context({"mathtext.cm": True}):
result = self.font_instance.get_sized_alternatives_for_symbol("it", r"\sum")

assert result == expected
self.font_instance._cm_font.get_sized_alternatives_for_symbol.assert_called_once_with(
"it", r"\sum"
)

def test_sized_alternatives_routes_non_operator_to_parent(self):
"""Test non-operator sizing still uses the parent mathtext handler."""
expected = [("it", "x")]
self.font_instance._cm_font = MagicMock()

with patch.object(
ufonts.UnicodeFonts,
"get_sized_alternatives_for_symbol",
return_value=expected,
):
result = self.font_instance.get_sized_alternatives_for_symbol("it", "x")

assert result == expected
self.font_instance._cm_font.get_sized_alternatives_for_symbol.assert_not_called()

def test_collect_replacements_no_regular_fonts(self, monkeypatch):
"""Test _collect_replacements with no 'regular' fonts in rcParams."""
# Mock rcParams with no 'regular' fonts
Expand Down