Skip to content

Commit d3cc8ce

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. [1] https://github.com/matplotlib/mplcairo/blob/v0.6.1/README.rst#font-formats-and-features
1 parent a1ed4ef commit d3cc8ce

File tree

16 files changed

+177
-19
lines changed

16 files changed

+177
-19
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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. Note though that subranges are not explicitly supported and behaviour may
16+
change in the future.
17+
18+
For example, the default font ``DejaVu Sans`` enables Standard Ligatures (the ``'liga'``
19+
tag) by default, and also provides optional Discretionary Ligatures (the ``dlig`` tag.)
20+
These may be toggled with ``+`` or ``-``.
21+
22+
.. plot::
23+
:include-source:
24+
25+
fig = plt.figure(figsize=(7, 3))
26+
27+
fig.text(0.5, 0.85, 'Ligatures', fontsize=40, horizontalalignment='center')
28+
29+
# Default has Standard Ligatures (liga).
30+
fig.text(0, 0.6, 'Default: fi ffi fl st', fontsize=40)
31+
32+
# Disable Standard Ligatures with -liga.
33+
fig.text(0, 0.35, 'Disabled: fi ffi fl st', fontsize=40,
34+
fontfeatures=['-liga'])
35+
36+
# Enable Discretionary Ligatures with dlig.
37+
fig.text(0, 0.1, 'Discretionary: fi ffi fl st', fontsize=40,
38+
fontfeatures=['dlig'])
39+
40+
Available font feature tags may be found at
41+
https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist

lib/matplotlib/_text_helpers.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def warn_on_missing_glyph(codepoint, fontnames):
2626
f"missing from font(s) {fontnames}.")
2727

2828

29-
def layout(string, font, *, kern_mode=Kerning.DEFAULT, language=None):
29+
def layout(string, font, *, features=None, kern_mode=Kerning.DEFAULT, language=None):
3030
"""
3131
Render *string* with *font*.
3232
@@ -39,6 +39,8 @@ def layout(string, font, *, kern_mode=Kerning.DEFAULT, language=None):
3939
The string to be rendered.
4040
font : FT2Font
4141
The font.
42+
features : tuple of str, optional
43+
The font features to apply to the text.
4244
kern_mode : Kerning
4345
A FreeType kerning mode.
4446
language : str, optional
@@ -51,7 +53,7 @@ def layout(string, font, *, kern_mode=Kerning.DEFAULT, language=None):
5153
"""
5254
x = 0
5355
prev_glyph_index = None
54-
char_to_font = font._get_fontmap(string) # TODO: Pass in language.
56+
char_to_font = font._get_fontmap(string) # TODO: Pass in features and language.
5557
base_font = font
5658
for char in string:
5759
# This has done the fallback logic

lib/matplotlib/backends/backend_agg.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
191191
# We pass '0' for angle here, since it will be rotated (in raster
192192
# space) in the following call to draw_text_image).
193193
font.set_text(s, 0, flags=get_hinting_flag(),
194+
features=mtext.get_fontfeatures() if mtext is not None else None,
194195
language=mtext.get_language() if mtext is not None else None)
195196
font.draw_glyphs_to_bitmap(
196197
antialiased=gc.get_antialiased())

lib/matplotlib/backends/backend_pdf.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2264,7 +2264,11 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
22642264
return self.draw_mathtext(gc, x, y, s, prop, angle)
22652265

22662266
fontsize = prop.get_size_in_points()
2267-
language = mtext.get_language() if mtext is not None else None
2267+
if mtext is not None:
2268+
features = mtext.get_fontfeatures()
2269+
language = mtext.get_language()
2270+
else:
2271+
features = language = None
22682272

22692273
if mpl.rcParams['pdf.use14corefonts']:
22702274
font = self._get_font_afm(prop)
@@ -2274,7 +2278,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
22742278
fonttype = mpl.rcParams['pdf.fonttype']
22752279

