ImageText.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. from __future__ import annotations
  2. from . import ImageFont
  3. from ._typing import _Ink
  4. class Text:
  5. def __init__(
  6. self,
  7. text: str | bytes,
  8. font: (
  9. ImageFont.ImageFont
  10. | ImageFont.FreeTypeFont
  11. | ImageFont.TransposedFont
  12. | None
  13. ) = None,
  14. mode: str = "RGB",
  15. spacing: float = 4,
  16. direction: str | None = None,
  17. features: list[str] | None = None,
  18. language: str | None = None,
  19. ) -> None:
  20. """
  21. :param text: String to be drawn.
  22. :param font: Either an :py:class:`~PIL.ImageFont.ImageFont` instance,
  23. :py:class:`~PIL.ImageFont.FreeTypeFont` instance,
  24. :py:class:`~PIL.ImageFont.TransposedFont` instance or ``None``. If
  25. ``None``, the default font from :py:meth:`.ImageFont.load_default`
  26. will be used.
  27. :param mode: The image mode this will be used with.
  28. :param spacing: The number of pixels between lines.
  29. :param direction: Direction of the text. It can be ``"rtl"`` (right to left),
  30. ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom).
  31. Requires libraqm.
  32. :param features: A list of OpenType font features to be used during text
  33. layout. This is usually used to turn on optional font features
  34. that are not enabled by default, for example ``"dlig"`` or
  35. ``"ss01"``, but can be also used to turn off default font
  36. features, for example ``"-liga"`` to disable ligatures or
  37. ``"-kern"`` to disable kerning. To get all supported
  38. features, see `OpenType docs`_.
  39. Requires libraqm.
  40. :param language: Language of the text. Different languages may use
  41. different glyph shapes or ligatures. This parameter tells
  42. the font which language the text is in, and to apply the
  43. correct substitutions as appropriate, if available.
  44. It should be a `BCP 47 language code`_.
  45. Requires libraqm.
  46. """
  47. self.text = text
  48. self.font = font or ImageFont.load_default()
  49. self.mode = mode
  50. self.spacing = spacing
  51. self.direction = direction
  52. self.features = features
  53. self.language = language
  54. self.embedded_color = False
  55. self.stroke_width: float = 0
  56. self.stroke_fill: _Ink | None = None
  57. def embed_color(self) -> None:
  58. """
  59. Use embedded color glyphs (COLR, CBDT, SBIX).
  60. """
  61. if self.mode not in ("RGB", "RGBA"):
  62. msg = "Embedded color supported only in RGB and RGBA modes"
  63. raise ValueError(msg)
  64. self.embedded_color = True
  65. def stroke(self, width: float = 0, fill: _Ink | None = None) -> None:
  66. """
  67. :param width: The width of the text stroke.
  68. :param fill: Color to use for the text stroke when drawing. If not given, will
  69. default to the ``fill`` parameter from
  70. :py:meth:`.ImageDraw.ImageDraw.text`.
  71. """
  72. self.stroke_width = width
  73. self.stroke_fill = fill
  74. def _get_fontmode(self) -> str:
  75. if self.mode in ("1", "P", "I", "F"):
  76. return "1"
  77. elif self.embedded_color:
  78. return "RGBA"
  79. else:
  80. return "L"
  81. def get_length(self) -> float:
  82. """
  83. Returns length (in pixels with 1/64 precision) of text.
  84. This is the amount by which following text should be offset.
  85. Text bounding box may extend past the length in some fonts,
  86. e.g. when using italics or accents.
  87. The result is returned as a float; it is a whole number if using basic layout.
  88. Note that the sum of two lengths may not equal the length of a concatenated
  89. string due to kerning. If you need to adjust for kerning, include the following
  90. character and subtract its length.
  91. For example, instead of::
  92. hello = ImageText.Text("Hello", font).get_length()
  93. world = ImageText.Text("World", font).get_length()
  94. helloworld = ImageText.Text("HelloWorld", font).get_length()
  95. assert hello + world == helloworld
  96. use::
  97. hello = (
  98. ImageText.Text("HelloW", font).get_length() -
  99. ImageText.Text("W", font).get_length()
  100. ) # adjusted for kerning
  101. world = ImageText.Text("World", font).get_length()
  102. helloworld = ImageText.Text("HelloWorld", font).get_length()
  103. assert hello + world == helloworld
  104. or disable kerning with (requires libraqm)::
  105. hello = ImageText.Text("Hello", font, features=["-kern"]).get_length()
  106. world = ImageText.Text("World", font, features=["-kern"]).get_length()
  107. helloworld = ImageText.Text(
  108. "HelloWorld", font, features=["-kern"]
  109. ).get_length()
  110. assert hello + world == helloworld
  111. :return: Either width for horizontal text, or height for vertical text.
  112. """
  113. if isinstance(self.text, str):
  114. multiline = "\n" in self.text
  115. else:
  116. multiline = b"\n" in self.text
  117. if multiline:
  118. msg = "can't measure length of multiline text"
  119. raise ValueError(msg)
  120. return self.font.getlength(
  121. self.text,
  122. self._get_fontmode(),
  123. self.direction,
  124. self.features,
  125. self.language,
  126. )
  127. def _split(
  128. self, xy: tuple[float, float], anchor: str | None, align: str
  129. ) -> list[tuple[tuple[float, float], str, str | bytes]]:
  130. if anchor is None:
  131. anchor = "lt" if self.direction == "ttb" else "la"
  132. elif len(anchor) != 2:
  133. msg = "anchor must be a 2 character string"
  134. raise ValueError(msg)
  135. lines = (
  136. self.text.split("\n")
  137. if isinstance(self.text, str)
  138. else self.text.split(b"\n")
  139. )
  140. if len(lines) == 1:
  141. return [(xy, anchor, self.text)]
  142. if anchor[1] in "tb" and self.direction != "ttb":
  143. msg = "anchor not supported for multiline text"
  144. raise ValueError(msg)
  145. fontmode = self._get_fontmode()
  146. line_spacing = (
  147. self.font.getbbox(
  148. "A",
  149. fontmode,
  150. None,
  151. self.features,
  152. self.language,
  153. self.stroke_width,
  154. )[3]
  155. + self.stroke_width
  156. + self.spacing
  157. )
  158. top = xy[1]
  159. parts = []
  160. if self.direction == "ttb":
  161. left = xy[0]
  162. for line in lines:
  163. parts.append(((left, top), anchor, line))
  164. left += line_spacing
  165. else:
  166. widths = []
  167. max_width: float = 0
  168. for line in lines:
  169. line_width = self.font.getlength(
  170. line, fontmode, self.direction, self.features, self.language
  171. )
  172. widths.append(line_width)
  173. max_width = max(max_width, line_width)
  174. if anchor[1] == "m":
  175. top -= (len(lines) - 1) * line_spacing / 2.0
  176. elif anchor[1] == "d":
  177. top -= (len(lines) - 1) * line_spacing
  178. idx = -1
  179. for line in lines:
  180. left = xy[0]
  181. idx += 1
  182. width_difference = max_width - widths[idx]
  183. # align by align parameter
  184. if align in ("left", "justify"):
  185. pass
  186. elif align == "center":
  187. left += width_difference / 2.0
  188. elif align == "right":
  189. left += width_difference
  190. else:
  191. msg = 'align must be "left", "center", "right" or "justify"'
  192. raise ValueError(msg)
  193. if (
  194. align == "justify"
  195. and width_difference != 0
  196. and idx != len(lines) - 1
  197. ):
  198. words = (
  199. line.split(" ") if isinstance(line, str) else line.split(b" ")
  200. )
  201. if len(words) > 1:
  202. # align left by anchor
  203. if anchor[0] == "m":
  204. left -= max_width / 2.0
  205. elif anchor[0] == "r":
  206. left -= max_width
  207. word_widths = [
  208. self.font.getlength(
  209. word,
  210. fontmode,
  211. self.direction,
  212. self.features,
  213. self.language,
  214. )
  215. for word in words
  216. ]
  217. word_anchor = "l" + anchor[1]
  218. width_difference = max_width - sum(word_widths)
  219. i = 0
  220. for word in words:
  221. parts.append(((left, top), word_anchor, word))
  222. left += word_widths[i] + width_difference / (len(words) - 1)
  223. i += 1
  224. top += line_spacing
  225. continue
  226. # align left by anchor
  227. if anchor[0] == "m":
  228. left -= width_difference / 2.0
  229. elif anchor[0] == "r":
  230. left -= width_difference
  231. parts.append(((left, top), anchor, line))
  232. top += line_spacing
  233. return parts
  234. def get_bbox(
  235. self,
  236. xy: tuple[float, float] = (0, 0),
  237. anchor: str | None = None,
  238. align: str = "left",
  239. ) -> tuple[float, float, float, float]:
  240. """
  241. Returns bounding box (in pixels) of text.
  242. Use :py:meth:`get_length` to get the offset of following text with 1/64 pixel
  243. precision. The bounding box includes extra margins for some fonts, e.g. italics
  244. or accents.
  245. :param xy: The anchor coordinates of the text.
  246. :param anchor: The text anchor alignment. Determines the relative location of
  247. the anchor to the text. The default alignment is top left,
  248. specifically ``la`` for horizontal text and ``lt`` for
  249. vertical text. See :ref:`text-anchors` for details.
  250. :param align: For multiline text, ``"left"``, ``"center"``, ``"right"`` or
  251. ``"justify"`` determines the relative alignment of lines. Use the
  252. ``anchor`` parameter to specify the alignment to ``xy``.
  253. :return: ``(left, top, right, bottom)`` bounding box
  254. """
  255. bbox: tuple[float, float, float, float] | None = None
  256. fontmode = self._get_fontmode()
  257. for xy, anchor, line in self._split(xy, anchor, align):
  258. bbox_line = self.font.getbbox(
  259. line,
  260. fontmode,
  261. self.direction,
  262. self.features,
  263. self.language,
  264. self.stroke_width,
  265. anchor,
  266. )
  267. bbox_line = (
  268. bbox_line[0] + xy[0],
  269. bbox_line[1] + xy[1],
  270. bbox_line[2] + xy[0],
  271. bbox_line[3] + xy[1],
  272. )
  273. if bbox is None:
  274. bbox = bbox_line
  275. else:
  276. bbox = (
  277. min(bbox[0], bbox_line[0]),
  278. min(bbox[1], bbox_line[1]),
  279. max(bbox[2], bbox_line[2]),
  280. max(bbox[3], bbox_line[3]),
  281. )
  282. assert bbox is not None
  283. return bbox