Skip to content

Commit 70b70a5

Browse files
committed
Add APIs to report FreeType features
Refs #9898
1 parent 807d689 commit 70b70a5

5 files changed

Lines changed: 211 additions & 0 deletions

File tree

Tests/test_features.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,23 @@ def test_supported_modules() -> None:
8787
assert isinstance(features.get_supported(), list)
8888

8989

90+
@skip_unless_feature("freetype2")
91+
def test_supported_freetype_features() -> None:
92+
supported = features.get_supported_freetype_features()
93+
for feature in supported:
94+
assert features.check_freetype_feature(feature)
95+
96+
97+
@skip_unless_feature("freetype2")
98+
def test_freetype_gpos_kerning_matches_probe() -> None:
99+
from PIL import ImageFont
100+
101+
# the built-in font has pair kerning in GPOS and no legacy 'kern' table,
102+
# so FreeType reports kerning data for it only when built to read GPOS
103+
font = ImageFont.load_default()
104+
assert features.check_freetype_feature("gpos_kerning") == font.font.has_kerning
105+
106+
90107
def test_unsupported_codec() -> None:
91108
# Arrange
92109
codec = "unsupported_codec"

docs/reference/features.rst

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,30 @@ Support for the following features can be checked:
6464
.. autofunction:: PIL.features.check_feature
6565
.. autofunction:: PIL.features.version_feature
6666
.. autofunction:: PIL.features.get_supported_features
67+
68+
FreeType build options
69+
----------------------
70+
71+
FreeType is highly configurable, and several of its build options change what Pillow can do with a font.
72+
73+
The following build options are mapped to the ``get_supported_freetype_features`` function:
74+
75+
* ``gpos_kerning``: Basic pair kerning from the ``GPOS`` table.
76+
Without it, :py:attr:`PIL.ImageFont.Layout.BASIC` can only kern fonts that carry a legacy ``kern`` table,
77+
which most modern fonts do not. Checked at runtime. Matches ``TT_CONFIG_OPTION_GPOS_KERNING``.
78+
* ``bytecode_interpreter``: If the TrueType bytecode interpreter is enabled, i.e. whether a font's own hinting instructions are used.
79+
Matches ``TT_CONFIG_OPTION_BYTECODE_INTERPRETER``.
80+
* ``subpixel_hinting``: Whether subpixel-hinting TrueType interpreter modes (versions 38 and 40) are available.
81+
Which one the loaded FreeType actually defaults to is reported by :py:func:`~PIL.features.pilinfo`.
82+
Matches ``TT_CONFIG_OPTION_SUBPIXEL_HINTING``.
83+
* ``color_layers``: If ``COLR``/``CPAL`` colour fonts are read. Matches ``TT_CONFIG_OPTION_COLOR_LAYERS``.
84+
* ``svg``: If OpenType ``SVG`` glyphs are supported. Matches ``FT_CONFIG_OPTION_SVG``.
85+
* ``png``: If PNG-compressed colour bitmap glyphs (``CBDT``, ``sbix``) are supported. Matches ``FT_CONFIG_OPTION_USE_PNG``.
86+
* ``brotli``: If WOFF2 fonts can be loaded. Matches ``FT_CONFIG_OPTION_USE_BROTLI``.
87+
* ``harfbuzz_autohinter``: If FreeType's auto-hinter can use HarfBuzz. Matches ``FT_CONFIG_OPTION_USE_HARFBUZZ``.
88+
* ``zlib``, ``bzip2``, ``lzw``: If compressed font data such as gzipped PCF is supported.
89+
Matches ``FT_CONFIG_OPTION_USE_ZLIB``, ``FT_CONFIG_OPTION_USE_BZIP2`` and ``FT_CONFIG_OPTION_USE_LZW``.
90+
* ``mac_fonts``: If Mac resource fork fonts are supported. Matches ``FT_CONFIG_OPTION_MAC_FONTS``.
91+
92+
.. autofunction:: PIL.features.check_freetype_feature
93+
.. autofunction:: PIL.features.get_supported_freetype_features

