Repository: python-pillow/Pillow

Issue:
Transparency is not applied to RGBA palette when opening PNG
### What did you do?
I need to get a palette with transparency information from a PNG image. When saving this is correctly applied to the output image, when loading only an opaque palette is returned.

### What did you expect to happen?
I expect that image.info["transparency"] is applied to a returned RGBA palette, i.e. alpha cleared for a single transparent colour, or alpha replaced with the array of transparency values.

### What actually happened?
A completely opaque palette is returned

### What are your OS, Python and Pillow versions?

* OS: Windows 10
* Python: 3.9.5 (64 bit)
* Pillow: 9.1.1

```python
from PIL import Image

img = Image.new("P", (256, 256))
pixel = []
for y in range(256):
    for x in range(256):
        v = ((y // 16) & 1) ^ ((x//16) & 1)
        pixel.append(v)
img.putdata(pixel)
pal = []
for i in range(256):
    if i == 0:
        pal.extend([0,0,0])
    else:
        pal.extend([255,255,255])
img.putpalette(pal, "RGB")

img.save("test_A.png", transparency=0)
img.save("test_B.png", transparency=bytes([0] + [255] * 255))

pal = []
for i in range(256):
    if i == 0:
        pal.extend([0,0,0,0])
    else:
        pal.extend([255,255,255,255])
img.putpalette(pal, "RGBA")
img.save("test_C.png")

for f in ["test_A.png", "test_B.png", "test_C.png"]:
    img = Image.open(f)
    pal = img.getpalette("RGBA")
    print (img.info, img.mode, pal[0:8])
```
This sets the transparency in 3 different ways and saves images to the current directory, then loads them back. 
The images show the expected transparency if opened in a program like GIMP.

Output is:
```
{'transparency': 0} P [0, 0, 0, 255, 255, 255, 255, 255]
{'transparency': 0} P [0, 0, 0, 255, 255, 255, 255, 255]
{'transparency': 0} P [0, 0, 0, 255, 255, 255, 255, 255]
```
Expected output should be something like:
```
{'transparency': 0} P [0, 0, 0, 0, 255, 255, 255, 255]
{'transparency': 0} P [0, 0, 0, 0, 255, 255, 255, 255]
{'transparency': 0} P [0, 0, 0, 0, 255, 255, 255, 255]
```



Discussion:
Hi. As you have said, the transparency information is in the info dictionary, but not in the palette. 

I'm not convinced this is a bug. I don't think it should be in both, but in one or the other. I don't like the idea of having two sources of truth about what the transparency is. The image is being opened with an RGB palette. When you are requesting an RGBA palette, the RGB palette is just being converted.

If I ask Pillow to open and re-save test_A.png, test_B.png and test_C.png, the transparency is retained, so there's no information loss occurring.

If you need the transparency index to become part of the palette for reasons external to Pillow, it should be straightforward for you to apply it in your code.
I do actually have the code to apply transparency from before Pillow supported RGBA palettes, I was hoping to remove it.

My reasoning is:
- palette transparency is set up the file format loader - you have to dig through the documentation to find how to get and set it. This took some time when I started to use Pillow.
- The PNG loader supports two forms of transparency (index or array), you need to handle both in your code. RGBA could hide this difference.
- The P->RGBA conversion handles transparency, so getting an RGBA palette should handle this as well.
- From a single source of truth perspective: All code needs to know where to look for the transparency, and that the one in the RGBA palette may not be correct.

Obviously one problem is existing code that expects transparency in the info dictionary. But that code probably uses an RGB palette as well.

How about this idea - rather than changing the default behaviour, what if we added an `include_transparency` argument to `getpalette()`?
I would expect this to be the default if I request an RGBA palette over an RGB palette, also another argument will change the API which may cause problems in the future if you want to change it.
I am happy if you want to leave it like it is for now, until there are other reasons change it.
Pillow values backwards compatibility, and this is not explicitly a bug - at the moment, I think `getpalette()` is correctly casting the RGB palette of the image to an RGBA palette, because you requested the palette be in RGBA format.
What about a separate "expand palette" function, that internally updates the palette from RGB to RGBA and applies transparency. It would do nothing if the palette is already RGBA.
A companion could be "compact palette" that would reduce the internal palette to RGB, and update the transparency index (or transparency array) in the info header?

Resolving patch:
diff --git a/src/PIL/Image.py b/src/PIL/Image.py
--- a/src/PIL/Image.py
+++ b/src/PIL/Image.py
@@ -1449,6 +1449,28 @@ def getpalette(self, rawmode="RGB"):
             rawmode = mode
         return list(self.im.getpalette(mode, rawmode))
 
+    def apply_transparency(self):
+        """
+        If a P mode image has a "transparency" key in the info dictionary,
+        remove the key and apply the transparency to the palette instead.
+        """
+        if self.mode != "P" or "transparency" not in self.info:
+            return
+
+        from . import ImagePalette
+
+        palette = self.getpalette("RGBA")
+        transparency = self.info["transparency"]
+        if isinstance(transparency, bytes):
+            for i, alpha in enumerate(transparency):
+                palette[i * 4 + 3] = alpha
+        else:
+            palette[transparency * 4 + 3] = 0
+        self.palette = ImagePalette.ImagePalette("RGBA", bytes(palette))
+        self.palette.dirty = 1
+
+        del self.info["transparency"]
+
     def getpixel(self, xy):
         """
         Returns the pixel value at a given position.


Test patch:
diff --git a/Tests/test_image.py b/Tests/test_image.py
--- a/Tests/test_image.py
+++ b/Tests/test_image.py
@@ -840,6 +840,35 @@ def test_zero_tobytes(self, size):
         im = Image.new("RGB", size)
         assert im.tobytes() == b""
 
+    def test_apply_transparency(self):
+        im = Image.new("P", (1, 1))
+        im.putpalette((0, 0, 0, 1, 1, 1))
+        assert im.palette.colors == {(0, 0, 0): 0, (1, 1, 1): 1}
+
+        # Test that no transformation is applied without transparency
+        im.apply_transparency()
+        assert im.palette.colors == {(0, 0, 0): 0, (1, 1, 1): 1}
+
+        # Test that a transparency index is applied
+        im.info["transparency"] = 0
+        im.apply_transparency()
+        assert "transparency" not in im.info
+        assert im.palette.colors == {(0, 0, 0, 0): 0, (1, 1, 1, 255): 1}
+
+        # Test that existing transparency is kept
+        im = Image.new("P", (1, 1))
+        im.putpalette((0, 0, 0, 255, 1, 1, 1, 128), "RGBA")
+        im.info["transparency"] = 0
+        im.apply_transparency()
+        assert im.palette.colors == {(0, 0, 0, 0): 0, (1, 1, 1, 128): 1}
+
+        # Test that transparency bytes are applied
+        with Image.open("Tests/images/pil123p.png") as im:
+            assert isinstance(im.info["transparency"], bytes)
+            assert im.palette.colors[(27, 35, 6)] == 24
+            im.apply_transparency()
+            assert im.palette.colors[(27, 35, 6, 214)] == 24
+
     def test_categories_deprecation(self):
         with pytest.warns(DeprecationWarning):
             assert hopper().category == 0