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.
"""
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(
'<img src="{}" width="50" height="50" style="object-fit: cover; border-radius: 4px;" />',
obj.image.url,
@ -85,6 +87,19 @@ class ImageLibraryAdmin(admin.ModelAdmin):
Display larger preview in detail view.
"""
if obj.image:
if obj.is_svg():
# For SVG files in detail view, use object tag for better rendering
# 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,

View file

@ -59,11 +59,17 @@ class ImageLibraryWidget(forms.Select):
selected = "selected" if str(image.pk) == str(value) 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(
f"""
<div class="image-option {selected}" data-value="{image.pk}">
<div class="image-preview">
<img src="{image_url}" alt="{image.alt_text}" loading="lazy">
{preview_html}
</div>
<div class="image-info">
<span class="image-name">{image.name}</span>

View file

@ -40,6 +40,8 @@ class ImageLibraryWidget(forms.Select):
for image in images:
thumbnail_html = ""
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(
' <img src="{}" style="width: 20px; height: 20px; object-fit: cover; margin-left: 5px; vertical-align: middle;" />',
image.image.url,
@ -64,16 +66,21 @@ class ImageLibraryWidget(forms.Select):
if value:
try:
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(
'<div class="image-preview" style="margin-top: 10px;">'
'<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>",
image.image.url,
image.name,
image.width or "?",
image.height or "?",
image.get_file_size_display(),
svg_indicator,
)
except ImageLibrary.DoesNotExist:
pass

View file

@ -2,6 +2,8 @@ from django.db import models
from django.core.exceptions import ValidationError
from django.utils.text import slugify
from django_prose_editor.fields import ProseEditorField
import mimetypes
import xml.etree.ElementTree as ET
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")
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):
CHF = "CHF", "Swiss Franc"
EUR = "EUR", "Euro"

View file

@ -1,12 +1,13 @@
import os
import mimetypes
import xml.etree.ElementTree as ET
from django.db import models
from django.utils import timezone
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.utils.text import slugify
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):
@ -35,10 +36,10 @@ class ImageLibrary(models.Model):
)
# Image file
image = models.ImageField(
image = models.FileField(
upload_to=get_image_upload_path,
validators=[validate_image_size],
help_text="Upload image file (max 1MB)",
validators=[validate_image_or_svg],
help_text="Upload image file (max 1MB) - supports JPEG, PNG, GIF, WebP, BMP, TIFF, and SVG",
)
# Image properties (automatically populated)
@ -122,14 +123,86 @@ class ImageLibrary(models.Model):
Update image properties like width, height, and file size.
"""
try:
# Get image dimensions
# Get file 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
# Get file size
self.file_size = self.image.size
# Save without calling the full save method to avoid recursion
ImageLibrary.objects.filter(pk=self.pk).update(
width=self.width, height=self.height, file_size=self.file_size
@ -175,6 +248,28 @@ class ImageLibrary(models.Model):
self.usage_count -= 1
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):
"""

View file

@ -77,3 +77,33 @@
background-color: #f0f0f0;
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):
"""
Render an image from the image library by slug or ID.
Automatically handles SVG files with proper rendering.
Usage:
{% image_library_img "my-image-slug" css_class="img-fluid" %}
@ -25,7 +26,35 @@ 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
final_alt_text = alt_text or image.alt_text
# Build HTML attributes
# Check if it's an SVG file
if image.is_svg():
# For SVG files, use object tag for better rendering
attrs = {
"data": image.image.url,
"type": "image/svg+xml",
"alt": final_alt_text,
}
if css_class:
attrs["class"] = css_class
if width:
attrs["width"] = width
if height:
attrs["height"] = height
# Build the object tag with img fallback
attr_string = " ".join(f'{k}="{v}"' for k, v in attrs.items())
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,