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

502 statements  

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 

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 

49 

50# experimental access to the outline API 

51Outline: Callable[[], Image.core._Outline] = Image.core.outline 

52 

53_Ink = float | tuple[int, ...] | str 

54 

55""" 

56A simple 2D drawing interface for PIL images. 

57<p> 

58Application code should use the <b>Draw</b> factory, instead of 

59directly. 

60""" 

61 

62 

63class ImageDraw: 

64 font: ( 

65 ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont | None 

66 ) = None 

67 

68 def __init__(self, im: Image.Image, mode: str | None = None) -> None: 

69 """ 

70 Create a drawing instance. 

71 

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 

109 

110 def getfont( 

111 self, 

112 ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont: 

113 """ 

114 Get the current default font. 

115 

116 To set the default font for this ImageDraw instance:: 

117 

118 from PIL import ImageDraw, ImageFont 

119 draw.font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") 

120 

121 To set the default font for all future ImageDraw instances:: 

122 

123 from PIL import ImageDraw, ImageFont 

124 ImageDraw.ImageDraw.font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") 

125 

126 If the current default font is ``None``, 

127 it is initialized with ``ImageFont.load_default()``. 

128 

129 :returns: An image font.""" 

130 if not self.font: 

131 # FIXME: should add a font repository 

132 from . import ImageFont 

133 

134 self.font = ImageFont.load_default() 

135 return self.font 

136 

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 

142 

143 return ImageFont.load_default(font_size) 

144 else: 

145 return self.getfont() 

146 

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 

171 

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) 

184 

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) 

195 

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) 

211 

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) 

225 

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) 

237 

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 

271 

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 ) 

285 

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) 

298 

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) 

314 

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) 

328 

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) 

344 

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) 

350 

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) 

372 

373 self.draw.draw_polygon(xy, ink, 0, width * 2 - 1, mask.im) 

374 

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) 

387 

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) 

401 

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) 

425 

426 d = radius * 2 

427 

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) 

445 

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) 

451 

452 r = int(d // 2) 

453 ink, fill_ink = self._getink(outline, fill) 

454 

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))) 

488 

489 if fill_ink is not None: 

490 draw_corners(True) 

491 

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) 

503 

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) 

512 

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) 

520 

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) 

534 

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) 

541 

542 def _multiline_check(self, text: AnyStr) -> bool: 

543 split_character = "\n" if isinstance(text, str) else b"\n" 

544 

545 return split_character in text 

546 

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) 

574 

575 if font is None: 

576 font = self._getfont(kwargs.get("font_size")) 

577 

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 ) 

594 

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 

601 

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) 

656 

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 

662 

663 if stroke_ink is not None: 

664 # Draw stroked text 

665 draw_text(stroke_ink, stroke_width) 

666 

667 # Draw normal text 

668 if ink != stroke_ink: 

669 draw_text(ink) 

670 else: 

671 # Only draw normal text 

672 draw_text(ink) 

673 

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) 

705 

706 if font is None: 

707 font = self._getfont(font_size) 

708 

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 ) 

715 

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) 

737 

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 

742 

743 for idx, line in enumerate(lines): 

744 left = xy[0] 

745 width_difference = max_width - widths[idx] 

746 

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) 

757 

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 

770 

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 

789 

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 

797 

798 return font, parts 

799 

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 ) 

837 

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 ) 

852 

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) 

876 

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) 

881 

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) 

907 

908 if font is None: 

909 font = self._getfont(font_size) 

910 

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 ) 

925 

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] 

931 

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 ) 

967 

968 bbox: tuple[float, float, float, float] | None = None 

969 

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 ) 

991 

992 if bbox is None: 

993 return xy[0], xy[1], xy[0], xy[1] 

994 return bbox 

995 

996 

997def Draw(im: Image.Image, mode: str | None = None) -> ImageDraw: 

998 """ 

999 A simple 2D drawing interface for PIL images. 

1000 

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) 

1012 

1013 

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 

1020 

1021 draw = ImageDraw2.Draw(im) if im is not None else None 

1022 return draw, ImageDraw2 

1023 

1024 

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. 

1034 

1035 Fills a bounded region with a given color. 

1036 

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 

1088 

1089 

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. 

1095 

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)]``) 

1105 

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]) 

1114 

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(φ) 

1119 

1120 Note: 

1121 φ = angle in degrees 

1122 x = 0 

1123 y = polygon_radius 

1124 

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) 

1139 

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) 

1144 

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) 

1149 

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) 

1157 

1158 if len(bounding_circle[0]) != 2: 

1159 msg = "bounding_circle centre should contain 2D coordinates (e.g. (x, y))" 

1160 raise ValueError(msg) 

1161 

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) 

1170 

1171 if polygon_radius <= 0: 

1172 msg = "bounding_circle radius should be > 0" 

1173 raise ValueError(msg) 

1174 

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) 

1179 

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 ) 

1196 

1197 def _compute_polygon_vertex(angle: float) -> tuple[float, float]: 

1198 start_point = [polygon_radius, 0] 

1199 return _apply_rotation(start_point, angle) 

1200 

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 

1212 

1213 # 3. Variable Declarations 

1214 angles = _get_angles(n_sides, rotation) 

1215 

1216 # 4. Compute Vertices 

1217 return [_compute_polygon_vertex(angle) for angle in angles] 

1218 

1219 

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,) 

1228 

1229 return sum(abs(first[i] - second[i]) for i in range(len(second)))