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