Skip to content

Commit

Permalink
Add support for animated GIFs
Browse files Browse the repository at this point in the history
  • Loading branch information
reebalazs committed Oct 20, 2022
1 parent 75fbf8b commit df79501
Show file tree
Hide file tree
Showing 5 changed files with 127 additions and 31 deletions.
1 change: 1 addition & 0 deletions news/69.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add support for animated GIFs @reebalazs
115 changes: 84 additions & 31 deletions plone/scale/scale.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import math
import PIL.Image
import PIL.ImageSequence
import PIL.ImageFile
import sys
import warnings
Expand Down Expand Up @@ -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
Expand All @@ -119,6 +137,7 @@ def scaleImage(
optimize=True,
progressive=True,
icc_profile=icc_profile,
**animated_kwargs,
)

if new_result:
Expand All @@ -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".
Expand Down
Binary file added plone/scale/tests/data/animated.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added plone/scale/tests/data/animated2.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 42 additions & 0 deletions plone/scale/tests/test_scale.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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))
Expand Down Expand Up @@ -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".
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit df79501

Please sign in to comment.