diff --git a/PIL/GifImagePlugin.py b/PIL/GifImagePlugin.py index 08567fcd0..83169bf21 100644 --- a/PIL/GifImagePlugin.py +++ b/PIL/GifImagePlugin.py @@ -24,7 +24,8 @@ # See the README file for information on usage and redistribution. # -from PIL import Image, ImageFile, ImagePalette, ImageChops, ImageSequence, _binary +from PIL import Image, ImageFile, ImagePalette, \ + ImageChops, ImageSequence, _binary __version__ = "0.9" @@ -300,14 +301,17 @@ RAWMODE = { } -def _convert_mode(im): +def _convert_mode(im, initial_call=False): # convert on the fly (EXPERIMENTAL -- I'm not sure PIL # should automatically convert images on save...) if Image.getmodebase(im.mode) == "RGB": - palette_size = 256 - if im.palette: - palette_size = len(im.palette.getdata()[1]) // 3 - return im.convert("P", palette=1, colors=palette_size) + if initial_call: + palette_size = 256 + if im.palette: + palette_size = len(im.palette.getdata()[1]) // 3 + return im.convert("P", palette=1, colors=palette_size) + else: + return im.convert("P") return im.convert("L") @@ -317,6 +321,7 @@ def _save_all(im, fp, filename): def _save(im, fp, filename, save_all=False): + im.encoderinfo.update(im.info) if _imaging_gif: # call external driver try: @@ -328,7 +333,7 @@ def _save(im, fp, filename, save_all=False): if im.mode in RAWMODE: im_out = im.copy() else: - im_out = _convert_mode(im) + im_out = _convert_mode(im, True) # header try: @@ -340,6 +345,7 @@ def _save(im, fp, filename, save_all=False): if save_all: previous = None + first_frame = None for im_frame in ImageSequence.Iterator(im): im_frame = _convert_mode(im_frame) @@ -347,22 +353,30 @@ def _save(im, fp, filename, save_all=False): # e.g. getdata(im_frame, duration=1000) if not previous: # global header - for s in getheader(im_frame, palette, im.encoderinfo)[0] + getdata(im_frame): - fp.write(s) + first_frame = getheader(im_frame, palette, im.encoderinfo)[0] + first_frame += getdata(im_frame, (0, 0), **im.encoderinfo) else: + if first_frame: + for s in first_frame: + fp.write(s) + first_frame = None + # delta frame - delta = ImageChops.subtract_modulo(im_frame, previous) + delta = ImageChops.subtract_modulo(im_frame, previous.copy()) bbox = delta.getbbox() if bbox: # compress difference - for s in getdata(im_frame.crop(bbox), offset=bbox[:2]): + for s in getdata(im_frame.crop(bbox), + bbox[:2], **im.encoderinfo): fp.write(s) else: # FIXME: what should we do in this case? pass - previous = im_frame.copy() - else: + previous = im_frame + if first_frame: + save_all = False + if not save_all: header = getheader(im_out, palette, im.encoderinfo)[0] for s in header: fp.write(s) @@ -533,8 +547,19 @@ def getheader(im, palette=None, info=None): # Header Block # http://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp + + version = b"87a" + for extensionKey in ["transparency", "duration", "loop"]: + if info and extensionKey in info and \ + not (extensionKey == "duration" and info[extensionKey] == 0): + version = b"89a" + break + else: + if im.info.get("version") == "89a": + version = b"89a" + header = [ - b"GIF87a" + # signature + version + b"GIF"+version + # signature + version o16(im.size[0]) + # canvas width o16(im.size[1]) # canvas height ] @@ -591,7 +616,16 @@ def getheader(im, palette=None, info=None): # size of global color table + global color table flag header.append(o8(color_table_size + 128)) # background + reserved/aspect - background = im.info["background"] if "background" in im.info else 0 + if info and "background" in info: + background = info["background"] + elif "background" in im.info: + # This elif is redundant within GifImagePlugin + # since im.info parameters are bundled into the info dictionary + # However, external scripts may call getheader directly + # So this maintains earlier behaviour + background = im.info["background"] + else: + background = 0 header.append(o8(background) + o8(0)) # end of Logical Screen Descriptor diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 70438eb03..0ac67cd63 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -24,6 +24,7 @@ class TestFileGif(PillowTestCase): self.assertEqual(im.mode, "P") self.assertEqual(im.size, (128, 128)) self.assertEqual(im.format, "GIF") + self.assertEqual(im.info["version"], b"GIF89a") def test_invalid_file(self): invalid_file = "Tests/images/flower.jpg" @@ -95,6 +96,21 @@ class TestFileGif(PillowTestCase): self.assertEqual(reread.n_frames, 5) + def test_headers_saving_for_animated_gifs(self): + important_headers = ['background', 'version', 'duration', 'loop'] + # Multiframe image + im = Image.open("Tests/images/dispose_bgnd.gif") + + out = self.tempfile('temp.gif') + im.save(out, save_all=True) + reread = Image.open(out) + + for header in important_headers: + self.assertEqual( + im.info[header], + reread.info[header] + ) + def test_palette_handling(self): # see https://github.com/python-pillow/Pillow/issues/513 @@ -251,6 +267,34 @@ class TestFileGif(PillowTestCase): self.assertEqual(reread.info['background'], im.info['background']) + def test_version(self): + out = self.tempfile('temp.gif') + + # Test that GIF87a is used by default + im = Image.new('L', (100, 100), '#000') + im.save(out) + reread = Image.open(out) + self.assertEqual(reread.info["version"], b"GIF87a") + + # Test that adding a GIF89a feature changes the version + im.info["transparency"] = 1 + im.save(out) + reread = Image.open(out) + self.assertEqual(reread.info["version"], b"GIF89a") + + # Test that a GIF87a image is also saved in that format + im = Image.open(TEST_GIF) + im.save(out) + reread = Image.open(out) + self.assertEqual(reread.info["version"], b"GIF87a") + + # Test that a GIF89a image is also saved in that format + im.info["version"] = "GIF89a" + im.save(out) + reread = Image.open(out) + self.assertEqual(reread.info["version"], b"GIF87a") + + if __name__ == '__main__': unittest.main() diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 11ec60401..ec3c49ecc 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -54,8 +54,11 @@ GIF ^^^ PIL reads GIF87a and GIF89a versions of the GIF file format. The library writes -run-length encoded GIF87a files. Note that GIF files are always read as -grayscale (``L``) or palette mode (``P``) images. +run-length encoded files in GIF87a by default, unless GIF89a features +are used or GIF89a is already in use. + +Note that GIF files are always read as grayscale (``L``) +or palette mode (``P``) images. The :py:meth:`~PIL.Image.Image.open` method sets the following :py:attr:`~PIL.Image.Image.info` properties: @@ -73,12 +76,32 @@ The :py:meth:`~PIL.Image.Image.open` method sets the following **version** Version (either ``GIF87a`` or ``GIF89a``). +**duration** + May not be present. The time to display each frame of the GIF, in + milliseconds. + +**loop** + May not be present. The number of times the GIF should loop. + Reading sequences ~~~~~~~~~~~~~~~~~ The GIF loader supports the :py:meth:`~file.seek` and :py:meth:`~file.tell` methods. You can seek to the next frame (``im.seek(im.tell() + 1)``), or rewind -the file by seeking to the first frame. Random access is not supported. ``im.seek()`` raises an ``EOFError`` if you try to seek after the last frame. +the file by seeking to the first frame. Random access is not supported. + +``im.seek()`` raises an ``EOFError`` if you try to seek after the last frame. + +Saving sequences +~~~~~~~~~~~~~~~~ + +When calling :py:meth:`~PIL.Image.Image.save`, if a multiframe image is used, +by default only the first frame will be saved. To save all frames, the +``save_all`` parameter must be present and set to ``True``. + +If present, the ``loop`` parameter can be used to set the number of times +the GIF should loop, and the ``duration`` parameter can set the number of +milliseconds between each frame. Reading local images ~~~~~~~~~~~~~~~~~~~~