Skip to content

Commit 0bcb662

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 b4cb934 commit 0bcb662

15 files changed

+137
-16
lines changed
+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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+
- Any API that creates a `.Text` object by passing the *fontfeatures* argument (e.g.,
10+
``plt.xlabel(..., fontfeatures=...)``)
11+
12+
Font feature strings are eventually passed to HarfBuzz, and so all `string formats
13+
supported by hb_feature_from_string()
14+
<https://harfbuzz.github.io/harfbuzz-hb-common.html#hb-feature-from-string>`__ are
15+
supported.
16+
17+
For example, the default font ``DejaVu Sans`` enables Standard Ligatures (the ``'liga'``
18+
tag) by default, and also provides optional Discretionary Ligatures (the ``dlig`` tag.)
19+
These may be toggled with ``+`` or ``-``.
20+
21+
.. plot::
22+
:include-source:
23+
24+
fig = plt.figure(figsize=(7, 3))
25+
26+
fig.text(0.5, 0.85, 'Ligatures', fontsize=40, horizontalalignment='center')
27+
28+
# Default has Standard Ligatures (liga).
29+
fig.text(0, 0.6, 'Default: fi ffi fl st', fontsize=40)
30+
31+
# Disable Standard Ligatures with -liga.
32+
fig.text(0, 0.35, 'Disabled: fi ffi fl st', fontsize=40,
33+
fontfeatures=['-liga'])
34+
35+
# Enable Discretionary Ligatures with dlig.
36+
fig.text(0, 0.1, 'Discretionary: fi ffi fl st', fontsize=40,
37+
fontfeatures=['dlig'])
38+
39+
Available font feature tags may be found at
40+
https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist

lib/matplotlib/_text_helpers.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def warn_on_missing_glyph(codepoint, fontnames):
4343
f"Matplotlib currently does not support {block} natively.")
4444

4545

46-
def layout(string, font, *, kern_mode=Kerning.DEFAULT):
46+
def layout(string, font, *, features=None, kern_mode=Kerning.DEFAULT):
4747
"""
4848
Render *string* with *font*.
4949
@@ -56,6 +56,8 @@ def layout(string, font, *, kern_mode=Kerning.DEFAULT):
5656
The string to be rendered.
5757
font : FT2Font
5858
The font.
59+
features : tuple of str, optional
60+
The font features to apply to the text.
5961
kern_mode : Kerning
6062
A FreeType kerning mode.
6163
@@ -65,7 +67,7 @@ def layout(string, font, *, kern_mode=Kerning.DEFAULT):
6567
"""
6668
x = 0
6769
prev_glyph_idx = None
68-
char_to_font = font._get_fontmap(string)
70+
char_to_font = font._get_fontmap(string) # TODO: Pass in features.
6971
base_font = font
7072
for char in string:
7173
# This has done the fallback logic

lib/matplotlib/backends/backend_agg.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,8 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
189189
font = self._prepare_font(prop)
190190
# We pass '0' for angle here, since it will be rotated (in raster
191191
# space) in the following call to draw_text_image).
192-
font.set_text(s, 0, flags=get_hinting_flag())
192+
font.set_text(s, 0, flags=get_hinting_flag(),
193+
features=mtext.get_fontfeatures() if mtext is not None else None)
193194
font.draw_glyphs_to_bitmap(
194195
antialiased=gc.get_antialiased())
195196
d = font.get_descent() / 64.0

lib/matplotlib/backends/backend_pdf.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -2345,6 +2345,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
23452345
return self.draw_mathtext(gc, x, y, s, prop, angle)
23462346

23472347
fontsize = prop.get_size_in_points()
2348+
features = mtext.get_fontfeatures() if mtext is not None else None
23482349

23492350
if mpl.rcParams['pdf.use14corefonts']:
23502351
font = self._get_font_afm(prop)
@@ -2355,7 +2356,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
23552356
fonttype = mpl.rcParams['pdf.fonttype']
23562357

23572358
if gc.get_url() is not None:
2358-
font.set_text(s)
2359+
font.set_text(s, features=features)
23592360
width, height = font.get_width_height()
23602361
self.file._annotations[-1][1].append(_get_link_annotation(
23612362
gc, x, y, width / 64, height / 64, angle))
@@ -2389,7 +2390,8 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
23892390
multibyte_glyphs = []
23902391
prev_was_multibyte = True
23912392
prev_font = font
2392-
for item in _text_helpers.layout(s, font, kern_mode=Kerning.UNFITTED):
2393+
for item in _text_helpers.layout(s, font, features=features,
2394+
kern_mode=Kerning.UNFITTED):
23932395
if _font_supports_glyph(fonttype, ord(item.char)):
23942396
if prev_was_multibyte or item.ft_object != prev_font:
23952397
singlebyte_chunks.append((item.ft_object, item.x, []))

