From fbc4fbe2d91fe75cd74b94d4201e997626011728 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Sat, 1 Mar 2025 00:39:18 -0500 Subject: [PATCH] Add font feature API to FontProperties and Text Font features allow font designers to provide alternate glyphs or shaping within a single font. These features may be accessed via special tags corresponding to internal tables of glyphs. The mplcairo backend supports font features via an elaborate re-use of the font file path [1]. This commit adds the API to make this officially supported in the main user API. At this time, nothing in Matplotlib itself uses these settings, but they will have an effect with libraqm. [1] https://github.com/matplotlib/mplcairo/blob/v0.6.1/README.rst#font-formats-and-features --- doc/users/next_whats_new/font_features.rst | 40 ++++++++++++++++++++++ lib/matplotlib/_text_helpers.py | 6 ++-- lib/matplotlib/backends/backend_agg.py | 3 +- lib/matplotlib/backends/backend_pdf.py | 6 ++-- lib/matplotlib/backends/backend_ps.py | 3 +- lib/matplotlib/font_manager.py | 2 +- lib/matplotlib/ft2font.pyi | 7 +++- lib/matplotlib/tests/test_ft2font.py | 13 +++++++ lib/matplotlib/text.py | 39 +++++++++++++++++++++ lib/matplotlib/text.pyi | 2 ++ lib/matplotlib/textpath.py | 9 ++--- lib/matplotlib/textpath.pyi | 9 ++++- src/ft2font.cpp | 3 +- src/ft2font.h | 2 ++ src/ft2font_wrapper.cpp | 13 +++++-- 15 files changed, 140 insertions(+), 17 deletions(-) create mode 100644 doc/users/next_whats_new/font_features.rst diff --git a/doc/users/next_whats_new/font_features.rst b/doc/users/next_whats_new/font_features.rst new file mode 100644 index 000000000000..fda2604b910a --- /dev/null +++ b/doc/users/next_whats_new/font_features.rst @@ -0,0 +1,40 @@ +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. + +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 b9603b114bc2..676ea6eed1b8 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, *, features=None, kern_mode=Kerning.DEFAULT): """ Render *string* with *font*. @@ -56,6 +56,8 @@ 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. @@ -65,7 +67,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 features. 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 b435ae565ce4..262a7328f1d4 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(), + features=mtext.get_fontfeatures() 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 eb9d217c932c..53bf6ceaa231 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -2338,6 +2338,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() + features = mtext.get_fontfeatures() if mtext is not None else None if mpl.rcParams['pdf.use14corefonts']: font = self._get_font_afm(prop) @@ -2348,7 +2349,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) width, height = font.get_width_height() self.file._annotations[-1][1].append(_get_link_annotation( gc, x, y, width / 64, height / 64, angle)) @@ -2382,7 +2383,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, features=features, + 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 62952caa32e1..7624de71743f 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -795,9 +795,10 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): thisx += width * scale else: + features = mtext.get_fontfeatures() 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, features=features): 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/font_manager.py b/lib/matplotlib/font_manager.py index 2db98b75ab2e..30e1b9aea59f 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -536,7 +536,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 b12710afd801..0edb9b3ffaed 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 = ..., + *, + features: tuple[str] | None = ..., ) -> NDArray[np.float64]: ... @property def ascender(self) -> int: ... diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index a9f2a56658aa..1f8bb4c3ccdc 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -199,6 +199,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/text.py b/lib/matplotlib/text.py index 3b0de58814d9..9cdce048e870 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._features = None self._reset_visual_defaults( text=text, color=color, @@ -847,6 +848,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. @@ -1094,6 +1101,38 @@ 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[str] + 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. + + 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((list, tuple, 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 41c7b761ae32..6fc81a152adb 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: list[str] | tuple[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 b57597ded363..1e16897ed274 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, *, features=None): """ Convert text *s* to path (a tuple of vertices and codes for matplotlib.path.Path). @@ -109,7 +109,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, features=features) else: glyph_info, glyph_map, rects = self.get_glyphs_mathtext(prop, s) @@ -130,7 +131,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, *, features=None): """ Convert string *s* to vertices and codes using the provided ttf font. """ @@ -145,7 +146,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, features=features): 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..0599ac012b23 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 = ..., ) -> list[np.ndarray]: ... def get_glyphs_with_font( self, @@ -24,6 +29,8 @@ class TextToPath: s: str, glyph_map: dict[str, tuple[np.ndarray, np.ndarray]] | None = ..., return_new_glyphs_only: bool = ..., + *, + features: tuple[str] | 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 94c554cf9f63..a629b3525e5f 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -397,7 +397,8 @@ void FT2Font::set_kerning_factor(int factor) } void FT2Font::set_text( - std::u32string_view text, double angle, FT_Int32 flags, std::vector &xys) + std::u32string_view text, double angle, FT_Int32 flags, + std::optional> features, std::vector &xys) { FT_Matrix matrix; /* transformation matrix */ diff --git a/src/ft2font.h b/src/ft2font.h index cb38e337157a..a27ba4cb5ccc 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -6,6 +6,7 @@ #ifndef MPL_FT2FONT_H #define MPL_FT2FONT_H +#include #include #include #include @@ -79,6 +80,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::optional> features, std::vector &xys); int get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode, bool fallback); int get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode, FT_Vector &delta); diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 18f26ad4e76b..bcac18d2bc90 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -703,6 +703,11 @@ 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 Returns ------- @@ -712,7 +717,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::optional> features = std::nullopt) { std::vector xys; LoadFlags flags; @@ -732,7 +738,7 @@ 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); + self->x->set_text(text, angle, static_cast(flags), features, xys); py::ssize_t dims[] = { static_cast(xys.size()) / 2, 2 }; py::array_t result(dims); @@ -1621,7 +1627,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(), + "features"_a=nullptr, PyFT2Font_set_text__doc__) .def("_get_fontmap", &PyFT2Font_get_fontmap, "string"_a, PyFT2Font_get_fontmap__doc__)