8000 Add font feature API to Text by QuLogic · Pull Request #29695 · matplotlib/matplotlib · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions doc/release/next_whats_new/font_features.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
Specifying font feature tags
----------------------------

OpenType fonts may support feature tags that specify alternate glyph shapes or
substitutions to be made optionally. The text API now supports setting a list of feature
tags to be used with the associated font. Feature tags can be set/get with:

- `matplotlib.text.Text.set_fontfeatures` / `matplotlib.text.Text.get_fontfeatures`
- Any API that creates a `.Text` object by passing the *fontfeatures* argument (e.g.,
``plt.xlabel(..., fontfeatures=...)``)

Font feature strings are eventually passed to HarfBuzz, and so all `string formats
supported by hb_feature_from_string()
<https://harfbuzz.github.io/harfbuzz-hb-common.html#hb-feature-from-string>`__ are
supported. Note though that subranges are not explicitly supported and behaviour may
change in the future.

For example, the default font ``DejaVu Sans`` enables Standard Ligatures (the ``'liga'``
tag) by default, and also provides optional Discretionary Ligatures (the ``dlig`` tag.)
These may be toggled with ``+`` or ``-``.

.. plot::
:include-source:

fig = plt.figure(figsize=(7, 3))

fig.text(0.5, 0.85, 'Ligatures', fontsize=40, horizontalalignment='center')

# Default has Standard Ligatures (liga).
fig.text(0, 0.6, 'Default: fi ffi fl st', fontsize=40)

# Disable Standard Ligatures with -liga.
fig.text(0, 0.35, 'Disabled: fi ffi fl st', fontsize=40,
fontfeatures=['-liga'])

# Enable Discretionary Ligatures with dlig.
fig.text(0, 0.1, 'Discretionary: fi ffi fl st', fontsize=40,
fontfeatures=['dlig'])

Available font feature tags may be found at
https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist
6 changes: 4 additions & 2 deletions lib/matplotlib/_text_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def warn_on_missing_glyph(codepoint, fontnames):
f"missing from font(s) {fontnames}.")


def layout(string, font, *, kern_mode=Kerning.DEFAULT, language=None):
def layout(string, font, *, features=None, kern_mode=Kerning.DEFAULT, language=None):
"""
Render *string* with *font*.

Expand All @@ -39,6 +39,8 @@ def layout(string, font, *, kern_mode=Kerning.DEFAULT, language=None):
The string to be rendered.
font : FT2Font
The font.
features : tuple of str, optional
The font features to apply to the text.
kern_mode : Kerning
A FreeType kerning mode.
language : str, optional
Expand All @@ -51,7 +53,7 @@ def layout(string, font, *, kern_mode=Kerning.DEFAULT, language=None):
"""
x = 0
prev_glyph_index = None
char_to_font = font._get_fontmap(string) # TODO: Pass in language.
char_to_font = font._get_fontmap(string) # TODO: Pass in features and language.
base_font = font
for char in string:
# This has done the fallback logic
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/backends/backend_agg.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
# We pass '0' for angle here, since it will be rotated (in raster
# space) in the following call to draw_text_image).
font.set_text(s, 0, flags=get_hinting_flag(),
features=mtext.get_fontfeatures() if mtext is not None else None,
language=mtext.get_language() if mtext is not None else None)
font.draw_glyphs_to_bitmap(
antialiased=gc.get_antialiased())
Expand Down
11 changes: 8 additions & 3 deletions lib/matplotlib/backends/backend_pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -2263,7 +2263,11 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
return self.draw_mathtext(gc, x, y, s, prop, angle)

fontsize = prop.get_size_in_points()
language = mtext.get_language() if mtext is not None else None
if mtext is not None:
features = mtext.get_fontfeatures()
language = mtext.get_language()
else:
features = language = None

if mpl.rcParams['pdf.use14corefonts']:
font = self._get_font_afm(prop)
Expand All @@ -2273,7 +2277,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
fonttype = mpl.rcParams['pdf.fonttype']

if gc.get_url() is not None:
font.set_text(s, language=language)
font.set_text(s, features=features, language=language)
width, height = font.get_width_height()
self.file._annotations[-1][1].append(_get_link_annotation(
gc, x, y, width / 64, height / 64, angle))
Expand Down Expand Up @@ -2321,7 +2325,8 @@ def output_singlebyte_chunk(kerns_or_chars):
prev_start_x = 0
# Emit all the characters in a BT/ET group.
self.file.output(Op.begin_text)
for item in _text_helpers.layout(s, font, kern_mode=Kerning.UNFITTED,
for item in _text_helpers.layout(s, font, features=features,
kern_mode=Kerning.UNFITTED,
language=language):
subset, charcode = self.file._character_tracker.track_glyph(
item.ft_object, item.char, item.glyph_index)
Expand Down
9 changes: 7 additions & 2 deletions lib/matplotlib/backends/backend_ps.py
Original file line number Diff line number Diff line change
Expand Up @@ -798,9 +798,14 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
thisx += width * scale

else:
language = mtext.get_language() if mtext is not None else None
if mtext is not None:
features = mtext.get_fontfeatures()
language = mtext.get_language()
else:
features = language = None
font = self._get_font_ttf(prop)
for item in _text_helpers.layout(s, font, language=language):
for item in _text_helpers.layout(s, font, features=features,
language=language):
# NOTE: We ignore the character code in the subset, because PS uses the
# glyph name to write text. The subset is only used to ensure that each
# one does not overflow format limits.
Expand Down
2 changes: 1 addition & 1 deletion lib/matplotlib/font_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,7 @@ def afmFontProperty(fontpath, 9E12 font):

def _cleanup_fontproperties_init(init_method):
"""
A decorator to limit the call signature to single a positional argument
A decorator to limit the call signature to a single positional argument
or alternatively only keyword arguments.

We still accept but deprecate all other call signatures.
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/ft2font.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ class FT2Font(Buffer):
angle: float = ...,
flags: LoadFlags = ...,
*,
features: tuple[str] | None = ...,
language: str | list[tuple[str, int, int]] | None = ...,
) -> NDArray[np.float64]: ...
@property
Expand Down
13 changes: 13 additions & 0 deletions lib/matplotlib/tests/test_ft2font.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,19 @@ def test_ft2font_set_size():
assert font.get_width_height() == tuple(pytest.approx(2 * x, 1e-1) for x in orig)


def test_ft2font_features():
# Smoke test that these are accepted as intended.
file = fm.findfont('DejaVu Sans')
font = ft2font.FT2Font(file)
font.set_text('foo', features=None) # unset
font.set_text('foo', features=['calt', 'dlig']) # list
font.set_text('foo', features=('calt', 'dlig')) # tuple
with pytest.raises(TypeError):
font.set_text('foo', features=123)
with pytest.raises(TypeError):
font.set_text('foo', features=[123, 456])


def test_ft2font_charmaps():
def enc(name):
# We don't expose the encoding enum from FreeType, but can generate it here.
Expand Down
16 changes: 16 additions & 0 deletions lib/matplotlib/tests/test_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -1204,6 +1204,22 @@ def test_ytick_rotation_mode():
plt.subplots_adjust(left=0.4, right=0.6, top=.99, bottom=.01)


@image_comparison(baseline_images=['features.png'], remove_text=False, style='mpl20')
def test_text_features():
fig = plt.figure(figsize=(5, 1.5))
t = fig.text(1, 0.7, 'Default: fi ffi fl st',
fontsize=32, horizontalalignment='right')
assert t.get_fontfeatures() is None
t = fig.text(1, 0.4, 'Disabled: fi ffi fl st',
fontsize=32, horizontalalignment='right',
fontfeatures=['-liga'])
assert t.get_fontfeatures() == ('-liga', )
t = fig.text(1, 0.1, 'Discretionary: fi ffi fl st',
fontsize=32, horizontalalignment='right')
t.set_fontfeatures(['dlig'])
assert t.get_fontfeatures() == ('dlig', )


@pytest.mark.parametrize(
'input, match',
[
Expand Down
40 changes: 40 additions & 0 deletions lib/matplotlib/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ def __init__(self,
super().__init__()
self._x, self._y = x, y
self._text = ''
self._features = None
self.set_language(None)
self._reset_visual_defaults(
text=text,
Expand Down Expand Up @@ -849,6 +850,12 @@ def get_fontfamily(self):
"""
return self._fontproperties.get_family()

def get_fontfeatures(self):
"""
Return a tuple of font feature tags to enable.
"""
return self._features

def get_fontname(self):
"""
Return the font name as a string.
Expand Down Expand Up @@ -1096,6 +1103,39 @@ def set_fontfamily(self, fontname):
self._fontproperties.set_family(fontname)
self.stale = True

def set_fontfeatures(self, features):
"""
Set the feature tags to enable on the font.

Parameters
----------
features : list of str, or tuple of str, or None
A list of feature tags to be used with the associated font. These strings
are eventually passed to HarfBuzz, and so all `string formats supported by
hb_feature_from_string()
<https://harfbuzz.github.io/harfbuzz-hb-common.html#hb-feature-from-string>`__
are supported. Note though that subranges are not explicitly supported and
behaviour may change in the future.

For example, if your desired font includes Stylistic Sets which enable
various typographic alternates including one that you do not wish to use
(e.g., Contextual Ligatures), then you can pass the following to enable one
and not the other::

fp.set_features([
'ss01', # Use Stylistic Set 1.
'-clig', # But disable Contextural Ligatures.
])

Available font feature tags may be found at
https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist
"""
_api.check_isinstance((Sequence, None), features=features)
if features is not None:
features = tuple(features)
self._features = features
self.stale = True

def set_fontvariant(self, variant):
"""
Set the font variant.
Expand Down
2 changes: 2 additions & 0 deletions lib/matplotlib/text.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class Text(Artist):
def get_color(self) -> ColorType: ...
def get_fontproperties(self) -> FontProperties: ...
def get_fontfamily(self) -> list[str]: ...
def get_fontfeatures(self) -> tuple[str, ...] | None: ...
def get_fontname(self) -> str: ...
def get_fontstyle(self) -> Literal["normal", "italic", "oblique"]: ...
def get_fontsize(self) -> float | str: ...
Expand All @@ -80,6 +81,7 @@ class Text(Artist):
def set_multialignment(self, align: Literal["left", "center", "right"]) -> None: ...
def set_linespacing(self, spacing: float) -> None: ...
def set_fontfamily(self, fontname: str | Iterable[str]) -> None: ...
def set_fontfeatures(self, features: Sequence[str] | None) -> None: ...
def set_fontvariant(self, variant: Literal["normal", "small-caps"]) -> None: ...
def set_fontstyle(
self, fontstyle: Literal["normal", "italic", "oblique"]
Expand Down