lib/matplotlib/backends/backend_ps.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -795,9 +795,10 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
795795
thisx += width * scale
796796

797797
else:
798+
features = mtext.get_fontfeatures() if mtext is not None else None
798799
font = self._get_font_ttf(prop)
799800
self._character_tracker.track(font, s)
800-
for item in _text_helpers.layout(s, font):
801+
for item in _text_helpers.layout(s, font, features=features):
801802
ps_name = (item.ft_object.postscript_name
802803
.encode("ascii", "replace").decode("ascii"))
803804
glyph_name = item.ft_object.get_glyph_name(item.glyph_idx)

lib/matplotlib/font_manager.py

+1-1
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.

lib/matplotlib/ft2font.pyi

+5-1
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,11 @@ class FT2Font(Buffer):
236236
def set_charmap(self, i: int) -> None: ...
237237
def set_size(self, ptsize: float, dpi: float) -> None: ...
238238
def set_text(
239-
self, string: str, angle: float = ..., flags: LoadFlags = ...
239+
self,
240+
string: str,
241+
angle: float = ...,
242+
flags: LoadFlags = ...,
243+
features: tuple[str] | None = ...,
240244
) -> NDArray[np.float64]: ...
241245
@property
242246
def ascender(self) -> int: ...

lib/matplotlib/tests/test_ft2font.py

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

201201

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

lib/matplotlib/text.py

