From 42c64b6143498233fb66d0da6f31fc4c0b83fde6 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 29 Jun 2026 11:42:23 +1000 Subject: [PATCH 1/3] 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/3] 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/3] 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