Merge d5dac21dfc into 877527cefc
This commit is contained in:
commit
ec1e449b5d
BIN
Tests/images/imagedraw_line_dash.png
Normal file
BIN
Tests/images/imagedraw_line_dash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 B |
BIN
Tests/images/imagedraw_polygon_dash.png
Normal file
BIN
Tests/images/imagedraw_polygon_dash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 265 B |
BIN
Tests/images/imagedraw_rectangle_dash.png
Normal file
BIN
Tests/images/imagedraw_rectangle_dash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 265 B |
@ -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=())
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user