Skip to content

Commit 1f2b330

Browse files
committed
Implement loading of any font in a collection
For backwards-compatibility, the path+index is passed around in a lightweight subclass of `str`.
1 parent 24c1576 commit 1f2b330

File tree

3 files changed

+133
-19
lines changed

3 files changed

+133
-19
lines changed

lib/matplotlib/font_manager.py

Lines changed: 76 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,61 @@ def findSystemFonts(fontpaths=None, fontext='ttf'):
310310
return [fname for fname in fontfiles if os.path.exists(fname)]
311311

312312

313+
class FontPath(str):
314+
"""
315+
A class to describe a path to a font with a face index.
316+
317+
Parameters
318+
----------
319+
path : str
320+
The path to a font.
321+
face_index : int
322+
The face index in the font.
323+
"""
324+
325+
__match_args__ = ('path', 'face_index')
326+
327+
def __new__(cls, path, face_index):
328+
ret = super().__new__(cls, path)
329+
ret._face_index = face_index
330+
return ret
331+
332+
@property
333+
def path(self):
334+
"""The path to a font."""
335+
return str(self)
336+
337+
@property
338+
def face_index(self):
339+
"""The face index in a font."""
340+
return self._face_index
341+
342+
def _as_tuple(self):
343+
return (self.path, self.face_index)
344+
345+
def __eq__(self, other):
346+
if isinstance(other, FontPath):
347+
return self._as_tuple() == other._as_tuple()
348+
return super().__eq__(other)
349+
350+
def __ne__(self, other):
351+
return not (self == other)
352+
353+
def __lt__(self, other):
354+
if isinstance(other, FontPath):
355+
return self._as_tuple() < other._as_tuple()
356+
return super().__lt__(other)
357+
358+
def __gt__(self, other):
359+
return not (self == other or self < other)
360+
361+
def __hash__(self):
362+
return hash(self._as_tuple())
363+
364+
def __repr__(self):
365+
return f'FontPath{self._as_tuple()}'
366+
367+
313368
@dataclasses.dataclass(frozen=True)
314369
class FontEntry:
315370
"""
@@ -1326,7 +1381,7 @@ def findfont(self, prop, fontext='ttf', directory=None,
13261381
13271382
Returns
13281383
-------
1329-
str
1384+
FontPath
13301385
The filename of the best matching font.
13311386
13321387
Notes
@@ -1396,7 +1451,7 @@ def _find_fonts_by_props(self, prop, fontext='ttf', directory=None,
13961451
13971452
Returns
13981453
-------
1399-
list[str]
1454+
list[FontPath]
14001455
The paths of the fonts found.
14011456
14021457
Notes
@@ -1542,7 +1597,7 @@ def _findfont_cached(self, prop, fontext, directory, fallback_to_default,
15421597
# actually raised.
15431598
return cbook._ExceptionInfo(ValueError, "No valid font could be found")
15441599

1545-
return _cached_realpath(result)
1600+
return FontPath(_cached_realpath(result), best_font.index)
15461601

15471602

15481603
@_api.deprecated("3.11")
@@ -1562,15 +1617,16 @@ def is_opentype_cff_font(filename):
15621617
@lru_cache(64)
15631618
def _get_font(font_filepaths, hinting_factor, *, _kerning_factor, thread_id,
15641619
enable_last_resort):
1565-
first_fontpath, *rest = font_filepaths
1620+
(first_fontpath, first_fontindex), *rest = font_filepaths
15661621
fallback_list = [
1567-
ft2font.FT2Font(fpath, hinting_factor, _kerning_factor=_kerning_factor)
1568-
for fpath in rest
1622+
ft2font.FT2Font(fpath, hinting_factor, face_index=index,
1623+
_kerning_factor=_kerning_factor)
1624+
for fpath, index in rest
15691625
]
15701626
last_resort_path = _cached_realpath(
15711627
cbook._get_data_path('fonts', 'ttf', 'LastResortHE-Regular.ttf'))
15721628
try:
1573-
last_resort_index = font_filepaths.index(last_resort_path)
1629+
last_resort_index = font_filepaths.index((last_resort_path, 0))
15741630
except ValueError:
15751631
last_resort_index = -1
15761632
# Add Last Resort font so we always have glyphs regardless of font, unless we're
@@ -1582,7 +1638,7 @@ def _get_font(font_filepaths, hinting_factor, *, _kerning_factor, thread_id,
15821638
_warn_if_used=True))
15831639
last_resort_index = len(fallback_list)
15841640
font = ft2font.FT2Font(
1585-
first_fontpath, hinting_factor,
1641+
first_fontpath, hinting_factor, face_index=first_fontindex,
15861642
_fallback_list=fallback_list,
15871643
_kerning_factor=_kerning_factor
15881644
)
@@ -1617,7 +1673,8 @@ def get_font(font_filepaths, hinting_factor=None):
16171673
16181674
Parameters
16191675
----------
1620-
font_filepaths : Iterable[str, bytes, os.PathLike], str, bytes, os.PathLike
1676+
font_filepaths : Iterable[str, bytes, os.PathLike, FontPath], \
1677+
str, bytes, os.PathLike, FontPath
16211678
Relative or absolute paths to the font files to be used.
16221679
16231680
If a single string, bytes, or `os.PathLike`, then it will be treated
@@ -1632,10 +1689,16 @@ def get_font(font_filepaths, hinting_factor=None):
16321689
`.ft2font.FT2Font`
16331690
16341691
"""
1635-
if isinstance(font_filepaths, (str, bytes, os.PathLike)):
1636-
paths = (_cached_realpath(font_filepaths),)
1637-
else:
1638-
paths = tuple(_cached_realpath(fname) for fname in font_filepaths)
1692+
match font_filepaths:
1693+
case FontPath(path, index):
1694+
paths = ((_cached_realpath(path), index), )
1695+
case str() | bytes() | os.PathLike() as path:
1696+
paths = ((_cached_realpath(path), 0), )
1697+
case _:
1698+
paths = tuple(
1699+
(_cached_realpath(fname.path), fname.face_index)
1700+
if isinstance(fname, FontPath) else (_cached_realpath(fname), 0)
1701+
for fname in font_filepaths)
16391702

