From 7f68decf2c34ad4bc2a700be8410df679180b8d9 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 3 Apr 2026 22:16:51 +1100 Subject: [PATCH 01/13] Clarified condition --- src/PIL/PngImagePlugin.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 76a15bd0d..9bfeb1104 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1443,9 +1443,9 @@ def _save( palette_bytes += b"\0" chunk(fp, b"PLTE", palette_bytes) - transparency = im.encoderinfo.get("transparency", im.info.get("transparency", None)) + transparency = im.encoderinfo.get("transparency", im.info.get("transparency")) - if transparency or transparency == 0: + if transparency is not None: if im.mode == "P": # limit to actual palette size alpha_bytes = colors @@ -1461,17 +1461,15 @@ def _save( elif im.mode == "RGB": red, green, blue = transparency chunk(fp, b"tRNS", o16(red) + o16(green) + o16(blue)) - else: - if "transparency" in im.encoderinfo: - # don't bother with transparency if it's an RGBA - # and it's in the info dict. It's probably just stale. - msg = "cannot use transparency for this mode" - raise OSError(msg) - else: - if im.mode == "P" and im.im.getpalettemode() == "RGBA": - alpha = im.im.getpalette("RGBA", "A") - alpha_bytes = colors - chunk(fp, b"tRNS", alpha[:alpha_bytes]) + elif im.encoderinfo.get("transparency") is not None: + # don't bother with transparency if it's an RGBA + # and it's in the info dict. It's probably just stale. + msg = "cannot use transparency for this mode" + raise OSError(msg) + elif im.mode == "P" and im.im.getpalettemode() == "RGBA": + alpha = im.im.getpalette("RGBA", "A") + alpha_bytes = colors + chunk(fp, b"tRNS", alpha[:alpha_bytes]) if dpi := im.encoderinfo.get("dpi"): chunk( From e58c67347a2f4eae454fa727008fa1ba4a71c923 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 3 Apr 2026 22:19:52 +1100 Subject: [PATCH 02/13] Raise error if transparency is incorrect type or length when saving --- Tests/test_file_png.py | 35 +++++++++++++++++++++++++++++++++-- src/PIL/PngImagePlugin.py | 24 +++++++++++++++++++----- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 3f08d1ad3..0fad0b391 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -502,8 +502,9 @@ class TestFilePng: im = roundtrip(im) assert im.info["transparency"] == (248, 248, 248) - im = roundtrip(im, transparency=(0, 1, 2)) - assert im.info["transparency"] == (0, 1, 2) + for transparency in ((0, 1, 2), [0, 1, 2]): + im = roundtrip(im, transparency=transparency) + assert im.info["transparency"] == (0, 1, 2) def test_trns_p(self, tmp_path: Path) -> None: # Check writing a transparency of 0, issue #528 @@ -518,6 +519,36 @@ class TestFilePng: assert_image_equal(im2.convert("RGBA"), im.convert("RGBA")) + def test_trns_invalid(self, tmp_path: Path) -> None: + out = tmp_path / "temp.png" + + for mode in ("1", "L", "I;16"): + im = Image.new(mode, (1, 1)) + with pytest.raises( + ValueError, match=f"transparency for {mode} must be an integer" + ): + im.save(out, transparency="invalid") + + im = Image.new("I", (1, 1)) + with pytest.warns(DeprecationWarning, match="Saving I mode images as PNG"): + with pytest.raises(ValueError): + im.save(out, transparency="invalid") + + im = Image.new("P", (1, 1)) + with pytest.raises( + ValueError, match="transparency for P must be an integer or bytes" + ): + im.save(out, transparency="invalid") + + im = Image.new("RGB", (1, 1)) + with pytest.raises( + ValueError, match="transparency for RGB must be list or tuple" + ): + im.save(out, transparency="invalid") + + with pytest.raises(ValueError, match="transparency for RGB must have length 3"): + im.save(out, transparency=(1, 2)) + def test_trns_null(self) -> None: # Check reading images with null tRNS value, issue #1239 test_file = "Tests/images/tRNS_null_1x1.png" diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 9bfeb1104..3f21fa48e 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1451,16 +1451,30 @@ def _save( alpha_bytes = colors if isinstance(transparency, bytes): chunk(fp, b"tRNS", transparency[:alpha_bytes]) - else: + elif isinstance(transparency, int): transparency = max(0, min(255, transparency)) alpha = b"\xff" * transparency + b"\0" chunk(fp, b"tRNS", alpha[:alpha_bytes]) + else: + msg = "transparency for P must be an integer or bytes" + raise ValueError(msg) elif im.mode in ("1", "L", "I", "I;16"): - transparency = max(0, min(65535, transparency)) - chunk(fp, b"tRNS", o16(transparency)) + if isinstance(transparency, int): + transparency = max(0, min(65535, transparency)) + chunk(fp, b"tRNS", o16(transparency)) + else: + msg = f"transparency for {im.mode} must be an integer" + raise ValueError(msg) elif im.mode == "RGB": - red, green, blue = transparency - chunk(fp, b"tRNS", o16(red) + o16(green) + o16(blue)) + if not isinstance(transparency, (list, tuple)): + msg = "transparency for RGB must be list or tuple" + raise ValueError(msg) + elif len(transparency) != 3: + msg = "transparency for RGB must have length 3" + raise ValueError(msg) + else: + red, green, blue = transparency + chunk(fp, b"tRNS", o16(red) + o16(green) + o16(blue)) elif im.encoderinfo.get("transparency") is not None: # don't bother with transparency if it's an RGBA # and it's in the info dict. It's probably just stale. From 1f3b8a831d9cd7e140081289259fd0cb5d90934f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 30 Apr 2026 00:13:37 +1000 Subject: [PATCH 03/13] If PdfParser buffer is memoryview, release it when closing --- src/PIL/PdfParser.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 2a5ade773..b0b32b1c9 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -383,7 +383,7 @@ class PdfParser: msg = "specify buf or f or filename, but not both buf and f" raise RuntimeError(msg) self.filename = filename - self.buf: bytes | bytearray | mmap.mmap | None = buf + self.buf: bytes | bytearray | memoryview | mmap.mmap | None = buf self.f = f self.start_offset = start_offset self.should_close_buf = False @@ -435,7 +435,9 @@ class PdfParser: self.seek_end() def close_buf(self) -> None: - if isinstance(self.buf, mmap.mmap): + if isinstance(self.buf, memoryview): + self.buf.release() + elif isinstance(self.buf, mmap.mmap): self.buf.close() self.buf = None From 7e4ca8b3abf1476b0c956bbc3642d0b2d5717e7c Mon Sep 17 00:00:00 2001 From: Hayato Ikoma Date: Fri, 1 May 2026 21:36:20 -0700 Subject: [PATCH 04/13] Correct integer overflow in 16-bit resampling (#9480) Co-authored-by: Andrew Murray --- Tests/test_image_resample.py | 34 ++++++++++++++++++++++++++++++++++ src/libImaging/Convert.c | 2 -- src/libImaging/ImagingUtils.h | 2 ++ src/libImaging/Resample.c | 12 ++++++------ 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index 73b25ed51..f188be81e 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -627,3 +627,37 @@ class TestCoreResampleBox: 0.4, f">>> {size} {box} {flt}", ) + + +class TestCoreResample16bpc: + # Lanczos weighting during downsampling can push accumulated float sums + @pytest.mark.parametrize( + "offset", + ( + # below 0. These must be clamped to 0, not corrupted byte-by-byte. + 0, # Left half = 65535, right half = 0 + # above 65535. These must be clamped to 65535, not corrupted byte-by-byte. + 50, # # Left half = 0, right half = 65535 + ), + ) + def test_resampling_clamp_overflow(self, offset: int) -> None: + ims = {} + width, height = 100, 10 + for mode in ("I;16", "F"): + im = Image.new(mode, (width, height)) + im.paste(65535, (offset, 0, offset + width // 2, height)) + + # 5x downsampling with Lanczos + # creates ~8.7% overshoot or undershoot at the step edge + ims[mode] = im.resize((20, height), Image.Resampling.LANCZOS) + + for y in range(height): + for x in range(20): + v = ims["F"].getpixel((x, y)) + assert isinstance(v, float) + expected = max(0, min(65535, round(v))) + + value = ims["I;16"].getpixel((x, y)) + assert ( + value == expected + ), f"Pixel ({x}, {y}): expected {expected}, got {value}" diff --git a/src/libImaging/Convert.c b/src/libImaging/Convert.c index 002497c32..f156810ff 100644 --- a/src/libImaging/Convert.c +++ b/src/libImaging/Convert.c @@ -37,8 +37,6 @@ #define MAX(a, b) (a) > (b) ? (a) : (b) #define MIN(a, b) (a) < (b) ? (a) : (b) -#define CLIP16(v) ((v) <= 0 ? 0 : (v) >= 65535 ? 65535 : (v)) - /* ITU-R Recommendation 601-2 (assuming nonlinear RGB) */ #define L(rgb) ((INT32)(rgb)[0] * 299 + (INT32)(rgb)[1] * 587 + (INT32)(rgb)[2] * 114) #define L24(rgb) ((rgb)[0] * 19595 + (rgb)[1] * 38470 + (rgb)[2] * 7471 + 0x8000) diff --git a/src/libImaging/ImagingUtils.h b/src/libImaging/ImagingUtils.h index 714458ad0..a362780d0 100644 --- a/src/libImaging/ImagingUtils.h +++ b/src/libImaging/ImagingUtils.h @@ -27,6 +27,8 @@ #define CLIP8(v) ((v) <= 0 ? 0 : (v) < 256 ? (v) : 255) +#define CLIP16(v) ((v) <= 0 ? 0 : (v) < 65536 ? (v) : 65535) + /* This is to work around a bug in GCC prior 4.9 in 64 bit mode. GCC generates code with partial dependency which is 3 times slower. See: https://stackoverflow.com/a/26588074/253146 */ diff --git a/src/libImaging/Resample.c b/src/libImaging/Resample.c index fea00eea0..1647dca14 100644 --- a/src/libImaging/Resample.c +++ b/src/libImaging/Resample.c @@ -492,9 +492,9 @@ ImagingResampleHorizontal_16bpc( << 8)) * k[x]; } - ss_int = ROUND_UP(ss); - imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = CLIP8(ss_int % 256); - imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = CLIP8(ss_int >> 8); + ss_int = CLIP16(ROUND_UP(ss)); + imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = ss_int & 0xFF; + imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = ss_int >> 8; } } ImagingSectionLeave(&cookie); @@ -531,9 +531,9 @@ ImagingResampleVertical_16bpc( (imIn->image8[y + ymin][xx * 2 + (bigendian ? 0 : 1)] << 8)) * k[y]; } - ss_int = ROUND_UP(ss); - imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = CLIP8(ss_int % 256); - imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = CLIP8(ss_int >> 8); + ss_int = CLIP16(ROUND_UP(ss)); + imOut->image8[yy][xx * 2 + (bigendian ? 1 : 0)] = ss_int & 0xFF; + imOut->image8[yy][xx * 2 + (bigendian ? 0 : 1)] = ss_int >> 8; } } ImagingSectionLeave(&cookie); From 2d02654c54c1584980fd239b58fefa7f1f8f4626 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 14:11:33 +1000 Subject: [PATCH 05/13] Update dependency cibuildwheel to v3.4.1 (#9607) --- .ci/requirements-cibw.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index fd4183aff..c824c10bc 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==3.4.0 +cibuildwheel==3.4.1 From d92b826c4a4fff179b1f8c1ec421fefd78cdb26f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 06:03:07 +0000 Subject: [PATCH 06/13] Update github-actions --- .github/workflows/cifuzz.yml | 4 ++-- .github/workflows/lint.yml | 2 +- .github/workflows/wheels.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml index 27b55cffc..a2e1112dc 100644 --- a/.github/workflows/cifuzz.yml +++ b/.github/workflows/cifuzz.yml @@ -30,14 +30,14 @@ jobs: steps: - name: Build Fuzzers id: build - uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@e41e2f295eb18d630932fdd33d072527ba74c87b # master + uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@c11174f47deee98f260dede5d661614bda78ae39 # master with: oss-fuzz-project-name: 'pillow' language: python dry-run: false - name: Run Fuzzers id: run - uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@e41e2f295eb18d630932fdd33d072527ba74c87b # master + uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@c11174f47deee98f260dede5d661614bda78ae39 # master with: oss-fuzz-project-name: 'pillow' fuzz-seconds: 600 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1aff5a0dd..dacf40cc1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,7 +25,7 @@ jobs: with: python-version: "3.x" - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Lint run: uvx --with tox-uv tox -e lint - name: Mypy diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index d5af65c98..e0edb3ac0 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -270,7 +270,7 @@ jobs: path: dist merge-multiple: true - name: Upload wheels to scientific-python-nightly-wheels - uses: scientific-python/upload-nightly-action@5748273c71e2d8d3a61f3a11a16421c8954f9ecf # 0.6.3 + uses: scientific-python/upload-nightly-action@e76cfec8a4611fd02808a801b0ff5a7d7c1b2d99 # 0.6.4 with: artifacts_path: dist anaconda_nightly_upload_token: ${{ secrets.ANACONDA_ORG_UPLOAD_TOKEN }} From 3bbb7a2a04c748b70ff1061572c67099a787ecc9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 10:25:22 +0000 Subject: [PATCH 07/13] Update dependency libpng to v1.6.58 --- .github/dependencies.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependencies.json b/.github/dependencies.json index 0f61b7817..a5e9b01f7 100644 --- a/.github/dependencies.json +++ b/.github/dependencies.json @@ -8,7 +8,7 @@ "lcms2": "2.18", "libavif": "1.4.1", "libimagequant": "4.4.1", - "libpng": "1.6.56", + "libpng": "1.6.58", "libwebp": "1.6.0", "libxcb": "1.17.0", "openjpeg": "2.5.4", From 956d434c68ee83f971057d4ce1321d12c12390be Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 10:25:27 +0000 Subject: [PATCH 08/13] Update dependency lcms2 to v2.19 --- .github/dependencies.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependencies.json b/.github/dependencies.json index 0f61b7817..cf4990c67 100644 --- a/.github/dependencies.json +++ b/.github/dependencies.json @@ -5,7 +5,7 @@ "fribidi": "1.0.16", "harfbuzz": "13.2.1", "jpegturbo": "3.1.4.1", - "lcms2": "2.18", + "lcms2": "2.19", "libavif": "1.4.1", "libimagequant": "4.4.1", "libpng": "1.6.56", From 32b6c5f0eee19ccb5e255e1edfdf7fd8833edfa7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 10:25:32 +0000 Subject: [PATCH 09/13] Update dependency harfbuzz to v14 --- .github/dependencies.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependencies.json b/.github/dependencies.json index 0f61b7817..c21c60e9e 100644 --- a/.github/dependencies.json +++ b/.github/dependencies.json @@ -3,7 +3,7 @@ "bzip2": "1.0.8", "freetype": "2.14.3", "fribidi": "1.0.16", - "harfbuzz": "13.2.1", + "harfbuzz": "14.2.0", "jpegturbo": "3.1.4.1", "lcms2": "2.18", "libavif": "1.4.1", From 575b33d811b8f50577fb8465d0f59e7bca4e5d95 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:29:50 +0000 Subject: [PATCH 10/13] Update dependency mypy to v1.20.2 --- .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 c64343a73..ad78bcb67 100644 --- a/.ci/requirements-mypy.txt +++ b/.ci/requirements-mypy.txt @@ -1,4 +1,4 @@ -mypy==1.19.1 +mypy==1.20.2 arro3-compute arro3-core IceSpringPySideStubs-PyQt6 From c234720acad29ff0ccd10b1564c0cdaae1d0fade Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 1 May 2026 11:40:49 +1000 Subject: [PATCH 11/13] Convert Exif to dictionary before checking --- Tests/test_image.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/test_image.py b/Tests/test_image.py index 32c799195..81bd47299 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -862,7 +862,7 @@ class TestImage: def test_exif_webp(self, tmp_path: Path) -> None: with Image.open("Tests/images/hopper.webp") as im: exif = im.getexif() - assert exif == {} + assert dict(exif) == {} out = tmp_path / "temp.webp" exif[258] = 8 @@ -884,7 +884,7 @@ class TestImage: def test_exif_png(self, tmp_path: Path) -> None: with Image.open("Tests/images/exif.png") as im: exif = im.getexif() - assert exif == {274: 1} + assert dict(exif) == {274: 1} out = tmp_path / "temp.png" exif[258] = 8 From 21790fc0da70f8dc9594248a1e30654c3cc05e65 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 1 May 2026 11:42:27 +1000 Subject: [PATCH 12/13] Check if sys.stdout is a TextIOWrapper instance --- src/PIL/Image.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 574980771..81add2f7a 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2639,11 +2639,8 @@ class Image: if is_path(fp): filename = os.fspath(fp) open_fp = True - elif fp == sys.stdout: - try: - fp = sys.stdout.buffer - except AttributeError: - pass + elif fp == sys.stdout and isinstance(sys.stdout, io.TextIOWrapper): + fp = sys.stdout.buffer if not filename and hasattr(fp, "name") and is_path(fp.name): # only set the name for metadata purposes filename = os.fspath(fp.name) From 4bba24632f13d2427ad7f52bf3402e35fe220b5f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 3 May 2026 22:13:11 +1000 Subject: [PATCH 13/13] Update docs --- docs/installation/building-from-source.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst index 79d54145a..ecb892e1f 100644 --- a/docs/installation/building-from-source.rst +++ b/docs/installation/building-from-source.rst @@ -51,7 +51,7 @@ Many of Pillow's features require external libraries: * **littlecms** provides color management * Pillow version 2.2.1 and below uses liblcms1, Pillow 2.3.0 and - above uses liblcms2. Tested with **1.19** and **2.7-2.18**. + above uses liblcms2. Tested with **1.19** and **2.7-2.19**. * **libwebp** provides the WebP format.