10000 ENH: support font fallback for Agg renderer · matplotlib/matplotlib@85bacdc · GitHub
[go: up one dir, main page]

Skip to content

Commit 85bacdc

Browse files
aitikguptaQuLogic
authored andcommitted
ENH: support font fallback for Agg renderer
Co-authored-by: Aitik Gupta <aitikgupta@gmail.com> Co-authored-by: Elliott Sales de Andrade <quantum.analyst@gmail.com>
1 parent 79b7f25 commit 85bacdc

File tree

10 files changed

+630
-110
lines changed

10 files changed

+630
-110
lines changed

.circleci/config.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ commands:
5555
texlive-latex-recommended \
5656
texlive-pictures \
5757
texlive-xetex \
58+
ttf-wqy-zenhei \
5859
graphviz \
5960
fonts-crosextra-carlito \
6061
fonts-freefont-otf \

doc/api/ft2font.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
**********************
2+
``matplotlib.ft2font``
3+
**********************
4+
5+
.. automodule:: matplotlib.ft2font
6+
:members:
7+
:undoc-members:
8+
:show-inheritance:

doc/api/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ Alphabetical list of modules:
120120
figure_api.rst
121121
font_manager_api.rst
122122
fontconfig_pattern_api.rst
123+
ft2font.rst
123124
gridspec_api.rst
124125
hatch_api.rst
125126
image_api.rst
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
Font Fallback in Agg
2+
--------------------
3+
4+
It is now possible to specify a list of fonts families and the Agg renderer
5+
will try them in order to locate a required glyph.
6+
7+
.. plot::
8+
:caption: Demonstration of mixed English and Chinese text with font fallback.
9+
:alt: The phrase "There are 几个汉字 in between!" rendered in various fonts.
10+
:include-source: True
11+
12+
import matplotlib.pyplot as plt
13+
14+
text = "There are 几个汉字 in between!"
15+
16+
plt.rcParams["font.size"] = 20
17+
fig = plt.figure(figsize=(4.75, 1.85))
18+
fig.text(0.05, 0.85, text, family=["WenQuanYi Zen Hei"])
19+
fig.text(0.05, 0.65, text, family=["Noto Sans CJK JP"])
20+
fig.text(0.05, 0.45, text, family=["DejaVu Sans", "Noto Sans CJK JP"])
21+
fig.text(0.05, 0.25, text, family=["DejaVu Sans", "WenQuanYi Zen Hei"])
22+
23+
plt.show()
24+
25+
26+
This currently only works with the Agg backend, but support for the vector
27+
backends is planned for Matplotlib 3.7.

lib/matplotlib/backends/backend_agg.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from matplotlib import _api, cbook
3232
from matplotlib.backend_bases import (
3333
_Backend, FigureCanvasBase, FigureManagerBase, RendererBase)
34-
from matplotlib.font_manager import findfont, get_font
34+
from matplotlib.font_manager import fontManager as _fontManager, get_font
3535
from matplotlib.ft2font import (LOAD_FORCE_AUTOHINT, LOAD_NO_HINTING,
3636
LOAD_DEFAULT, LOAD_NO_AUTOHINT)
3737
from matplotlib.mathtext import MathTextParser
@@ -272,7 +272,7 @@ def _prepare_font(self, font_prop):
272272
"""
273273
Get the `.FT2Font` for *font_prop*, clear its buffer, and set its size.
274274
"""
275-
font = get_font(findfont(font_prop))
275+
font = get_font(_fontManager._find_fonts_by_props(font_prop))
276276
font.clear()
277277
size = font_prop.get_size_in_points()
278278
font.set_size(size, self.dpi)

lib/matplotlib/font_manager.py

Lines changed: 157 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -167,11 +167,6 @@
167167
]
168168

169169