16401703
hinting_factor = mpl._val_or_rc(hinting_factor, 'text.hinting_factor')
16411704

lib/matplotlib/font_manager.pyi

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ from dataclasses import dataclass
33
from numbers import Integral
44
import os
55
from pathlib import Path
6-
from typing import Any, Literal
6+
from typing import Any, Final, Literal
77

88
from matplotlib._afm import AFM
99
from matplotlib import ft2font
@@ -26,6 +26,19 @@ def _get_fontconfig_fonts() -> list[Path]: ...
2626
def findSystemFonts(
2727
fontpaths: Iterable[str | os.PathLike] | None = ..., fontext: str = ...
2828
) -> list[str]: ...
29+
30+
class FontPath(str):
31+
__match_args__: Final[tuple[str, ...]]
32+
def __new__(cls: type[str], path: str, face_index: int) -> FontPath: ...
33+
@property
34+
def path(self) -> str: ...
35+
@property
36+
def face_index(self) -> int: ...
37+
def _as_tuple(self) -> tuple[str, int]: ...
38+
def __eq__(self, other: Any) -> bool: ...
39+
def __hash__(self) -> int: ...
40+
def __repr__(self) -> str: ...
41+
2942
@dataclass
3043
class FontEntry:
3144
fname: str = ...
@@ -116,12 +129,12 @@ class FontManager:
116129
directory: str | None = ...,
117130
fallback_to_default: bool = ...,
118131
rebuild_if_missing: bool = ...,
119-
) -> str: ...
132+
) -> FontPath: ...
120133
def get_font_names(self) -> list[str]: ...
121134

