Skip to content

Commit 7901fee

Browse files
committed
Add font feature API to FontProperties and Text
Font features allow font designers to provide alternate glyphs or shaping within a single font. These features may be accessed via special tags corresponding to internal tables of glyphs. The mplcairo backend supports font features via an elaborate re-use of the font file path [1]. This commit adds the API to make this officially supported in the main user API. At this time, nothing in Matplotlib itself uses these settings, but they will have an effect with libraqm. [1] https://github.com/matplotlib/mplcairo/blob/v0.6.1/README.rst#font-formats-and-features
1 parent f017a0e commit 7901fee

13 files changed

+189
-3
lines changed

Diff for: doc/users/next_whats_new/font_features.rst

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
Specifying font feature tags
2+
----------------------------
3+
4+
OpenType fonts may support feature tags that specify alternate glyph shapes or
5+
substitutions to be made optionally. The text API now supports setting a list of feature
6+
tags to be used with the associated font. Feature tags can be set/get with:
7+
8+
- `matplotlib.text.Text.set_fontfeatures` / `matplotlib.text.Text.get_fontfeatures`
9+
- `matplotlib.font_manager.FontProperties.set_features` /
10+
`matplotlib.font_manager.FontProperties.get_features`
11+
- Any API that creates a `.Text` object by passing the *fontfeatures* argument (e.g.,
12+
``plt.xlabel(..., fontfeatures=...)``)
13+
14+
Font feature strings are eventually passed to HarfBuzz, and so all `string formats
15+
supported by hb_feature_from_string()
16+
<https://harfbuzz.github.io/harfbuzz-hb-common.html#hb-feature-from-string>`__ are
17+
supported.
18+
19+
For example, the default font ``DejaVu Sans`` enables Standard Ligatures (the ``'liga'``
20+
tag) by default, and also provides optional Discretionary Ligatures (the ``dlig`` tag.)
21+
These may be toggled with ``+`` or ``-``.
22+
23+
.. plot::
24+
:include-source:
25+
26+
fig = plt.figure(figsize=(7, 3))
27+
28+
fig.text(0.5, 0.85, 'Ligatures', fontsize=40, horizontalalignment='center')
29+
30+
# Default has Standard Ligatures (liga).
31+
fig.text(0, 0.6, 'Default: fi ffi fl st', fontsize=40)
32+
33+
# Disable Standard Ligatures with -liga.
34+
fig.text(0, 0.35, 'Disabled: fi ffi fl st', fontsize=40,
35+
fontfeatures=['-liga'])
36+
37+
# Enable Discretionary Ligatures with dlig.
38+
fig.text(0, 0.1, 'Discretionary: fi ffi fl st', fontsize=40,
39+
fontfeatures=['dlig'])
40+
41+
Available font feature tags may be found at
42+
https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist

Diff for: lib/matplotlib/backends/_backend_pdf_ps.py

