From d48619c2ae733cfc093810135711cdef218a69a3 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 1 Apr 2026 18:17:22 -0400 Subject: [PATCH] ft2font: Add internal API for accessing font variations --- lib/matplotlib/ft2font.pyi | 32 +++ lib/matplotlib/tests/test_ft2font.py | 14 ++ src/ft2font.cpp | 70 +++++++ src/ft2font.h | 19 ++ src/ft2font_wrapper.cpp | 283 +++++++++++++++++++++++++++ 5 files changed, 418 insertions(+) diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index 3003f83932bc..b6a24f4e70dd 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -215,6 +215,34 @@ class _SfntPcltDict(TypedDict): widthType: int serifStyle: int +class VarAxisFlags(Flag): + DEFAULT = cast(int, ...) + HIDDEN = cast(int, ...) + +@final +class VariationAxis: + @property + def name(self) -> str: ... + @property + def minimum(self) -> float: ... + @property + def default(self) -> float: ... + @property + def maximum(self) -> float: ... + @property + def tag(self) -> int: ... + @property + def names(self) -> dict[tuple[int, int, int], str | bytes]: ... + @property + def flags(self) -> VarAxisFlags: ... + +@final +class VariationNamedStyle: + @property + def names(self) -> dict[tuple[int, int, int], str | bytes]: ... + @property + def psnames(self) -> dict[tuple[int, int, int], str | bytes] | None: ... + @final class LayoutItem: @property @@ -299,6 +327,10 @@ class FT2Font(Buffer): features: tuple[str] | None = ..., language: str | list[tuple[str, int, int]] | None = ..., ) -> NDArray[np.float64]: ... + def get_variation_descriptor(self) -> tuple[list[VariationAxis], list[VariationNamedStyle]]: ... + def get_default_variation_style(self) -> int: ... + def get_variations(self) -> tuple[float, ...]: ... + def set_variations(self, variations: tuple[float, ...] | int) -> None: ... @property def ascender(self) -> int: ... @property diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index 8b44792a0c2d..e80c57412b27 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -905,6 +905,20 @@ def test_ft2font_loading(): assert font.get_bitmap_offset() == (0, 0) +def test_ft2font_variations_invalid(): + # Smoke test as we don't have a font that has variations built in. + file = fm.findfont('DejaVu Sans') + font = ft2font.FT2Font(file) + with pytest.raises(RuntimeError): + font.get_variation_descriptor() + with pytest.raises(RuntimeError): + font.get_variations() + with pytest.raises(RuntimeError): + font.get_default_variation_style() + with pytest.raises(RuntimeError): + font.set_variations([0.0]) + + def test_ft2font_drawing(): expected_str = ( ' ', diff --git a/src/ft2font.cpp b/src/ft2font.cpp index dc9397dd75f0..83c1531095fd 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -780,3 +780,73 @@ long FT2Font::get_name_index(char *name) { return FT_Get_Name_Index(face, (FT_String *)name); } + +FT2Font::VariationInfo FT2Font::get_variation_descriptor() +{ + FT_MM_Var *master; + FT_CHECK(FT_Get_MM_Var, face, &master); + + std::vector axes; + axes.reserve(master->num_axis); + for (FT_UInt axis_index = 0; axis_index < master->num_axis; axis_index++) { + FT_UInt flags = 0; + FT_CHECK(FT_Get_Var_Axis_Flags, master, axis_index, &flags); + const auto &axis = master->axis[axis_index]; + axes.emplace_back(axis.name, + float(axis.minimum) / (1<<16), + float(axis.def) / (1<<16), + float(axis.maximum) / (1<<16), + axis.tag, + axis.strid, + flags); + } + + std::vector styles; + styles.reserve(master->num_namedstyles); + for (FT_UInt instance = 0; instance < master->num_namedstyles; instance++) { + const auto &style = master->namedstyle[instance]; + styles.emplace_back(style.strid, style.psid); + } + + FT_CHECK(FT_Done_MM_Var, _ft2Library, master); + + return {axes, styles}; +} + +FT_UInt FT2Font::get_default_variation_style() { + FT_UInt result; + FT_CHECK(FT_Get_Default_Named_Instance, face, &result); + return result; +} + +std::vector FT2Font::get_variations() { + FT_MM_Var *master; + FT_CHECK(FT_Get_MM_Var, face, &master); + std::vector fixed_coords(master->num_axis); + FT_CHECK(FT_Done_MM_Var, _ft2Library, master); + + FT_CHECK(FT_Get_Var_Design_Coordinates, face, fixed_coords.size(), + fixed_coords.data()); + + std::vector coords; + coords.reserve(fixed_coords.size()); + for (auto const &c : fixed_coords) { + coords.emplace_back(static_cast(c) / (1 << 16)); + } + + return coords; +} + +void FT2Font::set_variations(std::vector coords) { + std::vector fixed_coords; + fixed_coords.reserve(coords.size()); + for (auto const &c : coords) { + fixed_coords.emplace_back(static_cast(c * (1 << 16))); + } + FT_CHECK(FT_Set_Var_Design_Coordinates, face, fixed_coords.size(), + fixed_coords.data()); +} + +void FT2Font::set_variations(FT_UInt instance_index) { + FT_CHECK(FT_Set_Named_Instance, face, instance_index); +} diff --git a/src/ft2font.h b/src/ft2font.h index 3facec0fb244..a6a2168a249b 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -22,9 +22,11 @@ extern "C" { #include FT_BITMAP_H #include FT_FREETYPE_H #include FT_GLYPH_H +#include FT_MULTIPLE_MASTERS_H #include FT_OUTLINE_H #include FT_SFNT_NAMES_H #include FT_TYPE1_TABLES_H +#include FT_TRUETYPE_IDS_H #include FT_TRUETYPE_TABLES_H } @@ -183,6 +185,23 @@ class FT2Font return FT_HAS_KERNING(face); } + using VariationAxis = std::tuple< + std::string, // name + FT_Fixed, // minimum + FT_Fixed, // default + FT_Fixed, // maximum + FT_ULong, // tag + FT_UInt, // name ID + FT_UInt>; // flags + using VariationNamedStyle = std::tuple; // name ID, postscript ID + using VariationInfo = std::tuple, + std::vector>; + VariationInfo get_variation_descriptor(); + FT_UInt get_default_variation_style(); + std::vector get_variations(); + void set_variations(std::vector coords); + void set_variations(FT_UInt instance_index); + protected: virtual void ft_glyph_warn(FT_ULong charcode, std::set family_names) = 0; private: diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index bf345cd1d044..14422d8972bb 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -247,6 +247,28 @@ P11X_DECLARE_ENUM( {"BOLD", StyleFlags::BOLD}, ); +const char *VarAxisFlags__doc__ = R"""( + Flags from an OpenType Variation Axis Record. + + For more information, see `the FreeType documentation + `_. + + .. versionadded:: 3.11 +)"""; + +enum class VarAxisFlags : FT_UInt { +#define DECLARE_FLAG(name) name = FT_VAR_AXIS_FLAG_##name + DEFAULT = 0, + DECLARE_FLAG(HIDDEN) +#undef DECLARE_FLAG +}; + +P11X_DECLARE_ENUM( + "VarAxisFlags", "Flag", + {"DEFAULT", VarAxisFlags::DEFAULT}, + {"HIDDEN", VarAxisFlags::HIDDEN}, +); + /********************************************************************** * FT2Image * */ @@ -1464,6 +1486,214 @@ PyFT2Font__get_type1_encoding_vector(PyFT2Font *self) return indices; } +/********************************************************************** + * Font variations + * */ + +const char *PyVariationAxis__doc__ = R"""( + A description of a variation axis. + + For more information, see `the FreeType documentation + `_. + + .. versionadded:: 3.11 + + Attributes + ---------- + name: str + The axis's name. + minimum: float + The axis's minimum design coordinate. + default: float + The axis's default design coordinate. + maximum: float + The axis's maximum design coordinate. + tag: int + The axis's tag. + names: dict + The (possibly localized) names for the axis. The dictionary is keyed by + (platform_id, encoding_id, language_id) with values of the names. Names are + decoded to strings on a best effort basis, otherwise kept as bytes. + flags: VarAxisFlags + Flags describing this record. +)"""; + +struct PyVariationAxis { + std::string name; + FT_Fixed minimum; + FT_Fixed default_; + FT_Fixed maximum; + FT_ULong tag; + py::dict names; + VarAxisFlags flags; + + PyVariationAxis(const FT2Font::VariationAxis &info) { + FT_UInt name_id, int_flags; + std::tie(name, minimum, default_, maximum, tag, name_id, int_flags) = info; + flags = static_cast(int_flags); + } +}; + +const char *PyVariationNamedStyle__doc__ = R"""( + A description of a named style. + + For more information, see `the FreeType documentation + `_. + + .. versionadded:: 3.11 + + Attributes + ---------- + names: dict + The (possibly localized) names for the style. The dictionary is keyed by + (platform_id, encoding_id, language_id) with values of the names. Names are + decoded to strings on a best effort basis, otherwise kept as bytes. + psnames: dict, optional + The (possibly localized) PostScript names for the style. The dictionary is keyed + by (platform_id, encoding_id, language_id) with values of the names. Names are + decoded to strings on a best effort basis, otherwise kept as bytes. +)"""; + +struct PyVariationNamedStyle { + py::dict names; + py::object psnames; + + PyVariationNamedStyle(const FT2Font::VariationNamedStyle &style) { + auto psid = std::get<1>(style); + if (psid != 65535) { + psnames = py::dict(); + } else { + psnames = py::none(); + } + } +}; + +const char *PyFT2Font_get_variation_descriptor__doc__ = R"""( + Return the variation information of the font. + + .. note:: + This function only works on fonts that have `.FaceFlags.MULTIPLE_MASTERS` set in + their `.face_flags`. + + .. versionadded:: 3.11 + + Returns + ------- + axes : list of VariationAxis + A model of each axis in design space for Adobe MM fonts, TrueType GX, and + OpenType Font Variations. + styles : list of VariationNamedStyle + A model of each named instance in a TrueType GX or OpenType Font Variations. +)"""; + +static auto +PyFT2Font_get_variation_descriptor(PyFT2Font *self) +{ + auto [axes, styles] = self->get_variation_descriptor(); + + std::map> all_names; + + std::vector py_axes; + py_axes.reserve(axes.size()); + for (const auto &axis : axes) { + const auto &py_axis = py_axes.emplace_back(axis); + all_names[std::get<5>(axis)].push_back(py_axis.names); + } + + std::vector py_styles; + py_styles.reserve(styles.size()); + for (const auto &style : styles) { + const auto &py_style = py_styles.emplace_back(style); + all_names[std::get<0>(style)].push_back(py_style.names); + if (!py_style.psnames.is_none()) { + all_names[std::get<1>(style)].push_back(py_style.psnames); + } + } + + auto encodingTools = py::module_::import("fontTools.misc.encodingTools"); + auto getEncoding = encodingTools.attr("getEncoding"); + size_t count = FT_Get_Sfnt_Name_Count(self->get_face()); + for (FT_UInt i = 0; i < count; ++i) { + FT_SfntName sfnt; + FT_CHECK(FT_Get_Sfnt_Name, self->get_face(), i, &sfnt); + + const auto &names = all_names.find(sfnt.name_id); + if (names == all_names.end()) { + continue; + } + + auto key = py::make_tuple(sfnt.platform_id, sfnt.encoding_id, sfnt.language_id); + auto valb = py::bytes(reinterpret_cast(sfnt.string), + sfnt.string_len); + auto encoding = getEncoding(sfnt.platform_id, sfnt.encoding_id, sfnt.language_id); + py::object val = encoding.is_none() ? valb : valb.attr("decode")(encoding); + for (const auto &n : names->second) { + n[key] = val; + } + } + + return std::make_tuple(py_axes, py_styles); +} + +const char *PyFT2Font_get_default_variation_style__doc__ = R"""( + Return the default variation style. + + .. note:: + This function only works on fonts that have `.FaceFlags.MULTIPLE_MASTERS` set in + their `.face_flags`. + + .. versionadded:: 3.11 + + Returns + ------- + int + The index of the default variation style. +)"""; + +const char *PyFT2Font_get_variations__doc__ = R"""( + Return the current variation settings. + + .. note:: + This function only works on fonts that have `.FaceFlags.MULTIPLE_MASTERS` set in + their `.face_flags`. + + .. versionadded:: 3.11 + + Returns + ------- + list of float + The current settings, in the same order as the axes returned from + `.get_variation_descriptor`. +)"""; + +const char *PyFT2Font_set_variations_index__doc__ = R"""( + Set the variations from a named style index. + + .. note:: + This function only works on fonts that have `.FaceFlags.MULTIPLE_MASTERS` set in + their `.face_flags`. + + Parameters + ---------- + index: int + The index of the named style, in the same order of the styles from + `.get_variation_descriptor`. +)"""; + +const char *PyFT2Font_set_variations_vector__doc__ = R"""( + Set the variations from a list of axis values. + + .. note:: + This function only works on fonts that have `.FaceFlags.MULTIPLE_MASTERS` set in + their `.face_flags`. + + Parameters + ---------- + coords: list of float + The variation settings, in the same order as the axes returned from + `.get_variation_descriptor`. +)"""; + /********************************************************************** * Layout items * */ @@ -1664,6 +1894,7 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) p11x::enums["RenderMode"].attr("__doc__") = RenderMode__doc__; p11x::enums["FaceFlags"].attr("__doc__") = FaceFlags__doc__; p11x::enums["StyleFlags"].attr("__doc__") = StyleFlags__doc__; + p11x::enums["VarAxisFlags"].attr("__doc__") = VarAxisFlags__doc__; py::class_(m, "FT2Image", py::is_final(), py::buffer_protocol(), PyFT2Image__doc__) @@ -1749,6 +1980,45 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) item.glyph_index, item.x, item.y, item.prev_kern); }); + py::class_(m, "VariationAxis", py::is_final()) + .def(py::init<>([]() -> PyVariationAxis { + // VariationAxis is not useful from Python, so mark it as not constructible. + throw std::runtime_error("VariationAxis is not constructible"); + })) + .def_readonly("name", &PyVariationAxis::name, "The axis's name.") + .def_readonly("minimum", &PyVariationAxis::minimum, + "The axis's minimum design coordinate.") + .def_readonly("default", &PyVariationAxis::default_, + "The axis's default design coordinate.") + .def_readonly("maximum", &PyVariationAxis::maximum, + "The axis's maximum design coordinate.") + .def_readonly("tag", &PyVariationAxis::tag, "The axis's tag.") + .def_readonly("names", &PyVariationAxis::names, + "The axis's names from the name table (may be better than .name).") + .def_readonly("flags", &PyVariationAxis::flags, "The axis's flags.") + .def("__repr__", + [](const PyVariationAxis& info) { + return + "VariationAxis(name={!r}, minimum={}, default={}, "_s + "maximum={}, tag={}, names={}, flags={})"_s.format( + info.name, info.minimum, info.default_, info.maximum, info.tag, + info.names, info.flags); + }); + + py::class_(m, "VariationNamedStyle", py::is_final()) + .def(py::init<>([]() -> PyVariationNamedStyle { + // VariationNamedStyle is not useful from Python, so mark it as not constructible. + throw std::runtime_error("VariationNamedStyle is not constructible"); + })) + .def_readonly("names", &PyVariationNamedStyle::names) + .def_readonly("psnames", &PyVariationNamedStyle::psnames) + .def("__repr__", + [](const PyVariationNamedStyle& style) { + return + "VariationNamedStyle(names={!r}, psnames={!r})"_s.format( + style.names, style.psnames); + }); + auto cls = py::class_(m, "FT2Font", py::is_final(), py::buffer_protocol(), PyFT2Font__doc__) .def(py::init(&PyFT2Font_init), @@ -1820,6 +2090,19 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) .def("get_image", &PyFT2Font::get_image, PyFT2Font_get_image__doc__) .def("_get_type1_encoding_vector", &PyFT2Font__get_type1_encoding_vector, PyFT2Font__get_type1_encoding_vector__doc__) + .def("get_variation_descriptor", &PyFT2Font_get_variation_descriptor, + PyFT2Font_get_variation_descriptor__doc__) + .def("get_default_variation_style", &PyFT2Font::get_default_variation_style, + PyFT2Font_get_default_variation_style__doc__) + .def("get_variations", &PyFT2Font::get_variations, + PyFT2Font_get_variations__doc__) + .def("set_variations", + py::overload_cast(&PyFT2Font::set_variations), "index"_a, + PyFT2Font_set_variations_index__doc__) + .def("set_variations", + py::overload_cast>(&PyFT2Font::set_variations), + "coords"_a, + PyFT2Font_set_variations_vector__doc__) .def_property_readonly( "postscript_name", [](PyFT2Font *self) {