Skip to content

Commit a4fc291

Browse files
committed
Add language parameter to Text objects
1 parent bb9aae4 commit a4fc291

File tree

17 files changed

+191
-17
lines changed

17 files changed

+191
-17
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
Specifying text language
2+
------------------------
3+
4+
OpenType fonts may support language systems which can be used to select different
5+
typographic conventions, e.g., localized variants of letters that share a single Unicode
6+
code point, or different default font features. The text API now supports setting a
7+
language to be used and may be set/get with:
8+
9+
- `matplotlib.text.Text.set_language` / `matplotlib.text.Text.get_language`
10+
- Any API that creates a `.Text` object by passing the *language* argument (e.g.,
11+
``plt.xlabel(..., language=...)``)
12+
13+
The language of the text must be in a format accepted by libraqm, namely `a BCP47
14+
language code <https://www.w3.org/International/articles/language-tags/>`_. If None or
15+
unset, then no particular language will be implied, and default font settings will be
16+
used.
17+
18+
For example, the default font ``DejaVu Sans`` supports language-specific glyphs in the
19+
Serbian and Macedonian languages in the Cyrillic alphabet, or the Sámi family of
20+
languages in the Latin alphabet.
21+
22+
.. plot::
23+
:include-source:
24+
25+
fig = plt.figure(figsize=(7, 3))
26+
27+
char = '\U00000431'
28+
fig.text(0.5, 0.8, f'\\U{ord(char):08x}', fontsize=40, horizontalalignment='center')
29+
fig.text(0, 0.6, f'Serbian: {char}', fontsize=40, language='sr')
30+
fig.text(1, 0.6, f'Russian: {char}', fontsize=40, language='ru',
31+
horizontalalignment='right')
32+
33+
char = '\U0000014a'
34+
fig.text(0.5, 0.3, f'\\U{ord(char):08x}', fontsize=40, horizontalalignment='center')
35+
fig.text(0, 0.1, f'English: {char}', fontsize=40, language='en')
36+
fig.text(1, 0.1, f'Inari Sámi: {char}', fontsize=40, language='smn',
37+
horizontalalignment='right')

lib/matplotlib/_text_helpers.py

Lines changed: 5 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, *, language=None, kern_mode=Kerning.DEFAULT):
4747
"""
4848
Render *string* with *font*.
4949
@@ -56,6 +56,9 @@ def layout(string, font, *, kern_mode=Kerning.DEFAULT):
5656
The string to be rendered.
5757
font : FT2Font
5858
The font.
59+
language : str or list of tuples of (str, int, int), optional
60+
The language of the text in a format accepted by libraqm, namely `a BCP47
61+
language code <https://www.w3.org/International/articles/language-tags/>`_.
5962
kern_mode : Kerning
6063
A FreeType kerning mode.
6164
@@ -65,7 +68,7 @@ def layout(string, font, *, kern_mode=Kerning.DEFAULT):
6568
"""
6669
x = 0
6770
prev_glyph_idx = None
68-
char_to_font = font._get_fontmap(string)
71+
char_to_font = font._get_fontmap(string) # TODO: Pass in language.
6972
base_font = font
7073
for char in string:
7174
# 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+
language=mtext.get_language() 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
@@ -2342,6 +2342,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
23422342
return self.draw_mathtext(gc, x, y, s, prop, angle)
23432343

23442344
fontsize = prop.get_size_in_points()
2345+
language = mtext.get_language() if mtext is not None else None
23452346

23462347
if mpl.rcParams['pdf.use14corefonts']:
23472348
font = self._get_font_afm(prop)
@@ -2352,7 +2353,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
23522353
fonttype = mpl.rcParams['pdf.fonttype']
23532354

