8000 Merge pull request #23638 from tacaswell/svg_generic_fonts · matplotlib/matplotlib@8f6616c · GitHub
[go: up one dir, main page]

Skip to content

Commit 8f6616c

Browse files
authored
Merge pull request #23638 from tacaswell/svg_generic_fonts
FIX: correctly handle generic font families in svg text-as-text mode
2 parents 3feaa5d + 6e99a52 commit 8f6616c

File tree

3 files changed

+114
-6
lines changed

3 files changed

+114
-6
lines changed

lib/matplotlib/backends/backend_svg.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1153,10 +1153,48 @@ def _draw_text_as_text(self, gc, x, y, s, prop, angle, ismath, mtext=None):
11531153
weight = fm.weight_dict[prop.get_weight()]
11541154
if weight != 400:
11551155
font_parts.append(f'{weight}')
1156+
1157+
def _format_font_name(fn):
1158+
normalize_names = {
1159+
'sans': 'sans-serif',
1160+
'sans serif': 'sans-serif'
1161+
}
1162+
# A generic font family. We need to do two things:
1163+
# 1. list all of the configured fonts with quoted names
1164+
# 2. append the generic name unquoted
1165+
if fn in fm.font_family_aliases:
1166+
# fix spelling of sans-serif
1167+
# we accept 3 ways CSS only supports 1
1168+
fn = normalize_names.get(fn, fn)
1169+
# get all of the font names and fix spelling of sans-serif
1170+
# if it comes back
1171+
aliases = [
1172+
normalize_names.get(_, _) for _ in
1173+
fm.FontManager._expand_aliases(fn)
1174+
]
1175+
# make sure the generic name appears at least once
1176+
# duplicate is OK, next layer will deduplicate
1177+
aliases.append(fn)
1178+
1179+
for a in aliases:
1180+
# generic font families must not be quoted
1181+
if a in fm.font_family_aliases:
1182+
yield a
1183+
# specific font families must be quoted
1184+
else:
1185+
yield repr(a)
1186+
# specific font families must be quoted
1187+
else:
1188+
yield repr(fn)
1189+
1190+
def _get_all_names(prop):
1191+
for f in prop.get_family():
1192+
yield from _format_font_name(f)
1193+
11561194
font_parts.extend([
11571195
f'{_short_float_fmt(prop.get_size())}px',
1158-
# ensure quoting
1159-
f'{", ".join(repr(f) for f in prop.get_family())}',
1196+
# ensure quoting and expansion of font names
1197+
", ".join(dict.fromkeys(_get_all_names(prop)))
11601198
])
11611199
style['font'] = ' '.join(font_parts)
11621200
if prop.get_stretch() != 'normal':

lib/matplotlib/font_manager.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1345,9 +1345,12 @@ def findfont(self, prop, fontext='ttf', directory=None,
13451345
rc_params = tuple(tuple(mpl.rcParams[key]) for key in [
13461346
"font.serif", "font.sans-serif", "font.cursive", "font.fantasy",
13471347
"font.monospace"])
1348-
return self._findfont_cached(
1348+
ret = self._findfont_cached(
13491349
prop, fontext, directory, fallback_to_default, rebuild_if_missing,
13501350
rc_params)
1351+
if isinstance(ret, Exception):
1352+
raise ret
1353+
return ret
13511354

13521355
def get_font_names(self):
13531356
"""Return the list of available fonts."""
@@ -1496,8 +1499,11 @@ def _findfont_cached(self, prop, fontext, directory, fallback_to_default,
14961499
return self.findfont(default_prop, fontext, directory,
14971500
fallback_to_default=False)
14981501
else:
1499-
raise ValueError(f"Failed to find font {prop}, and fallback "
1500-
f"to the default font was disabled")
1502+
# This return instead of raise is intentional, as we wish to
1503+
# cache the resulting exception, which will not occur if it was
1504+
# actually raised.
1505+
return ValueError(f"Failed to find font {prop}, and fallback "
1506+
f"to the default font was disabled")
15011507
else:
15021508
_log.debug('findfont: Matching %s to %s (%r) with score of %f.',
15031509
prop, best_font.name, best_font.fname, best_score)
@@ -1516,7 +1522,10 @@ def _findfont_cached(self, prop, fontext, directory, fallback_to_default,
15161522
return self.findfont(
15171523
prop, fontext, directory, rebuild_if_missing=False)
15181524
else:
1519-
raise ValueError("No valid font could be found")
1525+
# This return instead of raise is intentional, as we wish to
1526+
# cache the resulting exception, which will not occur if it was
1527+
# actually raised.
1528+
return ValueError("No valid font could be found")
15201529

15211530
return _cached_realpath(result)
15221531

lib/matplotlib/tests/test_backend_svg.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,3 +527,64 @@ def test_svg_escape():
527527
fig.savefig(fd, format='svg')
528528
buf = fd.getvalue().decode()
529529
assert '<'"&>"' in buf
530+
531+
532+
@pytest.mark.parametrize("font_str", [
533+
"'DejaVu Sans', 'WenQuanYi Zen Hei', 'Arial', sans-serif",
534+
"'DejaVu Serif', 'WenQuanYi Zen Hei', 'Times New Roman', serif",
535+
"'Arial', 'WenQuanYi Zen Hei', cursive",
536+
"'Impact', 'WenQuanYi Zen Hei', fantasy",
537+
"'DejaVu Sans Mono', 'WenQuanYi Zen Hei', 'Courier New', monospace",
538+
# These do not work because the logic to get the font metrics will not find
539+
# WenQuanYi as the fallback logic stops with the first fallback font:
540+
# "'DejaVu Sans Mono', 'Courier New', 'WenQuanYi Zen Hei', monospace",
541+
# "'DejaVu Sans', 'Arial', 'WenQuanYi Zen Hei', sans-serif",
542+
# "'DejaVu Serif', 'Times New Roman', 'WenQuanYi Zen Hei', serif",
543+
])
544+
@pytest.mark.parametrize("include_generic", [True, False])
545+
def test_svg_font_string(font_str, include_generic):
546+
fp = fm.FontProperties(family=["WenQuanYi Zen Hei"])
547+
if Path(fm.findfont(fp)).name != "wqy-zenhei.ttc":
548+
pytest.skip("Font may be missing")
549+
550+
explicit, *rest, generic = map(
551+
lambda x: x.strip("'"), font_str.split(", ")
552+
)
553+
size = len(generic)
554+
if include_generic:
555+
rest = rest + [generic]
556+
plt.rcParams[f"font.{generic}"] = rest
557+
plt.rcParams["font.size"] = size
558+
plt.rcParams["svg.fonttype"] = "none"
559+
560+
fig, ax = plt.subplots()
561+
if generic == "sans-serif":
562+
generic_options = ["sans", "sans-serif", "sans serif"]
563+
else:
564+
generic_options = [generic]
565+
566+
for generic_name in generic_options:
567+
# test that fallback works
568+
ax.text(0.5, 0.5, "There are 几个汉字 in between!",
569+
family=[explicit, generic_name], ha="center")
570+
# test deduplication works
571+
ax.text(0.5, 0.1, "There are 几个汉字 in between!",
572+
family=[explicit, *rest, generic_name], ha="center")
573+
ax.axis("off")
574+
575+
with BytesIO() as fd:
576+
fig.savefig(fd, format="svg")
577+
buf = fd.getvalue()
578+
579+
tree = xml.etree.ElementTree.fromstring(buf)
580+
ns = "http://www.w3.org/2000/svg"
581+
text_count = 0
582+
for text_element in tree.findall(f".//{{{ns}}}text"):
583+
text_count += 1
584+
font_info = dict(
585+
map(lambda x: x.strip(), _.strip().split(":"))
586+
for _ in dict(text_element.items())["style"].split(";")
587+
)["font"]
588+
589+
assert font_info == f"{size}px {font_str}"
590+
assert text_count == len(ax.texts)

0 commit comments

Comments
 (0)
0