Skip to content

Commit a9abc29

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 83b0144 commit a9abc29

File tree

3 files changed

+144
-19
lines changed

3 files changed

+144
-19
lines changed

lib/matplotlib/font_manager.py

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

312312

313+
# To maintain backwards-compatibility with the current code we need to continue to
314+
# return a str. However to support indexing into the file we need to return both the
315+
# path and the index. Thus, we sub-class str to maintain compatibility and extend it to
316+
# carry the index.
317+
#
318+
# The other alternative would be to create a completely new API and deprecate the
319+
# existing one. In this case, sub-classing str is the simpler and less-disruptive
320+
# option.
321+
class FontPath(str):
322+
"""
323+
A class to describe a path to a font with a face index.
324+
325+
Parameters
326+
----------
327+
path : str
328+
The path to a font.
329+
face_index : int
330+
The face index in the font.
331+
"""
332+
333+
__match_args__ = ('path', 'face_index')
334+
335+
def __new__(cls, path, face_index):
336+
ret = super().__new__(cls, path)
337+
ret._face_index = face_index
338+
return ret
339+
340+
@property
341+
def path(self):
342+
"""The path to a font."""
343+
return str(self)
344+
345+
@property
346+
def face_index(self):
347+
"""The face index in a font."""
348+
return self._face_index
349+
350+
def _as_tuple(self):
351+
return (self.path, self.face_index)
352+
353+
def __eq__(self, other):
354+
if isinstance(other, FontPath):
355+
return self._as_tuple() == other._as_tuple()
356+
return super().__eq__(other)
357+
358+
def __ne__(self, other):
359+
return not (self == other)
360+
361+
def __lt__(self, other):
362+
if isinstance(other, FontPath):
363+
return self._as_tuple() < other._as_tuple()
364+
return super().__lt__(other)
365+
366+
def __gt__(self, other):
367+
return not (self == other or self < other)
368+
369+
def __hash__(self):
370+
return hash(self._as_tuple())
371+
372+
def __repr__(self):
373+
return f'FontPath{self._as_tuple()}'
374+
375+
313376
@dataclasses.dataclass(frozen=True)
314377
class FontEntry:
315378
"""
@@ -1326,7 +1389,7 @@ def findfont(self, prop, fontext='ttf', directory=None,
13261389
13271390
Returns
13281391
-------
1329-
str
1392+
FontPath
13301393
The filename of the best matching font.
13311394
13321395
Notes
@@ -1396,7 +1459,7 @@ def _find_fonts_by_props(self, prop, fontext='ttf', directory=None,
13961459
13971460
Returns
13981461
-------
1399-
list[str]
1462+
list[FontPath]
14001463
The paths of the fonts found.
14011464
14021465
Notes
@@ -1542,7 +1605,7 @@ def _findfont_cached(self, prop, fontext, directory, fallback_to_default,
15421605
# actually raised.
15431606
return cbook._ExceptionInfo(ValueError, "No valid font could be found")
15441607

1545-
return _cached_realpath(result)
1608+
return FontPath(_cached_realpath(result), best_font.index)
15461609

15471610

15481611
@_api.deprecated("3.11")
@@ -1562,15 +1625,16 @@ def is_opentype_cff_font(filename):
15621625
@lru_cache(64)
15631626
def _get_font(font_filepaths, hinting_factor, *, _kerning_factor, thread_id,
15641627
enable_last_resort):
1565-
first_fontpath, *rest = font_filepaths
1628+
(first_fontpath, first_fontindex), *rest = font_filepaths
15661629
fallback_list = [
1567-
ft2font.FT2Font(fpath, hinting_factor, _kerning_factor=_kerning_factor)
1568-
for fpath in rest
1630+
ft2font.FT2Font(fpath, hinting_factor, face_index=index,
1631+
_kerning_factor=_kerning_factor)
1632+
for fpath, index in rest
15691633
]
15701634
last_resort_path = _cached_realpath(
15711635
cbook._get_data_path('fonts', 'ttf', 'LastResortHE-Regular.ttf'))
15721636
try:
1573-
last_resort_index = font_filepaths.index(last_resort_path)
1637+
last_resort_index = font_filepaths.index((last_resort_path, 0))
15741638
except ValueError:
15751639
last_resort_index = -1
15761640
# Add Last Resort font so we always have glyphs regardless of font, unless we're
@@ -1582,7 +1646,7 @@ def _get_font(font_filepaths, hinting_factor, *, _kerning_factor, thread_id,
15821646
_warn_if_used=True))
15831647
last_resort_index = len(fallback_list)
15841648
font = ft2font.FT2Font(
1585-
first_fontpath, hinting_factor,
1649+
first_fontpath, hinting_factor, face_index=first_fontindex,
15861650
_fallback_list=fallback_list,
15871651
_kerning_factor=_kerning_factor
15881652
)
@@ -1617,7 +1681,8 @@ def get_font(font_filepaths, hinting_factor=None):
16171681
16181682
Parameters
16191683
----------
1620-
font_filepaths : Iterable[str, bytes, os.PathLike], str, bytes, os.PathLike
1684+
font_filepaths : Iterable[str, bytes, os.PathLike, FontPath], \
1685+
str, bytes, os.PathLike, FontPath
16211686
Relative or absolute paths to the font files to be used.
16221687
16231688
If a single string, bytes, or `os.PathLike`, then it will be treated
@@ -1632,10 +1697,16 @@ def get_font(font_filepaths, hinting_factor=None):
16321697
`.ft2font.FT2Font`
16331698
16341699
"""
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)
1700+
match font_filepaths:
1701+
case FontPath(path, index):
1702+
paths = ((_cached_realpath(path), index), )
1703+
case str() | bytes() | os.PathLike() as path:
1704+
paths = ((_cached_realpath(path), 0), )
1705+
case _:
1706+
paths = tuple(
1707+
(_cached_realpath(fname.path), fname.face_index)
1708+
if isinstance(fname, FontPath) else (_cached_realpath(fname), 0)
1709+
for fname in font_filepaths)
16391710

16401711
hinting_factor = mpl._val_or_rc(hinting_factor, 'text.hinting_factor')
16411712

lib/matplotlib/font_manager.pyi

Lines changed: 19 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,22 @@ 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 __ne__(self, other: Any) -> bool: ...
40+
def __lt__(self, other: Any) -> bool: ...
41+
def __gt__(self, other: Any) -> bool: ...
42+
def __hash__(self) -> int: ...
43+
def __repr__(self) -> str: ...
44+
2945
@dataclass
3046
class FontEntry:
3147
fname: str = ...
@@ -116,12 +132,12 @@ class FontManager:
116132
directory: str | None = ...,
117133
fallback_to_default: bool = ...,
118134
rebuild_if_missing: bool = ...,
119-
) -> str: ...
135+
) -> FontPath: ...
120136
def get_font_names(self) -> list[str]: ...
121137

122138
def is_opentype_cff_font(filename: str) -> bool: ...
123139
def get_font(
124-
font_filepaths: Iterable[str | bytes | os.PathLike] | str | bytes | os.PathLike,
140+
font_filepaths: Iterable[str | bytes | os.PathLike | FontPath] | str | bytes | os.PathLike | FontPath,
125141
hinting_factor: int | None = ...,
126142
) -> ft2font.FT2Font: ...
127143

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)