diff --git a/PIL/TiffImagePlugin.py b/PIL/TiffImagePlugin.py index 298c48759..b502c5661 100644 --- a/PIL/TiffImagePlugin.py +++ b/PIL/TiffImagePlugin.py @@ -44,18 +44,21 @@ from __future__ import division, print_function from PIL import Image, ImageFile from PIL import ImagePalette from PIL import _binary +from PIL import TiffTags import collections from fractions import Fraction +from numbers import Number, Rational + import io import itertools -from numbers import Number import os import struct import sys import warnings -from .TiffTags import TAGS_V2, TYPES, TagInfo +from .TiffTags import TYPES, TagInfo + __version__ = "1.3.5" DEBUG = False # Needs to be merged with the new logging approach. @@ -215,8 +218,7 @@ def _accept(prefix): def _limit_rational(val, max_val): inv = abs(val) > 1 - f = Fraction.from_float(1 / val if inv else val).limit_denominator(max_val) - n_d = (f.numerator, f.denominator) + n_d = IFDRational(1 / val if inv else val).limit_rational(max_val) return n_d[::-1] if inv else n_d ## @@ -225,6 +227,129 @@ def _limit_rational(val, max_val): _load_dispatch = {} _write_dispatch = {} +class IFDRational(Rational): + """ Implements a rational class where 0/0 is a legal value to match + the in the wild use of exif rationals. + + e.g., DigitalZoomRatio - 0.00/0.00 indicates that no digital zoom was used + """ + + """ If the denominator is 0, store this as a float('nan'), otherwise store + as a fractions.Fraction(). Delegate as appropriate + + """ + + __slots__ = ('_numerator', '_denominator', '_val') + + def __init__(self, value, denominator=1): + """ + :param value: either an integer numerator, a + float/rational/other number, or an IFDRational + :param denominator: Optional integer denominator + """ + self._denominator = denominator + self._numerator = value + self._val = float(1) + + if type(value) == Fraction: + self._numerator = value.numerator + self._denominator = value.denominator + self._val = value + + if type(value) == IFDRational: + self._denominator = value.denominator + self._numerator = value.numerator + self._val = value._val + return + + if denominator == 0: + self._val = float('nan') + return + + + elif denominator == 1: + if sys.hexversion < 0x2070000 and type(value) == float: + # python 2.6 is different. + self._val = Fraction.from_float(value) + else: + self._val = Fraction(value) + else: + self._val = Fraction(value, denominator) + + @property + def numerator(a): + return a._numerator + + @property + def denominator(a): + return a._denominator + + + def limit_rational(self, max_denominator): + """ + + :param max_denominator: Integer, the maximum denominator value + :returns: Tuple of (numerator, denominator) + """ + + if self.denominator == 0: + return (self.numerator, self.denominator) + + f = self._val.limit_denominator(max_denominator) + return (f.numerator, f.denominator) + + def __repr__(self): + return str(float(self._val)) + + def __hash__(self): + return self._val.__hash__() + + def __eq__(self,other): + return self._val == other + + def _delegate(op): + def delegate(self, *args): + return getattr(self._val,op)(*args) + return delegate + + """ a = ['add','radd', 'sub', 'rsub','div', 'rdiv', 'mul', 'rmul', + 'truediv', 'rtruediv', 'floordiv', + 'rfloordiv','mod','rmod', 'pow','rpow', 'pos', 'neg', + 'abs', 'trunc', 'lt', 'gt', 'le', 'ge', 'nonzero', + 'ceil', 'floor', 'round'] + print "\n".join("__%s__ = _delegate('__%s__')" % (s,s) for s in a) + """ + + __add__ = _delegate('__add__') + __radd__ = _delegate('__radd__') + __sub__ = _delegate('__sub__') + __rsub__ = _delegate('__rsub__') + __div__ = _delegate('__div__') + __rdiv__ = _delegate('__rdiv__') + __mul__ = _delegate('__mul__') + __rmul__ = _delegate('__rmul__') + __truediv__ = _delegate('__truediv__') + __rtruediv__ = _delegate('__rtruediv__') + __floordiv__ = _delegate('__floordiv__') + __rfloordiv__ = _delegate('__rfloordiv__') + __mod__ = _delegate('__mod__') + __rmod__ = _delegate('__rmod__') + __pow__ = _delegate('__pow__') + __rpow__ = _delegate('__rpow__') + __pos__ = _delegate('__pos__') + __neg__ = _delegate('__neg__') + __abs__ = _delegate('__abs__') + __trunc__ = _delegate('__trunc__') + __lt__ = _delegate('__lt__') + __gt__ = _delegate('__gt__') + __le__ = _delegate('__le__') + __ge__ = _delegate('__ge__') + __nonzero__ = _delegate('__nonzero__') + __ceil__ = _delegate('__ceil__') + __floor__ = _delegate('__floor__') + __round__ = _delegate('__round__') + + class ImageFileDirectory_v2(collections.MutableMapping): """This class represents a TIFF tag directory. To speed things up, we @@ -338,7 +463,7 @@ class ImageFileDirectory_v2(collections.MutableMapping): Returns the complete tag dictionary, with named tags where possible. """ - return dict((TAGS_V2.get(code, TagInfo()).name, value) + return dict((TiffTags.lookup(code).name, value) for code, value in self.items()) def __len__(self): @@ -370,15 +495,17 @@ class ImageFileDirectory_v2(collections.MutableMapping): if bytes is str: basetypes += unicode, - info = TAGS_V2.get(tag, TagInfo()) + info = TiffTags.lookup(tag) values = [value] if isinstance(value, basetypes) else value if tag not in self.tagtype: - try: + if info.type: self.tagtype[tag] = info.type - except KeyError: + else: self.tagtype[tag] = 7 - if all(isinstance(v, int) for v in values): + if all(isinstance(v, IFDRational) for v in values): + self.tagtype[tag] = 5 + elif all(isinstance(v, int) for v in values): if all(v < 2 ** 16 for v in values): self.tagtype[tag] = 3 else: @@ -477,7 +604,7 @@ class ImageFileDirectory_v2(collections.MutableMapping): @_register_loader(5, 8) def load_rational(self, data, legacy_api=True): vals = self._unpack("{0}L".format(len(data) // 4), data) - combine = lambda a, b: (a, b) if legacy_api else a / b + combine = lambda a, b: (a, b) if legacy_api else IFDRational(a, b) return tuple(combine(num, denom) for num, denom in zip(vals[::2], vals[1::2])) @@ -497,7 +624,7 @@ class ImageFileDirectory_v2(collections.MutableMapping): @_register_loader(10, 8) def load_signed_rational(self, data, legacy_api=True): vals = self._unpack("{0}l".format(len(data) // 4), data) - combine = lambda a, b: (a, b) if legacy_api else a / b + combine = lambda a, b: (a, b) if legacy_api else IFDRational(a, b) return tuple(combine(num, denom) for num, denom in zip(vals[::2], vals[1::2])) @@ -523,7 +650,7 @@ class ImageFileDirectory_v2(collections.MutableMapping): for i in range(self._unpack("H", self._ensure_read(fp, 2))[0]): tag, typ, count, data = self._unpack("HHL4s", self._ensure_read(fp, 12)) if DEBUG: - tagname = TAGS_V2.get(tag, TagInfo()).name + tagname = TiffTags.lookup(tag).name typname = TYPES.get(typ, "unknown") print("tag: %s (%d) - type: %s (%d)" % (tagname, tag, typname, typ), end=" ") @@ -591,7 +718,7 @@ class ImageFileDirectory_v2(collections.MutableMapping): values = value if isinstance(value, tuple) else (value,) data = self._write_dispatch[typ](self, *values) if DEBUG: - tagname = TAGS_V2.get(tag, TagInfo()).name + tagname = TiffTags.lookup(tag).name typname = TYPES.get(typ, "unknown") print("save: %s (%d) - type: %s (%d)" % (tagname, tag, typname, typ), end=" ") @@ -994,16 +1121,10 @@ class TiffImageFile(ImageFile.ImageFile): self.info["compression"] = self._compression - xres = self.tag_v2.get(X_RESOLUTION, (1, 1)) - yres = self.tag_v2.get(Y_RESOLUTION, (1, 1)) + xres = self.tag_v2.get(X_RESOLUTION,1) + yres = self.tag_v2.get(Y_RESOLUTION,1) - if xres and not isinstance(xres, tuple): - xres = (xres, 1.) - if yres and not isinstance(yres, tuple): - yres = (yres, 1.) if xres and yres: - xres = xres[0] / (xres[1] or 1) - yres = yres[0] / (yres[1] or 1) resunit = self.tag_v2.get(RESOLUTION_UNIT, 1) if resunit == 2: # dots per inch self.info["dpi"] = xres, yres @@ -1296,6 +1417,8 @@ def _save(im, fp, filename): if k not in atts and k not in blocklist: if isinstance(v, unicode if bytes is str else str): atts[k] = v.encode('ascii', 'replace') + b"\0" + elif isinstance(v, IFDRational): + atts[k] = float(v) else: atts[k] = v diff --git a/PIL/TiffTags.py b/PIL/TiffTags.py index dc8960478..432ec82da 100644 --- a/PIL/TiffTags.py +++ b/PIL/TiffTags.py @@ -23,13 +23,25 @@ from collections import namedtuple class TagInfo(namedtuple("_TagInfo", "value name type length enum")): __slots__ = [] - def __new__(cls, value=None, name="unknown", type=4, length=0, enum=None): + def __new__(cls, value=None, name="unknown", type=None, length=0, enum=None): return super(TagInfo, cls).__new__( cls, value, name, type, length, enum or {}) def cvt_enum(self, value): return self.enum.get(value, value) +def lookup(tag): + """ + :param tag: Integer tag number + :returns: Taginfo namedtuple, From the TAGS_V2 info if possible, + otherwise just populating the value and name from TAGS. + If the tag is not recognized, "unknown" is returned for the name + + """ + + return TAGS_V2.get(tag, TagInfo(tag, TAGS.get(tag, 'unknown'))) + + ## # Map tag numbers to tag info. # diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 312daea56..5c5958184 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -84,9 +84,9 @@ class TestFileTiff(PillowTestCase): self.assertIsInstance(im.tag[X_RESOLUTION][0], tuple) self.assertIsInstance(im.tag[Y_RESOLUTION][0], tuple) - # v2 api - self.assertIsInstance(im.tag_v2[X_RESOLUTION], float) - self.assertIsInstance(im.tag_v2[Y_RESOLUTION], float) + #v2 api + self.assert_(isinstance(im.tag_v2[X_RESOLUTION], TiffImagePlugin.IFDRational)) + self.assert_(isinstance(im.tag_v2[Y_RESOLUTION], TiffImagePlugin.IFDRational)) self.assertEqual(im.info['dpi'], (72., 72.)) diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py index b0f4e402a..1b88fca99 100644 --- a/Tests/test_file_tiff_metadata.py +++ b/Tests/test_file_tiff_metadata.py @@ -6,6 +6,7 @@ import struct from helper import unittest, PillowTestCase, hopper from PIL import Image, TiffImagePlugin, TiffTags +from PIL.TiffImagePlugin import _limit_rational, IFDRational tag_ids = dict((info.name, info.value) for info in TiffTags.TAGS_V2.values()) @@ -73,7 +74,7 @@ class TestFileTiffMetadata(PillowTestCase): def test_read_metadata(self): img = Image.open('Tests/images/hopper_g4.tif') - self.assertEqual({'YResolution': 4294967295 / 113653537, + self.assertEqual({'YResolution': IFDRational(4294967295, 113653537), 'PlanarConfiguration': 1, 'BitsPerSample': (1,), 'ImageLength': 128, @@ -83,7 +84,7 @@ class TestFileTiffMetadata(PillowTestCase): 'ResolutionUnit': 3, 'PhotometricInterpretation': 0, 'PageNumber': (0, 1), - 'XResolution': 4294967295 / 113653537, + 'XResolution': IFDRational(4294967295, 113653537), 'ImageWidth': 128, 'Orientation': 1, 'StripByteCounts': (1968,), @@ -121,13 +122,32 @@ class TestFileTiffMetadata(PillowTestCase): original = img.tag_v2.named() reloaded = loaded.tag_v2.named() - ignored = [ - 'StripByteCounts', 'RowsPerStrip', 'PageNumber', 'StripOffsets'] + for k,v in original.items(): + if type(v) == IFDRational: + original[k] = IFDRational(*_limit_rational(v,2**31)) + if type(v) == tuple and \ + type(v[0]) == IFDRational: + original[k] = tuple([IFDRational( + *_limit_rational(elt, 2**31)) for elt in v]) + + ignored = ['StripByteCounts', 'RowsPerStrip', + 'PageNumber', 'StripOffsets'] for tag, value in reloaded.items(): - if tag not in ignored: - self.assertEqual( - original[tag], value, "%s didn't roundtrip" % tag) + if tag in ignored: continue + if (type(original[tag]) == tuple + and type(original[tag][0]) == IFDRational): + # Need to compare element by element in the tuple, + # not comparing tuples of object references + self.assert_deep_equal(original[tag], + value, + "%s didn't roundtrip, %s, %s" % + (tag, original[tag], value)) + else: + self.assertEqual(original[tag], + value, + "%s didn't roundtrip, %s, %s" % + (tag, original[tag], value)) for tag, value in original.items(): if tag not in ignored: @@ -165,6 +185,20 @@ class TestFileTiffMetadata(PillowTestCase): self.assertEqual(im.tag_v2.tagtype[34675], 1) self.assertTrue(im.info['icc_profile']) + def test_exif_div_zero(self): + im = hopper() + info = TiffImagePlugin.ImageFileDirectory_v2() + info[41988] = TiffImagePlugin.IFDRational(0,0) + + out = self.tempfile('temp.tiff') + im.save(out, tiffinfo=info, compression='raw') + + reloaded = Image.open(out) + self.assertEqual(0, reloaded.tag_v2[41988][0].numerator) + self.assertEqual(0, reloaded.tag_v2[41988][0].denominator) + + + if __name__ == '__main__': unittest.main() diff --git a/Tests/test_tiff_ifdrational.py b/Tests/test_tiff_ifdrational.py new file mode 100644 index 000000000..5654d4c9c --- /dev/null +++ b/Tests/test_tiff_ifdrational.py @@ -0,0 +1,61 @@ +from __future__ import print_function + +from helper import PillowTestCase, hopper + +from PIL import TiffImagePlugin, Image +from PIL.TiffImagePlugin import IFDRational + +from fractions import Fraction + +class Test_IFDRational(PillowTestCase): + + + def _test_equal(self, num, denom, target): + + t = IFDRational(num, denom) + + self.assertEqual(target, t) + self.assertEqual(t, target) + + def test_sanity(self): + + self._test_equal(1, 1, 1) + self._test_equal(1, 1, Fraction(1,1)) + + self._test_equal(2, 2, 1) + self._test_equal(1.0, 1, Fraction(1,1)) + + self._test_equal(Fraction(1,1), 1, Fraction(1,1)) + self._test_equal(IFDRational(1,1), 1, 1) + + + self._test_equal(1, 2, Fraction(1,2)) + self._test_equal(1, 2, IFDRational(1,2)) + + def test_nonetype(self): + " Fails if the _delegate function doesn't return a valid function" + + xres = IFDRational(72) + yres = IFDRational(72) + self.assert_(xres._val is not None) + self.assert_(xres.numerator is not None) + self.assert_(xres.denominator is not None) + self.assert_(yres._val is not None) + + self.assert_(xres and 1) + self.assert_(xres and yres) + + + def test_ifd_rational_save(self): + for libtiff in (True, False): + TiffImagePlugin.WRITE_LIBTIFF = libtiff + + im = hopper() + out = self.tempfile('temp.tiff') + res = IFDRational(301,1) + im.save(out, dpi=(res,res), compression='raw') + + reloaded = Image.open(out) + self.assertEqual(float(IFDRational(301,1)), + float(reloaded.tag_v2[282])) + diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 46b0caa10..3f62c30ea 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -496,10 +496,12 @@ The :py:meth:`~PIL.Image.Image.open` method sets the following .. versionadded:: 1.1.5 -The :py:attr:`~PIL.Image.Image.tag_v2` attribute contains a dictionary of -TIFF metadata. The keys are numerical indexes from `~PIL.TiffTags.TAGS_V2`. -Values are strings or numbers for single items, multiple values are returned -in a tuple of values. Rational numbers are returned as a single value. +The :py:attr:`~PIL.Image.Image.tag_v2` attribute contains a dictionary +of TIFF metadata. The keys are numerical indexes from +`~PIL.TiffTags.TAGS_V2`. Values are strings or numbers for single +items, multiple values are returned in a tuple of values. Rational +numbers are returned as a :py:class:`~PIL.TiffImagePlugin.IFDRational` +object. .. versionadded:: 3.0.0 @@ -529,20 +531,27 @@ The :py:meth:`~PIL.Image.Image.save` method can take the following keyword argum .. versionadded:: 2.3.0 - For compatibility with legacy code, a - `~PIL.TiffImagePlugin.ImageFileDirectory_v1` object may be passed - in this field. However, this is deprecated. + Metadata values that are of the rational type should be passed in + using a :py:class:`~PIL.TiffImagePlugin.IFDRational` object. - ..versionadded:: 3.0.0 + .. versionadded:: 3.1.0 + + For compatibility with legacy code, a + :py:class:`~PIL.TiffImagePlugin.ImageFileDirectory_v1` object may + be passed in this field. However, this is deprecated. + + .. versionadded:: 3.0.0 **compression** A string containing the desired compression method for the file. (valid only with libtiff installed) Valid compression - methods are: ``[None, "tiff_ccitt", "group3", "group4", - "tiff_jpeg", "tiff_adobe_deflate", "tiff_thunderscan", - "tiff_deflate", "tiff_sgilog", "tiff_sgilog24", "tiff_raw_16"]`` + methods are: ``None``, ``"tiff_ccitt"``, ``"group3"``, + ``"group4"``, ``"tiff_jpeg"``, ``"tiff_adobe_deflate"``, + ``"tiff_thunderscan"``, ``"tiff_deflate"``, ``"tiff_sgilog"``, + ``"tiff_sgilog24"``, ``"tiff_raw_16"`` -These arguments to set the tiff header fields are an alternative to using the general tags available through tiffinfo. +These arguments to set the tiff header fields are an alternative to +using the general tags available through tiffinfo. **description** @@ -564,10 +573,11 @@ These arguments to set the tiff header fields are an alternative to using the ge **y_resolution** -**dpi** - Either a Float, Integer, or 2 tuple of (numerator, - denominator). Resolution implies an equal x and y resolution, dpi - also implies a unit of inches. +**dpi** + Either a Float, 2 tuple of (numerator, denominator) or a + :py:class:`~PIL.TiffImagePlugin.IFDRational`. Resolution implies + an equal x and y resolution, dpi also implies a unit of inches. + WebP ^^^^