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