diff --git a/Tests/test_imagetext.py b/Tests/test_imagetext.py new file mode 100644 index 000000000..3a3a58975 --- /dev/null +++ b/Tests/test_imagetext.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import pytest + +from PIL import ImageFont, ImageText + +from .helper import skip_unless_feature + +FONT_PATH = "Tests/fonts/FreeMono.ttf" + + +@pytest.fixture( + scope="module", + params=[ + pytest.param(ImageFont.Layout.BASIC), + pytest.param(ImageFont.Layout.RAQM, marks=skip_unless_feature("raqm")), + ], +) +def layout_engine(request: pytest.FixtureRequest) -> ImageFont.Layout: + return request.param + + +@pytest.fixture(scope="module") +def font(layout_engine: ImageFont.Layout) -> ImageFont.FreeTypeFont: + return ImageFont.truetype(FONT_PATH, 20, layout_engine=layout_engine) + + +def test_get_length(font: ImageFont.FreeTypeFont) -> None: + assert ImageText.ImageText("A", font).get_length() == 12 + assert ImageText.ImageText("AB", font).get_length() == 24 + assert ImageText.ImageText("M", font).get_length() == 12 + assert ImageText.ImageText("y", font).get_length() == 12 + assert ImageText.ImageText("a", font).get_length() == 12 + + +def test_get_bbox(font: ImageFont.FreeTypeFont) -> None: + assert ImageText.ImageText("A", font).get_bbox() == (0, 4, 12, 16) + assert ImageText.ImageText("AB", font).get_bbox() == (0, 4, 24, 16) + assert ImageText.ImageText("M", font).get_bbox() == (0, 4, 12, 16) + assert ImageText.ImageText("y", font).get_bbox() == (0, 7, 12, 20) + assert ImageText.ImageText("a", font).get_bbox() == (0, 7, 12, 16) diff --git a/docs/reference/ImageText.rst b/docs/reference/ImageText.rst new file mode 100644 index 000000000..ad5439751 --- /dev/null +++ b/docs/reference/ImageText.rst @@ -0,0 +1,30 @@ +.. py:module:: PIL.ImageText +.. py:currentmodule:: PIL.ImageText + +:py:mod:`~PIL.ImageText` module +=============================== + +The :py:mod:`~PIL.ImageText` module defines a class with the same name. Instances of +this class provide a way to use fonts with text strings or bytes. The result is a +simple API to apply styling to pieces of text and measure them. + +Example +------- + +:: + + from PIL import Image, ImageDraw, ImageFont, ImageText + font = ImageFont.truetype("Tests/fonts/FreeMono.ttf", 24) + + text = ImageText.ImageText("Hello world", font) + text.embed_color() + text.stroke(2, "#0f0") + + print(text.get_length()) # 154.0 + print(text.get_bbox()) # (-2, 3, 156, 22) + +Methods +------- + +.. autoclass:: PIL.ImageText.ImageText + :members: diff --git a/docs/reference/index.rst b/docs/reference/index.rst index effcd3c46..1ce26c909 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -24,6 +24,7 @@ Reference ImageSequence ImageShow ImageStat + ImageText ImageTk ImageTransform ImageWin diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index e95fa91f8..35ecbfb78 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -35,10 +35,10 @@ import math import struct from collections.abc import Sequence from types import ModuleType -from typing import Any, AnyStr, Callable, Union, cast +from typing import Any, AnyStr, Callable, cast -from . import Image, ImageColor -from ._typing import Coords +from . import Image, ImageColor, ImageText +from ._typing import Coords, _Ink # experimental access to the outline API Outline: Callable[[], Image.core._Outline] = Image.core.outline @@ -47,8 +47,6 @@ TYPE_CHECKING = False if TYPE_CHECKING: from . import ImageDraw2, ImageFont -_Ink = Union[float, tuple[int, ...], str] - """ A simple 2D drawing interface for PIL images.

