diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index 5853fb28f..bf944ae5b 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -1,4 +1,4 @@ -from .helper import PillowTestCase, hopper, fromstring, tostring +from .helper import unittest, PillowTestCase, hopper, fromstring, tostring from io import BytesIO @@ -6,6 +6,12 @@ from PIL import Image from PIL import ImageFile from PIL import EpsImagePlugin +try: + from PIL import _webp + HAVE_WEBP = True +except ImportError: + HAVE_WEBP = False + codecs = dir(Image.core) @@ -233,3 +239,87 @@ class TestPyDecoder(PillowTestCase): im = MockImageFile(buf) self.assertIsNone(im.format) self.assertIsNone(im.get_format_mimetype()) + + def test_exif_jpeg(self): + im = Image.open("Tests/images/exif-72dpi-int.jpg") # Little endian + exif = im.getexif() + self.assertNotIn(258, exif) + self.assertIn(40960, exif) + self.assertEqual(exif[40963], 450) + self.assertEqual(exif[11], "gThumb 3.0.1") + + out = self.tempfile('temp.jpg') + exif[258] = 8 + del exif[40960] + exif[40963] = 455 + exif[11] = "Pillow test" + im.save(out, exif=exif) + reloaded = Image.open(out) + reloaded_exif = reloaded.getexif() + self.assertEqual(reloaded_exif[258], 8) + self.assertNotIn(40960, exif) + self.assertEqual(reloaded_exif[40963], 455) + self.assertEqual(exif[11], "Pillow test") + + im = Image.open("Tests/images/no-dpi-in-exif.jpg") # Big endian + exif = im.getexif() + self.assertNotIn(258, exif) + self.assertIn(40962, exif) + self.assertEqual(exif[40963], 200) + self.assertEqual(exif[305], "Adobe Photoshop CC 2017 (Macintosh)") + + out = self.tempfile('temp.jpg') + exif[258] = 8 + del exif[34665] + exif[40963] = 455 + exif[305] = "Pillow test" + im.save(out, exif=exif) + reloaded = Image.open(out) + reloaded_exif = reloaded.getexif() + self.assertEqual(reloaded_exif[258], 8) + self.assertNotIn(40960, exif) + self.assertEqual(reloaded_exif[40963], 455) + self.assertEqual(exif[305], "Pillow test") + + @unittest.skipIf(not HAVE_WEBP or not _webp.HAVE_WEBPANIM, + "WebP support not installed with animation") + def test_exif_webp(self): + im = Image.open("Tests/images/hopper.webp") # WebP + exif = im.getexif() + self.assertEqual(exif, {}) + + out = self.tempfile('temp.webp') + exif[258] = 8 + exif[40963] = 455 + exif[305] = "Pillow test" + + def check_exif(): + reloaded = Image.open(out) + reloaded_exif = reloaded.getexif() + self.assertEqual(reloaded_exif[258], 8) + self.assertEqual(reloaded_exif[40963], 455) + self.assertEqual(exif[305], "Pillow test") + im.save(out, exif=exif) + check_exif() + im.save(out, exif=exif, save_all=True) + check_exif() + + def test_exif_png(self): + im = Image.open("Tests/images/exif.png") + exif = im.getexif() + self.assertEqual(exif, {274: 1}) + + out = self.tempfile('temp.png') + exif[258] = 8 + del exif[274] + exif[40963] = 455 + exif[305] = "Pillow test" + im.save(out, exif=exif) + + reloaded = Image.open(out) + reloaded_exif = reloaded.getexif() + self.assertEqual(reloaded_exif, { + 258: 8, + 40963: 455, + 305: 'Pillow test' + }) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 06e7b76ba..d6e59007a 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -28,11 +28,18 @@ # from . import Image -from ._util import isPath +from ._util import isPath, py3 import io import sys import struct +try: + # Python 3 + from collections.abc import MutableMapping +except ImportError: + # Python 2.7 + from collections import MutableMapping + MAXBLOCK = 65536 SAFEBLOCK = 1024*1024 @@ -293,6 +300,12 @@ class ImageFile(Image.Image): return self.tell() != frame + def getexif(self): + exif = Exif() + if "exif" in self.info: + exif.load(self.info["exif"]) + return exif + class StubImageFile(ImageFile): """ @@ -672,3 +685,98 @@ class PyDecoder(object): raise ValueError("not enough image data") if s[1] != 0: raise ValueError("cannot decode image data") + + +class Exif(MutableMapping): + _data = {} + endian = "<" + + def _fixup_dict(self, src_dict): + # Helper function for _getexif() + # returns a dict with any single item tuples/lists as individual values + def _fixup(value): + try: + if len(value) == 1 and not isinstance(value, dict): + return value[0] + except Exception: + pass + return value + + return {k: _fixup(v) for k, v in src_dict.items()} + + def load(self, data): + # Extract EXIF information. This is highly experimental, + # and is likely to be replaced with something better in a future + # version. + + # The EXIF record consists of a TIFF file embedded in a JPEG + # application marker (!). + fp = io.BytesIO(data[6:]) + head = fp.read(8) + # process dictionary + from . import TiffImagePlugin + info = TiffImagePlugin.ImageFileDirectory_v1(head) + self.endian = info._endian + fp.seek(info.next) + info.load(fp) + self._data = dict(self._fixup_dict(info)) + # get exif extension + try: + # exif field 0x8769 is an offset pointer to the location + # of the nested embedded exif ifd. + # It should be a long, but may be corrupted. + fp.seek(self._data[0x8769]) + except (KeyError, TypeError): + pass + else: + info = TiffImagePlugin.ImageFileDirectory_v1(head) + info.load(fp) + self._data.update(self._fixup_dict(info)) + # get gpsinfo extension + try: + # exif field 0x8825 is an offset pointer to the location + # of the nested embedded gps exif ifd. + # It should be a long, but may be corrupted. + fp.seek(self._data[0x8825]) + except (KeyError, TypeError): + pass + else: + info = TiffImagePlugin.ImageFileDirectory_v1(head) + info.load(fp) + self._data[0x8825] = self._fixup_dict(info) + + def toBytes(self, offset=0): + from . import TiffImagePlugin + if self.endian == "<": + head = b"II\x2A\x00\x08\x00\x00\x00" + else: + head = b"MM\x00\x2A\x00\x00\x00\x08" + ifd = TiffImagePlugin.ImageFileDirectory_v2(ifh=head) + for tag, value in self._data.items(): + ifd[tag] = value + return b"Exif\x00\x00"+head+ifd.toBytes(offset) + + def __str__(self): + return str(self._data) + + def __len__(self): + return len(self._data) + + def __getitem__(self, tag): + return self._data[tag] + + def __contains__(self, tag): + return tag in self._data + + if not py3: + def has_key(self, tag): + return tag in self + + def __setitem__(self, tag, value): + self._data[tag] = value + + def __delitem__(self, tag): + del self._data[tag] + + def __iter__(self): + return iter(set(self._data)) diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 541b84ee8..3592ee4f1 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -472,65 +472,20 @@ class JpegImageFile(ImageFile.ImageFile): def _fixup_dict(src_dict): # Helper function for _getexif() # returns a dict with any single item tuples/lists as individual values - def _fixup(value): - try: - if len(value) == 1 and not isinstance(value, dict): - return value[0] - except Exception: - pass - return value - - return {k: _fixup(v) for k, v in src_dict.items()} + exif = ImageFile.Exif() + return exif._fixup_dict(src_dict) def _getexif(self): - # Extract EXIF information. This method is highly experimental, - # and is likely to be replaced with something better in a future - # version. - # Use the cached version if possible try: return self.info["parsed_exif"] except KeyError: pass - # The EXIF record consists of a TIFF file embedded in a JPEG - # application marker (!). - try: - data = self.info["exif"] - except KeyError: + if "exif" not in self.info: return None - fp = io.BytesIO(data[6:]) - head = fp.read(8) - # process dictionary - info = TiffImagePlugin.ImageFileDirectory_v1(head) - fp.seek(info.next) - info.load(fp) - exif = dict(_fixup_dict(info)) - # get exif extension - try: - # exif field 0x8769 is an offset pointer to the location - # of the nested embedded exif ifd. - # It should be a long, but may be corrupted. - fp.seek(exif[0x8769]) - except (KeyError, TypeError): - pass - else: - info = TiffImagePlugin.ImageFileDirectory_v1(head) - info.load(fp) - exif.update(_fixup_dict(info)) - # get gpsinfo extension - try: - # exif field 0x8825 is an offset pointer to the location - # of the nested embedded gps exif ifd. - # It should be a long, but may be corrupted. - fp.seek(exif[0x8825]) - except (KeyError, TypeError): - pass - else: - info = TiffImagePlugin.ImageFileDirectory_v1(head) - info.load(fp) - exif[0x8825] = _fixup_dict(info) + exif = dict(self.getexif()) # Cache the result for future use self.info["parsed_exif"] = exif @@ -769,6 +724,10 @@ def _save(im, fp, filename): optimize = info.get("optimize", False) + exif = info.get("exif", b"") + if isinstance(exif, ImageFile.Exif): + exif = exif.toBytes() + # get keyword arguments im.encoderconfig = ( quality, @@ -780,7 +739,7 @@ def _save(im, fp, filename): subsampling, qtables, extra, - info.get("exif", b"") + exif ) # if we optimize, libjpeg needs a buffer big enough to hold the whole image @@ -800,7 +759,7 @@ def _save(im, fp, filename): # The exif info needs to be written as one block, + APP1, + one spare byte. # Ensure that our buffer is big enough. Same with the icc_profile block. - bufsize = max(ImageFile.MAXBLOCK, bufsize, len(info.get("exif", b"")) + 5, + bufsize = max(ImageFile.MAXBLOCK, bufsize, len(exif) + 5, len(extra) + 1) ImageFile._save(im, fp, [("jpeg", (0, 0)+im.size, 0, rawmode)], bufsize) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index bba7c1038..9aea1f059 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -696,8 +696,14 @@ class PngImageFile(ImageFile.ImageFile): def _getexif(self): if "exif" not in self.info: self.load() - from .JpegImagePlugin import _getexif - return _getexif(self) + if "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) # -------------------------------------------------------------------- @@ -880,6 +886,8 @@ def _save(im, fp, filename, chunk=putchunk): exif = im.encoderinfo.get("exif", im.info.get("exif")) if exif: + if isinstance(exif, ImageFile.Exif): + exif = exif.toBytes(8) if exif.startswith(b"Exif\x00\x00"): exif = exif[6:] chunk(fp, b"eXIf", exif) diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 34c6f39de..2c703e32c 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -785,17 +785,12 @@ class ImageFileDirectory_v2(MutableMapping): warnings.warn(str(msg)) return - def save(self, fp): - - if fp.tell() == 0: # skip TIFF header on subsequent pages - # tiff header -- PIL always starts the first IFD at offset 8 - fp.write(self._prefix + self._pack("HL", 42, 8)) - + def toBytes(self, offset=0): # FIXME What about tagdata? - fp.write(self._pack("H", len(self._tags_v2))) + result = self._pack("H", len(self._tags_v2)) entries = [] - offset = fp.tell() + len(self._tags_v2) * 12 + 4 + offset = offset + len(result) + len(self._tags_v2) * 12 + 4 stripoffsets = None # pass 1: convert tags to binary format @@ -844,18 +839,29 @@ class ImageFileDirectory_v2(MutableMapping): for tag, typ, count, value, data in entries: if DEBUG > 1: print(tag, typ, count, repr(value), repr(data)) - fp.write(self._pack("HHL4s", tag, typ, count, value)) + result += self._pack("HHL4s", tag, typ, count, value) # -- overwrite here for multi-page -- - fp.write(b"\0\0\0\0") # end of entries + result += b"\0\0\0\0" # end of entries # pass 3: write auxiliary data to file for tag, typ, count, value, data in entries: - fp.write(data) + result += data if len(data) & 1: - fp.write(b"\0") + result += b"\0" - return offset + return result + + def save(self, fp): + + if fp.tell() == 0: # skip TIFF header on subsequent pages + # tiff header -- PIL always starts the first IFD at offset 8 + fp.write(self._prefix + self._pack("HL", 42, 8)) + + offset = fp.tell() + result = self.toBytes(offset) + fp.write(result) + return offset + len(result) ImageFileDirectory_v2._load_dispatch = _load_dispatch diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index 25557326b..6b85da254 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -93,8 +93,9 @@ class WebPImageFile(ImageFile.ImageFile): self.seek(0) def _getexif(self): - from .JpegImagePlugin import _getexif - return _getexif(self) + if "exif" not in self.info: + return None + return dict(self.getexif()) @property def n_frames(self): @@ -216,6 +217,8 @@ def _save_all(im, fp, filename): method = im.encoderinfo.get("method", 0) icc_profile = im.encoderinfo.get("icc_profile", "") exif = im.encoderinfo.get("exif", "") + if isinstance(exif, ImageFile.Exif): + exif = exif.toBytes() xmp = im.encoderinfo.get("xmp", "") if allow_mixed: lossless = False @@ -315,6 +318,8 @@ def _save(im, fp, filename): quality = im.encoderinfo.get("quality", 80) icc_profile = im.encoderinfo.get("icc_profile", "") exif = im.encoderinfo.get("exif", "") + if isinstance(exif, ImageFile.Exif): + exif = exif.toBytes() xmp = im.encoderinfo.get("xmp", "") if im.mode not in _VALID_WEBP_LEGACY_MODES: