Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Implement Font-Fallback in Matplotlib #20740

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ commands:
texlive-latex-recommended \
texlive-pictures \
texlive-xetex \
ttf-wqy-zenhei \
graphviz \
fonts-crosextra-carlito \
fonts-freefont-otf \
Expand Down
8 changes: 8 additions & 0 deletions doc/api/ft2font.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
**********************
``matplotlib.ft2font``
**********************

.. automodule:: matplotlib.ft2font
:members:
:undoc-members:
:show-inheritance:
1 change: 1 addition & 0 deletions doc/api/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ Alphabetical list of modules:
figure_api.rst
font_manager_api.rst
fontconfig_pattern_api.rst
ft2font.rst
gridspec_api.rst
hatch_api.rst
image_api.rst
Expand Down
27 changes: 27 additions & 0 deletions doc/users/next_whats_new/font_fallback.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
Font Fallback in Agg
--------------------

It is now possible to specify a list of fonts families and the Agg renderer
will try them in order to locate a required glyph.

.. plot::
:caption: Demonstration of mixed English and Chinese text with font fallback.
:alt: The phrase "There are 几个汉字 in between!" rendered in various fonts.
:include-source: True

import matplotlib.pyplot as plt

text = "There are 几个汉字 in between!"

plt.rcParams["font.size"] = 20
fig = plt.figure(figsize=(4.75, 1.85))
fig.text(0.05, 0.85, text, family=["WenQuanYi Zen Hei"])
fig.text(0.05, 0.65, text, family=["Noto Sans CJK JP"])
fig.text(0.05, 0.45, text, family=["DejaVu Sans", "Noto Sans CJK JP"])
fig.text(0.05, 0.25, text, family=["DejaVu Sans", "WenQuanYi Zen Hei"])

plt.show()


This currently only works with the Agg backend, but support for the vector
backends is planned for Matplotlib 3.7.
4 changes: 2 additions & 2 deletions lib/matplotlib/backends/backend_agg.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from matplotlib import _api, cbook
from matplotlib.backend_bases import (
_Backend, FigureCanvasBase, FigureManagerBase, RendererBase)
from matplotlib.font_manager import findfont, get_font
from matplotlib.font_manager import fontManager as _fontManager, get_font
from matplotlib.ft2font import (LOAD_FORCE_AUTOHINT, LOAD_NO_HINTING,
LOAD_DEFAULT, LOAD_NO_AUTOHINT)
from matplotlib.mathtext import MathTextParser
Expand Down Expand Up @@ -272,7 +272,7 @@ def _prepare_font(self, font_prop):
"""
Get the `.FT2Font` for *font_prop*, clear its buffer, and set its size.
"""
font = get_font(findfont(font_prop))
font = get_font(_fontManager._find_fonts_by_props(font_prop))
font.clear()
size = font_prop.get_size_in_points()
font.set_size(size, self.dpi)
Expand Down
171 changes: 157 additions & 14 deletions lib/matplotlib/font_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,11 +167,6 @@
]


@lru_cache(64)
def _cached_realpath(path):
return os.path.realpath(path)


def get_fontext_synonyms(fontext):
"""
Return a list of file extensions that are synonyms for
Expand Down Expand Up @@ -1354,7 +1349,110 @@ def get_font_names(self):
"""Return the list of available fonts."""
return list(set([font.name for font in self.ttflist]))

@lru_cache()
def _find_fonts_by_props(self, prop, fontext='ttf', directory=None,
fallback_to_default=True, rebuild_if_missing=True):
"""
Find font families that most closely match the given properties.

Parameters
----------
prop : str or `~matplotlib.font_manager.FontProperties`
The font properties to search for. This can be either a
`.FontProperties` object or a string defining a
`fontconfig patterns`_.

fontext : {'ttf', 'afm'}, default: 'ttf'
The extension of the font file:

- 'ttf': TrueType and OpenType fonts (.ttf, .ttc, .otf)
- 'afm': Adobe Font Metrics (.afm)

