From e8775335a67b6ebac3f745086d166e0bc54bc9f2 Mon Sep 17 00:00:00 2001 From: Aitik Gupta Date: Wed, 30 Jun 2021 14:18:35 +0530 Subject: [PATCH 1/8] Allow font manager to parse all families --- lib/matplotlib/font_manager.py | 72 +++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index 9d45575eb13d..e5b9356b9555 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 @@ -947,6 +948,24 @@ def copy(self): return new +class FontsInterface: + def __init__(self, ordered_family): + self.set_orderedfamily(ordered_family) + + def set_orderedfamily(self, ordered_family): + self.families = [] + self.filepaths = [] + for key, value in ordered_family.items(): + self.families.append(key) + self.filepaths.append(value) + + def get_families(self): + return self.families + + def get_filepaths(self): + return self.filepaths + + class _JSONEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, FontManager): @@ -1304,16 +1323,51 @@ def findfont(self, prop, fontext='ttf', directory=None, rc_params = tuple(tuple(rcParams[key]) for key in [ "font.serif", "font.sans-serif", "font.cursive", "font.fantasy", "font.monospace"]) + + if not isinstance(prop, FontProperties): + prop = FontProperties._from_any(prop) + return self._findfont_cached( 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): + + prop = FontProperties._from_any(prop) + ffamily = prop.get_family() + + # maintain two dicts, one for available paths, + # the other for fallback paths + fpaths, fbpaths = OrderedDict(), OrderedDict() + for fidx in range(len(ffamily)): + cprop = prop.copy() + + # set current prop's family + cprop.set_family(ffamily[fidx]) + + try: + fpath = self.findfont(cprop, fontext, directory, + False, rebuild_if_missing) + fpaths[ffamily[fidx]] = fpath + + except ValueError as e: + if fallback_to_default: + fpath = self.findfont(cprop, fontext, directory, + True, rebuild_if_missing) + fbpaths.update({self.defaultFamily[fontext]: fpath}) + else: + raise e + + # append fallback font(s) to the very end + fpaths.update(fbpaths) + + return FontsInterface(fpaths) + @lru_cache() def _findfont_cached(self, prop, fontext, directory, fallback_to_default, rebuild_if_missing, rc_params): - prop = FontProperties._from_any(prop) - fname = prop.get_file() if fname is not None: return fname @@ -1401,9 +1455,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 +1473,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, FontsInterface): + paths = tuple([_cached_realpath(fname) for fname in filename.get_filepaths()]) + else: + paths = tuple([_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 +1505,4 @@ def _load_fontmanager(*, try_read_cache=True): fontManager = _load_fontmanager() findfont = fontManager.findfont +find_fontsprop = fontManager.find_fontsprop From dc2fb2bc08e19da04753f35c865533c5cd341049 Mon Sep 17 00:00:00 2001 From: Aitik Gupta Date: Wed, 30 Jun 2021 14:19:40 +0530 Subject: [PATCH 2/8] Use FontsInterface for agg backend --- lib/matplotlib/backends/backend_agg.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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() From 778de1feea008c95651f78a5134641b8a92b8c75 Mon Sep 17 00:00:00 2001 From: Aitik Gupta Date: Sun, 18 Jul 2021 21:28:22 +0530 Subject: [PATCH 3/8] Simplify family parsing, remove FontsInterface --- lib/matplotlib/font_manager.py | 119 ++++++++++++++++++++++----------- 1 file changed, 81 insertions(+), 38 deletions(-) diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index e5b9356b9555..689d81be3d3c 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -948,24 +948,6 @@ def copy(self): return new -class FontsInterface: - def __init__(self, ordered_family): - self.set_orderedfamily(ordered_family) - - def set_orderedfamily(self, ordered_family): - self.families = [] - self.filepaths = [] - for key, value in ordered_family.items(): - self.families.append(key) - self.filepaths.append(value) - - def get_families(self): - return self.families - - def get_filepaths(self): - return self.filepaths - - class _JSONEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, FontManager): @@ -1333,13 +1315,59 @@ def findfont(self, prop, fontext='ttf', directory=None, 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() - # maintain two dicts, one for available paths, - # the other for fallback paths - fpaths, fbpaths = OrderedDict(), OrderedDict() + fpaths = OrderedDict() for fidx in range(len(ffamily)): cprop = prop.copy() @@ -1347,22 +1375,37 @@ def find_fontsprop(self, prop, fontext='ttf', directory=None, cprop.set_family(ffamily[fidx]) try: - fpath = self.findfont(cprop, fontext, directory, - False, rebuild_if_missing) + fpath = self._findfont_cached( + cprop, fontext, directory, + False, rebuild_if_missing, rc_params + ) fpaths[ffamily[fidx]] = fpath + except ValueError: + _log.warning( + 'findfont: Font family [\'%s\'] not found.', ffamily[fidx] + ) + if ffamily[fidx].lower() in font_family_aliases: + _log.warning( + "findfont: Generic family [%r] not found because " + "none of the following families were found: %s", + ffamily[fidx], + ", ".join(self._expand_aliases(ffamily[fidx])) + ) + + if not fpaths: + if fallback_to_default: + dfamily = self.defaultFamily[fontext] + cprop = prop.copy().set_family(dfamily) + fpath = self._findfont_cached( + 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.") - except ValueError as e: - if fallback_to_default: - fpath = self.findfont(cprop, fontext, directory, - True, rebuild_if_missing) - fbpaths.update({self.defaultFamily[fontext]: fpath}) - else: - raise e - - # append fallback font(s) to the very end - fpaths.update(fbpaths) - - return FontsInterface(fpaths) + return fpaths @lru_cache() def _findfont_cached(self, prop, fontext, directory, fallback_to_default, @@ -1473,10 +1516,10 @@ def _get_font(fpaths, 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. - if isinstance(filename, FontsInterface): - paths = tuple([_cached_realpath(fname) for fname in filename.get_filepaths()]) + if isinstance(filename, OrderedDict): + paths = tuple(_cached_realpath(fname) for fname in filename.values()) else: - paths = tuple([_cached_realpath(filename)]) + 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 From 8f3f41077c5268eb36bd3e97042aec570f4d3ad7 Mon Sep 17 00:00:00 2001 From: Aitik Gupta Date: Sun, 18 Jul 2021 21:57:46 +0530 Subject: [PATCH 4/8] Small bugfix for _findfont_cached --- lib/matplotlib/font_manager.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index 689d81be3d3c..da83aa80ae43 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -1305,10 +1305,6 @@ def findfont(self, prop, fontext='ttf', directory=None, rc_params = tuple(tuple(rcParams[key]) for key in [ "font.serif", "font.sans-serif", "font.cursive", "font.fantasy", "font.monospace"]) - - if not isinstance(prop, FontProperties): - prop = FontProperties._from_any(prop) - return self._findfont_cached( prop, fontext, directory, fallback_to_default, rebuild_if_missing, rc_params) @@ -1411,6 +1407,8 @@ def find_fontsprop(self, prop, fontext='ttf', directory=None, def _findfont_cached(self, prop, fontext, directory, fallback_to_default, rebuild_if_missing, rc_params): + prop = FontProperties._from_any(prop) + fname = prop.get_file() if fname is not None: return fname From 6ffc56efa3899cc61b4b025c55aef7dc15eae594 Mon Sep 17 00:00:00 2001 From: Aitik Gupta Date: Mon, 19 Jul 2021 13:40:54 +0530 Subject: [PATCH 5/8] Add high-level documentation --- doc/users/fonts.rst | 55 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/doc/users/fonts.rst b/doc/users/fonts.rst index e385c98284c0..4ba8afd6b656 100644 --- a/doc/users/fonts.rst +++ b/doc/users/fonts.rst @@ -128,3 +128,58 @@ 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 moves on to the next character. + +How does Matplotlib achieve this? +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Well, Matplotlib doesn't achieve this, *yet*. 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 `_! + From aaf0b0a782245598b07d6eaa878dc2e42b9a73e9 Mon Sep 17 00:00:00 2001 From: Aitik Gupta Date: Thu, 22 Jul 2021 22:12:22 +0530 Subject: [PATCH 6/8] Reword sentences to be more formal --- doc/users/fonts.rst | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/doc/users/fonts.rst b/doc/users/fonts.rst index 4ba8afd6b656..d4aff698de02 100644 --- a/doc/users/fonts.rst +++ b/doc/users/fonts.rst @@ -134,7 +134,7 @@ 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 `_! +`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, @@ -144,7 +144,7 @@ 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: +following snippet in an HTML markup would: .. code-block:: html @@ -163,14 +163,15 @@ following snippet in an HTMl markup would: 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 moves on to the next character. +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? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Well, Matplotlib doesn't achieve this, *yet*. 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 +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:: From 42fcf0175f87c60c89d4d31197f508f637cd2ae1 Mon Sep 17 00:00:00 2001 From: Aitik Gupta Date: Thu, 22 Jul 2021 23:13:48 +0530 Subject: [PATCH 7/8] Restructure with a cached version --- lib/matplotlib/font_manager.py | 54 ++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index da83aa80ae43..61041726288a 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -1370,30 +1370,22 @@ def find_fontsprop(self, prop, fontext='ttf', directory=None, # set current prop's family cprop.set_family(ffamily[fidx]) - try: - fpath = self._findfont_cached( - cprop, fontext, directory, - False, rebuild_if_missing, rc_params - ) + # 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 - except ValueError: - _log.warning( - 'findfont: Font family [\'%s\'] not found.', ffamily[fidx] - ) - if ffamily[fidx].lower() in font_family_aliases: - _log.warning( - "findfont: Generic family [%r] not found because " - "none of the following families were found: %s", - ffamily[fidx], - ", ".join(self._expand_aliases(ffamily[fidx])) - ) + # 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._findfont_cached( - cprop, fontext, directory, + fpath = self._findfontsprop_cached( + dfamily, cprop, fontext, directory, True, rebuild_if_missing, rc_params ) fpaths[dfamily] = fpath @@ -1403,6 +1395,32 @@ def find_fontsprop(self, prop, fontext='ttf', directory=None, 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): From 7b5ea77b45c09ba6def6c391336da131ef5f4abd Mon Sep 17 00:00:00 2001 From: Aitik Gupta Date: Sat, 7 Aug 2021 05:10:41 +0530 Subject: [PATCH 8/8] Flake8 fixes --- lib/matplotlib/font_manager.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index 61041726288a..b126b92f8f66 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -1395,7 +1395,6 @@ def find_fontsprop(self, prop, fontext='ttf', directory=None, return fpaths - @lru_cache() def _findfontsprop_cached( self, family, prop, fontext, directory, @@ -1420,7 +1419,6 @@ def _findfontsprop_cached( 'findfont: Font family \'%s\' not found.', family ) - @lru_cache() def _findfont_cached(self, prop, fontext, directory, fallback_to_default, rebuild_if_missing, rc_params):