浏览代码

Add srcset_image tag for responsive images

Paarth Agarwal 1 年之前
父节点
当前提交
41dac89e1d

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

@@ -38,13 +38,13 @@ See also: [](image_tag)
 
 
 ## Generating multiple renditions for an image
 ## 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
 ```python
 image.get_renditions('width-600', 'height-400', 'fill-300x186|jpegquality-60')
 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
 ```python
 {
 {

+ 23 - 4
docs/reference/jinja2.md

@@ -70,19 +70,38 @@ See [](slugurl_tag) for more information
 
 
 ### `image()`
 ### `image()`
 
 
-Resize an image, and print an `<img>` tag:
+Resize an image, and render an `<img>` tag:
 
 
 ```html+jinja
 ```html+jinja
-{# Print an image tag #}
 {{ image(page.header_image, "fill-1024x200", class="header-image") }}
 {{ 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") %}
 {% 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
 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`
 ### `|richtext`
 
 
 Transform Wagtail's internal HTML representation, expanding internal references to pages and images.
 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.
 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)=
 
 
 ## 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
 ### 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
 ```html+django
 {% image page.photo width-400 as tmp_photo %}
 {% 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" />
     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}
 ```{note}
 The image property used for the `src` attribute is `image.url`, not `image.src`.
 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`
 ### `url`
 
 

+ 20 - 6
wagtail/images/jinja2tags.py

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

+ 92 - 6
wagtail/images/models.py

@@ -1,13 +1,15 @@
 import hashlib
 import hashlib
+import itertools
 import logging
 import logging
 import os.path
 import os.path
+import re
 import time
 import time
 from collections import OrderedDict, defaultdict
 from collections import OrderedDict, defaultdict
 from concurrent.futures import ThreadPoolExecutor
 from concurrent.futures import ThreadPoolExecutor
 from contextlib import contextmanager
 from contextlib import contextmanager
 from io import BytesIO
 from io import BytesIO
 from tempfile import SpooledTemporaryFile
 from tempfile import SpooledTemporaryFile
-from typing import Dict, Iterable, List, Union
+from typing import Any, Dict, Iterable, List, Optional, Union
 
 
 import willow
 import willow
 from django.apps import apps
 from django.apps import apps
@@ -496,16 +498,20 @@ class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Mode
         )
         )
         return rendition
         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
         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
         Note: If using custom image models, instances of the custom rendition
         model will be returned.
         model will be returned.
         """
         """
         Rendition = self.get_rendition_model()
         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
         # Find existing renditions where possible
         renditions = self.find_existing_renditions(*filters)
         renditions = self.find_existing_renditions(*filters)
@@ -528,8 +534,8 @@ class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Mode
         if cache_additions:
         if cache_additions:
             Rendition.cache_backend.set_many(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(
     def find_existing_renditions(
         self, *filters: "Filter"
         self, *filters: "Filter"
@@ -822,10 +828,45 @@ class Filter:
     but could potentially involve colour processing, etc.
     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):
     def __init__(self, spec=None):
         # The spec pattern is operation1-var1-var2|operation2-var1
         # The spec pattern is operation1-var1-var2|operation2-var1
         self.spec = spec
         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
     @cached_property
     def operations(self):
     def operations(self):
         # Search for operations
         # Search for operations
@@ -1002,6 +1043,51 @@ class Filter:
         return hashlib.sha1(vary_string.encode("utf-8")).hexdigest()[:8]
         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):
 class AbstractRendition(ImageFileMixin, models.Model):
     filter_spec = models.CharField(max_length=255, db_index=True)
     filter_spec = models.CharField(max_length=255, db_index=True)
     """ Use local ImageField with Willow support.  """
     """ 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 = Rendition(image=image, width=0, height=0)
         rendition.file.name = "not-found"
         rendition.file.name = "not-found"
         return rendition
         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 import template
 from django.core.exceptions import ImproperlyConfigured
 from django.core.exceptions import ImproperlyConfigured
 from django.urls import NoReverseMatch
 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.utils import to_svg_safe_spec
 from wagtail.images.views.serve import generate_image_url
 from wagtail.images.views.serve import generate_image_url
 
 
 register = template.Library()
 register = template.Library()
-allowed_filter_pattern = re.compile(r"^[A-Za-z0-9_\-\.]+$")
 
 
 
 
-@register.tag(name="image")
 def image(parser, token):
 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])
     image_expr = parser.compile_filter(bits[0])
     bits = bits[1:]
     bits = bits[1:]
 
 
@@ -24,8 +27,9 @@ def image(parser, token):
     output_var_name = None
     output_var_name = None
 
 
     as_context = False  # if True, the next bit to be read is the output variable name
     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
     preserve_svg = False
 
 
     for bit in bits:
     for bit in bits:
@@ -37,7 +41,7 @@ def image(parser, token):
                 output_var_name = bit
                 output_var_name = bit
             else:
             else:
                 # more than one item exists after 'as' - reject as invalid
                 # 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":
         elif bit == "preserve-svg":
             preserve_svg = True
             preserve_svg = True
         else:
         else:
@@ -47,36 +51,41 @@ def image(parser, token):
                     value
                     value
                 )  # setup to resolve context variables as value
                 )  # setup to resolve context variables as value
             except ValueError:
             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)
                     filter_specs.append(bit)
                 else:
                 else:
                     raise template.TemplateSyntaxError(
                     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)
                         "(given filter: {})".format(bit)
                     )
                     )
 
 
     if as_context and output_var_name is None:
     if as_context and output_var_name is None:
         # context was introduced but no variable given ...
         # 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:
     if output_var_name and attrs:
         # attributes are not valid when using the 'as img' form of the tag
         # 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:
     if len(filter_specs) == 0:
         # there must always be at least one filter spec provided
         # 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:
     if len(bits) == 0:
         # no resize rule provided eg. {% image page.image %}
         # 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,
             image_expr,
             filter_specs,
             filter_specs,
             attrs=attrs,
             attrs=attrs,
@@ -84,12 +93,18 @@ def image(parser, token):
             preserve_svg=preserve_svg,
             preserve_svg=preserve_svg,
         )
         )
     else:
     else:
+        errors = "; ".join(error_messages)
         raise template.TemplateSyntaxError(
         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):
 class ImageNode(template.Node):
     def __init__(
     def __init__(
         self,
         self,
@@ -110,19 +125,29 @@ class ImageNode(template.Node):
             return Filter(to_svg_safe_spec(self.filter_specs))
             return Filter(to_svg_safe_spec(self.filter_specs))
         return Filter(spec="|".join(self.filter_specs))
         return Filter(spec="|".join(self.filter_specs))
 
 
-    def render(self, context):
+    def validate_image(self, context):
         try:
         try:
             image = self.image_expr.resolve(context)
             image = self.image_expr.resolve(context)
         except template.VariableDoesNotExist:
         except template.VariableDoesNotExist:
-            return ""
+            return
 
 
         if not image:
         if not image:
             if self.output_var_name:
             if self.output_var_name:
                 context[self.output_var_name] = None
                 context[self.output_var_name] = None
-            return ""
+            return
 
 
         if not hasattr(image, "get_rendition"):
         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(
         rendition = get_rendition_or_not_found(
             image,
             image,
@@ -141,6 +166,35 @@ class ImageNode(template.Node):
             return rendition.img_tag(resolved_attrs)
             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()
 @register.simple_tag()
 def image_url(image, filter_spec, viewname="wagtailimages_serve"):
 def image_url(image, filter_spec, viewname="wagtailimages_serve"):
     try:
     try:

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

@@ -1,14 +1,16 @@
-import os
 import unittest.mock
 import unittest.mock
 
 
 from django.apps import apps
 from django.apps import apps
-from django.conf import settings
-from django.core import serializers
 from django.test import TestCase
 from django.test import TestCase
 
 
 from wagtail.images.blocks import ImageChooserBlock
 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):
 class TestImageChooserBlock(TestCase):
@@ -18,39 +20,15 @@ class TestImageChooserBlock(TestCase):
             file=get_test_image_file(),
             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()
         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):
     def test_render(self):
         block = ImageChooserBlock()
         block = ImageChooserBlock()
         html = block.render(self.image)
         html = block.render(self.image)
         expected_html = (
         expected_html = (
             '<img alt="Test image" src="{}" width="640" height="480">'.format(
             '<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
 import unittest.mock
 
 
-from django import template
 from django.apps import apps
 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 django.test import TestCase
 
 
 from wagtail.models import Site
 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):
     def setUp(self):
         self.engine = engines["jinja2"]
         self.engine = engines["jinja2"]
 
 
@@ -22,24 +23,7 @@ class TestImagesJinja(TestCase):
             file=get_test_image_file(),
             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()
         self.bad_image.save()
 
 
     def render(self, string, context=None, request_context=True):
     def render(self, string, context=None, request_context=True):
@@ -55,18 +39,13 @@ class TestImagesJinja(TestCase):
         template = self.engine.from_string(string)
         template = self.engine.from_string(string)
         return template.render(context)
         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):
     def test_image(self):
         self.assertHTMLEqual(
         self.assertHTMLEqual(
             self.render('{{ image(myimage, "width-200") }}', {"myimage": self.image}),
             self.render('{{ image(myimage, "width-200") }}', {"myimage": self.image}),
             '<img alt="Test image" src="{}" width="200" height="150">'.format(
             '<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},
                 {"myimage": self.image},
             ),
             ),
             '<img alt="alternate" src="{}" width="200" height="150" class="test">'.format(
             '<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):
     def test_image_assignment(self):
         template = (
         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)
         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):
     def test_missing_image(self):
         self.assertHTMLEqual(
         self.assertHTMLEqual(
             self.render(
             self.render(
@@ -98,7 +88,9 @@ class TestImagesJinja(TestCase):
         )
         )
 
 
     def test_invalid_character(self):
     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})
             self.render('{{ image(myimage, "fill-200×200") }}', {"myimage": self.image})
 
 
     def test_custom_default_attrs(self):
     def test_custom_default_attrs(self):
@@ -121,10 +113,12 @@ class TestImagesJinja(TestCase):
                 {"myimage": self.image},
                 {"myimage": self.image},
             ),
             ),
             '<img alt="Test image" src="{}" width="200" height="150">'.format(
             '<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):
     def test_image_url(self):
         self.assertRegex(
         self.assertRegex(
             self.render(
             self.render(
@@ -143,3 +137,138 @@ class TestImagesJinja(TestCase):
                 self.image.file.name.split("/")[-1]
                 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.core.files.uploadedfile import SimpleUploadedFile
 from django.db.models import Prefetch
 from django.db.models import Prefetch
 from django.db.utils import IntegrityError
 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 django.urls import reverse
 from willow.image import Image as WillowImage
 from willow.image import Image as WillowImage
 
 
 from wagtail.images.models import (
 from wagtail.images.models import (
     Filter,
     Filter,
     Rendition,
     Rendition,
+    ResponsiveImage,
     SourceImageIOError,
     SourceImageIOError,
     get_rendition_storage,
     get_rendition_storage,
 )
 )
@@ -26,7 +27,7 @@ from wagtail.test.testapp.models import (
 )
 )
 from wagtail.test.utils import WagtailTestUtils, override_settings
 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):
 class CustomStorage(Storage):
@@ -236,6 +237,147 @@ class TestImagePermissions(WagtailTestUtils, TestCase):
         self.assertFalse(self.image.is_editable_by_user(self.user))
         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(
 @override_settings(
     CACHES={"default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}}
     CACHES={"default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}}
 )
 )
@@ -292,6 +434,14 @@ class TestRenditions(TestCase):
         # Check that they are the same object
         # Check that they are the same object
         self.assertEqual(first_rendition, second_rendition)
         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):
     def test_prefetched_rendition_found(self):
         # Request a rendition that does not exist yet
         # Request a rendition that does not exist yet
         with self.assertNumQueries(5):
         with self.assertNumQueries(5):
@@ -364,6 +514,21 @@ class TestRenditions(TestCase):
 
 
         self.assertIs(second_rendition, third_rendition)
         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(
     def _test_get_renditions_performance(
         self,
         self,
         db_queries_expected: int,
         db_queries_expected: int,

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

@@ -1,6 +1,10 @@
 from django.test import TestCase
 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
 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")
         rendition = get_rendition_or_not_found(bad_image, "width-400")
         self.assertEqual(rendition.file.name, "not-found")
         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 django.test import TestCase
 
 
 from wagtail.images.models import Image, Rendition
 from wagtail.images.models import Image, Rendition
 from wagtail.images.templatetags.wagtailimages_tags import ImageNode
 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):
 class ImageNodeTestCase(TestCase):
@@ -103,3 +112,207 @@ class ImageNodeTestCase(TestCase):
                 self.assertEqual(
                 self.assertEqual(
                     node.get_filter(preserve_svg=image.is_svg()).spec, expected
                     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
 from io import BytesIO
 
 
 import PIL.Image
 import PIL.Image
+from django.conf import settings
+from django.core import serializers
 from django.core.files.images import ImageFile
 from django.core.files.images import ImageFile
 
 
 from wagtail.images import get_image_model
 from wagtail.images import get_image_model
@@ -8,6 +11,14 @@ from wagtail.images import get_image_model
 Image = 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)):
 def get_test_image_file(filename="test.png", colour="white", size=(640, 480)):
     f = BytesIO()
     f = BytesIO()
     image = PIL.Image.new("RGBA", size, colour)
     image = PIL.Image.new("RGBA", size, colour)
@@ -54,3 +65,24 @@ def get_test_image_file_svg(
     """
     """
     f = BytesIO(img.strip().encode("utf-8"))
     f = BytesIO(img.strip().encode("utf-8"))
     return ImageFile(f, filename)
     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