directory : str, optional
If given, only search this directory and its subdirectories.

fallback_to_default : bool
If True, will fallback to the default font family (usually
"DejaVu Sans" or "Helvetica") if none of the families were found.

rebuild_if_missing : bool
Whether to rebuild the font cache and search again if the first
match appears to point to a nonexisting font (i.e., the font cache
contains outdated entries).

Returns
-------
list[str]
The paths of the fonts found

Notes
-----
This is an extension/wrapper of the original findfont API, which only
returns a single font for given font properties. Instead, this API
returns an dict containing multiple fonts and their filepaths
which closely match the given font properties. Since this internally
uses the original API, there's no change to the logic of performing the
nearest neighbor search. See `findfont` for more details.

"""

rc_params = tuple(tuple(rcParams[key]) for key in [
"font.serif", "font.sans-serif", "font.cursive", "font.fantasy",
"font.monospace"])

prop = FontProperties._from_any(prop)

fpaths = []
for family in prop.get_family():
cprop = prop.copy()

# set current prop's family
cprop.set_family(family)

# do not fall back to default font
try:
fpaths.append(
self._findfont_cached(
cprop, fontext, directory,
fallback_to_default=False,
rebuild_if_missing=rebuild_if_missing,
rc_params=rc_params,
)
)
except ValueError:
if family in font_family_aliases:
_log.warning(
"findfont: Generic family %r not found because "
"none of the following families were found: %s",
family,
", ".join(self._expand_aliases(family))
)
else:
_log.warning(
'findfont: Font family \'%s\' not found.', family
)

# only add default family if no other font was found and
# fallback_to_default is enabled
if not fpaths:
if fallback_to_default:
dfamily = self.defaultFamily[fontext]
cprop = prop.copy()
cprop.set_family(dfamily)
fpaths.append(
self._findfont_cached(
cprop, fontext, directory,
fallback_to_default=True,
rebuild_if_missing=rebuild_if_missing,
rc_params=rc_params,
)
)
else:
raise ValueError("Failed to find any font, and fallback "
"to the default font was disabled.")

return fpaths

@lru_cache(1024)
def _findfont_cached(self, prop, fontext, directory, fallback_to_default,
rebuild_if_missing, rc_params):

Expand Down Expand Up @@ -1447,9 +1545,19 @@ def is_opentype_cff_font(filename):


@lru_cache(64)
def _get_font(filename, hinting_factor, *, _kerning_factor, thread_id):
def _get_font(font_filepaths, hinting_factor, *, _kerning_factor, thread_id):
first_fontpath, *rest = font_filepaths
return ft2font.FT2Font(
filename, hinting_factor, _kerning_factor=_kerning_factor)
first_fontpath, hinting_factor,
_fallback_list=[
ft2font.FT2Font(
fpath, hinting_factor,
_kerning_factor=_kerning_factor
)
for fpath in rest
],
_kerning_factor=_kerning_factor
)


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


def get_font(filename, hinting_factor=None):
@lru_cache(64)
def _cached_realpath(path):
# Resolving the path avoids embedding the font twice in pdf/ps output if a
# single font is selected using two different relative paths.
filename = _cached_realpath(filename)
return os.path.realpath(path)


@_api.rename_parameter('3.6', "filepath", "font_filepaths")
def get_font(font_filepaths, hinting_factor=None):
"""
Get an `.ft2font.FT2Font` object given a list of file paths.

Parameters
----------
font_filepaths : Iterable[str, Path, bytes], str, Path, bytes
Relative or absolute paths to the font files to be used.

If a single string, bytes, or `pathlib.Path`, then it will be treated
as a list with that entry only.

If more than one filepath is passed, then the returned FT2Font object
will fall back through the fonts, in the order given, to find a needed
glyph.

Returns
-------
`.ft2font.FT2Font`

