diff --git a/news/69.feature b/news/69.feature new file mode 100644 index 0000000..66b342b --- /dev/null +++ b/news/69.feature @@ -0,0 +1 @@ +Add support for animated GIFs @reebalazs \ No newline at end of file diff --git a/plone/scale/scale.py b/plone/scale/scale.py index bb8b42d..891092a 100644 --- a/plone/scale/scale.py +++ b/plone/scale/scale.py @@ -2,6 +2,7 @@ import math import PIL.Image +import PIL.ImageSequence import PIL.ImageFile import sys import warnings @@ -72,42 +73,59 @@ def scaleImage( """ if isinstance(image, (bytes, str)): image = StringIO(image) - image = PIL.Image.open(image) - # When we create a new image during scaling we loose the format - # information, so remember it here. - format_ = image.format - if format_ not in ("PNG", "GIF"): - # Always generate JPEG, except if format is PNG or GIF. - format_ = "JPEG" - elif format_ == "GIF": - # GIF scaled looks better if we have 8-bit alpha and no palette - format_ = "PNG" - - icc_profile = image.info.get("icc_profile") - image = scalePILImage(image, width, height, mode, direction=direction) - # convert to simpler mode if possible - colors = image.getcolors(maxcolors=256) - if image.mode not in ("P", "L") and colors: - if format_ == "JPEG": - # check if it's all grey - if all(rgb[0] == rgb[1] == rgb[2] for c, rgb in colors): - image = image.convert("L") - elif format_ == "PNG": - image = image.convert("P") + animated_kwargs = {} + with PIL.Image.open(image) as img: + icc_profile = img.info.get("icc_profile") + # When we create a new image during scaling we loose the format + # information, so remember it here. + format_ = img.format + if format_ == "GIF": + # Attempt to process multiple frames, to support animated GIFs + append_images = [] + for frame in PIL.ImageSequence.Iterator(img): + # We ignore the returned format_ as it won't get optimized + # in case of a GIF. This ensures that the format remains + # constant across all frames. + scaled_frame, _dummy_format_ = scaleSingleFrame( + frame, + width=width, + height=height, + mode=mode, + format_=format_, + quality=quality, + direction=direction, + ) + append_images.append(scaled_frame) + + # The first image is the basis for save + # All other images than the first will be added as a save parameter + image = append_images.pop(0) + if len(append_images) > 0: + # Saving as a multi page image + animated_kwargs['save_all'] = True + animated_kwargs['append_images'] = append_images + else: + # GIF scaled looks better if we have 8-bit alpha and no palette, + # but it only works for single frame, so don't do this for animated GIFs. + format_ = "PNG" - if image.mode == "RGBA" and format_ == "JPEG": - extrema = dict(zip(image.getbands(), image.getextrema())) - if extrema.get("A") == (255, 255): - # no alpha used, just change the mode, which causes the alpha band - # to be dropped on save - image.mode = "RGB" else: - # switch to PNG, which supports alpha - format_ = "PNG" + # All other formats only process a single frame + if format_ not in ("PNG", "GIF"): + # Always generate JPEG, except if format is PNG or GIF. + format_ = "JPEG" + image, format_ = scaleSingleFrame( + img, + width=width, + height=height, + mode=mode, + format_=format_, + quality=quality, + direction=direction, + ) new_result = False - if result is None: result = StringIO() new_result = True @@ -119,6 +137,7 @@ def scaleImage( optimize=True, progressive=True, icc_profile=icc_profile, + **animated_kwargs, ) if new_result: @@ -129,6 +148,40 @@ def scaleImage( return result, format_, image.size +def scaleSingleFrame( + image, + width, + height, + mode, + format_, + quality, + direction, +): + image = scalePILImage(image, width, height, mode, direction=direction) + + # convert to simpler mode if possible + colors = image.getcolors(maxcolors=256) + if image.mode not in ("P", "L") and colors: + if format_ == "JPEG": + # check if it's all grey + if all(rgb[0] == rgb[1] == rgb[2] for c, rgb in colors): + image = image.convert("L") + elif format_ in ("PNG", "GIF"): + image = image.convert("P") + + if image.mode == "RGBA" and format_ == "JPEG": + extrema = dict(zip(image.getbands(), image.getextrema())) + if extrema.get("A") == (255, 255): + # no alpha used, just change the mode, which causes the alpha band + # to be dropped on save + image.mode = "RGB" + else: + # switch to PNG, which supports alpha + format_ = "PNG" + + return image, format_ + + def _scale_thumbnail(image, width=None, height=None): """Scale with method "thumbnail". diff --git a/plone/scale/tests/data/animated.gif b/plone/scale/tests/data/animated.gif new file mode 100644 index 0000000..cef4bd7 Binary files /dev/null and b/plone/scale/tests/data/animated.gif differ diff --git a/plone/scale/tests/data/animated2.gif b/plone/scale/tests/data/animated2.gif new file mode 100644 index 0000000..3609359 Binary files /dev/null and b/plone/scale/tests/data/animated2.gif differ diff --git a/plone/scale/tests/test_scale.py b/plone/scale/tests/test_scale.py index 4ffaf42..8ad8913 100644 --- a/plone/scale/tests/test_scale.py +++ b/plone/scale/tests/test_scale.py @@ -22,6 +22,10 @@ CMYK = fio.read() with open(os.path.join(TEST_DATA_LOCATION, "profile.jpg"), "rb") as fio: PROFILE = fio.read() +with open(os.path.join(TEST_DATA_LOCATION, "animated.gif"), "rb") as fio: + ANIGIF = fio.read() +with open(os.path.join(TEST_DATA_LOCATION, "animated2.gif"), "rb") as fio: + ANIGIF2 = fio.read() class ScalingTests(TestCase): @@ -40,6 +44,12 @@ def testScaledImageKeepGIFto(self): def testScaledImageIsJpeg(self): self.assertEqual(scaleImage(TIFF, 84, 103, "contain")[1], "JPEG") + def testScaledAnigifKeepGIF(self): + self.assertEqual(scaleImage(ANIGIF, 84, 103, "contain")[1], "GIF") + + def testScaledAnigifKeepGIF2(self): + self.assertEqual(scaleImage(ANIGIF2, 84, 103, "contain")[1], "GIF") + def testAlphaForcesPNG(self): # first image without alpha src = PIL.Image.new("RGBA", (256, 256), (255, 255, 255, 255)) @@ -338,6 +348,20 @@ def testDeprecations(self): self.assertIs(w[0].category, DeprecationWarning) self.assertIn("The 'direction' option is deprecated", str(w[0].message)) + def testDeprecationsAni(self): + import plone.scale.scale + + # clear warnings registry, so the test actually sees the warning + plone.scale.scale.__warningregistry__.clear() + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + print('NEXT...') + scaleImage(ANIGIF, 16, 16, direction="keep") + self.assertEqual(len(w), 6) + for item in w: + self.assertIs(item.category, DeprecationWarning) + self.assertIn("The 'direction' option is deprecated", str(item.message)) + def test_calculate_scaled_dimensions_contain(self): """Test the calculate_scaled_dimensions function with mode "contain". @@ -396,6 +420,24 @@ def test_calculate_scaled_dimensions_scale(self): self.assertEqual(calc(600, 300, 400, 65536), (400, 200)) self.assertEqual(calc(600, 1200, 400, 65536), (400, 800)) + def testAnimatedGifContainsAllFrames(self): + image = scaleImage(ANIGIF, 84, 103, "contain")[0] + with PIL.Image.open(StringIO(image)) as img: + frames = [frame for frame in PIL.ImageSequence.Iterator(img)] + self.assertEqual(len(frames), 6) + for frame in frames: + self.assertEqual(frame.width, 84) + self.assertEqual(frame.height, 103) + + def testAnimatedGifContainsAllFrames2(self): + image = scaleImage(ANIGIF2, 84, 103, "contain")[0] + with PIL.Image.open(StringIO(image)) as img: + frames = [frame for frame in PIL.ImageSequence.Iterator(img)] + self.assertEqual(len(frames), 35) + for frame in frames: + self.assertEqual(frame.width, 84) + self.assertEqual(frame.height, 103) + def test_suite(): from unittest import defaultTestLoader