Repository: python-pillow/Pillow

Issue:
Allow non-standard chunks to be saved in PNG files
### What did you do?
I'm making a simple tool that's modifying sprites for ZDoom source port of Doom video game. It uses non-standard PNG chunks to store additional information, eg sprite offsets.

```python
import struct
from PIL import Image
from PIL.PngImagePlugin import PngInfo

img = Image.open('src.png')
pnginfo = PngInfo()
pnginfo.add(b'grAb', struct.pack('>II', 10, 49))
img.save('dst.png', pnginfo=pnginfo)
```

### What did you expect to happen?
To see a 'grAb' chunk inside saved PNG file (like source image has)

### What actually happened?
Chunk have not been saved because there is a forced list of chunks that's being saved inside `src/PIL/PngImagePlugin.py`

### What are your OS, Python and Pillow versions?

* OS: GNU/Linux
* Python: 3.8.0
* Pillow: 6.2.1

As far as I understand PNG specification doesn't strictly forbid saving unknown chunks with "Safe-to-copy bit" set to 0. PIL is great tool for image manipulation automation, I think it would be great to give developers more ways of using it. Thanks!


Discussion:

Resolving patch:
diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py
--- a/src/PIL/PngImagePlugin.py
+++ b/src/PIL/PngImagePlugin.py
@@ -228,15 +228,20 @@ class PngInfo:
     def __init__(self):
         self.chunks = []
 
-    def add(self, cid, data):
+    def add(self, cid, data, after_idat=False):
         """Appends an arbitrary chunk. Use with caution.
 
         :param cid: a byte string, 4 bytes long.
         :param data: a byte string of the encoded data
+        :param after_idat: for use with private chunks. Whether the chunk
+                           should be written after IDAT
 
         """
 
-        self.chunks.append((cid, data))
+        chunk = [cid, data]
+        if after_idat:
+            chunk.append(True)
+        self.chunks.append(tuple(chunk))
 
     def add_itxt(self, key, value, lang="", tkey="", zip=False):
         """Appends an iTXt chunk.
@@ -641,6 +646,7 @@ def _open(self):
         #
         # Parse headers up to the first IDAT or fDAT chunk
 
+        self.private_chunks = []
         self.png = PngStream(self.fp)
 
         while True:
@@ -657,6 +663,8 @@ def _open(self):
             except AttributeError:
                 logger.debug("%r %s %s (unknown)", cid, pos, length)
                 s = ImageFile._safe_read(self.fp, length)
+                if cid[1:2].islower():
+                    self.private_chunks.append((cid, s))
 
             self.png.crc(cid, s)
 
@@ -888,7 +896,9 @@ def load_end(self):
                 ImageFile._safe_read(self.fp, length)
             except AttributeError:
                 logger.debug("%r %s %s (unknown)", cid, pos, length)
-                ImageFile._safe_read(self.fp, length)
+                s = ImageFile._safe_read(self.fp, length)
+                if cid[1:2].islower():
+                    self.private_chunks.append((cid, s, True))
         self._text = self.png.im_text
         if not self.is_animated:
             self.png.close()
@@ -1217,12 +1227,18 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False):
     info = im.encoderinfo.get("pnginfo")
     if info:
         chunks_multiple_allowed = [b"sPLT", b"iTXt", b"tEXt", b"zTXt"]
-        for cid, data in info.chunks:
+        for info_chunk in info.chunks:
+            cid, data = info_chunk[:2]
             if cid in chunks:
                 chunks.remove(cid)
                 chunk(fp, cid, data)
             elif cid in chunks_multiple_allowed:
                 chunk(fp, cid, data)
+            elif cid[1:2].islower():
+                # Private chunk
+                after_idat = info_chunk[2:3]
+                if not after_idat:
+                    chunk(fp, cid, data)
 
     if im.mode == "P":
         palette_byte_number = (2 ** bits) * 3
@@ -1272,7 +1288,8 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False):
 
     if info:
         chunks = [b"bKGD", b"hIST"]
-        for cid, data in info.chunks:
+        for info_chunk in info.chunks:
+            cid, data = info_chunk[:2]
             if cid in chunks:
                 chunks.remove(cid)
                 chunk(fp, cid, data)
@@ -1290,6 +1307,15 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False):
     else:
         ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)])
 
+    if info:
+        for info_chunk in info.chunks:
+            cid, data = info_chunk[:2]
+            if cid[1:2].islower():
+                # Private chunk
+                after_idat = info_chunk[2:3]
+                if after_idat:
+                    chunk(fp, cid, data)
+
     chunk(fp, b"IEND", b"")
 
     if hasattr(fp, "flush"):


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
@@ -564,6 +564,28 @@ def test_getchunks(self):
         chunks = PngImagePlugin.getchunks(im)
         assert len(chunks) == 3
 
+    def test_read_private_chunks(self):
+        im = Image.open("Tests/images/exif.png")
+        assert im.private_chunks == [(b"orNT", b"\x01")]
+
+    def test_roundtrip_private_chunk(self):
+        # Check private chunk roundtripping
+
+        with Image.open(TEST_PNG_FILE) as im:
+            info = PngImagePlugin.PngInfo()
+            info.add(b"prIV", b"VALUE")
+            info.add(b"atEC", b"VALUE2")
+            info.add(b"prIV", b"VALUE3", True)
+
+            im = roundtrip(im, pnginfo=info)
+        assert im.private_chunks == [(b"prIV", b"VALUE"), (b"atEC", b"VALUE2")]
+        im.load()
+        assert im.private_chunks == [
+            (b"prIV", b"VALUE"),
+            (b"atEC", b"VALUE2"),
+            (b"prIV", b"VALUE3", True),
+        ]
+
     def test_textual_chunks_after_idat(self):
         with Image.open("Tests/images/hopper.png") as im:
             assert "comment" in im.text.keys()