Repository: python-pillow/Pillow

Issue:
Image.open not populating Image.text attribute for PNG files
[Documentation for the PNG file format](http://pillow.readthedocs.io/en/latest/handbook/image-file-formats.html#png) states that the `Image.open()` function populates the `text` attribute of the image object with strings from the tEXt, zTxT, and other chunks (it says as a 'list' but it seems to be a 'dict').  This does not actually happen: the `text` attribute is empty.

Here is a test case which creates a simple png file, uses ImageMagick's `convert` utility to add tEXt key, value pairs then uses PIL to read in that png file.  Even though the tEXt key value pairs are there and can be displayed by the ImageMagick `identify` utility and the Python pypng package, PIL displays an empty dict for the `text` attribute.

```python
from subprocess import call
from PIL import Image
import numpy as np

pixels = np.array([[[255, 0, 0], [0, 255, 0]], [[0, 0, 255], [255, 255, 0]]])
im = Image.fromarray(pixels.astype('uint8'), 'RGB')
im.save("test.png")

call(["convert", "test.png", "-set", "newkey", "newval", "test2.png"])
i = Image.open('test2.png')
print(i.text)
```
This is with Python 2.7.8 and pillow 4.3.0


Discussion:
They're not read because the metadata appears after the first IDAT chunk.

test2.png's raw data:
```
âPNG


IHDRÄògAMA±è¸a cHRMz&ÄÑ˙ÄËu0Í`:òpú∫Q<PLTEˇˇˇˇˇˇˇˇıNùœbKGDèhŸQtIME·!3iq“âIDAT◊c`dP*%”î‡Ñ%tEXtdate:create2017-12-27T10:33:51+02:00åÚ≈•%tEXtdate:modify2017-12-27T10:33:51+02:00˝Ø}
tEXtnewkeynewval⁄ÏqIENDÆB`Ç
```


Enabling logging (eg. https://stackoverflow.com/a/28194953/724176) shows it's stopping at IDAT and not getting to the tEXt chunk:
```
DEBUG:PIL.PngImagePlugin:STREAM b'IHDR' 16 13
DEBUG:PIL.PngImagePlugin:STREAM b'gAMA' 41 4
DEBUG:PIL.PngImagePlugin:STREAM b'cHRM' 57 32
DEBUG:PIL.PngImagePlugin:b'cHRM' 57 32 (unknown)
DEBUG:PIL.PngImagePlugin:STREAM b'PLTE' 101 15
DEBUG:PIL.PngImagePlugin:STREAM b'bKGD' 128 1
DEBUG:PIL.PngImagePlugin:b'bKGD' 128 1 (unknown)
DEBUG:PIL.PngImagePlugin:STREAM b'tIME' 141 7
DEBUG:PIL.PngImagePlugin:b'tIME' 141 7 (unknown)
DEBUG:PIL.PngImagePlugin:STREAM b'IDAT' 160 12
{}
```

`PngImageFile._open` says: `# Parse headers up to the first IDAT chunk`, and indeed it does stop at IDAT:

https://github.com/python-pillow/Pillow/blob/d42b57cf1e0f4d849d0ad591dc2209acc7c9ecaa/PIL/PngImagePlugin.py#L348-L353

---

This code (adapted from a unit test) creates metadata using Pillow, and reads it back:

```python
from PIL import Image, PngImagePlugin

info = PngImagePlugin.PngInfo()
info.add_text("TXT", "VALUE")
info.add_text("ZIP", "VALUE", zip=True)

im.save("test3.png", "PNG", pnginfo=info)
im3 = Image.open("test3.png")
print(im3.info)
print(im3.text)
```
It has the metadata before IDAT:
```
âPNG


IHDR˝‘ös	tEXtTXTVALUE≤¿—«zTXtZIPxúsÙ	uä~ír>IDATxú¡ÄˇOBPÔ¸§tÛIENDÆB`Ç
```
And logs:
```
DEBUG:PIL.PngImagePlugin:STREAM b'IHDR' 16 13
DEBUG:PIL.PngImagePlugin:STREAM b'tEXt' 41 9
DEBUG:PIL.PngImagePlugin:STREAM b'zTXt' 62 18
DEBUG:PIL.PngImagePlugin:STREAM b'IDAT' 92 21
{'TXT': 'VALUE', 'ZIP': 'VALUE'}
{'TXT': 'VALUE', 'ZIP': 'VALUE'}
```

---

test2.png: <img src=https://user-images.githubusercontent.com/1324225/34378406-56af94ce-eafe-11e7-8870-65d089eb7f58.png width=10 height=10>

test3.png: <img src=https://user-images.githubusercontent.com/1324225/34378427-6ce163ee-eafe-11e7-952b-8e0f8415a5ba.png width=10 height=10>

But the PNG spec says text chunks can be before IDAT.
```
   Critical chunks (must appear in this order, except PLTE
                    is optional):
   
           Name  Multiple  Ordering constraints
                   OK?
   
           IHDR    No      Must be first
           PLTE    No      Before IDAT
           IDAT    Yes     Multiple IDATs must be consecutive
           IEND    No      Must be last
   
   Ancillary chunks (need not appear in this order):
   
           Name  Multiple  Ordering constraints
                   OK?
   
           cHRM    No      Before PLTE and IDAT
           gAMA    No      Before PLTE and IDAT
           iCCP    No      Before PLTE and IDAT
           sBIT    No      Before PLTE and IDAT
           sRGB    No      Before PLTE and IDAT
           bKGD    No      After PLTE; before IDAT
           hIST    No      After PLTE; before IDAT
           tRNS    No      After PLTE; before IDAT
           pHYs    No      Before IDAT
           sPLT    Yes     Before IDAT
           tIME    No      None
           iTXt    Yes     None
           tEXt    Yes     None
           zTXt    Yes     None
```
Text chunks were added fairly recently, before that it looked a fair assumption that IDAT comes last.
Thanks for checking the PNG spec.  So this is a bonafide bug.  I played around with a patch to `chunk_IDAT()` in PngImagePlugin.py.  I think it is a good fix.  However, it breaks 10 or so test cases in the test suite.  They are all tests for reading corrupt/truncated PNG files.  Fixing up these tests is a lot of work so I'm taking a break.  

I imagine there are PIL users who rely on it to produce reasonable results when reading truncated files so I wouldn't want to ignore these tests.  I'll see if my C++ PNG writer can put the text chunks before the IDAT chunk.  Here is my patch.

```
@@ -348,9 +349,10 @@ class PngStream(ChunkStream):
     def chunk_IDAT(self, pos, length):
 
         # image data
-        self.im_tile = [("zip", (0, 0)+self.im_size, pos, self.im_rawmode)]
-        self.im_idat = length
-        raise EOFError
+        if not self.im_tile:
+            self.im_tile = [("zip", (0, 0)+self.im_size, pos, self.im_rawmode)]
+            self.im_idat = length
+        return ImageFile._safe_read(self.fp, length)
 
     def chunk_IEND(self, pos, length):
```

FWIW, it was a simple modification to lodepng to make it write the text chunks before the IDAT chunk.  Now PIL sees the text chunk data: https://github.com/lvandeve/lodepng/issues/64.

My posted test case uses ImageMagick.  It apparently also writes text chunks after IDAT.  That is the natural way to do it since text chunks are 'ancillary' and often never used by a reader.

Regarding integrating a fix for this bug, my reservations are about not breaking behavior when the LOAD_TRUNCATED_IMAGES flag is set to True.  

After you allow the reader to keep reading after the IDAT chunk you open the code up to a whole range of format errors in chunks after the IDAT chunk.  Ideally, if these problems are with chunks that don't affect the image data then the code should gracefully return an image ... at least when LOAD_TRUNCATED_IMAGES is True.

Maybe there should be a state in the reader that tracks if the IDAT was successfully read/parsed.  Then, if it encounters an error after that and LOAD_TRUNCATED_IMAGES is True, it will ignore the error?

This is a more involved change than the patch I tested.  And it would likely still involve fixing a bunch of regression tests.


Resolving patch:
diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py
--- a/src/PIL/PngImagePlugin.py
+++ b/src/PIL/PngImagePlugin.py
@@ -579,7 +579,7 @@ def _open(self):
         self.mode = self.png.im_mode
         self._size = self.png.im_size
         self.info = self.png.im_info
-        self.text = self.png.im_text  # experimental
+        self._text = None
         self.tile = self.png.im_tile
 
         if self.png.im_palette:
@@ -588,6 +588,15 @@ def _open(self):
 
         self.__idat = length  # used by load_read()
 
+    @property
+    def text(self):
+        # experimental
+        if self._text is None:
+            # iTxt, tEXt and zTXt chunks may appear at the end of the file
+            # So load the file to ensure that they are read
+            self.load()
+        return self._text
+
     def verify(self):
         "Verify PNG file"
 
@@ -640,7 +649,22 @@ def load_read(self, read_bytes):
 
     def load_end(self):
         "internal: finished reading image data"
+        while True:
+            self.fp.read(4)  # CRC
+
+            try:
+                cid, pos, length = self.png.read()
+            except (struct.error, SyntaxError):
+                break
+
+            if cid == b"IEND":
+                break
 
+            try:
+                self.png.call(cid, pos, length)
+            except UnicodeDecodeError:
+                break
+        self._text = self.png.im_text
         self.png.close()
         self.png = None
 


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
@@ -557,6 +557,28 @@ def test_getchunks(self):
         chunks = PngImagePlugin.getchunks(im)
         self.assertEqual(len(chunks), 3)
 
+    def test_textual_chunks_after_idat(self):
+        im = Image.open("Tests/images/hopper.png")
+        self.assertIn('comment', im.text.keys())
+        for k, v in {
+            'date:create': '2014-09-04T09:37:08+03:00',
+            'date:modify': '2014-09-04T09:37:08+03:00',
+        }.items():
+            self.assertEqual(im.text[k], v)
+
+        # Raises a SyntaxError in load_end
+        im = Image.open("Tests/images/broken_data_stream.png")
+        with self.assertRaises(IOError):
+            self.assertIsInstance(im.text, dict)
+
+        # Raises a UnicodeDecodeError in load_end
+        im = Image.open("Tests/images/truncated_image.png")
+        # The file is truncated
+        self.assertRaises(IOError, lambda: im.text)
+        ImageFile.LOAD_TRUNCATED_IMAGES = True
+        self.assertIsInstance(im.text, dict)
+        ImageFile.LOAD_TRUNCATED_IMAGES = False
+
 
 @unittest.skipIf(sys.platform.startswith('win32'), "requires Unix or macOS")
 class TestTruncatedPngPLeaks(PillowLeakTestCase):