diff --git a/doc/users/next_whats_new/text_language.rst b/doc/users/next_whats_new/text_language.rst new file mode 100644 index 000000000000..23d6d9916817 --- /dev/null +++ b/doc/users/next_whats_new/text_language.rst @@ -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 `_. If None or +unset, then no particular language will be implied, and default font settings will be +used. + +For example, the 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') diff --git a/lib/matplotlib/_text_helpers.py b/lib/matplotlib/_text_helpers.py index b9603b114bc2..d0f776b2c0b1 100644 --- a/lib/matplotlib/_text_helpers.py +++ b/lib/matplotlib/_text_helpers.py @@ -43,7 +43,7 @@ def warn_on_missing_glyph(codepoint, fontnames): f"Matplotlib currently does not support {block} natively.") -def layout(string, font, *, kern_mode=Kerning.DEFAULT): +def layout(string, font, *, language=None, kern_mode=Kerning.DEFAULT): """ Render *string* with *font*. @@ -56,6 +56,9 @@ def layout(string, font, *, kern_mode=Kerning.DEFAULT): The string to be rendered. font : FT2Font The font. + language : str or list of tuples of (str, int, int), optional + The language of the text in a format accepted by libraqm, namely `a BCP47 + language code `_. kern_mode : Kerning A FreeType kerning mode. @@ -65,7 +68,7 @@ def layout(string, font, *, kern_mode=Kerning.DEFAULT): """ x = 0 prev_glyph_idx = None - char_to_font = font._get_fontmap(string) + char_to_font = font._get_fontmap(string) # TODO: Pass in language. base_font = font for char in string: # This has done the fallback logic diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index f25b89e2b053..c646e08481a3 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -189,7 +189,8 @@ 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(), + 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 diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index ff351e301176..aea270dbd347 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -2345,6 +2345,7 @@ 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 mpl.rcParams['pdf.use14corefonts']: font = self._get_font_afm(prop) @@ -2355,7 +2356,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, 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)) @@ -2389,7 +2390,8 @@ 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, language=language, + kern_mode=Kerning.UNFITTED): 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, [])) diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index 368564a1518d..a3ba63ccf079 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -794,9 +794,10 @@ 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 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, 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) diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index a413cd3c1a76..90e531700b8a 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -236,7 +236,12 @@ 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 = ..., + *, + language: str | list[tuple[str, int, int]] | None = ..., ) -> NDArray[np.float64]: ... @property def ascender(self) -> int: ... diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index ec649560ba3b..187c4cdf9c63 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -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 `_. 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): diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 80d25659888e..cc312cf4f560 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -1045,6 +1045,7 @@ def _convert_validator_spec(key, conv): "text.kerning_factor": validate_int, "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, diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index 8b448e17b7fd..529105366c73 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -775,6 +775,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, _kerning_factor=0) + with pytest.raises(TypeError): + font.set_text('foo', language=input) + + +def test_ft2font_language(): + # TODO: This is just a smoke test. + file = fm.findfont('DejaVu Sans') + font = ft2font.FT2Font(file, hinting_factor=1, _kerning_factor=0) + 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, _kerning_factor=0) diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 7e1a50df8a2f..6c51f4d99a3a 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -1199,3 +1199,28 @@ def test_ytick_rotation_mode(): tick.set_rotation(angle) plt.subplots_adjust(left=0.4, right=0.6, top=.99, bottom=.01) + + +@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) + + +def test_text_language(): + # TODO: This is just a smoke test. + Text(0, 0, 'foo', language='en') + Text(0, 0, 'foo').set_language('en') + Text(0, 0, 'foo', language=[('en', 1, 2)]) + Text(0, 0, 'foo').set_language([('en', 1, 2)]) + # Not documented, but we'll allow it. + Text(0, 0, 'foo', language=(('en', 1, 2), )) + Text(0, 0, 'foo').set_language((('en', 1, 2), )) diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index acde4fb179a2..ffc2dae094ed 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -136,6 +136,7 @@ def __init__(self, super().__init__() self._x, self._y = x, y self._text = '' + self._language = None self._reset_visual_defaults( text=text, color=color, @@ -1422,6 +1423,41 @@ def _va_for_angle(self, angle): return 'baseline' if anchor_at_left else 'top' return 'top' if anchor_at_left else 'baseline' + def get_language(self): + """Return the language this Text is in.""" + return self._language + + def set_language(self, language): + """ + Set the language of the text. + + Parameters + ---------- + language : str or list[tuple[str, int, int]] or None + The language of the text in a format accepted by libraqm, namely `a BCP47 + language code `_. + + If None, then defaults to :rc:`text.language`. + """ + _api.check_isinstance((list, tuple, str, None), language=language) + language = mpl._val_or_rc(language, 'text.language') + + if not cbook.is_scalar_or_string(language): + for val in language: + if not isinstance(val, tuple) or len(val) != 3: + raise TypeError('language must be list of tuple, not {language!r}') + sublang, start, end = val + if not isinstance(sublang, str): + raise TypeError( + 'sub-language specification must be str, not {sublang!r}') + if not isinstance(start, int): + raise TypeError('start location must be int, not {start!r}') + if not isinstance(end, int): + raise TypeError('end location must be int, not {end!r}') + + self._language = language + self.stale = True + class OffsetFrom: """Callable helper class for working with `Annotation`.""" diff --git a/lib/matplotlib/text.pyi b/lib/matplotlib/text.pyi index 41c7b761ae32..8b7993189b99 100644 --- a/lib/matplotlib/text.pyi +++ b/lib/matplotlib/text.pyi @@ -108,6 +108,11 @@ class Text(Artist): def set_antialiased(self, antialiased: bool) -> None: ... def _ha_for_angle(self, angle: Any) -> Literal['center', 'right', 'left'] | None: ... def _va_for_angle(self, angle: Any) -> Literal['center', 'top', 'baseline'] | None: ... + def get_language(self) -> str | list[tuple[str, int, int]] | None: ... + def set_language( + self, + language: str | list[tuple[str, int, int]] | tuple[tuple[str, int, int], ...] | None, + ) -> None: ... class OffsetFrom: def __init__( diff --git a/lib/matplotlib/textpath.py b/lib/matplotlib/textpath.py index b57597ded363..8c02c281ddd0 100644 --- a/lib/matplotlib/textpath.py +++ b/lib/matplotlib/textpath.py @@ -69,7 +69,7 @@ def get_text_width_height_descent(self, s, prop, ismath): d /= 64.0 return w * scale, h * scale, d * scale - def get_text_path(self, prop, s, ismath=False): + def get_text_path(self, prop, s, ismath=False, *, language=None): """ Convert text *s* to path (a tuple of vertices and codes for matplotlib.path.Path). @@ -82,6 +82,9 @@ def get_text_path(self, prop, s, ismath=False): The text to be converted. ismath : {False, True, "TeX"} If True, use mathtext parser. If "TeX", use tex for rendering. + language : str or list of tuples of (str, int, int), optional + The language of the text in a format accepted by libraqm, namely `a BCP47 + language code `_. Returns ------- @@ -109,7 +112,8 @@ def get_text_path(self, prop, s, ismath=False): glyph_info, glyph_map, rects = self.get_glyphs_tex(prop, s) elif not ismath: font = self._get_font(prop) - glyph_info, glyph_map, rects = self.get_glyphs_with_font(font, s) + glyph_info, glyph_map, rects = self.get_glyphs_with_font(font, s, + language=language) else: glyph_info, glyph_map, rects = self.get_glyphs_mathtext(prop, s) @@ -130,7 +134,7 @@ def get_text_path(self, prop, s, ismath=False): return verts, codes def get_glyphs_with_font(self, font, s, glyph_map=None, - return_new_glyphs_only=False): + return_new_glyphs_only=False, *, language=None): """ Convert string *s* to vertices and codes using the provided ttf font. """ @@ -145,7 +149,7 @@ def get_glyphs_with_font(self, font, s, glyph_map=None, xpositions = [] glyph_ids = [] - for item in _text_helpers.layout(s, font): + for item in _text_helpers.layout(s, font, language=language): char_id = self._get_char_id(item.ft_object, ord(item.char)) glyph_ids.append(char_id) xpositions.append(item.x) diff --git a/lib/matplotlib/textpath.pyi b/lib/matplotlib/textpath.pyi index 34d4e92ac47e..b83b337aa541 100644 --- a/lib/matplotlib/textpath.pyi +++ b/lib/matplotlib/textpath.pyi @@ -16,7 +16,8 @@ class TextToPath: self, s: str, prop: FontProperties, ismath: bool | Literal["TeX"] ) -> tuple[float, float, float]: ... def get_text_path( - self, prop: FontProperties, s: str, ismath: bool | Literal["TeX"] = ... + self, prop: FontProperties, s: str, ismath: bool | Literal["TeX"] = ..., *, + language: str | list[tuple[str, int, int]] | None = ..., ) -> list[np.ndarray]: ... def get_glyphs_with_font( self, @@ -24,6 +25,8 @@ class TextToPath: s: str, glyph_map: dict[str, tuple[np.ndarray, np.ndarray]] | None = ..., return_new_glyphs_only: bool = ..., + *, + language: str | list[tuple[str, int, int]] | None = ..., ) -> tuple[ list[tuple[str, float, float, float]], dict[str, tuple[np.ndarray, np.ndarray]], diff --git a/src/ft2font.cpp b/src/ft2font.cpp index ca8881d98c50..bc1866601d8b 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -309,7 +309,9 @@ void FT2Font::set_kerning_factor(int factor) } void FT2Font::set_text( - std::u32string_view text, double angle, FT_Int32 flags, std::vector &xys) + // TODO: languages is unused with FreeType, but will with libraqm. + std::u32string_view text, double angle, FT_Int32 flags, LanguageType languages, + std::vector &xys) { FT_Matrix matrix; /* transformation matrix */ diff --git a/src/ft2font.h b/src/ft2font.h index 0881693e7557..22d73cece382 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -99,6 +100,9 @@ class FT2Font typedef void (*WarnFunc)(FT_ULong charcode, std::set family_names); public: + using LanguageRange = std::tuple; + using LanguageType = std::optional>; + FT2Font(FT_Open_Args &open_args, long hinting_factor, std::vector &fallback_list, WarnFunc warn, bool warn_if_used); @@ -108,7 +112,7 @@ class FT2Font void set_charmap(int i); void select_charmap(unsigned long i); void set_text(std::u32string_view codepoints, double angle, FT_Int32 flags, - std::vector &xys); + LanguageType languages, std::vector &xys); int get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode); void set_kerning_factor(int factor); void load_char(long charcode, FT_Int32 flags, FT2Font *&ft_object, bool fallback); diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index cb816efff9a9..a9b50cd88b34 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -711,7 +711,8 @@ const char *PyFT2Font_set_text__doc__ = R"""( static py::array_t PyFT2Font_set_text(PyFT2Font *self, std::u32string_view text, double angle = 0.0, - std::variant flags_or_int = LoadFlags::FORCE_AUTOHINT) + std::variant flags_or_int = LoadFlags::FORCE_AUTOHINT, + std::variant languages_or_str = nullptr) { std::vector xys; LoadFlags flags; @@ -731,7 +732,21 @@ PyFT2Font_set_text(PyFT2Font *self, std::u32string_view text, double angle = 0.0 throw py::type_error("flags must be LoadFlags or int"); } - self->x->set_text(text, angle, static_cast(flags), xys); + FT2Font::LanguageType languages; + if (auto value = std::get_if(&languages_or_str)) { + languages = std::move(*value); + } else if (auto value = std::get_if(&languages_or_str)) { + languages = std::vector{ + FT2Font::LanguageRange{*value, 0, text.size()} + }; + } else { + // NOTE: this can never happen as pybind11 would have checked the type in the + // Python wrapper before calling this function, but we need to keep the + // std::get_if instead of std::get for macOS 10.12 compatibility. + throw py::type_error("languages must be str or list of tuple"); + } + + self->x->set_text(text, angle, static_cast(flags), languages, xys); py::ssize_t dims[] = { static_cast(xys.size()) / 2, 2 }; py::array_t result(dims); @@ -1618,7 +1633,8 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) .def("get_kerning", &PyFT2Font_get_kerning, "left"_a, "right"_a, "mode"_a, PyFT2Font_get_kerning__doc__) .def("set_text", &PyFT2Font_set_text, - "string"_a, "angle"_a=0.0, "flags"_a=LoadFlags::FORCE_AUTOHINT, + "string"_a, "angle"_a=0.0, "flags"_a=LoadFlags::FORCE_AUTOHINT, py::kw_only(), + "language"_a=nullptr, PyFT2Font_set_text__doc__) .def("_get_fontmap", &PyFT2Font_get_fontmap, "string"_a, PyFT2Font_get_fontmap__doc__)