Skip to content

Commit 44c17bc

Browse files
authored
Merge pull request matplotlib#29794 from QuLogic/text-language
Add language parameter to Text objects
2 parents 105f9eb + 7ce8eae commit 44c17bc

File tree

17 files changed

+250
-18
lines changed

17 files changed

+250
-18
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, Matplotlib's default font ``DejaVu Sans`` supports language-specific glyphs
19+
in the Serbian and Macedonian languages in the Cyrillic alphabet (vs Russian),
20+
or the Sámi family of languages in the Latin alphabet (vs English).
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'Inari Sámi: {char}', fontsize=40, language='smn')
36+
fig.text(1, 0.1, f'English: {char}', fontsize=40, language='en',
37+
horizontalalignment='right')

lib/matplotlib/_text_helpers.py

Lines changed: 5 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):
29+
def layout(string, font, *, kern_mode=Kerning.DEFAULT, language=None):
3030
"""
3131
Render *string* with *font*.
3232
@@ -41,14 +41,17 @@ def layout(string, font, *, kern_mode=Kerning.DEFAULT):
4141
The font.
4242
kern_mode : Kerning
4343
A FreeType kerning mode.
44+
language : str, optional
45+
The language of the text in a format accepted by libraqm, namely `a BCP47
46+
language code <https://www.w3.org/International/articles/language-tags/>`_.
4447
4548
Yields
4649
------
4750
LayoutItem
4851
"""
4952
x = 0
5053
prev_glyph_index = None
51-
char_to_font = font._get_fontmap(string)
54+
char_to_font = font._get_fontmap(string) # TODO: Pass in language.
5255
base_font = font
5356
for char in string:
5457
# 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
@@ -190,7 +190,8 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
190190
font = self._prepare_font(prop)
191191
# We pass '0' for angle here, since it will be rotated (in raster
192192
# space) in the following call to draw_text_image).
193-
font.set_text(s, 0, flags=get_hinting_flag())
193+
font.set_text(s, 0, flags=get_hinting_flag(),
194+
language=mtext.get_language() if mtext is not None else None)
194195
font.draw_glyphs_to_bitmap(
195196
antialiased=gc.get_antialiased())
196197
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
@@ -2338,6 +2338,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
23382338
return self.draw_mathtext(gc, x, y, s, prop, angle)
23392339

23402340
fontsize = prop.get_size_in_points()
2341+
language = mtext.get_language() if mtext is not None else None
23412342

23422343
if mpl.rcParams['pdf.use14corefonts']:
23432344
font = self._get_font_afm(prop)
@@ -2348,7 +2349,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
23482349
fonttype = mpl.rcParams['pdf.fonttype']
23492350

23502351
if gc.get_url() is not None:
2351-
font.set_text(s)
2352+
font.set_text(s, language=language)
23522353
width, height = font.get_width_height()
23532354
self.file._annotations[-1][1].append(_get_link_annotation(
23542355
gc, x, y, width / 64, height / 64, angle))
@@ -2398,7 +2399,8 @@ def output_singlebyte_chunk(kerns_or_chars):
23982399
prev_start_x = 0
23992400
# Emit all the 1-byte characters in a BT/ET group.
24002401
self.file.output(Op.begin_text)
2401-
for item in _text_helpers.layout(s, font, kern_mode=Kerning.UNFITTED):
2402+
for item in _text_helpers.layout(s, font, kern_mode=Kerning.UNFITTED,
2403+
language=language):
24022404
if _font_supports_glyph(fonttype, ord(item.char)):
24032405
if item.ft_object != prev_font:
24042406
if singlebyte_chunk:

lib/matplotlib/backends/backend_ps.py

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

793793
else:
794+
language = mtext.get_language() if mtext is not None else None
794795
font = self._get_font_ttf(prop)
795796
self._character_tracker.track(font, s)
796-
for item in _text_helpers.layout(s, font):
797+
for item in _text_helpers.layout(s, font, language=language):
797798
ps_name = (item.ft_object.postscript_name
798799
.encode("ascii", "replace").decode("ascii"))
799800
glyph_name = item.ft_object.get_glyph_name(item.glyph_index)

lib/matplotlib/ft2font.pyi

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,12 @@ class FT2Font(Buffer):
243243
def set_charmap(self, i: int) -> None: ...
244244
def set_size(self, ptsize: float, dpi: float) -> None: ...
245245
def set_text(
246-
self, string: str, angle: float = ..., flags: LoadFlags = ...
246+
self,
247+
string: str,
248+
angle: float = ...,
249+
flags: LoadFlags = ...,
250+
*,
251+
language: str | list[tuple[str, int, int]] | None = ...,
247252
) -> NDArray[np.float64]: ...
248253
@property
249254
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
@@ -1045,6 +1045,7 @@ def _convert_validator_spec(key, conv):
10451045
"text.kerning_factor": validate_int_or_None,
10461046
"text.antialiased": validate_bool,
10471047
"text.parse_math": validate_bool,
1048+
"text.language": validate_string_or_None,
10481049

