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

362 lines
8.9 KiB
Python
Raw Normal View History

2025-07-25 21:32:16 +10:00
import os
import re
from io import BytesIO
from shutil import copyfileobj
from tempfile import NamedTemporaryFile, SpooledTemporaryFile
from typing import Optional
import filetype
from defusedxml import ElementTree
from filetype.types import image as image_types
from .registry import registry
class UnrecognisedImageFormatError(IOError):
pass
class BadImageOperationError(ValueError):
"""
Raised when the arguments to an image operation are invalid,
e.g. a crop where the left coordinate is greater than the right coordinate
"""
pass
class Image:
@classmethod
def check(cls):
pass
@staticmethod
def operation(func):
func._willow_operation = True
return func
@staticmethod
def converter_to(to_class, cost=None):
def wrapper(func):
func._willow_converter_to = (to_class, cost)
return func
return wrapper
@staticmethod
def converter_from(from_class, cost=None):
def wrapper(func):
if not hasattr(func, "_willow_converter_from"):
func._willow_converter_from = []
if isinstance(from_class, list):
func._willow_converter_from.extend([(sc, cost) for sc in from_class])
else:
func._willow_converter_from.append((from_class, cost))
return func
return wrapper
def __getattr__(self, attr):
try:
operation, _, conversion_path, _ = registry.find_operation(type(self), attr)
except LookupError:
# Operation doesn't exist
raise AttributeError(
f"{self.__class__.__name__!r} object has no attribute {attr!r}"
)
def wrapper(*args, **kwargs):
image = self
for converter, _ in conversion_path:
image = converter(image)
return operation(image, *args, **kwargs)
return wrapper
# A couple of helpful methods
@classmethod
def open(cls, f):
# Detect image format
image_format = filetype.guess_extension(f)
if image_format is None and cls.maybe_xml(f):
image_format = "svg"
# Find initial class
initial_class = INITIAL_IMAGE_CLASSES.get(image_format)
if not initial_class:
if image_format:
raise UnrecognisedImageFormatError(
f"Cannot load {image_format} images ({INITIAL_IMAGE_CLASSES!r})"
)
else:
raise UnrecognisedImageFormatError("Unknown image format")
return initial_class(f)
@classmethod
def maybe_xml(cls, f):
# Check if it looks like an XML doc, it will be validated
# properly when we parse it in SvgImageFile
f.seek(0)
pattern = re.compile(rb"^\s*<")
for line in f:
if pattern.match(line):
f.seek(0)
return True
f.seek(0)
return False
def save(
self, image_format, output, apply_optimizers=True
) -> Optional["ImageFile"]:
# Get operation name
if image_format not in [
"jpeg",
"png",
"gif",
"bmp",
"tiff",
"webp",
"svg",
"heic",
"avif",
"ico",
]:
raise ValueError(f"Unknown image format: {image_format}")
operation_name = "save_as_" + image_format
return getattr(self, operation_name)(output, apply_optimizers=apply_optimizers)
def optimize(self, image_file, image_format: str):
"""
Runs all available optimizers for the given image format on the given image file.
If the passed image file is a SpooledTemporaryFile or just bytes, we are converting it to a
NamedTemporaryFile to guarantee we can access the file so the optimizers to work on it.
If we get a string, we assume it's a path to a file, and will attempt to load it from
the file system.
"""
optimizers = registry.get_optimizers_for_format(image_format)
if not optimizers:
return
named_file_created = False
try:
if isinstance(image_file, (SpooledTemporaryFile, BytesIO)):
with NamedTemporaryFile(delete=False) as named_file:
named_file_created = True
image_file.seek(0)
copyfileobj(image_file, named_file)
file_path = named_file.name
elif hasattr(image_file, "name"):
file_path = image_file.name
elif isinstance(image_file, str):
file_path = image_file
elif isinstance(image_file, bytes):
with NamedTemporaryFile(delete=False) as named_file:
named_file.write(image_file)
file_path = named_file.name
named_file_created = True
else:
raise TypeError(
f"Cannot optimise {type(image_file)}. It must be a readable object, or a path to a file"
)
for optimizer in optimizers:
optimizer.process(file_path)
if hasattr(image_file, "seek"):
# rewind and replace the image file with the optimized version
image_file.seek(0)
with open(file_path, "rb") as f:
copyfileobj(f, image_file)
if hasattr(image_file, "truncate"):
image_file.truncate() # bring the file size down to the actual image size
finally:
if named_file_created:
os.unlink(file_path)
class ImageBuffer(Image):
def __init__(self, size, data):
self.size = size
self.data = data
@Image.operation
def get_size(self):
return self.size
class RGBImageBuffer(ImageBuffer):
mode = "RGB"
@Image.operation
def has_alpha(self):
return False
@Image.operation
def has_animation(self):
return False
class RGBAImageBuffer(ImageBuffer):
mode = "RGBA"
@Image.operation
def has_alpha(self):
return True
@Image.operation
def has_animation(self):
return False
class ImageFile(Image):
@property
def format_name(self):
"""
Willow internal name for the image format
ImageFile implementations MUST override this.
"""
raise NotImplementedError
@property
def mime_type(self):
"""
Returns the MIME type of the image file
ImageFile implementations MUST override this.
"""
raise NotImplementedError
def __init__(self, f):
self.f = f
class JPEGImageFile(ImageFile):
@property
def format_name(self):
return "jpeg"
@property
def mime_type(self):
return "image/jpeg"
class PNGImageFile(ImageFile):
@property
def format_name(self):
return "png"
@property
def mime_type(self):
return "image/png"
class GIFImageFile(ImageFile):
@property
def format_name(self):
return "gif"
@property
def mime_type(self):
return "image/gif"
class BMPImageFile(ImageFile):
@property
def format_name(self):
return "bmp"
@property
def mime_type(self):
return "image/bmp"
class TIFFImageFile(ImageFile):
@property
def format_name(self):
return "tiff"
@property
def mime_type(self):
return "image/tiff"
class WebPImageFile(ImageFile):
@property
def format_name(self):
return "webp"
@property
def mime_type(self):
return "image/webp"
class SvgImageFile(ImageFile):
format_name = "svg"
mime_type = "image/svg+xml"
def __init__(self, f, dom=None):
if dom is None:
f.seek(0)
# Will raise xml.etree.ElementTree.ParseError if invalid
self.dom = ElementTree.parse(f)
f.seek(0)
else:
self.dom = dom
super().__init__(f)
class HeicImageFile(ImageFile):
@property
def format_name(self):
return "heic"
@property
def mime_type(self):
return "image/heic"
class AvifImageFile(ImageFile):
@property
def format_name(self):
return "avif"
@property
def mime_type(self):
return "image/avif"
class IcoImageFile(ImageFile):
format_name = "ico"
mime_type = "image/x-icon"
INITIAL_IMAGE_CLASSES = {
# A mapping of image formats to their initial class
image_types.Jpeg().extension: JPEGImageFile,
image_types.Png().extension: PNGImageFile,
image_types.Gif().extension: GIFImageFile,
image_types.Bmp().extension: BMPImageFile,
image_types.Tiff().extension: TIFFImageFile,
image_types.Webp().extension: WebPImageFile,
"svg": SvgImageFile,
image_types.Heic().extension: HeicImageFile,
image_types.Avif().extension: AvifImageFile,
image_types.Ico().extension: IcoImageFile,
}