From 5ce88dbe53a3d46e20d464fbd6ef06031645bfda Mon Sep 17 00:00:00 2001 From: GUO YANKE Date: Mon, 7 Jul 2025 13:57:11 +0800 Subject: [PATCH 01/74] 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 02/74] 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 03/74] 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 04/74] 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 05/74] 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 06/74] 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 51e3fe45bf34fb4c344eaaaadf3434a079c5b6dd Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 20 Oct 2025 19:17:38 +1100 Subject: [PATCH 07/74] 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 208bbe95f9ccaf5659d5b246e18ce76bcefeea84 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 22 Oct 2025 22:22:00 +1100 Subject: [PATCH 08/74] 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 09/74] 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 10/74] 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 11/74] 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 12/74] 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 dfd24ba6150ea3803099d445ce1a486fb7e87c13 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 30 Oct 2025 22:03:39 +1100 Subject: [PATCH 13/74] 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 14/74] 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 15/74] 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 921a470506bdf7cc82d78d6ff4915b6aa5420b7b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 6 Nov 2025 18:24:10 +1100 Subject: [PATCH 16/74] Simplified code --- src/_imaging.c | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/_imaging.c b/src/_imaging.c index f6be4a901..d2a195887 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -2459,7 +2459,6 @@ _merge(PyObject *self, PyObject *args) { static PyObject * _split(ImagingObject *self) { - int fails = 0; Py_ssize_t i; PyObject *list; PyObject *imaging_object; @@ -2473,14 +2472,12 @@ _split(ImagingObject *self) { for (i = 0; i < self->image->bands; i++) { imaging_object = PyImagingNew(bands[i]); if (!imaging_object) { - fails += 1; + Py_DECREF(list); + list = NULL; + break; } PyTuple_SET_ITEM(list, i, imaging_object); } - if (fails) { - Py_DECREF(list); - list = NULL; - } return list; } From 142c1320b23fa645dd115e8b979407518e3cd696 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 14 Nov 2025 20:08:49 +1100 Subject: [PATCH 17/74] 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 6107b9e82d93b29ca86a4261eec832de48ee45f8 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 15 Nov 2025 07:41:59 +1100 Subject: [PATCH 18/74] 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 19/74] 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 20/74] 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 8814d42fd96ea8c86e5aa3bc4970066ade5de489 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 29 Nov 2025 14:24:43 +1100 Subject: [PATCH 21/74] 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 22/74] 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 23/74] 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 24/74] 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 ce3e08575164756e5dcbcc07d49105cd9e8d5c55 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:26:21 +0000 Subject: [PATCH 25/74] [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.14.3 → v0.14.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.14.3...v0.14.7) - [github.com/psf/black-pre-commit-mirror: 25.9.0 → 25.11.0](https://github.com/psf/black-pre-commit-mirror/compare/25.9.0...25.11.0) - [github.com/PyCQA/bandit: 1.8.6 → 1.9.2](https://github.com/PyCQA/bandit/compare/1.8.6...1.9.2) - [github.com/pre-commit/mirrors-clang-format: v21.1.2 → v21.1.6](https://github.com/pre-commit/mirrors-clang-format/compare/v21.1.2...v21.1.6) - [github.com/python-jsonschema/check-jsonschema: 0.34.1 → 0.35.0](https://github.com/python-jsonschema/check-jsonschema/compare/0.34.1...0.35.0) - [github.com/zizmorcore/zizmor-pre-commit: v1.16.2 → v1.18.0](https://github.com/zizmorcore/zizmor-pre-commit/compare/v1.16.2...v1.18.0) - [github.com/sphinx-contrib/sphinx-lint: v1.0.1 → v1.0.2](https://github.com/sphinx-contrib/sphinx-lint/compare/v1.0.1...v1.0.2) - [github.com/tox-dev/pyproject-fmt: v2.11.0 → v2.11.1](https://github.com/tox-dev/pyproject-fmt/compare/v2.11.0...v2.11.1) --- .pre-commit-config.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 564206ce1..8477729e6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,17 +1,17 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.3 + rev: v0.14.7 hooks: - id: ruff-check args: [--exit-non-zero-on-fix] - repo: https://github.com/psf/black-pre-commit-mirror - rev: 25.9.0 + rev: 25.11.0 hooks: - id: black - repo: https://github.com/PyCQA/bandit - rev: 1.8.6 + rev: 1.9.2 hooks: - id: bandit args: [--severity-level=high] @@ -24,7 +24,7 @@ repos: exclude: (Makefile$|\.bat$|\.cmake$|\.eps$|\.fits$|\.gd$|\.opt$) - repo: https://github.com/pre-commit/mirrors-clang-format - rev: v21.1.2 + rev: v21.1.6 hooks: - id: clang-format types: [c] @@ -51,24 +51,24 @@ repos: exclude: ^\.github/.*TEMPLATE|^Tests/(fonts|images)/ - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.34.1 + rev: 0.35.0 hooks: - id: check-github-workflows - id: check-readthedocs - id: check-renovate - repo: https://github.com/zizmorcore/zizmor-pre-commit - rev: v1.16.2 + rev: v1.18.0 hooks: - id: zizmor - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v1.0.1 + rev: v1.0.2 hooks: - id: sphinx-lint - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.11.0 + rev: v2.11.1 hooks: - id: pyproject-fmt From 9342e209b2176bde761b321a74846857257ea78c Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 2 Dec 2025 10:21:55 +1100 Subject: [PATCH 26/74] Disable https://docs.zizmor.sh/audits/#obfuscation --- .github/zizmor.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/zizmor.yml b/.github/zizmor.yml index b56709781..e60c79441 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -1,6 +1,8 @@ # Configuration for the zizmor static analysis tool, run via pre-commit in CI # https://docs.zizmor.sh/configuration/ rules: + obfuscation: + disable: true unpinned-uses: config: policies: From 47c6aae0cae6b5c4e956d4e56da7f189575ada9a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 2 Dec 2025 10:40:43 +1100 Subject: [PATCH 27/74] Fixed testing good P mode BMP images --- Tests/test_bmp_reference.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/test_bmp_reference.py b/Tests/test_bmp_reference.py index 82cab39c6..3cd0fbb2d 100644 --- a/Tests/test_bmp_reference.py +++ b/Tests/test_bmp_reference.py @@ -72,7 +72,7 @@ def test_good() -> None: "pal8-0.bmp": "pal8.png", "pal8rle.bmp": "pal8.png", "pal8topdown.bmp": "pal8.png", - "pal8nonsquare.bmp": "pal8nonsquare-v.png", + "pal8nonsquare.bmp": "pal8nonsquare-e.png", "pal8os2.bmp": "pal8.png", "pal8os2sp.bmp": "pal8.png", "pal8os2v2.bmp": "pal8.png", @@ -103,7 +103,7 @@ def test_good() -> None: # with paletized image, since the palette might # be differently ordered for an equivalent image. im = im.convert("RGBA") - compare = im.convert("RGBA") + compare = compare.convert("RGBA") assert_image_similar(im, compare, 5) except Exception as msg: From b3d9ba8e886147201acbd94c5c5f2cc4b3513311 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 2 Dec 2025 10:42:14 +1100 Subject: [PATCH 28/74] Removed unused files --- Tests/images/bmp/html/bkgd.png | Bin 126 -> 0 bytes Tests/images/bmp/html/bmpsuite.html | 578 ---------------------- Tests/images/bmp/html/fakealpha.png | Bin 1181 -> 0 bytes Tests/images/bmp/html/pal1p1.png | Bin 124 -> 0 bytes Tests/images/bmp/html/pal2.png | Bin 961 -> 0 bytes Tests/images/bmp/html/pal4rletrns-0.png | Bin 1441 -> 0 bytes Tests/images/bmp/html/pal4rletrns-b.png | Bin 1362 -> 0 bytes Tests/images/bmp/html/pal4rletrns.png | Bin 1465 -> 0 bytes Tests/images/bmp/html/pal8nonsquare-v.png | Bin 11576 -> 0 bytes Tests/images/bmp/html/pal8rletrns-0.png | Bin 3776 -> 0 bytes Tests/images/bmp/html/pal8rletrns-b.png | Bin 3715 -> 0 bytes Tests/images/bmp/html/pal8rletrns.png | Bin 3793 -> 0 bytes Tests/images/bmp/html/rgb16-231.png | Bin 2643 -> 0 bytes Tests/images/bmp/html/rgb24.jpg | Bin 2319 -> 0 bytes Tests/images/bmp/html/rgba16-4444.png | Bin 1093 -> 0 bytes Tests/images/bmp/html/rgba32.png | Bin 1229 -> 0 bytes 16 files changed, 578 deletions(-) delete mode 100644 Tests/images/bmp/html/bkgd.png delete mode 100644 Tests/images/bmp/html/bmpsuite.html delete mode 100644 Tests/images/bmp/html/fakealpha.png delete mode 100644 Tests/images/bmp/html/pal1p1.png delete mode 100644 Tests/images/bmp/html/pal2.png delete mode 100644 Tests/images/bmp/html/pal4rletrns-0.png delete mode 100644 Tests/images/bmp/html/pal4rletrns-b.png delete mode 100644 Tests/images/bmp/html/pal4rletrns.png delete mode 100644 Tests/images/bmp/html/pal8nonsquare-v.png delete mode 100644 Tests/images/bmp/html/pal8rletrns-0.png delete mode 100644 Tests/images/bmp/html/pal8rletrns-b.png delete mode 100644 Tests/images/bmp/html/pal8rletrns.png delete mode 100644 Tests/images/bmp/html/rgb16-231.png delete mode 100644 Tests/images/bmp/html/rgb24.jpg delete mode 100644 Tests/images/bmp/html/rgba16-4444.png delete mode 100644 Tests/images/bmp/html/rgba32.png diff --git a/Tests/images/bmp/html/bkgd.png b/Tests/images/bmp/html/bkgd.png deleted file mode 100644 index d66ca9d65263950295e774210a32056350c15901..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 126 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!D3?x-;bCrM;V{wqX6T`Z5GB1G~wg8_H*9GU# zPoMr@$gd<0D8gCb5n0T@z%2~Ij105pNB{)|JzX3_IIbuEIDeo)G*SF7KSSMq{*SLd S3ttCHGI+ZBxvX - - - - -BMP Suite Image List - - - - - - - -

BMP Suite Image List

- -

For BMP Suite -version 2.3

- -

This document describes the images in BMP Suite, and shows what -I allege to be the correct way to interpret them. PNG and JPEG images are -used for reference. -

- -

It also shows how your web browser displays the BMP images, -but that’s not its main purpose. -BMP is poor image format to use on web pages, so a web browser’s -level of support for it is arguably not important.

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FileVer.Correct displayIn your browserNotes
g/pal1.bmp31 bit/pixel paletted image, in which black is the first color in - the palette.
g/pal1wb.bmp31 bit/pixel paletted image, in which white is the first color in - the palette.
g/pal1bg.bmp31 bit/pixel paletted image, with colors other than black and white.
q/pal1p1.bmp31 bit/pixel paletted image, with only one color in the palette. - The documentation says that 1-bpp images have a palette size of 2 - (not “up to 2”), but it would be silly for a viewer not to - support a size of 1.
q/pal2.bmp3A paletted image with 2 bits/pixel. Usually only 1, 4, - and 8 are allowed, but 2 is legal on Windows CE.
g/pal4.bmp3Paletted image with 12 palette colors, and 4 bits/pixel.
g/pal4rle.bmp34-bit image that uses RLE compression.
q/pal4rletrns.bmp3
- or

- or
An RLE-compressed image that used “delta” - codes to skip over some pixels, leaving them undefined. Some viewers - make undefined pixels transparent, others make them black, and - others assign them palette color 0 (purple, in this case).
g/pal8.bmp3Our standard paletted image, with 252 palette colors, and 8 - bits/pixel.
g/pal8-0.bmp3Every field that can be set to 0 is set to 0: pixels/meter=0; - colors used=0 (meaning the default 256); size-of-image=0.
g/pal8rle.bmp38-bit image that uses RLE compression.
q/pal8rletrns.bmp3
- or

- or
8-bit version of q/pal4rletrns.bmp.
g/pal8w126.bmp3Images with different widths and heights. - In BMP format, rows are padded to a multiple of four bytes, so we - test all four possibilities.
g/pal8w125.bmp3
g/pal8w124.bmp3
g/pal8topdown.bmp3BMP images are normally stored from the bottom up, but - there is a way to store them from the top down.
q/pal8offs.bmp3A file with some unused bytes between the palette and the - image. This is probably valid, but I’m not 100% sure.
q/pal8oversizepal.bmp3An 8-bit image with 300 palette colors. This may be invalid, - because the documentation could - be interpreted to imply that 8-bit images aren’t allowed - to have more than 256 colors.
g/pal8nonsquare.bmp3 -
- or
- -
An image with non-square pixels: the X pixels/meter is twice - the Y pixels/meter. Image editors can be expected to - leave the image “squashed”; image viewers should - consider stretching it to its correct proportions.
g/pal8os2.bmpOS/2v1An OS/2-style bitmap.
q/pal8os2sp.bmpOS/2v1An OS/2v1 with a less-than-full-sized palette. - Probably not valid, but such files have been seen in the wild.
q/pal8os2v2.bmpOS/2v2My attempt to make an OS/2v2 bitmap.
q/pal8os2v2-16.bmpOS/2v2An OS/2v2 bitmap whose header has only 16 bytes, instead of the full 64.
g/pal8v4.bmp4A v4 bitmap. I’m not sure that the gamma and chromaticity values in - this file are sensible, because I can’t find any detailed documentation - of them.
g/pal8v5.bmp5A v5 bitmap. Version 5 has additional colorspace options over v4, so it - is easier to create, and ought to be more portable.
g/rgb16.bmp3A 16-bit image with the default color format: 5 bits each for red, - green, and blue, and 1 unused bit. - The whitest colors should (I assume) be displayed as pure white: - (255,255,255), not - (248,248,248).
g/rgb16-565.bmp3A 16-bit image with a BITFIELDS segment indicating 5 red, 6 green, - and 5 blue bits. This is a standard 16-bit format, even supported by - old versions of Windows that don’t support any other non-default 16-bit - formats. - The whitest colors should be displayed as pure white: - (255,255,255), not - (248,252,248).
g/rgb16-565pal.bmp3A 16-bit image with both a BITFIELDS segment and a palette.
q/rgb16-231.bmp3An unusual and silly 16-bit image, with 2 red bits, 3 green bits, and 1 - blue bit. Most viewers do support this image, but the colors may be darkened - with a yellow-green shadow. That’s because they’re doing simple - bit-shifting (possibly including one round of bit replication), instead of - proper scaling.
q/rgba16-4444.bmp5A 16-bit image with an alpha channel. There are 4 bits for each color - channel, and 4 bits for the alpha channel. - It’s not clear if this is valid, but I can’t find anything that - suggests it isn’t. -
g/rgb24.bmp3A perfectly ordinary 24-bit (truecolor) image.
g/rgb24pal.bmp3A 24-bit image, with a palette containing 256 colors. There is little if - any reason for a truecolor image to contain a palette, but it is legal.
q/rgb24largepal.bmp3A 24-bit image, with a palette containing 300 colors. - The fact that the palette has more than 256 colors may cause some viewers - to complain, but the documentation does not mention a size limit.
q/rgb24prof.bmp5My attempt to make a BMP file with an embedded color profile.
q/rgb24lprof.bmp5My attempt to make a BMP file with a linked color profile.
q/rgb24jpeg.bmp5My attempt to make BMP files with embedded JPEG and PNG images. - These are not likely to be supported by much of anything (they’re - intended for printers).
q/rgb24png.bmp5
g/rgb32.bmp3A 32-bit image using the default color format for 32-bit images (no - BITFIELDS segment). There are 8 bits per color channel, and 8 unused - bits. The unused bits are set to 0.
g/rgb32bf.bmp3A 32-bit image with a BITFIELDS segment. As usual, there are 8 bits per - color channel, and 8 unused bits. But the color channels are in an unusual - order, so the viewer must read the BITFIELDS, and not just guess.
q/rgb32fakealpha.bmp3
- or
- -
Same as g/rgb32.bmp, except that the unused bits are set to something - other than 0. - If the image becomes transparent toward the bottom, it probably means - the viewer uses heuristics to guess whether the undefined - data represents transparency.
q/rgb32-111110.bmp3A 32 bits/pixel image, with all 32 bits used: 11 each for red and - green, and 10 for blue. As far as I know, this is perfectly valid, but it - is unusual.
q/rgba32.bmp5A BMP with an alpha channel. Transparency is barely documented, - so it’s possible that this file is not correctly formed. - The color channels are in an unusual order, to prevent viewers from - passing this test by making a lucky guess.
q/rgba32abf.bmp3An image of type BI_ALHPABITFIELDS. Supposedly, this was used on - Windows CE. I don’t know whether it is constructed correctly.
b/badbitcount.bmp3N/AHeader indicates an absurdly large number of bits/pixel.
b/badbitssize.bmp3N/AHeader incorrectly indicates that the bitmap is several GB in size.
b/baddens1.bmp3N/ADensity (pixels per meter) suggests the image is much - larger in one dimension than the other.
b/baddens2.bmp3N/A
b/badfilesize.bmp3N/AHeader incorrectly indicates that the file is several GB in size.
b/badheadersize.bmp?N/AHeader size is 66 bytes, which is not a valid size for any known BMP - version.
b/badpalettesize.bmp3N/AHeader incorrectly indicates that the palette contains an absurdly large - number of colors.
b/badplanes.bmp3N/AThe “planes” setting, which is required to be 1, is not 1.
b/badrle.bmp3N/AAn invalid RLE-compressed image that tries to cause buffer overruns.
b/badwidth.bmp3N/AThe image claims to be a negative number of pixels in width.
b/pal8badindex.bmp3N/AMany of the palette indices used in the image are not present in the - palette.
b/reallybig.bmp3N/AAn image with a very large reported width and height.
b/rletopdown.bmp3N/AAn RLE-compressed image that tries to use top-down orientation, - which isn’t allowed.
b/shortfile.bmp3N/AA file that has been truncated in the middle of the bitmap.
- - - - diff --git a/Tests/images/bmp/html/fakealpha.png b/Tests/images/bmp/html/fakealpha.png deleted file mode 100644 index 89292bcbb4804bf7ab9f0a46b48c3664235b96ee..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1181 zcmeAS@N?(olHy`uVBq!ia0vp^^+4>v!3HFaC$7i_QjEnx?oJHr&dIz4aySb-B8wRq zxP?KOkzv*x2?hohCr=m0kcwMx=h*uQ2g)4Zf8HYIvC`t4O$XIhFBTS>9ih~$#xtX2 zLPpL~l>%L<7l}#=Zi|^!+Gbc(F?W_;x)^A7BcWN)%;VRj=Z6y4@7;Yb_I-cu{`a3I zWxxM^Z}0ctd+t~7|GxWM3zLWm?*$Wu$p@eAyqs=t#CV#6BSYMsZRY9X<>%{Cnx+Xd zWtPd`zI~g)MeFA(7L`jrFYnoYWz$$1wA=ilupM^vo#~HNfdAI-U$;EGp80c2%^`IR z#6Mxv;>Gi>pV#jTp7U!}ep>pmueTPzQD!*seeU~-hs@9FFg*D9c<$-SC@eyEd0LeqT|{^6AGVm_j)c3 zQMtM5{0T{+v+jrgzI*a+OI-fM?|J{$$6H3v{r39fm(?Lva*=c6ECYW8K6P`Qs(!lf zgYL}Rleaq`{{Odn)6+V?50775zZ3I^Sz&5sJjcb2%}b{I3hytOF6xx<<)Oy6eQ#K2 zeJd|t9wTEkGh+XvGPd1$XT_biXt`EthjKJdTq!71WA}lzzP|pJ^G~CVbze>X_%5Ar z|I_#7y;1wbZl!N#J8EGoQ+m>-SS^~ zjmO|a3Pbw)^Es{l{_}6HIa~X8+FbJ!%S*q1*&@%fFVe*NQt4-g6E>$E_J%U3oIBrb zchH@Ge~jLD^*@hVCpEkLn8g@+Kcx2if=Y&tMFnfO3vUn>KDv(MZ`suC_jx?xFV=3a z%TMK3=2q5aKCtrM!-t}8-s`LU(mB9c;(zGFl|`v%DXg=Y`aS$jsj*b#Q-8=9gtp z+k0C~{jR@0{rbZ5uL0IglYa{H?2+5B_3^K-Kl|qH^i!(o%`7amoanwccIz|q_1BMO zGhYody;AmJO-b9CEH;J(nM)b6D{Ee@3W;MB=(K(8FIwlQnIlr`_QNczk9onKsAExy zx+2D*0RqylEM;=H+ z-0{bM+nVCjDu3dd_KPYVa{p2L>g{f=?hm`!>#j^RIP_llbUs^rZGh^ZyaxGDmNQwf3r+`sj#(G(tC`$Z@<6 z)a^{*MpU-~{QHd!K<$AFQ=>Uciv$P6So z|6H>LCV`X`ctjR6FmMZlFeAgPITAoYAx{^_5R22v2@+ P$})Jm`njxgN@xNAt6>@< diff --git a/Tests/images/bmp/html/pal2.png b/Tests/images/bmp/html/pal2.png deleted file mode 100644 index 1bbfe175fc351d825b92a04467a466c71f12a682..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 961 zcmV;y13vtTP)Px#32;bRa{vGf6951U69E94oEQKA17=A?K~zY`g_OZ=+CUJ7hXkR5Dj5z`O7FdJ zi24AQYAMb=k-S#8^?{%=pmM{Pd!8^ED{fZ4-j}84#xMOx-+wDV?&~hkzmaF z-_QTdEJPSGP4krFU+##IZtQ0&p272cDP@u;7_YB+E`;zYmnxot6&C4UDWv;bF^po& zBaV0iuImS^Fh_|ME4YD7R&|B+7`A4>+p~xmgy&C5VTpkh`TYkBseM~zh$bLh+)~bI z&pBeO`phstQP|<5yBb4!R1ki-Cs81SNBMLr>3U)X+B03e#~@r^GmxW6ug?rC8Q?_> zVW=2(6r^B_Q-3;lDXV|*#2o;PDWTY@(-4qNqS;-22P~mcOvqpwNnwAXrYy`hW<{OaV$5D_T1u>8U zEx>1tPo@dTK(ARS^b`YG)AEguk)aQ4)P<`VxO(hlJ2KkiwUx1xhjyWGUohaC8=b$P zorG@%gG~p5f<%Q$(^3qLq93pa1^FPvo%sc80ib+vacD7|f?*SekLW{NT9)#m?5J*! z8BoD7`$kJL1J=UxN(LIn@rJfQg_s!zm7Z+$N+*?qW)LcvA=LeFK!xax0h9u_T#Z5C zRbk+!9iqY+12jm1=b|B?72vl6RR+tx4fR~~Lm&p2z&V3Vs3!Pps`6#76-Ln1hmy@j zi6YKrjR6k?QlO$>Yfw(-LzO{9F-^7Y(3mPFWC;19IaLf@Dhi&NDo^rKp~App`X&P& zQVABN{|aAntk8T#0f00000NkvXXu0mjf1fZdS diff --git a/Tests/images/bmp/html/pal4rletrns-0.png b/Tests/images/bmp/html/pal4rletrns-0.png deleted file mode 100644 index b689c842aa01a7d24c6bcad01bf1864a72e15544..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1441 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 UzIreXm;e9(07*qoM6N<$f*WyV$^ZZW diff --git a/Tests/images/bmp/html/pal4rletrns.png b/Tests/images/bmp/html/pal4rletrns.png deleted file mode 100644 index 9b0c044364123ce0c527082f3460f62380b96ec5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1465 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 C+Q0+= diff --git a/Tests/images/bmp/html/pal8rletrns-b.png b/Tests/images/bmp/html/pal8rletrns-b.png deleted file mode 100644 index 1ede504d4e57bb6874733944294f2e65ca78c1ed..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3715 zcmWkx3tSBA8=h^m73J1dsm=XTgrN{^IVBD%vdC>C29Zu2l(Msx?omlPlN36Xb;-3t znq3yD(Ne8+v)kne{cC51COf;|f12<2z3(^g`^+=X_x`^5W+o-T&)00E?MMiM%vP`R z-oSX2>7K@H#v1x>31S@U=my^v(4D%8UzzdCfVCTam>}fDbQop~pBcmO@F@aAFv55d z6NKU6I08Gt$Z!h77$aul2+W6(;S>ftA&3)<4QB}iLI}nym}?PO1S28{<|7CnhMWl4 z2}7I^%n2rjkpK)E2o}ST2!llkB7(7DFoy6EY#0wWIswcH1B`gUzzA53Az}m*!-N<@L>R^>;RA$EV0?_=BY+PR5CJI& zM1c?rMgV~fGb4nUQA=PVj9|p@0brP&2m~OI0>BD_P{2R|Az}g(V}zJd3osEuh%g{x z1Q39k31AKgRscjnUZ~e|GdvRyc{2RoBPId!AFn5{GR9y+54zVgO!4r&x?HV<=?<<9KD0~zx>wr ztxft(`r5@vFuE>4^B|I*AF9$wBx^z&f7SN&g*TQ^<&kv93jz0+zNOW@)m$f|$(9b@4OTG5)pfJ4QkU5p~$saG|;3vfeD|Z)D^a|Dt z`|RmLW6x)fLJJLdV*W1Hy)`u37B7QN>11PAB|Wl?$$FhG(AjY%&G}usEufhN!uRIm z`;P57)*bvgI6>npziJCY*kBDCxYdD!COzt{4qF{s3O|jRum5sjV^lOA2t_DrmG zi3bR!W&vYrvx;X*&!o`|Kydckt28#McGry8(0npif|}DOVo_sy616#&n*h=(Oit}) zcXMf%#k>Lhxy~0{K}lW@O`yrzrlHcu>=i*1O!oJO_kYFgf_&(j8ZQdctRD{v#hW&T zl$;1rZ}ACwx%=L^@#Eh$wl*pjxj#&&0`?2qePhlWZkNUX+2Us!+zDze!R>rBeZcoI z_$LL(bLpbJVeB6@TV2WA<@;Bj`H`MZq(kVmiDyqwNmpwJ)k}d*R})#3ORed5yOQ`M zG4Xnmh`T|NEMAUAL`b9Jb9eG9L)j5PcU?cuZ=L4$Azg^G16x+|7<}s0)&6<%B&Olb zoT7K=^c{yTp14<^PUnI7@xZ1B7p$OnFeMlA&)nB$cl)D87M&=hN5~8j%F(}2WjseM z1|HBW$fmd|tFQ5@&7P&Ikg>1oxRa}_4UkE}6AXS8^GV;1!UEh;m=f-}>1WY&zf^#6 zDXumaeBB774}G69HS;qf0oI>`SqdVjUG;w9O`ftIO1RzlD5No7U6M2N z*wmG$cAisitp5?XxgkIi(DcxKq5V0<*n-Um;;o}qxsp5OHD|As{ua!q_R$HvqHPCn zyyi4RBNuu2zOF0%J!S%Zkls3LdZ`Kxco$Rpm_z#`Pxd`|QT5lntD<1HC!_SG&O3X! zFzLGl=;-T(d148ueHh)cZd3C>^!w#x;XJSaXD=9Z8E}_Sqf|IYUns)uC@*UBJpEh> zACFGzJUEV*OnKdPO3rZfh@%G1or|GLf2_>%JDA8?vu>$eJb< zh$vgxi7B6qgd>`K+b!2}g6X)iyX=DLq2$u5gMaDP7Mxqub0bNsUi~xtd$!@~T5FGB z9k=c=nET6$zR1hbk2W45ae;(y%}H&HmmpK`xBfW)L7{A@nUl<=hWIV+hl5q8 ze=BfxDkzOo$#0fOII}lUBVws+P_J`JR<>%>sA=tJi`B>@>i^=D@B6+SAM!3-HE2Pb z=L7SX2k8~Fd7NrDVXgNPDerOly>gbms9QJY`ct+_o`4z|R!Cgp1c|_;L5+Lk57*Yr zNo#6D-<-yoWwsd2Tz5d_f2L_jo~`~=)yeBSdeW}*j}>#xRV5M2B95hb{JOW`%G&?U z#q|Fef}}AiCyr8<7JJ{E7yRC==bH;tTo)1pO=rzD<)xJW8S5oat9_DVpia3@C0jGO zb6Vw+SnArbqq?QQ$ko%YF6@$EVNvd0UG=LMx+Xdhct0P?XgYgWzH~>#X!2f;<0M* zeq3=?%pToXwBNqdIz`Y(<^By+@|qQu_Uym(y9)5KMC!%Ft)e=)4$m|6NTuRG-ta3t zVw9<_KGqho#M{)dW7O1m)P(L`ijL^9zC*&G8ozjS@OWoyN5hcY&Rsg0OJ14#8FVNW zG}^k7+yz1y*y5Xx+;_43uHLiszP&5iI<2@nKXdxh3(M9N9D5KmA44lw4wcV2aXVl6 za^P8My)$eG-t*JDX@;%+P z`@XwK5J2#vr^S?KUi|uw3X{1s)&7*>hiiDZ2Do?Z$K(Iqi7nI)9*%Uou5YAX9^6(< zygdzFeDV2yn%|9`Vk3Ot^9LS>@1L6g#$S_kVd5ym{pyN;IsIIXU7Ahsj1Ew5mYJ@H z)izWISgH@bce3JSMMjUDTgN?opRxcdLAPGBrum0T?L+RH{3$VVMrW^7E&XXH*do}n zg?jiXCnqN>XK#6PpG|*?#p#c(KO%@mVsL$wJ@V zc;Jtsc9w1`{d9va1B7>&bE<3Z1~&A#t{kIZ6nb?Z-L1T_T>X6!WjoznptA!Kxr}oB zJFcpzicLGnpD!k@%gV|YNHQWrvq}qXf9&rxs`82cG~#z%q8}OkEGZ!sRq?Fgci%Ks{^(Zal2mW0DcJS?kmbSKs?+m^iwbncpjnU6?c`-wnlAVoa zDOp+CV%!>r!!TD&}NQ4o4?6b08jsp5x(@lM^nI6Su;SJQI5%cux>cW30gGu0P3vv%bu8w;~HXBg4 z{ca9S{~+j|HP{%7he>gj5wJ^r&DpObGa^mzz8VqCzGx+#E#kH2^gT)VX&vqK?2Ia0 zVCu|~fK3A~JGulS)cIYMIqjn=jR&qOZ3kGF3cgtY1Nx84y;fA3RA~(QBx59CTc`EU zDZQ`PB@J00GN40gAN&98WlD~|7}u85s$%(w)-$2s>OH<)i2C+^-M1#IzNj;J#e@6V zRI3#HFGJ!N+!K~}&2HN}!!2Q(wCnb*U*0KIe)d{sP9kfdDu}lG=sD}lnpkC=S-Jsg zK^vH7hSioSk65}nI?#PPWiU%+?Gonr*rbfLK25IDr&WNmh_}2h`8;y79o4f0&nf7k z+feQK&%Db+xjqfyEX}A1o6Zj)>(@uEkJ{n8V_R!OS#k29R#_PpAIZ|VjTNL)^%trx zSPg$;SZGS6E_?PAZ7)wvb4qivbGK`4EquiLBgT>Z)z`YP`<1&y*Q)miWn3zkvZ|+E zQwAF4RN+Q@sds2XvrpQ*R{Nxa6WlZO4F6LZZ zM0pvr{0@$zYCCWW?bL8ywmmoCZ|3}jdHPsZo6&h0kwl_cAMt>rgL8B^EvbRyw7{y=bS(0Gbh$WggDraw}l|c zA#}yEwX8?k5opa}t;On%n^ z1S71MvOyRgjw3J+Mut-u#u_CXM_?h045u)dhafx{8_p64gb=LPuxk-m3L{bo79xlc zhIjV@nB*Y3Ba&{U?mJmF<6QqQWzTsV~7yJhVcOA5d;qgJP3m@SOY^!3{yg| z6hov4CS_F-h!Des2*E1k0gQ(MRy<%~1gyjmC4wnoLJ1*K3}clD0U{(YAw~!hAcP5s zfHVZ6K?n^ifIx=^s-vt1{cEHjCz@}LIFpR|vyXJ}*K6{vE`_@LRg91M_58s^4 zZ!0z>Ly(>G@GyfeTp9;K_UA&E1x0RrQgGg8i*x-{?u^HYxd4DyUu>-ZS9Q|UKrV>Yw5W1J=d##U)8~;EnT7S zu7%(M-JlppZ=*dQyIZ7-||UENYIMF}B}|PLD%)U!82pW>v!ib&16EqYENM zjm?&6i}hB^7QHAh4_@RSXlxGhjqk6Wq0bYfQ>AjMsW5?4w$W4P*ctXR?>c#|zvyr9 z3&>#3KN@S0r(ZD6lEu(rTJ307^)b~mL~P|-3c;Ko>Z;9yYSA!q}j=(HPob7r$*4M0#8*S+yFNJL{*&6JfGQL zIFNBlkTkxHZoz#Tr9Lft9)l!GECUk_aw$53S*nt&z(8|T0%tGBd!l<9K2u*Nn?{AT z;ciTYN?^fw(tCy>!81l2PB;I+rF};TzT@6>2y+!R;G<{)T|>PwmMq}Jef&5%zW^^N zjQw?|3(4bIUFE1lRzTE^cNce}vq|@l% zoW5JF;#bMWF!)wd>qFI9pvx%vbpu#XE!h0gIX~pJQijt(cJY73?%au3P`s;aejbCQ zYjO0Av2L88PCnU?*D7A_u;0NU+|f)>6c>G3geE3>r48B0$4@jKKMI&!#wjA4+?znh z%$Vq&wbl+)z8T!W`Sjzbc63by9WZ>jYo^uEZpKHzF9Q_maM4P78yl5`XP16Yt`|!z z&D8*+XX?0Sz@E-jab>`|zv-J@waCh}@Gr+B0>}ilT1Nk%z4V8?wvRPG@3kG>%t0-f zE|Z;pgW4i8T(miTicw*dfifzwPo-13&sac(y}y0ES&Y!i8OeT{5I zmUUIHH(q-B@vo;28UprvF7Y_CR+0BGQ|E()-DN=!WYE0UkdUSlljC?gSrR21BiEs8o+p(QjHOp2k7FM% zz4E(Y0#yC$PFGsA>F-7XqLW)QgT`lqc^fi7UR=k$KW^RmJm7suCx~W*iI$OVB9~Yx z>8e6!(gHJdzG29~RrRMqe|GzuXx(%=2IR!a6yFLUcY%NY zU|se&+$dE3%&~E{RvYa`iL$9*)rVAE75Q(@X z_AEMJ0|M%qXNylPUU@a0CY{y?%u8k86c~LTj=$R-{8LzxaPxpbCL%KR@PepaF45cINc3%>$G)?m+vzusZrhTn? zezxoN%kkYeBhmC5_vTh*;kt=rsxw-i*%O}C!A$Ae6CE8D9ldSWw*T^}$t^3W-~D9Y zWAsgOL8>x>(dyjqh3rpC^1i*Y3(wDgkl#O2UuGshMP`UDHam105A1(+F5_H=4Lw?+ zN~{{UI6%0IIY0N2YqQ-@W$K;8B=4wS0?&MO{UPy_>4e}4kHJ651SNMyI~T3eb!1b% ziY|terF}nmk#eanxsbX!sHbwP;r-*i|BaM?IooyV2)e`og2_vQfAO@GOX9TuJ&nJF zCW&Lj96H1#JgNO+FBP~ldq?RY++O0sOw}`gIF7zmt9@)LS$A&TWkKmr)2ThxV3}d? z?JNhno?HlwA9doe6U-kt`eAYFaccYKS~*7q!+~2=tEfWT_%~gMp`izj(nT@?cDR z)9Gv1CItuQSKWSoQ;k~DwAyP@c*+MlP-%i_{z|29VKJv7cP=dTw`i-kF6xQzms+^@ssHD);)Ns#t+t_ z-^?cX8@O|rNM?b+x9^U#tub}Z-nafdD%+)EhhmM-!o00vxBDVf=NRXJwA0_ju`Tzl zC7J)E;wh63le@{?na1L?YYw&bgAJFU)McDLmTF_7w#Z4-4?iwQi&3$ou=iXYVnL6 z9DX9OnHuP4YWh1ymkGj{56ZK9j_)}>xQwCO=r*71@+@}`W1%+_+{T^%F}LByXw*kv zoK-%l%8Z^h0Vj|39XoamfAHYWofmiSoGh-bZO)(ZMv9Bm(uy6wb+n;>Q1Fb@w|yfs z^}7>(EZW~rg?mYyNWN`FVQ03&>~QT6KW9h|V%vNZJb9IWUT7_!bI?%oi*rExRFER6 zeDbn0?oztRgp!W*ss!%Ql;^4b`;KToZXRdF)VzMZej(+wdS*7cE@NUit!mP};Rk|y zgPBq0_p&Xk1Cim=Uue03vccZU6)y~Y+6@K>6 ziTADYe_Sp2h_D&jy7H6#C=~Jks*RvibQx$%t-(#;QYnflzS@25Lhr@c`;Q)BL7e;n z4|OlJm?~_sS5PiN5zMYc%bjgp-1V&Z*}WZ{l!W~1GbFqEK4gabCipS>eK&O_PE5x! zw!Dblxe>nAEakZG-nX6BDUy7bsJPkuX6WDVWrMXk=ZPgI@oWkN$h1$5Mra(kuA6TX z&!xZ;^ zg3ZwflinxVwfiP+cyv*vE$iI!Lk2tsO18F(j-d9>CC+rPt|Swr=nl1ky*c2*WH6du zn%_Q_3R7tC<=7UJ}sZdth^4F2z>?+EIH(+;O@7`RU6(er98D7EsqKi_A!!IMwTlOQx*we0u%DXf$BP_ z$Ajr2A4BRs>dIg`K&MfcN1=)WYud%29Vrp>PosA3+uQS+scMm>#GhI3FG=vs z*d`}q^Q5>f!&miw&L?}fb8Lr-o~-RF81FiGqMmk51g8}Ap=01@6qBL3q}*20gC8A? zr9Wt=(iiX6xqh3OGiB&{fwge~Y!2kjt1Z7n%GXLB>)FV~ TO%d#03lth0vF!Ge?MMC}W~ANJ diff --git a/Tests/images/bmp/html/rgb16-231.png b/Tests/images/bmp/html/rgb16-231.png deleted file mode 100644 index 76efe526e5aba012c98601486a9c8488cd87ad40..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2643 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 DL8_22 diff --git a/Tests/images/bmp/html/rgb24.jpg b/Tests/images/bmp/html/rgb24.jpg deleted file mode 100644 index c43698c9b15c34365e85abd4c6b86a3e52ad6310..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2319 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 LNkvXXu0mjfovQ&k diff --git a/Tests/images/bmp/html/rgba32.png b/Tests/images/bmp/html/rgba32.png deleted file mode 100644 index 25e542a6551acf16d58489d2d7fde3a5d423b477..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1229 zcmZ{kdr%T~0LOm>wZyqpG<$h=(?#XV>nULkBNeSCkJ>eSMa%4^DB_ESLSA5HnQTK* zS!$=VBJq_{ouXl)9)x8R^N~OoK_zM-f{8fy=Wg5G=llKM_wM`m_X;5r=K@~?2LQN4 zgolt8leMVe<;xa*2k-Xz#emRBI4lsW*1cOSPATDJIsndyzXbuM!c_pkQX@k4AI%yz z2{DO?2oHJtp4bNUt_neJTGbMCKeu3Wo~nO!?9~IeKyO!QUj5_no($ACW5P72V#H;C zcc)M!u`$$->3Q_B`=embM+8FQR$l$I7}XSYdLcd|Q|+HDZ3r8-sFY=l_Uz<@n}Z^l z>yIlb|4-#n-~Y3MCjY0sP#=dt_JRMzXFXH$crgpp@+FT(27hnfH zJu(I}ef?QBLO!TNjt@1Zn=nZme^4Z?6?_oZHH@XwEtBWc4(Mc0oav3bSECIqyAc{d zbz(wA8vWJFl?{?xjnOrh;huQ&)rr@<`HNsHP7;e=c!kmj5(S%$OTy| z&ZDyXpSr)nL!Na%-hTkm%CMO-=CWkT8~J{+K-ld$TwqZpBN+@3DouYtF6a+-D&h4# z^qi}vLJ)lZVfB{83yh!?7uP>09?})1X2$Ld_FH|ko0G0-XI`7KK2Jh}@`D6X0`xe9 zJG_scKs;Si+Vz0hkgI%^Gtr0N@cr>$_!`srPpM}=O-ZAGcKTgGSZB#OWp(y~H1=Cb zKros8iBi~Lu5fGuS@qNFNdjC^Uo=Z<9TFu%*a+mr(%o;)BOhI>QUW%IQ_XuagEP$_ zNh;5B7|OqcytGxqk~~k!u*7?>G3L;Hs`>7qqp_4Y!Nhi&@B%_)jd?BD)u^%IT-wL? z-N~oaR<71Xf5>iwj5((e+4=CSfw;3Lo25Tj-CLhP^19tDCz`X!&>RLzc5JX^yR@3?vl-yFmfuUD~I*=1I=2q^_2tFVa4v?MPXX7 zmhoTtuG03t`H)j*X1V?L73O+Mm*WJoQjgq-w^2*8%^P+;u}oS&9&cS&JyO$zKdE+- zrs!me+C#s!>7#3hI*i`BvYk~+GTBp3<^Kc&wK1VcO-F Date: Tue, 2 Dec 2025 22:26:23 +1100 Subject: [PATCH 29/74] Added release notes for #9070 --- docs/releasenotes/12.1.0.rst | 59 ++++++++++++++++++++++++++++++++++++ docs/releasenotes/index.rst | 1 + 2 files changed, 60 insertions(+) create mode 100644 docs/releasenotes/12.1.0.rst diff --git a/docs/releasenotes/12.1.0.rst b/docs/releasenotes/12.1.0.rst new file mode 100644 index 000000000..b6e1810c6 --- /dev/null +++ b/docs/releasenotes/12.1.0.rst @@ -0,0 +1,59 @@ +12.1.0 +------ + +Security +======== + +TODO +^^^^ + +TODO + +:cve:`YYYY-XXXXX`: TODO +^^^^^^^^^^^^^^^^^^^^^^^ + +TODO + +Backwards incompatible changes +============================== + +TODO +^^^^ + +TODO + +Deprecations +============ + +TODO +^^^^ + +TODO + +API changes +=========== + +TODO +^^^^ + +TODO + +API additions +============= + +Specify window in ImageGrab on macOS +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When using :py:meth:`~PIL.ImageGrab.grab`, a specific window can now be selected on +macOS in addition to Windows. On macOS, this is a CGWindowID:: + + from PIL import ImageGrab + ImageGrab.grab(window=cgwindowid) + +Other changes +============= + +TODO +^^^^ + +TODO diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst index f66240c89..b097770a3 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 + 12.1.0 12.0.0 11.3.0 11.2.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 30/74] 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 31/74] 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 From fd3d44d2efb40df8066802c545bbecff687ac8be Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 3 Dec 2025 22:38:32 +1100 Subject: [PATCH 32/74] Updated zlib-ng to 2.3.2 --- .github/workflows/wheels-dependencies.sh | 12 ++---------- winbuild/build_prepare.py | 2 +- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 07ea75a75..12b5ea6c7 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -103,11 +103,7 @@ XZ_VERSION=5.8.1 ZSTD_VERSION=1.5.7 TIFF_VERSION=4.7.1 LCMS2_VERSION=2.17 -if [[ "$MB_ML_VER" == 2014 ]] && [[ "$PLAT" == "aarch64" ]]; then - ZLIB_NG_VERSION=2.2.5 -else - ZLIB_NG_VERSION=2.3.1 -fi +ZLIB_NG_VERSION=2.3.2 LIBWEBP_VERSION=1.6.0 BZIP2_VERSION=1.0.8 LIBXCB_VERSION=1.17.0 @@ -150,11 +146,7 @@ function build_zlib_ng { ORIGINAL_HOST_CONFIGURE_FLAGS=$HOST_CONFIGURE_FLAGS unset HOST_CONFIGURE_FLAGS - 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 + build_github zlib-ng/zlib-ng $ZLIB_NG_VERSION --installnamedir=$BUILD_PREFIX/lib --zlib-compat HOST_CONFIGURE_FLAGS=$ORIGINAL_HOST_CONFIGURE_FLAGS touch zlib-stamp diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index cd2ef13c1..4fbb7e820 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.3.1", + "ZLIBNG": "2.3.2", } V["LIBPNG_XY"] = "".join(V["LIBPNG"].split(".")[:2]) From 4d511d86edf5b78ec3778061c41c5b7ed8653339 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 1 Dec 2025 19:31:40 +1100 Subject: [PATCH 33/74] Changed argument type to match use --- Tests/test_file_libtiff.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 7cb3ea8e4..cf7676ab1 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -11,7 +11,15 @@ from typing import Any, NamedTuple import pytest -from PIL import Image, ImageFilter, ImageOps, TiffImagePlugin, TiffTags, features +from PIL import ( + Image, + ImageFile, + ImageFilter, + ImageOps, + TiffImagePlugin, + TiffTags, + features, +) from PIL.TiffImagePlugin import OSUBFILETYPE, SAMPLEFORMAT, STRIPOFFSETS, SUBIFD from .helper import ( @@ -27,7 +35,7 @@ from .helper import ( @skip_unless_feature("libtiff") class LibTiffTestCase: - def _assert_noerr(self, tmp_path: Path, im: TiffImagePlugin.TiffImageFile) -> None: + def _assert_noerr(self, tmp_path: Path, im: ImageFile.ImageFile) -> None: """Helper tests that assert basic sanity about the g4 tiff reading""" # 1 bit assert im.mode == "1" From 4024f0287d4e8a1467da8ce4ac86ea087e53af1c Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 1 Dec 2025 19:25:47 +1100 Subject: [PATCH 34/74] Assert image type --- Tests/test_file_iptc.py | 1 + Tests/test_file_libtiff.py | 1 + Tests/test_file_png.py | 2 ++ src/PIL/Image.py | 3 +++ 4 files changed, 7 insertions(+) diff --git a/Tests/test_file_iptc.py b/Tests/test_file_iptc.py index 0376b9997..9e2d8c06d 100644 --- a/Tests/test_file_iptc.py +++ b/Tests/test_file_iptc.py @@ -143,6 +143,7 @@ def test_getiptcinfo_tiff() -> None: # Test with LONG tag type with Image.open("Tests/images/hopper.Lab.tif") as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) im.tag_v2.tagtype[TiffImagePlugin.IPTC_NAA_CHUNK] = TiffTags.LONG iptc = IptcImagePlugin.getiptcinfo(im) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index cf7676ab1..77e0b4bc9 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -378,6 +378,7 @@ class TestFileLibTiff(LibTiffTestCase): im.save(out, tiffinfo=ifd) with Image.open(out) as reloaded: + assert isinstance(reloaded, TiffImagePlugin.TiffImageFile) assert reloaded.tag_v2[37000] == 100 def test_inknames_tag( diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 9875fe096..7f163a4d6 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -787,7 +787,9 @@ class TestFilePng: im.save(test_file, exif=im.getexif()) with Image.open(test_file) as reloaded: + assert isinstance(reloaded, PngImagePlugin.PngImageFile) exif = reloaded._getexif() + assert exif is not None assert exif[305] == "Adobe Photoshop CS Macintosh" def test_exif_argument(self, tmp_path: Path) -> None: diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 9d50812eb..b71395c62 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1519,6 +1519,9 @@ class Image: "".join(self.info["Raw profile type exif"].split("\n")[3:]) ) elif hasattr(self, "tag_v2"): + from . import TiffImagePlugin + + assert isinstance(self, TiffImagePlugin.TiffImageFile) self._exif.bigtiff = self.tag_v2._bigtiff self._exif.endian = self.tag_v2._endian self._exif.load_from_fp(self.fp, self.tag_v2._offset) From 46ac30aa80e592fb3f58e8094c03547c57e04cd5 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 1 Dec 2025 18:42:58 +1100 Subject: [PATCH 35/74] Use different variables for Image and ImageFile instances --- Tests/test_file_libtiff.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 77e0b4bc9..e36b5f39e 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -1155,9 +1155,9 @@ class TestFileLibTiff(LibTiffTestCase): with Image.open("Tests/images/g4_orientation_1.tif") as base_im: for i in range(2, 9): with Image.open("Tests/images/g4_orientation_" + str(i) + ".tif") as im: - im = ImageOps.exif_transpose(im) + im_transposed = ImageOps.exif_transpose(im) - assert_image_similar(base_im, im, 0.7) + assert_image_similar(base_im, im_transposed, 0.7) @pytest.mark.parametrize( "test_file", From 61b1c3c841341cc6f22ab2caa321b7303f61bd35 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 1 Dec 2025 23:51:00 +1100 Subject: [PATCH 36/74] Do not change variable type --- src/PIL/GifImagePlugin.py | 4 ++-- src/PIL/XVThumbImagePlugin.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 58c460ef3..b00953a9d 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -116,8 +116,8 @@ class GifImageFile(ImageFile.ImageFile): # check if palette contains colour indices p = self.fp.read(3 << bits) if self._is_palette_needed(p): - p = ImagePalette.raw("RGB", p) - self.global_palette = self.palette = p + palette = ImagePalette.raw("RGB", p) + self.global_palette = self.palette = palette self._fp = self.fp # FIXME: hack self.__rewind = self.fp.tell() diff --git a/src/PIL/XVThumbImagePlugin.py b/src/PIL/XVThumbImagePlugin.py index cde28388f..192c041d9 100644 --- a/src/PIL/XVThumbImagePlugin.py +++ b/src/PIL/XVThumbImagePlugin.py @@ -66,10 +66,10 @@ class XVThumbImageFile(ImageFile.ImageFile): break # parse header line (already read) - s = s.strip().split() + w, h = s.strip().split(maxsplit=2)[:2] self._mode = "P" - self._size = int(s[0]), int(s[1]) + self._size = int(w), int(h) self.palette = ImagePalette.raw("RGB", PALETTE) From 7c3ece07c9871ca992bd497961e023228a8ebd14 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 1 Dec 2025 19:06:02 +1100 Subject: [PATCH 37/74] Changed type so that im has fp attribute --- Tests/test_file_gribstub.py | 2 +- Tests/test_file_hdf5stub.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_gribstub.py b/Tests/test_file_gribstub.py index 960e5f4be..4dbed6b31 100644 --- a/Tests/test_file_gribstub.py +++ b/Tests/test_file_gribstub.py @@ -59,7 +59,7 @@ def test_handler(tmp_path: Path) -> None: def open(self, im: Image.Image) -> None: self.opened = True - def load(self, im: Image.Image) -> Image.Image: + def load(self, im: ImageFile.ImageFile) -> Image.Image: self.loaded = True im.fp.close() return Image.new("RGB", (1, 1)) diff --git a/Tests/test_file_hdf5stub.py b/Tests/test_file_hdf5stub.py index e4f09a09c..1e48597d3 100644 --- a/Tests/test_file_hdf5stub.py +++ b/Tests/test_file_hdf5stub.py @@ -61,7 +61,7 @@ def test_handler(tmp_path: Path) -> None: def open(self, im: Image.Image) -> None: self.opened = True - def load(self, im: Image.Image) -> Image.Image: + def load(self, im: ImageFile.ImageFile) -> Image.Image: self.loaded = True im.fp.close() return Image.new("RGB", (1, 1)) From fd1ddd6d56457c2611b5b947af9a2c1d5a9479b2 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 1 Dec 2025 19:12:58 +1100 Subject: [PATCH 38/74] Use consistent type --- src/PIL/GifImagePlugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index b00953a9d..0560a5a7d 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -256,7 +256,7 @@ class GifImageFile(ImageFile.ImageFile): info["comment"] += b"\n" + comment else: info["comment"] = comment - s = None + s = b"" continue elif s[0] == 255 and frame == 0 and block is not None: # @@ -299,7 +299,7 @@ class GifImageFile(ImageFile.ImageFile): bits = self.fp.read(1)[0] self.__offset = self.fp.tell() break - s = None + s = b"" if interlace is None: msg = "image not found in GIF frame" From db7a994ad65f2e25c0e5c5c35ea72210062a4f2c Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 6 Dec 2025 10:15:33 +1100 Subject: [PATCH 39/74] Updated libpng to 1.6.53 --- .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 07ea75a75..4201c335f 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.51 +LIBPNG_VERSION=1.6.53 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 cd2ef13c1..87cfef7d0 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.51", + "LIBPNG": "1.6.53", "LIBWEBP": "1.6.0", "OPENJPEG": "2.5.4", "TIFF": "4.7.1", From a01fa7d08eacf0d64c47cd95c8846e34281edf1f Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 7 Dec 2025 11:12:38 +0200 Subject: [PATCH 40/74] Test Python 3.15 pre-release --- .ci/install.sh | 5 ++--- .github/workflows/macos-install.sh | 5 ++--- .github/workflows/test-windows.yml | 2 +- .github/workflows/test.yml | 3 +++ tox.ini | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.ci/install.sh b/.ci/install.sh index 52b821417..aeb5e6514 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -27,14 +27,13 @@ python3 -m pip install --upgrade wheel python3 -m pip install coverage python3 -m pip install defusedxml python3 -m pip install ipython -python3 -m pip install numpy python3 -m pip install olefile python3 -m pip install -U pytest python3 -m pip install -U pytest-cov python3 -m pip install -U pytest-timeout python3 -m pip install pyroma -# optional test dependency, only install if there's a binary package. -# fails on beta 3.14 and PyPy +# optional test dependencies, only install if there's a binary package. +python3 -m pip install --only-binary=:all: numpy || true python3 -m pip install --only-binary=:all: pyarrow || true # PyQt6 doesn't support PyPy3 diff --git a/.github/workflows/macos-install.sh b/.github/workflows/macos-install.sh index b114d4a23..7c768af48 100755 --- a/.github/workflows/macos-install.sh +++ b/.github/workflows/macos-install.sh @@ -26,9 +26,8 @@ python3 -m pip install -U pytest python3 -m pip install -U pytest-cov python3 -m pip install -U pytest-timeout python3 -m pip install pyroma -python3 -m pip install numpy -# optional test dependency, only install if there's a binary package. -# fails on beta 3.14 and PyPy +# optional test dependencies, only install if there's a binary package. +python3 -m pip install --only-binary=:all: numpy || true python3 -m pip install --only-binary=:all: pyarrow || true # libavif diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index c4d0fa046..3450de355 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", "3.11", "3.12", "3.13", "3.14"] + python-version: ["pypy3.11", "3.11", "3.12", "3.13", "3.14", "3.15"] architecture: ["x64"] include: # Test the oldest Python on 32-bit diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 167faa239..da3eea066 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,6 +42,8 @@ jobs: ] python-version: [ "pypy3.11", + "3.15t", + "3.15", "3.14t", "3.14", "3.13t", @@ -54,6 +56,7 @@ jobs: - { python-version: "3.12", PYTHONOPTIMIZE: 1, REVERSE: "--reverse" } - { python-version: "3.11", PYTHONOPTIMIZE: 2 } # Free-threaded + - { python-version: "3.15t", disable-gil: true } - { python-version: "3.14t", disable-gil: true } - { python-version: "3.13t", disable-gil: true } # Intel diff --git a/tox.ini b/tox.ini index d58fd67b6..7f116c6e7 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ requires = tox>=4.2 env_list = lint - py{py3, 314, 313, 312, 311, 310} + py{py3, 315, 314, 313, 312, 311, 310} [testenv] deps = From 76532808f4b350741669e776598f338e4d2e5a96 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:26:14 +0200 Subject: [PATCH 41/74] Fix ResourceWarning in selftest.py --- selftest.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/selftest.py b/selftest.py index e9b5689a0..c484d4e2d 100755 --- a/selftest.py +++ b/selftest.py @@ -40,12 +40,14 @@ def testimage() -> None: >>> with Image.open("Tests/images/hopper.gif") as im: ... _info(im) ('GIF', 'P', (128, 128)) - >>> _info(Image.open("Tests/images/hopper.ppm")) + >>> with Image.open("Tests/images/hopper.ppm") as im: + ... _info(im) ('PPM', 'RGB', (128, 128)) >>> try: - ... _info(Image.open("Tests/images/hopper.jpg")) + ... with Image.open("Tests/images/hopper.jpg") as im: + ... _info(im) ... except OSError as v: - ... print(v) + ... print(v) ('JPEG', 'RGB', (128, 128)) PIL doesn't actually load the image data until it's needed, From b3da65df94ba1902502b59e9b70b15d535ccd902 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 11 Dec 2025 10:53:04 +1100 Subject: [PATCH 42/74] Updated libjpeg-turbo to 3.1.3 --- .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 b246d9255..b4d7ca45f 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -97,7 +97,7 @@ else fi HARFBUZZ_VERSION=12.2.0 LIBPNG_VERSION=1.6.53 -JPEGTURBO_VERSION=3.1.2 +JPEGTURBO_VERSION=3.1.3 OPENJPEG_VERSION=2.5.4 XZ_VERSION=5.8.1 ZSTD_VERSION=1.5.7 diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index c42a1fcf5..12960330d 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -117,7 +117,7 @@ V = { "FREETYPE": "2.14.1", "FRIBIDI": "1.0.16", "HARFBUZZ": "12.2.0", - "JPEGTURBO": "3.1.2", + "JPEGTURBO": "3.1.3", "LCMS2": "2.17", "LIBAVIF": "1.3.0", "LIBIMAGEQUANT": "4.4.1", From 79ae888d4574333ca666696453870dcc2bb7a8c3 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Thu, 11 Dec 2025 09:29:54 +0200 Subject: [PATCH 43/74] Docs: update major bump cadence --- docs/releasenotes/index.rst | 2 +- docs/releasenotes/versioning.rst | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst index b097770a3..4b25bb6a2 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 + versioning 12.1.0 12.0.0 11.3.0 @@ -80,4 +81,3 @@ expected to be backported to earlier versions. 2.5.2 2.3.2 2.3.1 - versioning diff --git a/docs/releasenotes/versioning.rst b/docs/releasenotes/versioning.rst index 2a0af9e59..884102d16 100644 --- a/docs/releasenotes/versioning.rst +++ b/docs/releasenotes/versioning.rst @@ -17,8 +17,8 @@ prior three months. A quarterly release bumps the MAJOR version when incompatible API changes are made, such as removing deprecated APIs or dropping an EOL Python version. In practice, -these occur every 12-18 months, guided by -`Python's EOL schedule `_, and +these occur every October, guided by +`Python's EOL schedule `__, and any APIs that have been deprecated for at least a year are removed at the same time. PATCH versions ("`Point Release `_" From 6a769da21bc6e58e09f849e647920c51a5a82cd7 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 11 Dec 2025 23:27:29 +1100 Subject: [PATCH 44/74] Corrected variable type --- Tests/test_file_iptc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_iptc.py b/Tests/test_file_iptc.py index 9e2d8c06d..3eb5cde8e 100644 --- a/Tests/test_file_iptc.py +++ b/Tests/test_file_iptc.py @@ -6,7 +6,7 @@ import pytest from PIL import Image, IptcImagePlugin, TiffImagePlugin, TiffTags -from .helper import assert_image_equal, hopper +from .helper import assert_image_equal TEST_FILE = "Tests/images/iptc.jpg" @@ -85,7 +85,7 @@ def test_getiptcinfo() -> None: def test_getiptcinfo_jpg_none() -> None: # Arrange - with hopper() as im: + with Image.open("Tests/images/hopper.jpg") as im: # Act iptc = IptcImagePlugin.getiptcinfo(im) From 6bf4313a68cd4d4e6ba4b4cf87c8d4e97e6ac278 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 18 Dec 2025 07:44:40 +1100 Subject: [PATCH 45/74] Updated xz to 5.8.2 --- .github/workflows/wheels-dependencies.sh | 2 +- winbuild/build_prepare.py | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index b4d7ca45f..e1477634d 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -99,7 +99,7 @@ HARFBUZZ_VERSION=12.2.0 LIBPNG_VERSION=1.6.53 JPEGTURBO_VERSION=3.1.3 OPENJPEG_VERSION=2.5.4 -XZ_VERSION=5.8.1 +XZ_VERSION=5.8.2 ZSTD_VERSION=1.5.7 TIFF_VERSION=4.7.1 LCMS2_VERSION=2.17 diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 12960330d..30fe26d14 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -125,7 +125,7 @@ V = { "LIBWEBP": "1.6.0", "OPENJPEG": "2.5.4", "TIFF": "4.7.1", - "XZ": "5.8.1", + "XZ": "5.8.2", "ZLIBNG": "2.3.2", } V["LIBPNG_XY"] = "".join(V["LIBPNG"].split(".")[:2]) @@ -183,11 +183,7 @@ DEPS: dict[str, dict[str, Any]] = { "filename": f"xz-{V['XZ']}.tar.gz", "license": "COPYING", "build": [ - *cmds_cmake( - "liblzma", - "-DBUILD_SHARED_LIBS:BOOL=OFF" - + (" -DXZ_CLMUL_CRC:BOOL=OFF" if struct.calcsize("P") == 4 else ""), - ), + *cmds_cmake("liblzma", "-DBUILD_SHARED_LIBS:BOOL=OFF"), cmd_mkdir(r"{inc_dir}\lzma"), cmd_copy(r"src\liblzma\api\lzma\*.h", r"{inc_dir}\lzma"), ], From 6df6cd448069ac09bafc360b6f7a470773c74707 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sat, 20 Dec 2025 23:51:05 +1100 Subject: [PATCH 46/74] Pin docutils to 0.21 (#9344) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index f4514925d..f22620805 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dynamic = [ "version", ] optional-dependencies.docs = [ + "docutils==0.21", # Pending https://github.com/pradyunsg/sphinx-inline-tabs/pull/51 "furo", "olefile", "sphinx>=8.2", From 9d3555c37e888a419105ec177ebb6d8aaebf306f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 21 Dec 2025 22:39:19 +1100 Subject: [PATCH 47/74] Test Windows Server 2022 --- .github/workflows/test-windows.yml | 5 +++-- docs/installation/platform-support.rst | 4 ++-- winbuild/README.md | 3 ++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 3450de355..7afafe07c 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -31,15 +31,16 @@ env: jobs: build: - runs-on: windows-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: python-version: ["pypy3.11", "3.11", "3.12", "3.13", "3.14", "3.15"] architecture: ["x64"] + os: ["windows-latest"] include: # Test the oldest Python on 32-bit - - { python-version: "3.10", architecture: "x86" } + - { python-version: "3.10", architecture: "x86", os: "windows-2022" } timeout-minutes: 45 diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index 17e38719a..ee70d8401 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -53,8 +53,8 @@ These platforms are built and tested for every change. | | | s390x | +----------------------------------+----------------------------+---------------------+ | Windows Server 2022 | 3.10 | x86 | -| +----------------------------+---------------------+ -| | 3.11, 3.12, 3.13, 3.14, | x86-64 | ++----------------------------------+----------------------------+---------------------+ +| Windows Server 2025 | 3.11, 3.12, 3.13, 3.14, | x86-64 | | | PyPy3 | | | +----------------------------+---------------------+ | | 3.12 (MinGW) | x86-64 | diff --git a/winbuild/README.md b/winbuild/README.md index db71f094e..b1c9262c2 100644 --- a/winbuild/README.md +++ b/winbuild/README.md @@ -11,7 +11,8 @@ For more extensive info, see the [Windows build instructions](build.rst). * Requires Microsoft Visual Studio 2017 or newer with C++ component. * Requires NASM for libjpeg-turbo, a required dependency when using this script. * Requires CMake 3.15 or newer (available as Visual Studio component). -* Tested on Windows Server 2022 with Visual Studio 2022 Enterprise (GitHub Actions). +* Tested on Windows Server 2025 and 2022 with Visual Studio 2022 Enterprise (GitHub + Actions). Here's an example script to build on Windows: From 9dd756f9fe574ab2b3c97585f299600582551310 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 22 Dec 2025 09:33:28 +1100 Subject: [PATCH 48/74] Revert "Pin docutils to 0.21 (#9344)" This reverts commit 6df6cd448069ac09bafc360b6f7a470773c74707. --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f22620805..f4514925d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,6 @@ dynamic = [ "version", ] optional-dependencies.docs = [ - "docutils==0.21", # Pending https://github.com/pradyunsg/sphinx-inline-tabs/pull/51 "furo", "olefile", "sphinx>=8.2", From ca21683316a96b348197a6f5ca97ea9c6c142bb0 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Mon, 22 Dec 2025 18:12:10 +1100 Subject: [PATCH 49/74] Cast to UINT32 before shifting bits (#9347) --- src/libImaging/BcnDecode.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libImaging/BcnDecode.c b/src/libImaging/BcnDecode.c index ac81ed6df..d99b0e28e 100644 --- a/src/libImaging/BcnDecode.c +++ b/src/libImaging/BcnDecode.c @@ -663,7 +663,7 @@ half_to_float(UINT16 h) { if (o.f >= m.f) { o.u |= 255 << 23; } - o.u |= (h & 0x8000) << 16; + o.u |= (UINT32)(h & 0x8000) << 16; return o.f; } From 9b7200d2b439a4a41411e074e0d6cc453a8e3e57 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 23 Dec 2025 12:50:26 +1100 Subject: [PATCH 50/74] Allow 1 mode images in MorphOp get_on_pixels() --- Tests/test_imagemorph.py | 9 +++++---- src/PIL/ImageMorph.py | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Tests/test_imagemorph.py b/Tests/test_imagemorph.py index ca192a809..59d1b0645 100644 --- a/Tests/test_imagemorph.py +++ b/Tests/test_imagemorph.py @@ -188,9 +188,10 @@ def test_corner() -> None: assert len(coords) == 4 assert tuple(coords) == ((2, 2), (4, 2), (2, 4), (4, 4)) - coords = mop.get_on_pixels(Aout) - assert len(coords) == 4 - assert tuple(coords) == ((2, 2), (4, 2), (2, 4), (4, 4)) + for image in (Aout, Aout.convert("1")): + coords = mop.get_on_pixels(image) + assert len(coords) == 4 + assert tuple(coords) == ((2, 2), (4, 2), (2, 4), (4, 4)) def test_mirroring() -> None: @@ -239,7 +240,7 @@ def test_incorrect_mode() -> None: mop.apply(im) with pytest.raises(ValueError, match="Image mode must be L"): mop.match(im) - with pytest.raises(ValueError, match="Image mode must be L"): + with pytest.raises(ValueError, match="Image mode must be 1 or L"): mop.get_on_pixels(im) diff --git a/src/PIL/ImageMorph.py b/src/PIL/ImageMorph.py index bd70aff7b..d6d0f8296 100644 --- a/src/PIL/ImageMorph.py +++ b/src/PIL/ImageMorph.py @@ -232,13 +232,13 @@ class MorphOp: return _imagingmorph.match(bytes(self.lut), image.getim()) def get_on_pixels(self, image: Image.Image) -> list[tuple[int, int]]: - """Get a list of all turned on pixels in a binary image + """Get a list of all turned on pixels in a 1 or L mode image. Returns a list of tuples of (x,y) coordinates of all matching pixels. See :ref:`coordinate-system`.""" - if image.mode != "L": - msg = "Image mode must be L" + if image.mode not in ("1", "L"): + msg = "Image mode must be 1 or L" raise ValueError(msg) return _imagingmorph.get_on_pixels(image.getim()) From a7047114040f38314a970d271d967cc17004c5e1 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 23 Dec 2025 14:13:51 +1100 Subject: [PATCH 51/74] Allow 1 mode images in apply() and match() --- Tests/images/morph_a.png | Bin 83 -> 79 bytes Tests/test_imagemorph.py | 38 ++++++++++++++++++-------------------- src/PIL/ImageMorph.py | 8 ++++---- 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/Tests/images/morph_a.png b/Tests/images/morph_a.png index 19f6b777ffe17d924b984ba4ae4679257f01d31f..035fbc4bb84d6b67ee99cd6bbb5e8996059dd142 100644 GIT binary patch delta 60 zcmWIcpCDn*$N&UyG_&e}l$fWBV@SoE Image.Image: rows = [s for s in image_string.replace(" ", "").split("\n") if len(s)] height = len(rows) width = len(rows[0]) - im = Image.new("L", (width, height)) - for i in range(width): - for j in range(height): - c = rows[j][i] - v = c in "X1" - im.putpixel((i, j), v) - + im = Image.new("1", (width, height)) + for x in range(width): + for y in range(height): + im.putpixel((x, y), rows[y][x] in "X1") return im @@ -42,10 +39,10 @@ def img_to_string(im: Image.Image) -> str: """Turn a (small) binary image into a string representation""" chars = ".1" result = [] - for r in range(im.height): + for y in range(im.height): line = "" - for c in range(im.width): - value = im.getpixel((c, r)) + for x in range(im.width): + value = im.getpixel((x, y)) assert not isinstance(value, tuple) assert value is not None line += chars[value > 0] @@ -165,10 +162,12 @@ def test_edge() -> None: ) -def test_corner() -> None: +@pytest.mark.parametrize("mode", ("1", "L")) +def test_corner(mode: str) -> None: # Create a corner detector pattern mop = ImageMorph.MorphOp(patterns=["1:(... ... ...)->0", "4:(00. 01. ...)->1"]) - count, Aout = mop.apply(A) + image = A.convert(mode) if mode == "L" else A + count, Aout = mop.apply(image) assert count == 5 assert_img_equal_img_string( Aout, @@ -184,14 +183,13 @@ def test_corner() -> None: ) # Test the coordinate counting with the same operator - coords = mop.match(A) + coords = mop.match(image) assert len(coords) == 4 assert tuple(coords) == ((2, 2), (4, 2), (2, 4), (4, 4)) - for image in (Aout, Aout.convert("1")): - coords = mop.get_on_pixels(image) - assert len(coords) == 4 - assert tuple(coords) == ((2, 2), (4, 2), (2, 4), (4, 4)) + coords = mop.get_on_pixels(Aout) + assert len(coords) == 4 + assert tuple(coords) == ((2, 2), (4, 2), (2, 4), (4, 4)) def test_mirroring() -> None: @@ -233,12 +231,12 @@ def test_negate() -> None: def test_incorrect_mode() -> None: - im = hopper("RGB") + im = hopper() mop = ImageMorph.MorphOp(op_name="erosion8") - with pytest.raises(ValueError, match="Image mode must be L"): + with pytest.raises(ValueError, match="Image mode must be 1 or L"): mop.apply(im) - with pytest.raises(ValueError, match="Image mode must be L"): + with pytest.raises(ValueError, match="Image mode must be 1 or L"): mop.match(im) with pytest.raises(ValueError, match="Image mode must be 1 or L"): mop.get_on_pixels(im) diff --git a/src/PIL/ImageMorph.py b/src/PIL/ImageMorph.py index d6d0f8296..69b0d1700 100644 --- a/src/PIL/ImageMorph.py +++ b/src/PIL/ImageMorph.py @@ -209,8 +209,8 @@ class MorphOp: msg = "No operator loaded" raise Exception(msg) - if image.mode != "L": - msg = "Image mode must be L" + if image.mode not in ("1", "L"): + msg = "Image mode must be 1 or L" raise ValueError(msg) outimage = Image.new(image.mode, image.size, None) count = _imagingmorph.apply(bytes(self.lut), image.getim(), outimage.getim()) @@ -226,8 +226,8 @@ class MorphOp: msg = "No operator loaded" raise Exception(msg) - if image.mode != "L": - msg = "Image mode must be L" + if image.mode not in ("1", "L"): + msg = "Image mode must be 1 or L" raise ValueError(msg) return _imagingmorph.match(bytes(self.lut), image.getim()) From e85700fe483f014ffcbaf91e81456a78f6303337 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sun, 28 Dec 2025 00:54:10 +1100 Subject: [PATCH 52/74] Test PyQt6 on Python 3.14 on Windows (#9353) --- .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 7afafe07c..e864763da 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -84,7 +84,7 @@ jobs: python3 -m pip install --upgrade pip - name: Install CPython dependencies - if: "!contains(matrix.python-version, 'pypy') && !contains(matrix.python-version, '3.14') && matrix.architecture != 'x86'" + if: "!contains(matrix.python-version, 'pypy') && matrix.architecture != 'x86'" run: | python3 -m pip install PyQt6 From 4be5b8a2fb0aad9777dc00db9ae353605e745129 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sun, 28 Dec 2025 07:34:57 +1100 Subject: [PATCH 53/74] Use unsigned long for DWORD (#9352) --- src/libImaging/FliDecode.c | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/libImaging/FliDecode.c b/src/libImaging/FliDecode.c index 44994823e..9b494dfa2 100644 --- a/src/libImaging/FliDecode.c +++ b/src/libImaging/FliDecode.c @@ -18,9 +18,9 @@ #define I16(ptr) ((ptr)[0] + ((int)(ptr)[1] << 8)) -#define I32(ptr) \ - ((ptr)[0] + ((INT32)(ptr)[1] << 8) + ((INT32)(ptr)[2] << 16) + \ - ((INT32)(ptr)[3] << 24)) +#define I32(ptr) \ + ((ptr)[0] + ((unsigned long)(ptr)[1] << 8) + ((unsigned long)(ptr)[2] << 16) + \ + ((unsigned long)(ptr)[3] << 24)) #define ERR_IF_DATA_OOB(offset) \ if ((data + (offset)) > ptr + bytes) { \ @@ -31,8 +31,8 @@ int ImagingFliDecode(Imaging im, ImagingCodecState state, UINT8 *buf, Py_ssize_t bytes) { UINT8 *ptr; - int framesize; - int c, chunks, advance; + unsigned long framesize, advance; + int c, chunks; int l, lines; int i, j, x = 0, y, ymax; From 66e3d65a72e100e0919b757460099c916d79b5d3 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Mon, 29 Dec 2025 02:04:44 +1100 Subject: [PATCH 54/74] Update harfbuzz to 12.3.0 (#9355) --- .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 e1477634d..e1586b7c5 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -95,7 +95,7 @@ if [[ -n "$IOS_SDK" ]]; then else FREETYPE_VERSION=2.14.1 fi -HARFBUZZ_VERSION=12.2.0 +HARFBUZZ_VERSION=12.3.0 LIBPNG_VERSION=1.6.53 JPEGTURBO_VERSION=3.1.3 OPENJPEG_VERSION=2.5.4 diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 30fe26d14..3377d952c 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -116,7 +116,7 @@ V = { "BROTLI": "1.2.0", "FREETYPE": "2.14.1", "FRIBIDI": "1.0.16", - "HARFBUZZ": "12.2.0", + "HARFBUZZ": "12.3.0", "JPEGTURBO": "3.1.3", "LCMS2": "2.17", "LIBAVIF": "1.3.0", From faa843e9c2ba5be8ddf42bede72f63c93ac75158 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Mon, 29 Dec 2025 08:01:23 +1100 Subject: [PATCH 55/74] Simplify WebP code (#9329) --- src/PIL/WebPImagePlugin.py | 41 +++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index 2847fed20..e20e40d91 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -45,33 +45,30 @@ class WebPImageFile(ImageFile.ImageFile): def _open(self) -> None: # Use the newer AnimDecoder API to parse the (possibly) animated file, # and access muxed chunks like ICC/EXIF/XMP. + assert self.fp is not None self._decoder = _webp.WebPAnimDecoder(self.fp.read()) # Get info from decoder - self._size, loop_count, bgcolor, frame_count, mode = self._decoder.get_info() - self.info["loop"] = loop_count - bg_a, bg_r, bg_g, bg_b = ( - (bgcolor >> 24) & 0xFF, - (bgcolor >> 16) & 0xFF, - (bgcolor >> 8) & 0xFF, - bgcolor & 0xFF, + self._size, self.info["loop"], bgcolor, self.n_frames, self.rawmode = ( + self._decoder.get_info() + ) + self.info["background"] = ( + (bgcolor >> 16) & 0xFF, # R + (bgcolor >> 8) & 0xFF, # G + bgcolor & 0xFF, # B + (bgcolor >> 24) & 0xFF, # A ) - self.info["background"] = (bg_r, bg_g, bg_b, bg_a) - self.n_frames = frame_count self.is_animated = self.n_frames > 1 - self._mode = "RGB" if mode == "RGBX" else mode - self.rawmode = mode + self._mode = "RGB" if self.rawmode == "RGBX" else self.rawmode # Attempt to read ICC / EXIF / XMP chunks from file - icc_profile = self._decoder.get_chunk("ICCP") - exif = self._decoder.get_chunk("EXIF") - xmp = self._decoder.get_chunk("XMP ") - if icc_profile: - self.info["icc_profile"] = icc_profile - if exif: - self.info["exif"] = exif - if xmp: - self.info["xmp"] = xmp + for key, chunk_name in { + "icc_profile": "ICCP", + "exif": "EXIF", + "xmp": "XMP ", + }.items(): + if value := self._decoder.get_chunk(chunk_name): + self.info[key] = value # Initialize seek state self._reset(reset=False) @@ -129,9 +126,7 @@ class WebPImageFile(ImageFile.ImageFile): self._seek(self.__logical_frame) # We need to load the image data for this frame - data, timestamp, duration = self._get_next() - self.info["timestamp"] = timestamp - self.info["duration"] = duration + data, self.info["timestamp"], self.info["duration"] = self._get_next() self.__loaded = self.__logical_frame # Set tile From a04c9806b12723b8882536325b69b4a1f96733aa Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Mon, 29 Dec 2025 08:03:47 +1100 Subject: [PATCH 56/74] Return LUT from LutBuilder build_default_lut() (#9350) --- Tests/test_imagemorph.py | 5 +++++ src/PIL/ImageMorph.py | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Tests/test_imagemorph.py b/Tests/test_imagemorph.py index ca192a809..12423ebf6 100644 --- a/Tests/test_imagemorph.py +++ b/Tests/test_imagemorph.py @@ -281,6 +281,11 @@ def test_pattern_syntax_error(pattern: str) -> None: lb.build_lut() +def test_build_default_lut() -> None: + lb = ImageMorph.LutBuilder(op_name="corner") + assert lb.build_default_lut() == lb.lut + + def test_load_invalid_mrl() -> None: # Arrange invalid_mrl = "Tests/images/hopper.png" diff --git a/src/PIL/ImageMorph.py b/src/PIL/ImageMorph.py index bd70aff7b..4d9517d02 100644 --- a/src/PIL/ImageMorph.py +++ b/src/PIL/ImageMorph.py @@ -92,10 +92,11 @@ class LutBuilder: def add_patterns(self, patterns: list[str]) -> None: self.patterns += patterns - def build_default_lut(self) -> None: + def build_default_lut(self) -> bytearray: symbols = [0, 1] m = 1 << 4 # pos of current pixel self.lut = bytearray(symbols[(i & m) > 0] for i in range(LUT_SIZE)) + return self.lut def get_lut(self) -> bytearray | None: return self.lut From 2ebb3e9964bcfb46e2b8dcaec1917caf67730a7d Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Mon, 29 Dec 2025 08:09:46 +1100 Subject: [PATCH 57/74] Use different variables for Image and ImageFile instances (#9316) --- Tests/helper.py | 7 ++-- Tests/test_bmp_reference.py | 18 +++++----- Tests/test_file_apng.py | 18 +++++----- Tests/test_file_bmp.py | 4 +-- Tests/test_file_dds.py | 8 ++--- Tests/test_file_eps.py | 32 ++++++++--------- Tests/test_file_gif.py | 68 ++++++++++++++++++------------------ Tests/test_file_libtiff.py | 14 ++++---- Tests/test_file_png.py | 37 ++++++++++---------- Tests/test_imageops.py | 6 ++-- Tests/test_pickle.py | 14 ++++---- src/PIL/IptcImagePlugin.py | 7 ++-- src/PIL/SpiderImagePlugin.py | 4 +-- 13 files changed, 118 insertions(+), 119 deletions(-) diff --git a/Tests/helper.py b/Tests/helper.py index dbdd30b42..b93828f58 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -104,10 +104,9 @@ def assert_image_equal_tofile( msg: str | None = None, mode: str | None = None, ) -> None: - with Image.open(filename) as img: - if mode: - img = img.convert(mode) - assert_image_equal(a, img, msg) + with Image.open(filename) as im: + converted_im = im.convert(mode) if mode else im + assert_image_equal(a, converted_im, msg) def assert_image_similar( diff --git a/Tests/test_bmp_reference.py b/Tests/test_bmp_reference.py index 3cd0fbb2d..8fbd73748 100644 --- a/Tests/test_bmp_reference.py +++ b/Tests/test_bmp_reference.py @@ -95,16 +95,16 @@ def test_good() -> None: for f in get_files("g"): try: with Image.open(f) as im: - im.load() with Image.open(get_compare(f)) as compare: - compare.load() - if im.mode == "P": - # assert image similar doesn't really work - # with paletized image, since the palette might - # be differently ordered for an equivalent image. - im = im.convert("RGBA") - compare = compare.convert("RGBA") - assert_image_similar(im, compare, 5) + # assert image similar doesn't really work + # with paletized image, since the palette might + # be differently ordered for an equivalent image. + im_converted = im.convert("RGBA") if im.mode == "P" else im + compare_converted = ( + compare.convert("RGBA") if im.mode == "P" else compare + ) + + assert_image_similar(im_converted, compare_converted, 5) except Exception as msg: # there are three here that are unsupported: diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index d918a24a7..644b7807a 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -278,25 +278,25 @@ def test_apng_mode() -> None: assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "P" im.seek(im.n_frames - 1) - im = im.convert("RGB") - assert im.getpixel((0, 0)) == (0, 255, 0) - assert im.getpixel((64, 32)) == (0, 255, 0) + im_rgb = im.convert("RGB") + assert im_rgb.getpixel((0, 0)) == (0, 255, 0) + assert im_rgb.getpixel((64, 32)) == (0, 255, 0) with Image.open("Tests/images/apng/mode_palette_alpha.png") as im: assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "P" im.seek(im.n_frames - 1) - im = im.convert("RGBA") - assert im.getpixel((0, 0)) == (0, 255, 0, 255) - assert im.getpixel((64, 32)) == (0, 255, 0, 255) + im_rgba = im.convert("RGBA") + assert im_rgba.getpixel((0, 0)) == (0, 255, 0, 255) + assert im_rgba.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/mode_palette_1bit_alpha.png") as im: assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "P" im.seek(im.n_frames - 1) - im = im.convert("RGBA") - assert im.getpixel((0, 0)) == (0, 0, 255, 128) - assert im.getpixel((64, 32)) == (0, 0, 255, 128) + im_rgba = im.convert("RGBA") + assert im_rgba.getpixel((0, 0)) == (0, 0, 255, 128) + assert im_rgba.getpixel((64, 32)) == (0, 0, 255, 128) def test_apng_chunk_errors() -> None: diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index c1c430aa5..28e863459 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -165,9 +165,9 @@ def test_rgba_bitfields() -> None: with Image.open("Tests/images/rgb32bf-rgba.bmp") as im: # So before the comparing the image, swap the channels b, g, r = im.split()[1:] - im = Image.merge("RGB", (r, g, b)) + im_rgb = Image.merge("RGB", (r, g, b)) - assert_image_equal_tofile(im, "Tests/images/bmp/q/rgb32bf-xbgr.bmp") + assert_image_equal_tofile(im_rgb, "Tests/images/bmp/q/rgb32bf-xbgr.bmp") # This test image has been manually hexedited # to change the bitfield compression in the header from XBGR to ABGR diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 60d0c09bc..931ff02f1 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -57,7 +57,7 @@ TEST_FILE_UNCOMPRESSED_RGB_WITH_ALPHA = "Tests/images/uncompressed_rgb.dds" def test_sanity_dxt1_bc1(image_path: str) -> None: """Check DXT1 and BC1 images can be opened""" with Image.open(TEST_FILE_DXT1.replace(".dds", ".png")) as target: - target = target.convert("RGBA") + target_rgba = target.convert("RGBA") with Image.open(image_path) as im: im.load() @@ -65,7 +65,7 @@ def test_sanity_dxt1_bc1(image_path: str) -> None: assert im.mode == "RGBA" assert im.size == (256, 256) - assert_image_equal(im, target) + assert_image_equal(im, target_rgba) def test_sanity_dxt3() -> None: @@ -520,9 +520,9 @@ def test_save_dx10_bc5(tmp_path: Path) -> None: im.save(out, pixel_format="BC5") assert_image_similar_tofile(im, out, 9.56) - im = hopper("L") + im_l = hopper("L") with pytest.raises(OSError, match="only RGB mode can be written as BC5"): - im.save(out, pixel_format="BC5") + im_l.save(out, pixel_format="BC5") @pytest.mark.parametrize( diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index b50915f28..d4e8db4f4 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -265,9 +265,9 @@ def test_bytesio_object() -> None: img.load() with Image.open(FILE1_COMPARE) as image1_scale1_compare: - image1_scale1_compare = image1_scale1_compare.convert("RGB") - image1_scale1_compare.load() - assert_image_similar(img, image1_scale1_compare, 5) + image1_scale1_compare_rgb = image1_scale1_compare.convert("RGB") + image1_scale1_compare_rgb.load() + assert_image_similar(img, image1_scale1_compare_rgb, 5) @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") @@ -301,17 +301,17 @@ def test_render_scale1() -> None: with Image.open(FILE1) as image1_scale1: image1_scale1.load() with Image.open(FILE1_COMPARE) as image1_scale1_compare: - image1_scale1_compare = image1_scale1_compare.convert("RGB") - image1_scale1_compare.load() - assert_image_similar(image1_scale1, image1_scale1_compare, 5) + image1_scale1_compare_rgb = image1_scale1_compare.convert("RGB") + image1_scale1_compare_rgb.load() + assert_image_similar(image1_scale1, image1_scale1_compare_rgb, 5) # Non-zero bounding box with Image.open(FILE2) as image2_scale1: image2_scale1.load() with Image.open(FILE2_COMPARE) as image2_scale1_compare: - image2_scale1_compare = image2_scale1_compare.convert("RGB") - image2_scale1_compare.load() - assert_image_similar(image2_scale1, image2_scale1_compare, 10) + image2_scale1_compare_rgb = image2_scale1_compare.convert("RGB") + image2_scale1_compare_rgb.load() + assert_image_similar(image2_scale1, image2_scale1_compare_rgb, 10) @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") @@ -324,18 +324,16 @@ def test_render_scale2() -> None: assert isinstance(image1_scale2, EpsImagePlugin.EpsImageFile) image1_scale2.load(scale=2) with Image.open(FILE1_COMPARE_SCALE2) as image1_scale2_compare: - image1_scale2_compare = image1_scale2_compare.convert("RGB") - image1_scale2_compare.load() - assert_image_similar(image1_scale2, image1_scale2_compare, 5) + image1_scale2_compare_rgb = image1_scale2_compare.convert("RGB") + assert_image_similar(image1_scale2, image1_scale2_compare_rgb, 5) # Non-zero bounding box with Image.open(FILE2) as image2_scale2: assert isinstance(image2_scale2, EpsImagePlugin.EpsImageFile) image2_scale2.load(scale=2) with Image.open(FILE2_COMPARE_SCALE2) as image2_scale2_compare: - image2_scale2_compare = image2_scale2_compare.convert("RGB") - image2_scale2_compare.load() - assert_image_similar(image2_scale2, image2_scale2_compare, 10) + image2_scale2_compare_rgb = image2_scale2_compare.convert("RGB") + assert_image_similar(image2_scale2, image2_scale2_compare_rgb, 10) @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") @@ -345,8 +343,8 @@ def test_render_scale2() -> None: def test_resize(filename: str) -> None: with Image.open(filename) as im: new_size = (100, 100) - im = im.resize(new_size) - assert im.size == new_size + im_resized = im.resize(new_size) + assert im_resized.size == new_size @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index acf79374e..2615f5a60 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -327,14 +327,13 @@ def test_loading_multiple_palettes(path: str, mode: str) -> None: im.seek(1) assert im.mode == mode - if mode == "RGBA": - im = im.convert("RGB") + im_rgb = im.convert("RGB") if mode == "RGBA" else im # Check a color only from the old palette - assert im.getpixel((0, 0)) == original_color + assert im_rgb.getpixel((0, 0)) == original_color # Check a color from the new palette - assert im.getpixel((24, 24)) not in first_frame_colors + assert im_rgb.getpixel((24, 24)) not in first_frame_colors def test_headers_saving_for_animated_gifs(tmp_path: Path) -> None: @@ -354,16 +353,16 @@ def test_palette_handling(tmp_path: Path) -> None: # see https://github.com/python-pillow/Pillow/issues/513 with Image.open(TEST_GIF) as im: - im = im.convert("RGB") + im_rgb = im.convert("RGB") - im = im.resize((100, 100), Image.Resampling.LANCZOS) - im2 = im.convert("P", palette=Image.Palette.ADAPTIVE, colors=256) + im_rgb = im_rgb.resize((100, 100), Image.Resampling.LANCZOS) + im_p = im_rgb.convert("P", palette=Image.Palette.ADAPTIVE, colors=256) - f = tmp_path / "temp.gif" - im2.save(f, optimize=True) + f = tmp_path / "temp.gif" + im_p.save(f, optimize=True) with Image.open(f) as reloaded: - assert_image_similar(im, reloaded.convert("RGB"), 10) + assert_image_similar(im_rgb, reloaded.convert("RGB"), 10) def test_palette_434(tmp_path: Path) -> None: @@ -383,35 +382,36 @@ def test_palette_434(tmp_path: Path) -> None: with roundtrip(im, optimize=True) as reloaded: assert_image_similar(im, reloaded, 1) - im = im.convert("RGB") - # check automatic P conversion - with roundtrip(im) as reloaded: - reloaded = reloaded.convert("RGB") - assert_image_equal(im, reloaded) + im_rgb = im.convert("RGB") + + # check automatic P conversion + with roundtrip(im_rgb) as reloaded: + reloaded = reloaded.convert("RGB") + assert_image_equal(im_rgb, reloaded) @pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available") def test_save_netpbm_bmp_mode(tmp_path: Path) -> None: with Image.open(TEST_GIF) as img: - img = img.convert("RGB") + img_rgb = img.convert("RGB") - tempfile = str(tmp_path / "temp.gif") - b = BytesIO() - GifImagePlugin._save_netpbm(img, b, tempfile) - with Image.open(tempfile) as reloaded: - assert_image_similar(img, reloaded.convert("RGB"), 0) + tempfile = str(tmp_path / "temp.gif") + b = BytesIO() + GifImagePlugin._save_netpbm(img_rgb, b, tempfile) + with Image.open(tempfile) as reloaded: + assert_image_similar(img_rgb, reloaded.convert("RGB"), 0) @pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available") def test_save_netpbm_l_mode(tmp_path: Path) -> None: with Image.open(TEST_GIF) as img: - img = img.convert("L") + img_l = img.convert("L") tempfile = str(tmp_path / "temp.gif") b = BytesIO() - GifImagePlugin._save_netpbm(img, b, tempfile) + GifImagePlugin._save_netpbm(img_l, b, tempfile) with Image.open(tempfile) as reloaded: - assert_image_similar(img, reloaded.convert("L"), 0) + assert_image_similar(img_l, reloaded.convert("L"), 0) def test_seek() -> None: @@ -1038,9 +1038,9 @@ def test_webp_background(tmp_path: Path) -> None: im.save(out) # Test non-opaque WebP background - im = Image.new("L", (100, 100), "#000") - im.info["background"] = (0, 0, 0, 0) - im.save(out) + im2 = Image.new("L", (100, 100), "#000") + im2.info["background"] = (0, 0, 0, 0) + im2.save(out) def test_comment(tmp_path: Path) -> None: @@ -1048,16 +1048,16 @@ def test_comment(tmp_path: Path) -> None: assert im.info["comment"] == b"File written by Adobe Photoshop\xa8 4.0" out = tmp_path / "temp.gif" - im = Image.new("L", (100, 100), "#000") - im.info["comment"] = b"Test comment text" - im.save(out) + im2 = Image.new("L", (100, 100), "#000") + im2.info["comment"] = b"Test comment text" + im2.save(out) with Image.open(out) as reread: - assert reread.info["comment"] == im.info["comment"] + assert reread.info["comment"] == im2.info["comment"] - im.info["comment"] = "Test comment text" - im.save(out) + im2.info["comment"] = "Test comment text" + im2.save(out) with Image.open(out) as reread: - assert reread.info["comment"] == im.info["comment"].encode() + assert reread.info["comment"] == im2.info["comment"].encode() # Test that GIF89a is used for comments assert reread.info["version"] == b"GIF89a" diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index e36b5f39e..e05563c6a 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -516,12 +516,12 @@ class TestFileLibTiff(LibTiffTestCase): # and save to compressed tif. out = tmp_path / "temp.tif" with Image.open("Tests/images/pport_g4.tif") as im: - im = im.convert("L") + im_l = im.convert("L") - im = im.filter(ImageFilter.GaussianBlur(4)) - im.save(out, compression="tiff_adobe_deflate") + im_l = im_l.filter(ImageFilter.GaussianBlur(4)) + im_l.save(out, compression="tiff_adobe_deflate") - assert_image_equal_tofile(im, out) + assert_image_equal_tofile(im_l, out) def test_compressions(self, tmp_path: Path) -> None: # Test various tiff compressions and assert similar image content but reduced @@ -1087,8 +1087,10 @@ class TestFileLibTiff(LibTiffTestCase): data = data[:102] + b"\x02" + data[103:] with Image.open(io.BytesIO(data)) as im: - im = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) - assert_image_equal_tofile(im, "Tests/images/old-style-jpeg-compression.png") + im_transposed = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + assert_image_equal_tofile( + im_transposed, "Tests/images/old-style-jpeg-compression.png" + ) def test_open_missing_samplesperpixel(self) -> None: with Image.open( diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 7f163a4d6..e9830cd3d 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -101,12 +101,13 @@ class TestFilePng: assert im.get_format_mimetype() == "image/png" for mode in ["1", "L", "P", "RGB", "I;16", "I;16B"]: - im = hopper(mode) - im.save(test_file) + im1 = hopper(mode) + im1.save(test_file) with Image.open(test_file) as reloaded: - if mode == "I;16B": - reloaded = reloaded.convert(mode) - assert_image_equal(reloaded, im) + converted_reloaded = ( + reloaded.convert(mode) if mode == "I;16B" else reloaded + ) + assert_image_equal(converted_reloaded, im1) def test_invalid_file(self) -> None: invalid_file = "Tests/images/flower.jpg" @@ -225,11 +226,11 @@ class TestFilePng: test_file = "Tests/images/pil123p.png" with Image.open(test_file) as im: assert_image(im, "P", (162, 150)) - im = im.convert("RGBA") - assert_image(im, "RGBA", (162, 150)) + im_rgba = im.convert("RGBA") + assert_image(im_rgba, "RGBA", (162, 150)) # image has 124 unique alpha values - colors = im.getchannel("A").getcolors() + colors = im_rgba.getchannel("A").getcolors() assert colors is not None assert len(colors) == 124 @@ -239,11 +240,11 @@ class TestFilePng: assert im.info["transparency"] == (0, 255, 52) assert_image(im, "RGB", (64, 64)) - im = im.convert("RGBA") - assert_image(im, "RGBA", (64, 64)) + im_rgba = im.convert("RGBA") + assert_image(im_rgba, "RGBA", (64, 64)) # image has 876 transparent pixels - colors = im.getchannel("A").getcolors() + colors = im_rgba.getchannel("A").getcolors() assert colors is not None assert colors[0][0] == 876 @@ -262,11 +263,11 @@ class TestFilePng: assert len(im.info["transparency"]) == 256 assert_image(im, "P", (162, 150)) - im = im.convert("RGBA") - assert_image(im, "RGBA", (162, 150)) + im_rgba = im.convert("RGBA") + assert_image(im_rgba, "RGBA", (162, 150)) # image has 124 unique alpha values - colors = im.getchannel("A").getcolors() + colors = im_rgba.getchannel("A").getcolors() assert colors is not None assert len(colors) == 124 @@ -285,13 +286,13 @@ class TestFilePng: assert im.info["transparency"] == 164 assert im.getpixel((31, 31)) == 164 assert_image(im, "P", (64, 64)) - im = im.convert("RGBA") - assert_image(im, "RGBA", (64, 64)) + im_rgba = im.convert("RGBA") + assert_image(im_rgba, "RGBA", (64, 64)) - assert im.getpixel((31, 31)) == (0, 255, 52, 0) + assert im_rgba.getpixel((31, 31)) == (0, 255, 52, 0) # image has 876 transparent pixels - colors = im.getchannel("A").getcolors() + colors = im_rgba.getchannel("A").getcolors() assert colors is not None assert colors[0][0] == 876 diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 63cd0e4d4..35fe3bb8a 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -457,9 +457,9 @@ def test_exif_transpose() -> None: assert 0x0112 not in transposed_im.getexif() # Orientation set directly on Image.Exif - im = hopper() - im.getexif()[0x0112] = 3 - transposed_im = ImageOps.exif_transpose(im) + im1 = hopper() + im1.getexif()[0x0112] = 3 + transposed_im = ImageOps.exif_transpose(im1) assert 0x0112 not in transposed_im.getexif() diff --git a/Tests/test_pickle.py b/Tests/test_pickle.py index fc76f81e9..2447ae67a 100644 --- a/Tests/test_pickle.py +++ b/Tests/test_pickle.py @@ -19,30 +19,28 @@ def helper_pickle_file( # Arrange with Image.open(test_file) as im: filename = tmp_path / "temp.pkl" - if mode: - im = im.convert(mode) + converted_im = im.convert(mode) if mode else im # Act with open(filename, "wb") as f: - pickle.dump(im, f, protocol) + pickle.dump(converted_im, f, protocol) with open(filename, "rb") as f: loaded_im = pickle.load(f) # Assert - assert im == loaded_im + assert converted_im == loaded_im def helper_pickle_string(protocol: int, test_file: str, mode: str | None) -> None: with Image.open(test_file) as im: - if mode: - im = im.convert(mode) + converted_im = im.convert(mode) if mode else im # Act - dumped_string = pickle.dumps(im, protocol) + dumped_string = pickle.dumps(converted_im, protocol) loaded_im = pickle.loads(dumped_string) # Assert - assert im == loaded_im + assert converted_im == loaded_im @pytest.mark.parametrize( diff --git a/src/PIL/IptcImagePlugin.py b/src/PIL/IptcImagePlugin.py index c28f4dcc7..cf7067daa 100644 --- a/src/PIL/IptcImagePlugin.py +++ b/src/PIL/IptcImagePlugin.py @@ -154,10 +154,11 @@ class IptcImageFile(ImageFile.ImageFile): if band is not None: bands = [Image.new("L", _im.size)] * Image.getmodebands(self.mode) bands[band] = _im - _im = Image.merge(self.mode, bands) + im = Image.merge(self.mode, bands) else: - _im.load() - self.im = _im.im + im = _im + im.load() + self.im = im.im self.tile = [] return ImageFile.ImageFile.load(self) diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index 868019e80..7c3e84d74 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -323,9 +323,9 @@ if __name__ == "__main__": outfile = sys.argv[2] # perform some image operation - im = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + transposed_im = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) print( f"saving a flipped version of {os.path.basename(filename)} " f"as {outfile} " ) - im.save(outfile, SpiderImageFile.format) + transposed_im.save(outfile, SpiderImageFile.format) From 080afe1bf7757208ba2c7995d1ca2754e3f78c2f Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 28 Dec 2025 23:46:02 +0200 Subject: [PATCH 58/74] Replace pre-commit with prek --- .github/workflows/lint.yml | 17 ++++++++--------- .github/zizmor.yml | 1 - tox.ini | 7 ++++--- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 77d1d1caa..5f72550c0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,18 +2,17 @@ name: Lint on: [push, pull_request, workflow_dispatch] +permissions: {} + env: FORCE_COLOR: 1 -permissions: - contents: read - concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - build: + lint: runs-on: ubuntu-latest @@ -24,13 +23,13 @@ jobs: with: persist-credentials: false - - name: pre-commit cache + - name: prek cache uses: actions/cache@v4 with: - path: ~/.cache/pre-commit - key: lint-pre-commit-${{ hashFiles('**/.pre-commit-config.yaml') }} + path: ~/.cache/prek + key: lint-prek-${{ hashFiles('**/.pre-commit-config.yaml') }} restore-keys: | - lint-pre-commit- + lint-prek- - name: Set up Python uses: actions/setup-python@v6 @@ -50,7 +49,7 @@ jobs: - name: Lint run: tox -e lint env: - PRE_COMMIT_COLOR: always + PREK_COLOR: always - name: Mypy run: tox -e mypy diff --git a/.github/zizmor.yml b/.github/zizmor.yml index e60c79441..f4949c30c 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -1,4 +1,3 @@ -# Configuration for the zizmor static analysis tool, run via pre-commit in CI # https://docs.zizmor.sh/configuration/ rules: obfuscation: diff --git a/tox.ini b/tox.ini index 7f116c6e7..de18946ef 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,7 @@ requires = tox>=4.2 env_list = lint + mypy py{py3, 315, 314, 313, 312, 311, 310} [testenv] @@ -18,11 +19,11 @@ commands = skip_install = true deps = check-manifest - pre-commit + prek pass_env = - PRE_COMMIT_COLOR + PREK_COLOR commands = - pre-commit run --all-files --show-diff-on-failure + prek run --all-files --show-diff-on-failure check-manifest [testenv:mypy] From 81e80f7a50e2e884d67d09ed7c735ee87f0f0f0a Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 29 Dec 2025 19:35:41 +0200 Subject: [PATCH 59/74] Install and run tox/lint/mypy via uv --- .github/workflows/lint.yml | 51 +++++++++++--------------------------- 1 file changed, 14 insertions(+), 37 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5f72550c0..e2f8bf47a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,6 +6,8 @@ permissions: {} env: FORCE_COLOR: 1 + PREK_COLOR: always + RUFF_OUTPUT_FORMAT: github concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -13,43 +15,18 @@ concurrency: jobs: lint: - runs-on: ubuntu-latest - name: Lint - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: prek cache - uses: actions/cache@v4 - with: - path: ~/.cache/prek - key: lint-prek-${{ hashFiles('**/.pre-commit-config.yaml') }} - restore-keys: | - lint-prek- - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.x" - cache: pip - cache-dependency-path: "setup.py" - - - name: Build system information - run: python3 .github/workflows/system-info.py - - - name: Install dependencies - run: | - python3 -m pip install -U pip - python3 -m pip install -U tox - - - name: Lint - run: tox -e lint - env: - PREK_COLOR: always - - - name: Mypy - run: tox -e mypy + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: "3.x" + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Lint + run: uvx --with tox-uv tox -e lint + - name: Mypy + run: uvx --with tox-uv tox -e mypy From 0a9a47fb9b9780fed4cf859da12cfa5fc867a71e Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Wed, 31 Dec 2025 23:02:31 +1100 Subject: [PATCH 60/74] Update ImageMorph documentation (#9349) Co-authored-by: Andrew Murray Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- docs/reference/ImageMorph.rst | 46 ++++++++++++++- src/PIL/ImageMorph.py | 108 +++++++++++++++++++++++++--------- 2 files changed, 122 insertions(+), 32 deletions(-) diff --git a/docs/reference/ImageMorph.rst b/docs/reference/ImageMorph.rst index 30b89a54d..77b96058a 100644 --- a/docs/reference/ImageMorph.rst +++ b/docs/reference/ImageMorph.rst @@ -4,10 +4,50 @@ :py:mod:`~PIL.ImageMorph` module ================================ -The :py:mod:`~PIL.ImageMorph` module provides morphology operations on images. +The :py:mod:`~PIL.ImageMorph` module allows `morphology`_ operators ("MorphOp") to be +applied to L mode images:: -.. automodule:: PIL.ImageMorph + from PIL import Image, ImageMorph + img = Image.open("Tests/images/hopper.bw") + mop = ImageMorph.MorphOp(op_name="erosion4") + count, imgOut = mop.apply(img) + imgOut.show() + +.. _morphology: https://en.wikipedia.org/wiki/Mathematical_morphology + +In addition to applying operators, you can also analyse images. + +You can inspect an image in isolation to determine which pixels are non-empty:: + + print(mop.get_on_pixels(img)) # [(0, 0), (1, 0), (2, 0), ...] + +Or you can retrieve a list of pixels that match the operator. This is the number of +pixels that will be non-empty after the operator is applied:: + + coords = mop.match(img) + print(coords) # [(17, 1), (18, 1), (34, 1), ...] + print(len(coords)) # 550 + + imgOut = mop.apply(img)[1] + print(len(mop.get_on_pixels(imgOut))) # 550 + +If you would like more customized operators, you can pass patterns to the MorphOp +class:: + + mop = ImageMorph.MorphOp(patterns=["1:(... ... ...)->0", "4:(00. 01. ...)->1"]) + +Or you can pass lookup table ("LUT") data directly. This LUT data can be constructed +with the :py:class:`~PIL.ImageMorph.LutBuilder`:: + + builder = ImageMorph.LutBuilder() + mop = ImageMorph.MorphOp(lut=builder.build_lut()) + +.. autoclass:: LutBuilder + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: MorphOp :members: :undoc-members: :show-inheritance: - :noindex: diff --git a/src/PIL/ImageMorph.py b/src/PIL/ImageMorph.py index 4d9517d02..90e199117 100644 --- a/src/PIL/ImageMorph.py +++ b/src/PIL/ImageMorph.py @@ -65,10 +65,12 @@ class LutBuilder: def __init__( self, patterns: list[str] | None = None, op_name: str | None = None ) -> None: - if patterns is not None: - self.patterns = patterns - else: - self.patterns = [] + """ + :param patterns: A list of input patterns, or None. + :param op_name: The name of a known pattern. One of "corner", "dilation4", + "dilation8", "erosion4", "erosion8" or "edge". + :exception Exception: If the op_name is not recognized. + """ self.lut: bytearray | None = None if op_name is not None: known_patterns = { @@ -88,21 +90,38 @@ class LutBuilder: raise Exception(msg) self.patterns = known_patterns[op_name] + elif patterns is not None: + self.patterns = patterns + else: + self.patterns = [] def add_patterns(self, patterns: list[str]) -> None: + """ + Append to list of patterns. + + :param patterns: Additional patterns. + """ self.patterns += patterns def build_default_lut(self) -> bytearray: + """ + Set the current LUT, and return it. + + This is the default LUT that patterns will be applied against when building. + """ symbols = [0, 1] m = 1 << 4 # pos of current pixel self.lut = bytearray(symbols[(i & m) > 0] for i in range(LUT_SIZE)) return self.lut def get_lut(self) -> bytearray | None: + """ + Returns the current LUT + """ return self.lut def _string_permute(self, pattern: str, permutation: list[int]) -> str: - """string_permute takes a pattern and a permutation and returns the + """Takes a pattern and a permutation and returns the string permuted according to the permutation list. """ assert len(permutation) == 9 @@ -111,7 +130,7 @@ class LutBuilder: def _pattern_permute( self, basic_pattern: str, options: str, basic_result: int ) -> list[tuple[str, int]]: - """pattern_permute takes a basic pattern and its result and clones + """Takes a basic pattern and its result and clones the pattern according to the modifications described in the $options parameter. It returns a list of all cloned patterns.""" patterns = [(basic_pattern, basic_result)] @@ -141,10 +160,9 @@ class LutBuilder: return patterns def build_lut(self) -> bytearray: - """Compile all patterns into a morphology lut. + """Compile all patterns into a morphology LUT, and return it. - TBD :Build based on (file) morphlut:modify_lut - """ + This is the data to be passed into MorphOp.""" self.build_default_lut() assert self.lut is not None patterns = [] @@ -164,15 +182,14 @@ class LutBuilder: patterns += self._pattern_permute(pattern, options, result) - # compile the patterns into regular expressions for speed + # Compile the patterns into regular expressions for speed compiled_patterns = [] for pattern in patterns: p = pattern[0].replace(".", "X").replace("X", "[01]") compiled_patterns.append((re.compile(p), pattern[1])) # Step through table and find patterns that match. - # Note that all the patterns are searched. The last one - # caught overrides + # Note that all the patterns are searched. The last one found takes priority for i in range(LUT_SIZE): # Build the bit pattern bitpattern = bin(i)[2:] @@ -194,18 +211,30 @@ class MorphOp: op_name: str | None = None, patterns: list[str] | None = None, ) -> None: - """Create a binary morphological operator""" - self.lut = lut - if op_name is not None: - self.lut = LutBuilder(op_name=op_name).build_lut() - elif patterns is not None: - self.lut = LutBuilder(patterns=patterns).build_lut() + """Create a binary morphological operator. + + If the LUT is not provided, then it is built using LutBuilder from the op_name + or the patterns. + + :param lut: The LUT data. + :param patterns: A list of input patterns, or None. + :param op_name: The name of a known pattern. One of "corner", "dilation4", + "dilation8", "erosion4", "erosion8", "edge". + :exception Exception: If the op_name is not recognized. + """ + if patterns is None and op_name is None: + self.lut = lut + else: + self.lut = LutBuilder(patterns, op_name).build_lut() def apply(self, image: Image.Image) -> tuple[int, Image.Image]: - """Run a single morphological operation on an image + """Run a single morphological operation on an image. Returns a tuple of the number of changed pixels and the - morphed image""" + morphed image. + + :exception Exception: If the current operator is None. + :exception ValueError: If the image is not L mode.""" if self.lut is None: msg = "No operator loaded" raise Exception(msg) @@ -213,7 +242,7 @@ class MorphOp: if image.mode != "L": msg = "Image mode must be L" raise ValueError(msg) - outimage = Image.new(image.mode, image.size, None) + outimage = Image.new(image.mode, image.size) count = _imagingmorph.apply(bytes(self.lut), image.getim(), outimage.getim()) return count, outimage @@ -221,8 +250,12 @@ class MorphOp: """Get a list of coordinates matching the morphological operation on an image. - Returns a list of tuples of (x,y) coordinates - of all matching pixels. See :ref:`coordinate-system`.""" + Returns a list of tuples of (x,y) coordinates of all matching pixels. See + :ref:`coordinate-system`. + + :param image: An L-mode image. + :exception Exception: If the current operator is None. + :exception ValueError: If the image is not L mode.""" if self.lut is None: msg = "No operator loaded" raise Exception(msg) @@ -233,10 +266,13 @@ class MorphOp: return _imagingmorph.match(bytes(self.lut), image.getim()) def get_on_pixels(self, image: Image.Image) -> list[tuple[int, int]]: - """Get a list of all turned on pixels in a binary image + """Get a list of all turned on pixels in a grayscale image - Returns a list of tuples of (x,y) coordinates - of all matching pixels. See :ref:`coordinate-system`.""" + Returns a list of tuples of (x,y) coordinates of all non-empty pixels. See + :ref:`coordinate-system`. + + :param image: An L-mode image. + :exception ValueError: If the image is not L mode.""" if image.mode != "L": msg = "Image mode must be L" @@ -244,7 +280,12 @@ class MorphOp: return _imagingmorph.get_on_pixels(image.getim()) def load_lut(self, filename: str) -> None: - """Load an operator from an mrl file""" + """ + Load an operator from an mrl file + + :param filename: The file to read from. + :exception Exception: If the length of the file data is not 512. + """ with open(filename, "rb") as f: self.lut = bytearray(f.read()) @@ -254,7 +295,12 @@ class MorphOp: raise Exception(msg) def save_lut(self, filename: str) -> None: - """Save an operator to an mrl file""" + """ + Save an operator to an mrl file. + + :param filename: The destination file. + :exception Exception: If the current operator is None. + """ if self.lut is None: msg = "No operator loaded" raise Exception(msg) @@ -262,5 +308,9 @@ class MorphOp: f.write(self.lut) def set_lut(self, lut: bytearray | None) -> None: - """Set the lut from an external source""" + """ + Set the LUT from an external source + + :param lut: A new LUT. + """ self.lut = lut From 19910ed03ecf6a845a2ad1dbe05f060959c595d9 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Wed, 31 Dec 2025 23:47:33 +1100 Subject: [PATCH 61/74] Call parent verify method (#9357) --- src/PIL/PngImagePlugin.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 967308221..e3588735f 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -855,9 +855,7 @@ class PngImageFile(ImageFile.ImageFile): self.png.verify() self.png.close() - if self._exclusive_fp: - self.fp.close() - self.fp = None + super().verify() def seek(self, frame: int) -> None: if not self._seek_check(frame): From 2ebfe30ae39d6d016b5239321ada65c85644e99e Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Wed, 31 Dec 2025 23:47:50 +1100 Subject: [PATCH 62/74] Added return type to ImageFile _close_fp() (#9356) --- src/PIL/ImageFile.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index a1d98bd51..ecb51b193 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -131,6 +131,7 @@ class ImageFile(Image.Image): self.decoderconfig: tuple[Any, ...] = () self.decodermaxblock = MAXBLOCK + self._fp: IO[bytes] | DeferredError if is_path(fp): # filename self.fp = open(fp, "rb") @@ -167,7 +168,7 @@ class ImageFile(Image.Image): def _open(self) -> None: pass - def _close_fp(self): + def _close_fp(self) -> None: if getattr(self, "_fp", False) and not isinstance(self._fp, DeferredError): if self._fp != self.fp: self._fp.close() From d62955031b593b6f993c655eb1e4332c15818df7 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Thu, 1 Jan 2026 08:53:04 +1100 Subject: [PATCH 63/74] Allow for duplicate font variation styles (#9362) Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Tests/fonts/AdobeVFPrototypeDuplicates.ttf | Bin 0 -> 6696 bytes Tests/fonts/LICENSE.txt | 2 +- Tests/test_imagefont.py | 17 ++++++++++++++++- src/PIL/ImageFont.py | 8 ++++++-- src/_imagingft.c | 1 - 5 files changed, 23 insertions(+), 5 deletions(-) create mode 100644 Tests/fonts/AdobeVFPrototypeDuplicates.ttf diff --git a/Tests/fonts/AdobeVFPrototypeDuplicates.ttf b/Tests/fonts/AdobeVFPrototypeDuplicates.ttf new file mode 100644 index 0000000000000000000000000000000000000000..acf0bc156c8bec3c79de96968766795951b3a8fb GIT binary patch literal 6696 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 None: font.get_variation_axes() font = ImageFont.truetype("Tests/fonts/AdobeVFPrototype.ttf") - assert font.get_variation_names(), [ + assert font.get_variation_names() == [ b"ExtraLight", b"Light", b"Regular", @@ -742,6 +742,21 @@ def test_variation_get(font: ImageFont.FreeTypeFont) -> None: ] +def test_variation_duplicates() -> None: + font = ImageFont.truetype("Tests/fonts/AdobeVFPrototypeDuplicates.ttf") + assert font.get_variation_names() == [ + b"ExtraLight", + b"Light", + b"Regular", + b"Semibold", + b"Bold", + b"Black", + b"Black Medium Contrast", + b"Black High Contrast", + b"Default", + ] + + def _check_text(font: ImageFont.FreeTypeFont, path: str, epsilon: float) -> None: im = Image.new("RGB", (100, 75), "white") d = ImageDraw.Draw(im) diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 2e8ace98d..d11f7bf01 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -675,8 +675,12 @@ class FreeTypeFont: :returns: A list of the named styles in a variation font. :exception OSError: If the font is not a variation font. """ - names = self.font.getvarnames() - return [name.replace(b"\x00", b"") for name in names] + names = [] + for name in self.font.getvarnames(): + name = name.replace(b"\x00", b"") + if name not in names: + names.append(name) + return names def set_variation_by_name(self, name: str | bytes) -> None: """ diff --git a/src/_imagingft.c b/src/_imagingft.c index d0af25b30..a371173d6 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -1287,7 +1287,6 @@ font_getvarnames(FontObject *self) { } PyList_SetItem(list_names, j, list_name); list_names_filled[j] = 1; - break; } } } From 900636e7dbc8ead2caaae0e387096fa8bf6474d5 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Thu, 1 Jan 2026 17:31:36 +1100 Subject: [PATCH 64/74] Use minimum supported Python version for Lint (#9364) --- .github/workflows/lint.yml | 2 +- .pre-commit-config.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e2f8bf47a..4f67be6f7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,7 +23,7 @@ jobs: persist-credentials: false - uses: actions/setup-python@v6 with: - python-version: "3.x" + python-version: "3.10" - name: Install uv uses: astral-sh/setup-uv@v7 - name: Lint diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8477729e6..10343f91a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -76,7 +76,7 @@ repos: rev: v0.24.1 hooks: - id: validate-pyproject - additional_dependencies: [trove-classifiers>=2024.10.12] + additional_dependencies: [tomli, trove-classifiers>=2024.10.12] - repo: https://github.com/tox-dev/tox-ini-fmt rev: 1.7.0 From 91f219fdcff8bc3eaafa2a0c2269286ebff0cca1 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 1 Jan 2026 13:02:21 +1100 Subject: [PATCH 65/74] Support saving float durations --- Tests/test_file_apng.py | 18 ++++++++++++++++++ docs/handbook/image-file-formats.rst | 5 ++--- src/PIL/PngImagePlugin.py | 11 ++++++++--- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 644b7807a..b57a1d1ad 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -518,6 +518,24 @@ def test_apng_save_duration_loop(tmp_path: Path) -> None: assert im.info["duration"] == 600 +def test_apng_save_duration_float(tmp_path: Path) -> None: + test_file = tmp_path / "temp.png" + im = Image.new("1", (1, 1)) + im2 = Image.new("1", (1, 1), 1) + im.save(test_file, save_all=True, append_images=[im2], duration=0.5) + + with Image.open(test_file) as reloaded: + assert reloaded.info["duration"] == 0.5 + + +def test_apng_save_large_duration(tmp_path: Path) -> None: + test_file = tmp_path / "temp.png" + im = Image.new("1", (1, 1)) + im2 = Image.new("1", (1, 1), 1) + with pytest.raises(ValueError, match="cannot write duration"): + im.save(test_file, save_all=True, append_images=[im2], duration=65536000) + + def test_apng_save_disposal(tmp_path: Path) -> None: test_file = tmp_path / "temp.png" size = (128, 64) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 03ee96c0f..d26c0fbae 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -1041,9 +1041,8 @@ following parameters can also be set: Defaults to 0. **duration** - Integer (or list or tuple of integers) length of time to display this APNG frame - (in milliseconds). - Defaults to 0. + The length of time (or list or tuple of lengths of time) to display this APNG frame + (in milliseconds). Defaults to 0. **disposal** An integer (or list or tuple of integers) specifying the APNG disposal diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index e3588735f..5f536035a 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -39,6 +39,7 @@ import struct import warnings import zlib from enum import IntEnum +from fractions import Fraction from typing import IO, NamedTuple, cast from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence @@ -1272,7 +1273,11 @@ def _write_multiple_frames( im_frame = im_frame.crop(bbox) size = im_frame.size encoderinfo = frame_data.encoderinfo - frame_duration = int(round(encoderinfo.get("duration", 0))) + frame_duration = encoderinfo.get("duration", 0) + delay = Fraction(frame_duration / 1000).limit_denominator(65535) + if delay.numerator > 65535: + msg = "cannot write duration" + raise ValueError(msg) frame_disposal = encoderinfo.get("disposal", disposal) frame_blend = encoderinfo.get("blend", blend) # frame control @@ -1284,8 +1289,8 @@ def _write_multiple_frames( o32(size[1]), # height o32(bbox[0]), # x_offset o32(bbox[1]), # y_offset - o16(frame_duration), # delay_numerator - o16(1000), # delay_denominator + o16(delay.numerator), # delay_numerator + o16(delay.denominator), # delay_denominator o8(frame_disposal), # dispose_op o8(frame_blend), # blend_op ) From 43f8efad7988bee378408d8acafeaab6f0f70b85 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Thu, 1 Jan 2026 18:41:34 +1100 Subject: [PATCH 66/74] Added release notes for #9350 (#9366) Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- docs/releasenotes/12.1.0.rst | 44 ++++-------------------------------- 1 file changed, 4 insertions(+), 40 deletions(-) diff --git a/docs/releasenotes/12.1.0.rst b/docs/releasenotes/12.1.0.rst index b6e1810c6..0541f882f 100644 --- a/docs/releasenotes/12.1.0.rst +++ b/docs/releasenotes/12.1.0.rst @@ -1,42 +1,14 @@ 12.1.0 ------ -Security -======== - -TODO -^^^^ - -TODO - -:cve:`YYYY-XXXXX`: TODO -^^^^^^^^^^^^^^^^^^^^^^^ - -TODO - -Backwards incompatible changes -============================== - -TODO -^^^^ - -TODO - -Deprecations -============ - -TODO -^^^^ - -TODO - API changes =========== -TODO -^^^^ +ImageMorph build_default_lut() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +To match the behaviour of :py:meth:`~PIL.ImageMorph.LutBuilder.build_lut`, +:py:meth:`~PIL.ImageMorph.LutBuilder.build_default_lut()` now returns the new LUT. API additions ============= @@ -49,11 +21,3 @@ macOS in addition to Windows. On macOS, this is a CGWindowID:: from PIL import ImageGrab ImageGrab.grab(window=cgwindowid) - -Other changes -============= - -TODO -^^^^ - -TODO From a868c29eb196a082536e9a0895c4972f18c443ad Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Thu, 1 Jan 2026 20:01:38 +1100 Subject: [PATCH 67/74] Assert fp is not None (#8617) --- Tests/test_file_bufrstub.py | 1 + Tests/test_file_gribstub.py | 1 + Tests/test_file_hdf5stub.py | 1 + Tests/test_file_jpeg.py | 3 ++- Tests/test_file_libtiff.py | 3 ++- Tests/test_file_tiff.py | 5 ++++- Tests/test_image_load.py | 1 + docs/example/DdsImagePlugin.py | 1 + src/PIL/AvifImagePlugin.py | 2 ++ src/PIL/BlpImagePlugin.py | 1 + src/PIL/BmpImagePlugin.py | 2 ++ src/PIL/BufrStubImagePlugin.py | 1 + src/PIL/DcxImagePlugin.py | 1 + src/PIL/EpsImagePlugin.py | 2 ++ src/PIL/FpxImagePlugin.py | 2 ++ src/PIL/FtexImagePlugin.py | 1 + src/PIL/GbrImagePlugin.py | 2 ++ src/PIL/GifImagePlugin.py | 2 ++ src/PIL/GribStubImagePlugin.py | 1 + src/PIL/Hdf5StubImagePlugin.py | 1 + src/PIL/IcnsImagePlugin.py | 1 + src/PIL/IcoImagePlugin.py | 1 + src/PIL/ImImagePlugin.py | 1 + src/PIL/Image.py | 2 ++ src/PIL/ImageFile.py | 1 + src/PIL/IptcImagePlugin.py | 3 +++ src/PIL/Jpeg2KImagePlugin.py | 2 ++ src/PIL/JpegImagePlugin.py | 7 +++++++ src/PIL/MicImagePlugin.py | 1 + src/PIL/MpoImagePlugin.py | 2 ++ src/PIL/PngImagePlugin.py | 3 +++ src/PIL/PsdImagePlugin.py | 1 + src/PIL/QoiImagePlugin.py | 1 + src/PIL/SpiderImagePlugin.py | 1 + src/PIL/WalImageFile.py | 2 ++ src/PIL/WmfImagePlugin.py | 2 ++ 36 files changed, 62 insertions(+), 3 deletions(-) diff --git a/Tests/test_file_bufrstub.py b/Tests/test_file_bufrstub.py index 362578c56..8c6bb1a69 100644 --- a/Tests/test_file_bufrstub.py +++ b/Tests/test_file_bufrstub.py @@ -61,6 +61,7 @@ def test_handler(tmp_path: Path) -> None: def load(self, im: ImageFile.StubImageFile) -> Image.Image: self.loaded = True + assert im.fp is not None im.fp.close() return Image.new("RGB", (1, 1)) diff --git a/Tests/test_file_gribstub.py b/Tests/test_file_gribstub.py index 4dbed6b31..05925d502 100644 --- a/Tests/test_file_gribstub.py +++ b/Tests/test_file_gribstub.py @@ -61,6 +61,7 @@ def test_handler(tmp_path: Path) -> None: def load(self, im: ImageFile.ImageFile) -> Image.Image: self.loaded = True + assert im.fp is not None im.fp.close() return Image.new("RGB", (1, 1)) diff --git a/Tests/test_file_hdf5stub.py b/Tests/test_file_hdf5stub.py index 1e48597d3..e1a56309b 100644 --- a/Tests/test_file_hdf5stub.py +++ b/Tests/test_file_hdf5stub.py @@ -63,6 +63,7 @@ def test_handler(tmp_path: Path) -> None: def load(self, im: ImageFile.ImageFile) -> Image.Image: self.loaded = True + assert im.fp is not None im.fp.close() return Image.new("RGB", (1, 1)) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 96e7f4239..f818927f6 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -1133,8 +1133,9 @@ class TestFileCloseW32: im.save(tmpfile) im = Image.open(tmpfile) + assert im.fp is not None + assert not im.fp.closed fp = im.fp - assert not fp.closed with pytest.raises(OSError): os.remove(tmpfile) im.load() diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index e05563c6a..90ca2cf12 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -610,8 +610,9 @@ class TestFileLibTiff(LibTiffTestCase): im.save(out, compression=compression) def test_fp_leak(self) -> None: - im: Image.Image | None = Image.open("Tests/images/hopper_g4_500.tif") + im: ImageFile.ImageFile | None = Image.open("Tests/images/hopper_g4_500.tif") assert im is not None + assert im.fp is not None fn = im.fp.fileno() os.fstat(fn) diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 556c88647..c6c8467d6 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -971,6 +971,7 @@ class TestFileTiff: im = Image.open(tmpfile) fp = im.fp + assert fp is not None assert not fp.closed im.load() assert fp.closed @@ -984,6 +985,7 @@ class TestFileTiff: with open(tmpfile, "rb") as f: im = Image.open(f) fp = im.fp + assert fp is not None assert not fp.closed im.load() assert not fp.closed @@ -1034,8 +1036,9 @@ class TestFileTiffW32: im.save(tmpfile) im = Image.open(tmpfile) + assert im.fp is not None + assert not im.fp.closed fp = im.fp - assert not fp.closed with pytest.raises(OSError): os.remove(tmpfile) im.load() diff --git a/Tests/test_image_load.py b/Tests/test_image_load.py index 4f1d63b8f..1d5f0d17c 100644 --- a/Tests/test_image_load.py +++ b/Tests/test_image_load.py @@ -38,6 +38,7 @@ def test_close_after_load(caplog: pytest.LogCaptureFixture) -> None: def test_contextmanager() -> None: fn = None with Image.open("Tests/images/hopper.gif") as im: + assert im.fp is not None fn = im.fp.fileno() os.fstat(fn) diff --git a/docs/example/DdsImagePlugin.py b/docs/example/DdsImagePlugin.py index e4b6b9c01..e0557976c 100644 --- a/docs/example/DdsImagePlugin.py +++ b/docs/example/DdsImagePlugin.py @@ -213,6 +213,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) diff --git a/src/PIL/AvifImagePlugin.py b/src/PIL/AvifImagePlugin.py index 366e0c864..43c39a9fb 100644 --- a/src/PIL/AvifImagePlugin.py +++ b/src/PIL/AvifImagePlugin.py @@ -77,6 +77,8 @@ class AvifImageFile(ImageFile.ImageFile): ): msg = "Invalid opening codec" raise ValueError(msg) + + assert self.fp is not None self._decoder = _avif.AvifDecoder( self.fp.read(), DECODE_CODEC_CHOICE, diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index f7be7746d..6bb92edf8 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -258,6 +258,7 @@ class BlpImageFile(ImageFile.ImageFile): format_description = "Blizzard Mipmap Format" def _open(self) -> None: + assert self.fp is not None self.magic = self.fp.read(4) if not _accept(self.magic): msg = f"Bad BLP magic {repr(self.magic)}" diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index 54fc69ab4..a12271370 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -76,6 +76,7 @@ class BmpImageFile(ImageFile.ImageFile): def _bitmap(self, header: int = 0, offset: int = 0) -> None: """Read relevant info about the BMP""" + assert self.fp is not None read, seek = self.fp.read, self.fp.seek if header: seek(header) @@ -311,6 +312,7 @@ class BmpImageFile(ImageFile.ImageFile): def _open(self) -> None: """Open file, check magic number and read header""" # read 14 bytes: magic number, filesize, reserved, header final offset + assert self.fp is not None head_data = self.fp.read(14) # choke if the file does not have the required magic bytes if not _accept(head_data): diff --git a/src/PIL/BufrStubImagePlugin.py b/src/PIL/BufrStubImagePlugin.py index 8c5da14f5..264564d2b 100644 --- a/src/PIL/BufrStubImagePlugin.py +++ b/src/PIL/BufrStubImagePlugin.py @@ -41,6 +41,7 @@ class BufrStubImageFile(ImageFile.StubImageFile): format_description = "BUFR" def _open(self) -> None: + assert self.fp is not None if not _accept(self.fp.read(4)): msg = "Not a BUFR file" raise SyntaxError(msg) diff --git a/src/PIL/DcxImagePlugin.py b/src/PIL/DcxImagePlugin.py index aea661b9c..d3f456ddc 100644 --- a/src/PIL/DcxImagePlugin.py +++ b/src/PIL/DcxImagePlugin.py @@ -45,6 +45,7 @@ class DcxImageFile(PcxImageFile): def _open(self) -> None: # Header + assert self.fp is not None s = self.fp.read(4) if not _accept(s): msg = "not a DCX file" diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index 69f3062b4..2effb816c 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -189,6 +189,7 @@ class EpsImageFile(ImageFile.ImageFile): mode_map = {1: "L", 2: "LAB", 3: "RGB", 4: "CMYK"} def _open(self) -> None: + assert self.fp is not None (length, offset) = self._find_offset(self.fp) # go to offset - start of "%!PS" @@ -403,6 +404,7 @@ class EpsImageFile(ImageFile.ImageFile): ) -> Image.core.PixelAccess | None: # Load EPS via Ghostscript if self.tile: + assert self.fp is not None self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency) self._mode = self.im.mode self._size = self.im.size diff --git a/src/PIL/FpxImagePlugin.py b/src/PIL/FpxImagePlugin.py index fd992cd9e..297971234 100644 --- a/src/PIL/FpxImagePlugin.py +++ b/src/PIL/FpxImagePlugin.py @@ -58,6 +58,7 @@ class FpxImageFile(ImageFile.ImageFile): # read the OLE directory and see if this is a likely # to be a FlashPix file + assert self.fp is not None try: self.ole = olefile.OleFileIO(self.fp) except OSError as e: @@ -229,6 +230,7 @@ class FpxImageFile(ImageFile.ImageFile): if y >= ysize: break # isn't really required + assert self.fp is not None self.stream = stream self._fp = self.fp self.fp = None diff --git a/src/PIL/FtexImagePlugin.py b/src/PIL/FtexImagePlugin.py index d60e75bb6..e4d836cbd 100644 --- a/src/PIL/FtexImagePlugin.py +++ b/src/PIL/FtexImagePlugin.py @@ -72,6 +72,7 @@ class FtexImageFile(ImageFile.ImageFile): format_description = "Texture File Format (IW2:EOC)" def _open(self) -> None: + assert self.fp is not None if not _accept(self.fp.read(4)): msg = "not an FTEX file" raise SyntaxError(msg) diff --git a/src/PIL/GbrImagePlugin.py b/src/PIL/GbrImagePlugin.py index d69295363..ec666c81c 100644 --- a/src/PIL/GbrImagePlugin.py +++ b/src/PIL/GbrImagePlugin.py @@ -42,6 +42,7 @@ class GbrImageFile(ImageFile.ImageFile): format_description = "GIMP brush file" def _open(self) -> None: + assert self.fp is not None header_size = i32(self.fp.read(4)) if header_size < 20: msg = "not a GIMP brush" @@ -88,6 +89,7 @@ class GbrImageFile(ImageFile.ImageFile): def load(self) -> Image.core.PixelAccess | None: if self._im is None: + assert self.fp is not None self.im = Image.core.new(self.mode, self.size) self.frombytes(self.fp.read(self._data_size)) return Image.Image.load(self) diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 0560a5a7d..b8755422d 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -87,6 +87,7 @@ class GifImageFile(ImageFile.ImageFile): global_palette = None def data(self) -> bytes | None: + assert self.fp is not None s = self.fp.read(1) if s and s[0]: return self.fp.read(s[0]) @@ -100,6 +101,7 @@ class GifImageFile(ImageFile.ImageFile): def _open(self) -> None: # Screen + assert self.fp is not None s = self.fp.read(13) if not _accept(s): msg = "not a GIF file" diff --git a/src/PIL/GribStubImagePlugin.py b/src/PIL/GribStubImagePlugin.py index dfa798893..146a6fa0d 100644 --- a/src/PIL/GribStubImagePlugin.py +++ b/src/PIL/GribStubImagePlugin.py @@ -41,6 +41,7 @@ class GribStubImageFile(ImageFile.StubImageFile): format_description = "GRIB" def _open(self) -> None: + assert self.fp is not None if not _accept(self.fp.read(8)): msg = "Not a GRIB file" raise SyntaxError(msg) diff --git a/src/PIL/Hdf5StubImagePlugin.py b/src/PIL/Hdf5StubImagePlugin.py index 76e640f15..1523e95d5 100644 --- a/src/PIL/Hdf5StubImagePlugin.py +++ b/src/PIL/Hdf5StubImagePlugin.py @@ -41,6 +41,7 @@ class HDF5StubImageFile(ImageFile.StubImageFile): format_description = "HDF5" def _open(self) -> None: + assert self.fp is not None if not _accept(self.fp.read(8)): msg = "Not an HDF file" raise SyntaxError(msg) diff --git a/src/PIL/IcnsImagePlugin.py b/src/PIL/IcnsImagePlugin.py index 197ea7a2b..058861d67 100644 --- a/src/PIL/IcnsImagePlugin.py +++ b/src/PIL/IcnsImagePlugin.py @@ -265,6 +265,7 @@ class IcnsImageFile(ImageFile.ImageFile): format_description = "Mac OS icns resource" def _open(self) -> None: + assert self.fp is not None self.icns = IcnsFile(self.fp) self._mode = "RGBA" self.info["sizes"] = self.icns.itersizes() diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index d5da07d47..8dd57ff85 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -340,6 +340,7 @@ class IcoImageFile(ImageFile.ImageFile): format_description = "Windows Icon" def _open(self) -> None: + assert self.fp is not None self.ico = IcoFile(self.fp) self.info["sizes"] = self.ico.sizes() self.size = self.ico.entry[0].dim diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index 71b999678..ef54f16e9 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -125,6 +125,7 @@ class ImImageFile(ImageFile.ImageFile): # Quick rejection: if there's not an LF among the first # 100 bytes, this is (probably) not a text header. + assert self.fp is not None if b"\n" not in self.fp.read(100): msg = "not an IM file" raise SyntaxError(msg) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index b71395c62..4e61900f6 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1524,6 +1524,8 @@ class Image: assert isinstance(self, TiffImagePlugin.TiffImageFile) self._exif.bigtiff = self.tag_v2._bigtiff self._exif.endian = self.tag_v2._endian + + assert self.fp is not None self._exif.load_from_fp(self.fp, self.tag_v2._offset) if exif_info is not None: self._exif.load(exif_info) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index ecb51b193..f609c7d13 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -286,6 +286,7 @@ class ImageFile(Image.Image): self.map: mmap.mmap | None = None use_mmap = self.filename and len(self.tile) == 1 + assert self.fp is not None readonly = 0 # look for read/seek overrides diff --git a/src/PIL/IptcImagePlugin.py b/src/PIL/IptcImagePlugin.py index cf7067daa..6fc824e4c 100644 --- a/src/PIL/IptcImagePlugin.py +++ b/src/PIL/IptcImagePlugin.py @@ -49,6 +49,7 @@ class IptcImageFile(ImageFile.ImageFile): def field(self) -> tuple[tuple[int, int] | None, int]: # # get a IPTC field header + assert self.fp is not None s = self.fp.read(5) if not s.strip(b"\x00"): return None, 0 @@ -76,6 +77,7 @@ class IptcImageFile(ImageFile.ImageFile): def _open(self) -> None: # load descriptive fields + assert self.fp is not None while True: offset = self.fp.tell() tag, size = self.field() @@ -131,6 +133,7 @@ class IptcImageFile(ImageFile.ImageFile): assert isinstance(args, tuple) compression, band = args + assert self.fp is not None self.fp.seek(self.tile[0].offset) # Copy image data to temporary file diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index 4c85dd4e2..d6ec38d43 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -252,6 +252,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile): format_description = "JPEG 2000 (ISO 15444)" def _open(self) -> None: + assert self.fp is not None sig = self.fp.read(4) if sig == b"\xff\x4f\xff\x51": self.codec = "j2k" @@ -304,6 +305,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile): ] def _parse_comment(self) -> None: + assert self.fp is not None while True: marker = self.fp.read(2) if not marker: diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 755ca648e..894c1547d 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -61,6 +61,7 @@ if TYPE_CHECKING: def Skip(self: JpegImageFile, marker: int) -> None: + assert self.fp is not None n = i16(self.fp.read(2)) - 2 ImageFile._safe_read(self.fp, n) @@ -70,6 +71,7 @@ def APP(self: JpegImageFile, marker: int) -> None: # Application marker. Store these in the APP dictionary. # Also look for well-known application markers. + assert self.fp is not None n = i16(self.fp.read(2)) - 2 s = ImageFile._safe_read(self.fp, n) @@ -174,6 +176,7 @@ def APP(self: JpegImageFile, marker: int) -> None: def COM(self: JpegImageFile, marker: int) -> None: # # Comment marker. Store these in the APP dictionary. + assert self.fp is not None n = i16(self.fp.read(2)) - 2 s = ImageFile._safe_read(self.fp, n) @@ -190,6 +193,7 @@ def SOF(self: JpegImageFile, marker: int) -> None: # mode. Note that this could be made a bit brighter, by # looking for JFIF and Adobe APP markers. + assert self.fp is not None n = i16(self.fp.read(2)) - 2 s = ImageFile._safe_read(self.fp, n) self._size = i16(s, 3), i16(s, 1) @@ -240,6 +244,7 @@ def DQT(self: JpegImageFile, marker: int) -> None: # FIXME: The quantization tables can be used to estimate the # compression quality. + assert self.fp is not None n = i16(self.fp.read(2)) - 2 s = ImageFile._safe_read(self.fp, n) while len(s): @@ -340,6 +345,7 @@ class JpegImageFile(ImageFile.ImageFile): format_description = "JPEG (ISO 10918)" def _open(self) -> None: + assert self.fp is not None s = self.fp.read(3) if not _accept(s): @@ -408,6 +414,7 @@ class JpegImageFile(ImageFile.ImageFile): For premature EOF and LOAD_TRUNCATED_IMAGES adds EOI marker so libjpeg can finish decoding """ + assert self.fp is not None s = self.fp.read(read_bytes) if not s and ImageFile.LOAD_TRUNCATED_IMAGES and not hasattr(self, "_ended"): diff --git a/src/PIL/MicImagePlugin.py b/src/PIL/MicImagePlugin.py index 9ce38c427..99a07bae0 100644 --- a/src/PIL/MicImagePlugin.py +++ b/src/PIL/MicImagePlugin.py @@ -67,6 +67,7 @@ class MicImageFile(TiffImagePlugin.TiffImageFile): self._n_frames = len(self.images) self.is_animated = self._n_frames > 1 + assert self.fp is not None self.__fp = self.fp self.seek(0) diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index b1ae07873..9360061ba 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -106,6 +106,7 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile): _close_exclusive_fp_after_loading = False def _open(self) -> None: + assert self.fp is not None self.fp.seek(0) # prep the fp in order to pass the JPEG test JpegImagePlugin.JpegImageFile._open(self) self._after_jpeg_open() @@ -125,6 +126,7 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile): assert self.n_frames == len(self.__mpoffsets) del self.info["mpoffset"] # no longer needed self.is_animated = self.n_frames > 1 + assert self.fp is not None self._fp = self.fp # FIXME: hack self._fp.seek(self.__mpoffsets[0]) # get ready to read first frame self.__frame = 0 diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index e3588735f..2508f8566 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -759,6 +759,7 @@ class PngImageFile(ImageFile.ImageFile): format_description = "Portable network graphics" def _open(self) -> None: + assert self.fp is not None if not _accept(self.fp.read(8)): msg = "not a PNG file" raise SyntaxError(msg) @@ -988,6 +989,7 @@ class PngImageFile(ImageFile.ImageFile): """internal: read more image data""" assert self.png is not None + assert self.fp is not None while self.__idat == 0: # end of chunk, skip forward to next one @@ -1021,6 +1023,7 @@ class PngImageFile(ImageFile.ImageFile): def load_end(self) -> None: """internal: finished reading image data""" assert self.png is not None + assert self.fp is not None if self.__idat != 0: self.fp.read(self.__idat) while True: diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py index f49aaeeb1..69a8703dd 100644 --- a/src/PIL/PsdImagePlugin.py +++ b/src/PIL/PsdImagePlugin.py @@ -61,6 +61,7 @@ class PsdImageFile(ImageFile.ImageFile): _close_exclusive_fp_after_loading = False def _open(self) -> None: + assert self.fp is not None read = self.fp.read # diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index dba5d809f..d0709b119 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -25,6 +25,7 @@ class QoiImageFile(ImageFile.ImageFile): format_description = "Quite OK Image" def _open(self) -> None: + assert self.fp is not None if not _accept(self.fp.read(4)): msg = "not a QOI file" raise SyntaxError(msg) diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index 7c3e84d74..866292243 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -104,6 +104,7 @@ class SpiderImageFile(ImageFile.ImageFile): def _open(self) -> None: # check header n = 27 * 4 # read 27 float values + assert self.fp is not None f = self.fp.read(n) try: diff --git a/src/PIL/WalImageFile.py b/src/PIL/WalImageFile.py index 5494f62e8..fb3e1c06a 100644 --- a/src/PIL/WalImageFile.py +++ b/src/PIL/WalImageFile.py @@ -39,6 +39,7 @@ class WalImageFile(ImageFile.ImageFile): self._mode = "P" # read header fields + assert self.fp is not None header = self.fp.read(32 + 24 + 32 + 12) self._size = i32(header, 32), i32(header, 36) Image._decompression_bomb_check(self.size) @@ -54,6 +55,7 @@ class WalImageFile(ImageFile.ImageFile): def load(self) -> Image.core.PixelAccess | None: if self._im is None: + assert self.fp is not None self.im = Image.core.new(self.mode, self.size) self.frombytes(self.fp.read(self.size[0] * self.size[1])) self.putpalette(quake2palette) diff --git a/src/PIL/WmfImagePlugin.py b/src/PIL/WmfImagePlugin.py index de714d337..3ae86242a 100644 --- a/src/PIL/WmfImagePlugin.py +++ b/src/PIL/WmfImagePlugin.py @@ -49,6 +49,7 @@ if hasattr(Image.core, "drawwmf"): self.bbox = im.info["wmf_bbox"] def load(self, im: ImageFile.StubImageFile) -> Image.Image: + assert im.fp is not None im.fp.seek(0) # rewind return Image.frombytes( "RGB", @@ -81,6 +82,7 @@ class WmfStubImageFile(ImageFile.StubImageFile): def _open(self) -> None: # check placeable header + assert self.fp is not None s = self.fp.read(44) if s.startswith(b"\xd7\xcd\xc6\x9a\x00\x00"): From 51b35d17e1429a7a1fda48e61832fb9dd9bc2adf Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 1 Jan 2026 20:15:05 +1100 Subject: [PATCH 68/74] Added fp type hint --- src/PIL/ImageFile.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index f609c7d13..1df1d33a1 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -131,6 +131,7 @@ class ImageFile(Image.Image): self.decoderconfig: tuple[Any, ...] = () self.decodermaxblock = MAXBLOCK + self.fp: IO[bytes] | None self._fp: IO[bytes] | DeferredError if is_path(fp): # filename @@ -268,7 +269,7 @@ class ImageFile(Image.Image): # raise exception if something's wrong. must be called # directly after open, and closes file when finished. - if self._exclusive_fp: + if self._exclusive_fp and self.fp: self.fp.close() self.fp = None From ce11a0c4993c284791b3100227cbd0338937074d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 1 Jan 2026 20:27:48 +1100 Subject: [PATCH 69/74] Added ImageFile context manager --- Tests/test_file_jpeg2k.py | 2 +- Tests/test_file_png.py | 2 +- Tests/test_file_ppm.py | 2 +- Tests/test_image_transform.py | 4 ++-- src/PIL/Image.py | 11 +++-------- src/PIL/ImageFile.py | 9 +++++++++ 6 files changed, 17 insertions(+), 13 deletions(-) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index a5365a90d..575d911de 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -164,7 +164,7 @@ def test_reduce() -> None: with Image.open("Tests/images/test-card-lossless.jp2") as im: assert callable(im.reduce) - im.reduce = 2 + im.reduce = 2 # type: ignore[assignment, method-assign] assert im.reduce == 2 im.load() diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index e9830cd3d..ed3a91285 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -823,7 +823,7 @@ class TestFilePng: monkeypatch.setattr(sys, "stdout", mystdout) with Image.open(TEST_PNG_FILE) as im: - im.save(sys.stdout, "PNG") + im.save(sys.stdout, "PNG") # type: ignore[arg-type] if isinstance(mystdout, MyStdOut): mystdout = mystdout.buffer diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index 598e9a445..fbca46be5 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -389,7 +389,7 @@ def test_save_stdout(buffer: bool, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(sys, "stdout", mystdout) with Image.open(TEST_FILE) as im: - im.save(sys.stdout, "PPM") + im.save(sys.stdout, "PPM") # type: ignore[arg-type] if isinstance(mystdout, MyStdOut): mystdout = mystdout.buffer diff --git a/Tests/test_image_transform.py b/Tests/test_image_transform.py index 7cf52ddba..3e2b9fee8 100644 --- a/Tests/test_image_transform.py +++ b/Tests/test_image_transform.py @@ -250,14 +250,14 @@ class TestImageTransform: def test_missing_method_data(self) -> None: with hopper() as im: with pytest.raises(ValueError): - im.transform((100, 100), None) + im.transform((100, 100), None) # type: ignore[arg-type] @pytest.mark.parametrize("resample", (Image.Resampling.BOX, "unknown")) def test_unknown_resampling_filter(self, resample: Image.Resampling | str) -> None: with hopper() as im: (w, h) = im.size with pytest.raises(ValueError): - im.transform((100, 100), Image.Transform.EXTENT, (0, 0, w, h), resample) + im.transform((100, 100), Image.Transform.EXTENT, (0, 0, w, h), resample) # type: ignore[arg-type] class TestImageTransformAffine: diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 4e61900f6..b4de099be 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -590,16 +590,11 @@ class Image: return new # Context manager support - def __enter__(self): + def __enter__(self) -> Image: return self - def __exit__(self, *args): - from . import ImageFile - - if isinstance(self, ImageFile.ImageFile): - if getattr(self, "_exclusive_fp", False): - self._close_fp() - self.fp = None + def __exit__(self, *args: object) -> None: + pass def close(self) -> None: """ diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 1df1d33a1..3390dfa97 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -169,6 +169,10 @@ class ImageFile(Image.Image): def _open(self) -> None: pass + # Context manager support + def __enter__(self) -> ImageFile: + return self + def _close_fp(self) -> None: if getattr(self, "_fp", False) and not isinstance(self._fp, DeferredError): if self._fp != self.fp: @@ -177,6 +181,11 @@ class ImageFile(Image.Image): if self.fp: self.fp.close() + def __exit__(self, *args: object) -> None: + if getattr(self, "_exclusive_fp", False): + self._close_fp() + self.fp = None + def close(self) -> None: """ Closes the file pointer, if possible. From 2d589107fb3a4aba8389932a65ff771bf9b4deb1 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 2 Jan 2026 03:49:56 +1100 Subject: [PATCH 70/74] Specify APNG duration type when opening --- docs/handbook/image-file-formats.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index d26c0fbae..35ec99ece 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -999,7 +999,7 @@ where applicable: The number of times to loop this APNG, 0 indicates infinite looping. **duration** - The time to display this APNG frame (in milliseconds). + The time to display this APNG frame (in milliseconds), given as a float. .. note:: From 432707ea810ae619e2a9e4a9737c169cacaa8eda Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 2 Jan 2026 04:18:15 +1100 Subject: [PATCH 71/74] Added release notes for #9348 --- docs/releasenotes/12.1.0.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/releasenotes/12.1.0.rst b/docs/releasenotes/12.1.0.rst index 0541f882f..5aaa52c90 100644 --- a/docs/releasenotes/12.1.0.rst +++ b/docs/releasenotes/12.1.0.rst @@ -21,3 +21,11 @@ macOS in addition to Windows. On macOS, this is a CGWindowID:: from PIL import ImageGrab ImageGrab.grab(window=cgwindowid) + +Other changes +============= + +Added MorphOp support for 1 mode images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:class:`~PIL.ImageMorph.MorphOp` now supports both 1 mode and L mode images. From 3baedf264804d199bc19458d11bcff02ce7598eb Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Fri, 2 Jan 2026 10:59:56 +1100 Subject: [PATCH 72/74] Deprecate getdata(), in favour of new get_flattened_data() (#9292) --- Tests/helper.py | 4 ++-- Tests/test_box_blur.py | 8 +++++-- Tests/test_file_avif.py | 2 -- Tests/test_file_libtiff.py | 1 - Tests/test_file_webp.py | 2 -- Tests/test_file_webp_alpha.py | 4 ---- Tests/test_file_webp_lossless.py | 1 - Tests/test_format_lab.py | 12 +++++----- Tests/test_image.py | 4 ++-- Tests/test_image_array.py | 2 +- Tests/test_image_crop.py | 4 ++-- Tests/test_image_getdata.py | 20 ++++++++++++---- Tests/test_image_putdata.py | 39 ++++++++++++++++---------------- Tests/test_image_resize.py | 2 +- Tests/test_imagecms.py | 12 +++++----- Tests/test_numpy.py | 14 +++++------- docs/deprecations.rst | 10 ++++++++ docs/reference/Image.rst | 1 + docs/releasenotes/12.1.0.rst | 18 +++++++++++++++ src/PIL/GifImagePlugin.py | 2 +- src/PIL/Image.py | 19 ++++++++++++++++ src/PIL/_deprecate.py | 2 ++ 22 files changed, 117 insertions(+), 66 deletions(-) diff --git a/Tests/helper.py b/Tests/helper.py index b93828f58..d77b4b807 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -55,8 +55,8 @@ def convert_to_comparable( if a.mode == "P": new_a = Image.new("L", a.size) new_b = Image.new("L", b.size) - new_a.putdata(a.getdata()) - new_b.putdata(b.getdata()) + new_a.putdata(a.get_flattened_data()) + new_b.putdata(b.get_flattened_data()) elif a.mode == "I;16": new_a = a.convert("I") new_b = b.convert("I") diff --git a/Tests/test_box_blur.py b/Tests/test_box_blur.py index cb267b204..07e62db8c 100644 --- a/Tests/test_box_blur.py +++ b/Tests/test_box_blur.py @@ -28,9 +28,13 @@ def box_blur(image: Image.Image, radius: float = 1, n: int = 1) -> Image.Image: def assert_image(im: Image.Image, data: list[list[int]], delta: int = 0) -> None: - it = iter(im.getdata()) + it = iter(im.get_flattened_data()) for data_row in data: - im_row = [next(it) for _ in range(im.size[0])] + im_row = [] + for _ in range(im.width): + im_v = next(it) + assert isinstance(im_v, (int, float)) + im_row.append(im_v) if any(abs(data_v - im_v) > delta for data_v, im_v in zip(data_row, im_row)): assert im_row == data_row with pytest.raises(StopIteration): diff --git a/Tests/test_file_avif.py b/Tests/test_file_avif.py index 727191153..ffc4ce021 100644 --- a/Tests/test_file_avif.py +++ b/Tests/test_file_avif.py @@ -121,7 +121,6 @@ class TestFileAvif: assert image.size == (128, 128) assert image.format == "AVIF" assert image.get_format_mimetype() == "image/avif" - image.getdata() # generated with: # avifdec hopper.avif hopper_avif_write.png @@ -143,7 +142,6 @@ class TestFileAvif: assert reloaded.mode == "RGB" assert reloaded.size == (128, 128) assert reloaded.format == "AVIF" - reloaded.getdata() # avifdec hopper.avif avif/hopper_avif_write.png assert_image_similar_tofile( diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 90ca2cf12..c2336c058 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -42,7 +42,6 @@ class LibTiffTestCase: # Does the data actually load im.load() - im.getdata() assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im._compression == "group4" diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index 5456adf59..f996cce67 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -60,7 +60,6 @@ class TestFileWebp: assert image.size == (128, 128) assert image.format == "WEBP" image.load() - image.getdata() # generated with: # dwebp -ppm ../../Tests/images/hopper.webp -o hopper_webp_bits.ppm @@ -77,7 +76,6 @@ class TestFileWebp: assert image.size == (128, 128) assert image.format == "WEBP" image.load() - image.getdata() if mode == self.rgb_mode: # generated with: dwebp -ppm temp.webp -o hopper_webp_write.ppm diff --git a/Tests/test_file_webp_alpha.py b/Tests/test_file_webp_alpha.py index c573390c4..b1aa45f6b 100644 --- a/Tests/test_file_webp_alpha.py +++ b/Tests/test_file_webp_alpha.py @@ -29,7 +29,6 @@ def test_read_rgba() -> None: assert image.size == (200, 150) assert image.format == "WEBP" image.load() - image.getdata() image.tobytes() @@ -60,7 +59,6 @@ def test_write_lossless_rgb(tmp_path: Path) -> None: assert image.size == pil_image.size assert image.format == "WEBP" image.load() - image.getdata() assert_image_equal(image, pil_image) @@ -83,7 +81,6 @@ def test_write_rgba(tmp_path: Path) -> None: assert image.size == (10, 10) assert image.format == "WEBP" image.load() - image.getdata() assert_image_similar(image, pil_image, 1.0) @@ -133,7 +130,6 @@ def test_write_unsupported_mode_PA(tmp_path: Path) -> None: assert image.format == "WEBP" image.load() - image.getdata() with Image.open(file_path) as im: target = im.convert("RGBA") diff --git a/Tests/test_file_webp_lossless.py b/Tests/test_file_webp_lossless.py index 5eaa4f599..b4c0448ac 100644 --- a/Tests/test_file_webp_lossless.py +++ b/Tests/test_file_webp_lossless.py @@ -24,6 +24,5 @@ def test_write_lossless_rgb(tmp_path: Path) -> None: assert image.size == (128, 128) assert image.format == "WEBP" image.load() - image.getdata() assert_image_equal(image, hopper(RGB_MODE)) diff --git a/Tests/test_format_lab.py b/Tests/test_format_lab.py index 4fcc37e88..5f4a704f2 100644 --- a/Tests/test_format_lab.py +++ b/Tests/test_format_lab.py @@ -13,15 +13,15 @@ def test_white() -> None: k = i.getpixel((0, 0)) - L = i.getdata(0) - a = i.getdata(1) - b = i.getdata(2) + L = i.get_flattened_data(0) + a = i.get_flattened_data(1) + b = i.get_flattened_data(2) assert k == (255, 128, 128) - assert list(L) == [255] * 100 - assert list(a) == [128] * 100 - assert list(b) == [128] * 100 + assert L == (255,) * 100 + assert a == (128,) * 100 + assert b == (128,) * 100 def test_green() -> None: diff --git a/Tests/test_image.py b/Tests/test_image.py index 88f55638e..afc6e8e16 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -1181,10 +1181,10 @@ class TestImageBytes: assert reloaded.tobytes() == source_bytes @pytest.mark.parametrize("mode", Image.MODES) - def test_getdata_putdata(self, mode: str) -> None: + def test_get_flattened_data_putdata(self, mode: str) -> None: im = hopper(mode) reloaded = Image.new(mode, im.size) - reloaded.putdata(im.getdata()) + reloaded.putdata(im.get_flattened_data()) assert_image_equal(im, reloaded) diff --git a/Tests/test_image_array.py b/Tests/test_image_array.py index abb22f949..220e128d1 100644 --- a/Tests/test_image_array.py +++ b/Tests/test_image_array.py @@ -78,7 +78,7 @@ def test_fromarray() -> None: }, ) out = Image.fromarray(wrapped) - return out.mode, out.size, list(i.getdata()) == list(out.getdata()) + return out.mode, out.size, i.get_flattened_data() == out.get_flattened_data() # assert test("1") == ("1", (128, 100), True) assert test("L") == ("L", (128, 100), True) diff --git a/Tests/test_image_crop.py b/Tests/test_image_crop.py index b90ce84bc..9df8883a4 100644 --- a/Tests/test_image_crop.py +++ b/Tests/test_image_crop.py @@ -95,10 +95,10 @@ def test_crop_zero() -> None: cropped = im.crop((10, 10, 20, 20)) assert cropped.size == (10, 10) - assert cropped.getdata()[0] == (0, 0, 0) + assert cropped.getpixel((0, 0)) == (0, 0, 0) im = Image.new("RGB", (0, 0)) cropped = im.crop((10, 10, 20, 20)) assert cropped.size == (10, 10) - assert cropped.getdata()[2] == (0, 0, 0) + assert cropped.getpixel((2, 0)) == (0, 0, 0) diff --git a/Tests/test_image_getdata.py b/Tests/test_image_getdata.py index c8b213d84..94d6cbaa2 100644 --- a/Tests/test_image_getdata.py +++ b/Tests/test_image_getdata.py @@ -1,23 +1,23 @@ from __future__ import annotations +import pytest + from PIL import Image from .helper import hopper def test_sanity() -> None: - data = hopper().getdata() - - len(data) - list(data) + data = hopper().get_flattened_data() + assert len(data) == 128 * 128 assert data[0] == (20, 20, 70) def test_mode() -> None: def getdata(mode: str) -> tuple[float | tuple[int, ...] | None, int, int]: im = hopper(mode).resize((32, 30), Image.Resampling.NEAREST) - data = im.getdata() + data = im.get_flattened_data() return data[0], len(data), len(list(data)) assert getdata("1") == (0, 960, 960) @@ -28,3 +28,13 @@ def test_mode() -> None: assert getdata("RGBA") == ((11, 13, 52, 255), 960, 960) assert getdata("CMYK") == ((244, 242, 203, 0), 960, 960) assert getdata("YCbCr") == ((16, 147, 123), 960, 960) + + +def test_deprecation() -> None: + im = hopper() + with pytest.warns(DeprecationWarning, match="getdata"): + data = im.getdata() + + assert len(data) == 128 * 128 + assert data[0] == (20, 20, 70) + assert list(data)[0] == (20, 20, 70) diff --git a/Tests/test_image_putdata.py b/Tests/test_image_putdata.py index bf8e89b53..226cb4c14 100644 --- a/Tests/test_image_putdata.py +++ b/Tests/test_image_putdata.py @@ -2,6 +2,7 @@ from __future__ import annotations import sys from array import array +from typing import cast import pytest @@ -12,21 +13,19 @@ from .helper import assert_image_equal, hopper def test_sanity() -> None: im1 = hopper() + for data in (im1.get_flattened_data(), im1.im): + im2 = Image.new(im1.mode, im1.size, 0) + im2.putdata(data) - data = list(im1.getdata()) + assert_image_equal(im1, im2) - im2 = Image.new(im1.mode, im1.size, 0) - im2.putdata(data) + # readonly + im2 = Image.new(im1.mode, im2.size, 0) + im2.readonly = 1 + im2.putdata(data) - assert_image_equal(im1, im2) - - # readonly - im2 = Image.new(im1.mode, im2.size, 0) - im2.readonly = 1 - im2.putdata(data) - - assert not im2.readonly - assert_image_equal(im1, im2) + assert not im2.readonly + assert_image_equal(im1, im2) def test_long_integers() -> None: @@ -60,22 +59,22 @@ def test_mode_with_L_with_float() -> None: @pytest.mark.parametrize("mode", ("I", "I;16", "I;16L", "I;16B")) def test_mode_i(mode: str) -> None: src = hopper("L") - data = list(src.getdata()) + data = src.get_flattened_data() im = Image.new(mode, src.size, 0) im.putdata(data, 2, 256) - target = [2 * elt + 256 for elt in data] - assert list(im.getdata()) == target + target = tuple(2 * elt + 256 for elt in cast(tuple[int, ...], data)) + assert im.get_flattened_data() == target def test_mode_F() -> None: src = hopper("L") - data = list(src.getdata()) + data = src.get_flattened_data() im = Image.new("F", src.size, 0) im.putdata(data, 2.0, 256.0) - target = [2.0 * float(elt) + 256.0 for elt in data] - assert list(im.getdata()) == target + target = tuple(2.0 * float(elt) + 256.0 for elt in cast(tuple[int, ...], data)) + assert im.get_flattened_data() == target def test_array_B() -> None: @@ -86,7 +85,7 @@ def test_array_B() -> None: im = Image.new("L", (150, 100)) im.putdata(arr) - assert len(im.getdata()) == len(arr) + assert len(im.get_flattened_data()) == len(arr) def test_array_F() -> None: @@ -97,7 +96,7 @@ def test_array_F() -> None: arr = array("f", [0.0]) * 15000 im.putdata(arr) - assert len(im.getdata()) == len(arr) + assert len(im.get_flattened_data()) == len(arr) def test_not_flattened() -> None: diff --git a/Tests/test_image_resize.py b/Tests/test_image_resize.py index 323d31f51..3e8979a5b 100644 --- a/Tests/test_image_resize.py +++ b/Tests/test_image_resize.py @@ -160,7 +160,7 @@ class TestImagingCoreResize: r = self.resize(Image.new("RGB", (0, 0), "white"), (212, 195), resample) assert r.mode == "RGB" assert r.size == (212, 195) - assert r.getdata()[0] == (0, 0, 0) + assert r.getpixel((0, 0)) == (0, 0, 0) def test_unknown_filter(self) -> None: with pytest.raises(ValueError): diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index 5fd7caa7c..a30fb18b8 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -274,13 +274,13 @@ def test_simple_lab() -> None: # not a linear luminance map. so L != 128: assert k == (137, 128, 128) - l_data = i_lab.getdata(0) - a_data = i_lab.getdata(1) - b_data = i_lab.getdata(2) + l_data = i_lab.get_flattened_data(0) + a_data = i_lab.get_flattened_data(1) + b_data = i_lab.get_flattened_data(2) - assert list(l_data) == [137] * 100 - assert list(a_data) == [128] * 100 - assert list(b_data) == [128] * 100 + assert l_data == (137,) * 100 + assert a_data == (128,) * 100 + assert b_data == (128,) * 100 def test_lab_color() -> None: diff --git a/Tests/test_numpy.py b/Tests/test_numpy.py index f6acb3aff..113d30755 100644 --- a/Tests/test_numpy.py +++ b/Tests/test_numpy.py @@ -20,21 +20,19 @@ TEST_IMAGE_SIZE = (10, 10) def test_numpy_to_image() -> None: def to_image(dtype: npt.DTypeLike, bands: int = 1, boolean: int = 0) -> Image.Image: + data = tuple(range(100)) if bands == 1: if boolean: - data = [0, 255] * 50 - else: - data = list(range(100)) + data = (0, 255) * 50 a = numpy.array(data, dtype=dtype) a.shape = TEST_IMAGE_SIZE i = Image.fromarray(a) - assert list(i.getdata()) == data + assert i.get_flattened_data() == data else: - data = list(range(100)) a = numpy.array([[x] * bands for x in data], dtype=dtype) a.shape = TEST_IMAGE_SIZE[0], TEST_IMAGE_SIZE[1], bands i = Image.fromarray(a) - assert list(i.getchannel(0).getdata()) == list(range(100)) + assert i.get_flattened_data(0) == tuple(range(100)) return i # Check supported 1-bit integer formats @@ -191,7 +189,7 @@ def test_putdata() -> None: arr = numpy.zeros((15000,), numpy.float32) im.putdata(arr) - assert len(im.getdata()) == len(arr) + assert len(im.get_flattened_data()) == len(arr) def test_resize() -> None: @@ -248,7 +246,7 @@ def test_bool() -> None: a[0][0] = True im2 = Image.fromarray(a) - assert im2.getdata()[0] == 255 + assert im2.getpixel((0, 0)) == 255 def test_no_resource_warning_for_numpy_array() -> None: diff --git a/docs/deprecations.rst b/docs/deprecations.rst index cc5ac283f..b6a7af0a8 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -73,6 +73,16 @@ Image._show ``Image._show`` has been deprecated, and will be removed in Pillow 13 (2026-10-15). Use :py:meth:`~PIL.ImageShow.show` instead. +Image getdata() +~~~~~~~~~~~~~~~ + +.. deprecated:: 12.1.0 + +:py:meth:`~PIL.Image.Image.getdata` has been deprecated. +:py:meth:`~PIL.Image.Image.get_flattened_data` can be used instead. This new method is +identical, except that it returns a tuple of pixel values, instead of an internal +Pillow data type. + Removed features ---------------- diff --git a/docs/reference/Image.rst b/docs/reference/Image.rst index e68722900..adee49228 100644 --- a/docs/reference/Image.rst +++ b/docs/reference/Image.rst @@ -191,6 +191,7 @@ This helps to get the bounding box coordinates of the input image:: .. automethod:: PIL.Image.Image.getchannel .. automethod:: PIL.Image.Image.getcolors .. automethod:: PIL.Image.Image.getdata +.. automethod:: PIL.Image.Image.get_flattened_data .. automethod:: PIL.Image.Image.getexif .. automethod:: PIL.Image.Image.getextrema .. automethod:: PIL.Image.Image.getpalette diff --git a/docs/releasenotes/12.1.0.rst b/docs/releasenotes/12.1.0.rst index 5aaa52c90..9740b7008 100644 --- a/docs/releasenotes/12.1.0.rst +++ b/docs/releasenotes/12.1.0.rst @@ -1,6 +1,17 @@ 12.1.0 ------ +Deprecations +============ + +Image getdata() +^^^^^^^^^^^^^^^ + +:py:meth:`~PIL.Image.Image.getdata` has been deprecated. +:py:meth:`~PIL.Image.Image.get_flattened_data` can be used instead. This new method is +identical, except that it returns a tuple of pixel values, instead of an internal +Pillow data type. + API changes =========== @@ -13,6 +24,13 @@ To match the behaviour of :py:meth:`~PIL.ImageMorph.LutBuilder.build_lut`, API additions ============= +Image get_flattened_data() +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:meth:`~PIL.Image.Image.get_flattened_data` is identical to the deprecated +:py:meth:`~PIL.Image.Image.getdata`, except that the new method returns a tuple of +pixel values, instead of an internal Pillow data type. + Specify window in ImageGrab on macOS ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index b8755422d..76a0d4ab9 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -753,7 +753,7 @@ def _write_multiple_frames( if delta.mode == "P": # Convert to L without considering palette delta_l = Image.new("L", delta.size) - delta_l.putdata(delta.getdata()) + delta_l.putdata(delta.get_flattened_data()) delta = delta_l mask = ImageMath.lambda_eval( lambda args: args["convert"](args["im"] * 255, "1"), diff --git a/src/PIL/Image.py b/src/PIL/Image.py index b4de099be..57ebea689 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1435,12 +1435,31 @@ class Image: value (e.g. 0 to get the "R" band from an "RGB" image). :returns: A sequence-like object. """ + deprecate("Image.Image.getdata", 14, "get_flattened_data") self.load() if band is not None: return self.im.getband(band) return self.im # could be abused + def get_flattened_data( + self, band: int | None = None + ) -> tuple[tuple[int, ...], ...] | tuple[float, ...]: + """ + Returns the contents of this image as a tuple containing pixel values. + The sequence object is flattened, so that values for line one follow + directly after the values of line zero, and so on. + + :param band: What band to return. The default is to return + all bands. To return a single band, pass in the index + value (e.g. 0 to get the "R" band from an "RGB" image). + :returns: A tuple containing pixel values. + """ + self.load() + if band is not None: + return tuple(self.im.getband(band)) + return tuple(self.im) + def getextrema(self) -> tuple[float, float] | tuple[tuple[int, int], ...]: """ Gets the minimum and maximum pixel values for each band in diff --git a/src/PIL/_deprecate.py b/src/PIL/_deprecate.py index 616a9aace..711c62ab2 100644 --- a/src/PIL/_deprecate.py +++ b/src/PIL/_deprecate.py @@ -48,6 +48,8 @@ def deprecate( raise RuntimeError(msg) elif when == 13: removed = "Pillow 13 (2026-10-15)" + elif when == 14: + removed = "Pillow 14 (2027-10-15)" else: msg = f"Unknown removal version: {when}. Update {__name__}?" raise ValueError(msg) From 46f45f674d47b5d8bc54230dda8fe9e214598b87 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 2 Jan 2026 17:03:05 +1100 Subject: [PATCH 73/74] 12.1.0 version bump --- src/PIL/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/_version.py b/src/PIL/_version.py index 41cb17a36..b32ff446a 100644 --- a/src/PIL/_version.py +++ b/src/PIL/_version.py @@ -1,4 +1,4 @@ # Master version for Pillow from __future__ import annotations -__version__ = "12.1.0.dev0" +__version__ = "12.1.0" From 4337139f0caa7d858131ce8f36e2ded54c60fd60 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 2 Jan 2026 20:16:49 +1100 Subject: [PATCH 74/74] 12.2.0.dev0 version bump --- src/PIL/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/_version.py b/src/PIL/_version.py index b32ff446a..96363e9f1 100644 --- a/src/PIL/_version.py +++ b/src/PIL/_version.py @@ -1,4 +1,4 @@ # Master version for Pillow from __future__ import annotations -__version__ = "12.1.0" +__version__ = "12.2.0.dev0"