diff --git a/lib/matplotlib/backends/backend_svg.py b/lib/matplotlib/backends/backend_svg.py index bc0f2565330a..721fb43ee6c6 100644 --- a/lib/matplotlib/backends/backend_svg.py +++ b/lib/matplotlib/backends/backend_svg.py @@ -1153,10 +1153,48 @@ def _draw_text_as_text(self, gc, x, y, s, prop, angle, ismath, mtext=None): weight = fm.weight_dict[prop.get_weight()] if weight != 400: font_parts.append(f'{weight}') + + def _format_font_name(fn): + normalize_names = { + 'sans': 'sans-serif', + 'sans serif': 'sans-serif' + } + # A generic font family. We need to do two things: + # 1. list all of the configured fonts with quoted names + # 2. append the generic name unquoted + if fn in fm.font_family_aliases: + # fix spelling of sans-serif + # we accept 3 ways CSS only supports 1 + fn = normalize_names.get(fn, fn) + # get all of the font names and fix spelling of sans-serif + # if it comes back + aliases = [ + normalize_names.get(_, _) for _ in + fm.FontManager._expand_aliases(fn) + ] + # make sure the generic name appears at least once + # duplicate is OK, next layer will deduplicate + aliases.append(fn) + + for a in aliases: + # generic font families must not be quoted + if a in fm.font_family_aliases: + yield a + # specific font families must be quoted + else: + yield repr(a) + # specific font families must be quoted + else: + yield repr(fn) + + def _get_all_names(prop): + for f in prop.get_family(): + yield from _format_font_name(f) + font_parts.extend([ f'{_short_float_fmt(prop.get_size())}px', - # ensure quoting - f'{", ".join(repr(f) for f in prop.get_family())}', + # ensure quoting and expansion of font names + ", ".join(dict.fromkeys(_get_all_names(prop))) ]) style['font'] = ' '.join(font_parts) if prop.get_stretch() != 'normal': diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index 99e1fecabbeb..a5742ef88f61 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -1345,9 +1345,12 @@ def findfont(self, prop, fontext='ttf', directory=None, rc_params = tuple(tuple(mpl.rcParams[key]) for key in [ "font.serif", "font.sans-serif", "font.cursive", "font.fantasy", "font.monospace"]) - return self._findfont_cached( + ret = self._findfont_cached( prop, fontext, directory, fallback_to_default, rebuild_if_missing, rc_params) + if isinstance(ret, Exception): + raise ret + return ret def get_font_names(self): """Return the list of available fonts.""" @@ -1496,8 +1499,11 @@ def _findfont_cached(self, prop, fontext, directory, fallback_to_default, return self.findfont(default_prop, fontext, directory, fallback_to_default=False) else: - raise ValueError(f"Failed to find font {prop}, and fallback " - f"to the default font was disabled") + # This return instead of raise is intentional, as we wish to + # cache the resulting exception, which will not occur if it was + # actually raised. + return ValueError(f"Failed to find font {prop}, and fallback " + f"to the default font was disabled") else: _log.debug('findfont: Matching %s to %s (%r) with score of %f.', prop, best_font.name, best_font.fname, best_score) @@ -1516,7 +1522,10 @@ def _findfont_cached(self, prop, fontext, directory, fallback_to_default, return self.findfont( prop, fontext, directory, rebuild_if_missing=False) else: - raise ValueError("No valid font could be found") + # This return instead of raise is intentional, as we wish to + # cache the resulting exception, which will not occur if it was + # actually raised. + return ValueError("No valid font could be found") return _cached_realpath(result) diff --git a/lib/matplotlib/tests/test_backend_svg.py b/lib/matplotlib/tests/test_backend_svg.py index 7ea81730f20d..680efd67379b 100644 --- a/lib/matplotlib/tests/test_backend_svg.py +++ b/lib/matplotlib/tests/test_backend_svg.py @@ -527,3 +527,64 @@ def test_svg_escape(): fig.savefig(fd, format='svg') buf = fd.getvalue().decode() assert '<'"&>"' in buf + + +@pytest.mark.parametrize("font_str", [ + "'DejaVu Sans', 'WenQuanYi Zen Hei', 'Arial', sans-serif", + "'DejaVu Serif', 'WenQuanYi Zen Hei', 'Times New Roman', serif", + "'Arial', 'WenQuanYi Zen Hei', cursive", + "'Impact', 'WenQuanYi Zen Hei', fantasy", + "'DejaVu Sans Mono', 'WenQuanYi Zen Hei', 'Courier New', monospace", + # These do not work because the logic to get the font metrics will not find + # WenQuanYi as the fallback logic stops with the first fallback font: + # "'DejaVu Sans Mono', 'Courier New', 'WenQuanYi Zen Hei', monospace", + # "'DejaVu Sans', 'Arial', 'WenQuanYi Zen Hei', sans-serif", + # "'DejaVu Serif', 'Times New Roman', 'WenQuanYi Zen Hei', serif", +]) +@pytest.mark.parametrize("include_generic", [True, False]) +def test_svg_font_string(font_str, include_generic): + fp = fm.FontProperties(family=["WenQuanYi Zen Hei"]) + if Path(fm.findfont(fp)).name != "wqy-zenhei.ttc": + pytest.skip("Font may be missing") + + explicit, *rest, generic = map( + lambda x: x.strip("'"), font_str.split(", ") + ) + size = len(generic) + if include_generic: + rest = rest + [generic] + plt.rcParams[f"font.{generic}"] = rest + plt.rcParams["font.size"] = size + plt.rcParams["svg.fonttype"] = "none" + + fig, ax = plt.subplots() + if generic == "sans-serif": + generic_options = ["sans", "sans-serif", "sans serif"] + else: + generic_options = [generic] + + for generic_name in generic_options: + # test that fallback works + ax.text(0.5, 0.5, "There are 几个汉字 in between!", + family=[explicit, generic_name], ha="center") + # test deduplication works + ax.text(0.5, 0.1, "There are 几个汉字 in between!", + family=[explicit, *rest, generic_name], ha="center") + ax.axis("off") + + with BytesIO() as fd: + fig.savefig(fd, format="svg") + buf = fd.getvalue() + + tree = xml.etree.ElementTree.fromstring(buf) + ns = "http://www.w3.org/2000/svg" + text_count = 0 + for text_element in tree.findall(f".//{{{ns}}}text"): + text_count += 1 + font_info = dict( + map(lambda x: x.strip(), _.strip().split(":")) + for _ in dict(text_element.items())["style"].split(";") + )["font"] + + assert font_info == f"{size}px {font_str}" + assert text_count == len(ax.texts)