Initial commit

This commit is contained in:
2024-08-27 20:33:44 +02:00
commit 1f1832267d
14794 changed files with 1599592 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
from willow.image import Image # noqa: F401
def setup():
from xml.etree import ElementTree
from willow.image import (
AvifImageFile,
BMPImageFile,
GIFImageFile,
HeicImageFile,
IcoImageFile,
JPEGImageFile,
PNGImageFile,
RGBAImageBuffer,
RGBImageBuffer,
SvgImageFile,
TIFFImageFile,
WebPImageFile,
)
from willow.optimizers import Cwebp, Gifsicle, Jpegoptim, Optipng, Pngquant
from willow.plugins import opencv, pillow, wand
from willow.registry import registry
from willow.svg import SvgImage
registry.register_image_class(JPEGImageFile)
registry.register_image_class(PNGImageFile)
registry.register_image_class(GIFImageFile)
registry.register_image_class(BMPImageFile)
registry.register_image_class(TIFFImageFile)
registry.register_image_class(WebPImageFile)
registry.register_image_class(HeicImageFile)
registry.register_image_class(RGBImageBuffer)
registry.register_image_class(RGBAImageBuffer)
registry.register_image_class(SvgImageFile)
registry.register_image_class(SvgImage)
registry.register_image_class(AvifImageFile)
registry.register_image_class(IcoImageFile)
registry.register_plugin(pillow)
registry.register_plugin(wand)
registry.register_plugin(opencv)
registry.register_optimizer(Cwebp)
registry.register_optimizer(Gifsicle)
registry.register_optimizer(Jpegoptim)
registry.register_optimizer(Optipng)
registry.register_optimizer(Pngquant)
# Prevents etree from prefixing XML tag names with anonymous
# namespaces, e.g. "<ns0:svg ..."
ElementTree.register_namespace("", "http://www.w3.org/2000/svg")
setup()
__version__ = "1.8.0"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,358 @@
import os
import re
from io import BytesIO
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("Unknown image format: %s" % 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):
file = image_file._file
with NamedTemporaryFile(delete=False) as named_file:
if hasattr(file, "getvalue"): # e.g. BytesIO
named_file.write(file.getvalue())
else: # e.g. BufferedRandom
file.seek(0)
named_file.write(file.read())
file_path = named_file.name
named_file_created = True
elif isinstance(image_file, BytesIO):
with NamedTemporaryFile(delete=False) as named_file:
named_file.write(image_file.getvalue())
file_path = named_file.name
named_file_created = True
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
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:
image_file.write(f.read())
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,
}

View File

@@ -0,0 +1,5 @@
from .cwebp import Cwebp # noqa: F401
from .gifsicle import Gifsicle # noqa: F401
from .jpegoptim import Jpegoptim # noqa: F401
from .optipng import Optipng # noqa: F401
from .pngquant import Pngquant # noqa: F401

View File

