add support for svg images in library

This commit is contained in:
Tobias Brunner 2025-07-08 15:24:47 +02:00
parent 2c217939b0
commit ff3a09d30c
No known key found for this signature in database
7 changed files with 257 additions and 30 deletions

View file

@ -72,6 +72,8 @@ class ImageLibraryAdmin(admin.ModelAdmin):
Display small thumbnail in list view. Display small thumbnail in list view.
""" """
if obj.image: if obj.image:
# Use img tag for all images in list view to maintain clickability
# SVG files will still display correctly with img tag
return format_html( return format_html(
'<img src="{}" width="50" height="50" style="object-fit: cover; border-radius: 4px;" />', '<img src="{}" width="50" height="50" style="object-fit: cover; border-radius: 4px;" />',
obj.image.url, obj.image.url,
@ -85,10 +87,23 @@ class ImageLibraryAdmin(admin.ModelAdmin):
Display larger preview in detail view. Display larger preview in detail view.
""" """
if obj.image: if obj.image:
return format_html( if obj.is_svg():
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 4px;" />', # For SVG files in detail view, use object tag for better rendering
obj.image.url, # This is only for display, not for clickable elements
) return format_html(
'<div style="pointer-events: none;">'
'<object data="{}" type="image/svg+xml" style="max-width: 300px; max-height: 300px; border-radius: 4px; background: #f5f5f5;">'
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 4px;" />'
"</object>"
"</div>",
obj.image.url,
obj.image.url,
)
else:
return format_html(
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 4px;" />',
obj.image.url,
)
return "No Image" return "No Image"
image_preview.short_description = "Preview" image_preview.short_description = "Preview"

View file

@ -59,11 +59,17 @@ class ImageLibraryWidget(forms.Select):
selected = "selected" if str(image.pk) == str(value) else "" selected = "selected" if str(image.pk) == str(value) else ""
image_url = image.image.url if image.image else "" image_url = image.image.url if image.image else ""
# Use img tag for all images in widget to maintain clickability
# SVG files will still display correctly with img tag
preview_html = (
f'<img src="{image_url}" alt="{image.alt_text}" loading="lazy">'
)
html_parts.append( html_parts.append(
f""" f"""
<div class="image-option {selected}" data-value="{image.pk}"> <div class="image-option {selected}" data-value="{image.pk}">
<div class="image-preview"> <div class="image-preview">
<img src="{image_url}" alt="{image.alt_text}" loading="lazy"> {preview_html}
</div> </div>
<div class="image-info"> <div class="image-info">
<span class="image-name">{image.name}</span> <span class="image-name">{image.name}</span>

View file

@ -40,6 +40,8 @@ class ImageLibraryWidget(forms.Select):
for image in images: for image in images:
thumbnail_html = "" thumbnail_html = ""
if self.show_thumbnails and image.image: if self.show_thumbnails and image.image:
# Use img tag for all images in dropdowns to maintain functionality
# SVG files will still display correctly with img tag
thumbnail_html = format_html( thumbnail_html = format_html(
' <img src="{}" style="width: 20px; height: 20px; object-fit: cover; margin-left: 5px; vertical-align: middle;" />', ' <img src="{}" style="width: 20px; height: 20px; object-fit: cover; margin-left: 5px; vertical-align: middle;" />',
image.image.url, image.image.url,
@ -64,16 +66,21 @@ class ImageLibraryWidget(forms.Select):
if value: if value:
try: try:
image = ImageLibrary.objects.get(pk=value) image = ImageLibrary.objects.get(pk=value)
# Use img tag for all images in preview for consistency
# Add SVG indicator in the text if it's an SVG file
svg_indicator = " (SVG)" if image.is_svg() else ""
preview_html = format_html( preview_html = format_html(
'<div class="image-preview" style="margin-top: 10px;">' '<div class="image-preview" style="margin-top: 10px;">'
'<img src="{}" style="max-width: 200px; max-height: 200px; border: 1px solid #ddd; border-radius: 4px;" />' '<img src="{}" style="max-width: 200px; max-height: 200px; border: 1px solid #ddd; border-radius: 4px;" />'
'<p style="margin-top: 5px; font-size: 12px; color: #666;">{} - {}x{} - {}</p>' '<p style="margin-top: 5px; font-size: 12px; color: #666;">{} - {}x{} - {}{}</p>'
"</div>", "</div>",
image.image.url, image.image.url,
image.name, image.name,
image.width or "?", image.width or "?",
image.height or "?", image.height or "?",
image.get_file_size_display(), image.get_file_size_display(),
svg_indicator,
) )
except ImageLibrary.DoesNotExist: except ImageLibrary.DoesNotExist:
pass pass

View file

@ -2,6 +2,8 @@ from django.db import models
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.text import slugify from django.utils.text import slugify
from django_prose_editor.fields import ProseEditorField from django_prose_editor.fields import ProseEditorField
import mimetypes
import xml.etree.ElementTree as ET
def validate_image_size(value, mb=1): def validate_image_size(value, mb=1):
@ -10,6 +12,49 @@ def validate_image_size(value, mb=1):
raise ValidationError(f"Maximum file size is {mb} MB") raise ValidationError(f"Maximum file size is {mb} MB")
def validate_image_or_svg(value):
"""
Validate that the uploaded file is either a valid image or SVG file.
"""
# Check file size first
validate_image_size(value)
# Get the file extension and MIME type
filename = value.name.lower()
mime_type, _ = mimetypes.guess_type(filename)
# List of allowed image formats
allowed_image_types = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
"image/tiff",
"image/svg+xml",
]
# Check if it's an SVG file
if filename.endswith(".svg") or mime_type == "image/svg+xml":
try:
# Reset file pointer and read content
value.seek(0)
content = value.read()
value.seek(0) # Reset for later use
# Try to parse as XML to ensure it's valid SVG
ET.fromstring(content)
return # Valid SVG
except ET.ParseError:
raise ValidationError("Invalid SVG file format")
# For non-SVG files, check MIME type
if mime_type not in allowed_image_types:
raise ValidationError(
f"Unsupported file type. Allowed types: JPEG, PNG, GIF, WebP, BMP, TIFF, SVG"
)
class Currency(models.TextChoices): class Currency(models.TextChoices):
CHF = "CHF", "Swiss Franc" CHF = "CHF", "Swiss Franc"
EUR = "EUR", "Euro" EUR = "EUR", "Euro"

View file

@ -1,12 +1,13 @@
import os import os
import mimetypes
import xml.etree.ElementTree as ET
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.text import slugify from django.utils.text import slugify
from PIL import Image as PILImage from PIL import Image as PILImage
from .base import validate_image_size from .base import validate_image_or_svg
def get_image_upload_path(instance, filename): def get_image_upload_path(instance, filename):
@ -35,10 +36,10 @@ class ImageLibrary(models.Model):
) )
# Image file # Image file
image = models.ImageField( image = models.FileField(
upload_to=get_image_upload_path, upload_to=get_image_upload_path,
validators=[validate_image_size], validators=[validate_image_or_svg],
help_text="Upload image file (max 1MB)", help_text="Upload image file (max 1MB) - supports JPEG, PNG, GIF, WebP, BMP, TIFF, and SVG",
) )
# Image properties (automatically populated) # Image properties (automatically populated)
@ -122,14 +123,86 @@ class ImageLibrary(models.Model):
Update image properties like width, height, and file size. Update image properties like width, height, and file size.
""" """
try: try:
# Get image dimensions
with PILImage.open(self.image.path) as img:
self.width = img.width
self.height = img.height
# Get file size # Get file size
self.file_size = self.image.size self.file_size = self.image.size
# Check if it's an SVG file
filename = self.image.name.lower()
mime_type, _ = mimetypes.guess_type(filename)
if filename.endswith(".svg") or mime_type == "image/svg+xml":
# For SVG files, try to extract dimensions from the SVG content
try:
with open(self.image.path, "r", encoding="utf-8") as f:
content = f.read()
# Parse the SVG to extract width and height
root = ET.fromstring(content)
# Get width and height attributes
width = root.get("width")
height = root.get("height")
# Extract numeric values if they exist
if width and height:
# Remove units like 'px', 'em', etc. and convert to int
try:
width_val = int(
float(
width.replace("px", "")
.replace("em", "")
.replace("pt", "")
)
)
height_val = int(
float(
height.replace("px", "")
.replace("em", "")
.replace("pt", "")
)
)
self.width = width_val
self.height = height_val
except (ValueError, TypeError):
# If we can't parse dimensions, try viewBox
viewbox = root.get("viewBox")
if viewbox:
try:
viewbox_parts = viewbox.split()
if len(viewbox_parts) >= 4:
self.width = int(float(viewbox_parts[2]))
self.height = int(float(viewbox_parts[3]))
except (ValueError, TypeError):
# Default SVG dimensions if we can't parse
self.width = 100
self.height = 100
else:
# Check for viewBox if width/height attributes don't exist
viewbox = root.get("viewBox")
if viewbox:
try:
viewbox_parts = viewbox.split()
if len(viewbox_parts) >= 4:
self.width = int(float(viewbox_parts[2]))
self.height = int(float(viewbox_parts[3]))
except (ValueError, TypeError):
self.width = 100
self.height = 100
else:
# Default SVG dimensions
self.width = 100
self.height = 100
except (ET.ParseError, FileNotFoundError, UnicodeDecodeError):
# If SVG parsing fails, set default dimensions
self.width = 100
self.height = 100
else:
# For raster images, use PIL
with PILImage.open(self.image.path) as img:
self.width = img.width
self.height = img.height
# Save without calling the full save method to avoid recursion # Save without calling the full save method to avoid recursion
ImageLibrary.objects.filter(pk=self.pk).update( ImageLibrary.objects.filter(pk=self.pk).update(
width=self.width, height=self.height, file_size=self.file_size width=self.width, height=self.height, file_size=self.file_size
@ -175,6 +248,28 @@ class ImageLibrary(models.Model):
self.usage_count -= 1 self.usage_count -= 1
self.save(update_fields=["usage_count"]) self.save(update_fields=["usage_count"])
def is_svg(self):
"""
Check if the uploaded file is an SVG.
"""
if not self.image:
return False
filename = self.image.name.lower()
mime_type, _ = mimetypes.guess_type(filename)
return filename.endswith(".svg") or mime_type == "image/svg+xml"
def get_mime_type(self):
"""
Return the MIME type of the image file.
"""
if not self.image:
return None
mime_type, _ = mimetypes.guess_type(self.image.name)
return mime_type
class ImageReference(models.Model): class ImageReference(models.Model):
""" """

View file

@ -77,3 +77,33 @@
background-color: #f0f0f0; background-color: #f0f0f0;
color: #666; color: #666;
} }
/* SVG support */
.svg-preview {
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
padding: 5px;
}
.svg-preview object {
width: 100%;
height: 100%;
}
/* SVG thumbnails in admin */
.image-thumbnail object {
background: #f5f5f5;
border-radius: 4px;
}
.image-preview object {
background: #f5f5f5;
border-radius: 4px;
}
/* Category badges */
.category-badge.svg {
background-color: #f3e8ff;
color: #7c3aed;
}

View file

@ -10,6 +10,7 @@ register = template.Library()
def image_library_img(slug_or_id, css_class="", alt_text="", width=None, height=None): def image_library_img(slug_or_id, css_class="", alt_text="", width=None, height=None):
""" """
Render an image from the image library by slug or ID. Render an image from the image library by slug or ID.
Automatically handles SVG files with proper rendering.
Usage: Usage:
{% image_library_img "my-image-slug" css_class="img-fluid" %} {% image_library_img "my-image-slug" css_class="img-fluid" %}
@ -25,24 +26,52 @@ def image_library_img(slug_or_id, css_class="", alt_text="", width=None, height=
# Use provided alt_text or fall back to image's alt_text # Use provided alt_text or fall back to image's alt_text
final_alt_text = alt_text or image.alt_text final_alt_text = alt_text or image.alt_text
# Build HTML attributes # Check if it's an SVG file
attrs = { if image.is_svg():
"src": image.image.url, # For SVG files, use object tag for better rendering
"alt": final_alt_text, attrs = {
} "data": image.image.url,
"type": "image/svg+xml",
"alt": final_alt_text,
}
if css_class: if css_class:
attrs["class"] = css_class attrs["class"] = css_class
if width: if width:
attrs["width"] = width attrs["width"] = width
if height: if height:
attrs["height"] = height attrs["height"] = height
# Build the HTML # Build the object tag with img fallback
attr_string = " ".join(f'{k}="{v}"' for k, v in attrs.items()) attr_string = " ".join(f'{k}="{v}"' for k, v in attrs.items())
return format_html("<img {}/>", attr_string) return format_html(
'<object {}><img src="{}" alt="{}" class="{}"/></object>',
attr_string,
image.image.url,
final_alt_text,
css_class or "",
)
else:
# For raster images, use img tag
attrs = {
"src": image.image.url,
"alt": final_alt_text,
}
if css_class:
attrs["class"] = css_class
if width:
attrs["width"] = width
if height:
attrs["height"] = height
# Build the HTML
attr_string = " ".join(f'{k}="{v}"' for k, v in attrs.items())
return format_html("<img {}/>", attr_string)
except ImageLibrary.DoesNotExist: except ImageLibrary.DoesNotExist:
# Return empty string or placeholder if image not found # Return empty string or placeholder if image not found