From 70b70a5764cfb66218d3cf4432848753391ff8f2 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Tue, 25 Aug 2026 15:17:04 +0300 Subject: [PATCH] Add APIs to report FreeType features Refs #9898 --- Tests/test_features.py | 17 ++++++++ docs/reference/features.rst | 27 ++++++++++++ src/PIL/_imagingft.pyi | 7 ++++ src/PIL/features.py | 78 +++++++++++++++++++++++++++++++++++ src/_imagingft.c | 82 +++++++++++++++++++++++++++++++++++++ 5 files changed, 211 insertions(+) diff --git a/Tests/test_features.py b/Tests/test_features.py index 93d803fc133..9f214a70c31 100644 --- a/Tests/test_features.py +++ b/Tests/test_features.py @@ -87,6 +87,23 @@ def test_supported_modules() -> None: assert isinstance(features.get_supported(), list) +@skip_unless_feature("freetype2") +def test_supported_freetype_features() -> None: + supported = features.get_supported_freetype_features() + for feature in supported: + assert features.check_freetype_feature(feature) + + +@skip_unless_feature("freetype2") +def test_freetype_gpos_kerning_matches_probe() -> None: + from PIL import ImageFont + + # the built-in font has pair kerning in GPOS and no legacy 'kern' table, + # so FreeType reports kerning data for it only when built to read GPOS + font = ImageFont.load_default() + assert features.check_freetype_feature("gpos_kerning") == font.font.has_kerning + + def test_unsupported_codec() -> None: # Arrange codec = "unsupported_codec" diff --git a/docs/reference/features.rst b/docs/reference/features.rst index 45067ba3587..2bd1e35efa6 100644 --- a/docs/reference/features.rst +++ b/docs/reference/features.rst @@ -64,3 +64,30 @@ Support for the following features can be checked: .. autofunction:: PIL.features.check_feature .. autofunction:: PIL.features.version_feature .. autofunction:: PIL.features.get_supported_features + +FreeType build options +---------------------- + +FreeType is highly configurable, and several of its build options change what Pillow can do with a font. + +The following build options are mapped to the ``get_supported_freetype_features`` function: + +* ``gpos_kerning``: Basic pair kerning from the ``GPOS`` table. + Without it, :py:attr:`PIL.ImageFont.Layout.BASIC` can only kern fonts that carry a legacy ``kern`` table, + which most modern fonts do not. Checked at runtime. Matches ``TT_CONFIG_OPTION_GPOS_KERNING``. +* ``bytecode_interpreter``: If the TrueType bytecode interpreter is enabled, i.e. whether a font's own hinting instructions are used. + Matches ``TT_CONFIG_OPTION_BYTECODE_INTERPRETER``. +* ``subpixel_hinting``: Whether subpixel-hinting TrueType interpreter modes (versions 38 and 40) are available. + Which one the loaded FreeType actually defaults to is reported by :py:func:`~PIL.features.pilinfo`. + Matches ``TT_CONFIG_OPTION_SUBPIXEL_HINTING``. +* ``color_layers``: If ``COLR``/``CPAL`` colour fonts are read. Matches ``TT_CONFIG_OPTION_COLOR_LAYERS``. +* ``svg``: If OpenType ``SVG`` glyphs are supported. Matches ``FT_CONFIG_OPTION_SVG``. +* ``png``: If PNG-compressed colour bitmap glyphs (``CBDT``, ``sbix``) are supported. Matches ``FT_CONFIG_OPTION_USE_PNG``. +* ``brotli``: If WOFF2 fonts can be loaded. Matches ``FT_CONFIG_OPTION_USE_BROTLI``. +* ``harfbuzz_autohinter``: If FreeType's auto-hinter can use HarfBuzz. Matches ``FT_CONFIG_OPTION_USE_HARFBUZZ``. +* ``zlib``, ``bzip2``, ``lzw``: If compressed font data such as gzipped PCF is supported. + Matches ``FT_CONFIG_OPTION_USE_ZLIB``, ``FT_CONFIG_OPTION_USE_BZIP2`` and ``FT_CONFIG_OPTION_USE_LZW``. +* ``mac_fonts``: If Mac resource fork fonts are supported. Matches ``FT_CONFIG_OPTION_MAC_FONTS``. + +.. autofunction:: PIL.features.check_freetype_feature +.. autofunction:: PIL.features.get_supported_freetype_features diff --git a/src/PIL/_imagingft.pyi b/src/PIL/_imagingft.pyi index 2136810ba6a..d3e7e97656e 100644 --- a/src/PIL/_imagingft.pyi +++ b/src/PIL/_imagingft.pyi @@ -20,6 +20,8 @@ class Font: def y_ppem(self) -> int: ... @property def glyphs(self) -> int: ... + @property + def has_kerning(self) -> bool: ... def render( self, string: str | bytes, @@ -67,4 +69,9 @@ def getfont( font_bytes: bytes, layout_engine: int, ) -> Font: ... + +freetype2_version: str +freetype2_features: str +freetype2_interpreter_version: int | None + def __getattr__(name: str) -> Any: ... diff --git a/src/PIL/features.py b/src/PIL/features.py index 8d72d92cb05..b9eb9fd35ef 100644 --- a/src/PIL/features.py +++ b/src/PIL/features.py @@ -1,6 +1,7 @@ from __future__ import annotations import collections +import functools import os import sys import warnings @@ -121,6 +122,68 @@ def get_supported_codecs() -> list[str]: return [f for f in codecs if check_codec(f)] +@functools.cache +def _enabled_freetype_features() -> frozenset[str]: + """Get the compiled-in FreeType features that affect Pillow.""" + if not check_module("freetype2"): + return frozenset() + + from PIL import _imagingft + + return frozenset(getattr(_imagingft, "freetype2_features", "").split()) + + +@functools.cache +def _freetype_reads_gpos_kerning() -> bool | None: + """ + Ask the loaded FreeType whether it can read kerning out of ``GPOS``. + + Pillow's built-in font has pair kerning in ``GPOS`` and no legacy ``kern`` + table, so FreeType only reports kerning data for it when it was built with + ``TT_CONFIG_OPTION_GPOS_KERNING``. + + :returns: ``True`` or ``False``, or ``None`` if the probe could not be run. + """ + if not check_module("freetype2"): + return None + + from . import ImageFont + + try: + font = ImageFont.load_default() + return bool(font.font.has_kerning) + except (AttributeError, OSError): + return None + + +def check_freetype_feature(feature: str) -> bool: + """ + Checks whether a FreeType feature that affects Pillow is enabled. + + :param feature: The feature to check for. + :returns: ``True`` if enabled, ``False`` otherwise. + """ + if feature == "gpos_kerning": + # This requires loading the embedded font. + probed = _freetype_reads_gpos_kerning() + if probed is not None: + return probed + + return feature in _enabled_freetype_features() + + +def get_supported_freetype_features() -> list[str]: + """ + :returns: A sorted list of all enabled FreeType build features. + """ + supported = set(_enabled_freetype_features()) + if check_freetype_feature("gpos_kerning"): + supported.add("gpos_kerning") + else: + supported.discard("gpos_kerning") + return sorted(supported) + + features: dict[str, tuple[str, str, str | None]] = { "raqm": ("PIL._imagingft", "HAVE_RAQM", "raqm_version"), "fribidi": ("PIL._imagingft", "HAVE_FRIBIDI", "fribidi_version"), @@ -226,6 +289,19 @@ def get_supported() -> list[str]: return ret +def _print_freetype_build(out: IO[str]) -> None: + """Report how the loaded FreeType was built, for bug reports.""" + from PIL import _imagingft + + interpreter_version = getattr(_imagingft, "freetype2_interpreter_version", None) + if interpreter_version is not None: + print(f" TrueType interpreter version {interpreter_version}", file=out) + + enabled = get_supported_freetype_features() + if enabled: + print(" FreeType built with", ", ".join(enabled), file=out) + + def pilinfo(out: IO[str] | None = None, supported_formats: bool = True) -> None: """ Prints information about this installation of Pillow. @@ -309,6 +385,8 @@ def pilinfo(out: IO[str] | None = None, supported_formats: bool = True) -> None: print("---", feature, "support ok,", t, v, file=out) else: print("---", feature, "support ok", file=out) + if name == "freetype2": + _print_freetype_build(out) else: print("***", feature, "support not installed", file=out) print("-" * 68, file=out) diff --git a/src/_imagingft.c b/src/_imagingft.c index 4b49eb04766..34933d5b1d2 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -28,6 +28,7 @@ #include FT_GLYPH_H #include FT_BITMAP_H #include FT_STROKER_H +#include FT_MODULE_H #include FT_MULTIPLE_MASTERS_H #include FT_SFNT_NAMES_H #ifdef FT_COLOR_H @@ -1614,6 +1615,17 @@ font_getattr_glyphs(FontObject *self, void *closure) { return PyLong_FromLong(self->face->num_glyphs); } +static PyObject * +font_getattr_has_kerning(FontObject *self, void *closure) { + /* + * whether FreeType can extract kerning for this face, i.e. whether the basic + * layout engine will kern it at all. This covers the legacy 'kern' table, and + * pair kerning from GPOS only if FreeType was built with + * TT_CONFIG_OPTION_GPOS_KERNING. + */ + return PyBool_FromLong(FT_HAS_KERNING(self->face)); +} + static struct PyGetSetDef font_getsetters[] = { {"family", (getter)font_getattr_family}, {"style", (getter)font_getattr_style}, @@ -1623,6 +1635,7 @@ static struct PyGetSetDef font_getsetters[] = { {"x_ppem", (getter)font_getattr_x_ppem}, {"y_ppem", (getter)font_getattr_y_ppem}, {"glyphs", (getter)font_getattr_glyphs}, + {"has_kerning", (getter)font_getattr_has_kerning}, {NULL} }; @@ -1638,6 +1651,55 @@ static PyMethodDef _functions[] = { {"getfont", (PyCFunction)getfont, METH_VARARGS | METH_KEYWORDS}, {NULL, NULL} }; +/* + * FreeType build options that change what Pillow can do with a font, as a space + * separated list of the names used by PIL.features. + * + * These are read from the ftoption.h that Pillow was compiled against. FreeType's + * build system writes the options it was configured with into the header it + * installs, so this normally describes the library that gets loaded as well, but + * it cannot see an option that was enabled with a compiler flag alone. + */ +static const char *freetype2_features = + "" +#ifdef TT_CONFIG_OPTION_GPOS_KERNING + "gpos_kerning " +#endif +#ifdef TT_CONFIG_OPTION_BYTECODE_INTERPRETER + "bytecode_interpreter " +#endif +#ifdef TT_CONFIG_OPTION_SUBPIXEL_HINTING + "subpixel_hinting " +#endif +#ifdef TT_CONFIG_OPTION_COLOR_LAYERS + "color_layers " +#endif +#ifdef FT_CONFIG_OPTION_SVG + "svg " +#endif +#ifdef FT_CONFIG_OPTION_USE_PNG + "png " +#endif +#ifdef FT_CONFIG_OPTION_USE_BROTLI + "brotli " +#endif +#ifdef FT_CONFIG_OPTION_USE_HARFBUZZ + "harfbuzz_autohinter " +#endif +#ifdef FT_CONFIG_OPTION_USE_ZLIB + "zlib " +#endif +#ifdef FT_CONFIG_OPTION_USE_BZIP2 + "bzip2 " +#endif +#ifdef FT_CONFIG_OPTION_USE_LZW + "lzw " +#endif +#ifdef FT_CONFIG_OPTION_MAC_FONTS + "mac_fonts " +#endif + ; + static int setup_module(PyObject *m) { PyObject *d; @@ -1664,6 +1726,26 @@ setup_module(PyObject *m) { PyDict_SetItemString(d, "freetype2_version", v); Py_DECREF(v); + v = PyUnicode_FromString(freetype2_features); + if (!v) { + return -1; + } + PyDict_SetItemString(d, "freetype2_features", v); + Py_DECREF(v); + + FT_UInt interpreter_version = 0; + v = NULL; + if (FT_Property_Get( + library, "truetype", "interpreter-version", &interpreter_version + ) == 0) { + v = PyLong_FromUnsignedLong(interpreter_version); + if (!v) { + return -1; + } + } + PyDict_SetItemString(d, "freetype2_interpreter_version", v ? v : Py_None); + Py_XDECREF(v); + #ifdef HAVE_RAQM #if defined(HAVE_RAQM_SYSTEM) || defined(HAVE_FRIBIDI_SYSTEM) have_raqm = 1;