23542355
if gc.get_url() is not None:
2355-
font.set_text(s)
2356+
font.set_text(s, language=language)
23562357
width, height = font.get_width_height()
23572358
self.file._annotations[-1][1].append(_get_link_annotation(
23582359
gc, x, y, width / 64, height / 64, angle))
@@ -2386,7 +2387,8 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
23862387
multibyte_glyphs = []
23872388
prev_was_multibyte = True
23882389
prev_font = font
2389-
for item in _text_helpers.layout(s, font, kern_mode=Kerning.UNFITTED):
2390+
for item in _text_helpers.layout(s, font, language=language,
2391+
kern_mode=Kerning.UNFITTED):
23902392
if _font_supports_glyph(fonttype, ord(item.char)):
23912393
if prev_was_multibyte or item.ft_object != prev_font:
23922394
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+
language = mtext.get_language() 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, language=language):
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/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+
language: str | list[tuple[str, int, int]] | None = ...,
240245
) -> NDArray[np.float64]: ...
241246
@property
242247
def ascender(self) -> int: ...

lib/matplotlib/mpl-data/matplotlibrc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,11 @@
292292
## for more information on text properties
293293
#text.color: black
294294

295+
## The language of the text in a format accepted by libraqm, namely `a BCP47 language
296+
## code <https://www.w3.org/International/articles/language-tags/>`_. If None, then no
297+
## particular language will be implied, and default font settings will be used.
298+
#text.language: None
299+
295300
## FreeType hinting flag ("foo" corresponds to FT_LOAD_FOO); may be one of the
296301
## following (Proprietary Matplotlib-specific synonyms are given in parentheses,
297302
## but their use is discouraged):

lib/matplotlib/rcsetup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,6 +1032,7 @@ def _convert_validator_spec(key, conv):
10321032
"text.kerning_factor": validate_int,
10331033
"text.antialiased": validate_bool,
10341034
"text.parse_math": validate_bool,
1035+
"text.language": validate_string_or_None,
10351036

10361037
"mathtext.cal": validate_font_properties,
10371038
"mathtext.rm": validate_font_properties,

lib/matplotlib/tests/test_ft2font.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,37 @@ def test_ft2font_set_text():
775775
assert font.get_bitmap_offset() == (6, 0)
776776

777777

778+
@pytest.mark.parametrize(
779+
'input',
780+
[
781+
[1, 2, 3],
782+
[(1, 2)],
783+
[('en', 'foo', 2)],
784+
[('en', 1, 'foo')],
785+
],
786+
ids=[
787+
'nontuple',
788+
'wrong length',
789+
'wrong start type',
790+
'wrong end type',
791+
],
792+
)
793+
def test_ft2font_language_invalid(input):
794+
file = fm.findfont('DejaVu Sans')
795+
font = ft2font.FT2Font(file, hinting_factor=1, _kerning_factor=0)
796+
with pytest.raises(TypeError):
797+
font.set_text('foo', language=input)
798+
799+
800+
def test_ft2font_language():
801+
# TODO: This is just a smoke test.
802+
file = fm.findfont('DejaVu Sans')
803+
font = ft2font.FT2Font(file, hinting_factor=1, _kerning_factor=0)
804+
font.set_text('foo')
805+
font.set_text('foo', language='en')
806+
font.set_text('foo', language=[('en', 1, 2)])
807+
808+
778809
def test_ft2font_loading():
779810
file = fm.findfont('DejaVu Sans')
780811
font = ft2font.FT2Font(file, hinting_factor=1, _kerning_factor=0)

lib/matplotlib/tests/test_text.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1199,3 +1199,24 @@ def test_ytick_rotation_mode():
11991199
tick.set_rotation(angle)
12001200

12011201
plt.subplots_adjust(left=0.4, right=0.6, top=.99, bottom=.01)
1202+
1203+
1204+
@pytest.mark.parametrize(
1205+
'input, match',
1206+
[
1207+
([1, 2, 3], 'must be list of tuple'),
1208+
([(1, 2)], 'must be list of tuple'),
1209+
([('en', 'foo', 2)], 'start location must be int'),
1210+
([('en', 1, 'foo')], 'end location must be int'),
1211+
],
1212+
)
1213+
def test_text_language_invalid(input, match):
1214+
with pytest.raises(TypeError, match=match):
1215+
Text(0, 0, 'foo', language=input)
1216+
1217+
1218+
def test_text_language():
1219+
# TODO: This is just a smoke test.
1220+
Text(0, 0, 'foo', language='en')
1221+
Text(0, 0, 'foo', language=[('en', 1, 2)])
1222+
Text(0, 0, 'foo', language=(('en', 1, 2), )) # Not documented, but we'll allow it.