+1
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ def _get_font_ttf(self, prop):
181181
font = font_manager.get_font(fnames)
182182
font.clear()
183183
font.set_size(prop.get_size_in_points(), 72)
184+
font.set_features(prop.get_features())
184185
return font
185186
except RuntimeError:
186187
logging.getLogger(__name__).warning(

Diff for: lib/matplotlib/backends/backend_agg.py

+1
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ def _prepare_font(self, font_prop):
253253
font.clear()
254254
size = font_prop.get_size_in_points()
255255
font.set_size(size, self.dpi)
256+
font.set_features(font_prop.get_features())
256257
return font
257258

258259
def points_to_pixels(self, points):

Diff for: lib/matplotlib/font_manager.py

+48-3
Original file line numberDiff line numberDiff line change
@@ -536,7 +536,7 @@ def afmFontProperty(fontpath, font):
536536

537537
def _cleanup_fontproperties_init(init_method):
538538
"""
539-
A decorator to limit the call signature to single a positional argument
539+
A decorator to limit the call signature to a single positional argument
540540
or alternatively only keyword arguments.
541541
542542
We still accept but deprecate all other call signatures.
@@ -624,6 +624,13 @@ class FontProperties:
624624
Supported values are: 'dejavusans', 'dejavuserif', 'cm',
625625
'stix', 'stixsans' and 'custom'. Default: :rc:`mathtext.fontset`
626626
627+
- features: A list of advanced font feature tags to enable. Font features are a
628+
component of OpenType fonts that allows picking from multiple stylistic variations
629+
within a single font. This may include small caps, ligatures, alternate forms of
630+
commonly-confused glyphs (e.g., capital I vs. lower-case l), and various other
631+
options. A `list of feature tags may be found here
632+
<https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist>`__.
633+
627634
Alternatively, a font may be specified using the absolute path to a font
628635
file, by using the *fname* kwarg. However, in this case, it is typically
629636
simpler to just pass the path (as a `pathlib.Path`, not a `str`) to the
@@ -657,7 +664,8 @@ class FontProperties:
657664
def __init__(self, family=None, style=None, variant=None, weight=None,
658665
stretch=None, size=None,
659666
fname=None, # if set, it's a hardcoded filename to use
660-
math_fontfamily=None):
667+
math_fontfamily=None,
668+
features=None):
661669
self.set_family(family)
662670
self.set_style(style)
663671
self.set_variant(variant)
@@ -666,6 +674,7 @@ def __init__(self, family=None, style=None, variant=None, weight=None,
666674
self.set_file(fname)
667675
self.set_size(size)
668676
self.set_math_fontfamily(math_fontfamily)
677+
self.set_features(features)
669678
# Treat family as a fontconfig pattern if it is the only parameter
670679
# provided. Even in that case, call the other setters first to set
671680
# attributes not specified by the pattern to the rcParams defaults.
@@ -705,7 +714,8 @@ def __hash__(self):
705714
self.get_stretch(),
706715
self.get_size(),
707716
self.get_file(),
708-
self.get_math_fontfamily())
717+
self.get_math_fontfamily(),
718+
self.get_features())
709719
return hash(l)
710720

711721
def __eq__(self, other):
@@ -952,6 +962,41 @@ def set_math_fontfamily(self, fontfamily):
952962
_api.check_in_list(valid_fonts, math_fontfamily=fontfamily)
953963
self._math_fontfamily = fontfamily
954964

965+
def get_features(self):
966+
"""Return a tuple of font feature tags to enable."""
967+
return self._features
968+
969+
def set_features(self, features):
970+
"""
971+
Set the font feature tags to enable on this font.
972+
973+
Parameters
974+
----------
975+
features : list[str]
976+
A list of feature tags to be used with the associated font. These strings
977+
are eventually passed to HarfBuzz, and so all `string formats supported by
978+
hb_feature_from_string()
979+
<https://harfbuzz.github.io/harfbuzz-hb-common.html#hb-feature-from-string>`__
980+
are supported.
981+
982+
For example, if your desired font includes Stylistic Sets which enable
983+
various typographic alternates including one that you do not wish to use
984+
(e.g., Contextual Ligatures), then you can pass the following to enable one
985+
and not the other::
986+
987+
fp.set_features([
988+
'ss01', # Use Stylistic Set 1.
989+
'-clig', # But disable Contextural Ligatures.
990+
])
991+
992+
Available font feature tags may be found at
993+
https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist
994+
"""
995+
_api.check_isinstance((list, tuple, None), features=features)
996+
if features is not None:
997+
features = tuple(features)
998+
self._features = features
999+
9551000
def copy(self):
9561001
"""Return a copy of self."""
9571002
return copy.copy(self)

Diff for: lib/matplotlib/font_manager.pyi

+3
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class FontProperties:
5252
size: float | str | None = ...,
5353
fname: str | os.PathLike | Path | None = ...,
5454
math_fontfamily: str | None = ...,
55+
features: list[str] | None = ...,
5556
) -> None: ...
5657
def __hash__(self) -> int: ...
5758
def __eq__(self, other: object) -> bool: ...
@@ -76,6 +77,8 @@ class FontProperties:
7677
def set_fontconfig_pattern(self, pattern: str) -> None: ...
7778
def get_math_fontfamily(self) -> str: ...
7879
def set_math_fontfamily(self, fontfamily: str | None) -> None: ...
80+
def get_features(self) -> tuple[str, ...]: ...
81+
def set_features(self, features: list[str] | tuple[str, ...] | None) -> None: ...
7982
def copy(self) -> FontProperties: ...
8083
# Aliases
8184
set_name = set_family

Diff for: lib/matplotlib/ft2font.pyi

+1
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ class FT2Font(Buffer):
234234
def load_glyph(self, glyphindex: int, flags: LoadFlags = ...) -> Glyph: ...
235235
def select_charmap(self, i: int) -> None: ...
236236
def set_charmap(self, i: int) -> None: ...
237+
def set_features(self, features: tuple[str, ...]) -> None: ...
237238
def set_size(self, ptsize: float, dpi: float) -> None: ...
238239
def set_text(
239240
self, string: str, angle: float = ..., flags: LoadFlags = ...

Diff for: lib/matplotlib/tests/test_ft2font.py

+13
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,19 @@ def test_ft2font_set_size():
198198
assert font.get_width_height() == tuple(pytest.approx(2 * x, 1e-1) for x in orig)
199199

200200

201+
def test_ft2font_features():
202+
# Smoke test that these are accepted as intended.
203+
file = fm.findfont('DejaVu Sans')
204+
font = ft2font.FT2Font(file)
205+
font.set_features(None) # unset
206+
font.set_features(['calt', 'dlig']) # list
207+
font.set_features(('calt', 'dlig')) # tuple
208+
with pytest.raises(TypeError):
209+
font.set_features(123)
210+
with pytest.raises(TypeError):
211+
font.set_features([123, 456])
212+
213+
201214
def test_ft2font_charmaps():
202215
def enc(name):
203216
# We don't expose the encoding enum from FreeType, but can generate it here.

Diff for: lib/matplotlib/text.py

+43
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,16 @@ def get_fontfamily(self):
847847
"""
848848
return self._fontproperties.get_family()
849849

850+
def get_fontfeatures(self):
851+
"""
852+
Return a tuple of font feature tags to enable.
853+
854+
See Also
855+
--------
856+
.font_manager.FontProperties.get_features
857+
"""
858+
return self._fontproperties.get_features()
859+
850860
def get_fontname(self):
851861
"""
852862
Return the font name as a string.
@@ -1094,6 +1104,39 @@ def set_fontfamily(self, fontname):
10941104
self._fontproperties.set_family(fontname)
10951105
self.stale = True
10961106

1107+
def set_fontfeatures(self, features):
1108+
"""
1109+
Set the feature tags to enable on the font.
1110+
1111+
Parameters
1112+
----------
1113+
features : list[str]
1114+
A list of feature tags to be used with the associated font. These strings
1115+
are eventually passed to HarfBuzz, and so all `string formats supported by
1116+
hb_feature_from_string()
1117+
<https://harfbuzz.github.io/harfbuzz-hb-common.html#hb-feature-from-string>`__
1118+
are supported.
1119+
1120+
For example, if your desired font includes Stylistic Sets which enable
1121+
various typographic alternates including one that you do not wish to use
1122+
(e.g., Contextual Ligatures), then you can pass the following to enable one
1123+
and not the other::
1124+
1125+
fp.set_features([
1126+
'ss01', # Use Stylistic Set 1.
1127+
'-clig', # But disable Contextural Ligatures.
1128+
])
1129+
1130+
Available font feature tags may be found at
1131+
https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist
1132+
1133+
See Also
1134+
--------
1135+
.font_manager.FontProperties.set_features
1136+
"""
1137+
self._fontproperties.set_features(features)
1138+
self.stale = True
1139+
10971140
def set_fontvariant(self, variant):
10981141
"""
10991142
Set the font variant.

Diff for: lib/matplotlib/text.pyi

+2
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class Text(Artist):
5656
def get_color(self) -> ColorType: ...
5757
def get_fontproperties(self) -> FontProperties: ...
5858
def get_fontfamily(self) -> list[str]: ...
59+
def get_fontfeatures(self) -> tuple[str, ...] | None: ...
5960
def get_fontname(self) -> str: ...
6061
def get_fontstyle(self) -> Literal["normal", "italic", "oblique"]: ...
6162
def get_fontsize(self) -> float | str: ...
@@ -80,6 +81,7 @@ class Text(Artist):
8081
def set_multialignment(self, align: Literal["left", "center", "right"]) -> None: ...
8182
def set_linespacing(self, spacing: float) -> None: ...
8283
def set_fontfamily(self, fontname: str | Iterable[str]) -> None: ...
84+
def set_fontfeatures(self, features: list[str] | tuple[str, ...] | None) -> None: ...
8385
def set_fontvariant(self, variant: Literal["normal", "small-caps"]) -> None: ...
8486
def set_fontstyle(
8587
self, fontstyle: Literal["normal", "italic", "oblique"]

Diff for: lib/matplotlib/textpath.py

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def _get_font(self, prop):
3434
filenames = _fontManager._find_fonts_by_props(prop)
3535
font = get_font(filenames)
3636
font.set_size(self.FONT_SCALE, self.DPI)
37+
font.set_features(prop.get_features())
3738
return font
3839

3940
def _get_hinting_flag(self):

Diff for: src/ft2font.cpp

+5
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,11 @@ void FT2Font::set_size(double ptsize, double dpi)
336336
}
337337
}
338338

