Skip to content
Merged
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
82 changes: 82 additions & 0 deletions docs/fonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ug_fonts_cm>`).
# #. **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
Expand Down
85 changes: 83 additions & 2 deletions ultraplot/internals/fonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -65,13 +125,34 @@ 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:
# Suppress duplicate warnings in case API changes
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:
Expand Down
17 changes: 17 additions & 0 deletions ultraplot/internals/rcsetup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import functools
import re
import sys
from collections.abc import MutableMapping
from numbers import Integral, Real

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
Loading