From 992dd85c7809c4668e2d61a79b92c44f9601e9ab Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Thu, 8 Oct 2009 16:44:53 +1300 Subject: [PATCH] Initial alpha version --- LICENSE | 25 ++ README | 77 +++++ easy_thumbnails/__init__.py | 1 + easy_thumbnails/defaults.py | 19 ++ easy_thumbnails/engine.py | 83 +++++ easy_thumbnails/fields.py | 66 ++++ easy_thumbnails/files.py | 376 +++++++++++++++++++++ easy_thumbnails/models.py | 1 + easy_thumbnails/processors.py | 101 ++++++ easy_thumbnails/storage.py | 18 + easy_thumbnails/templatetags/__init__.py | 0 easy_thumbnails/templatetags/thumbnails.py | 159 +++++++++ easy_thumbnails/utils.py | 73 ++++ setup.py | 39 +++ 14 files changed, 1038 insertions(+) create mode 100644 LICENSE create mode 100644 README create mode 100644 easy_thumbnails/__init__.py create mode 100644 easy_thumbnails/defaults.py create mode 100644 easy_thumbnails/engine.py create mode 100644 easy_thumbnails/fields.py create mode 100644 easy_thumbnails/files.py create mode 100644 easy_thumbnails/models.py create mode 100644 easy_thumbnails/processors.py create mode 100644 easy_thumbnails/storage.py create mode 100644 easy_thumbnails/templatetags/__init__.py create mode 100755 easy_thumbnails/templatetags/thumbnails.py create mode 100644 easy_thumbnails/utils.py create mode 100644 setup.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..75fe9f6e --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2009, Chris Beaven +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name easy-thumbnails nor the names of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README b/README new file mode 100644 index 00000000..47d5cf23 --- /dev/null +++ b/README @@ -0,0 +1,77 @@ +=============== +Easy Thumbnails +=============== + +The powerful, yet easy to implement thumbnailing application for Django. + +To install this application into your project, just add it to your +``INSTALLED_APPS`` setting:: + + INSTALLED_APPS = ( + ... + 'easy_thumbnails', + ) + + +Template usage +============== + +To generate thumbnails in your template, use the ``{% thumbnail %}`` tag. To +make this tag available for use in your template, use:: + + {% load thumbnails %} + +Basic tag Syntax:: + + {% thumbnail [source] [size] [options] %} + +*source* must be a ``File`` object, usually an Image/FileField of a model +instance. + +*size* can either be: + +* the size in the format ``[width]x[height]`` (for example, + ``{% thumbnail person.photo 100x50 %}``) or + +* a variable containing a valid size (i.e. either a string in the + ``[width]x[height]`` format or a tuple containing two integers): + ``{% thumbnail person.photo size_var %}``. + +*options* are a space separated list of options which are used when processing +the image to a thumbnail such as ``sharpen``, ``crop`` and ``quality=90``. + + +Model usage +=========== + +You can use the ``ThumbnailerField`` or ``ThumbnailerImageField`` fields (based +on ``FileField`` and ``ImageField``, respectively) for easier access to +retrieve (or generate) thumbnail images. + +Lower level usage +================= + +Thumbnails are generated with a ``Thumbnailer`` instance. For example:: + + from easy_thumbnails import Thumbnailer + + def square_thumbnail(source): + thumbnail_options = dict(size=(100, 100), crop=True, bw=True) + return Thumbnailer(source).get_thumbnail(thumbnail_options) + +By default, ``get_thumbnail`` saves the file (using file storage). The source +file used to instanciate the ``Thumbnailer`` must have a ``name`` instance +relative to the storage root. + +The ``ThumbnailFile`` object provided makes this easy:: + + from easy_thumbnails import ThumbnailFile + + # For an existing file in storage: + source = ThumbnailFile('animals/aarvark.jpg') + square_thumbnail(source) + + # For a new file: + picture = open('/home/zookeeper/pictures/my_anteater.jpg') + source = ThumbnailFile('animals/anteater.jpg', file=picture) + square_thumbnail(source) diff --git a/easy_thumbnails/__init__.py b/easy_thumbnails/__init__.py new file mode 100644 index 00000000..7718d5e5 --- /dev/null +++ b/easy_thumbnails/__init__.py @@ -0,0 +1 @@ +from files import Thumbnailer, ThumbnailFile diff --git a/easy_thumbnails/defaults.py b/easy_thumbnails/defaults.py new file mode 100644 index 00000000..2db24211 --- /dev/null +++ b/easy_thumbnails/defaults.py @@ -0,0 +1,19 @@ +DEBUG = False + +DEFAULT_STORAGE = 'easy_thumbnails.storage.ThumbnailFileSystemStorage' +MEDIA_ROOT = '' +MEDIA_URL = '' + +BASEDIR = '' +SUBDIR = '' +PREFIX = '' + +QUALITY = 85 +EXTENSION = 'jpg' +PROCESSORS = ( + 'easy_thumbnails.processors.colorspace', + 'easy_thumbnails.processors.autocrop', + 'easy_thumbnails.processors.scale_and_crop', + 'easy_thumbnails.processors.filters', +) +IMAGEMAGICK_FILE_TYPES = ('eps', 'pdf', 'psd') diff --git a/easy_thumbnails/engine.py b/easy_thumbnails/engine.py new file mode 100644 index 00000000..7cae9390 --- /dev/null +++ b/easy_thumbnails/engine.py @@ -0,0 +1,83 @@ +from easy_thumbnails import defaults, utils +from django.core.files.base import ContentFile +import os +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO + + +DEFAULT_PROCESSORS = [utils.dynamic_import(p) + for p in utils.get_setting('PROCESSORS')] + + +def process_image(source, processor_options, processors=None): + """ + Process a source PIL image through a series of image processors, returning + the (potentially) altered image. + + """ + if processors is None: + processors = DEFAULT_PROCESSORS + image = source + for processor in processors: + image = processor(image, **processor_options) + return image + + +def save_image(image, destination=None, format='JPEG', quality=85): + """ + Save a PIL image. + + """ + if destination is None: + destination = StringIO() + try: + image.save(destination, format=format, quality=quality, optimize=1) + except IOError: + # Try again, without optimization (PIL can't optimize an image + # larger than ImageFile.MAXBLOCK, which is 64k by default) + image.save(destination, format=format, quality=quality) + if hasattr(destination, 'seek'): + destination.seek(0) + return destination + + +def get_filetype(filename, is_path=False): + """ + Return the standardized extension based on the ``filename``. + + If ``is_path`` is True, try using imagemagick to determine the file type + rather than just relying on the filename extension. + + """ + if is_path: + filetype = get_filetype_magic(filename) + if not is_path or not filetype: + filetype = os.path.splitext(filename)[1].lower()[:1] + if filetype == 'jpeg': + filetype = 'jpg' + return filetype + + +def get_filetype_magic(path): + """ + Return a standardized extention by using imagemagick (or ``None`` if + imagemagick can not be imported). + + """ + try: + import magic + except ImportError: + return None + + m = magic.open(magic.MAGIC_NONE) + m.load() + filetype = m.file(path) + if filetype.find('Microsoft Office Document') != -1: + return 'doc' + elif filetype.find('PDF document') != -1: + return 'pdf' + elif filetype.find('JPEG') != -1: + return 'jpg' + return filetype diff --git a/easy_thumbnails/fields.py b/easy_thumbnails/fields.py new file mode 100644 index 00000000..d49af509 --- /dev/null +++ b/easy_thumbnails/fields.py @@ -0,0 +1,66 @@ +from django.db.models.fields.files import FileField, ImageField +from easy_thumbnails import files + + +class ThumbnailerField(FileField): + """ + A file field which provides easier access for retrieving (and generating) + thumbnails. + + To use a different file storage for thumbnails, provide the + ``thumbnail_storage`` keyword argument. + + """ + attr_class = files.ThumbnailerFieldFile + + def __init__(self, *args, **kwargs): + # Arguments not explicitly defined so that the normal ImageField + # positional arguments can be used. + self.thumbnail_storage = kwargs.pop('thumbnail_storage', None) + + super(ThumbnailerField, self).__init__(*args, **kwargs) + + def south_field_triple(self): + """ + Return a suitable description of this field for South. + + """ + from south.modelsinspector import introspector + field_class = 'django.db.models.fields.files.FileField' + args, kwargs = introspector(FileField) + return (field_class, args, kwargs) + + +class ThumbnailerImageField(ThumbnailerField, ImageField): + """ + An image field which provides easier access for retrieving (and generating) + thumbnails. + + To use a different file storage for thumbnails, provide the + ``thumbnail_storage`` keyword argument. + + To thumbnail the original source image before saving, provide the + ``resize_source`` keyword argument, passing it a usual thumbnail option + dictionary. For example:: + + ThumbnailField(..., resize_source=dict(size=(100, 100), sharpen=True)) + + """ + attr_class = files.ThumbnailerImageFieldFile + + def __init__(self, *args, **kwargs): + # Arguments not explicitly defined so that the normal ImageField + # positional arguments can be used. + self.resize_source = kwargs.pop('resize_source', None) + + super(ThumbnailerImageField, self).__init__(*args, **kwargs) + + def south_field_triple(self): + """ + Return a suitable description of this field for South. + + """ + from south.modelsinspector import introspector + field_class = 'django.db.models.fields.files.ImageField' + args, kwargs = introspector(ImageField) + return (field_class, args, kwargs) diff --git a/easy_thumbnails/files.py b/easy_thumbnails/files.py new file mode 100644 index 00000000..47da323c --- /dev/null +++ b/easy_thumbnails/files.py @@ -0,0 +1,376 @@ +from PIL import Image +from django.core.files.base import File, ContentFile +from django.core.files.storage import get_storage_class, default_storage +from django.db.models.fields.files import ImageFieldFile, FieldFile +from django.utils.html import escape +from django.utils.safestring import mark_safe +from easy_thumbnails import engine, utils +import os + + +DEFAULT_THUMBNAIL_STORAGE = get_storage_class( + utils.get_setting('DEFAULT_STORAGE'))() +print DEFAULT_THUMBNAIL_STORAGE.base_url + + +def get_thumbnailer(source, relative_name=None): + """ + Get a thumbnailer for a source file. + + """ + if isinstance(source, Thumbnailer): + return source + elif isinstance(source, FieldFile): + if not relative_name: + relative_name = source.name + return ThumbnailerFieldFile(source.instance, source.field, + relative_name) + elif isinstance(source, File): + return Thumbnailer(source.file, relative_name) + raise TypeError('The source object must either be a Thumbnailer, a ' + 'FieldFile or a File with the relative_name argument ' + 'provided.') + + +def save_thumbnail(thumbnail_file, storage): + """ + Save a thumbnailed file. + + """ + filename = thumbnail_file.name + if storage.exists(filename): + try: + storage.delete(filename) + except: + pass + return storage.save(filename, thumbnail_file) + + +class FakeField(object): + name = 'fake' + + def __init__(self, storage=None): + self.storage = storage or default_storage + + def generate_filename(self, instance, name, *args, **kwargs): + return name + + +class FakeInstance(object): + def save(self, *args, **kwargs): + pass + + +class ThumbnailFile(ImageFieldFile): + """ + A thumbnailed file. + + """ + def __init__(self, name, file=None, storage=None, *args, **kwargs): + fake_field = FakeField(storage=storage) + super(ThumbnailFile, self).__init__(FakeInstance(), fake_field, name, + *args, **kwargs) + if file: + self.file = file + + def _get_image(self): + """ + Get a PIL image instance of this file. + + The image is cached to avoid the file needing to be read again if the + function is called again. + + """ + if not hasattr(self, '_image_cache'): + self.image = Image.open(self) + return self._image_cache + + def _set_image(self, image): + """ + Set the image for this file. + + This also caches the dimensions of the image. + + """ + if image: + self._image_cache = image + self._dimensions_cache = image.size + else: + if hasattr(self, '_image_cache'): + del self._cached_image + if hasattr(self, '_dimensions_cache'): + del self._dimensions_cache + + image = property(_get_image, _set_image) + + def tag(self, alt='', use_size=True, **attrs): + """ + Return a standard XHTML ```` tag for this field. + + """ + attrs['alt'] = escape(alt) + attrs['src'] = escape(self.url) + if use_size: + attrs.update(dict(width=self.width, height=self.height)) + attrs = ' '.join(['%s="%s"' % (key, escape(value)) + for key, value in attrs.items()]) + return mark_safe('' % attrs) + + tag = property(tag) + + def _get_file(self): + self._require_file() + if not hasattr(self, '_file') or self._file is None: + self._file = self.storage.open(self.name, 'rb') + return self._file + + def _set_file(self, file): + self._file = file + + def _del_file(self): + del self._file + + file = property(_get_file, _set_file, _del_file) + + +class Thumbnailer(File): + """ + A file-like object which provides some methods to generate thumbnail + images. + + """ + thumbnail_basedir = utils.get_setting('BASEDIR') + thumbnail_subdir = utils.get_setting('SUBDIR') + thumbnail_prefix = utils.get_setting('PREFIX') + thumbnail_quality = utils.get_setting('QUALITY') + thumbnail_extension = utils.get_setting('EXTENSION') + + def __init__(self, file, name=None, source_storage=None, + thumbnail_storage=None, *args, **kwargs): + super(Thumbnailer, self).__init__(file, name, *args, **kwargs) + self.source_storage = source_storage or default_storage + self.thumbnail_storage = (thumbnail_storage or + DEFAULT_THUMBNAIL_STORAGE) + + def generate_thumbnail(self, thumbnail_options): + """ + Return a ``ThumbnailFile`` containing a thumbnail image. + + The thumbnail image is generated using the ``thumbnail_options`` + dictionary. + + """ + thumbnail_image = engine.process_image(self.image, thumbnail_options) + quality = thumbnail_options.get('quality', self.thumbnail_quality) + data = engine.save_image(thumbnail_image, quality=quality).read() + + filename = self.get_thumbnail_name(thumbnail_options) + thumbnail = ThumbnailFile(filename, ContentFile(data)) + thumbnail.image = thumbnail_image + thumbnail._committed = False + + return thumbnail + + def get_thumbnail_name(self, thumbnail_options): + """ + Return a thumbnail filename for the given ``thumbnail_options`` + dictionary and ``source_name`` (which defaults to the File's ``name`` + if not provided). + + """ + path, source_filename = os.path.split(self.name) + source_extension = os.path.splitext(source_filename)[1][1:] + filename = '%s%s' % (self.thumbnail_prefix, source_filename) + extension = (self.thumbnail_extension or source_extension.lower() + or 'jpg') + + thumbnail_options = thumbnail_options.copy() + size = tuple(thumbnail_options.pop('size')) + quality = thumbnail_options.pop('quality', self.thumbnail_quality) + initial_opts = ['%sx%s' % size, 'q%s' % quality] + + opts = thumbnail_options.items() + opts.sort() # Sort the options so the file name is consistent. + opts = ['%s' % (v is not True and '%s-%s' % (k, v) or k) + for k, v in opts if v] + + all_opts = '_'.join(initial_opts + opts) + + data = {'opts': all_opts} + basedir = self.thumbnail_basedir % data + subdir = self.thumbnail_subdir % data + + filename_parts = [filename] + if ('%(opts)s' in self.thumbnail_basedir or + '%(opts)s' in self.thumbnail_subdir): + if extension != source_extension: + filename_parts.append(extension) + else: + filename_parts += [all_opts, extension] + filename = '.'.join(filename_parts) + + return os.path.join(basedir, path, subdir, filename) + + def get_thumbnail(self, thumbnail_options, save=True): + """ + Return a ``ThumbnailFile`` containing a thumbnail. + + It the file already exists, it will simply be returned. + + Otherwise a new thumbnail image is generated using the + ``thumbnail_options`` dictionary. If the ``save`` argument is ``True`` + (default), the generated thumbnail will be saved too. + + """ + name = self.get_thumbnail_name(thumbnail_options) + + if self.thumbnail_exists(thumbnail_options): + thumbnail = ThumbnailFile(name=name, + storage=self.thumbnail_storage) + return thumbnail + + thumbnail = self.generate_thumbnail(thumbnail_options) + + if save and self.name: + save_thumbnail(thumbnail, self.thumbnail_storage) + if self.source_storage != self.thumbnail_storage: + # If the source storage is local and the thumbnail storage is + # remote, save a copy of the thumbnail there too. This helps to + # keep the testing of thumbnail existence as a local activity. + try: + self.thumbnail_storage.path(name) + except NotImplementedError: + try: + self.field_storage.path(name) + except NotImplementedError: + pass + else: + self.save_thumbnail(thumbnail, self.field_storage) + return thumbnail + + def thumbnail_exists(self, thumbnail_options): + """ + Calculate whether the thumbnail already exists and that the source is + not newer than the thumbnail. + + If neither the source nor the thumbnail are using local storages, only + the existance of the thumbnail will be checked. + + """ + filename = self.get_thumbnail_name(thumbnail_options) + + try: + source_path = self.source_storage.path(self.name) + except NotImplementedError: + source_path = None + try: + thumbnail_path = self.thumbnail_storage.path(filename) + except NotImplementedError: + thumbnail_path = None + + if not source_path and not thumbnail_path: + # This is the worst-case scenario - neither storage was local so + # this will cause a remote existence check. + return self.thumbnail_storage.exists(filename) + + # If either storage wasn't local, use the other for the path. + if not source_path: + source_path = self.thumbnail_storage.path(self.name) + if not thumbnail_path: + thumbnail_path = self.source_storage.path(filename) + + if os.path.isfile(thumbnail_path): + if not os.path.isfile(source_path): + return True + else: + return False + return (os.path.getmtime(source_path) <= + os.path.getmtime(thumbnail_path)) + + def _image(self): + if not hasattr(self, '_cached_image'): + # TODO: Use different methods of generating the file, rather than + # just relying on PIL. + self._cached_image = Image.open(self) + # Image.open() is a lazy operation, so force the load so we + # can close this file again if appropriate. + self._cached_image.load() + return self._cached_image + + image = property(_image) + + +class ThumbnailerFieldFile(FieldFile, Thumbnailer): + """ + A field file which provides some methods for generating (and returning) + thumbnail images. + + """ + def __init__(self, *args, **kwargs): + super(ThumbnailerFieldFile, self).__init__(*args, **kwargs) + self.source_storage = self.field.storage + thumbnail_storage = getattr(self.field, 'thumbnail_storage', None) + if thumbnail_storage: + self.thumbnail_storage = thumbnail_storage + + def save(self, name, content, *args, **kwargs): + """ + Save the file. + + If the thumbnail storage is local and differs from the field storage, + save a place-holder of the source file there too. This helps to keep + the testing of thumbnail existence as a local activity. + + """ + super(ThumbnailerFieldFile, self).save(name, content, *args, **kwargs) + # If the thumbnail storage differs and is local, save a place-holder of + # the source file there too. + if self.thumbnail_storage != self.field.storage: + try: + path = self.thumbnail_storage.path(self.name) + except NotImplementedError: + pass + else: + if not os.path.exists(path): + try: + os.makedirs(os.path.dirname(path)) + except OSError: + pass + open(path, 'w').close() + +# TODO: deletion should use the storage for listing and deleting. +# def delete(self, *args, **kwargs): +# """ +# Delete the image, along with any thumbnails which match the filename +# pattern for this source image. +# +# """ +# super(ThumbnailFieldFile, self).delete(*args, **kwargs) + + +class ThumbnailerImageFieldFile(ImageFieldFile, ThumbnailerFieldFile): + """ + A field file which provides some methods for generating (and returning) + thumbnail images. + + """ + def save(self, name, content, *args, **kwargs): + """ + Save the image. + + If the thumbnail storage is local and differs from the field storage, + save a place-holder of the source image there too. This helps to keep + the testing of thumbnail existence as a local activity. + + The image will be resized down using a ``ThumbnailField`` if + ``resize_source`` (a dictionary of thumbnail options) is provided by + the field. + + """ + options = getattr(self.field, 'resize_source', None) + if options: + if not 'quality' in options: + options['quality'] = self.thumbnail_quality + content = Thumbnailer(content).generate_thumbnail(options) + super(ThumbnailerImageFieldFile, self).save(name, content, *args, + **kwargs) diff --git a/easy_thumbnails/models.py b/easy_thumbnails/models.py new file mode 100644 index 00000000..ec325fd5 --- /dev/null +++ b/easy_thumbnails/models.py @@ -0,0 +1 @@ +# Needs a models.py file so that tests are picked up. diff --git a/easy_thumbnails/processors.py b/easy_thumbnails/processors.py new file mode 100644 index 00000000..cbb7495e --- /dev/null +++ b/easy_thumbnails/processors.py @@ -0,0 +1,101 @@ +from PIL import Image, ImageFilter, ImageChops +from easy_thumbnails import utils +import re + + +def colorspace(im, bw=False, **kwargs): + if bw and im.mode != 'L': + im = im.convert('L') + elif im.mode not in ('L', 'RGB', 'RGBA'): + im = im.convert('RGB') + return im + + +def autocrop(im, autocrop=False, **kwargs): + if autocrop: + bw = im.convert('1') + bw = bw.filter(ImageFilter.MedianFilter) + # White background. + bg = Image.new('1', im.size, 255) + diff = ImageChops.difference(bw, bg) + bbox = diff.getbbox() + if bbox: + im = im.crop(bbox) + return im + + +def scale_and_crop(im, size, crop=False, upscale=False, **kwargs): + x, y = [float(v) for v in im.size] + xr, yr = [float(v) for v in size] + + if crop: + r = max(xr / x, yr / y) + else: + r = min(xr / x, yr / y) + + if r < 1.0 or (r > 1.0 and upscale): + im = im.resize((int(x * r), int(y * r)), resample=Image.ANTIALIAS) + + if crop: + # Difference (for x and y) between new image size and requested size. + x, y = [float(v) for v in im.size] + dx, dy = (x - min(x, xr)), (y - min(y, yr)) + if dx or dy: + # Center cropping (default). + ex, ey = dx / 2, dy / 2 + box = [ex, ey, x - ex, y - ey] + # See if an edge cropping argument was provided. + edge_crop = (isinstance(crop, basestring) and + re.match(r'(?:(-?)(\d+))?,(?:(-?)(\d+))?$', crop)) + if edge_crop and filter(None, edge_crop.groups()): + x_right, x_crop, y_bottom, y_crop = edge_crop.groups() + if x_crop: + offset = min(x * int(x_crop) / 100, dx) + if x_right: + box[0] = dx - offset + box[2] = x - offset + else: + box[0] = offset + box[2] = x - (dx - offset) + if y_crop: + offset = min(y * int(y_crop) / 100, dy) + if y_bottom: + box[1] = dy - offset + box[3] = y - offset + else: + box[1] = offset + box[3] = y - (dy - offset) + # See if the image should be "smart cropped". + elif crop == 'smart': + left = top = 0 + right, bottom = x, y + while dx: + slice = min(dx, 10) + l_sl = im.crop((0, 0, slice, y)) + r_sl = im.crop((x - slice, 0, x, y)) + if utils.image_entropy(l_sl) >= utils.image_entropy(r_sl): + right -= slice + else: + left += slice + dx -= slice + while dy: + slice = min(dy, 10) + t_sl = im.crop((0, 0, x, slice)) + b_sl = im.crop((0, y - slice, x, y)) + if utils.image_entropy(t_sl) >= utils.image_entropy(b_sl): + bottom -= slice + else: + top += slice + dy -= slice + box = (left, top, right, bottom) + # Finally, crop the image! + im = im.crop([int(v) for v in box]) + return im + + +def filters(im, detail=False, sharpen=False, **kwargs): + if detail: + im = im.filter(ImageFilter.DETAIL) + if sharpen: + im = im.filter(ImageFilter.SHARPEN) + return im diff --git a/easy_thumbnails/storage.py b/easy_thumbnails/storage.py new file mode 100644 index 00000000..c90d9ae1 --- /dev/null +++ b/easy_thumbnails/storage.py @@ -0,0 +1,18 @@ +from django.core.files.storage import FileSystemStorage +from easy_thumbnails import utils + + +class ThumbnailFileSystemStorage(FileSystemStorage): + """ + Standard file system storage. + + The default ``location`` and ``base_url`` are set to + ``THUMBNAIL_MEDIA_ROOT`` and ``THUMBNAIL_MEDIA_URL``, falling back to the + standard ``MEDIA_ROOT`` and ``MEDIA_URL`` if the custom settings are blank. + + """ + def __init__(self, location=None, base_url=None, *args, **kwargs): + location = utils.get_setting('MEDIA_ROOT', override=location) or None + base_url = utils.get_setting('MEDIA_URL', override=base_url) or None + super(ThumbnailFileSystemStorage, self).__init__(location, base_url, + *args, **kwargs) diff --git a/easy_thumbnails/templatetags/__init__.py b/easy_thumbnails/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/easy_thumbnails/templatetags/thumbnails.py b/easy_thumbnails/templatetags/thumbnails.py new file mode 100755 index 00000000..d134534d --- /dev/null +++ b/easy_thumbnails/templatetags/thumbnails.py @@ -0,0 +1,159 @@ +from django.template import Library, Node, VariableDoesNotExist, \ + TemplateSyntaxError +from easy_thumbnails import utils +from easy_thumbnails.files import get_thumbnailer +from django.utils.html import escape +import re + +register = Library() + +RE_SIZE = re.compile(r'(\d+)x(\d+)$') + +VALID_OPTIONS = utils.valid_processor_options() +VALID_OPTIONS.remove('size') + + +def split_args(args): + """ + Split a list of argument strings into a dictionary where each key is an + argument name. + + An argument looks like ``crop``, ``crop="some option"`` or ``crop=my_var``. + Arguments which provide no value get a value of ``True``. + + """ + args_dict = {} + for arg in args: + split_arg = arg.split('=', 1) + if len(split_arg) > 1: + value = split_arg[1] + else: + value = True + args_dict[split_arg[0]] = value + return args_dict + + +class ThumbnailNode(Node): + def __init__(self, source_var, opts, context_name=None): + self.source_var = source_var + self.opts = opts + self.context_name = context_name + + def render(self, context): + # Note that this isn't a global constant because we need to change the + # value for tests. + raise_errors = utils.get_setting('DEBUG') + # Get the source file. + try: + source = self.source_var.resolve(context) + except VariableDoesNotExist: + if raise_errors: + raise VariableDoesNotExist("Variable '%s' does not exist." % + self.source_var) + return self.bail_out(context) + # Resolve the thumbnail option values. + try: + opts = {} + for key, value in self.opts.iteritems(): + if hasattr(value, 'resolve'): + value = value.resolve(context) + opts[str(key)] = value + except: + if raise_errors: + raise + return self.bail_out(context) + # Size variable can be either a tuple/list of two integers or a + # valid string, only the string is checked. + size = opts['size'] + if isinstance(size, basestring): + m = RE_SIZE.match(size) + if m: + opts['size'] = (int(m.group(1)), int(m.group(2))) + else: + if raise_errors: + raise TemplateSyntaxError("Variable '%s' was resolved " + "but '%s' is not a valid size." % + (self.size_var, size)) + return self.bail_out(context) + + try: + thumbnail = get_thumbnailer(source).get_thumbnail(opts) + except: + if raise_errors: + raise + return self.bail_out(context) + # Return the thumbnail file url, or put the file on the context. + if self.context_name is None: + return escape(thumbnail.url) + else: + context[self.context_name] = thumbnail + return '' + + def bail_out(self, context): + if self.context_name: + context[self.context_name] = '' + return '' + + +def thumbnail(parser, token): + """ + Creates a thumbnail of an ImageField. + + To just output the absolute url to the thumbnail:: + + {% thumbnail image 80x80 %} + + After the image path and dimensions, you can put any options:: + + {% thumbnail image 80x80 quality=95 sharpen %} + + To put the ThumbnailedField instance on the context rather than simply + rendering the url, finish the tag with ``as [context_var_name]``:: + + {% thumbnail image 80x80 as thumb %} + {{ thumb.width }} x {{ thumb.height }} + + """ + args = token.split_contents() + tag = args[0] + + # Check to see if we're setting to a context variable. + if len(args) > 4 and args[-2] == 'as': + context_name = args[-1] + args = args[:-2] + else: + context_name = None + + if len(args) < 3: + raise TemplateSyntaxError("Invalid syntax. Expected " + "'{%% %s source size [option1 option2 ...] %%}' or " + "'{%% %s source size [option1 option2 ...] as variable %%}'" % + (tag, tag)) + + opts = {} + + # The first argument is the source file. + source_var = parser.compile_filter(args[1]) + + # The second argument is the requested size. If it's the static "10x10" + # format, wrap it in quotes so that it is compiled correctly. + size = args[2] + match = RE_SIZE.match(size) + if match: + size = '"%s"' % size + opts['size'] = parser.compile_filter(size) + + # All further arguments are options. + args_list = split_args(args[3:]).items() + for arg, value in args_list: + if arg in VALID_OPTIONS: + if value and value is not True: + value = parser.compile_filter(value) + opts[arg] = value + else: + raise TemplateSyntaxError("'%s' tag received a bad argument: " + "'%s'" % (tag, arg)) + return ThumbnailNode(source_var, opts=opts, context_name=context_name) + + +register.tag(thumbnail) diff --git a/easy_thumbnails/utils.py b/easy_thumbnails/utils.py new file mode 100644 index 00000000..b345f10a --- /dev/null +++ b/easy_thumbnails/utils.py @@ -0,0 +1,73 @@ +from django.conf import settings +from easy_thumbnails import defaults +import inspect +import math + + +def image_entropy(im): + """ + Calculate the entropy of an image. Used for "smart cropping". + + """ + hist = im.histogram() + hist_size = float(sum(hist)) + hist = [h / hist_size for h in hist] + return -sum([p * math.log(p, 2) for p in hist if p != 0]) + + +def dynamic_import(import_string): + """ + Dynamically import a module or object. + + """ + # Use rfind rather than rsplit for Python 2.3 compatibility. + lastdot = import_string.rfind('.') + if lastdot == -1: + return __import__(import_string, {}, {}, []) + module_name, attr = import_string[:lastdot], import_string[lastdot + 1:] + parent_module = __import__(module_name, {}, {}, [attr]) + return getattr(parent_module, attr) + + +def valid_processor_options(processors=None): + """ + Return a list of unique valid options for a list of image processors. + + """ + if processors is None: + processors = [dynamic_import(p) for p in + get_setting('PROCESSORS')] + valid_options = set(['size', 'quality']) + for processor in processors: + args = inspect.getargspec(processor)[0] + # Add all arguments apart from the first (the source image). + valid_options.update(args[1:]) + return list(valid_options) + + +def get_setting(setting, override=None): + """ + Get a thumbnail setting from Django settings module, falling back to the + default. + + If override is not None, it will be used instead of the setting. + + """ + if override is not None: + return override + if hasattr(settings, 'THUMBNAIL_%s' % setting): + return getattr(settings, 'THUMBNAIL_%s' % setting) + else: + return getattr(defaults, setting) + + +def is_storage_local(storage): + """ + Check to see if a file storage is local. + + """ + try: + storage.path('test') + except NotImplementedError: + return False + return True diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..5763c3f5 --- /dev/null +++ b/setup.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python +from distutils.core import setup + + +VERSION = '1.0a' + +README_FILE = open('README') +try: + long_description = README_FILE.read() +finally: + README_FILE.close() + + +setup( + name='easy-thumbnails', + version=VERSION, + #url='', + #download_url='' % VERSION, + description='Easy thumbnails for Django', + long_description=long_description, + author='Chris Beaven', + email='smileychris@gmail.com', + platforms=['any'], + packages=[ + 'easy_thumbnails', + 'easy_thumbnails.templatetags', + ], + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Environment :: Web Environment', + 'Framework :: Django', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Topic :: Software Development :: Libraries :: Application Frameworks', + 'Topic :: Software Development :: Libraries :: Python Modules', + ], +)