Add srcset_image tag for responsive images

pull/11069/head
Paarth Agarwal 2023-08-11 19:37:54 +05:30 zatwierdzone przez Thibaud Colas
rodzic 234e144f50
commit 41dac89e1d
13 zmienionych plików z 911 dodań i 120 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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