From afc8e80e5cf407d5e3a6012eeeb1cf034d4f20ec Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 26 Aug 2026 08:49:47 +0300 Subject: [PATCH 1/8] Document _imagingft.GlyphInfo --- src/_imagingft.c | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/_imagingft.c b/src/_imagingft.c index 4b49eb04766..0825aa7cf59 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -66,9 +66,16 @@ static int have_raqm = 0; #define LAYOUT_FALLBACK 0 #define LAYOUT_RAQM 1 +// These mirror raqm_glyph_t, which in turn mirrors harfbuzz's hb_glyph_info_t, +// with the addition of y_advance and y_offset for vertical text layout. +// Coordinate units are 26.6 fixed-point precision. typedef struct { - int index, x_offset, x_advance, y_offset, y_advance; - unsigned int cluster; + unsigned int index; // the index of the glyph in the font file + int x_offset; // horizontal movement of the glyph from current point + int x_advance; // glyph advance width in horizontal text + int y_offset; // vertical movement of the glyph from current point + int y_advance; // glyph advance height in vertical text + unsigned int cluster; // character index in original text } GlyphInfo; struct { From db8bf093b4f930afecd6c28b8190923f1cf21ace Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 26 Aug 2026 17:03:17 +0300 Subject: [PATCH 2/8] Add docs for font_getsize_impl and font_render_impl Co-authored-by: Andrew Murray --- src/_imagingft.c | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/_imagingft.c b/src/_imagingft.c index 0825aa7cf59..9793af50699 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -761,6 +761,19 @@ bounding_box_and_anchors( return 1; } +/** Calculate size and bearing for a given string. + * + * Python parameters: + * - string: the text to measure + * - mode_name: imaging mode string + * - dir: text direction string + * - features: font features sequence + * - lang: language string + * - anchor: anchor string + * + * Python return value: + * - ((width, height), (x_offset, y_offset)): size and bearing of the text, in pixels + */ static PyObject * font_getsize_impl(FontObject *self, PyObject *args) { int64_t width, height; @@ -779,8 +792,6 @@ font_getsize_impl(FontObject *self, PyObject *args) { PyObject *features = Py_None; PyObject *string; - /* calculate size and bearing for a given string */ - if (!PyArg_ParseTuple( args, "O|zzOzz:getsize", @@ -845,6 +856,25 @@ font_getsize(FontObject *self, PyObject *args) { return result; } +/** + * Rasterize a string into an image buffer. + * + * Python parameters: + * - string: the text to render + * - fill: Python function to create a core image object at a specified width and height + * - mode_name: imaging mode string + * - dir: text direction string + * - features: font features sequence + * - lang: language string + * - stroke_width: stroke width in pixels + * - stroke_filled: bool: omit inner stroke border, to stroke outside + fill inside + * - anchor: anchor string + * - foreground_ink_long: foreground color as a long integer + * - x_start: starting x position of the pen in pixels + * - y_start: starting y position of the pen in pixels + * + * Returns: a new image object containing the rendered text. + */ static PyObject * font_render_impl(FontObject *self, PyObject *args) { int x, y; /* pen position, in 26.6 precision */ From 24b72efd471c2554d562009e8173b44669330c26 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 26 Aug 2026 18:45:53 +0300 Subject: [PATCH 3/8] Add docs for font_getlength_impl --- src/PIL/_imagingft.pyi | 2 +- src/_imagingft.c | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/PIL/_imagingft.pyi b/src/PIL/_imagingft.pyi index 2136810ba6a..481ce3477a4 100644 --- a/src/PIL/_imagingft.pyi +++ b/src/PIL/_imagingft.pyi @@ -53,7 +53,7 @@ class Font: features: list[str] | None, lang: str | None, /, - ) -> float: ... + ) -> int: ... def getvarnames(self) -> list[bytes]: ... def getvaraxes(self) -> list[ImageFont.Axis]: ... def setvarname(self, instance_index: int, /) -> None: ... diff --git a/src/_imagingft.c b/src/_imagingft.c index 9793af50699..3a3c9bb4c14 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -524,6 +524,24 @@ text_layout( return count; } +/** Calculate how far the pen advances over a given string. + * + * This is the sum of the glyph advances, which is not the width of the inked + * area that font_getsize_impl() reports: it includes side bearings and any + * trailing whitespace. + * + * Python parameters: + * - string: the text to measure + * - mode_name: imaging mode string + * - dir: text direction string + * - features: font features sequence + * - lang: language string + * + * Python return value: + * - length: advance along the primary axis, 26.6 precision integer. + * Unlike the other methods here this is not in pixels; + * ImageFont.FreeTypeFont.getlength() divides by 64. + */ static PyObject * font_getlength_impl(FontObject *self, PyObject *args) { int length; /* length along primary axis, in 26.6 precision */ @@ -538,8 +556,6 @@ font_getlength_impl(FontObject *self, PyObject *args) { PyObject *features = Py_None; PyObject *string; - /* calculate size and bearing for a given string */ - if (!PyArg_ParseTuple( args, "O|zzOz:getlength", &string, &mode_name, &dir, &features, &lang )) { From c7c9bc10b03aecfe9472afb81f6e1774de0263b3 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 26 Aug 2026 16:11:12 +0300 Subject: [PATCH 4/8] Add macros to manipulate FT 26.6 format --- src/_imagingft.c | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/_imagingft.c b/src/_imagingft.c index 3a3c9bb4c14..d58ed1377c4 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -103,6 +103,14 @@ static PyTypeObject Font_Type; /* round a 26.6 pixel coordinate to the nearest integer */ #define PIXEL(x) ((((x) + 32) & -64) >> 6) +/* round a 26.6 pixel coordinate down to integer */ +#define PIXEL_FLOOR(x) ((x) >> 6) +/* round a 26.6 pixel coordinate up to integer */ +#define PIXEL_CEIL(x) (((x) + 63) >> 6) +/* the sub-pixel part of a 26.6 pixel coordinate */ +#define PIXEL_FRAC(x) ((x) & 63) +/* convert from pixels to 26.6 fixed-point */ +#define PIXEL_TO_FIXED(x) ((x) * 64) static PyObject * geterror(int code) { @@ -222,7 +230,7 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { } if (!error) { - width = size * 64; + width = PIXEL_TO_FIXED(size); req.type = FT_SIZE_REQUEST_TYPE_NOMINAL; req.width = width; req.height = width; From 25f54243de8e6c3561d1d304cc9004e81c687af0 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 26 Aug 2026 16:48:55 +0300 Subject: [PATCH 5/8] Use FT_F26Dot6 type where obvious --- src/_imagingft.c | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/_imagingft.c b/src/_imagingft.c index d58ed1377c4..51617f0f688 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -71,10 +71,10 @@ static int have_raqm = 0; // Coordinate units are 26.6 fixed-point precision. typedef struct { unsigned int index; // the index of the glyph in the font file - int x_offset; // horizontal movement of the glyph from current point - int x_advance; // glyph advance width in horizontal text - int y_offset; // vertical movement of the glyph from current point - int y_advance; // glyph advance height in vertical text + FT_F26Dot6 x_offset; // horizontal movement of the glyph from current point + FT_F26Dot6 x_advance; // glyph advance width in horizontal text + FT_F26Dot6 y_offset; // vertical movement of the glyph from current point + FT_F26Dot6 y_advance; // glyph advance height in vertical text unsigned int cluster; // character index in original text } GlyphInfo; @@ -552,7 +552,7 @@ text_layout( */ static PyObject * font_getlength_impl(FontObject *self, PyObject *args) { - int length; /* length along primary axis, in 26.6 precision */ + FT_F26Dot6 length; /* length along primary axis */ GlyphInfo *glyph_info = NULL; /* computed text layout */ size_t i, count; /* glyph_info index and length */ int horizontal_dir; /* is primary axis horizontal? */ @@ -620,9 +620,9 @@ bounding_box_and_anchors( int *x_offset, int *y_offset ) { - long position; /* pen position along primary axis, in 26.6 precision */ - long advanced; /* pen position along primary axis, in pixels */ - int px, py; /* position of current glyph, in pixels */ + FT_F26Dot6 position; /* pen position along primary axis */ + long advanced; /* pen position along primary axis, in pixels */ + int px, py; /* position of current glyph, in pixels */ int x_min, x_max, y_min, y_max; /* text bounding box, in pixels */ int x_anchor, y_anchor; /* offset of point drawn at (0, 0), in pixels */ int error; @@ -901,7 +901,7 @@ font_getsize(FontObject *self, PyObject *args) { */ static PyObject * font_render_impl(FontObject *self, PyObject *args) { - int x, y; /* pen position, in 26.6 precision */ + FT_F26Dot6 x, y; /* pen position */ int px, py; /* position of current glyph, in pixels */ int x_min, y_max; /* text offset in 26.6 precision */ int load_flags; /* FreeType load_flags parameter */ @@ -1051,10 +1051,10 @@ font_render_impl(FontObject *self, PyObject *args) { FT_Stroker_Set( stroker, - (FT_Fixed)round(stroke_width * 64), + (FT_F26Dot6)roundf(PIXEL_TO_FIXED(stroke_width)), FT_STROKER_LINECAP_ROUND, FT_STROKER_LINEJOIN_ROUND, - 0 + 0 // 16.16 units ); } @@ -1089,8 +1089,8 @@ font_render_impl(FontObject *self, PyObject *args) { } /* set pen position to text origin */ - x = round((-x_min + stroke_width + x_start) * 64); - y = round((-y_max + (-stroke_width) - y_start) * 64); + x = roundf(PIXEL_TO_FIXED(-x_min + stroke_width + x_start)); + y = roundf(PIXEL_TO_FIXED(-y_max + (-stroke_width) - y_start)); if (stroker == NULL) { load_flags |= FT_LOAD_RENDER; From 30a5f7ee1b1d686284024758480096cb92909e96 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 26 Aug 2026 09:23:34 +0300 Subject: [PATCH 6/8] Fix misleading comment for x_min, y_max --- src/_imagingft.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_imagingft.c b/src/_imagingft.c index 51617f0f688..277a5ed4085 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -903,7 +903,7 @@ static PyObject * font_render_impl(FontObject *self, PyObject *args) { FT_F26Dot6 x, y; /* pen position */ int px, py; /* position of current glyph, in pixels */ - int x_min, y_max; /* text offset in 26.6 precision */ + int x_min, y_max; /* text offset, in pixels */ int load_flags; /* FreeType load_flags parameter */ int error; FT_Glyph glyph; From 81d3184eda0a3f0205705695b42444d078c39942 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 26 Aug 2026 20:55:29 +0300 Subject: [PATCH 7/8] Make all arguments for _imagingft.getfont() actually required Avoids a segfault when `encoding` and `font_bytes` weren't passed at all (they were left uninitialized) --- src/PIL/ImageFont.py | 4 +--- src/_imagingft.c | 8 ++++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 1f5fe66197a..c850ee2d7c6 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -297,9 +297,7 @@ def load_from_bytes(f: IO[bytes]) -> None: with open(font, "rb") as f: load_from_bytes(f) return - self.font = core.getfont( - font, size, index, encoding, layout_engine=layout_engine - ) + self.font = core.getfont(font, size, index, encoding, b"", layout_engine) else: load_from_bytes(cast("IO[bytes]", font)) diff --git a/src/_imagingft.c b/src/_imagingft.c index 277a5ed4085..33ac296259e 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -140,8 +140,8 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { FT_Long width; Py_ssize_t index = 0; Py_ssize_t layout_engine = 0; - unsigned char *encoding; - unsigned char *font_bytes; + unsigned char *encoding = NULL; + unsigned char *font_bytes = NULL; Py_ssize_t font_bytes_size = 0; static char *kwlist[] = { "filename", "size", "index", "encoding", "font_bytes", "layout_engine", NULL @@ -158,7 +158,7 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { if (!PyArg_ParseTupleAndKeywords( args, kw, - "etf|nsy#n", + "etfnsy#n", kwlist, config.filesystem_encoding, &filename, @@ -177,7 +177,7 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { if (!PyArg_ParseTupleAndKeywords( args, kw, - "etf|nsy#n", + "etfnsy#n", kwlist, Py_FileSystemDefaultEncoding, &filename, From 6428532aa71dc07e610e6173312a7d4335902d9d Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 26 Aug 2026 20:58:32 +0300 Subject: [PATCH 8/8] Adjust _imagingft.pyi to match reality (and add tests to match) --- Tests/test_font_pcf_charsets.py | 4 ++-- Tests/test_imagefont.py | 18 ++++++++++++++++ src/PIL/ImageFont.py | 14 ++++++------ src/PIL/_imagingft.pyi | 38 ++++++++++++++++----------------- 4 files changed, 45 insertions(+), 29 deletions(-) diff --git a/Tests/test_font_pcf_charsets.py b/Tests/test_font_pcf_charsets.py index 58dec008174..51c6f992cb4 100644 --- a/Tests/test_font_pcf_charsets.py +++ b/Tests/test_font_pcf_charsets.py @@ -97,12 +97,12 @@ def test_textsize( tempname = save_font(request, tmp_path, encoding) font = ImageFont.load(tempname) for i in range(255): - ox, oy, dx, dy = font.getbbox(bytearray([i])) + ox, oy, dx, dy = font.getbbox(bytes([i])) assert ox == 0 assert oy == 0 assert dy == 20 assert dx in (0, 10) - assert font.getlength(bytearray([i])) == dx + assert font.getlength(bytes([i])) == dx message = charsets[encoding]["message"].encode(encoding) for i in range(len(message)): msg = message[: i + 1] diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 536a985435b..f647ee43379 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -571,6 +571,24 @@ def test_getbbox_empty(font: ImageFont.FreeTypeFont) -> None: assert (0, 0, 0, 0) == font.getbbox("") +def test_bytearray_not_supported(font: ImageFont.FreeTypeFont) -> None: + # The C text layout only accepts str and bytes, so bytearray is rejected + # even though these methods used to be annotated as taking one. + for op in (font.getlength, font.getbbox, font.getmask): + assert op(b"A") is not None + with pytest.raises(TypeError, match="expected string or bytes"): + op(bytearray(b"A")) # type: ignore[arg-type] + + +def test_core_font_arguments_are_optional(font: ImageFont.FreeTypeFont) -> None: + # Every argument after the leading string is optional at the C level, + # and the mode argument additionally accepts None. + assert font.font.getlength("A") == font.font.getlength("A", "", None, None, None) + assert font.font.getlength("A", None) == font.font.getlength("A", "") + assert font.font.getsize("A") == font.font.getsize("A", "", None, None, None, None) + assert font.font.getsize("A", None) == font.font.getsize("A", "") + + def test_render_empty(font: ImageFont.FreeTypeFont) -> None: # issue 2666 im = Image.new(mode="RGB", size=(300, 100)) diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index c850ee2d7c6..49c242e8807 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -72,7 +72,7 @@ class Layout(IntEnum): core = DeferredError.new(ex) -def _string_length_check(text: str | bytes | bytearray) -> None: +def _string_length_check(text: str | bytes) -> None: if MAX_STRING_LENGTH is not None and len(text) > MAX_STRING_LENGTH: msg = "too many characters in string" raise ValueError(msg) @@ -99,7 +99,7 @@ class BaseImageFont(abc.ABC): @abc.abstractmethod def getbbox( - self, text: str | bytes | bytearray, *args: Any, **kwargs: Any + self, text: str | bytes, *args: Any, **kwargs: Any ) -> tuple[float, float, float, float]: pass @@ -204,7 +204,7 @@ def getmask( return self.font.getmask(text, mode) def getbbox( - self, text: str | bytes | bytearray, *args: Any, **kwargs: Any + self, text: str | bytes, *args: Any, **kwargs: Any ) -> tuple[int, int, int, int]: """ Returns bounding box (in pixels) of given text. @@ -219,9 +219,7 @@ def getbbox( width, height = self.font.getsize(text) return 0, 0, width, height - def getlength( - self, text: str | bytes | bytearray, *args: Any, **kwargs: Any - ) -> int: + def getlength(self, text: str | bytes, *args: Any, **kwargs: Any) -> int: """ Returns length (in pixels) of given text. This is the amount by which following text should be offset. @@ -404,7 +402,7 @@ def getlength( def getbbox( self, - text: str | bytes | bytearray, + text: str | bytes, mode: str = "", direction: str | None = None, features: list[str] | None = None, @@ -772,7 +770,7 @@ def getmask( return im def getbbox( - self, text: str | bytes | bytearray, *args: Any, **kwargs: Any + self, text: str | bytes, *args: Any, **kwargs: Any ) -> tuple[int, int, float, float]: # TransposedFont doesn't support getmask2, move top-left point to (0, 0) # this has no effect on ImageFont and simulates anchor="lt" for FreeTypeFont diff --git a/src/PIL/_imagingft.pyi b/src/PIL/_imagingft.pyi index 481ce3477a4..cda075348e1 100644 --- a/src/PIL/_imagingft.pyi +++ b/src/PIL/_imagingft.pyi @@ -24,34 +24,34 @@ class Font: self, string: str | bytes, fill: Callable[[int, int], _imaging.ImagingCore], - mode: str, - dir: str | None, - features: list[str] | None, - lang: str | None, - stroke_width: float, - stroke_filled: bool, - anchor: str | None, - foreground_ink_long: int, - start: tuple[float, float], + mode: str | None = ..., + dir: str | None = ..., + features: list[str] | None = ..., + lang: str | None = ..., + stroke_width: float = ..., + stroke_filled: bool = ..., + anchor: str | None = ..., + foreground_ink_long: int = ..., + start: tuple[float, float] = ..., /, ) -> tuple[_imaging.ImagingCore, tuple[int, int]]: ... def getsize( self, - string: str | bytes | bytearray, - mode: str, - dir: str | None, - features: list[str] | None, - lang: str | None, - anchor: str | None, + string: str | bytes, + mode: str | None = ..., + dir: str | None = ..., + features: list[str] | None = ..., + lang: str | None = ..., + anchor: str | None = ..., /, ) -> tuple[tuple[int, int], tuple[int, int]]: ... def getlength( self, string: str | bytes, - mode: str, - dir: str | None, - features: list[str] | None, - lang: str | None, + mode: str | None = ..., + dir: str | None = ..., + features: list[str] | None = ..., + lang: str | None = ..., /, ) -> int: ... def getvarnames(self) -> list[bytes]: ...