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

Full run details

Historical runs