From 2d533040b23b3e6cc0f5257e3a99e346a90d4117 Mon Sep 17 00:00:00 2001 From: lllleolin-max <244385774+lllleolin-max@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:13:07 +0800 Subject: [PATCH 1/5] Preserve transparency when padding and expanding images --- Tests/test_imageops.py | 69 ++++++++++++++++++++++++++++++++++++ Tests/test_imagepalette.py | 23 ++++++++++++ docs/releasenotes/13.0.0.rst | 9 +++++ src/PIL/ImageOps.py | 4 ++- src/PIL/ImagePalette.py | 12 ++++--- 5 files changed, 112 insertions(+), 5 deletions(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index c7e63fbe548..8604e1a8328 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 Image, ImageDraw, ImageOps, ImageStat, features @@ -232,6 +234,73 @@ def test_rgba_palette() -> None: assert expanded_im.convert("RGBA").getpixel((0, 0)) == translucent_black +@pytest.mark.parametrize( + "mode, transparency", + (("P", 0), ("P", b"\x00\x80\xff"), ("L", 0), ("RGB", (0, 0, 0))), +) +@pytest.mark.parametrize("operation", ("expand", "pad_horizontal", "pad_vertical")) +def test_transparency( + mode: str, transparency: int | bytes | tuple[int, int, int], operation: str +) -> None: + im = Image.frombytes("L", (3, 2), b"\x00\x01\x02\x02\x01\x00") + if mode == "P": + im.putpalette([0, 0, 0, 0, 255, 0, 255, 0, 0]) + elif mode == "RGB": + im = im.convert(mode) + im.info["transparency"] = transparency + expected = im.convert("RGBA") + original_info = im.info.copy() + original_palette = im.getpalette() + + if operation == "expand": + out = ImageOps.expand(im, 1, fill="red") + box = (1, 1, 4, 3) + elif operation == "pad_horizontal": + out = ImageOps.pad(im, (5, 2), color="red") + box = (1, 0, 4, 2) + else: + out = ImageOps.pad(im, (3, 4), color="red") + box = (0, 1, 3, 3) + + assert out.mode == mode + rgba = out.convert("RGBA") + assert_image_equal(rgba.crop(box), expected) + assert out.info["transparency"] == transparency + assert rgba.getpixel((0, 0)) == Image.new(mode, (1, 1), "red").convert( + "RGBA" + ).getpixel((0, 0)) + with BytesIO() as buffer: + out.save(buffer, "PNG") + buffer.seek(0) + with Image.open(buffer) as reloaded: + assert_image_equal(reloaded.convert("RGBA"), rgba) + assert im.info == original_info + assert im.getpalette() == original_palette + assert_image_equal(im.convert("RGBA"), expected) + + +@pytest.mark.parametrize("transparency", (1, b"\xff\x00", b"\xff\x80")) +@pytest.mark.parametrize("operation", ("expand", "pad")) +def test_transparency_new_palette_color( + transparency: int | bytes, operation: str +) -> None: + im = Image.new("P", (1, 1)) + im.putpalette([0, 0, 0]) + im.info["transparency"] = transparency + if operation == "expand": + out = ImageOps.expand(im, 1, fill="red") + position = (1, 1) + else: + out = ImageOps.pad(im, (3, 1), color="red") + position = (1, 0) + + rgba = out.convert("RGBA") + assert rgba.getpixel((0, 0)) == (255, 0, 0, 255) + assert rgba.getpixel(position) == (0, 0, 0, 255) + assert im.info["transparency"] == transparency + assert im.getpalette() == [0, 0, 0] + + def test_pil163() -> None: # Division by zero in equalize if < 255 pixels in image (@PIL163) diff --git a/Tests/test_imagepalette.py b/Tests/test_imagepalette.py index 2fcf6cffead..dba7137b73f 100644 --- a/Tests/test_imagepalette.py +++ b/Tests/test_imagepalette.py @@ -116,6 +116,29 @@ def test_getcolor_not_special(index: int, palette: ImagePalette.ImagePalette) -> assert roundtripped_palette.colors[(0, 0, 2)] == index2 +@pytest.mark.parametrize( + "palette_size, transparency, expected_index", + ((1, b"\xff\x00\x80\xff", 3), (256, b"\xff" * 254 + b"\x00\x80", 253)), + ids=("partial-palette", "full-palette"), +) +def test_getcolor_transparency_table( + palette_size: int, transparency: bytes, expected_index: int +) -> None: + im = Image.new("P", (1, 1)) + im.info["transparency"] = transparency + palette = ImagePalette.ImagePalette("RGB", [0, 0, 0] * palette_size) + assert palette.getcolor((255, 0, 0), im) == expected_index + assert im.info["transparency"] == transparency + + +def test_getcolor_transparency_table_full() -> None: + im = Image.new("P", (1, 1)) + im.info["transparency"] = b"\x00" * 256 + palette = ImagePalette.ImagePalette("RGB", [0, 0, 0] * 256) + with pytest.raises(ValueError, match="cannot allocate more than 256 colors"): + palette.getcolor((255, 0, 0), im) + + def test_file(tmp_path: Path) -> None: palette = ImagePalette.ImagePalette("RGB", list(range(256)) * 3) diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 46dbed3b58b..89578dbf32f 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -126,6 +126,15 @@ Two new filters are available for :py:meth:`~PIL.Image.Image.resize` and Other changes ============= +Preserved transparency when padding or expanding images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:func:`~PIL.ImageOps.pad` and :py:func:`~PIL.ImageOps.expand` now preserve the +``transparency`` value from the source image's ``info`` dictionary. Transparent +pixels in P, L and RGB images previously became opaque when a border was added. +New palette colors are allocated without using reserved transparency entries, +including entries in transparency tables. + Python 3.15 ^^^^^^^^^^^ diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 94964e12156..593f801b031 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -349,12 +349,14 @@ def _new_with_fill( mode = image.palette.mode palette = ImagePalette.ImagePalette(mode, image.getpalette(mode)) if isinstance(color, tuple) and len(color) in (3, 4): - color = palette.getcolor(color) + color = palette.getcolor(color, image) else: palette = None out = Image.new(image.mode, size, color) if palette: out.putpalette(palette.palette, mode) + if "transparency" in image.info: + out.info["transparency"] = image.info["transparency"] return out diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index 6eff2eeeb72..b661bd833fa 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -132,10 +132,14 @@ def _new_color_index( index = len(self.palette) // len(self.mode) special_colors: tuple[int | tuple[int, ...] | None, ...] = () if image: - special_colors = ( - image.info.get("background"), - image.info.get("transparency"), - ) + special_colors = (image.info.get("background"),) + transparency = image.info.get("transparency") + if isinstance(transparency, bytes): + special_colors += tuple( + i for i, alpha in enumerate(transparency) if alpha != 255 + ) + else: + special_colors += (transparency,) assert isinstance(self._palette, bytearray) while index in special_colors: # Background or transparency index points past the end of the palette. From de0892e130fafe1fc915bab3c0d2689c2a4c543e Mon Sep 17 00:00:00 2001 From: lllleolin-max <244385774+lllleolin-max@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:39:20 +0800 Subject: [PATCH 2/5] Clarify ImageOps transparency release note --- docs/releasenotes/13.0.0.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 89578dbf32f..300dc7f83b9 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -126,14 +126,14 @@ Two new filters are available for :py:meth:`~PIL.Image.Image.resize` and Other changes ============= -Preserved transparency when padding or expanding images -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Preserved transparency when padding or expanding images in ImageOps +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ :py:func:`~PIL.ImageOps.pad` and :py:func:`~PIL.ImageOps.expand` now preserve the ``transparency`` value from the source image's ``info`` dictionary. Transparent pixels in P, L and RGB images previously became opaque when a border was added. New palette colors are allocated without using reserved transparency entries, -including entries in transparency tables. +including entries marked as non-opaque by transparency bytes in PNG images. Python 3.15 ^^^^^^^^^^^ From 5d055948cee106ea26fed690feb3ceeb462fca46 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:41:13 +1000 Subject: [PATCH 3/5] List modes in order of complexity --- docs/releasenotes/13.0.0.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index 300dc7f83b9..ee9d88db973 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -131,7 +131,7 @@ Preserved transparency when padding or expanding images in ImageOps :py:func:`~PIL.ImageOps.pad` and :py:func:`~PIL.ImageOps.expand` now preserve the ``transparency`` value from the source image's ``info`` dictionary. Transparent -pixels in P, L and RGB images previously became opaque when a border was added. +pixels in L, P and RGB images previously became opaque when a border was added. New palette colors are allocated without using reserved transparency entries, including entries marked as non-opaque by transparency bytes in PNG images. From 3c2e8ba91393107dbd0fa5e9528b01b67983569d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 15 Sep 2026 13:42:09 +1000 Subject: [PATCH 4/5] Remove changes to handle invalid PNGs --- Tests/test_imageops.py | 9 +++------ Tests/test_imagepalette.py | 23 ----------------------- docs/releasenotes/13.0.0.rst | 17 ++++++++--------- src/PIL/ImagePalette.py | 12 ++++-------- 4 files changed, 15 insertions(+), 46 deletions(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 8604e1a8328..811a7e14f16 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -279,14 +279,11 @@ def test_transparency( assert_image_equal(im.convert("RGBA"), expected) -@pytest.mark.parametrize("transparency", (1, b"\xff\x00", b"\xff\x80")) @pytest.mark.parametrize("operation", ("expand", "pad")) -def test_transparency_new_palette_color( - transparency: int | bytes, operation: str -) -> None: +def test_transparency_new_palette_color(operation: str) -> None: im = Image.new("P", (1, 1)) im.putpalette([0, 0, 0]) - im.info["transparency"] = transparency + im.info["transparency"] = 1 if operation == "expand": out = ImageOps.expand(im, 1, fill="red") position = (1, 1) @@ -297,7 +294,7 @@ def test_transparency_new_palette_color( rgba = out.convert("RGBA") assert rgba.getpixel((0, 0)) == (255, 0, 0, 255) assert rgba.getpixel(position) == (0, 0, 0, 255) - assert im.info["transparency"] == transparency + assert im.info["transparency"] == 1 assert im.getpalette() == [0, 0, 0] diff --git a/Tests/test_imagepalette.py b/Tests/test_imagepalette.py index dba7137b73f..2fcf6cffead 100644 --- a/Tests/test_imagepalette.py +++ b/Tests/test_imagepalette.py @@ -116,29 +116,6 @@ def test_getcolor_not_special(index: int, palette: ImagePalette.ImagePalette) -> assert roundtripped_palette.colors[(0, 0, 2)] == index2 -@pytest.mark.parametrize( - "palette_size, transparency, expected_index", - ((1, b"\xff\x00\x80\xff", 3), (256, b"\xff" * 254 + b"\x00\x80", 253)), - ids=("partial-palette", "full-palette"), -) -def test_getcolor_transparency_table( - palette_size: int, transparency: bytes, expected_index: int -) -> None: - im = Image.new("P", (1, 1)) - im.info["transparency"] = transparency - palette = ImagePalette.ImagePalette("RGB", [0, 0, 0] * palette_size) - assert palette.getcolor((255, 0, 0), im) == expected_index - assert im.info["transparency"] == transparency - - -def test_getcolor_transparency_table_full() -> None: - im = Image.new("P", (1, 1)) - im.info["transparency"] = b"\x00" * 256 - palette = ImagePalette.ImagePalette("RGB", [0, 0, 0] * 256) - with pytest.raises(ValueError, match="cannot allocate more than 256 colors"): - palette.getcolor((255, 0, 0), im) - - def test_file(tmp_path: Path) -> None: palette = ImagePalette.ImagePalette("RGB", list(range(256)) * 3) diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index ee9d88db973..a8ed553654e 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -126,15 +126,6 @@ Two new filters are available for :py:meth:`~PIL.Image.Image.resize` and Other changes ============= -Preserved transparency when padding or expanding images in ImageOps -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -:py:func:`~PIL.ImageOps.pad` and :py:func:`~PIL.ImageOps.expand` now preserve the -``transparency`` value from the source image's ``info`` dictionary. Transparent -pixels in L, P and RGB images previously became opaque when a border was added. -New palette colors are allocated without using reserved transparency entries, -including entries marked as non-opaque by transparency bytes in PNG images. - Python 3.15 ^^^^^^^^^^^ @@ -144,6 +135,14 @@ immediately at the release of 3.15.0 final (2026-10-01, :pep:`790`). Pillow 13.0.0 now officially supports Python 3.15. +Preserved transparency when padding or expanding images in ImageOps +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:func:`~PIL.ImageOps.pad` and :py:func:`~PIL.ImageOps.expand` now preserve the +``transparency`` value from the source image's ``info`` dictionary. Transparent +pixels in L, P and RGB images previously became opaque when a border was added. +New palette colors are allocated without using reserved transparency entries. + Fixed ImageChops.offset() for 16-bit images ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index b661bd833fa..6eff2eeeb72 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -132,14 +132,10 @@ def _new_color_index( index = len(self.palette) // len(self.mode) special_colors: tuple[int | tuple[int, ...] | None, ...] = () if image: - special_colors = (image.info.get("background"),) - transparency = image.info.get("transparency") - if isinstance(transparency, bytes): - special_colors += tuple( - i for i, alpha in enumerate(transparency) if alpha != 255 - ) - else: - special_colors += (transparency,) + special_colors = ( + image.info.get("background"), + image.info.get("transparency"), + ) assert isinstance(self._palette, bytearray) while index in special_colors: # Background or transparency index points past the end of the palette. From c3ef8a3147101b4fdf5f88da64e9af5eb1df7f7a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 15 Sep 2026 19:41:22 +1000 Subject: [PATCH 5/5] Simplify tests --- Tests/test_imageops.py | 77 ++++++++---------------------------------- 1 file changed, 14 insertions(+), 63 deletions(-) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 811a7e14f16..4a4826d22e3 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 Image, ImageDraw, ImageOps, ImageStat, features @@ -234,68 +232,21 @@ def test_rgba_palette() -> None: assert expanded_im.convert("RGBA").getpixel((0, 0)) == translucent_black -@pytest.mark.parametrize( - "mode, transparency", - (("P", 0), ("P", b"\x00\x80\xff"), ("L", 0), ("RGB", (0, 0, 0))), -) -@pytest.mark.parametrize("operation", ("expand", "pad_horizontal", "pad_vertical")) -def test_transparency( - mode: str, transparency: int | bytes | tuple[int, int, int], operation: str -) -> None: - im = Image.frombytes("L", (3, 2), b"\x00\x01\x02\x02\x01\x00") - if mode == "P": - im.putpalette([0, 0, 0, 0, 255, 0, 255, 0, 0]) - elif mode == "RGB": - im = im.convert(mode) - im.info["transparency"] = transparency - expected = im.convert("RGBA") - original_info = im.info.copy() - original_palette = im.getpalette() - - if operation == "expand": - out = ImageOps.expand(im, 1, fill="red") - box = (1, 1, 4, 3) - elif operation == "pad_horizontal": - out = ImageOps.pad(im, (5, 2), color="red") - box = (1, 0, 4, 2) - else: - out = ImageOps.pad(im, (3, 4), color="red") - box = (0, 1, 3, 3) - - assert out.mode == mode - rgba = out.convert("RGBA") - assert_image_equal(rgba.crop(box), expected) - assert out.info["transparency"] == transparency - assert rgba.getpixel((0, 0)) == Image.new(mode, (1, 1), "red").convert( - "RGBA" - ).getpixel((0, 0)) - with BytesIO() as buffer: - out.save(buffer, "PNG") - buffer.seek(0) - with Image.open(buffer) as reloaded: - assert_image_equal(reloaded.convert("RGBA"), rgba) - assert im.info == original_info - assert im.getpalette() == original_palette - assert_image_equal(im.convert("RGBA"), expected) - - -@pytest.mark.parametrize("operation", ("expand", "pad")) -def test_transparency_new_palette_color(operation: str) -> None: +def test_transparency() -> None: im = Image.new("P", (1, 1)) - im.putpalette([0, 0, 0]) - im.info["transparency"] = 1 - if operation == "expand": - out = ImageOps.expand(im, 1, fill="red") - position = (1, 1) - else: - out = ImageOps.pad(im, (3, 1), color="red") - position = (1, 0) - - rgba = out.convert("RGBA") - assert rgba.getpixel((0, 0)) == (255, 0, 0, 255) - assert rgba.getpixel(position) == (0, 0, 0, 255) - assert im.info["transparency"] == 1 - assert im.getpalette() == [0, 0, 0] + im.info["transparency"] = 0 + + expanded_im = ImageOps.expand(im, 1, "blue") + assert expanded_im.info["transparency"] == 0 + expanded_im_rgba = expanded_im.convert("RGBA") + assert expanded_im_rgba.getpixel((0, 0)) == (0, 0, 255, 255) + assert expanded_im_rgba.getpixel((1, 1)) == (0, 0, 0, 0) + + padded_im = ImageOps.pad(im, (3, 1), color="blue") + assert padded_im.info["transparency"] == 0 + padded_im_rgba = padded_im.convert("RGBA") + assert padded_im_rgba.getpixel((0, 0)) == (0, 0, 255, 255) + assert padded_im_rgba.getpixel((1, 0)) == (0, 0, 0, 0) def test_pil163() -> None: