angrybeanie_wagtail/env/lib/python3.12/site-packages/willow/plugins/pillow.py

527 lines
18 KiB
Python
Raw Normal View History

2025-07-25 21:32:16 +10:00
from io import BytesIO
try:
from pillow_heif import HeifImagePlugin # noqa: F401
except ImportError:
pass
from willow.image import (
AvifImageFile,
BadImageOperationError,
BMPImageFile,
GIFImageFile,
HeicImageFile,
IcoImageFile,
Image,
JPEGImageFile,
PNGImageFile,
RGBAImageBuffer,
RGBImageBuffer,
TIFFImageFile,
WebPImageFile,
)
class UnsupportedRotation(Exception):
pass
def _PIL_Image():
import PIL.Image
return PIL.Image
def _PIL_ImageCms():
import PIL.ImageCms
return PIL.ImageCms
class PillowImage(Image):
def __init__(self, image):
self.image = image
@classmethod
def check(cls):
_PIL_Image()
@classmethod
def is_format_supported(cls, image_format):
formats = _PIL_Image().registered_extensions()
return image_format in formats.values()
@Image.operation
def get_size(self):
return self.image.size
@Image.operation
def get_frame_count(self):
# Animation is not supported by PIL
return 1
@Image.operation
def has_alpha(self):
img = self.image
return img.mode in ("RGBA", "LA") or (
img.mode == "P" and "transparency" in img.info
)
@Image.operation
def has_animation(self):
# Animation is not supported by PIL
return False
@Image.operation
def resize(self, size):
# Convert 1 and P images to RGB to improve resize quality
# (palleted images don't get antialiased or filtered when minified)
if self.image.mode in ["1", "P"]:
if self.has_alpha():
image = self.image.convert("RGBA")
else:
image = self.image.convert("RGB")
else:
image = self.image
# LANCZOS was previously known as ANTIALIAS
return PillowImage(image.resize(size, _PIL_Image().Resampling.LANCZOS))
@Image.operation
def crop(self, rect):
left, top, right, bottom = rect
width, height = self.image.size
if (
left >= right
or left >= width
or right <= 0
or top >= bottom
or top >= height
or bottom <= 0
):
raise BadImageOperationError(f"Invalid crop dimensions: {rect!r}")
# clamp to image boundaries
clamped_rect = (
max(0, left),
max(0, top),
min(right, width),
min(bottom, height),
)
return PillowImage(self.image.crop(clamped_rect))
@Image.operation
def rotate(self, angle):
"""
Accept a multiple of 90 to pass to the underlying Pillow function
to rotate the image.
"""
Image = _PIL_Image()
ORIENTATION_TO_TRANSPOSE = {
90: Image.Transpose.ROTATE_90,
180: Image.Transpose.ROTATE_180,
270: Image.Transpose.ROTATE_270,
}
modulo_angle = angle % 360
# is we're rotating a multiple of 360, it's the same as a no-op
if not modulo_angle:
return self
transpose_code = ORIENTATION_TO_TRANSPOSE.get(modulo_angle)
if not transpose_code:
raise UnsupportedRotation(
"Sorry - we only support right angle rotations - i.e. multiples of 90 degrees"
)
# We call "transpose", as it rotates the image,
# updating the height and width, whereas using 'rotate'
# only changes the contents of the image.
rotated = self.image.transpose(transpose_code)
return PillowImage(rotated)
@Image.operation
def set_background_color_rgb(self, color):
if not self.has_alpha():
# Don't change image that doesn't have an alpha channel
return self
# Check type of color
if not isinstance(color, (tuple, list)) or not len(color) == 3:
raise TypeError("the 'color' argument must be a 3-element tuple or list")
# Convert non-RGB colour formats to RGB
# As we only allow the background color to be passed in as RGB, we
# convert the format of the original image to match.
image = self.image.convert("RGBA")
# Generate a new image with background colour and draw existing image on top of it
# The new image must temporarily be RGBA in order for alpha_composite to work
new_image = _PIL_Image().new(
"RGBA", self.image.size, (color[0], color[1], color[2], 255)
)
if hasattr(new_image, "alpha_composite"):
new_image.alpha_composite(image)
else:
# Pillow < 4.2.0 fallback
# This method may be slower as the operation generates a new image
new_image = _PIL_Image().alpha_composite(new_image, image)
return PillowImage(new_image.convert("RGB"))
def get_icc_profile(self):
return self.image.info.get("icc_profile")
def get_exif_data(self):
return self.image.info.get("exif")
@Image.operation
def transform_colorspace_to_srgb(self, rendering_intent=0):
"""
Transforms the color of the image to fit inside sRGB color gamut using the
embedded ICC profile. The resulting image will always be in RGB(A) mode
and will have a small generic sRGB ICC profile embedded.
If the image does not have an ICC profile this operation is a no-op.
Images without a profile are commonly assumed to be in sRGB color space
already.
:param rendering_intent: Controls how out-of-gamut colors and handled.
Defaults to 0 (perceptual) because this is what Pillow defaults to.
:return: PillowImage in RGB mode
:raises: PIL.ImageCms.PyCMSError
Further reading:
* https://pillow.readthedocs.io/en/stable/reference/ImageCms.html#PIL.ImageCms.profileToProfile
* https://www.permajet.com/blog/rendering-intents-explained/
"""
icc_profile = self.get_icc_profile()
# Can't transform if there is no profile, no-op
if icc_profile is None:
return self
ImageCms = _PIL_ImageCms()
# ImageCmsProfile expects profile data to be file-like, give it BytesIO that quacks like a file 🦆
icc_profile = ImageCms.ImageCmsProfile(BytesIO(icc_profile))
# Output mode should always be RGB, unless the image has an alpha channel.
output_mode = "RGBA" if self.has_alpha() else "RGB"
# Attempt to convert from the embedded profile of the image to a generic sRGB one
image = ImageCms.profileToProfile(
self.image,
icc_profile,
ImageCms.createProfile("sRGB"),
renderingIntent=rendering_intent,
outputMode=output_mode,
)
return PillowImage(image)
@Image.operation
def save_as_jpeg(
self,
f,
quality: int = 85,
optimize: bool = False,
progressive: bool = False,
apply_optimizers: bool = True,
):
"""
Save the image as a JPEG file.
:param f: the file or file-like object to save to
:param quality: the image quality
:param optimize: Whether Pillow should optimize the file. When True, Pillow will
attempt to compress the palette by eliminating unused colors.
:param progressive: whether to save as progressive JPEG file.
:param apply_optimizers: controls whether to run any configured optimizer libraries
:return: JPEGImageFile
"""
if self.image.mode in ["1", "P"]:
image = self.image.convert("RGB")
else:
image = self.image
kwargs = {"quality": quality}
if optimize:
kwargs["optimize"] = True
if progressive:
kwargs["progressive"] = True
icc_profile = self.get_icc_profile()
if icc_profile is not None:
kwargs["icc_profile"] = icc_profile
exif_data = self.get_exif_data()
if exif_data is not None:
kwargs["exif"] = exif_data
image.save(f, "JPEG", **kwargs)
if apply_optimizers:
self.optimize(f, "jpeg")
return JPEGImageFile(f)
@Image.operation
def save_as_png(self, f, optimize: bool = False, apply_optimizers: bool = True):
"""
Save the image as a PNG file.
:param f: the file or file-like object to save to
:param optimize: Whether Pillow should optimize the file. When True, Pillow will
attempt to compress the palette by eliminating unused colors.
:param apply_optimizers: controls whether to run any configured optimizer libraries
:return: PNGImageFile
"""
kwargs = {}
image = self.image
icc_profile = self.get_icc_profile()
if icc_profile is not None:
# If the image is in CMYK mode *and* has an ICC profile, we need to be more diligent
# about how we handle the color conversion to RGB. We don't want to retain
# the color profile as-is because it is not meant for RGB images and
# will result in inaccurate colors. The transformation to sRGB should result
# in a more accurate representation of the original image, though
# it will likely not be perfect.
if self.image.mode == "CMYK":
pillow_image = self.transform_colorspace_to_srgb()
image = pillow_image.image
kwargs["icc_profile"] = pillow_image.get_icc_profile()
else:
kwargs["icc_profile"] = icc_profile
elif image.mode == "CMYK":
image = image.convert("RGB")
# Pillow 11.2 and older used to silently clip 32-bit I-mode images to 16
# bits, which is the maximum PNG supports. This is silent behavior
# deprecated in Pillow 11.3 but we explicitly want to retain it for
# backwards compatibility.
# See: https://pillow.readthedocs.io/en/stable/releasenotes/11.3.0.html#saving-i-mode-images-as-png
if image.mode == "I":
image = image.convert("I;16")
# Pillow only checks presence of optimize kwarg, not its value
if optimize:
kwargs["optimize"] = True
exif_data = self.get_exif_data()
if exif_data is not None:
kwargs["exif"] = exif_data
image.save(f, "PNG", **kwargs)
if apply_optimizers:
self.optimize(f, "png")
return PNGImageFile(f)
@Image.operation
def save_as_gif(self, f, apply_optimizers: bool = True):
image = self.image
# All gif files use either the L or P mode but we sometimes convert them
# to RGB/RGBA to improve the quality of resizing. We must make sure that
# they are converted back before saving.
if image.mode not in ["L", "P"]:
image = image.convert("P", palette=_PIL_Image().Palette.ADAPTIVE)
kwargs = {}
if "transparency" in image.info:
kwargs["transparency"] = image.info["transparency"]
image.save(f, "GIF", **kwargs)
if apply_optimizers:
self.optimize(f, "gif")
return GIFImageFile(f)
@Image.operation
def save_as_webp(
self,
f,
quality: int = 80,
lossless: bool = False,
apply_optimizers: bool = True,
):
"""
Save the image as a WEBP file.
:param f: the file or file-like object to save to
:param quality: the image quality
:param lossless: whether to save as lossless WEBP file.
:param apply_optimizers: controls whether to run any configured optimizer libraries.
Note that when lossless=True, this will be ignored.
:return: WebPImageFile
"""
kwargs = {"quality": quality, "lossless": lossless}
image = self.image
icc_profile = self.get_icc_profile()
if icc_profile is not None:
# If the image is in CMYK mode *and* has an ICC profile, we need to be more diligent
# about how we handle the color space. WEBP will encode as RGB so we need to do extra
# work to ensure the colors are as accurate as possible. We don't want to retain
# the color profile as-is because it is not meant for RGB images and
# will result in inaccurate colors. The transformation to sRGB should result
# in a more accurate representation of the original image, though
# it will likely not be perfect.
if image.mode == "CMYK":
pillow_image = self.transform_colorspace_to_srgb()
image = pillow_image.image
kwargs["icc_profile"] = pillow_image.get_icc_profile()
else:
kwargs["icc_profile"] = icc_profile
image.save(f, "WEBP", **kwargs)
if apply_optimizers and not lossless:
self.optimize(f, "webp")
return WebPImageFile(f)
@Image.operation
def save_as_heic(
self,
f,
quality: int = 80,
lossless: bool = False,
apply_optimizers: bool = True,
):
"""
Save the image as a HEIC file.
:param f: the file or file-like object to save to
:param quality: the image quality
:param lossless: whether to save as lossless HEIC/HEIF file.
:param apply_optimizers: controls whether to run any configured optimizer libraries.
Note that when lossless=True, this will be ignored.
:return: HeicImageFile
"""
kwargs = {"quality": quality}
if lossless:
kwargs = {"quality": -1, "chroma": 444}
image = self.image
icc_profile = self.get_icc_profile()
if icc_profile is not None:
# If the image is in CMYK mode *and* has an ICC profile, we need to be more diligent
# about how we handle the color space. HEIC will encode as RGB so we need to do extra
# work to ensure the colors are as accurate as possible. We don't want to retain
# the color profile as-is because it is not meant for RGB images and
# will result in inaccurate colors. The transformation to sRGB should result
# in a more accurate representation of the original image, though
# it will likely not be perfect.
if image.mode == "CMYK":
pillow_image = self.transform_colorspace_to_srgb()
image = pillow_image.image
kwargs["icc_profile"] = pillow_image.get_icc_profile()
else:
kwargs["icc_profile"] = icc_profile
image.save(f, "HEIF", **kwargs)
if not lossless and apply_optimizers:
self.optimize(f, "heic")
return HeicImageFile(f)
@Image.operation
def save_as_avif(self, f, quality=80, lossless=False, apply_optimizers=True):
kwargs = {"quality": quality}
if lossless:
kwargs = {
# Quality of 100 implies lossless (according to libavif documentation)
"quality": 100,
# When encoding lossless images, don't use chroma subsampling (4:4:4 retains the most information)
"subsampling": "4:4:4",
}
image = self.image
icc_profile = self.get_icc_profile()
if icc_profile is not None:
# If the image is in CMYK mode *and* has an ICC profile, we need to be more diligent
# about how we handle the color space. AVIF will encode as RGB so we need to do extra
# work to ensure the colors are as accurate as possible. We don't want to retain
# the color profile as-is because it is not meant for RGB images and
# will result in inaccurate colors. The transformation to sRGB should result
# in a more accurate representation of the original image, though
# it will likely not be perfect.
if image.mode == "CMYK":
pillow_image = self.transform_colorspace_to_srgb()
image = pillow_image.image
kwargs["icc_profile"] = pillow_image.get_icc_profile()
else:
kwargs["icc_profile"] = icc_profile
image.save(f, "AVIF", **kwargs)
if not lossless and apply_optimizers:
self.optimize(f, "heic")
return AvifImageFile(f)
@Image.operation
def save_as_ico(self, f, apply_optimizers=True):
self.image.save(f, "ICO")
if apply_optimizers:
self.optimize(f, "ico")
return IcoImageFile(f)
@Image.operation
def auto_orient(self):
# JPEG files can be orientated using an EXIF tag.
# Make sure this orientation is applied to the data
from PIL import ImageOps
image = ImageOps.exif_transpose(self.image)
return PillowImage(image)
@Image.operation
def get_pillow_image(self):
return self.image
@classmethod
@Image.converter_from(JPEGImageFile)
@Image.converter_from(PNGImageFile)
@Image.converter_from(GIFImageFile, cost=200)
@Image.converter_from(BMPImageFile)
@Image.converter_from(TIFFImageFile)
@Image.converter_from(WebPImageFile)
@Image.converter_from(HeicImageFile)
@Image.converter_from(AvifImageFile)
@Image.converter_from(IcoImageFile)
def open(cls, image_file):
image_file.f.seek(0)
image = _PIL_Image().open(image_file.f)
image.load()
return cls(image)
@Image.converter_to(RGBImageBuffer)
def to_buffer_rgb(self):
image = self.image
if image.mode != "RGB":
image = image.convert("RGB")
return RGBImageBuffer(image.size, image.tobytes())
@Image.converter_to(RGBAImageBuffer)
def to_buffer_rgba(self):
image = self.image
if image.mode != "RGBA":
image = image.convert("RGBA")
return RGBAImageBuffer(image.size, image.tobytes())
willow_image_classes = [PillowImage]