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