Skip to content

Commit 42a8813

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 f231f2e commit 42a8813

File tree

15 files changed

+143
-17
lines changed

15 files changed

+143
-17
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 the interaction of subranges and multiline text is currently
16+
unspecified and behaviour may 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
@@ -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

Lines changed: 2 additions & 1 deletion
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

Lines changed: 4 additions & 2 deletions
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

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

796796
else:
797+
features = mtext.get_fontfeatures() if mtext is not None else None
797798
font = self._get_font_ttf(prop)
798799
self._character_tracker.track(font, s)
799-
for item in _text_helpers.layout(s, font):
800+
for item in _text_helpers.layout(s, font, features=features):
800801
ps_name = (item.ft_object.postscript_name
801802
.encode("ascii", "replace").decode("ascii"))
802803
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
@@ -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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,12 @@ 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+
*,
244+
features: tuple[str] | None = ...,
240245
) -> NDArray[np.float64]: ...
241246
@property
242247
def ascender(self) -> int: ...

lib/matplotlib/tests/test_ft2font.py

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

202202

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

lib/matplotlib/text.py

Lines changed: 40 additions & 0 deletions
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,39 @@ 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. The interaction of subranges and multiline text is currently
1116+
unspecified and behavior may change in the future.
1117+
1118+
For example, if your desired font includes Stylistic Sets which enable
1119+
various typographic alternates including one that you do not wish to use
1120+
(e.g., Contextual Ligatures), then you can pass the following to enable one
1121+
and not the other::
1122+
1123+
fp.set_features([
1124+
'ss01', # Use Stylistic Set 1.
1125+
'-clig', # But disable Contextural Ligatures.
1126+
])
1127+
1128+
Available font feature tags may be found at
1129+
https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist
1130+
"""
1131+
_api.check_isinstance((list, tuple, None), features=features)
1132+
if features is not None:
1133+
features = tuple(features)
1134+
self._features = features
1135+
self.stale = True
1136+
10971137
def set_fontvariant(self, variant):
10981138
"""
10991139
Set the font variant.

lib/matplotlib/text.pyi

Lines changed: 2 additions & 0 deletions
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"]

0 commit comments

Comments
 (0)