This commit is contained in:
Krishna Chaitanya 2026-05-15 22:01:26 +10:00 committed by GitHub
commit ec1e449b5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 275 additions and 21 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B

View File

@ -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=())

View File

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

View File

@ -231,34 +231,109 @@ class ImageDraw:
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 = 1,
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 and width != 0:
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 @@ class ImageDraw:
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 @@ class ImageDraw:
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 @@ class ImageDraw:
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)