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