emoji: Support animated PNGs.

This commit is contained in:
Alex Vandiver
2022-02-17 00:07:58 +00:00
committed by Tim Abbott
parent fc793c10fa
commit 95892a5ed3
3 changed files with 53 additions and 40 deletions

View File

@@ -27,8 +27,7 @@ from django.utils.translation import gettext as _
from markupsafe import Markup as mark_safe
from mypy_boto3_s3.client import S3Client
from mypy_boto3_s3.service_resource import Bucket, Object
from PIL import Image, ImageOps
from PIL.GifImagePlugin import GifImageFile
from PIL import GifImagePlugin, Image, ImageOps, PngImagePlugin
from PIL.Image import DecompressionBombError
from zerver.lib.avatar_hash import user_avatar_path
@@ -145,7 +144,8 @@ def resize_logo(image_data: bytes) -> bytes:
return out.getvalue()
def resize_gif(im: GifImageFile, size: int = DEFAULT_EMOJI_SIZE) -> bytes:
def resize_animated(im: Image.Image, size: int = DEFAULT_EMOJI_SIZE) -> bytes:
assert im.n_frames > 1
frames = []
duration_info = []
disposals = []
@@ -157,21 +157,29 @@ def resize_gif(im: GifImageFile, size: int = DEFAULT_EMOJI_SIZE) -> bytes:
new_frame.paste(im, (0, 0), im.convert("RGBA"))
new_frame = ImageOps.pad(new_frame, (size, size), Image.ANTIALIAS)
frames.append(new_frame)
if im.info.get("duration") is None: # nocoverage
raise BadImageError(_("Corrupt animated image."))
duration_info.append(im.info["duration"])
disposals.append(
im.disposal_method # type: ignore[attr-defined] # private member missing from stubs
)
if isinstance(im, GifImagePlugin.GifImageFile):
disposals.append(
im.disposal_method # type: ignore[attr-defined] # private member missing from stubs
)
elif isinstance(im, PngImagePlugin.PngImageFile):
disposals.append(im.info.get("disposal", PngImagePlugin.APNG_DISPOSE_OP_NONE))
else: # nocoverage
raise BadImageError(_("Unknown animated image format."))
out = io.BytesIO()
frames[0].save(
out,
save_all=True,
optimize=False,
format="GIF",
format=im.format,
append_images=frames[1:],
duration=duration_info,
disposal=disposals if len(frames) > 1 else disposals[0],
disposal=disposals,
loop=loop,
)
return out.getvalue()
@@ -186,12 +194,11 @@ def resize_emoji(
try:
im = Image.open(io.BytesIO(image_data))
image_format = im.format
if image_format == "GIF":
assert isinstance(im, GifImageFile)
# There are a number of bugs in Pillow.GifImagePlugin which cause
# results in resized gifs being broken. To work around this we
# only resize under certain conditions to minimize the chance of
# creating ugly gifs.
if getattr(im, "n_frames", 1) > 1:
# There are a number of bugs in Pillow which cause results
# in resized images being broken. To work around this we
# only resize under certain conditions to minimize the
# chance of creating ugly images.
should_resize = (
im.size[0] != im.size[1] # not square
or im.size[0] > MAX_EMOJI_GIF_SIZE # dimensions too large
@@ -209,12 +216,9 @@ def resize_emoji(
still_image_data = out.getvalue()
if should_resize:
image_data = resize_gif(im, size)
image_data = resize_animated(im, size)
if im.n_frames > 1:
return image_data, True, still_image_data
else:
return image_data, False, None
return image_data, True, still_image_data
else:
# Note that this is essentially duplicated in the
# still_image code path, above.

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1303,32 +1303,33 @@ class EmojiTest(UploadSerializeMixin, ZulipTestCase):
with self.assertRaises(BadImageError):
resize_emoji(corrupted_img_data)
animated_large_img_data = read_test_image_file(f"animated_large_img.gif")
for img_format in ("gif", "png"):
animated_large_img_data = read_test_image_file(f"animated_large_img.{img_format}")
def test_resize(size: int = 50) -> None:
resized_img_data, is_animated, still_img_data = resize_emoji(
animated_large_img_data, size=50
)
im = Image.open(io.BytesIO(resized_img_data))
self.assertEqual((size, size), im.size)
self.assertTrue(is_animated)
assert still_img_data
still_image = Image.open(io.BytesIO(still_img_data))
self.assertEqual((50, 50), still_image.size)
def test_resize(size: int = 50) -> None:
resized_img_data, is_animated, still_img_data = resize_emoji(
animated_large_img_data, size=50
)
im = Image.open(io.BytesIO(resized_img_data))
self.assertEqual((size, size), im.size)
self.assertTrue(is_animated)
assert still_img_data
still_image = Image.open(io.BytesIO(still_img_data))
self.assertEqual((50, 50), still_image.size)
# Test an image larger than max is resized
with patch("zerver.lib.upload.MAX_EMOJI_GIF_SIZE", 128):
test_resize()
# Test an image larger than max is resized
with patch("zerver.lib.upload.MAX_EMOJI_GIF_SIZE", 128):
test_resize()
# Test an image file larger than max is resized
with patch("zerver.lib.upload.MAX_EMOJI_GIF_FILE_SIZE_BYTES", 3 * 1024 * 1024):
test_resize()
# Test an image file larger than max is resized
with patch("zerver.lib.upload.MAX_EMOJI_GIF_FILE_SIZE_BYTES", 3 * 1024 * 1024):
test_resize()
# Test an image smaller than max and smaller than file size max is not resized
with patch("zerver.lib.upload.MAX_EMOJI_GIF_SIZE", 512):
test_resize(size=256)
# Test an image smaller than max and smaller than file size max is not resized
with patch("zerver.lib.upload.MAX_EMOJI_GIF_SIZE", 512):
test_resize(size=256)
# Test a non-animated image which does need to be resized
# Test a non-animated GIF image which does need to be resized
still_large_img_data = read_test_image_file("still_large_img.gif")
resized_img_data, is_animated, no_still_data = resize_emoji(still_large_img_data, size=50)
im = Image.open(io.BytesIO(resized_img_data))
@@ -1336,6 +1337,14 @@ class EmojiTest(UploadSerializeMixin, ZulipTestCase):
self.assertFalse(is_animated)
assert no_still_data is None
# Test a non-animated and non-animatable image format which needs to be resized
still_large_img_data = read_test_image_file("img.jpg")
resized_img_data, is_animated, no_still_data = resize_emoji(still_large_img_data, size=50)
im = Image.open(io.BytesIO(resized_img_data))
self.assertEqual((50, 50), im.size)
self.assertFalse(is_animated)
assert no_still_data is None
def tearDown(self) -> None:
destroy_uploads()
super().tearDown()