diff --git a/doc/release/next_whats_new/font_features.rst b/doc/release/next_whats_new/font_features.rst new file mode 100644 index 000000000000..022d36e1e21d --- /dev/null +++ b/doc/release/next_whats_new/font_features.rst @@ -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() +`__ 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 diff --git a/lib/matplotlib/_text_helpers.py b/lib/matplotlib/_text_helpers.py index fa5d36bc99c8..e4e6bb03a145 100644 --- a/lib/matplotlib/_text_helpers.py +++ b/lib/matplotlib/_text_helpers.py @@ -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*. @@ -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 @@ -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 diff --git a/lib/matplotlib/backends/backend_agg.py b/lib/matplotlib/backends/backend_agg.py index 2da422a88e84..43d40d1c0c68 100644 --- a/lib/matplotlib/backends/backend_agg.py +++ b/lib/matplotlib/backends/backend_agg.py @@ -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()) diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index a850f229ab29..a5035d16e24f 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -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) @@ -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)) @@ -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) diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index 374e06da68e9..2743da13aec5 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -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. diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index 47339d4491dd..d1a96826fbf6 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -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. diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index fce67131e67b..a4d1c77061be 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -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 diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index e27a00b740e3..3c066a59e939 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -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. diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 4d8a7a59c731..e3bec7c36910 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -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', [ diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index 4d80f9874941..827b6bcb7667 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -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, @@ -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. @@ -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() + `__ + 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. diff --git a/lib/matplotlib/text.pyi b/lib/matplotlib/text.pyi index eb3c076b1c5c..7992ecb20a8d 100644 --- a/lib/matplotlib/text.pyi +++ b/lib/matplotlib/text.pyi @@ -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: ... @@ -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"] diff --git a/lib/matplotlib/textpath.py b/lib/matplotlib/textpath.py index 6f6f4daa4cfa..e7bb95159deb 100644 --- a/lib/matplotlib/textpath.py +++ b/lib/matplotlib/textpath.py @@ -67,7 +67,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, *, language=None): + def get_text_path(self, prop, s, ismath=False, *, features=None, language=None): """ Convert text *s* to path (a tuple of vertices and codes for matplotlib.path.Path). @@ -110,8 +110,8 @@ def get_text_path(self, prop, s, ismath=False, *, language=None): 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, - language=language) + glyph_info, glyph_map, rects = self.get_glyphs_with_font( + font, s, features=features, language=language) else: glyph_info, glyph_map, rects = self.get_glyphs_mathtext(prop, s) @@ -132,7 +132,8 @@ def get_text_path(self, prop, s, ismath=False, *, language=None): return verts, codes def get_glyphs_with_font(self, font, s, glyph_map=None, - return_new_glyphs_only=False, *, language=None): + return_new_glyphs_only=False, *, features=None, + language=None): """ Convert string *s* to vertices and codes using the provided ttf font. """ @@ -147,7 +148,7 @@ def get_glyphs_with_font(self, font, s, glyph_map=None, xpositions = [] glyph_reprs = [] - for item in _text_helpers.layout(s, font, language=language): + for item in _text_helpers.layout(s, font, features=features, language=language): glyph_repr = self._get_glyph_repr(item.ft_object, item.glyph_index) glyph_reprs.append(glyph_repr) xpositions.append(item.x) diff --git a/lib/matplotlib/textpath.pyi b/lib/matplotlib/textpath.pyi index b83b337aa541..07f81598aa75 100644 --- a/lib/matplotlib/textpath.pyi +++ b/lib/matplotlib/textpath.pyi @@ -16,7 +16,12 @@ 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"] = ..., + *, + features: tuple[str] | None = ..., language: str | list[tuple[str, int, int]] | None = ..., ) -> list[np.ndarray]: ... def get_glyphs_with_font( @@ -26,6 +31,7 @@ class TextToPath: glyph_map: dict[str, tuple[np.ndarray, np.ndarray]] | None = ..., return_new_glyphs_only: bool = ..., *, + features: tuple[str] | None = ..., language: str | list[tuple[str, int, int]] | None = ..., ) -> tuple[ list[tuple[str, float, float, float]], diff --git a/src/ft2font.cpp b/src/ft2font.cpp index 6f3db040f17d..8838f68ee5f8 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -320,7 +320,7 @@ void FT2Font::set_kerning_factor(int factor) std::vector FT2Font::layout( std::u32string_view text, FT_Int32 flags, - LanguageType languages, + std::optional> features, LanguageType languages, std::set& glyph_seen_fonts) { clear(); @@ -344,6 +344,13 @@ std::vector FT2Font::layout( if (!raqm_set_freetype_load_flags(rq, flags)) { throw std::runtime_error("failed to set text flags for layout"); } + if (features) { + for (auto const& feature : *features) { + if (!raqm_add_font_feature(rq, feature.c_str(), feature.size())) { + throw std::runtime_error("failed to set font feature {}"_s.format(feature)); + } + } + } if (languages) { for (auto & [lang_str, start, end] : *languages) { if (!raqm_set_language(rq, lang_str.c_str(), start, end - start)) { @@ -417,6 +424,14 @@ std::vector FT2Font::layout( if (!raqm_set_freetype_load_flags(rq, flags)) { throw std::runtime_error("failed to set text flags for layout"); } + if (features) { + for (auto const& feature : *features) { + if (!raqm_add_font_feature(rq, feature.c_str(), feature.size())) { + throw std::runtime_error( + "failed to set font feature {}"_s.format(feature)); + } + } + } if (languages) { for (auto & [lang_str, start, end] : *languages) { if (!raqm_set_language(rq, lang_str.c_str(), start, end - start)) { @@ -440,7 +455,7 @@ std::vector FT2Font::layout( void FT2Font::set_text( std::u32string_view text, double angle, FT_Int32 flags, - LanguageType languages, + std::optional> features, LanguageType languages, std::vector &xys) { FT_Matrix matrix; /* transformation matrix */ @@ -457,7 +472,7 @@ void FT2Font::set_text( matrix.yy = (FT_Fixed)cosangle; std::set glyph_seen_fonts; - auto rq_glyphs = layout(text, flags, languages, glyph_seen_fonts); + auto rq_glyphs = layout(text, flags, features, languages, glyph_seen_fonts); bbox.xMin = bbox.yMin = 32000; bbox.xMax = bbox.yMax = -32000; diff --git a/src/ft2font.h b/src/ft2font.h index 841c66cfb5ee..b1458fe28ada 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -114,9 +114,11 @@ class FT2Font void set_charmap(int i); void select_charmap(unsigned long i); std::vector layout(std::u32string_view text, FT_Int32 flags, + std::optional> features, LanguageType languages, std::set& glyph_seen_fonts); void set_text(std::u32string_view codepoints, double angle, FT_Int32 flags, + std::optional> features, LanguageType languages, std::vector &xys); int get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode); void set_kerning_factor(int factor); diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index e0d5e0c23391..a348f0d312b6 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -687,6 +687,13 @@ const char *PyFT2Font_set_text__doc__ = R"""( .. versionchanged:: 3.10 This now takes an `.ft2font.LoadFlags` instead of an int. + features : tuple[str, ...] + The font feature tags to use for the font. + + Available font feature tags may be found at + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist + + .. versionadded:: 3.11 Returns ------- @@ -697,6 +704,7 @@ 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::optional> features = std::nullopt, std::variant languages_or_str = nullptr) { std::vector xys; @@ -731,7 +739,7 @@ PyFT2Font_set_text(PyFT2Font *self, std::u32string_view text, double angle = 0.0 throw py::type_error("languages must be str or list of tuple"); } - self->set_text(text, angle, static_cast(flags), languages, xys); + self->set_text(text, angle, static_cast(flags), features, languages, xys); py::ssize_t dims[] = { static_cast(xys.size()) / 2, 2 }; py::array_t result(dims); @@ -1553,7 +1561,7 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) PyFT2Font_get_kerning__doc__) .def("set_text", &PyFT2Font_set_text, "string"_a, "angle"_a=0.0, "flags"_a=LoadFlags::FORCE_AUTOHINT, py::kw_only(), - "language"_a=nullptr, + "features"_a=nullptr, "language"_a=nullptr, PyFT2Font_set_text__doc__) .def("_get_fontmap", &PyFT2Font_get_fontmap, "string"_a, PyFT2Font_get_fontmap__doc__)