diff --git a/lib/matplotlib/backends/backend_svg.py b/lib/matplotlib/backends/backend_svg.py index cffee2552828..29e94d936c92 100644 --- a/lib/matplotlib/backends/backend_svg.py +++ b/lib/matplotlib/backends/backend_svg.py @@ -14,15 +14,13 @@ from PIL import Image import matplotlib as mpl -from matplotlib import _api, cbook +from matplotlib import _api, cbook, font_manager as fm from matplotlib.backend_bases import ( _Backend, _check_savefig_extra_args, FigureCanvasBase, FigureManagerBase, RendererBase, _no_output_draw) from matplotlib.backends.backend_mixed import MixedModeRenderer from matplotlib.colors import rgb2hex from matplotlib.dates import UTC -from matplotlib.font_manager import findfont, get_font -from matplotlib.ft2font import LOAD_NO_HINTING from matplotlib.mathtext import MathTextParser from matplotlib.path import Path from matplotlib import _path @@ -94,6 +92,12 @@ def escape_attrib(s): return s +def _quote_escape_attrib(s): + return ('"' + escape_cdata(s) + '"' if '"' not in s else + "'" + escape_cdata(s) + "'" if "'" not in s else + '"' + escape_attrib(s) + '"') + + def short_float_fmt(x): """ Create a short string representation of a float, which is %f @@ -159,8 +163,8 @@ def start(self, tag, attrib={}, **extra): for k, v in {**attrib, **extra}.items(): if v: k = escape_cdata(k) - v = escape_attrib(v) - self.__write(' %s="%s"' % (k, v)) + v = _quote_escape_attrib(v) + self.__write(' %s=%s' % (k, v)) self.__open = 1 return len(self.__tags) - 1 @@ -262,15 +266,7 @@ def generate_transform(transform_list=[]): def generate_css(attrib={}): - if attrib: - output = StringIO() - attrib = attrib.items() - for k, v in attrib: - k = escape_attrib(k) - v = escape_attrib(v) - output.write("%s:%s;" % (k, v)) - return output.getvalue() - return '' + return "; ".join(f"{k}: {v}" for k, v in attrib.items()) _capstyle_d = {'projecting': 'square', 'butt': 'butt', 'round': 'round'} @@ -464,8 +460,8 @@ def _make_flip_transform(self, transform): .translate(0.0, self.height)) def _get_font(self, prop): - fname = findfont(prop) - font = get_font(fname) + fname = fm.findfont(prop) + font = fm.get_font(fname) font.clear() size = prop.get_size_in_points() font.set_size(size, 72.0) @@ -1128,16 +1124,23 @@ def _draw_text_as_text(self, gc, x, y, s, prop, angle, ismath, mtext=None): style['opacity'] = short_float_fmt(alpha) if not ismath: - font = self._get_font(prop) - font.set_text(s, 0.0, flags=LOAD_NO_HINTING) - attrib = {} - style['font-family'] = str(font.family_name) - style['font-weight'] = str(prop.get_weight()).lower() - style['font-stretch'] = str(prop.get_stretch()).lower() - style['font-style'] = prop.get_style().lower() - # Must add "px" to workaround a Firefox bug - style['font-size'] = short_float_fmt(prop.get_size()) + 'px' + + font_parts = [] + if prop.get_style() != 'normal': + font_parts.append(prop.get_style()) + if prop.get_variant() != 'normal': + font_parts.append(prop.get_variant()) + weight = fm.weight_dict[prop.get_weight()] + if weight != 400: + font_parts.append(f'{weight}') + font_parts.extend([ + f'{short_float_fmt(prop.get_size())}px', + f'{prop.get_family()[0]!r}', # ensure quoting + ]) + style['font'] = ' '.join(font_parts) + if prop.get_stretch() != 'normal': + style['font-stretch'] = prop.get_stretch() attrib['style'] = generate_css(style) if mtext and (angle == 0 or mtext.get_rotation_mode() == "anchor"): @@ -1197,11 +1200,22 @@ def _draw_text_as_text(self, gc, x, y, s, prop, angle, ismath, mtext=None): # Sort the characters by font, and output one tspan for each. spans = OrderedDict() for font, fontsize, thetext, new_x, new_y in glyphs: - style = generate_css({ - 'font-size': short_float_fmt(fontsize) + 'px', - 'font-family': font.family_name, - 'font-style': font.style_name.lower(), - 'font-weight': font.style_name.lower()}) + entry = fm.ttfFontProperty(font) + font_parts = [] + if entry.style != 'normal': + font_parts.append(entry.style) + if entry.variant != 'normal': + font_parts.append(entry.variant) + if entry.weight != 400: + font_parts.append(f'{entry.weight}') + font_parts.extend([ + f'{short_float_fmt(fontsize)}px', + f'{entry.name!r}', # ensure quoting + ]) + style = {'font': ' '.join(font_parts)} + if entry.stretch != 'normal': + style['font-stretch'] = entry.stretch + style = generate_css(style) if thetext == 32: thetext = 0xa0 # non-breaking space spans.setdefault(style, []).append((new_x, -new_y, thetext)) diff --git a/lib/matplotlib/tests/test_backend_svg.py b/lib/matplotlib/tests/test_backend_svg.py index 66ec48a477bf..f3dfadff93df 100644 --- a/lib/matplotlib/tests/test_backend_svg.py +++ b/lib/matplotlib/tests/test_backend_svg.py @@ -216,7 +216,7 @@ def test_unicode_won(): def test_svgnone_with_data_coordinates(): - plt.rcParams['svg.fonttype'] = 'none' + plt.rcParams.update({'svg.fonttype': 'none', 'font.stretch': 'condensed'}) expected = 'Unlikely to appear by chance' fig, ax = plt.subplots() @@ -229,9 +229,7 @@ def test_svgnone_with_data_coordinates(): fd.seek(0) buf = fd.read().decode() - assert expected in buf - for prop in ["family", "weight", "stretch", "style", "size"]: - assert f"font-{prop}:" in buf + assert expected in buf and "condensed" in buf def test_gid(): diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py index b5fd906d2f9c..c6d3d32de460 100644 --- a/lib/matplotlib/tests/test_mathtext.py +++ b/lib/matplotlib/tests/test_mathtext.py @@ -1,6 +1,8 @@ import io -import os +from pathlib import Path import re +import shlex +from xml.etree import ElementTree as ET import numpy as np import pytest @@ -349,7 +351,7 @@ def test_mathtext_fallback_to_cm_invalid(): ("stix", ['DejaVu Sans', 'mpltest', 'STIXGeneral'])]) def test_mathtext_fallback(fallback, fontlist): mpl.font_manager.fontManager.addfont( - os.path.join((os.path.dirname(os.path.realpath(__file__))), 'mpltest.ttf')) + str(Path(__file__).resolve().parent / 'mpltest.ttf')) mpl.rcParams["svg.fonttype"] = 'none' mpl.rcParams['mathtext.fontset'] = 'custom' mpl.rcParams['mathtext.rm'] = 'mpltest' @@ -363,12 +365,13 @@ def test_mathtext_fallback(fallback, fontlist): fig, ax = plt.subplots() fig.text(.5, .5, test_str, fontsize=40, ha='center') fig.savefig(buff, format="svg") - char_fonts = [ - line.split("font-family:")[-1].split(";")[0] - for line in str(buff.getvalue()).split(r"\n") if "tspan" in line - ] + tspans = (ET.fromstring(buff.getvalue()) + .findall(".//{http://www.w3.org/2000/svg}tspan[@style]")) + # Getting the last element of the style attrib is a close enough + # approximation for parsing the font property. + char_fonts = [shlex.split(tspan.attrib["style"])[-1] for tspan in tspans] assert char_fonts == fontlist - mpl.font_manager.fontManager.ttflist = mpl.font_manager.fontManager.ttflist[:-1] + mpl.font_manager.fontManager.ttflist.pop() def test_math_to_image(tmpdir):