Quellcode durchsuchen

Add srcset_image tag for responsive images

Paarth Agarwal vor 1 Jahr
Ursprung
Commit
41dac89e1d

+ 2 - 2
docs/advanced_topics/images/renditions.md

@@ -38,13 +38,13 @@ See also: [](image_tag)
 
 ## Generating multiple renditions for an image
 
-You can generate multiple renditions of the same image from Python using the native `get_renditions()` method. It will accept any number of 'specification' strings, and will generate a set of matching renditions much more efficiently than generating each one individually. For example:
+You can generate multiple renditions of the same image from Python using the native `get_renditions()` method. It will accept any number of 'specification' strings or `Filter instances`, and will generate a set of matching renditions much more efficiently than generating each one individually. For example:
 
 ```python
 image.get_renditions('width-600', 'height-400', 'fill-300x186|jpegquality-60')
 ```
 
-The return value is a dictionary of renditions keyed by the specification strings that were provided to the method. The return value from the above example would look something like this:
+The return value is a dictionary of renditions keyed by the specifications that were provided to the method. The return value from the above example would look something like this:
 
 ```python
 {

+ 23 - 4
docs/reference/jinja2.md

@@ -70,19 +70,38 @@ See [](slugurl_tag) for more information
 
 ### `image()`
 
-Resize an image, and print an `<img>` tag:
+Resize an image, and render an `<img>` tag:
 
 ```html+jinja