@@ -0,0 +1,53 @@
import logging
import subprocess
from typing import ClassVar, List
logger = logging.getLogger("willow")
class OptimizerBase:
library_name: ClassVar[str] = ""
image_format: ClassVar[str] = ""
class Meta:
abstract = True
@classmethod
def applies_to(cls, image_format: str) -> bool:
return image_format.lower() == cls.image_format.lower()
@classmethod
def get_check_library_arguments(cls) -> List[str]:
"""
Return a list of arguments to check if the library exists.
Note: using --help by default as that usually returns a zero exit code
"""
return ["--help"]
@classmethod
def check_library(cls) -> bool:
args = [cls.library_name] + cls.get_check_library_arguments()
try:
subprocess.check_output(args, stderr=subprocess.STDOUT)
return True
except (FileNotFoundError, subprocess.CalledProcessError):
return False
@classmethod
def get_command_arguments(cls, file_path: str) -> List[str]:
"""Return a list of arguments for the given optimizer library."""
return []
@classmethod
def process(cls, file_path: str):
args = [cls.library_name] + cls.get_command_arguments(file_path)
try:
subprocess.check_output(args, stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as exc:
logger.exception(
"Error optimizing %s with the '%s' library with error: %s",
file_path,
cls.library_name,
exc.output,
)

View File

@@ -0,0 +1,34 @@
from typing import ClassVar, List
from .base import OptimizerBase
__all__ = ["Cwebp"]
class Cwebp(OptimizerBase):
"""https://developers.google.com/speed/webp/docs/cwebp"""
library_name: ClassVar[str] = "cwebp"
image_format: ClassVar[str] = "webp"
@classmethod
def get_check_library_arguments(cls) -> List[str]:
# running just cwebp gives basic infor and returns a zero exit code
return []
@classmethod
def get_command_arguments(
cls, file_path: str, progressive: bool = False
) -> List[str]:
return [
"-m",
"6", # inspect all encoding possibilities for best file size
"-mt", # use multithreading if possible
"-pass",
"10", # max number of passes
"-q",
"75", # compression factor. 100 produces the highest quality.
file_path,
"-o",
file_path,
]

View File

@@ -0,0 +1,20 @@
from typing import ClassVar, List
from .base import OptimizerBase
__all__ = ["Gifsicle"]
class Gifsicle(OptimizerBase):
"""http://www.lcdf.org/gifsicle/"""
library_name: ClassVar[str] = "gifsicle"
image_format: ClassVar[str] = "gif"
@classmethod
def get_command_arguments(cls, file_path: str) -> List[str]:
return [
"-b", # required parameter for the package
"-O3", # slowest, but produces best results
file_path, # the file
]

View File

@@ -0,0 +1,21 @@
from typing import ClassVar, List
from .base import OptimizerBase
__all__ = ["Jpegoptim"]
class Jpegoptim(OptimizerBase):
"""https://github.com/tjko/jpegoptim"""
library_name: ClassVar[str] = "jpegoptim"
image_format: ClassVar[str] = "jpeg"
@classmethod
def get_command_arguments(cls, file_path: str) -> List[str]:
return [
"--strip-all", # strip out all text information like comments and EXIF data
"--max=85", # set maximum quality
"--all-progressive", # make the resulting image progressive
file_path,
]

View File

@@ -0,0 +1,21 @@
from typing import ClassVar, List
from .base import OptimizerBase
__all__ = ["Optipng"]
class Optipng(OptimizerBase):
"""https://optipng.sourceforge.net/"""
library_name: ClassVar[str] = "optipng"
image_format: ClassVar[str] = "png"
@classmethod
def get_command_arguments(cls, file_path: str) -> List[str]:
return [
"-quiet",
"-o2", # optimization level 2 (out of 7)
"-i0", # non-interlaced, progressive scanned image
file_path, # the file
]

View File

@@ -0,0 +1,25 @@
from typing import ClassVar, List
from .base import OptimizerBase
__all__ = ["Pngquant"]
class Pngquant(OptimizerBase):
"""https://pngquant.org/"""
library_name: ClassVar[str] = "pngquant"
image_format: ClassVar[str] = "png"
@classmethod
def get_command_arguments(
cls, file_path: str, progressive: bool = False
) -> List[str]:
return [
"--force", # allow overwriting existing files
"--strip", # remove optional metadata
"--skip-if-larger",
file_path, # the file as input
"--output",
file_path, # the file as output
]

View File

@@ -0,0 +1,141 @@
import os
from willow.image import Image, RGBImageBuffer
def _cv2():
try:
import cv2
except ImportError:
from cv import cv2
return cv2
def _numpy():
import numpy
return numpy
class BaseOpenCVImage(Image):
def __init__(self, image, size):
self.image = image
self.size = size
@classmethod
def check(cls):
_cv2()
@Image.operation
def get_size(self):
return self.size
@Image.operation
def get_frame_count(self):
# Animation is not supported by OpenCV
return 1
@Image.operation
def has_alpha(self):
# Alpha is not supported by OpenCV
return False
@Image.operation
def has_animation(self):
# Animation is not supported by OpenCV
return False
class OpenCVColorImage(BaseOpenCVImage):
@classmethod
def check(cls):
super().check()
_numpy()
@classmethod
@Image.converter_from(RGBImageBuffer)
def from_buffer_rgb(cls, image_buffer):
"""
Converts a Color Image buffer into a numpy array suitable for use with OpenCV
"""
numpy = _numpy()
cv2 = _cv2()
image = numpy.frombuffer(image_buffer.data, dtype=numpy.uint8)
image = image.reshape(image_buffer.size[1], image_buffer.size[0], 3)
image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
return cls(image, image_buffer.size)
class OpenCVGrayscaleImage(BaseOpenCVImage):
face_haar_flags = 0
face_min_neighbors = 3
face_haar_scale = 1.1
face_min_size = (40, 40)
@Image.operation
def detect_features(self):
"""
Find interesting features of an image suitable for cropping to.
"""
numpy = _numpy()
cv2 = _cv2()
points = cv2.goodFeaturesToTrack(self.image, 20, 0.04, 1.0)
if points is None:
return []
else:
points = numpy.reshape(
points, (-1, 2)
) # Numpy returns it with an extra third dimension
return points.tolist()
@Image.operation
def detect_faces(self, cascade_filename="haarcascade_frontalface_alt2.xml"):
"""
Run OpenCV face detection on the image. Returns a list of coordinates representing a box around each face.
"""
cv2 = _cv2()
cascade_filename = self._find_cascade(cascade_filename)
cascade = cv2.CascadeClassifier(cascade_filename)
equalised_image = cv2.equalizeHist(self.image)
faces = cascade.detectMultiScale(
equalised_image,
self.face_haar_scale,
self.face_min_neighbors,
self.face_haar_flags,
self.face_min_size,
)
return [
(
face[0],
face[1],
face[0] + face[2],
face[1] + face[3],
)
for face in faces
]
def _find_cascade(self, cascade_filename):
"""
Find the requested OpenCV cascade file. If a relative path was provided, check local cascades directory.
"""
if not os.path.isabs(cascade_filename):
cascade_filename = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
"data/cascades",
cascade_filename,
)
return cascade_filename
@classmethod
@Image.converter_from(OpenCVColorImage)
def from_color(cls, colour_image):
"""
Convert OpenCVColorImage to an OpenCVGrayscaleImage.
"""
cv2 = _cv2()
image = cv2.cvtColor(colour_image.image, cv2.COLOR_BGR2GRAY)
return cls(image, colour_image.size)
willow_image_classes = [OpenCVColorImage, OpenCVGrayscaleImage]

