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..67388c7176e 100644 --- a/Tests/test_imagepalette.py +++ b/Tests/test_imagepalette.py @@ -85,6 +85,51 @@ 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", (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: + 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]) + 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..81b9c611d5b 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. + 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: