diff --git a/Tests/images/imagedraw_line_dash.png b/Tests/images/imagedraw_line_dash.png new file mode 100644 index 00000000000..e03c70fee88 Binary files /dev/null and b/Tests/images/imagedraw_line_dash.png differ diff --git a/Tests/images/imagedraw_polygon_dash.png b/Tests/images/imagedraw_polygon_dash.png new file mode 100644 index 00000000000..c95a7b8ee98 Binary files /dev/null and b/Tests/images/imagedraw_polygon_dash.png differ diff --git a/Tests/images/imagedraw_rectangle_dash.png b/Tests/images/imagedraw_rectangle_dash.png new file mode 100644 index 00000000000..5e5c76143d3 Binary files /dev/null and b/Tests/images/imagedraw_rectangle_dash.png differ diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 3bcb7b90178..fa5aa730356 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1757,3 +1757,122 @@ 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_line_dash() -> None: + # Arrange + im = Image.new("RGB", (W, H)) + draw = ImageDraw.Draw(im) + + # Act + draw.line([(10, 50), (90, 50)], "yellow", 2, dash=(10, 5)) + + # Assert + assert_image_equal_tofile(im, "Tests/images/imagedraw_line_dash.png") + + +def test_line_dash_multi_segment() -> None: + # Arrange + im = Image.new("RGB", (W, H)) + draw = ImageDraw.Draw(im) + + # Act - draw a dashed multi-segment line + draw.line([(10, 10), (50, 50), (90, 10)], "yellow", 2, dash=(8, 4)) + + # Assert - verify the image is not all black (dashes were drawn) + assert im.getbbox() is not None + + +def test_line_dash_odd_pattern() -> None: + # An odd-length dash pattern should be doubled per SVG spec + im = Image.new("RGB", (W, H)) + draw = ImageDraw.Draw(im) + draw.line([(10, 50), (90, 50)], "yellow", 2, dash=(10,)) + + expected = Image.new("RGB", (W, H)) + draw2 = ImageDraw.Draw(expected) + draw2.line([(10, 50), (90, 50)], "yellow", 2, dash=(10, 10)) + + # odd pattern (10,) becomes (10, 10) + assert_image_equal(im, expected) + + +def test_line_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=()) + + +def test_polygon_dash() -> None: + # Arrange + im = Image.new("RGB", (W, H)) + draw = ImageDraw.Draw(im) + + # Act + draw.polygon( + [(10, 10), (90, 10), (90, 90), (10, 90)], + outline="blue", + width=1, + dash=(10, 5), + ) + + # Assert + assert_image_equal_tofile(im, "Tests/images/imagedraw_polygon_dash.png") + + +def test_polygon_dash_with_fill() -> None: + # Dashed polygon with fill should draw fill and dashed outline + im = Image.new("RGB", (W, H)) + draw = ImageDraw.Draw(im) + + draw.polygon( + [(10, 10), (90, 10), (90, 90), (10, 90)], + fill="red", + outline="blue", + width=1, + dash=(10, 5), + ) + + # Verify center pixel is red (fill) and some edge pixels are blue (outline) + assert im.getpixel((50, 50)) == (255, 0, 0) + + +def test_polygon_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.polygon([(10, 10), (90, 10), (90, 90)], dash=()) + + +def test_rectangle_dash() -> 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)) + + # Assert + assert_image_equal_tofile(im, "Tests/images/imagedraw_rectangle_dash.png") + + +def test_rectangle_dash_with_fill() -> None: + # Dashed rectangle with fill should draw fill and dashed outline + im = Image.new("RGB", (W, H)) + draw = ImageDraw.Draw(im) + + draw.rectangle([10, 10, 90, 90], fill="red", outline="green", width=1, dash=(10, 5)) + + # Verify center pixel is red (fill) + assert im.getpixel((50, 50)) == (255, 0, 0) + + +def test_rectangle_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.rectangle([10, 10, 90, 90], dash=()) diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index 4c956759334..7ef6bee7e3c 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 ints. + 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, the pattern is + doubled (following the SVG specification). 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 ints. + 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, the pattern is + doubled (following the SVG specification). + + .. 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 ints. + 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, the pattern is + doubled (following the SVG specification). + + .. 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 eb108ac41ca..f2d044dc03b 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -231,34 +231,109 @@ 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, ...], + fill: _Ink | None, + 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.line([(x, y), (nx, ny)], fill, 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 = 0, joint: str | None = None, + dash: tuple[int, ...] | None = None, ) -> None: """Draw a line, or a connected sequence of line segments.""" + if dash is not None: + if len(dash) == 0: + msg = "dash must be a non-empty tuple of ints" + raise ValueError(msg) + # If odd number of elements, double the pattern per SVG spec + if len(dash) % 2 != 0: + dash *= 2 + 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, fill, width, dash_offset + ) + return ink = self._getink(fill)[0] if ink is not None: 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,12 +425,28 @@ 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 dash is not None: + if len(dash) == 0: + msg = "dash must be a non-empty tuple of ints" + raise ValueError(msg) + if len(dash) % 2 != 0: + dash *= 2 + 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, outline, width, dash_offset + ) + elif 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: @@ -387,12 +478,37 @@ 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 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), + ] + if len(dash) % 2 != 0: + dash *= 2 + dash_offset = 0 + for i in range(len(rect_points) - 1): + dash_offset = self._draw_dashed_line( + rect_points[i], + rect_points[i + 1], + dash, + outline, + width, + dash_offset, + ) + elif ink is not None and ink != fill_ink and width != 0: 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)