Skip to content
Open
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
37 changes: 37 additions & 0 deletions doc/release/next_whats_new/text_language.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
Specifying text language
------------------------

OpenType fonts may support language systems which can be used to select different
typographic conventions, e.g., localized variants of letters that share a single Unicode
code point, or different default font features. The text API now supports setting a
language to be used and may be set/get with:

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

The language of the text must be in a format accepted by libraqm, namely `a BCP47
language code <https://www.w3.org/International/articles/language-tags/>`_. If None or
unset, then no particular language will be implied, and default font settings will be
used.

For example, Matplotlib's default font ``DejaVu Sans`` supports language-specific glyphs
in the Serbian and Macedonian languages in the Cyrillic alphabet, or the Sámi family of
languages in the Latin alphabet.

.. plot::
:include-source:

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

char = '\U00000431'
fig.text(0.5, 0.8, f'\\U{ord(char):08x}', fontsize=40, horizontalalignment='center')
fig.text(0, 0.6, f'Serbian: {char}', fontsize=40, language='sr')
fig.text(1, 0.6, f'Russian: {char}', fontsize=40, language='ru',
horizontalalignment='right')

char = '\U0000014a'
fig.text(0.5, 0.3, f'\\U{ord(char):08x}', fontsize=40, horizontalalignment='center')
fig.text(0, 0.1, f'English: {char}', fontsize=40, language='en')
fig.text(1, 0.1, f'Inari Sámi: {char}', fontsize=40, language='smn',
horizontalalignment='right')
9 changes: 7 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):
def layout(string, font, *, features=None, kern_mode=Kerning.DEFAULT, language=None):
"""
Render *string* with *font*.

Expand All @@ -39,16 +39,21 @@ def layout(string, font, *, kern_mode=Kerning.DEFAULT):
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
The language of the text in a format accepted by libraqm, namely `a BCP47
language code <https://www.w3.org/International/articles/language-tags/>`_.

Yields
------
LayoutItem
"""
x = 0
prev_glyph_idx = None
char_to_font = font._get_fontmap(string)
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
4 changes: 3 additions & 1 deletion lib/matplotlib/backends/backend_agg.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,9 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
font = self._prepare_font(prop)
# 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())
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())
d = font.get_descent() / 64.0
Expand Down
11 changes: 9 additions & 2 deletions lib/matplotlib/backends/backend_pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -2345,6 +2345,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()
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 @@ -2355,7 +2360,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)
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 @@ -2389,7 +2394,9 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
multibyte_glyphs = []
prev_was_multibyte = True
prev_font = font
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):
if _font_supports_glyph(fonttype, ord(item.char)):
if prev_was_multibyte or item.ft_object != prev_font:
singlebyte_chunks.append((item.ft_object, item.x, []))
Expand Down
8 changes: 7 additions & 1 deletion lib/matplotlib/backends/backend_ps.py
Original file line number Diff line number Diff line change
Expand Up @@ -794,9 +794,15 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
thisx += width * scale

else:
if mtext is not None:
features = mtext.get_fontfeatures()
language = mtext.get_language()
else:
features = language = None
font = self._get_font_ttf(prop)
self._character_tracker.track(font, s)
for item in _text_helpers.layout(s, font):
for item in _text_helpers.layout(s, font, features=features,
language=language):
ps_name = (item.ft_object.postscript_name
.encode("ascii", "replace").decode("ascii"))
glyph_name = item.ft_object.get_glyph_name(item.glyph_idx)
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, 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
8 changes: 7 additions & 1 deletion lib/matplotlib/ft2font.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,13 @@ class FT2Font(Buffer):
def set_charmap(self, i: int) -> None: ...
def set_size(self, ptsize: float, dpi: float) -> None: ...
def set_text(
self, string: str, angle: float = ..., flags: LoadFlags = ...
self,
string: str,
angle: float = ...,
flags: LoadFlags = ...,
*,
features: tuple[str] | None = ...,
language: str | list[tuple[str, int, int]] | None = ...,
) -> NDArray[np.float64]: ...
@property
def ascender(self) -> int: ...
Expand Down
5 changes: 5 additions & 0 deletions lib/matplotlib/mpl-data/matplotlibrc
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,11 @@
## for more information on text properties
#text.color: black

## The language of the text in a format accepted by libraqm, namely `a BCP47 language
## code <https://www.w3.org/International/articles/language-tags/>`_. If None, then no
## particular language will be implied, and default font settings will be used.
#text.language: None

## FreeType hinting flag ("foo" corresponds to FT_LOAD_FOO); may be one of the
## following (Proprietary Matplotlib-specific synonyms are given in parentheses,
## but their use is discouraged):
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/rcsetup.py
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,7 @@ def _convert_validator_spec(key, conv):
"text.kerning_factor": validate_int_or_None,
"text.antialiased": validate_bool,
"text.parse_math": validate_bool,
"text.language": validate_string_or_None,

"mathtext.cal": validate_font_properties,
"mathtext.rm": validate_font_properties,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 44 additions & 0 deletions lib/matplotlib/tests/test_ft2font.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,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 Expand Up @@ -783,6 +796,37 @@ def test_ft2font_set_text():
assert font.get_bitmap_offset() == (6, 0)


@pytest.mark.parametrize(
'input',
[
[1, 2, 3],
[(1, 2)],
[('en', 'foo', 2)],
[('en', 1, 'foo')],
],
ids=[
'nontuple',
'wrong length',
'wrong start type',
'wrong end type',
],
)
def test_ft2font_language_invalid(input):
file = fm.findfont('DejaVu Sans')
font = ft2font.FT2Font(file, hinting_factor=1)
with pytest.raises(TypeError):
font.set_text('foo', language=input)


def test_ft2font_language():
# This is just a smoke test.
file = fm.findfont('DejaVu Sans')
font = ft2font.FT2Font(file, hinting_factor=1)
font.set_text('foo')
font.set_text('foo', language='en')
font.set_text('foo', language=[('en', 1, 2)])


def test_ft2font_loading():
file = fm.findfont('DejaVu Sans')
font = ft2font.FT2Font(file, hinting_factor=1)
Expand Down
70 changes: 70 additions & 0 deletions lib/matplotlib/tests/test_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -1202,3 +1202,73 @@ def test_ytick_rotation_mode():
tick.set_rotation(angle)

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(0, 0.7, 'Default: fi ffi fl st', fontsize=32)
assert t.get_fontfeatures() is None
t = fig.text(0, 0.4, 'Disabled: fi ffi fl st', fontsize=32,
fontfeatures=['-liga'])
assert t.get_fontfeatures() == ('-liga', )
t = fig.text(0, 0.1, 'Discretionary: fi ffi fl st', fontsize=32)
t.set_fontfeatures(['dlig'])
assert t.get_fontfeatures() == ('dlig', )


@pytest.mark.parametrize(
'input, match',
[
([1, 2, 3], 'must be list of tuple'),
([(1, 2)], 'must be list of tuple'),
([('en', 'foo', 2)], 'start location must be int'),
([('en', 1, 'foo')], 'end location must be int'),
],
)
def test_text_language_invalid(input, match):
with pytest.raises(TypeError, match=match):
Text(0, 0, 'foo', language=input)


@image_comparison(baseline_images=['language.png'], remove_text=False, style='mpl20')
def test_text_language():
fig = plt.figure(figsize=(5, 3))

t = fig.text(0, 0.8, 'Default', fontsize=32)
assert t.get_language() is None
t = fig.text(0, 0.55, 'Lang A', fontsize=32)
assert t.get_language() is None
t = fig.text(0, 0.3, 'Lang B', fontsize=32)
assert t.get_language() is None
t = fig.text(0, 0.05, 'Mixed', fontsize=32)
assert t.get_language() is None

# DejaVu Sans supports language-specific glyphs in the Serbian and Macedonian
# languages in the Cyrillic alphabet.
cyrillic = '\U00000431'
t = fig.text(0.4, 0.8, cyrillic, fontsize=32)
assert t.get_language() is None
t = fig.text(0.4, 0.55, cyrillic, fontsize=32, language='sr')
assert t.get_language() == 'sr'
t = fig.text(0.4, 0.3, cyrillic, fontsize=32)
t.set_language('ru')
assert t.get_language() == 'ru'
t = fig.text(0.4, 0.05, cyrillic * 4, fontsize=32,
language=[('ru', 0, 1), ('sr', 1, 2), ('ru', 2, 3), ('sr', 3, 4)])
assert t.get_language() == (('ru', 0, 1), ('sr', 1, 2), ('ru', 2, 3), ('sr', 3, 4))

# Or the Sámi family of languages in the Latin alphabet.
latin = '\U0000014a'
t = fig.text(0.7, 0.8, latin, fontsize=32)
assert t.get_language() is None
with plt.rc_context({'text.language': 'en'}):
t = fig.text(0.7, 0.55, latin, fontsize=32)
assert t.get_language() == 'en'
t = fig.text(0.7, 0.3, latin, fontsize=32, language='smn')
assert t.get_language() == 'smn'
# Tuples are not documented, but we'll allow it.
t = fig.text(0.7, 0.05, latin * 4, fontsize=32)
t.set_language((('en', 0, 1), ('smn', 1, 2), ('en', 2, 3), ('smn', 3, 4)))
assert t.get_language() == (
('en', 0, 1), ('smn', 1, 2), ('en', 2, 3), ('smn', 3, 4))
Loading
Loading