Repository: python-pillow/Pillow

Issue:
PngStream does not decode EXIF data from "Raw profile" tags
<!--
Thank you for reporting an issue.

Follow these guidelines to ensure your issue is handled properly.

If you have a ...

1.  General question: consider asking the question on Stack Overflow
    with the python-imaging-library tag:

    * https://stackoverflow.com/questions/tagged/python-imaging-library

    Do not ask a question in both places.

    If you think you have found a bug or have an unexplained exception
    then file a bug report here.

2.  Bug report: include a self-contained, copy-pastable example that
    generates the issue if possible. Be concise with code posted.
    Guidelines on how to provide a good bug report:

    * https://stackoverflow.com/help/mcve

    Bug reports which follow these guidelines are easier to diagnose,
    and are often handled much more quickly.

3.  Feature request: do a quick search of existing issues
    to make sure this has not been asked before.

We know asking good questions takes effort, and we appreciate your time.
Thank you.
-->

### What did you do?

I attempted to read the meta-data from one of the test images from Exiv2 ("ReaganSmallPng.png"). This file contains EXIF and IPTC meta-data stored under compressed "Raw profile type" tags, typical of ImageMagick (and possibly other software).

### What did you expect to happen?

- The Image's info["exif"] contains the EXIF data, and getexif() returns an object containing some or all of the meta-data, **or**
- The Image's info["Raw profile type exif"] contains the EXIF data, which can be manually fed back into info["exif"]

### What actually happened?

The Image's info["exif"] was empty, and getexif() returned no metadata. The "Raw profile type exif" was read, but was not suitable to be used as info["exif"]. (Attempting this produces a SyntaxError in the TiffImagePlugin, which I haven't investigated further.) I tried modifying PngStream.chunk_zTXt in "PngImagePlugin.py" to skip decoding the bytes as "latin-1", but I still got the same SyntaxError.

```
Found Exif tags: 0
Traceback (most recent call last):
  File "pil_err.py", line 9, in <module>
    exif = reagan.getexif()
  File "/usr/lib64/python3.6/site-packages/PIL/PngImagePlugin.py", line 704, in getexif
    return ImageFile.ImageFile.getexif(self)
  File "/usr/lib64/python3.6/site-packages/PIL/Image.py", line 1275, in getexif
    self._exif.load(self.info.get("exif"))
  File "/usr/lib64/python3.6/site-packages/PIL/Image.py", line 3237, in load
    self._info = TiffImagePlugin.ImageFileDirectory_v1(self.head)
  File "/usr/lib64/python3.6/site-packages/PIL/TiffImagePlugin.py", line 900, in __init__
    super().__init__(*args, **kwargs)
  File "/usr/lib64/python3.6/site-packages/PIL/TiffImagePlugin.py", line 466, in __init__
    raise SyntaxError("not a TIFF file (header %r not valid)" % ifh)
SyntaxError: not a TIFF file (header b'    8526' not valid)
```

### What are your OS, Python and Pillow versions?

* OS: Gentoo Base System release 2.6, Linux kernel 4.19.97-gentoo x86_64
* Python: Python 3.6.10
* Pillow: Pillow 6.2.2 (from Gentoo), Pillow 7.0.0 (from Gentoo)

<!--
Please include **code** that reproduces the issue and whenever possible, an **image** that demonstrates the issue. Please upload images to GitHub, not to third-party file hosting sites. If necessary, add the image to a zip or tar archive.

The best reproductions are self-contained scripts with minimal dependencies. If you are using a framework such as Plone, Django, or Buildout, try to replicate the issue just using Pillow.
-->

```python
import PIL.Image

reagan = PIL.Image.open("ReaganSmallPng.png")
exif = reagan.getexif()
print("Found Exif tags:", len(exif.keys()))

reagan.info["exif"] = reagan.info['Raw profile type exif'].encode('latin-1')
exif = reagan.getexif()
print("Found Exif tags:", len(exif.keys()))
```

### Image causing the issue
![ReaganSmallPng](https://raw.githubusercontent.com/Exiv2/exiv2/master/test/data/ReaganSmallPng.png)

### Additional information
ImageMagick's PNG encoder:
https://github.com/ImageMagick/ImageMagick/blob/master/coders/png.c

ExifTool's guide to PNG Tags:
https://exiftool.org/TagNames/PNG.html


Discussion:
I found https://sourceforge.net/p/png-mng/mailman/png-mng-misc/?viewmonth=201612&viewday=31 which pointed out that the data is hexencoded. So I find that this gives me 55 EXIF keys.

```python
from PIL import Image

reagan = Image.open("ReaganSmallPng.png")
exif = reagan.getexif()
print("Found Exif tags:", len(exif.keys()))

reagan.info["exif"] = bytes.fromhex("".join(reagan.info['Raw profile type exif'].split("\n")[3:]))
exif = reagan.getexif()
print("Found Exif tags:", len(exif.keys()))
```
Thanks, that's what I was missing!

With that as a "work-around", I think that this issue could be classified as a feature-request rather than a bug.
Looking into this, ImageMagick implemented "Raw profile type exif" before Exif became part of the PNG standard - https://ask.xiaolee.net/questions/1122297. It doesn't write both - https://github.com/ImageMagick/ImageMagick/blob/868aad754ee599eb7153b84d610f2ecdf7b339f6/coders/png.c#L11068-L11071. So it seems reasonable to alter PngImagePlugin to read this as well.

However, this means it is slightly harder to generate an image with a "Raw profile type exif", since ImageMagick hasn't written it in two years. Would you happen to have an image that could be included in the test suite under the Pillow license?
I managed to create a file, and have created PR #4471 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
@@ -694,14 +694,24 @@ def load_end(self):
     def _getexif(self):
         if "exif" not in self.info:
             self.load()
-        if "exif" not in self.info:
+        if "exif" not in self.info and "Raw profile type exif" not in self.info:
             return None
         return dict(self.getexif())
 
     def getexif(self):
         if "exif" not in self.info:
             self.load()
-        return ImageFile.ImageFile.getexif(self)
+
+        if self._exif is None:
+            self._exif = Image.Exif()
+
+        exif_info = self.info.get("exif")
+        if exif_info is None and "Raw profile type exif" in self.info:
+            exif_info = bytes.fromhex(
+                "".join(self.info["Raw profile type exif"].split("\n")[3:])
+            )
+        self._exif.load(exif_info)
+        return self._exif
 
 
 # --------------------------------------------------------------------


Test patch:
diff --git a/Tests/images/exif_imagemagick.png b/Tests/images/exif_imagemagick.png
new file mode 100644
Binary files /dev/null and b/Tests/images/exif_imagemagick.png differ
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
@@ -592,8 +592,15 @@ def test_textual_chunks_after_idat(self):
         with Image.open("Tests/images/hopper_idat_after_image_end.png") as im:
             assert im.text == {"TXT": "VALUE", "ZIP": "VALUE"}
 
-    def test_exif(self):
-        with Image.open("Tests/images/exif.png") as im:
+    @pytest.mark.parametrize(
+        "test_file",
+        [
+            "Tests/images/exif.png",  # With an EXIF chunk
+            "Tests/images/exif_imagemagick.png",  # With an ImageMagick zTXt chunk
+        ],
+    )
+    def test_exif(self, test_file):
+        with Image.open(test_file) as im:
             exif = im._getexif()
         assert exif[274] == 1