Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 85bacdc

Browse filesBrowse 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
Copy full SHA for 85bacdc

File tree

Expand file treeCollapse file tree

10 files changed

+630
-110
lines changed
Filter options
Expand file treeCollapse file tree

10 files changed

+630
-110
lines changed

‎.circleci/config.yml

Copy file name to clipboardExpand all lines: .circleci/config.yml
+1Lines 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

Copy file name to clipboard
+8Lines 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

Copy file name to clipboardExpand all lines: doc/api/index.rst
+1Lines 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
+27Lines 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

Copy file name to clipboardExpand all lines: lib/matplotlib/backends/backend_agg.py
+2-2Lines 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

Copy file name to clipboardExpand all lines: lib/matplotlib/font_manager.py
+157-14Lines 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

Copy file name to clipboard
+78Lines 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)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.