diff --git a/Tests/images/imagedraw_dash_line.png b/Tests/images/imagedraw_dash_line.png new file mode 100644 index 00000000000..c0b0ee8c98f Binary files /dev/null and b/Tests/images/imagedraw_dash_line.png differ diff --git a/Tests/images/imagedraw_dash_polygon.png b/Tests/images/imagedraw_dash_polygon.png new file mode 100644 index 00000000000..8e335863e27 Binary files /dev/null and b/Tests/images/imagedraw_dash_polygon.png differ diff --git a/Tests/images/imagedraw_dash_rectangle.png b/Tests/images/imagedraw_dash_rectangle.png new file mode 100644 index 00000000000..e4cc1c5485c Binary files /dev/null and b/Tests/images/imagedraw_dash_rectangle.png differ diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 3bcb7b90178..b69723e8a12 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1757,3 +1757,67 @@ def test_incorrectly_ordered_coordinates(xy: tuple[int, int, int, int]) -> None: draw.rectangle(xy) with pytest.raises(ValueError): draw.rounded_rectangle(xy) + + +def test_dash_line() -> None: + # Arrange + im = Image.new("RGB", (W, H)) + draw = ImageDraw.Draw(im) + + # Act + draw.line([(10, 90), (90, 90)], "green", 2, dash=(10, 5)) + draw.line([(10, 10), (50, 50), (90, 10)], "green", 2, dash=(8, 4)) + + # Assert + assert_image_equal_tofile(im, "Tests/images/imagedraw_dash_line.png") + + +def test_dash_polygon() -> None: + # Arrange + im = Image.new("RGB", (W, H)) + draw = ImageDraw.Draw(im) + + # Act + draw.polygon( + [(10, 10), (90, 10), (10, 90)], + outline="green", + width=1, + dash=(10, 5), + ) + draw.polygon( + [(20, 20), (60, 20), (20, 60)], + fill="red", + outline="green", + width=1, + dash=(10, 5), + ) + + # Assert + assert_image_equal_tofile(im, "Tests/images/imagedraw_dash_polygon.png") + + +def test_dash_rectangle() -> None: + # Arrange + im = Image.new("RGB", (W, H)) + draw = ImageDraw.Draw(im) + + # Act + draw.rectangle([10, 10, 90, 90], outline="green", width=1, dash=(10, 5)) + draw.rectangle([30, 30, 70, 70], fill="red", outline="green", width=1, dash=(10, 5)) + + # Assert + assert_image_equal_tofile(im, "Tests/images/imagedraw_dash_rectangle.png") + + +def test_dash_empty() -> None: + im = Image.new("RGB", (W, H)) + draw = ImageDraw.Draw(im) + + with pytest.raises(ValueError, match="dash must be a non-empty tuple of ints"): + draw.line([(10, 50), (90, 50)], dash=()) + + with pytest.raises(ValueError, match="dash must be a non-empty tuple of ints"): + draw.polygon([(10, 10), (90, 10), (90, 90)], dash=()) + + with pytest.raises(ValueError, match="dash must be a non-empty tuple of ints"): + draw.rectangle([10, 10, 90, 90], dash=()) diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index 4c956759334..0adc0f913b3 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -287,7 +287,7 @@ Methods .. versionadded:: 5.3.0 -.. py:method:: ImageDraw.line(xy, fill=None, width=0, joint=None) +.. py:method:: ImageDraw.line(xy, fill=None, width=0, joint=None, dash=None) Draws a line between the coordinates in the ``xy`` list. The coordinate pixels are included in the drawn line. @@ -303,6 +303,14 @@ Methods :param joint: Joint type between a sequence of lines. It can be ``"curve"``, for rounded edges, or :data:`None`. .. versionadded:: 5.3.0 + :param dash: An optional dash pattern, given as a tuple of integers. + The dash pattern specifies the lengths of alternating drawn and blank segments + (e.g. ``(10, 5)`` draws 10 pixels, skips 5, and repeats). If an odd number of + values is given, it continues to alternate (e.g. ``(1, 2, 3)`` draws 1 pixel, + skips 2, draws 3, skips 1, draws 2, and so on). When ``dash`` is set, ``joint`` + is ignored. + + .. versionadded:: 12.3.0 .. py:method:: ImageDraw.pieslice(xy, start, end, fill=None, outline=None, width=1) @@ -329,7 +337,7 @@ Methods numeric values like ``[x, y, x, y, ...]``. :param fill: Color to use for the point. -.. py:method:: ImageDraw.polygon(xy, fill=None, outline=None, width=1) +.. py:method:: ImageDraw.polygon(xy, fill=None, outline=None, width=1, dash=None) Draws a polygon. @@ -342,6 +350,13 @@ Methods :param fill: Color to use for the fill. :param outline: Color to use for the outline. :param width: The line width, in pixels. + :param dash: An optional dash pattern, given as a tuple of integers. + The dash pattern specifies the lengths of alternating drawn and blank segments + (e.g. ``(10, 5)`` draws 10 pixels, skips 5, and repeats). If an odd number of + values is given, it continues to alternate (e.g. ``(1, 2, 3)`` draws 1 pixel, + skips 2, draws 3, skips 1, draws 2, and so on). + + .. versionadded:: 12.3.0 .. py:method:: ImageDraw.regular_polygon(bounding_circle, n_sides, rotation=0, fill=None, outline=None, width=1) @@ -362,7 +377,7 @@ Methods :param width: The line width, in pixels. -.. py:method:: ImageDraw.rectangle(xy, fill=None, outline=None, width=1) +.. py:method:: ImageDraw.rectangle(xy, fill=None, outline=None, width=1, dash=None) Draws a rectangle. @@ -374,6 +389,13 @@ Methods :param width: The line width, in pixels. .. versionadded:: 5.3.0 + :param dash: An optional dash pattern, given as a tuple of integers. + The dash pattern specifies the lengths of alternating drawn and blank segments + (e.g. ``(10, 5)`` draws 10 pixels, skips 5, and repeats). If an odd number of + values is given, it continues to alternate (e.g. ``(1, 2, 3)`` draws 1 pixel, + skips 2, draws 3, skips 1, draws 2, and so on). + + .. versionadded:: 12.3.0 .. py:method:: ImageDraw.rounded_rectangle(xy, radius=0, fill=None, outline=None, width=1, corners=None) diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 66511697a93..ac368944ee7 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -231,34 +231,108 @@ def circle( ellipse_xy = (xy[0] - radius, xy[1] - radius, xy[0] + radius, xy[1] + radius) self.ellipse(ellipse_xy, fill, outline, width) + def _normalize_points(self, xy: Coords) -> list[Sequence[float]]: + """Convert various coordinate formats to a list of (x, y) tuples.""" + if isinstance(xy[0], (list, tuple)): + return list(cast(Sequence[Sequence[float]], xy)) + else: + flat_xy = cast(Sequence[float], xy) + return [flat_xy[i : i + 2] for i in range(0, len(flat_xy), 2)] + + def _draw_dashed_line( + self, + p1: Sequence[float], + p2: Sequence[float], + dash: tuple[int, ...], + ink: int, + width: int, + dash_offset: int, + ) -> int: + """Draw a single dashed line segment between two points. + + Returns the updated dash_offset for continuing the pattern + along the next segment. + """ + dx = p2[0] - p1[0] + dy = p2[1] - p1[1] + segment_length = math.hypot(dx, dy) + if segment_length == 0: + return dash_offset + + vx = dx / segment_length + vy = dy / segment_length + + remaining = segment_length + x, y = p1 + + # Determine where we are in the dash pattern + dash_cycle_length = sum(dash) + offset = dash_offset % dash_cycle_length + dash_index = 0 + consumed = 0 + for i, d in enumerate(dash): + if consumed + d > offset: + dash_index = i + break + consumed += d + pixels_used: float = offset - consumed + + while remaining > 0.5: + current_dash_length = dash[dash_index % len(dash)] + step = min(current_dash_length - pixels_used, remaining) + + nx = x + vx * step + ny = y + vy * step + + if dash_index % 2 == 0: + self.draw.draw_lines([(x, y), (nx, ny)], ink, width) + + x = nx + y = ny + remaining -= step + pixels_used += step + + if pixels_used >= current_dash_length: + pixels_used = 0 + dash_index += 1 + + return (dash_offset + int(round(segment_length))) % dash_cycle_length + def line( self, xy: Coords, fill: _Ink | None = None, width: int = 1, joint: str | None = None, + dash: tuple[int, ...] | None = None, ) -> None: """Draw a line, or a connected sequence of line segments.""" ink = self._getink(fill)[0] - if ink is not None and width != 0: + if ink is None or width == 0: + return + + if dash is not None: + if len(dash) == 0: + msg = "dash must be a non-empty tuple of ints" + raise ValueError(msg) + points = self._normalize_points(xy) + dash_offset = 0 + for i in range(len(points) - 1): + dash_offset = self._draw_dashed_line( + points[i], points[i + 1], dash, ink, width, dash_offset + ) + else: self.draw.draw_lines(xy, ink, width) if joint == "curve" and width > 4: - points: Sequence[Sequence[float]] - if isinstance(xy[0], (list, tuple)): - points = cast(Sequence[Sequence[float]], xy) - else: - points = [ - cast(Sequence[float], tuple(xy[i : i + 2])) - for i in range(0, len(xy), 2) - ] - for i in range(1, len(points) - 1): - point = points[i] + joint_points = self._normalize_points(xy) + for i in range(1, len(joint_points) - 1): + point = joint_points[i] angles = [ math.degrees(math.atan2(end[0] - start[0], start[1] - end[1])) % 360 for start, end in ( - (points[i - 1], point), - (point, points[i + 1]), + (joint_points[i - 1], point), + (point, joint_points[i + 1]), ) ] if angles[0] == angles[1]: @@ -350,23 +424,39 @@ def polygon( fill: _Ink | None = None, outline: _Ink | None = None, width: int = 1, + dash: tuple[int, ...] | None = None, ) -> None: """Draw a polygon.""" ink, fill_ink = self._getink(outline, fill) if fill_ink is not None: self.draw.draw_polygon(xy, fill_ink, 1) - if ink is not None and ink != fill_ink and width != 0: - if width == 1: - self.draw.draw_polygon(xy, ink, 0, width) - elif self.im is not None: - # To avoid expanding the polygon outwards, - # use the fill as a mask - mask = Image.new("1", self.im.size) - mask_ink = self._getink(1)[0] - draw = Draw(mask) - draw.draw.draw_polygon(xy, mask_ink, 1) - - self.draw.draw_polygon(xy, ink, 0, width * 2 - 1, mask.im) + if ink is None or ink == fill_ink or width == 0: + return + + if dash is not None: + if len(dash) == 0: + msg = "dash must be a non-empty tuple of ints" + raise ValueError(msg) + points = self._normalize_points(xy) + # Close the polygon by connecting last point to first + if points[0] != points[-1]: + points.append(points[0]) + dash_offset = 0 + for i in range(len(points) - 1): + dash_offset = self._draw_dashed_line( + points[i], points[i + 1], dash, ink, width, dash_offset + ) + elif width == 1: + self.draw.draw_polygon(xy, ink, 0, width) + elif self.im is not None: + # To avoid expanding the polygon outwards, + # use the fill as a mask + mask = Image.new("1", self.im.size) + mask_ink = self._getink(1)[0] + draw = Draw(mask) + draw.draw.draw_polygon(xy, mask_ink, 1) + + self.draw.draw_polygon(xy, ink, 0, width * 2 - 1, mask.im) def regular_polygon( self, @@ -387,12 +477,38 @@ def rectangle( fill: _Ink | None = None, outline: _Ink | None = None, width: int = 1, + dash: tuple[int, ...] | None = None, ) -> None: """Draw a rectangle.""" ink, fill_ink = self._getink(outline, fill) if fill_ink is not None: self.draw.draw_rectangle(xy, fill_ink, 1) - if ink is not None and ink != fill_ink and width != 0: + if ink is None or ink == fill_ink or width == 0: + return + + if dash is not None: + if len(dash) == 0: + msg = "dash must be a non-empty tuple of ints" + raise ValueError(msg) + (x0, y0), (x1, y1) = self._normalize_points(xy) + rect_points = [ + (x0, y0), + (x1, y0), + (x1, y1), + (x0, y1), + (x0, y0), + ] + dash_offset = 0 + for i in range(4): + dash_offset = self._draw_dashed_line( + rect_points[i], + rect_points[i + 1], + dash, + ink, + width, + dash_offset, + ) + else: self.draw.draw_rectangle(xy, ink, 0, width) def rounded_rectangle( @@ -406,10 +522,7 @@ def rounded_rectangle( corners: tuple[bool, bool, bool, bool] | None = None, ) -> None: """Draw a rounded rectangle.""" - if isinstance(xy[0], (list, tuple)): - (x0, y0), (x1, y1) = cast(Sequence[Sequence[float]], xy) - else: - x0, y0, x1, y1 = cast(Sequence[float], xy) + (x0, y0), (x1, y1) = self._normalize_points(xy) if x1 < x0: msg = "x1 must be greater than or equal to x0" raise ValueError(msg)