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