Merge branch 'img-proc-backends' of https://github.com/spapas/wagtail into spapas-img-proc-backends

pull/152/merge
Matt Westcott 2014-03-26 13:19:59 +00:00
commit 1a28b572c2
8 zmienionych plików z 370 dodań i 147 usunięć

Wyświetl plik

@ -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,

Wyświetl plik

@ -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)

Wyświetl plik

@ -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)

Wyświetl plik

@ -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)
)

Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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):