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 1f5fe66197a..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. @@ -297,9 +295,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)) @@ -406,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, @@ -774,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 2136810ba6a..cda075348e1 100644 --- a/src/PIL/_imagingft.pyi +++ b/src/PIL/_imagingft.pyi @@ -24,36 +24,36 @@ 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 = ..., /, - ) -> 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 4b49eb04766..33ac296259e 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 + 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; struct { @@ -96,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) { @@ -125,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 @@ -143,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, @@ -162,7 +177,7 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { if (!PyArg_ParseTupleAndKeywords( args, kw, - "etf|nsy#n", + "etfnsy#n", kwlist, Py_FileSystemDefaultEncoding, &filename, @@ -215,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; @@ -517,9 +532,27 @@ 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 */ + 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? */ @@ -531,8 +564,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 )) { @@ -589,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; @@ -754,6 +785,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; @@ -772,8 +816,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", @@ -838,11 +880,30 @@ 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 */ + 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; @@ -990,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 ); } @@ -1028,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;