diff --git a/docs/fonts.py b/docs/fonts.py index e1212c30a..590f5f8c5 100644 --- a/docs/fonts.py +++ b/docs/fonts.py @@ -100,6 +100,88 @@ # :rcraw:`mathtext.fontset` back to one of matplotlib's math-specialized font sets # (e.g., ``'stixsans'`` or ``'dejavusans'``). # +# In short, math fonts can be tailored to taste at three levels, depending on +# how much of the "LaTeX look" you want: +# +# #. **Match the document font** (the default). With the ``'custom'`` font set, +# math follows the active text font -- including fonts assigned to +# individual text objects, as demonstrated below. +# #. **Computer Modern symbols**. Keep the active font for letters and numbers +# but render ``\mathcal`` and big operators with authentic Computer Modern +# glyphs by setting :rcraw:`mathtext.cm_symbols` to ``True`` +# (see :ref:`below `). +# #. **Full LaTeX**. Set matplotlib's :rcraw:`text.usetex` to ``True`` to +# delegate all text rendering to an external LaTeX installation. This gives +# complete LaTeX typesetting but is much slower and requires TeX on your +# system -- with the previous two options it is rarely necessary. +# +# The example below demonstrates the first level: the same expression rendered +# with different fonts assigned per text object. + +# %% +import ultraplot as uplt + +expr = ( + r"$\mathcal{ABCXYZ}\quad" + r"\sum_{i=0}^{n}\quad" + r"\prod_{j=1}^{m}\quad" + r"\int_a^b\quad" + r"\oint_C$" +) + +fig, axs = uplt.subplots(nrows=3, refwidth=6, refheight=1.1, share=False, span=False) +for ax, font in zip(axs, ("TeX Gyre Heros", "Fira Math", "DejaVu Sans")): + ax.text(0.02, 0.5, expr, transform="axes", va="center", fontsize=24, fontname=font) + ax.format(title=font, titleloc="left") +axs.format(xlocator="null", ylocator="null", xspineloc="none", yspineloc="none") + +# %% [raw] raw_mimetype="text/restructuredtext" +# +# .. _ug_fonts_cm: +# +# Computer Modern symbols +# ^^^^^^^^^^^^^^^^^^^^^^^ +# +# If you want to retain the active alphabet and number fonts while using +# Computer Modern for selected LaTeX-style symbols, set :rcraw:`mathtext.cm_symbols` +# to ``True``. This routes ``\mathcal`` through ``cmsy10`` and big operators like +# ``\sum``, ``\prod``, ``\int``, ``\oint``, ``\bigcup``, and ``\bigoplus`` 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. Note that math text is parsed when the +# figure is *drawn*, so the setting must be active at draw time -- assign it +# globally rather than wrapping plotting commands in a context block. + +# %% +fig, ax = uplt.subplots(refwidth=6, refheight=1.1) +ax.text(0.02, 0.5, expr, transform="axes", va="center", fontsize=24) +ax.format( + title="Default custom math text", + titleloc="left", + xlocator="null", + ylocator="null", + xspineloc="none", + yspineloc="none", +) + +# %% +uplt.rc["mathtext.cm_symbols"] = True +fig, ax = uplt.subplots(refwidth=6, refheight=1.1) +ax.text(0.02, 0.5, expr, transform="axes", va="center", fontsize=24) +ax.format( + title="Computer Modern math symbols", + titleloc="left", + xlocator="null", + ylocator="null", + xspineloc="none", + yspineloc="none", +) + +# %% +uplt.rc["mathtext.cm_symbols"] = False # restore the default + +# %% [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 diff --git a/ultraplot/internals/fonts.py b/ultraplot/internals/fonts.py index be9ea318c..505a6be6e 100644 --- a/ultraplot/internals/fonts.py +++ b/ultraplot/internals/fonts.py @@ -10,12 +10,51 @@ 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 -# Global constant + BakomaFonts = None + +# Global constants WARN_MATHPARSER = True +WARN_BAKOMA = True +_CM_SYMBOLS = frozenset( + ( + r"\sum", + r"\prod", + r"\coprod", + r"\int", + r"\oint", + r"\bigcup", + r"\bigcap", + r"\bigvee", + r"\bigwedge", + r"\biguplus", + r"\bigoplus", + r"\bigotimes", + r"\bigodot", + ) +) + + +def _is_cm_mathtext_enabled(): + try: + from ..config import rc + except ImportError: + return False + return bool(rc["mathtext.cm_symbols"]) + + +def _clear_math_parse_cache(): + # NOTE: Matplotlib caches parse results keyed on the input string and font + # properties only, so long-lived parsers (e.g. on agg renderers) would + # otherwise keep serving glyphs from the previous 'mathtext.cm_symbols' + # value after the setting changes. + cache = getattr(MathTextParser, "_parse_cached", None) + clear = getattr(cache, "cache_clear", None) + if clear is not None: + clear() class _UnicodeFonts(UnicodeFonts): @@ -41,8 +80,29 @@ 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): + # NOTE: The rc setting is read once per instance. Instances live for a + # single parse, and toggling the setting clears matplotlib's parse cache. + global WARN_BAKOMA + self._cm_font = None + self._cm_enabled = _is_cm_mathtext_enabled() + if not self._cm_enabled: + return + if BakomaFonts is None: + if WARN_BAKOMA: + warnings._warn_ultraplot( + "Cannot route math text symbols through Computer Modern with " + "this matplotlib version. Ignoring rc['mathtext.cm_symbols']." + ) + WARN_BAKOMA = False + self._cm_enabled = False + return + self._cm_font = BakomaFonts(*args, **kwargs) + self.fontmap["cal"] = self._cm_font.fontmap["cal"] + def _collect_replacements(self) -> tuple[dict, dict]: ctx = {} # rc context regular = {} # styles @@ -65,6 +125,10 @@ def _replace_fonts(self, regular: dict): font = self._fonts["regular"] # an ft2font.FT2Font instance font = ttfFontProperty(font) for texfont, prop in regular.items(): + # NOTE: Computer Modern calligraphic takes precedence over the + # 'regular' dummy value when symbol routing is enabled. + if texfont == "cal" and getattr(self, "_cm_enabled", False): + continue prop = prop.replace("regular", font.name) self.fontmap[texfont] = findfont(prop, fallback_to_default=False) elif WARN_MATHPARSER: @@ -72,6 +136,23 @@ def _replace_fonts(self, regular: dict): warnings._warn_ultraplot("Failed to update the math text parser.") WARN_MATHPARSER = False + def _uses_cm_symbol(self, sym: str) -> bool: + return ( + getattr(self, "_cm_enabled", False) + and getattr(self, "_cm_font", None) is not None + and sym in _CM_SYMBOLS + ) + + def _get_glyph(self, fontname: str, font_class: str, sym: str): + if self._uses_cm_symbol(sym): + return self._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): + if self._uses_cm_symbol(sym): + return self._cm_font.get_sized_alternatives_for_symbol(fontname, sym) + return super().get_sized_alternatives_for_symbol(fontname, sym) + # Replace the parser try: diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index 9b2726b8f..6493df60d 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -5,6 +5,7 @@ import functools import re +import sys from collections.abc import MutableMapping from numbers import Integral, Real @@ -652,6 +653,12 @@ def __setitem__(self, key, value): except (ValueError, TypeError) as error: raise ValueError(f"Key {key}: {error}") from None if key is not None: + # NOTE: Matplotlib's math text parse cache does not key on this + # ultraplot setting, so stale results must be dropped on change. + if key == "mathtext.cm_symbols" and dict.get(self, key) != value: + fonts = sys.modules.get("ultraplot.internals.fonts") + if fonts is not None: + fonts._clear_math_parse_cache() dict.__setitem__(self, key, value) @staticmethod @@ -1742,6 +1749,16 @@ def _validator_accepts(validator, value): _validate_bool, "Alias for :rcraw:`axes.formatter.useOffset`.", ), + # Math text settings + "mathtext.cm_symbols": ( + False, + _validate_bool, + "Whether to render ``\\mathcal`` and big operator symbols " + "(``\\sum``, ``\\int``, ``\\bigcup``, etc.) with Computer Modern " + "while preserving the active font for ordinary letters and numbers. " + "Unlike ``mathtext.fontset: cm`` this does not affect the rest " + "of the math text.", + ), # Geographic axes settings "geo.backend": ( "cartopy", diff --git a/ultraplot/tests/test_fonts.py b/ultraplot/tests/test_fonts.py index 7e9b9e8fb..52079bfd4 100644 --- a/ultraplot/tests/test_fonts.py +++ b/ultraplot/tests/test_fonts.py @@ -15,6 +15,94 @@ def test_replacement(): pass +# Postscript names of the Computer Modern fonts used for symbol routing +_CM_FONT_NAMES = {"Cmsy10", "Cmex10"} + + +def _mathtext_postscript_names(text, parser=None): + parser = parser or MathTextParser("path") + parsed = parser.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.""" + names = _mathtext_postscript_names(r"$ABC123$") + + assert names + assert not _CM_FONT_NAMES & set(names) + + +def test_mathtext_keeps_default_mathcal_font(): + """Test that mathcal does not use Computer Modern unless requested.""" + names = _mathtext_postscript_names(r"$\mathcal{ABC}$") + + assert names + assert "Cmsy10" not in names + + +def test_mathtext_cm_routes_mathcal_to_computer_modern(): + """Test that mathcal uses Computer Modern script glyphs when requested.""" + with uplt.rc.context({"mathtext.cm_symbols": True}): + names = _mathtext_postscript_names(r"$\mathcal{ABC}$") + + assert names == ["Cmsy10", "Cmsy10", "Cmsy10"] + + +def test_mathtext_keeps_default_operator_fonts(): + """Test that big operators use default fonts unless requested.""" + names = _mathtext_postscript_names(r"$\sum x \int y$") + + assert names[0] != "Cmex10" + assert names[2] != "Cmex10" + + +def test_mathtext_cm_routes_big_operators_to_computer_modern(): + """Test that big operators use Computer Modern glyphs when requested.""" + with uplt.rc.context({"mathtext.cm_symbols": True}): + names = _mathtext_postscript_names(r"$\sum x \bigcup y$") + + assert names[0] == "Cmex10" + assert names[2] == "Cmex10" + assert names[1] not in _CM_FONT_NAMES + assert names[3] not in _CM_FONT_NAMES + + +def test_mathtext_cm_toggle_invalidates_parse_cache(): + """Test that toggling the setting takes effect on a long-lived parser.""" + # NOTE: Matplotlib caches parse results per parser instance keyed on the + # input string, so without explicit invalidation a persistent parser (as + # kept by e.g. agg renderers) would serve stale glyphs after a toggle. + parser = MathTextParser("path") + expr = r"$\sum x$" + + assert _mathtext_postscript_names(expr, parser)[0] != "Cmex10" + with uplt.rc.context({"mathtext.cm_symbols": True}): + assert _mathtext_postscript_names(expr, parser)[0] == "Cmex10" + assert _mathtext_postscript_names(expr, parser)[0] != "Cmex10" + + +def test_mathtext_cm_warns_when_bakoma_unavailable(monkeypatch): + """Test one-time warning when Computer Modern fonts cannot be loaded.""" + monkeypatch.setattr(ufonts, "BakomaFonts", None) + monkeypatch.setattr(ufonts, "WARN_BAKOMA", True) + instance = ufonts._UnicodeFonts.__new__(ufonts._UnicodeFonts) + + with ( + uplt.rc.context({"mathtext.cm_symbols": True}), + patch("ultraplot.internals.warnings._warn_ultraplot") as mock_warn, + ): + instance._init_computer_modern_fonts() + assert instance._cm_enabled is False + assert instance._cm_font is None + mock_warn.assert_called_once() + + # Second instance should not warn again + instance = ufonts._UnicodeFonts.__new__(ufonts._UnicodeFonts) + instance._init_computer_modern_fonts() + mock_warn.assert_called_once() + + def test_warning_on_missing_attributes(monkeypatch): """Test warning is raised when font instance is missing required attributes.""" # Create a mock instance without initialization @@ -80,6 +168,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, @@ -106,6 +195,97 @@ 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_enabled = True + self.font_instance._cm_font = MagicMock() + self.font_instance._cm_font._get_glyph.return_value = expected + + 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_enabled = True + 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_get_glyph_routes_to_parent_when_disabled(self): + """Test operators use the parent handler when routing is disabled.""" + expected = object() + self.font_instance._cm_enabled = False + self.font_instance._cm_font = MagicMock() + + with patch.object(ufonts.UnicodeFonts, "_get_glyph", return_value=expected): + result = self.font_instance._get_glyph("it", "it", r"\sum") + + 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_enabled = True + self.font_instance._cm_font = MagicMock() + self.font_instance._cm_font.get_sized_alternatives_for_symbol.return_value = ( + expected + ) + + 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_enabled = True + 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_replace_fonts_keeps_computer_modern_cal(self): + """Test 'regular' cal replacement does not clobber Computer Modern cal.""" + self.font_instance._cm_enabled = True + self.font_instance.fontmap = {"cal": "cmsy10_path", "rm": "original_path"} + self.font_instance._fonts = {"regular": MagicMock()} + mock_font_prop = MagicMock() + mock_font_prop.name = "test-font" + + with ( + patch( + "ultraplot.internals.fonts.ttfFontProperty", return_value=mock_font_prop + ), + patch("ultraplot.internals.fonts.findfont", return_value="new_font_path"), + ): + self.font_instance._replace_fonts( + {"cal": "regular:script", "rm": "regular:normal"} + ) + + assert self.font_instance.fontmap["cal"] == "cmsy10_path" + assert self.font_instance.fontmap["rm"] == "new_font_path" + def test_collect_replacements_no_regular_fonts(self, monkeypatch): """Test _collect_replacements with no 'regular' fonts in rcParams.""" # Mock rcParams with no 'regular' fonts