339+
void FT2Font::set_features(std::vector<std::string> features)
340+
{
341+
feature_tags = std::move(features);
342+
}
343+
339344
void FT2Font::set_charmap(int i)
340345
{
341346
if (i >= face->num_charmaps) {

Diff for: src/ft2font.h

+2
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ class FT2Font
7575
virtual ~FT2Font();
7676
void clear();
7777
void set_size(double ptsize, double dpi);
78+
void set_features(std::vector<std::string> features);
7879
void set_charmap(int i);
7980
void select_charmap(unsigned long i);
8081
void set_text(std::u32string_view codepoints, double angle, FT_Int32 flags,
@@ -150,6 +151,7 @@ class FT2Font
150151
FT_Pos advance;
151152
long hinting_factor;
152153
int kerning_factor;
154+
std::vector<std::string> feature_tags;
153155

154156
// prevent copying
155157
FT2Font(const FT2Font &);

Diff for: src/ft2font_wrapper.cpp

+27
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,31 @@ PyFT2Font_set_size(PyFT2Font *self, double ptsize, double dpi)
534534
self->x->set_size(ptsize, dpi);
535535
}
536536

537+
const char *PyFT2Font_set_features__doc__ = R"""(
538+
Set the font feature tags used for the font.
539+
540+
Parameters
541+
----------
542+
features : tuple[str, ...]
543+
The font feature tags to use for the font.
544+
545+
Available font feature tags may be found at
546+
https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist
547+
)""";
548+
549+
static void
550+
PyFT2Font_set_features(PyFT2Font *self, py::object features_obj)
551+
{
552+
std::vector<std::string> features;
553+
if (!features_obj.is_none()) {
554+
auto features_list = py::cast<py::tuple>(features_obj);
555+
for (auto &feature : features_list) {
556+
features.push_back(feature.cast<std::string>());
557+
}
558+
}
559+
self->x->set_features(std::move(features));
560+
}
561+
537562
const char *PyFT2Font_set_charmap__doc__ = R"""(
538563
Make the i-th charmap current.
539564
@@ -1744,6 +1769,8 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used())
17441769
.def("clear", &PyFT2Font_clear, PyFT2Font_clear__doc__)
17451770
.def("set_size", &PyFT2Font_set_size, "ptsize"_a, "dpi"_a,
17461771
PyFT2Font_set_size__doc__)
1772+
.def("set_features", &PyFT2Font_set_features, "features"_a,
1773+
PyFT2Font_set_features__doc__)
17471774
.def("set_charmap", &PyFT2Font_set_charmap, "i"_a,
17481775
PyFT2Font_set_charmap__doc__)
17491776
.def("select_charmap", &PyFT2Font_select_charmap, "i"_a,

0 commit comments

Comments
 (0)