-{# Print an image tag #}
 {{ image(page.header_image, "fill-1024x200", class="header-image") }}
+```
+
+Or resize an image and retrieve the resized image object (rendition) for more bespoke use:
 
-{# Resize an image #}
+```html+jinja
 {% set background=image(page.background_image, "max-1024x1024") %}
-<div class="wrapper" style="background-image: url({{ background.url }});">
+<div class="wrapper" style="background-image: url({{ background.url }});"></div>
 ```
 
 See [](image_tag) for more information
 
+### `srcset_image()`
+
+Resize an image, and render an `<img>` tag including `srcset` with multiple sizes.
+Browsers will select the most appropriate image to load based on [responsive image rules](https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images).
+The `sizes` attribute is mandatory unless you store the output of `srcset_image` for later use.
+
+```html+jinja
+{{ srcset_image(page.header_image, "fill-{512x100,1024x200}", sizes="100vw", class="header-image") }}
+```
+
+Or resize an image and retrieve the renditions for more bespoke use:
+
+```html+jinja
+{% set bg=srcset_image(page.background_image, "max-{512x512,1024x1024}") %}
+<div class="wrapper" style="background-image: image-set(url({{ bg.renditions[0].url }}) 1x, url({{ bg.renditions[1].url }}) 2x);"></div>
+```
+
 ### `|richtext`
 
 Transform Wagtail's internal HTML representation, expanding internal references to pages and images.

+ 42 - 2
docs/topics/images.md

@@ -29,6 +29,21 @@ In the above syntax example `[image]` is the Django object referring to the imag
 
 Note that a space separates `[image]` and `[resize-rule]`, but the resize rule must not contain spaces. The width is always specified before the height. Resized images will maintain their original aspect ratio unless the `fill` rule is used, which may result in some pixels being cropped.
 
+(responsive_images)=
+
+## Responsive images
+
+In addition to `image`, Wagtail also provides a `srcset_image` template tag which generates an `<img>` tag with a `srcset` attribute. This allows browsers to select the most appropriate image file to load based on [responsive image rules](https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images).
+
+The syntax for `srcset_image` is the same as `image, with two exceptions:
+
+```html+django
+{% srcset_image [image] [resize-rule-with-brace-expansion] sizes="100vw" %}
+```
+
+- The resize rule should be provided with multiple sizes in a brace-expansion pattern, like `width-{200,400}`. This will generate the `srcset` attribute, with as many URLs as there are sizes defined in the resize rule.
+- The `sizes` attribute is mandatory. This tells the browser how large the image will be displayed on the page, so that it can select the most appropriate image to load.
+
 (available_resizing_methods)=
 
 ## Available resizing methods
@@ -177,7 +192,7 @@ You can also add default attributes to all images (a default class or data attri
 
 ### 2. Generating the image "as foo" to access individual properties
 
-Wagtail can assign the image data to another variable using Django's `as` syntax:
+Wagtail can assign the image data to another variable using Django's `as` syntax, to access the underlying image Rendition (`tmp_photo`):
 
 ```html+django
 {% image page.photo width-400 as tmp_photo %}
@@ -186,11 +201,36 @@ Wagtail can assign the image data to another variable using Django's `as` syntax
     height="{{ tmp_photo.height }}" alt="{{ tmp_photo.alt }}" class="my-custom-class" />
 ```
 
+This is also possible with the `srcset_image` tag, to retrieve multiple size renditions:
+
+```html+django
+{% srcset_image page.photo width-{200,400} as tmp_photo %}
+
+<img
+    src="{{ tmp_photo.renditions.0.url }}"
+    width="{{ tmp_photo.renditions.0.width }}"
+    height="{{ tmp_photo.renditions.0.height }}"
+    alt="{{ tmp_photo.renditions.0.alt }}"
+    srcset="{{ tmp_photo.renditions.0.url }} 200w, {{ tmp_photo.renditions.1.url }} 400w"
+    sizes="100vw"
+    class="my-custom-class"
+/>
+```
+
+And with the picture tag, to retrieve multiple formats:
+
+```html+django
+{% picture page.photo format-{avif,jpeg} as tmp_photo %}
+
+{{ tmp_photo.avif.0.url }}
+{{ tmp_photo.jpeg.0.url }}
+```
+
 ```{note}
 The image property used for the `src` attribute is `image.url`, not `image.src`.
 ```
 
-This syntax exposes the underlying image Rendition (`tmp_photo`) to the developer. A "Rendition" contains the information specific to the way you've requested to format the image using the resize-rule, dimensions, and source URL. The following properties are available:
+Renditions contain the information specific to the way you've requested to format the image using the resize-rule, dimensions, and source URL. The following properties are available:
 
 ### `url`
 

+ 20 - 6
wagtail/images/jinja2tags.py

@@ -1,19 +1,16 @@
-import re
-
 from django import template
 from jinja2.ext import Extension
 
-from .shortcuts import get_rendition_or_not_found
+from .models import Filter, ResponsiveImage
+from .shortcuts import get_rendition_or_not_found, get_renditions_or_not_found
 from .templatetags.wagtailimages_tags import image_url
 
-allowed_filter_pattern = re.compile(r"^[A-Za-z0-9_\-\.\|]+$")
-
 
 def image(image, filterspec, **attrs):
     if not image:
         return ""
 
-    if not allowed_filter_pattern.match(filterspec):
+    if not Filter.pipe_spec_pattern.match(filterspec):
         raise template.TemplateSyntaxError(
             "filter specs in 'image' tag may only contain A-Z, a-z, 0-9, dots, hyphens, pipes and underscores. "
             "(given filter: {})".format(filterspec)
@@ -27,6 +24,22 @@ def image(image, filterspec, **attrs):
         return rendition
 
 
+def srcset_image(image, filterspec, **attrs):
+    if not image:
+        return ""
+
+    if not Filter.pipe_expanding_spec_pattern.match(filterspec):
+        raise template.TemplateSyntaxError(
+            "filter specs in 'srcset_image' tag may only contain A-Z, a-z, 0-9, dots, hyphens, curly braces, commas, pipes and underscores. "
+            "(given filter: {})".format(filterspec)
+        )
+
+    specs = Filter.expand_spec(filterspec)
+    renditions = get_renditions_or_not_found(image, specs)
+
+    return ResponsiveImage(renditions, attrs)
+
+
 class WagtailImagesExtension(Extension):
     def __init__(self, environment):
         super().__init__(environment)
@@ -35,6 +48,7 @@ class WagtailImagesExtension(Extension):
             {
                 "image": image,
                 "image_url": image_url,
+                "srcset_image": srcset_image,
             }
         )
 

+ 92 - 6
wagtail/images/models.py

@@ -1,13 +1,15 @@
 import hashlib
+import itertools
 import logging
 import os.path
+import re
 import time
 from collections import OrderedDict, defaultdict
 from concurrent.futures import ThreadPoolExecutor
 from contextlib import contextmanager
 from io import BytesIO
 from tempfile import SpooledTemporaryFile
-from typing import Dict, Iterable, List, Union
+from typing import Any, Dict, Iterable, List, Optional, Union
 
 import willow
 from django.apps import apps
@@ -496,16 +498,20 @@ class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Mode
         )
         return rendition
 
-    def get_renditions(self, *filter_specs: str) -> Dict[str, "AbstractRendition"]:
+    def get_renditions(
+        self, *filters: Union["Filter", str]
+    ) -> Dict[str, "AbstractRendition"]:
         """
         Returns a ``dict`` of ``Rendition`` instances with image files reflecting
-        the supplied ``filter_specs``, keyed by the relevant ``filter_spec`` string.
+        the supplied ``filters``, keyed by filter spec patterns.
 
         Note: If using custom image models, instances of the custom rendition
         model will be returned.
         """
         Rendition = self.get_rendition_model()
-        filters = [Filter(spec) for spec in dict.fromkeys(filter_specs).keys()]
+        # We don’t support providing mixed Filter and string arguments in the same call.
+        if isinstance(filters[0], str):
+            filters = [Filter(spec) for spec in dict.fromkeys(filters).keys()]
 
         # Find existing renditions where possible
         renditions = self.find_existing_renditions(*filters)
@@ -528,8 +534,8 @@ class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Mode
         if cache_additions:
             Rendition.cache_backend.set_many(cache_additions)
 
-        # Return a dict in the expected format
-        return {filter.spec: rendition for filter, rendition in renditions.items()}
+        # Make sure key insertion order matches the input order.
+        return {filter.spec: renditions[filter] for filter in filters}
 
     def find_existing_renditions(
         self, *filters: "Filter"
@@ -822,10 +828,45 @@ class Filter:
     but could potentially involve colour processing, etc.
     """
 
+    spec_pattern = re.compile(r"^[A-Za-z0-9_\-\.]+$")
+    pipe_spec_pattern = re.compile(r"^[A-Za-z0-9_\-\.\|]+$")
+    expanding_spec_pattern = re.compile(r"^[A-Za-z0-9_\-\.{},]+$")
+    pipe_expanding_spec_pattern = re.compile(r"^[A-Za-z0-9_\-\.{},\|]+$")
+
     def __init__(self, spec=None):
         # The spec pattern is operation1-var1-var2|operation2-var1
         self.spec = spec
 
+    @classmethod
+    def expand_spec(self, spec: Union["str", Iterable["str"]]) -> List["str"]:
+        """
+        Converts a spec pattern with brace-expansions, into a list of spec patterns.
+        For example, "width-{100,200}" becomes ["width-100", "width-200"].
+
+        Supports providing filter specs already split, or pipe or space-separated.
+        """
+        if isinstance(spec, str):
+            separator = "|" if "|" in spec else " "
+            spec = spec.split(separator)
+
+        expanded_segments = []
+        for segment in spec:
+            # Check if segment has braces to expand
+            if "{" in segment and "}" in segment:
+                prefix, options_suffixed = segment.split("{")
+                options_pattern, suffix = options_suffixed.split("}")
+                options = options_pattern.split(",")
+                expanded_segments.append(
+                    [prefix + option + suffix for option in options]
+                )
+            else:
+                expanded_segments.append([segment])
+
+        # Cartesian product of all expanded segments (equivalent to nested for loops).
+        combinations = itertools.product(*expanded_segments)
+
+        return ["|".join(combination) for combination in combinations]
+
     @cached_property
     def operations(self):
         # Search for operations
@@ -1002,6 +1043,51 @@ class Filter:
         return hashlib.sha1(vary_string.encode("utf-8")).hexdigest()[:8]
 
 
+class ResponsiveImage:
+    """
+    A custom object used to represent a collection of renditions.
+    Provides a 'renditions' property to access the renditions,
+    and renders to the front-end HTML.
+    """
+
+    def __init__(
+        self,
+        renditions: Dict[str, "AbstractRendition"],
+        attrs: Optional[Dict[str, Any]] = None,
+    ):
+        self.renditions = list(renditions.values())
+        self.attrs = attrs
+
+    @classmethod
+    def get_width_srcset(cls, renditions_list: List["AbstractRendition"]):
+        if len(renditions_list) == 1:
+            # No point in using width descriptors if there is a single image.
+            return renditions_list[0].url
+
+        return ", ".join([f"{r.url} {r.width}w" for r in renditions_list])
+
+    def __html__(self):
+        attrs = self.attrs or {}
+
+        # No point in adding a srcset if there is a single image.
+        if len(self.renditions) > 1:
+            attrs["srcset"] = self.get_width_srcset(self.renditions)
+
+        # The first rendition is the "base" / "fallback" image.
+        return self.renditions[0].img_tag(attrs)
+
+    def __str__(self):
+        return mark_safe(self.__html__())
+
+    def __bool__(self):
+        return bool(self.renditions)
+
+    def __eq__(self, other: "ResponsiveImage"):
+        if isinstance(other, ResponsiveImage):
+            return self.renditions == other.renditions and self.attrs == other.attrs
+        return False
+
+
 class AbstractRendition(ImageFileMixin, models.Model):
     filter_spec = models.CharField(max_length=255, db_index=True)
     """ Use local ImageField with Willow support.  """

+ 19 - 0
wagtail/images/shortcuts.py

@@ -21,3 +21,22 @@ def get_rendition_or_not_found(image, specs):
         rendition = Rendition(image=image, width=0, height=0)
         rendition.file.name = "not-found"
         return rendition
+
+
+def get_renditions_or_not_found(image, specs):
+    """
+    Like get_rendition_or_not_found, but for multiple renditions.
+    Tries to get / create the renditions for the image or renders not-found images if the image does not exist.
+
+    :param image: AbstractImage
+    :param specs: iterable of str or Filter
+    """
+    try:
+        return image.get_renditions(*specs)
+    except SourceImageIOError:
+        Rendition = image.renditions.model
+        rendition = Rendition(image=image, width=0, height=0)
+        rendition.file.name = "not-found"
+        return {
+            spec if isinstance(spec, str) else spec.spec: rendition for spec in specs
+        }

+ 82 - 28
wagtail/images/templatetags/wagtailimages_tags.py

@@ -1,21 +1,24 @@
-import re
-
 from django import template
 from django.core.exceptions import ImproperlyConfigured
 from django.urls import NoReverseMatch
 
-from wagtail.images.models import Filter
-from wagtail.images.shortcuts import get_rendition_or_not_found
+from wagtail.images.models import Filter, ResponsiveImage
+from wagtail.images.shortcuts import (
+    get_rendition_or_not_found,
+    get_renditions_or_not_found,
+)
 from wagtail.images.utils import to_svg_safe_spec
 from wagtail.images.views.serve import generate_image_url
 
 register = template.Library()
-allowed_filter_pattern = re.compile(r"^[A-Za-z0-9_\-\.]+$")
 
 
-@register.tag(name="image")
 def image(parser, token):
-    bits = token.split_contents()[1:]
+    """
+    Image tag parser implementation. Shared between all image tags supporting filter specs
+    as space-separated arguments.
+    """
+    tag_name, *bits = token.split_contents()
     image_expr = parser.compile_filter(bits[0])
     bits = bits[1:]
 
@@ -24,8 +27,9 @@ def image(parser, token):
     output_var_name = None
 
     as_context = False  # if True, the next bit to be read is the output variable name
-    is_valid = True
+    error_messages = []
 
+    multi_rendition = tag_name != "image"
     preserve_svg = False
 
     for bit in bits:
@@ -37,7 +41,7 @@ def image(parser, token):
                 output_var_name = bit
             else:
                 # more than one item exists after 'as' - reject as invalid
-                is_valid = False
+                error_messages.append("More than one variable name after 'as'")
         elif bit == "preserve-svg":
             preserve_svg = True
         else:
@@ -47,36 +51,41 @@ def image(parser, token):
                     value
                 )  # setup to resolve context variables as value
             except ValueError:
-                if allowed_filter_pattern.match(bit):
+                allowed_pattern = (
+                    Filter.expanding_spec_pattern
+                    if multi_rendition
+                    else Filter.spec_pattern
+                )
+                if allowed_pattern.match(bit):
                     filter_specs.append(bit)
                 else:
                     raise template.TemplateSyntaxError(
-                        "filter specs in 'image' tag may only contain A-Z, a-z, 0-9, dots, hyphens and underscores. "
+                        "filter specs in image tags may only contain A-Z, a-z, 0-9, dots, hyphens and underscores (and commas and curly braces for multi-image tags). "
                         "(given filter: {})".format(bit)
                     )
 
     if as_context and output_var_name is None:
         # context was introduced but no variable given ...
-        is_valid = False
+        error_messages.append("Missing a variable name after 'as'")
 
     if output_var_name and attrs:
         # attributes are not valid when using the 'as img' form of the tag
-        is_valid = False
+        error_messages.append("Do not use attributes with 'as' context assignments")
 
     if len(filter_specs) == 0:
         # there must always be at least one filter spec provided
-        is_valid = False
+        error_messages.append("Image tags must be used with at least one filter spec")
 
     if len(bits) == 0:
         # no resize rule provided eg. {% image page.image %}
-        raise template.TemplateSyntaxError(
-            "no resize rule provided. "
-            "'image' tag should be of the form {% image self.photo max-320x200 [ custom-attr=\"value\" ... ] %} "
-            "or {% image self.photo max-320x200 as img %}"
-        )
-
-    if is_valid:
-        return ImageNode(
+        error_messages.append("No resize rule provided")
+
+    if len(error_messages) == 0:
+        Node = {
+            "image": ImageNode,
+            "srcset_image": SrcsetImageNode,
+        }
+        return Node[tag_name](
             image_expr,
             filter_specs,
             attrs=attrs,
@@ -84,12 +93,18 @@ def image(parser, token):
             preserve_svg=preserve_svg,
         )
     else:
+        errors = "; ".join(error_messages)
         raise template.TemplateSyntaxError(
-            "'image' tag should be of the form {% image self.photo max-320x200 [ custom-attr=\"value\" ... ] %} "
-            "or {% image self.photo max-320x200 as img %}"
+            f"Invalid arguments provided to {tag_name}: {errors}. "
+            'Image tags should be of the form {% image self.photo max-320x200 [ custom-attr="value" ... ] %} '
+            "or {% image self.photo max-320x200 as img %}. "
         )
 
 
+register.tag("image", image)
+register.tag("srcset_image", image)
+
+
 class ImageNode(template.Node):
     def __init__(
         self,
@@ -110,19 +125,29 @@ class ImageNode(template.Node):
             return Filter(to_svg_safe_spec(self.filter_specs))
         return Filter(spec="|".join(self.filter_specs))
 
-    def render(self, context):
+    def validate_image(self, context):
         try:
             image = self.image_expr.resolve(context)
         except template.VariableDoesNotExist:
-            return ""
+            return
 
         if not image:
             if self.output_var_name:
                 context[self.output_var_name] = None
-            return ""
+            return
 
         if not hasattr(image, "get_rendition"):
-            raise ValueError("image tag expected an Image object, got %r" % image)
+            raise ValueError(
+                "Image template tags expect an Image object, got %r" % image
+            )
+
+        return image
+
+    def render(self, context):
+        image = self.validate_image(context)
+
+        if not image:
+            return ""
 
         rendition = get_rendition_or_not_found(
             image,
@@ -141,6 +166,35 @@ class ImageNode(template.Node):
             return rendition.img_tag(resolved_attrs)
 
 
+class SrcsetImageNode(ImageNode):
+    def get_filters(self, preserve_svg=False):
+        filter_specs = Filter.expand_spec(self.filter_specs)
+        if preserve_svg:
+            return [Filter(to_svg_safe_spec(f)) for f in filter_specs]
+        return [Filter(spec=f) for f in filter_specs]
+
+    def render(self, context):
+        image = self.validate_image(context)
+
+        if not image:
+            return ""
+
+        specs = self.get_filters(preserve_svg=self.preserve_svg and image.is_svg())
+        renditions = get_renditions_or_not_found(image, specs)
+
+        if self.output_var_name:
+            # Wrap the renditions in ResponsiveImage object, to support both
+            # rendering as-is and access to the data.
+            context[self.output_var_name] = ResponsiveImage(renditions)
+            return ""
+
+        resolved_attrs = {}
+        for key in self.attrs:
+            resolved_attrs[key] = self.attrs[key].resolve(context)
+
+        return ResponsiveImage(renditions, resolved_attrs).__html__()
+
+
 @register.simple_tag()
 def image_url(image, filter_spec, viewname="wagtailimages_serve"):
     try:

+ 8 - 30
wagtail/images/tests/test_blocks.py

@@ -1,14 +1,16 @@
-import os
 import unittest.mock
 
 from django.apps import apps
-from django.conf import settings
-from django.core import serializers
 from django.test import TestCase
 
 from wagtail.images.blocks import ImageChooserBlock
 
-from .utils import Image, get_test_image_file
+from .utils import (
+    Image,
+    get_test_bad_image,
+    get_test_image_file,
+    get_test_image_filename,
+)
 
 
 class TestImageChooserBlock(TestCase):
@@ -18,39 +20,15 @@ class TestImageChooserBlock(TestCase):
             file=get_test_image_file(),
         )
 
-        # Create an image with a missing file, by deserializing fom a python object
-        # (which bypasses FileField's attempt to read the file)
-        self.bad_image = list(
-            serializers.deserialize(
-                "python",
-                [
-                    {
-                        "fields": {
-                            "title": "missing image",
-                            "height": 100,
-                            "file": "original_images/missing-image.jpg",
-                            "width": 100,
-                        },
-                        "model": "wagtailimages.image",
-                    }
-                ],
-            )
-        )[0].object
+        self.bad_image = get_test_bad_image()
         self.bad_image.save()
 
-    def get_image_filename(self, image, filterspec):
-        """
-        Get the generated filename for a resized image
-        """
-        name, ext = os.path.splitext(os.path.basename(image.file.name))
-        return f"{settings.MEDIA_URL}images/{name}.{filterspec}{ext}"
-
     def test_render(self):
         block = ImageChooserBlock()
         html = block.render(self.image)
         expected_html = (
             '<img alt="Test image" src="{}" width="640" height="480">'.format(
-                self.get_image_filename(self.image, "original")
+                get_test_image_filename(self.image, "original")
             )
         )
 

+ 167 - 38
wagtail/images/tests/test_jinja2.py

@@ -1,19 +1,20 @@
-import os
 import unittest.mock
 
-from django import template
 from django.apps import apps
-from django.conf import settings
-from django.core import serializers
-from django.template import engines
+from django.template import TemplateSyntaxError, engines
 from django.test import TestCase
 
 from wagtail.models import Site
 
-from .utils import Image, get_test_image_file
+from .utils import (
+    Image,
+    get_test_bad_image,
+    get_test_image_file,
+    get_test_image_filename,
+)
 
 
-class TestImagesJinja(TestCase):
+class JinjaImagesTestCase(TestCase):
     def setUp(self):
         self.engine = engines["jinja2"]
 
@@ -22,24 +23,7 @@ class TestImagesJinja(TestCase):
             file=get_test_image_file(),
         )
 
-        # Create an image with a missing file, by deserializing fom a python object
-        # (which bypasses FileField's attempt to read the file)
-        self.bad_image = list(
-            serializers.deserialize(
-                "python",
-                [
-                    {
-                        "fields": {
-                            "title": "missing image",
-                            "height": 100,
-                            "file": "original_images/missing-image.jpg",
-                            "width": 100,
-                        },
-                        "model": "wagtailimages.image",
-                    }
-                ],
-            )
-        )[0].object
+        self.bad_image = get_test_bad_image()
         self.bad_image.save()
 
     def render(self, string, context=None, request_context=True):
@@ -55,18 +39,13 @@ class TestImagesJinja(TestCase):
         template = self.engine.from_string(string)
         return template.render(context)
 
-    def get_image_filename(self, image, filterspec):
-        """
-        Get the generated filename for a resized image
-        """
-        name, ext = os.path.splitext(os.path.basename(image.file.name))
-        return f"{settings.MEDIA_URL}images/{name}.{filterspec}{ext}"
 
+class TestImageJinja(JinjaImagesTestCase):
     def test_image(self):
         self.assertHTMLEqual(
             self.render('{{ image(myimage, "width-200") }}', {"myimage": self.image}),
             '<img alt="Test image" src="{}" width="200" height="150">'.format(
-                self.get_image_filename(self.image, "width-200")
+                get_test_image_filename(self.image, "width-200")
             ),
         )
 
@@ -77,18 +56,29 @@ class TestImagesJinja(TestCase):
                 {"myimage": self.image},
             ),
             '<img alt="alternate" src="{}" width="200" height="150" class="test">'.format(
-                self.get_image_filename(self.image, "width-200")
+                get_test_image_filename(self.image, "width-200")
             ),
         )
 
     def test_image_assignment(self):
         template = (
-            '{% set background=image(myimage, "width-200") %}'
-            "width: {{ background.width }}, url: {{ background.url }}"
+            '{% set bg=image(myimage, "width-200") %}'
+            "width: {{ bg.width }}, url: {{ bg.url }}"
         )
-        output = "width: 200, url: " + self.get_image_filename(self.image, "width-200")
+        output = "width: 200, url: " + get_test_image_filename(self.image, "width-200")
         self.assertHTMLEqual(self.render(template, {"myimage": self.image}), output)
 
+    def test_image_assignment_render_as_is(self):
+        self.assertHTMLEqual(
+            self.render(
+                '{% set bg=image(myimage, "width-200") %}{{ bg }}',
+                {"myimage": self.image},
+            ),
+            '<img alt="Test image" src="{}" width="200" height="150">'.format(
+                get_test_image_filename(self.image, "width-200")
+            ),
+        )
+
     def test_missing_image(self):
         self.assertHTMLEqual(
             self.render(
@@ -98,7 +88,9 @@ class TestImagesJinja(TestCase):
         )
 
     def test_invalid_character(self):
-        with self.assertRaises(template.TemplateSyntaxError):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "filter specs in 'image' tag may only"
+        ):
             self.render('{{ image(myimage, "fill-200×200") }}', {"myimage": self.image})
 
     def test_custom_default_attrs(self):
@@ -121,10 +113,12 @@ class TestImagesJinja(TestCase):
                 {"myimage": self.image},
             ),
             '<img alt="Test image" src="{}" width="200" height="150">'.format(
-                self.get_image_filename(self.image, "width-200.jpegquality-40")
+                get_test_image_filename(self.image, "width-200.jpegquality-40")
             ),
         )
 
+
+class TestImageURLJinja(JinjaImagesTestCase):
     def test_image_url(self):
         self.assertRegex(
             self.render(
@@ -143,3 +137,138 @@ class TestImagesJinja(TestCase):
                 self.image.file.name.split("/")[-1]
             ),
         )
+
+
+class TestSrcsetImageJinja(JinjaImagesTestCase):
+    def test_srcset_image(self):
+        filename_200 = get_test_image_filename(self.image, "width-200")
+        filename_400 = get_test_image_filename(self.image, "width-400")
+        rendered = self.render(
+            '{{ srcset_image(myimage, "width-{200,400}", sizes="100vw") }}',
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <img
+                sizes="100vw"
+                src="{filename_200}"
+                srcset="{filename_200} 200w, {filename_400} 400w"
+                alt="Test image"
+                width="200"
+                height="150"
+            >
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_srcset_output_single_image(self):
+        self.assertHTMLEqual(
+            self.render(
+                '{{ srcset_image(myimage, "width-200") }}',
+                {"myimage": self.image},
+            ),
+            self.render(
+                '{{ image(myimage, "width-200") }}',
+                {"myimage": self.image},
+            ),
+        )
+
+    def test_srcset_image_assignment(self):
+        template = (
+            '{% set bg=srcset_image(myimage, "width-{200,400}") %}'
+            "width: {{ bg.renditions[0].width }}, url: {{ bg.renditions[0].url }} "
+            "width: {{ bg.renditions[1].width }}, url: {{ bg.renditions[1].url }} "
+        )
+        rendered = self.render(template, {"myimage": self.image})
+        expected = f"""
+            width: 200, url: {get_test_image_filename(self.image, "width-200")}
+            width: 400, url: {get_test_image_filename(self.image, "width-400")}
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_srcset_image_assignment_render_as_is(self):
+        filename_200 = get_test_image_filename(self.image, "width-200")
+        filename_400 = get_test_image_filename(self.image, "width-400")
+        rendered = self.render(
+            '{% set bg=srcset_image(myimage, "width-{200,400}") %}{{ bg }}',
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <img
+                src="{filename_200}"
+                srcset="{filename_200} 200w, {filename_400} 400w"
+                alt="Test image"
+                width="200"
+                height="150"
+            >
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_missing_srcset_image(self):
+        rendered = self.render(
+            '{{ srcset_image(myimage, "width-{200,400}", sizes="100vw") }}',
+            {"myimage": self.bad_image},
+        )
+        expected = """
+            <img
+                sizes="100vw"
+                src="/media/not-found"
+                srcset="/media/not-found 0w, /media/not-found 0w"
+                alt="missing image"
+                width="0"
+                height="0"
+            >
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_invalid_character(self):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "filter specs in 'srcset_image' tag may only"
+        ):
+            self.render(
+                '{{ srcset_image(myimage, "fill-{20×20,40×40}", sizes="100vw") }}',
+                {"myimage": self.image},
+            )
+
+    def test_custom_default_attrs(self):
+        with unittest.mock.patch.object(
+            apps.get_app_config("wagtailimages"),
+            "default_attrs",
+            new={"decoding": "async", "loading": "lazy"},
+        ):
+            rendered = self.render(
+                '{{ srcset_image(myimage, "width-{20,40}", sizes="100vw") }}',
+                {"myimage": self.bad_image},
+            )
+            expected = """
+                <img
+                    sizes="100vw"
+                    src="/media/not-found"
+                    srcset="/media/not-found 0w, /media/not-found 0w"
+                    alt="missing image"
+                    width="0"
+                    height="0"
+                    decoding="async"
+                    loading="lazy"
+                >
+            """
+            self.assertHTMLEqual(rendered, expected)
+
+    def test_chaining_filterspecs(self):
+        filenames = [
+            get_test_image_filename(self.image, "width-200.jpegquality-40"),
+            get_test_image_filename(self.image, "width-400.jpegquality-40"),
+        ]
+        rendered = self.render(
+            '{{ srcset_image(myimage, "width-{200,400}|jpegquality-40", sizes="100vw") }}',
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <img
+                sizes="100vw"
+                src="{filenames[0]}"
+                srcset="{filenames[0]} 200w, {filenames[1]} 400w"
+                alt="Test image"
+                width="200"
+                height="150"
+            >
+        """
+        self.assertHTMLEqual(rendered, expected)

+ 167 - 2
wagtail/images/tests/test_models.py

@@ -7,13 +7,14 @@ from django.core.files.storage import DefaultStorage, Storage
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.db.models import Prefetch
 from django.db.utils import IntegrityError
-from django.test import TestCase, TransactionTestCase
+from django.test import SimpleTestCase, TestCase, TransactionTestCase
 from django.urls import reverse
 from willow.image import Image as WillowImage
 
 from wagtail.images.models import (
     Filter,
     Rendition,
+    ResponsiveImage,
     SourceImageIOError,
     get_rendition_storage,
 )
@@ -26,7 +27,7 @@ from wagtail.test.testapp.models import (
 )
 from wagtail.test.utils import WagtailTestUtils, override_settings
 
-from .utils import Image, get_test_image_file
+from .utils import Image, get_test_image_file, get_test_image_filename
 
 
 class CustomStorage(Storage):
@@ -236,6 +237,147 @@ class TestImagePermissions(WagtailTestUtils, TestCase):
         self.assertFalse(self.image.is_editable_by_user(self.user))
 
 
+class TestFilters(SimpleTestCase):
+    def test_expand_spec_single(self):
+        self.assertEqual(Filter.expand_spec("width-100"), ["width-100"])
+
+    def test_expand_spec_flat(self):
+        self.assertEqual(
+            Filter.expand_spec("width-100 jpegquality-20"), ["width-100|jpegquality-20"]
+        )
+
+    def test_expand_spec_pipe(self):
+        self.assertEqual(
+            Filter.expand_spec("width-100|jpegquality-20"), ["width-100|jpegquality-20"]
+        )
+
+    def test_expand_spec_list(self):
+        self.assertEqual(
+            Filter.expand_spec(["width-100", "jpegquality-20"]),
+            ["width-100|jpegquality-20"],
+        )
+
+    def test_expand_spec_braced(self):
+        self.assertEqual(
+            Filter.expand_spec("width-{100,200}"), ["width-100", "width-200"]
+        )
+
+    def test_expand_spec_mixed(self):
+        self.assertEqual(
+            Filter.expand_spec("width-{100,200} jpegquality-40"),
+            ["width-100|jpegquality-40", "width-200|jpegquality-40"],
+        )
+
+    def test_expand_spec_mixed_pipe(self):
+        self.assertEqual(
+            Filter.expand_spec("width-{100,200}|jpegquality-40"),
+            ["width-100|jpegquality-40", "width-200|jpegquality-40"],
+        )
+
+    def test_expand_spec_multiple_braces(self):
+        self.assertEqual(
+            Filter.expand_spec("width-{100,200} jpegquality-{40,80} grayscale"),
+            [
+                "width-100|jpegquality-40|grayscale",
+                "width-100|jpegquality-80|grayscale",
+                "width-200|jpegquality-40|grayscale",
+                "width-200|jpegquality-80|grayscale",
+            ],
+        )
+
+
+class TestResponsiveImage(TestCase):
+    def setUp(self):
+        # Create an image for running tests on
+        self.image = Image.objects.create(
+            title="Test image",
+            file=get_test_image_file(),
+        )
+        self.rendition_10 = self.image.get_rendition("width-10")
+
+    def test_construct_empty(self):
+        img = ResponsiveImage({})
+        self.assertEqual(img.renditions, [])
+        self.assertEqual(img.attrs, None)
+
+    def test_construct_with_renditions(self):
+        renditions = {"a": self.rendition_10}
+        img = ResponsiveImage(renditions)
+        self.assertEqual(img.renditions, [self.rendition_10])
+
+    def test_evaluate_value(self):
+        self.assertFalse(ResponsiveImage({}))
+        self.assertFalse(ResponsiveImage({}, {"sizes": "100vw"}))
+
+        renditions = {"a": self.rendition_10}
+        self.assertTrue(ResponsiveImage(renditions))
+
+    def test_compare_value(self):
+        renditions = {"a": self.rendition_10}
+        value1 = ResponsiveImage(renditions)
+        value2 = ResponsiveImage(renditions)
+        value3 = ResponsiveImage({"a": self.image.get_rendition("width-15")})
+        value4 = ResponsiveImage(renditions, {"sizes": "100vw"})
+        self.assertNotEqual(value1, value3)
+        self.assertNotEqual(value1, 12345)
+        self.assertEqual(value1, value2)
+        self.assertNotEqual(value1, value4)
+
+    def test_get_width_srcset(self):
+        renditions = {
+            "width-10": self.rendition_10,
+            "width-90": self.image.get_rendition("width-90"),
+        }
+        filenames = [
+            get_test_image_filename(self.image, "width-10"),
+            get_test_image_filename(self.image, "width-90"),
+        ]
+        self.assertEqual(
+            ResponsiveImage.get_width_srcset(list(renditions.values())),
+            f"{filenames[0]} 10w, {filenames[1]} 90w",
+        )
+
+    def test_get_width_srcset_single_rendition(self):
+        renditions = {"width-10": self.rendition_10}
+        self.assertEqual(
+            ResponsiveImage.get_width_srcset(list(renditions.values())),
+            get_test_image_filename(self.image, "width-10"),
+        )
+
+    def test_render(self):
+        renditions = {
+            "width-10": self.rendition_10,
+            "width-90": self.image.get_rendition("width-90"),
+        }
+        img = ResponsiveImage(renditions)
+        filenames = [
+            get_test_image_filename(self.image, "width-10"),
+            get_test_image_filename(self.image, "width-90"),
+        ]
+        self.assertHTMLEqual(
+            img.__html__(),
+            f"""
+                <img
+                    alt="Test image"
+                    src="{filenames[0]}"
+                    srcset="{filenames[0]} 10w, {filenames[1]} 90w"
+                    width="10"
+                    height="7"
+                >
+            """,
+        )
+
+    def test_render_single_image_same_as_img_tag(self):
+        renditions = {
+            "width-10": self.rendition_10,
+        }
+        img = ResponsiveImage(renditions)
+        self.assertHTMLEqual(
+            img.__html__(),
+            self.rendition_10.img_tag(),
+        )
+
+
 @override_settings(
     CACHES={"default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}}
 )
@@ -292,6 +434,14 @@ class TestRenditions(TestCase):
         # Check that they are the same object
         self.assertEqual(first_rendition, second_rendition)
 
+    def test_get_with_filter_instance(self):
+        # Get two renditions with the same filter
+        first_rendition = self.image.get_rendition("width-400")
+        second_rendition = self.image.get_rendition(Filter("width-400"))
+
+        # Check that they are the same object
+        self.assertEqual(first_rendition, second_rendition)
+
     def test_prefetched_rendition_found(self):
         # Request a rendition that does not exist yet
         with self.assertNumQueries(5):
@@ -364,6 +514,21 @@ class TestRenditions(TestCase):
 
         self.assertIs(second_rendition, third_rendition)
 
+    def test_get_renditions_with_filter_instance(self):
+        # Get two renditions with the same filter
+        first = list(self.image.get_renditions("width-400").values())
+        second = list(self.image.get_renditions(Filter("width-400")).values())
+
+        # Check that they are the same object
+        self.assertEqual(first[0], second[0])
+
+    def test_get_renditions_key_order(self):
+        # Fetch one of the renditions so it exists before the other two.
+        self.image.get_rendition("width-40")
+        specs = ["width-30", "width-40", "width-50"]
+        renditions_keys = list(self.image.get_renditions(*specs).keys())
+        self.assertEqual(renditions_keys, specs)
+
     def _test_get_renditions_performance(
         self,
         db_queries_expected: int,

+ 43 - 1
wagtail/images/tests/test_shortcuts.py

@@ -1,6 +1,10 @@
 from django.test import TestCase
 
-from wagtail.images.shortcuts import get_rendition_or_not_found
+from wagtail.images.models import Filter
+from wagtail.images.shortcuts import (
+    get_rendition_or_not_found,
+    get_renditions_or_not_found,
+)
 
 from .utils import Image, get_test_image_file
 
@@ -21,3 +25,41 @@ class TestShortcuts(TestCase):
 
         rendition = get_rendition_or_not_found(bad_image, "width-400")
         self.assertEqual(rendition.file.name, "not-found")
+
+    def test_multiple_fallback_to_not_found(self):
+        bad_image = Image.objects.get(id=1)
+        good_image = Image.objects.create(
+            title="Test image",
+            file=get_test_image_file(),
+        )
+
+        renditions = get_renditions_or_not_found(good_image, ("width-200", "width-400"))
+        self.assertEqual(tuple(renditions.keys()), ("width-200", "width-400"))
+        self.assertEqual(renditions["width-200"].width, 200)
+        self.assertEqual(renditions["width-400"].width, 400)
+
+        renditions = get_renditions_or_not_found(bad_image, ("width-200", "width-400"))
+        self.assertEqual(tuple(renditions.keys()), ("width-200", "width-400"))
+        self.assertEqual(renditions["width-200"].file.name, "not-found")
+        self.assertEqual(renditions["width-400"].file.name, "not-found")
+
+    def test_multiple_fallback_to_not_found_with_filters(self):
+        bad_image = Image.objects.get(id=1)
+        good_image = Image.objects.create(
+            title="Test image",
+            file=get_test_image_file(),
+        )
+
+        renditions = get_renditions_or_not_found(
+            good_image, (Filter("width-200"), Filter("width-400"))
+        )
+        self.assertEqual(tuple(renditions.keys()), ("width-200", "width-400"))
+        self.assertEqual(renditions["width-200"].width, 200)
+        self.assertEqual(renditions["width-400"].width, 400)
+
+        renditions = get_renditions_or_not_found(
+            bad_image, (Filter("width-200"), Filter("width-400"))
+        )
+        self.assertEqual(tuple(renditions.keys()), ("width-200", "width-400"))
+        self.assertEqual(renditions["width-200"].file.name, "not-found")
+        self.assertEqual(renditions["width-400"].file.name, "not-found")

+ 215 - 2
wagtail/images/tests/test_templatetags.py

@@ -1,9 +1,18 @@
-from django.template import Variable
+from django.template import Context, Engine, TemplateSyntaxError, Variable
 from django.test import TestCase
 
 from wagtail.images.models import Image, Rendition
 from wagtail.images.templatetags.wagtailimages_tags import ImageNode
-from wagtail.images.tests.utils import get_test_image_file, get_test_image_file_svg
+from wagtail.images.tests.utils import (
+    get_test_bad_image,
+    get_test_image_file,
+    get_test_image_file_svg,
+    get_test_image_filename,
+)
+
+LIBRARIES = {
+    "wagtailimages_tags": "wagtail.images.templatetags.wagtailimages_tags",
+}
 
 
 class ImageNodeTestCase(TestCase):
@@ -103,3 +112,207 @@ class ImageNodeTestCase(TestCase):
                 self.assertEqual(
                     node.get_filter(preserve_svg=image.is_svg()).spec, expected
                 )
+
+
+class ImagesTestCase(TestCase):
+    maxDiff = None
+
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        cls.engine = Engine(
+            app_dirs=True,
+            libraries=LIBRARIES,
+            builtins=[LIBRARIES["wagtailimages_tags"]],
+        )
+
+    @classmethod
+    def setUpTestData(cls):
+        # Create an image for running tests on
+        cls.image = Image.objects.create(
+            title="Test image",
+            file=get_test_image_file(),
+        )
+        cls.svg_image = Image.objects.create(
+            title="Test SVG image",
+            file=get_test_image_file_svg(),
+        )
+        cls.bad_image = get_test_bad_image()
+        cls.bad_image.save()
+
+    def render(self, string, context=None):
+        if context is None:
+            context = {}
+
+        template = self.engine.from_string(string)
+        return template.render(Context(context, autoescape=False))
+
+
+class ImageTagTestCase(ImagesTestCase):
+    def test_image(self):
+        filename_200 = get_test_image_filename(self.image, "width-200")
+
+        rendered = self.render("{% image myimage width-200 %}", {"myimage": self.image})
+        self.assertHTMLEqual(
+            rendered,
+            f'<img alt="Test image" height="150" src="{filename_200}" width="200" />',
+        )
+
+    def test_none(self):
+        rendered = self.render("{% image myimage width-200 %}", {"myimage": None})
+        self.assertEqual(rendered, "")
+
+    def test_missing_image(self):
+        rendered = self.render(
+            "{% image myimage width-200 %}", {"myimage": self.bad_image}
+        )
+        self.assertHTMLEqual(
+            rendered,
+            '<img alt="missing image" src="/media/not-found" width="0" height="0">',
+        )
+
+    def test_not_an_image(self):
+        with self.assertRaisesMessage(
+            ValueError, "Image template tags expect an Image object, got 'not a pipe'"
+        ):
+            self.render(
+                "{% image myimage width-200 %}",
+                {"myimage": "not a pipe"},
+            )
+
+    def test_invalid_character(self):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "filter specs in image tags may only"
+        ):
+            self.render(
+                "{% image myimage fill-200×200 %}",
+                {"myimage": self.image},
+            )
+
+    def test_multiple_as_variable(self):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "More than one variable name after 'as'"
+        ):
+            self.render(
+                "{% image myimage width-200 as a b %}",
+                {"myimage": self.image},
+            )
+
+    def test_missing_as_variable(self):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "Missing a variable name after 'as'"
+        ):
+            self.render(
+                "{% image myimage width-200 as %}",
+                {"myimage": self.image},
+            )
+
+    def test_mixing_as_variable_and_attrs(self):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "Do not use attributes with 'as' context assignments"
+        ):
+            self.render(
+                "{% image myimage width-200 alt='Test' as test %}",
+                {"myimage": self.image},
+            )
+
+    def test_missing_filter_spec(self):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "Image tags must be used with at least one filter spec"
+        ):
+            self.render(
+                "{% image myimage %}",
+                {"myimage": self.image},
+            )
+
+
+class SrcsetImageTagTestCase(ImagesTestCase):
+    def test_srcset_image(self):
+        filename_20 = get_test_image_filename(self.image, "width-20")
+        filename_40 = get_test_image_filename(self.image, "width-40")
+
+        rendered = self.render(
+            "{% srcset_image myimage width-{20,40} sizes='100vw' %}",
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <img
+                sizes="100vw"
+                src="{filename_20}"
+                srcset="{filename_20} 20w, {filename_40} 40w"
+                alt="Test image"
+                width="20"
+                height="15"
+            >
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_srcset_output_single_image(self):
+        self.assertHTMLEqual(
+            self.render(
+                "{% srcset_image myimage width-20 %}",
+                {"myimage": self.image},
+            ),
+            self.render(
+                "{% image myimage width-20 %}",
+                {"myimage": self.image},
+            ),
+        )
+
+    def test_invalid_character(self):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "filter specs in image tags may only contain"
+        ):
+            self.render(
+                "{% srcset_image myimage fill-{200×200,400×400} sizes='100vw' %}",
+                {"myimage": self.image},
+            )
+
+    def test_srcset_image_assignment(self):
+        template = (
+            "{% srcset_image myimage width-{30,60} as bg %}"
+            "width: {{ bg.renditions.0.width }}, url: {{ bg.renditions.0.url }} "
+            "width: {{ bg.renditions.1.width }}, url: {{ bg.renditions.1.url }} "
+        )
+        rendered = self.render(template, {"myimage": self.image})
+        expected = f"""
+            width: 30, url: {get_test_image_filename(self.image, "width-30")}
+            width: 60, url: {get_test_image_filename(self.image, "width-60")}
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_srcset_image_assignment_render_as_is(self):
+        filename_35 = get_test_image_filename(self.image, "width-35")
+        filename_70 = get_test_image_filename(self.image, "width-70")
+
+        rendered = self.render(
+            "{% srcset_image myimage width-{35,70} as bg %}{{ bg }}",
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <img
+                src="{filename_35}"
+                srcset="{filename_35} 35w, {filename_70} 70w"
+                alt="Test image"
+                width="35"
+                height="26"
+            >
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_missing_srcset_image(self):
+        rendered = self.render(
+            "{% srcset_image myimage width-{200,400} sizes='100vw' %}",
+            {"myimage": self.bad_image},
+        )
+        expected = """
+            <img
+                sizes="100vw"
+                src="/media/not-found"
+                srcset="/media/not-found 0w, /media/not-found 0w"
+                alt="missing image"
+                width="0"
+                height="0"
+            >
+        """
+        self.assertHTMLEqual(rendered, expected)

+ 32 - 0
wagtail/images/tests/utils.py

@@ -1,6 +1,9 @@
+import os
 from io import BytesIO
 
 import PIL.Image
+from django.conf import settings
+from django.core import serializers
 from django.core.files.images import ImageFile
 
 from wagtail.images import get_image_model
@@ -8,6 +11,14 @@ from wagtail.images import get_image_model
 Image = get_image_model()
 
 
+def get_test_image_filename(image, filterspec):
+    """
+    Get the generated filename for a resized image
+    """
+    name, ext = os.path.splitext(os.path.basename(image.file.name))
+    return f"{settings.MEDIA_URL}images/{name}.{filterspec}{ext}"
+
+
 def get_test_image_file(filename="test.png", colour="white", size=(640, 480)):
     f = BytesIO()
     image = PIL.Image.new("RGBA", size, colour)
@@ -54,3 +65,24 @@ def get_test_image_file_svg(
     """
     f = BytesIO(img.strip().encode("utf-8"))
     return ImageFile(f, filename)
+
+
+def get_test_bad_image():
+    # Create an image with a missing file, by deserializing fom a python object
+    # (which bypasses FileField's attempt to read the file)
+    return list(
+        serializers.deserialize(
+            "python",
+            [
+                {
+                    "fields": {
+                        "title": "missing image",
+                        "height": 100,
+                        "file": "original_images/missing-image.jpg",
+                        "width": 100,
+                    },
+                    "model": "wagtailimages.image",
+                }
+            ],
+        )
+    )[0].object