diff --git a/doc/users/fonts.rst b/doc/users/fonts.rst index e385c98284c0..d4aff698de02 100644 --- a/doc/users/fonts.rst +++ b/doc/users/fonts.rst @@ -128,3 +128,59 @@ This is especially helpful to generate *really lightweight* documents.:: free versions of the proprietary fonts. This also violates the *what-you-see-is-what-you-get* feature of Matplotlib. + +Are we reinventing the wheel? +----------------------------- +Internally, a feasible response to the question of 'reinventing the +wheel would be, well, Yes *and No*. The font-matching algorithm used +by Matplotlib has been *inspired* by web browsers, more specifically, +`CSS Specifications `_. + +Currently, the simplest way (and the only way) to tell Matplotlib what fonts +you want it to use for your document is via the **font.family** rcParam, +see :doc:`Customizing text properties `. + +This is similar to how one tells a browser to use multiple font families +(specified in their order of preference) for their HTML webpages. By using +**font-family** in their stylesheet, users can essentially trigger a very +useful feature provided by browers, known as Font-Fallback. For example, the +following snippet in an HTML markup would: + +.. code-block:: html + + + + + + some text + + + +For every character/glyph in *"some text"*, the browser will iterate through +the whole list of font-families, and check whether that character/glyph is +available in that font-family. As soon as a font is found which has the +required glyph(s), the browser uses that font to render that character, and +subsequently moves on to the next character. + +How does Matplotlib achieve this? +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Currently, Matplotlib can't render a multi-font document. It was initially +only designed to use a **single font** throughout the document, i.e., no matter +how many families you pass to **font.family** rcParam, Matplotlib would use the +very first font it's able to find on your system, and try to render all your +characters/glyphs from that *and only that* font. + +.. note:: + This is, because the internal font matching was written/adapted + from a very old `CSS1 spec `_, + **written in 1998**! + + However, allowing multiple fonts for a single document (also enabling + Font-Fallback) is one of the goals for 2021's Google Summer of Code project. + + `Read more on Matplotblog `_! + diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index b937c64fce95..0de7f1bbc0f0 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -35,7 +35,7 @@ from matplotlib.backend_bases import ( _Backend, _check_savefig_extra_args, FigureCanvasBase, FigureManagerBase, RendererBase) -from matplotlib.font_manager import findfont, get_font +from matplotlib.font_manager import get_font, find_fontsprop from matplotlib.ft2font import (LOAD_FORCE_AUTOHINT, LOAD_NO_HINTING, LOAD_DEFAULT, LOAD_NO_AUTOHINT) from matplotlib.mathtext import MathTextParser @@ -251,8 +251,8 @@ def _get_agg_font(self, prop): """ Get the font for text instance t, caching for efficiency """ - fname = findfont(prop) - font = get_font(fname) + finterface = find_fontsprop(prop) + font = get_font(finterface) font.clear() size = prop.get_size_in_points() diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index 9d45575eb13d..b126b92f8f66 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -23,6 +23,7 @@ # - setWeights function needs improvement # - 'light' is an invalid weight value, remove it. +from collections import OrderedDict import dataclasses from functools import lru_cache import json @@ -1308,6 +1309,116 @@ def findfont(self, prop, fontext='ttf', directory=None, prop, fontext, directory, fallback_to_default, rebuild_if_missing, rc_params) + def find_fontsprop(self, prop, fontext='ttf', directory=None, + fallback_to_default=True, rebuild_if_missing=True): + """ + Find font families that most closely matches the given properties. + + Parameters + ---------- + prop : str or `~matplotlib.font_manager.FontProperties` + The font properties to search for. This can be either a + `.FontProperties` object or a string defining a + `fontconfig patterns`_. + + fontext : {'ttf', 'afm'}, default: 'ttf' + The extension of the font file: + + - 'ttf': TrueType and OpenType fonts (.ttf, .ttc, .otf) + - 'afm': Adobe Font Metrics (.afm) + + directory : str, optional + If given, only search this directory and its subdirectories. + + fallback_to_default : bool + If True, will fallback to the default font family (usually + "DejaVu Sans" or "Helvetica") if none of the families were found. + + rebuild_if_missing : bool + Whether to rebuild the font cache and search again if the first + match appears to point to a nonexisting font (i.e., the font cache + contains outdated entries). + + Returns + ------- + OrderedDict + key, value pair of families and their corresponding filepaths. + + Notes + ----- + This is a plugin to original findfont API, which only returns a + single font for given font properties. Instead, this API returns + an OrderedDict containing multiple fonts and their filepaths which + closely match the given font properties. + Since this internally uses original API, there's no change + to the logic of performing the nearest neighbor search. + See `findfont` for more details. + + """ + + rc_params = tuple(tuple(rcParams[key]) for key in [ + "font.serif", "font.sans-serif", "font.cursive", "font.fantasy", + "font.monospace"]) + + prop = FontProperties._from_any(prop) + ffamily = prop.get_family() + + fpaths = OrderedDict() + for fidx in range(len(ffamily)): + cprop = prop.copy() + + # set current prop's family + cprop.set_family(ffamily[fidx]) + + # do not fall back to default font + fpath = self._findfontsprop_cached( + ffamily[fidx], cprop, fontext, directory, + False, rebuild_if_missing, rc_params + ) + if fpath: + fpaths[ffamily[fidx]] = fpath + + # only add default family if no other font was found + # and fallback_to_default is enabled + if not fpaths: + if fallback_to_default: + dfamily = self.defaultFamily[fontext] + cprop = prop.copy().set_family(dfamily) + fpath = self._findfontsprop_cached( + dfamily, cprop, fontext, directory, + True, rebuild_if_missing, rc_params + ) + fpaths[dfamily] = fpath + else: + raise ValueError("Failed to find any font, and fallback " + "to the default font was disabled.") + + return fpaths + + @lru_cache() + def _findfontsprop_cached( + self, family, prop, fontext, directory, + fallback_to_default, rebuild_if_missing, rc_params + ): + try: + return self._findfont_cached( + prop, fontext, directory, fallback_to_default, + rebuild_if_missing, rc_params + ) + except ValueError: + if not fallback_to_default: + if family.lower() in font_family_aliases: + _log.warning( + "findfont: Generic family %r not found because " + "none of the following families were found: %s", + family, + ", ".join(self._expand_aliases(family)) + ) + else: + _log.warning( + 'findfont: Font family \'%s\' not found.', family + ) + @lru_cache() def _findfont_cached(self, prop, fontext, directory, fallback_to_default, rebuild_if_missing, rc_params): @@ -1401,9 +1512,11 @@ def is_opentype_cff_font(filename): @lru_cache(64) -def _get_font(filename, hinting_factor, *, _kerning_factor, thread_id): +def _get_font(fpaths, hinting_factor, *, _kerning_factor, thread_id): + # multiple paths, take first one + # for now, which is always guaranteed return ft2font.FT2Font( - filename, hinting_factor, _kerning_factor=_kerning_factor) + fpaths[0], hinting_factor, _kerning_factor=_kerning_factor) # FT2Font objects cannot be used across fork()s because they reference the same @@ -1417,11 +1530,14 @@ def _get_font(filename, hinting_factor, *, _kerning_factor, thread_id): def get_font(filename, hinting_factor=None): # Resolving the path avoids embedding the font twice in pdf/ps output if a # single font is selected using two different relative paths. - filename = _cached_realpath(filename) + if isinstance(filename, OrderedDict): + paths = tuple(_cached_realpath(fname) for fname in filename.values()) + else: + paths = (_cached_realpath(filename),) if hinting_factor is None: hinting_factor = rcParams['text.hinting_factor'] # also key on the thread ID to prevent segfaults with multi-threading - return _get_font(filename, hinting_factor, + return _get_font(paths, hinting_factor, _kerning_factor=rcParams['text.kerning_factor'], thread_id=threading.get_ident()) @@ -1446,3 +1562,4 @@ def _load_fontmanager(*, try_read_cache=True): fontManager = _load_fontmanager() findfont = fontManager.findfont +find_fontsprop = fontManager.find_fontsprop