Repository: python-pillow/Pillow

Issue:
PNG: unnecessary padding in PLTE
_Pillow_ incorrectly enforces a minimum of `2` palette entries.
```Python
from PIL import Image
img = Image.new(size = (1, 1), mode = 'P')
img.putpalette((1, 1, 1))
img.save('file.png')
```
```Bash
$ pngcheck -v file.png 
...
    1 x 1 image, 1-bit palette, non-interlaced
  chunk PLTE at offset 0x00025, length 6: 2 palette entries
...
```
And pads the palette to `bit ** 2` entries.
```Python
from PIL import Image
img = Image.new(size = (3, 3), mode = 'P')
img.putpalette((1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 4, 5)) # 5
img.save('file.png')
```
```Bash
$ pngcheck -v file.png 
...
    3 x 3 image, 4-bit palette, non-interlaced
  chunk PLTE at offset 0x00025, length 48: 16 palette entries
...
```

It's [neither required nor recommended](https://www.w3.org/TR/PNG/#11PLTE).
> It is permissible to have fewer entries than the bit depth would allow.



Discussion:
```diff
diff --git a/PIL/PngImagePlugin.py b/PIL/PngImagePlugin.py
index 5e5eb14..9f883c9 100644
--- a/PIL/PngImagePlugin.py
+++ b/PIL/PngImagePlugin.py
@@ -671,7 +671,7 @@ def _save(im, fp, filename, chunk=putchunk, check=0):
         else:
             # check palette contents
             if im.palette:
-                colors = max(min(len(im.palette.getdata()[1])//3, 256), 2)
+                colors = max(min(len(im.palette.getdata()[1])//3, 256), 1)
             else:
                 colors = 256
 
@@ -740,7 +740,7 @@ def _save(im, fp, filename, chunk=putchunk, check=0):
                 chunk(fp, cid, data)
 
     if im.mode == "P":
-        palette_byte_number = (2 ** bits) * 3
+        palette_byte_number = min(2 ** bits, colors) * 3
         palette_bytes = im.im.getpalette("RGB")[:palette_byte_number]
         while len(palette_bytes) < palette_byte_number:
             palette_bytes += b'\0'
@@ -752,7 +752,7 @@ def _save(im, fp, filename, chunk=putchunk, check=0):
     if transparency or transparency == 0:
         if im.mode == "P":
             # limit to actual palette size
-            alpha_bytes = 2**bits
+            alpha_bytes = min(2**bits, colors)
             if isinstance(transparency, bytes):
                 chunk(fp, b"tRNS", transparency[:alpha_bytes])
             else:
@@ -773,7 +773,7 @@ def _save(im, fp, filename, chunk=putchunk, check=0):
     else:
         if im.mode == "P" and im.im.getpalettemode() == "RGBA":
             alpha = im.im.getpalette("RGBA", "A")
-            alpha_bytes = 2**bits
+            alpha_bytes = min(2**bits, colors)
             chunk(fp, b"tRNS", alpha[:alpha_bytes])
 
     dpi = im.encoderinfo.get("dpi")
```
Works, but it's not tested extensively.
```Python
img = Image.frombytes('P', (1, 1), '\x00')
img.putpalette('\xFF\x00\x00') # R
img.save('file.png')
# chunk PLTE at offset 0x00025, length 3: 1 palette entry

img = Image.frombytes('P', (3, 3), '\x00' * 3 + '\x01' * 3 + '\x02' * 3)
img.putpalette('\xFF\x00\x00' '\x00\xFF\x00' '\x00\x00\xFF') # RGB
img.save('file.png')
# chunk PLTE at offset 0x00025, length 9: 3 palette entries

img.save('file.png', transparency = 0)
# chunk PLTE at offset 0x00025, length 9: 3 palette entries
# chunk tRNS at offset 0x0003a, length 1: 1 transparency entry

img.save('file.png', transparency = '\x00\x80\xFF')
# chunk PLTE at offset 0x00025, length 9: 3 palette entries
# chunk tRNS at offset 0x0003a, length 3: 3 transparency entries
```
Just a note - the minimum of 2 was added in https://github.com/python-pillow/Pillow/pull/537/commits/6457eed2cbedfd785df8579933d0ced0f6729df2
I've created #5330 to resolve this.

Resolving patch:
diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py
--- a/src/PIL/PngImagePlugin.py
+++ b/src/PIL/PngImagePlugin.py
@@ -1186,23 +1186,21 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False):
         # attempt to minimize storage requirements for palette images
         if "bits" in im.encoderinfo:
             # number of bits specified by user
-            colors = 1 << im.encoderinfo["bits"]
+            colors = min(1 << im.encoderinfo["bits"], 256)
         else:
             # check palette contents
             if im.palette:
-                colors = max(min(len(im.palette.getdata()[1]) // 3, 256), 2)
+                colors = max(min(len(im.palette.getdata()[1]) // 3, 256), 1)
             else:
                 colors = 256
 
-        if colors <= 2:
-            bits = 1
-        elif colors <= 4:
-            bits = 2
-        elif colors <= 16:
-            bits = 4
-        else:
-            bits = 8
-        if bits != 8:
+        if colors <= 16:
+            if colors <= 2:
+                bits = 1
+            elif colors <= 4:
+                bits = 2
+            else:
+                bits = 4
             mode = f"{mode};{bits}"
 
     # encoder options
@@ -1270,7 +1268,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False):
                     chunk(fp, cid, data)
 
     if im.mode == "P":
-        palette_byte_number = (2 ** bits) * 3
+        palette_byte_number = colors * 3
         palette_bytes = im.im.getpalette("RGB")[:palette_byte_number]
         while len(palette_bytes) < palette_byte_number:
             palette_bytes += b"\0"
@@ -1281,7 +1279,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False):
     if transparency or transparency == 0:
         if im.mode == "P":
             # limit to actual palette size
-            alpha_bytes = 2 ** bits
+            alpha_bytes = colors
             if isinstance(transparency, bytes):
                 chunk(fp, b"tRNS", transparency[:alpha_bytes])
             else:
@@ -1302,7 +1300,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False):
     else:
         if im.mode == "P" and im.im.getpalettemode() == "RGBA":
             alpha = im.im.getpalette("RGBA", "A")
-            alpha_bytes = 2 ** bits
+            alpha_bytes = colors
             chunk(fp, b"tRNS", alpha[:alpha_bytes])
 
     dpi = im.encoderinfo.get("dpi")


Test patch:
diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py
--- a/Tests/test_file_png.py
+++ b/Tests/test_file_png.py
@@ -634,6 +634,16 @@ def test_specify_bits(self, tmp_path):
         with Image.open(out) as reloaded:
             assert len(reloaded.png.im_palette[1]) == 48
 
+    def test_plte_length(self, tmp_path):
+        im = Image.new("P", (1, 1))
+        im.putpalette((1, 1, 1))
+
+        out = str(tmp_path / "temp.png")
+        im.save(str(tmp_path / "temp.png"))
+
+        with Image.open(out) as reloaded:
+            assert len(reloaded.png.im_palette[1]) == 3
+
     def test_exif(self):
         # With an EXIF chunk
         with Image.open("Tests/images/exif.png") as im: