From e667c620206105709244320938fe3edc0374831f Mon Sep 17 00:00:00 2001 From: sergioperezcheco Date: Wed, 30 Sep 2026 11:53:42 +0800 Subject: [PATCH 1/4] Fix color lookup after reusing a palette entry Signed-off-by: sergioperezcheco --- Tests/test_imagedraw.py | 4 +++- Tests/test_imagepalette.py | 40 ++++++++++++++++++++++++++++++++++++ docs/releasenotes/13.0.0.rst | 8 ++++++++ src/PIL/ImagePalette.py | 3 ++- 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 680a68c302b..eae7733a6c1 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -82,7 +82,9 @@ def test_new_color() -> None: # Test drawing another new color, now that the palette is dirty draw.point((0, 0), fill=(1, 0, 0)) - assert len(im.palette.colors) == 251 + # The one-coordinate line did not use black, so its slot is reused. + assert len(im.palette.colors) == 250 + assert (0, 0, 0) not in im.palette.colors assert im.convert("RGB").getpixel((0, 0)) == (1, 0, 0) diff --git a/Tests/test_imagepalette.py b/Tests/test_imagepalette.py index bb53a7cda68..216cd849a8f 100644 --- a/Tests/test_imagepalette.py +++ b/Tests/test_imagepalette.py @@ -85,6 +85,46 @@ def test_getcolor() -> None: palette.getcolor("unknown") # type: ignore[arg-type] +@pytest.mark.parametrize("mode", ("RGB", "RGBA")) +@pytest.mark.parametrize("duplicate", (False, True)) +def test_getcolor_reused_index(mode: str, duplicate: bool) -> None: + colors: list[tuple[int, ...]] = [(i, i, i) for i in range(256)] + if duplicate: + colors[2] = colors[1] + if mode == "RGBA": + colors = [color + (255,) for color in colors] + im = Image.new("P", (255, 1)) + im.putdata([0] + list(range(2, 256))) + im.putpalette([channel for color in colors for channel in color], mode) + assert im.palette is not None + + new_color = (255, 0, 0) if mode == "RGB" else (255, 0, 0, 255) + assert im.palette.getcolor(new_color, im) == 1 + assert im.palette.getcolor(new_color, im) == 1 + if duplicate: + assert im.palette.getcolor(colors[1], im) == 2 + else: + with pytest.raises(ValueError, match="cannot allocate more than 256 colors"): + im.palette.getcolor(colors[1]) + assert ( + im.palette.colors == ImagePalette.ImagePalette(mode, im.palette.palette).colors + ) + + +def test_putpixel_reused_palette_color(tmp_path: Path) -> None: + im = Image.new("P", (3, 1)) + im.putpalette([channel for i in range(256) for channel in (i, i, i)]) + im.putpixel((0, 0), (255, 0, 0)) + im.putpixel((1, 0), (255, 255, 255)) + expected = Image.new("RGB", (3, 1)) + expected.putdata([(255, 0, 0), (255, 255, 255), (0, 0, 0)]) + assert_image_equal(im.convert("RGB"), expected) + path = tmp_path / "reused_palette.png" + im.save(path) + with Image.open(path) as reopened: + assert_image_equal(reopened.convert("RGB"), expected) + + def test_getcolor_rgba() -> None: palette = ImagePalette.ImagePalette("RGBA", (1, 2, 3, 4)) palette.getcolor((5, 6, 7, 8)) diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index b9906b7f19d..25d1e49eb46 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -134,6 +134,14 @@ escape sequences. Other changes ============= +Fixed color lookup after reusing a palette entry +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When allocating a color in a full palette, Pillow can reuse an entry that is not +used by the image. The color lookup is now updated when this happens. Previously, +requesting the replaced color again could return the reused entry and draw the +wrong color. Duplicate colors in other palette entries remain available. + Python 3.15 ^^^^^^^^^^^ diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index e6b68c1e34c..815413f0db1 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -189,7 +189,6 @@ def getcolor( # allocate new color slot index = self._new_color_index(image, e) assert isinstance(self._palette, bytearray) - self.colors[color] = index mode_len = len(self.mode) if index * mode_len < len(self.palette): self._palette = ( @@ -197,8 +196,10 @@ def getcolor( + bytes(color) + self._palette[index * mode_len + mode_len :] ) + self._colors = None else: self._palette += bytes(color) + self.colors[color] = index self.dirty = 1 return index else: From 50c3d4c02d255415ef3a24a00f1472961952f128 Mon Sep 17 00:00:00 2001 From: sergioperezcheco Date: Wed, 30 Sep 2026 12:03:22 +0800 Subject: [PATCH 2/4] docs: fix palette release note heading underline Signed-off-by: sergioperezcheco --- 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 25d1e49eb46..d0daffeb597 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -135,7 +135,7 @@ Other changes ============= Fixed color lookup after reusing a palette entry -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ When allocating a color in a full palette, Pillow can reuse an entry that is not used by the image. The color lookup is now updated when this happens. Previously, From 913bb26297fc122aaa9e724dbd939cc4d15d5dc4 Mon Sep 17 00:00:00 2001 From: sergioperezcheco Date: Wed, 30 Sep 2026 19:33:23 +0800 Subject: [PATCH 3/4] Clarify palette reuse release note Signed-off-by: sergioperezcheco --- 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 d0daffeb597..81b9c611d5b 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -140,7 +140,7 @@ Fixed color lookup after reusing a palette entry When allocating a color in a full palette, Pillow can reuse an entry that is not used by the image. The color lookup is now updated when this happens. Previously, requesting the replaced color again could return the reused entry and draw the -wrong color. Duplicate colors in other palette entries remain available. +wrong color. Python 3.15 ^^^^^^^^^^^ From f9c20e66c21c811a2e78aadfa0f1955148756886 Mon Sep 17 00:00:00 2001 From: sergioperezcheco Date: Wed, 30 Sep 2026 20:35:52 +0800 Subject: [PATCH 4/4] Exercise duplicate palette colors after drawing the reused entry Draw red into the reused slot before requesting the surviving gray color. Every palette index is now in use, so losing gray's cached mapping raises ValueError rather than merely choosing another unused index. Check rendered colors as well as indices. Signed-off-by: sergioperezcheco --- Tests/test_imagepalette.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Tests/test_imagepalette.py b/Tests/test_imagepalette.py index 216cd849a8f..67388c7176e 100644 --- a/Tests/test_imagepalette.py +++ b/Tests/test_imagepalette.py @@ -93,16 +93,21 @@ def test_getcolor_reused_index(mode: str, duplicate: bool) -> None: colors[2] = colors[1] if mode == "RGBA": colors = [color + (255,) for color in colors] - im = Image.new("P", (255, 1)) - im.putdata([0] + list(range(2, 256))) + im = Image.new("P", (256, 1)) + im.putdata([0] + list(range(2, 256)) + [0]) im.putpalette([channel for color in colors for channel in color], mode) assert im.palette is not None new_color = (255, 0, 0) if mode == "RGB" else (255, 0, 0, 255) assert im.palette.getcolor(new_color, im) == 1 + im.putpixel((255, 0), new_color) assert im.palette.getcolor(new_color, im) == 1 if duplicate: - assert im.palette.getcolor(colors[1], im) == 2 + im.putpixel((0, 0), colors[1]) + assert im.getpixel((0, 0)) == 2 + expected_color = colors[1][:3] + assert im.convert("RGB").getpixel((0, 0)) == expected_color + assert im.convert("RGB").getpixel((255, 0)) == new_color[:3] else: with pytest.raises(ValueError, match="cannot allocate more than 256 colors"): im.palette.getcolor(colors[1])