Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a638382
Add dash parameter support for line, polygon, and rectangle drawing
Krishnachaitanyakc Mar 25, 2026
51ca4e6
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 25, 2026
ffd8dc2
Skip build 1.4.1 for lint
Krishnachaitanyakc Mar 25, 2026
10f007c
Fix mypy type errors in ImageDraw.py
Krishnachaitanyakc Mar 25, 2026
eae92d6
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 25, 2026
108ea06
Merge branch 'main' into add-dashed-line-support
radarhere Mar 26, 2026
ae1c06b
Remove build upgrade
radarhere Mar 28, 2026
44a04e4
Updated version
radarhere Apr 11, 2026
736d984
Do not convert to float when normalizing
radarhere Apr 11, 2026
bbd1e59
Simplified code
radarhere Apr 11, 2026
86d79d0
Assert that odd pattern image matches even pattern image
radarhere Apr 11, 2026
b079607
Match error message
radarhere Apr 11, 2026
52b6e3f
Merge pull request #1 from radarhere/add-dashed-line-support
Krishnachaitanyakc Apr 15, 2026
d5dac21
Merge branch 'main' into add-dashed-line-support
radarhere May 12, 2026
523ae52
Call C draw_lines directly from _draw_dashed_line
radarhere Apr 27, 2026
7ca3a90
Do not draw dashed line if width is zero or ink would be invisible
radarhere Apr 27, 2026
b0cf48f
Do not double the pattern length
radarhere May 7, 2026
ca4178e
Combine tests to check output visually
radarhere Apr 26, 2026
91d987a
Merge branch 'main' into add-dashed-line-support
radarhere Aug 24, 2026
9341c21
Only apply dash for width 1
radarhere Aug 25, 2026
1af2a67
Move dash check outside for loop
radarhere Aug 25, 2026
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.
62 changes: 62 additions & 0 deletions Tests/test_imagedraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -1774,3 +1774,65 @@ 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", dash=(10, 5))
draw.line([(10, 10), (50, 50), (90, 10)], "green", 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",
dash=(10, 5),
)
draw.polygon(
[(20, 20), (60, 20), (20, 60)],
fill="red",
outline="green",
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", dash=(10, 5))
draw.rectangle([30, 30, 70, 70], fill="red", outline="green", 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=())
30 changes: 27 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, ``width``
and ``joint`` are ignored.

.. versionadded:: 13.0.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,14 @@ 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). When ``dash`` is set, ``width``
is ignored.

.. versionadded:: 13.0.0


.. py:method:: ImageDraw.regular_polygon(bounding_circle, n_sides, rotation=0, fill=None, outline=None, width=1)
Expand All @@ -362,7 +378,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 +390,14 @@ 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). When ``dash`` is set, ``width``
is ignored.

.. versionadded:: 13.0.0

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

Expand Down
89 changes: 63 additions & 26 deletions src/PIL/ImageDraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,26 +222,38 @@ 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_coords(self, xy: Coords) -> Sequence[Sequence[float]]:
"""Normalize 1 or 2 dimensional coord sequence into 2d sequence."""
if isinstance(xy[0], (list, tuple)):
return cast("Sequence[Sequence[float]]", xy)
else:
return [
cast("Sequence[float]", tuple(xy[i : i + 2]))
for i in range(0, len(xy), 2)
]

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 or any(not isinstance(v, int) for v in dash):
msg = "dash must be a non-empty tuple of ints"
raise ValueError(msg)
self.draw.draw_lines(xy, ink, 1, dash)
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)
]
points = self._normalize_coords(xy)
for i in range(1, len(points) - 1):
point = points[i]
angles = [
Expand Down Expand Up @@ -341,23 +353,31 @@ 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 or any(not isinstance(v, int) for v in dash):
msg = "dash must be a non-empty tuple of ints"
raise ValueError(msg)
self.draw.draw_polygon(xy, ink, 0, 1, dash)
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, None, mask.im)

def regular_polygon(
self,
Expand All @@ -378,12 +398,32 @@ 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 or any(not isinstance(v, int) for v in dash):
msg = "dash must be a non-empty tuple of ints"
raise ValueError(msg)
self.draw.draw_lines(
[
(xy[0], xy[1]),
(xy[2], xy[1]),
(xy[2], xy[3]),
(xy[0], xy[3]),
(xy[0], xy[1]),
],
ink,
1,
dash,
)
else:
self.draw.draw_rectangle(xy, ink, 0, width)

def rounded_rectangle(
Expand All @@ -397,10 +437,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_coords(xy)
if x1 < x0:
msg = "x1 must be greater than or equal to x0"
raise ValueError(msg)
Expand Down
17 changes: 12 additions & 5 deletions src/_imaging.c
Original file line number Diff line number Diff line change
Expand Up @@ -3217,7 +3217,8 @@ _draw_lines(ImagingDrawObject *self, PyObject *args) {
PyObject *data;
int ink;
int width;
if (!PyArg_ParseTuple(args, "Oii", &data, &ink, &width)) {
PyObject *dash = NULL;
if (!PyArg_ParseTuple(args, "Oii|O", &data, &ink, &width, &dash)) {
return NULL;
}

Expand All @@ -3228,6 +3229,7 @@ _draw_lines(ImagingDrawObject *self, PyObject *args) {

if (width == 1) {
double *p = NULL;
int dash_offset = 0;
for (i = 0; i < n - 1; i++) {
p = &xy[i + i];
if (ImagingDrawLine(
Expand All @@ -3237,7 +3239,9 @@ _draw_lines(ImagingDrawObject *self, PyObject *args) {
(int)p[2],
(int)p[3],
&ink,
self->blend
self->blend,
dash,
&dash_offset
) < 0) {
free(xy);
return NULL;
Expand Down Expand Up @@ -3399,8 +3403,9 @@ _draw_polygon(ImagingDrawObject *self, PyObject *args) {
int fill = 0;
int width = 0;
ImagingObject *maskp = NULL;
PyObject *dash = NULL;
if (!PyArg_ParseTuple(
args, "Oi|iiO!", &data, &ink, &fill, &width, &Imaging_Type, &maskp
args, "Oi|iiOO!", &data, &ink, &fill, &width, &dash, &Imaging_Type, &maskp
)) {
return NULL;
}
Expand Down Expand Up @@ -3439,7 +3444,8 @@ _draw_polygon(ImagingDrawObject *self, PyObject *args) {
fill,
width,
self->blend,
maskp ? maskp->image : NULL
maskp ? maskp->image : NULL,
dash != Py_None ? dash : NULL
) < 0) {
free(ixy);
return NULL;
Expand All @@ -3459,7 +3465,8 @@ _draw_rectangle(ImagingDrawObject *self, PyObject *args) {
int ink;
int fill = 0;
int width = 0;
if (!PyArg_ParseTuple(args, "Oi|ii", &data, &ink, &fill, &width)) {
PyObject *dash = NULL;
if (!PyArg_ParseTuple(args, "Oi|iiO", &data, &ink, &fill, &width, &dash)) {
return NULL;
}

Expand Down
Loading
Loading