From 24b0712fc1706b74320c7fa54ba31f564d67c7c2 Mon Sep 17 00:00:00 2001 From: Serafeim Papastefanos Date: Mon, 10 Mar 2014 17:17:57 +0200 Subject: [PATCH 01/12] Use unidecode to improve image filenames (fix#136) Image filenames containing non ascii characters would be translated to a series of underscores (____.png). To fix this, we use the unidecoe library (which we also add to the required packages for Wagtail) which translates each unicode character to an ascii equivalent. For more info on how unidecode works please check @Evgeny's answer at this question: http://stackoverflow.com/questions/702337/how-to-make-django-slugify-work-properly-with-unicode-strings --- setup.py | 1 + wagtail/wagtailimages/models.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b33576f54..c4b030c73 100644 --- a/setup.py +++ b/setup.py @@ -48,6 +48,7 @@ setup( "Pillow>=2.3.0", "beautifulsoup4>=4.3.2", "lxml>=3.3.0", + 'Unidecode>=0.04.14', "BeautifulSoup==3.2.1", # django-compressor gets confused if we have lxml but not BS3 installed ], zip_safe=False, diff --git a/wagtail/wagtailimages/models.py b/wagtail/wagtailimages/models.py index d30f969ad..61082c2cb 100644 --- a/wagtail/wagtailimages/models.py +++ b/wagtail/wagtailimages/models.py @@ -14,6 +14,8 @@ from django.utils.html import escape from django.conf import settings from django.utils.translation import ugettext_lazy as _ +from unidecode import unidecode + from wagtail.wagtailadmin.taggable import TagSearchable from wagtail.wagtailimages import image_ops @@ -25,8 +27,9 @@ class AbstractImage(models.Model, TagSearchable): folder_name = 'original_images' filename = self.file.field.storage.get_valid_name(filename) + # do a unidecode in the filename and then # replace non-ascii characters in filename with _ , to sidestep issues with filesystem encoding - filename = "".join((i if ord(i) < 128 else '_') for i in filename) + filename = "".join((i if ord(i) < 128 else '_') for i in unidecode(filename)) while len(os.path.join(folder_name, filename)) >= 95: prefix, dot, extension = filename.rpartition('.') From 11400e3df762da761e557516e9fd103d04588ec1 Mon Sep 17 00:00:00 2001 From: Serafeim Papastefanos Date: Tue, 11 Mar 2014 00:21:00 +0200 Subject: [PATCH 02/12] Add image processing backends This is a first version of Wagtail wit support for image processing backends. A first refactoring of image processing code has been done, moving the image processing code to the wagtailimages/backends package and changing models.py (more specifically, the process_image method of Filter model) to use the code there. The backends package contains the __init__.py module which defines the get_image_backend method that will get the correct image backend based on the passe parameer (or default if not parameer is passed) and the WAGTAILIMAGES_BACKENDS option. The code is copie with small modifications from similar code in wagtailsearch/backends package. Concerning the backends, a BaseImageBackend is defined in the base module. This interface defines a number of methods that the backends inheriting from it should implement. The only methods that need to be implemented is open_image, save_image, resize and crop_to_centre. The other methods are more or less the same (if the image object provides a size attribute). The two backends, wand_backend and pillow_backend provide implementations for Pillow and Wand. Both seem to be working but Wand needs some more testing. To be able to wand, we have to install Wand + ImageMagick. Follow the instructions here: http://docs.wand-py.org/en/0.3.5/guide/install.html. Things to do next: a. Check again the API that BaseImageBackend exposes -- it's not very intuitive and some things should be more DRY b. Find out a method of choosing the backend from the outside world - we may have an Image class that we want to render with Wand and another class that we want to render with Pillow. c. Assert that crop_to_centre method works fine also. d. Check resize filters of Wand (Imagemagick) e. Make tests pass with both Pillow and Wand backends --- wagtail/wagtailimages/backends/__init__.py | 73 ++++++++++ wagtail/wagtailimages/backends/base.py | 132 ++++++++++++++++++ .../wagtailimages/backends/pillow_backend.py | 41 ++++++ .../wagtailimages/backends/wand_backend.py | 39 ++++++ wagtail/wagtailimages/models.py | 41 +++--- 5 files changed, 307 insertions(+), 19 deletions(-) create mode 100644 wagtail/wagtailimages/backends/__init__.py create mode 100644 wagtail/wagtailimages/backends/base.py create mode 100644 wagtail/wagtailimages/backends/pillow_backend.py create mode 100644 wagtail/wagtailimages/backends/wand_backend.py diff --git a/wagtail/wagtailimages/backends/__init__.py b/wagtail/wagtailimages/backends/__init__.py new file mode 100644 index 000000000..ea9a03c43 --- /dev/null +++ b/wagtail/wagtailimages/backends/__init__.py @@ -0,0 +1,73 @@ +# Backend loading +# Based on the Django cache framework and wagtailsearch +# https://github.com/django/django/blob/5d263dee304fdaf95e18d2f0619d6925984a7f02/django/core/cache/__init__.py + +from importlib import import_module +from django.utils import six +import sys +from django.conf import settings + +from base import InvalidImageBackendError + +# Pinched from django 1.7 source code. +# TODO: Replace this with "from django.utils.module_loading import import_string" +# when django 1.7 is released +# TODO: This is not DRY - should be imported from a utils module +def import_string(dotted_path): + """ + Import a dotted module path and return the attribute/class designated by the + last name in the path. Raise ImportError if the import failed. + """ + try: + module_path, class_name = dotted_path.rsplit('.', 1) + except ValueError: + msg = "%s doesn't look like a module path" % dotted_path + six.reraise(ImportError, ImportError(msg), sys.exc_info()[2]) + + module = import_module(module_path) + + try: + return getattr(module, class_name) + except AttributeError: + msg = 'Module "%s" does not define a "%s" attribute/class' % ( + dotted_path, class_name) + six.reraise(ImportError, ImportError(msg), sys.exc_info()[2]) + + +def get_image_backend(backend='default', **kwargs): + # Get configuration + default_conf = { + 'default': { + 'BACKEND': 'wagtail.wagtailimages.backends.wand_backend.WandBackend', + }, + } + WAGTAILIMAGES_BACKENDS = getattr( + settings, 'WAGTAILIMAGES_BACKENDS', default_conf) + + # Try to find the backend + try: + # Try to get the WAGTAILIMAGES_BACKENDS entry for the given backend name first + conf = WAGTAILIMAGES_BACKENDS[backend] + except KeyError: + try: + # Trying to import the given backend, in case it's a dotted path + import_string(backend) + except ImportError as e: + raise InvalidImageBackendError("Could not find backend '%s': %s" % ( + backend, e)) + params = kwargs + else: + # Backend is a conf entry + params = conf.copy() + params.update(kwargs) + backend = params.pop('BACKEND') + + # Try to import the backend + try: + backend_cls = import_string(backend) + except ImportError as e: + raise InvalidImageBackendError("Could not find backend '%s': %s" % ( + backend, e)) + + # Create backend + return backend_cls(params) diff --git a/wagtail/wagtailimages/backends/base.py b/wagtail/wagtailimages/backends/base.py new file mode 100644 index 000000000..7ae700ba6 --- /dev/null +++ b/wagtail/wagtailimages/backends/base.py @@ -0,0 +1,132 @@ +from django.db import models +from django.core.exceptions import ImproperlyConfigured + +class InvalidImageBackendError(ImproperlyConfigured): + pass + + +class BaseImageBackend(object): + def __init__(self, params): + pass + + def open_image(self, input_file): + """ + Open an image and return the backend specific image object to pass + to other methods. The object return has to have a size attribute + which is a tuple with the width and height of the image and a format + attribute with the format of the image. + """ + raise NotImplementedError('subclasses of BaseImageBackend must provide an resize() method') + + + def save_image(self, image, output): + """ + Save the image to the output + """ + raise NotImplementedError('subclasses of BaseImageBackend must provide an resize() method') + + def resize(self, image, size): + """ + resize image to the requested size, using highest quality settings + (antialiasing enabled, converting to true colour if required) + """ + raise NotImplementedError('subclasses of BaseImageBackend must provide an resize() method') + + + def crop_to_centre(self, image, size): + raise NotImplementedError('subclasses of BaseImageBackend must provide an crop_to_centre() method') + + + def resize_to_max(self, image, size): + """ + Resize image down to fit within the given dimensions, preserving aspect ratio. + Will leave image unchanged if it's already within those dimensions. + """ + (original_width, original_height) = image.size + (target_width, target_height) = size + + if original_width <= target_width and original_height <= target_height: + return image + + # scale factor if we were to downsize the image to fit the target width + horz_scale = float(target_width) / original_width + # scale factor if we were to downsize the image to fit the target height + vert_scale = float(target_height) / original_height + + # choose whichever of these gives a smaller image + if horz_scale < vert_scale: + final_size = (target_width, int(original_height * horz_scale)) + else: + final_size = (int(original_width * vert_scale), target_height) + + return self.resize(image, final_size) + + + + def resize_to_min(self, image, size): + """ + Resize image down to cover the given dimensions, preserving aspect ratio. + Will leave image unchanged if width or height is already within those limits. + """ + (original_width, original_height) = image.size + (target_width, target_height) = size + + if original_width <= target_width or original_height <= target_height: + return image + + # scale factor if we were to downsize the image to fit the target width + horz_scale = float(target_width) / original_width + # scale factor if we were to downsize the image to fit the target height + vert_scale = float(target_height) / original_height + + # choose whichever of these gives a larger image + if horz_scale > vert_scale: + final_size = (target_width, int(original_height * horz_scale)) + else: + final_size = (int(original_width * vert_scale), target_height) + + return self.resize(image, final_size) + + + def resize_to_width(self, image, target_width): + """ + Resize image down to the given width, preserving aspect ratio. + Will leave image unchanged if it's already within that width. + """ + (original_width, original_height) = image.size + + if original_width <= target_width: + return image + + scale = float(target_width) / original_width + + final_size = (target_width, int(original_height * scale)) + + return self.resize(image, final_size) + + + def resize_to_height(self, image, target_height): + """ + Resize image down to the given height, preserving aspect ratio. + Will leave image unchanged if it's already within that height. + """ + (original_width, original_height) = image.size + + if original_height <= target_height: + return image + + scale = float(target_height) / original_height + + final_size = (int(original_width * scale), target_height) + + return self.resize(image, final_size) + + + def resize_to_fill(self, image, size): + """ + Resize down and crop image to fill the given dimensions. Most suitable for thumbnails. + (The final image will match the requested size, unless one or the other dimension is + already smaller than the target size) + """ + resized_image = resize_to_min(image, size) + return self.crop_to_centre(resized_image, size) diff --git a/wagtail/wagtailimages/backends/pillow_backend.py b/wagtail/wagtailimages/backends/pillow_backend.py new file mode 100644 index 000000000..bebc76144 --- /dev/null +++ b/wagtail/wagtailimages/backends/pillow_backend.py @@ -0,0 +1,41 @@ +from django.db import models + +from wagtail.wagtailsearch.backends.base import BaseSearch +from wagtail.wagtailsearch.indexed import Indexed +from base import BaseImageBackend +import PIL.Image + +class PillowBackend(BaseImageBackend): + def __init__(self, params): + super(PillowBackend, self).__init__(params) + + def open_image(self, input_file): + image = PIL.Image.open(input_file) + return image + + def save_image(self, image, output, format): + image.save(output, format) + + + def resize(self, image, size): + if image.mode in ['1', 'P']: + image = image.convert('RGB') + return image.resize(size, PIL.Image.ANTIALIAS) + + + def crop_to_centre(self, image, size): + (original_width, original_height) = image.size + (target_width, target_height) = size + + # final dimensions should not exceed original dimensions + final_width = min(original_width, target_width) + final_height = min(original_height, target_height) + + if final_width == original_width and final_height == original_height: + return image + + left = (original_width - final_width) / 2 + top = (original_height - final_height) / 2 + return image.crop( + (left, top, left + final_width, top + final_height) + ) \ No newline at end of file diff --git a/wagtail/wagtailimages/backends/wand_backend.py b/wagtail/wagtailimages/backends/wand_backend.py new file mode 100644 index 000000000..339453907 --- /dev/null +++ b/wagtail/wagtailimages/backends/wand_backend.py @@ -0,0 +1,39 @@ +from django.db import models +from django.conf import settings +from base import BaseImageBackend + +from wand.image import Image + +class WandBackend(BaseImageBackend): + def __init__(self, params): + super(WandBackend, self).__init__(params) + + def open_image(self, input_file): + image = Image(file=input_file) + return image + + def save_image(self, image, output, format): + image.format = format + image.save(file=output) + + def resize(self, image, size): + image.resize(size[0], size[1]) + return image + + + def crop_to_centre(self, image, size): + (original_width, original_height) = image.size + (target_width, target_height) = size + + # final dimensions should not exceed original dimensions + final_width = min(original_width, target_width) + final_height = min(original_height, target_height) + + if final_width == original_width and final_height == original_height: + return image + + left = (original_width - final_width) / 2 + top = (original_height - final_height) / 2 + return image.crop( + (left, top, left + final_width, top + final_height) + ) \ No newline at end of file diff --git a/wagtail/wagtailimages/models.py b/wagtail/wagtailimages/models.py index 61082c2cb..f15556d95 100644 --- a/wagtail/wagtailimages/models.py +++ b/wagtail/wagtailimages/models.py @@ -1,7 +1,6 @@ import StringIO import os.path -import PIL.Image from taggit.managers import TaggableManager from django.core.files import File @@ -17,8 +16,7 @@ from django.utils.translation import ugettext_lazy as _ from unidecode import unidecode from wagtail.wagtailadmin.taggable import TagSearchable -from wagtail.wagtailimages import image_ops - +from wagtail.wagtailimages.backends import get_image_backend class AbstractImage(models.Model, TagSearchable): title = models.CharField(max_length=255, verbose_name=_('Title') ) @@ -146,11 +144,11 @@ class Filter(models.Model): spec = models.CharField(max_length=255, db_index=True) OPERATION_NAMES = { - 'max': image_ops.resize_to_max, - 'min': image_ops.resize_to_min, - 'width': image_ops.resize_to_width, - 'height': image_ops.resize_to_height, - 'fill': image_ops.resize_to_fill, + 'max': 'resize_to_max', + 'min': 'resize_to_min', + 'width': 'resize_to_width', + 'height': 'resize_to_height', + 'fill': 'resize_to_fill', } def __init__(self, *args, **kwargs): @@ -159,12 +157,12 @@ class Filter(models.Model): def _parse_spec_string(self): # parse the spec string, which is formatted as (method)-(arg), - # and save the results to self.method and self.method_arg + # and save the results to self.method_name and self.method_arg try: - (method_name, method_arg_string) = self.spec.split('-') - self.method = Filter.OPERATION_NAMES[method_name] + (method_name_simple, method_arg_string) = self.spec.split('-') + self.method_name = Filter.OPERATION_NAMES[method_name_simple] - if method_name in ('max', 'min', 'fill'): + if method_name_simple in ('max', 'min', 'fill'): # method_arg_string is in the form 640x480 (width, height) = [int(i) for i in method_arg_string.split('x')] self.method_arg = (width, height) @@ -181,18 +179,23 @@ class Filter(models.Model): generate an output image with this filter applied, returning it as another django.core.files.File object """ + + # TODO: Pass default this as a parameter + backend = get_image_backend('default') + if not self.method: self._parse_spec_string() - - input_file.open() - image = PIL.Image.open(input_file) + + image = backend.open_image(input_file) file_format = image.format - # perform the resize operation - image = self.method(image, self.method_arg) + method = getattr(backend, self.method_name) + + image = method(image, self.method_arg) output = StringIO.StringIO() - image.save(output, file_format) + backend.save_image(image, output, file_format) + # generate new filename derived from old one, inserting the filter spec string before the extension input_filename_parts = os.path.basename(input_file.name).split('.') @@ -202,7 +205,7 @@ class Filter(models.Model): output_filename = '.'.join(output_filename_parts) output_file = File(output, name=output_filename) - input_file.close() + return output_file From db65b22d5a44e393e7154348f40635dd620b50cb Mon Sep 17 00:00:00 2001 From: Serafeim Papastefanos Date: Tue, 11 Mar 2014 19:49:01 +0200 Subject: [PATCH 03/12] Fix minor bug with crop --- wagtail/wagtailimages/backends/base.py | 2 +- wagtail/wagtailimages/backends/wand_backend.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/wagtail/wagtailimages/backends/base.py b/wagtail/wagtailimages/backends/base.py index 7ae700ba6..77f609350 100644 --- a/wagtail/wagtailimages/backends/base.py +++ b/wagtail/wagtailimages/backends/base.py @@ -128,5 +128,5 @@ class BaseImageBackend(object): (The final image will match the requested size, unless one or the other dimension is already smaller than the target size) """ - resized_image = resize_to_min(image, size) + resized_image = self.resize_to_min(image, size) return self.crop_to_centre(resized_image, size) diff --git a/wagtail/wagtailimages/backends/wand_backend.py b/wagtail/wagtailimages/backends/wand_backend.py index 339453907..013f5d98c 100644 --- a/wagtail/wagtailimages/backends/wand_backend.py +++ b/wagtail/wagtailimages/backends/wand_backend.py @@ -34,6 +34,7 @@ class WandBackend(BaseImageBackend): left = (original_width - final_width) / 2 top = (original_height - final_height) / 2 - return image.crop( - (left, top, left + final_width, top + final_height) - ) \ No newline at end of file + image.crop( + left=left, top=top, right=left + final_width, bottom=top + final_height + ) + return image From 1fe4f08866e34dd7e7f11456601f852226cfb672 Mon Sep 17 00:00:00 2001 From: Serafeim Papastefanos Date: Tue, 11 Mar 2014 21:49:10 +0200 Subject: [PATCH 04/12] Add test configuration for image backends --- runtests.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/runtests.py b/runtests.py index 4dd047670..092c4bedf 100755 --- a/runtests.py +++ b/runtests.py @@ -18,6 +18,17 @@ if not settings.configured: except ImportError: has_elasticsearch = False + WAGTAILIMAGES_BACKENDS = { + 'default': { + 'BACKEND': 'wagtail.wagtailimages.backends.pillow_backend.PillowBackend', + }, + 'pillow': { + 'BACKEND': 'wagtail.wagtailimages.backends.pillow_backend.PillowBackend', + }, + 'wand': { + 'BACKEND': 'wagtail.wagtailimages.backends.wand_backend.WandBackend', + }, + } WAGTAILSEARCH_BACKENDS = { 'default': { 'BACKEND': 'wagtail.wagtailsearch.backends.db.DBSearch', @@ -86,6 +97,7 @@ if not settings.configured: 'django.contrib.auth.hashers.MD5PasswordHasher', # don't use the intentionally slow default password hasher ), WAGTAILSEARCH_BACKENDS=WAGTAILSEARCH_BACKENDS, + WAGTAILIMAGE_BACKENDS=WAGTAILIMAGE_BACKENDS, WAGTAIL_SITE_NAME='Test Site' ) From 8aeed6a16e3dad8f604357d974d098fc5c2262a0 Mon Sep 17 00:00:00 2001 From: Serafeim Papastefanos Date: Tue, 11 Mar 2014 21:53:50 +0200 Subject: [PATCH 05/12] Allow configurable image backend --- wagtail/wagtailimages/models.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/wagtail/wagtailimages/models.py b/wagtail/wagtailimages/models.py index f15556d95..f62f62cd9 100644 --- a/wagtail/wagtailimages/models.py +++ b/wagtail/wagtailimages/models.py @@ -69,7 +69,11 @@ class AbstractImage(models.Model, TagSearchable): rendition = self.renditions.get(filter=filter) except ObjectDoesNotExist: file_field = self.file - generated_image_file = filter.process_image(file_field.file) + + # If we have a backend attribute then pass it to process + # image - else pass 'default' + backend_name = getattr(self, 'backend', 'default') + generated_image_file = filter.process_image(file_field.file, backend_name=backend_name) rendition, created = self.renditions.get_or_create( filter=filter, defaults={'file': generated_image_file}) @@ -173,19 +177,21 @@ class Filter(models.Model): except (ValueError, KeyError): raise ValueError("Invalid image filter spec: %r" % self.spec) - def process_image(self, input_file): + def process_image(self, input_file, backend_name='default'): """ Given an input image file as a django.core.files.File object, generate an output image with this filter applied, returning it as another django.core.files.File object """ - # TODO: Pass default this as a parameter - backend = get_image_backend('default') + backend = get_image_backend(backend_name) if not self.method: self._parse_spec_string() + # If file is closed, open it + if input_file.closed: + input_file.open() image = backend.open_image(input_file) file_format = image.format From 37b37a07070a5bdd75d5f1c3798ef58a2602333f Mon Sep 17 00:00:00 2001 From: Serafeim Papastefanos Date: Tue, 11 Mar 2014 21:54:27 +0200 Subject: [PATCH 06/12] Add tests for backends --- wagtail/wagtailimages/tests.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/wagtail/wagtailimages/tests.py b/wagtail/wagtailimages/tests.py index c89961773..69f5888bb 100644 --- a/wagtail/wagtailimages/tests.py +++ b/wagtail/wagtailimages/tests.py @@ -78,26 +78,57 @@ class TestRenditions(TestCase): file=get_test_image_file(), ) + self.image2 = Image.objects.create( + title="Test image", + file=get_test_image_file(), + ) + self.image.backend = 'pillow' + + self.image3 = Image.objects.create( + title="Test image", + file=get_test_image_file(), + ) + self.image.backend = 'wand' + def test_minification(self): rendition = self.image.get_rendition('width-400') + rendition2 = self.image2.get_rendition('width-400') + rendition3 = self.image3.get_rendition('width-400') # Check size self.assertEqual(rendition.width, 400) self.assertEqual(rendition.height, 300) + self.assertEqual(rendition2.width, 400) + self.assertEqual(rendition2.height, 300) + self.assertEqual(rendition3.width, 400) + self.assertEqual(rendition3.height, 300) def test_resize_to_max(self): rendition = self.image.get_rendition('max-100x100') + rendition2 = self.image2.get_rendition('max-100x100') + rendition3 = self.image3.get_rendition('max-100x100') # Check size self.assertEqual(rendition.width, 100) self.assertEqual(rendition.height, 75) + self.assertEqual(rendition2.width, 100) + self.assertEqual(rendition2.height, 75) + self.assertEqual(rendition3.width, 100) + self.assertEqual(rendition3.height, 75) + def test_resize_to_min(self): rendition = self.image.get_rendition('min-120x120') + rendition2 = self.image2.get_rendition('min-120x120') + rendition3 = self.image3.get_rendition('min-120x120') # Check size self.assertEqual(rendition.width, 160) self.assertEqual(rendition.height, 120) + self.assertEqual(rendition2.width, 160) + self.assertEqual(rendition2.height, 120) + self.assertEqual(rendition3.width, 160) + self.assertEqual(rendition3.height, 120) def test_cache(self): # Get two renditions with the same filter From fca4ad205670ff78105659413f66d8e6a60a764e Mon Sep 17 00:00:00 2001 From: Serafeim Papastefanos Date: Tue, 11 Mar 2014 23:07:10 +0200 Subject: [PATCH 07/12] Make pillow the default backend --- wagtail/wagtailimages/backends/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wagtail/wagtailimages/backends/__init__.py b/wagtail/wagtailimages/backends/__init__.py index ea9a03c43..e028c3033 100644 --- a/wagtail/wagtailimages/backends/__init__.py +++ b/wagtail/wagtailimages/backends/__init__.py @@ -38,7 +38,7 @@ def get_image_backend(backend='default', **kwargs): # Get configuration default_conf = { 'default': { - 'BACKEND': 'wagtail.wagtailimages.backends.wand_backend.WandBackend', + 'BACKEND': 'wagtail.wagtailimages.backends.pillow_backend.PillowBackend', }, } WAGTAILIMAGES_BACKENDS = getattr( From 2dd75ec6015ecd11e25872e1a32b18fa6d868c3a Mon Sep 17 00:00:00 2001 From: Serafeim Papastefanos Date: Wed, 12 Mar 2014 10:16:05 +0200 Subject: [PATCH 08/12] Fix minor error with runtests.py --- runtests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtests.py b/runtests.py index 092c4bedf..68cd74920 100755 --- a/runtests.py +++ b/runtests.py @@ -97,7 +97,7 @@ if not settings.configured: 'django.contrib.auth.hashers.MD5PasswordHasher', # don't use the intentionally slow default password hasher ), WAGTAILSEARCH_BACKENDS=WAGTAILSEARCH_BACKENDS, - WAGTAILIMAGE_BACKENDS=WAGTAILIMAGE_BACKENDS, + WAGTAILIMAGES_BACKENDS=WAGTAILIMAGES_BACKENDS, WAGTAIL_SITE_NAME='Test Site' ) From 7911d460549772d09d67ee917e1af8fe2f89185f Mon Sep 17 00:00:00 2001 From: Serafeim Papastefanos Date: Wed, 12 Mar 2014 12:22:12 +0200 Subject: [PATCH 09/12] Remove not needed image_ops.py --- wagtail/wagtailimages/image_ops.py | 124 ----------------------------- 1 file changed, 124 deletions(-) delete mode 100644 wagtail/wagtailimages/image_ops.py diff --git a/wagtail/wagtailimages/image_ops.py b/wagtail/wagtailimages/image_ops.py deleted file mode 100644 index 83d424c81..000000000 --- a/wagtail/wagtailimages/image_ops.py +++ /dev/null @@ -1,124 +0,0 @@ -from PIL import Image - - -def resize(image, size): - """ - resize image to the requested size, using highest quality settings - (antialiasing enabled, converting to true colour if required) - """ - if image.mode in ['1', 'P']: - image = image.convert('RGB') - - return image.resize(size, Image.ANTIALIAS) - - -def crop_to_centre(image, size): - (original_width, original_height) = image.size - (target_width, target_height) = size - - # final dimensions should not exceed original dimensions - final_width = min(original_width, target_width) - final_height = min(original_height, target_height) - - if final_width == original_width and final_height == original_height: - return image - - left = (original_width - final_width) / 2 - top = (original_height - final_height) / 2 - return image.crop( - (left, top, left + final_width, top + final_height) - ) - - -def resize_to_max(image, size): - """ - Resize image down to fit within the given dimensions, preserving aspect ratio. - Will leave image unchanged if it's already within those dimensions. - """ - (original_width, original_height) = image.size - (target_width, target_height) = size - - if original_width <= target_width and original_height <= target_height: - return image - - # scale factor if we were to downsize the image to fit the target width - horz_scale = float(target_width) / original_width - # scale factor if we were to downsize the image to fit the target height - vert_scale = float(target_height) / original_height - - # choose whichever of these gives a smaller image - if horz_scale < vert_scale: - final_size = (target_width, int(original_height * horz_scale)) - else: - final_size = (int(original_width * vert_scale), target_height) - - return resize(image, final_size) - - -def resize_to_min(image, size): - """ - Resize image down to cover the given dimensions, preserving aspect ratio. - Will leave image unchanged if width or height is already within those limits. - """ - (original_width, original_height) = image.size - (target_width, target_height) = size - - if original_width <= target_width or original_height <= target_height: - return image - - # scale factor if we were to downsize the image to fit the target width - horz_scale = float(target_width) / original_width - # scale factor if we were to downsize the image to fit the target height - vert_scale = float(target_height) / original_height - - # choose whichever of these gives a larger image - if horz_scale > vert_scale: - final_size = (target_width, int(original_height * horz_scale)) - else: - final_size = (int(original_width * vert_scale), target_height) - - return resize(image, final_size) - - -def resize_to_width(image, target_width): - """ - Resize image down to the given width, preserving aspect ratio. - Will leave image unchanged if it's already within that width. - """ - (original_width, original_height) = image.size - - if original_width <= target_width: - return image - - scale = float(target_width) / original_width - - final_size = (target_width, int(original_height * scale)) - - return resize(image, final_size) - - -def resize_to_height(image, target_height): - """ - Resize image down to the given height, preserving aspect ratio. - Will leave image unchanged if it's already within that height. - """ - (original_width, original_height) = image.size - - if original_height <= target_height: - return image - - scale = float(target_height) / original_height - - final_size = (int(original_width * scale), target_height) - - return resize(image, final_size) - - -def resize_to_fill(image, size): - """ - Resize down and crop image to fill the given dimensions. Most suitable for thumbnails. - (The final image will match the requested size, unless one or the other dimension is - already smaller than the target size) - """ - resized_image = resize_to_min(image, size) - return crop_to_centre(resized_image, size) From 9eba6d9a3a234d24b68df794a7a6c04b82123c53 Mon Sep 17 00:00:00 2001 From: Serafeim Papastefanos Date: Wed, 12 Mar 2014 12:39:44 +0200 Subject: [PATCH 10/12] Split tests to use Pillow and Wand backend --- wagtail/wagtailimages/tests.py | 78 ++++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 31 deletions(-) diff --git a/wagtail/wagtailimages/tests.py b/wagtail/wagtailimages/tests.py index 69f5888bb..11c3ad4a6 100644 --- a/wagtail/wagtailimages/tests.py +++ b/wagtail/wagtailimages/tests.py @@ -8,6 +8,8 @@ from wagtail.tests.utils import login from wagtail.wagtailimages.models import get_image_model from wagtail.wagtailimages.templatetags import image_tags +from wagtail.wagtailimages.backends import get_image_backend +from wagtail.wagtailimages.backends.pillow_backend import PillowBackend def get_test_image_file(): from StringIO import StringIO @@ -78,57 +80,32 @@ class TestRenditions(TestCase): file=get_test_image_file(), ) - self.image2 = Image.objects.create( - title="Test image", - file=get_test_image_file(), - ) - self.image.backend = 'pillow' - - self.image3 = Image.objects.create( - title="Test image", - file=get_test_image_file(), - ) - self.image.backend = 'wand' - + def test_default_backend(self): + # default backend should be pillow + backend = get_image_backend() + self.assertTrue(isinstance(backend, PillowBackend)) + def test_minification(self): rendition = self.image.get_rendition('width-400') - rendition2 = self.image2.get_rendition('width-400') - rendition3 = self.image3.get_rendition('width-400') - + # Check size self.assertEqual(rendition.width, 400) self.assertEqual(rendition.height, 300) - self.assertEqual(rendition2.width, 400) - self.assertEqual(rendition2.height, 300) - self.assertEqual(rendition3.width, 400) - self.assertEqual(rendition3.height, 300) def test_resize_to_max(self): rendition = self.image.get_rendition('max-100x100') - rendition2 = self.image2.get_rendition('max-100x100') - rendition3 = self.image3.get_rendition('max-100x100') # Check size self.assertEqual(rendition.width, 100) self.assertEqual(rendition.height, 75) - self.assertEqual(rendition2.width, 100) - self.assertEqual(rendition2.height, 75) - self.assertEqual(rendition3.width, 100) - self.assertEqual(rendition3.height, 75) def test_resize_to_min(self): rendition = self.image.get_rendition('min-120x120') - rendition2 = self.image2.get_rendition('min-120x120') - rendition3 = self.image3.get_rendition('min-120x120') # Check size self.assertEqual(rendition.width, 160) self.assertEqual(rendition.height, 120) - self.assertEqual(rendition2.width, 160) - self.assertEqual(rendition2.height, 120) - self.assertEqual(rendition3.width, 160) - self.assertEqual(rendition3.height, 120) def test_cache(self): # Get two renditions with the same filter @@ -137,7 +114,46 @@ class TestRenditions(TestCase): # Check that they are the same object self.assertEqual(first_rendition, second_rendition) + +class TestRenditionsWand(TestCase): + def setUp(self): + # Create an image for running tests on + self.image = Image.objects.create( + title="Test image", + file=get_test_image_file(), + ) + self.image.backend = 'wagtail.wagtailimages.backends.wand_backend.WandBackend' + + def test_minification(self): + rendition = self.image.get_rendition('width-400') + + # Check size + self.assertEqual(rendition.width, 400) + self.assertEqual(rendition.height, 300) + + def test_resize_to_max(self): + rendition = self.image.get_rendition('max-100x100') + + # Check size + self.assertEqual(rendition.width, 100) + self.assertEqual(rendition.height, 75) + + def test_resize_to_min(self): + rendition = self.image.get_rendition('min-120x120') + + # Check size + self.assertEqual(rendition.width, 160) + self.assertEqual(rendition.height, 120) + + def test_cache(self): + # Get two renditions with the same filter + first_rendition = self.image.get_rendition('width-400') + second_rendition = self.image.get_rendition('width-400') + + # Check that they are the same object + self.assertEqual(first_rendition, second_rendition) + class TestImageTag(TestCase): def setUp(self): From a72a67d50c6b843bb776fa7a084ff9c9b6cdeb73 Mon Sep 17 00:00:00 2001 From: Serafeim Papastefanos Date: Wed, 12 Mar 2014 14:11:13 +0200 Subject: [PATCH 11/12] Remove image backends config from runtests.py It is not needed since Pillow is the default and get_backend works with full package name. --- runtests.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/runtests.py b/runtests.py index 68cd74920..4dd047670 100755 --- a/runtests.py +++ b/runtests.py @@ -18,17 +18,6 @@ if not settings.configured: except ImportError: has_elasticsearch = False - WAGTAILIMAGES_BACKENDS = { - 'default': { - 'BACKEND': 'wagtail.wagtailimages.backends.pillow_backend.PillowBackend', - }, - 'pillow': { - 'BACKEND': 'wagtail.wagtailimages.backends.pillow_backend.PillowBackend', - }, - 'wand': { - 'BACKEND': 'wagtail.wagtailimages.backends.wand_backend.WandBackend', - }, - } WAGTAILSEARCH_BACKENDS = { 'default': { 'BACKEND': 'wagtail.wagtailsearch.backends.db.DBSearch', @@ -97,7 +86,6 @@ if not settings.configured: 'django.contrib.auth.hashers.MD5PasswordHasher', # don't use the intentionally slow default password hasher ), WAGTAILSEARCH_BACKENDS=WAGTAILSEARCH_BACKENDS, - WAGTAILIMAGES_BACKENDS=WAGTAILIMAGES_BACKENDS, WAGTAIL_SITE_NAME='Test Site' ) From 4cc53eb4c9ad0920be0b6ddbcee4dc04b6dee6df Mon Sep 17 00:00:00 2001 From: Serafeim Papastefanos Date: Wed, 12 Mar 2014 14:41:26 +0200 Subject: [PATCH 12/12] Refactor backend names to pillow and wand This uses absolute_import and refactors the backend names to pillow & wand. It changes tests to use these names and also modified the test runner to not pass any parameters concerning the image backends since the defaults are enough for tests. It also fixes a minor bug that was occured when the image file had already been read (multiple renditions in a single page) --- wagtail/wagtailimages/backends/__init__.py | 2 +- .../wagtailimages/backends/{pillow_backend.py => pillow.py} | 0 wagtail/wagtailimages/backends/{wand_backend.py => wand.py} | 4 +++- wagtail/wagtailimages/models.py | 3 +-- wagtail/wagtailimages/tests.py | 4 ++-- 5 files changed, 7 insertions(+), 6 deletions(-) rename wagtail/wagtailimages/backends/{pillow_backend.py => pillow.py} (100%) rename wagtail/wagtailimages/backends/{wand_backend.py => wand.py} (94%) diff --git a/wagtail/wagtailimages/backends/__init__.py b/wagtail/wagtailimages/backends/__init__.py index e028c3033..9d6b9590c 100644 --- a/wagtail/wagtailimages/backends/__init__.py +++ b/wagtail/wagtailimages/backends/__init__.py @@ -38,7 +38,7 @@ def get_image_backend(backend='default', **kwargs): # Get configuration default_conf = { 'default': { - 'BACKEND': 'wagtail.wagtailimages.backends.pillow_backend.PillowBackend', + 'BACKEND': 'wagtail.wagtailimages.backends.pillow.PillowBackend', }, } WAGTAILIMAGES_BACKENDS = getattr( diff --git a/wagtail/wagtailimages/backends/pillow_backend.py b/wagtail/wagtailimages/backends/pillow.py similarity index 100% rename from wagtail/wagtailimages/backends/pillow_backend.py rename to wagtail/wagtailimages/backends/pillow.py diff --git a/wagtail/wagtailimages/backends/wand_backend.py b/wagtail/wagtailimages/backends/wand.py similarity index 94% rename from wagtail/wagtailimages/backends/wand_backend.py rename to wagtail/wagtailimages/backends/wand.py index 013f5d98c..4631ecbdc 100644 --- a/wagtail/wagtailimages/backends/wand_backend.py +++ b/wagtail/wagtailimages/backends/wand.py @@ -1,6 +1,8 @@ +from __future__ import absolute_import + from django.db import models from django.conf import settings -from base import BaseImageBackend +from .base import BaseImageBackend from wand.image import Image diff --git a/wagtail/wagtailimages/models.py b/wagtail/wagtailimages/models.py index f62f62cd9..f37f8f858 100644 --- a/wagtail/wagtailimages/models.py +++ b/wagtail/wagtailimages/models.py @@ -190,8 +190,7 @@ class Filter(models.Model): self._parse_spec_string() # If file is closed, open it - if input_file.closed: - input_file.open() + input_file.open('rb') image = backend.open_image(input_file) file_format = image.format diff --git a/wagtail/wagtailimages/tests.py b/wagtail/wagtailimages/tests.py index 11c3ad4a6..9c3bf0f28 100644 --- a/wagtail/wagtailimages/tests.py +++ b/wagtail/wagtailimages/tests.py @@ -9,7 +9,7 @@ from wagtail.wagtailimages.models import get_image_model from wagtail.wagtailimages.templatetags import image_tags from wagtail.wagtailimages.backends import get_image_backend -from wagtail.wagtailimages.backends.pillow_backend import PillowBackend +from wagtail.wagtailimages.backends.pillow import PillowBackend def get_test_image_file(): from StringIO import StringIO @@ -123,7 +123,7 @@ class TestRenditionsWand(TestCase): title="Test image", file=get_test_image_file(), ) - self.image.backend = 'wagtail.wagtailimages.backends.wand_backend.WandBackend' + self.image.backend = 'wagtail.wagtailimages.backends.wand.WandBackend' def test_minification(self): rendition = self.image.get_rendition('width-400')