From 477bb753aec357e6be9893757df1a58a2c1484af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Thu, 12 Feb 2026 21:29:17 +0100 Subject: [PATCH] Add support for modern image formats (WebP and AVIF) and implement image conversion commands --- templates/twitch/badge_list.html | 7 +- templates/twitch/badge_set_detail.html | 10 +- templates/twitch/campaign_detail.html | 21 +- templates/twitch/campaign_list.html | 14 +- templates/twitch/dashboard.html | 13 +- templates/twitch/emote_gallery.html | 9 +- templates/twitch/game_detail.html | 31 +-- templates/twitch/games_grid.html | 8 +- templates/twitch/reward_campaign_detail.html | 6 +- .../convert_images_to_modern_formats.py | 188 +++++++++++++++ .../management/commands/download_box_art.py | 42 ++++ .../commands/download_campaign_images.py | 42 ++++ twitch/templatetags/__init__.py | 1 + twitch/templatetags/image_tags.py | 103 ++++++++ twitch/tests/test_image_tags.py | 227 ++++++++++++++++++ 15 files changed, 629 insertions(+), 93 deletions(-) create mode 100644 twitch/management/commands/convert_images_to_modern_formats.py create mode 100644 twitch/templatetags/__init__.py create mode 100644 twitch/templatetags/image_tags.py create mode 100644 twitch/tests/test_image_tags.py diff --git a/templates/twitch/badge_list.html b/templates/twitch/badge_list.html index 9db3ef5..b7a8226 100644 --- a/templates/twitch/badge_list.html +++ b/templates/twitch/badge_list.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% load image_tags %} {% block title %} Chat Badges - ttvdrops {% endblock title %} @@ -14,11 +15,7 @@ - {{ badge.title }} + {% picture badge.image_url_4x alt=badge.title width=36 height=36 %} diff --git a/templates/twitch/badge_set_detail.html b/templates/twitch/badge_set_detail.html index e724454..5a8f847 100644 --- a/templates/twitch/badge_set_detail.html +++ b/templates/twitch/badge_set_detail.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% load image_tags %} {% block title %} {{ badge_set.set_id }} Badges - ttvdrops {% endblock title %} @@ -31,14 +32,7 @@ {{ badge.badge_id }} - {{ badge.title }} + {% picture badge.image_url_4x alt=badge.title width=72 height=72 style="width: 72px !important; height: 72px !important; object-fit: contain" %} {{ badge.title }} {{ badge.description }} diff --git a/templates/twitch/campaign_detail.html b/templates/twitch/campaign_detail.html index b133cc2..93a3af1 100644 --- a/templates/twitch/campaign_detail.html +++ b/templates/twitch/campaign_detail.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% load static %} +{% load image_tags %} {% block title %} {{ campaign.clean_name }} {% endblock title %} @@ -20,10 +21,7 @@ {% endfor %} {% if campaign.image_best_url %} - {{ campaign.name }} + {% picture campaign.image_best_url alt=campaign.name width=160 height=160 %} {% endif %}

{{ campaign.description|linebreaksbr }}

@@ -106,24 +104,13 @@ {% for benefit in drop.drop.benefits.all %} {% if benefit.image_asset_url %} - {{ benefit.name }} + {% picture benefit.image_best_url|default:benefit.image_asset_url alt=benefit.name width=160 height=160 style="object-fit: cover; margin-right: 3px" %} {% endif %} {% if benefit.distribution_type == "BADGE" and drop.awarded_badge %}
Awards Badge: - {{ drop.awarded_badge.title }} badge + {% picture drop.awarded_badge.image_url_2x alt=drop.awarded_badge.title|add:" badge" width=24 height=24 style="vertical-align: middle; margin-right: 4px" %} {{ drop.awarded_badge.title }} {% if drop.awarded_badge.description %} diff --git a/templates/twitch/campaign_list.html b/templates/twitch/campaign_list.html index 19bbdef..d2c26a9 100644 --- a/templates/twitch/campaign_list.html +++ b/templates/twitch/campaign_list.html @@ -1,5 +1,7 @@ {% extends "base.html" %} {% load static %} +{% load image_tags %} +{% load image_tags %} {% block title %} Drop Campaigns - Twitch Drops Tracker {% endblock title %} @@ -53,11 +55,7 @@
{% if game_group.grouper.box_art_best_url %} - Box art for {{ game_group.grouper.display_name }} + {% picture game_group.grouper.box_art_best_url alt="Box art for "|add:game_group.grouper.display_name width=120 height=160 %} {% else %}
{% if campaign.image_best_url %} - Campaign artwork for {{ campaign.name }} + {% picture campaign.image_best_url alt="Campaign artwork for "|add:campaign.name width=120 height=120 style="border-radius: 4px" %} {% endif %}

