8000 Merge pull request #20740 from aitikgupta/fallback-revamp · matplotlib/matplotlib@535c953 · GitHub
[go: up one dir, main page]

Skip to content

Commit 535c953

Browse files
authored
Merge pull request #20740 from aitikgupta/fallback-revamp
Implement Font-Fallback in Matplotlib
2 parents eeac402 + 85bacdc commit 535c953

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 67E6 _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
"""
177< 10000 code>172
Return a list of file extensions that are synonyms for
@@ -1358,7 +1353,110 @@ def get_font_names(self):
13581353
"""Return the list of available fonts."""
13591354
return list(set([font.name for font in self.ttflist]))
13601355

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

@@ -1451,9 +1549,19 @@ def is_opentype_cff_font(filename):
14511549

14521550

14531551
@lru_cache(64)
1454-
def _get_font(filename, hinting_factor, *, _kerning_factor, thread_id):
1552+
def _get_font(font_filepaths, hinting_factor, *, _kerning_factor, thread_id):
1553+
first_fontpath, *rest = font_filepaths
14551554
return ft2font.FT2Font(
1456-
filename, hinting_factor, _kerning_factor=_kerning_factor)
1555+
first_fontpath, hinting_factor,
1556+
_fallback_list=[
1557+
ft2font.FT2Font(
1558+
fpath, hinting_factor,
1559+
_kerning_factor=_kerning_factor
1560+
)
1561+
for fpath in rest
1562+
],
1563+
_kerning_factor=_kerning_factor
1564+
)
14571565

14581566

14591567
# FT2Font objects cannot be used across fork()s because they reference the same
@@ -1465,16 +1573,51 @@ def _get_font(filename, hinting_factor, *, _kerning_factor, thread_id):
14651573
os.register_at_fork(after_in_child=_get_font.cache_clear)
14661574

14671575

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

14791622

14801623
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