Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/134.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix a problem where scaled animated GIFs could be saved with a much larger file size than the original image. @davisagli
79 changes: 49 additions & 30 deletions src/plone/scale/scale.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@
try:
# Pillow 9.1.0+
LANCZOS = PIL.Image.Resampling.LANCZOS
NEAREST = PIL.Image.Resampling.NEAREST
except AttributeError:
LANCZOS = PIL.Image.ANTIALIAS
NEAREST = PIL.Image.NEAREST
RESAMPLE = LANCZOS

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -86,27 +89,32 @@ def scaleImage(
if isinstance(image, (bytes, str)):
image = io.BytesIO(image)

animated_kwargs = {}
save_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
# When we create a new image during scaling we lose the format
# information, so remember it here.
format_ = img.format
if format_ in ("GIF", "WEBP"):
# Attempt to process multiple frames, to support animated GIFs
if format_ in ("GIF", "WEBP") and img.is_animated:
# Process multiple frames, to support animations
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(
# Call scalePILImage directly to avoid converting to palette mode,
# which interferes with optimized saving of the animation
frame = frame.convert("RGBA")
scaled_frame = scalePILImage(
frame,
width=width,
height=height,
mode=mode,
format_=format_,
quality=quality,
direction=direction,
# The default resampling creates more colors,
# which interferes with optimizing animated GIF size
# by omitting parts of the frame that haven't changed.
# Using NEAREST won't look as good,
# but avoids saving scaled animated GIFs
# that are much larger than the original.
resample=NEAREST,
)
append_images.append(scaled_frame)

Expand All @@ -115,17 +123,20 @@ def scaleImage(
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
save_kwargs["save_all"] = True
save_kwargs["append_images"] = append_images
elif format_ == "GIF":
# GIF scaled looks better if we have 8-bit alpha and no palette,
# PNG 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"

else:
# All other formats only process a single frame
if format_ not in ("PNG", "GIF"):
# Always generate JPEG, except if format is WEBP, PNG or GIF.
# No animation; just scale single frame
if format_ == "GIF":
# PNG looks better if we have 8-bit alpha and no palette.
# (It only works for single frame, so we don't do this for animated GIFs.)
format_ = "PNG"
elif format_ not in ("PNG", "WEBP"):
format_ = "JPEG"
image, format_ = scaleSingleFrame(
img,
Expand All @@ -149,7 +160,7 @@ def scaleImage(
optimize=True,
progressive=True,
icc_profile=icc_profile,
**animated_kwargs,
**save_kwargs,
)

if new_result:
Expand All @@ -166,10 +177,13 @@ def scaleSingleFrame(
height,
mode,
format_,
quality,
quality, # not used, but here for backwards compatibility
direction,
resample=RESAMPLE,
):
image = scalePILImage(image, width, height, mode, direction=direction)
image = scalePILImage(
image, width, height, mode, direction=direction, resample=resample
)

# convert to simpler mode if possible
colors = image.getcolors(maxcolors=256)
Expand All @@ -194,7 +208,7 @@ def scaleSingleFrame(
return image, format_


def _scale_thumbnail(image, width=None, height=None):
def _scale_thumbnail(image, width=None, height=None, resample=RESAMPLE):
"""Scale with method "thumbnail".

Aspect Ratio is kept. Resulting image has to fit in the given box.
Expand All @@ -211,7 +225,7 @@ def _scale_thumbnail(image, width=None, height=None):
return image

image.draft(image.mode, (dimensions.target_width, dimensions.target_height))
image = image.resize((dimensions.target_width, dimensions.target_height), LANCZOS)
image = image.resize((dimensions.target_width, dimensions.target_height), resample)
return image


Expand Down Expand Up @@ -410,12 +424,14 @@ def calculate_scaled_dimensions(
return (dimensions.final_width, dimensions.final_height)


def scalePILImage(image, width=None, height=None, mode="scale", direction=None):
def scalePILImage(
image, width=None, height=None, mode="scale", direction=None, resample=RESAMPLE
):
"""Scale a PIL image to another size.

This is all about scaling for the display in a web browser.
The `image` parameter must be an instance of the `PIL.Image` class.

Either width or height - or both - must be given.
Either `width` or `height` - or both - must be given.

Three different scaling options are supported via `mode` and correspond to
the CSS background-size values
Expand All @@ -438,9 +454,12 @@ def scalePILImage(image, width=None, height=None, mode="scale", direction=None):
requires both width and height to be specified.
Does not scale up.

The `image` parameter must be an instance of the `PIL.Image` class.
The `direction` parameter is deprecated. Use `mode` instead.

Use the `resample` parameter to set a resampling filter from PIL.Image.Resampling.
The default is `LANCZOS` (or `ANTIALIAS` in older versions of PIL).

The return value the scaled image in the form of another instance of
The return value is the scaled image in the form of another instance of
`PIL.Image`.
"""
# convert zero to None, same semantics: calculate this scale
Expand Down Expand Up @@ -470,7 +489,7 @@ def scalePILImage(image, width=None, height=None, mode="scale", direction=None):

# for scale we're done:
if mode == "scale":
return _scale_thumbnail(image, width, height)
return _scale_thumbnail(image, width, height, resample)

dimensions = _calculate_all_dimensions(
image.size[0], image.size[1], width, height, mode
Expand All @@ -480,9 +499,9 @@ def scalePILImage(image, width=None, height=None, mode="scale", direction=None):
# The original already has the right aspect ratio, so we only need
# to scale.
if mode == "contain":
image.thumbnail((dimensions.final_width, dimensions.final_height), LANCZOS)
image.thumbnail((dimensions.final_width, dimensions.final_height), resample)
return image
return image.resize((dimensions.final_width, dimensions.final_height), LANCZOS)
return image.resize((dimensions.final_width, dimensions.final_height), resample)

if dimensions.pre_scale_crop:
# crop image before scaling to avoid excessive memory use
Expand All @@ -495,7 +514,7 @@ def scalePILImage(image, width=None, height=None, mode="scale", direction=None):
return image

image.draft(image.mode, (dimensions.target_width, dimensions.target_height))
image = image.resize((dimensions.target_width, dimensions.target_height), LANCZOS)
image = image.resize((dimensions.target_width, dimensions.target_height), resample)

if dimensions.post_scale_crop:
# crop off remains due to rounding before scaling
Expand Down
Loading