Преглед на файлове

Add preserve-svg option to image template tag

Joshua Munn преди 2 години
родител
ревизия
34691bdd3a
променени са 5 файла, в които са добавени 105 реда и са изтрити 14 реда
  1. 2 1
      wagtail/images/models.py
  2. 27 8
      wagtail/images/templatetags/wagtailimages_tags.py
  3. 40 5
      wagtail/images/tests/test_templatetags.py
  4. 13 0
      wagtail/images/tests/utils.py
  5. 23 0
      wagtail/images/utils.py

+ 2 - 1
wagtail/images/models.py

@@ -581,7 +581,8 @@ class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Mode
         return self.height < self.width
 
     def is_svg(self):
-        return self.file.name.endswith(".svg")
+        _, ext = os.path.splitext(self.file.name)
+        return ext.lower() == ".svg"
 
     @property
     def filename(self):

+ 27 - 8
wagtail/images/templatetags/wagtailimages_tags.py

@@ -1,12 +1,13 @@
 import re
+from functools import cache
 
 from django import template
 from django.core.exceptions import ImproperlyConfigured
 from django.urls import NoReverseMatch
-from django.utils.functional import cached_property
 
 from wagtail.images.models import Filter
 from wagtail.images.shortcuts import get_rendition_or_not_found
+from wagtail.images.utils import to_svg_safe_spec
 from wagtail.images.views.serve import generate_image_url
 
 register = template.Library()
@@ -26,6 +27,8 @@ def image(parser, token):
     as_context = False  # if True, the next bit to be read is the output variable name
     is_valid = True
 
+    preserve_svg = False
+
     for bit in bits:
         if bit == "as":
             # token is of the form {% image self.photo max-320x200 as img %}
@@ -36,6 +39,8 @@ def image(parser, token):
             else:
                 # more than one item exists after 'as' - reject as invalid
                 is_valid = False
+        elif bit == "preserve-svg":
+            preserve_svg = True
         else:
             try:
                 name, value = bit.split("=")
@@ -74,9 +79,10 @@ def image(parser, token):
     if is_valid:
         return ImageNode(
             image_expr,
-            "|".join(filter_specs),
+            filter_specs,
             attrs=attrs,
             output_var_name=output_var_name,
+            preserve_svg=preserve_svg,
         )
     else:
         raise template.TemplateSyntaxError(
@@ -86,15 +92,25 @@ def image(parser, token):
 
 
 class ImageNode(template.Node):
-    def __init__(self, image_expr, filter_spec, output_var_name=None, attrs={}):
+    def __init__(
+        self,
+        image_expr,
+        filter_specs,
+        output_var_name=None,
+        attrs={},
+        preserve_svg=False,
+    ):
         self.image_expr = image_expr
         self.output_var_name = output_var_name
         self.attrs = attrs
-        self.filter_spec = filter_spec
+        self.filter_specs = filter_specs
+        self.preserve_svg = preserve_svg
 
-    @cached_property
-    def filter(self):
-        return Filter(spec=self.filter_spec)
+    @cache
+    def get_filter(self, preserve_svg=False):
+        if preserve_svg:
+            return Filter(to_svg_safe_spec(self.filter_specs))
+        return Filter(spec="|".join(self.filter_specs))
 
     def render(self, context):
         try:
@@ -110,7 +126,10 @@ class ImageNode(template.Node):
         if not hasattr(image, "get_rendition"):
             raise ValueError("image tag expected an Image object, got %r" % image)
 
-        rendition = get_rendition_or_not_found(image, self.filter)
+        rendition = get_rendition_or_not_found(
+            image,
+            self.get_filter(preserve_svg=self.preserve_svg and image.is_svg()),
+        )
 
         if self.output_var_name:
             # return the rendition object in the given variable

+ 40 - 5
wagtail/images/tests/test_templatetags.py

@@ -3,7 +3,7 @@ from django.test import TestCase
 
 from wagtail.images.models import Image, Rendition
 from wagtail.images.templatetags.wagtailimages_tags import ImageNode
-from wagtail.images.tests.utils import get_test_image_file
+from wagtail.images.tests.utils import get_test_image_file, get_test_image_file_svg
 
 
 class ImageNodeTestCase(TestCase):
@@ -14,13 +14,17 @@ class ImageNodeTestCase(TestCase):
             title="Test image",
             file=get_test_image_file(),
         )
