356 lines
10 KiB
Python
356 lines
10 KiB
Python
import functools
|
|
from ctypes import c_char_p, c_void_p
|
|
|
|
from willow.image import (
|
|
AvifImageFile,
|
|
BadImageOperationError,
|
|
BMPImageFile,
|
|
GIFImageFile,
|
|
HeicImageFile,
|
|
IcoImageFile,
|
|
Image,
|
|
JPEGImageFile,
|
|
PNGImageFile,
|
|
RGBAImageBuffer,
|
|
RGBImageBuffer,
|
|
TIFFImageFile,
|
|
WebPImageFile,
|
|
)
|
|
|
|
|
|
class UnsupportedRotation(Exception):
|
|
pass
|
|
|
|
|
|
def _wand_image():
|
|
import wand.image
|
|
|
|
return wand.image
|
|
|
|
|
|
def _wand_color():
|
|
import wand.color
|
|
|
|
return wand.color
|
|
|
|
|
|
def _wand_api():
|
|
import wand.api
|
|
|
|
return wand.api
|
|
|
|
|
|
def _wand_version():
|
|
import wand.version
|
|
|
|
return wand.version
|
|
|
|
|
|
class WandImage(Image):
|
|
def __init__(self, image):
|
|
self.image = image
|
|
|
|
@classmethod
|
|
def check(cls):
|
|
_wand_image()
|
|
_wand_color()
|
|
_wand_api()
|
|
_wand_version()
|
|
|
|
def _clone(self):
|
|
return WandImage(self.image.clone())
|
|
|
|
@classmethod
|
|
def is_format_supported(cls, image_format):
|
|
return bool(_wand_version().formats(image_format))
|
|
|
|
@Image.operation
|
|
def get_size(self):
|
|
return self.image.size
|
|
|
|
@Image.operation
|
|
def get_frame_count(self):
|
|
return len(self.image.sequence)
|
|
|
|
@Image.operation
|
|
def has_alpha(self):
|
|
return self.image.alpha_channel
|
|
|
|
@Image.operation
|
|
def has_animation(self):
|
|
return self.image.animation
|
|
|
|
@Image.operation
|
|
def resize(self, size):
|
|
clone = self._clone()
|
|
clone.image.resize(size[0], size[1])
|
|
return clone
|
|
|
|
@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}")
|
|
|
|
clone = self._clone()
|
|
clone.image.crop(
|
|
# clamp to image boundaries
|
|
left=max(0, left),
|
|
top=max(0, top),
|
|
right=min(right, width),
|
|
bottom=min(bottom, height),
|
|
)
|
|
return clone
|
|
|
|
@Image.operation
|
|
def rotate(self, angle):
|
|
not_a_multiple_of_90 = angle % 90
|
|
|
|
if not_a_multiple_of_90:
|
|
raise UnsupportedRotation(
|
|
"Sorry - we only support right angle rotations - i.e. multiples of 90 degrees"
|
|
)
|
|
|
|
clone = self.image.clone()
|
|
clone.rotate(angle)
|
|
return WandImage(clone)
|
|
|
|
@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")
|
|
|
|
clone = self._clone()
|
|
|
|
# Wand will perform the compositing at the point of setting alpha_channel to 'remove'
|
|
clone.image.background_color = _wand_color().Color(
|
|
"rgb({}, {}, {})".format(*color)
|
|
)
|
|
clone.image.alpha_channel = "remove"
|
|
|
|
if clone.image.alpha_channel:
|
|
# ImageMagick <=6 fails to set alpha_channel to False, so do it manually
|
|
clone.image.alpha_channel = False
|
|
|
|
return clone
|
|
|
|
def get_icc_profile(self):
|
|
return self.image.profiles.get("icc")
|
|
|
|
def get_exif_data(self):
|
|
return self.image.profiles.get("exif")
|
|
|
|
@Image.operation
|
|
def save_as_jpeg(
|
|
self,
|
|
f,
|
|
quality: int = 85,
|
|
progressive: bool = False,
|
|
apply_optimizers: bool = True,
|
|
**kwargs,
|
|
):
|
|
"""
|
|
Save the image as a JPEG file.
|
|
|
|
:param f: the file or file-like object to save to
|
|
:param quality: the image quality
|
|
:param progressive: whether to save as progressive JPEG file.
|
|
:param apply_optimizers: controls whether to run any configured optimizer libraries
|
|
:return: JPEGImageFile
|
|
"""
|
|
with self.image.convert("pjpeg" if progressive else "jpeg") as converted:
|
|
converted.compression_quality = quality
|
|
|
|
icc_profile = self.get_icc_profile()
|
|
if icc_profile is not None:
|
|
converted.profiles["icc"] = icc_profile
|
|
|
|
exif_data = self.get_exif_data()
|
|
if exif_data is not None:
|
|
converted.profiles["exif"] = exif_data
|
|
|
|
converted.save(file=f)
|
|
|
|
if apply_optimizers:
|
|
self.optimize(f, "jpeg")
|
|
return JPEGImageFile(f)
|
|
|
|
@Image.operation
|
|
def save_as_png(self, f, apply_optimizers: bool = True, **kwargs):
|
|
"""
|
|
Save the image as a PNG file.
|
|
|
|
:param f: the file or file-like object to save to
|
|
:param apply_optimizers: controls whether to run any configured optimizer libraries
|
|
:return: PNGImageFile
|
|
"""
|
|
with self.image.convert("png") as converted:
|
|
exif_data = self.get_exif_data()
|
|
if exif_data is not None:
|
|
converted.profiles["exif"] = exif_data
|
|
|
|
converted.save(file=f)
|
|
|
|
if apply_optimizers:
|
|
self.optimize(f, "png")
|
|
return PNGImageFile(f)
|
|
|
|
@Image.operation
|
|
def save_as_gif(self, f, apply_optimizers: bool = True):
|
|
with self.image.convert("gif") as converted:
|
|
converted.save(file=f)
|
|
|
|
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
|
|
"""
|
|
with self.image.convert("webp") as converted:
|
|
if lossless:
|
|
library = _wand_api().library
|
|
library.MagickSetOption.argtypes = [c_void_p, c_char_p, c_char_p]
|
|
library.MagickSetOption(
|
|
converted.wand,
|
|
b"webp:lossless",
|
|
b"true",
|
|
)
|
|
else:
|
|
converted.compression_quality = quality
|
|
|
|
icc_profile = self.get_icc_profile()
|
|
if icc_profile is not None:
|
|
converted.profiles["icc"] = icc_profile
|
|
|
|
converted.save(file=f)
|
|
|
|
if not lossless and apply_optimizers:
|
|
self.optimize(f, "webp")
|
|
return WebPImageFile(f)
|
|
|
|
@Image.operation
|
|
def save_as_avif(self, f, quality=80, lossless=False, apply_optimizers=True):
|
|
with self.image.convert("avif") as converted:
|
|
if lossless:
|
|
converted.compression_quality = 100
|
|
library = _wand_api().library
|
|
library.MagickSetOption.argtypes = [c_void_p, c_char_p, c_char_p]
|
|
library.MagickSetOption(
|
|
converted.wand,
|
|
b"heic:lossless",
|
|
b"true",
|
|
)
|
|
else:
|
|
converted.compression_quality = quality
|
|
converted.save(file=f)
|
|
|
|
if not lossless and apply_optimizers:
|
|
self.optimize(f, "avif")
|
|
|
|
return AvifImageFile(f)
|
|
|
|
@Image.operation
|
|
def save_as_ico(self, f, apply_optimizers=True):
|
|
with self.image.convert("ico") as converted:
|
|
converted.save(file=f)
|
|
|
|
if apply_optimizers:
|
|
self.optimize(f, "ico")
|
|
|
|
return IcoImageFile(f)
|
|
|
|
@Image.operation
|
|
def auto_orient(self):
|
|
image = self.image
|
|
|
|
if image.orientation not in ["top_left", "undefined"]:
|
|
image = image.clone()
|
|
if hasattr(image, "auto_orient"):
|
|
# Wand 0.4.1 +
|
|
image.auto_orient()
|
|
else:
|
|
orientation_ops = {
|
|
"top_right": [image.flop],
|
|
"bottom_right": [functools.partial(image.rotate, degree=180.0)],
|
|
"bottom_left": [image.flip],
|
|
"left_top": [
|
|
image.flip,
|
|
functools.partial(image.rotate, degree=90.0),
|
|
],
|
|
"right_top": [functools.partial(image.rotate, degree=90.0)],
|
|
"right_bottom": [
|
|
image.flop,
|
|
functools.partial(image.rotate, degree=90.0),
|
|
],
|
|
"left_bottom": [functools.partial(image.rotate, degree=270.0)],
|
|
}
|
|
fns = orientation_ops.get(image.orientation)
|
|
|
|
if fns:
|
|
for fn in fns:
|
|
fn()
|
|
|
|
image.orientation = "top_left"
|
|
|
|
return WandImage(image)
|
|
|
|
@Image.operation
|
|
def get_wand_image(self):
|
|
return self.image
|
|
|
|
@classmethod
|
|
@Image.converter_from(JPEGImageFile, cost=150)
|
|
@Image.converter_from(PNGImageFile, cost=150)
|
|
@Image.converter_from(GIFImageFile, cost=150)
|
|
@Image.converter_from(BMPImageFile, cost=150)
|
|
@Image.converter_from(TIFFImageFile, cost=150)
|
|
@Image.converter_from(WebPImageFile, cost=150)
|
|
@Image.converter_from(HeicImageFile, cost=150)
|
|
@Image.converter_from(AvifImageFile, cost=150)
|
|
@Image.converter_from(IcoImageFile, cost=150)
|
|
def open(cls, image_file):
|
|
image_file.f.seek(0)
|
|
image = _wand_image().Image(file=image_file.f)
|
|
image.wand = _wand_api().library.MagickCoalesceImages(image.wand)
|
|
|
|
return cls(image)
|
|
|
|
@Image.converter_to(RGBImageBuffer)
|
|
def to_buffer_rgb(self):
|
|
return RGBImageBuffer(self.image.size, self.image.make_blob("RGB"))
|
|
|
|
@Image.converter_to(RGBAImageBuffer)
|
|
def to_buffer_rgba(self):
|
|
return RGBImageBuffer(self.image.size, self.image.make_blob("RGBA"))
|
|
|
|
|
|
willow_image_classes = [WandImage]
|