New Upstream Release - willow
Ready changes
Summary
Merged new upstream version: 1.5 (was: 1.4).
Diff
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..ab5f51a
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,30 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ include:
+ - python: "3.7"
+ - python: "3.8"
+ - python: "3.9"
+ - python: "3.10"
+ - python: "3.11"
+ steps:
+ - uses: actions/checkout@v2
+ - name: Set up Python ${{ matrix.python }}
+ uses: actions/setup-python@v2
+ with:
+ python-version: ${{ matrix.python }}
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install Pillow Wand
+ pip install -e .[testing]
+ - name: Test
+ run: ./runtests.py
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 94b7353..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,23 +0,0 @@
-language: python
-
-# Test matrix
-python:
- - 2.7
- - 3.4
- - 3.5
- - 3.6
-
-matrix:
- include:
- - python: 3.7
- dist: xenial
- sudo: true
-
-# Package installation
-install:
- - pip install Pillow Wand
- - python setup.py install
-
-# Run the tests
-script:
- python runtests.py
diff --git a/Dockerfile.py2 b/Dockerfile.py2
deleted file mode 100644
index a87b749..0000000
--- a/Dockerfile.py2
+++ /dev/null
@@ -1,7 +0,0 @@
-FROM debian:jessie
-MAINTAINER Karl Hobley <karlhobley10@gmail.com>
-
-RUN apt-get update && apt-get install -y python python-opencv python-numpy python-pillow python-wand python-mock
-
-VOLUME ["/src"]
-WORKDIR /src
diff --git a/README.rst b/README.rst
index 548c120..5d8ceee 100644
--- a/README.rst
+++ b/README.rst
@@ -18,7 +18,7 @@ Willow is a simple image library that combines the APIs of `Pillow <https://pill
Willow currently has basic resize and crop operations, face and feature detection and animated GIF support. New operations and library integrations can also be `easily implemented <http://willow.readthedocs.org/en/latest/guide/extend.html>`_.
-It is written in pure-Python (versions 2.7, 3.3, 3.4, 3.5 and 3.6 are supported)
+It is written in pure-Python (versions 3.7, 3.8 3.9 and 3.10 are supported)
Examples
--------
diff --git a/debian/changelog b/debian/changelog
index d5e600c..8d6f733 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+willow (1.5-1) UNRELEASED; urgency=low
+
+ * New upstream release.
+
+ -- Debian Janitor <janitor@jelmer.uk> Sat, 29 Apr 2023 19:31:25 -0000
+
willow (1.4-3) unstable; urgency=medium
[ Debian Janitor ]
diff --git a/docker-test.sh b/docker-test.sh
index 3380ba5..77fbfb3 100755
--- a/docker-test.sh
+++ b/docker-test.sh
@@ -1,8 +1,5 @@
# Runs the tests using the provided Dockerfile
# Saves having to install OpenCV locally
-docker build -f Dockerfile.py2 -t willow-py2 .
-docker run --rm -ti -v $(pwd):/src willow-py2 python runtests.py --opencv
-
docker build -f Dockerfile.py3 -t willow-py3 .
docker run --rm -ti -v $(pwd):/src willow-py3 python3 runtests.py --opencv
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 6fd6859..9793d5b 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -1,6 +1,23 @@
Changelog
=========
+1.5 (29/03/2023)
+----------------
+
+ - Drop support for Python versions below 3.7
+ - Drop support for Pillow versions below 9.1 and fix Pillow 10 deprecation warnings (Alex Tomkins)
+ - Replace deprecated ``imghdr`` with ``filetype``. This allows detecting newer image formats such as HEIC (Herbert Poul)
+ - Add SVG support (Joshua Munn)
+ - Add HEIF support via the ``pillow-heif`` library (Alexander Piskun)
+
+
+1.4.1 (25/02/2022)
+------------------
+
+ - Drop support for Python 3.4
+ - Imagemagick 7 compatibility fixes (Matt Westcott)
+ - Fix: Implemented consistent behaviour between Pillow and Wand for out-of-bounds crop rectangles (Matt Westcott)
+
1.4 (26/05/2020)
----------------
diff --git a/docs/guide/operations.rst b/docs/guide/operations.rst
index 6e6b0dd..8ff1b47 100644
--- a/docs/guide/operations.rst
+++ b/docs/guide/operations.rst
@@ -57,6 +57,7 @@ It returns a new :class:`~Image` object containing the rotated image. The
original image is not modified.
.. code-block:: python
+
# in this case, assume 'i' is a 300x150 pixel image
i = i.rotate(90)
isinstance(i, Image)
diff --git a/docs/index.rst b/docs/index.rst
index f4b21fd..b818177 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -10,6 +10,10 @@ libraries are required (but you should have either Pillow or Wand installed to
use most features). It also has a plugin interface which allows you to add
support for more libraries, image formats and operations.
+Willow supports processing of SVG images without any additional libraries.
+Format conversion to or from SVG is not currently supported.
+
+
Index
=====
diff --git a/docs/installation.rst b/docs/installation.rst
index f89b6bd..80c0aa2 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -1,7 +1,7 @@
Installation
============
-Willow supports Python 2.7+ and 3.4+. It's a pure-python library with no hard
+Willow supports Python 3.7+. It's a pure-python library with no hard
dependencies so doesn't require a C compiler for a basic installation.
Installation using ``pip``
@@ -17,8 +17,8 @@ Installing underlying libraries
In order for most features of Willow to work, you need to install either Pillow
or Wand.
- - `Pillow installation <http://pillow.readthedocs.org/en/3.0.x/installation.html#basic-installation>`_
- - `Wand installation <http://docs.wand-py.org/en/0.4.2/guide/install.html>`_
+ - `Pillow installation <https://pillow.readthedocs.io/en/stable/installation.html#basic-installation>`_
+ - `Wand installation <https://docs.wand-py.org/en/stable/guide/install.html>`_
Note that Pillow doesn't support animated GIFs and Wand isn't as fast.
Installing both will give best results.
diff --git a/docs/reference.rst b/docs/reference.rst
index 732ce6c..3eada63 100644
--- a/docs/reference.rst
+++ b/docs/reference.rst
@@ -10,7 +10,7 @@ The ``Image`` class
.. classmethod:: open(file)
Opens the provided image file detects the format from the image header using
- Python's :mod:`imghdr` module.
+ Python's :mod:`filetype` module.
Returns a subclass of :class:`ImageFile`
@@ -126,7 +126,7 @@ Here's a full list of operations provided by Willow out of the box:
.. method:: resize(size)
- (Pillow/Wand only)
+ (supported natively for SVG, Pillow/Wand required for others)
Stretches the image to fit the specified size. Size must be a sequence of two integers:
@@ -137,16 +137,22 @@ Here's a full list of operations provided by Willow out of the box:
.. method:: crop(region)
- (Pillow/Wand only)
+ (supported natively for SVG, Pillow/Wand required for others)
Cuts out the specified region of the image. The region must be a sequence of
- four integers (top, left, right, bottom):
+ four integers (left, top, right, bottom):
.. code-block:: python
# Cut out a square from the middle of the image
cropped_image = source_image.resize((100, 100, 200, 200))
+ If the crop rectangle overlaps the image boundaries, it will be reduced to fit within those
+ boundaries, resulting in an output image smaller than specified. If the crop rectangle is
+ entirely outside the image, or the coordinates are out of range in any other way (such as
+ a left coordinate greater than the right coordinate), this throws a
+ :class:`willow.image.BadImageOperationError` (a subclass of :class:`ValueError`).
+
.. method:: set_background_color_rgb(color)
(Pillow/Wand only)
@@ -270,6 +276,35 @@ Here's a full list of operations provided by Willow out of the box:
with open('out.webp', 'wb') as f:
image.save_as_webp(f)
+
+.. method:: save_as_heic(file, quality=80, lossless=False)
+
+ (Pillow only; requires the pillow-heif library)
+
+ Saves the image to the specified file-like object in HEIF format.
+
+ returns a ``HeicImageFile`` wrapping the file.
+
+ .. code-block:: python
+
+ with open('out.heic', 'wb') as f:
+ image.save_as_heic(f)
+
+
+.. method:: save_as_svg(file)
+
+ (SVG images only)
+
+ Saves the image to the specified file-like object in SVG format.
+
+ returns a ``SvgImageFile`` wrapping the file.
+
+ .. code-block:: python
+
+ with open('out.svg', 'w') as f:
+ image.save_as_svg(f)
+
+
.. method:: get_pillow_image()
(Pillow only)
diff --git a/runtests.py b/runtests.py
old mode 100644
new mode 100755
index d5c7547..57f8503
--- a/runtests.py
+++ b/runtests.py
@@ -1,3 +1,5 @@
+#!/usr/bin/env python
+
import sys
import unittest
@@ -5,6 +7,8 @@ from tests.test_registry import *
from tests.test_pillow import *
from tests.test_wand import *
from tests.test_image import *
+from tests.test_svg_image import *
+from tests.test_svg_coordinate_transforms import *
if __name__ == '__main__':
diff --git a/setup.cfg b/setup.cfg
index 2a9acf1..dd38343 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,2 +1,2 @@
[bdist_wheel]
-universal = 1
+python-tag = py3
diff --git a/setup.py b/setup.py
index 3172197..3565509 100644
--- a/setup.py
+++ b/setup.py
@@ -20,7 +20,7 @@ except ImportError:
setup(
name='Willow',
- version='1.4',
+ version='1.5',
description='A Python image library that sits on top of Pillow, Wand and OpenCV',
author='Karl Hobley',
author_email='karl@kaed.uk',
@@ -36,14 +36,24 @@ setup(
'License :: OSI Approved :: BSD License',
'Operating System :: OS Independent',
'Programming Language :: Python',
- 'Programming Language :: Python :: 2',
- 'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
- 'Programming Language :: Python :: 3.4',
- 'Programming Language :: Python :: 3.5',
- 'Programming Language :: Python :: 3.6',
+ 'Programming Language :: Python :: 3 :: Only',
'Programming Language :: Python :: 3.7',
+ 'Programming Language :: Python :: 3.8',
+ 'Programming Language :: Python :: 3.9',
+ 'Programming Language :: Python :: 3.10',
+ 'Programming Language :: Python :: 3.11',
],
- install_requires=[],
+ python_requires='>=3.7',
+ install_requires=[
+ "filetype>=1.0.10,!=1.1.0",
+ "defusedxml>=0.7,<1.0",
+ ],
+ extras_require={"testing": [
+ "Pillow>=9.1.0,<11.0.0",
+ "Wand>=0.6,<1.0",
+ "mock>=3.0,<4.0",
+ "pillow-heif>=0.7.0,<1.0.0"
+ ]},
zip_safe=False,
)
diff --git a/tests/images/svg/layered-peaks-haikei.svg b/tests/images/svg/layered-peaks-haikei.svg
new file mode 100644
index 0000000..6c733d2
--- /dev/null
+++ b/tests/images/svg/layered-peaks-haikei.svg
@@ -0,0 +1 @@
+<svg id="visual" viewBox="0 0 900 600" width="900" height="600" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"><rect x="0" y="0" width="900" height="600" fill="#931C1C"></rect><path d="M0 367L129 356L257 438L386 358L514 443L643 405L771 439L900 438L900 601L771 601L643 601L514 601L386 601L257 601L129 601L0 601Z" fill="#f5730a"></path><path d="M0 454L129 445L257 458L386 407L514 457L643 444L771 467L900 475L900 601L771 601L643 601L514 601L386 601L257 601L129 601L0 601Z" fill="#da5b09"></path><path d="M0 451L129 463L257 506L386 477L514 444L643 456L771 508L900 466L900 601L771 601L643 601L514 601L386 601L257 601L129 601L0 601Z" fill="#be4407"></path><path d="M0 490L129 515L257 527L386 524L514 493L643 491L771 528L900 488L900 601L771 601L643 601L514 601L386 601L257 601L129 601L0 601Z" fill="#a32d04"></path><path d="M0 547L129 525L257 539L386 534L514 574L643 546L771 551L900 559L900 601L771 601L643 601L514 601L386 601L257 601L129 601L0 601Z" fill="#871400"></path></svg>
\ No newline at end of file
diff --git a/tests/images/tree.heic b/tests/images/tree.heic
new file mode 100644
index 0000000..41fb09b
Binary files /dev/null and b/tests/images/tree.heic differ
diff --git a/tests/test_image.py b/tests/test_image.py
index 4937603..c43f5b1 100644
--- a/tests/test_image.py
+++ b/tests/test_image.py
@@ -1,14 +1,32 @@
import io
import unittest
import mock
+import filetype
+from xml.etree.ElementTree import ParseError as XMLParseError
from willow.image import (
- Image, JPEGImageFile, PNGImageFile, GIFImageFile, UnrecognisedImageFormatError,
- BMPImageFile, TIFFImageFile
+ Image, ImageFile, JPEGImageFile, PNGImageFile, GIFImageFile, UnrecognisedImageFormatError,
+ BMPImageFile, TIFFImageFile, WebPImageFile, SvgImageFile, HeicImageFile
)
-class TestOpenImage(unittest.TestCase):
+class BrokenImageFileImplementation(ImageFile):
+ pass
+
+class TestImageFile(unittest.TestCase):
+
+ def test_image_format_must_be_implemented(self):
+ broken = BrokenImageFileImplementation(None)
+ with self.assertRaises(NotImplementedError):
+ broken.format_name
+
+ def test_mime_type_must_be_implemented(self):
+ broken = BrokenImageFileImplementation(None)
+ with self.assertRaises(NotImplementedError):
+ broken.mime_type
+
+
+class TestDetectImageFormatFromStream(unittest.TestCase):
"""
Tests that Image.open responds correctly to different image headers.
@@ -16,8 +34,6 @@ class TestOpenImage(unittest.TestCase):
these tests do not require valid images.
"""
def test_opens_jpeg(self):
- import imghdr
-
f = io.BytesIO()
f.write(b'\xff\xd8\xff\xe0\x00\x10JFIF\x00')
f.seek(0)
@@ -26,10 +42,9 @@ class TestOpenImage(unittest.TestCase):
self.assertIsInstance(image, JPEGImageFile)
self.assertEqual(image.format_name, 'jpeg')
self.assertEqual(image.original_format, 'jpeg')
+ self.assertEqual(image.mime_type, 'image/jpeg')
def test_opens_png(self):
- import imghdr
-
f = io.BytesIO()
f.write(b'\x89PNG\x0d\x0a\x1a\x0a')
f.seek(0)
@@ -38,10 +53,9 @@ class TestOpenImage(unittest.TestCase):
self.assertIsInstance(image, PNGImageFile)
self.assertEqual(image.format_name, 'png')
self.assertEqual(image.original_format, 'png')
+ self.assertEqual(image.mime_type, 'image/png')
def test_opens_gif(self):
- import imghdr
-
f = io.BytesIO()
f.write(b'GIF89a')
f.seek(0)
@@ -50,10 +64,9 @@ class TestOpenImage(unittest.TestCase):
self.assertIsInstance(image, GIFImageFile)
self.assertEqual(image.format_name, 'gif')
self.assertEqual(image.original_format, 'gif')
+ self.assertEqual(image.mime_type, 'image/gif')
def test_raises_error_on_invalid_header(self):
- import imghdr
-
f = io.BytesIO()
f.write(b'Not an image')
f.seek(0)
@@ -61,11 +74,52 @@ class TestOpenImage(unittest.TestCase):
with self.assertRaises(UnrecognisedImageFormatError) as e:
Image.open(f)
+ def test_opens_svg(self):
+ f = io.BytesIO(b"<svg></svg>")
+ image = Image.open(f)
+ self.assertIsInstance(image, SvgImageFile)
+ self.assertEqual(image.format_name, "svg")
+ self.assertEqual(image.original_format, "svg")
+
+ def test_invalid_svg_raises(self):
+ f = io.BytesIO(b"<svg><")
+ with self.assertRaises(XMLParseError):
+ Image.open(f)
+
class TestImageFormats(unittest.TestCase):
"""
Tests image formats that are not well covered by the remaining tests.
"""
+ def test_jpeg(self):
+ with open('tests/images/flower.jpg', 'rb') as f:
+ image = Image.open(f)
+ width, height = image.get_size()
+
+ self.assertIsInstance(image, JPEGImageFile)
+ self.assertEqual(width, 480)
+ self.assertEqual(height, 360)
+ self.assertEqual(image.mime_type, 'image/jpeg')
+
+ def test_png(self):
+ with open('tests/images/transparent.png', 'rb') as f:
+ image = Image.open(f)
+ width, height = image.get_size()
+
+ self.assertIsInstance(image, PNGImageFile)
+ self.assertEqual(width, 200)
+ self.assertEqual(height, 150)
+ self.assertEqual(image.mime_type, 'image/png')
+
+ def test_gif(self):
+ with open('tests/images/newtons_cradle.gif', 'rb') as f:
+ image = Image.open(f)
+ width, height = image.get_size()
+
+ self.assertIsInstance(image, GIFImageFile)
+ self.assertEqual(width, 480)
+ self.assertEqual(height, 360)
+ self.assertEqual(image.mime_type, 'image/gif')
def test_bmp(self):
with open('tests/images/sails.bmp', 'rb') as f:
@@ -75,6 +129,7 @@ class TestImageFormats(unittest.TestCase):
self.assertIsInstance(image, BMPImageFile)
self.assertEqual(width, 768)
self.assertEqual(height, 512)
+ self.assertEqual(image.mime_type, 'image/bmp')
def test_tiff(self):
with open('tests/images/cameraman.tif', 'rb') as f:
@@ -84,6 +139,27 @@ class TestImageFormats(unittest.TestCase):
self.assertIsInstance(image, TIFFImageFile)
self.assertEqual(width, 256)
self.assertEqual(height, 256)
+ self.assertEqual(image.mime_type, 'image/tiff')
+
+ def test_webp(self):
+ with open('tests/images/tree.webp', 'rb') as f:
+ image = Image.open(f)
+ width, height = image.get_size()
+
+ self.assertIsInstance(image, WebPImageFile)
+ self.assertEqual(width, 320)
+ self.assertEqual(height, 241)
+ self.assertEqual(image.mime_type, 'image/webp')
+
+ def test_heic(self):
+ with open('tests/images/tree.heic', 'rb') as f:
+ image = Image.open(f)
+ width, height = image.get_size()
+
+ self.assertIsInstance(image, HeicImageFile)
+ self.assertEqual(width, 320)
+ self.assertEqual(height, 241)
+ self.assertEqual(image.mime_type, 'image/heiс')
class TestSaveImage(unittest.TestCase):
@@ -99,6 +175,16 @@ class TestSaveImage(unittest.TestCase):
image.save("jpeg", "outfile")
image.save_as_jpeg.assert_called_with("outfile")
+ def test_save_as_heic(self):
+ with open('tests/images/sails.bmp', 'rb') as f:
+ image = Image.open(f)
+ buf = io.BytesIO()
+ image.save("heic", buf)
+ buf.seek(0)
+ image = Image.open(buf)
+ self.assertIsInstance(image, HeicImageFile)
+ self.assertEqual(image.mime_type, 'image/heiс')
+
def test_save_as_foo(self):
image = Image()
image.save_as_jpeg = mock.MagicMock()
@@ -111,23 +197,19 @@ class TestSaveImage(unittest.TestCase):
class TestImghdrJPEGPatch(unittest.TestCase):
def test_detects_photoshop3_jpeg(self):
- import imghdr
-
f = io.BytesIO()
f.write(b'\xff\xd8\xff\xed\x00,Photoshop 3.0\x00')
f.seek(0)
- image_format = imghdr.what(f)
+ image_format = filetype.guess_extension(f)
- self.assertEqual(image_format, 'jpeg')
+ self.assertEqual(image_format, 'jpg')
def test_junk(self):
- import imghdr
-
f = io.BytesIO()
f.write(b'Not an image')
f.seek(0)
- image_format = imghdr.what(f)
+ image_format = filetype.guess_extension(f)
self.assertIsNone(image_format)
diff --git a/tests/test_opencv.py b/tests/test_opencv.py
index 7b85e6c..150dc8f 100644
--- a/tests/test_opencv.py
+++ b/tests/test_opencv.py
@@ -1,6 +1,6 @@
import unittest
import io
-import imghdr
+from numpy.testing import assert_allclose
from willow.image import JPEGImageFile
from willow.plugins.pillow import PillowImage
@@ -46,7 +46,7 @@ class TestOpenCVOperations(unittest.TestCase):
self.assertIsInstance(features, list)
# There are 20 features in the image
self.assertEqual(len(features), 20)
- self.assertEqual(features, self.expected_features)
+ assert_allclose(features, self.expected_features, atol=2)
def test_detect_faces(self):
faces = self.image.detect_faces()
@@ -54,4 +54,4 @@ class TestOpenCVOperations(unittest.TestCase):
self.assertIsInstance(faces, list)
# There are two faces in the image
self.assertEqual(len(faces), 2)
- self.assertEqual(faces, self.expected_faces)
+ assert_allclose(faces, self.expected_faces, atol=2)
diff --git a/tests/test_pillow.py b/tests/test_pillow.py
index a4d2b06..ac4e648 100644
--- a/tests/test_pillow.py
+++ b/tests/test_pillow.py
@@ -1,10 +1,12 @@
import unittest
import io
-import imghdr
+import filetype
from PIL import Image as PILImage
-from willow.image import JPEGImageFile, PNGImageFile, GIFImageFile, WebPImageFile
+from willow.image import (
+ JPEGImageFile, PNGImageFile, GIFImageFile, WebPImageFile, BadImageOperationError
+)
from willow.plugins.pillow import _PIL_Image, PillowImage, UnsupportedRotation
@@ -33,6 +35,52 @@ class TestPillowOperations(unittest.TestCase):
cropped_image = self.image.crop((10, 10, 100, 100))
self.assertEqual(cropped_image.get_size(), (90, 90))
+ def test_crop_out_of_bounds(self):
+ # crop rectangle should be clamped to the image boundaries
+ bottom_right_cropped_image = self.image.crop((150, 100, 250, 200))
+ self.assertEqual(bottom_right_cropped_image.get_size(), (50, 50))
+
+ top_left_cropped_image = self.image.crop((-50, -50, 50, 50))
+ self.assertEqual(top_left_cropped_image.get_size(), (50, 50))
+
+ # fail if the crop rectangle is entirely to the left of the image
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((-100, 50, -50, 100))
+ # right edge of crop rectangle is exclusive, so 0 is also invalid
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((-50, 50, 0, 100))
+
+ # fail if the crop rectangle is entirely above the image
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((50, -100, 100, -50))
+ # bottom edge of crop rectangle is exclusive, so 0 is also invalid
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((50, -50, 100, 0))
+
+ # fail if the crop rectangle is entirely to the right of the image
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((250, 50, 300, 100))
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((200, 50, 250, 100))
+
+ # fail if the crop rectangle is entirely below the image
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((50, 200, 100, 250))
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((50, 150, 100, 200))
+
+ # fail if left edge >= right edge
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((125, 25, 25, 125))
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((100, 25, 100, 125))
+
+ # fail if bottom edge >= top edge
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((25, 125, 125, 25))
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((25, 100, 125, 100))
+
def test_rotate(self):
rotated_image = self.image.rotate(90)
width, height = rotated_image.get_size()
@@ -72,7 +120,7 @@ class TestPillowOperations(unittest.TestCase):
return_value = image.save_as_jpeg(output)
output.seek(0)
- self.assertEqual(imghdr.what(output), 'jpeg')
+ self.assertEqual(filetype.guess_extension(output), 'jpg')
self.assertIsInstance(return_value, JPEGImageFile)
self.assertEqual(return_value.f, output)
@@ -99,7 +147,7 @@ class TestPillowOperations(unittest.TestCase):
return_value = self.image.save_as_png(output)
output.seek(0)
- self.assertEqual(imghdr.what(output), 'png')
+ self.assertEqual(filetype.guess_extension(output), 'png')
self.assertIsInstance(return_value, PNGImageFile)
self.assertEqual(return_value.f, output)
@@ -115,7 +163,7 @@ class TestPillowOperations(unittest.TestCase):
return_value = self.image.save_as_gif(output)
output.seek(0)
- self.assertEqual(imghdr.what(output), 'gif')
+ self.assertEqual(filetype.guess_extension(output), 'gif')
self.assertIsInstance(return_value, GIFImageFile)
self.assertEqual(return_value.f, output)
@@ -209,7 +257,7 @@ class TestPillowOperations(unittest.TestCase):
return_value = self.image.save_as_webp(output)
output.seek(0)
- self.assertEqual(imghdr.what(output), 'webp')
+ self.assertEqual(filetype.guess_extension(output), 'webp')
self.assertIsInstance(return_value, WebPImageFile)
self.assertEqual(return_value.f, output)
diff --git a/tests/test_svg_coordinate_transforms.py b/tests/test_svg_coordinate_transforms.py
new file mode 100644
index 0000000..2cfd388
--- /dev/null
+++ b/tests/test_svg_coordinate_transforms.py
@@ -0,0 +1,210 @@
+from functools import partial
+
+from willow.svg import (
+ get_viewport_to_user_space_transform,
+ transform_rect_to_user_space,
+ SvgImage,
+ ViewportToUserSpaceTransform,
+)
+
+from .test_svg_image import SvgWrapperTestCase
+
+
+class ViewportToUserSpaceTransformTestCase(SvgWrapperTestCase):
+ def test_get_transform_same_ratio(self):
+ svg = SvgImage(
+ self.get_svg_wrapper(width=100, height=100, view_box="0 0 100 100")
+ )
+ transform = get_viewport_to_user_space_transform(svg)
+ self.assertEqual(transform, ViewportToUserSpaceTransform(1, 1, 0, 0))
+
+ def test_get_transform_equivalent_ratios(self):
+ svg = SvgImage(self.get_svg_wrapper(width=90, height=30, view_box="0 0 9 3"))
+ transform = get_viewport_to_user_space_transform(svg)
+ self.assertEqual(transform, ViewportToUserSpaceTransform(10, 10, 0, 0))
+
+ def test_get_transform_equivalent_ratios_floats(self):
+ svg = SvgImage(
+ self.get_svg_wrapper(width=95, height=35, view_box="0 0 9.5 3.5")
+ )
+ transform = get_viewport_to_user_space_transform(svg)
+ self.assertEqual(transform, ViewportToUserSpaceTransform(10, 10, 0, 0))
+
+ def test_preserve_aspect_ratio_none(self):
+ svg = SvgImage(
+ self.get_svg_wrapper(
+ width=100,
+ height=100,
+ view_box="0 0 50 80",
+ preserve_aspect_ratio="none",
+ )
+ )
+ transform = get_viewport_to_user_space_transform(svg)
+ self.assertEqual(transform, ViewportToUserSpaceTransform(2, 1.25, 0, 0))
+
+ def test_transform_rect_to_user_space_translate_x(self):
+ svg_wrapper = partial(
+ self.get_svg_wrapper, width=100, height=50, view_box="0 0 25 25"
+ )
+ params = [
+ # transform = (2, 2, 0, 0)
+ ("xMinYMid meet", (10, 10, 20, 20), (5, 5, 10, 10)),
+ # transform = (2, 2, 25, 0)
+ ("xMidYMid meet", (10, 10, 20, 20), (-7.5, 5, -2.5, 10)),
+ # transform = (2, 2, 50, 0)
+ ("xMaxYMid meet", (10, 10, 20, 20), (-20, 5, -15, 10)),
+ ]
+ for preserve_aspect_ratio, rect, expected_result in params:
+ with self.subTest(preserve_aspect_ratio=preserve_aspect_ratio, rect=rect):
+ svg = SvgImage(svg_wrapper(preserve_aspect_ratio=preserve_aspect_ratio))
+ self.assertEqual(
+ transform_rect_to_user_space(svg, rect), expected_result
+ )
+
+ def test_transform_rect_to_user_space_translate_y(self):
+ svg_wrapper = partial(
+ self.get_svg_wrapper, width=50, height=100, view_box="0 0 25 25"
+ )
+ params = [
+ # transform = (2, 2, 0, 0)
+ ("xMidYMin meet", (10, 10, 20, 20), (5, 5, 10, 10)),
+ # transform = (2, 2, 0, 25)
+ ("xMidYMid meet", (10, 10, 20, 20), (5, -7.5, 10, -2.5)),
+ # transform = (2, 2, 0, 50)
+ ("xMidYMax meet", (10, 10, 20, 20), (5, -20, 10, -15)),
+ ]
+ for preserve_aspect_ratio, rect, expected_result in params:
+ with self.subTest(preserve_aspect_ratio=preserve_aspect_ratio, rect=rect):
+ svg = SvgImage(svg_wrapper(preserve_aspect_ratio=preserve_aspect_ratio))
+ self.assertEqual(
+ transform_rect_to_user_space(svg, rect), expected_result
+ )
+
+ def test_complex_user_space_origin_transform(self):
+ svg = SvgImage(
+ self.get_svg_wrapper(
+ width=100,
+ height=100,
+ view_box="-50 -50 100 100",
+ preserve_aspect_ratio="none",
+ )
+ )
+ self.assertEqual(
+ transform_rect_to_user_space(svg, (0, 0, 50, 50)),
+ (-50, -50, 0, 0),
+ )
+
+ svg = SvgImage(
+ self.get_svg_wrapper(
+ width=200,
+ height=200,
+ view_box="-50 -50 100 100",
+ preserve_aspect_ratio="none",
+ )
+ )
+ self.assertEqual(
+ transform_rect_to_user_space(svg, (0, 0, 50, 50)),
+ (-25, -25, 0, 0),
+ )
+
+
+class PreserveAspectRatioMeetTestCase(SvgWrapperTestCase):
+ def test_portrait_view_box(self):
+ # With "meet", the scaling factor will be min(scale_x,
+ # scale_y). In the case of a portrait ratio view box in a
+ # square viewport, this will be scale_y
+ svg_wrapper = partial(
+ self.get_svg_wrapper, width=100, height=100, view_box="0 0 50 80"
+ )
+ params = [
+ ("xMinYMin meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, 0)),
+ ("xMinYMid meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, 0)),
+ ("xMinYMax meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, 0)),
+ ("xMidYMin meet", ViewportToUserSpaceTransform(1.25, 1.25, 18.75, 0)),
+ ("xMidYMid meet", ViewportToUserSpaceTransform(1.25, 1.25, 18.75, 0)),
+ ("xMidYMax meet", ViewportToUserSpaceTransform(1.25, 1.25, 18.75, 0)),
+ ("xMaxYMin meet", ViewportToUserSpaceTransform(1.25, 1.25, 37.5, 0)),
+ ("xMaxYMid meet", ViewportToUserSpaceTransform(1.25, 1.25, 37.5, 0)),
+ ("xMaxYMax meet", ViewportToUserSpaceTransform(1.25, 1.25, 37.5, 0)),
+ ]
+ for preserve_aspect_ratio, expected_result in params:
+ with self.subTest(preserve_aspect_ratio=preserve_aspect_ratio):
+ svg = SvgImage(svg_wrapper(preserve_aspect_ratio=preserve_aspect_ratio))
+ self.assertEqual(
+ get_viewport_to_user_space_transform(svg), expected_result
+ )
+
+ def test_landscape_view_box(self):
+ # With a landscape orientation view box, we will use scale_x
+ # as the scaling factor
+ svg_wrapper = partial(
+ self.get_svg_wrapper, width=100, height=100, view_box="0 0 80 50"
+ )
+ params = [
+ ("xMinYMin meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, 0)),
+ ("xMidYMin meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, 0)),
+ ("xMaxYMin meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, 0)),
+ ("xMinYMid meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, 18.75)),
+ ("xMidYMid meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, 18.75)),
+ ("xMaxYMid meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, 18.75)),
+ ("xMinYMax meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, 37.5)),
+ ("xMidYMax meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, 37.5)),
+ ("xMaxYMax meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, 37.5)),
+ ]
+ for preserve_aspect_ratio, expected_result in params:
+ with self.subTest(preserve_aspect_ratio=preserve_aspect_ratio):
+ svg = SvgImage(svg_wrapper(preserve_aspect_ratio=preserve_aspect_ratio))
+ self.assertEqual(
+ get_viewport_to_user_space_transform(svg), expected_result
+ )
+
+
+class PreserveAspectRatioSliceTestCase(SvgWrapperTestCase):
+ def test_portrait_view_box(self):
+ # With "slice", the scaling factor will be max(scale_x,
+ # scale_y). In the case of a portrait ratio view box in a
+ # square viewport, this will be scale_x
+ svg_wrapper = partial(
+ self.get_svg_wrapper, width=100, height=100, view_box="0 0 40 80"
+ )
+ params = [
+ ("xMinYMin slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, 0)),
+ ("xMidYMin slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, 0)),
+ ("xMaxYMin slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, 0)),
+ ("xMinYMid slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, -50)),
+ ("xMidYMid slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, -50)),
+ ("xMaxYMid slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, -50)),
+ ("xMinYMax slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, -100)),
+ ("xMidYMax slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, -100)),
+ ("xMaxYMax slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, -100)),
+ ]
+ for preserve_aspect_ratio, expected_result in params:
+ with self.subTest(preserve_aspect_ratio=preserve_aspect_ratio):
+ svg = SvgImage(svg_wrapper(preserve_aspect_ratio=preserve_aspect_ratio))
+ self.assertEqual(
+ get_viewport_to_user_space_transform(svg), expected_result
+ )
+
+ def test_landscape_view_box(self):
+ # With a landscape orientation view box, we will use scale_y
+ # as the scaling factor
+ svg_wrapper = partial(
+ self.get_svg_wrapper, width=100, height=100, view_box="0 0 80 40"
+ )
+ params = [
+ ("xMinYMin slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, 0)),
+ ("xMinYMid slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, 0)),
+ ("xMinYMax slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, 0)),
+ ("xMidYMin slice", ViewportToUserSpaceTransform(2.5, 2.5, -50, 0)),
+ ("xMidYMid slice", ViewportToUserSpaceTransform(2.5, 2.5, -50, 0)),
+ ("xMidYMax slice", ViewportToUserSpaceTransform(2.5, 2.5, -50, 0)),
+ ("xMaxYMin slice", ViewportToUserSpaceTransform(2.5, 2.5, -100, 0)),
+ ("xMaxYMid slice", ViewportToUserSpaceTransform(2.5, 2.5, -100, 0)),
+ ("xMaxYMax slice", ViewportToUserSpaceTransform(2.5, 2.5, -100, 0)),
+ ]
+ for preserve_aspect_ratio, expected_result in params:
+ with self.subTest(preserve_aspect_ratio=preserve_aspect_ratio):
+ svg = SvgImage(svg_wrapper(preserve_aspect_ratio=preserve_aspect_ratio))
+ self.assertEqual(
+ get_viewport_to_user_space_transform(svg), expected_result
+ )
diff --git a/tests/test_svg_image.py b/tests/test_svg_image.py
new file mode 100644
index 0000000..e62590a
--- /dev/null
+++ b/tests/test_svg_image.py
@@ -0,0 +1,386 @@
+import unittest
+from io import BytesIO
+from itertools import product
+from string import Template
+
+from defusedxml import ElementTree
+
+from willow.image import Image, SvgImageFile, BadImageOperationError
+from willow.svg import (
+ SvgWrapper,
+ InvalidSvgAttribute,
+ InvalidSvgSizeAttribute,
+ SvgImage,
+ ViewBox,
+)
+
+
+class SvgWrapperTestCase(unittest.TestCase):
+ svg_template = Template(
+ '<svg width="$width" height="$height" viewBox="$view_box" '
+ 'preserveAspectRatio="$preserve_aspect_ratio"></svg>'
+ )
+
+ def get_svg_wrapper(
+ self,
+ *,
+ dpi=96,
+ font_size_px=16,
+ width="",
+ height="",
+ view_box="",
+ preserve_aspect_ratio="xMidYMid meet",
+ ):
+ svg_raw = self.svg_template.substitute(
+ {
+ "width": width,
+ "height": height,
+ "view_box": view_box,
+ "preserve_aspect_ratio": preserve_aspect_ratio,
+ }
+ )
+ svg_buf = BytesIO(bytes(svg_raw, encoding="utf-8"))
+ # ElementTree.parse returns an ElementTree,
+ # ElementTree.fromstring returns an Element, a pointer to the
+ # root node. SvgWrapper needs an ElementTree.
+ return SvgWrapper(
+ ElementTree.parse(svg_buf), dpi=dpi, font_size_px=font_size_px
+ )
+
+
+class SvgWrapperSizeTestCase(SvgWrapperTestCase):
+ def test_size_no_unit_both_attrs(self):
+ svg = self.get_svg_wrapper(width=42, height=47)
+ self.assertEqual((svg.width, svg.height), (42, 47))
+
+ def test_size_no_unit_width_only(self):
+ # If only height or only width are declared, the undeclared
+ # attribute takes its value from the other
+ svg = self.get_svg_wrapper(width=42)
+ self.assertEqual((svg.width, svg.height), (42, 42))
+
+ def test_size_no_unit_height_only(self):
+ svg = self.get_svg_wrapper(height=42)
+ self.assertEqual((svg.width, svg.height), (42, 42))
+
+ def test_size_px_unit(self):
+ svg = self.get_svg_wrapper(width="42px", height="32px")
+ self.assertEqual((svg.width, svg.height), (42, 32))
+
+ def test_size_em_unit(self):
+ svg = self.get_svg_wrapper(width="1em", height="2em")
+ self.assertEqual((svg.width, svg.height), (16, 32))
+
+ def test_size_ex_unit(self):
+ svg = self.get_svg_wrapper(width="1ex", height="2ex")
+ self.assertEqual((svg.width, svg.height), (8, 16))
+
+ def test_size_pt_unit(self):
+ svg = self.get_svg_wrapper(width="12pt", height="16pt")
+ self.assertEqual(svg.width, 16)
+ self.assertAlmostEqual(21.3, svg.height, places=1)
+
+ def test_size_mm_unit(self):
+ svg = self.get_svg_wrapper(width="25mm")
+ self.assertAlmostEqual(svg.width, 94.5, places=1)
+
+ def test_size_cm_unit(self):
+ svg = self.get_svg_wrapper(width="2.5cm")
+ self.assertAlmostEqual(svg.width, 94.5, places=1)
+
+ def test_size_in_unit(self):
+ svg = self.get_svg_wrapper(width="0.5in")
+ self.assertEqual(svg.width, 48)
+
+ def test_size_pc_unit(self):
+ svg = self.get_svg_wrapper(height="2pc")
+ self.assertEqual(svg.height, 32)
+
+ def test_size_mixed_unit(self):
+ svg = self.get_svg_wrapper(width="0.25in", height="0.5pc")
+ self.assertEqual((svg.width, svg.height), (24, 8))
+
+ def test_size_with_extra_whitespace(self):
+ sizes = (" 1px", " 1px ", "1px ")
+ for size in sizes:
+ with self.subTest(size=size):
+ svg = self.get_svg_wrapper(width=size)
+ self.assertEqual(svg.width, 1)
+
+ def test_invalid_size_fails(self):
+ bad_sizes = (
+ "twenty-three-px",
+ "1 2in",
+ )
+ for size in bad_sizes:
+ with self.subTest(size=size):
+ with self.assertRaises(InvalidSvgSizeAttribute):
+ self.get_svg_wrapper(width=size)
+
+ def test_size_from_view_box(self):
+ values = (
+ "10 10 30 40",
+ "10,10,30,40",
+ "10, 10, 30, 40",
+ "10, 10,30 40",
+ "10, 10,30 40 ",
+ " 10, 10,30 40 ",
+ " 10, 10,30 40",
+ )
+ for view_box in values:
+ svg = self.get_svg_wrapper(view_box=view_box)
+ with self.subTest(view_box=view_box):
+ self.assertEqual((svg.width, svg.height), (30, 40))
+
+ def test_view_box_float_parsing(self):
+ values = (
+ ("0 0 0 10.0", 10.0),
+ ("0 0 0 -1.0", -1.0),
+ ("0 0 0 +1.0", 1.0),
+ ("0 0 0 +.5", 0.5),
+ ("0 0 0 -.5", -0.5),
+ ("0 0 0 +.5e9", 0.5e9),
+ ("0 0 0 +.5E9", 0.5e9),
+ ("0 0 0 -.5e9", -0.5e9),
+ ("0 0 0 -.5E9", -0.5e9),
+ ("0 0 0 1.55e9", 1.55e9),
+ ("0 0 0 1.55E9", 1.55e9),
+ ("0 0 0 +12.55e9", 12.55e9),
+ ("0 0 0 -12.55E9", -12.55e9),
+ ("0 0 0 1e9", 1e9),
+ ("0 0 0 1E9", 1e9),
+ )
+ for view_box, expected in values:
+ svg = self.get_svg_wrapper(view_box=view_box)
+ with self.subTest(view_box=view_box):
+ self.assertEqual(svg.height, expected)
+
+ def test_raises_negative_size(self):
+ with self.assertRaises(InvalidSvgSizeAttribute):
+ self.get_svg_wrapper(width="-3")
+
+ def test_raises_zero_size(self):
+ with self.assertRaises(InvalidSvgSizeAttribute):
+ self.get_svg_wrapper(width="0")
+
+ def test_parse_preserve_aspect_ratio(self):
+ svg = SvgImage(self.get_svg_wrapper(preserve_aspect_ratio="none"))
+ self.assertEqual(svg.image.preserve_aspect_ratio, "none")
+
+ svg = SvgImage(self.get_svg_wrapper(preserve_aspect_ratio=""))
+ self.assertEqual(svg.image.preserve_aspect_ratio, "xMidYMid meet")
+
+ svg = SvgImage(self.get_svg_wrapper())
+ self.assertEqual(svg.image.preserve_aspect_ratio, "xMidYMid meet")
+
+ combos = list(
+ product(
+ ("Min", "Mid", "Max"),
+ ("Min", "Mid", "Max"),
+ ("", " meet", " slice"),
+ )
+ )
+ for x, y, meet_or_slice in combos:
+ preserve_aspect_ratio = f"x{x}Y{y}{meet_or_slice}"
+ with self.subTest(preserve_aspect_ratio=preserve_aspect_ratio):
+ svg = SvgImage(
+ self.get_svg_wrapper(preserve_aspect_ratio=preserve_aspect_ratio)
+ )
+ self.assertEqual(svg.image.preserve_aspect_ratio, preserve_aspect_ratio)
+
+ def test_parse_preserve_aspect_ratio_throws(self):
+ with self.assertRaises(InvalidSvgAttribute):
+ SvgImage(self.get_svg_wrapper(preserve_aspect_ratio="xMidYMin foo"))
+
+ with self.assertRaises(InvalidSvgAttribute):
+ SvgImage(self.get_svg_wrapper(preserve_aspect_ratio="non"))
+
+ with self.assertRaises(InvalidSvgAttribute):
+ SvgImage(self.get_svg_wrapper(preserve_aspect_ratio="xminYMin"))
+
+ with self.assertRaises(InvalidSvgAttribute):
+ SvgImage(self.get_svg_wrapper(preserve_aspect_ratio="xMinYMa"))
+
+ def test_relative_size_uses_view_box(self):
+ cases = [
+ ({"width": "100%", "height": "100%", "view_box": "0 0 42 42"}, (42, 42)),
+ ({"width": "200%", "height": "200%", "view_box": "0 0 42 42"}, (42, 42)),
+ ({"width": "1.5%", "height": "3.2%", "view_box": "0 0 42 42"}, (42, 42)),
+ ]
+ for attrs, expected in cases:
+ with self.subTest(attrs=attrs, expected=expected):
+ svg_wrapper = self.get_svg_wrapper(**attrs)
+ self.assertEqual((svg_wrapper.width, svg_wrapper.height), expected)
+
+ def test_relative_size_uses_other_absolute_size(self):
+ cases = [
+ ({"width": "100%", "height": "100", "view_box": "0 0 42 42"}, (100, 100)),
+ ({"width": "100", "height": "100%", "view_box": "0 0 42 42"}, (100, 100)),
+ ({"width": "300%", "height": "100", "view_box": "0 0 42 42"}, (100, 100)),
+ ({"width": "100", "height": "300%", "view_box": "0 0 42 42"}, (100, 100)),
+ (
+ {"width": "100%", "height": "200em", "view_box": "0 0 42 42"},
+ (3200, 3200),
+ ),
+ (
+ {"width": "200em", "height": "100%", "view_box": "0 0 42 42"},
+ (3200, 3200),
+ ),
+ ]
+ for attrs, expected in cases:
+ with self.subTest(attrs=attrs, expected=expected):
+ svg_wrapper = self.get_svg_wrapper(**attrs)
+ self.assertEqual((svg_wrapper.width, svg_wrapper.height), expected)
+
+ def test_relative_size_no_view_box_uses_defaults(self):
+ cases = [
+ {"width": "100%", "height": "", "view_box": ""},
+ {"width": "", "height": "100%", "view_box": ""},
+ {"width": "100%", "height": "100%", "view_box": ""},
+ ]
+ for attrs in cases:
+ with self.subTest(attrs=attrs):
+ svg_wrapper = self.get_svg_wrapper(**attrs)
+ self.assertEqual((svg_wrapper.width, svg_wrapper.height), (300, 150))
+
+
+class SvgImageTestCase(SvgWrapperTestCase):
+ def test_resize(self):
+ f = BytesIO(b'<svg width="42" height="42" viewBox="0 0 42 42"></svg>')
+ svg_image_file = Image.open(f)
+ resized = svg_image_file.resize((10, 10))
+ self.assertEqual(resized.get_size(), (10, 10))
+ self.assertIsInstance(resized, SvgImage)
+
+ # Check the underlying etree has been updated
+ self.assertEqual(resized.image.root.get("width"), "10")
+ self.assertEqual(resized.image.root.get("height"), "10")
+
+ def test_resize_throws(self):
+ f = BytesIO(b'<svg width="42" height="42" viewBox="0 0 42 42"></svg>')
+ svg_image_file = Image.open(f)
+
+ with self.assertRaises(BadImageOperationError):
+ svg_image_file.resize((0, 10))
+
+ with self.assertRaises(BadImageOperationError):
+ svg_image_file.resize((10, 0))
+
+ def test_crop(self):
+ f = BytesIO(b'<svg width="42" height="42" viewBox="0 0 42 42"></svg>')
+ svg_image_file = Image.open(f)
+ cropped = svg_image_file.crop((5, 5, 15, 15))
+ self.assertEqual(cropped.get_size(), (10, 10))
+ self.assertEqual(cropped.image.view_box, ViewBox(5, 5, 10, 10))
+
+ # Check the underlying etree has been updated
+ self.assertEqual(float(cropped.image.root.get("width")), 10)
+ self.assertEqual(float(cropped.image.root.get("height")), 10)
+ self.assertEqual(
+ [float(x) for x in cropped.image.root.get("viewBox").split()],
+ [5, 5, 10, 10],
+ )
+
+ def test_crop_with_transform(self):
+ # user coordinates scaled by 2 and translated 50 units south
+ svg = SvgImage(
+ self.get_svg_wrapper(
+ width=50,
+ height=100,
+ view_box="0 0 25 25",
+ preserve_aspect_ratio="xMidYMax meet",
+ )
+ )
+
+ cropped = svg.crop((10, 10, 20, 20))
+ self.assertEqual(cropped.get_size(), (10, 10))
+ self.assertEqual(cropped.image.view_box, ViewBox(5, -20, 5, 5))
+ self.assertEqual(float(cropped.image.root.get("width")), 10)
+ self.assertEqual(float(cropped.image.root.get("height")), 10)
+
+ def test_crop_throws(self):
+ f = BytesIO(b'<svg width="42" height="42" viewBox="0 0 42 42"></svg>')
+ svg_image_file = Image.open(f)
+
+ with self.assertRaises(BadImageOperationError):
+ # left > right
+ svg_image_file.crop((10, 0, 0, 10))
+
+ with self.assertRaises(BadImageOperationError):
+ # left == right
+ svg_image_file.crop((10, 0, 10, 10))
+
+ with self.assertRaises(BadImageOperationError):
+ # top > bottom
+ svg_image_file.crop((0, 10, 10, 0))
+
+ with self.assertRaises(BadImageOperationError):
+ # top == bottom
+ svg_image_file.crop((0, 10, 10, 10))
+
+ def test_save_as_svg(self):
+ f = BytesIO(b'<svg width="13" height="13" viewBox="0 0 13 13"></svg>')
+ svg_image_file = Image.open(f)
+ svg = SvgImage(image=SvgWrapper(svg_image_file.dom))
+ written = svg.save_as_svg(BytesIO())
+ self.assertIsInstance(written, SvgImageFile)
+ self.assertEqual(written.dom.getroot().get("width"), "13")
+ self.assertEqual(written.dom.getroot().get("height"), "13")
+
+ def test_get_frame_count(self):
+ svg = SvgImage(self.get_svg_wrapper())
+ self.assertEqual(svg.get_frame_count(), 1)
+
+ def test_crop_preserves_original_image(self):
+ """
+ Cropping should create a new image, leaving the original untouched.
+ """
+ f = BytesIO(b'<svg width="42" height="42" viewBox="0 0 42 42"></svg>')
+ svg_image_file = Image.open(f)
+ svg = SvgImage.open(svg_image_file)
+ svg.crop((0, 0, 10, 10))
+ self.assertEqual(svg.image.view_box, ViewBox(0, 0, 42, 42))
+
+ def test_resize_preserves_original_image(self):
+ f = BytesIO(b'<svg width="42" height="42" viewBox="0 0 42 42"></svg>')
+ svg_image_file = Image.open(f)
+ svg = SvgImage.open(svg_image_file)
+ svg.resize((21, 21))
+ self.assertEqual(svg.get_size(), (42, 42))
+
+
+class SvgViewBoxTestCase(unittest.TestCase):
+ def test_view_box_re(self):
+ params = [
+ ("0 0 1 1", ViewBox(0, 0, 1, 1)),
+ (" 0 0 1 1 ", ViewBox(0, 0, 1, 1)),
+ ("-0 +0 -1 +1", ViewBox(0, 0, -1, 1)),
+ ("+0 -0 +1 -1", ViewBox(0, 0, 1, -1)),
+ ("-0,+0,-1,+1", ViewBox(0, 0, -1, 1)),
+ ("+0,-0,+1,-1", ViewBox(0, 0, 1, -1)),
+ ("-0, +0, -1 +1", ViewBox(0, 0, -1, 1)),
+ ("+0, -0, +1 -1", ViewBox(0, 0, 1, -1)),
+ ("150 150 200 200", ViewBox(150, 150, 200, 200)),
+ (
+ "150.1 150.12 200.123 200.1234",
+ ViewBox(150.1, 150.12, 200.123, 200.1234),
+ ),
+ ("150.0,150.0,200.0,200.0", ViewBox(150, 150, 200, 200)),
+ ("150.0, 150.0, 200.0, 200.0", ViewBox(150, 150, 200, 200)),
+ ("150.0, 150.0, 200.0 200.0", ViewBox(150, 150, 200, 200)),
+ ("-350 360.1 464 81.9", ViewBox(-350, 360.1, 464, 81.9)),
+ ("0 0 1e2 12.13e3", ViewBox(0, 0, 100, 12130)),
+ ("-0 -0 -1e2 -12.13e3", ViewBox(0, 0, -100, -12130)),
+ ("0 0 .5 .5", ViewBox(0, 0, 0.5, 0.5)),
+ ("0 0 .5e1 .5e1", ViewBox(0, 0, 5, 5)),
+ ("0 0 .5e0 .5e0", ViewBox(0, 0, 0.5, 0.5)),
+ ("0 0 -.5e1 +.5e1", ViewBox(0, 0, -5, 5)),
+ ("0 0 -.0e1 +.0e1", ViewBox(0, 0, 0, 0)),
+ ("0 0,-.0e1,+.0e1", ViewBox(0, 0, 0, 0)),
+ (".1,0,0,0", ViewBox(0.1, 0, 0, 0)),
+ ("+.1,0,0,0", ViewBox(0.1, 0, 0, 0)),
+ ("-.1,0,0,0", ViewBox(-0.1, 0, 0, 0)),
+ ]
+ for value, expected in params:
+ with self.subTest(value=value, expected=expected):
+ self.assertEqual(SvgWrapper._parse_view_box(value), expected)
diff --git a/tests/test_wand.py b/tests/test_wand.py
index 4d70ad3..acdb90c 100644
--- a/tests/test_wand.py
+++ b/tests/test_wand.py
@@ -1,12 +1,14 @@
import unittest
import io
-import imghdr
+import filetype
from wand.color import Color
from PIL import Image as PILImage
-from willow.image import JPEGImageFile, PNGImageFile, GIFImageFile, WebPImageFile
+from willow.image import (
+ JPEGImageFile, PNGImageFile, GIFImageFile, WebPImageFile, BadImageOperationError
+)
from willow.plugins.wand import _wand_image, WandImage, UnsupportedRotation
@@ -35,6 +37,52 @@ class TestWandOperations(unittest.TestCase):
cropped_image = self.image.crop((10, 10, 100, 100))
self.assertEqual(cropped_image.get_size(), (90, 90))
+ def test_crop_out_of_bounds(self):
+ # crop rectangle should be clamped to the image boundaries
+ bottom_right_cropped_image = self.image.crop((150, 100, 250, 200))
+ self.assertEqual(bottom_right_cropped_image.get_size(), (50, 50))
+
+ top_left_cropped_image = self.image.crop((-50, -50, 50, 50))
+ self.assertEqual(top_left_cropped_image.get_size(), (50, 50))
+
+ # fail if the crop rectangle is entirely to the left of the image
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((-100, 50, -50, 100))
+ # right edge of crop rectangle is exclusive, so 0 is also invalid
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((-50, 50, 0, 100))
+
+ # fail if the crop rectangle is entirely above the image
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((50, -100, 100, -50))
+ # bottom edge of crop rectangle is exclusive, so 0 is also invalid
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((50, -50, 100, 0))
+
+ # fail if the crop rectangle is entirely to the right of the image
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((250, 50, 300, 100))
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((200, 50, 250, 100))
+
+ # fail if the crop rectangle is entirely below the image
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((50, 200, 100, 250))
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((50, 150, 100, 200))
+
+ # fail if left edge >= right edge
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((125, 25, 25, 125))
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((100, 25, 100, 125))
+
+ # fail if bottom edge >= top edge
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((25, 125, 125, 25))
+ with self.assertRaises(BadImageOperationError):
+ self.image.crop((25, 100, 125, 100))
+
def test_rotate(self):
rotated_image = self.image.rotate(90)
width, height = rotated_image.get_size()
@@ -77,7 +125,7 @@ class TestWandOperations(unittest.TestCase):
return_value = image.save_as_jpeg(output)
output.seek(0)
- self.assertEqual(imghdr.what(output), 'jpeg')
+ self.assertEqual(filetype.guess_extension(output), 'jpg')
self.assertIsInstance(return_value, JPEGImageFile)
self.assertEqual(return_value.f, output)
@@ -105,7 +153,7 @@ class TestWandOperations(unittest.TestCase):
return_value = self.image.save_as_png(output)
output.seek(0)
- self.assertEqual(imghdr.what(output), 'png')
+ self.assertEqual(filetype.guess_extension(output), 'png')
self.assertIsInstance(return_value, PNGImageFile)
self.assertEqual(return_value.f, output)
@@ -122,7 +170,7 @@ class TestWandOperations(unittest.TestCase):
return_value = self.image.save_as_gif(output)
output.seek(0)
- self.assertEqual(imghdr.what(output), 'gif')
+ self.assertEqual(filetype.guess_extension(output), 'gif')
self.assertIsInstance(return_value, GIFImageFile)
self.assertEqual(return_value.f, output)
@@ -184,7 +232,7 @@ class TestWandOperations(unittest.TestCase):
return_value = self.image.save_as_webp(output)
output.seek(0)
- self.assertEqual(imghdr.what(output), 'webp')
+ self.assertEqual(filetype.guess_extension(output), 'webp')
self.assertIsInstance(return_value, WebPImageFile)
self.assertEqual(return_value.f, output)
@@ -240,15 +288,15 @@ class TestWandImageOrientation(unittest.TestCase):
# Check that the red flower is in the bottom left
# The JPEGs have compressed slightly differently so the colours won't be spot on
colour = image.image[282][155]
- self.assertAlmostEqual(colour.red * 255, 217, delta=12)
- self.assertAlmostEqual(colour.green * 255, 38, delta=11)
- self.assertAlmostEqual(colour.blue * 255, 46, delta=13)
+ self.assertAlmostEqual(colour.red * 255, 217, delta=15)
+ self.assertAlmostEqual(colour.green * 255, 38, delta=15)
+ self.assertAlmostEqual(colour.blue * 255, 46, delta=15)
# Check that the water is at the bottom
colour = image.image[434][377]
- self.assertAlmostEqual(colour.red * 255, 85, delta=11)
- self.assertAlmostEqual(colour.green * 255, 93, delta=12)
- self.assertAlmostEqual(colour.blue * 255, 65, delta=11)
+ self.assertAlmostEqual(colour.red * 255, 85, delta=15)
+ self.assertAlmostEqual(colour.green * 255, 93, delta=15)
+ self.assertAlmostEqual(colour.blue * 255, 65, delta=15)
def test_jpeg_with_orientation_1(self):
with open('tests/images/orientation/landscape_1.jpg', 'rb') as f:
diff --git a/willow/__init__.py b/willow/__init__.py
index cfc6ca4..2f0c336 100644
--- a/willow/__init__.py
+++ b/willow/__init__.py
@@ -1,6 +1,8 @@
from willow.image import Image
+
def setup():
+ from xml.etree import ElementTree
from willow.registry import registry
from willow.image import (
@@ -12,8 +14,11 @@ def setup():
RGBAImageBuffer,
TIFFImageFile,
WebPImageFile,
+ SvgImageFile,
+ HeicImageFile,
)
from willow.plugins import pillow, wand, opencv
+ from willow.svg import SvgImage
registry.register_image_class(JPEGImageFile)
registry.register_image_class(PNGImageFile)
@@ -21,14 +26,22 @@ def setup():
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_plugin(pillow)
registry.register_plugin(wand)
registry.register_plugin(opencv)
+ # 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.4'
+__version__ = "1.4"
diff --git a/willow/image.py b/willow/image.py
index 2253b34..fa30296 100644
--- a/willow/image.py
+++ b/willow/image.py
@@ -1,21 +1,24 @@
-import imghdr
+import re
+
+import filetype
import warnings
+from defusedxml import ElementTree
+from filetype.types import image as image_types
+
from .registry import registry
from .utils.deprecation import RemovedInWillow05Warning
-try:
- imghdr.test_webp
-except AttributeError:
- # Add in webp test for 2.7 and 3.5, see http://bugs.python.org/issue20197
- def test_webp(h, f):
- if h.startswith(b'RIFF') and h[8:12] == b'WEBP':
- return 'webp'
- imghdr.tests.append(test_webp)
+class UnrecognisedImageFormatError(IOError):
+ pass
-class UnrecognisedImageFormatError(IOError):
+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
@@ -78,21 +81,37 @@ class Image(object):
@classmethod
def open(cls, f):
# Detect image format
- image_format = imghdr.what(f)
+ 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("Cannot load %s images" % image_format)
+ raise UnrecognisedImageFormatError("Cannot load %s images (%r)" % (image_format, INITIAL_IMAGE_CLASSES))
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):
# Get operation name
- if image_format not in ['jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp']:
+ if image_format not in ['jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp', 'svg', 'heic']:
raise ValueError("Unknown image format: %s" % image_format)
operation_name = 'save_as_' + image_format
@@ -134,7 +153,22 @@ class RGBAImageBuffer(ImageBuffer):
class ImageFile(Image):
- format_name = None
+
+ @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
@property
def original_format(self):
@@ -149,43 +183,97 @@ class ImageFile(Image):
class JPEGImageFile(ImageFile):
- format_name = 'jpeg'
+ @property
+ def format_name(self):
+ return "jpeg"
+
+ @property
+ def mime_type(self):
+ return "image/jpeg"
class PNGImageFile(ImageFile):
- format_name = 'png'
+ @property
+ def format_name(self):
+ return "png"
+
+ @property
+ def mime_type(self):
+ return "image/png"
class GIFImageFile(ImageFile):
- format_name = 'gif'
+ @property
+ def format_name(self):
+ return "gif"
+
+ @property
+ def mime_type(self):
+ return "image/gif"
class BMPImageFile(ImageFile):
- format_name = 'bmp'
+ @property
+ def format_name(self):
+ return "bmp"
+
+ @property
+ def mime_type(self):
+ return "image/bmp"
class TIFFImageFile(ImageFile):
- format_name = 'tiff'
+ @property
+ def format_name(self):
+ return "tiff"
+
+ @property
+ def mime_type(self):
+ return "image/tiff"
class WebPImageFile(ImageFile):
- format_name = 'webp'
+ @property
+ def format_name(self):
+ return "webp"
+ @property
+ def mime_type(self):
+ return "image/webp"
-INITIAL_IMAGE_CLASSES = {
- # A mapping of image formats to their initial class
- 'jpeg': JPEGImageFile,
- 'png': PNGImageFile,
- 'gif': GIFImageFile,
- 'bmp': BMPImageFile,
- 'tiff': TIFFImageFile,
- 'webp': WebPImageFile,
-}
+class SvgImageFile(ImageFile):
+ format_name = "svg"
+
+ 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/heiс"
-# 12 - Make imghdr detect JPEGs based on first two bytes
-def test_jpeg(h, f):
- if h[0:1] == b'\377':
- return 'jpeg'
-imghdr.tests.append(test_jpeg)
+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,
+}
diff --git a/willow/plugins/pillow.py b/willow/plugins/pillow.py
index 09e0ea7..0866332 100644
--- a/willow/plugins/pillow.py
+++ b/willow/plugins/pillow.py
@@ -1,5 +1,10 @@
from __future__ import absolute_import
+try:
+ from pillow_heif import HeifImagePlugin
+except ImportError:
+ pass
+
from willow.image import (
Image,
JPEGImageFile,
@@ -8,8 +13,10 @@ from willow.image import (
BMPImageFile,
TIFFImageFile,
WebPImageFile,
+ HeicImageFile,
RGBImageBuffer,
RGBAImageBuffer,
+ BadImageOperationError,
)
class UnsupportedRotation(Exception): pass
@@ -63,11 +70,30 @@ class PillowImage(Image):
else:
image = self.image
- return PillowImage(image.resize(size, _PIL_Image().ANTIALIAS))
+ # LANCZOS was previously known as ANTIALIAS
+ return PillowImage(image.resize(size, _PIL_Image().Resampling.LANCZOS))
@Image.operation
def crop(self, rect):
- return PillowImage(self.image.crop(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("Invalid crop dimensions: %r" % (rect,))
+
+ # 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):
@@ -78,9 +104,9 @@ class PillowImage(Image):
Image = _PIL_Image()
ORIENTATION_TO_TRANSPOSE = {
- 90: Image.ROTATE_90,
- 180: Image.ROTATE_180,
- 270: Image.ROTATE_270,
+ 90: Image.Transpose.ROTATE_90,
+ 180: Image.Transpose.ROTATE_180,
+ 270: Image.Transpose.ROTATE_270,
}
modulo_angle = angle % 360
@@ -166,7 +192,7 @@ class PillowImage(Image):
# 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().ADAPTIVE)
+ image = image.convert('P', palette=_PIL_Image().Palette.ADAPTIVE)
if 'transparency' in image.info:
image.save(f, 'GIF', transparency=image.info['transparency'])
@@ -180,6 +206,14 @@ class PillowImage(Image):
self.image.save(f, 'WEBP', quality=quality, lossless=lossless)
return WebPImageFile(f)
+ @Image.operation
+ def save_as_heic(self, f, quality=80, lossless=False):
+ if lossless:
+ self.image.save(f, 'HEIF', quality=-1, chroma=444)
+ else:
+ self.image.save(f, 'HEIF', quality=quality)
+ return HeicImageFile(f)
+
@Image.operation
def auto_orient(self):
# JPEG files can be orientated using an EXIF tag.
@@ -200,13 +234,13 @@ class PillowImage(Image):
Image = _PIL_Image()
ORIENTATION_TO_TRANSPOSE = {
1: (),
- 2: (Image.FLIP_LEFT_RIGHT,),
- 3: (Image.ROTATE_180,),
- 4: (Image.ROTATE_180, Image.FLIP_LEFT_RIGHT),
- 5: (Image.ROTATE_270, Image.FLIP_LEFT_RIGHT),
- 6: (Image.ROTATE_270,),
- 7: (Image.ROTATE_90, Image.FLIP_LEFT_RIGHT),
- 8: (Image.ROTATE_90,),
+ 2: (Image.Transpose.FLIP_LEFT_RIGHT,),
+ 3: (Image.Transpose.ROTATE_180,),
+ 4: (Image.Transpose.ROTATE_180, Image.Transpose.FLIP_LEFT_RIGHT),
+ 5: (Image.Transpose.ROTATE_270, Image.Transpose.FLIP_LEFT_RIGHT),
+ 6: (Image.Transpose.ROTATE_270,),
+ 7: (Image.Transpose.ROTATE_90, Image.Transpose.FLIP_LEFT_RIGHT),
+ 8: (Image.Transpose.ROTATE_90,),
}
for transpose in ORIENTATION_TO_TRANSPOSE[orientation]:
@@ -225,6 +259,7 @@ class PillowImage(Image):
@Image.converter_from(BMPImageFile)
@Image.converter_from(TIFFImageFile)
@Image.converter_from(WebPImageFile)
+ @Image.converter_from(HeicImageFile)
def open(cls, image_file):
image_file.f.seek(0)
image = _PIL_Image().open(image_file.f)
diff --git a/willow/plugins/wand.py b/willow/plugins/wand.py
index b9ecc41..81562d2 100644
--- a/willow/plugins/wand.py
+++ b/willow/plugins/wand.py
@@ -14,6 +14,8 @@ from willow.image import (
RGBAImageBuffer,
TIFFImageFile,
WebPImageFile,
+ HeicImageFile,
+ BadImageOperationError,
)
@@ -83,8 +85,24 @@ class WandImage(Image):
@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("Invalid crop dimensions: %r" % (rect,))
+
clone = self._clone()
- clone.image.crop(left=rect[0], top=rect[1], right=rect[2], bottom=rect[3])
+ 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
@@ -116,8 +134,9 @@ class WandImage(Image):
clone.image.background_color = _wand_color().Color('rgb({}, {}, {})'.format(*color))
clone.image.alpha_channel = 'remove'
- # Set alpha_channel to False manually as Wand doesn't do it
- clone.image.alpha_channel = False
+ 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
@@ -199,6 +218,7 @@ class WandImage(Image):
@Image.converter_from(BMPImageFile, cost=150)
@Image.converter_from(TIFFImageFile, cost=150)
@Image.converter_from(WebPImageFile, cost=150)
+ @Image.converter_from(HeicImageFile, cost=150)
def open(cls, image_file):
image_file.f.seek(0)
image = _wand_image().Image(file=image_file.f)
diff --git a/willow/registry.py b/willow/registry.py
index 5687228..b600fd8 100644
--- a/willow/registry.py
+++ b/willow/registry.py
@@ -241,6 +241,10 @@ class WillowRegistry(object):
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
diff --git a/willow/svg.py b/willow/svg.py
new file mode 100644
index 0000000..0342373
--- /dev/null
+++ b/willow/svg.py
@@ -0,0 +1,359 @@
+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 __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
+
+ viewport_aspect_ratio = svg.image.width / svg.image.height
+ user_aspect_ratio = view_box.width / view_box.height
+ if viewport_aspect_ratio == user_aspect_ratio:
+ scale = svg.image.width / view_box.width
+ translate = 0
+ return ViewportToUserSpaceTransform(scale, scale, translate, translate)
+
+ aspect_ratio = svg.image.preserve_aspect_ratio.split()
+ try:
+ align, meet_or_slice = aspect_ratio
+ except ValueError:
+ align = 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":
+ 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
+ scale_x = scale_y = choose_coefficient(scale_x, scale_y)
+
+ # Translations to be applied after scaling
+ translate_x = 0
+ 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
+
+ translate_y = 0
+ 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
+ # 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")
+
+
+def transform_rect_to_user_space(svg: "SvgImage", rect):
+ # As well as scaling and translating the input rect to handle the
+ # preserveAspectRatio attribute, we need to account for the fact
+ # that the origin in the viewport coordinate space (i.e. as a
+ # viewer perceives the image) is not necessarily the same as
+ # the origin in the user coordinate space. For example, if we
+ # may have an SVG with a viewBox of (-8, -8, 10, 10), what the
+ # viewer perceives as (0, 0), is (-8, -8) in the user space.
+ left, top, right, bottom = rect
+ width = right - left
+ height = bottom - top
+ left += svg.image.view_box.min_x
+ top += svg.image.view_box.min_y
+ right = left + width
+ bottom = top + height
+
+ transform = get_viewport_to_user_space_transform(svg)
+ return transform((left, top, right, bottom))
+
+
+class SvgImage(Image):
+ def __init__(self, image):
+ self.image: SvgWrapper = image
+
+ @Image.operation
+ def crop(self, rect, transformer=transform_rect_to_user_space):
+ 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 = transformer(self, rect) if callable(transformer) else 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))
More details
Historical runs
- failed: ERROR: test_heic (tests.test_image.TestImageFormats.test_heic)
- new-upstream-tarball-missing: New upstream version (willow/1.4.1+ds) found, but was missing when retrieved as tarball from <UScanSource(<GitWorkingTree of /tmp/janitorfrou5bg1/willow>, subpath='', top_level=False, auto_fix=True)>.
- nothing-new-to-do: Last upstream version 1.4.1 already imported. Import a snapshot by specifying --snapshot.
- success: Merged new upstream version 1.4.1
- worker-failure: TypeError: cannot use a bytes pattern on a string-like object