diff --git a/cast/appsettings.py b/cast/appsettings.py index 8d586ea5..b07a66e3 100644 --- a/cast/appsettings.py +++ b/cast/appsettings.py @@ -14,8 +14,12 @@ CAST_FILTERSET_FACETS: list[str] = getattr( settings, "CAST_FILTERSET_FACETS", ["search", "date", "date_facets", "category_facets", "tag_facets", "o"] ) -CAST_IMAGE_SLOT_DIMENSIONS: tuple[int, int] = getattr(settings, "CAST_IMAGE_SLOT_DIMENSIONS", (1110, 740)) -CAST_THUMBNAIL_SLOT_DIMENSIONS: tuple[int, int] = getattr(settings, "CAST_THUMBNAIL_SLOT_DIMENSIONS", (120, 80)) +CAST_REGULAR_IMAGE_SLOT_DIMENSIONS: list[tuple[int, int]] = getattr( + settings, "CAST_REGULAR_IMAGE_SLOT_DIMENSIONS", [(1110, 740)] +) +CAST_GALLERY_IMAGE_SLOT_DIMENSIONS: list[tuple[int, int]] = getattr( + settings, "CAST_GALLERY_IMAGE_SLOT_DIMENSIONS", [(1110, 740), (120, 80)] +) SettingValue = Union[str, bool, int] diff --git a/cast/blocks.py b/cast/blocks.py index 588118a4..343e9768 100644 --- a/cast/blocks.py +++ b/cast/blocks.py @@ -1,6 +1,6 @@ from collections.abc import Iterable from itertools import chain, islice, tee -from typing import TYPE_CHECKING, Literal, Optional, Union +from typing import TYPE_CHECKING, Optional, Union from django.db.models import QuerySet from django.template.loader import TemplateDoesNotExist, get_template @@ -11,10 +11,18 @@ from pygments.lexers import ClassNotFound, get_lexer_by_name from wagtail.blocks import CharBlock, ChooserBlock, ListBlock, StructBlock, TextBlock from wagtail.images.blocks import ImageChooserBlock -from wagtail.images.models import AbstractImage, AbstractRendition, Image +from wagtail.images.models import AbstractImage from . import appsettings as settings from .models import Gallery +from .renditions import ( + Height, + ImageFormats, + ImageForSlot, + Rectangle, + RenditionFilters, + Width, +) if TYPE_CHECKING: from .models import Audio, Video @@ -36,100 +44,44 @@ def previous_and_next(all_items: Iterable) -> Iterable: return zip(previous_items, items, next_items) -def calculate_thumbnail_width(original_width, original_height, rect_width, rect_height): - # Calculate aspect ratios - original_aspect_ratio = original_width / original_height - rect_aspect_ratio = rect_width / rect_height - - # Determine if the image needs to be scaled based on width or height - if original_aspect_ratio > rect_aspect_ratio: - # Scale based on width - thumbnail_width = rect_width - else: - # Scale based on height (maintain aspect ratio) - thumbnail_width = rect_height * original_aspect_ratio - - return thumbnail_width - - -ImageFormat = Literal["jpeg", "avif", "webp"] - - -ImageFormats = Iterable[ImageFormat] - - -class Thumbnail: - def __init__( - self, - image: Image, - slot_width: int, - slot_height: int, - max_scale_factor: int = 3, - formats: ImageFormats = ("jpeg", "avif"), - ) -> None: - self.image = image - self.formats: ImageFormats = formats - thumbnail_width = round(calculate_thumbnail_width(image.width, image.height, slot_width, slot_height)) - self.renditions = {} - for image_format in self.formats: - self.renditions[image_format] = self.build_renditions( - image, thumbnail_width, max_scale_factor=max_scale_factor, format=image_format - ) - - @staticmethod - def build_renditions( - image: AbstractImage, width: int, max_scale_factor: int = 3, format: str = "jpeg" - ) -> list[AbstractRendition]: - renditions = [] - for scale_factor in range(1, max_scale_factor + 1): - scaled_width = width * scale_factor - if scaled_width > image.width * 0.8: - # already big enough - continue - renditions.append(image.get_rendition(f"width-{scaled_width}|format-{format}")) - if len(renditions) == 0: - # no renditions found, so add at least the original image - if format == "jpeg": - # just append the original image to avoid compressing it twice but add url attribute to make it - # compatible with renditions - image.url = image.file.url - renditions.append(image) - else: - # convert if format is not jpeg - renditions.append(image.get_rendition(f"width-{image.width}|format-{format}")) - return renditions - - @property - def src(self) -> dict[ImageFormat, str]: - format_to_src = {} - for image_format in self.formats: - format_to_src[image_format] = self.renditions[image_format][0].url - return format_to_src - - @property - def srcset(self) -> dict[ImageFormat, str]: - format_to_srcset = {} - for image_format in self.formats: - format_to_srcset[image_format] = ", ".join( - f"{rendition.url} {rendition.width}w" for rendition in self.renditions[image_format] - ) - return format_to_srcset - - @property - def first_rendition(self) -> AbstractRendition: - return self.renditions["jpeg"][0] - - @property - def sizes(self) -> str: - return f"{self.first_rendition.width}px" - - @property - def width(self) -> int: - return self.first_rendition.width - - @property - def height(self) -> int: - return self.first_rendition.height +def get_srcset_images_for_slots( + image: AbstractImage, slots: list[Rectangle], image_formats: ImageFormats +) -> dict[Rectangle, ImageForSlot]: + """ + Get the srcset images for the given slots and image formats. This will fetch + renditions from wagtail and return a list of ImageInSlot objects. + """ + images_for_slots = {} + rendition_filters = RenditionFilters.from_wagtail_image(image=image, slots=slots, image_formats=image_formats) + rendition_filter_strings = rendition_filters.filter_strings + if len(rendition_filter_strings) > 0: + renditions = image.get_renditions(*rendition_filter_strings) + rendition_filters.set_filter_to_url_via_wagtail_renditions(renditions) + for slot in slots: + try: + images_for_slots[slot] = rendition_filters.get_image_for_slot(slot) + except ValueError: + print("yes, value error!") + # no fitting image found for slot -> use original image + src = {} + for image_format in image_formats: + if image_format == rendition_filters.original_format: + src[image_format] = image.file.url + else: + # convert to image_format + rendition = image.get_rendition(f"format-{image_format}") + src[image_format] = rendition.url + srcset = {} + for image_format in image_formats: + if image_format == rendition_filters.original_format: + srcset[image_format] = f"{image.file.url} {image.width}w" + else: + # convert to image_format + rendition = image.get_rendition(f"format-{image_format}") + srcset[image_format] = f"{rendition.url} {rendition.width}w" + width = rendition_filters.slot_to_fitting_width[slot] + images_for_slots[slot] = ImageForSlot(Rectangle(width, slot.height), src, srcset) + return images_for_slots class CastImageChooserBlock(ImageChooserBlock): @@ -139,8 +91,9 @@ class CastImageChooserBlock(ImageChooserBlock): """ def get_context(self, image: AbstractImage, parent_context: Optional[dict] = None) -> dict: - slot_width, slot_height = settings.CAST_IMAGE_SLOT_DIMENSIONS - image.thumbnail = Thumbnail(image, slot_width, slot_height) + [slot] = [Rectangle(Width(w), Height(h)) for w, h in settings.CAST_REGULAR_IMAGE_SLOT_DIMENSIONS] + slot_to_image = get_srcset_images_for_slots(image, [slot], ["jpeg", "avif"]) + image.regular = slot_to_image[slot] return super().get_context(image, parent_context=parent_context) @@ -170,11 +123,14 @@ def get_template(self, context: Optional[dict] = None) -> str: @staticmethod def add_image_thumbnails(gallery: QuerySet[Gallery]) -> None: - thumbnail_slot_width, thumbnail_slot_height = settings.CAST_THUMBNAIL_SLOT_DIMENSIONS - image_slot_width, image_slot_height = settings.CAST_IMAGE_SLOT_DIMENSIONS + modal_slot, thumbnail_slot = slots = [ + Rectangle(Width(w), Height(h)) for w, h in settings.CAST_GALLERY_IMAGE_SLOT_DIMENSIONS + ] + image_formats: ImageFormats = ["jpeg", "avif"] for image in gallery: - image.thumbnail = Thumbnail(image, thumbnail_slot_width, thumbnail_slot_height) - image.modal = Thumbnail(image, image_slot_width, image_slot_height) + images_for_slots = get_srcset_images_for_slots(image, slots, image_formats) + image.modal = images_for_slots[modal_slot] + image.thumbnail = images_for_slots[thumbnail_slot] def get_context(self, gallery: QuerySet[Gallery], parent_context: Optional[dict] = None) -> dict: self.add_prev_next(gallery) diff --git a/cast/renditions.py b/cast/renditions.py new file mode 100644 index 00000000..0e842556 --- /dev/null +++ b/cast/renditions.py @@ -0,0 +1,245 @@ +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, NewType, cast, get_args + +from wagtail.images.models import AbstractImage, AbstractRendition + +Width = NewType("Width", int) +Height = NewType("Height", int) + + +@dataclass +class Rectangle: + """ + Just a simple class to hold width and height. It is hashable to be used as a key + when getting all renditions for a slot represented by a rectangle. + """ + + width: Width + height: Height + + def __eq__(self, other): + if not isinstance(other, Rectangle): + raise ValueError(f"Can't compare RectDimension with {type(other)}") + return (self.width, self.height) == (other.width, other.height) + + def __hash__(self): + return hash((self.width, self.height)) + + +def calculate_fitting_width(image: Rectangle, slot: Rectangle) -> Width: + """ + Calculate the width of an image that fits into a rectangular slot. + + Returns the width the image needs to be scaled to in order to fit into the rect. + """ + + # Calculate aspect ratios + image_aspect_ratio = image.width / image.height + rect_aspect_ratio = slot.width / slot.height + + # Determine if the image needs to be scaled based on width or height + if image_aspect_ratio > rect_aspect_ratio: + # Scale based on width + fitting_width = slot.width + else: + # Scale based on height (maintain aspect ratio) + fitting_width = Width(round(slot.height * image_aspect_ratio)) + + return fitting_width + + +ImageFormat = Literal["jpeg", "avif", "webp", "png", "svg"] +SUPPORTED_IMAGE_FORMATS = set(get_args(ImageFormat)) +ImageFormats = Iterable[ImageFormat] + + +@dataclass +class RenditionFilter: + """ + A rendition filter for an image that fits into a slot. + """ + + width: Width # width of the rendition + slot: Rectangle # slot the image needs to fit into + format: ImageFormat # desired image format + + def get_wagtail_filter_str(self, original_format: ImageFormat) -> str: + """Return the filter string in wagtail format.""" + filter_parts = [f"width-{self.width}"] + if self.format != original_format: + filter_parts.append(f"format-{self.format}") + return "|".join(filter_parts) + + +def get_rendition_filters_for_image_and_slot( + image: Rectangle, # dimensions of the original image + slot: Rectangle, # slot the image needs to fit into + image_format: ImageFormat, # desired image format + max_scale_factor: int = 3, # don't scale up renditions more than this +) -> list[RenditionFilter]: + """ + Get a list of rendition filters for an image that has to fit into a slot. + Don't scale up renditions more than max_scale_factor. If the rendition_width + is nearly as big as the image_width, don't create an additional rendition filter. + """ + filters = [] + fitting_width = calculate_fitting_width(image, slot) + for pixel_density in range(1, max_scale_factor + 1): + rendition_width = Width(fitting_width * pixel_density) + if rendition_width > image.width * 0.8: + # already big enough + continue + filters.append(RenditionFilter(width=rendition_width, slot=slot, format=image_format)) + return filters + + +def get_image_format_by_name(file_name: str) -> ImageFormat: + """Guess the image format from the file name.""" + suffix_to_format: dict[str, ImageFormat] = { + "jpg": "jpeg", + } + suffix = Path(file_name).suffix.lower().strip().lstrip(".") + suffix = suffix_to_format.get(suffix, suffix) + if suffix not in SUPPORTED_IMAGE_FORMATS: + raise ValueError(f"Image format {suffix} not supported.") + else: + return cast(ImageFormat, suffix) + + +class ImageForSlot: + """ + Image fitting into a slot. It has a src and a srcset for all image formats. + Its purpose is to be used in a or tag in a django template. + It is potentially a lot smaller than the slot. + + For example if there's a slot of 120x80 and an image of 4000x6000, the image + for the slot is 53x80. The srcset contains renditions of 53, 106 and 159 pixels. + """ + + def __init__( + self, + image: Rectangle, + src: dict[ImageFormat, str], + srcset: dict[ImageFormat, str], + ) -> None: + self.width = image.width + self.height = image.height + self.sizes = f"{self.width}px" + self.src = src + self.srcset = srcset + + +Filters = dict[Rectangle, dict[ImageFormat, list[RenditionFilter]]] + + +class RenditionFilters: + def __init__( + self, + *, + image: Rectangle, + original_format: ImageFormat, + slots: list[Rectangle], + image_formats: ImageFormats, + ) -> None: + super().__init__() + self.image = image + self.original_format = original_format + self.image_formats = image_formats + self.slots = slots + self.slot_to_fitting_width: dict[Rectangle, Width] = {} + for slot in slots: + self.slot_to_fitting_width[slot] = Width(calculate_fitting_width(image, slot)) + self.filters = self.build_filters(self.image, self.slots, self.image_formats) + self.filter_to_url: dict[str, str] = {} + + @classmethod + def from_wagtail_image(cls, image: AbstractImage, slots: list[Rectangle], image_formats: ImageFormats): + original_format = get_image_format_by_name(image.file.name) + image = Rectangle(Width(image.width), Height(image.height)) + return cls(image=image, original_format=original_format, slots=slots, image_formats=image_formats) + + def set_filter_to_url_via_wagtail_renditions(self, renditions: dict[str, AbstractRendition]) -> None: + self.filter_to_url = {fs: renditions[fs].url for fs in self.filter_strings} + + @staticmethod + def build_filters(image: Rectangle, slots: list[Rectangle], image_formats: ImageFormats) -> Filters: + """ + Build all filters for all slots and image formats. + """ + filters: Filters = {slot: {} for slot in slots} + for slot in slots: + for image_format in image_formats: + filters[slot][image_format] = get_rendition_filters_for_image_and_slot(image, slot, image_format) + return filters + + def get_filter_by_slot_format_and_fitting_width( + self, slot: Rectangle, image_format: ImageFormat, fitting_width: Width + ) -> RenditionFilter: + """ + Get a rendition filter by format and width. Raise ValueError if no filter found + or more than one filter found. + """ + filters = [f for f in self.filters[slot][image_format] if f.width == fitting_width] + if len(filters) == 0: + raise ValueError(f"No filter found for format {image_format} and width {fitting_width}") + if len(filters) > 1: + raise ValueError(f"More than one filter found for format {image_format} and width {fitting_width}") + return filters[0] + + @staticmethod + def get_all_filters(filters: dict[Rectangle, dict[ImageFormat, list[RenditionFilter]]]) -> list[RenditionFilter]: + """ + Return a flat list of all filters. + """ + all = [] + for slot_filters in filters.values(): + for format_filters in slot_filters.values(): + all.extend(format_filters) + return all + + @property + def all_filters(self) -> list[RenditionFilter]: + return self.get_all_filters(self.filters) + + def get_filter_strings(self, original_format: ImageFormat) -> list[str]: + """ + Return a list of filter strings in wagtail format. + """ + return [f.get_wagtail_filter_str(original_format) for f in self.all_filters] + + @property + def filter_strings(self) -> list[str]: + """ + Return a list of filter strings in wagtail format. + """ + return self.get_filter_strings(self.original_format) + + def get_src_for_slot(self, slot: Rectangle) -> dict[ImageFormat, str]: + src = {} + fitting_width = self.slot_to_fitting_width[slot] + for image_format in self.image_formats: + format_filter = self.get_filter_by_slot_format_and_fitting_width(slot, image_format, fitting_width) + format_filter_string = format_filter.get_wagtail_filter_str(self.original_format) + src[image_format] = self.filter_to_url[format_filter_string] + return src + + def get_srcset_for_slot(self, slot: Rectangle) -> dict[ImageFormat, str]: + srcset = {} + filters_for_slot = self.filters[slot] + for image_format in self.image_formats: + filters_for_format = filters_for_slot[image_format] + filter_strings_for_format = [f.get_wagtail_filter_str(self.original_format) for f in filters_for_format] + urls_for_filters = [self.filter_to_url[fs] for fs in filter_strings_for_format] + srcset_parts = [] + for url, filter_string in zip(urls_for_filters, filters_for_format): + srcset_parts.append(f"{url} {filter_string.width}w") + srcset[image_format] = ", ".join(srcset_parts) + return srcset + + def get_image_for_slot(self, slot: Rectangle) -> ImageForSlot: + src = self.get_src_for_slot(slot) + srcset = self.get_srcset_for_slot(slot) + fitting_image = Rectangle(width=self.slot_to_fitting_width[slot], height=slot.height) + return ImageForSlot(image=fitting_image, src=src, srcset=srcset) diff --git a/cast/templates/cast/image/image.html b/cast/templates/cast/image/image.html index 4fd41fd3..80541a12 100644 --- a/cast/templates/cast/image/image.html +++ b/cast/templates/cast/image/image.html @@ -2,18 +2,18 @@ {{ value.default_alt_text }} diff --git a/tests/blocks_test.py b/tests/blocks_test.py index 42a37860..884a438d 100644 --- a/tests/blocks_test.py +++ b/tests/blocks_test.py @@ -1,7 +1,11 @@ +from typing import cast + import pytest from wagtail.images.blocks import ImageChooserBlock +from wagtail.images.models import AbstractImage -from cast.blocks import CodeBlock, GalleryBlock, Thumbnail, calculate_thumbnail_width +from cast.blocks import CodeBlock, GalleryBlock, get_srcset_images_for_slots +from cast.renditions import Height, Rectangle, Width @pytest.mark.parametrize( @@ -10,7 +14,7 @@ (None, ""), # make sure None is rendered as empty string ( # make sure source is rendered even if language is not found {"language": "nonexistent", "source": "blub"}, - ('
blub\n' "
\n"), + '
blub\n' "
\n", ), ( # happy path { @@ -52,46 +56,71 @@ def test_gallery_block_template_from_theme(mocker): assert template_name == "cast/vue/gallery.html" -@pytest.mark.parametrize( - "original_width, original_height, slot_width, slot_height, expected_width", - [ - (6000, 4000, 120, 80, 120), # 3:2 landscape - (8000, 4000, 120, 80, 120), # 2:1 landscape - (4000, 6000, 120, 80, 53), # 2:3 portrait - (4000, 4000, 120, 80, 80), # 1:1 square - ], -) -def test_calculate_thumbnail_width(original_width, original_height, slot_width, slot_height, expected_width): - assert round(calculate_thumbnail_width(original_width, original_height, slot_width, slot_height)) == expected_width - - -class StubImage: - url = "image_url" - - def __init__(self, width: int, height: int): - self.width = width - self.height = height - self.file = self - - def get_rendition(self, filter_str: str) -> "StubImage": - self.rendition_filter = filter_str - return self - - -def test_thumbnail_attributes(): - image = StubImage(6000, 4000) - thumbnail = Thumbnail(image, 120, 80, max_scale_factor=1) - assert thumbnail.src["jpeg"] == image.url - assert thumbnail.srcset["jpeg"] == f"{image.url} {image.width}w" - assert thumbnail.sizes == f"{image.width}px" - - -def test_thumbnail_attributes_small_source(): - # given a small source image - image = StubImage(800, 835) - # when the thumbnail is created - thumbnail = Thumbnail(image, 1110, 740) - # then the list of renditions contains just one rendition - assert len(thumbnail.renditions["jpeg"]) == 1 - # and the thumbnail is the same size as the source image - assert thumbnail.first_rendition.width == image.width +class StubWagtailImage: + class File: + name = "test.jpg" + url = "https://example.com/test.jpg" + + width = Width(6000) + height = Height(4000) + file = File() + + @staticmethod + def get_renditions(*filter_strings): + class StubImage: + url = "https://example.com/test.jpg" + width = Width(120) + + return {fs: StubImage() for fs in filter_strings} + + +def test_get_srcset_images_for_slots_get_renditions_is_called_when_filters_not_empty(): + # Given an image that should generate multiple renditions for a slot + slot = Rectangle(Width(120), Height(80)) + images_for_slots = get_srcset_images_for_slots( + cast(AbstractImage, StubWagtailImage()), + [slot], + ["jpeg", "avif"], + ) + # When we get the srcset images for the slot + image_for_slot = images_for_slots[slot] + split_srcset = image_for_slot.srcset["jpeg"].replace(",", "").split(" ") # type: ignore + srcset_widths = sorted([int(t.rstrip("w")) for t in split_srcset if t.endswith("w")]) + # Then the srcset widths should be 120 * 1, 120 * 2, 120 * 3 + assert srcset_widths == [120, 240, 360] + + +class Stub1PxImage: + class File: + name = "test.jpg" + url = "https://example.com/test.jpg" + + width = Width(1) + height = Height(1) + file = File() + + @staticmethod + def get_rendition(_filter_string): + class Avif: + url = "https://example.com/test.avif" + width = Width(1) + + return Avif() + + +def test_get_srcset_images_for_slots_use_original_if_image_too_small(): + # Given an image that is too small for the slot + slot = Rectangle(Width(120), Height(80)) + images_for_slots = get_srcset_images_for_slots( + cast(AbstractImage, Stub1PxImage()), + [slot], + ["jpeg", "avif"], + ) + # When we get the srcset images for the slot + image_for_slot = images_for_slots[slot] + # Then the image for the slot should be the original image + assert image_for_slot.src["jpeg"] == "https://example.com/test.jpg" + assert image_for_slot.srcset["jpeg"] == "https://example.com/test.jpg 1w" + # And it should have been converted to avif + assert image_for_slot.src["avif"] == "https://example.com/test.avif" + assert image_for_slot.srcset["avif"] == "https://example.com/test.avif 1w" diff --git a/tests/renditions_test.py b/tests/renditions_test.py new file mode 100644 index 00000000..d270bd5a --- /dev/null +++ b/tests/renditions_test.py @@ -0,0 +1,145 @@ +import pytest + +from cast.renditions import ( + Height, + ImageFormat, + Rectangle, + RenditionFilter, + RenditionFilters, + Width, + calculate_fitting_width, + get_image_format_by_name, + get_rendition_filters_for_image_and_slot, +) + +rect = Rectangle +w1, w53, w106, w120, w159, w1110, w4000, w6000, w8000 = ( + Width(x) for x in [1, 53, 106, 120, 159, 1110, 4000, 6000, 8000] +) +h1, h80, h740, h4000, h6000 = Height(1), Height(80), Height(740), Height(4000), Height(6000) +thumbnail_slot = rect(w120, h80) + + +def test_rectangle_is_equal(): + assert rect(w1, h1) == rect(w1, h1) + assert rect(w1, h1) != rect(w1, h80) + with pytest.raises(ValueError): + rect(w1, h1) == "foo" # noqa + + +@pytest.mark.parametrize( + "width, slot, image_format, original_format, expected_filter_str", + [ + (w53, rect(w120, h80), "jpeg", "jpeg", "width-53"), + (w53, rect(w120, h80), "jpeg", "avif", "width-53|format-jpeg"), + (w53, rect(w120, h80), "avif", "jpeg", "width-53|format-avif"), + ], +) +def test_get_wagtail_filter_str( + width, slot, image_format: ImageFormat, original_format: ImageFormat, expected_filter_str +): + rendition_filter = RenditionFilter(width=width, slot=slot, format=image_format) + assert rendition_filter.get_wagtail_filter_str(original_format) == expected_filter_str + + +@pytest.mark.parametrize( + "image, slot, scaled_width", + [ + (rect(w6000, h4000), rect(w120, h80), 120), # 3:2 landscape + (rect(w8000, h4000), rect(w120, h80), 120), # 2:1 landscape + (rect(w4000, h6000), rect(w120, h80), 53), # 2:3 portrait + (rect(w4000, h4000), rect(w120, h80), 80), # 1:1 square + ], +) +def test_calculate_fitting_width(image, slot, scaled_width): + assert round(calculate_fitting_width(image, slot)) == scaled_width + + +@pytest.mark.parametrize( + "image, slot, image_format, max_scale_factor, expected_rendition_filters", + [ + (rect(w1, h1), rect(w120, h80), "jpeg", 3, []), # dummy image is too small -> no rendition_filters + (rect(w1, h1), rect(w120, h80), "avif", 3, []), # 60 is nearly as big as 53 -> no rendition_filters + ( + rect(w4000, h6000), + rect(w120, h80), + "jpeg", + 3, + [ + RenditionFilter(width=w53, slot=rect(w120, h80), format="jpeg"), + RenditionFilter(width=w106, slot=rect(w120, h80), format="jpeg"), + RenditionFilter(width=w159, slot=rect(w120, h80), format="jpeg"), + ], + ), # 2:3 portrait generates 3 renditions with max_scale_factor=3 because 53*3 < 4000*0.8 + ], +) +def test_get_rendition_filters_for_image_and_slot( + image, slot, image_format: ImageFormat, max_scale_factor, expected_rendition_filters +): + rendition_filters = get_rendition_filters_for_image_and_slot( + image, slot, image_format, max_scale_factor=max_scale_factor + ) + assert rendition_filters == expected_rendition_filters + + +@pytest.mark.parametrize( + "image_name, expected_image_format", + [ + ("foo.jpg", "jpeg"), + ("bar.jpeg", "jpeg"), + ("foo/baz.png", "png"), + ("an/s/v/g.svg", "svg"), + ("image.WEBP", "webp"), + (" image.avif ", "avif"), + ], +) +def test_get_image_format_by_name(image_name, expected_image_format): + assert get_image_format_by_name(image_name) == expected_image_format + + +def test_get_image_format_by_name_not_supported(): + with pytest.raises(ValueError): + get_image_format_by_name("foo.bmp") + + +def test_rendition_filters_build_filters(): + # Given a 2:3 portrait image and a thumbnail and a modal image slot + slots = [ + rect(w120, h80), # thumbnail + rect(w1110, h740), # modal image + ] + image = rect(w4000, h6000) # 2:3 portrait + # When we get the filters for the image + filters_dict = RenditionFilters.build_filters(image, slots, image_formats=["avif", "jpeg"]) + filters = RenditionFilters.get_all_filters(filters_dict) + # Then we get 3 filters for the thumbnail and 3 filters for the modal image + widths = sorted({f.width for f in filters}) + assert widths == [53, 106, 159, 493, 986, 1479] + + +def test_rendition_filters_by_format_and_width(): + # Given an empty list of rendition filters + image_1px = rect(w1, h1) + [slot] = slots = [Rectangle(w120, h80)] + rendition_filters = RenditionFilters( + image=image_1px, original_format="jpeg", slots=slots, image_formats=["avif", "jpeg"] + ) + # When we try to get a filter by format and width + with pytest.raises(ValueError): + # Then we get a ValueError + rendition_filters.get_filter_by_slot_format_and_fitting_width(slot, "jpeg", w53) + + # Given a list of rendition filters containing a filter for jpeg and width 53 + expected_filter = RenditionFilter(width=w53, slot=thumbnail_slot, format="jpeg") + rendition_filters.filters[slot]["jpeg"] = [expected_filter] + # When we get a filter by format and width + actual_filter = rendition_filters.get_filter_by_slot_format_and_fitting_width(slot, "jpeg", w53) + # Then we get the expected filter + assert actual_filter == expected_filter + + # Given there are two filters for jpeg and width 53 and 106 + rendition_filters.filters[slot]["jpeg"] = [expected_filter, expected_filter] + # When we get a filter by format and width + with pytest.raises(ValueError): + # Then we get a ValueError + rendition_filters.get_filter_by_slot_format_and_fitting_width(slot, "jpeg", w53)