From a638382d3497898596c64e7c842c8bb19480943b Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Balusu Date: Tue, 24 Mar 2026 22:31:59 -0400 Subject: [PATCH 01/17] Add dash parameter support for line, polygon, and rectangle drawing Add a `dash` parameter to `ImageDraw.line()`, `ImageDraw.polygon()`, and `ImageDraw.rectangle()` methods that allows drawing dashed outlines. The dash pattern follows the SVG `stroke-dasharray` specification: - Accepts a tuple of ints specifying alternating drawn/blank segment lengths - Odd-length patterns are automatically doubled per the SVG spec - The dash pattern is continuous across connected line segments and polygon edges - Empty dash tuples raise ValueError Closes #9127 --- Tests/images/imagedraw_line_dash.png | Bin 0 -> 130 bytes Tests/images/imagedraw_polygon_dash.png | Bin 0 -> 265 bytes Tests/images/imagedraw_rectangle_dash.png | Bin 0 -> 265 bytes Tests/test_imagedraw.py | 120 ++++++++++++++++++ docs/reference/ImageDraw.rst | 28 ++++- src/PIL/ImageDraw.py | 142 +++++++++++++++++++++- 6 files changed, 285 insertions(+), 5 deletions(-) create mode 100644 Tests/images/imagedraw_line_dash.png create mode 100644 Tests/images/imagedraw_polygon_dash.png create mode 100644 Tests/images/imagedraw_rectangle_dash.png diff --git a/Tests/images/imagedraw_line_dash.png b/Tests/images/imagedraw_line_dash.png new file mode 100644 index 0000000000000000000000000000000000000000..e03c70fee885f9a947a9104883998c66d55ce514 GIT binary patch literal 130 zcmeAS@N?(olHy`uVBq!ia0vp^DImf+VCYC?V%FnfNt!FC0223f^>bP0 Hl+XkKovj|Q literal 0 HcmV?d00001 diff --git a/Tests/images/imagedraw_polygon_dash.png b/Tests/images/imagedraw_polygon_dash.png new file mode 100644 index 0000000000000000000000000000000000000000..c95a7b8ee986566ccbacfc07ed63693841c02e37 GIT binary patch literal 265 zcmeAS@N?(olHy`uVBq!ia0vp^DIm;X;EQf7O_5?tVnu_DZ%%ck8Syf2WI}@sWvQ;l vGus*?P~pMVGIHwwI)AU{V`eyDvDv)HUOc7u;-6}un;AS^{an^LB{Ts5FC<># literal 0 HcmV?d00001 diff --git a/Tests/images/imagedraw_rectangle_dash.png b/Tests/images/imagedraw_rectangle_dash.png new file mode 100644 index 0000000000000000000000000000000000000000..5e5c76143d310e6b71d27d7ddd047f05d4a3ce10 GIT binary patch literal 265 zcmeAS@N?(olHy`uVBq!ia0vp^DIm 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=()) diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index 4c956759334..05c8ddd0847 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -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.2.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.2.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.2.0 .. py:method:: ImageDraw.rounded_rectangle(xy, radius=0, fill=None, outline=None, width=1, corners=None) diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index eb108ac41ca..164aed52fc4 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -231,14 +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[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 = 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) @@ -350,12 +444,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: @@ -387,12 +497,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( From 51ca4e61f3a068815289105dfd6fcf13decc8043 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 02:33:00 +0000 Subject: [PATCH 02/17] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- Tests/test_imagedraw.py | 4 +--- src/PIL/ImageDraw.py | 7 ++----- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 75694a33f4c..2349ad0d1bf 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1842,9 +1842,7 @@ def test_polygon_dash_empty_raises() -> None: draw = ImageDraw.Draw(im) with pytest.raises(ValueError): - draw.polygon( - [(10, 10), (90, 10), (90, 90)], outline="blue", dash=() - ) + draw.polygon([(10, 10), (90, 10), (90, 90)], outline="blue", dash=()) def test_rectangle_dash() -> None: diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 164aed52fc4..c83bd6ae126 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -231,9 +231,7 @@ 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]]: + 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 [ @@ -243,8 +241,7 @@ def _normalize_points( else: flat = cast(Sequence[float], xy) return [ - (float(flat[i]), float(flat[i + 1])) - for i in range(0, len(flat), 2) + (float(flat[i]), float(flat[i + 1])) for i in range(0, len(flat), 2) ] def _draw_dashed_line( From ffd8dc253a6edfc97cf71c5ee66ad8672f37f21e Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Balusu Date: Wed, 25 Mar 2026 09:22:25 -0400 Subject: [PATCH 03/17] Skip build 1.4.1 for lint Pending https://github.com/pypa/build/pull/1003 --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index de18946efa7..37e2296fc1f 100644 --- a/tox.ini +++ b/tox.ini @@ -18,6 +18,7 @@ commands = [testenv:lint] skip_install = true deps = + build!=1.4.1 # pending https://github.com/pypa/build/pull/1003 check-manifest prek pass_env = From 10f007cd13636f9dad103560ef712fe8a16d6f12 Mon Sep 17 00:00:00 2001 From: Krishna Chaitanya Balusu Date: Wed, 25 Mar 2026 12:33:31 -0400 Subject: [PATCH 04/17] Fix mypy type errors in ImageDraw.py - Annotate pixels_used as float to fix float/int assignment mismatch - Rename redefined 'points' variable to 'joint_points' in curve joint code - Cast flat xy to Sequence[float] before slicing to fix type compatibility --- src/PIL/ImageDraw.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index c83bd6ae126..cac80a01a0d 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -280,7 +280,7 @@ def _draw_dashed_line( dash_index = i break consumed += d - pixels_used = offset - consumed + pixels_used: float = offset - consumed while remaining > 0.5: current_dash_length = dash[dash_index % len(dash)] @@ -334,22 +334,23 @@ def line( 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]: From eae92d6a45a3bf145a620fed09d3cf1df95f2257 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:34:13 +0000 Subject: [PATCH 05/17] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/PIL/ImageDraw.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index cac80a01a0d..4aa2202a40a 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -340,8 +340,7 @@ def line( else: flat_xy = cast(Sequence[float], xy) joint_points = [ - tuple(flat_xy[i : i + 2]) - for i in range(0, len(flat_xy), 2) + tuple(flat_xy[i : i + 2]) for i in range(0, len(flat_xy), 2) ] for i in range(1, len(joint_points) - 1): point = joint_points[i] From ae1c06b258e09cb518e93b66bffccdc21636a94b Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:36:34 +1100 Subject: [PATCH 06/17] Remove build upgrade --- tox.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/tox.ini b/tox.ini index 37e2296fc1f..de18946efa7 100644 --- a/tox.ini +++ b/tox.ini @@ -18,7 +18,6 @@ commands = [testenv:lint] skip_install = true deps = - build!=1.4.1 # pending https://github.com/pypa/build/pull/1003 check-manifest prek pass_env = From 44a04e4370d3923a0414ebd6307b517a7c837e52 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 11 Apr 2026 19:15:53 +1000 Subject: [PATCH 07/17] Updated version --- docs/reference/ImageDraw.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index 05c8ddd0847..7ef6bee7e3c 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -310,7 +310,7 @@ Methods doubled (following the SVG specification). When ``dash`` is set, ``joint`` is ignored. - .. versionadded:: 12.2.0 + .. versionadded:: 12.3.0 .. py:method:: ImageDraw.pieslice(xy, start, end, fill=None, outline=None, width=1) @@ -356,7 +356,7 @@ Methods repeats). If an odd number of values is given, the pattern is doubled (following the SVG specification). - .. versionadded:: 12.2.0 + .. versionadded:: 12.3.0 .. py:method:: ImageDraw.regular_polygon(bounding_circle, n_sides, rotation=0, fill=None, outline=None, width=1) @@ -395,7 +395,7 @@ Methods repeats). If an odd number of values is given, the pattern is doubled (following the SVG specification). - .. versionadded:: 12.2.0 + .. versionadded:: 12.3.0 .. py:method:: ImageDraw.rounded_rectangle(xy, radius=0, fill=None, outline=None, width=1, corners=None) From 736d98428c836738364548e54800f089462c6beb Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 11 Apr 2026 20:46:49 +1000 Subject: [PATCH 08/17] Do not convert to float when normalizing --- src/PIL/ImageDraw.py | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 4aa2202a40a..7e88069d45f 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -235,14 +235,11 @@ 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) + (point[0], 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) - ] + return [(flat[i], flat[i + 1]) for i in range(0, len(flat), 2)] def _draw_dashed_line( self, @@ -334,14 +331,7 @@ def line( if ink is not None: self.draw.draw_lines(xy, ink, width) if joint == "curve" and width > 4: - joint_points: Sequence[Sequence[float]] - if isinstance(xy[0], (list, tuple)): - joint_points = cast(Sequence[Sequence[float]], xy) - else: - flat_xy = cast(Sequence[float], xy) - joint_points = [ - tuple(flat_xy[i : i + 2]) for i in range(0, len(flat_xy), 2) - ] + joint_points = self._normalize_points(xy) for i in range(1, len(joint_points) - 1): point = joint_points[i] angles = [ @@ -504,10 +494,7 @@ def rectangle( 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) + (x0, y0), (x1, y1) = self._normalize_points(xy) rect_points: list[tuple[float, float]] = [ (x0, y0), (x1, y0), @@ -541,10 +528,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) From bbd1e597242354a1e8db8bf28e6e8339e7e12a7e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 11 Apr 2026 21:37:27 +1000 Subject: [PATCH 09/17] Simplified code --- Tests/test_imagedraw.py | 18 +++++++++--------- src/PIL/ImageDraw.py | 30 ++++++++++++------------------ 2 files changed, 21 insertions(+), 27 deletions(-) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 2349ad0d1bf..3a4b1dae092 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1765,7 +1765,7 @@ def test_line_dash() -> None: draw = ImageDraw.Draw(im) # Act - draw.line([(10, 50), (90, 50)], fill="yellow", width=2, dash=(10, 5)) + draw.line([(10, 50), (90, 50)], "yellow", 2, dash=(10, 5)) # Assert assert_image_equal_tofile(im, "Tests/images/imagedraw_line_dash.png") @@ -1777,7 +1777,7 @@ def test_line_dash_multi_segment() -> None: 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)) + 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 @@ -1789,17 +1789,17 @@ def test_line_dash_odd_pattern() -> None: 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,)) + draw.line([(10, 50), (90, 50)], "yellow", 2, dash=(10,)) assert im.getbbox() is not None -def test_line_dash_empty_raises() -> None: +def test_line_dash_empty() -> None: im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) with pytest.raises(ValueError): - draw.line([(10, 50), (90, 50)], fill="yellow", dash=()) + draw.line([(10, 50), (90, 50)], dash=()) def test_polygon_dash() -> None: @@ -1837,12 +1837,12 @@ def test_polygon_dash_with_fill() -> None: assert im.getbbox() is not None -def test_polygon_dash_empty_raises() -> None: +def test_polygon_dash_empty() -> 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=()) + draw.polygon([(10, 10), (90, 10), (90, 90)], dash=()) def test_rectangle_dash() -> None: @@ -1869,9 +1869,9 @@ def test_rectangle_dash_with_fill() -> None: assert im.getbbox() is not None -def test_rectangle_dash_empty_raises() -> None: +def test_rectangle_dash_empty() -> None: im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) with pytest.raises(ValueError): - draw.rectangle([10, 10, 90, 90], outline="green", dash=()) + draw.rectangle([10, 10, 90, 90], dash=()) diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 7e88069d45f..f2d044dc03b 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -231,20 +231,18 @@ 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]]: + 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 [ - (point[0], point[1]) for point in cast(Sequence[Sequence[float]], xy) - ] + return list(cast(Sequence[Sequence[float]], xy)) else: - flat = cast(Sequence[float], xy) - return [(flat[i], flat[i + 1]) for i in range(0, len(flat), 2)] + 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: tuple[float, float], - p2: tuple[float, float], + p1: Sequence[float], + p2: Sequence[float], dash: tuple[int, ...], fill: _Ink | None, width: int, @@ -257,7 +255,7 @@ def _draw_dashed_line( """ dx = p2[0] - p1[0] dy = p2[1] - p1[1] - segment_length = math.sqrt(dx * dx + dy * dy) + segment_length = math.hypot(dx, dy) if segment_length == 0: return dash_offset @@ -287,11 +285,7 @@ def _draw_dashed_line( ny = y + vy * step if dash_index % 2 == 0: - self.line( - [(x, y), (nx, ny)], - fill=fill, - width=width, - ) + self.line([(x, y), (nx, ny)], fill, width) x = nx y = ny @@ -319,7 +313,7 @@ def line( raise ValueError(msg) # If odd number of elements, double the pattern per SVG spec if len(dash) % 2 != 0: - dash = dash + dash + dash *= 2 points = self._normalize_points(xy) dash_offset = 0 for i in range(len(points) - 1): @@ -442,7 +436,7 @@ def polygon( msg = "dash must be a non-empty tuple of ints" raise ValueError(msg) if len(dash) % 2 != 0: - dash = dash + dash + dash *= 2 points = self._normalize_points(xy) # Close the polygon by connecting last point to first if points[0] != points[-1]: @@ -495,7 +489,7 @@ def rectangle( msg = "dash must be a non-empty tuple of ints" raise ValueError(msg) (x0, y0), (x1, y1) = self._normalize_points(xy) - rect_points: list[tuple[float, float]] = [ + rect_points = [ (x0, y0), (x1, y0), (x1, y1), @@ -503,7 +497,7 @@ def rectangle( (x0, y0), ] if len(dash) % 2 != 0: - dash = dash + dash + dash *= 2 dash_offset = 0 for i in range(len(rect_points) - 1): dash_offset = self._draw_dashed_line( From 86d79d00c102ef63d005a79a0fc857e83bdc9c72 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 11 Apr 2026 21:26:21 +1000 Subject: [PATCH 10/17] Assert that odd pattern image matches even pattern image --- Tests/test_imagedraw.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 3a4b1dae092..3b374ce467c 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1787,11 +1787,14 @@ 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)], "yellow", 2, dash=(10,)) - assert im.getbbox() is not None + 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: @@ -1834,7 +1837,6 @@ def test_polygon_dash_with_fill() -> None: # 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() -> None: @@ -1866,7 +1868,6 @@ def test_rectangle_dash_with_fill() -> None: # 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() -> None: From b079607d6f4868968cb4781f26426c40fc51dd86 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 11 Apr 2026 21:31:04 +1000 Subject: [PATCH 11/17] Match error message --- Tests/test_imagedraw.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 3b374ce467c..fa5aa730356 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1801,7 +1801,7 @@ def test_line_dash_empty() -> None: im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="dash must be a non-empty tuple of ints"): draw.line([(10, 50), (90, 50)], dash=()) @@ -1843,7 +1843,7 @@ def test_polygon_dash_empty() -> None: im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="dash must be a non-empty tuple of ints"): draw.polygon([(10, 10), (90, 10), (90, 90)], dash=()) @@ -1874,5 +1874,5 @@ def test_rectangle_dash_empty() -> None: im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="dash must be a non-empty tuple of ints"): draw.rectangle([10, 10, 90, 90], dash=()) From 523ae52881aee8a26b2299dec04e00c19c485260 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 27 Apr 2026 19:33:59 +1000 Subject: [PATCH 12/17] Call C draw_lines directly from _draw_dashed_line --- src/PIL/ImageDraw.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index f0e3539aa3b..76d2a361740 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -244,7 +244,7 @@ def _draw_dashed_line( p1: Sequence[float], p2: Sequence[float], dash: tuple[int, ...], - fill: _Ink | None, + ink: int, width: int, dash_offset: int, ) -> int: @@ -285,7 +285,7 @@ def _draw_dashed_line( ny = y + vy * step if dash_index % 2 == 0: - self.line([(x, y), (nx, ny)], fill, width) + self.draw.draw_lines([(x, y), (nx, ny)], ink, width) x = nx y = ny @@ -318,7 +318,7 @@ def line( 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 + points[i], points[i + 1], dash, ink, width, dash_offset ) return ink = self._getink(fill)[0] @@ -444,7 +444,7 @@ def polygon( 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 + points[i], points[i + 1], dash, ink, width, dash_offset ) elif ink is not None and ink != fill_ink and width != 0: if width == 1: @@ -504,7 +504,7 @@ def rectangle( rect_points[i], rect_points[i + 1], dash, - outline, + ink, width, dash_offset, ) From 7ca3a90a02f899b864f8b592aaa7c996b86a92d4 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 27 Apr 2026 19:57:22 +1000 Subject: [PATCH 13/17] Do not draw dashed line if width is zero or ink would be invisible --- src/PIL/ImageDraw.py | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 76d2a361740..7d8f8cf0043 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -307,6 +307,10 @@ def line( dash: tuple[int, ...] | None = None, ) -> None: """Draw a line, or a connected sequence of line segments.""" + ink = self._getink(fill)[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" @@ -320,9 +324,7 @@ def line( dash_offset = self._draw_dashed_line( points[i], points[i + 1], dash, ink, width, dash_offset ) - return - ink = self._getink(fill)[0] - if ink is not None and width != 0: + else: self.draw.draw_lines(xy, ink, width) if joint == "curve" and width > 4: joint_points = self._normalize_points(xy) @@ -431,6 +433,9 @@ def 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 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" @@ -446,18 +451,17 @@ def polygon( dash_offset = self._draw_dashed_line( points[i], points[i + 1], dash, ink, 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: - # 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) + 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, @@ -484,6 +488,9 @@ def 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 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" @@ -508,7 +515,7 @@ def rectangle( width, dash_offset, ) - elif ink is not None and ink != fill_ink and width != 0: + else: self.draw.draw_rectangle(xy, ink, 0, width) def rounded_rectangle( From b0cf48f9c1ef021ce52ee258ae8c6014ec1f9bb3 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 7 May 2026 20:54:37 +1000 Subject: [PATCH 14/17] Do not double the pattern length --- Tests/test_imagedraw.py | 14 -------------- docs/reference/ImageDraw.rst | 32 ++++++++++++++++---------------- src/PIL/ImageDraw.py | 9 +-------- 3 files changed, 17 insertions(+), 38 deletions(-) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index fa5aa730356..31419a21288 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1783,20 +1783,6 @@ def test_line_dash_multi_segment() -> None: 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) diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index 7ef6bee7e3c..0adc0f913b3 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -303,12 +303,12 @@ 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. + :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 @@ -350,11 +350,11 @@ 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). + :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 @@ -389,11 +389,11 @@ 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). + :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 diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 7d8f8cf0043..ac368944ee7 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -315,9 +315,6 @@ def line( 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): @@ -440,8 +437,6 @@ def polygon( 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]: @@ -503,10 +498,8 @@ def rectangle( (x0, y1), (x0, y0), ] - if len(dash) % 2 != 0: - dash *= 2 dash_offset = 0 - for i in range(len(rect_points) - 1): + for i in range(4): dash_offset = self._draw_dashed_line( rect_points[i], rect_points[i + 1], From ca4178e19d13f6b1ae4587e3d72d3c699ec8a96f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 26 Apr 2026 23:50:05 +1000 Subject: [PATCH 15/17] Combine tests to check output visually --- Tests/images/imagedraw_dash_line.png | Bin 0 -> 455 bytes Tests/images/imagedraw_dash_polygon.png | Bin 0 -> 558 bytes Tests/images/imagedraw_dash_rectangle.png | Bin 0 -> 361 bytes Tests/images/imagedraw_line_dash.png | Bin 130 -> 0 bytes Tests/images/imagedraw_polygon_dash.png | Bin 265 -> 0 bytes Tests/images/imagedraw_rectangle_dash.png | Bin 265 -> 0 bytes Tests/test_imagedraw.py | 79 ++++++---------------- 7 files changed, 19 insertions(+), 60 deletions(-) create mode 100644 Tests/images/imagedraw_dash_line.png create mode 100644 Tests/images/imagedraw_dash_polygon.png create mode 100644 Tests/images/imagedraw_dash_rectangle.png delete mode 100644 Tests/images/imagedraw_line_dash.png delete mode 100644 Tests/images/imagedraw_polygon_dash.png delete mode 100644 Tests/images/imagedraw_rectangle_dash.png diff --git a/Tests/images/imagedraw_dash_line.png b/Tests/images/imagedraw_dash_line.png new file mode 100644 index 0000000000000000000000000000000000000000..c0b0ee8c98ff32d7aa9822709be1f0a38b32acc8 GIT binary patch literal 455 zcmeAS@N?(olHy`uVBq!ia0vp^DImL`CrG`Y8n8$>d&r z?djc=t{(ON#I@J8Pm0+2mR7}?+S~fh-d%6*s(+xnDzEC({8FBoi+m5W#n1f|o6?oG zH*sb0z67JA!pVtW&q+Bit2pu4@Xg$6&6$sOHg1>`wxP*K&@JC${i%iRmoGXlbLd?b^h(QlP&*ckuHx$e`M0^ z4MA$%pO$Cm3WS|fpY*$Wg6$!%@|`!B6_9eC!m{&Q$|tOjNr~skI$MvcjB{4nc}fe*`S738CM9`B z)@v1#IVnjJmUZQcb)^i&hm~rLJ{BBSp`<2Y)X4SJ7DkOsY73)ACbfl8Ba_;K(%R{2 zMiuaBn0)2w=E%tE`|g`G9oFrWlG(8G+;K2+pR+ox(S5(7$#%!V$Z=$~yW`*%R==dg zDXf-BiBniDlbV20W3#In)%uaucuXw|tHd1#XBaj1Tcg`RX}wia*0*7&s~KnfE@2i7 zCwzLx+d*^zWgxl$M@ZaTI!M`7Qb^WMYDm*b0f;UjllcG7DuiJj;1(9VI!+81?o}Qx z*`*0MZ+~BiE}#rV7f=SG3n&B81!RHf0y5zW{s2Q*SXM+AIQaUlVfsb-lFR2S^bf;d wX#+dY=?i>3pS85I0}v)0j`;t}|5uUB#h959jS-E? zv(hhTzXeNyqz?%CCO~xdW!!5~o~11ebW?%L=bWmwb45GAFz2VtpUv0pUfz@IzJJo9 ziqTqnvt#~YkY)#+hHfc${e<*nvlq7wz`gjKcnl1?ihTRs-)2n<<=bol P3_=D^S3j3^P6f+VCYC?V%FnfNt!FC0223f^>bP0 Hl+XkKovj|Q diff --git a/Tests/images/imagedraw_polygon_dash.png b/Tests/images/imagedraw_polygon_dash.png deleted file mode 100644 index c95a7b8ee986566ccbacfc07ed63693841c02e37..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 265 zcmeAS@N?(olHy`uVBq!ia0vp^DIm;X;EQf7O_5?tVnu_DZ%%ck8Syf2WI}@sWvQ;l vGus*?P~pMVGIHwwI)AU{V`eyDvDv)HUOc7u;-6}un;AS^{an^LB{Ts5FC<># diff --git a/Tests/images/imagedraw_rectangle_dash.png b/Tests/images/imagedraw_rectangle_dash.png deleted file mode 100644 index 5e5c76143d310e6b71d27d7ddd047f05d4a3ce10..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 265 zcmeAS@N?(olHy`uVBq!ia0vp^DIm None: draw.rounded_rectangle(xy) -def test_line_dash() -> None: +def test_dash_line() -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) # Act - draw.line([(10, 50), (90, 50)], "yellow", 2, dash=(10, 5)) + 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_line_dash.png") + assert_image_equal_tofile(im, "Tests/images/imagedraw_dash_line.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_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: +def test_dash_polygon() -> 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", + [(10, 10), (90, 10), (10, 90)], + outline="green", 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)], + [(20, 20), (60, 20), (20, 60)], fill="red", - outline="blue", + outline="green", 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=()) + # Assert + assert_image_equal_tofile(im, "Tests/images/imagedraw_dash_polygon.png") -def test_rectangle_dash() -> None: +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_rectangle_dash.png") + assert_image_equal_tofile(im, "Tests/images/imagedraw_dash_rectangle.png") -def test_rectangle_dash_with_fill() -> None: - # Dashed rectangle with fill should draw fill and dashed outline +def test_dash_empty() -> None: 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) - + with pytest.raises(ValueError, match="dash must be a non-empty tuple of ints"): + draw.line([(10, 50), (90, 50)], dash=()) -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.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=()) From 9341c212ee0a6f8528e0ab9856b2828dd8561912 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 25 Aug 2026 17:46:20 +1000 Subject: [PATCH 16/17] Only apply dash for width 1 --- Tests/images/imagedraw_dash_line.png | Bin 455 -> 270 bytes Tests/images/imagedraw_dash_polygon.png | Bin 558 -> 505 bytes Tests/images/imagedraw_dash_rectangle.png | Bin 361 -> 370 bytes Tests/test_imagedraw.py | 10 +- docs/reference/ImageDraw.rst | 16 +- src/PIL/ImageDraw.py | 138 ++++------------ src/_imaging.c | 17 +- src/libImaging/Draw.c | 190 +++++++++++++++++++--- src/libImaging/Imaging.h | 15 +- 9 files changed, 236 insertions(+), 150 deletions(-) diff --git a/Tests/images/imagedraw_dash_line.png b/Tests/images/imagedraw_dash_line.png index c0b0ee8c98ff32d7aa9822709be1f0a38b32acc8..a3bb51434d6b778f853d0c3d918a170f17ee9736 100644 GIT binary patch delta 243 zcmX@k+{ZLQxt`≀B4q#hkb2PUkfT2)H;tKmYsk=FU%(a_6|-G2W5YC{QrFcqan` z!}Ez{2PQAAsq5#7nyKr&{n^Q;qmr9)wySqvpRcX{^Z0*n>xAAd%nh@)Fi-Qeeh_er z(SC_+f%`4Sa3B?Wi_v_k><8~#jO>^AeoQGl;CzX1$C-?Zkhq*5?`JQOWmq%4?0`|^ zjyqFSX9uTzxaA_b%{Z1#Cuh>7n`=KE30|-9mZ5<^lU-*r`@9dT=I?gu-Crl;Z4Go3 r3^Yu<$9M0!e*XE7AOAjPU=Z8BYy#oNklZhG>cbqtxJO~VFXo$_5|ie|lGsdK(yGJnMrV`25q8bhXd$m22W zoqH*n;<0HfD@ErubM^^grMSGJ=5?Tm^ViJjCcmPQDNv##rbsEB@bXWlm}brfpQC9F z-8BoR`z*lZCJ_UdyaQ_8I7G+yKfv5cXW^` zP@-vrPo{8QYMS?r)zBxR>~)m`+JZ*!b*3!*a9E)xsfY3 zF2;VZXk-eMD0%?^0000000000000000001hUDbXxpeI@bW$O11&fmVxZ+8Fy0Pwy3 Y0K0lhxAGo3qSARzjS`l^kVJ%w6rmXZ_U&@TGMuU#>o)Oos|G)ZfX}8)xpj_X2K2xuoZ4s$nH(y0e1v7csGy7zmvv4F FO#t47?0EnH delta 533 zcmV+w0_y$w1Fi&+B!BfuL_t(|obB4(a)K}r1>m*Q2S0CaFYJYkk_~K@{o#Dmajb!` zoW>ZWB1A+)L_}1_LurTB0d}6(-B{gys^9jYwEtdFM1FXCwD!^)q6;7*QlLzy(NL`4 zI5@wnZJnhZ`1!WMW9h~x&l#?N{&Clkrv&`CoXdnCYX_Ipu775C;DRY-cz{bs6C%gd zg2OPW14k;5)Kb~!tOw+Yv-8wyrm*x6cpFnvSXzpRWFA@8q_DD>6_9eC!m{&Q$|tOj zNr~skI$MvcjB{4nc}fe*`S738CM9`B)@v1#IVnjJmUZQcb)^i&hm~rLJ{BBSp`<2Y z)X4SJ7DkOsYJUr(Mkcj|Q6rPug3{XQYDN|CX_$QF>E_7D>ih1SG#%FMlakr6^4xJS za-Xw0tH_FJRdKxw^IQr5R&r>hxf{4QY@3@3bg$J;@40e@v6x&TK=+*>+G*;P_V)=z3k z(@6n{E+CWm|IaFfVIANW7Q8x63>WTI9xmCX2{>D;t`mJI5Mf#G<=PUFN!(V9wJJ0D0d_19DczYxTq6;7*A|fIpB6oig X$?#dsd=97200000NkvXXu0mjfQDg#0 diff --git a/Tests/images/imagedraw_dash_rectangle.png b/Tests/images/imagedraw_dash_rectangle.png index e4cc1c5485c53121e808bfd308eef11e016cf4c7..fce36a021eacb2ce893b3024c5a835a420830516 100644 GIT binary patch literal 370 zcmeAS@N?(olHy`uVBq!ia0vp^DIm{SNB2maV78l+k2@&9c0>?#-#sC zH@oqb2Df|lWvn{`G`pc~qs+xQPKPIl9*5Y}m%-oEu7x<-iod7 SRxvOv89ZJ6T-G@yGywnzMW2}f literal 361 zcmeAS@N?(olHy`uVBq!ia0vp^DIm85I0}v)0j`;t}|5uUB#h959jS-E? zv(hhTzXeNyqz?%CCO~xdW!!5~o~11ebW?%L=bWmwb45GAFz2VtpUv0pUfz@IzJJo9 ziqTqnvt#~YkY)#+hHfc${e<*nvlq7wz`gjKcnl1?ihTRs-)2n<<=bol P3_=D^S3j3^P6 None: 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)) + 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") @@ -1798,14 +1798,12 @@ def test_dash_polygon() -> None: 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), ) @@ -1819,8 +1817,8 @@ def test_dash_rectangle() -> None: 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)) + 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") diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index a48516117a3..70d3db2a2f2 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -307,10 +307,10 @@ Methods 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. + skips 2, draws 3, skips 1, draws 2, and so on). When ``dash`` is set, ``width`` + and ``joint`` are ignored. - .. versionadded:: 12.3.0 + .. versionadded:: 13.0.0 .. py:method:: ImageDraw.pieslice(xy, start, end, fill=None, outline=None, width=1) @@ -354,9 +354,10 @@ Methods 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). + skips 2, draws 3, skips 1, draws 2, and so on). When ``dash`` is set, ``width`` + is ignored. - .. versionadded:: 12.3.0 + .. versionadded:: 13.0.0 .. py:method:: ImageDraw.regular_polygon(bounding_circle, n_sides, rotation=0, fill=None, outline=None, width=1) @@ -393,9 +394,10 @@ Methods 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). + skips 2, draws 3, skips 1, draws 2, and so on). When ``dash`` is set, ``width`` + is ignored. - .. versionadded:: 12.3.0 + .. versionadded:: 13.0.0 .. py:method:: ImageDraw.rounded_rectangle(xy, radius=0, fill=None, outline=None, width=1, corners=None) diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 5fb62afc2ff..ec994b62a5f 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -222,72 +222,15 @@ 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.""" + 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 list(cast("Sequence[Sequence[float]]", xy)) + return 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 + return [ + cast("Sequence[float]", tuple(xy[i : i + 2])) + for i in range(0, len(xy), 2) + ] def line( self, @@ -303,27 +246,22 @@ def line( return if dash is not None: - if len(dash) == 0: + 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) - 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 - ) + self.draw.draw_lines(xy, ink, 1, dash) else: self.draw.draw_lines(xy, ink, width) if joint == "curve" and width > 4: - joint_points = self._normalize_points(xy) - for i in range(1, len(joint_points) - 1): - point = joint_points[i] + points = self._normalize_coords(xy) + for i in range(1, len(points) - 1): + point = points[i] angles = [ math.degrees(math.atan2(end[0] - start[0], start[1] - end[1])) % 360 for start, end in ( - (joint_points[i - 1], point), - (point, joint_points[i + 1]), + (points[i - 1], point), + (point, points[i + 1]), ) ] if angles[0] == angles[1]: @@ -425,18 +363,10 @@ def polygon( return if dash is not None: - if len(dash) == 0: + 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) - 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 - ) + 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: @@ -447,7 +377,7 @@ def polygon( draw = Draw(mask) draw.draw.draw_polygon(xy, mask_ink, 1) - self.draw.draw_polygon(xy, ink, 0, width * 2 - 1, mask.im) + self.draw.draw_polygon(xy, ink, 0, width * 2 - 1, None, mask.im) def regular_polygon( self, @@ -478,27 +408,21 @@ def rectangle( return if dash is not None: - if len(dash) == 0: + 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) - (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, - ) + 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) @@ -513,7 +437,7 @@ def rounded_rectangle( corners: tuple[bool, bool, bool, bool] | None = None, ) -> None: """Draw a rounded rectangle.""" - (x0, y0), (x1, y1) = self._normalize_points(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) diff --git a/src/_imaging.c b/src/_imaging.c index 9bdb6328782..1ff7638a240 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -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; } @@ -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( @@ -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; @@ -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; } @@ -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; @@ -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; } diff --git a/src/libImaging/Draw.c b/src/libImaging/Draw.c index 3217953a3e8..6e75f2159f3 100644 --- a/src/libImaging/Draw.c +++ b/src/libImaging/Draw.c @@ -205,8 +205,44 @@ hline32rgba(Imaging im, int x0, int y0, int x1, int ink, Imaging mask) { } } +static inline int +should_draw_dash(int i, int *dash_offset, PyObject *dash) { + if (dash == NULL) { + return 1; + } + i += *dash_offset; + int total = 0; + int tuple_index = -1; + int tuple_size = PyTuple_GET_SIZE(dash); + while (total <= i) { + tuple_index += 1; + if (tuple_index == tuple_size) { + tuple_index = 0; + } + PyObject *value = PyTuple_GetItem(dash, tuple_index); + if (!PyLong_Check(value)) { + return 0; + } + int v = PyLong_AsLongLong(value); + if (v == -1 && PyErr_Occurred()) { + return 0; + } + total += v; + } + return tuple_index % 2 == 0; +} + static inline void -line8(Imaging im, int x0, int y0, int x1, int y1, int ink) { +line8( + Imaging im, + int x0, + int y0, + int x1, + int y1, + int ink, + PyObject *dash, + int *dash_offset +) { int i, n, e; int dx, dy; int xs, ys; @@ -230,16 +266,26 @@ line8(Imaging im, int x0, int y0, int x1, int y1, int ink) { if (dx == 0) { /* vertical */ for (i = 0; i < dy; i++) { - point8(im, x0, y0, ink); + if (should_draw_dash(i, dash_offset, dash)) { + point8(im, x0, y0, ink); + } y0 += ys; } + if (dash != NULL) { + *dash_offset += dy; + } } else if (dy == 0) { /* horizontal */ for (i = 0; i < dx; i++) { - point8(im, x0, y0, ink); + if (should_draw_dash(i, dash_offset, dash)) { + point8(im, x0, y0, ink); + } x0 += xs; } + if (dash != NULL) { + *dash_offset += dx; + } } else if (dx > dy) { /* bresenham, horizontal slope */ @@ -249,7 +295,9 @@ line8(Imaging im, int x0, int y0, int x1, int y1, int ink) { dx += dx; for (i = 0; i < n; i++) { - point8(im, x0, y0, ink); + if (should_draw_dash(i, dash_offset, dash)) { + point8(im, x0, y0, ink); + } if (e >= 0) { y0 += ys; e -= dx; @@ -257,6 +305,9 @@ line8(Imaging im, int x0, int y0, int x1, int y1, int ink) { e += dy; x0 += xs; } + if (dash != NULL) { + *dash_offset += n; + } } else { /* bresenham, vertical slope */ @@ -266,7 +317,9 @@ line8(Imaging im, int x0, int y0, int x1, int y1, int ink) { dy += dy; for (i = 0; i < n; i++) { - point8(im, x0, y0, ink); + if (should_draw_dash(i, dash_offset, dash)) { + point8(im, x0, y0, ink); + } if (e >= 0) { x0 += xs; e -= dy; @@ -274,11 +327,23 @@ line8(Imaging im, int x0, int y0, int x1, int y1, int ink) { e += dx; y0 += ys; } + if (dash != NULL) { + *dash_offset += n; + } } } static inline void -line32(Imaging im, int x0, int y0, int x1, int y1, int ink) { +line32( + Imaging im, + int x0, + int y0, + int x1, + int y1, + int ink, + PyObject *dash, + int *dash_offset +) { int i, n, e; int dx, dy; int xs, ys; @@ -302,16 +367,26 @@ line32(Imaging im, int x0, int y0, int x1, int y1, int ink) { if (dx == 0) { /* vertical */ for (i = 0; i < dy; i++) { - point32(im, x0, y0, ink); + if (should_draw_dash(i, dash_offset, dash)) { + point32(im, x0, y0, ink); + } y0 += ys; } + if (dash != NULL) { + *dash_offset += dy; + } } else if (dy == 0) { /* horizontal */ for (i = 0; i < dx; i++) { - point32(im, x0, y0, ink); + if (should_draw_dash(i, dash_offset, dash)) { + point32(im, x0, y0, ink); + } x0 += xs; } + if (dash != NULL) { + *dash_offset += dx; + } } else if (dx > dy) { /* bresenham, horizontal slope */ @@ -321,7 +396,9 @@ line32(Imaging im, int x0, int y0, int x1, int y1, int ink) { dx += dx; for (i = 0; i < n; i++) { - point32(im, x0, y0, ink); + if (should_draw_dash(i, dash_offset, dash)) { + point32(im, x0, y0, ink); + } if (e >= 0) { y0 += ys; e -= dx; @@ -329,6 +406,9 @@ line32(Imaging im, int x0, int y0, int x1, int y1, int ink) { e += dy; x0 += xs; } + if (dash != NULL) { + *dash_offset += n; + } } else { /* bresenham, vertical slope */ @@ -338,7 +418,9 @@ line32(Imaging im, int x0, int y0, int x1, int y1, int ink) { dy += dy; for (i = 0; i < n; i++) { - point32(im, x0, y0, ink); + if (should_draw_dash(i, dash_offset, dash)) { + point32(im, x0, y0, ink); + } if (e >= 0) { x0 += xs; e -= dy; @@ -346,11 +428,23 @@ line32(Imaging im, int x0, int y0, int x1, int y1, int ink) { e += dx; y0 += ys; } + if (dash != NULL) { + *dash_offset += n; + } } } static inline void -line32rgba(Imaging im, int x0, int y0, int x1, int y1, int ink) { +line32rgba( + Imaging im, + int x0, + int y0, + int x1, + int y1, + int ink, + PyObject *dash, + int *dash_offset +) { int i, n, e; int dx, dy; int xs, ys; @@ -374,16 +468,26 @@ line32rgba(Imaging im, int x0, int y0, int x1, int y1, int ink) { if (dx == 0) { /* vertical */ for (i = 0; i < dy; i++) { - point32rgba(im, x0, y0, ink); + if (should_draw_dash(i, dash_offset, dash)) { + point32rgba(im, x0, y0, ink); + } y0 += ys; } + if (dash != NULL) { + *dash_offset += dy; + } } else if (dy == 0) { /* horizontal */ for (i = 0; i < dx; i++) { - point32rgba(im, x0, y0, ink); + if (should_draw_dash(i, dash_offset, dash)) { + point32rgba(im, x0, y0, ink); + } x0 += xs; } + if (dash != NULL) { + *dash_offset += dx; + } } else if (dx > dy) { /* bresenham, horizontal slope */ @@ -393,7 +497,9 @@ line32rgba(Imaging im, int x0, int y0, int x1, int y1, int ink) { dx += dx; for (i = 0; i < n; i++) { - point32rgba(im, x0, y0, ink); + if (should_draw_dash(i, dash_offset, dash)) { + point32rgba(im, x0, y0, ink); + } if (e >= 0) { y0 += ys; e -= dx; @@ -401,6 +507,9 @@ line32rgba(Imaging im, int x0, int y0, int x1, int y1, int ink) { e += dy; x0 += xs; } + if (dash != NULL) { + *dash_offset += n; + } } else { /* bresenham, vertical slope */ @@ -410,7 +519,9 @@ line32rgba(Imaging im, int x0, int y0, int x1, int y1, int ink) { dy += dy; for (i = 0; i < n; i++) { - point32rgba(im, x0, y0, ink); + if (should_draw_dash(i, dash_offset, dash)) { + point32rgba(im, x0, y0, ink); + } if (e >= 0) { x0 += xs; e -= dy; @@ -418,6 +529,9 @@ line32rgba(Imaging im, int x0, int y0, int x1, int y1, int ink) { e += dx; y0 += ys; } + if (dash != NULL) { + *dash_offset += n; + } } } @@ -664,7 +778,16 @@ add_edge(Edge *e, int x0, int y0, int x1, int y1) { typedef struct { void (*point)(Imaging im, int x, int y, int ink); void (*hline)(Imaging im, int x0, int y0, int x1, int ink, Imaging mask); - void (*line)(Imaging im, int x0, int y0, int x1, int y1, int ink); + void (*line)( + Imaging im, + int x0, + int y0, + int x1, + int y1, + int ink, + PyObject *dash, + int *dash_offset + ); } DRAW; DRAW draw8 = {point8, hline8, line8}; @@ -701,13 +824,23 @@ ImagingDrawPoint(Imaging im, int x0, int y0, const void *ink_, int op) { } int -ImagingDrawLine(Imaging im, int x0, int y0, int x1, int y1, const void *ink_, int op) { +ImagingDrawLine( + Imaging im, + int x0, + int y0, + int x1, + int y1, + const void *ink_, + int op, + PyObject *dash, + int *dash_offset +) { DRAW *draw; INT32 ink; DRAWINIT(); - draw->line(im, x0, y0, x1, y1, ink); + draw->line(im, x0, y0, x1, y1, ink, dash, dash_offset); return 0; } @@ -816,8 +949,8 @@ ImagingDrawRectangle( for (i = 0; i < width; i++) { draw->hline(im, x0, y0 + i, x1, ink, NULL); draw->hline(im, x0, y1 - i, x1, ink, NULL); - draw->line(im, x1 - i, y0 + width, x1 - i, y1 - width + 1, ink); - draw->line(im, x0 + i, y0 + width, x0 + i, y1 - width + 1, ink); + draw->line(im, x1 - i, y0 + width, x1 - i, y1 - width + 1, ink, NULL, NULL); + draw->line(im, x0 + i, y0 + width, x0 + i, y1 - width + 1, ink, NULL, NULL); } } @@ -833,7 +966,8 @@ ImagingDrawPolygon( int fill, int width, int op, - Imaging mask + Imaging mask, + PyObject *dash ) { int i, n, x0, y0, x1, y1; DRAW *draw; @@ -883,12 +1017,22 @@ ImagingDrawPolygon( } else { /* Outline */ if (width == 1) { + int dash_offset = 0; for (i = 0; i < count - 1; i++) { draw->line( - im, xy[i * 2], xy[i * 2 + 1], xy[i * 2 + 2], xy[i * 2 + 3], ink + im, + xy[i * 2], + xy[i * 2 + 1], + xy[i * 2 + 2], + xy[i * 2 + 3], + ink, + dash, + &dash_offset ); } - draw->line(im, xy[i * 2], xy[i * 2 + 1], xy[0], xy[1], ink); + draw->line( + im, xy[i * 2], xy[i * 2 + 1], xy[0], xy[1], ink, dash, &dash_offset + ); } else { for (i = 0; i < count - 1; i++) { ImagingDrawWideLine( diff --git a/src/libImaging/Imaging.h b/src/libImaging/Imaging.h index 472bda5d0fd..a784c1d3a58 100644 --- a/src/libImaging/Imaging.h +++ b/src/libImaging/Imaging.h @@ -498,7 +498,17 @@ ImagingDrawEllipse( int op ); extern int -ImagingDrawLine(Imaging im, int x0, int y0, int x1, int y1, const void *ink, int op); +ImagingDrawLine( + Imaging im, + int x0, + int y0, + int x1, + int y1, + const void *ink, + int op, + PyObject *dash, + int *dash_offset +); extern int ImagingDrawWideLine( Imaging im, @@ -536,7 +546,8 @@ ImagingDrawPolygon( int fill, int width, int op, - Imaging mask + Imaging mask, + PyObject *dash ); extern int ImagingDrawRectangle( From 1af2a6779e2c408bf7a3fadbfed877c98f15dee7 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 25 Aug 2026 18:15:28 +1000 Subject: [PATCH 17/17] Move dash check outside for loop --- src/libImaging/Draw.c | 297 +++++++++++++++++++++++++++--------------- 1 file changed, 192 insertions(+), 105 deletions(-) diff --git a/src/libImaging/Draw.c b/src/libImaging/Draw.c index 6e75f2159f3..f5f1d93581f 100644 --- a/src/libImaging/Draw.c +++ b/src/libImaging/Draw.c @@ -207,9 +207,6 @@ hline32rgba(Imaging im, int x0, int y0, int x1, int ink, Imaging mask) { static inline int should_draw_dash(int i, int *dash_offset, PyObject *dash) { - if (dash == NULL) { - return 1; - } i += *dash_offset; int total = 0; int tuple_index = -1; @@ -265,26 +262,36 @@ line8( if (dx == 0) { /* vertical */ - for (i = 0; i < dy; i++) { - if (should_draw_dash(i, dash_offset, dash)) { - point8(im, x0, y0, ink); - } - y0 += ys; - } if (dash != NULL) { + for (i = 0; i < dy; i++) { + if (should_draw_dash(i, dash_offset, dash)) { + point8(im, x0, y0, ink); + } + y0 += ys; + } *dash_offset += dy; + } else { + for (i = 0; i < dy; i++) { + point8(im, x0, y0, ink); + y0 += ys; + } } } else if (dy == 0) { /* horizontal */ - for (i = 0; i < dx; i++) { - if (should_draw_dash(i, dash_offset, dash)) { - point8(im, x0, y0, ink); - } - x0 += xs; - } if (dash != NULL) { + for (i = 0; i < dx; i++) { + if (should_draw_dash(i, dash_offset, dash)) { + point8(im, x0, y0, ink); + } + x0 += xs; + } *dash_offset += dx; + } else { + for (i = 0; i < dx; i++) { + point8(im, x0, y0, ink); + x0 += xs; + } } } else if (dx > dy) { @@ -294,19 +301,29 @@ line8( e = dy - dx; dx += dx; - for (i = 0; i < n; i++) { - if (should_draw_dash(i, dash_offset, dash)) { - point8(im, x0, y0, ink); - } - if (e >= 0) { - y0 += ys; - e -= dx; - } - e += dy; - x0 += xs; - } if (dash != NULL) { + for (i = 0; i < n; i++) { + if (should_draw_dash(i, dash_offset, dash)) { + point8(im, x0, y0, ink); + } + if (e >= 0) { + y0 += ys; + e -= dx; + } + e += dy; + x0 += xs; + } *dash_offset += n; + } else { + for (i = 0; i < n; i++) { + point8(im, x0, y0, ink); + if (e >= 0) { + y0 += ys; + e -= dx; + } + e += dy; + x0 += xs; + } } } else { @@ -316,19 +333,29 @@ line8( e = dx - dy; dy += dy; - for (i = 0; i < n; i++) { - if (should_draw_dash(i, dash_offset, dash)) { - point8(im, x0, y0, ink); - } - if (e >= 0) { - x0 += xs; - e -= dy; - } - e += dx; - y0 += ys; - } if (dash != NULL) { + for (i = 0; i < n; i++) { + if (should_draw_dash(i, dash_offset, dash)) { + point8(im, x0, y0, ink); + } + if (e >= 0) { + x0 += xs; + e -= dy; + } + e += dx; + y0 += ys; + } *dash_offset += n; + } else { + for (i = 0; i < n; i++) { + point8(im, x0, y0, ink); + if (e >= 0) { + x0 += xs; + e -= dy; + } + e += dx; + y0 += ys; + } } } } @@ -366,26 +393,36 @@ line32( if (dx == 0) { /* vertical */ - for (i = 0; i < dy; i++) { - if (should_draw_dash(i, dash_offset, dash)) { - point32(im, x0, y0, ink); - } - y0 += ys; - } if (dash != NULL) { + for (i = 0; i < dy; i++) { + if (should_draw_dash(i, dash_offset, dash)) { + point32(im, x0, y0, ink); + } + y0 += ys; + } *dash_offset += dy; + } else { + for (i = 0; i < dy; i++) { + point32(im, x0, y0, ink); + y0 += ys; + } } } else if (dy == 0) { /* horizontal */ - for (i = 0; i < dx; i++) { - if (should_draw_dash(i, dash_offset, dash)) { - point32(im, x0, y0, ink); - } - x0 += xs; - } if (dash != NULL) { + for (i = 0; i < dx; i++) { + if (should_draw_dash(i, dash_offset, dash)) { + point32(im, x0, y0, ink); + } + x0 += xs; + } *dash_offset += dx; + } else { + for (i = 0; i < dx; i++) { + point32(im, x0, y0, ink); + x0 += xs; + } } } else if (dx > dy) { @@ -395,19 +432,29 @@ line32( e = dy - dx; dx += dx; - for (i = 0; i < n; i++) { - if (should_draw_dash(i, dash_offset, dash)) { - point32(im, x0, y0, ink); - } - if (e >= 0) { - y0 += ys; - e -= dx; - } - e += dy; - x0 += xs; - } if (dash != NULL) { + for (i = 0; i < n; i++) { + if (should_draw_dash(i, dash_offset, dash)) { + point32(im, x0, y0, ink); + } + if (e >= 0) { + y0 += ys; + e -= dx; + } + e += dy; + x0 += xs; + } *dash_offset += n; + } else { + for (i = 0; i < n; i++) { + point32(im, x0, y0, ink); + if (e >= 0) { + y0 += ys; + e -= dx; + } + e += dy; + x0 += xs; + } } } else { @@ -417,19 +464,29 @@ line32( e = dx - dy; dy += dy; - for (i = 0; i < n; i++) { - if (should_draw_dash(i, dash_offset, dash)) { - point32(im, x0, y0, ink); - } - if (e >= 0) { - x0 += xs; - e -= dy; - } - e += dx; - y0 += ys; - } if (dash != NULL) { + for (i = 0; i < n; i++) { + if (should_draw_dash(i, dash_offset, dash)) { + point32(im, x0, y0, ink); + } + if (e >= 0) { + x0 += xs; + e -= dy; + } + e += dx; + y0 += ys; + } *dash_offset += n; + } else { + for (i = 0; i < n; i++) { + point32(im, x0, y0, ink); + if (e >= 0) { + x0 += xs; + e -= dy; + } + e += dx; + y0 += ys; + } } } } @@ -467,26 +524,36 @@ line32rgba( if (dx == 0) { /* vertical */ - for (i = 0; i < dy; i++) { - if (should_draw_dash(i, dash_offset, dash)) { - point32rgba(im, x0, y0, ink); - } - y0 += ys; - } if (dash != NULL) { + for (i = 0; i < dy; i++) { + if (should_draw_dash(i, dash_offset, dash)) { + point32rgba(im, x0, y0, ink); + } + y0 += ys; + } *dash_offset += dy; + } else { + for (i = 0; i < dy; i++) { + point32rgba(im, x0, y0, ink); + y0 += ys; + } } } else if (dy == 0) { /* horizontal */ - for (i = 0; i < dx; i++) { - if (should_draw_dash(i, dash_offset, dash)) { - point32rgba(im, x0, y0, ink); - } - x0 += xs; - } if (dash != NULL) { + for (i = 0; i < dx; i++) { + if (should_draw_dash(i, dash_offset, dash)) { + point32rgba(im, x0, y0, ink); + } + x0 += xs; + } *dash_offset += dx; + } else { + for (i = 0; i < dx; i++) { + point32rgba(im, x0, y0, ink); + x0 += xs; + } } } else if (dx > dy) { @@ -496,19 +563,29 @@ line32rgba( e = dy - dx; dx += dx; - for (i = 0; i < n; i++) { - if (should_draw_dash(i, dash_offset, dash)) { - point32rgba(im, x0, y0, ink); - } - if (e >= 0) { - y0 += ys; - e -= dx; - } - e += dy; - x0 += xs; - } if (dash != NULL) { + for (i = 0; i < n; i++) { + if (should_draw_dash(i, dash_offset, dash)) { + point32rgba(im, x0, y0, ink); + } + if (e >= 0) { + y0 += ys; + e -= dx; + } + e += dy; + x0 += xs; + } *dash_offset += n; + } else { + for (i = 0; i < n; i++) { + point32rgba(im, x0, y0, ink); + if (e >= 0) { + y0 += ys; + e -= dx; + } + e += dy; + x0 += xs; + } } } else { @@ -518,19 +595,29 @@ line32rgba( e = dx - dy; dy += dy; - for (i = 0; i < n; i++) { - if (should_draw_dash(i, dash_offset, dash)) { - point32rgba(im, x0, y0, ink); - } - if (e >= 0) { - x0 += xs; - e -= dy; - } - e += dx; - y0 += ys; - } if (dash != NULL) { + for (i = 0; i < n; i++) { + if (should_draw_dash(i, dash_offset, dash)) { + point32rgba(im, x0, y0, ink); + } + if (e >= 0) { + x0 += xs; + e -= dy; + } + e += dx; + y0 += ys; + } *dash_offset += n; + } else { + for (i = 0; i < n; i++) { + point32rgba(im, x0, y0, ink); + if (e >= 0) { + x0 += xs; + e -= dy; + } + e += dx; + y0 += ys; + } } } }