| | from __future__ import annotations |
| |
|
| | from . import ImageFont |
| | from ._typing import _Ink |
| |
|
| |
|
| | class Text: |
| | 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) -> float: |
| | """ |
| | 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.Text("Hello", font).get_length() |
| | world = ImageText.Text("World", font).get_length() |
| | helloworld = ImageText.Text("HelloWorld", font).get_length() |
| | assert hello + world == helloworld |
| | |
| | use:: |
| | |
| | hello = ( |
| | ImageText.Text("HelloW", font).get_length() - |
| | ImageText.Text("W", font).get_length() |
| | ) # adjusted for kerning |
| | world = ImageText.Text("World", font).get_length() |
| | helloworld = ImageText.Text("HelloWorld", font).get_length() |
| | assert hello + world == helloworld |
| | |
| | or disable kerning with (requires libraqm):: |
| | |
| | hello = ImageText.Text("Hello", font, features=["-kern"]).get_length() |
| | world = ImageText.Text("World", font, features=["-kern"]).get_length() |
| | helloworld = ImageText.Text( |
| | "HelloWorld", font, features=["-kern"] |
| | ).get_length() |
| | assert hello + world == helloworld |
| | |
| | :return: Either width for horizontal text, or height for vertical text. |
| | """ |
| | if isinstance(self.text, str): |
| | multiline = "\n" in self.text |
| | else: |
| | multiline = b"\n" in self.text |
| | if multiline: |
| | 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] |
| |
|
| | |
| | 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: |
| | |
| | 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 |
| |
|
| | |
| | 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]), |
| | ) |
| |
|
| | assert bbox is not None |
| | return bbox |
| |
|