diff --git a/hub/services/admin/images.py b/hub/services/admin/images.py index 917d0db..761fd2d 100644 --- a/hub/services/admin/images.py +++ b/hub/services/admin/images.py @@ -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( '', obj.image.url, @@ -85,10 +87,23 @@ class ImageLibraryAdmin(admin.ModelAdmin): Display larger preview in detail view. """ if obj.image: - return format_html( - '', - obj.image.url, - ) + 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( + '
' + '' + '' + "" + "
", + obj.image.url, + obj.image.url, + ) + else: + return format_html( + '', + obj.image.url, + ) return "No Image" image_preview.short_description = "Preview" diff --git a/hub/services/admin/widgets.py b/hub/services/admin/widgets.py index 81e1b1e..13c8e4d 100644 --- a/hub/services/admin/widgets.py +++ b/hub/services/admin/widgets.py @@ -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'{image.alt_text}' + ) + html_parts.append( f"""
- {image.alt_text} + {preview_html}
{image.name} diff --git a/hub/services/forms/image_library.py b/hub/services/forms/image_library.py index 1770456..6435ef8 100644 --- a/hub/services/forms/image_library.py +++ b/hub/services/forms/image_library.py @@ -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( ' ', 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( '
' '' - '

{} - {}x{} - {}

' + '

{} - {}x{} - {}{}

' "
", image.image.url, image.name, image.width or "?", image.height or "?", image.get_file_size_display(), + svg_indicator, ) except ImageLibrary.DoesNotExist: pass diff --git a/hub/services/models/base.py b/hub/services/models/base.py index a047330..db365e7 100644 --- a/hub/services/models/base.py +++ b/hub/services/models/base.py @@ -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" diff --git a/hub/services/models/images.py b/hub/services/models/images.py index 3bf72f7..ccaa26e 100644 --- a/hub/services/models/images.py +++ b/hub/services/models/images.py @@ -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 - 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 + # 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 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): """ diff --git a/hub/services/static/admin/css/image_library.css b/hub/services/static/admin/css/image_library.css index 6d589bb..06c4af0 100644 --- a/hub/services/static/admin/css/image_library.css +++ b/hub/services/static/admin/css/image_library.css @@ -76,4 +76,34 @@ .category-badge.other { 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; } \ No newline at end of file diff --git a/hub/services/templatetags/image_library.py b/hub/services/templatetags/image_library.py index 99339b7..4a04344 100644 --- a/hub/services/templatetags/image_library.py +++ b/hub/services/templatetags/image_library.py @@ -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,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 final_alt_text = alt_text or image.alt_text - # Build HTML attributes - attrs = { - "src": image.image.url, - "alt": final_alt_text, - } + # 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 css_class: + attrs["class"] = css_class - if width: - attrs["width"] = width + if width: + attrs["width"] = width - if height: - attrs["height"] = height + if height: + attrs["height"] = height - # Build the HTML - attr_string = " ".join(f'{k}="{v}"' for k, v in attrs.items()) - return format_html("", attr_string) + # Build the object tag with img fallback + attr_string = " ".join(f'{k}="{v}"' for k, v in attrs.items()) + return format_html( + '{}', + 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("", attr_string) except ImageLibrary.DoesNotExist: # Return empty string or placeholder if image not found