From 42c64b6143498233fb66d0da6f31fc4c0b83fde6 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 29 Jun 2026 11:42:23 +1000 Subject: [PATCH 1/5] Route selected mathtext glyphs to Computer Modern --- ultraplot/internals/fonts.py | 20 +++++++- ultraplot/internals/rcsetup.py | 2 +- ultraplot/tests/test_fonts.py | 88 ++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/ultraplot/internals/fonts.py b/ultraplot/internals/fonts.py index be9ea318c..b356fb160 100644 --- a/ultraplot/internals/fonts.py +++ b/ultraplot/internals/fonts.py @@ -10,12 +10,14 @@ 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")) class _UnicodeFonts(UnicodeFonts): @@ -41,8 +43,12 @@ 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 + def _collect_replacements(self) -> tuple[dict, dict]: ctx = {} # rc context regular = {} # styles @@ -72,6 +78,18 @@ 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 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 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: diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index eedb4ae38..2de32e288 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -926,7 +926,7 @@ def _validator_accepts(validator, value): "mathtext.default": "it", "mathtext.fontset": "custom", "mathtext.bf": "regular:bold", # custom settings implemented above - "mathtext.cal": "cursive", + "mathtext.cal": "cmsy10", "mathtext.it": "regular:italic", "mathtext.rm": "regular", "mathtext.sf": "regular", diff --git a/ultraplot/tests/test_fonts.py b/ultraplot/tests/test_fonts.py index 7e9b9e8fb..cac41feb0 100644 --- a/ultraplot/tests/test_fonts.py +++ b/ultraplot/tests/test_fonts.py @@ -15,6 +15,39 @@ 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_routes_mathcal_to_computer_modern(): + """Test that mathcal uses Computer Modern script glyphs by default.""" + with uplt.rc.context({}): + names = _mathtext_postscript_names(r"$\mathcal{ABC}$") + + assert names == ["Cmsy10", "Cmsy10", "Cmsy10"] + + +def test_mathtext_routes_selected_operators_to_computer_modern(): + """Test that selected large operators use Computer Modern glyphs.""" + with uplt.rc.context({}): + 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 @@ -80,6 +113,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 +140,60 @@ 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 + + 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 + ) + + 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 From 244d5a44a3c4ccda161ed8efa1bc19173b992996 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 29 Jun 2026 11:54:41 +1000 Subject: [PATCH 2/5] Defaults are defaults --- ultraplot/internals/fonts.py | 23 +++++++++++++++++++++-- ultraplot/internals/rcsetup.py | 9 ++++++++- ultraplot/tests/test_fonts.py | 32 ++++++++++++++++++++++++++------ 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/ultraplot/internals/fonts.py b/ultraplot/internals/fonts.py index b356fb160..652d2662a 100644 --- a/ultraplot/internals/fonts.py +++ b/ultraplot/internals/fonts.py @@ -13,6 +13,7 @@ from matplotlib._mathtext import BakomaFonts, UnicodeFonts except ImportError: # older versions from matplotlib.mathtext import UnicodeFonts + BakomaFonts = None # Global constant @@ -20,6 +21,14 @@ _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): """ A simple `~matplotlib._mathtext.UnicodeFonts` subclass that @@ -48,6 +57,8 @@ def __init__(self, *args, **kwargs): 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 @@ -80,13 +91,21 @@ def _replace_fonts(self, regular: dict): def _get_glyph(self, fontname: str, font_class: str, sym: str): cm_font = getattr(self, "_cm_font", None) - if cm_font is not None and sym in _CM_OPERATOR_SYMBOLS: + 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 cm_font is not None and sym in _CM_OPERATOR_SYMBOLS: + 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) diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index 2de32e288..d1b5a5e37 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -926,7 +926,7 @@ def _validator_accepts(validator, value): "mathtext.default": "it", "mathtext.fontset": "custom", "mathtext.bf": "regular:bold", # custom settings implemented above - "mathtext.cal": "cmsy10", + "mathtext.cal": "cursive", "mathtext.it": "regular:italic", "mathtext.rm": "regular", "mathtext.sf": "regular", @@ -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", diff --git a/ultraplot/tests/test_fonts.py b/ultraplot/tests/test_fonts.py index cac41feb0..cdbf613f5 100644 --- a/ultraplot/tests/test_fonts.py +++ b/ultraplot/tests/test_fonts.py @@ -29,19 +29,37 @@ def test_mathtext_letters_and_numbers_keep_active_font(): assert all(name.lower() not in {"cmex10", "cmsy10"} for name in names) -def test_mathtext_routes_mathcal_to_computer_modern(): - """Test that mathcal uses Computer Modern script glyphs by default.""" +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_routes_selected_operators_to_computer_modern(): - """Test that selected large operators use Computer Modern glyphs.""" +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"} @@ -146,7 +164,8 @@ def test_get_glyph_routes_operator_to_computer_modern(self): 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") + 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( @@ -172,7 +191,8 @@ def test_sized_alternatives_routes_operator_to_computer_modern(self): expected ) - result = self.font_instance.get_sized_alternatives_for_symbol("it", r"\sum") + 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( From 9dc1f45f9c35b773cc41424cf15e6aa383e8392c Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 29 Jun 2026 12:01:43 +1000 Subject: [PATCH 3/5] Add some docs --- docs/fonts.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/fonts.py b/docs/fonts.py index e1212c30a..8ff942a43 100644 --- a/docs/fonts.py +++ b/docs/fonts.py @@ -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 From 45074367961cb4d3a65d1214302c4f90965205f8 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 19 Jul 2026 21:09:04 +1000 Subject: [PATCH 4/5] Changed a few things as I noticed that the fonts were not reloading properly. The following changes are made: - Rename mathtext.cm to mathtext.cm_symbols to avoid confusion with matplotlib's mathtext.fontset 'cm' - Clear matplotlib's mathtext parse cache when the setting changes so toggling takes effect on long-lived parsers - Load Bakoma fonts lazily and read the rc setting once per parse - Keep Computer Modern cal when mathtext.cal is set to 'regular' - Warn once when Bakoma fonts are unavailable on old matplotlib - Broaden routing to all cmex10 big operators - Fix docs example: mathtext parses at draw time, so rc.context around ax.text had no effect --- docs/fonts.py | 44 +++++++----- ultraplot/internals/fonts.py | 84 +++++++++++++++++------ ultraplot/internals/rcsetup.py | 16 ++++- ultraplot/tests/test_fonts.py | 118 ++++++++++++++++++++++++++------- 4 files changed, 201 insertions(+), 61 deletions(-) diff --git a/docs/fonts.py b/docs/fonts.py index 8ff942a43..5fe3fb1c5 100644 --- a/docs/fonts.py +++ b/docs/fonts.py @@ -101,27 +101,41 @@ # (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. +# 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. # %% 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$" +fig, ax = uplt.subplots(refwidth=6, refheight=1.4) +ax.text(0.02, 0.5, expr, transform="axes", va="center", fontsize=20) +ax.format( + title="Default custom math text", + titleloc="left", + xlocator="null", + ylocator="null", +) -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") +# %% +uplt.rc["mathtext.cm_symbols"] = True +fig, ax = uplt.subplots(refwidth=6, refheight=1.4) +ax.text(0.02, 0.5, expr, transform="axes", va="center", fontsize=20) +ax.format( + title="Computer Modern math symbols", + titleloc="left", + xlocator="null", + ylocator="null", +) + +# %% +uplt.rc["mathtext.cm_symbols"] = False # restore the default # %% [raw] raw_mimetype="text/restructuredtext" # diff --git a/ultraplot/internals/fonts.py b/ultraplot/internals/fonts.py index 652d2662a..505a6be6e 100644 --- a/ultraplot/internals/fonts.py +++ b/ultraplot/internals/fonts.py @@ -16,9 +16,26 @@ BakomaFonts = None -# Global constant +# Global constants WARN_MATHPARSER = True -_CM_OPERATOR_SYMBOLS = frozenset((r"\sum", r"\prod", r"\coprod", r"\int", r"\oint")) +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(): @@ -26,7 +43,18 @@ def _is_cm_mathtext_enabled(): from ..config import rc except ImportError: return False - return bool(rc["mathtext.cm"]) + 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): @@ -56,9 +84,24 @@ def __init__(self, *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"] + # 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 @@ -82,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: @@ -89,24 +136,21 @@ 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): - 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) + 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): - 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) + 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) diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index 31fabd144..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 @@ -1743,11 +1750,14 @@ def _validator_accepts(validator, value): "Alias for :rcraw:`axes.formatter.useOffset`.", ), # Math text settings - "mathtext.cm": ( + "mathtext.cm_symbols": ( False, _validate_bool, - "Whether to route selected math text glyphs through Computer Modern " - "while preserving the active font for ordinary letters and numbers.", + "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": ( diff --git a/ultraplot/tests/test_fonts.py b/ultraplot/tests/test_fonts.py index cdbf613f5..52079bfd4 100644 --- a/ultraplot/tests/test_fonts.py +++ b/ultraplot/tests/test_fonts.py @@ -15,55 +15,92 @@ def test_replacement(): pass -def _mathtext_postscript_names(text): - parsed = MathTextParser("path").parse(text, dpi=72) +# 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.""" - with uplt.rc.context({}): - names = _mathtext_postscript_names(r"$ABC123$") + names = _mathtext_postscript_names(r"$ABC123$") assert names - assert all(name.lower() not in {"cmex10", "cmsy10"} for name in 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.""" - with uplt.rc.context({}): - names = _mathtext_postscript_names(r"$\mathcal{ABC}$") + names = _mathtext_postscript_names(r"$\mathcal{ABC}$") assert names - assert all(name != "Cmsy10" for name in names) + assert "Cmsy10" not 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}): + """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 selected operators use default fonts unless requested.""" - with uplt.rc.context({}): - names = _mathtext_postscript_names(r"$\sum x \int y$") + """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_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$") +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].lower() not in {"cmex10", "cmsy10"} - assert names[3].lower() not in {"cmex10", "cmsy10"} + 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): @@ -161,11 +198,11 @@ def test_init_method(self, monkeypatch): 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 - with uplt.rc.context({"mathtext.cm": True}): - result = self.font_instance._get_glyph("it", "it", r"\sum") + 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( @@ -175,6 +212,7 @@ def test_get_glyph_routes_operator_to_computer_modern(self): 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): @@ -183,16 +221,28 @@ def test_get_glyph_routes_non_operator_to_parent(self): 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 ) - with uplt.rc.context({"mathtext.cm": True}): - result = self.font_instance.get_sized_alternatives_for_symbol("it", r"\sum") + 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( @@ -202,6 +252,7 @@ def test_sized_alternatives_routes_operator_to_computer_modern(self): 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( @@ -214,6 +265,27 @@ def test_sized_alternatives_routes_non_operator_to_parent(self): 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 From 88d6cd5bf61483b66d306345f57a9b9887337b1e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sun, 19 Jul 2026 21:34:52 +1000 Subject: [PATCH 5/5] Expand math text font docs - Outline the three levels of math font customization: matching the document font, Computer Modern symbol routing, and full usetex - Demonstrate per-text-object math fonts with a multi-row comparison - Give mathtext.cm_symbols its own linkable subsection with richer before/after comparison figures --- docs/fonts.py | 59 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/docs/fonts.py b/docs/fonts.py index 5fe3fb1c5..590f5f8c5 100644 --- a/docs/fonts.py +++ b/docs/fonts.py @@ -100,7 +100,49 @@ # :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 +# 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 @@ -111,27 +153,28 @@ # globally rather than wrapping plotting commands in a context block. # %% -import ultraplot as uplt - -expr = r"$\mathcal{ABC}\quad \sum_i x_i \quad \int_a^b f(x)\,dx$" -fig, ax = uplt.subplots(refwidth=6, refheight=1.4) -ax.text(0.02, 0.5, expr, transform="axes", va="center", fontsize=20) +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.4) -ax.text(0.02, 0.5, expr, transform="axes", va="center", fontsize=20) +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", ) # %%