+39
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ def __init__(self,
136136
super().__init__()
137137
self._x, self._y = x, y
138138
self._text = ''
139+
self._features = None
139140
self._reset_visual_defaults(
140141
text=text,
141142
color=color,
@@ -847,6 +848,12 @@ def get_fontfamily(self):
847848
"""
848849
return self._fontproperties.get_family()
849850

851+
def get_fontfeatures(self):
852+
"""
853+
Return a tuple of font feature tags to enable.
854+
"""
855+
return self._features
856+
850857
def get_fontname(self):
851858
"""
852859
Return the font name as a string.
@@ -1094,6 +1101,38 @@ def set_fontfamily(self, fontname):
10941101
self._fontproperties.set_family(fontname)
10951102
self.stale = True
10961103

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

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"]

lib/matplotlib/textpath.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def get_text_width_height_descent(self, s, prop, ismath):
6969
d /= 64.0
7070
return w * scale, h * scale, d * scale
7171

72-
def get_text_path(self, prop, s, ismath=False):
72+
def get_text_path(self, prop, s, ismath=False, features=None):
7373
"""
7474
Convert text *s* to path (a tuple of vertices and codes for
7575
matplotlib.path.Path).
@@ -109,7 +109,8 @@ def get_text_path(self, prop, s, ismath=False):
109109
glyph_info, glyph_map, rects = self.get_glyphs_tex(prop, s)
110110
elif not ismath:
111111
font = self._get_font(prop)
112-
glyph_info, glyph_map, rects = self.get_glyphs_with_font(font, s)
112+
glyph_info, glyph_map, rects = self.get_glyphs_with_font(
113+
font, s, features=features)
113114
else:
114115
glyph_info, glyph_map, rects = self.get_glyphs_mathtext(prop, s)
115116

@@ -130,7 +131,7 @@ def get_text_path(self, prop, s, ismath=False):
130131
return verts, codes
131132

132133
def get_glyphs_with_font(self, font, s, glyph_map=None,
133-
return_new_glyphs_only=False):
134+
return_new_glyphs_only=False, features=None):
134135
"""
135136
Convert string *s* to vertices and codes using the provided ttf font.
136137
"""
@@ -145,7 +146,7 @@ def get_glyphs_with_font(self, font, s, glyph_map=None,
145146

146147
xpositions = []
147148
glyph_ids = []
148-
for item in _text_helpers.layout(s, font):
149+
for item in _text_helpers.layout(s, font, features=features):
149150
char_id = self._get_char_id(item.ft_object, ord(item.char))
150151
glyph_ids.append(char_id)
151152
xpositions.append(item.x)

lib/matplotlib/textpath.pyi

+6-1
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,19 @@ class TextToPath:
1616
self, s: str, prop: FontProperties, ismath: bool | Literal["TeX"]
1717
) -> tuple[float, float, float]: ...
1818
def get_text_path(
19-
self, prop: FontProperties, s: str, ismath: bool | Literal["TeX"] = ...
19+
self,
20+
prop: FontProperties,
21+
s: str,
22+
ismath: bool | Literal["TeX"] = ...,
23+
features: tuple[str] | None = ...,
2024
) -> list[np.ndarray]: ...
2125
def get_glyphs_with_font(
2226
self,
2327
font: FT2Font,
2428
s: str,
2529
glyph_map: dict[str, tuple[np.ndarray, np.ndarray]] | None = ...,
2630
return_new_glyphs_only: bool = ...,
31+
features: tuple[str] | None = ...,
2732
) -> tuple[
2833
list[tuple[str, float, float, float]],
2934
dict[str, tuple[np.ndarray, np.ndarray]],

src/ft2font.cpp

+2-1
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,8 @@ void FT2Font::set_kerning_factor(int factor)
397397
}
398398

399399
void FT2Font::set_text(
400-
std::u32string_view text, double angle, FT_Int32 flags, std::vector<double> &xys)
400+
std::u32string_view text, double angle, FT_Int32 flags,
401+
std::optional<std::vector<std::string>> features, std::vector<double> &xys)
401402
{
402403
FT_Matrix matrix; /* transformation matrix */
403404

src/ft2font.h

+3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#ifndef MPL_FT2FONT_H
77
#define MPL_FT2FONT_H
88

9+
#include <optional>
910
#include <set>
1011
#include <string>
1112
#include <string_view>
@@ -79,6 +80,7 @@ class FT2Font
7980
void set_charmap(int i);
8081
void select_charmap(unsigned long i);
8182
void set_text(std::u32string_view codepoints, double angle, FT_Int32 flags,
83+
std::optional<std::vector<std::string>> features,
8284
std::vector<double> &xys);
8385
int get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode, bool fallback);
8486
int get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode, FT_Vector &delta);
@@ -152,6 +154,7 @@ class FT2Font
152154
FT_Pos advance;
153155
long hinting_factor;
154156
int kerning_factor;
157+
std::vector<std::string> feature_tags;
155158

156159
// prevent copying
157160
FT2Font(const FT2Font &);

src/ft2font_wrapper.cpp

+9-2
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,11 @@ const char *PyFT2Font_set_text__doc__ = R"""(
703703
704704
.. versionchanged:: 3.10
705705
This now takes an `.ft2font.LoadFlags` instead of an int.
706+
features : tuple[str, ...]
707+
The font feature tags to use for the font.
708+
709+
Available font feature tags may be found at
710+
https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist
706711
707712
Returns
708713
-------
@@ -712,7 +717,8 @@ const char *PyFT2Font_set_text__doc__ = R"""(
712717

713718
static py::array_t<double>
714719
PyFT2Font_set_text(PyFT2Font *self, std::u32string_view text, double angle = 0.0,
715-
std::variant<LoadFlags, FT_Int32> flags_or_int = LoadFlags::FORCE_AUTOHINT)
720+
std::variant<LoadFlags, FT_Int32> flags_or_int = LoadFlags::FORCE_AUTOHINT,
721+
std::optional<std::vector<std::string>> features = std::nullopt)
716722
{
717723
std::vector<double> xys;
718724
LoadFlags flags;
@@ -732,7 +738,7 @@ PyFT2Font_set_text(PyFT2Font *self, std::u32string_view text, double angle = 0.0
732738
throw py::type_error("flags must be LoadFlags or int");
733739
}
734740

735-
self->x->set_text(text, angle, static_cast<FT_Int32>(flags), xys);
741+
self->x->set_text(text, angle, static_cast<FT_Int32>(flags), features, xys);
736742

737743
py::ssize_t dims[] = { static_cast<py::ssize_t>(xys.size()) / 2, 2 };
738744
py::array_t<double> result(dims);
@@ -1622,6 +1628,7 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used())
16221628
PyFT2Font_get_kerning__doc__)
16231629
.def("set_text", &PyFT2Font_set_text,
16241630
"string"_a, "angle"_a=0.0, "flags"_a=LoadFlags::FORCE_AUTOHINT,
1631+
"features"_a=nullptr,
16251632
PyFT2Font_set_text__doc__)
16261633
.def("_get_fontmap", &PyFT2Font_get_fontmap, "string"_a,
16271634
PyFT2Font_get_fontmap__doc__)

0 commit comments

Comments
 (0)