src/PIL/_imagingft.pyi

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ class Font:
2020
def y_ppem(self) -> int: ...
2121
@property
2222
def glyphs(self) -> int: ...
23+
@property
24+
def has_kerning(self) -> bool: ...
2325
def render(
2426
self,
2527
string: str | bytes,
@@ -67,4 +69,9 @@ def getfont(
6769
font_bytes: bytes,
6870
layout_engine: int,
6971
) -> Font: ...
72+
73+
freetype2_version: str
74+
freetype2_features: str
75+
freetype2_interpreter_version: int | None
76+
7077
def __getattr__(name: str) -> Any: ...

src/PIL/features.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import collections
4+
import functools
45
import os
56
import sys
67
import warnings
@@ -121,6 +122,68 @@ def get_supported_codecs() -> list[str]:
121122
return [f for f in codecs if check_codec(f)]
122123

123124

125+
@functools.cache
126+
def _enabled_freetype_features() -> frozenset[str]:
127+
"""Get the compiled-in FreeType features that affect Pillow."""
128+
if not check_module("freetype2"):
129+
return frozenset()
130+
131+
from PIL import _imagingft
132+
133+
return frozenset(getattr(_imagingft, "freetype2_features", "").split())
134+
135+
136+
@functools.cache
137+
def _freetype_reads_gpos_kerning() -> bool | None:
138+
"""
139+
Ask the loaded FreeType whether it can read kerning out of ``GPOS``.
140+
141+
Pillow's built-in font has pair kerning in ``GPOS`` and no legacy ``kern``
142+
table, so FreeType only reports kerning data for it when it was built with
143+
``TT_CONFIG_OPTION_GPOS_KERNING``.
144+
145+
:returns: ``True`` or ``False``, or ``None`` if the probe could not be run.
146+
"""
147+
if not check_module("freetype2"):
148+
return None
149+
150+
from . import ImageFont
151+
152+
try:
153+
font = ImageFont.load_default()
154+
return bool(font.font.has_kerning)
155+
except (AttributeError, OSError):
156+
return None
157+
158+
159+
def check_freetype_feature(feature: str) -> bool:
160+
"""
161+
Checks whether a FreeType feature that affects Pillow is enabled.
162+
163+
:param feature: The feature to check for.
164+
:returns: ``True`` if enabled, ``False`` otherwise.
165+
"""
166+
if feature == "gpos_kerning":
167+
# This requires loading the embedded font.
168+
probed = _freetype_reads_gpos_kerning()
169+
if probed is not None:
170+
return probed
171+
172+
return feature in _enabled_freetype_features()
173+
174+
175+
def get_supported_freetype_features() -> list[str]:
176+
"""
177+
:returns: A sorted list of all enabled FreeType build features.
178+
"""
179+
supported = set(_enabled_freetype_features())
180+
if check_freetype_feature("gpos_kerning"):
181+
supported.add("gpos_kerning")
182+
else:
183+
supported.discard("gpos_kerning")
184+
return sorted(supported)
185+
186+
124187
features: dict[str, tuple[str, str, str | None]] = {
125188
"raqm": ("PIL._imagingft", "HAVE_RAQM", "raqm_version"),
126189
"fribidi": ("PIL._imagingft", "HAVE_FRIBIDI", "fribidi_version"),
@@ -226,6 +289,19 @@ def get_supported() -> list[str]:
226289
return ret
227290

228291

292+
def _print_freetype_build(out: IO[str]) -> None:
293+
"""Report how the loaded FreeType was built, for bug reports."""
294+
from PIL import _imagingft
295+
296+
interpreter_version = getattr(_imagingft, "freetype2_interpreter_version", None)
297+
if interpreter_version is not None:
298+
print(f" TrueType interpreter version {interpreter_version}", file=out)
299+
300+
enabled = get_supported_freetype_features()
301+
if enabled:
302+
print(" FreeType built with", ", ".join(enabled), file=out)
303+
304+
229305
def pilinfo(out: IO[str] | None = None, supported_formats: bool = True) -> None:
230306
"""
231307
Prints information about this installation of Pillow.
@@ -309,6 +385,8 @@ def pilinfo(out: IO[str] | None = None, supported_formats: bool = True) -> None:
309385
print("---", feature, "support ok,", t, v, file=out)
310386
else:
311387
print("---", feature, "support ok", file=out)
388+
if name == "freetype2":
389+
_print_freetype_build(out)
312390
else:
313391
print("***", feature, "support not installed", file=out)
314392
print("-" * 68, file=out)

src/_imagingft.c

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
#include FT_GLYPH_H
2929
#include FT_BITMAP_H
3030
#include FT_STROKER_H
31+
#include FT_MODULE_H
3132
#include FT_MULTIPLE_MASTERS_H
3233
#include FT_SFNT_NAMES_H
3334
#ifdef FT_COLOR_H
@@ -1614,6 +1615,17 @@ font_getattr_glyphs(FontObject *self, void *closure) {
16141615
return PyLong_FromLong(self->face->num_glyphs);
16151616
}
16161617

1618+
static PyObject *
1619+
font_getattr_has_kerning(FontObject *self, void *closure) {
1620+
/*
1621+
* whether FreeType can extract kerning for this face, i.e. whether the basic
1622+
* layout engine will kern it at all. This covers the legacy 'kern' table, and
1623+
* pair kerning from GPOS only if FreeType was built with
1624+
* TT_CONFIG_OPTION_GPOS_KERNING.
1625+
*/
1626+
return PyBool_FromLong(FT_HAS_KERNING(self->face));
1627+
}
1628+
16171629
static struct PyGetSetDef font_getsetters[] = {
16181630
{"family", (getter)font_getattr_family},
16191631
{"style", (getter)font_getattr_style},
@@ -1623,6 +1635,7 @@ static struct PyGetSetDef font_getsetters[] = {
16231635
{"x_ppem", (getter)font_getattr_x_ppem},
16241636
{"y_ppem", (getter)font_getattr_y_ppem},
16251637
{"glyphs", (getter)font_getattr_glyphs},
1638+
{"has_kerning", (getter)font_getattr_has_kerning},
16261639
{NULL}
16271640
};
16281641

@@ -1638,6 +1651,55 @@ static PyMethodDef _functions[] = {
16381651
{"getfont", (PyCFunction)getfont, METH_VARARGS | METH_KEYWORDS}, {NULL, NULL}
16391652
};
16401653

1654+
/*
1655+
* FreeType build options that change what Pillow can do with a font, as a space
1656+
* separated list of the names used by PIL.features.
1657+
*
1658+
* These are read from the ftoption.h that Pillow was compiled against. FreeType's
1659+
* build system writes the options it was configured with into the header it
1660+
* installs, so this normally describes the library that gets loaded as well, but
1661+
* it cannot see an option that was enabled with a compiler flag alone.
1662+
*/
1663+
static const char *freetype2_features =
1664+
""
1665+
#ifdef TT_CONFIG_OPTION_GPOS_KERNING
1666+
"gpos_kerning "
1667+
#endif
1668+
#ifdef TT_CONFIG_OPTION_BYTECODE_INTERPRETER
1669+
"bytecode_interpreter "
1670+
#endif
1671+
#ifdef TT_CONFIG_OPTION_SUBPIXEL_HINTING
1672+
"subpixel_hinting "
1673+
#endif
1674+
#ifdef TT_CONFIG_OPTION_COLOR_LAYERS
1675+
"color_layers "
1676+
#endif
1677+
#ifdef FT_CONFIG_OPTION_SVG
1678+
"svg "
1679+
#endif
1680+
#ifdef FT_CONFIG_OPTION_USE_PNG
1681+
"png "
1682+
#endif
1683+
#ifdef FT_CONFIG_OPTION_USE_BROTLI
1684+
"brotli "
1685+
#endif
1686+
#ifdef FT_CONFIG_OPTION_USE_HARFBUZZ
1687+
"harfbuzz_autohinter "
1688+
#endif
1689+
#ifdef FT_CONFIG_OPTION_USE_ZLIB
1690+
"zlib "
1691+
#endif
1692+
#ifdef FT_CONFIG_OPTION_USE_BZIP2
1693+
"bzip2 "
1694+
#endif
1695+
#ifdef FT_CONFIG_OPTION_USE_LZW
1696+
"lzw "
1697+
#endif
1698+
#ifdef FT_CONFIG_OPTION_MAC_FONTS
1699+
"mac_fonts "
1700+
#endif
1701+
;
1702+
16411703
static int
16421704
setup_module(PyObject *m) {
16431705
PyObject *d;
@@ -1664,6 +1726,26 @@ setup_module(PyObject *m) {
16641726
PyDict_SetItemString(d, "freetype2_version", v);
16651727
Py_DECREF(v);
16661728

1729+
v = PyUnicode_FromString(freetype2_features);
1730+
if (!v) {
1731+
return -1;
1732+
}
1733+
PyDict_SetItemString(d, "freetype2_features", v);
1734+
Py_DECREF(v);
1735+
1736+
FT_UInt interpreter_version = 0;
1737+
v = NULL;
1738+
if (FT_Property_Get(
1739+
library, "truetype", "interpreter-version", &interpreter_version
1740+
) == 0) {
1741+
v = PyLong_FromUnsignedLong(interpreter_version);
1742+
if (!v) {
1743+
return -1;
1744+
}
1745+
}
1746+
PyDict_SetItemString(d, "freetype2_interpreter_version", v ? v : Py_None);
1747+
Py_XDECREF(v);
1748+
16671749
#ifdef HAVE_RAQM
16681750
#if defined(HAVE_RAQM_SYSTEM) || defined(HAVE_FRIBIDI_SYSTEM)
16691751
have_raqm = 1;

0 commit comments

Comments
 (0)