10491050
"mathtext.cal": validate_font_properties,
10501051
"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
@@ -783,6 +783,37 @@ def test_ft2font_set_text():
783783
assert font.get_bitmap_offset() == (6, 0)
784784

785785

786+
@pytest.mark.parametrize(
787+
'input',
788+
[
789+
[1, 2, 3],
790+
[(1, 2)],
791+
[('en', 'foo', 2)],
792+
[('en', 1, 'foo')],
793+
],
794+
ids=[
795+
'nontuple',
796+
'wrong length',
797+
'wrong start type',
798+
'wrong end type',
799+
],
800+
)
801+
def test_ft2font_language_invalid(input):
802+
file = fm.findfont('DejaVu Sans')
803+
font = ft2font.FT2Font(file, hinting_factor=1)
804+
with pytest.raises(TypeError):
805+
font.set_text('foo', language=input)
806+
807+
808+
def test_ft2font_language():
809+
# This is just a smoke test.
810+
file = fm.findfont('DejaVu Sans')
811+
font = ft2font.FT2Font(file, hinting_factor=1)
812+
font.set_text('foo')
813+
font.set_text('foo', language='en')
814+
font.set_text('foo', language=[('en', 1, 2)])
815+
816+
786817
def test_ft2font_loading():
787818
file = fm.findfont('DejaVu Sans')
788819
font = ft2font.FT2Font(file, hinting_factor=1)

lib/matplotlib/tests/test_text.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,3 +1202,60 @@ def test_ytick_rotation_mode():
12021202
tick.set_rotation(angle)
12031203

12041204
plt.subplots_adjust(left=0.4, right=0.6, top=.99, bottom=.01)
1205+
1206+
1207+
@pytest.mark.parametrize(
1208+
'input, match',
1209+
[
1210+
([1, 2, 3], 'must be list of tuple'),
1211+
([(1, 2)], 'must be list of tuple'),
1212+
([('en', 'foo', 2)], 'start location must be int'),
1213+
([('en', 1, 'foo')], 'end location must be int'),
1214+
],
1215+
)
1216+
def test_text_language_invalid(input, match):
1217+
with pytest.raises(TypeError, match=match):
1218+
Text(0, 0, 'foo', language=input)
1219+
1220+
1221+
@image_comparison(baseline_images=['language.png'], remove_text=False, style='mpl20')
1222+
def test_text_language():
1223+
fig = plt.figure(figsize=(5, 3))
1224+
1225+
t = fig.text(0, 0.8, 'Default', fontsize=32)
1226+
assert t.get_language() is None
1227+
t = fig.text(0, 0.55, 'Lang A', fontsize=32)
1228+
assert t.get_language() is None
1229+
t = fig.text(0, 0.3, 'Lang B', fontsize=32)
1230+
assert t.get_language() is None
1231+
t = fig.text(0, 0.05, 'Mixed', fontsize=32)
1232+
assert t.get_language() is None
1233+
1234+
# DejaVu Sans supports language-specific glyphs in the Serbian and Macedonian
1235+
# languages in the Cyrillic alphabet.
1236+
cyrillic = '\U00000431'
1237+
t = fig.text(0.4, 0.8, cyrillic, fontsize=32)
1238+
assert t.get_language() is None
1239+
t = fig.text(0.4, 0.55, cyrillic, fontsize=32, language='sr')
1240+
assert t.get_language() == 'sr'
1241+
t = fig.text(0.4, 0.3, cyrillic, fontsize=32)
1242+
t.set_language('ru')
1243+
assert t.get_language() == 'ru'
1244+
t = fig.text(0.4, 0.05, cyrillic * 4, fontsize=32,
1245+
language=[('ru', 0, 1), ('sr', 1, 2), ('ru', 2, 3), ('sr', 3, 4)])
1246+
assert t.get_language() == (('ru', 0, 1), ('sr', 1, 2), ('ru', 2, 3), ('sr', 3, 4))
1247+
1248+
# Or the Sámi family of languages in the Latin alphabet.
1249+
latin = '\U0000014a'
1250+
t = fig.text(0.7, 0.8, latin, fontsize=32)
1251+
assert t.get_language() is None
1252+
with plt.rc_context({'text.language': 'en'}):
1253+
t = fig.text(0.7, 0.55, latin, fontsize=32)
1254+
assert t.get_language() == 'en'
1255+
t = fig.text(0.7, 0.3, latin, fontsize=32, language='smn')
1256+
assert t.get_language() == 'smn'
1257+
# Tuples are not documented, but we'll allow it.
1258+
t = fig.text(0.7, 0.05, latin * 4, fontsize=32)
1259+
t.set_language((('en', 0, 1), ('smn', 1, 2), ('en', 2, 3), ('smn', 3, 4)))
1260+
assert t.get_language() == (
1261+
('en', 0, 1), ('smn', 1, 2), ('en', 2, 3), ('smn', 3, 4))

0 commit comments

Comments
 (0)