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 @@
-
+ {% 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 }}
-
+ {% 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 %}
-
+ {% 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 %}
-
+ {% 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:
-
+ {% 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 %}
-
+ {% 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 %}
-
+ {% 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.
-
+ {% 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">
{% if game.box_art_best_url %}
-
+ {% 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 %}
-
+ {% 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 %}
-
+ {% 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 %}
-
+ {% 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 %}
-
+ {% 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 %}
-
+ {% 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()