170-
@lru_cache(64)
171-
def _cached_realpath(path):
172-
return os.path.realpath(path)
173-
174-
175170
def get_fontext_synonyms(fontext):
176171
"""
177172
Return a list of file extensions that are synonyms for
@@ -1354,7 +1349,110 @@ def get_font_names(self):
13541349
"""Return the list of available fonts."""
13551350
return list(set([font.name for font in self.ttflist]))
13561351

1357-
@lru_cache()
1352+
def _find_fonts_by_props(self, prop, fontext='ttf', directory=None,
1353+
fallback_to_default=True, rebuild_if_missing=True):
1354+
"""
1355+
Find font families that most closely match the given properties.
1356+
1357+
Parameters
1358+
----------
1359+
prop : str or `~matplotlib.font_manager.FontProperties`
1360+
The font properties to search for. This can be either a
1361+
`.FontProperties` object or a string defining a
1362+
`fontconfig patterns`_.
1363+
1364+
fontext : {'ttf', 'afm'}, default: 'ttf'
1365+
The extension of the font file:
1366+
1367+
- 'ttf': TrueType and OpenType fonts (.ttf, .ttc, .otf)
1368+
- 'afm': Adobe Font Metrics (.afm)
1369+
1370+
directory : str, optional
1371+
If given, only search this directory and its subdirectories.
1372+
1373+
fallback_to_default : bool
1374+
If True, will fallback to the default font family (usually
1375+
"DejaVu Sans" or "Helvetica") if none of the families were found.
1376+
1377+
rebuild_if_missing : bool
1378+
Whether to rebuild the font cache and search again if the first
1379+
match appears to point to a nonexisting font (i.e., the font cache
1380+
contains outdated entries).
1381+
1382+
Returns
1383+
-------
1384+
list[str]
1385+
The paths of the fonts found
1386+
1387+
Notes
1388+
-----
1389+
This is an extension/wrapper of the original findfont API, which only
1390+
returns a single font for given font properties. Instead, this API
1391+
returns an dict containing multiple fonts and their filepaths
1392+
which closely match the given font properties. Since this internally
1393+
uses the original API, there's no change to the logic of performing the
1394+
nearest neighbor search. See `findfont` for more details.
1395+
1396+
"""
1397+
1398+
rc_params = tuple(tuple(rcParams[key]) for key in [
1399+
"font.serif", "font.sans-serif", "font.cursive", "font.fantasy",
1400+
"font.monospace"])
1401+
1402+
prop = FontProperties._from_any(prop)
1403+
1404+
fpaths = []
1405+
for family in prop.get_family():
1406+
cprop = prop.copy()
1407+
1408+
# set current prop's family
1409+
cprop.set_family(family)
1410+
1411+
# do not fall back to default font
1412+
try:
1413+
fpaths.append(
1414+
self._findfont_cached(
1415+
cprop, fontext, directory,
1416+
fallback_to_default=False,
1417+
rebuild_if_missing=rebuild_if_missing,
1418+
rc_params=rc_params,
1419+
)
1420+
)
1421+
except ValueError:
1422+
if family in font_family_aliases:
1423+
_log.warning(
1424+
"findfont: Generic family %r not found because "
1425+
"none of the following families were found: %s",
1426+
family,
1427+
", ".join(self._expand_aliases(family))
1428+
)
1429+
else:
1430+
_log.warning(
1431+
'findfont: Font family \'%s\' not found.', family
1432+
)
1433+
1434+
# only add default family if no other font was found and
1435+
# fallback_to_default is enabled
1436+
if not fpaths:
1437+
if fallback_to_default:
1438+
dfamily = self.defaultFamily[fontext]
1439+
cprop = prop.copy()
1440+
cprop.set_family(dfamily)
1441+
fpaths.append(
1442+
self._findfont_cached(
1443+
cprop, fontext, directory,
1444+
fallback_to_default=True,
1445+
rebuild_if_missing=rebuild_if_missing,
1446+
rc_params=rc_params,
1447+
)
1448+
)
1449+
else:
1450+
raise ValueError("Failed to find any font, and fallback "
1451+
"to the default font was disabled.")
1452+
1453+
return fpaths
1454+
1455+
@lru_cache(1024)
13581456
def _findfont_cached(self, prop, fontext, directory, fallback_to_default,
13591457
rebuild_if_missing, rc_params):
13601458

@@ -1447,9 +1545,19 @@ def is_opentype_cff_font(filename):
14471545

14481546

14491547
@lru_cache(64)
1450-
def _get_font(filename, hinting_factor, *, _kerning_factor, thread_id):
1548+
def _get_font(font_filepaths, hinting_factor, *, _kerning_factor, thread_id):
1549+
first_fontpath, *rest = font_filepaths
14511550
return ft2font.FT2Font(
1452-
filename, hinting_factor, _kerning_factor=_kerning_factor)
1551+
first_fontpath, hinting_factor,
1552+
_fallback_list=[
1553+
ft2font.FT2Font(
1554+
fpath, hinting_factor,
1555+
_kerning_factor=_kerning_factor
1556+
)
1557+
for fpath in rest
1558+
],
1559+
_kerning_factor=_kerning_factor
1560+
)
14531561

14541562

14551563
# FT2Font objects cannot be used across fork()s because they reference the same
@@ -1461,16 +1569,51 @@ def _get_font(filename, hinting_factor, *, _kerning_factor, thread_id):
14611569
os.register_at_fork(after_in_child=_get_font.cache_clear)
14621570

14631571

1464-
def get_font(filename, hinting_factor=None):
1572+
@lru_cache(64)
1573+
def _cached_realpath(path):
14651574
# Resolving the path avoids embedding the font twice in pdf/ps output if a
14661575
# single font is selected using two different relative paths.
1467-
filename = _cached_realpath(filename)
1576+
return os.path.realpath(path)
1577+
1578+
1579+
@_api.rename_parameter('3.6', "filepath", "font_filepaths")
1580+
def get_font(font_filepaths, hinting_factor=None):
1581+
"""
1582+
Get an `.ft2font.FT2Font` object given a list of file paths.
1583+
1584+
Parameters
1585+
----------
1586+
font_filepaths : Iterable[str, Path, bytes], str, Path, bytes
1587+
Relative or absolute paths to the font files to be used.
1588+
1589+
If a single string, bytes, or `pathlib.Path`, then it will be treated
1590+
as a list with that entry only.
1591+
1592+
If more than one filepath is passed, then the returned FT2Font object
1593+
will fall back through the fonts, in the order given, to find a needed
1594+
glyph.
1595+
1596+
Returns
1597+
-------
1598+
`.ft2font.FT2Font`
1599+
1600+
"""
1601+
if isinstance(font_filepaths, (str, Path, bytes)):
1602+
paths = (_cached_realpath(font_filepaths),)
1603+
else:
1604+
paths = tuple(_cached_realpath(fname) for fname in font_filepaths)
1605+
14681606
if hinting_factor is None:
14691607
hinting_factor = rcParams['text.hinting_factor']
1470-
# also key on the thread ID to prevent segfaults with multi-threading
1471-
return _get_font(filename, hinting_factor,
1472-
_kerning_factor=rcParams['text.kerning_factor'],
1473-
thread_id=threading.get_ident())
1608+
1609+
return _get_font(
1610+
# must be a tuple to be cached
1611+
paths,
1612+
hinting_factor,
1613+
_kerning_factor=rcParams['text.kerning_factor'],
1614+
# also key on the thread ID to prevent segfaults with multi-threading
1615+
thread_id=threading.get_ident()
1616+
)
14741617

14751618

14761619
def _load_fontmanager(*, try_read_cache=True):

lib/matplotlib/tests/test_ft2font.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from pathlib import Path
2+
import io
3+
4+
import pytest
5+
6+
from matplotlib import ft2font
7+
from matplotlib.testing.decorators import check_figures_equal
8+
import matplotlib.font_manager as fm
9+
import matplotlib.pyplot as plt
10+
11+
12+
def test_fallback_errors():
13+
file_name = fm.findfont('DejaVu Sans')
14+
15+
with pytest.raises(TypeError, match="Fallback list must be a list"):
16+
# failing to be a list will fail before the 0
17+
ft2font.FT2Font(file_name, _fallback_list=(0,))
18+
19+
with pytest.raises(
20+
TypeError, match="Fallback fonts must be FT2Font objects."
21+
):
22+
ft2font.FT2Font(file_name, _fallback_list=[0])
23+
24+
25+
def test_ft2font_positive_hinting_factor():
26+
file_name = fm.findfont('DejaVu Sans')
27+
with pytest.raises(
28+
ValueError, match="hinting_factor must be greater than 0"
29+
):
30+
ft2font.FT2Font(file_name, 0)
31+
32+
33+
def test_fallback_smoke():
34+
fp = fm.FontProperties(family=["WenQuanYi Zen Hei"])
35+
if Path(fm.findfont(fp)).name != "wqy-zenhei.ttc":
36+
pytest.skip("Font wqy-zenhei.ttc may be missing")
37+
38+
fp = fm.FontProperties(family=["Noto Sans CJK JP"])
39+
if Path(fm.findfont(fp)).name != "NotoSansCJK-Regular.ttc":
40+
pytest.skip("Noto Sans CJK JP font may be missing.")
41+
42+
plt.rcParams['font.size'] = 20
43+
fig = plt.figure(figsize=(4.75, 1.85))
44+
fig.text(0.05, 0.45, "There are 几个汉字 in between!",
45+
family=['DejaVu Sans', "Noto Sans CJK JP"])
46+
fig.text(0.05, 0.25, "There are 几个汉字 in between!",
47+
family=['DejaVu Sans', "WenQuanYi Zen Hei"])
48+
fig.text(0.05, 0.65, "There are 几个汉字 in between!",
49+
family=["Noto Sans CJK JP"])
50+
fig.text(0.05, 0.85, "There are 几个汉字 in between!",
51+
family=["WenQuanYi Zen Hei"])
52+
53+
# TODO enable fallback for other backends!
54+
for fmt in ['png', 'raw']: # ["svg", "pdf", "ps"]:
55+
fig.savefig(io.BytesIO(), format=fmt)
56+
57+
58+
@pytest.mark.parametrize('family_name, file_name',
59+
[("WenQuanYi Zen Hei", "wqy-zenhei.ttc"),
60+
("Noto Sans CJK JP", "NotoSansCJK-Regular.ttc")]
61+
)
62+
@check_figures_equal(extensions=["png"])
63+
def test_font_fallback_chinese(fig_test, fig_ref, family_name, file_name):
64+
fp = fm.FontProperties(family=[family_name])
65+
if Path(fm.findfont(fp)).name != file_name:
66+
pytest.skip(f"Font {family_name} ({file_name}) is missing")
67+
68+
text = ["There are", "几个汉字", "in between!"]
69+
70+
plt.rcParams["font.size"] = 20
71+
test_fonts = [["DejaVu Sans", family_name]] * 3
72+
ref_fonts = [["DejaVu Sans"], [family_name], ["DejaVu Sans"]]
73+
74+
for j, (txt, test_font, ref_font) in enumerate(
75+
zip(text, test_fonts, ref_fonts)
76+
):
77+
fig_ref.text(0.05, .85 - 0.15*j, txt, family=ref_font)
78+
fig_test.text(0.05, .85 - 0.15*j, txt, family=test_font)

0 commit comments

Comments
 (0)
0