22762280
if gc.get_url() is not None:
2277-
font.set_text(s, language=language)
2281+
font.set_text(s, features=features, language=language)
22782282
width, height = font.get_width_height()
22792283
self.file._annotations[-1][1].append(_get_link_annotation(
22802284
gc, x, y, width / 64, height / 64, angle))
@@ -2322,7 +2326,8 @@ def output_singlebyte_chunk(kerns_or_chars):
23222326
prev_start_x = 0
23232327
# Emit all the characters in a BT/ET group.
23242328
self.file.output(Op.begin_text)
2325-
for item in _text_helpers.layout(s, font, kern_mode=Kerning.UNFITTED,
2329+
for item in _text_helpers.layout(s, font, features=features,
2330+
kern_mode=Kerning.UNFITTED,
23262331
language=language):
23272332
subset, charcode = self.file._character_tracker.track_glyph(
23282333
item.ft_object, ord(item.char), item.glyph_index)

lib/matplotlib/backends/backend_ps.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -791,10 +791,15 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
791791
thisx += width * scale
792792

793793
else:
794-
language = mtext.get_language() if mtext is not None else None
794+
if mtext is not None:
795+
features = mtext.get_fontfeatures()
796+
language = mtext.get_language()
797+
else:
798+
features = language = None
795799
font = self._get_font_ttf(prop)
796800
self._character_tracker.track(font, s)
797-
for item in _text_helpers.layout(s, font, language=language):
801+
for item in _text_helpers.layout(s, font, features=features,
802+
language=language):
798803
ps_name = (item.ft_object.postscript_name
799804
.encode("ascii", "replace").decode("ascii"))
800805
glyph_name = item.ft_object.get_glyph_name(item.glyph_index)

lib/matplotlib/font_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -540,7 +540,7 @@ def afmFontProperty(fontpath, font):
540540

541541
def _cleanup_fontproperties_init(init_method):
542542
"""
543-
A decorator to limit the call signature to single a positional argument
543+
A decorator to limit the call signature to a single positional argument
544544
or alternatively only keyword arguments.
545545
546546
We still accept but deprecate all other call signatures.

lib/matplotlib/ft2font.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ class FT2Font(Buffer):
249249
angle: float = ...,
250250
flags: LoadFlags = ...,
251251
*,
252+
features: tuple[str] | None = ...,
252253
language: str | list[tuple[str, int, int]] | None = ...,
253254
) -> NDArray[np.float64]: ...
254255
@property

lib/matplotlib/tests/test_ft2font.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,19 @@ def test_ft2font_set_size():
229229
assert font.get_width_height() == tuple(pytest.approx(2 * x, 1e-1) for x in orig)
230230

231231

232+
def test_ft2font_features():
233+
# Smoke test that these are accepted as intended.
234+
file = fm.findfont('DejaVu Sans')
235+
font = ft2font.FT2Font(file)
236+
font.set_text('foo', features=None) # unset
237+
font.set_text('foo', features=['calt', 'dlig']) # list
238+
font.set_text('foo', features=('calt', 'dlig')) # tuple
239+
with pytest.raises(TypeError):
240+
font.set_text('foo', features=123)
241+
with pytest.raises(TypeError):
242+
font.set_text('foo', features=[123, 456])
243+
244+
232245
def test_ft2font_charmaps():
233246
def enc(name):
234247
# We don't expose the encoding enum from FreeType, but can generate it here.

lib/matplotlib/tests/test_text.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,6 +1204,22 @@ def test_ytick_rotation_mode():
12041204
plt.subplots_adjust(left=0.4, right=0.6, top=.99, bottom=.01)
12051205

12061206

1207+
@image_comparison(baseline_images=['features.png'], remove_text=False, style='mpl20')
1208+
def test_text_features():
1209+
fig = plt.figure(figsize=(5, 1.5))
1210+
t = fig.text(1, 0.7, 'Default: fi ffi fl st',
1211+
fontsize=32, horizontalalignment='right')
1212+
assert t.get_fontfeatures() is None
1213+
t = fig.text(1, 0.4, 'Disabled: fi ffi fl st',
1214+
fontsize=32, horizontalalignment='right',
1215+
fontfeatures=['-liga'])
1216+
assert t.get_fontfeatures() == ('-liga', )
1217+
t = fig.text(1, 0.1, 'Discretionary: fi ffi fl st',
1218+
fontsize=32, horizontalalignment='right')
1219+
t.set_fontfeatures(['dlig'])
1220+
assert t.get_fontfeatures() == ('dlig', )
1221+
1222+
12071223
@pytest.mark.parametrize(
12081224
'input, match',
12091225
[

lib/matplotlib/text.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ def __init__(self,
137137
super().__init__()
138138
self._x, self._y = x, y
139139
self._text = ''
140+
self._features = None
140141
self.set_language(None)
141142
self._reset_visual_defaults(
142143
text=text,
@@ -849,6 +850,12 @@ def get_fontfamily(self):
849850
"""
850851
return self._fontproperties.get_family()
851852

853+
def get_fontfeatures(self):
854+
"""
855+
Return a tuple of font feature tags to enable.
856+
"""
857+
return self._features
858+
852859
def get_fontname(self):
853860
"""
854861
Return the font name as a string.
@@ -1096,6 +1103,39 @@ def set_fontfamily(self, fontname):
10961103
self._fontproperties.set_family(fontname)
10971104
self.stale = True
10981105

1106+
def set_fontfeatures(self, features):
1107+
"""
1108+
Set the feature tags to enable on the font.
1109+
1110+
Parameters
1111+
----------
1112+
features : list of str, or tuple of str, or None
1113+
A list of feature tags to be used with the associated font. These strings
1114+
are eventually passed to HarfBuzz, and so all `string formats supported by
1115+
hb_feature_from_string()
1116+
<https://harfbuzz.github.io/harfbuzz-hb-common.html#hb-feature-from-string>`__
1117+
are supported. Note though that subranges are not explicitly supported and
1118+
behaviour may change in the future.
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+
_api.check_isinstance((Sequence, None), features=features)
1134+
if features is not None:
1135+
features = tuple(features)
1136+
self._features = features
1137+
self.stale = True
1138+
10991139
def set_fontvariant(self, variant):
11001140
"""
11011141
Set the font variant.

0 commit comments

Comments
 (0)