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_line_dash.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_polygon_dash.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_rectangle_dash.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
118 changes: 118 additions & 0 deletions Tests/test_imagedraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -1757,3 +1757,121 @@ 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)], fill="yellow", width=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)], fill="yellow", width=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)

# Should not raise; odd pattern (10,) becomes (10, 10)
draw.line([(10, 50), (90, 50)], fill="yellow", width=2, dash=(10,))

assert im.getbbox() is not None


def test_line_dash_empty_raises() -> None:
im = Image.new("RGB", (W, H))
draw = ImageDraw.Draw(im)

with pytest.raises(ValueError):
draw.line([(10, 50), (90, 50)], fill="yellow", 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)
assert im.getbbox() is not None


def test_polygon_dash_empty_raises() -> None:
im = Image.new("RGB", (W, H))
draw = ImageDraw.Draw(im)

with pytest.raises(ValueError):
draw.polygon([(10, 10), (90, 10), (90, 90)], outline="blue", 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)
assert im.getbbox() is not None


def test_rectangle_dash_empty_raises() -> None:
im = Image.new("RGB", (W, H))
draw = ImageDraw.Draw(im)

with pytest.raises(ValueError):
draw.rectangle([10, 10, 90, 90], outline="green", 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 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.2.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 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.2.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 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.2.0

.. py:method:: ImageDraw.rounded_rectangle(xy, radius=0, fill=None, outline=None, width=1, corners=None)

Expand Down
157 changes: 146 additions & 11 deletions src/PIL/ImageDraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,34 +231,125 @@ 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[tuple[float, float]]:
"""Convert various coordinate formats to a list of (x, y) tuples."""
if isinstance(xy[0], (list, tuple)):
return [
(float(point[0]), float(point[1]))
for point in cast(Sequence[Sequence[float]], xy)
]
else:
flat = cast(Sequence[float], xy)
return [
(float(flat[i]), float(flat[i + 1])) for i in range(0, len(flat), 2)
]

def _draw_dashed_line(
self,
p1: tuple[float, float],
p2: tuple[float, 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.sqrt(dx * dx + dy * 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=fill,
width=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 = dash + dash
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]]
joint_points: Sequence[Sequence[float]]
if isinstance(xy[0], (list, tuple)):
points = cast(Sequence[Sequence[float]], xy)
joint_points = cast(Sequence[Sequence[float]], xy)
else:
points = [
cast(Sequence[float], tuple(xy[i : i + 2]))
for i in range(0, len(xy), 2)
flat_xy = cast(Sequence[float], xy)
joint_points = [
tuple(flat_xy[i : i + 2]) for i in range(0, len(flat_xy), 2)
]
for i in range(1, len(points) - 1):
point = points[i]
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,12 +441,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 = dash + dash
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:
Expand Down Expand Up @@ -387,12 +494,40 @@ 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)
if isinstance(xy[0], (list, tuple)):
(x0, y0), (x1, y1) = cast(Sequence[Sequence[float]], xy)
else:
x0, y0, x1, y1 = cast(Sequence[float], xy)
rect_points: list[tuple[float, float]] = [
(x0, y0),
(x1, y0),
(x1, y1),
(x0, y1),
(x0, y0),
]
if len(dash) % 2 != 0:
dash = dash + dash
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(
Expand Down
Loading
Loading