ImageText.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. from __future__ import annotations
  2. import math
  3. import re
  4. from typing import AnyStr, Generic, NamedTuple
  5. from . import ImageFont
  6. from ._typing import _Ink
  7. Font = ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont
  8. class _Line(NamedTuple):
  9. x: float
  10. y: float
  11. anchor: str
  12. text: str | bytes
  13. class _Wrap(Generic[AnyStr]):
  14. lines: list[AnyStr] = []
  15. position = 0
  16. offset = 0
  17. def __init__(
  18. self,
  19. text: Text[AnyStr],
  20. width: int,
  21. height: int | None = None,
  22. font: Font | None = None,
  23. ) -> None:
  24. self.text: Text[AnyStr] = text
  25. self.width = width
  26. self.height = height
  27. self.font = font
  28. input_text = self.text.text
  29. emptystring = "" if isinstance(input_text, str) else b""
  30. line = emptystring
  31. for word in re.findall(
  32. r"\s*\S+" if isinstance(input_text, str) else rb"\s*\S+", input_text
  33. ):
  34. newlines = re.findall(
  35. r"[^\S\n]*\n" if isinstance(input_text, str) else rb"[^\S\n]*\n", word
  36. )
  37. if newlines:
  38. if not self.add_line(line):
  39. break
  40. for i, line in enumerate(newlines):
  41. if i != 0 and not self.add_line(emptystring):
  42. break
  43. self.position += len(line)
  44. word = word[len(line) :]
  45. line = emptystring
  46. new_line = line + word
  47. if self.text._get_bbox(new_line, self.font)[2] <= width:
  48. # This word fits on the line
  49. line = new_line
  50. continue
  51. # This word does not fit on the line
  52. if line and not self.add_line(line):
  53. break
  54. original_length = len(word)
  55. word = word.lstrip()
  56. self.offset = original_length - len(word)
  57. if self.text._get_bbox(word, self.font)[2] > width:
  58. if font is None:
  59. msg = "Word does not fit within line"
  60. raise ValueError(msg)
  61. break
  62. line = word
  63. else:
  64. if line:
  65. self.add_line(line)
  66. self.remaining_text: AnyStr = input_text[self.position :]
  67. def add_line(self, line: AnyStr) -> bool:
  68. lines = self.lines + [line]
  69. if self.height is not None:
  70. last_line_y = self.text._split(lines=lines)[-1].y
  71. last_line_height = self.text._get_bbox(line, self.font)[3]
  72. if last_line_y + last_line_height > self.height:
  73. return False
  74. self.lines = lines
  75. self.position += len(line) + self.offset
  76. self.offset = 0
  77. return True
  78. class Text(Generic[AnyStr]):
  79. def __init__(
  80. self,
  81. text: AnyStr,
  82. font: Font | None = None,
  83. mode: str = "RGB",
  84. spacing: float = 4,
  85. direction: str | None = None,
  86. features: list[str] | None = None,
  87. language: str | None = None,
  88. ) -> None:
  89. """
  90. :param text: String to be drawn.
  91. :param font: Either an :py:class:`~PIL.ImageFont.ImageFont` instance,
  92. :py:class:`~PIL.ImageFont.FreeTypeFont` instance,
  93. :py:class:`~PIL.ImageFont.TransposedFont` instance or ``None``. If
  94. ``None``, the default font from :py:meth:`.ImageFont.load_default`
  95. will be used.
  96. :param mode: The image mode this will be used with.
  97. :param spacing: The number of pixels between lines.
  98. :param direction: Direction of the text. It can be ``"rtl"`` (right to left),
  99. ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom).
  100. Requires libraqm.
  101. :param features: A list of OpenType font features to be used during text
  102. layout. This is usually used to turn on optional font features
  103. that are not enabled by default, for example ``"dlig"`` or
  104. ``"ss01"``, but can be also used to turn off default font
  105. features, for example ``"-liga"`` to disable ligatures or
  106. ``"-kern"`` to disable kerning. To get all supported
  107. features, see `OpenType docs`_.
  108. Requires libraqm.
  109. :param language: Language of the text. Different languages may use
  110. different glyph shapes or ligatures. This parameter tells
  111. the font which language the text is in, and to apply the
  112. correct substitutions as appropriate, if available.
  113. It should be a `BCP 47 language code`_.
  114. Requires libraqm.
  115. """
  116. self.text: AnyStr = text
  117. self.font = font or ImageFont.load_default()
  118. self.mode = mode
  119. self.spacing = spacing
  120. self.direction = direction
  121. self.features = features
  122. self.language = language
  123. self.embedded_color = False
  124. self.stroke_width: float = 0
  125. self.stroke_fill: _Ink | None = None
  126. def embed_color(self) -> None:
  127. """
  128. Use embedded color glyphs (COLR, CBDT, SBIX).
  129. """
  130. if self.mode not in ("RGB", "RGBA"):
  131. msg = "Embedded color supported only in RGB and RGBA modes"
  132. raise ValueError(msg)
  133. self.embedded_color = True
  134. def stroke(self, width: float = 0, fill: _Ink | None = None) -> None:
  135. """
  136. :param width: The width of the text stroke.
  137. :param fill: Color to use for the text stroke when drawing. If not given, will
  138. default to the ``fill`` parameter from
  139. :py:meth:`.ImageDraw.ImageDraw.text`.
  140. """
  141. self.stroke_width = width
  142. self.stroke_fill = fill
  143. def _get_fontmode(self) -> str:
  144. if self.mode in ("1", "P", "I", "F"):
  145. return "1"
  146. elif self.embedded_color:
  147. return "RGBA"
  148. else:
  149. return "L"
  150. def wrap(
  151. self,
  152. width: int,
  153. height: int | None = None,
  154. scaling: str | tuple[str, int] | None = None,
  155. ) -> Text[AnyStr] | None:
  156. """
  157. Wrap text to fit within a given width.
  158. :param width: The width to fit within.
  159. :param height: An optional height limit. Any text that does not fit within this
  160. will be returned as a new :py:class:`.Text` object.
  161. :param scaling: An optional directive to scale the text, either "grow" as much
  162. as possible within the given dimensions, or "shrink" until it
  163. fits. It can also be a tuple of (direction, limit), with an
  164. integer limit to stop scaling at.
  165. :returns: An :py:class:`.Text` object, or None.
  166. """
  167. if isinstance(self.font, ImageFont.TransposedFont):
  168. msg = "TransposedFont not supported"
  169. raise ValueError(msg)
  170. if self.direction not in (None, "ltr"):
  171. msg = "Only ltr direction supported"
  172. raise ValueError(msg)
  173. if scaling is None:
  174. wrap = _Wrap(self, width, height)
  175. else:
  176. if not isinstance(self.font, ImageFont.FreeTypeFont):
  177. msg = "'scaling' only supports FreeTypeFont"
  178. raise ValueError(msg)
  179. if height is None:
  180. msg = "'scaling' requires 'height'"
  181. raise ValueError(msg)
  182. if isinstance(scaling, str):
  183. limit = 1
  184. else:
  185. scaling, limit = scaling
  186. font = self.font
  187. wrap = _Wrap(self, width, height, font)
  188. if scaling == "shrink":
  189. if not wrap.remaining_text:
  190. return None
  191. size = math.ceil(font.size)
  192. while wrap.remaining_text:
  193. if size == max(limit, 1):
  194. msg = "Text could not be scaled"
  195. raise ValueError(msg)
  196. size -= 1
  197. font = self.font.font_variant(size=size)
  198. wrap = _Wrap(self, width, height, font)
  199. self.font = font
  200. else:
  201. if wrap.remaining_text:
  202. msg = "Text could not be scaled"
  203. raise ValueError(msg)
  204. size = math.floor(font.size)
  205. while not wrap.remaining_text:
  206. if size == limit:
  207. msg = "Text could not be scaled"
  208. raise ValueError(msg)
  209. size += 1
  210. font = self.font.font_variant(size=size)
  211. last_wrap = wrap
  212. wrap = _Wrap(self, width, height, font)
  213. size -= 1
  214. if size != self.font.size:
  215. self.font = self.font.font_variant(size=size)
  216. wrap = last_wrap
  217. if wrap.remaining_text:
  218. text = Text(
  219. text=wrap.remaining_text,
  220. font=self.font,
  221. mode=self.mode,
  222. spacing=self.spacing,
  223. direction=self.direction,
  224. features=self.features,
  225. language=self.language,
  226. )
  227. text.embedded_color = self.embedded_color
  228. text.stroke_width = self.stroke_width
  229. text.stroke_fill = self.stroke_fill
  230. else:
  231. text = None
  232. newline = "\n" if isinstance(self.text, str) else b"\n"
  233. self.text = newline.join(wrap.lines)
  234. return text
  235. def get_length(self) -> float:
  236. """
  237. Returns length (in pixels with 1/64 precision) of text.
  238. This is the amount by which following text should be offset.
  239. Text bounding box may extend past the length in some fonts,
  240. e.g. when using italics or accents.
  241. The result is returned as a float; it is a whole number if using basic layout.
  242. Note that the sum of two lengths may not equal the length of a concatenated
  243. string due to kerning. If you need to adjust for kerning, include the following
  244. character and subtract its length.
  245. For example, instead of::
  246. hello = ImageText.Text("Hello", font).get_length()
  247. world = ImageText.Text("World", font).get_length()
  248. helloworld = ImageText.Text("HelloWorld", font).get_length()
  249. assert hello + world == helloworld
  250. use::
  251. hello = (
  252. ImageText.Text("HelloW", font).get_length() -
  253. ImageText.Text("W", font).get_length()
  254. ) # adjusted for kerning
  255. world = ImageText.Text("World", font).get_length()
  256. helloworld = ImageText.Text("HelloWorld", font).get_length()
  257. assert hello + world == helloworld
  258. or disable kerning with (requires libraqm)::
  259. hello = ImageText.Text("Hello", font, features=["-kern"]).get_length()
  260. world = ImageText.Text("World", font, features=["-kern"]).get_length()
  261. helloworld = ImageText.Text(
  262. "HelloWorld", font, features=["-kern"]
  263. ).get_length()
  264. assert hello + world == helloworld
  265. :return: Either width for horizontal text, or height for vertical text.
  266. """
  267. if isinstance(self.text, str):
  268. multiline = "\n" in self.text
  269. else:
  270. multiline = b"\n" in self.text
  271. if multiline:
  272. msg = "can't measure length of multiline text"
  273. raise ValueError(msg)
  274. return self.font.getlength(
  275. self.text,
  276. self._get_fontmode(),
  277. self.direction,
  278. self.features,
  279. self.language,
  280. )
  281. def _split(
  282. self,
  283. xy: tuple[float, float] = (0, 0),
  284. anchor: str | None = None,
  285. align: str = "left",
  286. lines: list[str] | list[bytes] | None = None,
  287. ) -> list[_Line]:
  288. if anchor is None:
  289. anchor = "lt" if self.direction == "ttb" else "la"
  290. elif len(anchor) != 2:
  291. msg = "anchor must be a 2 character string"
  292. raise ValueError(msg)
  293. if lines is None:
  294. lines = (
  295. self.text.split("\n")
  296. if isinstance(self.text, str)
  297. else self.text.split(b"\n")
  298. )
  299. if len(lines) == 1:
  300. return [_Line(xy[0], xy[1], anchor, lines[0])]
  301. if anchor[1] in "tb" and self.direction != "ttb":
  302. msg = "anchor not supported for multiline text"
  303. raise ValueError(msg)
  304. fontmode = self._get_fontmode()
  305. line_spacing = (
  306. self.font.getbbox(
  307. "A",
  308. fontmode,
  309. None,
  310. self.features,
  311. self.language,
  312. self.stroke_width,
  313. )[3]
  314. + self.stroke_width
  315. + self.spacing
  316. )
  317. top = xy[1]
  318. parts = []
  319. if self.direction == "ttb":
  320. left = xy[0]
  321. for line in lines:
  322. parts.append(_Line(left, top, anchor, line))
  323. left += line_spacing
  324. else:
  325. widths = []
  326. max_width: float = 0
  327. for line in lines:
  328. line_width = self.font.getlength(
  329. line, fontmode, self.direction, self.features, self.language
  330. )
  331. widths.append(line_width)
  332. max_width = max(max_width, line_width)
  333. if anchor[1] == "m":
  334. top -= (len(lines) - 1) * line_spacing / 2.0
  335. elif anchor[1] == "d":
  336. top -= (len(lines) - 1) * line_spacing
  337. idx = -1
  338. for line in lines:
  339. left = xy[0]
  340. idx += 1
  341. width_difference = max_width - widths[idx]
  342. # align by align parameter
  343. if align in ("left", "justify"):
  344. pass
  345. elif align == "center":
  346. left += width_difference / 2.0
  347. elif align == "right":
  348. left += width_difference
  349. else:
  350. msg = 'align must be "left", "center", "right" or "justify"'
  351. raise ValueError(msg)
  352. if (
  353. align == "justify"
  354. and width_difference != 0
  355. and idx != len(lines) - 1
  356. ):
  357. words = (
  358. line.split(" ") if isinstance(line, str) else line.split(b" ")
  359. )
  360. if len(words) > 1:
  361. # align left by anchor
  362. if anchor[0] == "m":
  363. left -= max_width / 2.0
  364. elif anchor[0] == "r":
  365. left -= max_width
  366. word_widths = [
  367. self.font.getlength(
  368. word,
  369. fontmode,
  370. self.direction,
  371. self.features,
  372. self.language,
  373. )
  374. for word in words
  375. ]
  376. word_anchor = "l" + anchor[1]
  377. width_difference = max_width - sum(word_widths)
  378. i = 0
  379. for word in words:
  380. parts.append(_Line(left, top, word_anchor, word))
  381. left += word_widths[i] + width_difference / (len(words) - 1)
  382. i += 1
  383. top += line_spacing
  384. continue
  385. # align left by anchor
  386. if anchor[0] == "m":
  387. left -= width_difference / 2.0
  388. elif anchor[0] == "r":
  389. left -= width_difference
  390. parts.append(_Line(left, top, anchor, line))
  391. top += line_spacing
  392. return parts
  393. def _get_bbox(
  394. self, text: str | bytes, font: Font | None = None, anchor: str | None = None
  395. ) -> tuple[float, float, float, float]:
  396. return (font or self.font).getbbox(
  397. text,
  398. self._get_fontmode(),
  399. self.direction,
  400. self.features,
  401. self.language,
  402. self.stroke_width,
  403. anchor,
  404. )
  405. def get_bbox(
  406. self,
  407. xy: tuple[float, float] = (0, 0),
  408. anchor: str | None = None,
  409. align: str = "left",
  410. ) -> tuple[float, float, float, float]:
  411. """
  412. Returns bounding box (in pixels) of text.
  413. Use :py:meth:`get_length` to get the offset of following text with 1/64 pixel
  414. precision. The bounding box includes extra margins for some fonts, e.g. italics
  415. or accents.
  416. :param xy: The anchor coordinates of the text.
  417. :param anchor: The text anchor alignment. Determines the relative location of
  418. the anchor to the text. The default alignment is top left,
  419. specifically ``la`` for horizontal text and ``lt`` for
  420. vertical text. See :ref:`text-anchors` for details.
  421. :param align: For multiline text, ``"left"``, ``"center"``, ``"right"`` or
  422. ``"justify"`` determines the relative alignment of lines. Use the
  423. ``anchor`` parameter to specify the alignment to ``xy``.
  424. :return: ``(left, top, right, bottom)`` bounding box
  425. """
  426. bbox: tuple[float, float, float, float] | None = None
  427. for x, y, anchor, text in self._split(xy, anchor, align):
  428. bbox_line = self._get_bbox(text, anchor=anchor)
  429. bbox_line = (
  430. bbox_line[0] + x,
  431. bbox_line[1] + y,
  432. bbox_line[2] + x,
  433. bbox_line[3] + y,
  434. )
  435. if bbox is None:
  436. bbox = bbox_line
  437. else:
  438. bbox = (
  439. min(bbox[0], bbox_line[0]),
  440. min(bbox[1], bbox_line[1]),
  441. max(bbox[2], bbox_line[2]),
  442. max(bbox[3], bbox_line[3]),
  443. )
  444. assert bbox is not None
  445. return bbox