Skip to content

Commit b04a2f1

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 1858a56 commit b04a2f1

File tree

17 files changed

+170
-16
lines changed

17 files changed

+170
-16
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_idx = 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
@@ -2345,7 +2345,11 @@ 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-
language = mtext.get_language() if mtext is not None else None
2348+
if mtext is not None:
2349+
features = mtext.get_fontfeatures()
2350+
language = mtext.get_language()
2351+
else:
2352+
features = language = None
23492353

23502354
if mpl.rcParams['pdf.use14corefonts']:
23512355
font = self._get_font_afm(prop)
@@ -2356,7 +2360,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
23562360
fonttype = mpl.rcParams['pdf.fonttype']
23572361

23582362
if gc.get_url() is not None:
2359-
font.set_text(s, language=language)
2363+
font.set_text(s, features=features, language=language)
23602364
width, height = font.get_width_height()
23612365
self.file._annotations[-1][1].append(_get_link_annotation(
23622366
gc, x, y, width / 64, height / 64, angle))
@@ -2390,7 +2394,8 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
23902394
multibyte_glyphs = []
23912395
prev_was_multibyte = True
23922396
prev_font = font
2393-
for item in _text_helpers.layout(s, font, kern_mode=Kerning.UNFITTED,
2397+
for item in _text_helpers.layout(s, font, features=features,
2398+
kern_mode=Kerning.UNFITTED,
23942399
language=language):
23952400
if _font_supports_glyph(fonttype, ord(item.char)):
23962401
if prev_was_multibyte or item.ft_object != prev_font:

lib/matplotlib/backends/backend_ps.py

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

796796
else:
797-
language = mtext.get_language() if mtext is not None else None
797+
if mtext is not None:
798+
features = mtext.get_fontfeatures()
799+
language = mtext.get_language()
800+
else:
801+
features = language = None
798802
font = self._get_font_ttf(prop)
799803
self._character_tracker.track(font, s)
800-
for item in _text_helpers.layout(s, font, language=language):
804+
for item in _text_helpers.layout(s, font, features=features,
805+
language=language):
801806
ps_name = (item.ft_object.postscript_name
802807
.encode("ascii", "replace").decode("ascii"))
803808
glyph_name = item.ft_object.get_glyph_name(item.glyph_idx)

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
@@ -248,6 +248,7 @@ class FT2Font(Buffer):
248248
angle: float = ...,
249249
flags: LoadFlags = ...,
250250
*,
251+
features: tuple[str] | None = ...,
251252
language: str | list[tuple[str, int, int]] | None = ...,
252253
) -> NDArray[np.float64]: ...
253254
@property
16.5 KB
Loading

lib/matplotlib/tests/test_ft2font.py

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

209209

210+
def test_ft2font_features():
211+
# Smoke test that these are accepted as intended.
212+
file = fm.findfont('DejaVu Sans')
213+
font = ft2font.FT2Font(file)
214+
font.set_text('foo', features=None) # unset
215+
font.set_text('foo', features=['calt', 'dlig']) # list
216+
font.set_text('foo', features=('calt', 'dlig')) # tuple
217+
with pytest.raises(TypeError):
218+
font.set_text('foo', features=123)
219+
with pytest.raises(TypeError):
220+
font.set_text('foo', features=[123, 456])
221+
222+
210223
def test_ft2font_charmaps():
211224
def enc(name):
212225
# We don't expose the encoding enum from FreeType, but can generate it here.

lib/matplotlib/tests/test_text.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,6 +1204,16 @@ 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+
fig.text(0, 0.7, 'Default: fi ffi fl st', fontsize=32)
1211+
fig.text(0, 0.4, 'Disabled: fi ffi fl st', fontsize=32,
1212+
fontfeatures=['-liga'])
1213+
fig.text(0, 0.1, 'Discretionary: fi ffi fl st', fontsize=32,
1214+
fontfeatures=['dlig'])
1215+
1216+
12071217
@pytest.mark.parametrize(
12081218
'input, match',
12091219
[

0 commit comments

Comments
 (0)