diff --git a/setup.py b/setup.py index 67a26c523..eee178ed2 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/backends/__init__.py b/wagtail/wagtailimages/backends/__init__.py new file mode 100644 index 000000000..9d6b9590c --- /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.pillow.PillowBackend', + }, + } + 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..77f609350 --- /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 = self.resize_to_min(image, size) + return self.crop_to_centre(resized_image, size) diff --git a/wagtail/wagtailimages/backends/pillow.py b/wagtail/wagtailimages/backends/pillow.py new file mode 100644 index 000000000..bebc76144 --- /dev/null +++ b/wagtail/wagtailimages/backends/pillow.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.py b/wagtail/wagtailimages/backends/wand.py new file mode 100644 index 000000000..4631ecbdc --- /dev/null +++ b/wagtail/wagtailimages/backends/wand.py @@ -0,0 +1,42 @@ +from __future__ import absolute_import + +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 + image.crop( + left=left, top=top, right=left + final_width, bottom=top + final_height + ) + return image 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) diff --git a/wagtail/wagtailimages/models.py b/wagtail/wagtailimages/models.py index d30f969ad..f37f8f858 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 @@ -14,9 +13,10 @@ from django.utils.html import escape from django.conf import settings from django.utils.translation import ugettext_lazy as _ -from wagtail.wagtailadmin.taggable import TagSearchable -from wagtail.wagtailimages import image_ops +from unidecode import unidecode +from wagtail.wagtailadmin.taggable import TagSearchable +from wagtail.wagtailimages.backends import get_image_backend class AbstractImage(models.Model, TagSearchable): title = models.CharField(max_length=255, verbose_name=_('Title') ) @@ -25,8 +25,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('.') @@ -68,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}) @@ -143,11 +148,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): @@ -156,12 +161,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) @@ -172,24 +177,30 @@ 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 """ + + backend = get_image_backend(backend_name) + if not self.method: self._parse_spec_string() - - input_file.open() - image = PIL.Image.open(input_file) + + # If file is closed, open it + input_file.open('rb') + 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('.') @@ -199,7 +210,7 @@ class Filter(models.Model): output_filename = '.'.join(output_filename_parts) output_file = File(output, name=output_filename) - input_file.close() + return output_file diff --git a/wagtail/wagtailimages/tests.py b/wagtail/wagtailimages/tests.py index c89961773..9c3bf0f28 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 import PillowBackend def get_test_image_file(): from StringIO import StringIO @@ -78,9 +80,14 @@ class TestRenditions(TestCase): file=get_test_image_file(), ) + 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') - + # Check size self.assertEqual(rendition.width, 400) self.assertEqual(rendition.height, 300) @@ -92,6 +99,7 @@ class TestRenditions(TestCase): self.assertEqual(rendition.width, 100) self.assertEqual(rendition.height, 75) + def test_resize_to_min(self): rendition = self.image.get_rendition('min-120x120') @@ -106,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.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):