| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320 |
- 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]
- # 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]),
- )
- assert bbox is not None
- return bbox
|