lib/matplotlib/text.py

Lines changed: 36 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._language = None
139140
self._reset_visual_defaults(
140141
text=text,
141142
color=color,
@@ -1422,6 +1423,41 @@ def _va_for_angle(self, angle):
14221423
return 'baseline' if anchor_at_left else 'top'
14231424
return 'top' if anchor_at_left else 'baseline'
14241425

1426+
def get_language(self):
1427+
"""Return the language this Text is in."""
1428+
return self._language
1429+
1430+
def set_language(self, language):
1431+
"""
1432+
Set the language of the text.
1433+
1434+
Parameters
1435+
----------
1436+
language : str or list[tuple[str, int, int]] or None
1437+
The language of the text in a format accepted by libraqm, namely `a BCP47
1438+
language code <https://www.w3.org/International/articles/language-tags/>`_.
1439+
1440+
If None, then defaults to :rc:`text.language`.
1441+
"""
1442+
_api.check_isinstance((list, tuple, str, None), language=language)
1443+
language = mpl._val_or_rc(language, 'text.language')
1444+
1445+
if not cbook.is_scalar_or_string(language):
1446+
for val in language:
1447+
if not isinstance(val, tuple) or len(val) != 3:
1448+
raise TypeError('language must be list of tuple, not {language!r}')
1449+
sublang, start, end = val
1450+
if not isinstance(sublang, str):
1451+
raise TypeError(
1452+
'sub-language specification must be str, not {sublang!r}')
1453+
if not isinstance(start, int):
1454+
raise TypeError('start location must be int, not {start!r}')
1455+
if not isinstance(end, int):
1456+
raise TypeError('end location must be int, not {end!r}')
1457+
1458+
self._language = language
1459+
self.stale = True
1460+
14251461

14261462
class OffsetFrom:
14271463
"""Callable helper class for working with `Annotation`."""

lib/matplotlib/text.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ class Text(Artist):
108108
def set_antialiased(self, antialiased: bool) -> None: ...
109109
def _ha_for_angle(self, angle: Any) -> Literal['center', 'right', 'left'] | None: ...
110110
def _va_for_angle(self, angle: Any) -> Literal['center', 'top', 'baseline'] | None: ...
111+
def get_language(self) -> str | list[tuple[str, int, int]] | None: ...
112+
def set_language(self, language: str | list[tuple[str, int, int]] | None) -> None: ...
111113

112114
class OffsetFrom:
113115
def __init__(

lib/matplotlib/textpath.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def get_text_width_height_descent(self, s, prop, ismath):
6969
d /= 64.0
7070
return w * scale, h * scale, d * scale
7171

72-
def get_text_path(self, prop, s, ismath=False):
72+
def get_text_path(self, prop, s, ismath=False, *, language=None):
7373
"""
7474
Convert text *s* to path (a tuple of vertices and codes for
7575
matplotlib.path.Path).
@@ -82,6 +82,9 @@ def get_text_path(self, prop, s, ismath=False):
8282
The text to be converted.
8383
ismath : {False, True, "TeX"}
8484
If True, use mathtext parser. If "TeX", use tex for rendering.
85+
language : str or list of tuples of (str, int, int), optional
86+
The language of the text in a format accepted by libraqm, namely `a BCP47
87+
language code <https://www.w3.org/International/articles/language-tags/>`_.
8588
8689
Returns
8790
-------
@@ -109,7 +112,8 @@ def get_text_path(self, prop, s, ismath=False):
109112
glyph_info, glyph_map, rects = self.get_glyphs_tex(prop, s)
110113
elif not ismath:
111114
font = self._get_font(prop)
112-
glyph_info, glyph_map, rects = self.get_glyphs_with_font(font, s)
115+
glyph_info, glyph_map, rects = self.get_glyphs_with_font(font, s,
116+
language=language)
113117
else:
114118
glyph_info, glyph_map, rects = self.get_glyphs_mathtext(prop, s)
115119

@@ -130,7 +134,7 @@ def get_text_path(self, prop, s, ismath=False):
130134
return verts, codes
131135

132136
def get_glyphs_with_font(self, font, s, glyph_map=None,
133-
return_new_glyphs_only=False):
137+
return_new_glyphs_only=False, *, language=None):
134138
"""
135139
Convert string *s* to vertices and codes using the provided ttf font.
136140
"""
@@ -145,7 +149,7 @@ def get_glyphs_with_font(self, font, s, glyph_map=None,
145149

146150
xpositions = []
147151
glyph_ids = []
148-
for item in _text_helpers.layout(s, font):
152+
for item in _text_helpers.layout(s, font, language=language):
149153
char_id = self._get_char_id(item.ft_object, ord(item.char))
150154
glyph_ids.append(char_id)
151155
xpositions.append(item.x)

lib/matplotlib/textpath.pyi

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,17 @@ class TextToPath:
1616
self, s: str, prop: FontProperties, ismath: bool | Literal["TeX"]
1717
) -> tuple[float, float, float]: ...
1818
def get_text_path(
19-
self, prop: FontProperties, s: str, ismath: bool | Literal["TeX"] = ...
19+
self, prop: FontProperties, s: str, ismath: bool | Literal["TeX"] = ..., *,
20+
language: str | list[tuple[str, int, int]] | None = ...,
2021
) -> list[np.ndarray]: ...
2122
def get_glyphs_with_font(
2223
self,
2324
font: FT2Font,
2425
s: str,
2526
glyph_map: dict[str, tuple[np.ndarray, np.ndarray]] | None = ...,
2627
return_new_glyphs_only: bool = ...,
28+
*,
29+
language: str | list[tuple[str, int, int]] | None = ...,
2730
) -> tuple[
2831
list[tuple[str, float, float, float]],
2932
dict[str, tuple[np.ndarray, np.ndarray]],

src/ft2font.cpp

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,9 @@ void FT2Font::set_kerning_factor(int factor)
309309
}
310310

311311
void FT2Font::set_text(
312-
std::u32string_view text, double angle, FT_Int32 flags, std::vector<double> &xys)
312+
// TODO: languages is unused with FreeType, but will with libraqm.
313+
std::u32string_view text, double angle, FT_Int32 flags, LanguageType languages,
314+
std::vector<double> &xys)
313315
{
314316
FT_Matrix matrix; /* transformation matrix */
315317

src/ft2font.h

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#ifndef MPL_FT2FONT_H
77
#define MPL_FT2FONT_H
88

9+
#include <optional>
910
#include <set>
1011
#include <string>
1112
#include <string_view>
@@ -98,6 +99,9 @@ class FT2Font
9899
typedef void (*WarnFunc)(FT_ULong charcode, std::set<FT_String*> family_names);
99100

100101
public:
102+
using LanguageRange = std::tuple<std::string, int, int>;
103+
using LanguageType = std::optional<std::vector<LanguageRange>>;
104+
101105
FT2Font(FT_Open_Args &open_args, long hinting_factor,
102106
std::vector<FT2Font *> &fallback_list,
103107
WarnFunc warn, bool warn_if_used);
@@ -107,7 +111,7 @@ class FT2Font
107111
void set_charmap(int i);
108112
void select_charmap(unsigned long i);
109113
void set_text(std::u32string_view codepoints, double angle, FT_Int32 flags,
110-
std::vector<double> &xys);
114+
LanguageType languages, std::vector<double> &xys);
111115
int get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode);
112116
void set_kerning_factor(int factor);
113117
void load_char(long charcode, FT_Int32 flags, FT2Font *&ft_object, bool fallback);

0 commit comments

Comments
 (0)