From 041acf13440f9307dcc129eff21bcd065362ae91 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 25 May 2025 15:00:47 +1000 Subject: [PATCH 01/44] Clear core image if memory mapping was used for last load --- Tests/test_tiff_crashes.py | 14 ++++++++++++++ src/PIL/TiffImagePlugin.py | 5 +++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Tests/test_tiff_crashes.py b/Tests/test_tiff_crashes.py index 073e5415c..976f62384 100644 --- a/Tests/test_tiff_crashes.py +++ b/Tests/test_tiff_crashes.py @@ -52,3 +52,17 @@ def test_tiff_crashes(test_file: str) -> None: pytest.skip("test image not found") except OSError: pass + + +def test_tiff_mmap() -> None: + try: + with Image.open("Tests/images/crash_mmap.tif") as im: + im.seek(1) + im.load() + + im.seek(0) + im.load() + except FileNotFoundError: + if on_ci(): + raise + pytest.skip("test image not found") diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 88af9162e..5cbac0c26 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1217,9 +1217,10 @@ class TiffImageFile(ImageFile.ImageFile): return self._seek(frame) if self._im is not None and ( - self.im.size != self._tile_size or self.im.mode != self.mode + self.im.size != self._tile_size + or self.im.mode != self.mode + or self.readonly ): - # The core image will no longer be used self._im = None def _seek(self, frame: int) -> None: From eff667a8614ba3b567684597795924387c8cbaaa Mon Sep 17 00:00:00 2001 From: wiredfool Date: Fri, 23 May 2025 10:22:59 +0100 Subject: [PATCH 02/44] Mark the image read-only in the C layer if it's created from a read only buffer --- src/map.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/map.c b/src/map.c index c66702981..9a3144ab9 100644 --- a/src/map.c +++ b/src/map.c @@ -137,6 +137,7 @@ PyImaging_MapBuffer(PyObject *self, PyObject *args) { } } + im->read_only = view.readonly; im->destroy = mapping_destroy_buffer; Py_INCREF(target); From 2ee2a1496d9d4b2cc1f8455342d0e2f5da8f542c Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 21 May 2025 22:06:27 +1000 Subject: [PATCH 03/44] Simplified code --- src/PIL/ImageGrab.py | 5 +---- src/PIL/JpegImagePlugin.py | 9 +++------ src/libImaging/Arrow.c | 6 +++--- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index c29350b7a..d11609483 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -134,10 +134,7 @@ def grabclipboard() -> Image.Image | list[str] | None: import struct o = struct.unpack_from("I", data)[0] - if data[16] != 0: - files = data[o:].decode("utf-16le").split("\0") - else: - files = data[o:].decode("mbcs").split("\0") + files = data[o:].decode("mbcs" if data[16] == 0 else "utf-16le").split("\0") return files[: files.index("")] if isinstance(data, bytes): data = io.BytesIO(data) diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 969528841..defe9f773 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -762,8 +762,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: extra = info.get("extra", b"") MAX_BYTES_IN_MARKER = 65533 - xmp = info.get("xmp") - if xmp: + if xmp := info.get("xmp"): overhead_len = 29 # b"http://ns.adobe.com/xap/1.0/\x00" max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len if len(xmp) > max_data_bytes_in_marker: @@ -772,8 +771,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: size = o16(2 + overhead_len + len(xmp)) extra += b"\xff\xe1" + size + b"http://ns.adobe.com/xap/1.0/\x00" + xmp - icc_profile = info.get("icc_profile") - if icc_profile: + if icc_profile := info.get("icc_profile"): overhead_len = 14 # b"ICC_PROFILE\0" + o8(i) + o8(len(markers)) max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len markers = [] @@ -831,7 +829,6 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # in a shot. Guessing on the size, at im.size bytes. (raw pixel size is # channels*size, this is a value that's been used in a django patch. # https://github.com/matthewwithanm/django-imagekit/issues/50 - bufsize = 0 if optimize or progressive: # CMYK can be bigger if im.mode == "CMYK": @@ -848,7 +845,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: else: # The EXIF info needs to be written as one block, + APP1, + one spare byte. # Ensure that our buffer is big enough. Same with the icc_profile block. - bufsize = max(bufsize, len(exif) + 5, len(extra) + 1) + bufsize = max(len(exif) + 5, len(extra) + 1) ImageFile._save( im, fp, [ImageFile._Tile("jpeg", (0, 0) + im.size, 0, rawmode)], bufsize diff --git a/src/libImaging/Arrow.c b/src/libImaging/Arrow.c index 33ff2ce77..3d34076dc 100644 --- a/src/libImaging/Arrow.c +++ b/src/libImaging/Arrow.c @@ -98,7 +98,7 @@ export_imaging_schema(Imaging im, struct ArrowSchema *schema) { } /* for now, single block images */ - if (!(im->blocks_count == 0 || im->blocks_count == 1)) { + if (im->blocks_count > 1) { return IMAGING_ARROW_MEMORY_LAYOUT; } @@ -157,7 +157,7 @@ export_single_channel_array(Imaging im, struct ArrowArray *array) { int length = im->xsize * im->ysize; /* for now, single block images */ - if (!(im->blocks_count == 0 || im->blocks_count == 1)) { + if (im->blocks_count > 1) { return IMAGING_ARROW_MEMORY_LAYOUT; } @@ -200,7 +200,7 @@ export_fixed_pixel_array(Imaging im, struct ArrowArray *array) { int length = im->xsize * im->ysize; /* for now, single block images */ - if (!(im->blocks_count == 0 || im->blocks_count == 1)) { + if (im->blocks_count > 1) { return IMAGING_ARROW_MEMORY_LAYOUT; } From fcac6e78966f7cac4813731dc2303f80f844b320 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 29 May 2025 18:27:17 +1000 Subject: [PATCH 04/44] Removed hasAlpha argument --- src/libImaging/Draw.c | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/libImaging/Draw.c b/src/libImaging/Draw.c index d5aff8709..046293379 100644 --- a/src/libImaging/Draw.c +++ b/src/libImaging/Draw.c @@ -439,9 +439,7 @@ draw_horizontal_lines( * Filled polygon draw function using scan line algorithm. */ static inline int -polygon_generic( - Imaging im, int n, Edge *e, int ink, int eofill, hline_handler hline, int hasAlpha -) { +polygon_generic(Imaging im, int n, Edge *e, int ink, int eofill, hline_handler hline) { Edge **edge_table; float *xx; int edge_count = 0; @@ -461,6 +459,7 @@ polygon_generic( return -1; } + int hasAlpha = hline == hline32rgba; for (i = 0; i < n; i++) { if (ymin > e[i].ymin) { ymin = e[i].ymin; @@ -592,17 +591,17 @@ polygon_generic( static inline int polygon8(Imaging im, int n, Edge *e, int ink, int eofill) { - return polygon_generic(im, n, e, ink, eofill, hline8, 0); + return polygon_generic(im, n, e, ink, eofill, hline8); } static inline int polygon32(Imaging im, int n, Edge *e, int ink, int eofill) { - return polygon_generic(im, n, e, ink, eofill, hline32, 0); + return polygon_generic(im, n, e, ink, eofill, hline32); } static inline int polygon32rgba(Imaging im, int n, Edge *e, int ink, int eofill) { - return polygon_generic(im, n, e, ink, eofill, hline32rgba, 1); + return polygon_generic(im, n, e, ink, eofill, hline32rgba); } static inline void From 62da23bf83a568079cd514cbac6a99bf37009820 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 29 May 2025 18:22:49 +1000 Subject: [PATCH 05/44] Removed polygon from DRAW struct --- src/libImaging/Draw.c | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/src/libImaging/Draw.c b/src/libImaging/Draw.c index 046293379..4c08e9855 100644 --- a/src/libImaging/Draw.c +++ b/src/libImaging/Draw.c @@ -589,21 +589,6 @@ polygon_generic(Imaging im, int n, Edge *e, int ink, int eofill, hline_handler h return 0; } -static inline int -polygon8(Imaging im, int n, Edge *e, int ink, int eofill) { - return polygon_generic(im, n, e, ink, eofill, hline8); -} - -static inline int -polygon32(Imaging im, int n, Edge *e, int ink, int eofill) { - return polygon_generic(im, n, e, ink, eofill, hline32); -} - -static inline int -polygon32rgba(Imaging im, int n, Edge *e, int ink, int eofill) { - return polygon_generic(im, n, e, ink, eofill, hline32rgba); -} - static inline void add_edge(Edge *e, int x0, int y0, int x1, int y1) { /* printf("edge %d %d %d %d\n", x0, y0, x1, y1); */ @@ -640,12 +625,11 @@ typedef struct { void (*point)(Imaging im, int x, int y, int ink); void (*hline)(Imaging im, int x0, int y0, int x1, int ink); void (*line)(Imaging im, int x0, int y0, int x1, int y1, int ink); - int (*polygon)(Imaging im, int n, Edge *e, int ink, int eofill); } DRAW; -DRAW draw8 = {point8, hline8, line8, polygon8}; -DRAW draw32 = {point32, hline32, line32, polygon32}; -DRAW draw32rgba = {point32rgba, hline32rgba, line32rgba, polygon32rgba}; +DRAW draw8 = {point8, hline8, line8}; +DRAW draw32 = {point32, hline32, line32}; +DRAW draw32rgba = {point32rgba, hline32rgba, line32rgba}; /* -------------------------------------------------------------------- */ /* Interface */ @@ -730,7 +714,7 @@ ImagingDrawWideLine( add_edge(e + 2, vertices[2][0], vertices[2][1], vertices[3][0], vertices[3][1]); add_edge(e + 3, vertices[3][0], vertices[3][1], vertices[0][0], vertices[0][1]); - draw->polygon(im, 4, e, ink, 0); + polygon_generic(im, 4, e, ink, 0, draw->hline); } return 0; } @@ -838,7 +822,7 @@ ImagingDrawPolygon( if (xy[i * 2] != xy[0] || xy[i * 2 + 1] != xy[1]) { add_edge(&e[n++], xy[i * 2], xy[i * 2 + 1], xy[0], xy[1]); } - draw->polygon(im, n, e, ink, 0); + polygon_generic(im, n, e, ink, 0, draw->hline); free(e); } else { @@ -1988,7 +1972,7 @@ ImagingDrawOutline( DRAWINIT(); - draw->polygon(im, outline->count, outline->edges, ink, 0); + polygon_generic(im, outline->count, outline->edges, ink, 0, draw->hline); return 0; } From eb0256acc082e362b4172f3256a551a412ef4b09 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 3 Jun 2025 22:44:26 +1000 Subject: [PATCH 06/44] Fixed test --- Tests/test_deprecate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/test_deprecate.py b/Tests/test_deprecate.py index 82ff14181..88479ff0d 100644 --- a/Tests/test_deprecate.py +++ b/Tests/test_deprecate.py @@ -47,7 +47,6 @@ def test_unknown_version() -> None: ], ) def test_old_version(deprecated: str, plural: bool, expected: str) -> None: - expected = r"" with pytest.raises(RuntimeError, match=expected): _deprecate.deprecate(deprecated, 1, plural=plural) From cb077a16c80e9d23bb3976182acae7fc090aa5dc Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 4 Jun 2025 20:07:13 +1000 Subject: [PATCH 07/44] Handle UNDEFINED XMP data --- Tests/test_file_tiff.py | 24 ++++++++++++++++++++++++ src/PIL/TiffImagePlugin.py | 5 ++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index d0d394aa9..73046eb5f 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -14,6 +14,7 @@ from PIL import ( ImageFile, JpegImagePlugin, TiffImagePlugin, + TiffTags, UnidentifiedImageError, ) from PIL.TiffImagePlugin import RESOLUTION_UNIT, X_RESOLUTION, Y_RESOLUTION @@ -900,6 +901,29 @@ class TestFileTiff: assert description[0]["format"] == "image/tiff" assert description[3]["BitsPerSample"]["Seq"]["li"] == ["8", "8", "8"] + def test_getxmp_undefined(self, tmp_path: Path) -> None: + tmpfile = tmp_path / "temp.tif" + im = Image.new("L", (1, 1)) + ifd = TiffImagePlugin.ImageFileDirectory_v2() + ifd.tagtype[700] = TiffTags.UNDEFINED + with Image.open("Tests/images/lab.tif") as im_xmp: + ifd[700] = im_xmp.info["xmp"] + im.save(tmpfile, tiffinfo=ifd) + + with Image.open(tmpfile) as im_reloaded: + if ElementTree is None: + with pytest.warns( + UserWarning, + match="XMP data cannot be read without defusedxml dependency", + ): + assert im_reloaded.getxmp() == {} + else: + assert "xmp" in im_reloaded.info + xmp = im_reloaded.getxmp() + + description = xmp["xmpmeta"]["RDF"]["Description"] + assert description[0]["format"] == "image/tiff" + def test_get_photoshop_blocks(self) -> None: with Image.open("Tests/images/lab.tif") as im: assert isinstance(im, TiffImagePlugin.TiffImageFile) diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 88af9162e..22c5208e2 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1259,7 +1259,10 @@ class TiffImageFile(ImageFile.ImageFile): self.fp.seek(self._frame_pos[frame]) self.tag_v2.load(self.fp) if XMP in self.tag_v2: - self.info["xmp"] = self.tag_v2[XMP] + xmp = self.tag_v2[XMP] + if isinstance(xmp, tuple) and len(xmp) == 1: + xmp = xmp[0] + self.info["xmp"] = xmp elif "xmp" in self.info: del self.info["xmp"] self._reload_exif() From f03c23683ed83a9d8f73e73073ac28f1ab2b74ea Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 4 Jun 2025 20:08:58 +1000 Subject: [PATCH 08/44] Trim whitespace from end when parsing XMP data --- Tests/test_image.py | 2 +- src/PIL/Image.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/test_image.py b/Tests/test_image.py index 14a067127..ac358f5bf 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -989,7 +989,7 @@ class TestImage: im = Image.new("RGB", (1, 1)) im.info["xmp"] = ( b'\n' - b'\n\x00\x00' + b'\n\x00\x00 ' ) if ElementTree is None: with pytest.warns( diff --git a/src/PIL/Image.py b/src/PIL/Image.py index ed2f728aa..e03e9cc8a 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1511,7 +1511,7 @@ class Image: return {} if "xmp" not in self.info: return {} - root = ElementTree.fromstring(self.info["xmp"].rstrip(b"\x00")) + root = ElementTree.fromstring(self.info["xmp"].rstrip(b"\x00 ")) return {get_name(root.tag): get_value(root)} def getexif(self) -> Exif: From 9d5ea827e4ac401b85ec0ed61b7d2e97a101b05a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 5 Jun 2025 18:16:05 +1000 Subject: [PATCH 09/44] Call startswith once with a tuple --- setup.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index ab36c6b17..3716a7b9f 100644 --- a/setup.py +++ b/setup.py @@ -163,7 +163,7 @@ def _find_library_dirs_ldconfig() -> list[str]: args: list[str] env: dict[str, str] expr: str - if sys.platform.startswith("linux") or sys.platform.startswith("gnu"): + if sys.platform.startswith(("linux", "gnu")): if struct.calcsize("l") == 4: machine = os.uname()[4] + "-32" else: @@ -623,11 +623,7 @@ class pil_build_ext(build_ext): for extension in self.extensions: extension.extra_compile_args = ["-Wno-nullability-completeness"] - elif ( - sys.platform.startswith("linux") - or sys.platform.startswith("gnu") - or sys.platform.startswith("freebsd") - ): + elif sys.platform.startswith(("linux", "gnu", "freebsd")): for dirname in _find_library_dirs_ldconfig(): _add_directory(library_dirs, dirname) if sys.platform.startswith("linux") and os.environ.get("ANDROID_ROOT"): From f3b05d6fab2a8fb0db033fc507d0d6f19ed2330e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 7 Jun 2025 11:07:21 +1000 Subject: [PATCH 10/44] Update dependency mypy to v1.16.0 (#8991) Co-authored-by: Andrew Murray --- .ci/requirements-mypy.txt | 2 +- Tests/test_file_jpeg.py | 10 ++++++---- Tests/test_image.py | 1 + src/PIL/GifImagePlugin.py | 9 ++++++--- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt index 86ac2e0b2..a9c18ae2b 100644 --- a/.ci/requirements-mypy.txt +++ b/.ci/requirements-mypy.txt @@ -1,4 +1,4 @@ -mypy==1.15.0 +mypy==1.16.0 IceSpringPySideStubs-PyQt6 IceSpringPySideStubs-PySide6 ipython diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index b9eec591d..2827937cf 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -145,14 +145,16 @@ class TestFileJpeg: assert k > 0.9 # roundtrip, and check again im = self.roundtrip(im) - c, m, y, k = (x / 255.0 for x in im.getpixel((0, 0))) + cmyk = im.getpixel((0, 0)) + assert isinstance(cmyk, tuple) + c, m, y, k = (x / 255.0 for x in cmyk) assert c == 0.0 assert m > 0.8 assert y > 0.8 assert k == 0.0 - c, m, y, k = ( - x / 255.0 for x in im.getpixel((im.size[0] - 1, im.size[1] - 1)) - ) + cmyk = im.getpixel((im.size[0] - 1, im.size[1] - 1)) + assert isinstance(cmyk, tuple) + k = cmyk[3] / 255.0 assert k > 0.9 def test_rgb(self) -> None: diff --git a/Tests/test_image.py b/Tests/test_image.py index 14a067127..4cc841603 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -671,6 +671,7 @@ class TestImage: im_remapped = im.remap_palette(list(range(256))) assert_image_equal(im, im_remapped) assert im.palette is not None + assert im_remapped.palette is not None assert im.palette.palette == im_remapped.palette.palette # Test illegal image mode diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 4392c4cb9..c98e02f69 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -31,7 +31,7 @@ import os import subprocess from enum import IntEnum from functools import cached_property -from typing import IO, Any, Literal, NamedTuple, Union +from typing import IO, Any, Literal, NamedTuple, Union, cast from . import ( Image, @@ -350,12 +350,15 @@ class GifImageFile(ImageFile.ImageFile): if self._frame_palette: if color * 3 + 3 > len(self._frame_palette.palette): color = 0 - return tuple(self._frame_palette.palette[color * 3 : color * 3 + 3]) + return cast( + tuple[int, int, int], + tuple(self._frame_palette.palette[color * 3 : color * 3 + 3]), + ) else: return (color, color, color) self.dispose = None - self.dispose_extent = frame_dispose_extent + self.dispose_extent: tuple[int, int, int, int] | None = frame_dispose_extent if self.dispose_extent and self.disposal_method >= 2: try: if self.disposal_method == 2: From ef1f90fe1c92ec4d038ddf4d03638f467ba94181 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Mon, 9 Jun 2025 09:06:08 +1000 Subject: [PATCH 11/44] Check for equality rather than inequality Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- src/PIL/ImageGrab.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index d11609483..1eb450734 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -134,7 +134,10 @@ def grabclipboard() -> Image.Image | list[str] | None: import struct o = struct.unpack_from("I", data)[0] - files = data[o:].decode("mbcs" if data[16] == 0 else "utf-16le").split("\0") + if data[16] == 0: + files = data[o:].decode("mbcs").split("\0") + else: + files = data[o:].decode("utf-16le").split("\0") return files[: files.index("")] if isinstance(data, bytes): data = io.BytesIO(data) From 313969cf0bcf6b6185d486830478d2864eb56fe1 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 9 Jun 2025 12:21:49 +1000 Subject: [PATCH 12/44] Removed unnecessary seek --- src/PIL/PcxImagePlugin.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/PIL/PcxImagePlugin.py b/src/PIL/PcxImagePlugin.py index 299405ae0..47b6e80e2 100644 --- a/src/PIL/PcxImagePlugin.py +++ b/src/PIL/PcxImagePlugin.py @@ -66,6 +66,8 @@ class PcxImageFile(ImageFile.ImageFile): raise SyntaxError(msg) logger.debug("BBox: %s %s %s %s", *bbox) + offset = self.fp.tell() + # format version = s[1] bits = s[3] @@ -102,7 +104,6 @@ class PcxImageFile(ImageFile.ImageFile): break if mode == "P": self.palette = ImagePalette.raw("RGB", s[1:]) - self.fp.seek(128) elif version == 5 and bits == 8 and planes == 3: mode = "RGB" @@ -128,9 +129,7 @@ class PcxImageFile(ImageFile.ImageFile): bbox = (0, 0) + self.size logger.debug("size: %sx%s", *self.size) - self.tile = [ - ImageFile._Tile("pcx", bbox, self.fp.tell(), (rawmode, planes * stride)) - ] + self.tile = [ImageFile._Tile("pcx", bbox, offset, (rawmode, planes * stride))] # -------------------------------------------------------------------- From 7341e70f6be9c3e910c81f563bb7900167873c02 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 9 Jun 2025 12:20:52 +1000 Subject: [PATCH 13/44] Reduced number of bytes read for header --- src/PIL/PcxImagePlugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PIL/PcxImagePlugin.py b/src/PIL/PcxImagePlugin.py index 47b6e80e2..458d586c4 100644 --- a/src/PIL/PcxImagePlugin.py +++ b/src/PIL/PcxImagePlugin.py @@ -54,7 +54,7 @@ class PcxImageFile(ImageFile.ImageFile): # header assert self.fp is not None - s = self.fp.read(128) + s = self.fp.read(68) if not _accept(s): msg = "not a PCX file" raise SyntaxError(msg) @@ -66,7 +66,7 @@ class PcxImageFile(ImageFile.ImageFile): raise SyntaxError(msg) logger.debug("BBox: %s %s %s %s", *bbox) - offset = self.fp.tell() + offset = self.fp.tell() + 60 # format version = s[1] From 7b163cc35d3ef9bb0204613add92f05eda65ab63 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 10 Jun 2025 11:46:12 +1000 Subject: [PATCH 14/44] Use mask in C when drawing wide polygon lines (#8984) --- src/PIL/ImageDraw.py | 16 +----- src/_imaging.c | 20 +++++-- src/libImaging/Draw.c | 113 ++++++++++++++++++++++++++++----------- src/libImaging/Imaging.h | 19 ++++++- 4 files changed, 116 insertions(+), 52 deletions(-) diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index e6c7b0298..98ae67539 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -365,22 +365,10 @@ class ImageDraw: # use the fill as a mask mask = Image.new("1", self.im.size) mask_ink = self._getink(1)[0] - - fill_im = mask.copy() - draw = Draw(fill_im) + draw = Draw(mask) draw.draw.draw_polygon(xy, mask_ink, 1) - ink_im = mask.copy() - draw = Draw(ink_im) - width = width * 2 - 1 - draw.draw.draw_polygon(xy, mask_ink, 0, width) - - mask.paste(ink_im, mask=fill_im) - - im = Image.new(self.mode, self.im.size) - draw = Draw(im) - draw.draw.draw_polygon(xy, ink, 0, width) - self.im.paste(im.im, (0, 0) + im.size, mask.im) + self.draw.draw_polygon(xy, ink, 0, width * 2 - 1, mask.im) def regular_polygon( self, diff --git a/src/_imaging.c b/src/_imaging.c index 9213ba13d..2a7bc8d3f 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -3220,7 +3220,8 @@ _draw_lines(ImagingDrawObject *self, PyObject *args) { (int)p[3], &ink, width, - self->blend + self->blend, + NULL ) < 0) { free(xy); return NULL; @@ -3358,7 +3359,10 @@ _draw_polygon(ImagingDrawObject *self, PyObject *args) { int ink; int fill = 0; int width = 0; - if (!PyArg_ParseTuple(args, "Oi|ii", &data, &ink, &fill, &width)) { + ImagingObject *maskp = NULL; + if (!PyArg_ParseTuple( + args, "Oi|iiO!", &data, &ink, &fill, &width, &Imaging_Type, &maskp + )) { return NULL; } @@ -3388,8 +3392,16 @@ _draw_polygon(ImagingDrawObject *self, PyObject *args) { free(xy); - if (ImagingDrawPolygon(self->image->image, n, ixy, &ink, fill, width, self->blend) < - 0) { + if (ImagingDrawPolygon( + self->image->image, + n, + ixy, + &ink, + fill, + width, + self->blend, + maskp ? maskp->image : NULL + ) < 0) { free(ixy); return NULL; } diff --git a/src/libImaging/Draw.c b/src/libImaging/Draw.c index 4c08e9855..70f267ae4 100644 --- a/src/libImaging/Draw.c +++ b/src/libImaging/Draw.c @@ -63,7 +63,7 @@ typedef struct { } Edge; /* Type used in "polygon*" functions */ -typedef void (*hline_handler)(Imaging, int, int, int, int); +typedef void (*hline_handler)(Imaging, int, int, int, int, Imaging); static inline void point8(Imaging im, int x, int y, int ink) { @@ -103,7 +103,7 @@ point32rgba(Imaging im, int x, int y, int ink) { } static inline void -hline8(Imaging im, int x0, int y0, int x1, int ink) { +hline8(Imaging im, int x0, int y0, int x1, int ink, Imaging mask) { int pixelwidth; if (y0 >= 0 && y0 < im->ysize) { @@ -119,15 +119,30 @@ hline8(Imaging im, int x0, int y0, int x1, int ink) { } if (x0 <= x1) { pixelwidth = strncmp(im->mode, "I;16", 4) == 0 ? 2 : 1; - memset( - im->image8[y0] + x0 * pixelwidth, (UINT8)ink, (x1 - x0 + 1) * pixelwidth - ); + if (mask == NULL) { + memset( + im->image8[y0] + x0 * pixelwidth, + (UINT8)ink, + (x1 - x0 + 1) * pixelwidth + ); + } else { + UINT8 *p = im->image8[y0]; + while (x0 <= x1) { + if (mask->image8[y0][x0]) { + p[x0 * pixelwidth] = ink; + if (pixelwidth == 2) { + p[x0 * pixelwidth + 1] = ink; + } + } + x0++; + } + } } } } static inline void -hline32(Imaging im, int x0, int y0, int x1, int ink) { +hline32(Imaging im, int x0, int y0, int x1, int ink, Imaging mask) { INT32 *p; if (y0 >= 0 && y0 < im->ysize) { @@ -143,13 +158,16 @@ hline32(Imaging im, int x0, int y0, int x1, int ink) { } p = im->image32[y0]; while (x0 <= x1) { - p[x0++] = ink; + if (mask == NULL || mask->image8[y0][x0]) { + p[x0] = ink; + } + x0++; } } } static inline void -hline32rgba(Imaging im, int x0, int y0, int x1, int ink) { +hline32rgba(Imaging im, int x0, int y0, int x1, int ink, Imaging mask) { unsigned int tmp; if (y0 >= 0 && y0 < im->ysize) { @@ -167,9 +185,11 @@ hline32rgba(Imaging im, int x0, int y0, int x1, int ink) { UINT8 *out = (UINT8 *)im->image[y0] + x0 * 4; UINT8 *in = (UINT8 *)&ink; while (x0 <= x1) { - out[0] = BLEND(in[3], out[0], in[0], tmp); - out[1] = BLEND(in[3], out[1], in[1], tmp); - out[2] = BLEND(in[3], out[2], in[2], tmp); + if (mask == NULL || mask->image8[y0][x0]) { + out[0] = BLEND(in[3], out[0], in[0], tmp); + out[1] = BLEND(in[3], out[1], in[1], tmp); + out[2] = BLEND(in[3], out[2], in[2], tmp); + } x0++; out += 4; } @@ -407,7 +427,14 @@ x_cmp(const void *x0, const void *x1) { static void draw_horizontal_lines( - Imaging im, int n, Edge *e, int ink, int *x_pos, int y, hline_handler hline + Imaging im, + int n, + Edge *e, + int ink, + int *x_pos, + int y, + hline_handler hline, + Imaging mask ) { int i; for (i = 0; i < n; i++) { @@ -429,7 +456,7 @@ draw_horizontal_lines( } } - (*hline)(im, xmin, e[i].ymin, xmax, ink); + (*hline)(im, xmin, e[i].ymin, xmax, ink, mask); *x_pos = xmax + 1; } } @@ -439,7 +466,9 @@ draw_horizontal_lines( * Filled polygon draw function using scan line algorithm. */ static inline int -polygon_generic(Imaging im, int n, Edge *e, int ink, int eofill, hline_handler hline) { +polygon_generic( + Imaging im, int n, Edge *e, int ink, int eofill, hline_handler hline, Imaging mask +) { Edge **edge_table; float *xx; int edge_count = 0; @@ -469,7 +498,7 @@ polygon_generic(Imaging im, int n, Edge *e, int ink, int eofill, hline_handler h } if (e[i].ymin == e[i].ymax) { if (hasAlpha != 1) { - (*hline)(im, e[i].xmin, e[i].ymin, e[i].xmax, ink); + (*hline)(im, e[i].xmin, e[i].ymin, e[i].xmax, ink, mask); } continue; } @@ -557,7 +586,7 @@ polygon_generic(Imaging im, int n, Edge *e, int ink, int eofill, hline_handler h // Line would be before the current position continue; } - draw_horizontal_lines(im, n, e, ink, &x_pos, ymin, hline); + draw_horizontal_lines(im, n, e, ink, &x_pos, ymin, hline, mask); if (x_end < x_pos) { // Line would be before the current position continue; @@ -573,13 +602,13 @@ polygon_generic(Imaging im, int n, Edge *e, int ink, int eofill, hline_handler h continue; } } - (*hline)(im, x_start, ymin, x_end, ink); + (*hline)(im, x_start, ymin, x_end, ink, mask); x_pos = x_end + 1; } - draw_horizontal_lines(im, n, e, ink, &x_pos, ymin, hline); + draw_horizontal_lines(im, n, e, ink, &x_pos, ymin, hline, mask); } else { for (i = 1; i < j; i += 2) { - (*hline)(im, ROUND_UP(xx[i - 1]), ymin, ROUND_DOWN(xx[i]), ink); + (*hline)(im, ROUND_UP(xx[i - 1]), ymin, ROUND_DOWN(xx[i]), ink, mask); } } } @@ -623,7 +652,7 @@ 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); + 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); } DRAW; @@ -674,7 +703,15 @@ ImagingDrawLine(Imaging im, int x0, int y0, int x1, int y1, const void *ink_, in int ImagingDrawWideLine( - Imaging im, int x0, int y0, int x1, int y1, const void *ink_, int width, int op + Imaging im, + int x0, + int y0, + int x1, + int y1, + const void *ink_, + int width, + int op, + Imaging mask ) { DRAW *draw; INT32 ink; @@ -714,7 +751,7 @@ ImagingDrawWideLine( add_edge(e + 2, vertices[2][0], vertices[2][1], vertices[3][0], vertices[3][1]); add_edge(e + 3, vertices[3][0], vertices[3][1], vertices[0][0], vertices[0][1]); - polygon_generic(im, 4, e, ink, 0, draw->hline); + polygon_generic(im, 4, e, ink, 0, draw->hline, mask); } return 0; } @@ -757,7 +794,7 @@ ImagingDrawRectangle( } for (y = y0; y <= y1; y++) { - draw->hline(im, x0, y, x1, ink); + draw->hline(im, x0, y, x1, ink, NULL); } } else { @@ -766,8 +803,8 @@ ImagingDrawRectangle( width = 1; } for (i = 0; i < width; i++) { - draw->hline(im, x0, y0 + i, x1, ink); - draw->hline(im, x0, y1 - i, x1, ink); + 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); } @@ -778,7 +815,14 @@ ImagingDrawRectangle( int ImagingDrawPolygon( - Imaging im, int count, int *xy, const void *ink_, int fill, int width, int op + Imaging im, + int count, + int *xy, + const void *ink_, + int fill, + int width, + int op, + Imaging mask ) { int i, n, x0, y0, x1, y1; DRAW *draw; @@ -822,7 +866,7 @@ ImagingDrawPolygon( if (xy[i * 2] != xy[0] || xy[i * 2 + 1] != xy[1]) { add_edge(&e[n++], xy[i * 2], xy[i * 2 + 1], xy[0], xy[1]); } - polygon_generic(im, n, e, ink, 0, draw->hline); + polygon_generic(im, n, e, ink, 0, draw->hline, mask); free(e); } else { @@ -844,11 +888,12 @@ ImagingDrawPolygon( xy[i * 2 + 3], ink_, width, - op + op, + mask ); } ImagingDrawWideLine( - im, xy[i * 2], xy[i * 2 + 1], xy[0], xy[1], ink_, width, op + im, xy[i * 2], xy[i * 2 + 1], xy[0], xy[1], ink_, width, op, mask ); } } @@ -1519,7 +1564,9 @@ ellipseNew( ellipse_init(&st, a, b, width); int32_t X0, Y, X1; while (ellipse_next(&st, &X0, &Y, &X1) != -1) { - draw->hline(im, x0 + (X0 + a) / 2, y0 + (Y + b) / 2, x0 + (X1 + a) / 2, ink); + draw->hline( + im, x0 + (X0 + a) / 2, y0 + (Y + b) / 2, x0 + (X1 + a) / 2, ink, NULL + ); } return 0; } @@ -1554,7 +1601,9 @@ clipEllipseNew( int32_t X0, Y, X1; int next_code; while ((next_code = clip_ellipse_next(&st, &X0, &Y, &X1)) >= 0) { - draw->hline(im, x0 + (X0 + a) / 2, y0 + (Y + b) / 2, x0 + (X1 + a) / 2, ink); + draw->hline( + im, x0 + (X0 + a) / 2, y0 + (Y + b) / 2, x0 + (X1 + a) / 2, ink, NULL + ); } clip_ellipse_free(&st); return next_code == -1 ? 0 : -1; @@ -1972,7 +2021,7 @@ ImagingDrawOutline( DRAWINIT(); - polygon_generic(im, outline->count, outline->edges, ink, 0, draw->hline); + polygon_generic(im, outline->count, outline->edges, ink, 0, draw->hline, NULL); return 0; } diff --git a/src/libImaging/Imaging.h b/src/libImaging/Imaging.h index 234f9943c..39ecdbff6 100644 --- a/src/libImaging/Imaging.h +++ b/src/libImaging/Imaging.h @@ -510,7 +510,15 @@ extern int ImagingDrawLine(Imaging im, int x0, int y0, int x1, int y1, const void *ink, int op); extern int ImagingDrawWideLine( - Imaging im, int x0, int y0, int x1, int y1, const void *ink, int width, int op + Imaging im, + int x0, + int y0, + int x1, + int y1, + const void *ink, + int width, + int op, + Imaging mask ); extern int ImagingDrawPieslice( @@ -530,7 +538,14 @@ extern int ImagingDrawPoint(Imaging im, int x, int y, const void *ink, int op); extern int ImagingDrawPolygon( - Imaging im, int points, int *xy, const void *ink, int fill, int width, int op + Imaging im, + int points, + int *xy, + const void *ink, + int fill, + int width, + int op, + Imaging mask ); extern int ImagingDrawRectangle( From 6bd55684e0d172749a74dd2098ebc18ce5f34fdd Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 10 Jun 2025 16:00:08 +1000 Subject: [PATCH 15/44] Only accept missing tkinter when building wheels on Windows (#8981) --- Tests/check_wheel.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Tests/check_wheel.py b/Tests/check_wheel.py index 8ba40ba3f..9602410da 100644 --- a/Tests/check_wheel.py +++ b/Tests/check_wheel.py @@ -11,13 +11,14 @@ from .helper import is_pypy def test_wheel_modules() -> None: expected_modules = {"pil", "tkinter", "freetype2", "littlecms2", "webp"} - # tkinter is not available in cibuildwheel installed CPython on Windows - try: - import tkinter + if sys.platform == "win32": + # tkinter is not available in cibuildwheel installed CPython on Windows + try: + import tkinter - assert tkinter - except ImportError: - expected_modules.remove("tkinter") + assert tkinter + except ImportError: + expected_modules.remove("tkinter") assert set(features.get_supported_modules()) == expected_modules From e65e5bea45e92a118590c69c022d6e6741e3b101 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 10 Jun 2025 20:30:18 +1000 Subject: [PATCH 16/44] Start decoding with a zero-initialized array of previously seen pixels --- Tests/images/op_index.qoi | Bin 0 -> 15 bytes Tests/test_file_qoi.py | 6 ++++++ src/PIL/QoiImagePlugin.py | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 Tests/images/op_index.qoi diff --git a/Tests/images/op_index.qoi b/Tests/images/op_index.qoi new file mode 100644 index 0000000000000000000000000000000000000000..e626aafe6a433487cbacb4bdbcbbe5e66e8d07db GIT binary patch literal 15 TcmXTS&rD-rU| None: with pytest.raises(SyntaxError): QoiImagePlugin.QoiImageFile(invalid_file) + + +def test_op_index() -> None: + # QOI_OP_INDEX as the first chunk + with Image.open("Tests/images/op_index.qoi") as im: + assert im.getpixel((0, 0)) == (0, 0, 0, 0) diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index df552243e..75070abd7 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -51,7 +51,7 @@ class QoiDecoder(ImageFile.PyDecoder): assert self.fd is not None self._previously_seen_pixels = {} - self._add_to_previous_pixels(bytearray((0, 0, 0, 255))) + self._previous_pixel = bytearray((0, 0, 0, 255)) data = bytearray() bands = Image.getmodebands(self.mode) From 646885e546ecd02a8162d91b51d32eed9da67b7a Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 10 Jun 2025 21:06:28 +1000 Subject: [PATCH 17/44] Parse XMP tag bytes without decoding to string (#8960) Co-authored-by: Andrew Murray --- Tests/test_image.py | 5 +++++ src/PIL/Image.py | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Tests/test_image.py b/Tests/test_image.py index 4cc841603..512a52433 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -974,6 +974,11 @@ class TestImage: assert tag not in exif.get_ifd(0x8769) assert exif.get_ifd(0xA005) + def test_exif_from_xmp_bytes(self) -> None: + im = Image.new("RGB", (1, 1)) + im.info["xmp"] = b'\xff tiff:Orientation="2"' + assert im.getexif()[274] == 2 + def test_empty_xmp(self) -> None: with Image.open("Tests/images/hopper.gif") as im: if ElementTree is None: diff --git a/src/PIL/Image.py b/src/PIL/Image.py index ed2f728aa..216022565 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1542,10 +1542,11 @@ class Image: # XMP tags if ExifTags.Base.Orientation not in self._exif: xmp_tags = self.info.get("XML:com.adobe.xmp") + pattern: str | bytes = r'tiff:Orientation(="|>)([0-9])' if not xmp_tags and (xmp_tags := self.info.get("xmp")): - xmp_tags = xmp_tags.decode("utf-8") + pattern = rb'tiff:Orientation(="|>)([0-9])' if xmp_tags: - match = re.search(r'tiff:Orientation(="|>)([0-9])', xmp_tags) + match = re.search(pattern, xmp_tags) if match: self._exif[ExifTags.Base.Orientation] = int(match[2]) From 36cea1953231d71f1184ef1396c1f01ff11c939a Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 10 Jun 2025 21:08:29 +1000 Subject: [PATCH 18/44] Do not decode bytes in PPM error message (#8958) --- Tests/test_file_ppm.py | 7 ++++--- src/PIL/PpmImagePlugin.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index 41e2b5416..c7d1f4df4 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -288,12 +288,13 @@ def test_non_integer_token(tmp_path: Path) -> None: pass -def test_header_token_too_long(tmp_path: Path) -> None: +@pytest.mark.parametrize("data", (b"P3\x0cAAAAAAAAAA\xee", b"P6\n 01234567890")) +def test_header_token_too_long(tmp_path: Path, data: bytes) -> None: path = tmp_path / "temp.ppm" with open(path, "wb") as f: - f.write(b"P6\n 01234567890") + f.write(data) - with pytest.raises(ValueError, match="Token too long in file header: 01234567890"): + with pytest.raises(ValueError, match="Token too long in file header: "): with Image.open(path): pass diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py index 03afa2d2e..db34d107a 100644 --- a/src/PIL/PpmImagePlugin.py +++ b/src/PIL/PpmImagePlugin.py @@ -94,8 +94,8 @@ class PpmImageFile(ImageFile.ImageFile): msg = "Reached EOF while reading header" raise ValueError(msg) elif len(token) > 10: - msg = f"Token too long in file header: {token.decode()}" - raise ValueError(msg) + msg_too_long = b"Token too long in file header: %s" % token + raise ValueError(msg_too_long) return token def _open(self) -> None: From d7a45cc250f8ae35ee8095753eff0cad1c9f8216 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 10 Jun 2025 21:57:37 +1000 Subject: [PATCH 19/44] ImageFont does not handle multiline text (#9000) --- docs/reference/ImageFont.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/reference/ImageFont.rst b/docs/reference/ImageFont.rst index 8b2f92323..aac55fe6b 100644 --- a/docs/reference/ImageFont.rst +++ b/docs/reference/ImageFont.rst @@ -18,6 +18,9 @@ OpenType fonts (as well as other font formats supported by the FreeType library). For earlier versions, TrueType support is only available as part of the imToolkit package. +When measuring text sizes, this module will not break at newline characters. For +multiline text, see the :py:mod:`~PIL.ImageDraw` module. + .. warning:: To protect against potential DOS attacks when using arbitrary strings as text input, Pillow will raise a :py:exc:`ValueError` if the number of characters From 056dc89a3c85cbd6d6c960cbfc5aaa52f996bd3d Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 10 Jun 2025 22:12:40 +1000 Subject: [PATCH 20/44] Correct drawing I;16 horizontal lines (#8985) --- Tests/images/imagedraw_rectangle_I.tiff | Bin 20122 -> 20122 bytes Tests/test_imagedraw.py | 3 ++- src/libImaging/Draw.c | 34 +++++++++++++++--------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/Tests/images/imagedraw_rectangle_I.tiff b/Tests/images/imagedraw_rectangle_I.tiff index 9b9eda883a371d9cc88b4677b09d2e351c42e609..f0cb534b63e47c940ecb6c3323cb9de3dce573d4 100644 GIT binary patch literal 20122 zcmeI&F%AJy7=_V)j0hbKjY4fF8mq7hdz`h*7CbV=ls6F~a){*Rweqr`|14bRhJA-KYQ None: draw = ImageDraw.Draw(im) # Act - draw.rectangle(bbox, outline=0xFFFF) + draw.rectangle(bbox, outline=0xCDEF) # Assert + assert im.getpixel((X0, Y0)) == 0xCDEF assert_image_equal_tofile(im, "Tests/images/imagedraw_rectangle_I.tiff") diff --git a/src/libImaging/Draw.c b/src/libImaging/Draw.c index 70f267ae4..27cac687e 100644 --- a/src/libImaging/Draw.c +++ b/src/libImaging/Draw.c @@ -104,8 +104,6 @@ point32rgba(Imaging im, int x, int y, int ink) { static inline void hline8(Imaging im, int x0, int y0, int x1, int ink, Imaging mask) { - int pixelwidth; - if (y0 >= 0 && y0 < im->ysize) { if (x0 < 0) { x0 = 0; @@ -118,20 +116,30 @@ hline8(Imaging im, int x0, int y0, int x1, int ink, Imaging mask) { x1 = im->xsize - 1; } if (x0 <= x1) { - pixelwidth = strncmp(im->mode, "I;16", 4) == 0 ? 2 : 1; - if (mask == NULL) { - memset( - im->image8[y0] + x0 * pixelwidth, - (UINT8)ink, - (x1 - x0 + 1) * pixelwidth - ); + int bigendian = -1; + if (strncmp(im->mode, "I;16", 4) == 0) { + bigendian = + ( +#ifdef WORDS_BIGENDIAN + strcmp(im->mode, "I;16") == 0 || strcmp(im->mode, "I;16L") == 0 +#else + strcmp(im->mode, "I;16B") == 0 +#endif + ) + ? 1 + : 0; + } + if (mask == NULL && bigendian == -1) { + memset(im->image8[y0] + x0, (UINT8)ink, (x1 - x0 + 1)); } else { UINT8 *p = im->image8[y0]; while (x0 <= x1) { - if (mask->image8[y0][x0]) { - p[x0 * pixelwidth] = ink; - if (pixelwidth == 2) { - p[x0 * pixelwidth + 1] = ink; + if (mask == NULL || mask->image8[y0][x0]) { + if (bigendian == -1) { + p[x0] = ink; + } else { + p[x0 * 2 + (bigendian ? 1 : 0)] = ink; + p[x0 * 2 + (bigendian ? 0 : 1)] = ink >> 8; } } x0++; From 3eb893f0c16c958962758c03a597454aacac8f84 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Wed, 11 Jun 2025 20:56:28 +1000 Subject: [PATCH 21/44] Updated libjpeg-turbo to 3.1.1 (#9009) --- .github/workflows/wheels-dependencies.sh | 2 +- winbuild/build_prepare.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 1583435c1..b46811f5a 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -40,7 +40,7 @@ ARCHIVE_SDIR=pillow-depends-main FREETYPE_VERSION=2.13.3 HARFBUZZ_VERSION=11.2.1 LIBPNG_VERSION=1.6.48 -JPEGTURBO_VERSION=3.1.0 +JPEGTURBO_VERSION=3.1.1 OPENJPEG_VERSION=2.5.3 XZ_VERSION=5.8.1 TIFF_VERSION=4.7.0 diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 6e176e29c..0cc383733 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -114,7 +114,7 @@ V = { "FREETYPE": "2.13.3", "FRIBIDI": "1.0.16", "HARFBUZZ": "11.2.1", - "JPEGTURBO": "3.1.0", + "JPEGTURBO": "3.1.1", "LCMS2": "2.17", "LIBAVIF": "1.3.0", "LIBIMAGEQUANT": "4.3.4", From 8ccdc399df1254c89bdb4e8fda6d6daf98943ab6 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Wed, 11 Jun 2025 23:19:09 +1000 Subject: [PATCH 22/44] Remove padding between interleaved PCX palette data (#9005) --- Tests/images/p_4_planes.pcx | Bin 0 -> 136 bytes Tests/test_file_pcx.py | 5 +++++ src/libImaging/PcxDecode.c | 22 ++++++++++++++++------ 3 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 Tests/images/p_4_planes.pcx diff --git a/Tests/images/p_4_planes.pcx b/Tests/images/p_4_planes.pcx new file mode 100644 index 0000000000000000000000000000000000000000..8c5743a98554c89fa46188308332461a4aa87f91 GIT binary patch literal 136 ocmd;LWn^T4f)s`n7?XkFKZ1#u#lpnE2!?o7;goD(XaLIr0O6ei;s5{u literal 0 HcmV?d00001 diff --git a/Tests/test_file_pcx.py b/Tests/test_file_pcx.py index 5d7fd1c1b..2e999eff6 100644 --- a/Tests/test_file_pcx.py +++ b/Tests/test_file_pcx.py @@ -37,6 +37,11 @@ def test_sanity(tmp_path: Path) -> None: im.save(f) +def test_p_4_planes() -> None: + with Image.open("Tests/images/p_4_planes.pcx") as im: + assert im.getpixel((0, 0)) == 3 + + def test_bad_image_size() -> None: with open("Tests/images/pil184.pcx", "rb") as fp: data = fp.read() diff --git a/src/libImaging/PcxDecode.c b/src/libImaging/PcxDecode.c index 942c8dc22..a65952fb1 100644 --- a/src/libImaging/PcxDecode.c +++ b/src/libImaging/PcxDecode.c @@ -60,15 +60,25 @@ ImagingPcxDecode(Imaging im, ImagingCodecState state, UINT8 *buf, Py_ssize_t byt } if (state->x >= state->bytes) { - if (state->bytes % state->xsize && state->bytes > state->xsize) { - int bands = state->bytes / state->xsize; - int stride = state->bytes / bands; + int bands; + int xsize = 0; + int stride = 0; + if (state->bits == 2 || state->bits == 4) { + xsize = (state->xsize + 7) / 8; + bands = state->bits; + stride = state->bytes / state->bits; + } else { + xsize = state->xsize; + bands = state->bytes / state->xsize; + if (bands != 0) { + stride = state->bytes / bands; + } + } + if (stride > xsize) { int i; for (i = 1; i < bands; i++) { // note -- skipping first band memmove( - &state->buffer[i * state->xsize], - &state->buffer[i * stride], - state->xsize + &state->buffer[i * xsize], &state->buffer[i * stride], xsize ); } } From b65a7acf259682c9d02d7ab6bef57bd4ca596c74 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 11 Jun 2025 13:20:34 +0000 Subject: [PATCH 23/44] Update dependency cibuildwheel to v3 --- .ci/requirements-cibw.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index 0e314b8bf..520b6e320 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==2.23.3 +cibuildwheel==3.0.0 From d2295c0843e828816af892da53b03d1698a3a616 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 12 Jun 2025 18:53:35 +1000 Subject: [PATCH 24/44] Do not activate virtualenv --- .github/workflows/wheels-test.ps1 | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/wheels-test.ps1 b/.github/workflows/wheels-test.ps1 index a1edc14ef..256e84edf 100644 --- a/.github/workflows/wheels-test.ps1 +++ b/.github/workflows/wheels-test.ps1 @@ -9,17 +9,16 @@ if ("$venv" -like "*\cibw-run-*\pp*-win_amd64\*") { C:\vc_redist.x64.exe /install /quiet /norestart | Out-Null } $env:path += ";$pillow\winbuild\build\bin\" -& "$venv\Scripts\activate.ps1" & reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\python.exe" /v "GlobalFlag" /t REG_SZ /d "0x02000000" /f if ("$venv" -like "*\cibw-run-*-win_amd64\*") { - & python -m pip install numpy + & $venv\Scripts\python.exe -m pip install numpy } cd $pillow -& python -VV +& $venv\Scripts\python.exe -VV if (!$?) { exit $LASTEXITCODE } -& python selftest.py +& $venv\Scripts\python.exe selftest.py if (!$?) { exit $LASTEXITCODE } -& python -m pytest -vx Tests\check_wheel.py +& $venv\Scripts\python.exe -m pytest -vx Tests\check_wheel.py if (!$?) { exit $LASTEXITCODE } -& python -m pytest -vx Tests +& $venv\Scripts\python.exe -m pytest -vx Tests if (!$?) { exit $LASTEXITCODE } From b9aac77003cda8c4af13fcf7f4fd712506374951 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 12 Jun 2025 22:48:27 +1000 Subject: [PATCH 25/44] Test Python 3.14t --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 006d574f3..b4b516228 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,6 +43,7 @@ jobs: python-version: [ "pypy3.11", "pypy3.10", + "3.14t", "3.14", "3.13t", "3.13", @@ -55,6 +56,7 @@ jobs: - { python-version: "3.11", PYTHONOPTIMIZE: 1, REVERSE: "--reverse" } - { python-version: "3.10", PYTHONOPTIMIZE: 2 } # Free-threaded + - { python-version: "3.14t", disable-gil: true } - { python-version: "3.13t", disable-gil: true } # M1 only available for 3.10+ - { os: "macos-13", python-version: "3.9" } From 9bffc015e6d97151cf49e71eed31deb20548652f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 12 Jun 2025 23:52:51 +1000 Subject: [PATCH 26/44] Use pypy.exe if it exists --- .github/workflows/wheels-test.ps1 | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wheels-test.ps1 b/.github/workflows/wheels-test.ps1 index 256e84edf..54e7fbbfc 100644 --- a/.github/workflows/wheels-test.ps1 +++ b/.github/workflows/wheels-test.ps1 @@ -9,16 +9,21 @@ if ("$venv" -like "*\cibw-run-*\pp*-win_amd64\*") { C:\vc_redist.x64.exe /install /quiet /norestart | Out-Null } $env:path += ";$pillow\winbuild\build\bin\" +if (Test-Path $venv\Scripts\pypy.exe) { + $python = "pypy.exe" +} else { + $python = "python.exe" +} & reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\python.exe" /v "GlobalFlag" /t REG_SZ /d "0x02000000" /f if ("$venv" -like "*\cibw-run-*-win_amd64\*") { - & $venv\Scripts\python.exe -m pip install numpy + & $venv\Scripts\$python -m pip install numpy } cd $pillow -& $venv\Scripts\python.exe -VV +& $venv\Scripts\$python -VV if (!$?) { exit $LASTEXITCODE } -& $venv\Scripts\python.exe selftest.py +& $venv\Scripts\$python selftest.py if (!$?) { exit $LASTEXITCODE } -& $venv\Scripts\python.exe -m pytest -vx Tests\check_wheel.py +& $venv\Scripts\$python -m pytest -vx Tests\check_wheel.py if (!$?) { exit $LASTEXITCODE } -& $venv\Scripts\python.exe -m pytest -vx Tests +& $venv\Scripts\$python -m pytest -vx Tests if (!$?) { exit $LASTEXITCODE } From 4a1eea84669dec76e573db2fc25e9b0ec3ea58e3 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 9 Jun 2025 13:15:49 +0300 Subject: [PATCH 27/44] Add Python 3.14 beta wheels --- docs/releasenotes/11.3.0.rst | 56 ++++++++++++++++++++++++++++++++++++ docs/releasenotes/index.rst | 1 + tox.ini | 2 +- 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 docs/releasenotes/11.3.0.rst diff --git a/docs/releasenotes/11.3.0.rst b/docs/releasenotes/11.3.0.rst new file mode 100644 index 000000000..b0595def9 --- /dev/null +++ b/docs/releasenotes/11.3.0.rst @@ -0,0 +1,56 @@ +11.3.0 +------ + +Security +======== + +TODO +^^^^ + +TODO + +:cve:`YYYY-XXXXX`: TODO +^^^^^^^^^^^^^^^^^^^^^^^ + +TODO + +Backwards incompatible changes +============================== + +TODO +^^^^ + +Deprecations +============ + +TODO +^^^^ + +TODO + +API changes +=========== + +TODO +^^^^ + +TODO + +API additions +============= + +TODO +^^^^ + +TODO + +Other changes +============= + +Python 3.14 beta +^^^^^^^^^^^^^^^^ + +To help other projects prepare for Python 3.14, wheels are now built for the +3.14 beta as a preview. This is not official support for Python 3.14, but rather +an opportunity for you to test how Pillow works with the beta and report any +problems. diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst index 5d7b21d59..a85f1e075 100644 --- a/docs/releasenotes/index.rst +++ b/docs/releasenotes/index.rst @@ -14,6 +14,7 @@ expected to be backported to earlier versions. .. toctree:: :maxdepth: 2 + 11.3.0 11.2.1 11.1.0 11.0.0 diff --git a/tox.ini b/tox.ini index 4065245ee..967d4b537 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ requires = tox>=4.2 env_list = lint - py{py3, 313, 312, 311, 310, 39} + py{py3, 314, 313, 312, 311, 310, 39} [testenv] deps = From aca0e57126ce5938dfd3cd57eed1a668cac5abc7 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Thu, 12 Jun 2025 19:22:35 +0300 Subject: [PATCH 28/44] Add 3.14 to CI targets --- docs/installation/platform-support.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index 57a2298f8..a56f94316 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -40,12 +40,12 @@ These platforms are built and tested for every change. | macOS 13 Ventura | 3.9 | x86-64 | +----------------------------------+----------------------------+---------------------+ | macOS 14 Sonoma | 3.10, 3.11, 3.12, 3.13, | arm64 | -| | PyPy3 | | +| | 3.14, PyPy3 | | +----------------------------------+----------------------------+---------------------+ | Ubuntu Linux 22.04 LTS (Jammy) | 3.10 | x86-64 | +----------------------------------+----------------------------+---------------------+ | Ubuntu Linux 24.04 LTS (Noble) | 3.9, 3.10, 3.11, | x86-64 | -| | 3.12, 3.13, PyPy3 | | +| | 3.12, 3.13, 3.14, PyPy3 | | | +----------------------------+---------------------+ | | 3.12 | arm64v8, ppc64le, | | | | s390x | @@ -53,7 +53,7 @@ These platforms are built and tested for every change. | Windows Server 2022 | 3.9 | x86 | | +----------------------------+---------------------+ | | 3.10, 3.11, 3.12, 3.13, | x86-64 | -| | PyPy3 | | +| | 3.14, PyPy3 | | | +----------------------------+---------------------+ | | 3.12 (MinGW) | x86-64 | | +----------------------------+---------------------+ From 3841db0252cc2dfd485eae96363dc91cffd65c0c Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Fri, 13 Jun 2025 00:08:52 +0300 Subject: [PATCH 29/44] Fix: Invalid skip selector: 'pp39-*' --- .github/workflows/wheels.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 33e1976f0..72516651f 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -110,7 +110,6 @@ jobs: CIBW_MANYLINUX_PYPY_AARCH64_IMAGE: ${{ matrix.manylinux }} CIBW_MANYLINUX_PYPY_X86_64_IMAGE: ${{ matrix.manylinux }} CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux }} - CIBW_SKIP: pp39-* MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }} - uses: actions/upload-artifact@v4 @@ -188,7 +187,6 @@ jobs: CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd" CIBW_CACHE_PATH: "C:\\cibw" CIBW_ENABLE: cpython-prerelease cpython-freethreading pypy - CIBW_SKIP: pp39-* CIBW_TEST_SKIP: "*-win_arm64" CIBW_TEST_COMMAND: 'docker run --rm -v {project}:C:\pillow From a3d91cb0ce30bc5c8418956ab9dd32d1b7a13f00 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sat, 14 Jun 2025 05:21:31 +0300 Subject: [PATCH 30/44] CI: Require Python >= 3.13.5 on Windows (#9017) --- .github/workflows/test-windows.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 6b76351b0..6d8acc44f 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -35,7 +35,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["pypy3.11", "pypy3.10", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["pypy3.11", "pypy3.10", "3.10", "3.11", "3.12", ">=3.13.5", "3.14"] architecture: ["x64"] include: # Test the oldest Python on 32-bit From a219e96fd3e0d4be53b2dad9dfa08bed993c6f80 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 13 Jun 2025 21:03:08 +1000 Subject: [PATCH 31/44] Fixed warning --- Tests/test_file_ppm.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index c7d1f4df4..68f2f9468 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -294,9 +294,10 @@ def test_header_token_too_long(tmp_path: Path, data: bytes) -> None: with open(path, "wb") as f: f.write(data) - with pytest.raises(ValueError, match="Token too long in file header: "): + with pytest.raises(ValueError) as e: with Image.open(path): pass + assert "Token too long in file header: " in repr(e) def test_truncated_file(tmp_path: Path) -> None: From 5aa09cd1078440578b6cd040f86977358c7983b9 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 13 Jun 2025 14:56:18 +1000 Subject: [PATCH 32/44] Updated libpng to 1.6.49 --- .github/workflows/wheels-dependencies.sh | 2 +- winbuild/build_prepare.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index b46811f5a..996d32bc2 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -39,7 +39,7 @@ ARCHIVE_SDIR=pillow-depends-main # Package versions for fresh source builds FREETYPE_VERSION=2.13.3 HARFBUZZ_VERSION=11.2.1 -LIBPNG_VERSION=1.6.48 +LIBPNG_VERSION=1.6.49 JPEGTURBO_VERSION=3.1.1 OPENJPEG_VERSION=2.5.3 XZ_VERSION=5.8.1 diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 0cc383733..098716b60 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -118,7 +118,7 @@ V = { "LCMS2": "2.17", "LIBAVIF": "1.3.0", "LIBIMAGEQUANT": "4.3.4", - "LIBPNG": "1.6.48", + "LIBPNG": "1.6.49", "LIBWEBP": "1.5.0", "OPENJPEG": "2.5.3", "TIFF": "4.7.0", From 27ce12bb7a4b5e7d8f662d7eb3a6e39fe636b7c8 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 14 Jun 2025 16:44:42 +1000 Subject: [PATCH 33/44] Added release notes for #8969 --- docs/releasenotes/11.3.0.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/releasenotes/11.3.0.rst b/docs/releasenotes/11.3.0.rst index b0595def9..bf9506a3b 100644 --- a/docs/releasenotes/11.3.0.rst +++ b/docs/releasenotes/11.3.0.rst @@ -47,6 +47,13 @@ TODO Other changes ============= +Do not build against libavif < 1 +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Pillow only supports libavif 1.0.0 or later. In order to prevent errors when building +from source, if a user happens to have an earlier libavif on their system, Pillow will +now ignore it. + Python 3.14 beta ^^^^^^^^^^^^^^^^ From 3ac1edf6dafddd26ecfae1dbf041e678d4eda97c Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 14 Jun 2025 17:13:02 +1000 Subject: [PATCH 34/44] Added release notes for #8912 --- docs/releasenotes/11.3.0.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/releasenotes/11.3.0.rst b/docs/releasenotes/11.3.0.rst index bf9506a3b..ea22a9c8e 100644 --- a/docs/releasenotes/11.3.0.rst +++ b/docs/releasenotes/11.3.0.rst @@ -47,6 +47,12 @@ TODO Other changes ============= +Support using grim with ImageGrab on Linux +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:meth:`~PIL.ImageGrab.grab` is now able to use ``gnome-screenshot``, ``grim`` or +``spectacle`` on Linux in order to take a snapshot of the screen. + Do not build against libavif < 1 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From 59667bbec54c7e37887ba1dfd0a3389c2d5bc413 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 14 Jun 2025 18:39:30 +1000 Subject: [PATCH 35/44] Use *_tofile helpers --- Tests/test_file_blp.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Tests/test_file_blp.py b/Tests/test_file_blp.py index 9f50df22d..f64a9d420 100644 --- a/Tests/test_file_blp.py +++ b/Tests/test_file_blp.py @@ -7,9 +7,8 @@ import pytest from PIL import BlpImagePlugin, Image from .helper import ( - assert_image_equal, assert_image_equal_tofile, - assert_image_similar, + assert_image_similar_tofile, hopper, ) @@ -52,15 +51,13 @@ def test_save(tmp_path: Path) -> None: im = hopper("P") im.save(f, blp_version=version) - with Image.open(f) as reloaded: - assert_image_equal(im.convert("RGB"), reloaded) + assert_image_equal_tofile(im.convert("RGB"), f) with Image.open("Tests/images/transparent.png") as im: f = tmp_path / "temp.blp" im.convert("P").save(f, blp_version=version) - with Image.open(f) as reloaded: - assert_image_similar(im, reloaded, 8) + assert_image_similar_tofile(im, f, 8) im = hopper() with pytest.raises(ValueError): From ce8083e0d871899294142e09c88d249409334aaf Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 14 Jun 2025 18:40:03 +1000 Subject: [PATCH 36/44] Match error message --- Tests/test_file_blp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_file_blp.py b/Tests/test_file_blp.py index f64a9d420..5f6b263a1 100644 --- a/Tests/test_file_blp.py +++ b/Tests/test_file_blp.py @@ -60,7 +60,7 @@ def test_save(tmp_path: Path) -> None: assert_image_similar_tofile(im, f, 8) im = hopper() - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Unsupported BLP image mode"): im.save(f) From cb433ad00ad4ad6eeaed8e70c191ea94e8087298 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 16 Jun 2025 08:15:08 +1000 Subject: [PATCH 37/44] Replaced ImagingError_Clear with PyErr_Clear --- src/_imaging.c | 5 ----- src/libImaging/Imaging.h | 2 -- src/libImaging/Storage.c | 2 +- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/_imaging.c b/src/_imaging.c index 2a7bc8d3f..2e8c8b0ad 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -369,11 +369,6 @@ ImagingError_ValueError(const char *message) { return NULL; } -void -ImagingError_Clear(void) { - PyErr_Clear(); -} - /* -------------------------------------------------------------------- */ /* HELPERS */ /* -------------------------------------------------------------------- */ diff --git a/src/libImaging/Imaging.h b/src/libImaging/Imaging.h index 39ecdbff6..29e21c551 100644 --- a/src/libImaging/Imaging.h +++ b/src/libImaging/Imaging.h @@ -280,8 +280,6 @@ extern void * ImagingError_Mismatch(void); /* maps to ValueError by default */ extern void * ImagingError_ValueError(const char *message); -extern void -ImagingError_Clear(void); /* Transform callbacks */ /* ------------------- */ diff --git a/src/libImaging/Storage.c b/src/libImaging/Storage.c index 11d6c06cc..6fe26e1bd 100644 --- a/src/libImaging/Storage.c +++ b/src/libImaging/Storage.c @@ -645,7 +645,7 @@ ImagingNewInternal(const char *mode, int xsize, int ysize, int dirty) { return im; } - ImagingError_Clear(); + PyErr_Clear(); // Try to allocate the image once more with smallest possible block size MUTEX_LOCK(&ImagingDefaultArena.mutex); From 8309962926f8e4f77c9899c4c5b763e9f5966311 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 16 Jun 2025 08:19:27 +1000 Subject: [PATCH 38/44] Replaced ImagingError_OSError with PyErr_SetString --- src/_imaging.c | 6 ------ src/libImaging/File.c | 2 +- src/libImaging/Imaging.h | 2 -- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/_imaging.c b/src/_imaging.c index 2e8c8b0ad..6241dc3ca 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -338,12 +338,6 @@ static const char *no_palette = "image has no palette"; static const char *readonly = "image is readonly"; /* static const char* no_content = "image has no content"; */ -void * -ImagingError_OSError(void) { - PyErr_SetString(PyExc_OSError, "error when accessing file"); - return NULL; -} - void * ImagingError_MemoryError(void) { return PyErr_NoMemory(); diff --git a/src/libImaging/File.c b/src/libImaging/File.c index 76d0abccc..901fe83ad 100644 --- a/src/libImaging/File.c +++ b/src/libImaging/File.c @@ -54,7 +54,7 @@ ImagingSavePPM(Imaging im, const char *outfile) { fp = fopen(outfile, "wb"); if (!fp) { - (void)ImagingError_OSError(); + PyErr_SetString(PyExc_OSError, "error when accessing file"); return 0; } diff --git a/src/libImaging/Imaging.h b/src/libImaging/Imaging.h index 29e21c551..bfe67d462 100644 --- a/src/libImaging/Imaging.h +++ b/src/libImaging/Imaging.h @@ -270,8 +270,6 @@ ImagingSectionLeave(ImagingSectionCookie *cookie); /* Exceptions */ /* ---------- */ -extern void * -ImagingError_OSError(void); extern void * ImagingError_MemoryError(void); extern void * From c19afb94301de3980ea0a6fd7c26aa9ab3c05065 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Mon, 16 Jun 2025 20:05:34 +1000 Subject: [PATCH 39/44] Use names Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- docs/releasenotes/11.3.0.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/releasenotes/11.3.0.rst b/docs/releasenotes/11.3.0.rst index ea22a9c8e..45dff04de 100644 --- a/docs/releasenotes/11.3.0.rst +++ b/docs/releasenotes/11.3.0.rst @@ -50,8 +50,8 @@ Other changes Support using grim with ImageGrab on Linux ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -:py:meth:`~PIL.ImageGrab.grab` is now able to use ``gnome-screenshot``, ``grim`` or -``spectacle`` on Linux in order to take a snapshot of the screen. +:py:meth:`~PIL.ImageGrab.grab` is now able to use GNOME Screenshot, grim or +Spectacle on Linux in order to take a snapshot of the screen. Do not build against libavif < 1 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From 7b5e11deb7441cab16df247f1f0890cd0d303372 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 16 Jun 2025 20:06:53 +1000 Subject: [PATCH 40/44] Updated heading --- docs/releasenotes/11.3.0.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/releasenotes/11.3.0.rst b/docs/releasenotes/11.3.0.rst index 45dff04de..ba091fa2c 100644 --- a/docs/releasenotes/11.3.0.rst +++ b/docs/releasenotes/11.3.0.rst @@ -47,11 +47,11 @@ TODO Other changes ============= -Support using grim with ImageGrab on Linux -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Support using more screenshot utilities with ImageGrab on Linux +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -:py:meth:`~PIL.ImageGrab.grab` is now able to use GNOME Screenshot, grim or -Spectacle on Linux in order to take a snapshot of the screen. +:py:meth:`~PIL.ImageGrab.grab` is now able to use GNOME Screenshot, grim or Spectacle +on Linux in order to take a snapshot of the screen. Do not build against libavif < 1 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From d23d56e195d34734b42452995682e4a9649e5332 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 17 Jun 2025 23:10:15 +1000 Subject: [PATCH 41/44] Deprecate saving I mode images as PNG --- Tests/test_file_png.py | 14 ++++++++++++-- docs/deprecations.rst | 14 ++++++++++++++ docs/releasenotes/11.3.0.rst | 13 ++++++++++--- src/PIL/PngImagePlugin.py | 3 +++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 0f0886ab8..15f67385a 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -100,11 +100,11 @@ class TestFilePng: assert im.format == "PNG" assert im.get_format_mimetype() == "image/png" - for mode in ["1", "L", "P", "RGB", "I", "I;16", "I;16B"]: + for mode in ["1", "L", "P", "RGB", "I;16", "I;16B"]: im = hopper(mode) im.save(test_file) with Image.open(test_file) as reloaded: - if mode in ("I", "I;16B"): + if mode == "I;16B": reloaded = reloaded.convert(mode) assert_image_equal(reloaded, im) @@ -801,6 +801,16 @@ class TestFilePng: with Image.open("Tests/images/truncated_end_chunk.png") as im: assert_image_equal_tofile(im, "Tests/images/hopper.png") + def test_deprecation(self, tmp_path: Path) -> None: + test_file = tmp_path / "out.png" + + im = hopper("I") + with pytest.warns(DeprecationWarning): + im.save(test_file) + + with Image.open(test_file) as reloaded: + assert_image_equal(im, reloaded.convert("I")) + @pytest.mark.skipif(is_win32(), reason="Requires Unix or macOS") @skip_unless_feature("zlib") diff --git a/docs/deprecations.rst b/docs/deprecations.rst index 0490ba439..a36eb4aa7 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -193,6 +193,20 @@ Image.Image.get_child_images() method uses an image's file pointer, and so child images could only be retrieved from an :py:class:`PIL.ImageFile.ImageFile` instance. +Saving I mode images as PNG +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 11.3.0 + +In order to fit the 32 bits of I mode images into PNG, when PNG images can only contain +at most 16 bits for a channel, Pillow has been clipping the values. Rather than quietly +changing the data, this is now deprecated. Instead, the image can be converted to +another mode before saving:: + + from PIL import Image + im = Image.new("I", (1, 1)) + im.convert("I;16").save("out.png") + Removed features ---------------- diff --git a/docs/releasenotes/11.3.0.rst b/docs/releasenotes/11.3.0.rst index ba091fa2c..5dd151bf3 100644 --- a/docs/releasenotes/11.3.0.rst +++ b/docs/releasenotes/11.3.0.rst @@ -23,10 +23,17 @@ TODO Deprecations ============ -TODO -^^^^ +Saving I mode images as PNG +^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +In order to fit the 32 bits of I mode images into PNG, when PNG images can only contain +at most 16 bits for a channel, Pillow has been clipping the values. Rather than quietly +changing the data, this is now deprecated. Instead, the image can be converted to +another mode before saving:: + + from PIL import Image + im = Image.new("I", (1, 1)) + im.convert("I;16").save("out.png") API changes =========== diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index f3815a122..7999381a6 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -48,6 +48,7 @@ from ._binary import i32be as i32 from ._binary import o8 from ._binary import o16be as o16 from ._binary import o32be as o32 +from ._deprecate import deprecate from ._util import DeferredError TYPE_CHECKING = False @@ -1368,6 +1369,8 @@ def _save( except KeyError as e: msg = f"cannot write mode {mode} as PNG" raise OSError(msg) from e + if outmode == "I": + deprecate("Saving I mode images as PNG", 13) # # write minimal PNG file From 79e0b0b6ada86f16bf7a8cf1c878756225d85d44 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 18 Jun 2025 22:19:20 +1000 Subject: [PATCH 42/44] Allow for custom stacklevel in deprecations --- src/PIL/PngImagePlugin.py | 2 +- src/PIL/_deprecate.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 7999381a6..1b9a89aef 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1370,7 +1370,7 @@ def _save( msg = f"cannot write mode {mode} as PNG" raise OSError(msg) from e if outmode == "I": - deprecate("Saving I mode images as PNG", 13) + deprecate("Saving I mode images as PNG", 13, stacklevel=4) # # write minimal PNG file diff --git a/src/PIL/_deprecate.py b/src/PIL/_deprecate.py index 9f9d8bbc9..170d44490 100644 --- a/src/PIL/_deprecate.py +++ b/src/PIL/_deprecate.py @@ -12,6 +12,7 @@ def deprecate( *, action: str | None = None, plural: bool = False, + stacklevel: int = 3, ) -> None: """ Deprecations helper. @@ -67,5 +68,5 @@ def deprecate( warnings.warn( f"{deprecated} {is_} deprecated and will be removed in {removed}{action}", DeprecationWarning, - stacklevel=3, + stacklevel=stacklevel, ) From 92de1db067112d5b15c034036b2216009822a3b0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 11:12:40 +1000 Subject: [PATCH 43/44] Update dependency mypy to v1.16.1 (#9026) --- .ci/requirements-mypy.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt index a9c18ae2b..44b5badab 100644 --- a/.ci/requirements-mypy.txt +++ b/.ci/requirements-mypy.txt @@ -1,4 +1,4 @@ -mypy==1.16.0 +mypy==1.16.1 IceSpringPySideStubs-PyQt6 IceSpringPySideStubs-PySide6 ipython From ef0bab0c6579fd00987740a6a327603ff3886a38 Mon Sep 17 00:00:00 2001 From: thisismypassport <109758321+thisismypassport@users.noreply.github.com> Date: Thu, 19 Jun 2025 11:16:26 +0300 Subject: [PATCH 44/44] Support writing QOI images (#9007) Co-authored-by: Andrew Murray --- Tests/test_file_qoi.py | 23 +++++- docs/handbook/image-file-formats.rst | 29 +++++-- docs/releasenotes/11.3.0.rst | 7 ++ src/PIL/QoiImagePlugin.py | 119 +++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 10 deletions(-) diff --git a/Tests/test_file_qoi.py b/Tests/test_file_qoi.py index 7ce1da209..b9becb24f 100644 --- a/Tests/test_file_qoi.py +++ b/Tests/test_file_qoi.py @@ -1,10 +1,12 @@ from __future__ import annotations +from pathlib import Path + import pytest from PIL import Image, QoiImagePlugin -from .helper import assert_image_equal_tofile +from .helper import assert_image_equal_tofile, hopper def test_sanity() -> None: @@ -34,3 +36,22 @@ def test_op_index() -> None: # QOI_OP_INDEX as the first chunk with Image.open("Tests/images/op_index.qoi") as im: assert im.getpixel((0, 0)) == (0, 0, 0, 0) + + +def test_save(tmp_path: Path) -> None: + f = tmp_path / "temp.qoi" + + im = hopper() + im.save(f, colorspace="sRGB") + + assert_image_equal_tofile(im, f) + + for path in ("Tests/images/default_font.png", "Tests/images/pil123rgba.png"): + with Image.open(path) as im: + im.save(f) + + assert_image_equal_tofile(im, f) + + im = hopper("P") + with pytest.raises(ValueError, match="Unsupported QOI image mode"): + im.save(f) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 5ca549c37..a15e84574 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -1082,6 +1082,26 @@ Pillow reads and writes PBM, PGM, PPM and PNM files containing ``1``, ``L``, ``I Since Pillow 9.2.0, "plain" (P1 to P3) formats can be read as well. +QOI +^^^ + +.. versionadded:: 9.5.0 + +Pillow reads and writes images in Quite OK Image format using a Python codec. If you +wish to write code specifically for this format, :pypi:`qoi` is an alternative library +that uses C to decode the image and interfaces with NumPy. + +.. _qoi-saving: + +Saving +~~~~~~ + +The :py:meth:`~PIL.Image.Image.save` method can take the following keyword arguments: + +**colorspace** + If set to "sRGB", the colorspace will be written as sRGB with linear alpha, instead + of all channels being linear. + SGI ^^^ @@ -1578,15 +1598,6 @@ PSD Pillow identifies and reads PSD files written by Adobe Photoshop 2.5 and 3.0. -QOI -^^^ - -.. versionadded:: 9.5.0 - -Pillow reads images in Quite OK Image format using a Python decoder. If you wish to -write code specifically for this format, :pypi:`qoi` is an alternative library that -uses C to decode the image and interfaces with NumPy. - SUN ^^^ diff --git a/docs/releasenotes/11.3.0.rst b/docs/releasenotes/11.3.0.rst index ba091fa2c..6bd4f7481 100644 --- a/docs/releasenotes/11.3.0.rst +++ b/docs/releasenotes/11.3.0.rst @@ -47,6 +47,13 @@ TODO Other changes ============= +Added QOI saving +^^^^^^^^^^^^^^^^ + +Support has been added for saving QOI images. ``colorspace`` can be used to specify the +colorspace as sRGB with linear alpha, e.g. ``im.save("out.qoi", colorspace="sRGB")``. +By default, all channels will be linear. + Support using more screenshot utilities with ImageGrab on Linux ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index 75070abd7..dba5d809f 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -8,9 +8,12 @@ from __future__ import annotations import os +from typing import IO from . import Image, ImageFile from ._binary import i32be as i32 +from ._binary import o8 +from ._binary import o32be as o32 def _accept(prefix: bytes) -> bool: @@ -110,6 +113,122 @@ class QoiDecoder(ImageFile.PyDecoder): return -1, 0 +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if im.mode == "RGB": + channels = 3 + elif im.mode == "RGBA": + channels = 4 + else: + msg = "Unsupported QOI image mode" + raise ValueError(msg) + + colorspace = 0 if im.encoderinfo.get("colorspace") == "sRGB" else 1 + + fp.write(b"qoif") + fp.write(o32(im.size[0])) + fp.write(o32(im.size[1])) + fp.write(o8(channels)) + fp.write(o8(colorspace)) + + ImageFile._save(im, fp, [ImageFile._Tile("qoi", (0, 0) + im.size)]) + + +class QoiEncoder(ImageFile.PyEncoder): + _pushes_fd = True + _previous_pixel: tuple[int, int, int, int] | None = None + _previously_seen_pixels: dict[int, tuple[int, int, int, int]] = {} + _run = 0 + + def _write_run(self) -> bytes: + data = o8(0b11000000 | (self._run - 1)) # QOI_OP_RUN + self._run = 0 + return data + + def _delta(self, left: int, right: int) -> int: + result = (left - right) & 255 + if result >= 128: + result -= 256 + return result + + def encode(self, bufsize: int) -> tuple[int, int, bytes]: + assert self.im is not None + + self._previously_seen_pixels = {0: (0, 0, 0, 0)} + self._previous_pixel = (0, 0, 0, 255) + + data = bytearray() + w, h = self.im.size + bands = Image.getmodebands(self.mode) + + for y in range(h): + for x in range(w): + pixel = self.im.getpixel((x, y)) + if bands == 3: + pixel = (*pixel, 255) + + if pixel == self._previous_pixel: + self._run += 1 + if self._run == 62: + data += self._write_run() + else: + if self._run: + data += self._write_run() + + r, g, b, a = pixel + hash_value = (r * 3 + g * 5 + b * 7 + a * 11) % 64 + if self._previously_seen_pixels.get(hash_value) == pixel: + data += o8(hash_value) # QOI_OP_INDEX + elif self._previous_pixel: + self._previously_seen_pixels[hash_value] = pixel + + prev_r, prev_g, prev_b, prev_a = self._previous_pixel + if prev_a == a: + delta_r = self._delta(r, prev_r) + delta_g = self._delta(g, prev_g) + delta_b = self._delta(b, prev_b) + + if ( + -2 <= delta_r < 2 + and -2 <= delta_g < 2 + and -2 <= delta_b < 2 + ): + data += o8( + 0b01000000 + | (delta_r + 2) << 4 + | (delta_g + 2) << 2 + | (delta_b + 2) + ) # QOI_OP_DIFF + else: + delta_gr = self._delta(delta_r, delta_g) + delta_gb = self._delta(delta_b, delta_g) + if ( + -8 <= delta_gr < 8 + and -32 <= delta_g < 32 + and -8 <= delta_gb < 8 + ): + data += o8( + 0b10000000 | (delta_g + 32) + ) # QOI_OP_LUMA + data += o8((delta_gr + 8) << 4 | (delta_gb + 8)) + else: + data += o8(0b11111110) # QOI_OP_RGB + data += bytes(pixel[:3]) + else: + data += o8(0b11111111) # QOI_OP_RGBA + data += bytes(pixel) + + self._previous_pixel = pixel + + if self._run: + data += self._write_run() + data += bytes((0, 0, 0, 0, 0, 0, 0, 1)) # padding + + return len(data), 0, data + + Image.register_open(QoiImageFile.format, QoiImageFile, _accept) Image.register_decoder("qoi", QoiDecoder) Image.register_extension(QoiImageFile.format, ".qoi") + +Image.register_save(QoiImageFile.format, _save) +Image.register_encoder("qoi", QoiEncoder)