Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Implement loading of any font in a collection
For backwards-compatibility, the path+index is passed around in a
lightweight subclass of `str`.
  • Loading branch information
QuLogic committed Nov 20, 2025
commit 6d2ae678f44e0250e5ecbedccad5dc634d4cbd62
97 changes: 84 additions & 13 deletions lib/matplotlib/font_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,69 @@ def findSystemFonts(fontpaths=None, fontext='ttf'):
return [fname for fname in fontfiles if os.path.exists(fname)]


# To maintain backwards-compatibility with the current code we need to continue to
# return a str. However to support indexing into the file we need to return both the
# path and the index. Thus, we sub-class str to maintain compatibility and extend it to
# carry the index.
#
# The other alternative would be to create a completely new API and deprecate the
# existing one. In this case, sub-classing str is the simpler and less-disruptive
# option.
class FontPath(str):
Comment thread
tacaswell marked this conversation as resolved.
"""
A class to describe a path to a font with a face index.

Parameters
----------
path : str
The path to a font.
face_index : int
The face index in the font.
"""

__match_args__ = ('path', 'face_index')

def __new__(cls, path, face_index):
ret = super().__new__(cls, path)
ret._face_index = face_index
return ret

@property
def path(self):
"""The path to a font."""
return str(self)

@property
def face_index(self):
"""The face index in a font."""
return self._face_index

def _as_tuple(self):
return (self.path, self.face_index)

def __eq__(self, other):
if isinstance(other, FontPath):
return self._as_tuple() == other._as_tuple()
return super().__eq__(other)

def __ne__(self, other):
return not (self == other)

def __lt__(self, other):
if isinstance(other, FontPath):
return self._as_tuple() < other._as_tuple()
return super().__lt__(other)

def __gt__(self, other):
return not (self == other or self < other)

def __hash__(self):
return hash(self._as_tuple())

def __repr__(self):
return f'FontPath{self._as_tuple()}'


@dataclasses.dataclass(frozen=True)
class FontEntry:
"""
Expand Down Expand Up @@ -1326,7 +1389,7 @@ def findfont(self, prop, fontext='ttf', directory=None,

Returns
-------
str
FontPath
The filename of the best matching font.

Notes
Expand Down Expand Up @@ -1396,7 +1459,7 @@ def _find_fonts_by_props(self, prop, fontext='ttf', directory=None,

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

Notes
Expand Down Expand Up @@ -1542,7 +1605,7 @@ def _findfont_cached(self, prop, fontext, directory, fallback_to_default,
# actually raised.
return cbook._ExceptionInfo(ValueError, "No valid font could be found")

return _cached_realpath(result)
return FontPath(_cached_realpath(result), best_font.index)


@_api.deprecated("3.11")
Expand All @@ -1562,15 +1625,16 @@ def is_opentype_cff_font(filename):
@lru_cache(64)
def _get_font(font_filepaths, hinting_factor, *, _kerning_factor, thread_id,
enable_last_resort):
first_fontpath, *rest = font_filepaths
(first_fontpath, first_fontindex), *rest = font_filepaths
fallback_list = [
ft2font.FT2Font(fpath, hinting_factor, _kerning_factor=_kerning_factor)
for fpath in rest
ft2font.FT2Font(fpath, hinting_factor, face_index=index,
_kerning_factor=_kerning_factor)
for fpath, index in rest
]
last_resort_path = _cached_realpath(
cbook._get_data_path('fonts', 'ttf', 'LastResortHE-Regular.ttf'))
try:
last_resort_index = font_filepaths.index(last_resort_path)
last_resort_index = font_filepaths.index((last_resort_path, 0))
except ValueError:
last_resort_index = -1
# Add Last Resort font so we always have glyphs regardless of font, unless we're
Expand All @@ -1582,7 +1646,7 @@ def _get_font(font_filepaths, hinting_factor, *, _kerning_factor, thread_id,
_warn_if_used=True))
last_resort_index = len(fallback_list)
font = ft2font.FT2Font(
first_fontpath, hinting_factor,
first_fontpath, hinting_factor, face_index=first_fontindex,
_fallback_list=fallback_list,
_kerning_factor=_kerning_factor
)
Expand Down Expand Up @@ -1617,7 +1681,8 @@ def get_font(font_filepaths, hinting_factor=None):

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

If a single string, bytes, or `os.PathLike`, then it will be treated
Expand All @@ -1632,10 +1697,16 @@ def get_font(font_filepaths, hinting_factor=None):
`.ft2font.FT2Font`

"""
if isinstance(font_filepaths, (str, bytes, os.PathLike)):
paths = (_cached_realpath(font_filepaths),)
else:
paths = tuple(_cached_realpath(fname) for fname in font_filepaths)
match font_filepaths:
case FontPath(path, index):
paths = ((_cached_realpath(path), index), )
case str() | bytes() | os.PathLike() as path:
paths = ((_cached_realpath(path), 0), )
case _:
paths = tuple(
(_cached_realpath(fname.path), fname.face_index)
if isinstance(fname, FontPath) else (_cached_realpath(fname), 0)
for fname in font_filepaths)

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

