From 9687e8eb5b00ab9b462968f7b9c7260dd50a6074 Mon Sep 17 00:00:00 2001 From: Jeff <138259519+jdotc1@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:35:52 -0400 Subject: [PATCH 1/4] fix: preserve palettes in empty contain results --- Tests/test_imageops.py | 79 ++++++++++++++++++++++++++++++++++++ docs/releasenotes/13.1.0.rst | 10 +++++ src/PIL/ImageOps.py | 6 +-- 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index c52d3420e8f..069e7c2412c 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -1,5 +1,7 @@ from __future__ import annotations +from io import BytesIO + import pytest from PIL import ExifTags, Image, ImageDraw, ImageOps, ImageStat, features @@ -178,6 +180,83 @@ def test_contain_zero() -> None: assert new_im.size == (0, 0) +@pytest.mark.parametrize( + "size, target, expected", + ( + ((1, 1000), (10, 10), (0, 10)), + ((1000, 1), (10, 10), (10, 0)), + ((1, 0), (10, 10), (10, 0)), + ((1, 1), (10, 0), (0, 0)), + ), +) +def test_contain_empty_palette( + size: tuple[int, int], target: tuple[int, int], expected: tuple[int, int] +) -> None: + im = Image.new("P", size, 1) + im.putpalette((0, 0, 0, 255, 0, 0)) + im.info["transparency"] = 1 + + contained = ImageOps.contain(im, target) + + assert contained.size == expected + assert contained.mode == im.mode + assert contained.getpalette() == im.getpalette() + assert contained.info["transparency"] == 1 + assert contained.palette is not im.palette + + +@pytest.mark.parametrize("size", ((1, 1000), (1000, 1))) +@pytest.mark.parametrize("mode", ("P", "PA")) +def test_pad_empty_palette(size: tuple[int, int], mode: str) -> None: + im = Image.new(mode, size) + im.putpalette((0, 0, 0, 255, 0, 0)) + color = (1, 127) if mode == "PA" else 1 + if mode == "P": + im.info["transparency"] = 1 + + padded = ImageOps.pad(im, (10, 10), color=color) + + assert padded.size == (10, 10) + assert padded.mode == mode + assert padded.convert("RGBA").getpixel((0, 0)) == ( + 255, + 0, + 0, + 127 if mode == "PA" else 0, + ) + + +def test_pad_empty_rgba_palette() -> None: + im = Image.new("P", (1, 1000)) + im.putpalette((0, 0, 0, 255, 255, 0, 0, 127), "RGBA") + padded = ImageOps.pad(im, (10, 10), color=1) + assert padded.convert("RGBA").getpixel((0, 0)) == (255, 0, 0, 127) + + +def test_pad_empty_full_palette() -> None: + im = Image.new("P", (1, 1024)) + im.putpalette([component for i in range(256) for component in (i, 0, 0)]) + im.frombytes(bytes(range(256)) * 4) + original_palette = im.getpalette() + + padded = ImageOps.pad(im, (10, 10), color=(0, 255, 0)) + + assert padded.convert("RGB").getpixel((0, 0)) == (0, 255, 0) + assert im.getpalette() == original_palette + + +@pytest.mark.parametrize("format", ("PNG", "GIF")) +def test_pad_empty_lazy_palette(format: str) -> None: + im = Image.new("P", (1, 1000), 1) + im.putpalette((0, 0, 0, 255, 0, 0)) + with BytesIO() as buffer: + im.save(buffer, format, transparency=1, optimize=False) + buffer.seek(0) + with Image.open(buffer) as lazy_im: + padded = ImageOps.pad(lazy_im, (10, 10), color=1) + assert padded.convert("RGBA").getpixel((0, 0)) == (255, 0, 0, 0) + + @pytest.mark.parametrize( "image_name, expected_size", ( diff --git a/docs/releasenotes/13.1.0.rst b/docs/releasenotes/13.1.0.rst index 5edc0d28693..7fe138ec51d 100644 --- a/docs/releasenotes/13.1.0.rst +++ b/docs/releasenotes/13.1.0.rst @@ -61,3 +61,13 @@ Blurring I;16 images :py:class:`~PIL.ImageFilter.GaussianBlur` and :py:class:`~PIL.ImageFilter.BoxBlur` now support I;16, I;16L, I;16B and I;16N images. + +Palettes in empty contain results +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:func:`~PIL.ImageOps.contain` now preserves the palette and transparency +metadata when the result has zero width or height. This also preserves the +padding color and transparency in :py:func:`~PIL.ImageOps.pad` when an image +becomes empty after resizing. +As with a non-empty resize, the source image is loaded before returning +the empty result. diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index b40af6330bb..11247ba0a57 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -313,10 +313,10 @@ def contain( """ if image.height == 0: - return Image.new(image.mode, (size[0], 0)) + return image.crop((0, 0, size[0], 0)) im_ratio = image.width / image.height if size[1] == 0: - return Image.new(image.mode, (0, 0)) + return image.crop((0, 0, 0, 0)) dest_ratio = size[0] / size[1] if im_ratio != dest_ratio: @@ -329,7 +329,7 @@ def contain( if new_width != size[0]: size = (new_width, size[1]) if min(size) == 0: - return Image.new(image.mode, size) + return image.crop((0, 0, *size)) return image.resize(size, resample=method) From 07c81370d49e34c44172133eea59285292c97726 Mon Sep 17 00:00:00 2001 From: Jeff <138259519+jdotc1@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:06:35 -0400 Subject: [PATCH 2/4] Fix empty contain release note heading underline --- docs/releasenotes/13.1.0.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releasenotes/13.1.0.rst b/docs/releasenotes/13.1.0.rst index 7fe138ec51d..a77c8eea2d4 100644 --- a/docs/releasenotes/13.1.0.rst +++ b/docs/releasenotes/13.1.0.rst @@ -63,7 +63,7 @@ Blurring I;16 images now support I;16, I;16L, I;16B and I;16N images. Palettes in empty contain results -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ :py:func:`~PIL.ImageOps.contain` now preserves the palette and transparency metadata when the result has zero width or height. This also preserves the From 1acdf1e48ccf87a1a319188ab7213f08abfea536 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 24 Oct 2026 12:30:02 +1100 Subject: [PATCH 3/4] Use _new, rather than crop() --- docs/releasenotes/13.1.0.rst | 10 ---------- src/PIL/ImageOps.py | 20 ++++++++++++-------- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/docs/releasenotes/13.1.0.rst b/docs/releasenotes/13.1.0.rst index a77c8eea2d4..5edc0d28693 100644 --- a/docs/releasenotes/13.1.0.rst +++ b/docs/releasenotes/13.1.0.rst @@ -61,13 +61,3 @@ Blurring I;16 images :py:class:`~PIL.ImageFilter.GaussianBlur` and :py:class:`~PIL.ImageFilter.BoxBlur` now support I;16, I;16L, I;16B and I;16N images. - -Palettes in empty contain results -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -:py:func:`~PIL.ImageOps.contain` now preserves the palette and transparency -metadata when the result has zero width or height. This also preserves the -padding color and transparency in :py:func:`~PIL.ImageOps.pad` when an image -becomes empty after resizing. -As with a non-empty resize, the source image is loaded before returning -the empty result. diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 11247ba0a57..ceff85e1c1a 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -89,6 +89,10 @@ def _lut(image: Image.Image, lut: list[int], palette: bool = False) -> Image.Ima raise OSError(msg) +def _new_empty(image: Image.Image, size: tuple[int, int]) -> Image.Image: + return image._new(Image.core.new(image.mode, size)) + + # # actions @@ -313,10 +317,10 @@ def contain( """ if image.height == 0: - return image.crop((0, 0, size[0], 0)) + return _new_empty(image, (size[0], 0)) im_ratio = image.width / image.height if size[1] == 0: - return image.crop((0, 0, 0, 0)) + return _new_empty(image, (0, 0)) dest_ratio = size[0] / size[1] if im_ratio != dest_ratio: @@ -329,7 +333,7 @@ def contain( if new_width != size[0]: size = (new_width, size[1]) if min(size) == 0: - return image.crop((0, 0, *size)) + return _new_empty(image, size) return image.resize(size, resample=method) @@ -350,12 +354,12 @@ def cover( """ if image.width == 0: - return Image.new(image.mode, (0, size[1])) + return _new_empty(image, (0, size[1])) if image.height == 0: - return Image.new(image.mode, (size[0], 0)) + return _new_empty(image, (size[0], 0)) im_ratio = image.width / image.height if size[1] == 0: - return Image.new(image.mode, (0, 0)) + return _new_empty(image, (0, 0)) dest_ratio = size[0] / size[1] if im_ratio != dest_ratio: @@ -606,7 +610,7 @@ def fit( # https://www.cazabon.com if min(size) == 0: - return Image.new(image.mode, size) + return _new_empty(image, size) centering_x, centering_y = centering if not 0.0 <= centering_x <= 1.0: @@ -630,7 +634,7 @@ def fit( # calculate the aspect ratio of the live_size if live_size[1] == 0: - return Image.new(image.mode, size) + return _new_empty(image, size) live_size_ratio = live_size[0] / live_size[1] # calculate the aspect ratio of the output image From a75824c3f08ecad062a8838a2f35cb72649b4fef Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 24 Oct 2026 12:38:40 +1100 Subject: [PATCH 4/4] Mark copied palette as dirty --- Tests/test_imageops.py | 153 ++++++++--------------------------------- src/PIL/Image.py | 1 + 2 files changed, 29 insertions(+), 125 deletions(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 069e7c2412c..124f2a7535a 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -1,7 +1,5 @@ from __future__ import annotations -from io import BytesIO - import pytest from PIL import ExifTags, Image, ImageDraw, ImageOps, ImageStat, features @@ -108,6 +106,34 @@ def test_crop( assert_image_equal(ImageOps.crop(im, border), im.crop(expected_box)) +def test_zero() -> None: + def _check( + size: tuple[int, int], + operation: Callable[[Image.Image], Image.Image], + expected_size: tuple[int, int], + ) -> None: + im = Image.new("P", size) + im.putpalette(list(range(256)) * 3) + im.info["transparency"] = 0 + new_im = operation(im) + assert new_im.getpalette(None) == im.getpalette(None) + assert new_im.info["transparency"] == 0 + assert new_im.size == expected_size + + _check((5, 5), lambda im: ImageOps.fit(im, (0, 5)), (0, 5)) + _check((5, 0), lambda im: ImageOps.fit(im, (5, 5)), (5, 5)) + _check((5, 5), lambda im: ImageOps.fit(im, (10, 0)), (10, 0)) + + _check((1, 10), lambda im: ImageOps.contain(im, (5, 5)), (0, 5)) + _check((10, 1), lambda im: ImageOps.contain(im, (5, 5)), (5, 0)) + _check((10, 0), lambda im: ImageOps.contain(im, (5, 5)), (5, 0)) + _check((5, 5), lambda im: ImageOps.contain(im, (2, 0)), (0, 0)) + + _check((0, 10), lambda im: ImageOps.cover(im, (5, 5)), (0, 5)) + _check((10, 0), lambda im: ImageOps.cover(im, (5, 5)), (5, 0)) + _check((5, 5), lambda im: ImageOps.cover(im, (2, 0)), (0, 0)) + + def test_1pxfit() -> None: # Division by zero in equalize if image is 1 pixel high newimg = ImageOps.fit(hopper("RGB").resize((1, 1)), (35, 35)) @@ -131,20 +157,6 @@ def test_fit_same_ratio() -> None: assert new_im.size == (1000, 755) -def test_fit_zero() -> None: - im = Image.new("1", (5, 5)) - new_im = ImageOps.fit(im, (0, 5)) - assert new_im.width == 0 - - im = Image.new("1", (5, 0)) - new_im = ImageOps.fit(im, (5, 5)) - assert new_im.size == (5, 5) - - im = Image.new("1", (5, 5)) - new_im = ImageOps.fit(im, (10, 0)) - assert new_im.size == (10, 0) - - @pytest.mark.parametrize("new_size", ((256, 256), (512, 256), (256, 512))) def test_contain(new_size: tuple[int, int]) -> None: im = hopper() @@ -162,101 +174,6 @@ def test_contain_round() -> None: assert new_im.height == 5 -def test_contain_zero() -> None: - im = Image.new("1", (1, 10)) - new_im = ImageOps.contain(im, (5, 5)) - assert new_im.width == 0 - - im = Image.new("1", (10, 1)) - new_im = ImageOps.contain(im, (5, 5)) - assert new_im.height == 0 - - im = Image.new("1", (10, 0)) - new_im = ImageOps.contain(im, (5, 5)) - assert new_im.size == (5, 0) - - im = Image.new("1", (5, 5)) - new_im = ImageOps.contain(im, (2, 0)) - assert new_im.size == (0, 0) - - -@pytest.mark.parametrize( - "size, target, expected", - ( - ((1, 1000), (10, 10), (0, 10)), - ((1000, 1), (10, 10), (10, 0)), - ((1, 0), (10, 10), (10, 0)), - ((1, 1), (10, 0), (0, 0)), - ), -) -def test_contain_empty_palette( - size: tuple[int, int], target: tuple[int, int], expected: tuple[int, int] -) -> None: - im = Image.new("P", size, 1) - im.putpalette((0, 0, 0, 255, 0, 0)) - im.info["transparency"] = 1 - - contained = ImageOps.contain(im, target) - - assert contained.size == expected - assert contained.mode == im.mode - assert contained.getpalette() == im.getpalette() - assert contained.info["transparency"] == 1 - assert contained.palette is not im.palette - - -@pytest.mark.parametrize("size", ((1, 1000), (1000, 1))) -@pytest.mark.parametrize("mode", ("P", "PA")) -def test_pad_empty_palette(size: tuple[int, int], mode: str) -> None: - im = Image.new(mode, size) - im.putpalette((0, 0, 0, 255, 0, 0)) - color = (1, 127) if mode == "PA" else 1 - if mode == "P": - im.info["transparency"] = 1 - - padded = ImageOps.pad(im, (10, 10), color=color) - - assert padded.size == (10, 10) - assert padded.mode == mode - assert padded.convert("RGBA").getpixel((0, 0)) == ( - 255, - 0, - 0, - 127 if mode == "PA" else 0, - ) - - -def test_pad_empty_rgba_palette() -> None: - im = Image.new("P", (1, 1000)) - im.putpalette((0, 0, 0, 255, 255, 0, 0, 127), "RGBA") - padded = ImageOps.pad(im, (10, 10), color=1) - assert padded.convert("RGBA").getpixel((0, 0)) == (255, 0, 0, 127) - - -def test_pad_empty_full_palette() -> None: - im = Image.new("P", (1, 1024)) - im.putpalette([component for i in range(256) for component in (i, 0, 0)]) - im.frombytes(bytes(range(256)) * 4) - original_palette = im.getpalette() - - padded = ImageOps.pad(im, (10, 10), color=(0, 255, 0)) - - assert padded.convert("RGB").getpixel((0, 0)) == (0, 255, 0) - assert im.getpalette() == original_palette - - -@pytest.mark.parametrize("format", ("PNG", "GIF")) -def test_pad_empty_lazy_palette(format: str) -> None: - im = Image.new("P", (1, 1000), 1) - im.putpalette((0, 0, 0, 255, 0, 0)) - with BytesIO() as buffer: - im.save(buffer, format, transparency=1, optimize=False) - buffer.seek(0) - with Image.open(buffer) as lazy_im: - padded = ImageOps.pad(lazy_im, (10, 10), color=1) - assert padded.convert("RGBA").getpixel((0, 0)) == (255, 0, 0, 0) - - @pytest.mark.parametrize( "image_name, expected_size", ( @@ -271,20 +188,6 @@ def test_cover(image_name: str, expected_size: tuple[int, int]) -> None: assert new_im.size == expected_size -def test_cover_zero() -> None: - im = Image.new("1", (0, 10)) - new_im = ImageOps.cover(im, (5, 5)) - assert new_im.size == (0, 5) - - im = Image.new("1", (10, 0)) - new_im = ImageOps.cover(im, (5, 5)) - assert new_im.size == (5, 0) - - im = Image.new("1", (5, 5)) - new_im = ImageOps.cover(im, (2, 0)) - assert new_im.size == (0, 0) - - def test_pad() -> None: # Same ratio im = hopper() diff --git a/src/PIL/Image.py b/src/PIL/Image.py index f008b689e1f..40e43501081 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -686,6 +686,7 @@ def _new(self, im: core.ImagingCore) -> Image: if im.mode in ("P", "PA"): if self.palette: new.palette = self.palette.copy() + new.palette.dirty = 1 else: from . import ImagePalette