Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added Tests/images/imagedraw_dash_line.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Tests/images/imagedraw_dash_polygon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Tests/images/imagedraw_dash_rectangle.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
64 changes: 64 additions & 0 deletions Tests/test_imagedraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=())
28 changes: 25 additions & 3 deletions docs/reference/ImageDraw.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)

Expand All @@ -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.

Expand All @@ -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)
Expand All @@ -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.

Expand All @@ -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)

Expand Down
173 changes: 143 additions & 30 deletions src/PIL/ImageDraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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)
Expand Down
Loading