@@ -536,11 +534,6 @@ class ImageDraw: right[3] -= r + 1 self.draw.draw_rectangle(right, ink, 1) - def _multiline_check(self, text: AnyStr) -> bool: - split_character = "\n" if isinstance(text, str) else b"\n" - - return split_character in text - def text( self, xy: tuple[float, float], @@ -565,29 +558,15 @@ class ImageDraw: **kwargs: Any, ) -> None: """Draw text.""" - if embedded_color and self.mode not in ("RGB", "RGBA"): - msg = "Embedded color supported only in RGB and RGBA modes" - raise ValueError(msg) - if font is None: font = self._getfont(kwargs.get("font_size")) - - if self._multiline_check(text): - return self.multiline_text( - xy, - text, - fill, - font, - anchor, - spacing, - align, - direction, - features, - language, - stroke_width, - stroke_fill, - embedded_color, - ) + imagetext = ImageText.ImageText( + text, font, self.mode, spacing, direction, features, language + ) + if embedded_color: + imagetext.embed_color() + if stroke_width: + imagetext.stroke(stroke_width, stroke_fill) def getink(fill: _Ink | None) -> int: ink, fill_ink = self._getink(fill) @@ -596,70 +575,79 @@ class ImageDraw: return fill_ink return ink - def draw_text(ink: int, stroke_width: float = 0) -> None: - mode = self.fontmode - if stroke_width == 0 and embedded_color: - mode = "RGBA" - coord = [] - for i in range(2): - coord.append(int(xy[i])) - start = (math.modf(xy[0])[0], math.modf(xy[1])[0]) - try: - mask, offset = font.getmask2( # type: ignore[union-attr,misc] - text, - mode, - direction=direction, - features=features, - language=language, - stroke_width=stroke_width, - stroke_filled=True, - anchor=anchor, - ink=ink, - start=start, - *args, - **kwargs, - ) - coord = [coord[0] + offset[0], coord[1] + offset[1]] - except AttributeError: + ink = getink(fill) + if ink is None: + return + + stroke_ink = None + if imagetext.stroke_width: + stroke_ink = ( + getink(imagetext.stroke_fill) + if imagetext.stroke_fill is not None + else ink + ) + + for xy, anchor, line in imagetext._split(xy, anchor, align): + + def draw_text(ink: int, stroke_width: float = 0) -> None: + mode = self.fontmode + if stroke_width == 0 and embedded_color: + mode = "RGBA" + coord = [] + for i in range(2): + coord.append(int(xy[i])) + start = (math.modf(xy[0])[0], math.modf(xy[1])[0]) try: - mask = font.getmask( # type: ignore[misc] - text, + mask, offset = imagetext.font.getmask2( # type: ignore[union-attr,misc] + line, mode, - direction, - features, - language, - stroke_width, - anchor, - ink, + direction=direction, + features=features, + language=language, + stroke_width=stroke_width, + stroke_filled=True, + anchor=anchor, + ink=ink, start=start, *args, **kwargs, ) - except TypeError: - mask = font.getmask(text) - if mode == "RGBA": - # font.getmask2(mode="RGBA") returns color in RGB bands and mask in A - # extract mask and set text alpha - color, mask = mask, mask.getband(3) - ink_alpha = struct.pack("i", ink)[3] - color.fillband(3, ink_alpha) - x, y = coord - if self.im is not None: - self.im.paste( - color, (x, y, x + mask.size[0], y + mask.size[1]), mask - ) - else: - self.draw.draw_bitmap(coord, mask, ink) - - ink = getink(fill) - if ink is not None: - stroke_ink = None - if stroke_width: - stroke_ink = getink(stroke_fill) if stroke_fill is not None else ink + coord = [coord[0] + offset[0], coord[1] + offset[1]] + except AttributeError: + try: + mask = imagetext.font.getmask( # type: ignore[misc] + line, + mode, + direction, + features, + language, + stroke_width, + anchor, + ink, + start=start, + *args, + **kwargs, + ) + except TypeError: + mask = imagetext.font.getmask(line) + if mode == "RGBA": + # imagetext.font.getmask2(mode="RGBA") + # returns color in RGB bands and mask in A + # extract mask and set text alpha + color, mask = mask, mask.getband(3) + ink_alpha = struct.pack("i", ink)[3] + color.fillband(3, ink_alpha) + x, y = coord + if self.im is not None: + self.im.paste( + color, (x, y, x + mask.size[0], y + mask.size[1]), mask + ) + else: + self.draw.draw_bitmap(coord, mask, ink) if stroke_ink is not None: # Draw stroked text - draw_text(stroke_ink, stroke_width) + draw_text(stroke_ink, imagetext.stroke_width) # Draw normal text if ink != stroke_ink: @@ -668,132 +656,6 @@ class ImageDraw: # Only draw normal text draw_text(ink) - def _prepare_multiline_text( - self, - xy: tuple[float, float], - text: AnyStr, - font: ( - ImageFont.ImageFont - | ImageFont.FreeTypeFont - | ImageFont.TransposedFont - | None - ), - anchor: str | None, - spacing: float, - align: str, - direction: str | None, - features: list[str] | None, - language: str | None, - stroke_width: float, - embedded_color: bool, - font_size: float | None, - ) -> tuple[ - ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont, - list[tuple[tuple[float, float], str, AnyStr]], - ]: - if anchor is None: - anchor = "lt" if direction == "ttb" else "la" - elif len(anchor) != 2: - msg = "anchor must be a 2 character string" - raise ValueError(msg) - elif anchor[1] in "tb" and direction != "ttb": - msg = "anchor not supported for multiline text" - raise ValueError(msg) - - if font is None: - font = self._getfont(font_size) - - lines = text.split("\n" if isinstance(text, str) else b"\n") - line_spacing = ( - self.textbbox((0, 0), "A", font, stroke_width=stroke_width)[3] - + stroke_width - + spacing - ) - - top = xy[1] - parts = [] - if direction == "ttb": - left = xy[0] - for line in lines: - parts.append(((left, top), anchor, line)) - left += line_spacing - else: - widths = [] - max_width: float = 0 - for line in lines: - line_width = self.textlength( - line, - font, - direction=direction, - features=features, - language=language, - embedded_color=embedded_color, - ) - widths.append(line_width) - max_width = max(max_width, line_width) - - if anchor[1] == "m": - top -= (len(lines) - 1) * line_spacing / 2.0 - elif anchor[1] == "d": - top -= (len(lines) - 1) * line_spacing - - for idx, line in enumerate(lines): - left = xy[0] - width_difference = max_width - widths[idx] - - # align by align parameter - if align in ("left", "justify"): - pass - elif align == "center": - left += width_difference / 2.0 - elif align == "right": - left += width_difference - else: - msg = 'align must be "left", "center", "right" or "justify"' - raise ValueError(msg) - - if ( - align == "justify" - and width_difference != 0 - and idx != len(lines) - 1 - ): - words = line.split(" " if isinstance(text, str) else b" ") - if len(words) > 1: - # align left by anchor - if anchor[0] == "m": - left -= max_width / 2.0 - elif anchor[0] == "r": - left -= max_width - - word_widths = [ - self.textlength( - word, - font, - direction=direction, - features=features, - language=language, - embedded_color=embedded_color, - ) - for word in words - ] - word_anchor = "l" + anchor[1] - width_difference = max_width - sum(word_widths) - for i, word in enumerate(words): - parts.append(((left, top), word_anchor, word)) - left += word_widths[i] + width_difference / (len(words) - 1) - top += line_spacing - continue - - # align left by anchor - if anchor[0] == "m": - left -= width_difference / 2.0 - elif anchor[0] == "r": - left -= width_difference - parts.append(((left, top), anchor, line)) - top += line_spacing - - return font, parts - def multiline_text( self, xy: tuple[float, float], @@ -817,9 +679,10 @@ class ImageDraw: *, font_size: float | None = None, ) -> None: - font, lines = self._prepare_multiline_text( + return self.text( xy, text, + fill, font, anchor, spacing, @@ -828,25 +691,11 @@ class ImageDraw: features, language, stroke_width, + stroke_fill, embedded_color, - font_size, + font_size=font_size, ) - for xy, anchor, line in lines: - self.text( - xy, - line, - fill, - font, - anchor, - direction=direction, - features=features, - language=language, - stroke_width=stroke_width, - stroke_fill=stroke_fill, - embedded_color=embedded_color, - ) - def textlength( self, text: AnyStr, @@ -864,17 +713,19 @@ class ImageDraw: font_size: float | None = None, ) -> float: """Get the length of a given string, in pixels with 1/64 precision.""" - if self._multiline_check(text): - msg = "can't measure length of multiline text" - raise ValueError(msg) - if embedded_color and self.mode not in ("RGB", "RGBA"): - msg = "Embedded color supported only in RGB and RGBA modes" - raise ValueError(msg) - if font is None: font = self._getfont(font_size) - mode = "RGBA" if embedded_color else self.fontmode - return font.getlength(text, mode, direction, features, language) + imagetext = ImageText.ImageText( + text, + font, + self.mode, + direction=direction, + features=features, + language=language, + ) + if embedded_color: + imagetext.embed_color() + return imagetext.get_length() def textbbox( self, @@ -898,33 +749,16 @@ class ImageDraw: font_size: float | None = None, ) -> tuple[float, float, float, float]: """Get the bounding box of a given string, in pixels.""" - if embedded_color and self.mode not in ("RGB", "RGBA"): - msg = "Embedded color supported only in RGB and RGBA modes" - raise ValueError(msg) - if font is None: font = self._getfont(font_size) - - if self._multiline_check(text): - return self.multiline_textbbox( - xy, - text, - font, - anchor, - spacing, - align, - direction, - features, - language, - stroke_width, - embedded_color, - ) - - mode = "RGBA" if embedded_color else self.fontmode - bbox = font.getbbox( - text, mode, direction, features, language, stroke_width, anchor + imagetext = ImageText.ImageText( + text, font, self.mode, spacing, direction, features, language ) - return bbox[0] + xy[0], bbox[1] + xy[1], bbox[2] + xy[0], bbox[3] + xy[1] + if embedded_color: + imagetext.embed_color() + if stroke_width: + imagetext.stroke(stroke_width) + return imagetext.get_bbox(xy, anchor, align) def multiline_textbbox( self, @@ -947,7 +781,7 @@ class ImageDraw: *, font_size: float | None = None, ) -> tuple[float, float, float, float]: - font, lines = self._prepare_multiline_text( + return self.textbbox( xy, text, font, @@ -959,37 +793,9 @@ class ImageDraw: language, stroke_width, embedded_color, - font_size, + font_size=font_size, ) - bbox: tuple[float, float, float, float] | None = None - - for xy, anchor, line in lines: - bbox_line = self.textbbox( - xy, - line, - font, - anchor, - direction=direction, - features=features, - language=language, - stroke_width=stroke_width, - embedded_color=embedded_color, - ) - if bbox is None: - bbox = bbox_line - else: - bbox = ( - min(bbox[0], bbox_line[0]), - min(bbox[1], bbox_line[1]), - max(bbox[2], bbox_line[2]), - max(bbox[3], bbox_line[3]), - ) - - if bbox is None: - return xy[0], xy[1], xy[0], xy[1] - return bbox - def Draw(im: Image.Image, mode: str | None = None) -> ImageDraw: """ diff --git a/src/PIL/ImageText.py b/src/PIL/ImageText.py new file mode 100644 index 000000000..9bb31a1c8 --- /dev/null +++ b/src/PIL/ImageText.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +from . import ImageFont +from ._typing import _Ink + + +class ImageText: + def __init__( + self, + text: str | bytes, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, + mode: str = "RGB", + spacing: float = 4, + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + ) -> None: + """ + :param text: String to be drawn. + :param font: Either an :py:class:`~PIL.ImageFont.ImageFont` instance, + :py:class:`~PIL.ImageFont.FreeTypeFont` instance, + :py:class:`~PIL.ImageFont.TransposedFont` instance or ``None``. If + ``None``, the default font from :py:meth:`.ImageFont.load_default` + will be used. + :param mode: The image mode this will be used with. + :param spacing: The number of pixels between lines. + :param direction: Direction of the text. It can be ``"rtl"`` (right to left), + ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). + Requires libraqm. + :param features: A list of OpenType font features to be used during text + layout. This is usually used to turn on optional font features + that are not enabled by default, for example ``"dlig"`` or + ``"ss01"``, but can be also used to turn off default font + features, for example ``"-liga"`` to disable ligatures or + ``"-kern"`` to disable kerning. To get all supported + features, see `OpenType docs`_. + Requires libraqm. + :param language: Language of the text. Different languages may use + different glyph shapes or ligatures. This parameter tells + the font which language the text is in, and to apply the + correct substitutions as appropriate, if available. + It should be a `BCP 47 language code`_. + Requires libraqm. + """ + self.text = text + self.font = font or ImageFont.load_default() + + self.mode = mode + self.spacing = spacing + self.direction = direction + self.features = features + self.language = language + + self.embedded_color = False + + self.stroke_width: float = 0 + self.stroke_fill: _Ink | None = None + + def embed_color(self) -> None: + """ + Use embedded color glyphs (COLR, CBDT, SBIX). + """ + if self.mode not in ("RGB", "RGBA"): + msg = "Embedded color supported only in RGB and RGBA modes" + raise ValueError(msg) + self.embedded_color = True + + def stroke(self, width: float = 0, fill: _Ink | None = None) -> None: + """ + :param width: The width of the text stroke. + :param fill: Color to use for the text stroke when drawing. If not given, will + default to the ``fill`` parameter from + :py:meth:`.ImageDraw.ImageDraw.text`. + """ + self.stroke_width = width + self.stroke_fill = fill + + def _get_fontmode(self) -> str: + if self.mode in ("1", "P", "I", "F"): + return "1" + elif self.embedded_color: + return "RGBA" + else: + return "L" + + def get_length(self): + """ + Returns length (in pixels with 1/64 precision) of text. + + This is the amount by which following text should be offset. + Text bounding box may extend past the length in some fonts, + e.g. when using italics or accents. + + The result is returned as a float; it is a whole number if using basic layout. + + Note that the sum of two lengths may not equal the length of a concatenated + string due to kerning. If you need to adjust for kerning, include the following + character and subtract its length. + + For example, instead of:: + + hello = ImageText.ImageText("Hello", font).get_length() + world = ImageText.ImageText("World", font).get_length() + helloworld = ImageText.ImageText("HelloWorld", font).get_length() + assert hello + world == helloworld + + use:: + + hello = ( + ImageText.ImageText("HelloW", font).get_length() - + ImageText.ImageText("W", font).get_length() + ) # adjusted for kerning + world = ImageText.ImageText("World", font).get_length() + helloworld = ImageText.ImageText("HelloWorld", font).get_length() + assert hello + world == helloworld + + or disable kerning with (requires libraqm):: + + hello = ImageText.ImageText("Hello", font, features=["-kern"]).get_length() + world = ImageText.ImageText("World", font, features=["-kern"]).get_length() + helloworld = ImageText.ImageText( + "HelloWorld", font, features=["-kern"] + ).get_length() + assert hello + world == helloworld + + :return: Either width for horizontal text, or height for vertical text. + """ + split_character = "\n" if isinstance(self.text, str) else b"\n" + if split_character in self.text: + msg = "can't measure length of multiline text" + raise ValueError(msg) + return self.font.getlength( + self.text, + self._get_fontmode(), + self.direction, + self.features, + self.language, + ) + + def _split( + self, xy: tuple[float, float], anchor: str | None, align: str + ) -> list[tuple[tuple[float, float], str, str | bytes]]: + if anchor is None: + anchor = "lt" if self.direction == "ttb" else "la" + elif len(anchor) != 2: + msg = "anchor must be a 2 character string" + raise ValueError(msg) + + lines = ( + self.text.split("\n") + if isinstance(self.text, str) + else self.text.split(b"\n") + ) + if len(lines) == 1: + return [(xy, anchor, self.text)] + + if anchor[1] in "tb" and self.direction != "ttb": + msg = "anchor not supported for multiline text" + raise ValueError(msg) + + fontmode = self._get_fontmode() + line_spacing = ( + self.font.getbbox( + "A", + fontmode, + None, + self.features, + self.language, + self.stroke_width, + )[3] + + self.stroke_width + + self.spacing + ) + + top = xy[1] + parts = [] + if self.direction == "ttb": + left = xy[0] + for line in lines: + parts.append(((left, top), anchor, line)) + left += line_spacing + else: + widths = [] + max_width: float = 0 + for line in lines: + line_width = self.font.getlength( + line, fontmode, self.direction, self.features, self.language + ) + widths.append(line_width) + max_width = max(max_width, line_width) + + if anchor[1] == "m": + top -= (len(lines) - 1) * line_spacing / 2.0 + elif anchor[1] == "d": + top -= (len(lines) - 1) * line_spacing + + idx = -1 + for line in lines: + left = xy[0] + idx += 1 + width_difference = max_width - widths[idx] + + # align by align parameter + if align in ("left", "justify"): + pass + elif align == "center": + left += width_difference / 2.0 + elif align == "right": + left += width_difference + else: + msg = 'align must be "left", "center", "right" or "justify"' + raise ValueError(msg) + + if ( + align == "justify" + and width_difference != 0 + and idx != len(lines) - 1 + ): + words = ( + line.split(" ") if isinstance(line, str) else line.split(b" ") + ) + if len(words) > 1: + # align left by anchor + if anchor[0] == "m": + left -= max_width / 2.0 + elif anchor[0] == "r": + left -= max_width + + word_widths = [ + self.font.getlength( + word, + fontmode, + self.direction, + self.features, + self.language, + ) + for word in words + ] + word_anchor = "l" + anchor[1] + width_difference = max_width - sum(word_widths) + i = 0 + for word in words: + parts.append(((left, top), word_anchor, word)) + left += word_widths[i] + width_difference / (len(words) - 1) + i += 1 + top += line_spacing + continue + + # align left by anchor + if anchor[0] == "m": + left -= width_difference / 2.0 + elif anchor[0] == "r": + left -= width_difference + parts.append(((left, top), anchor, line)) + top += line_spacing + + return parts + + def get_bbox( + self, + xy: tuple[float, float] = (0, 0), + anchor: str | None = None, + align: str = "left", + ) -> tuple[float, float, float, float]: + """ + Returns bounding box (in pixels) of text. + + Use :py:meth:`get_length` to get the offset of following text with 1/64 pixel + precision. The bounding box includes extra margins for some fonts, e.g. italics + or accents. + + :param xy: The anchor coordinates of the text. + :param anchor: The text anchor alignment. Determines the relative location of + the anchor to the text. The default alignment is top left, + specifically ``la`` for horizontal text and ``lt`` for + vertical text. See :ref:`text-anchors` for details. + :param align: For multiline text, ``"left"``, ``"center"``, ``"right"`` or + ``"justify"`` determines the relative alignment of lines. Use the + ``anchor`` parameter to specify the alignment to ``xy``. + + :return: ``(left, top, right, bottom)`` bounding box + """ + bbox: tuple[float, float, float, float] | None = None + fontmode = self._get_fontmode() + for xy, anchor, line in self._split(xy, anchor, align): + bbox_line = self.font.getbbox( + line, + fontmode, + self.direction, + self.features, + self.language, + self.stroke_width, + anchor, + ) + bbox_line = ( + bbox_line[0] + xy[0], + bbox_line[1] + xy[1], + bbox_line[2] + xy[0], + bbox_line[3] + xy[1], + ) + if bbox is None: + bbox = bbox_line + else: + bbox = ( + min(bbox[0], bbox_line[0]), + min(bbox[1], bbox_line[1]), + max(bbox[2], bbox_line[2]), + max(bbox[3], bbox_line[3]), + ) + + if bbox is None: + return xy[0], xy[1], xy[0], xy[1] + return bbox diff --git a/src/PIL/_typing.py b/src/PIL/_typing.py index 373938e71..685c425d5 100644 --- a/src/PIL/_typing.py +++ b/src/PIL/_typing.py @@ -38,6 +38,8 @@ else: return bool +_Ink = Union[float, tuple[int, ...], str] + Coords = Union[Sequence[float], Sequence[Sequence[float]]]