From 349cc44fd47427a7cefbf5444dcd4010c8723360 Mon Sep 17 00:00:00 2001 From: Stefan <96178532+stefan6419846@users.noreply.github.com> Date: Wed, 7 May 2025 17:21:22 +0200 Subject: [PATCH 01/43] Add Apache-2.0 notice to IcoImagePlugin Closes #8946. --- src/PIL/IcoImagePlugin.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index 55c57f203..8ddb94b37 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -17,6 +17,20 @@ # . # https://code.google.com/archive/p/casadebender/wikis/Win32IconImagePlugin.wiki # +# Copyright 2008 Bryan Davis +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + # Icon format references: # * https://en.wikipedia.org/wiki/ICO_(file_format) # * https://msdn.microsoft.com/en-us/library/ms997538.aspx From a666057989efc669909e85754943f2734c5f2055 Mon Sep 17 00:00:00 2001 From: Stefan <96178532+stefan6419846@users.noreply.github.com> Date: Wed, 21 May 2025 15:40:16 +0200 Subject: [PATCH 02/43] HTTP -> HTTPS Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- src/PIL/IcoImagePlugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index 8ddb94b37..151eef2c9 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -23,7 +23,7 @@ # not use this file except in compliance with the License. You may obtain # a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, From 49efe40f28ce0413ced31d8dcc0c707b0134d6bf Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 30 Jun 2025 22:19:14 +1000 Subject: [PATCH 03/43] Escape period --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d5fd964f1..c5276723a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -48,7 +48,7 @@ repos: - id: end-of-file-fixer exclude: ^Tests/images/|\.patch$ - id: trailing-whitespace - exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/|\.patch$ + exclude: ^\.github/.*TEMPLATE|^Tests/(fonts|images)/|\.patch$ - repo: https://github.com/python-jsonschema/check-jsonschema rev: 0.33.1 From 5ce88dbe53a3d46e20d464fbd6ef06031645bfda Mon Sep 17 00:00:00 2001 From: GUO YANKE Date: Mon, 7 Jul 2025 13:57:11 +0800 Subject: [PATCH 04/43] feat(ImageGrab): enhance grab function to support window-based screenshot capturing on macOS --- src/PIL/ImageGrab.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index 1eb450734..ba2c9b141 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -43,7 +43,10 @@ def grab( fh, filepath = tempfile.mkstemp(".png") os.close(fh) args = ["screencapture"] - if bbox: + if window: + args += ["-l", str(window)] + # -R is not working with -l + if bbox and not window: left, top, right, bottom = bbox args += ["-R", f"{left},{top},{right-left},{bottom-top}"] subprocess.call(args + ["-x", filepath]) @@ -51,9 +54,16 @@ def grab( im.load() os.unlink(filepath) if bbox: - im_resized = im.resize((right - left, bottom - top)) - im.close() - return im_resized + # manual crop for windowed mode + if window: + left, top, right, bottom = bbox + im_cropped = im.crop((left, top, right, bottom)) + im.close() + return im_cropped + else: + im_resized = im.resize((right - left, bottom - top)) + im.close() + return im_resized return im elif sys.platform == "win32": if window is not None: From 1f7e9c3b51db100de0164eab45ca16ec4e24bd78 Mon Sep 17 00:00:00 2001 From: Yan-Ke Guo Date: Mon, 7 Jul 2025 16:29:38 +0800 Subject: [PATCH 05/43] Apply suggestions from code review Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- src/PIL/ImageGrab.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index ba2c9b141..c18874581 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -56,8 +56,7 @@ def grab( if bbox: # manual crop for windowed mode if window: - left, top, right, bottom = bbox - im_cropped = im.crop((left, top, right, bottom)) + im_cropped = im.crop(bbox) im.close() return im_cropped else: From 7eaac3fcf0cd0fa54ba91784727f1d1a7654b31b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 7 Jul 2025 18:13:07 +1000 Subject: [PATCH 06/43] Updated documentation --- docs/reference/ImageGrab.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/ImageGrab.rst b/docs/reference/ImageGrab.rst index f6a2ec5bc..25afc9926 100644 --- a/docs/reference/ImageGrab.rst +++ b/docs/reference/ImageGrab.rst @@ -42,9 +42,9 @@ or the clipboard to a PIL image memory. .. versionadded:: 7.1.0 :param window: - HWND, to capture a single window. Windows only. + Capture a single window. On Windows, this is a HWND. On macOS, it uses windowid. - .. versionadded:: 11.2.1 + .. versionadded:: 11.2.1 (Windows), 12.0.0 (macOS) :return: An image .. py:function:: grabclipboard() From 79914ec8a57e1beeb2a5c62809044c116828962b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 9 Jul 2025 20:00:20 +1000 Subject: [PATCH 07/43] Check for scaling in macOS windows --- src/PIL/ImageGrab.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index c18874581..b82a2ff3a 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -45,8 +45,7 @@ def grab( args = ["screencapture"] if window: args += ["-l", str(window)] - # -R is not working with -l - if bbox and not window: + elif bbox: left, top, right, bottom = bbox args += ["-R", f"{left},{top},{right-left},{bottom-top}"] subprocess.call(args + ["-x", filepath]) @@ -54,9 +53,29 @@ def grab( im.load() os.unlink(filepath) if bbox: - # manual crop for windowed mode if window: - im_cropped = im.crop(bbox) + # Determine if the window was in retina mode or not + # by capturing it without the shadow, + # and checking how different the width is + fh, filepath = tempfile.mkstemp(".png") + os.close(fh) + subprocess.call( + ["screencapture", "-l", str(window), "-o", "-x", filepath] + ) + with Image.open(filepath) as im_no_shadow: + retina = im.width - im_no_shadow.width > 100 + os.unlink(filepath) + + # Since screencapture's -R does not work with -l, + # crop the image manually + if retina: + left, top, right, bottom = bbox + im_cropped = im.resize( + (right - left, bottom - top), + box=tuple(coord * 2 for coord in bbox), + ) + else: + im_cropped = im.crop(bbox) im.close() return im_cropped else: From 53302c2281a9576acabf5815f3eda1f48f253cf0 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 16 Sep 2025 19:43:03 +1000 Subject: [PATCH 08/43] Split versionadded info Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- docs/reference/ImageGrab.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/reference/ImageGrab.rst b/docs/reference/ImageGrab.rst index 25afc9926..00d7f8e3c 100644 --- a/docs/reference/ImageGrab.rst +++ b/docs/reference/ImageGrab.rst @@ -44,7 +44,8 @@ or the clipboard to a PIL image memory. :param window: Capture a single window. On Windows, this is a HWND. On macOS, it uses windowid. - .. versionadded:: 11.2.1 (Windows), 12.0.0 (macOS) + .. versionadded:: 11.2.1 Windows support + .. versionadded:: 12.0.0 macOS support :return: An image .. py:function:: grabclipboard() From ca3528f46eacb005d3875410655b6ae9dc91c45c Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 16 Sep 2025 21:43:24 +1000 Subject: [PATCH 09/43] Document that macOS window value is a CGWindowID --- docs/reference/ImageGrab.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/reference/ImageGrab.rst b/docs/reference/ImageGrab.rst index c6671ca71..e7dd41de1 100644 --- a/docs/reference/ImageGrab.rst +++ b/docs/reference/ImageGrab.rst @@ -44,7 +44,8 @@ or the clipboard to a PIL image memory. .. versionadded:: 7.1.0 :param window: - Capture a single window. On Windows, this is a HWND. On macOS, it uses windowid. + Capture a single window. On Windows, this is a HWND. On macOS, this is a + CGWindowID. .. versionadded:: 11.2.1 Windows support .. versionadded:: 12.0.0 macOS support From 933df2450d9b415eeed656525d4d69c347fa1c7e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 16 Oct 2025 07:21:15 +1100 Subject: [PATCH 10/43] Reapply "Use macos-latest for iOS arm64 simulator" This reverts commit 592b2f820aa1f75f8ae8bf4f30e1b4bc62023535. --- .github/workflows/wheels.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 6dc8db7e9..21ea79553 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -100,7 +100,7 @@ jobs: cibw_arch: arm64_iphoneos - name: "iOS arm64 simulator" platform: ios - os: macos-14 + os: macos-latest cibw_arch: arm64_iphonesimulator - name: "iOS x86_64 simulator" platform: ios From ae43b36030a3d8dca20bb908bf158dcadc2cf35f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 16 Oct 2025 20:55:56 +1100 Subject: [PATCH 11/43] Simplified code now that I;16* modes are the only IMAGING_TYPE_SPECIAL --- src/_imaging.c | 31 +++++-------------------------- src/libImaging/Geometry.c | 9 +-------- 2 files changed, 6 insertions(+), 34 deletions(-) diff --git a/src/_imaging.c b/src/_imaging.c index 41af72568..f6be4a901 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -543,12 +543,7 @@ getpixel(Imaging im, ImagingAccess access, int x, int y) { case IMAGING_TYPE_FLOAT32: return PyFloat_FromDouble(pixel.f); case IMAGING_TYPE_SPECIAL: - if (im->bands == 1) { - return PyLong_FromLong(pixel.h); - } else { - return Py_BuildValue("BBB", pixel.b[0], pixel.b[1], pixel.b[2]); - } - break; + return PyLong_FromLong(pixel.h); } /* unknown type */ @@ -665,26 +660,10 @@ getink(PyObject *color, Imaging im, char *ink) { memcpy(ink, &ftmp, sizeof(ftmp)); return ink; case IMAGING_TYPE_SPECIAL: - if (isModeI16(im->mode)) { - ink[0] = (UINT8)r; - ink[1] = (UINT8)(r >> 8); - ink[2] = ink[3] = 0; - return ink; - } else { - if (rIsInt) { - b = (UINT8)(r >> 16); - g = (UINT8)(r >> 8); - r = (UINT8)r; - } else if (tupleSize != 3) { - PyErr_SetString( - PyExc_TypeError, - "color must be int, or tuple of one or three elements" - ); - return NULL; - } else if (!PyArg_ParseTuple(color, "iiL", &b, &g, &r)) { - return NULL; - } - } + ink[0] = (UINT8)r; + ink[1] = (UINT8)(r >> 8); + ink[2] = ink[3] = 0; + return ink; } PyErr_SetString(PyExc_ValueError, wrong_mode); diff --git a/src/libImaging/Geometry.c b/src/libImaging/Geometry.c index 80ecd7cb6..2186f95f8 100644 --- a/src/libImaging/Geometry.c +++ b/src/libImaging/Geometry.c @@ -714,14 +714,7 @@ getfilter(Imaging im, int filterid) { case IMAGING_TYPE_UINT8: return nearest_filter8; case IMAGING_TYPE_SPECIAL: - switch (im->pixelsize) { - case 1: - return nearest_filter8; - case 2: - return nearest_filter16; - case 4: - return nearest_filter32; - } + return nearest_filter16; } } else { return nearest_filter32; From e969fa7aeac1cfc46ef1e6a8e77699f71a9effe8 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 17 Oct 2025 06:14:02 +1100 Subject: [PATCH 12/43] Correct __getitem__ return type --- Tests/test_image_getdata.py | 2 +- src/PIL/_imaging.pyi | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/test_image_getdata.py b/Tests/test_image_getdata.py index dd3d70b34..c8b213d84 100644 --- a/Tests/test_image_getdata.py +++ b/Tests/test_image_getdata.py @@ -15,7 +15,7 @@ def test_sanity() -> None: def test_mode() -> None: - def getdata(mode: str) -> tuple[float | tuple[int, ...], int, int]: + def getdata(mode: str) -> tuple[float | tuple[int, ...] | None, int, int]: im = hopper(mode).resize((32, 30), Image.Resampling.NEAREST) data = im.getdata() return data[0], len(data), len(list(data)) diff --git a/src/PIL/_imaging.pyi b/src/PIL/_imaging.pyi index 998bc52eb..81028a596 100644 --- a/src/PIL/_imaging.pyi +++ b/src/PIL/_imaging.pyi @@ -1,7 +1,7 @@ from typing import Any class ImagingCore: - def __getitem__(self, index: int) -> float: ... + def __getitem__(self, index: int) -> float | tuple[int, ...] | None: ... def __getattr__(self, name: str) -> Any: ... class ImagingFont: From 51e3fe45bf34fb4c344eaaaadf3434a079c5b6dd Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 20 Oct 2025 19:17:38 +1100 Subject: [PATCH 13/43] Use different variables for Image and ImageFile instances --- Tests/test_file_mic.py | 4 ++-- Tests/test_file_mpo.py | 8 ++++---- Tests/test_file_sun.py | 4 ++-- Tests/test_file_tiff.py | 6 +++--- Tests/test_file_tiff_metadata.py | 6 +++--- Tests/test_image.py | 26 +++++++++++++------------- Tests/test_image_convert.py | 4 ++-- Tests/test_image_crop.py | 8 ++++---- Tests/test_image_quantize.py | 18 +++++++++--------- Tests/test_image_resize.py | 4 ++-- Tests/test_image_rotate.py | 16 ++++++++-------- Tests/test_image_thumbnail.py | 4 ++-- Tests/test_imagedraw.py | 4 ++-- Tests/test_imageops.py | 12 ++++++------ Tests/test_pickle.py | 10 +++++----- Tests/test_shell_injection.py | 10 ++++++---- 16 files changed, 73 insertions(+), 71 deletions(-) diff --git a/Tests/test_file_mic.py b/Tests/test_file_mic.py index 9aeb306e4..0706af4c0 100644 --- a/Tests/test_file_mic.py +++ b/Tests/test_file_mic.py @@ -22,10 +22,10 @@ def test_sanity() -> None: # Adjust for the gamma of 2.2 encoded into the file lut = ImagePalette.make_gamma_lut(1 / 2.2) - im = Image.merge("RGBA", [chan.point(lut) for chan in im.split()]) + im1 = Image.merge("RGBA", [chan.point(lut) for chan in im.split()]) im2 = hopper("RGBA") - assert_image_similar(im, im2, 10) + assert_image_similar(im1, im2, 10) def test_n_frames() -> None: diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index f947d1419..4db62bd6d 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -300,12 +300,12 @@ def test_save_all() -> None: im_reloaded.seek(1) assert_image_similar(im, im_reloaded, 30) - im = Image.new("RGB", (1, 1)) + im_rgb = Image.new("RGB", (1, 1)) for colors in (("#f00",), ("#f00", "#0f0")): append_images = [Image.new("RGB", (1, 1), color) for color in colors] - im_reloaded = roundtrip(im, save_all=True, append_images=append_images) + im_reloaded = roundtrip(im_rgb, save_all=True, append_images=append_images) - assert_image_equal(im, im_reloaded) + assert_image_equal(im_rgb, im_reloaded) assert isinstance(im_reloaded, MpoImagePlugin.MpoImageFile) assert im_reloaded.mpinfo is not None assert im_reloaded.mpinfo[45056] == b"0100" @@ -315,7 +315,7 @@ def test_save_all() -> None: assert_image_similar(im_reloaded, im_expected, 1) # Test that a single frame image will not be saved as an MPO - jpg = roundtrip(im, save_all=True) + jpg = roundtrip(im_rgb, save_all=True) assert "mp" not in jpg.info diff --git a/Tests/test_file_sun.py b/Tests/test_file_sun.py index c2f162cf9..78534e154 100644 --- a/Tests/test_file_sun.py +++ b/Tests/test_file_sun.py @@ -84,8 +84,8 @@ def test_rgbx() -> None: with Image.open(io.BytesIO(data)) as im: r, g, b = im.split() - im = Image.merge("RGB", (b, g, r)) - assert_image_equal_tofile(im, os.path.join(EXTRA_DIR, "32bpp.png")) + im_rgb = Image.merge("RGB", (b, g, r)) + assert_image_equal_tofile(im_rgb, os.path.join(EXTRA_DIR, "32bpp.png")) @pytest.mark.skipif( diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index bd364377b..556c88647 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -764,9 +764,9 @@ class TestFileTiff: # Test appending images mp = BytesIO() - im = Image.new("RGB", (100, 100), "#f00") + im_rgb = Image.new("RGB", (100, 100), "#f00") ims = [Image.new("RGB", (100, 100), color) for color in ["#0f0", "#00f"]] - im.copy().save(mp, format="TIFF", save_all=True, append_images=ims) + im_rgb.copy().save(mp, format="TIFF", save_all=True, append_images=ims) mp.seek(0, os.SEEK_SET) with Image.open(mp) as reread: @@ -778,7 +778,7 @@ class TestFileTiff: yield from ims mp = BytesIO() - im.save(mp, format="TIFF", save_all=True, append_images=im_generator(ims)) + im_rgb.save(mp, format="TIFF", save_all=True, append_images=im_generator(ims)) mp.seek(0, os.SEEK_SET) with Image.open(mp) as reread: diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py index 36ad8cee9..322ef5abc 100644 --- a/Tests/test_file_tiff_metadata.py +++ b/Tests/test_file_tiff_metadata.py @@ -175,13 +175,13 @@ def test_change_stripbytecounts_tag_type(tmp_path: Path) -> None: del info[278] # Resize the image so that STRIPBYTECOUNTS will be larger than a SHORT - im = im.resize((500, 500)) - info[TiffImagePlugin.IMAGEWIDTH] = im.width + im_resized = im.resize((500, 500)) + info[TiffImagePlugin.IMAGEWIDTH] = im_resized.width # STRIPBYTECOUNTS can be a SHORT or a LONG info.tagtype[TiffImagePlugin.STRIPBYTECOUNTS] = TiffTags.SHORT - im.save(out, tiffinfo=info) + im_resized.save(out, tiffinfo=info) with Image.open(out) as reloaded: assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) diff --git a/Tests/test_image.py b/Tests/test_image.py index ac30f785c..88f55638e 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -613,8 +613,8 @@ class TestImage: assert im.getpixel((0, 0)) == 0 assert im.getpixel((255, 255)) == 255 with Image.open(target_file) as target: - target = target.convert(mode) - assert_image_equal(im, target) + im_target = target.convert(mode) + assert_image_equal(im, im_target) def test_radial_gradient_wrong_mode(self) -> None: # Arrange @@ -638,8 +638,8 @@ class TestImage: assert im.getpixel((0, 0)) == 255 assert im.getpixel((128, 128)) == 0 with Image.open(target_file) as target: - target = target.convert(mode) - assert_image_equal(im, target) + im_target = target.convert(mode) + assert_image_equal(im, im_target) def test_register_extensions(self) -> None: test_format = "a" @@ -663,20 +663,20 @@ class TestImage: assert_image_equal(im, im.remap_palette(list(range(256)))) # Test identity transform with an RGBA palette - im = Image.new("P", (256, 1)) + im_p = Image.new("P", (256, 1)) for x in range(256): - im.putpixel((x, 0), x) - im.putpalette(list(range(256)) * 4, "RGBA") - im_remapped = im.remap_palette(list(range(256))) - assert_image_equal(im, im_remapped) - assert im.palette is not None + im_p.putpixel((x, 0), x) + im_p.putpalette(list(range(256)) * 4, "RGBA") + im_remapped = im_p.remap_palette(list(range(256))) + assert_image_equal(im_p, im_remapped) + assert im_p.palette is not None assert im_remapped.palette is not None - assert im.palette.palette == im_remapped.palette.palette + assert im_p.palette.palette == im_remapped.palette.palette # Test illegal image mode - with hopper() as im: + with hopper() as im_hopper: with pytest.raises(ValueError): - im.remap_palette([]) + im_hopper.remap_palette([]) def test_remap_palette_transparency(self) -> None: im = Image.new("P", (1, 2), (0, 0, 0)) diff --git a/Tests/test_image_convert.py b/Tests/test_image_convert.py index 8d0ef4b22..547a6c2c6 100644 --- a/Tests/test_image_convert.py +++ b/Tests/test_image_convert.py @@ -80,8 +80,8 @@ def test_16bit() -> None: _test_float_conversion(im) for color in (65535, 65536): - im = Image.new("I", (1, 1), color) - im_i16 = im.convert("I;16") + im_i = Image.new("I", (1, 1), color) + im_i16 = im_i.convert("I;16") assert im_i16.getpixel((0, 0)) == 65535 diff --git a/Tests/test_image_crop.py b/Tests/test_image_crop.py index 07fec2e64..b90ce84bc 100644 --- a/Tests/test_image_crop.py +++ b/Tests/test_image_crop.py @@ -78,13 +78,13 @@ def test_crop_crash() -> None: extents = (1, 1, 10, 10) # works prepatch with Image.open(test_img) as img: - img2 = img.crop(extents) - img2.load() + img1 = img.crop(extents) + img1.load() # fail prepatch with Image.open(test_img) as img: - img = img.crop(extents) - img.load() + img2 = img.crop(extents) + img2.load() def test_crop_zero() -> None: diff --git a/Tests/test_image_quantize.py b/Tests/test_image_quantize.py index e8b783ff3..887628560 100644 --- a/Tests/test_image_quantize.py +++ b/Tests/test_image_quantize.py @@ -58,8 +58,8 @@ def test_rgba_quantize() -> None: def test_quantize() -> None: with Image.open("Tests/images/caption_6_33_22.png") as image: - image = image.convert("RGB") - converted = image.quantize() + converted = image.convert("RGB") + converted = converted.quantize() assert converted.mode == "P" assert_image_similar(converted.convert("RGB"), image, 1) @@ -67,13 +67,13 @@ def test_quantize() -> None: def test_quantize_no_dither() -> None: image = hopper() with Image.open("Tests/images/caption_6_33_22.png") as palette: - palette = palette.convert("P") + palette_p = palette.convert("P") - converted = image.quantize(dither=Image.Dither.NONE, palette=palette) + converted = image.quantize(dither=Image.Dither.NONE, palette=palette_p) assert converted.mode == "P" assert converted.palette is not None - assert palette.palette is not None - assert converted.palette.palette == palette.palette.palette + assert palette_p.palette is not None + assert converted.palette.palette == palette_p.palette.palette def test_quantize_no_dither2() -> None: @@ -97,10 +97,10 @@ def test_quantize_no_dither2() -> None: def test_quantize_dither_diff() -> None: image = hopper() with Image.open("Tests/images/caption_6_33_22.png") as palette: - palette = palette.convert("P") + palette_p = palette.convert("P") - dither = image.quantize(dither=Image.Dither.FLOYDSTEINBERG, palette=palette) - nodither = image.quantize(dither=Image.Dither.NONE, palette=palette) + dither = image.quantize(dither=Image.Dither.FLOYDSTEINBERG, palette=palette_p) + nodither = image.quantize(dither=Image.Dither.NONE, palette=palette_p) assert dither.tobytes() != nodither.tobytes() diff --git a/Tests/test_image_resize.py b/Tests/test_image_resize.py index 270500a44..323d31f51 100644 --- a/Tests/test_image_resize.py +++ b/Tests/test_image_resize.py @@ -314,8 +314,8 @@ class TestImageResize: @skip_unless_feature("libtiff") def test_transposed(self) -> None: with Image.open("Tests/images/g4_orientation_5.tif") as im: - im = im.resize((64, 64)) - assert im.size == (64, 64) + im_resized = im.resize((64, 64)) + assert im_resized.size == (64, 64) @pytest.mark.parametrize( "mode", ("L", "RGB", "I", "I;16", "I;16L", "I;16B", "I;16N", "F") diff --git a/Tests/test_image_rotate.py b/Tests/test_image_rotate.py index 252a15db7..c3ff52f57 100644 --- a/Tests/test_image_rotate.py +++ b/Tests/test_image_rotate.py @@ -43,8 +43,8 @@ def test_angle(angle: int) -> None: with Image.open("Tests/images/test-card.png") as im: rotate(im, im.mode, angle) - im = hopper() - assert_image_equal(im.rotate(angle), im.rotate(angle, expand=1)) + im_hopper = hopper() + assert_image_equal(im_hopper.rotate(angle), im_hopper.rotate(angle, expand=1)) @pytest.mark.parametrize("angle", (0, 45, 90, 180, 270)) @@ -76,9 +76,9 @@ def test_center_0() -> None: with Image.open("Tests/images/hopper_45.png") as target: target_origin = target.size[1] / 2 - target = target.crop((0, target_origin, 128, target_origin + 128)) + im_target = target.crop((0, target_origin, 128, target_origin + 128)) - assert_image_similar(im, target, 15) + assert_image_similar(im, im_target, 15) def test_center_14() -> None: @@ -87,22 +87,22 @@ def test_center_14() -> None: with Image.open("Tests/images/hopper_45.png") as target: target_origin = target.size[1] / 2 - 14 - target = target.crop((6, target_origin, 128 + 6, target_origin + 128)) + im_target = target.crop((6, target_origin, 128 + 6, target_origin + 128)) - assert_image_similar(im, target, 10) + assert_image_similar(im, im_target, 10) def test_translate() -> None: im = hopper() with Image.open("Tests/images/hopper_45.png") as target: target_origin = (target.size[1] / 2 - 64) - 5 - target = target.crop( + im_target = target.crop( (target_origin, target_origin, target_origin + 128, target_origin + 128) ) im = im.rotate(45, translate=(5, 5), resample=Image.Resampling.BICUBIC) - assert_image_similar(im, target, 1) + assert_image_similar(im, im_target, 1) def test_fastpath_center() -> None: diff --git a/Tests/test_image_thumbnail.py b/Tests/test_image_thumbnail.py index 1181f6fca..2ae230f3d 100644 --- a/Tests/test_image_thumbnail.py +++ b/Tests/test_image_thumbnail.py @@ -159,9 +159,9 @@ def test_reducing_gap_for_DCT_scaling() -> None: with Image.open("Tests/images/hopper.jpg") as ref: # thumbnail should call draft with reducing_gap scale ref.draft(None, (18 * 3, 18 * 3)) - ref = ref.resize((18, 18), Image.Resampling.BICUBIC) + im_ref = ref.resize((18, 18), Image.Resampling.BICUBIC) with Image.open("Tests/images/hopper.jpg") as im: im.thumbnail((18, 18), Image.Resampling.BICUBIC, reducing_gap=3.0) - assert_image_similar(ref, im, 1.4) + assert_image_similar(im_ref, im, 1.4) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 790acee2a..49765cd68 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -198,10 +198,10 @@ def test_bitmap() -> None: im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) with Image.open("Tests/images/pil123rgba.png") as small: - small = small.resize((50, 50), Image.Resampling.NEAREST) + small_resized = small.resize((50, 50), Image.Resampling.NEAREST) # Act - draw.bitmap((10, 10), small) + draw.bitmap((10, 10), small_resized) # Assert assert_image_equal_tofile(im, "Tests/images/imagedraw_bitmap.png") diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 27ac6f308..63cd0e4d4 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -261,10 +261,10 @@ def test_colorize_2color() -> None: # Open test image (256px by 10px, black to white) with Image.open("Tests/images/bw_gradient.png") as im: - im = im.convert("L") + im_l = im.convert("L") # Create image with original 2-color functionality - im_test = ImageOps.colorize(im, "red", "green") + im_test = ImageOps.colorize(im_l, "red", "green") # Test output image (2-color) left = (0, 1) @@ -301,11 +301,11 @@ def test_colorize_2color_offset() -> None: # Open test image (256px by 10px, black to white) with Image.open("Tests/images/bw_gradient.png") as im: - im = im.convert("L") + im_l = im.convert("L") # Create image with original 2-color functionality with offsets im_test = ImageOps.colorize( - im, black="red", white="green", blackpoint=50, whitepoint=100 + im_l, black="red", white="green", blackpoint=50, whitepoint=100 ) # Test output image (2-color) with offsets @@ -343,11 +343,11 @@ def test_colorize_3color_offset() -> None: # Open test image (256px by 10px, black to white) with Image.open("Tests/images/bw_gradient.png") as im: - im = im.convert("L") + im_l = im.convert("L") # Create image with new three color functionality with offsets im_test = ImageOps.colorize( - im, + im_l, black="red", white="green", mid="blue", diff --git a/Tests/test_pickle.py b/Tests/test_pickle.py index 54cef00ad..fc76f81e9 100644 --- a/Tests/test_pickle.py +++ b/Tests/test_pickle.py @@ -90,18 +90,18 @@ def test_pickle_la_mode_with_palette(tmp_path: Path) -> None: # Arrange filename = tmp_path / "temp.pkl" with Image.open("Tests/images/hopper.jpg") as im: - im = im.convert("PA") + im_pa = im.convert("PA") # Act / Assert for protocol in range(pickle.HIGHEST_PROTOCOL + 1): - im._mode = "LA" + im_pa._mode = "LA" with open(filename, "wb") as f: - pickle.dump(im, f, protocol) + pickle.dump(im_pa, f, protocol) with open(filename, "rb") as f: loaded_im = pickle.load(f) - im._mode = "PA" - assert im == loaded_im + im_pa._mode = "PA" + assert im_pa == loaded_im @skip_unless_feature("webp") diff --git a/Tests/test_shell_injection.py b/Tests/test_shell_injection.py index 465517bb6..a7e95ed83 100644 --- a/Tests/test_shell_injection.py +++ b/Tests/test_shell_injection.py @@ -49,11 +49,13 @@ class TestShellInjection: @pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available") def test_save_netpbm_filename_bmp_mode(self, tmp_path: Path) -> None: with Image.open(TEST_GIF) as im: - im = im.convert("RGB") - self.assert_save_filename_check(tmp_path, im, GifImagePlugin._save_netpbm) + im_rgb = im.convert("RGB") + self.assert_save_filename_check( + tmp_path, im_rgb, GifImagePlugin._save_netpbm + ) @pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available") def test_save_netpbm_filename_l_mode(self, tmp_path: Path) -> None: with Image.open(TEST_GIF) as im: - im = im.convert("L") - self.assert_save_filename_check(tmp_path, im, GifImagePlugin._save_netpbm) + im_l = im.convert("L") + self.assert_save_filename_check(tmp_path, im_l, GifImagePlugin._save_netpbm) From 4b90888a7db8953f3f56bf166672661b93343f5a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 20 Oct 2025 19:38:29 +1100 Subject: [PATCH 14/43] Added type hints --- Tests/test_file_gbr.py | 2 +- Tests/test_file_iptc.py | 2 +- src/PIL/ImageText.py | 5 ++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Tests/test_file_gbr.py b/Tests/test_file_gbr.py index b8851d82b..d89ef0583 100644 --- a/Tests/test_file_gbr.py +++ b/Tests/test_file_gbr.py @@ -33,7 +33,7 @@ def test_multiple_load_operations() -> None: assert_image_equal_tofile(im, "Tests/images/gbr.png") -def create_gbr_image(info: dict[str, int] = {}, magic_number=b"") -> BytesIO: +def create_gbr_image(info: dict[str, int] = {}, magic_number: bytes = b"") -> BytesIO: return BytesIO( b"".join( _binary.o32be(i) diff --git a/Tests/test_file_iptc.py b/Tests/test_file_iptc.py index 5a8aaa3ef..0376b9997 100644 --- a/Tests/test_file_iptc.py +++ b/Tests/test_file_iptc.py @@ -12,7 +12,7 @@ TEST_FILE = "Tests/images/iptc.jpg" def create_iptc_image(info: dict[str, int] = {}) -> BytesIO: - def field(tag, value): + def field(tag: tuple[int, int], value: bytes) -> bytes: return bytes((0x1C,) + tag + (0, len(value))) + value data = field((3, 60), bytes((info.get("layers", 1), info.get("component", 0)))) diff --git a/src/PIL/ImageText.py b/src/PIL/ImageText.py index c74570e69..6c5a8a8a3 100644 --- a/src/PIL/ImageText.py +++ b/src/PIL/ImageText.py @@ -88,7 +88,7 @@ class Text: else: return "L" - def get_length(self): + def get_length(self) -> float: """ Returns length (in pixels with 1/64 precision) of text. @@ -130,8 +130,7 @@ class Text: :return: Either width for horizontal text, or height for vertical text. """ - split_character = "\n" if isinstance(self.text, str) else b"\n" - if split_character in self.text: + if "\n" in self.text if isinstance(self.text, str) else b"\n" in self.text: msg = "can't measure length of multiline text" raise ValueError(msg) return self.font.getlength( From e90bb1559cfd0ab18c2af653a1a8b1305bb0b2ca Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 20 Oct 2025 21:00:29 +1100 Subject: [PATCH 15/43] Rearranged code --- src/PIL/ImageText.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/PIL/ImageText.py b/src/PIL/ImageText.py index 6c5a8a8a3..8bfd89884 100644 --- a/src/PIL/ImageText.py +++ b/src/PIL/ImageText.py @@ -130,7 +130,11 @@ class Text: :return: Either width for horizontal text, or height for vertical text. """ - if "\n" in self.text if isinstance(self.text, str) else b"\n" in self.text: + if isinstance(self.text, str): + multiline = "\n" in self.text + else: + multiline = b"\n" in self.text + if multiline: msg = "can't measure length of multiline text" raise ValueError(msg) return self.font.getlength( From e1f4352ce9b3b92912f7a678666c9f5bca09101e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 21 Oct 2025 23:11:18 +1100 Subject: [PATCH 16/43] Fixed ZeroDivisionError --- Tests/images/zero_mask_totals.dds | Bin 0 -> 131 bytes Tests/test_file_dds.py | 5 +++++ src/PIL/DdsImagePlugin.py | 3 +++ 3 files changed, 8 insertions(+) create mode 100644 Tests/images/zero_mask_totals.dds diff --git a/Tests/images/zero_mask_totals.dds b/Tests/images/zero_mask_totals.dds new file mode 100644 index 0000000000000000000000000000000000000000..31e329e4f266b8c8407531b77e65fbd9c46b41a4 GIT binary patch literal 131 pcmZ>930A0KU|`@EU|?Vb(jd$X#N+@4pe6^XMhPg5LILf-0s!Yi0we$c literal 0 HcmV?d00001 diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 116dfa59c..60d0c09bc 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -380,6 +380,11 @@ def test_palette() -> None: assert_image_equal_tofile(im, "Tests/images/transparent.gif") +def test_zero_mask_totals() -> None: + with Image.open("Tests/images/zero_mask_totals.dds") as im: + im.load() + + def test_unsupported_header_size() -> None: with pytest.raises(OSError, match="Unsupported header size 0"): with Image.open(BytesIO(b"DDS " + b"\x00" * 4)): diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index f9ade18f9..37e16d527 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -333,6 +333,7 @@ class DdsImageFile(ImageFile.ImageFile): format_description = "DirectDraw Surface" def _open(self) -> None: + assert self.fp is not None if not _accept(self.fp.read(4)): msg = "not a DDS file" raise SyntaxError(msg) @@ -516,6 +517,8 @@ class DdsRgbDecoder(ImageFile.PyDecoder): # Remove the zero padding, and scale it to 8 bits data += o8( int(((masked_value >> mask_offsets[i]) / mask_totals[i]) * 255) + if mask_totals[i] + else 0 ) self.set_as_raw(data) return -1, 0 From 7d6f2ce90b688ef4dfd485cd5336ea52cdf48c77 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 21 Oct 2025 23:35:17 +1100 Subject: [PATCH 17/43] Removed BytesIO --- src/PIL/DdsImagePlugin.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index f9ade18f9..19a210275 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -12,7 +12,6 @@ https://creativecommons.org/publicdomain/zero/1.0/ from __future__ import annotations -import io import struct import sys from enum import IntEnum, IntFlag @@ -340,21 +339,20 @@ class DdsImageFile(ImageFile.ImageFile): if header_size != 124: msg = f"Unsupported header size {repr(header_size)}" raise OSError(msg) - header_bytes = self.fp.read(header_size - 4) - if len(header_bytes) != 120: - msg = f"Incomplete header: {len(header_bytes)} bytes" + header = self.fp.read(header_size - 4) + if len(header) != 120: + msg = f"Incomplete header: {len(header)} bytes" raise OSError(msg) - header = io.BytesIO(header_bytes) - flags, height, width = struct.unpack("<3I", header.read(12)) + flags, height, width = struct.unpack("<3I", header[:12]) self._size = (width, height) extents = (0, 0) + self.size - pitch, depth, mipmaps = struct.unpack("<3I", header.read(12)) - struct.unpack("<11I", header.read(44)) # reserved + pitch, depth, mipmaps = struct.unpack("<3I", header[12:24]) + struct.unpack("<11I", header[24:68]) # reserved # pixel format - pfsize, pfflags, fourcc, bitcount = struct.unpack("<4I", header.read(16)) + pfsize, pfflags, fourcc, bitcount = struct.unpack("<4I", header[68:84]) n = 0 rawmode = None if pfflags & DDPF.RGB: @@ -366,7 +364,7 @@ class DdsImageFile(ImageFile.ImageFile): self._mode = "RGB" mask_count = 3 - masks = struct.unpack(f"<{mask_count}I", header.read(mask_count * 4)) + masks = struct.unpack(f"<{mask_count}I", header[84 : 84 + mask_count * 4]) self.tile = [ImageFile._Tile("dds_rgb", extents, 0, (bitcount, masks))] return elif pfflags & DDPF.LUMINANCE: From b1e2f2e6528f92c0b763c86374ef782210a625fe Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 22 Oct 2025 20:08:22 +1100 Subject: [PATCH 18/43] Improved coverage --- Tests/test_imagetext.py | 11 +++++++++++ src/PIL/ImageText.py | 3 +-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Tests/test_imagetext.py b/Tests/test_imagetext.py index 7db229897..46afea064 100644 --- a/Tests/test_imagetext.py +++ b/Tests/test_imagetext.py @@ -32,6 +32,10 @@ def test_get_length(font: ImageFont.FreeTypeFont) -> None: assert ImageText.Text("y", font).get_length() == 12 assert ImageText.Text("a", font).get_length() == 12 + text = ImageText.Text("\n", font) + with pytest.raises(ValueError, match="can't measure length of multiline text"): + text.get_length() + def test_get_bbox(font: ImageFont.FreeTypeFont) -> None: assert ImageText.Text("A", font).get_bbox() == (0, 4, 12, 16) @@ -45,6 +49,7 @@ def test_standard_embedded_color(layout_engine: ImageFont.Layout) -> None: font = ImageFont.truetype(FONT_PATH, 40, layout_engine=layout_engine) text = ImageText.Text("Hello World!", font) text.embed_color() + assert text.get_length() == 288 im = Image.new("RGB", (300, 64), "white") draw = ImageDraw.Draw(im) @@ -52,6 +57,12 @@ def test_standard_embedded_color(layout_engine: ImageFont.Layout) -> None: assert_image_similar_tofile(im, "Tests/images/standard_embedded.png", 3.1) + text = ImageText.Text("", mode="1") + with pytest.raises( + ValueError, match="Embedded color supported only in RGB and RGBA modes" + ): + text.embed_color() + @skip_unless_feature("freetype2") def test_stroke() -> None: diff --git a/src/PIL/ImageText.py b/src/PIL/ImageText.py index 8bfd89884..e6ccd8243 100644 --- a/src/PIL/ImageText.py +++ b/src/PIL/ImageText.py @@ -316,6 +316,5 @@ class Text: max(bbox[3], bbox_line[3]), ) - if bbox is None: - return xy[0], xy[1], xy[0], xy[1] + assert bbox is not None return bbox From 208bbe95f9ccaf5659d5b246e18ce76bcefeea84 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 22 Oct 2025 22:22:00 +1100 Subject: [PATCH 19/43] Remove I;32L and I;32B modes --- src/libImaging/Mode.c | 1 - src/libImaging/Mode.h | 6 ++---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/libImaging/Mode.c b/src/libImaging/Mode.c index 7521f4cda..d6ee26131 100644 --- a/src/libImaging/Mode.c +++ b/src/libImaging/Mode.c @@ -20,7 +20,6 @@ const ModeData MODES[] = { [IMAGING_MODE_I_16] = {"I;16"}, [IMAGING_MODE_I_16L] = {"I;16L"}, [IMAGING_MODE_I_16B] = {"I;16B"}, [IMAGING_MODE_I_16N] = {"I;16N"}, - [IMAGING_MODE_I_32L] = {"I;32L"}, [IMAGING_MODE_I_32B] = {"I;32B"}, }; const ModeID diff --git a/src/libImaging/Mode.h b/src/libImaging/Mode.h index a3eb3d86d..8cb96c984 100644 --- a/src/libImaging/Mode.h +++ b/src/libImaging/Mode.h @@ -25,8 +25,6 @@ typedef enum { IMAGING_MODE_I_16L, IMAGING_MODE_I_16B, IMAGING_MODE_I_16N, - IMAGING_MODE_I_32L, - IMAGING_MODE_I_32B, } ModeID; typedef struct { @@ -64,8 +62,6 @@ typedef enum { IMAGING_RAWMODE_I_16L, IMAGING_RAWMODE_I_16B, IMAGING_RAWMODE_I_16N, - IMAGING_RAWMODE_I_32L, - IMAGING_RAWMODE_I_32B, // Rawmodes IMAGING_RAWMODE_1_8, @@ -106,6 +102,8 @@ typedef enum { IMAGING_RAWMODE_C_I, IMAGING_RAWMODE_Cb, IMAGING_RAWMODE_Cr, + IMAGING_RAWMODE_I_32B, + IMAGING_RAWMODE_I_32L, IMAGING_RAWMODE_F_16, IMAGING_RAWMODE_F_16B, IMAGING_RAWMODE_F_16BS, From 109ee1569ddc3156229dfc4b683d252409afe51f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 22 Oct 2025 22:24:15 +1100 Subject: [PATCH 20/43] Removed I;32L rawmode --- src/libImaging/Mode.c | 1 - src/libImaging/Mode.h | 1 - 2 files changed, 2 deletions(-) diff --git a/src/libImaging/Mode.c b/src/libImaging/Mode.c index d6ee26131..2e459c48f 100644 --- a/src/libImaging/Mode.c +++ b/src/libImaging/Mode.c @@ -75,7 +75,6 @@ const RawModeData RAWMODES[] = { [IMAGING_RAWMODE_I_16L] = {"I;16L"}, [IMAGING_RAWMODE_I_16B] = {"I;16B"}, [IMAGING_RAWMODE_I_16N] = {"I;16N"}, - [IMAGING_RAWMODE_I_32L] = {"I;32L"}, [IMAGING_RAWMODE_I_32B] = {"I;32B"}, [IMAGING_RAWMODE_1_8] = {"1;8"}, diff --git a/src/libImaging/Mode.h b/src/libImaging/Mode.h index 8cb96c984..39c0eb919 100644 --- a/src/libImaging/Mode.h +++ b/src/libImaging/Mode.h @@ -103,7 +103,6 @@ typedef enum { IMAGING_RAWMODE_Cb, IMAGING_RAWMODE_Cr, IMAGING_RAWMODE_I_32B, - IMAGING_RAWMODE_I_32L, IMAGING_RAWMODE_F_16, IMAGING_RAWMODE_F_16B, IMAGING_RAWMODE_F_16BS, From b04d8792f5779b24c3c723dd368a4d43d7609276 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 23 Oct 2025 08:53:00 +1100 Subject: [PATCH 21/43] Support writing InkNames --- Tests/test_file_libtiff.py | 12 ++++++++++++ src/PIL/TiffTags.py | 1 - src/encode.c | 11 ++++++----- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 4908496cf..e53832db3 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -355,6 +355,18 @@ class TestFileLibTiff(LibTiffTestCase): # Should not segfault im.save(outfile) + def test_inknames_tag( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True) + + out = tmp_path / "temp.tif" + hopper("L").save(out, tiffinfo={333: "name\x00"}) + + with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) + assert reloaded.tag_v2[333] in ("name", "name\x00") + def test_whitepoint_tag( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/src/PIL/TiffTags.py b/src/PIL/TiffTags.py index 761aa3f6b..613a3b7de 100644 --- a/src/PIL/TiffTags.py +++ b/src/PIL/TiffTags.py @@ -558,7 +558,6 @@ LIBTIFF_CORE = { LIBTIFF_CORE.remove(255) # We don't have support for subfiletypes LIBTIFF_CORE.remove(322) # We don't have support for writing tiled images with libtiff LIBTIFF_CORE.remove(323) # Tiled images -LIBTIFF_CORE.remove(333) # Ink Names either # Note to advanced users: There may be combinations of these # parameters and values that when added properly, will work and diff --git a/src/encode.c b/src/encode.c index b1d0181e0..f0e204bc6 100644 --- a/src/encode.c +++ b/src/encode.c @@ -668,10 +668,10 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { int key_int, status, is_core_tag, is_var_length, num_core_tags, i; TIFFDataType type = TIFF_NOTYPE; // This list also exists in TiffTags.py - const int core_tags[] = {256, 257, 258, 259, 262, 263, 266, 269, 274, - 277, 278, 280, 281, 340, 341, 282, 283, 284, - 286, 287, 296, 297, 320, 321, 338, 32995, 32998, - 32996, 339, 32997, 330, 531, 530, 65537, 301, 532}; + const int core_tags[] = {256, 257, 258, 259, 262, 263, 266, 269, 274, 277, + 278, 280, 281, 282, 283, 284, 286, 287, 296, 297, + 301, 320, 321, 330, 333, 338, 339, 340, 341, 530, + 531, 532, 32995, 32996, 32997, 32998, 65537}; Py_ssize_t tags_size; PyObject *item; @@ -821,7 +821,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { } } - if (type == TIFF_BYTE || type == TIFF_UNDEFINED) { + if (type == TIFF_BYTE || type == TIFF_UNDEFINED || + key_int == TIFFTAG_INKNAMES) { status = ImagingLibTiffSetField( &encoder->state, (ttag_t)key_int, From ddd4f007209a3af7f8976322c13c97f205cc4f06 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 23 Oct 2025 20:03:14 +1100 Subject: [PATCH 22/43] Support writing IFD tag types --- Tests/test_file_libtiff.py | 14 ++++++++++++++ src/encode.c | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index e53832db3..38e4111a1 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -355,6 +355,20 @@ class TestFileLibTiff(LibTiffTestCase): # Should not segfault im.save(outfile) + def test_ifd(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True) + + ifd = TiffImagePlugin.ImageFileDirectory_v2() + ifd[37000] = 100 + ifd.tagtype[37000] = TiffTags.IFD + + out = tmp_path / "temp.tif" + im = Image.new("L", (1, 1)) + im.save(out, tiffinfo=ifd) + + with Image.open(out) as reloaded: + assert reloaded.tag_v2[37000] == 100 + def test_inknames_tag( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/src/encode.c b/src/encode.c index f0e204bc6..2e9ef843d 100644 --- a/src/encode.c +++ b/src/encode.c @@ -974,7 +974,7 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { status = ImagingLibTiffSetField( &encoder->state, (ttag_t)key_int, (UINT16)PyLong_AsLong(value) ); - } else if (type == TIFF_LONG) { + } else if (type == TIFF_LONG || type == TIFF_IFD) { status = ImagingLibTiffSetField( &encoder->state, (ttag_t)key_int, (UINT32)PyLong_AsLong(value) ); From 82cdaa456c88139a2d8d6d23cac8b9dacf4dc75f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 24 Oct 2025 03:55:45 +1100 Subject: [PATCH 23/43] Support writing SIGNED_RATIONAL tag types --- Tests/test_file_libtiff.py | 7 +++++-- src/encode.c | 7 ++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 38e4111a1..7cb3ea8e4 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -355,12 +355,15 @@ class TestFileLibTiff(LibTiffTestCase): # Should not segfault im.save(outfile) - def test_ifd(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + @pytest.mark.parametrize("tagtype", (TiffTags.SIGNED_RATIONAL, TiffTags.IFD)) + def test_tag_type( + self, tagtype: int, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True) ifd = TiffImagePlugin.ImageFileDirectory_v2() ifd[37000] = 100 - ifd.tagtype[37000] = TiffTags.IFD + ifd.tagtype[37000] = tagtype out = tmp_path / "temp.tif" im = Image.new("L", (1, 1)) diff --git a/src/encode.c b/src/encode.c index 2e9ef843d..513309c8d 100644 --- a/src/encode.c +++ b/src/encode.c @@ -990,10 +990,6 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { status = ImagingLibTiffSetField( &encoder->state, (ttag_t)key_int, (FLOAT32)PyFloat_AsDouble(value) ); - } else if (type == TIFF_DOUBLE) { - status = ImagingLibTiffSetField( - &encoder->state, (ttag_t)key_int, (FLOAT64)PyFloat_AsDouble(value) - ); } else if (type == TIFF_SBYTE) { status = ImagingLibTiffSetField( &encoder->state, (ttag_t)key_int, (INT8)PyLong_AsLong(value) @@ -1002,7 +998,8 @@ PyImaging_LibTiffEncoderNew(PyObject *self, PyObject *args) { status = ImagingLibTiffSetField( &encoder->state, (ttag_t)key_int, PyBytes_AsString(value) ); - } else if (type == TIFF_RATIONAL) { + } else if (type == TIFF_DOUBLE || type == TIFF_SRATIONAL || + type == TIFF_RATIONAL) { status = ImagingLibTiffSetField( &encoder->state, (ttag_t)key_int, (FLOAT64)PyFloat_AsDouble(value) ); From 29c5ffe7451bc981925a9c4f8cb294d69e086d32 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 27 Oct 2025 02:48:10 +0000 Subject: [PATCH 24/43] Update github-actions --- .github/workflows/cifuzz.yml | 4 ++-- .github/workflows/test-windows.yml | 2 +- .github/workflows/test.yml | 2 +- .github/workflows/wheels.yml | 14 +++++++------- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml index 0456bbaba..6a86b8aeb 100644 --- a/.github/workflows/cifuzz.yml +++ b/.github/workflows/cifuzz.yml @@ -44,13 +44,13 @@ jobs: language: python dry-run: false - name: Upload New Crash - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: failure() && steps.build.outcome == 'success' with: name: artifacts path: ./out/artifacts - name: Upload Legacy Crash - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: steps.run.outcome == 'success' with: name: crash diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index f6a7dd46b..02d4da999 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -216,7 +216,7 @@ jobs: shell: bash - name: Upload errors - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: failure() with: name: errors diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b52000a27..ef7b34b8d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -140,7 +140,7 @@ jobs: mkdir -p Tests/errors - name: Upload errors - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: failure() with: name: errors diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 6dc8db7e9..4bc48bec9 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -134,7 +134,7 @@ jobs: CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.manylinux }} MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: dist-${{ matrix.name }} path: ./wheelhouse/*.whl @@ -220,13 +220,13 @@ jobs: shell: cmd - name: Upload wheels - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: dist-windows-${{ matrix.cibw_arch }} path: ./wheelhouse/*.whl - name: Upload fribidi.dll - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: fribidi-windows-${{ matrix.cibw_arch }} path: winbuild\build\bin\fribidi* @@ -246,7 +246,7 @@ jobs: - run: make sdist - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: dist-sdist path: dist/*.tar.gz @@ -256,7 +256,7 @@ jobs: runs-on: ubuntu-latest name: Count dists steps: - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@v6 with: pattern: dist-* path: dist @@ -275,7 +275,7 @@ jobs: runs-on: ubuntu-latest name: Upload wheels to scientific-python-nightly-wheels steps: - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@v6 with: pattern: dist-!(sdist)* path: dist @@ -297,7 +297,7 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@v6 with: pattern: dist-* path: dist From dfd24ba6150ea3803099d445ce1a486fb7e87c13 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 30 Oct 2025 22:03:39 +1100 Subject: [PATCH 25/43] Read all non-zero transparency from mode 1 images in the same way --- Tests/test_file_png.py | 9 +++++++++ src/PIL/PngImagePlugin.py | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index dc1077fed..9875fe096 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -338,6 +338,15 @@ class TestFilePng: assert colors is not None assert colors[0][0] == num_transparent + def test_save_1_transparency(self, tmp_path: Path) -> None: + out = tmp_path / "temp.png" + + im = Image.new("1", (1, 1), 1) + im.save(out, transparency=1) + + with Image.open(out) as reloaded: + assert reloaded.info["transparency"] == 255 + def test_save_rgb_single_transparency(self, tmp_path: Path) -> None: in_file = "Tests/images/caption_6_33_22.png" with Image.open(in_file) as im: diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index d0f22f812..11a48e55c 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -509,7 +509,9 @@ class PngStream(ChunkStream): # otherwise, we have a byte string with one alpha value # for each palette entry self.im_info["transparency"] = s - elif self.im_mode in ("1", "L", "I;16"): + elif self.im_mode == "1": + self.im_info["transparency"] = 255 if i16(s) else 0 + elif self.im_mode in ("L", "I;16"): self.im_info["transparency"] = i16(s) elif self.im_mode == "RGB": self.im_info["transparency"] = i16(s), i16(s, 2), i16(s, 4) From 1a27f958d7d51e9fef68493d68a29c423e5f6e38 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 31 Oct 2025 18:19:05 +1100 Subject: [PATCH 26/43] Updated brotli to 1.2.0 --- .github/workflows/wheels-dependencies.sh | 9 ++--- .pre-commit-config.yaml | 6 ++-- MANIFEST.in | 1 - patches/README.md | 14 -------- patches/iOS/brotli-1.1.0.tar.gz.patch | 46 ------------------------ winbuild/build_prepare.py | 2 +- 6 files changed, 7 insertions(+), 71 deletions(-) delete mode 100644 patches/README.md delete mode 100644 patches/iOS/brotli-1.1.0.tar.gz.patch diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 7d6eb8681..cdc1faf15 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -32,7 +32,6 @@ if [[ "$CIBW_PLATFORM" == "ios" ]]; then # or `build/deps/iphonesimulator` WORKDIR=$(pwd)/build/$IOS_SDK BUILD_PREFIX=$(pwd)/build/deps/$IOS_SDK - PATCH_DIR=$(pwd)/patches/iOS # GNU tooling insists on using aarch64 rather than arm64 if [[ $PLAT == "arm64" ]]; then @@ -90,9 +89,7 @@ fi ARCHIVE_SDIR=pillow-depends-main -# Package versions for fresh source builds. Version numbers with "Patched" -# annotations have a source code patch that is required for some platforms. If -# you change those versions, ensure the patch is also updated. +# Package versions for fresh source builds. if [[ -n "$IOS_SDK" ]]; then FREETYPE_VERSION=2.13.3 else @@ -110,7 +107,7 @@ ZLIB_NG_VERSION=2.2.5 LIBWEBP_VERSION=1.6.0 BZIP2_VERSION=1.0.8 LIBXCB_VERSION=1.17.0 -BROTLI_VERSION=1.1.0 # Patched; next release won't need patching. See patch file. +BROTLI_VERSION=1.2.0 LIBAVIF_VERSION=1.3.0 function build_pkg_config { @@ -168,7 +165,7 @@ function build_brotli { if [ -e brotli-stamp ]; then return; fi local out_dir=$(fetch_unpack https://github.com/google/brotli/archive/v$BROTLI_VERSION.tar.gz brotli-$BROTLI_VERSION.tar.gz) (cd $out_dir \ - && cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_LIBDIR=$BUILD_PREFIX/lib -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib $HOST_CMAKE_FLAGS . \ + && cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_LIBDIR=$BUILD_PREFIX/lib -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib -DCMAKE_MACOSX_BUNDLE=OFF $HOST_CMAKE_FLAGS . \ && make -j4 install) touch brotli-stamp } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ab0153687..b9f7e599b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: rev: v1.5.5 hooks: - id: remove-tabs - exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$|\.patch$) + exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$) - repo: https://github.com/pre-commit/mirrors-clang-format rev: v21.1.2 @@ -46,9 +46,9 @@ repos: - id: check-yaml args: [--allow-multiple-documents] - id: end-of-file-fixer - exclude: ^Tests/images/|\.patch$ + exclude: ^Tests/images/ - id: trailing-whitespace - exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/|\.patch$ + exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/ - repo: https://github.com/python-jsonschema/check-jsonschema rev: 0.34.0 diff --git a/MANIFEST.in b/MANIFEST.in index 6623f227d..d4623a4a8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -15,7 +15,6 @@ include tox.ini graft Tests graft Tests/images graft checks -graft patches graft src graft depends graft winbuild diff --git a/patches/README.md b/patches/README.md deleted file mode 100644 index ff4a8f099..000000000 --- a/patches/README.md +++ /dev/null @@ -1,14 +0,0 @@ -Although we try to use official sources for dependencies, sometimes the official -sources don't support a platform (especially mobile platforms), or there's a bug -fix/feature that is required to support Pillow's usage. - -This folder contains patches that must be applied to official sources, organized -by the platforms that need those patches. - -Each patch is against the root of the unpacked official tarball, and is named by -appending `.patch` to the end of the tarball that is to be patched. This -includes the full version number; so if the version is bumped, the patch will -at a minimum require a filename change. - -Wherever possible, these patches should be contributed upstream, in the hope that -future Pillow versions won't need to maintain these patches. diff --git a/patches/iOS/brotli-1.1.0.tar.gz.patch b/patches/iOS/brotli-1.1.0.tar.gz.patch deleted file mode 100644 index f165a9ac1..000000000 --- a/patches/iOS/brotli-1.1.0.tar.gz.patch +++ /dev/null @@ -1,46 +0,0 @@ -# Brotli 1.1.0 doesn't have explicit support for iOS as a CMAKE_SYSTEM_NAME. -# That release was from 2023; there have been subsequent changes that allow -# Brotli to build on iOS without any patches, as long as -DBROTLI_BUILD_TOOLS=NO -# is specified on the command line. -# -diff -ru brotli-1.1.0-orig/CMakeLists.txt brotli-1.1.0/CMakeLists.txt ---- brotli-1.1.0-orig/CMakeLists.txt 2023-08-29 19:00:29 -+++ brotli-1.1.0/CMakeLists.txt 2024-11-07 10:46:26 -@@ -114,6 +114,8 @@ - add_definitions(-DOS_MACOSX) - set(CMAKE_MACOS_RPATH TRUE) - set(CMAKE_INSTALL_NAME_DIR "${CMAKE_INSTALL_PREFIX}/lib") -+elseif(${CMAKE_SYSTEM_NAME} MATCHES "iOS") -+ add_definitions(-DOS_IOS) - endif() - - if(BROTLI_EMSCRIPTEN) -@@ -174,10 +176,12 @@ - - # Installation - if(NOT BROTLI_BUNDLED_MODE) -- install( -- TARGETS brotli -- RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" -- ) -+ if(NOT ${CMAKE_SYSTEM_NAME} MATCHES "iOS") -+ install( -+ TARGETS brotli -+ RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" -+ ) -+ endif() - - install( - TARGETS ${BROTLI_LIBRARIES_CORE} -diff -ru brotli-1.1.0-orig/c/common/platform.h brotli-1.1.0/c/common/platform.h ---- brotli-1.1.0-orig/c/common/platform.h 2023-08-29 19:00:29 -+++ brotli-1.1.0/c/common/platform.h 2024-11-07 10:47:28 -@@ -33,7 +33,7 @@ - #include - #elif defined(OS_FREEBSD) - #include --#elif defined(OS_MACOSX) -+#elif defined(OS_MACOSX) || defined(OS_IOS) - #include - /* Let's try and follow the Linux convention */ - #define BROTLI_X_BYTE_ORDER BYTE_ORDER diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 186a80cca..1277e404f 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -113,7 +113,7 @@ ARCHITECTURES = { } V = { - "BROTLI": "1.1.0", + "BROTLI": "1.2.0", "FREETYPE": "2.14.1", "FRIBIDI": "1.0.16", "HARFBUZZ": "12.1.0", From b3d9bd9950d9806ef904566896228d37df824821 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 3 Nov 2025 23:07:15 +1100 Subject: [PATCH 27/43] Test ImageFont.ImageFont, in case freetype2 is not supported --- Tests/test_imagetext.py | 75 ++++++++++++++++++++++++++++------------- 1 file changed, 51 insertions(+), 24 deletions(-) diff --git a/Tests/test_imagetext.py b/Tests/test_imagetext.py index 46afea064..2b424629d 100644 --- a/Tests/test_imagetext.py +++ b/Tests/test_imagetext.py @@ -2,7 +2,7 @@ from __future__ import annotations import pytest -from PIL import Image, ImageDraw, ImageFont, ImageText +from PIL import Image, ImageDraw, ImageFont, ImageText, features from .helper import assert_image_similar_tofile, skip_unless_feature @@ -20,42 +20,69 @@ def layout_engine(request: pytest.FixtureRequest) -> ImageFont.Layout: return request.param -@pytest.fixture(scope="module") -def font(layout_engine: ImageFont.Layout) -> ImageFont.FreeTypeFont: - return ImageFont.truetype(FONT_PATH, 20, layout_engine=layout_engine) +@pytest.fixture( + scope="module", + params=[ + None, + pytest.param(ImageFont.Layout.BASIC, marks=skip_unless_feature("freetype2")), + pytest.param(ImageFont.Layout.RAQM, marks=skip_unless_feature("raqm")), + ], +) +def font( + request: pytest.FixtureRequest, +) -> ImageFont.ImageFont | ImageFont.FreeTypeFont: + layout_engine = request.param + if layout_engine is None: + return ImageFont.load_default_imagefont() + else: + return ImageFont.truetype(FONT_PATH, 20, layout_engine=layout_engine) -def test_get_length(font: ImageFont.FreeTypeFont) -> None: - assert ImageText.Text("A", font).get_length() == 12 - assert ImageText.Text("AB", font).get_length() == 24 - assert ImageText.Text("M", font).get_length() == 12 - assert ImageText.Text("y", font).get_length() == 12 - assert ImageText.Text("a", font).get_length() == 12 +def test_get_length(font: ImageFont.ImageFont | ImageFont.FreeTypeFont) -> None: + factor = 1 if isinstance(font, ImageFont.ImageFont) else 2 + assert ImageText.Text("A", font).get_length() == 6 * factor + assert ImageText.Text("AB", font).get_length() == 12 * factor + assert ImageText.Text("M", font).get_length() == 6 * factor + assert ImageText.Text("y", font).get_length() == 6 * factor + assert ImageText.Text("a", font).get_length() == 6 * factor text = ImageText.Text("\n", font) with pytest.raises(ValueError, match="can't measure length of multiline text"): text.get_length() -def test_get_bbox(font: ImageFont.FreeTypeFont) -> None: - assert ImageText.Text("A", font).get_bbox() == (0, 4, 12, 16) - assert ImageText.Text("AB", font).get_bbox() == (0, 4, 24, 16) - assert ImageText.Text("M", font).get_bbox() == (0, 4, 12, 16) - assert ImageText.Text("y", font).get_bbox() == (0, 7, 12, 20) - assert ImageText.Text("a", font).get_bbox() == (0, 7, 12, 16) +@pytest.mark.parametrize( + "text, expected", + ( + ("A", (0, 4, 12, 16)), + ("AB", (0, 4, 24, 16)), + ("M", (0, 4, 12, 16)), + ("y", (0, 7, 12, 20)), + ("a", (0, 7, 12, 16)), + ), +) +def test_get_bbox( + font: ImageFont.ImageFont | ImageFont.FreeTypeFont, + text: str, + expected: tuple[int, int, int, int], +) -> None: + if isinstance(font, ImageFont.ImageFont): + expected = (0, 0, expected[2] // 2, 11) + assert ImageText.Text(text, font).get_bbox() == expected def test_standard_embedded_color(layout_engine: ImageFont.Layout) -> None: - font = ImageFont.truetype(FONT_PATH, 40, layout_engine=layout_engine) - text = ImageText.Text("Hello World!", font) - text.embed_color() - assert text.get_length() == 288 + if features.check_module("freetype2"): + font = ImageFont.truetype(FONT_PATH, 40, layout_engine=layout_engine) + text = ImageText.Text("Hello World!", font) + text.embed_color() + assert text.get_length() == 288 - im = Image.new("RGB", (300, 64), "white") - draw = ImageDraw.Draw(im) - draw.text((10, 10), text, "#fa6") + im = Image.new("RGB", (300, 64), "white") + draw = ImageDraw.Draw(im) + draw.text((10, 10), text, "#fa6") - assert_image_similar_tofile(im, "Tests/images/standard_embedded.png", 3.1) + assert_image_similar_tofile(im, "Tests/images/standard_embedded.png", 3.1) text = ImageText.Text("", mode="1") with pytest.raises( From 85d783fb52cd93584da76018baec888cbf680f6d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Nov 2025 17:20:17 +0000 Subject: [PATCH 28/43] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.13.3 → v0.14.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.13.3...v0.14.3) - [github.com/python-jsonschema/check-jsonschema: 0.34.0 → 0.34.1](https://github.com/python-jsonschema/check-jsonschema/compare/0.34.0...0.34.1) - [github.com/zizmorcore/zizmor-pre-commit: v1.14.2 → v1.16.2](https://github.com/zizmorcore/zizmor-pre-commit/compare/v1.14.2...v1.16.2) - [github.com/sphinx-contrib/sphinx-lint: v1.0.0 → v1.0.1](https://github.com/sphinx-contrib/sphinx-lint/compare/v1.0.0...v1.0.1) - [github.com/tox-dev/pyproject-fmt: v2.7.0 → v2.11.0](https://github.com/tox-dev/pyproject-fmt/compare/v2.7.0...v2.11.0) - [github.com/tox-dev/tox-ini-fmt: 1.6.0 → 1.7.0](https://github.com/tox-dev/tox-ini-fmt/compare/1.6.0...1.7.0) --- .pre-commit-config.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ab0153687..beef225fd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.13.3 + rev: v0.14.3 hooks: - id: ruff-check args: [--exit-non-zero-on-fix] @@ -51,24 +51,24 @@ repos: exclude: ^.github/.*TEMPLATE|^Tests/(fonts|images)/|\.patch$ - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.34.0 + rev: 0.34.1 hooks: - id: check-github-workflows - id: check-readthedocs - id: check-renovate - repo: https://github.com/zizmorcore/zizmor-pre-commit - rev: v1.14.2 + rev: v1.16.2 hooks: - id: zizmor - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v1.0.0 + rev: v1.0.1 hooks: - id: sphinx-lint - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.7.0 + rev: v2.11.0 hooks: - id: pyproject-fmt @@ -79,7 +79,7 @@ repos: additional_dependencies: [trove-classifiers>=2024.10.12] - repo: https://github.com/tox-dev/tox-ini-fmt - rev: 1.6.0 + rev: 1.7.0 hooks: - id: tox-ini-fmt From 666dd5247819ceec8b772758f2d0d2d1e9ec0572 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 3 Nov 2025 19:56:21 +0200 Subject: [PATCH 29/43] Drop removed rule --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0006ccd12..f4514925d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -189,7 +189,6 @@ lint.ignore = [ "PT012", # pytest-raises-with-multiple-statements "PT017", # pytest-assert-in-except "PYI034", # flake8-pyi: typing.Self added in Python 3.11 - "UP038", # pyupgrade: deprecated rule ] lint.per-file-ignores."Tests/oss-fuzz/fuzz_font.py" = [ "I002", From e44ce2f00e6c1fd8fffa30b812aa522e5c16fbdb Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 5 Nov 2025 19:09:49 +1100 Subject: [PATCH 30/43] Updated harfbuzz to 12.2.0 --- .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 7d6eb8681..226fcdb6a 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -98,7 +98,7 @@ if [[ -n "$IOS_SDK" ]]; then else FREETYPE_VERSION=2.14.1 fi -HARFBUZZ_VERSION=12.1.0 +HARFBUZZ_VERSION=12.2.0 LIBPNG_VERSION=1.6.50 JPEGTURBO_VERSION=3.1.2 OPENJPEG_VERSION=2.5.4 diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 186a80cca..2401dd4ec 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -116,7 +116,7 @@ V = { "BROTLI": "1.1.0", "FREETYPE": "2.14.1", "FRIBIDI": "1.0.16", - "HARFBUZZ": "12.1.0", + "HARFBUZZ": "12.2.0", "JPEGTURBO": "3.1.2", "LCMS2": "2.17", "LIBAVIF": "1.3.0", From 18c7f87fe3180e494c9397d63f917aa9f05d870c Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 5 Nov 2025 23:21:46 +1100 Subject: [PATCH 31/43] Added Fedora 43 --- .github/workflows/test-docker.yml | 1 + docs/installation/platform-support.rst | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 581e1f52b..213062ee2 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -50,6 +50,7 @@ jobs: debian-13-trixie-x86, debian-13-trixie-amd64, fedora-42-amd64, + fedora-43-amd64, gentoo, ubuntu-22.04-jammy-amd64, ubuntu-24.04-noble-amd64, diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index e0c4a8eec..17e38719a 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -35,6 +35,8 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | Fedora 42 | 3.13 | x86-64 | +----------------------------------+----------------------------+---------------------+ +| Fedora 43 | 3.14 | x86-64 | ++----------------------------------+----------------------------+---------------------+ | Gentoo | 3.12 | x86-64 | +----------------------------------+----------------------------+---------------------+ | macOS 15 Sequoia | 3.10 | x86-64 | From 142c1320b23fa645dd115e8b979407518e3cd696 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 14 Nov 2025 20:08:49 +1100 Subject: [PATCH 32/43] Apply encoder options when saving multiple PNG frames --- Tests/test_file_apng.py | 20 ++++++++++++++++++++ src/PIL/PngImagePlugin.py | 25 ++++++++++++++----------- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 12204b5b7..d918a24a7 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -1,5 +1,6 @@ from __future__ import annotations +from io import BytesIO from pathlib import Path import pytest @@ -718,6 +719,25 @@ def test_apng_save_size(tmp_path: Path) -> None: assert reloaded.size == (200, 200) +def test_compress_level() -> None: + compress_level_sizes = {} + for compress_level in (0, 9): + out = BytesIO() + + im = Image.new("L", (100, 100)) + im.save( + out, + "PNG", + save_all=True, + append_images=[Image.new("L", (200, 200))], + compress_level=compress_level, + ) + + compress_level_sizes[compress_level] = len(out.getvalue()) + + assert compress_level_sizes[0] > compress_level_sizes[9] + + def test_seek_after_close() -> None: im = Image.open("Tests/images/apng/delay.png") im.seek(1) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index d0f22f812..b89c10da3 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1152,6 +1152,15 @@ class _fdat: self.seq_num += 1 +def _apply_encoderinfo(im: Image.Image, encoderinfo: dict[str, Any]) -> None: + im.encoderconfig = ( + encoderinfo.get("optimize", False), + encoderinfo.get("compress_level", -1), + encoderinfo.get("compress_type", -1), + encoderinfo.get("dictionary", b""), + ) + + class _Frame(NamedTuple): im: Image.Image bbox: tuple[int, int, int, int] | None @@ -1245,10 +1254,10 @@ def _write_multiple_frames( # default image IDAT (if it exists) if default_image: - if im.mode != mode: - im = im.convert(mode) + default_im = im if im.mode == mode else im.convert(mode) + _apply_encoderinfo(default_im, im.encoderinfo) ImageFile._save( - im, + default_im, cast(IO[bytes], _idat(fp, chunk)), [ImageFile._Tile("zip", (0, 0) + im.size, 0, rawmode)], ) @@ -1282,6 +1291,7 @@ def _write_multiple_frames( ) seq_num += 1 # frame data + _apply_encoderinfo(im_frame, im.encoderinfo) if frame == 0 and not default_image: # first frame must be in IDAT chunks for backwards compatibility ImageFile._save( @@ -1357,14 +1367,6 @@ def _save( bits = 4 outmode += f";{bits}" - # encoder options - im.encoderconfig = ( - im.encoderinfo.get("optimize", False), - im.encoderinfo.get("compress_level", -1), - im.encoderinfo.get("compress_type", -1), - im.encoderinfo.get("dictionary", b""), - ) - # get the corresponding PNG mode try: rawmode, bit_depth, color_type = _OUTMODES[outmode] @@ -1494,6 +1496,7 @@ def _save( im, fp, chunk, mode, rawmode, default_image, append_images ) if single_im: + _apply_encoderinfo(single_im, im.encoderinfo) ImageFile._save( single_im, cast(IO[bytes], _idat(fp, chunk)), From bc1237ef3d22f7407fbeb3de19c1c867ed5dfd0a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 14 Nov 2025 21:22:58 +1100 Subject: [PATCH 33/43] Update dependency cibuildwheel to v3.3.0 --- .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 56517374f..485866de6 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==3.2.1 +cibuildwheel==3.3.0 From 6107b9e82d93b29ca86a4261eec832de48ee45f8 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 15 Nov 2025 07:41:59 +1100 Subject: [PATCH 34/43] Update libimagequant to 4.4.1 --- depends/install_imagequant.sh | 2 +- docs/installation/building-from-source.rst | 2 +- winbuild/build_prepare.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/depends/install_imagequant.sh b/depends/install_imagequant.sh index 357214f1f..de63abdec 100755 --- a/depends/install_imagequant.sh +++ b/depends/install_imagequant.sh @@ -2,7 +2,7 @@ # install libimagequant archive_name=libimagequant -archive_version=4.4.0 +archive_version=4.4.1 archive=$archive_name-$archive_version diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst index 6080d29af..4349f9804 100644 --- a/docs/installation/building-from-source.rst +++ b/docs/installation/building-from-source.rst @@ -64,7 +64,7 @@ Many of Pillow's features require external libraries: * **libimagequant** provides improved color quantization - * Pillow has been tested with libimagequant **2.6-4.4.0** + * Pillow has been tested with libimagequant **2.6-4.4.1** * Libimagequant is licensed GPLv3, which is more restrictive than the Pillow license, therefore we will not be distributing binaries with libimagequant support enabled. diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 186a80cca..6cbc5c1e1 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -120,7 +120,7 @@ V = { "JPEGTURBO": "3.1.2", "LCMS2": "2.17", "LIBAVIF": "1.3.0", - "LIBIMAGEQUANT": "4.4.0", + "LIBIMAGEQUANT": "4.4.1", "LIBPNG": "1.6.50", "LIBWEBP": "1.6.0", "OPENJPEG": "2.5.4", From 88247a9ef38d8eba9610393ddfe34616e108257d Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Wed, 19 Nov 2025 21:31:27 +1100 Subject: [PATCH 35/43] Updated version --- docs/reference/ImageGrab.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/ImageGrab.rst b/docs/reference/ImageGrab.rst index e7dd41de1..413866785 100644 --- a/docs/reference/ImageGrab.rst +++ b/docs/reference/ImageGrab.rst @@ -48,7 +48,7 @@ or the clipboard to a PIL image memory. CGWindowID. .. versionadded:: 11.2.1 Windows support - .. versionadded:: 12.0.0 macOS support + .. versionadded:: 12.1.0 macOS support :return: An image .. py:function:: grabclipboard() From cce73b1e892a14d8d48146ac6ad3500be4d40d44 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 19 Nov 2025 21:52:21 +1100 Subject: [PATCH 36/43] Close image on ImageFont exception --- src/PIL/ImageFont.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 92eb763a5..2e8ace98d 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -127,11 +127,15 @@ class ImageFont: def _load_pilfont_data(self, file: IO[bytes], image: Image.Image) -> None: # check image if image.mode not in ("1", "L"): + image.close() + msg = "invalid font image mode" raise TypeError(msg) # read PILfont header if file.read(8) != b"PILfont\n": + image.close() + msg = "Not a PILfont file" raise SyntaxError(msg) file.readline() From 7055937eb15a66209ebf8f5e275e58cbd56ca629 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 22 Nov 2025 17:47:09 +1100 Subject: [PATCH 37/43] Updated Ubuntu version --- docs/installation/building-from-source.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst index 6080d29af..40e2a1938 100644 --- a/docs/installation/building-from-source.rst +++ b/docs/installation/building-from-source.rst @@ -116,7 +116,7 @@ Many of Pillow's features require external libraries: .. Note:: ``redhat-rpm-config`` is required on Fedora 23, but not earlier versions. - Prerequisites for **Ubuntu 16.04 LTS - 22.04 LTS** are installed with:: + Prerequisites for **Ubuntu 16.04 LTS - 24.04 LTS** are installed with:: sudo apt-get install libtiff5-dev libjpeg8-dev libopenjp2-7-dev zlib1g-dev \ libfreetype6-dev liblcms2-dev libwebp-dev tcl8.6-dev tk8.6-dev python3-tk \ From 8814d42fd96ea8c86e5aa3bc4970066ade5de489 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 29 Nov 2025 14:24:43 +1100 Subject: [PATCH 38/43] Update zlib-ng to 2.3.1, except on manylinux2014 aarch64 --- .github/workflows/wheels-dependencies.sh | 21 ++++++++++----------- winbuild/build_prepare.py | 6 +++--- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 226fcdb6a..194c51a94 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -106,7 +106,11 @@ XZ_VERSION=5.8.1 ZSTD_VERSION=1.5.7 TIFF_VERSION=4.7.1 LCMS2_VERSION=2.17 -ZLIB_NG_VERSION=2.2.5 +if [[ "$MB_ML_VER" == 2014 ]] && [[ "$PLAT" == "aarch64" ]]; then + ZLIB_NG_VERSION=2.2.5 +else + ZLIB_NG_VERSION=2.3.1 +fi LIBWEBP_VERSION=1.6.0 BZIP2_VERSION=1.0.8 LIBXCB_VERSION=1.17.0 @@ -149,18 +153,13 @@ function build_zlib_ng { ORIGINAL_HOST_CONFIGURE_FLAGS=$HOST_CONFIGURE_FLAGS unset HOST_CONFIGURE_FLAGS - build_github zlib-ng/zlib-ng $ZLIB_NG_VERSION --zlib-compat + if [[ "$ZLIB_NG_VERSION" == 2.2.5 ]]; then + build_github zlib-ng/zlib-ng $ZLIB_NG_VERSION --zlib-compat + else + build_github zlib-ng/zlib-ng $ZLIB_NG_VERSION --installnamedir=$BUILD_PREFIX/lib --zlib-compat + fi HOST_CONFIGURE_FLAGS=$ORIGINAL_HOST_CONFIGURE_FLAGS - - if [[ -n "$IS_MACOS" ]] && [[ -z "$IOS_SDK" ]]; then - # Ensure that on macOS, the library name is an absolute path, not an - # @rpath, so that delocate picks up the right library (and doesn't need - # DYLD_LIBRARY_PATH to be set). The default Makefile doesn't have an - # option to control the install_name. This isn't needed on iOS, as iOS - # only builds the static library. - install_name_tool -id $BUILD_PREFIX/lib/libz.1.dylib $BUILD_PREFIX/lib/libz.1.dylib - fi touch zlib-stamp } diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 2401dd4ec..65d0af481 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -126,7 +126,7 @@ V = { "OPENJPEG": "2.5.4", "TIFF": "4.7.1", "XZ": "5.8.1", - "ZLIBNG": "2.2.5", + "ZLIBNG": "2.3.1", } V["LIBPNG_XY"] = "".join(V["LIBPNG"].split(".")[:2]) @@ -167,12 +167,12 @@ DEPS: dict[str, dict[str, Any]] = { "license": "LICENSE.md", "patch": { r"CMakeLists.txt": { - "set_target_properties(zlib PROPERTIES OUTPUT_NAME zlibstatic${{SUFFIX}})": "set_target_properties(zlib PROPERTIES OUTPUT_NAME zlib)", # noqa: E501 + "set_target_properties(zlib-ng PROPERTIES OUTPUT_NAME zlibstatic${{SUFFIX}})": "set_target_properties(zlib-ng PROPERTIES OUTPUT_NAME zlib)", # noqa: E501 }, }, "build": [ *cmds_cmake( - "zlib", "-DBUILD_SHARED_LIBS:BOOL=OFF", "-DZLIB_COMPAT:BOOL=ON" + "zlib-ng", "-DBUILD_SHARED_LIBS:BOOL=OFF", "-DZLIB_COMPAT:BOOL=ON" ), ], "headers": [r"z*.h"], From 37da2ba381ecb47fb7a06af88fe6ed9dc64349f7 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 29 Nov 2025 17:22:44 +1100 Subject: [PATCH 39/43] Corrected allocating new color to RGBA palette --- Tests/test_imagepalette.py | 6 ++++++ src/PIL/ImagePalette.py | 9 +++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Tests/test_imagepalette.py b/Tests/test_imagepalette.py index 782022f51..6ad21502f 100644 --- a/Tests/test_imagepalette.py +++ b/Tests/test_imagepalette.py @@ -49,6 +49,12 @@ def test_getcolor() -> None: palette.getcolor("unknown") # type: ignore[arg-type] +def test_getcolor_rgba() -> None: + palette = ImagePalette.ImagePalette("RGBA", (1, 2, 3, 4)) + palette.getcolor((5, 6, 7, 8)) + assert palette.palette == b"\x01\x02\x03\x04\x05\x06\x07\x08" + + def test_getcolor_rgba_color_rgb_palette() -> None: palette = ImagePalette.ImagePalette("RGB") diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index 103697117..eae7aea8f 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -118,7 +118,7 @@ class ImagePalette: ) -> int: if not isinstance(self.palette, bytearray): self._palette = bytearray(self.palette) - index = len(self.palette) // 3 + index = len(self.palette) // len(self.mode) special_colors: tuple[int | tuple[int, ...] | None, ...] = () if image: special_colors = ( @@ -168,11 +168,12 @@ class ImagePalette: index = self._new_color_index(image, e) assert isinstance(self._palette, bytearray) self.colors[color] = index - if index * 3 < len(self.palette): + mode_len = len(self.mode) + if index * mode_len < len(self.palette): self._palette = ( - self._palette[: index * 3] + self._palette[: index * mode_len] + bytes(color) - + self._palette[index * 3 + 3 :] + + self._palette[index * mode_len + mode_len :] ) else: self._palette += bytes(color) From 65c32ecca4019984862aa9caa916fb2196e1cb2d Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 30 Nov 2025 21:55:59 +0200 Subject: [PATCH 40/43] retina -> Retina --- src/PIL/ImageGrab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index b82a2ff3a..4228078b1 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -54,7 +54,7 @@ def grab( os.unlink(filepath) if bbox: if window: - # Determine if the window was in retina mode or not + # Determine if the window was in Retina mode or not # by capturing it without the shadow, # and checking how different the width is fh, filepath = tempfile.mkstemp(".png") From 370da461cf5e1226376d7ea591265a84dc5a5b06 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:02:09 +1100 Subject: [PATCH 41/43] Updated libpng to 1.6.51 (#9305) Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- .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 e033b69a9..07ea75a75 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -96,7 +96,7 @@ else FREETYPE_VERSION=2.14.1 fi HARFBUZZ_VERSION=12.2.0 -LIBPNG_VERSION=1.6.50 +LIBPNG_VERSION=1.6.51 JPEGTURBO_VERSION=3.1.2 OPENJPEG_VERSION=2.5.4 XZ_VERSION=5.8.1 diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 99421ebe2..cd2ef13c1 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -121,7 +121,7 @@ V = { "LCMS2": "2.17", "LIBAVIF": "1.3.0", "LIBIMAGEQUANT": "4.4.1", - "LIBPNG": "1.6.50", + "LIBPNG": "1.6.51", "LIBWEBP": "1.6.0", "OPENJPEG": "2.5.4", "TIFF": "4.7.1", From 7adecb792c07023cc6c7b410323bb4eabe5d2a23 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 07:14:12 +0000 Subject: [PATCH 42/43] Update dependency mypy to v1.19.0 --- .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 6ca35d286..5b0e2eaf8 100644 --- a/.ci/requirements-mypy.txt +++ b/.ci/requirements-mypy.txt @@ -1,4 +1,4 @@ -mypy==1.18.2 +mypy==1.19.0 arro3-compute arro3-core IceSpringPySideStubs-PyQt6 From b633f49b9c58079975628f2a7eb5acf7118483a5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 07:14:19 +0000 Subject: [PATCH 43/43] Update actions/checkout action to v6 --- .github/workflows/docs.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/test-docker.yml | 2 +- .github/workflows/test-mingw.yml | 2 +- .github/workflows/test-valgrind-memory.yml | 2 +- .github/workflows/test-valgrind.yml | 2 +- .github/workflows/test-windows.yml | 6 +++--- .github/workflows/test.yml | 2 +- .github/workflows/wheels.yml | 8 ++++---- 9 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index cf917407c..e88abf16f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -32,7 +32,7 @@ jobs: name: Docs steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: persist-credentials: false diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2addbaf67..77d1d1caa 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -20,7 +20,7 @@ jobs: name: Lint steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: persist-credentials: false diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 213062ee2..091edb222 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -68,7 +68,7 @@ jobs: name: ${{ matrix.docker }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: persist-credentials: false diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml index 6c4206083..e247414c8 100644 --- a/.github/workflows/test-mingw.yml +++ b/.github/workflows/test-mingw.yml @@ -45,7 +45,7 @@ jobs: steps: - name: Checkout Pillow - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false diff --git a/.github/workflows/test-valgrind-memory.yml b/.github/workflows/test-valgrind-memory.yml index 0f36fe30d..bd244aa5a 100644 --- a/.github/workflows/test-valgrind-memory.yml +++ b/.github/workflows/test-valgrind-memory.yml @@ -41,7 +41,7 @@ jobs: name: ${{ matrix.docker }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: persist-credentials: false diff --git a/.github/workflows/test-valgrind.yml b/.github/workflows/test-valgrind.yml index 30caa0d4e..81cfb8456 100644 --- a/.github/workflows/test-valgrind.yml +++ b/.github/workflows/test-valgrind.yml @@ -39,7 +39,7 @@ jobs: name: ${{ matrix.docker }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: persist-credentials: false diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 02d4da999..c4d0fa046 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -47,19 +47,19 @@ jobs: steps: - name: Checkout Pillow - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Checkout cached dependencies - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false repository: python-pillow/pillow-depends path: winbuild\depends - name: Checkout extra test images - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false repository: python-pillow/test-images diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef7b34b8d..167faa239 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,7 +65,7 @@ jobs: name: ${{ matrix.os }} Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: persist-credentials: false diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index e33d74a81..fb71ead37 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -107,7 +107,7 @@ jobs: os: macos-15-intel cibw_arch: x86_64_iphonesimulator steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: persist-credentials: false submodules: true @@ -154,12 +154,12 @@ jobs: - cibw_arch: ARM64 os: windows-11-arm steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: persist-credentials: false - name: Checkout extra test images - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false repository: python-pillow/test-images @@ -235,7 +235,7 @@ jobs: if: github.event_name != 'schedule' || github.repository_owner == 'python-pillow' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: persist-credentials: false