View File

@@ -0,0 +1,513 @@
from io import BytesIO
try:
from pillow_heif import AvifImagePlugin, 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 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": -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. 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]

View File

@@ -0,0 +1,356 @@
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]

View File

@@ -0,0 +1,430 @@
from collections import defaultdict
from typing import TYPE_CHECKING, List
if TYPE_CHECKING:
from .optimizers import OptimizerBase
class UnrecognisedOperationError(LookupError):
"""
Raised when the operation isn't in any of the known image classes.
"""
pass
class UnavailableOperationError(LookupError):
"""
Raised when all the image classes the operation exists in are not available.
(most likely due to a missing image library.)
"""
pass
class UnroutableOperationError(LookupError):
"""
Raised when there is no way to convert the image into an image class that
supports the operation.
"""
pass
class WillowRegistry:
def __init__(self):
self._registered_image_classes = set()
self._unavailable_image_classes = {}
self._registered_operations = defaultdict(dict)
self._registered_converters = {}
self._registered_converter_costs = {}
self._registered_optimizers: List["OptimizerBase"] = []
def register_operation(self, image_class, operation_name, func):
self._registered_operations[image_class][operation_name] = func
def register_converter(self, from_image_class, to_image_class, func, cost=None):
self._registered_converters[from_image_class, to_image_class] = func
if cost is not None:
self._registered_converter_costs[from_image_class, to_image_class] = cost
def register_image_class(self, image_class):
self._registered_image_classes.add(image_class)
# Check the image class
try:
image_class.check()
except Exception as e: # noqa: BLE001
self._unavailable_image_classes[image_class] = e
# Find and register operations/converters
for attr in dir(image_class):
val = getattr(image_class, attr)
if hasattr(val, "_willow_operation"):
self.register_operation(image_class, val.__name__, val)
elif hasattr(val, "_willow_converter_to"):
self.register_converter(
image_class,
val._willow_converter_to[0],
val,
cost=val._willow_converter_to[1],
)
elif hasattr(val, "_willow_converter_from"):
for converter_from, cost in val._willow_converter_from:
self.register_converter(converter_from, image_class, val, cost=cost)
def register_plugin(self, plugin):
image_classes = getattr(plugin, "willow_image_classes", [])
operations = getattr(plugin, "willow_operations", [])
converters = getattr(plugin, "willow_converters", [])
for image_class in image_classes:
self.register_image_class(image_class)
for operation in operations:
self.register_operation(operation[0], operation[1], operation[2])
for converter in converters:
self.register_converter(converter[0], converter[1], converter[2])
def register_optimizer(self, optimizer_class: "OptimizerBase"):
"""Registers an optimizer class."""
try:
# try to check Django settings, if used in that context
from django.conf import settings
enabled_optimizers = getattr(settings, "WILLOW_OPTIMIZERS", False)
except ImportError:
# fall back to env vars.
import os
enabled_optimizers = os.environ.get("WILLOW_OPTIMIZERS", False)
if not enabled_optimizers:
# WILLOW_OPTIMIZERS is either not set, or is set to a false-y value, so skip registration
return
if isinstance(enabled_optimizers, str):
if enabled_optimizers.lower() == "false":
return
elif enabled_optimizers.lower() == "true":
enabled_optimizers = True
else:
enabled_optimizers = enabled_optimizers.split(",")
if enabled_optimizers is True:
add_optimizer = True
else:
add_optimizer = optimizer_class.library_name in enabled_optimizers
if (
add_optimizer
and optimizer_class.check_library()
and optimizer_class not in self._registered_optimizers
):
self._registered_optimizers.append(optimizer_class)
def get_operation(self, image_class, operation_name):
return self._registered_operations[image_class][operation_name]
def operation_exists(self, operation_name):
for image_class_operations in self._registered_operations.values():
if operation_name in image_class_operations:
return True
return False
def get_converter(self, from_image_class, to_image_class):
return self._registered_converters[from_image_class, to_image_class]
def get_converter_cost(self, from_image_class, to_image_class):
return self._registered_converter_costs.get(
(from_image_class, to_image_class), 100
)
def get_image_classes(self, with_operation=None, available=None):
image_classes = self._registered_image_classes.copy()
if with_operation:
image_classes = set(
filter(
lambda image_class: image_class in self._registered_operations
and with_operation in self._registered_operations[image_class],
image_classes,
)
)
if not image_classes:
raise UnrecognisedOperationError(
f"Could not find image class with the '{with_operation}' operation"
)
if available:
# Remove unavailable image classes
available_image_classes = image_classes - set(
self._unavailable_image_classes.keys()
)
# Raise error if all image classes failed the check
if not available_image_classes:
raise UnavailableOperationError(
"\n".join(
[
"The operation '{}' is available in the following image classes but they all raised errors:".format(
with_operation
)
]
+ [
"{image_class_name}: {error_message}".format(
image_class_name=image_class.__name__,
error_message=str(
self._unavailable_image_classes.get(
image_class, "Unknown error"
)
),
)
for image_class in image_classes
]
)
)
return available_image_classes
else:
return image_classes
def get_optimizers_for_format(self, image_format: str) -> List["OptimizerBase"]:
optimizers = []
for optimizer in self._registered_optimizers:
if optimizer.applies_to(image_format):
optimizers.append(optimizer)
return optimizers
# Routing
# In some cases, it may not be possible to convert directly between two
# image classes, so we need to use one or more intermediate classes in order
# to get to where we want to be.
# For example, the OpenCV plugin doesn't load JPEG images, so the image
# needs to be loaded into either Pillow or Wand first and converted to
# OpenCV.
# Using a routing algorithm, we're able to work out the best path to take.
def get_converters_from(self, from_image_class):
"""
Yields a tuple for each image class that can be directly converted
from the specified image classes. The tuple contains the converter
function and the image class.
For example:
>>> list(registry.get_converters_from(Pillow))
[
(convert_pillow_to_wand, Wand),
(save_as_jpeg, JpegFile)
...
]
"""
for (c_from, c_to), converter in self._registered_converters.items():
if c_from is from_image_class:
yield converter, c_to
def find_all_paths(self, start, end, path=[], seen_classes=set()):
"""
Returns all paths between two image classes.
Each path is a list of tuples representing the steps to take in order to
convert to the new class. Each tuple contains two items: The converter
function to call and the class that step converts to.
The order of the paths returned is undefined.
For example:
>>> registry.find_all_paths(JpegFile, OpenCV)
[
[
(load_jpeg_into_pillow, Pillow),
(convert_pillow_to_opencv, OpenCV)
],
[
(load_jpeg_into_wand, Wand),
(convert_wand_to_opencv, OpenCV)
]
]
"""
# Implementation based on https://www.python.org/doc/essays/graphs/
if start == end:
return [path]
if start in seen_classes:
return []
if (
start not in self._registered_image_classes
or start in self._unavailable_image_classes
):
return []
paths = []
for converter, next_class in self.get_converters_from(start):
if next_class not in path:
newpaths = self.find_all_paths(
next_class,
end,
path + [(converter, next_class)],
seen_classes.union({start}),
)
paths.extend(newpaths)
return paths
def get_path_cost(self, start, path):
"""
Costs up a path and returns the cost as an integer.
"""
last_class = start
total_cost = 0
for converter, next_class in path:
total_cost += self.get_converter_cost(last_class, next_class)
last_class = next_class
return total_cost
def find_shortest_path(self, start, end):
"""
Finds the shortest path between two image classes.
This is similar to the find_all_paths function, except it only returns
the path with the lowest cost.
"""
current_path = None
current_cost = None
for path in self.find_all_paths(start, end):
cost = self.get_path_cost(start, path)
if current_cost is None or cost < current_cost:
current_cost = cost
current_path = path
return current_path, current_cost
def find_closest_image_class(self, start, image_classes):
"""
Finds which of the specified image classes is the closest, based on the
sum of the costs for the conversions needed to convert the image into it.
"""
current_class = None
current_path = None
current_cost = None
for image_class in image_classes:
path, cost = self.find_shortest_path(start, image_class)
if cost is None:
# no path found, e.g. from BMP to SVG
continue
if current_cost is None or cost < current_cost:
current_class = image_class
current_cost = cost
current_path = path
return current_class, current_path, current_cost
def find_operation(self, from_class, operation_name):
"""
Finds an operation that can be used by an image in the specified from_class.
This function returns four values:
- The operation function
- The class which the operation is implemented on
- A path to convert the image into the correct class for the operation
- The total cost of all the conversions
The path (third value) is a list of two-element tuple. Each tuple contains
a function to call and a reference to the class that step converts to. See
below for an example.
How it works:
If the specified operation_name is implemented for from_class, that is returned
with an empty conversion path.
If the specified operation_name is implemented on another class (but not from_class)
that operation is returned with the conversion path to that new class.
If it's implemented on multiple image classes, the closest one is chosen (based
on the sum of the costs of each conversion step).
If the operation_name is not implemented anywhere, there is no route to
any image class that implements it or all the image classes that implement
it are unavailable, a LookupError will be raised.
Basic example:
>>> func, cls, path, cost = registry.find_operation(JPEGImageFile, 'resize')
>>> func
PillowImage.resize
>>> cls
PillowImage
>>> path
[
(PillowImage.open, PillowImage)
]
>>> cost
100
To run the found operation on an image, run each conversion function on that
image then run the operation function:
>>> image = Image.open(...)
>>> func, cls, path, cost = registry.find_operation(type(image), operation_name)
>>> for converter, new_class in path:
... image = converter(image)
...
>>> func(image, *args, **kwargs)
"""
try:
# Firstly, we check if the operation is implemented on from_class
func = self.get_operation(from_class, operation_name)
cls = from_class
path = []
cost = 0
except LookupError:
# Not implemented on the current class. Find the closest, available,
# routable class that has it instead
image_classes = self.get_image_classes(
with_operation=operation_name, available=True
)
# Choose an image class
# image_classes will always have a value here as get_image_classes raises
# LookupError if there are no image classes available.
cls, path, cost = self.find_closest_image_class(from_class, image_classes)
if path is None:
raise UnroutableOperationError(
"The operation '{}' is available in the image class '{}'"
" but it can't be converted to from '{}'".format(
operation_name,
", ".join(
image_class.__name__ for image_class in image_classes
),
from_class.__name__,
)
)
# Get the operation function
func = self.get_operation(cls, operation_name)
return func, cls, path, cost
registry = WillowRegistry()

