From 42a8813c7292d3c3efbaf8f52a0beea825c168bf 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 | 41 ++++++++++++++++++++++ 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 | 40 +++++++++++++++++++++ lib/matplotlib/text.pyi | 2 ++ lib/matplotlib/textpath.py | 9 ++--- lib/matplotlib/textpath.pyi | 9 ++++- src/ft2font.cpp | 4 ++- src/ft2font.h | 2 ++ src/ft2font_wrapper.cpp | 13 +++++-- 15 files changed, 143 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..220de6f370fc --- /dev/null +++ b/doc/users/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 the interaction of subranges and multiline text is currently +unspecified 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 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 f25b89e2b053..a1787f12fc68 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 ff351e301176..8cc0055aabf5 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() + features = mtext.get_fontfeatures() 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, 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)) @@ -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, 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 368564a1518d..309cf2bf2003 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: + 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 a413cd3c1a76..bc7a93523d58 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 8b448e17b7fd..a3371915a013 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -200,6 +200,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 acde4fb179a2..50a83e93a8e5 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,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[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. The interaction of subranges and multiline text is currently + unspecified and behavior 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((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 ca8881d98c50..286f1d84d36f 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) + std::u32string_view text, double angle, FT_Int32 flags, + std::optional> features, std::vector &xys) + // TODO: Apply features with libraqm. { FT_Matrix matrix; /* transformation matrix */ diff --git a/src/ft2font.h b/src/ft2font.h index 0881693e7557..89a9030be749 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -108,6 +109,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); void set_kerning_factor(int factor); diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index cb816efff9a9..b83ba99f42f8 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -702,6 +702,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 ------- @@ -711,7 +716,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; @@ -731,7 +737,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); @@ -1618,7 +1624,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__) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy