| 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):
|
| """
|
| 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.
|
| """
|
| 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]
|
|
|
|
|
| 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]),
|
| )
|
|
|
| if bbox is None:
|
| return xy[0], xy[1], xy[0], xy[1]
|
| return bbox
|
|
|