"""
if isinstance(font_filepaths, (str, Path, bytes)):
paths = (_cached_realpath(font_filepaths),)
else:
paths = tuple(_cached_realpath(fname) for fname in font_filepaths)

if hinting_factor is None:
hinting_factor = rcParams['text.hinting_factor']
# also key on the thread ID to prevent segfaults with multi-threading
return _get_font(filename, hinting_factor,
_kerning_factor=rcParams['text.kerning_factor'],
thread_id=threading.get_ident())

return _get_font(
# must be a tuple to be cached
paths,
hinting_factor,
_kerning_factor=rcParams['text.kerning_factor'],
# also key on the thread ID to prevent segfaults with multi-threading
thread_id=threading.get_ident()
)


def _load_fontmanager(*, try_read_cache=True):
Expand Down
78 changes: 78 additions & 0 deletions lib/matplotlib/tests/test_ft2font.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from pathlib import Path
import io

import pytest

from matplotlib import ft2font
from matplotlib.testing.decorators import check_figures_equal
import matplotlib.font_manager as fm
import matplotlib.pyplot as plt


def test_fallback_errors():
file_name = fm.findfont('DejaVu Sans')

with pytest.raises(TypeError, match="Fallback list must be a list"):
# failing to be a list will fail before the 0
ft2font.FT2Font(file_name, _fallback_list=(0,))

with pytest.raises(
TypeError, match="Fallback fonts must be FT2Font objects."
):
ft2font.FT2Font(file_name, _fallback_list=[0])


def test_ft2font_positive_hinting_factor():
file_name = fm.findfont('DejaVu Sans')
with pytest.raises(
ValueError, match="hinting_factor must be greater than 0"
):
ft2font.FT2Font(file_name, 0)


def test_fallback_smoke():
fp = fm.FontProperties(family=["WenQuanYi Zen Hei"])
if Path(fm.findfont(fp)).name != "wqy-zenhei.ttc":
pytest.skip("Font wqy-zenhei.ttc may be missing")

fp = fm.FontProperties(family=["Noto Sans CJK JP"])
if Path(fm.findfont(fp)).name != "NotoSansCJK-Regular.ttc":
pytest.skip("Noto Sans CJK JP font may be missing.")

plt.rcParams['font.size'] = 20
fig = plt.figure(figsize=(4.75, 1.85))
fig.text(0.05, 0.45, "There are 几个汉字 in between!",
family=['DejaVu Sans', "Noto Sans CJK JP"])
fig.text(0.05, 0.25, "There are 几个汉字 in between!",
family=['DejaVu Sans', "WenQuanYi Zen Hei"])
fig.text(0.05, 0.65, "There are 几个汉字 in between!",
family=["Noto Sans CJK JP"])
fig.text(0.05, 0.85, "There are 几个汉字 in between!",
family=["WenQuanYi Zen Hei"])

# TODO enable fallback for other backends!
for fmt in ['png', 'raw']: # ["svg", "pdf", "ps"]:
fig.savefig(io.BytesIO(), format=fmt)


@pytest.mark.parametrize('family_name, file_name',
[("WenQuanYi Zen Hei", "wqy-zenhei.ttc"),
("Noto Sans CJK JP", "NotoSansCJK-Regular.ttc")]
)
@check_figures_equal(extensions=["png"])
def test_font_fallback_chinese(fig_test, fig_ref, family_name, file_name):
fp = fm.FontProperties(family=[family_name])
if Path(fm.findfont(fp)).name != file_name:
pytest.skip(f"Font {family_name} ({file_name}) is missing")

text = ["There are", "几个汉字", "in between!"]

plt.rcParams["font.size"] = 20
test_fonts = [["DejaVu Sans", family_name]] * 3
ref_fonts = [["DejaVu Sans"], [family_name], ["DejaVu Sans"]]

for j, (txt, test_font, ref_font) in enumerate(
zip(text, test_fonts, ref_fonts)
):
fig_ref.text(0.05, .85 - 0.15*j, txt, family=ref_font)
fig_test.text(0.05, .85 - 0.15*j, txt, family=test_font)
Loading