diff --git a/Tests/images/fujifilm.mpo b/Tests/images/fujifilm.mpo new file mode 100644 index 00000000000..ff0deb8a676 Binary files /dev/null and b/Tests/images/fujifilm.mpo differ diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index aab13ce388e..ce1ca96b672 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -64,6 +64,18 @@ def test_frame_size(self): im.seek(1) self.assertEqual(im.size, (680, 480)) + def test_parallax(self): + # Nintendo + im = Image.open("Tests/images/sugarshack.mpo") + exif = im.getexif() + self.assertEqual(exif.get_ifd(0x927c)[0x1101]["Parallax"], -44.798187255859375) + + # Fujifilm + im = Image.open("Tests/images/fujifilm.mpo") + im.seek(1) + exif = im.getexif() + self.assertEqual(exif.get_ifd(0x927c)[0xb211], -3.125) + def test_mp(self): for test_file in test_files: im = Image.open(test_file) diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index 5853fb28f85..e2339d76de0 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 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,97 @@ def test_no_format(self): 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") + 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', + }) + + def test_exif_interop(self): + im = Image.open("Tests/images/flower.jpg") + exif = im.getexif() + self.assertEqual(exif.get_ifd(0xa005), { + 1: 'R98', + 2: b'0100', + 4097: 2272, + 4098: 1704, + }) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 06e7b76baf9..99cb7ec373c 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -27,11 +27,20 @@ # See the README file for information on usage and redistribution. # -from . import Image -from ._util import isPath +from . import Image, TiffTags +from ._binary import i32le +from ._util import isPath, py3 import io import sys import struct +import warnings + +try: + # Python 3 + from collections.abc import MutableMapping +except ImportError: + # Python 2.7 + from collections import MutableMapping MAXBLOCK = 65536 @@ -293,6 +302,12 @@ def _seek_check(self, frame): 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 +687,182 @@ def set_as_raw(self, data, rawmode=None): raise ValueError("not enough image data") if s[1] != 0: raise ValueError("cannot decode image data") + + +class Exif(MutableMapping): + endian = "<" + + def __init__(self): + self._data = {} + self._ifds = {} + + 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 _get_ifd_dict(self, tag): + try: + # an offset pointer to the location of the nested embedded IFD. + # It should be a long, but may be corrupted. + self.fp.seek(self._data[tag]) + except (KeyError, TypeError): + pass + else: + from . import TiffImagePlugin + info = TiffImagePlugin.ImageFileDirectory_v1(self.head) + info.load(self.fp) + return self._fixup_dict(info) + + 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 (!). + self.fp = io.BytesIO(data[6:]) + self.head = self.fp.read(8) + # process dictionary + from . import TiffImagePlugin + info = TiffImagePlugin.ImageFileDirectory_v1(self.head) + self.endian = info._endian + self.fp.seek(info.next) + info.load(self.fp) + self._data = dict(self._fixup_dict(info)) + + # get EXIF extension + ifd = self._get_ifd_dict(0x8769) + if ifd: + self._data.update(ifd) + self._ifds[0x8769] = ifd + + # get gpsinfo extension + ifd = self._get_ifd_dict(0x8825) + if ifd: + self._data[0x8825] = ifd + self._ifds[0x8825] = ifd + + 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 get_ifd(self, tag): + if tag not in self._ifds and tag in self._data: + if tag == 0xa005: # interop + self._ifds[tag] = self._get_ifd_dict(tag) + elif tag == 0x927c: # makernote + from . import TiffImagePlugin + if self._data[0x927c][:8] == b"FUJIFILM": + exif_data = self._data[0x927c] + ifd_offset = i32le(exif_data[8:12]) + ifd_data = exif_data[ifd_offset:] + + makernote = {} + for i in range(0, struct.unpack(" 4: + offset, = struct.unpack("H", ifd_data[:2])[0]): + ifd_tag, typ, count, data = struct.unpack( + ">HHL4s", ifd_data[i*12 + 2:(i+1)*12 + 2]) + if ifd_tag == 0x1101: + # CameraInfo + offset, = struct.unpack(">L", data) + self.fp.seek(offset) + + camerainfo = {'ModelID': self.fp.read(4)} + + self.fp.read(4) + # Seconds since 2000 + camerainfo['TimeStamp'] = i32le(self.fp.read(12)) + + self.fp.read(4) + camerainfo['InternalSerialNumber'] = self.fp.read(4) + + self.fp.read(12) + parallax = self.fp.read(4) + handler =\ + TiffImagePlugin.ImageFileDirectory_v2._load_dispatch[ + TiffTags.FLOAT + ][1] + camerainfo['Parallax'] = handler( + TiffImagePlugin.ImageFileDirectory_v2(), + parallax, False) + + self.fp.read(4) + camerainfo['Category'] = self.fp.read(2) + + makernote = {0x1101: dict(self._fixup_dict(camerainfo))} + self._ifds[0x927c] = makernote + return self._ifds.get(tag, {}) + + 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 541b84ee803..946006c758d 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -86,7 +86,7 @@ def APP(self, marker): self.info["jfif_density"] = jfif_density elif marker == 0xFFE1 and s[:5] == b"Exif\0": if "exif" not in self.info: - # extract Exif information (incomplete) + # extract EXIF information (incomplete) self.info["exif"] = s # FIXME: value will change elif marker == 0xFFE2 and s[:5] == b"FPXR\0": # extract FlashPix information (incomplete) @@ -168,7 +168,7 @@ def APP(self, marker): dpi *= 2.54 self.info["dpi"] = dpi, dpi except (KeyError, SyntaxError, ZeroDivisionError): - # SyntaxError for invalid/unreadable exif + # SyntaxError for invalid/unreadable EXIF # KeyError for dpi not included # ZeroDivisionError for invalid dpi rational value self.info["dpi"] = 72, 72 @@ -472,65 +472,20 @@ def _getmp(self): 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 validate_qtables(qtables): 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 validate_qtables(qtables): subsampling, qtables, extra, - info.get("exif", b"") + exif ) # if we optimize, libjpeg needs a buffer big enough to hold the whole image @@ -798,9 +757,9 @@ def validate_qtables(qtables): else: bufsize = im.size[0] * im.size[1] - # The exif info needs to be written as one block, + APP1, + one spare byte. + # 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 bba7c103886..588cee02f3b 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -696,8 +696,14 @@ def load_end(self): 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 34c6f39defc..77688e56bcb 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -785,17 +785,12 @@ def load(self, fp): 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 @@ def save(self, fp): 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 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)) - return offset + 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 25557326bfc..4a11a18c136 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -93,8 +93,9 @@ def _open(self): 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: