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