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