Expand Down
22 changes: 19 additions & 3 deletions lib/matplotlib/font_manager.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ from dataclasses import dataclass
from numbers import Integral
import os
from pathlib import Path
from typing import Any, Literal
from typing import Any, Final, Literal

from matplotlib._afm import AFM
from matplotlib import ft2font
Expand All @@ -26,6 +26,22 @@ def _get_fontconfig_fonts() -> list[Path]: ...
def findSystemFonts(
fontpaths: Iterable[str | os.PathLike] | None = ..., fontext: str = ...
) -> list[str]: ...

class FontPath(str):
__match_args__: Final[tuple[str, ...]]
def __new__(cls: type[str], path: str, face_index: int) -> FontPath: ...
@property
def path(self) -> str: ...
@property
def face_index(self) -> int: ...
def _as_tuple(self) -> tuple[str, int]: ...
def __eq__(self, other: Any) -> bool: ...
def __ne__(self, other: Any) -> bool: ...
def __lt__(self, other: Any) -> bool: ...
def __gt__(self, other: Any) -> bool: ...
def __hash__(self) -> int: ...
def __repr__(self) -> str: ...

@dataclass
class FontEntry:
fname: str = ...
Expand Down Expand Up @@ -116,12 +132,12 @@ class FontManager:
directory: str | None = ...,
fallback_to_default: bool = ...,
rebuild_if_missing: bool = ...,
) -> str: ...
) -> FontPath: ...
def get_font_names(self) -> list[str]: ...

def is_opentype_cff_font(filename: str) -> bool: ...
def get_font(
font_filepaths: Iterable[str | bytes | os.PathLike] | str | bytes | os.PathLike,
font_filepaths: Iterable[str | bytes | os.PathLike | FontPath] | str | bytes | os.PathLike | FontPath,
hinting_factor: int | None = ...,
) -> ft2font.FT2Font: ...

Expand Down
44 changes: 41 additions & 3 deletions lib/matplotlib/tests/test_font_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

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


def test_font_path():
fp = FontPath('foo', 123)
fp2 = FontPath('foo', 321)
assert str(fp) == 'foo'
assert repr(fp) == "FontPath('foo', 123)"
assert fp.path == 'foo'
assert fp.face_index == 123
# Should be immutable.
with pytest.raises(AttributeError, match='has no setter'):
fp.path = 'bar'
with pytest.raises(AttributeError, match='has no setter'):
fp.face_index = 321
# Should be comparable with str and itself.
assert fp == 'foo'
assert fp == FontPath('foo', 123)
assert fp <= fp
assert fp >= fp
assert fp != fp2
assert fp < fp2
assert fp <= fp2
assert fp2 > fp
assert fp2 >= fp
# Should be hashable, but not the same as str.
d = {fp: 1, 'bar': 2}
assert fp in d
assert d[fp] == 1
assert d[FontPath('foo', 123)] == 1
assert fp2 not in d
assert 'foo' not in d
assert FontPath('bar', 0) not in d


def test_font_priority():
with rc_context(rc={
'font.sans-serif':
Expand Down Expand Up @@ -122,8 +154,12 @@ def test_find_ttc():
pytest.skip("Font wqy-zenhei.ttc may be missing")
# All fonts from this collection should have loaded as well.
for name in ["WenQuanYi Zen Hei Mono", "WenQuanYi Zen Hei Sharp"]:
assert findfont(FontProperties(family=[name]),
fallback_to_default=False) == fontpath
subfontpath = findfont(FontProperties(family=[name]), fallback_to_default=False)
assert subfontpath.path == fontpath.path
assert subfontpath.face_index != fontpath.face_index
subfont = get_font(subfontpath)
assert subfont.fname == subfontpath.path
assert subfont.face_index == subfontpath.face_index
fig, ax = plt.subplots()
ax.text(.5, .5, "\N{KANGXI RADICAL DRAGON}", fontproperties=fp)
for fmt in ["raw", "svg", "pdf", "ps"]:
Expand Down Expand Up @@ -161,6 +197,8 @@ def __fspath__(self):
assert font.fname == file_str
font = get_font(PathLikeClass(file_bytes))
assert font.fname == file_bytes
font = get_font(FontPath(file_str, 0))
assert font.fname == file_str

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