122135
def is_opentype_cff_font(filename: str) -> bool: ...
123136
def get_font(
124-
font_filepaths: Iterable[str | bytes | os.PathLike] | str | bytes | os.PathLike,
137+
font_filepaths: Iterable[str | bytes | os.PathLike | FontPath] | str | bytes | os.PathLike | FontPath,
125138
hinting_factor: int | None = ...,
126139
) -> ft2font.FT2Font: ...
127140

lib/matplotlib/tests/test_font_manager.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
import matplotlib as mpl
1515
from matplotlib.font_manager import (
16-
findfont, findSystemFonts, FontEntry, FontProperties, fontManager,
16+
findfont, findSystemFonts, FontEntry, FontPath, FontProperties, fontManager,
1717
json_dump, json_load, get_font, is_opentype_cff_font,
1818
MSUserFontDirectories, ttfFontProperty,
1919
_get_fontconfig_fonts, _normalize_weight)
@@ -24,6 +24,38 @@
2424
has_fclist = shutil.which('fc-list') is not None
2525

2626

27+
def test_font_path():
28+
fp = FontPath('foo', 123)
29+
fp2 = FontPath('foo', 321)
30+
assert str(fp) == 'foo'
31+
assert repr(fp) == "FontPath('foo', 123)"
32+
assert fp.path == 'foo'
33+
assert fp.face_index == 123
34+
# Should be immutable.
35+
with pytest.raises(AttributeError, match='has no setter'):
36+
fp.path = 'bar'
37+
with pytest.raises(AttributeError, match='has no setter'):
38+
fp.face_index = 321
39+
# Should be comparable with str and itself.
40+
assert fp == 'foo'
41+
assert fp == FontPath('foo', 123)
42+
assert fp <= fp
43+
assert fp >= fp
44+
assert fp != fp2
45+
assert fp < fp2
46+
assert fp <= fp2
47+
assert fp2 > fp
48+
assert fp2 >= fp
49+
# Should be hashable, but not the same as str.
50+
d = {fp: 1, 'bar': 2}
51+
assert fp in d
52+
assert d[fp] == 1
53+
assert d[FontPath('foo', 123)] == 1
54+
assert fp2 not in d
55+
assert 'foo' not in d
56+
assert FontPath('bar', 0) not in d
57+
58+
2759
def test_font_priority():
2860
with rc_context(rc={
2961
'font.sans-serif':
@@ -122,8 +154,12 @@ def test_find_ttc():
122154
pytest.skip("Font wqy-zenhei.ttc may be missing")
123155
# All fonts from this collection should have loaded as well.
124156
for name in ["WenQuanYi Zen Hei Mono", "WenQuanYi Zen Hei Sharp"]:
125-
assert findfont(FontProperties(family=[name]),
126-
fallback_to_default=False) == fontpath
157+
subfontpath = findfont(FontProperties(family=[name]), fallback_to_default=False)
158+
assert subfontpath.path == fontpath.path
159+
assert subfontpath.face_index != fontpath.face_index
160+
subfont = get_font(subfontpath)
161+
assert subfont.fname == subfontpath.path
162+
assert subfont.face_index == subfontpath.face_index
127163
fig, ax = plt.subplots()
128164
ax.text(.5, .5, "\N{KANGXI RADICAL DRAGON}", fontproperties=fp)
129165
for fmt in ["raw", "svg", "pdf", "ps"]:
@@ -161,6 +197,8 @@ def __fspath__(self):
161197
assert font.fname == file_str
162198
font = get_font(PathLikeClass(file_bytes))
163199
assert font.fname == file_bytes
200+
font = get_font(FontPath(file_str, 0))
201+
assert font.fname == file_str
164202

165203
# Note, fallbacks are not currently accessible.
166204
font = get_font([file_str, file_bytes,

0 commit comments

Comments
 (0)