Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions Tests/test_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 27 additions & 0 deletions docs/reference/features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions src/PIL/_imagingft.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: ...
78 changes: 78 additions & 0 deletions src/PIL/features.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import collections
import functools
import os
import sys
import warnings
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
82 changes: 82 additions & 0 deletions src/_imagingft.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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},
Expand All @@ -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}
};

Expand All @@ -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;
Expand All @@ -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;
Expand Down
Loading