View File

@@ -0,0 +1,348 @@
import re
from collections import namedtuple
from copy import copy
from xml.etree.ElementTree import ElementTree
from .image import BadImageOperationError, Image, SvgImageFile
class WillowSvgException(Exception):
pass
class InvalidSvgAttribute(WillowSvgException):
pass
class InvalidSvgSizeAttribute(WillowSvgException):
pass
class SvgViewBoxParseError(WillowSvgException):
pass
ViewBox = namedtuple("ViewBox", "min_x min_y width height")
def view_box_to_attr_str(view_box):
return f"{view_box.min_x} {view_box.min_y} {view_box.width} {view_box.height}"
class ViewportToUserSpaceTransform:
def __init__(self, scale_x, scale_y, translate_x, translate_y):
self.scale_x = scale_x
self.scale_y = scale_y
self.translate_x = translate_x
self.translate_y = translate_y
def __repr__(self):
return (
f"{self.__class__.__name__}(scale_x={self.scale_x}, scale_y="
f"{self.scale_y}, translate_x={self.translate_x}, "
f"translate_y={self.translate_y})"
)
def __eq__(self, other):
if not isinstance(other, self.__class__):
return False
return (
self.scale_x == other.scale_x
and self.scale_y == other.scale_y
and self.translate_x == other.translate_x
and self.translate_y == other.translate_y
)
def __call__(self, rect):
left, top, right, bottom = rect
return (
(left + self.translate_x) / self.scale_x,
(top + self.translate_y) / self.scale_y,
(right + self.translate_x) / self.scale_x,
(bottom + self.translate_y) / self.scale_y,
)
def get_viewport_to_user_space_transform(
svg: "SvgImage",
) -> ViewportToUserSpaceTransform:
# cairosvg used as a reference
view_box = svg.image.view_box
preserve_aspect_ratio = svg.image.preserve_aspect_ratio.split()
try:
align, meet_or_slice = preserve_aspect_ratio
except ValueError:
align = preserve_aspect_ratio[0]
meet_or_slice = None
scale_x = svg.image.width / view_box.width
scale_y = svg.image.height / view_box.height
if align == "none":
# if align is "none", the viewBox will be scaled non-uniformly,
# so we keep and use both scale_x and scale_y
x_position = "min"
y_position = "min"
else:
x_position = align[1:4].lower()
y_position = align[5:].lower()
choose_coefficient = max if meet_or_slice == "slice" else min
# all values of preserveAspectRatio's `align', other than
# "none", force uniform scaling, so choose the appropriate
# coefficient and use it for scaling both axes
scale_x = scale_y = choose_coefficient(scale_x, scale_y)
# initial offsets to account for non-zero viewBox min-x and min-y
translate_x = view_box.min_x * scale_x
translate_y = view_box.min_y * scale_y
# adjust the offsets by the amount the viewBox has been translated
# to fit into the viewport (if any)
if x_position == "mid":
translate_x -= (svg.image.width - view_box.width * scale_x) / 2
elif x_position == "max":
translate_x -= svg.image.width - view_box.width * scale_x
if y_position == "mid":
translate_y -= (svg.image.height - view_box.height * scale_y) / 2
elif y_position == "max":
translate_y -= svg.image.height - view_box.height * scale_y
return ViewportToUserSpaceTransform(scale_x, scale_y, translate_x, translate_y)
class SvgWrapper:
# https://developer.mozilla.org/en-US/docs/Web/SVG/Content_type#length
UNIT_RE = re.compile(r"(?:em|ex|px|in|cm|mm|pt|pc|%)$")
# https://www.w3.org/TR/SVG11/types.html#DataTypeNumber
# https://www.w3.org/TR/2013/WD-SVG2-20130409/types.html#DataTypeNumber
# This will exclude some inputs that Python will accept (e.g. "1.e9", "1."),
# but for integration with other tools, we should adhere to the spec
NUMBER_PATTERN = r"([+-]?(?:\d*\.)?\d+(?:[Ee][+-]?\d+)?)"
# https://www.w3.org/Graphics/SVG/1.1/coords.html#ViewBoxAttribute
VIEW_BOX_RE = re.compile(
rf"^{NUMBER_PATTERN}(?:,\s*|\s+){NUMBER_PATTERN}(?:,\s*|\s+)"
rf"{NUMBER_PATTERN}(?:,\s*|\s+){NUMBER_PATTERN}$"
)
PRESERVE_ASPECT_RATIO_RE = re.compile(
r"^none$|^x(Min|Mid|Max)Y(Min|Mid|Max)(\s+(meet|slice))?$",
)
# Borrowed from cairosvg
COEFFICIENTS = {
"mm": 1 / 25.4,
"cm": 1 / 2.54,
"in": 1,
"pt": 1 / 72.0,
"pc": 1 / 6.0,
}
def __init__(self, dom: ElementTree, dpi=96, font_size_px=16):
self.dom = dom
self.dpi = dpi
self.font_size_px = font_size_px
self.view_box = self._get_view_box()
self.preserve_aspect_ratio = self._get_preserve_aspect_ratio()
width, width_unit = self._get_width()
height, height_unit = self._get_height()
# If one attr is missing or relative, we fall back to the other. After
# this either both will be valid, or neither will, which will be handled
# below. Relative width/height are treated as being undefined - so fall
# back first to the other attribute, then the viewBox, then the browser
# fallback. This gives us some flexibility for real world use cases, where
# SVGs may have a relative height, a relative width, or both
if width is None:
width = height
width_unit = height_unit
elif height is None:
height = width
height_unit = width_unit
elif width_unit == "%":
width = height
width_unit = height_unit
elif height_unit == "%":
height = width
height_unit = width_unit
# If the root svg element has no width, height, or viewBox attributes,
# emulate browser behaviour and set width and height to 300 and 150
# respectively, and set the viewBox to match
# (https://svgwg.org/specs/integration/#svg-css-sizing). This means we
# can always crop and resize without needing to rasterise
if width is None and height is None or width_unit == "%" and height_unit == "%":
if self.view_box is not None:
self.width = self.view_box.width
self.height = self.view_box.height
else:
self.width = 300
self.height = 150
else:
self.width = self._convert_to_px(width, width_unit)
self.height = self._convert_to_px(height, height_unit)
if self.view_box is None:
self.view_box = ViewBox(0, 0, self.width, self.height)
def __copy__(self):
# copy() called on ElementTree.Element makes a shallow copy (child
# elements are shared with the original) so is efficient enough - we
# only need to copy the root SVG element, as that is the only element
# we will mutate
dom = ElementTree(copy(self.dom.getroot()))
return self.__class__(dom, dpi=self.dpi, font_size_px=self.font_size_px)
@classmethod
def from_file(cls, f):
return cls(SvgImageFile(f).dom)
@property
def root(self):
return self.dom.getroot()
def _get_preserve_aspect_ratio(self):
value = self.root.get("preserveAspectRatio", "").strip()
if value == "":
return "xMidYMid meet"
if not self.PRESERVE_ASPECT_RATIO_RE.match(value):
raise InvalidSvgAttribute(
f"Unable to parse preserveAspectRatio value '{value}'"
)
return value
def _get_width(self):
attr_value = self.root.get("width")
if attr_value:
return self._parse_size(attr_value)
return None, None
def _get_height(self):
attr_value = self.root.get("height")
if attr_value:
return self._parse_size(attr_value)
return None, None
def _parse_size(self, raw_value):
clean_value = raw_value.strip()
match = self.UNIT_RE.search(clean_value)
unit = clean_value[match.start() :] if match else None
amount_raw = clean_value[: -len(unit)] if unit else clean_value
try:
amount = float(amount_raw)
except ValueError as err:
raise InvalidSvgSizeAttribute(
f"Unable to parse value from '{raw_value}'"
) from err
if amount <= 0:
raise InvalidSvgSizeAttribute(f"Negative or 0 sizes are invalid ({amount})")
return amount, unit
def _convert_to_px(self, size, unit):
if unit in (None, "px"):
return size
elif unit == "em":
return size * self.font_size_px
elif unit == "ex":
# This is not exactly correct, but it's the best we can do
return size * self.font_size_px / 2
else:
return size * self.dpi * self.COEFFICIENTS[unit]
def _get_view_box(self):
attr_value = self.root.get("viewBox")
if attr_value:
return self._parse_view_box(attr_value)
@classmethod
def _parse_view_box(cls, raw_value):
match = cls.VIEW_BOX_RE.match(raw_value.strip())
if match is None:
raise SvgViewBoxParseError(f"Unable to parse viewBox value '{raw_value}'")
return ViewBox(*map(float, match.groups()))
def set_root_attr(self, attr, value):
self.root.set(attr, str(value))
def set_width(self, width):
self.set_root_attr("width", width)
self.width = width
def set_height(self, height):
self.set_root_attr("height", height)
self.height = height
def set_view_box(self, view_box):
self.set_root_attr("viewBox", view_box_to_attr_str(view_box))
self.view_box = view_box
def write(self, f):
self.dom.write(f, encoding="utf-8")
class SvgImage(Image):
def __init__(self, image):
self.image: SvgWrapper = image
@Image.operation
def crop(self, rect, get_transformer=get_viewport_to_user_space_transform):
left, top, right, bottom = rect
if left >= right or top >= bottom:
raise BadImageOperationError(f"Invalid crop dimensions: {rect}")
viewport_width = right - left
viewport_height = bottom - top
transformed_rect = get_transformer(self)(rect)
left, top, right, bottom = transformed_rect
svg_wrapper = copy(self.image)
view_box_width = right - left
view_box_height = bottom - top
svg_wrapper.set_view_box(ViewBox(left, top, view_box_width, view_box_height))
svg_wrapper.set_width(viewport_width)
svg_wrapper.set_height(viewport_height)
return self.__class__(image=svg_wrapper)
@Image.operation
def resize(self, size):
new_width, new_height = size
if new_width < 1 or new_height < 1:
raise BadImageOperationError(f"Invalid resize dimensions: {size}")
svg_wrapper = copy(self.image)
svg_wrapper.set_width(new_width)
svg_wrapper.set_height(new_height)
return self.__class__(image=svg_wrapper)
@Image.operation
def get_size(self):
return (self.image.width, self.image.height)
@Image.operation
def auto_orient(self):
return self
@Image.operation
def has_animation(self):
return False
@Image.operation
def get_frame_count(self):
return 1
def write(self, f):
self.image.write(f)
f.seek(0)
@Image.operation
def save_as_svg(self, f):
self.write(f)
return SvgImageFile(f, dom=self.image.dom)
@classmethod
@Image.converter_from(SvgImageFile)
def open(cls, svg_image_file):
return cls(image=SvgWrapper(svg_image_file.dom))

View File

@@ -0,0 +1,2 @@
class RemovedInWillow17Warning(DeprecationWarning):
pass