Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/PIL/ImageDraw.py: 31%
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
1#
2# The Python Imaging Library
3# $Id$
4#
5# drawing interface operations
6#
7# History:
8# 1996-04-13 fl Created (experimental)
9# 1996-08-07 fl Filled polygons, ellipses.
10# 1996-08-13 fl Added text support
11# 1998-06-28 fl Handle I and F images
12# 1998-12-29 fl Added arc; use arc primitive to draw ellipses
13# 1999-01-10 fl Added shape stuff (experimental)
14# 1999-02-06 fl Added bitmap support
15# 1999-02-11 fl Changed all primitives to take options
16# 1999-02-20 fl Fixed backwards compatibility
17# 2000-10-12 fl Copy on write, when necessary
18# 2001-02-18 fl Use default ink for bitmap/text also in fill mode
19# 2002-10-24 fl Added support for CSS-style color strings
20# 2002-12-10 fl Added experimental support for RGBA-on-RGB drawing
21# 2002-12-11 fl Refactored low-level drawing API (work in progress)
22# 2004-08-26 fl Made Draw() a factory function, added getdraw() support
23# 2004-09-04 fl Added width support to line primitive
24# 2004-09-10 fl Added font mode handling
25# 2006-06-19 fl Added font bearing support (getmask2)
26#
27# Copyright (c) 1997-2006 by Secret Labs AB
28# Copyright (c) 1996-2006 by Fredrik Lundh
29#
30# See the README file for information on usage and redistribution.
31#
32from __future__ import annotations
34import math
35import struct
36from collections.abc import Sequence
37from types import ModuleType
38from typing import Any, AnyStr, Callable, Union, cast
40from . import Image, ImageColor
41from ._deprecate import deprecate
42from ._typing import Coords
44# experimental access to the outline API
45Outline: Callable[[], Image.core._Outline] = Image.core.outline
47TYPE_CHECKING = False
48if TYPE_CHECKING:
49 from . import ImageDraw2, ImageFont
51_Ink = Union[float, tuple[int, ...], str]
53"""
54A simple 2D drawing interface for PIL images.
55<p>
56Application code should use the <b>Draw</b> factory, instead of
57directly.
58"""
61class ImageDraw:
62 font: (
63 ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont | None
64 ) = None
66 def __init__(self, im: Image.Image, mode: str | None = None) -> None:
67 """
68 Create a drawing instance.
70 :param im: The image to draw in.
71 :param mode: Optional mode to use for color values. For RGB
72 images, this argument can be RGB or RGBA (to blend the
73 drawing into the image). For all other modes, this argument
74 must be the same as the image mode. If omitted, the mode
75 defaults to the mode of the image.
76 """
77 im.load()
78 if im.readonly:
79 im._copy() # make it writeable
80 blend = 0
81 if mode is None:
82 mode = im.mode
83 if mode != im.mode:
84 if mode == "RGBA" and im.mode == "RGB":
85 blend = 1
86 else:
87 msg = "mode mismatch"
88 raise ValueError(msg)
89 if mode == "P":
90 self.palette = im.palette
91 else:
92 self.palette = None
93 self._image = im
94 self.im = im.im
95 self.draw = Image.core.draw(self.im, blend)
96 self.mode = mode
97 if mode in ("I", "F"):
98 self.ink = self.draw.draw_ink(1)
99 else:
100 self.ink = self.draw.draw_ink(-1)
101 if mode in ("1", "P", "I", "F"):
102 # FIXME: fix Fill2 to properly support matte for I+F images
103 self.fontmode = "1"
104 else:
105 self.fontmode = "L" # aliasing is okay for other modes
106 self.fill = False
108 def getfont(
109 self,
110 ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont:
111 """
112 Get the current default font.
114 To set the default font for this ImageDraw instance::
116 from PIL import ImageDraw, ImageFont
117 draw.font = ImageFont.truetype("Tests/fonts/FreeMono.ttf")
119 To set the default font for all future ImageDraw instances::
121 from PIL import ImageDraw, ImageFont
122 ImageDraw.ImageDraw.font = ImageFont.truetype("Tests/fonts/FreeMono.ttf")
124 If the current default font is ``None``,
125 it is initialized with ``ImageFont.load_default()``.
127 :returns: An image font."""
128 if not self.font:
129 # FIXME: should add a font repository
130 from . import ImageFont
132 self.font = ImageFont.load_default()
133 return self.font
135 def _getfont(
136 self, font_size: float | None
137 ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont:
138 if font_size is not None:
139 from . import ImageFont
141 return ImageFont.load_default(font_size)
142 else:
143 return self.getfont()
145 def _getink(
146 self, ink: _Ink | None, fill: _Ink | None = None
147 ) -> tuple[int | None, int | None]:
148 result_ink = None
149 result_fill = None
150 if ink is None and fill is None:
151 if self.fill:
152 result_fill = self.ink
153 else:
154 result_ink = self.ink
155 else:
156 if ink is not None:
157 if isinstance(ink, str):
158 ink = ImageColor.getcolor(ink, self.mode)
159 if self.palette and isinstance(ink, tuple):
160 ink = self.palette.getcolor(ink, self._image)
161 result_ink = self.draw.draw_ink(ink)
162 if fill is not None:
163 if isinstance(fill, str):
164 fill = ImageColor.getcolor(fill, self.mode)
165 if self.palette and isinstance(fill, tuple):
166 fill = self.palette.getcolor(fill, self._image)
167 result_fill = self.draw.draw_ink(fill)
168 return result_ink, result_fill
170 def arc(
171 self,
172 xy: Coords,
173 start: float,
174 end: float,
175 fill: _Ink | None = None,
176 width: int = 1,
177 ) -> None:
178 """Draw an arc."""
179 ink, fill = self._getink(fill)
180 if ink is not None:
181 self.draw.draw_arc(xy, start, end, ink, width)
183 def bitmap(
184 self, xy: Sequence[int], bitmap: Image.Image, fill: _Ink | None = None
185 ) -> None:
186 """Draw a bitmap."""
187 bitmap.load()
188 ink, fill = self._getink(fill)
189 if ink is None:
190 ink = fill
191 if ink is not None:
192 self.draw.draw_bitmap(xy, bitmap.im, ink)
194 def chord(
195 self,
196 xy: Coords,
197 start: float,
198 end: float,
199 fill: _Ink | None = None,
200 outline: _Ink | None = None,
201 width: int = 1,
202 ) -> None:
203 """Draw a chord."""
204 ink, fill_ink = self._getink(outline, fill)
205 if fill_ink is not None:
206 self.draw.draw_chord(xy, start, end, fill_ink, 1)
207 if ink is not None and ink != fill_ink and width != 0:
208 self.draw.draw_chord(xy, start, end, ink, 0, width)
210 def ellipse(
211 self,
212 xy: Coords,
213 fill: _Ink | None = None,
214 outline: _Ink | None = None,
215 width: int = 1,
216 ) -> None:
217 """Draw an ellipse."""
218 ink, fill_ink = self._getink(outline, fill)
219 if fill_ink is not None:
220 self.draw.draw_ellipse(xy, fill_ink, 1)
221 if ink is not None and ink != fill_ink and width != 0:
222 self.draw.draw_ellipse(xy, ink, 0, width)
224 def circle(
225 self,
226 xy: Sequence[float],
227 radius: float,
228 fill: _Ink | None = None,
229 outline: _Ink | None = None,
230 width: int = 1,
231 ) -> None:
232 """Draw a circle given center coordinates and a radius."""
233 ellipse_xy = (xy[0] - radius, xy[1] - radius, xy[0] + radius, xy[1] + radius)
234 self.ellipse(ellipse_xy, fill, outline, width)
236 def line(
237 self,
238 xy: Coords,
239 fill: _Ink | None = None,
240 width: int = 0,
241 joint: str | None = None,
242 ) -> None:
243 """Draw a line, or a connected sequence of line segments."""
244 ink = self._getink(fill)[0]
245 if ink is not None:
246 self.draw.draw_lines(xy, ink, width)
247 if joint == "curve" and width > 4:
248 points: Sequence[Sequence[float]]
249 if isinstance(xy[0], (list, tuple)):
250 points = cast(Sequence[Sequence[float]], xy)
251 else:
252 points = [
253 cast(Sequence[float], tuple(xy[i : i + 2]))
254 for i in range(0, len(xy), 2)
255 ]
256 for i in range(1, len(points) - 1):
257 point = points[i]
258 angles = [
259 math.degrees(math.atan2(end[0] - start[0], start[1] - end[1]))
260 % 360
261 for start, end in (
262 (points[i - 1], point),
263 (point, points[i + 1]),
264 )
265 ]
266 if angles[0] == angles[1]:
267 # This is a straight line, so no joint is required
268 continue
270 def coord_at_angle(
271 coord: Sequence[float], angle: float
272 ) -> tuple[float, ...]:
273 x, y = coord
274 angle -= 90
275 distance = width / 2 - 1
276 return tuple(
277 p + (math.floor(p_d) if p_d > 0 else math.ceil(p_d))
278 for p, p_d in (
279 (x, distance * math.cos(math.radians(angle))),
280 (y, distance * math.sin(math.radians(angle))),
281 )
282 )
284 flipped = (
285 angles[1] > angles[0] and angles[1] - 180 > angles[0]
286 ) or (angles[1] < angles[0] and angles[1] + 180 > angles[0])
287 coords = [
288 (point[0] - width / 2 + 1, point[1] - width / 2 + 1),
289 (point[0] + width / 2 - 1, point[1] + width / 2 - 1),
290 ]
291 if flipped:
292 start, end = (angles[1] + 90, angles[0] + 90)
293 else:
294 start, end = (angles[0] - 90, angles[1] - 90)
295 self.pieslice(coords, start - 90, end - 90, fill)
297 if width > 8:
298 # Cover potential gaps between the line and the joint
299 if flipped:
300 gap_coords = [
301 coord_at_angle(point, angles[0] + 90),
302 point,
303 coord_at_angle(point, angles[1] + 90),
304 ]
305 else:
306 gap_coords = [
307 coord_at_angle(point, angles[0] - 90),
308 point,
309 coord_at_angle(point, angles[1] - 90),
310 ]
311 self.line(gap_coords, fill, width=3)
313 def shape(
314 self,
315 shape: Image.core._Outline,
316 fill: _Ink | None = None,
317 outline: _Ink | None = None,
318 ) -> None:
319 """(Experimental) Draw a shape."""
320 shape.close()
321 ink, fill_ink = self._getink(outline, fill)
322 if fill_ink is not None:
323 self.draw.draw_outline(shape, fill_ink, 1)
324 if ink is not None and ink != fill_ink:
325 self.draw.draw_outline(shape, ink, 0)
327 def pieslice(
328 self,
329 xy: Coords,
330 start: float,
331 end: float,
332 fill: _Ink | None = None,
333 outline: _Ink | None = None,
334 width: int = 1,
335 ) -> None:
336 """Draw a pieslice."""
337 ink, fill_ink = self._getink(outline, fill)
338 if fill_ink is not None:
339 self.draw.draw_pieslice(xy, start, end, fill_ink, 1)
340 if ink is not None and ink != fill_ink and width != 0:
341 self.draw.draw_pieslice(xy, start, end, ink, 0, width)
343 def point(self, xy: Coords, fill: _Ink | None = None) -> None:
344 """Draw one or more individual pixels."""
345 ink, fill = self._getink(fill)
346 if ink is not None:
347 self.draw.draw_points(xy, ink)
349 def polygon(
350 self,
351 xy: Coords,
352 fill: _Ink | None = None,
353 outline: _Ink | None = None,
354 width: int = 1,
355 ) -> None:
356 """Draw a polygon."""
357 ink, fill_ink = self._getink(outline, fill)
358 if fill_ink is not None:
359 self.draw.draw_polygon(xy, fill_ink, 1)
360 if ink is not None and ink != fill_ink and width != 0:
361 if width == 1:
362 self.draw.draw_polygon(xy, ink, 0, width)
363 elif self.im is not None:
364 # To avoid expanding the polygon outwards,
365 # use the fill as a mask
366 mask = Image.new("1", self.im.size)
367 mask_ink = self._getink(1)[0]
368 draw = Draw(mask)
369 draw.draw.draw_polygon(xy, mask_ink, 1)
371 self.draw.draw_polygon(xy, ink, 0, width * 2 - 1, mask.im)
373 def regular_polygon(
374 self,
375 bounding_circle: Sequence[Sequence[float] | float],
376 n_sides: int,
377 rotation: float = 0,
378 fill: _Ink | None = None,
379 outline: _Ink | None = None,
380 width: int = 1,
381 ) -> None:
382 """Draw a regular polygon."""
383 xy = _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation)
384 self.polygon(xy, fill, outline, width)
386 def rectangle(
387 self,
388 xy: Coords,
389 fill: _Ink | None = None,
390 outline: _Ink | None = None,
391 width: int = 1,
392 ) -> None:
393 """Draw a rectangle."""
394 ink, fill_ink = self._getink(outline, fill)
395 if fill_ink is not None:
396 self.draw.draw_rectangle(xy, fill_ink, 1)
397 if ink is not None and ink != fill_ink and width != 0:
398 self.draw.draw_rectangle(xy, ink, 0, width)
400 def rounded_rectangle(
401 self,
402 xy: Coords,
403 radius: float = 0,
404 fill: _Ink | None = None,
405 outline: _Ink | None = None,
406 width: int = 1,
407 *,
408 corners: tuple[bool, bool, bool, bool] | None = None,
409 ) -> None:
410 """Draw a rounded rectangle."""
411 if isinstance(xy[0], (list, tuple)):
412 (x0, y0), (x1, y1) = cast(Sequence[Sequence[float]], xy)
413 else:
414 x0, y0, x1, y1 = cast(Sequence[float], xy)
415 if x1 < x0:
416 msg = "x1 must be greater than or equal to x0"
417 raise ValueError(msg)
418 if y1 < y0:
419 msg = "y1 must be greater than or equal to y0"
420 raise ValueError(msg)
421 if corners is None:
422 corners = (True, True, True, True)
424 d = radius * 2
426 x0 = round(x0)
427 y0 = round(y0)
428 x1 = round(x1)
429 y1 = round(y1)
430 full_x, full_y = False, False
431 if all(corners):
432 full_x = d >= x1 - x0 - 1
433 if full_x:
434 # The two left and two right corners are joined
435 d = x1 - x0
436 full_y = d >= y1 - y0 - 1
437 if full_y:
438 # The two top and two bottom corners are joined
439 d = y1 - y0
440 if full_x and full_y:
441 # If all corners are joined, that is a circle
442 return self.ellipse(xy, fill, outline, width)
444 if d == 0 or not any(corners):
445 # If the corners have no curve,
446 # or there are no corners,
447 # that is a rectangle
448 return self.rectangle(xy, fill, outline, width)
450 r = int(d // 2)
451 ink, fill_ink = self._getink(outline, fill)
453 def draw_corners(pieslice: bool) -> None:
454 parts: tuple[tuple[tuple[float, float, float, float], int, int], ...]
455 if full_x:
456 # Draw top and bottom halves
457 parts = (
458 ((x0, y0, x0 + d, y0 + d), 180, 360),
459 ((x0, y1 - d, x0 + d, y1), 0, 180),
460 )
461 elif full_y:
462 # Draw left and right halves
463 parts = (
464 ((x0, y0, x0 + d, y0 + d), 90, 270),
465 ((x1 - d, y0, x1, y0 + d), 270, 90),
466 )
467 else:
468 # Draw four separate corners
469 parts = tuple(
470 part
471 for i, part in enumerate(
472 (
473 ((x0, y0, x0 + d, y0 + d), 180, 270),
474 ((x1 - d, y0, x1, y0 + d), 270, 360),
475 ((x1 - d, y1 - d, x1, y1), 0, 90),
476 ((x0, y1 - d, x0 + d, y1), 90, 180),
477 )
478 )
479 if corners[i]
480 )
481 for part in parts:
482 if pieslice:
483 self.draw.draw_pieslice(*(part + (fill_ink, 1)))
484 else:
485 self.draw.draw_arc(*(part + (ink, width)))
487 if fill_ink is not None:
488 draw_corners(True)
490 if full_x:
491 self.draw.draw_rectangle((x0, y0 + r + 1, x1, y1 - r - 1), fill_ink, 1)
492 elif x1 - r - 1 > x0 + r + 1:
493 self.draw.draw_rectangle((x0 + r + 1, y0, x1 - r - 1, y1), fill_ink, 1)
494 if not full_x and not full_y:
495 left = [x0, y0, x0 + r, y1]
496 if corners[0]:
497 left[1] += r + 1
498 if corners[3]:
499 left[3] -= r + 1
500 self.draw.draw_rectangle(left, fill_ink, 1)
502 right = [x1 - r, y0, x1, y1]
503 if corners[1]:
504 right[1] += r + 1
505 if corners[2]:
506 right[3] -= r + 1
507 self.draw.draw_rectangle(right, fill_ink, 1)
508 if ink is not None and ink != fill_ink and width != 0:
509 draw_corners(False)
511 if not full_x:
512 top = [x0, y0, x1, y0 + width - 1]
513 if corners[0]:
514 top[0] += r + 1
515 if corners[1]:
516 top[2] -= r + 1
517 self.draw.draw_rectangle(top, ink, 1)
519 bottom = [x0, y1 - width + 1, x1, y1]
520 if corners[3]:
521 bottom[0] += r + 1
522 if corners[2]:
523 bottom[2] -= r + 1
524 self.draw.draw_rectangle(bottom, ink, 1)
525 if not full_y:
526 left = [x0, y0, x0 + width - 1, y1]
527 if corners[0]:
528 left[1] += r + 1
529 if corners[3]:
530 left[3] -= r + 1
531 self.draw.draw_rectangle(left, ink, 1)
533 right = [x1 - width + 1, y0, x1, y1]
534 if corners[1]:
535 right[1] += r + 1
536 if corners[2]:
537 right[3] -= r + 1
538 self.draw.draw_rectangle(right, ink, 1)
540 def _multiline_check(self, text: AnyStr) -> bool:
541 split_character = "\n" if isinstance(text, str) else b"\n"
543 return split_character in text
545 def text(
546 self,
547 xy: tuple[float, float],
548 text: AnyStr,
549 fill: _Ink | None = None,
550 font: (
551 ImageFont.ImageFont
552 | ImageFont.FreeTypeFont
553 | ImageFont.TransposedFont
554 | None
555 ) = None,
556 anchor: str | None = None,
557 spacing: float = 4,
558 align: str = "left",
559 direction: str | None = None,
560 features: list[str] | None = None,
561 language: str | None = None,
562 stroke_width: float = 0,
563 stroke_fill: _Ink | None = None,
564 embedded_color: bool = False,
565 *args: Any,
566 **kwargs: Any,
567 ) -> None:
568 """Draw text."""
569 if embedded_color and self.mode not in ("RGB", "RGBA"):
570 msg = "Embedded color supported only in RGB and RGBA modes"
571 raise ValueError(msg)
573 if font is None:
574 font = self._getfont(kwargs.get("font_size"))
576 if self._multiline_check(text):
577 return self.multiline_text(
578 xy,
579 text,
580 fill,
581 font,
582 anchor,
583 spacing,
584 align,
585 direction,
586 features,
587 language,
588 stroke_width,
589 stroke_fill,
590 embedded_color,
591 )
593 def getink(fill: _Ink | None) -> int:
594 ink, fill_ink = self._getink(fill)
595 if ink is None:
596 assert fill_ink is not None
597 return fill_ink
598 return ink
600 def draw_text(ink: int, stroke_width: float = 0) -> None:
601 mode = self.fontmode
602 if stroke_width == 0 and embedded_color:
603 mode = "RGBA"
604 coord = []
605 for i in range(2):
606 coord.append(int(xy[i]))
607 start = (math.modf(xy[0])[0], math.modf(xy[1])[0])
608 try:
609 mask, offset = font.getmask2( # type: ignore[union-attr,misc]
610 text,
611 mode,
612 direction=direction,
613 features=features,
614 language=language,
615 stroke_width=stroke_width,
616 stroke_filled=True,
617 anchor=anchor,
618 ink=ink,
619 start=start,
620 *args,
621 **kwargs,
622 )
623 coord = [coord[0] + offset[0], coord[1] + offset[1]]
624 except AttributeError:
625 try:
626 mask = font.getmask( # type: ignore[misc]
627 text,
628 mode,
629 direction,
630 features,
631 language,
632 stroke_width,
633 anchor,
634 ink,
635 start=start,
636 *args,
637 **kwargs,
638 )
639 except TypeError:
640 mask = font.getmask(text)
641 if mode == "RGBA":
642 # font.getmask2(mode="RGBA") returns color in RGB bands and mask in A
643 # extract mask and set text alpha
644 color, mask = mask, mask.getband(3)
645 ink_alpha = struct.pack("i", ink)[3]
646 color.fillband(3, ink_alpha)
647 x, y = coord
648 if self.im is not None:
649 self.im.paste(
650 color, (x, y, x + mask.size[0], y + mask.size[1]), mask
651 )
652 else:
653 self.draw.draw_bitmap(coord, mask, ink)
655 ink = getink(fill)
656 if ink is not None:
657 stroke_ink = None
658 if stroke_width:
659 stroke_ink = getink(stroke_fill) if stroke_fill is not None else ink
661 if stroke_ink is not None:
662 # Draw stroked text
663 draw_text(stroke_ink, stroke_width)
665 # Draw normal text
666 if ink != stroke_ink:
667 draw_text(ink)
668 else:
669 # Only draw normal text
670 draw_text(ink)
672 def _prepare_multiline_text(
673 self,
674 xy: tuple[float, float],
675 text: AnyStr,
676 font: (
677 ImageFont.ImageFont
678 | ImageFont.FreeTypeFont
679 | ImageFont.TransposedFont
680 | None
681 ),
682 anchor: str | None,
683 spacing: float,
684 align: str,
685 direction: str | None,
686 features: list[str] | None,
687 language: str | None,
688 stroke_width: float,
689 embedded_color: bool,
690 font_size: float | None,
691 ) -> tuple[
692 ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont,
693 str,
694 list[tuple[tuple[float, float], AnyStr]],
695 ]:
696 if direction == "ttb":
697 msg = "ttb direction is unsupported for multiline text"
698 raise ValueError(msg)
700 if anchor is None:
701 anchor = "la"
702 elif len(anchor) != 2:
703 msg = "anchor must be a 2 character string"
704 raise ValueError(msg)
705 elif anchor[1] in "tb":
706 msg = "anchor not supported for multiline text"
707 raise ValueError(msg)
709 if font is None:
710 font = self._getfont(font_size)
712 widths = []
713 max_width: float = 0
714 lines = text.split("\n" if isinstance(text, str) else b"\n")
715 line_spacing = (
716 self.textbbox((0, 0), "A", font, stroke_width=stroke_width)[3]
717 + stroke_width
718 + spacing
719 )
721 for line in lines:
722 line_width = self.textlength(
723 line,
724 font,
725 direction=direction,
726 features=features,
727 language=language,
728 embedded_color=embedded_color,
729 )
730 widths.append(line_width)
731 max_width = max(max_width, line_width)
733 top = xy[1]
734 if anchor[1] == "m":
735 top -= (len(lines) - 1) * line_spacing / 2.0
736 elif anchor[1] == "d":
737 top -= (len(lines) - 1) * line_spacing
739 parts = []
740 for idx, line in enumerate(lines):
741 left = xy[0]
742 width_difference = max_width - widths[idx]
744 # first align left by anchor
745 if anchor[0] == "m":
746 left -= width_difference / 2.0
747 elif anchor[0] == "r":
748 left -= width_difference
750 # then align by align parameter
751 if align in ("left", "justify"):
752 pass
753 elif align == "center":
754 left += width_difference / 2.0
755 elif align == "right":
756 left += width_difference
757 else:
758 msg = 'align must be "left", "center", "right" or "justify"'
759 raise ValueError(msg)
761 if align == "justify" and width_difference != 0:
762 words = line.split(" " if isinstance(text, str) else b" ")
763 word_widths = [
764 self.textlength(
765 word,
766 font,
767 direction=direction,
768 features=features,
769 language=language,
770 embedded_color=embedded_color,
771 )
772 for word in words
773 ]
774 width_difference = max_width - sum(word_widths)
775 for i, word in enumerate(words):
776 parts.append(((left, top), word))
777 left += word_widths[i] + width_difference / (len(words) - 1)
778 else:
779 parts.append(((left, top), line))
781 top += line_spacing
783 return font, anchor, parts
785 def multiline_text(
786 self,
787 xy: tuple[float, float],
788 text: AnyStr,
789 fill: _Ink | None = None,
790 font: (
791 ImageFont.ImageFont
792 | ImageFont.FreeTypeFont
793 | ImageFont.TransposedFont
794 | None
795 ) = None,
796 anchor: str | None = None,
797 spacing: float = 4,
798 align: str = "left",
799 direction: str | None = None,
800 features: list[str] | None = None,
801 language: str | None = None,
802 stroke_width: float = 0,
803 stroke_fill: _Ink | None = None,
804 embedded_color: bool = False,
805 *,
806 font_size: float | None = None,
807 ) -> None:
808 font, anchor, lines = self._prepare_multiline_text(
809 xy,
810 text,
811 font,
812 anchor,
813 spacing,
814 align,
815 direction,
816 features,
817 language,
818 stroke_width,
819 embedded_color,
820 font_size,
821 )
823 for xy, line in lines:
824 self.text(
825 xy,
826 line,
827 fill,
828 font,
829 anchor,
830 direction=direction,
831 features=features,
832 language=language,
833 stroke_width=stroke_width,
834 stroke_fill=stroke_fill,
835 embedded_color=embedded_color,
836 )
838 def textlength(
839 self,
840 text: AnyStr,
841 font: (
842 ImageFont.ImageFont
843 | ImageFont.FreeTypeFont
844 | ImageFont.TransposedFont
845 | None
846 ) = None,
847 direction: str | None = None,
848 features: list[str] | None = None,
849 language: str | None = None,
850 embedded_color: bool = False,
851 *,
852 font_size: float | None = None,
853 ) -> float:
854 """Get the length of a given string, in pixels with 1/64 precision."""
855 if self._multiline_check(text):
856 msg = "can't measure length of multiline text"
857 raise ValueError(msg)
858 if embedded_color and self.mode not in ("RGB", "RGBA"):
859 msg = "Embedded color supported only in RGB and RGBA modes"
860 raise ValueError(msg)
862 if font is None:
863 font = self._getfont(font_size)
864 mode = "RGBA" if embedded_color else self.fontmode
865 return font.getlength(text, mode, direction, features, language)
867 def textbbox(
868 self,
869 xy: tuple[float, float],
870 text: AnyStr,
871 font: (
872 ImageFont.ImageFont
873 | ImageFont.FreeTypeFont
874 | ImageFont.TransposedFont
875 | None
876 ) = None,
877 anchor: str | None = None,
878 spacing: float = 4,
879 align: str = "left",
880 direction: str | None = None,
881 features: list[str] | None = None,
882 language: str | None = None,
883 stroke_width: float = 0,
884 embedded_color: bool = False,
885 *,
886 font_size: float | None = None,
887 ) -> tuple[float, float, float, float]:
888 """Get the bounding box of a given string, in pixels."""
889 if embedded_color and self.mode not in ("RGB", "RGBA"):
890 msg = "Embedded color supported only in RGB and RGBA modes"
891 raise ValueError(msg)
893 if font is None:
894 font = self._getfont(font_size)
896 if self._multiline_check(text):
897 return self.multiline_textbbox(
898 xy,
899 text,
900 font,
901 anchor,
902 spacing,
903 align,
904 direction,
905 features,
906 language,
907 stroke_width,
908 embedded_color,
909 )
911 mode = "RGBA" if embedded_color else self.fontmode
912 bbox = font.getbbox(
913 text, mode, direction, features, language, stroke_width, anchor
914 )
915 return bbox[0] + xy[0], bbox[1] + xy[1], bbox[2] + xy[0], bbox[3] + xy[1]
917 def multiline_textbbox(
918 self,
919 xy: tuple[float, float],
920 text: AnyStr,
921 font: (
922 ImageFont.ImageFont
923 | ImageFont.FreeTypeFont
924 | ImageFont.TransposedFont
925 | None
926 ) = None,
927 anchor: str | None = None,
928 spacing: float = 4,
929 align: str = "left",
930 direction: str | None = None,
931 features: list[str] | None = None,
932 language: str | None = None,
933 stroke_width: float = 0,
934 embedded_color: bool = False,
935 *,
936 font_size: float | None = None,
937 ) -> tuple[float, float, float, float]:
938 font, anchor, lines = self._prepare_multiline_text(
939 xy,
940 text,
941 font,
942 anchor,
943 spacing,
944 align,
945 direction,
946 features,
947 language,
948 stroke_width,
949 embedded_color,
950 font_size,
951 )
953 bbox: tuple[float, float, float, float] | None = None
955 for xy, line in lines:
956 bbox_line = self.textbbox(
957 xy,
958 line,
959 font,
960 anchor,
961 direction=direction,
962 features=features,
963 language=language,
964 stroke_width=stroke_width,
965 embedded_color=embedded_color,
966 )
967 if bbox is None:
968 bbox = bbox_line
969 else:
970 bbox = (
971 min(bbox[0], bbox_line[0]),
972 min(bbox[1], bbox_line[1]),
973 max(bbox[2], bbox_line[2]),
974 max(bbox[3], bbox_line[3]),
975 )
977 if bbox is None:
978 return xy[0], xy[1], xy[0], xy[1]
979 return bbox
982def Draw(im: Image.Image, mode: str | None = None) -> ImageDraw:
983 """
984 A simple 2D drawing interface for PIL images.
986 :param im: The image to draw in.
987 :param mode: Optional mode to use for color values. For RGB
988 images, this argument can be RGB or RGBA (to blend the
989 drawing into the image). For all other modes, this argument
990 must be the same as the image mode. If omitted, the mode
991 defaults to the mode of the image.
992 """
993 try:
994 return getattr(im, "getdraw")(mode)
995 except AttributeError:
996 return ImageDraw(im, mode)
999def getdraw(
1000 im: Image.Image | None = None, hints: list[str] | None = None
1001) -> tuple[ImageDraw2.Draw | None, ModuleType]:
1002 """
1003 :param im: The image to draw in.
1004 :param hints: An optional list of hints. Deprecated.
1005 :returns: A (drawing context, drawing resource factory) tuple.
1006 """
1007 if hints is not None:
1008 deprecate("'hints' parameter", 12)
1009 from . import ImageDraw2
1011 draw = ImageDraw2.Draw(im) if im is not None else None
1012 return draw, ImageDraw2
1015def floodfill(
1016 image: Image.Image,
1017 xy: tuple[int, int],
1018 value: float | tuple[int, ...],
1019 border: float | tuple[int, ...] | None = None,
1020 thresh: float = 0,
1021) -> None:
1022 """
1023 .. warning:: This method is experimental.
1025 Fills a bounded region with a given color.
1027 :param image: Target image.
1028 :param xy: Seed position (a 2-item coordinate tuple). See
1029 :ref:`coordinate-system`.
1030 :param value: Fill color.
1031 :param border: Optional border value. If given, the region consists of
1032 pixels with a color different from the border color. If not given,
1033 the region consists of pixels having the same color as the seed
1034 pixel.
1035 :param thresh: Optional threshold value which specifies a maximum
1036 tolerable difference of a pixel value from the 'background' in
1037 order for it to be replaced. Useful for filling regions of
1038 non-homogeneous, but similar, colors.
1039 """
1040 # based on an implementation by Eric S. Raymond
1041 # amended by yo1995 @20180806
1042 pixel = image.load()
1043 assert pixel is not None
1044 x, y = xy
1045 try:
1046 background = pixel[x, y]
1047 if _color_diff(value, background) <= thresh:
1048 return # seed point already has fill color
1049 pixel[x, y] = value
1050 except (ValueError, IndexError):
1051 return # seed point outside image
1052 edge = {(x, y)}
1053 # use a set to keep record of current and previous edge pixels
1054 # to reduce memory consumption
1055 full_edge = set()
1056 while edge:
1057 new_edge = set()
1058 for x, y in edge: # 4 adjacent method
1059 for s, t in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
1060 # If already processed, or if a coordinate is negative, skip
1061 if (s, t) in full_edge or s < 0 or t < 0:
1062 continue
1063 try:
1064 p = pixel[s, t]
1065 except (ValueError, IndexError):
1066 pass
1067 else:
1068 full_edge.add((s, t))
1069 if border is None:
1070 fill = _color_diff(p, background) <= thresh
1071 else:
1072 fill = p not in (value, border)
1073 if fill:
1074 pixel[s, t] = value
1075 new_edge.add((s, t))
1076 full_edge = edge # discard pixels processed
1077 edge = new_edge
1080def _compute_regular_polygon_vertices(
1081 bounding_circle: Sequence[Sequence[float] | float], n_sides: int, rotation: float
1082) -> list[tuple[float, float]]:
1083 """
1084 Generate a list of vertices for a 2D regular polygon.
1086 :param bounding_circle: The bounding circle is a sequence defined
1087 by a point and radius. The polygon is inscribed in this circle.
1088 (e.g. ``bounding_circle=(x, y, r)`` or ``((x, y), r)``)
1089 :param n_sides: Number of sides
1090 (e.g. ``n_sides=3`` for a triangle, ``6`` for a hexagon)
1091 :param rotation: Apply an arbitrary rotation to the polygon
1092 (e.g. ``rotation=90``, applies a 90 degree rotation)
1093 :return: List of regular polygon vertices
1094 (e.g. ``[(25, 50), (50, 50), (50, 25), (25, 25)]``)
1096 How are the vertices computed?
1097 1. Compute the following variables
1098 - theta: Angle between the apothem & the nearest polygon vertex
1099 - side_length: Length of each polygon edge
1100 - centroid: Center of bounding circle (1st, 2nd elements of bounding_circle)
1101 - polygon_radius: Polygon radius (last element of bounding_circle)
1102 - angles: Location of each polygon vertex in polar grid
1103 (e.g. A square with 0 degree rotation => [225.0, 315.0, 45.0, 135.0])
1105 2. For each angle in angles, get the polygon vertex at that angle
1106 The vertex is computed using the equation below.
1107 X= xcos(φ) + ysin(φ)
1108 Y= −xsin(φ) + ycos(φ)
1110 Note:
1111 φ = angle in degrees
1112 x = 0
1113 y = polygon_radius
1115 The formula above assumes rotation around the origin.
1116 In our case, we are rotating around the centroid.
1117 To account for this, we use the formula below
1118 X = xcos(φ) + ysin(φ) + centroid_x
1119 Y = −xsin(φ) + ycos(φ) + centroid_y
1120 """
1121 # 1. Error Handling
1122 # 1.1 Check `n_sides` has an appropriate value
1123 if not isinstance(n_sides, int):
1124 msg = "n_sides should be an int" # type: ignore[unreachable]
1125 raise TypeError(msg)
1126 if n_sides < 3:
1127 msg = "n_sides should be an int > 2"
1128 raise ValueError(msg)
1130 # 1.2 Check `bounding_circle` has an appropriate value
1131 if not isinstance(bounding_circle, (list, tuple)):
1132 msg = "bounding_circle should be a sequence"
1133 raise TypeError(msg)
1135 if len(bounding_circle) == 3:
1136 if not all(isinstance(i, (int, float)) for i in bounding_circle):
1137 msg = "bounding_circle should only contain numeric data"
1138 raise ValueError(msg)
1140 *centroid, polygon_radius = cast(list[float], list(bounding_circle))
1141 elif len(bounding_circle) == 2 and isinstance(bounding_circle[0], (list, tuple)):
1142 if not all(
1143 isinstance(i, (int, float)) for i in bounding_circle[0]
1144 ) or not isinstance(bounding_circle[1], (int, float)):
1145 msg = "bounding_circle should only contain numeric data"
1146 raise ValueError(msg)
1148 if len(bounding_circle[0]) != 2:
1149 msg = "bounding_circle centre should contain 2D coordinates (e.g. (x, y))"
1150 raise ValueError(msg)
1152 centroid = cast(list[float], list(bounding_circle[0]))
1153 polygon_radius = cast(float, bounding_circle[1])
1154 else:
1155 msg = (
1156 "bounding_circle should contain 2D coordinates "
1157 "and a radius (e.g. (x, y, r) or ((x, y), r) )"
1158 )
1159 raise ValueError(msg)
1161 if polygon_radius <= 0:
1162 msg = "bounding_circle radius should be > 0"
1163 raise ValueError(msg)
1165 # 1.3 Check `rotation` has an appropriate value
1166 if not isinstance(rotation, (int, float)):
1167 msg = "rotation should be an int or float" # type: ignore[unreachable]
1168 raise ValueError(msg)
1170 # 2. Define Helper Functions
1171 def _apply_rotation(point: list[float], degrees: float) -> tuple[float, float]:
1172 return (
1173 round(
1174 point[0] * math.cos(math.radians(360 - degrees))
1175 - point[1] * math.sin(math.radians(360 - degrees))
1176 + centroid[0],
1177 2,
1178 ),
1179 round(
1180 point[1] * math.cos(math.radians(360 - degrees))
1181 + point[0] * math.sin(math.radians(360 - degrees))
1182 + centroid[1],
1183 2,
1184 ),
1185 )
1187 def _compute_polygon_vertex(angle: float) -> tuple[float, float]:
1188 start_point = [polygon_radius, 0]
1189 return _apply_rotation(start_point, angle)
1191 def _get_angles(n_sides: int, rotation: float) -> list[float]:
1192 angles = []
1193 degrees = 360 / n_sides
1194 # Start with the bottom left polygon vertex
1195 current_angle = (270 - 0.5 * degrees) + rotation
1196 for _ in range(n_sides):
1197 angles.append(current_angle)
1198 current_angle += degrees
1199 if current_angle > 360:
1200 current_angle -= 360
1201 return angles
1203 # 3. Variable Declarations
1204 angles = _get_angles(n_sides, rotation)
1206 # 4. Compute Vertices
1207 return [_compute_polygon_vertex(angle) for angle in angles]
1210def _color_diff(
1211 color1: float | tuple[int, ...], color2: float | tuple[int, ...]
1212) -> float:
1213 """
1214 Uses 1-norm distance to calculate difference between two values.
1215 """
1216 first = color1 if isinstance(color1, tuple) else (color1,)
1217 second = color2 if isinstance(color2, tuple) else (color2,)
1219 return sum(abs(first[i] - second[i]) for i in range(len(second)))