diff --git a/docs/topics/images.rst b/docs/topics/images.rst index 1854becbce..25a5a4fd0d 100644 --- a/docs/topics/images.rst +++ b/docs/topics/images.rst @@ -283,6 +283,7 @@ Wagtail may automatically change the format of some images when they are resized - PNG and JPEG images don't change format - GIF images without animation are converted to PNGs - BMP images are converted to PNGs + - WebP images are converted to PNGs It is also possible to override the output format on a per-tag basis by using the ``format`` filter after the resize rule. @@ -295,6 +296,15 @@ For example, to make the tag always convert the image to a JPEG, use ``format-jp You may also use ``format-png`` or ``format-gif``. +Lossless WebP +^^^^^^^^^^^^^ + +You can encode the image into lossless WebP format by using the ``format-webp-lossless`` filter: + +.. code-block:: html+Django + + {% image page.photo width-400 format-webp-lossless %} + .. _image_background_color: Background color @@ -315,19 +325,19 @@ representing the color you would like to use: {# Sets the image background to black #} {% image page.photo width-400 bgcolor-000 format-jpeg %} -.. _jpeg_image_quality: +.. __image_quality: -JPEG image quality +Image quality ------------------ -Wagtail's JPEG image quality setting defaults to 85 (which is quite high). This -can be changed either globally or on a per-tag basis. +Wagtail's JPEG and WebP image quality settings default to 85 (which is quite high). +This can be changed either globally or on a per-tag basis. Changing globally ^^^^^^^^^^^^^^^^^ -Use the ``WAGTAILIMAGES_JPEG_QUALITY`` setting to change the global default JPEG -quality: +Use the ``WAGTAILIMAGES_JPEG_QUALITY`` and ``WAGTAILIMAGES_WEBP_QUALITY`` settings +to change the global defaults of JPEG and WebP quality: .. code-block:: python @@ -335,6 +345,7 @@ quality: # Make low-quality but small images WAGTAILIMAGES_JPEG_QUALITY = 40 + WAGTAILIMAGES_WEBP_QUALITY = 45 Note that this won't affect any previously generated images so you may want to delete all renditions so they can regenerate with the new setting. This can be @@ -349,20 +360,23 @@ done from the Django shell: Changing per-tag ^^^^^^^^^^^^^^^^ -It's also possible to have different JPEG qualities on individual tags by using -the ``jpegquality`` filter. This will always override the default setting: +It's also possible to have different JPEG and WebP qualities on individual tags +by using ``jpegquality`` and ``webpquality`` filters. This will always override +the default setting: .. code-block:: html+Django - {% image page.photo width-400 jpegquality-40 %} + {% image page.photo_jpeg width-400 jpegquality-40 %} + {% image page.photo_webp width-400 webpquality-50 %} Note that this will have no effect on PNG or GIF files. If you want all images -to be low quality, you can use this filter with ``format-jpeg`` (which forces -all images to output in JPEG format): +to be low quality, you can use this filter with ``format-jpeg`` or ``format-webp`` +(which forces all images to output in JPEG or WebP format): .. code-block:: html+Django {% image page.photo width-400 format-jpeg jpegquality-40 %} + {% image page.photo width-400 format-webp webpquality-50 %} Generating image renditions in Python ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/setup.py b/setup.py index 5e40c6bdcd..77b8e2052e 100755 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ install_requires = [ "beautifulsoup4>=4.8,<4.9", "html5lib>=0.999,<2", "Unidecode>=0.04.14,<2.0", - "Willow>=1.3,<1.4", + "Willow>=1.4,<1.5", "requests>=2.11.1,<3.0", "l18n>=2018.5", "xlsxwriter>=1.2.8,<2.0", diff --git a/wagtail/images/image_operations.py b/wagtail/images/image_operations.py index 1996b8cd77..f4e6dbda87 100644 --- a/wagtail/images/image_operations.py +++ b/wagtail/images/image_operations.py @@ -254,9 +254,21 @@ class JPEGQualityOperation(Operation): env['jpeg-quality'] = self.quality +class WebPQualityOperation(Operation): + def construct(self, quality): + self.quality = int(quality) + + if self.quality > 100: + raise ValueError("WebP quality must not be higher than 100") + + def run(self, willow, image, env): + env['webp-quality'] = self.quality + + class FormatOperation(Operation): - def construct(self, fmt): - self.format = fmt + def construct(self, format, *options): + self.format = format + self.options = options if self.format not in ['jpeg', 'png', 'gif', 'webp']: raise ValueError( @@ -264,6 +276,7 @@ class FormatOperation(Operation): def run(self, willow, image, env): env['output-format'] = self.format + env['output-format-options'] = self.options class BackgroundColorOperation(Operation): diff --git a/wagtail/images/models.py b/wagtail/images/models.py index bda801160c..55ae1b0c2d 100644 --- a/wagtail/images/models.py +++ b/wagtail/images/models.py @@ -446,10 +446,8 @@ class Filter: # Allow changing of JPEG compression quality if 'jpeg-quality' in env: quality = env['jpeg-quality'] - elif hasattr(settings, 'WAGTAILIMAGES_JPEG_QUALITY'): - quality = settings.WAGTAILIMAGES_JPEG_QUALITY else: - quality = 85 + quality = getattr(settings, 'WAGTAILIMAGES_JPEG_QUALITY', 85) # If the image has an alpha channel, give it a white background if willow.has_alpha(): @@ -461,7 +459,16 @@ class Filter: elif output_format == 'gif': return willow.save_as_gif(output) elif output_format == 'webp': - return willow.save_as_webp(output) + # Allow changing of WebP compression quality + if ('output-format-options' in env + and 'lossless' in env['output-format-options']): + return willow.save_as_webp(output, lossless=True) + elif 'webp-quality' in env: + quality = env['webp-quality'] + else: + quality = getattr(settings, 'WAGTAILIMAGES_WEBP_QUALITY', 85) + + return willow.save_as_webp(output, quality=quality) def get_cache_key(self, image): vary_parts = [] diff --git a/wagtail/images/tests/test_image_operations.py b/wagtail/images/tests/test_image_operations.py index 01694e31d3..970daf0ee7 100644 --- a/wagtail/images/tests/test_image_operations.py +++ b/wagtail/images/tests/test_image_operations.py @@ -556,6 +556,20 @@ class TestFormatFilter(TestCase): self.assertEqual(out.format_name, 'webp') + def test_webp_lossless(self): + fil = Filter(spec='width-400|format-webp-lossless') + image = Image.objects.create( + title="Test image", + file=get_test_image_file(), + ) + + f = BytesIO() + with patch('PIL.Image.Image.save') as save: + fil.run(image, f) + + # quality=80 is default for Williw and PIL libs + save.assert_called_with(f, 'WEBP', quality=80, lossless=True) + def test_invalid(self): fil = Filter(spec='width-400|format-foo') image = Image.objects.create( @@ -649,6 +663,90 @@ class TestJPEGQualityFilter(TestCase): save.assert_called_with(f, 'JPEG', quality=40, optimize=True, progressive=True) +class TestWebPQualityFilter(TestCase): + def test_default_quality(self): + fil = Filter(spec='width-400|format-webp') + image = Image.objects.create( + title="Test image", + file=get_test_image_file_jpeg(), + ) + + f = BytesIO() + with patch('PIL.Image.Image.save') as save: + fil.run(image, f) + + save.assert_called_with(f, 'WEBP', quality=85, lossless=False) + + def test_webp_quality_filter(self): + fil = Filter(spec='width-400|webpquality-40|format-webp') + image = Image.objects.create( + title="Test image", + file=get_test_image_file_jpeg(), + ) + + f = BytesIO() + with patch('PIL.Image.Image.save') as save: + fil.run(image, f) + + save.assert_called_with(f, 'WEBP', quality=40, lossless=False) + + def test_webp_quality_filter_invalid(self): + fil = Filter(spec='width-400|webpquality-abc|format-webp') + image = Image.objects.create( + title="Test image", + file=get_test_image_file_jpeg(), + ) + self.assertRaises(InvalidFilterSpecError, fil.run, image, BytesIO()) + + def test_webp_quality_filter_no_value(self): + fil = Filter(spec='width-400|webpquality') + image = Image.objects.create( + title="Test image", + file=get_test_image_file_jpeg(), + ) + self.assertRaises(InvalidFilterSpecError, fil.run, image, BytesIO()) + + def test_webp_quality_filter_too_big(self): + fil = Filter(spec='width-400|webpquality-101|format-webp') + image = Image.objects.create( + title="Test image", + file=get_test_image_file_jpeg(), + ) + self.assertRaises(InvalidFilterSpecError, fil.run, image, BytesIO()) + + @override_settings( + WAGTAILIMAGES_WEBP_QUALITY=50 + ) + def test_webp_quality_setting(self): + fil = Filter(spec='width-400|format-webp') + image = Image.objects.create( + title="Test image", + file=get_test_image_file_jpeg(), + ) + + f = BytesIO() + with patch('PIL.Image.Image.save') as save: + fil.run(image, f) + + save.assert_called_with(f, 'WEBP', quality=50, lossless=False) + + @override_settings( + WAGTAILIMAGES_WEBP_QUALITY=50 + ) + def test_jpeg_quality_filter_overrides_setting(self): + fil = Filter(spec='width-400|webpquality-40|format-webp') + image = Image.objects.create( + title="Test image", + file=get_test_image_file_jpeg(), + ) + + f = BytesIO() + with patch('PIL.Image.Image.save') as save: + fil.run(image, f) + + save.assert_called_with(f, 'WEBP', quality=40, lossless=False) + + class TestBackgroundColorFilter(TestCase): def test_original_has_alpha(self): # Checks that the test image we're using has alpha diff --git a/wagtail/images/wagtail_hooks.py b/wagtail/images/wagtail_hooks.py index 47840666f3..cfc9d039f9 100644 --- a/wagtail/images/wagtail_hooks.py +++ b/wagtail/images/wagtail_hooks.py @@ -117,6 +117,7 @@ def register_image_operations(): ('height', image_operations.WidthHeightOperation), ('scale', image_operations.ScaleOperation), ('jpegquality', image_operations.JPEGQualityOperation), + ('webpquality', image_operations.WebPQualityOperation), ('format', image_operations.FormatOperation), ('bgcolor', image_operations.BackgroundColorOperation), ]