import inspect from wagtail.images.exceptions import InvalidFilterSpecError from wagtail.images.rect import Rect, Vector from wagtail.images.utils import parse_color_string class Operation: def __init__(self, method, *args): self.method = method self.args = args # Check arguments try: inspect.getcallargs(self.construct, *args) except TypeError as e: raise InvalidFilterSpecError(e) # Call construct try: self.construct(*args) except ValueError as e: raise InvalidFilterSpecError(e) def construct(self, *args): raise NotImplementedError # Transforms class ImageTransform: """ Tracks transformations that are performed on an image. This allows multiple transforms to be processed in a single operation and also accumulates the operations into a single scale/offset which can be used for features such as transforming the focal point of the image. """ def __init__(self, size, image_is_svg=False): self._check_size(size, allow_floating_point=image_is_svg) self.image_is_svg = image_is_svg self.size = size self.scale = (1.0, 1.0) self.offset = (0.0, 0.0) def clone(self): clone = ImageTransform(self.size, self.image_is_svg) clone.scale = self.scale clone.offset = self.offset return clone def resize(self, size): """ Change the image size, stretching the transform to make it fit the new size. """ self._check_size(size, allow_floating_point=self.image_is_svg) clone = self.clone() clone.scale = ( clone.scale[0] * size[0] / self.size[0], clone.scale[1] * size[1] / self.size[1], ) clone.size = size return clone def crop(self, rect): """ Crop the image to the specified rect. """ self._check_size(tuple(rect.size), allow_floating_point=self.image_is_svg) # Transform the image so the top left of the rect is at (0, 0), then set the size clone = self.clone() clone.offset = ( clone.offset[0] - rect.left / self.scale[0], clone.offset[1] - rect.top / self.scale[1], ) clone.size = tuple(rect.size) return clone def transform_vector(self, vector): """ Transforms the given vector into the coordinate space of the final image. Use this to find out where a point on the source image would end up in the final image after cropping/resizing has been performed. Returns a new vector. """ return Vector( (vector.x + self.offset[0]) * self.scale[0], (vector.y + self.offset[1]) * self.scale[1], ) def untransform_vector(self, vector): """ Transforms the given vector back to the coordinate space of the source image. This performs the inverse of `transform_vector`. Use this to find where a point in the final cropped/resized image originated from in the source image. Returns a new vector. """ return Vector( vector.x / self.scale[0] - self.offset[0], vector.y / self.scale[1] - self.offset[1], ) def get_rect(self): """ Returns a Rect representing the region of the original image to be cropped. """ return Rect( -self.offset[0], -self.offset[1], -self.offset[0] + self.size[0] / self.scale[0], -self.offset[1] + self.size[1] / self.scale[1], ) @staticmethod def _check_size(size, allow_floating_point=False): if not isinstance(size, tuple) or len(size) != 2: raise TypeError("Image size must be a 2-tuple") if not allow_floating_point and ( int(size[0]) != size[0] or int(size[1]) != size[1] ): raise TypeError("Image size must be a 2-tuple of integers") if size[0] < 1 or size[1] < 1: raise ValueError("Image width and height must both be 1 or greater") class TransformOperation(Operation): def run(self, image, transform): raise NotImplementedError class FillOperation(TransformOperation): vary_fields = ( "focal_point_width", "focal_point_height", "focal_point_x", "focal_point_y", ) def construct(self, size, *extra): # Get width and height width_str, height_str = size.split("x") self.width = int(width_str) self.height = int(height_str) # Crop closeness self.crop_closeness = 0 for extra_part in extra: if extra_part.startswith("c"): self.crop_closeness = int(extra_part[1:]) else: raise ValueError("Unrecognised filter spec part: %s" % extra_part) # Divide it by 100 (as it's a percentage) self.crop_closeness /= 100 # Clamp it if self.crop_closeness > 1: self.crop_closeness = 1 def run(self, transform, image): image_width, image_height = transform.size focal_point = image.get_focal_point() # Get crop aspect ratio crop_aspect_ratio = self.width / self.height # Get crop max crop_max_scale = min(image_width, image_height * crop_aspect_ratio) crop_max_width = crop_max_scale crop_max_height = crop_max_scale / crop_aspect_ratio # Initialise crop width and height to max crop_width = crop_max_width crop_height = crop_max_height # Use crop closeness to zoom in if focal_point is not None: # Get crop min crop_min_scale = max( focal_point.width, focal_point.height * crop_aspect_ratio ) crop_min_width = crop_min_scale crop_min_height = crop_min_scale / crop_aspect_ratio # Sometimes, the focal point may be bigger than the image... if not crop_min_scale >= crop_max_scale: # Calculate max crop closeness to prevent upscaling max_crop_closeness = max( 1 - (self.width - crop_min_width) / (crop_max_width - crop_min_width), 1 - (self.height - crop_min_height) / (crop_max_height - crop_min_height), ) # Apply max crop closeness crop_closeness = min(self.crop_closeness, max_crop_closeness) if 1 >= crop_closeness >= 0: # Get crop width and height crop_width = ( crop_max_width + (crop_min_width - crop_max_width) * crop_closeness ) crop_height = ( crop_max_height + (crop_min_height - crop_max_height) * crop_closeness ) # Find focal point UV if focal_point is not None: fp_x, fp_y = focal_point.centroid else: # Fall back to positioning in the centre fp_x = image_width / 2 fp_y = image_height / 2 fp_u = fp_x / image_width fp_v = fp_y / image_height # Position crop box based on focal point UV crop_x = fp_x - (fp_u - 0.5) * crop_width crop_y = fp_y - (fp_v - 0.5) * crop_height # Convert crop box into rect rect = Rect.from_point(crop_x, crop_y, crop_width, crop_height) # Make sure the entire focal point is in the crop box if focal_point is not None: rect = rect.move_to_cover(focal_point) # Don't allow the crop box to go over the image boundary rect = rect.move_to_clamp(Rect(0, 0, image_width, image_height)) # Crop! transform = transform.crop(rect.round()) # Get scale for resizing # The scale should be the same for both the horizontal and # vertical axes aftercrop_width, aftercrop_height = transform.size scale = self.width / aftercrop_width # Only resize if the image is too big if scale < 1.0: # Resize! transform = transform.resize((self.width, self.height)) return transform class MinMaxOperation(TransformOperation): def construct(self, size): # Get width and height width_str, height_str = size.split("x") self.width = int(width_str) self.height = int(height_str) def run(self, transform, image): image_width, image_height = transform.size horz_scale = self.width / image_width vert_scale = self.height / image_height if self.method == "min": if image_width <= self.width or image_height <= self.height: return transform if horz_scale > vert_scale: width = self.width height = int(image_height * horz_scale) else: width = int(image_width * vert_scale) height = self.height elif self.method == "max": if image_width <= self.width and image_height <= self.height: return transform if horz_scale < vert_scale: width = self.width height = int(image_height * horz_scale) else: width = int(image_width * vert_scale) height = self.height else: # Unknown method return transform # prevent zero width or height, it causes a ValueError on transform.resize width = width if width > 0 else 1 height = height if height > 0 else 1 return transform.resize((width, height)) class WidthHeightOperation(TransformOperation): def construct(self, size): self.size = int(size) def run(self, transform, image): image_width, image_height = transform.size if self.method == "width": if image_width <= self.size: return transform scale = self.size / image_width width = self.size height = int(image_height * scale) elif self.method == "height": if image_height <= self.size: return transform scale = self.size / image_height width = int(image_width * scale) height = self.size else: # Unknown method return transform # prevent zero width or height, it causes a ValueError on transform.resize width = width if width > 0 else 1 height = height if height > 0 else 1 return transform.resize((width, height)) class ScaleOperation(TransformOperation): def construct(self, percent): self.percent = float(percent) def run(self, transform, image): image_width, image_height = transform.size scale = self.percent / 100 width = int(image_width * scale) height = int(image_height * scale) # prevent zero width or height, it causes a ValueError on transform.resize width = width if width > 0 else 1 height = height if height > 0 else 1 return transform.resize((width, height)) # Filters class FilterOperation(Operation): def run(self, willow, image, env): raise NotImplementedError class DoNothingOperation(FilterOperation): def construct(self): pass def run(self, willow, image, env): return willow class JPEGQualityOperation(FilterOperation): def construct(self, quality): self.quality = int(quality) if self.quality > 100: raise ValueError("JPEG quality must not be higher than 100") def run(self, willow, image, env): env["jpeg-quality"] = self.quality class AvifQualityOperation(FilterOperation): def construct(self, quality): self.quality = int(quality) if self.quality > 100: raise ValueError("AVIF quality must not be higher than 100") def run(self, willow, image, env): env["avif-quality"] = self.quality class WebPQualityOperation(FilterOperation): def construct(self, quality): self.quality = int(quality) if self.quality > 100: raise ValueError("WebP quality must not be higher than 100") def run(self, willow, image, env): env["webp-quality"] = self.quality class FormatOperation(FilterOperation): supported_formats = ["jpeg", "png", "gif", "webp", "avif", "ico", "heic"] def construct(self, format, *options): self.format = format self.options = options if self.format not in self.supported_formats: raise ValueError( f"Format must be one of: {', '.join(self.supported_formats)}. Got: {self.format}" ) def run(self, willow, image, env): env["output-format"] = self.format env["output-format-options"] = self.options class BackgroundColorOperation(FilterOperation): def construct(self, color_string): self.color = parse_color_string(color_string) def run(self, willow, image, env): return willow.set_background_color_rgb(self.color)