{{ campaign.clean_name }}

diff --git a/templates/twitch/dashboard.html b/templates/twitch/dashboard.html index a03f62b..849caa8 100644 --- a/templates/twitch/dashboard.html +++ b/templates/twitch/dashboard.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% load static %} +{% load image_tags %} {% block title %} Twitch drops {% endblock title %} @@ -35,11 +36,7 @@ Hover over the end time to see the exact date and time.
- Box art for {{ game_data.name }} + {% picture game_data.box_art alt="Box art for "|add:game_data.name width=200 height=267 style="border-radius: 8px" %}
@@ -52,11 +49,7 @@ Hover over the end time to see the exact date and time. flex-shrink: 0">
- Image for {{ campaign_data.campaign.name }} + {% picture campaign_data.campaign.image_best_url|default:campaign_data.campaign.image_url alt="Image for "|add:campaign_data.campaign.name width=120 height=120 style="border-radius: 4px" %}

{{ campaign_data.campaign.clean_name }}

{% if game.box_art_best_url %} - {{ game.name }} + {% picture game.box_art_best_url alt=game.name width=160 height=160 %} {% endif %} {% if owners %} @@ -53,13 +50,7 @@ {% for benefit in campaign.sorted_benefits %} {% if benefit.image_best_url or benefit.image_asset_url %} - {{ benefit.name }} + {% picture benefit.image_best_url|default:benefit.image_asset_url alt=benefit.name width=24 height=24 style="display: inline-block; margin-right: 4px; vertical-align: middle" %} {% endif %} {{ benefit.name }} @@ -88,13 +79,7 @@ {% for benefit in campaign.sorted_benefits %} {% if benefit.image_best_url or benefit.image_asset_url %} - {{ benefit.name }} + {% picture benefit.image_best_url|default:benefit.image_asset_url alt=benefit.name width=24 height=24 style="display: inline-block; margin-right: 4px; vertical-align: middle" %} {% endif %} {{ benefit.name }} @@ -124,13 +109,7 @@ {% for benefit in campaign.sorted_benefits %} {% if benefit.image_best_url or benefit.image_asset_url %} - {{ benefit.name }} + {% picture benefit.image_best_url|default:benefit.image_asset_url alt=benefit.name width=24 height=24 style="display: inline-block; margin-right: 4px; vertical-align: middle" %} {% endif %} {{ benefit.name }} diff --git a/templates/twitch/games_grid.html b/templates/twitch/games_grid.html index c4e13fe..7df53a5 100644 --- a/templates/twitch/games_grid.html +++ b/templates/twitch/games_grid.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% load image_tags %} {% block title %} Games - Grid View {% endblock title %} @@ -27,12 +28,7 @@ text-align: center">
{% if item.game.box_art_best_url %} - Box art for {{ item.game.display_name }} + {% picture item.game.box_art_best_url alt="Box art for "|add:item.game.display_name width=180 height=240 style="border-radius: 8px" %} {% else %}
{% if reward_campaign.image_best_url %} - {{ reward_campaign.name }} + {% picture reward_campaign.image_best_url alt=reward_campaign.name width=160 height=160 %} {% endif %}
diff --git a/twitch/management/commands/convert_images_to_modern_formats.py b/twitch/management/commands/convert_images_to_modern_formats.py new file mode 100644 index 0000000..f25f07c --- /dev/null +++ b/twitch/management/commands/convert_images_to_modern_formats.py @@ -0,0 +1,188 @@ +"""Management command to convert existing images to WebP and AVIF formats.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +from django.conf import settings +from django.core.management.base import BaseCommand +from PIL import Image + +if TYPE_CHECKING: + from argparse import ArgumentParser + +logger = logging.getLogger("ttvdrops") + + +class Command(BaseCommand): + """Convert all existing JPG/PNG images to WebP and AVIF formats.""" + + help = "Convert existing images in MEDIA_ROOT to WebP and AVIF formats" + + def add_arguments(self, parser: ArgumentParser) -> None: + """Add command-line arguments.""" + parser.add_argument( + "--overwrite", + action="store_true", + help="Overwrite existing WebP/AVIF files", + ) + parser.add_argument( + "--quality", + type=int, + default=85, + help="Quality for WebP/AVIF encoding (1-100, default: 85)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be converted without actually converting", + ) + + def handle(self, **options) -> None: + """Execute the command.""" + overwrite: bool = bool(options.get("overwrite")) + quality: int = int(options.get("quality", 85)) + dry_run: bool = bool(options.get("dry_run")) + + media_root = Path(settings.MEDIA_ROOT) + if not media_root.exists(): + self.stdout.write(self.style.WARNING(f"MEDIA_ROOT does not exist: {media_root}")) + return + + # Find all JPG and PNG files + image_extensions = {".jpg", ".jpeg", ".png"} + image_files = [f for f in media_root.rglob("*") if f.is_file() and f.suffix.lower() in image_extensions] + + if not image_files: + self.stdout.write(self.style.SUCCESS("No images found to convert")) + return + + self.stdout.write(f"Found {len(image_files)} images to process") + + converted_count = 0 + skipped_count = 0 + error_count = 0 + + for image_path in image_files: + base_path = image_path.with_suffix("") + + webp_path = base_path.with_suffix(".webp") + avif_path = base_path.with_suffix(".avif") + + # Check if conversion is needed + needs_webp = overwrite or not webp_path.exists() + needs_avif = overwrite or not avif_path.exists() + + if not needs_webp and not needs_avif: + skipped_count += 1 + continue + + if dry_run: + self.stdout.write(f"Would convert: {image_path.relative_to(media_root)}") + if needs_webp: + self.stdout.write(f" → {webp_path.relative_to(media_root)}") + if needs_avif: + self.stdout.write(f" → {avif_path.relative_to(media_root)}") + converted_count += 1 + continue + + try: + result = self._convert_image( + image_path, + webp_path if needs_webp else None, + avif_path if needs_avif else None, + quality, + media_root, + ) + if result: + converted_count += 1 + else: + error_count += 1 + + except Exception as e: + error_count += 1 + self.stdout.write( + self.style.ERROR(f"✗ Error converting {image_path.relative_to(media_root)}: {e}"), + ) + logger.exception("Failed to convert image: %s", image_path) + + # Summary + self.stdout.write("\n" + "=" * 50) + if dry_run: + self.stdout.write(self.style.SUCCESS(f"Dry run complete. Would convert {converted_count} images")) + else: + self.stdout.write(self.style.SUCCESS(f"Converted: {converted_count}")) + self.stdout.write(f"Skipped (already exist): {skipped_count}") + if error_count > 0: + self.stdout.write(self.style.ERROR(f"Errors: {error_count}")) + + def _convert_image( + self, + source_path: Path, + webp_path: Path | None, + avif_path: Path | None, + quality: int, + media_root: Path, + ) -> bool: + """Convert a single image to WebP and/or AVIF. + + Args: + source_path: Path to the source image + webp_path: Path for WebP output (None to skip) + avif_path: Path for AVIF output (None to skip) + quality: Quality setting for encoding + media_root: Media root path for relative display + + Returns: + True if conversion succeeded, False otherwise + """ + with Image.open(source_path) as original_img: + # Convert to RGB if needed (AVIF doesn't support RGBA well) + rgb_img = self._prepare_image_for_encoding(original_img) + + # Save as WebP + if webp_path: + rgb_img.save( + webp_path, + "WEBP", + quality=quality, + method=6, # Slowest but best compression + ) + self.stdout.write( + self.style.SUCCESS(f"✓ WebP: {webp_path.relative_to(media_root)}"), + ) + + # Save as AVIF + if avif_path: + rgb_img.save( + avif_path, + "AVIF", + quality=quality, + speed=4, # 0-10, lower is slower but better compression + ) + self.stdout.write( + self.style.SUCCESS(f"✓ AVIF: {avif_path.relative_to(media_root)}"), + ) + + return True + + def _prepare_image_for_encoding(self, img: Image.Image) -> Image.Image: + """Prepare an image for WebP/AVIF encoding by converting to RGB. + + Args: + img: Source PIL Image + + Returns: + RGB PIL Image ready for encoding + """ + if img.mode in {"RGBA", "LA"} or (img.mode == "P" and "transparency" in img.info): + # Create white background for transparency + background = Image.new("RGB", img.size, (255, 255, 255)) + rgba_img = img.convert("RGBA") if img.mode == "P" else img + background.paste(rgba_img, mask=rgba_img.split()[-1] if rgba_img.mode in {"RGBA", "LA"} else None) + return background + if img.mode != "RGB": + return img.convert("RGB") + return img diff --git a/twitch/management/commands/download_box_art.py b/twitch/management/commands/download_box_art.py index 41b429f..31afc95 100644 --- a/twitch/management/commands/download_box_art.py +++ b/twitch/management/commands/download_box_art.py @@ -10,6 +10,7 @@ from django.conf import settings from django.core.files.base import ContentFile from django.core.management.base import BaseCommand from django.core.management.base import CommandParser +from PIL import Image from twitch.models import Game from twitch.utils import is_twitch_box_art_url @@ -89,6 +90,10 @@ class Command(BaseCommand): continue game.box_art_file.save(file_name, ContentFile(response.content), save=True) + + # Auto-convert to WebP and AVIF + self._convert_to_modern_formats(game.box_art_file.path) + downloaded += 1 self.stdout.write( @@ -99,3 +104,40 @@ class Command(BaseCommand): ) box_art_dir: Path = Path(settings.MEDIA_ROOT) / "games" / "box_art" self.stdout.write(self.style.SUCCESS(f"Saved box art to: {box_art_dir}")) + + def _convert_to_modern_formats(self, image_path: str) -> None: + """Convert downloaded image to WebP and AVIF formats. + + Args: + image_path: Absolute path to the downloaded image file + """ + try: + source_path = Path(image_path) + if not source_path.exists() or source_path.suffix.lower() not in {".jpg", ".jpeg", ".png"}: + return + + base_path = source_path.with_suffix("") + webp_path = base_path.with_suffix(".webp") + avif_path = base_path.with_suffix(".avif") + + with Image.open(source_path) as img: + # Convert to RGB if needed + if img.mode in {"RGBA", "LA"} or (img.mode == "P" and "transparency" in img.info): + background = Image.new("RGB", img.size, (255, 255, 255)) + rgba_img = img.convert("RGBA") if img.mode == "P" else img + background.paste(rgba_img, mask=rgba_img.split()[-1] if rgba_img.mode in {"RGBA", "LA"} else None) + rgb_img = background + elif img.mode != "RGB": + rgb_img = img.convert("RGB") + else: + rgb_img = img + + # Save WebP + rgb_img.save(webp_path, "WEBP", quality=85, method=6) + + # Save AVIF + rgb_img.save(avif_path, "AVIF", quality=85, speed=4) + + except (OSError, ValueError) as e: + # Don't fail the download if conversion fails + self.stdout.write(self.style.WARNING(f"Failed to convert {image_path}: {e}")) diff --git a/twitch/management/commands/download_campaign_images.py b/twitch/management/commands/download_campaign_images.py index b652572..cf48908 100644 --- a/twitch/management/commands/download_campaign_images.py +++ b/twitch/management/commands/download_campaign_images.py @@ -12,6 +12,7 @@ from django.conf import settings from django.core.files.base import ContentFile from django.core.management.base import BaseCommand from django.core.management.base import CommandParser +from PIL import Image from twitch.models import DropBenefit from twitch.models import DropCampaign @@ -245,10 +246,51 @@ class Command(BaseCommand): # Save the image to the FileField if hasattr(file_field, "save"): file_field.save(file_name, ContentFile(response.content), save=True) + + # Auto-convert to WebP and AVIF + self._convert_to_modern_formats(file_field.path) + return "downloaded" return "failed" + def _convert_to_modern_formats(self, image_path: str) -> None: + """Convert downloaded image to WebP and AVIF formats. + + Args: + image_path: Absolute path to the downloaded image file + """ + try: + source_path = Path(image_path) + if not source_path.exists() or source_path.suffix.lower() not in {".jpg", ".jpeg", ".png"}: + return + + base_path = source_path.with_suffix("") + webp_path = base_path.with_suffix(".webp") + avif_path = base_path.with_suffix(".avif") + + with Image.open(source_path) as img: + # Convert to RGB if needed + if img.mode in {"RGBA", "LA"} or (img.mode == "P" and "transparency" in img.info): + background = Image.new("RGB", img.size, (255, 255, 255)) + rgba_img = img.convert("RGBA") if img.mode == "P" else img + background.paste(rgba_img, mask=rgba_img.split()[-1] if rgba_img.mode in {"RGBA", "LA"} else None) + rgb_img = background + elif img.mode != "RGB": + rgb_img = img.convert("RGB") + else: + rgb_img = img + + # Save WebP + rgb_img.save(webp_path, "WEBP", quality=85, method=6) + + # Save AVIF + rgb_img.save(avif_path, "AVIF", quality=85, speed=4) + + except (OSError, ValueError) as e: + # Don't fail the download if conversion fails + self.stdout.write(self.style.WARNING(f"Failed to convert {image_path}: {e}")) + def _merge_stats(self, total: dict[str, int], new: dict[str, int]) -> None: """Merge statistics from a single model into the total stats.""" for key in ["total", "downloaded", "skipped", "failed", "placeholders_404"]: diff --git a/twitch/templatetags/__init__.py b/twitch/templatetags/__init__.py new file mode 100644 index 0000000..5d35b5c --- /dev/null +++ b/twitch/templatetags/__init__.py @@ -0,0 +1 @@ +# Template tags for the twitch app diff --git a/twitch/templatetags/image_tags.py b/twitch/templatetags/image_tags.py new file mode 100644 index 0000000..774d867 --- /dev/null +++ b/twitch/templatetags/image_tags.py @@ -0,0 +1,103 @@ +"""Custom template tags for rendering responsive images with modern formats.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from urllib.parse import urlparse + +from django import template +from django.utils.html import format_html +from django.utils.safestring import SafeString + +if TYPE_CHECKING: + from django.utils.safestring import SafeText + +register = template.Library() + + +def get_format_url(image_url: str, fmt: str) -> str: + """Convert an image URL to a different format. + + Args: + image_url: The original image URL + fmt: The target format (webp or avif) + + Returns: + The URL with the new format extension + """ + if not image_url: + return "" + + # Parse the URL to separate the path from query params + parsed = urlparse(image_url) + path = parsed.path + + # Only convert jpg, jpeg, and png to modern formats + if not path.lower().endswith((".jpg", ".jpeg", ".png")): + return image_url + + # Replace extension with new format using string manipulation to preserve forward slashes + # (Path would convert to backslashes on Windows) + dot_index = path.rfind(".") + new_path = path[:dot_index] + f".{fmt}" + + # Reconstruct URL with new path + return parsed._replace(path=new_path).geturl() + + +@register.simple_tag +def picture( # noqa: PLR0913, PLR0917 + src: str, + alt: str = "", + width: int | None = None, + height: int | None = None, + loading: str = "lazy", + css_class: str = "", + style: str = "", +) -> SafeText: + """Render a responsive picture element with modern image formats. + + Args: + src: The source image URL (jpg/png fallback) + alt: Alt text for the image + width: Width attribute + height: Height attribute + loading: Loading strategy (lazy/eager) + css_class: CSS class to apply + style: Inline styles to apply + + Returns: + SafeText containing the picture element HTML + """ + if not src: + return SafeString("") + + # Generate URLs for modern formats + avif_url: str = get_format_url(src, "avif") + webp_url: str = get_format_url(src, "webp") + + # Build source elements using format_html for safety + sources: list[SafeString] = [] + + # AVIF first (best compression) + if avif_url != src: + sources.append(format_html('', avif_url)) + + # WebP second (good compression, widely supported) + if webp_url != src: + sources.append(format_html('', webp_url)) + + # Build img tag with format_html + img_html: SafeString = format_html( + format_string='', + src=src, + width=format_html(' width="{}"', width) if width else "", + height=format_html(' height="{}"', height) if height else "", + loading=format_html(' loading="{}"', loading) if loading else "", + css_class=format_html(' class="{}"', css_class) if css_class else "", + style=format_html(' style="{}"', style) if style else "", + alt=format_html(' alt="{}"', alt) if alt is not None else "", + ) + + # Combine all parts safely + return format_html("{}{}", SafeString("".join(sources)), img_html) diff --git a/twitch/tests/test_image_tags.py b/twitch/tests/test_image_tags.py new file mode 100644 index 0000000..8ad3042 --- /dev/null +++ b/twitch/tests/test_image_tags.py @@ -0,0 +1,227 @@ +"""Tests for custom image template tags.""" + +from __future__ import annotations + +from django.template import Context +from django.template import Template +from django.utils.safestring import SafeString + +from twitch.templatetags.image_tags import get_format_url +from twitch.templatetags.image_tags import picture + + +class TestGetFormatUrl: + """Tests for the get_format_url helper function.""" + + def test_empty_url(self) -> None: + """Test that empty URL returns empty string.""" + assert not get_format_url("", "webp") + + def test_jpg_to_webp(self) -> None: + """Test converting JPG to WebP.""" + assert get_format_url("/static/img/banner.jpg", "webp") == "/static/img/banner.webp" + + def test_jpeg_to_avif(self) -> None: + """Test converting JPEG to AVIF.""" + assert get_format_url("/static/img/photo.jpeg", "avif") == "/static/img/photo.avif" + + def test_png_to_webp(self) -> None: + """Test converting PNG to WebP.""" + assert get_format_url("/static/img/logo.png", "webp") == "/static/img/logo.webp" + + def test_uppercase_extension(self) -> None: + """Test converting uppercase extensions.""" + assert get_format_url("/static/img/photo.JPG", "webp") == "/static/img/photo.webp" + + def test_non_convertible_format(self) -> None: + """Test that non-convertible formats return unchanged.""" + svg_url = "/static/img/icon.svg" + assert get_format_url(svg_url, "webp") == svg_url + + gif_url = "/static/img/animated.gif" + assert get_format_url(gif_url, "avif") == gif_url + + def test_url_with_query_params(self) -> None: + """Test URL with query parameters preserves them.""" + result = get_format_url("/static/img/photo.jpg?v=123", "webp") + assert result == "/static/img/photo.webp?v=123" + + def test_full_url(self) -> None: + """Test full URL with domain.""" + result: str = get_format_url("https://example.com/img/photo.jpg", "avif") + assert result == "https://example.com/img/photo.avif" + + +class TestPictureTag: + """Tests for the picture template tag.""" + + def test_empty_src(self) -> None: + """Test that empty src returns empty string.""" + result: SafeString = picture("") + assert not result + + def test_basic_picture(self) -> None: + """Test basic picture tag with minimal parameters.""" + result: SafeString = picture("/static/img/photo.jpg") + + # Should contain picture element + assert "" in result + assert "" in result + + # Should have AVIF and WebP sources + assert '' in result + assert '' in result + + # Should have fallback img tag + assert ' None: + """Test picture tag with all optional attributes.""" + result: SafeString = picture( + src="/static/img/photo.jpg", + alt="Test photo", + width=800, + height=600, + loading="eager", + css_class="rounded shadow", + style="max-width: 100%", + ) + + assert 'alt="Test photo"' in result + assert 'width="800"' in result + assert 'height="600"' in result + assert 'loading="eager"' in result + assert 'class="rounded shadow"' in result + assert 'style="max-width: 100%"' in result + + def test_non_convertible_format(self) -> None: + """Test that non-convertible formats don't generate source tags.""" + result: SafeString = picture("/static/img/icon.svg") + + # Should not have source tags since SVG can't be converted + assert "" in result + assert '" in result + + def test_xss_prevention_in_src(self) -> None: + """Test that XSS attempts in src are escaped.""" + malicious_src = '">" not in result + assert "" not in result + assert """ in result + + def test_xss_prevention_in_alt(self) -> None: + """Test that XSS attempts in alt text are escaped.""" + result: SafeString = picture( + "/static/img/photo.jpg", + alt='">', + ) + + # Should escape the malicious code + assert "" not in result + assert """ in result + + def test_xss_prevention_in_css_class(self) -> None: + """Test that XSS attempts in CSS class are escaped.""" + result: SafeString = picture( + "/static/img/photo.jpg", + css_class='">', + ) + + # Should escape the malicious code + assert "" not in result + + def test_xss_prevention_in_style(self) -> None: + """Test that XSS attempts in style are escaped.""" + result: SafeString = picture( + "/static/img/photo.jpg", + style='">', + ) + + # Should escape the malicious code + assert "" not in result + + def test_returns_safestring(self) -> None: + """Test that the result is a SafeString.""" + result: SafeString = picture("/static/img/photo.jpg") + assert isinstance(result, SafeString) + + def test_alt_empty_string(self) -> None: + """Test that alt="" includes empty alt attribute.""" + result: SafeString = picture("/static/img/photo.jpg", alt="") + assert 'alt=""' in result + + def test_no_width_or_height(self) -> None: + """Test that missing width/height are not included.""" + result: SafeString = picture("/static/img/photo.jpg") + + # Should not have width or height attributes + assert 'width="' not in result + assert 'height="' not in result + + def test_url_with_query_params(self) -> None: + """Test that query parameters are preserved in formats.""" + result: SafeString = picture("/static/img/photo.jpg?v=123") + + assert "/static/img/photo.avif?v=123" in result + assert "/static/img/photo.webp?v=123" in result + assert "/static/img/photo.jpg?v=123" in result + + def test_full_url(self) -> None: + """Test with full URL including domain.""" + result: SafeString = picture("https://cdn.example.com/images/photo.jpg") + + assert "https://cdn.example.com/images/photo.avif" in result + assert "https://cdn.example.com/images/photo.webp" in result + assert "https://cdn.example.com/images/photo.jpg" in result + + +class TestPictureTagTemplate: + """Tests for the picture tag used in templates.""" + + def test_picture_tag_in_template(self) -> None: + """Test that the picture tag works when called from a template.""" + template = Template('{% load image_tags %}{% picture src="/img/photo.jpg" alt="Test" %}') + context = Context({}) + result: SafeString = template.render(context) + + assert "" in result + assert "" in result + assert ' None: + """Test using context variables in the picture tag.""" + template = Template("{% load image_tags %}{% picture src=image_url alt=image_alt width=image_width %}") + context = Context({ + "image_url": "/img/banner.png", + "image_alt": "Banner image", + "image_width": 1200, + }) + result: SafeString = template.render(context) + + assert ' None: + """Test that the tag requires at least src parameter.""" + # This should work with empty src but return empty string + template = Template('{% load image_tags %}{% picture src="" %}') + context = Context({}) + result: SafeString = template.render(context) + + assert not result.strip()