+        cls.svg_image = Image.objects.create(
+            title="Test SVG image",
+            file=get_test_image_file_svg(),
+        )
 
     def test_render_valid_image_to_string(self):
         """
         Tests that an ImageNode with a valid image renders an img tag
         """
         context = {"image": self.image}
-        node = ImageNode(Variable("image"), "original")
+        node = ImageNode(Variable("image"), ["original"])
 
         rendered = node.render(context)
 
@@ -31,7 +35,7 @@ class ImageNodeTestCase(TestCase):
         Tests that an ImageNode without image renders an empty string
         """
         context = {"image": None}
-        node = ImageNode(Variable("image"), "original")
+        node = ImageNode(Variable("image"), ["original"])
 
         rendered = node.render(context)
 
@@ -43,7 +47,7 @@ class ImageNodeTestCase(TestCase):
         renders an empty string and puts a rendition in the context variable
         """
         context = {"image": self.image, "image_node": "fake value"}
-        node = ImageNode(Variable("image"), "original", "image_node")
+        node = ImageNode(Variable("image"), ["original"], "image_node")
 
         rendered = node.render(context)
 
@@ -56,9 +60,40 @@ class ImageNodeTestCase(TestCase):
         renders an empty string and puts None in the context variable
         """
         context = {"image": None, "image_node": "fake value"}
-        node = ImageNode(Variable("image"), "original", "image_node")
+        node = ImageNode(Variable("image"), ["original"], "image_node")
 
         rendered = node.render(context)
 
         self.assertEqual(rendered, "")
         self.assertIsNone(context["image_node"])
+
+    def test_filters_preserve_svg(self):
+        """
+        If the image is an SVG, and we set the preserve_svg parameter of ImageNode
+        to True, we should only use filters that don't require rasterisation (at this
+        time, resize and crop operations only).
+        """
+        params = [
+            (self.svg_image, ["original"], "original"),
+            (self.svg_image, ["fill-400x400", "bgcolor-000"], "fill-400x400"),
+            (
+                self.svg_image,
+                ["fill-400x400", "format-webp", "webpquality-50"],
+                "fill-400x400",
+            ),
+            (self.image, ["fill-400x400", "bgcolor-000"], "fill-400x400|bgcolor-000"),
+            (self.image, ["fill-400x400", "format-webp"], "fill-400x400|format-webp"),
+            (
+                self.image,
+                ["fill-400x400", "format-webp", "webpquality-50"],
+                "fill-400x400|format-webp|webpquality-50",
+            ),
+        ]
+        for image, filter_specs, expected in params:
+            with self.subTest(img=image, filter_specs=filter_specs, expected=expected):
+                context = {"image": image, "image_node": "fake_value"}
+                node = ImageNode(Variable("image"), filter_specs, preserve_svg=True)
+                node.render(context)
+                self.assertEqual(
+                    node.get_filter(preserve_svg=image.is_svg()).spec, expected
+                )

+ 13 - 0
wagtail/images/tests/utils.py

@@ -34,3 +34,16 @@ def get_test_image_file_tiff(filename="test.tiff", colour="white", size=(640, 48
     image = PIL.Image.new("RGB", size, colour)
     image.save(f, "TIFF")
     return ImageFile(f, name=filename)
+
+
+def get_test_image_file_svg(
+    filename="test.svg", width=100, height=100, view_box="0 0 100 100"
+):
+    img = f"""
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="{view_box}">
+</svg>
+    """
+    f = BytesIO(img.strip().encode("utf-8"))
+    return ImageFile(f, filename)

+ 23 - 0
wagtail/images/utils.py

@@ -106,3 +106,26 @@ def find_image_duplicates(image, user, permission_policy):
 
     instances = permission_policy.instances_user_has_permission_for(user, "choose")
     return instances.exclude(pk=image.pk).filter(file_hash=image.file_hash)
+
+
+def to_svg_safe_spec(filter_specs):
+    """
+    Remove any directives that would require an SVG to be rasterised
+    """
+    if isinstance(filter_specs, str):
+        filter_specs = filter_specs.split("|")
+    svg_preserving_specs = [
+        "max",
+        "min",
+        "width",
+        "height",
+        "scale",
+        "fill",
+        "original",
+    ]
+    safe_specs = [
+        x
+        for x in filter_specs
+        if any(map(lambda prefix: x.startswith(prefix), svg_preserving_specs))
+    ]
+    return "|".join(safe_specs)