ソースを参照

Add picture tag implementation and tests

Thibaud Colas 1 年間 前
コミット
4bfbbae405

+ 25 - 1
docs/reference/jinja2.md

@@ -89,7 +89,7 @@ See [](image_tag) for more information
 
 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.
+The [`sizes`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#sizes) attribute is essential 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") }}
@@ -102,6 +102,30 @@ Or resize an image and retrieve the renditions for more bespoke use:
 <div class="wrapper" style="background-image: image-set(url({{ bg.renditions[0].url }}) 1x, url({{ bg.renditions[1].url }}) 2x);"></div>
 ```
 
+### `picture()`
+
+Resize or convert an image, rendering a `<picture>` tag including multiple `source` formats with `srcset` for multiple sizes.
+Browsers will select the [first supported image format](https://web.dev/learn/design/picture-element/#image-formats), and pick a size based on [responsive image rules](https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images).
+
+`picture` can render an image in multiple formats:
+
+```html+jinja
+{{ picture(page.header_image, "format-{avif,jpeg}") }}
+```
+
+Or render multiple formats and multiple sizes like `srcset_image` does. The [`sizes`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#sizes) attribute is essential when the picture tag renders images in multiple sizes:
+
+```html+jinja
+{{ picture(page.header_image, "format-{avif,jpeg}|fill-{512x100,1024x200}", sizes="100vw") }}
+```
+
+Or resize an image and retrieve the renditions for more bespoke use:
+
+```html+jinja
+{% set bg=picture(page.background_image, "format-{avif,jpeg}|max-{512x512,1024x1024}") %}
+<div class="wrapper" style="background-image: image-set(url({{ bg.formats['avif'][0].url }}) 1x type('image/avif'), url({{ bg.formats['avif'][1].url }}) 2x type('image/avif'), url({{ bg.formats['jpeg'][0].url }}) 1x type('image/jpeg'), url({{ bg.formats['jpeg'][1].url }}) 2x type('image/jpeg'));"></div>
+```
+
 ### `|richtext`
 
 Transform Wagtail's internal HTML representation, expanding internal references to pages and images.

+ 24 - 2
docs/topics/images.md

@@ -29,11 +29,25 @@ 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.
 
+(multiple_formats)=
+
+## Multiple formats
+
+To render an image in multiple formats, you can use the `picture` tag:
+
+```html+django
+{% picture page.photo width-400 format-{avif,jpeg} %}
+```
+
+Compared to `image`, this will render a `<picture>` element with one `<source>` element per format. The browser [picks the first format it supports](https://web.dev/learn/design/picture-element/#source), or defaults to a fallback `<img>` element.
+
+`picture` can also be used with responsive image resize rules.
+
 (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).
+Wagtail provides `picture` and `srcset_image` template tags which can generate 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:
 
@@ -42,7 +56,15 @@ The syntax for `srcset_image` is the same as `image, with two exceptions:
 ```
 
 - 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.
+- The [`sizes`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#sizes) attribute is essential. 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.
+
+Here is an example with the `picture` tag:
+
+```html+django
+{% picture page.photo fill-{512x100,1024x200} format-{avif,jpeg} sizes="100vw" %}
+```
+
+This will generate a `<picture>` element with one `<source>` for AVIF and one fallback `<img>` with JPEG. Both the source and fallback image will have the `sizes` attribute, and a `srcset` with two URLs each.
 
 (available_resizing_methods)=
 

+ 4 - 2
wagtail/images/image_operations.py

@@ -409,13 +409,15 @@ class WebPQualityOperation(FilterOperation):
 
 
 class FormatOperation(FilterOperation):
+    supported_formats = ["jpeg", "png", "gif", "webp", "avif"]
+
     def construct(self, format, *options):
         self.format = format
         self.options = options
 
-        if self.format not in ["jpeg", "png", "gif", "webp", "avif"]:
+        if self.format not in self.supported_formats:
             raise ValueError(
-                "Format must be either 'jpeg', 'png', 'gif', 'webp' or 'avif'"
+                f"Format must be one of: {', '.join(self.supported_formats)}. Got: {self.format}"
             )
 
     def run(self, willow, image, env):

+ 18 - 1
wagtail/images/jinja2tags.py

@@ -1,7 +1,7 @@
 from django import template
 from jinja2.ext import Extension
 
-from .models import Filter, ResponsiveImage
+from .models import Filter, Picture, ResponsiveImage
 from .shortcuts import get_rendition_or_not_found, get_renditions_or_not_found
 from .templatetags.wagtailimages_tags import image_url
 
@@ -40,6 +40,22 @@ def srcset_image(image, filterspec, **attrs):
     return ResponsiveImage(renditions, attrs)
 
 
+def picture(image, filterspec, **attrs):
+    if not image:
+        return ""
+
+    if not Filter.pipe_expanding_spec_pattern.match(filterspec):
+        raise template.TemplateSyntaxError(
+            "filter specs in 'picture' 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 Picture(renditions, attrs)
+
+
 class WagtailImagesExtension(Extension):
     def __init__(self, environment):
         super().__init__(environment)
@@ -49,6 +65,7 @@ class WagtailImagesExtension(Extension):
                 "image": image,
                 "image_url": image_url,
                 "srcset_image": srcset_image,
+                "picture": picture,
             }
         )
 

+ 72 - 0
wagtail/images/models.py

@@ -36,8 +36,10 @@ from wagtail.images.exceptions import (
     InvalidFilterSpecError,
     UnknownOutputImageFormatError,
 )
+from wagtail.images.fields import image_format_name_to_content_type
 from wagtail.images.image_operations import (
     FilterOperation,
+    FormatOperation,
     ImageTransform,
     TransformOperation,
 )
@@ -1088,6 +1090,76 @@ class ResponsiveImage:
         return False
 
 
+class Picture(ResponsiveImage):
+    # Keep this separate from FormatOperation.supported_formats,
+    # as the order our formats are defined in is essential for the picture tag.
+    # Defines the order of <source> elements in the tag when format operations
+    # are in use, and the priority order to identify the "fallback" format.
+    # The browser will pick the first supported format in this list.
+    source_format_order = ["avif", "webp", "jpeg", "png", "gif"]
+
+    def __init__(
+        self,
+        renditions: Dict[str, "AbstractRendition"],
+        attrs: Optional[Dict[str, Any]] = None,
+    ):
+        super().__init__(renditions, attrs)
+        # Store renditions grouped by format separately for access from templates.
+        self.formats = self.get_formats(renditions)
+
+    def get_formats(
+        self, renditions: Dict[str, "AbstractRendition"]
+    ) -> Dict[str, List["AbstractRendition"]]:
+        """
+        Group renditions by the format they are for, if any.
+        If there is only one format, no grouping is required.
+        """
+        formats = defaultdict(list)
+        for spec, rendition in renditions.items():
+            for fmt in FormatOperation.supported_formats:
+                # Identify the spec’s format (if any).
+                if f"format-{fmt}" in spec:
+                    formats[fmt].append(rendition)
+                    break
+        # Avoid the split by format if there is only one.
+        if len(formats.keys()) < 2:
+            return {}
+
+        return formats
+
+    def get_fallback_format(self):
+        for fmt in reversed(self.source_format_order):
+            if fmt in self.formats:
+                return fmt
+
+    def __html__(self):
+        # If there aren’t multiple formats, render a vanilla img tag with srcset.
+        if not self.formats:
+            return mark_safe(f"<picture>{super().__html__()}</picture>")
+
+        attrs = self.attrs or {}
+
+        sizes = f'sizes="{attrs["sizes"]}" ' if "sizes" in attrs else ""
+        fallback_format = self.get_fallback_format()
+        fallback_renditions = self.formats[fallback_format]
+
+        sources = []
+
+        for fmt in self.source_format_order:
+            if fmt != fallback_format and fmt in self.formats:
+                srcset = self.get_width_srcset(self.formats[fmt])
+                mime = image_format_name_to_content_type(fmt)
+                sources.append(f'<source srcset="{srcset}" {sizes}type="{mime}">')
+
+        if len(fallback_renditions) > 1:
+            attrs["srcset"] = self.get_width_srcset(fallback_renditions)
+
+        # The first rendition is the "base" / "fallback" image.
+        fallback = fallback_renditions[0].img_tag(attrs)
+
+        return mark_safe(f"<picture>{''.join(sources)}{fallback}</picture>")
+
+
 class AbstractRendition(ImageFileMixin, models.Model):
     filter_spec = models.CharField(max_length=255, db_index=True)
     """ Use local ImageField with Willow support.  """

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

@@ -2,7 +2,7 @@ from django import template
 from django.core.exceptions import ImproperlyConfigured
 from django.urls import NoReverseMatch
 
-from wagtail.images.models import Filter, ResponsiveImage
+from wagtail.images.models import Filter, Picture, ResponsiveImage
 from wagtail.images.shortcuts import (
     get_rendition_or_not_found,
     get_renditions_or_not_found,
@@ -76,14 +76,11 @@ def image(parser, token):
         # there must always be at least one filter spec provided
         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 %}
-        error_messages.append("No resize rule provided")
-
     if len(error_messages) == 0:
         Node = {
             "image": ImageNode,
             "srcset_image": SrcsetImageNode,
+            "picture": PictureNode,
         }
         return Node[tag_name](
             image_expr,
@@ -103,6 +100,7 @@ def image(parser, token):
 
 register.tag("image", image)
 register.tag("srcset_image", image)
+register.tag("picture", image)
 
 
 class ImageNode(template.Node):
@@ -195,6 +193,31 @@ class SrcsetImageNode(ImageNode):
         return ResponsiveImage(renditions, resolved_attrs).__html__()
 
 
+class PictureNode(SrcsetImageNode):
+    def render(self, context):
+        image = self.validate_image(context)
+
+        if not image:
+            return ""
+
+        renditions = get_renditions_or_not_found(
+            image,
+            self.get_filters(preserve_svg=self.preserve_svg and image.is_svg()),
+        )
+
+        if self.output_var_name:
+            # Wrap the renditions in Picture object, to support both
+            # rendering as-is and access to the data.
+            context[self.output_var_name] = Picture(renditions)
+            return ""
+
+        resolved_attrs = {}
+        for key in self.attrs:
+            resolved_attrs[key] = self.attrs[key].resolve(context)
+
+        return Picture(renditions, resolved_attrs).__html__()
+
+
 @register.simple_tag()
 def image_url(image, filter_spec, viewname="wagtailimages_serve"):
     try:

+ 199 - 11
wagtail/images/tests/test_jinja2.py

@@ -15,6 +15,8 @@ from .utils import (
 
 
 class JinjaImagesTestCase(TestCase):
+    maxDiff = None
+
     def setUp(self):
         self.engine = engines["jinja2"]
 
@@ -49,6 +51,10 @@ class TestImageJinja(JinjaImagesTestCase):
             ),
         )
 
+    def test_no_image(self):
+        rendered = self.render('{{ image(myimage, "width-2") }}', {"myimage": None})
+        self.assertEqual(rendered, "")
+
     def test_image_attributes(self):
         self.assertHTMLEqual(
             self.render(
@@ -159,6 +165,12 @@ class TestSrcsetImageJinja(JinjaImagesTestCase):
         """
         self.assertHTMLEqual(rendered, expected)
 
+    def test_no_image(self):
+        rendered = self.render(
+            '{{ srcset_image(myimage, "width-2") }}', {"myimage": None}
+        )
+        self.assertEqual(rendered, "")
+
     def test_srcset_output_single_image(self):
         self.assertHTMLEqual(
             self.render(
@@ -185,21 +197,14 @@ class TestSrcsetImageJinja(JinjaImagesTestCase):
         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"
-            >
-        """
+        expected = self.render(
+            '{{ srcset_image(myimage, "width-{200,400}") }}',
+            {"myimage": self.image},
+        )
         self.assertHTMLEqual(rendered, expected)
 
     def test_missing_srcset_image(self):
@@ -272,3 +277,186 @@ class TestSrcsetImageJinja(JinjaImagesTestCase):
             >
         """
         self.assertHTMLEqual(rendered, expected)
+
+
+class TestPictureJinja(JinjaImagesTestCase):
+    def test_picture_formats_multi_sizes(self):
+        filenames = [
+            get_test_image_filename(self.image, "width-200.format-jpeg"),
+            get_test_image_filename(self.image, "width-400.format-jpeg"),
+            get_test_image_filename(self.image, "width-200.format-webp"),
+            get_test_image_filename(self.image, "width-400.format-webp"),
+            get_test_image_filename(self.image, "width-200.format-gif"),
+            get_test_image_filename(self.image, "width-400.format-gif"),
+        ]
+
+        rendered = self.render(
+            '{{ picture(myimage, "width-{200,400}|format-{jpeg,webp,gif}", sizes="100vw") }}',
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <picture>
+            <source srcset="{filenames[2]} 200w, {filenames[3]} 400w" sizes="100vw" type="image/webp">
+            <source srcset="{filenames[0]} 200w, {filenames[1]} 400w" sizes="100vw" type="image/jpeg">
+            <img
+                sizes="100vw"
+                src="{filenames[4]}"
+                srcset="{filenames[4]} 200w, {filenames[5]} 400w"
+                alt="Test image"
+                width="200"
+                height="150"
+            >
+            </picture>
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_picture_formats_only(self):
+        filename_jpeg = get_test_image_filename(self.image, "format-jpeg")
+        filename_webp = get_test_image_filename(self.image, "format-webp")
+
+        rendered = self.render(
+            '{{ picture(myimage, "format-{jpeg,webp}") }}',
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <picture>
+            <source srcset="{filename_webp}" type="image/webp">
+            <img
+                src="{filename_jpeg}"
+                alt="Test image"
+                width="640"
+                height="480"
+            >
+            </picture>
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_picture_sizes_only(self):
+        rendered = self.render(
+            '{{ picture(myimage, "width-{200,400}", sizes="100vw") }}',
+            {"myimage": self.image},
+        )
+        expected = self.render(
+            '<picture>{{ srcset_image(myimage, "width-{200,400}", sizes="100vw") }}</picture>',
+            {"myimage": self.image},
+        )
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_picture_single_format(self):
+        rendered = self.render(
+            '{{ picture(myimage, "format-jpeg") }}',
+            {"myimage": self.image},
+        )
+        expected = self.render(
+            '<picture>{{ image(myimage, "format-jpeg") }}</picture>',
+            {"myimage": self.image},
+        )
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_no_image(self):
+        rendered = self.render('{{ picture(myimage, "width-2") }}', {"myimage": None})
+        self.assertEqual(rendered, "")
+
+    def test_picture_assignment(self):
+        template = (
+            '{% set bg=picture(myimage, "width-{200,400}|format-{jpeg,webp}") %}'
+            "width: {{ bg.formats['jpeg'][0].width }}, url: {{ bg.formats['jpeg'][0].url }} "
+            "width: {{ bg.formats['jpeg'][1].width }}, url: {{ bg.formats['jpeg'][1].url }} "
+            "width: {{ bg.formats['webp'][0].width }}, url: {{ bg.formats['webp'][0].url }} "
+            "width: {{ bg.formats['webp'][1].width }}, url: {{ bg.formats['webp'][1].url }} "
+        )
+        rendered = self.render(template, {"myimage": self.image})
+        expected = f"""
+            width: 200, url: {get_test_image_filename(self.image, "width-200.format-jpeg")}
+            width: 400, url: {get_test_image_filename(self.image, "width-400.format-jpeg")}
+            width: 200, url: {get_test_image_filename(self.image, "width-200.format-webp")}
+            width: 400, url: {get_test_image_filename(self.image, "width-400.format-webp")}
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_picture_assignment_render_as_is(self):
+        rendered = self.render(
+            '{% set bg=picture(myimage, "width-{200,400}|format-{jpeg,webp,gif}", sizes="100vw") %}{{ bg }}',
+            {"myimage": self.image},
+        )
+        expected = self.render(
+            '{{ picture(myimage, "width-{200,400}|format-{jpeg,webp,gif}", sizes="100vw") }}',
+            {"myimage": self.image},
+        )
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_missing_picture(self):
+        rendered = self.render(
+            '{{ picture(myimage, "format-{jpeg,webp}") }}',
+            {"myimage": self.bad_image},
+        )
+        expected = """
+            <picture>
+                <source srcset="/media/not-found" type="image/webp">
+                <img
+                    src="/media/not-found"
+                    alt="missing image"
+                    width="0"
+                    height="0"
+                >
+            </picture>
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_invalid_character(self):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "filter specs in 'picture' tag may only"
+        ):
+            self.render(
+                '{{ picture(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(
+                '{{ picture(myimage, "format-{jpeg,webp}") }}',
+                {"myimage": self.bad_image},
+            )
+            expected = """
+                <picture>
+                    <source srcset="/media/not-found" type="image/webp">
+                    <img
+                        src="/media/not-found"
+                        alt="missing image"
+                        width="0"
+                        height="0"
+                        decoding="async"
+                        loading="lazy"
+                    >
+                </picture>
+            """
+            self.assertHTMLEqual(rendered, expected)
+
+    def test_chaining_filterspecs(self):
+        filename_jpeg = get_test_image_filename(
+            self.image, "format-jpeg.jpegquality-40.webpquality-40"
+        )
+        filename_webp = get_test_image_filename(
+            self.image, "format-webp.jpegquality-40.webpquality-40"
+        )
+        rendered = self.render(
+            '{{ picture(myimage, "format-{jpeg,webp}|jpegquality-40|webpquality-40") }}',
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <picture>
+            <source srcset="{filename_webp}" type="image/webp">
+            <img
+                src="{filename_jpeg}"
+                alt="Test image"
+                width="640"
+                height="480"
+            >
+            </picture>
+        """
+        self.assertHTMLEqual(rendered, expected)

+ 90 - 3
wagtail/images/tests/test_models.py

@@ -13,6 +13,7 @@ from willow.image import Image as WillowImage
 
 from wagtail.images.models import (
     Filter,
+    Picture,
     Rendition,
     ResponsiveImage,
     SourceImageIOError,
@@ -368,13 +369,99 @@ class TestResponsiveImage(TestCase):
         )
 
     def test_render_single_image_same_as_img_tag(self):
+        img = ResponsiveImage({"width-10": self.rendition_10})
+        self.assertHTMLEqual(img.__html__(), self.rendition_10.img_tag())
+
+
+class TestPicture(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_formats(self):
         renditions = {
-            "width-10": self.rendition_10,
+            "format-jpeg": self.rendition_10,
+            "format-webp": self.rendition_10,
         }
-        img = ResponsiveImage(renditions)
+        img = Picture(renditions)
+        self.assertEqual(
+            img.formats, {"jpeg": [self.rendition_10], "webp": [self.rendition_10]}
+        )
+
+    def test_single_format(self):
+        renditions = {"format-jpeg": self.rendition_10}
+        img = Picture(renditions)
+        self.assertEqual(img.formats, {})
+
+    def test_mixed_format(self):
+        renditions = {
+            "format-jpeg": self.rendition_10,
+            "format-webp": self.rendition_10,
+            "format-webp-lossless": self.rendition_10,
+        }
+        img = Picture(renditions)
+        self.assertEqual(
+            img.formats,
+            {
+                "jpeg": [self.rendition_10],
+                "webp": [self.rendition_10, self.rendition_10],
+            },
+        )
+
+    def test_fallback_format(self):
+        avif = {"format-avif": self.rendition_10}
+        webp = {"format-webp": self.rendition_10}
+        jpeg = {"format-jpeg": self.rendition_10}
+        png = {"format-png": self.rendition_10}
+        gif = {"format-gif": self.rendition_10}
+        fallbacks = {
+            "gif": {**avif, **webp, **jpeg, **png, **gif},
+            "png": {**avif, **webp, **jpeg, **png},
+            "jpeg": {**avif, **webp, **jpeg},
+            "webp": {**avif, **webp},
+        }
+        for fmt, renditions in fallbacks.items():
+            self.assertEqual(Picture(renditions).get_fallback_format(), fmt)
+
+    def test_render_multi_format_sizes(self):
+        renditions = {
+            "format-jpeg|width-10": self.image.get_rendition("format-jpeg|width-10"),
+            "format-jpeg|width-90": self.image.get_rendition("format-jpeg|width-90"),
+            "format-webp|width-10": self.image.get_rendition("format-webp|width-10"),
+            "format-webp|width-90": self.image.get_rendition("format-webp|width-90"),
+        }
+        img = Picture(renditions, {"sizes": "100vw"})
+        filenames = [
+            get_test_image_filename(self.image, "format-jpeg.width-10"),
+            get_test_image_filename(self.image, "format-jpeg.width-90"),
+            get_test_image_filename(self.image, "format-webp.width-10"),
+            get_test_image_filename(self.image, "format-webp.width-90"),
+        ]
         self.assertHTMLEqual(
             img.__html__(),
-            self.rendition_10.img_tag(),
+            f"""
+                <picture>
+                    <source srcset="{filenames[2]} 10w, {filenames[3]} 90w" sizes="100vw" type="image/webp">
+                    <img
+                        alt="Test image"
+                        sizes="100vw"
+                        src="{filenames[0]}"
+                        srcset="{filenames[0]} 10w, {filenames[1]} 90w"
+                        width="10"
+                        height="7"
+                    >
+                </picture>
+            """,
+        )
+
+    def test_render_single_image_same_as_img_tag(self):
+        img = Picture({"width-10": self.rendition_10})
+        self.assertHTMLEqual(
+            img.__html__(), f"<picture>{self.rendition_10.img_tag()}</picture>"
         )
 
 

+ 163 - 1
wagtail/images/tests/test_templatetags.py

@@ -159,7 +159,7 @@ class ImageTagTestCase(ImagesTestCase):
         )
 
     def test_none(self):
-        rendered = self.render("{% image myimage width-200 %}", {"myimage": None})
+        rendered = self.render("{% image myimage width-2 %}", {"myimage": None})
         self.assertEqual(rendered, "")
 
     def test_missing_image(self):
@@ -259,6 +259,10 @@ class SrcsetImageTagTestCase(ImagesTestCase):
             ),
         )
 
+    def test_none(self):
+        rendered = self.render("{% srcset_image myimage width-2 %}", {"myimage": None})
+        self.assertEqual(rendered, "")
+
     def test_invalid_character(self):
         with self.assertRaisesRegex(
             TemplateSyntaxError, "filter specs in image tags may only contain"
@@ -316,3 +320,161 @@ class SrcsetImageTagTestCase(ImagesTestCase):
             >
         """
         self.assertHTMLEqual(rendered, expected)
+
+
+class PictureTagTestCase(ImagesTestCase):
+    def test_picture_formats_multi_sizes(self):
+        filenames = [
+            get_test_image_filename(self.image, "width-200.format-jpeg"),
+            get_test_image_filename(self.image, "width-400.format-jpeg"),
+            get_test_image_filename(self.image, "width-200.format-webp"),
+            get_test_image_filename(self.image, "width-400.format-webp"),
+            get_test_image_filename(self.image, "width-200.format-gif"),
+            get_test_image_filename(self.image, "width-400.format-gif"),
+        ]
+
+        rendered = self.render(
+            '{% picture myimage width-{200,400} format-{jpeg,webp,gif} sizes="100vw" %}',
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <picture>
+            <source srcset="{filenames[2]} 200w, {filenames[3]} 400w" sizes="100vw" type="image/webp">
+            <source srcset="{filenames[0]} 200w, {filenames[1]} 400w" sizes="100vw" type="image/jpeg">
+            <img
+                sizes="100vw"
+                src="{filenames[4]}"
+                srcset="{filenames[4]} 200w, {filenames[5]} 400w"
+                alt="Test image"
+                width="200"
+                height="150"
+            >
+            </picture>
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_picture_formats_only(self):
+        filename_jpeg = get_test_image_filename(self.image, "format-jpeg")
+        filename_webp = get_test_image_filename(self.image, "format-webp")
+
+        rendered = self.render(
+            "{% picture myimage format-{jpeg,webp} %}",
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <picture>
+            <source srcset="{filename_webp}" type="image/webp">
+            <img
+                src="{filename_jpeg}"
+                alt="Test image"
+                width="640"
+                height="480"
+            >
+            </picture>
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_picture_sizes_only(self):
+        rendered = self.render(
+            '{% picture myimage width-{350,450} sizes="100vw" %}',
+            {"myimage": self.image},
+        )
+        expected = self.render(
+            '<picture>{% srcset_image myimage width-{350,450} sizes="100vw" %}</picture>',
+            {"myimage": self.image},
+        )
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_picture_single_format(self):
+        rendered = self.render(
+            "{% picture myimage format-jpeg %}",
+            {"myimage": self.image},
+        )
+        expected = self.render(
+            "<picture>{% image myimage format-jpeg %}</picture>",
+            {"myimage": self.image},
+        )
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_none(self):
+        rendered = self.render("{% picture myimage width-2 %}", {"myimage": None})
+        self.assertEqual(rendered, "")
+
+    def test_picture_assignment(self):
+        template = (
+            "{% picture myimage width-{550,600} format-{jpeg,webp} as bg %}"
+            "width: {{ bg.formats.jpeg.0.width }}, url: {{ bg.formats.jpeg.0.url }} "
+            "width: {{ bg.formats.jpeg.1.width }}, url: {{ bg.formats.jpeg.1.url }} "
+            "width: {{ bg.formats.webp.0.width }}, url: {{ bg.formats.webp.0.url }} "
+            "width: {{ bg.formats.webp.1.width }}, url: {{ bg.formats.webp.1.url }} "
+        )
+        rendered = self.render(template, {"myimage": self.image})
+        expected = f"""
+            width: 550, url: {get_test_image_filename(self.image, "width-550.format-jpeg")}
+            width: 600, url: {get_test_image_filename(self.image, "width-600.format-jpeg")}
+            width: 550, url: {get_test_image_filename(self.image, "width-550.format-webp")}
+            width: 600, url: {get_test_image_filename(self.image, "width-600.format-webp")}
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_picture_assignment_render_as_is(self):
+        rendered = self.render(
+            "{% picture myimage width-{2000,4000} format-{jpeg,webp} as bg %}{{ bg }}",
+            {"myimage": self.image},
+        )
+        expected = self.render(
+            "{% picture myimage width-{2000,4000} format-{jpeg,webp} %}",
+            {"myimage": self.image},
+        )
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_missing_picture(self):
+        rendered = self.render(
+            "{% picture myimage width-{200,400} %}",
+            {"myimage": self.bad_image},
+        )
+        expected = """
+            <picture>
+                <img
+                    src="/media/not-found"
+                    srcset="/media/not-found 0w, /media/not-found 0w"
+                    alt="missing image"
+                    width="0"
+                    height="0"
+                >
+            </picture>
+        """
+        self.assertHTMLEqual(rendered, expected)
+
+    def test_invalid_character(self):
+        with self.assertRaisesRegex(
+            TemplateSyntaxError, "filter specs in image tags may only"
+        ):
+            self.render(
+                '{% picture myimage fill-{20×20,40×40} sizes="100vw" %}',
+                {"myimage": self.image},
+            )
+
+    def test_chaining_filterspecs(self):
+        filename_jpeg = get_test_image_filename(
+            self.image, "format-jpeg.jpegquality-40.webpquality-40"
+        )
+        filename_webp = get_test_image_filename(
+            self.image, "format-webp.jpegquality-40.webpquality-40"
+        )
+        rendered = self.render(
+            "{% picture myimage format-{jpeg,webp} jpegquality-40 webpquality-40 %}",
+            {"myimage": self.image},
+        )
+        expected = f"""
+            <picture>
+            <source srcset="{filename_webp}" type="image/webp">
+            <img
+                src="{filename_jpeg}"
+                alt="Test image"
+                width="640"
+                height="480"
+            >
+            </picture>
+        """
+        self.assertHTMLEqual(rendered, expected)

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

@@ -16,6 +16,11 @@ 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))
+    # Use the correct extension if the filterspec is a format operation.
+    if "format-" in filterspec:
+        ext = "." + filterspec.split("format-")[1].split("-")[0].split(".")[0].replace(
+            "jpeg", "jpg"
+        )
     return f"{settings.MEDIA_URL}images/{name}.{filterspec}{ext}"