From 98066eea93efcb06f223c97abe358498a77518e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timothe=CC=81e=20Peignier?= Date: Tue, 14 Feb 2012 17:34:51 +0100 Subject: [PATCH 01/88] fix import and formatting --- imagekit/imagecache.py | 2 +- imagekit/models/fields/__init__.py | 2 ++ imagekit/models/fields/files.py | 4 ++-- imagekit/processors/resize.py | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/imagekit/imagecache.py b/imagekit/imagecache.py index 3784957..0edc8d6 100644 --- a/imagekit/imagecache.py +++ b/imagekit/imagecache.py @@ -74,7 +74,7 @@ def get_default_image_cache_backend(): except ValueError: raise ImproperlyConfigured("%s isn't an image cache backend module." % \ import_path) - module, classname = import_path[:dot], import_path[dot+1:] + module, classname = import_path[:dot], import_path[dot + 1:] try: mod = import_module(module) except ImportError, e: diff --git a/imagekit/models/fields/__init__.py b/imagekit/models/fields/__init__.py index f7c5237..81c66b3 100644 --- a/imagekit/models/fields/__init__.py +++ b/imagekit/models/fields/__init__.py @@ -1,3 +1,5 @@ +import os + from django.db import models from django.db.models.signals import post_init, post_save, post_delete diff --git a/imagekit/models/fields/files.py b/imagekit/models/fields/files.py index 7e964bc..862a09a 100644 --- a/imagekit/models/fields/files.py +++ b/imagekit/models/fields/files.py @@ -1,7 +1,7 @@ import os import datetime -from django.db.models.fields.files import ImageFieldFile +from django.db.models.fields.files import ImageField, ImageFieldFile from django.utils.encoding import force_unicode, smart_str @@ -19,7 +19,7 @@ class ImageSpecFieldFile(ImageFieldFile): else: image_fields = [getattr(self.instance, f.attname) for f in \ self.instance.__class__._meta.fields if \ - isinstance(f, models.ImageField)] + isinstance(f, ImageField)] if len(image_fields) == 0: raise Exception('%s does not define any ImageFields, so your' \ ' %s ImageSpecField has no image to act on.' % \ diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py index 1838e73..923877a 100644 --- a/imagekit/processors/resize.py +++ b/imagekit/processors/resize.py @@ -154,7 +154,7 @@ class Fit(object): img = BasicResize(new_dimensions[0], new_dimensions[1]).process(img) if self.mat_color: - new_img = Image.new('RGBA', (self.width, self.height), self.mat_color) + new_img = Image.new('RGBA', (self.width, self.height), self.mat_color) new_img.paste(img, ((self.width - img.size[0]) / 2, (self.height - img.size[1]) / 2)) img = new_img return img From a004071e2722a4d8528a29f49dc441d7920fbb21 Mon Sep 17 00:00:00 2001 From: Bryan Veloso Date: Tue, 14 Feb 2012 11:18:36 -0800 Subject: [PATCH 02/88] We're in 2.0alpha now, really. --- imagekit/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/imagekit/__init__.py b/imagekit/__init__.py index 1b2a4a3..c376984 100644 --- a/imagekit/__init__.py +++ b/imagekit/__init__.py @@ -1,4 +1,4 @@ __title__ = 'django-imagekit' __author__ = 'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett, Matthew Tretter, Eric Eldredge' -__version__ = (1, 1, 0, 'final', 0) +__version__ = (2, 0, 0, 'alpha', 0) __license__ = 'BSD' From dafbe2040d3ce8aef61f560b16f6bc4053e7a31b Mon Sep 17 00:00:00 2001 From: Bryan Veloso Date: Wed, 15 Feb 2012 12:57:36 -0800 Subject: [PATCH 03/88] The serial must be greater than 0 for an alpha release. Fixes #101. Thanks @vesterbaek! --- imagekit/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/imagekit/__init__.py b/imagekit/__init__.py index c376984..1d7d570 100644 --- a/imagekit/__init__.py +++ b/imagekit/__init__.py @@ -1,4 +1,4 @@ __title__ = 'django-imagekit' __author__ = 'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett, Matthew Tretter, Eric Eldredge' -__version__ = (2, 0, 0, 'alpha', 0) +__version__ = (2, 0, 0, 'alpha', 1) __license__ = 'BSD' From fa244cdcc9695aa7d4a17c0043f19262d52402d4 Mon Sep 17 00:00:00 2001 From: Eduard Iskandarov Date: Thu, 16 Feb 2012 19:47:22 +0600 Subject: [PATCH 04/88] fix unicode error --- imagekit/models/fields/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/imagekit/models/fields/__init__.py b/imagekit/models/fields/__init__.py index 81c66b3..a7d8c69 100644 --- a/imagekit/models/fields/__init__.py +++ b/imagekit/models/fields/__init__.py @@ -163,7 +163,7 @@ class ProcessedImageField(models.ImageField): os.path.basename(filename))) name, ext = os.path.splitext(filename) ext = self.generator.suggest_extension(filename) - return '%s%s' % (name, ext) + return u'%s%s' % (name, ext) try: From 60c78e74243161b35b088d31efcb699bb981d272 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 17 Feb 2012 16:06:50 -0500 Subject: [PATCH 05/88] Clarifying documentation --- README.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index c968bd7..d60119d 100644 --- a/README.rst +++ b/README.rst @@ -3,7 +3,8 @@ ImageKit is a Django app that helps you to add variations of uploaded images to your models. These variations are called "specs" and can include things like different sizes (e.g. thumbnails) and black and white versions. -For the full documentation, see `ImageKit on RTD`_. +**For the complete documentation on the latest stable version of ImageKit, see** +`ImageKit on RTD`_. .. _`ImageKit on RTD`: http://django-imagekit.readthedocs.org From 6816196da7c617759422399a70ef258839d67fe6 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 17 Feb 2012 18:22:05 -0500 Subject: [PATCH 06/88] Use "super" --- imagekit/models/fields/files.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/imagekit/models/fields/files.py b/imagekit/models/fields/files.py index 862a09a..8900b74 100644 --- a/imagekit/models/fields/files.py +++ b/imagekit/models/fields/files.py @@ -7,7 +7,7 @@ from django.utils.encoding import force_unicode, smart_str class ImageSpecFieldFile(ImageFieldFile): def __init__(self, instance, field, attname): - ImageFieldFile.__init__(self, instance, field, None) + super(ImageSpecFieldFile, self).__init__(instance, field, None) self.attname = attname self.storage = self.field.storage or self.source_file.storage From 677c52b73086b9f3c6660d31ed0af538c7ad9787 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 17 Feb 2012 18:39:51 -0500 Subject: [PATCH 07/88] Rename descriptor and file to match Django --- imagekit/models/fields/__init__.py | 4 ++-- imagekit/models/fields/utils.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/imagekit/models/fields/__init__.py b/imagekit/models/fields/__init__.py index a7d8c69..0e00f83 100644 --- a/imagekit/models/fields/__init__.py +++ b/imagekit/models/fields/__init__.py @@ -6,7 +6,7 @@ from django.db.models.signals import post_init, post_save, post_delete from ...imagecache import get_default_image_cache_backend from ...generators import SpecFileGenerator from .files import ImageSpecFieldFile, ProcessedImageFieldFile -from .utils import ImageSpecFieldDescriptor, ImageKitMeta, BoundImageKitMeta +from .utils import ImageSpecFileDescriptor, ImageKitMeta, BoundImageKitMeta class ImageSpecField(object): @@ -75,7 +75,7 @@ class ImageSpecField(object): get_default_image_cache_backend() def contribute_to_class(self, cls, name): - setattr(cls, name, ImageSpecFieldDescriptor(self, name)) + setattr(cls, name, ImageSpecFileDescriptor(self, name)) try: ik = getattr(cls, '_ik') except AttributeError: diff --git a/imagekit/models/fields/utils.py b/imagekit/models/fields/utils.py index ce40bbb..4e43ab2 100644 --- a/imagekit/models/fields/utils.py +++ b/imagekit/models/fields/utils.py @@ -24,7 +24,7 @@ class ImageKitMeta(object): return ik -class ImageSpecFieldDescriptor(object): +class ImageSpecFileDescriptor(object): def __init__(self, field, attname): self.attname = attname self.field = field From 1bd3035f2c4ede9ba21c82806a4670f195620178 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 17 Feb 2012 18:53:04 -0500 Subject: [PATCH 08/88] Extract image/model generation utils --- tests/core/tests.py | 54 +++++++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/tests/core/tests.py b/tests/core/tests.py index 6ccc977..9150d74 100644 --- a/tests/core/tests.py +++ b/tests/core/tests.py @@ -15,6 +15,33 @@ from imagekit.processors.resize import Fill from imagekit.processors.crop import SmartCrop +def generate_lenna(): + """ + See also: + + http://en.wikipedia.org/wiki/Lenna + http://sipi.usc.edu/database/database.php?volume=misc&image=12 + + """ + tmp = tempfile.TemporaryFile() + lennapath = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', 'lenna-800x600-white-border.jpg') + with open(lennapath, "r+b") as lennafile: + Image.open(lennafile).save(tmp, 'JPEG') + tmp.seek(0) + return tmp + + +def create_photo(name): + photo = Photo() + img = generate_lenna() + file = ContentFile(img.read()) + photo.original_image = file + photo.original_image.save(name, file) + photo.save() + img.close() + return photo + + class Photo(models.Model): original_image = models.ImageField(upload_to='photos') @@ -34,33 +61,8 @@ class IKTest(TestCase): tmp.seek(0) return tmp - def generate_lenna(self): - """ - See also: - - http://en.wikipedia.org/wiki/Lenna - http://sipi.usc.edu/database/database.php?volume=misc&image=12 - - """ - tmp = tempfile.TemporaryFile() - lennapath = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', 'lenna-800x600-white-border.jpg') - with open(lennapath, "r+b") as lennafile: - Image.open(lennafile).save(tmp, 'JPEG') - tmp.seek(0) - return tmp - - def create_photo(self, name): - photo = Photo() - img = self.generate_lenna() - file = ContentFile(img.read()) - photo.original_image = file - photo.original_image.save(name, file) - photo.save() - img.close() - return photo - def setUp(self): - self.photo = self.create_photo('test.jpg') + self.photo = create_photo('test.jpg') def test_nodelete(self): """Don't delete the spec file when the source image hasn't changed. From 280d01f07eeb067a61320c1ba912b4ea9d9aefe8 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 17 Feb 2012 19:22:49 -0500 Subject: [PATCH 09/88] Failing test to illustrate #97 --- tests/core/tests.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/core/tests.py b/tests/core/tests.py index 9150d74..ba9605d 100644 --- a/tests/core/tests.py +++ b/tests/core/tests.py @@ -1,6 +1,8 @@ from __future__ import with_statement import os +import pickle +from StringIO import StringIO import tempfile from django.core.files.base import ContentFile @@ -111,3 +113,15 @@ class IKUtilsTest(TestCase): with self.assertRaises(utils.UnknownFormatError): utils.format_to_extension('TXT') + + +class PickleTest(TestCase): + def test_source_file(self): + ph = create_photo('pickletest.jpg') + pickled_model = StringIO() + pickle.dump(ph, pickled_model) + pickled_model.seek(0) + unpickled_model = pickle.load(pickled_model) + + # This isn't supposed to error. + unpickled_model.thumbnail.source_file From 84d36791b83d12acb09492285d6ecadcbbd9b3fc Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 17 Feb 2012 20:07:50 -0500 Subject: [PATCH 10/88] Pass the pickle test; should fix #97 --- imagekit/models/fields/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/imagekit/models/fields/utils.py b/imagekit/models/fields/utils.py index 4e43ab2..23b0b53 100644 --- a/imagekit/models/fields/utils.py +++ b/imagekit/models/fields/utils.py @@ -35,5 +35,8 @@ class ImageSpecFileDescriptor(object): else: img_spec_file = ImageSpecFieldFile(instance, self.field, self.attname) - setattr(instance, self.attname, img_spec_file) + instance.__dict__[self.attname] = img_spec_file return img_spec_file + + def __set__(self, instance, value): + instance.__dict__[self.attname] = value From 310fd86c21f2ce0000d8023b95385d895c0f01bd Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 17 Feb 2012 20:44:47 -0500 Subject: [PATCH 11/88] Extract utility functions --- tests/core/tests.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/core/tests.py b/tests/core/tests.py index ba9605d..cf2b994 100644 --- a/tests/core/tests.py +++ b/tests/core/tests.py @@ -33,15 +33,19 @@ def generate_lenna(): return tmp -def create_photo(name): - photo = Photo() +def create_instance(model_class, image_name): + instance = model_class() img = generate_lenna() file = ContentFile(img.read()) - photo.original_image = file - photo.original_image.save(name, file) - photo.save() + instance.original_image = file + instance.original_image.save(image_name, file) + instance.save() img.close() - return photo + return instance + + +def create_photo(name): + return create_instance(Photo, name) class Photo(models.Model): From 35b04aeb1678f3b68bd8d88201ef597b6e791945 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 17 Feb 2012 20:45:29 -0500 Subject: [PATCH 12/88] Abstract inheritance test; illustrates #100 --- tests/core/tests.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/core/tests.py b/tests/core/tests.py index cf2b994..c11d89b 100644 --- a/tests/core/tests.py +++ b/tests/core/tests.py @@ -60,6 +60,22 @@ class Photo(models.Model): format='JPEG', options={'quality': 90}) +class AbstractImageModel(models.Model): + original_image = models.ImageField(upload_to='photos') + abstract_class_spec = ImageSpecField() + + class Meta: + abstract = True + + +class ConcreteImageModel1(AbstractImageModel): + first_spec = ImageSpecField() + + +class ConcreteImageModel2(AbstractImageModel): + second_spec = ImageSpecField() + + class IKTest(TestCase): def generate_image(self): tmp = tempfile.TemporaryFile() @@ -129,3 +145,13 @@ class PickleTest(TestCase): # This isn't supposed to error. unpickled_model.thumbnail.source_file + + +class InheritanceTest(TestCase): + def test_abstract_base(self): + self.assertEqual(set(AbstractImageModel._ik.spec_fields), + set(['abstract_class_spec'])) + self.assertEqual(set(ConcreteImageModel1._ik.spec_fields), + set(['abstract_class_spec', 'first_spec'])) + self.assertEqual(set(ConcreteImageModel2._ik.spec_fields), + set(['abstract_class_spec', 'second_spec'])) From b01606580725e18d48bc6bd4b9a6b8f0d655711a Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 17 Feb 2012 21:08:06 -0500 Subject: [PATCH 13/88] Fix inheritance of ImageKitMeta objects Fixes #100 --- imagekit/models/fields/__init__.py | 13 ++++++++++--- imagekit/models/fields/utils.py | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/imagekit/models/fields/__init__.py b/imagekit/models/fields/__init__.py index 0e00f83..505ea7e 100644 --- a/imagekit/models/fields/__init__.py +++ b/imagekit/models/fields/__init__.py @@ -77,9 +77,16 @@ class ImageSpecField(object): def contribute_to_class(self, cls, name): setattr(cls, name, ImageSpecFileDescriptor(self, name)) try: - ik = getattr(cls, '_ik') - except AttributeError: - ik = ImageKitMeta() + # Make sure we don't modify an inherited ImageKitMeta instance + ik = cls.__dict__['ik'] + except KeyError: + try: + base = getattr(cls, '_ik') + except AttributeError: + ik = ImageKitMeta() + else: + # Inherit all the spec fields. + ik = ImageKitMeta(base.spec_fields) setattr(cls, '_ik', ik) ik.spec_fields.append(name) diff --git a/imagekit/models/fields/utils.py b/imagekit/models/fields/utils.py index 23b0b53..1b3ccaa 100644 --- a/imagekit/models/fields/utils.py +++ b/imagekit/models/fields/utils.py @@ -13,7 +13,7 @@ class BoundImageKitMeta(object): class ImageKitMeta(object): def __init__(self, spec_fields=None): - self.spec_fields = spec_fields or [] + self.spec_fields = list(spec_fields) if spec_fields else [] def __get__(self, instance, owner): if instance is None: From 09ecbae1437ca9f394eb236ca7198d4842682720 Mon Sep 17 00:00:00 2001 From: Jan Sagemueller Date: Mon, 13 Feb 2012 15:06:28 +0100 Subject: [PATCH 14/88] Resize processor: Externalized transparency padding into new class Mat Conflicts: imagekit/processors/resize.py --- imagekit/processors/resize.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py index 923877a..08c903a 100644 --- a/imagekit/processors/resize.py +++ b/imagekit/processors/resize.py @@ -116,6 +116,20 @@ class Crop(Fill): super(Crop, self).__init__(*args, **kwargs) +class Mat(object): + def __init__(self, width, height, color=(0, 0, 0, 0), anchor=(0.5, 0.5)): + """Anchor behaves like Crop's anchor argument""" + self.width = width + self.height = height + self.color = color + self.anchor = anchor + + def process(self, img): + new_img = Image.new('RGBA', (self.width, self.height), self.color) + new_img.paste(img, ((self.width - img.size[0]) / 2, (self.height - img.size[1]) / 2)) + return new_img + + class Fit(object): """ Resizes an image to fit within the specified dimensions. @@ -154,9 +168,7 @@ class Fit(object): img = BasicResize(new_dimensions[0], new_dimensions[1]).process(img) if self.mat_color: - new_img = Image.new('RGBA', (self.width, self.height), self.mat_color) - new_img.paste(img, ((self.width - img.size[0]) / 2, (self.height - img.size[1]) / 2)) - img = new_img + img = Mat(self.width, self.height, self.mat_color).process(img) return img From b073868bb740f0b9e84da045c412cac0cad4b9d2 Mon Sep 17 00:00:00 2001 From: Jan Sagemueller Date: Thu, 16 Feb 2012 02:46:31 +0100 Subject: [PATCH 15/88] AddBorder, Anchor, and ResizeCanvas processors [NEW] Processors: AddBorder [NEW] Processors: Anchor has now its own class, taken from Crop [CHG] Processors: Renamed Mat => ResizeCanvas, and will now use either an anchor from Anchor or a user defined pixel offset --- imagekit/processors/__init__.py | 32 ++++++++++++- imagekit/processors/crop.py | 28 ++--------- imagekit/processors/resize.py | 83 +++++++++++++++++++++------------ 3 files changed, 87 insertions(+), 56 deletions(-) diff --git a/imagekit/processors/__init__.py b/imagekit/processors/__init__.py index c3be66c..d6d2aad 100644 --- a/imagekit/processors/__init__.py +++ b/imagekit/processors/__init__.py @@ -8,7 +8,6 @@ from both the filesystem and the ORM. """ from imagekit.lib import Image, ImageColor, ImageEnhance -from imagekit.processors import resize, crop RGBA_TRANSPARENCY_FORMATS = ['PNG'] @@ -198,7 +197,8 @@ class AutoConvert(object): def process(self, img): matte = False self.save_kwargs = {} - if img.mode == 'RGBA': + self.rgba_ = img.mode == 'RGBA' + if self.rgba_: if self.format in RGBA_TRANSPARENCY_FORMATS: pass elif self.format in PALETTE_TRANSPARENCY_FORMATS: @@ -264,3 +264,31 @@ class AutoConvert(object): self.save_kwargs['optimize'] = True return img + + +class Anchor(object): + """ + Defines all the anchor points needed by the various processor classes. + + """ + TOP_LEFT = 'tl' + TOP = 't' + TOP_RIGHT = 'tr' + BOTTOM_LEFT = 'bl' + BOTTOM = 'b' + BOTTOM_RIGHT = 'br' + CENTER = 'c' + LEFT = 'l' + RIGHT = 'r' + + _ANCHOR_PTS = { + TOP_LEFT: (0, 0), + TOP: (0.5, 0), + TOP_RIGHT: (1, 0), + LEFT: (0, 0.5), + CENTER: (0.5, 0.5), + RIGHT: (1, 0.5), + BOTTOM_LEFT: (0, 1), + BOTTOM: (0.5, 1), + BOTTOM_RIGHT: (1, 1), + } \ No newline at end of file diff --git a/imagekit/processors/crop.py b/imagekit/processors/crop.py index 304297d..3476b0f 100644 --- a/imagekit/processors/crop.py +++ b/imagekit/processors/crop.py @@ -1,5 +1,6 @@ from ..lib import Image, ImageChops, ImageDraw, ImageStat from .utils import histogram_entropy +from . import Anchor class Side(object): @@ -99,27 +100,6 @@ class Crop(object): relative to the anchor. """ - TOP_LEFT = 'tl' - TOP = 't' - TOP_RIGHT = 'tr' - BOTTOM_LEFT = 'bl' - BOTTOM = 'b' - BOTTOM_RIGHT = 'br' - CENTER = 'c' - LEFT = 'l' - RIGHT = 'r' - - _ANCHOR_PTS = { - TOP_LEFT: (0, 0), - TOP: (0.5, 0), - TOP_RIGHT: (1, 0), - LEFT: (0, 0.5), - CENTER: (0.5, 0.5), - RIGHT: (1, 0.5), - BOTTOM_LEFT: (0, 1), - BOTTOM: (0.5, 1), - BOTTOM_RIGHT: (1, 1), - } def __init__(self, width=None, height=None, anchor=None): """ @@ -158,9 +138,9 @@ class Crop(object): # If the user passed in one of the string values, convert it to a # percentage tuple. - anchor = self.anchor or Crop.CENTER - if anchor in Crop._ANCHOR_PTS.keys(): - anchor = Crop._ANCHOR_PTS[anchor] + anchor = self.anchor or Anchor.CENTER + if anchor in Anchor._ANCHOR_PTS.keys(): + anchor = Anchor._ANCHOR_PTS[anchor] x = int(float(trim_x) * float(anchor[0])) y = int(float(trim_y) * float(anchor[1])) diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py index 08c903a..7706d9c 100644 --- a/imagekit/processors/resize.py +++ b/imagekit/processors/resize.py @@ -1,6 +1,7 @@ from imagekit.lib import Image from . import crop import warnings +from . import Anchor class BasicResize(object): @@ -47,36 +48,16 @@ class Cover(object): class Fill(object): """ - Resizes an image , cropping it to the exact specified width and height. + Resizes an image, cropping it to the exact specified width and height. """ - TOP_LEFT = crop.Crop.TOP_LEFT - TOP = crop.Crop.TOP - TOP_RIGHT = crop.Crop.TOP_RIGHT - BOTTOM_LEFT = crop.Crop.BOTTOM_LEFT - BOTTOM = crop.Crop.BOTTOM - BOTTOM_RIGHT = crop.Crop.BOTTOM_RIGHT - CENTER = crop.Crop.CENTER - LEFT = crop.Crop.LEFT - RIGHT = crop.Crop.RIGHT def __init__(self, width=None, height=None, anchor=None): """ :param width: The target width, in pixels. :param height: The target height, in pixels. :param anchor: Specifies which part of the image should be retained - when cropping. Valid values are: - - - Fill.TOP_LEFT - - Fill.TOP - - Fill.TOP_RIGHT - - Fill.LEFT - - Fill.CENTER - - Fill.RIGHT - - Fill.BOTTOM_LEFT - - Fill.BOTTOM - - Fill.BOTTOM_RIGHT - + when cropping. """ self.width = width self.height = height @@ -94,7 +75,6 @@ class SmartFill(object): entropy to crop the image instead of a user-specified anchor point. Internally, it simply runs the ``resize.Cover`` and ``crop.SmartCrop`` processors in series. - """ def __init__(self, width, height): """ @@ -116,26 +96,68 @@ class Crop(Fill): super(Crop, self).__init__(*args, **kwargs) -class Mat(object): - def __init__(self, width, height, color=(0, 0, 0, 0), anchor=(0.5, 0.5)): - """Anchor behaves like Crop's anchor argument""" +class ResizeCanvas(object): + """ + Takes an image an resizes the canvas, using a specific background color + if the new size is larger than the current image. + + """ + def __init__(self, width, height, color=None, top=None, left=None, anchor=None): + """ + :param width: The target width, in pixels. + :param height: The target height, in pixels. + :param color: The background color to use for padding. + :param anchor: Specifies the relative position of the original image. + + """ + if (anchor and not (top is None and left is None)) \ + or (anchor is None and top is None and left is None): + raise Exception('You provide either an anchor or x and y position, but not both or none.') self.width = width self.height = height self.color = color + self.top = top + self.left = left self.anchor = anchor def process(self, img): - new_img = Image.new('RGBA', (self.width, self.height), self.color) - new_img.paste(img, ((self.width - img.size[0]) / 2, (self.height - img.size[1]) / 2)) + new_img = Image.new('RGBA', (self.width, self.height), self.color) + if self.anchor: + self.top = int(abs(self.width - img.size[0]) * Anchor._ANCHOR_PTS[self.anchor][0]) + self.left = int(abs(self.height - img.size[1]) * Anchor._ANCHOR_PTS[self.anchor][1]) + new_img.paste(img, (self.top, self.left)) return new_img +class AddBorder(object): + """ + Add a border of specific color and size to an image. + + """ + def __init__(self, color, thickness): + """ + :param color: Color to use for the border + :param thickness: Thickness of the border which is either an int or a 4-tuple of ints. + """ + self.color = color + if isinstance(thickness, int): + self.top = self.right = self.bottom = self.left = thickness + else: + self.top, self.right, self.bottom, self.left = thickness + + def process(self, img): + new_width = img.size[0] + self.left + self.right + new_height = img.size[1] + self.top + self.bottom + return ResizeCanvas(new_width, new_height, self.color, self.top, self.left).process(img) + + class Fit(object): """ Resizes an image to fit within the specified dimensions. """ - def __init__(self, width=None, height=None, upscale=None, mat_color=None): + + def __init__(self, width=None, height=None, upscale=None, mat_color=None, anchor=Anchor.CENTER): """ :param width: The maximum width of the desired image. :param height: The maximum height of the desired image. @@ -150,6 +172,7 @@ class Fit(object): self.height = height self.upscale = upscale self.mat_color = mat_color + self.anchor = anchor def process(self, img): cur_width, cur_height = img.size @@ -168,7 +191,7 @@ class Fit(object): img = BasicResize(new_dimensions[0], new_dimensions[1]).process(img) if self.mat_color: - img = Mat(self.width, self.height, self.mat_color).process(img) + img = ResizeCanvas(self.width, self.height, self.mat_color, anchor=self.anchor).process(img) return img From 441266a6d78f180e1701fe29b3a3129ca6498894 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 17 Feb 2012 22:14:30 -0500 Subject: [PATCH 16/88] Small ResizeCanvas fixes --- imagekit/processors/resize.py | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py index 7706d9c..5ce1c39 100644 --- a/imagekit/processors/resize.py +++ b/imagekit/processors/resize.py @@ -98,11 +98,11 @@ class Crop(Fill): class ResizeCanvas(object): """ - Takes an image an resizes the canvas, using a specific background color - if the new size is larger than the current image. + Resizes the canvas, using the provided background color if the new size is + larger than the current image. """ - def __init__(self, width, height, color=None, top=None, left=None, anchor=None): + def __init__(self, width, height, color=None, anchor=None, x=None, y=None): """ :param width: The target width, in pixels. :param height: The target height, in pixels. @@ -110,22 +110,29 @@ class ResizeCanvas(object): :param anchor: Specifies the relative position of the original image. """ - if (anchor and not (top is None and left is None)) \ - or (anchor is None and top is None and left is None): - raise Exception('You provide either an anchor or x and y position, but not both or none.') + if x is not None or y is not None: + if anchor: + raise Exception('You may provide either an anchor or x and y' + ' coordinate, but not both.') + else: + self.x, self.y = x or 0, y or 0 + self.anchor = None + else: + self.anchor = anchor or Anchor.CENTER + self.x = self.y = None + self.width = width self.height = height self.color = color - self.top = top - self.left = left - self.anchor = anchor def process(self, img): new_img = Image.new('RGBA', (self.width, self.height), self.color) if self.anchor: - self.top = int(abs(self.width - img.size[0]) * Anchor._ANCHOR_PTS[self.anchor][0]) - self.left = int(abs(self.height - img.size[1]) * Anchor._ANCHOR_PTS[self.anchor][1]) - new_img.paste(img, (self.top, self.left)) + x = int(abs(self.width - img.size[0]) * Anchor._ANCHOR_PTS[self.anchor][0]) + y = int(abs(self.height - img.size[1]) * Anchor._ANCHOR_PTS[self.anchor][1]) + else: + x, y = self.x, self.y + new_img.paste(img, (x, y)) return new_img @@ -148,7 +155,8 @@ class AddBorder(object): def process(self, img): new_width = img.size[0] + self.left + self.right new_height = img.size[1] + self.top + self.bottom - return ResizeCanvas(new_width, new_height, self.color, self.top, self.left).process(img) + return ResizeCanvas(new_width, new_height, color=self.color, + x=self.left, y=self.top).process(img) class Fit(object): From dd5efac0ebcc9b2be571daaf8bcbc72c3c0b6b57 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 17 Feb 2012 22:26:10 -0500 Subject: [PATCH 17/88] A little AddBorer cleanup --- imagekit/processors/resize.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py index 5ce1c39..c8e457f 100644 --- a/imagekit/processors/resize.py +++ b/imagekit/processors/resize.py @@ -141,10 +141,11 @@ class AddBorder(object): Add a border of specific color and size to an image. """ - def __init__(self, color, thickness): + def __init__(self, thickness, color=None): """ :param color: Color to use for the border - :param thickness: Thickness of the border which is either an int or a 4-tuple of ints. + :param thickness: Thickness of the border. Can be either an int or + a 4-tuple of ints of the form (top, right, bottom, left). """ self.color = color if isinstance(thickness, int): From 6cca16ef990c5dd901b1601f935dc45d13b7ae28 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 17 Feb 2012 22:29:41 -0500 Subject: [PATCH 18/88] Allow negative coordinates when using an anchor --- imagekit/processors/resize.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py index c8e457f..9501806 100644 --- a/imagekit/processors/resize.py +++ b/imagekit/processors/resize.py @@ -128,8 +128,8 @@ class ResizeCanvas(object): def process(self, img): new_img = Image.new('RGBA', (self.width, self.height), self.color) if self.anchor: - x = int(abs(self.width - img.size[0]) * Anchor._ANCHOR_PTS[self.anchor][0]) - y = int(abs(self.height - img.size[1]) * Anchor._ANCHOR_PTS[self.anchor][1]) + x = int((self.width - img.size[0]) * Anchor._ANCHOR_PTS[self.anchor][0]) + y = int((self.height - img.size[1]) * Anchor._ANCHOR_PTS[self.anchor][1]) else: x, y = self.x, self.y new_img.paste(img, (x, y)) From 568c3d29a1e2a8adcb78eb34502569f1894525ad Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 17 Feb 2012 22:39:43 -0500 Subject: [PATCH 19/88] Default ResizeCanvas color --- imagekit/processors/resize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py index 9501806..f22f93c 100644 --- a/imagekit/processors/resize.py +++ b/imagekit/processors/resize.py @@ -123,7 +123,7 @@ class ResizeCanvas(object): self.width = width self.height = height - self.color = color + self.color = color or (255, 255, 255, 0) def process(self, img): new_img = Image.new('RGBA', (self.width, self.height), self.color) From a164427074b09a19377ac8f94f71c8c5aa269e06 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 17 Feb 2012 23:04:45 -0500 Subject: [PATCH 20/88] Don't use Anchor internals; allow any anchor tuple ResizeCanvas now uses the anchor behavior of the Crop processor --- imagekit/processors/__init__.py | 13 ++++++++++++- imagekit/processors/resize.py | 12 +++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/imagekit/processors/__init__.py b/imagekit/processors/__init__.py index d6d2aad..c7002ef 100644 --- a/imagekit/processors/__init__.py +++ b/imagekit/processors/__init__.py @@ -291,4 +291,15 @@ class Anchor(object): BOTTOM_LEFT: (0, 1), BOTTOM: (0.5, 1), BOTTOM_RIGHT: (1, 1), - } \ No newline at end of file + } + + @staticmethod + def get_tuple(anchor): + """Normalizes anchor values (strings or tuples) to tuples. + + """ + # If the user passed in one of the string values, convert it to a + # percentage tuple. + if anchor in Anchor._ANCHOR_PTS.keys(): + anchor = Anchor._ANCHOR_PTS[anchor] + return anchor diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py index f22f93c..a18a533 100644 --- a/imagekit/processors/resize.py +++ b/imagekit/processors/resize.py @@ -126,12 +126,18 @@ class ResizeCanvas(object): self.color = color or (255, 255, 255, 0) def process(self, img): - new_img = Image.new('RGBA', (self.width, self.height), self.color) + original_width, original_height = img.size + if self.anchor: - x = int((self.width - img.size[0]) * Anchor._ANCHOR_PTS[self.anchor][0]) - y = int((self.height - img.size[1]) * Anchor._ANCHOR_PTS[self.anchor][1]) + anchor = Anchor.get_tuple(self.anchor) + trim_x, trim_y = self.width - original_width, \ + self.height - original_height + x = int(float(trim_x) * float(anchor[0])) + y = int(float(trim_y) * float(anchor[1])) else: x, y = self.x, self.y + + new_img = Image.new('RGBA', (self.width, self.height), self.color) new_img.paste(img, (x, y)) return new_img From 3e2bd2f21f631542e0cb480e44d3bfdc878b84ac Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 17 Feb 2012 23:20:54 -0500 Subject: [PATCH 21/88] Crop processor consolidation BasicCrop is absorbed into Crop and Crop uses ResizeCanvas --- imagekit/processors/crop.py | 75 ++++++----------------------------- imagekit/processors/resize.py | 18 ++++++++- 2 files changed, 30 insertions(+), 63 deletions(-) diff --git a/imagekit/processors/crop.py b/imagekit/processors/crop.py index 3476b0f..5f7af38 100644 --- a/imagekit/processors/crop.py +++ b/imagekit/processors/crop.py @@ -1,6 +1,6 @@ -from ..lib import Image, ImageChops, ImageDraw, ImageStat -from .utils import histogram_entropy from . import Anchor +from .utils import histogram_entropy +from ..lib import Image, ImageChops, ImageDraw, ImageStat class Side(object): @@ -72,80 +72,31 @@ class TrimBorderColor(object): return img -class BasicCrop(object): - """Crops an image to the specified rectangular region. - - """ - def __init__(self, x, y, width, height): - """ - :param x: The x position of the clipping box, in pixels. - :param y: The y position of the clipping box, in pixels. - :param width: The width position of the clipping box, in pixels. - :param height: The height position of the clipping box, in pixels. - - """ - self.x = x - self.y = y - self.width = width - self.height = height - - def process(self, img): - box = (self.x, self.y, self.x + self.width, self.y + self.height) - return img.crop(box) - - class Crop(object): """ - Crops an image , cropping it to the specified width and height - relative to the anchor. + Crops an image, cropping it to the specified width and height. You may + optionally provide either an anchor or x and y coordinates. This processor + functions exactly the same as ``ResizeCanvas`` except that it will never + enlarge the image. """ - def __init__(self, width=None, height=None, anchor=None): - """ - :param width: The target width, in pixels. - :param height: The target height, in pixels. - :param anchor: Specifies which part of the image should be retained - when cropping. Valid values are: - - - Crop.TOP_LEFT - - Crop.TOP - - Crop.TOP_RIGHT - - Crop.LEFT - - Crop.CENTER - - Crop.RIGHT - - Crop.BOTTOM_LEFT - - Crop.BOTTOM - - Crop.BOTTOM_RIGHT - - You may also pass a tuple that indicates the percentages of excess - to be trimmed from each dimension. For example, ``(0, 0)`` - corresponds to "top left", ``(0.5, 0.5)`` to "center" and ``(1, 1)`` - to "bottom right". This is basically the same as using percentages - in CSS background positions. - - """ + def __init__(self, width=None, height=None, anchor=None, x=None, y=None): self.width = width self.height = height self.anchor = anchor + self.x = x + self.y = y def process(self, img): + from .resize import ResizeCanvas + original_width, original_height = img.size new_width, new_height = min(original_width, self.width), \ min(original_height, self.height) - trim_x, trim_y = original_width - new_width, \ - original_height - new_height - # If the user passed in one of the string values, convert it to a - # percentage tuple. - anchor = self.anchor or Anchor.CENTER - if anchor in Anchor._ANCHOR_PTS.keys(): - anchor = Anchor._ANCHOR_PTS[anchor] - - x = int(float(trim_x) * float(anchor[0])) - y = int(float(trim_y) * float(anchor[1])) - - return BasicCrop(x, y, new_width, new_height).process(img) + return ResizeCanvas(new_width, new_height, anchor=self.anchor, + x=self.x, y=self.y).process(img) class SmartCrop(object): diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py index a18a533..65677ab 100644 --- a/imagekit/processors/resize.py +++ b/imagekit/processors/resize.py @@ -107,7 +107,23 @@ class ResizeCanvas(object): :param width: The target width, in pixels. :param height: The target height, in pixels. :param color: The background color to use for padding. - :param anchor: Specifies the relative position of the original image. + :param anchor: Specifies the position of the original image on the new + canvas. Valid values are: + + - Anchor.TOP_LEFT + - Anchor.TOP + - Anchor.TOP_RIGHT + - Anchor.LEFT + - Anchor.CENTER + - Anchor.RIGHT + - Anchor.BOTTOM_LEFT + - Anchor.BOTTOM + - Anchor.BOTTOM_RIGHT + + You may also pass a tuple that indicates the position in + percentages. For example, ``(0, 0)`` corresponds to "top left", + ``(0.5, 0.5)`` to "center" and ``(1, 1)`` to "bottom right". This is + basically the same as using percentages in CSS background positions. """ if x is not None or y is not None: From 3fad906305e77fa4eee7f361dc40c510e9c8bc54 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 17 Feb 2012 23:54:39 -0500 Subject: [PATCH 22/88] Remove crop.Crop to avoid confusion with resize.Crop --- imagekit/processors/resize.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py index 65677ab..b50ae51 100644 --- a/imagekit/processors/resize.py +++ b/imagekit/processors/resize.py @@ -89,13 +89,6 @@ class SmartFill(object): return crop.SmartCrop(self.width, self.height).process(img) -class Crop(Fill): - def __init__(self, *args, **kwargs): - warnings.warn('`imagekit.processors.resize.Crop` has been renamed to' - '`imagekit.processors.resize.Fill`.', DeprecationWarning) - super(Crop, self).__init__(*args, **kwargs) - - class ResizeCanvas(object): """ Resizes the canvas, using the provided background color if the new size is From 5a8564d03970be849ee6a61c66fb4714a684739d Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 17 Feb 2012 23:55:59 -0500 Subject: [PATCH 23/88] Rename BasicResize to Resize --- imagekit/processors/resize.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py index b50ae51..cc19027 100644 --- a/imagekit/processors/resize.py +++ b/imagekit/processors/resize.py @@ -4,7 +4,7 @@ import warnings from . import Anchor -class BasicResize(object): +class Resize(object): """ Resizes an image to the specified width and height. @@ -43,7 +43,7 @@ class Cover(object): float(self.height) / original_height) new_width, new_height = (int(original_width * ratio), int(original_height * ratio)) - return BasicResize(new_width, new_height).process(img) + return Resize(new_width, new_height).process(img) class Fill(object): @@ -212,7 +212,7 @@ class Fit(object): int(round(cur_height * ratio))) if (cur_width > new_dimensions[0] or cur_height > new_dimensions[1]) or \ self.upscale: - img = BasicResize(new_dimensions[0], + img = Resize(new_dimensions[0], new_dimensions[1]).process(img) if self.mat_color: img = ResizeCanvas(self.width, self.height, self.mat_color, anchor=self.anchor).process(img) From 3912003f02ff8a4ff981379059103f1817856376 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Sat, 18 Feb 2012 00:02:37 -0500 Subject: [PATCH 24/88] Rename Fit and Fill to ResizeToFit and ResizeToFill --- imagekit/processors/resize.py | 2 +- tests/core/tests.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py index cc19027..c18fc47 100644 --- a/imagekit/processors/resize.py +++ b/imagekit/processors/resize.py @@ -175,7 +175,7 @@ class AddBorder(object): x=self.left, y=self.top).process(img) -class Fit(object): +class ResizeToFit(object): """ Resizes an image to fit within the specified dimensions. diff --git a/tests/core/tests.py b/tests/core/tests.py index c11d89b..396aab5 100644 --- a/tests/core/tests.py +++ b/tests/core/tests.py @@ -13,8 +13,8 @@ from imagekit import utils from imagekit.lib import Image from imagekit.models.fields import ImageSpecField from imagekit.processors import Adjust -from imagekit.processors.resize import Fill -from imagekit.processors.crop import SmartCrop +from imagekit.processors import ResizeToFill +from imagekit.processors import SmartCrop def generate_lenna(): @@ -52,7 +52,7 @@ class Photo(models.Model): original_image = models.ImageField(upload_to='photos') thumbnail = ImageSpecField([Adjust(contrast=1.2, sharpness=1.1), - Fill(50, 50)], image_field='original_image', format='JPEG', + ResizeToFill(50, 50)], image_field='original_image', format='JPEG', options={'quality': 90}) smartcropped_thumbnail = ImageSpecField([Adjust(contrast=1.2, From e67934852dacde2bfe4ff7e6ca59d46281cabfbd Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Sat, 18 Feb 2012 00:16:41 -0500 Subject: [PATCH 25/88] Rename processors and clean up packages --- README.rst | 6 +- imagekit/processors/__init__.py | 298 +------------------------------- imagekit/processors/base.py | 296 +++++++++++++++++++++++++++++++ imagekit/processors/crop.py | 2 +- imagekit/processors/resize.py | 29 ++-- 5 files changed, 314 insertions(+), 317 deletions(-) create mode 100644 imagekit/processors/base.py diff --git a/README.rst b/README.rst index d60119d..800efa4 100644 --- a/README.rst +++ b/README.rst @@ -61,12 +61,12 @@ your spec, you can expose different versions of the original image:: from django.db import models from imagekit.models.fields import ImageSpecField - from imagekit.processors import resize, Adjust + from imagekit.processors import ResizeToFill, Adjust class Photo(models.Model): original_image = models.ImageField(upload_to='photos') thumbnail = ImageSpecField([Adjust(contrast=1.2, sharpness=1.1), - resize.Fill(50, 50)], image_field='original_image', + ResizeToFill(50, 50)], image_field='original_image', format='JPEG', options={'quality': 90}) The ``thumbnail`` property will now return a cropped image:: @@ -77,7 +77,7 @@ The ``thumbnail`` property will now return a cropped image:: photo.original_image.width # > 1000 The original image is not modified; ``thumbnail`` is a new file that is the -result of running the ``imagekit.processors.resize.Fill`` processor on the +result of running the ``imagekit.processors.ResizeToFill`` processor on the original. The ``imagekit.processors`` module contains processors for many common diff --git a/imagekit/processors/__init__.py b/imagekit/processors/__init__.py index c7002ef..c2c9320 100644 --- a/imagekit/processors/__init__.py +++ b/imagekit/processors/__init__.py @@ -7,299 +7,7 @@ should be limited to image manipulations--they should be completely decoupled from both the filesystem and the ORM. """ -from imagekit.lib import Image, ImageColor, ImageEnhance - -RGBA_TRANSPARENCY_FORMATS = ['PNG'] -PALETTE_TRANSPARENCY_FORMATS = ['PNG', 'GIF'] - - -class ProcessorPipeline(list): - """ - A :class:`list` of other processors. This class allows any object that - knows how to deal with a single processor to deal with a list of them. - For example:: - - processed_image = ProcessorPipeline([ProcessorA(), ProcessorB()]).process(image) - - """ - def process(self, img): - for proc in self: - img = proc.process(img) - return img - - -class Adjust(object): - """ - Performs color, brightness, contrast, and sharpness enhancements on the - image. See :mod:`PIL.ImageEnhance` for more imformation. - - """ - def __init__(self, color=1.0, brightness=1.0, contrast=1.0, sharpness=1.0): - """ - :param color: A number between 0 and 1 that specifies the saturation - of the image. 0 corresponds to a completely desaturated image - (black and white) and 1 to the original color. - See :class:`PIL.ImageEnhance.Color` - :param brightness: A number representing the brightness; 0 results in - a completely black image whereas 1 corresponds to the brightness - of the original. See :class:`PIL.ImageEnhance.Brightness` - :param contrast: A number representing the contrast; 0 results in a - completely gray image whereas 1 corresponds to the contrast of - the original. See :class:`PIL.ImageEnhance.Contrast` - :param sharpness: A number representing the sharpness; 0 results in a - blurred image; 1 corresponds to the original sharpness; 2 - results in a sharpened image. See - :class:`PIL.ImageEnhance.Sharpness` - - """ - self.color = color - self.brightness = brightness - self.contrast = contrast - self.sharpness = sharpness - - def process(self, img): - original = img = img.convert('RGBA') - for name in ['Color', 'Brightness', 'Contrast', 'Sharpness']: - factor = getattr(self, name.lower()) - if factor != 1.0: - try: - img = getattr(ImageEnhance, name)(img).enhance(factor) - except ValueError: - pass - else: - # PIL's Color and Contrast filters both convert the image - # to L mode, losing transparency info, so we put it back. - # See https://github.com/jdriscoll/django-imagekit/issues/64 - if name in ('Color', 'Contrast'): - img = Image.merge('RGBA', img.split()[:3] + - original.split()[3:4]) - return img - - -class Reflection(object): - """ - Creates an image with a reflection. - - """ - background_color = '#FFFFFF' - size = 0.0 - opacity = 0.6 - - def process(self, img): - # Convert bgcolor string to RGB value. - background_color = ImageColor.getrgb(self.background_color) - # Handle palleted images. - img = img.convert('RGB') - # Copy orignial image and flip the orientation. - reflection = img.copy().transpose(Image.FLIP_TOP_BOTTOM) - # Create a new image filled with the bgcolor the same size. - background = Image.new("RGB", img.size, background_color) - # Calculate our alpha mask. - start = int(255 - (255 * self.opacity)) # The start of our gradient. - steps = int(255 * self.size) # The number of intermedite values. - increment = (255 - start) / float(steps) - mask = Image.new('L', (1, 255)) - for y in range(255): - if y < steps: - val = int(y * increment + start) - else: - val = 255 - mask.putpixel((0, y), val) - alpha_mask = mask.resize(img.size) - # Merge the reflection onto our background color using the alpha mask. - reflection = Image.composite(background, reflection, alpha_mask) - # Crop the reflection. - reflection_height = int(img.size[1] * self.size) - reflection = reflection.crop((0, 0, img.size[0], reflection_height)) - # Create new image sized to hold both the original image and - # the reflection. - composite = Image.new("RGB", (img.size[0], img.size[1] + reflection_height), background_color) - # Paste the orignal image and the reflection into the composite image. - composite.paste(img, (0, 0)) - composite.paste(reflection, (0, img.size[1])) - # Return the image complete with reflection effect. - return composite - - -class Transpose(object): - """ - Rotates or flips the image. - - """ - AUTO = 'auto' - FLIP_HORIZONTAL = Image.FLIP_LEFT_RIGHT - FLIP_VERTICAL = Image.FLIP_TOP_BOTTOM - ROTATE_90 = Image.ROTATE_90 - ROTATE_180 = Image.ROTATE_180 - ROTATE_270 = Image.ROTATE_270 - - methods = [AUTO] - _EXIF_ORIENTATION_STEPS = { - 1: [], - 2: [FLIP_HORIZONTAL], - 3: [ROTATE_180], - 4: [FLIP_VERTICAL], - 5: [ROTATE_270, FLIP_HORIZONTAL], - 6: [ROTATE_270], - 7: [ROTATE_90, FLIP_HORIZONTAL], - 8: [ROTATE_90], - } - - def __init__(self, *args): - """ - Possible arguments: - - Transpose.AUTO - - Transpose.FLIP_HORIZONTAL - - Transpose.FLIP_VERTICAL - - Transpose.ROTATE_90 - - Transpose.ROTATE_180 - - Transpose.ROTATE_270 - - The order of the arguments dictates the order in which the - Transposition steps are taken. - - If Transpose.AUTO is present, all other arguments are ignored, and - the processor will attempt to rotate the image according to the - EXIF Orientation data. - - """ - super(Transpose, self).__init__() - if args: - self.methods = args - - def process(self, img): - if self.AUTO in self.methods: - try: - orientation = img._getexif()[0x0112] - ops = self._EXIF_ORIENTATION_STEPS[orientation] - except (KeyError, TypeError, AttributeError): - ops = [] - else: - ops = self.methods - for method in ops: - img = img.transpose(method) - return img - - -class AutoConvert(object): - """A processor that does some common-sense conversions based on the target - format. This includes things like preserving transparency and quantizing. - This processors is used automatically by ``ImageSpecField`` and - ``ProcessedImageField`` immediately before saving the image unless you - specify ``autoconvert=False``. - - """ - - def __init__(self, format): - self.format = format - - def process(self, img): - matte = False - self.save_kwargs = {} - self.rgba_ = img.mode == 'RGBA' - if self.rgba_: - if self.format in RGBA_TRANSPARENCY_FORMATS: - pass - elif self.format in PALETTE_TRANSPARENCY_FORMATS: - # If you're going from a format with alpha transparency to one - # with palette transparency, transparency values will be - # snapped: pixels that are more opaque than not will become - # fully opaque; pixels that are more transparent than not will - # become fully transparent. This will not produce a good-looking - # result if your image contains varying levels of opacity; in - # that case, you'll probably want to use a processor to matte - # the image on a solid color. The reason we don't matte by - # default is because not doing so allows processors to treat - # RGBA-format images as a super-type of P-format images: if you - # have an RGBA-format image with only a single transparent - # color, and save it as a GIF, it will retain its transparency. - # In other words, a P-format image converted to an - # RGBA-formatted image by a processor and then saved as a - # P-format image will give the expected results. - alpha = img.split()[-1] - mask = Image.eval(alpha, lambda a: 255 if a <= 128 else 0) - img = img.convert('RGB').convert('P', palette=Image.ADAPTIVE, - colors=255) - img.paste(255, mask) - self.save_kwargs['transparency'] = 255 - else: - # Simply converting an RGBA-format image to an RGB one creates a - # gross result, so we matte the image on a white background. If - # that's not what you want, that's fine: use a processor to deal - # with the transparency however you want. This is simply a - # sensible default that will always produce something that looks - # good. Or at least, it will look better than just a straight - # conversion. - matte = True - elif img.mode == 'P': - if self.format in PALETTE_TRANSPARENCY_FORMATS: - try: - self.save_kwargs['transparency'] = img.info['transparency'] - except KeyError: - pass - elif self.format in RGBA_TRANSPARENCY_FORMATS: - # Currently PIL doesn't support any RGBA-mode formats that - # aren't also P-mode formats, so this will never happen. - img = img.convert('RGBA') - else: - matte = True - else: - img = img.convert('RGB') - - # GIFs are always going to be in palette mode, so we can do a little - # optimization. Note that the RGBA sources also use adaptive - # quantization (above). Images that are already in P mode don't need - # any quantization because their colors are already limited. - if self.format == 'GIF': - img = img.convert('P', palette=Image.ADAPTIVE) - - if matte: - img = img.convert('RGBA') - bg = Image.new('RGBA', img.size, (255, 255, 255)) - bg.paste(img, img) - img = bg.convert('RGB') - - if self.format == 'JPEG': - self.save_kwargs['optimize'] = True - - return img - - -class Anchor(object): - """ - Defines all the anchor points needed by the various processor classes. - - """ - TOP_LEFT = 'tl' - TOP = 't' - TOP_RIGHT = 'tr' - BOTTOM_LEFT = 'bl' - BOTTOM = 'b' - BOTTOM_RIGHT = 'br' - CENTER = 'c' - LEFT = 'l' - RIGHT = 'r' - - _ANCHOR_PTS = { - TOP_LEFT: (0, 0), - TOP: (0.5, 0), - TOP_RIGHT: (1, 0), - LEFT: (0, 0.5), - CENTER: (0.5, 0.5), - RIGHT: (1, 0.5), - BOTTOM_LEFT: (0, 1), - BOTTOM: (0.5, 1), - BOTTOM_RIGHT: (1, 1), - } - - @staticmethod - def get_tuple(anchor): - """Normalizes anchor values (strings or tuples) to tuples. - - """ - # If the user passed in one of the string values, convert it to a - # percentage tuple. - if anchor in Anchor._ANCHOR_PTS.keys(): - anchor = Anchor._ANCHOR_PTS[anchor] - return anchor +from .base import * +from .crop import * +from .resize import * diff --git a/imagekit/processors/base.py b/imagekit/processors/base.py new file mode 100644 index 0000000..afc0f4f --- /dev/null +++ b/imagekit/processors/base.py @@ -0,0 +1,296 @@ +from imagekit.lib import Image, ImageColor, ImageEnhance + + +RGBA_TRANSPARENCY_FORMATS = ['PNG'] +PALETTE_TRANSPARENCY_FORMATS = ['PNG', 'GIF'] + + +class ProcessorPipeline(list): + """ + A :class:`list` of other processors. This class allows any object that + knows how to deal with a single processor to deal with a list of them. + For example:: + + processed_image = ProcessorPipeline([ProcessorA(), ProcessorB()]).process(image) + + """ + def process(self, img): + for proc in self: + img = proc.process(img) + return img + + +class Adjust(object): + """ + Performs color, brightness, contrast, and sharpness enhancements on the + image. See :mod:`PIL.ImageEnhance` for more imformation. + + """ + def __init__(self, color=1.0, brightness=1.0, contrast=1.0, sharpness=1.0): + """ + :param color: A number between 0 and 1 that specifies the saturation + of the image. 0 corresponds to a completely desaturated image + (black and white) and 1 to the original color. + See :class:`PIL.ImageEnhance.Color` + :param brightness: A number representing the brightness; 0 results in + a completely black image whereas 1 corresponds to the brightness + of the original. See :class:`PIL.ImageEnhance.Brightness` + :param contrast: A number representing the contrast; 0 results in a + completely gray image whereas 1 corresponds to the contrast of + the original. See :class:`PIL.ImageEnhance.Contrast` + :param sharpness: A number representing the sharpness; 0 results in a + blurred image; 1 corresponds to the original sharpness; 2 + results in a sharpened image. See + :class:`PIL.ImageEnhance.Sharpness` + + """ + self.color = color + self.brightness = brightness + self.contrast = contrast + self.sharpness = sharpness + + def process(self, img): + original = img = img.convert('RGBA') + for name in ['Color', 'Brightness', 'Contrast', 'Sharpness']: + factor = getattr(self, name.lower()) + if factor != 1.0: + try: + img = getattr(ImageEnhance, name)(img).enhance(factor) + except ValueError: + pass + else: + # PIL's Color and Contrast filters both convert the image + # to L mode, losing transparency info, so we put it back. + # See https://github.com/jdriscoll/django-imagekit/issues/64 + if name in ('Color', 'Contrast'): + img = Image.merge('RGBA', img.split()[:3] + + original.split()[3:4]) + return img + + +class Reflection(object): + """ + Creates an image with a reflection. + + """ + background_color = '#FFFFFF' + size = 0.0 + opacity = 0.6 + + def process(self, img): + # Convert bgcolor string to RGB value. + background_color = ImageColor.getrgb(self.background_color) + # Handle palleted images. + img = img.convert('RGB') + # Copy orignial image and flip the orientation. + reflection = img.copy().transpose(Image.FLIP_TOP_BOTTOM) + # Create a new image filled with the bgcolor the same size. + background = Image.new("RGB", img.size, background_color) + # Calculate our alpha mask. + start = int(255 - (255 * self.opacity)) # The start of our gradient. + steps = int(255 * self.size) # The number of intermedite values. + increment = (255 - start) / float(steps) + mask = Image.new('L', (1, 255)) + for y in range(255): + if y < steps: + val = int(y * increment + start) + else: + val = 255 + mask.putpixel((0, y), val) + alpha_mask = mask.resize(img.size) + # Merge the reflection onto our background color using the alpha mask. + reflection = Image.composite(background, reflection, alpha_mask) + # Crop the reflection. + reflection_height = int(img.size[1] * self.size) + reflection = reflection.crop((0, 0, img.size[0], reflection_height)) + # Create new image sized to hold both the original image and + # the reflection. + composite = Image.new("RGB", (img.size[0], img.size[1] + reflection_height), background_color) + # Paste the orignal image and the reflection into the composite image. + composite.paste(img, (0, 0)) + composite.paste(reflection, (0, img.size[1])) + # Return the image complete with reflection effect. + return composite + + +class Transpose(object): + """ + Rotates or flips the image. + + """ + AUTO = 'auto' + FLIP_HORIZONTAL = Image.FLIP_LEFT_RIGHT + FLIP_VERTICAL = Image.FLIP_TOP_BOTTOM + ROTATE_90 = Image.ROTATE_90 + ROTATE_180 = Image.ROTATE_180 + ROTATE_270 = Image.ROTATE_270 + + methods = [AUTO] + _EXIF_ORIENTATION_STEPS = { + 1: [], + 2: [FLIP_HORIZONTAL], + 3: [ROTATE_180], + 4: [FLIP_VERTICAL], + 5: [ROTATE_270, FLIP_HORIZONTAL], + 6: [ROTATE_270], + 7: [ROTATE_90, FLIP_HORIZONTAL], + 8: [ROTATE_90], + } + + def __init__(self, *args): + """ + Possible arguments: + - Transpose.AUTO + - Transpose.FLIP_HORIZONTAL + - Transpose.FLIP_VERTICAL + - Transpose.ROTATE_90 + - Transpose.ROTATE_180 + - Transpose.ROTATE_270 + + The order of the arguments dictates the order in which the + Transposition steps are taken. + + If Transpose.AUTO is present, all other arguments are ignored, and + the processor will attempt to rotate the image according to the + EXIF Orientation data. + + """ + super(Transpose, self).__init__() + if args: + self.methods = args + + def process(self, img): + if self.AUTO in self.methods: + try: + orientation = img._getexif()[0x0112] + ops = self._EXIF_ORIENTATION_STEPS[orientation] + except (KeyError, TypeError, AttributeError): + ops = [] + else: + ops = self.methods + for method in ops: + img = img.transpose(method) + return img + + +class AutoConvert(object): + """A processor that does some common-sense conversions based on the target + format. This includes things like preserving transparency and quantizing. + This processors is used automatically by ``ImageSpecField`` and + ``ProcessedImageField`` immediately before saving the image unless you + specify ``autoconvert=False``. + + """ + + def __init__(self, format): + self.format = format + + def process(self, img): + matte = False + self.save_kwargs = {} + self.rgba_ = img.mode == 'RGBA' + if self.rgba_: + if self.format in RGBA_TRANSPARENCY_FORMATS: + pass + elif self.format in PALETTE_TRANSPARENCY_FORMATS: + # If you're going from a format with alpha transparency to one + # with palette transparency, transparency values will be + # snapped: pixels that are more opaque than not will become + # fully opaque; pixels that are more transparent than not will + # become fully transparent. This will not produce a good-looking + # result if your image contains varying levels of opacity; in + # that case, you'll probably want to use a processor to matte + # the image on a solid color. The reason we don't matte by + # default is because not doing so allows processors to treat + # RGBA-format images as a super-type of P-format images: if you + # have an RGBA-format image with only a single transparent + # color, and save it as a GIF, it will retain its transparency. + # In other words, a P-format image converted to an + # RGBA-formatted image by a processor and then saved as a + # P-format image will give the expected results. + alpha = img.split()[-1] + mask = Image.eval(alpha, lambda a: 255 if a <= 128 else 0) + img = img.convert('RGB').convert('P', palette=Image.ADAPTIVE, + colors=255) + img.paste(255, mask) + self.save_kwargs['transparency'] = 255 + else: + # Simply converting an RGBA-format image to an RGB one creates a + # gross result, so we matte the image on a white background. If + # that's not what you want, that's fine: use a processor to deal + # with the transparency however you want. This is simply a + # sensible default that will always produce something that looks + # good. Or at least, it will look better than just a straight + # conversion. + matte = True + elif img.mode == 'P': + if self.format in PALETTE_TRANSPARENCY_FORMATS: + try: + self.save_kwargs['transparency'] = img.info['transparency'] + except KeyError: + pass + elif self.format in RGBA_TRANSPARENCY_FORMATS: + # Currently PIL doesn't support any RGBA-mode formats that + # aren't also P-mode formats, so this will never happen. + img = img.convert('RGBA') + else: + matte = True + else: + img = img.convert('RGB') + + # GIFs are always going to be in palette mode, so we can do a little + # optimization. Note that the RGBA sources also use adaptive + # quantization (above). Images that are already in P mode don't need + # any quantization because their colors are already limited. + if self.format == 'GIF': + img = img.convert('P', palette=Image.ADAPTIVE) + + if matte: + img = img.convert('RGBA') + bg = Image.new('RGBA', img.size, (255, 255, 255)) + bg.paste(img, img) + img = bg.convert('RGB') + + if self.format == 'JPEG': + self.save_kwargs['optimize'] = True + + return img + + +class Anchor(object): + """ + Defines all the anchor points needed by the various processor classes. + + """ + TOP_LEFT = 'tl' + TOP = 't' + TOP_RIGHT = 'tr' + BOTTOM_LEFT = 'bl' + BOTTOM = 'b' + BOTTOM_RIGHT = 'br' + CENTER = 'c' + LEFT = 'l' + RIGHT = 'r' + + _ANCHOR_PTS = { + TOP_LEFT: (0, 0), + TOP: (0.5, 0), + TOP_RIGHT: (1, 0), + LEFT: (0, 0.5), + CENTER: (0.5, 0.5), + RIGHT: (1, 0.5), + BOTTOM_LEFT: (0, 1), + BOTTOM: (0.5, 1), + BOTTOM_RIGHT: (1, 1), + } + + @staticmethod + def get_tuple(anchor): + """Normalizes anchor values (strings or tuples) to tuples. + + """ + # If the user passed in one of the string values, convert it to a + # percentage tuple. + if anchor in Anchor._ANCHOR_PTS.keys(): + anchor = Anchor._ANCHOR_PTS[anchor] + return anchor diff --git a/imagekit/processors/crop.py b/imagekit/processors/crop.py index 5f7af38..da5c0fb 100644 --- a/imagekit/processors/crop.py +++ b/imagekit/processors/crop.py @@ -1,4 +1,4 @@ -from . import Anchor +from .base import Anchor from .utils import histogram_entropy from ..lib import Image, ImageChops, ImageDraw, ImageStat diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py index c18fc47..721768a 100644 --- a/imagekit/processors/resize.py +++ b/imagekit/processors/resize.py @@ -1,7 +1,6 @@ from imagekit.lib import Image -from . import crop import warnings -from . import Anchor +from .base import Anchor class Resize(object): @@ -26,7 +25,7 @@ class Cover(object): """ Resizes the image to the smallest possible size that will entirely cover the provided dimensions. You probably won't be using this processor directly, - but it's used internally by ``Fill`` and ``SmartFill``. + but it's used internally by ``ResizeToFill`` and ``SmartResize``. """ def __init__(self, width, height): @@ -46,7 +45,7 @@ class Cover(object): return Resize(new_width, new_height).process(img) -class Fill(object): +class ResizeToFill(object): """ Resizes an image, cropping it to the exact specified width and height. @@ -64,16 +63,17 @@ class Fill(object): self.anchor = anchor def process(self, img): + from .crop import Crop img = Cover(self.width, self.height).process(img) - return crop.Crop(self.width, self.height, + return Crop(self.width, self.height, anchor=self.anchor).process(img) -class SmartFill(object): +class SmartResize(object): """ - The ``SmartFill`` processor is identical to ``Fill``, except that it uses - entropy to crop the image instead of a user-specified anchor point. - Internally, it simply runs the ``resize.Cover`` and ``crop.SmartCrop`` + The ``SmartResize`` processor is identical to ``ResizeToFill``, except that + it uses entropy to crop the image instead of a user-specified anchor point. + Internally, it simply runs the ``ResizeToCover`` and ``SmartCrop`` processors in series. """ def __init__(self, width, height): @@ -85,8 +85,9 @@ class SmartFill(object): self.width, self.height = width, height def process(self, img): + from .crop import SmartCrop img = Cover(self.width, self.height).process(img) - return crop.SmartCrop(self.width, self.height).process(img) + return SmartCrop(self.width, self.height).process(img) class ResizeCanvas(object): @@ -217,11 +218,3 @@ class ResizeToFit(object): if self.mat_color: img = ResizeCanvas(self.width, self.height, self.mat_color, anchor=self.anchor).process(img) return img - - -class SmartCrop(crop.SmartCrop): - def __init__(self, *args, **kwargs): - warnings.warn('The SmartCrop processor has been moved to' - ' `imagekit.processors.crop.SmartCrop`, where it belongs.', - DeprecationWarning) - super(SmartCrop, self).__init__(*args, **kwargs) From 3b4e74c58b5df2a3b933a5c4357ab9cde751985d Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Sat, 18 Feb 2012 00:35:34 -0500 Subject: [PATCH 26/88] Add @version2 to contributors --- AUTHORS | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index d20dfee..8263ccc 100644 --- a/AUTHORS +++ b/AUTHORS @@ -22,7 +22,7 @@ Contributors * `Alexander Bohn`_ * `Timothée Peignier`_ * `Madis Väin`_ - +* `Jan Sagemüller`_ .. _Justin Driscoll: http://github.com/jdriscoll .. _HZDG: http://hzdg.com @@ -39,3 +39,4 @@ Contributors .. _Alexander Bohn: http://github.com/fish2000 .. _Timothée Peignier: http://github.com/cyberdelia .. _Madis Väin: http://github.com/madisvain +.. _Jan Sagemüller: https://github.com/version2 From 2431aa2d2e04d42c1931629266e54769f6b5fe3a Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Sat, 18 Feb 2012 00:48:08 -0500 Subject: [PATCH 27/88] Contributing note --- README.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.rst b/README.rst index 800efa4..5a2ea01 100644 --- a/README.rst +++ b/README.rst @@ -120,3 +120,17 @@ AdminThumbnail can even use a custom template. For more information, see ``imagekit.admin.AdminThumbnail``. .. _`Django admin change list`: https://docs.djangoproject.com/en/dev/intro/tutorial02/#customize-the-admin-change-list + + +Contributing +------------ + +We love contributions! And you don't have to be an expert with the library—or +even Django—to contribute either: ImageKit's processors are standalone classes +that are completely separate from the more intimidating internals of Django's +ORM. If you've written a processor that you think might be useful to other +people, open a pull request so we can take a look! + +ImageKit's image cache backends are also fairly isolated from the ImageKit guts. +If you've fine-tuned one to work perfectly for a popular file storage backend, +let us take a look! Maybe other people could use it. From 8fc71e689a14f3475acbd1ed82ad553f4c6b10be Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Sat, 18 Feb 2012 01:38:19 -0500 Subject: [PATCH 28/88] Cover renamed to ResizeToCover --- imagekit/processors/resize.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py index 721768a..e4b747a 100644 --- a/imagekit/processors/resize.py +++ b/imagekit/processors/resize.py @@ -21,7 +21,7 @@ class Resize(object): return img.resize((self.width, self.height), Image.ANTIALIAS) -class Cover(object): +class ResizeToCover(object): """ Resizes the image to the smallest possible size that will entirely cover the provided dimensions. You probably won't be using this processor directly, @@ -64,7 +64,7 @@ class ResizeToFill(object): def process(self, img): from .crop import Crop - img = Cover(self.width, self.height).process(img) + img = ResizeToCover(self.width, self.height).process(img) return Crop(self.width, self.height, anchor=self.anchor).process(img) @@ -86,7 +86,7 @@ class SmartResize(object): def process(self, img): from .crop import SmartCrop - img = Cover(self.width, self.height).process(img) + img = ResizeToCover(self.width, self.height).process(img) return SmartCrop(self.width, self.height).process(img) From 24a2d772a60cf108fcaebe3f97fa33b16cd63180 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Sat, 18 Feb 2012 01:56:17 -0500 Subject: [PATCH 29/88] Organized tests --- tests/core/models.py | 34 ++++++++++++++++++++ tests/core/tests.py | 70 ++--------------------------------------- tests/core/testutils.py | 38 ++++++++++++++++++++++ 3 files changed, 75 insertions(+), 67 deletions(-) create mode 100644 tests/core/testutils.py diff --git a/tests/core/models.py b/tests/core/models.py index e69de29..35ffffb 100644 --- a/tests/core/models.py +++ b/tests/core/models.py @@ -0,0 +1,34 @@ +from django.db import models + +from imagekit.models.fields import ImageSpecField +from imagekit.processors import Adjust +from imagekit.processors import ResizeToFill +from imagekit.processors import SmartCrop + + +class Photo(models.Model): + original_image = models.ImageField(upload_to='photos') + + thumbnail = ImageSpecField([Adjust(contrast=1.2, sharpness=1.1), + ResizeToFill(50, 50)], image_field='original_image', format='JPEG', + options={'quality': 90}) + + smartcropped_thumbnail = ImageSpecField([Adjust(contrast=1.2, + sharpness=1.1), SmartCrop(50, 50)], image_field='original_image', + format='JPEG', options={'quality': 90}) + + +class AbstractImageModel(models.Model): + original_image = models.ImageField(upload_to='photos') + abstract_class_spec = ImageSpecField() + + class Meta: + abstract = True + + +class ConcreteImageModel1(AbstractImageModel): + first_spec = ImageSpecField() + + +class ConcreteImageModel2(AbstractImageModel): + second_spec = ImageSpecField() diff --git a/tests/core/tests.py b/tests/core/tests.py index 396aab5..d3039e4 100644 --- a/tests/core/tests.py +++ b/tests/core/tests.py @@ -3,77 +3,13 @@ from __future__ import with_statement import os import pickle from StringIO import StringIO -import tempfile -from django.core.files.base import ContentFile -from django.db import models from django.test import TestCase from imagekit import utils -from imagekit.lib import Image -from imagekit.models.fields import ImageSpecField -from imagekit.processors import Adjust -from imagekit.processors import ResizeToFill -from imagekit.processors import SmartCrop - - -def generate_lenna(): - """ - See also: - - http://en.wikipedia.org/wiki/Lenna - http://sipi.usc.edu/database/database.php?volume=misc&image=12 - - """ - tmp = tempfile.TemporaryFile() - lennapath = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', 'lenna-800x600-white-border.jpg') - with open(lennapath, "r+b") as lennafile: - Image.open(lennafile).save(tmp, 'JPEG') - tmp.seek(0) - return tmp - - -def create_instance(model_class, image_name): - instance = model_class() - img = generate_lenna() - file = ContentFile(img.read()) - instance.original_image = file - instance.original_image.save(image_name, file) - instance.save() - img.close() - return instance - - -def create_photo(name): - return create_instance(Photo, name) - - -class Photo(models.Model): - original_image = models.ImageField(upload_to='photos') - - thumbnail = ImageSpecField([Adjust(contrast=1.2, sharpness=1.1), - ResizeToFill(50, 50)], image_field='original_image', format='JPEG', - options={'quality': 90}) - - smartcropped_thumbnail = ImageSpecField([Adjust(contrast=1.2, - sharpness=1.1), SmartCrop(50, 50)], image_field='original_image', - format='JPEG', options={'quality': 90}) - - -class AbstractImageModel(models.Model): - original_image = models.ImageField(upload_to='photos') - abstract_class_spec = ImageSpecField() - - class Meta: - abstract = True - - -class ConcreteImageModel1(AbstractImageModel): - first_spec = ImageSpecField() - - -class ConcreteImageModel2(AbstractImageModel): - second_spec = ImageSpecField() +from .models import (Photo, AbstractImageModel, ConcreteImageModel1, + ConcreteImageModel2) +from .testutils import generate_lenna, create_photo class IKTest(TestCase): diff --git a/tests/core/testutils.py b/tests/core/testutils.py new file mode 100644 index 0000000..27e0f52 --- /dev/null +++ b/tests/core/testutils.py @@ -0,0 +1,38 @@ +import os +import tempfile + +from django.core.files.base import ContentFile + +from imagekit.lib import Image +from .models import Photo + + +def generate_lenna(): + """ + See also: + + http://en.wikipedia.org/wiki/Lenna + http://sipi.usc.edu/database/database.php?volume=misc&image=12 + + """ + tmp = tempfile.TemporaryFile() + lennapath = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', 'lenna-800x600-white-border.jpg') + with open(lennapath, "r+b") as lennafile: + Image.open(lennafile).save(tmp, 'JPEG') + tmp.seek(0) + return tmp + + +def create_instance(model_class, image_name): + instance = model_class() + img = generate_lenna() + file = ContentFile(img.read()) + instance.original_image = file + instance.original_image.save(image_name, file) + instance.save() + img.close() + return instance + + +def create_photo(name): + return create_instance(Photo, name) From 3a5d7da0d8086cf231fa105d019001e27befd0c8 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Thu, 23 Feb 2012 17:46:57 -0500 Subject: [PATCH 30/88] Validate the image any time the file is required This means that accessing `path` or `size` will also validate, closing #109 --- imagekit/models/fields/files.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/imagekit/models/fields/files.py b/imagekit/models/fields/files.py index 8900b74..1e1d3e4 100644 --- a/imagekit/models/fields/files.py +++ b/imagekit/models/fields/files.py @@ -36,12 +36,8 @@ class ImageSpecFieldFile(ImageFieldFile): def _require_file(self): if not self.source_file: raise ValueError("The '%s' attribute's image_field has no file associated with it." % self.attname) - - def _get_file(self): - self.validate() - return super(ImageFieldFile, self).file - - file = property(_get_file, ImageFieldFile._set_file, ImageFieldFile._del_file) + else: + self.validate() def clear(self): return self.field.image_cache_backend.clear(self) @@ -61,11 +57,6 @@ class ImageSpecFieldFile(ImageFieldFile): return self.field.generator.generate_file(self.name, self.source_file, save) - @property - def url(self): - self.validate() - return super(ImageFieldFile, self).url - def delete(self, save=False): """ Pulled almost verbatim from ``ImageFieldFile.delete()`` and From 2cf425d8a302f6e04c9e4549648be1def55d5ed9 Mon Sep 17 00:00:00 2001 From: Clay McClure Date: Wed, 29 Feb 2012 04:50:20 -0500 Subject: [PATCH 31/88] Wrap ContentFile in a file-like object with a filename and content_type. This extra layer of indirection allows us to tack some attributes (name and content_type) onto the underlying file, which we cannot do with a StringIO since it's a native ctype. These attributes are used by various third-party software that expects to work with django.core.files.File instances, and not directly with StringIO instances. By way of example, the django-storages mosso backend (CloudFilesStorage) looks for a content_type attribute, and the cloudfiles Object backend looks for a name attribute. --- AUTHORS | 2 ++ imagekit/generators.py | 21 ++++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index 8263ccc..129596e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -23,6 +23,7 @@ Contributors * `Timothée Peignier`_ * `Madis Väin`_ * `Jan Sagemüller`_ +* `Clay McClure`_ .. _Justin Driscoll: http://github.com/jdriscoll .. _HZDG: http://hzdg.com @@ -40,3 +41,4 @@ Contributors .. _Timothée Peignier: http://github.com/cyberdelia .. _Madis Väin: http://github.com/madisvain .. _Jan Sagemüller: https://github.com/version2 +.. _Clay McClure: https://github.com/claymation diff --git a/imagekit/generators.py b/imagekit/generators.py index bf012e1..a53bf09 100644 --- a/imagekit/generators.py +++ b/imagekit/generators.py @@ -1,4 +1,6 @@ +import mimetypes import os + from StringIO import StringIO from django.core.files.base import ContentFile @@ -9,6 +11,23 @@ from .utils import img_to_fobj, open_image, \ UnknownExtensionError +class SpecFile(ContentFile): + """ + Wraps a ContentFile in a file-like object with a filename + and a content_type. + """ + def __init__(self, filename, content): + self.file = ContentFile(content) + self.file.name = filename + try: + self.file.content_type = mimetypes.guess_type(filename)[0] + except IndexError: + self.file.content_type = None + + def __str__(self): + return self.file.name + + class SpecFileGenerator(object): def __init__(self, processors=None, format=None, options={}, autoconvert=True, storage=None): @@ -50,7 +69,7 @@ class SpecFileGenerator(object): options.items()) imgfile = img_to_fobj(img, format, **options) - content = ContentFile(imgfile.read()) + content = SpecFile(filename, imgfile.read()) return img, content def suggest_extension(self, name): From 42c79d7bb24f34b8627b74fe6b6c9aff2c6a9547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timothe=CC=81e=20Peignier?= Date: Mon, 5 Mar 2012 12:27:53 +0100 Subject: [PATCH 32/88] declare celery backends in a separate module --- imagekit/imagecache/__init__.py | 31 ++++++++++++++++ .../{imagecache.py => imagecache/base.py} | 34 ------------------ imagekit/imagecache/celery.py | 35 +++++++++++++++++++ imagekit/settings.py | 2 +- 4 files changed, 67 insertions(+), 35 deletions(-) create mode 100644 imagekit/imagecache/__init__.py rename imagekit/{imagecache.py => imagecache/base.py} (53%) create mode 100644 imagekit/imagecache/celery.py diff --git a/imagekit/imagecache/__init__.py b/imagekit/imagecache/__init__.py new file mode 100644 index 0000000..05414cc --- /dev/null +++ b/imagekit/imagecache/__init__.py @@ -0,0 +1,31 @@ +from django.core.exceptions import ImproperlyConfigured +from django.utils.importlib import import_module + +_default_image_cache_backend = None + + +def get_default_image_cache_backend(): + """ + Get the default image cache backend. Uses the same method as + django.core.file.storage.get_storage_class + + """ + global _default_image_cache_backend + if not _default_image_cache_backend: + from ..settings import DEFAULT_IMAGE_CACHE_BACKEND as import_path + try: + dot = import_path.rindex('.') + except ValueError: + raise ImproperlyConfigured("%s isn't an image cache backend module." % \ + import_path) + module, classname = import_path[:dot], import_path[dot + 1:] + try: + mod = import_module(module) + except ImportError, e: + raise ImproperlyConfigured('Error importing image cache backend module %s: "%s"' % (module, e)) + try: + cls = getattr(mod, classname) + _default_image_cache_backend = cls() + except AttributeError: + raise ImproperlyConfigured('Image cache backend module "%s" does not define a "%s" class.' % (module, classname)) + return _default_image_cache_backend diff --git a/imagekit/imagecache.py b/imagekit/imagecache/base.py similarity index 53% rename from imagekit/imagecache.py rename to imagekit/imagecache/base.py index 0edc8d6..b7e3bc2 100644 --- a/imagekit/imagecache.py +++ b/imagekit/imagecache/base.py @@ -1,7 +1,3 @@ -from django.core.exceptions import ImproperlyConfigured -from django.utils.importlib import import_module - - class PessimisticImageCacheBackend(object): """ A very safe image cache backend. Guarantees that files will always be @@ -55,33 +51,3 @@ class NonValidatingImageCacheBackend(object): def clear(self, file): file.delete(save=False) - - -_default_image_cache_backend = None - - -def get_default_image_cache_backend(): - """ - Get the default image cache backend. Uses the same method as - django.core.file.storage.get_storage_class - - """ - global _default_image_cache_backend - if not _default_image_cache_backend: - from .settings import DEFAULT_IMAGE_CACHE_BACKEND as import_path - try: - dot = import_path.rindex('.') - except ValueError: - raise ImproperlyConfigured("%s isn't an image cache backend module." % \ - import_path) - module, classname = import_path[:dot], import_path[dot + 1:] - try: - mod = import_module(module) - except ImportError, e: - raise ImproperlyConfigured('Error importing image cache backend module %s: "%s"' % (module, e)) - try: - cls = getattr(mod, classname) - _default_image_cache_backend = cls() - except AttributeError: - raise ImproperlyConfigured('Image cache backend module "%s" does not define a "%s" class.' % (module, classname)) - return _default_image_cache_backend diff --git a/imagekit/imagecache/celery.py b/imagekit/imagecache/celery.py new file mode 100644 index 0000000..77dac73 --- /dev/null +++ b/imagekit/imagecache/celery.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +from celery.task import task + +from imagekit.imagecache.base import PessimisticImageCacheBackend + + +@task +def generate(model, pk, attr): + try: + instance = model._default_manager.get(pk=pk) + except model.DoesNotExist: + pass # The model was deleted since the task was scheduled. NEVER MIND! + else: + getattr(instance, attr).generate(save=True) + + +class CeleryCacheStateBackend(PessimisticImageCacheBackend): + """ + A pessimistic cache state backend that uses celery to generate its spec + images. Like PessimisticCacheStateBackend, this one checks to see if the + file exists on validation, so the storage is hit fairly frequently, but an + image is guaranteed to exist. However, while validation guarantees the + existence of *an* image, it does not necessarily guarantee that you will get + the correct image, as the spec may be pending regeneration. In other words, + while there are `generate` tasks in the queue, it is possible to get a + stale spec image. The tradeoff is that calling `invalidate()` won't block + to interact with file storage. + + """ + + def invalidate(self, file): + generate.delay(file.instance.__class__, file.instance.pk, file.attname) + + def clear(self, file): + file.delete(save=False) diff --git a/imagekit/settings.py b/imagekit/settings.py index d84030d..ca6f4c8 100644 --- a/imagekit/settings.py +++ b/imagekit/settings.py @@ -2,4 +2,4 @@ from django.conf import settings DEFAULT_IMAGE_CACHE_BACKEND = getattr(settings, 'IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND', - 'imagekit.imagecache.PessimisticImageCacheBackend') + 'imagekit.imagecache.base.PessimisticImageCacheBackend') From 0fa86f7da82dafd3b9f4473e1a748c594c2120b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timothe=CC=81e=20Peignier?= Date: Mon, 5 Mar 2012 13:58:09 +0100 Subject: [PATCH 33/88] rename celery module to async --- imagekit/imagecache/{celery.py => async.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename imagekit/imagecache/{celery.py => async.py} (100%) diff --git a/imagekit/imagecache/celery.py b/imagekit/imagecache/async.py similarity index 100% rename from imagekit/imagecache/celery.py rename to imagekit/imagecache/async.py From 9fbdd7bef4ee9d34200311df148a638f0ceb100f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timothe=CC=81e=20Peignier?= Date: Mon, 5 Mar 2012 16:55:40 +0100 Subject: [PATCH 34/88] import base backends in imagecache module --- imagekit/imagecache/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/imagekit/imagecache/__init__.py b/imagekit/imagecache/__init__.py index 05414cc..dab06cd 100644 --- a/imagekit/imagecache/__init__.py +++ b/imagekit/imagecache/__init__.py @@ -1,6 +1,8 @@ from django.core.exceptions import ImproperlyConfigured from django.utils.importlib import import_module +from imagekit.imagecache.base import PessimisticImageCacheBackend, NonValidatingImageCacheBackend + _default_image_cache_backend = None From 983bceff6288a6d240a0a06dfd49c194d4ed30b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timothe=CC=81e=20Peignier?= Date: Mon, 5 Mar 2012 17:00:35 +0100 Subject: [PATCH 35/88] rename CeleryCacheStateBackend as CeleryImageCacheBackend --- imagekit/imagecache/async.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/imagekit/imagecache/async.py b/imagekit/imagecache/async.py index 77dac73..213e679 100644 --- a/imagekit/imagecache/async.py +++ b/imagekit/imagecache/async.py @@ -14,7 +14,7 @@ def generate(model, pk, attr): getattr(instance, attr).generate(save=True) -class CeleryCacheStateBackend(PessimisticImageCacheBackend): +class CeleryImageCacheBackend(PessimisticImageCacheBackend): """ A pessimistic cache state backend that uses celery to generate its spec images. Like PessimisticCacheStateBackend, this one checks to see if the From f3976a5c68c0ec2d5cc1677666ff3edff3b529c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timothe=CC=81e=20Peignier?= Date: Mon, 5 Mar 2012 17:36:08 +0100 Subject: [PATCH 36/88] change import path --- imagekit/imagecache/async.py | 2 +- imagekit/settings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/imagekit/imagecache/async.py b/imagekit/imagecache/async.py index 213e679..f5dc7a2 100644 --- a/imagekit/imagecache/async.py +++ b/imagekit/imagecache/async.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from celery.task import task -from imagekit.imagecache.base import PessimisticImageCacheBackend +from imagekit.imagecache import PessimisticImageCacheBackend @task diff --git a/imagekit/settings.py b/imagekit/settings.py index ca6f4c8..d84030d 100644 --- a/imagekit/settings.py +++ b/imagekit/settings.py @@ -2,4 +2,4 @@ from django.conf import settings DEFAULT_IMAGE_CACHE_BACKEND = getattr(settings, 'IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND', - 'imagekit.imagecache.base.PessimisticImageCacheBackend') + 'imagekit.imagecache.PessimisticImageCacheBackend') From 7af4940914587161c2792475a2c489ed0e3614c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timothe=CC=81e=20Peignier?= Date: Mon, 5 Mar 2012 17:47:19 +0100 Subject: [PATCH 37/88] try to import celery on init --- imagekit/imagecache/__init__.py | 2 +- imagekit/imagecache/async.py | 35 ++++++++++++++++++++------------- imagekit/imagecache/base.py | 7 +++++++ 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/imagekit/imagecache/__init__.py b/imagekit/imagecache/__init__.py index dab06cd..3c582c8 100644 --- a/imagekit/imagecache/__init__.py +++ b/imagekit/imagecache/__init__.py @@ -1,7 +1,7 @@ from django.core.exceptions import ImproperlyConfigured from django.utils.importlib import import_module -from imagekit.imagecache.base import PessimisticImageCacheBackend, NonValidatingImageCacheBackend +from imagekit.imagecache.base import InvalidImageCacheBackendError, PessimisticImageCacheBackend, NonValidatingImageCacheBackend _default_image_cache_backend = None diff --git a/imagekit/imagecache/async.py b/imagekit/imagecache/async.py index f5dc7a2..38426af 100644 --- a/imagekit/imagecache/async.py +++ b/imagekit/imagecache/async.py @@ -1,17 +1,5 @@ # -*- coding: utf-8 -*- -from celery.task import task - -from imagekit.imagecache import PessimisticImageCacheBackend - - -@task -def generate(model, pk, attr): - try: - instance = model._default_manager.get(pk=pk) - except model.DoesNotExist: - pass # The model was deleted since the task was scheduled. NEVER MIND! - else: - getattr(instance, attr).generate(save=True) +from imagekit.imagecache import PessimisticImageCacheBackend, InvalidImageCacheBackendError class CeleryImageCacheBackend(PessimisticImageCacheBackend): @@ -27,9 +15,28 @@ class CeleryImageCacheBackend(PessimisticImageCacheBackend): to interact with file storage. """ + def __init__(self): + try: + import celery + except: + raise InvalidImageCacheBackendError("Celery image cache backend requires either the 'celery' library") + + @property + def _task(self): + from celery.task import task + + @task + def generate(model, pk, attr): + try: + instance = model._default_manager.get(pk=pk) + except model.DoesNotExist: + pass # The model was deleted since the task was scheduled. NEVER MIND! + else: + getattr(instance, attr).generate(save=True) + return generate def invalidate(self, file): - generate.delay(file.instance.__class__, file.instance.pk, file.attname) + self._task.delay(file.instance.__class__, file.instance.pk, file.attname) def clear(self, file): file.delete(save=False) diff --git a/imagekit/imagecache/base.py b/imagekit/imagecache/base.py index b7e3bc2..f06c9b5 100644 --- a/imagekit/imagecache/base.py +++ b/imagekit/imagecache/base.py @@ -1,3 +1,10 @@ +from django.core.exceptions import ImproperlyConfigured + + +class InvalidImageCacheBackendError(ImproperlyConfigured): + pass + + class PessimisticImageCacheBackend(object): """ A very safe image cache backend. Guarantees that files will always be From 1e4ac109f79bc4d54e5faaf39276a404f3e51f01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timothe=CC=81e=20Peignier?= Date: Thu, 8 Mar 2012 19:58:36 +0100 Subject: [PATCH 38/88] rename async to celery --- imagekit/imagecache/{async.py => celery.py} | 2 ++ 1 file changed, 2 insertions(+) rename imagekit/imagecache/{async.py => celery.py} (97%) diff --git a/imagekit/imagecache/async.py b/imagekit/imagecache/celery.py similarity index 97% rename from imagekit/imagecache/async.py rename to imagekit/imagecache/celery.py index 38426af..973f2f3 100644 --- a/imagekit/imagecache/async.py +++ b/imagekit/imagecache/celery.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from __future__ import absolute_import + from imagekit.imagecache import PessimisticImageCacheBackend, InvalidImageCacheBackendError From 9700a10d4bf21db18f8ab27dae9a5f1c9192d1da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timothe=CC=81e=20Peignier?= Date: Sat, 10 Mar 2012 12:02:32 +0100 Subject: [PATCH 39/88] fix documentation build. close #56 --- docs/apireference.rst | 8 ++++---- docs/conf.py | 3 ++- docs/index.rst | 4 +++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/apireference.rst b/docs/apireference.rst index cbaca49..d4a2ed8 100644 --- a/docs/apireference.rst +++ b/docs/apireference.rst @@ -5,7 +5,7 @@ API Reference :mod:`models` Module -------------------- -.. automodule:: imagekit.models +.. automodule:: imagekit.models.fields :members: @@ -13,13 +13,13 @@ API Reference ------------------------ .. automodule:: imagekit.processors - :members: + :members: .. automodule:: imagekit.processors.resize - :members: + :members: .. automodule:: imagekit.processors.crop -:members: + :members: :mod:`admin` Module diff --git a/docs/conf.py b/docs/conf.py index 274e849..e37017b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,6 +18,7 @@ import sys, os # documentation root, use os.path.abspath to make it absolute, like shown here. #sys.path.insert(0, os.path.abspath('.')) sys.path.append(os.path.abspath('_themes')) +os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' # -- General configuration ----------------------------------------------------- @@ -121,7 +122,7 @@ html_theme_path = ['_themes'] # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +# html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. diff --git a/docs/index.rst b/docs/index.rst index 5e78c02..5634f21 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,7 +7,9 @@ Getting Started Commands -------- -.. automodule:: imagekit.management.commands.ikflush +.. automodule:: imagekit.management.commands.ikcacheinvalidate + +.. automodule:: imagekit.management.commands.ikcachevalidate Authors From d1af56ba3e44e44b9ebb5423357d17bc16ab87a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timothe=CC=81e=20Peignier?= Date: Thu, 15 Mar 2012 10:37:28 +0100 Subject: [PATCH 40/88] ensure task is correctly created --- imagekit/imagecache/celery.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/imagekit/imagecache/celery.py b/imagekit/imagecache/celery.py index 973f2f3..7e3cf46 100644 --- a/imagekit/imagecache/celery.py +++ b/imagekit/imagecache/celery.py @@ -4,6 +4,15 @@ from __future__ import absolute_import from imagekit.imagecache import PessimisticImageCacheBackend, InvalidImageCacheBackendError +def generate(model, pk, attr): + try: + instance = model._default_manager.get(pk=pk) + except model.DoesNotExist: + pass # The model was deleted since the task was scheduled. NEVER MIND! + else: + getattr(instance, attr).generate(save=True) + + class CeleryImageCacheBackend(PessimisticImageCacheBackend): """ A pessimistic cache state backend that uses celery to generate its spec @@ -19,23 +28,11 @@ class CeleryImageCacheBackend(PessimisticImageCacheBackend): """ def __init__(self): try: - import celery + from celery.task import task except: raise InvalidImageCacheBackendError("Celery image cache backend requires either the 'celery' library") - - @property - def _task(self): - from celery.task import task - - @task - def generate(model, pk, attr): - try: - instance = model._default_manager.get(pk=pk) - except model.DoesNotExist: - pass # The model was deleted since the task was scheduled. NEVER MIND! - else: - getattr(instance, attr).generate(save=True) - return generate + if not getattr(CeleryImageCacheBackend, '_task', None): + CeleryImageCacheBackend._task = task(generate) def invalidate(self, file): self._task.delay(file.instance.__class__, file.instance.pk, file.attname) From 3d1480004983bee340d22a14862d714eec7276ae Mon Sep 17 00:00:00 2001 From: Bryan Veloso Date: Tue, 20 Mar 2012 02:39:28 -0700 Subject: [PATCH 41/88] Getting rid of versiontools. --- setup.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/setup.py b/setup.py index 46aef9a..50753e7 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,4 @@ setup( 'Programming Language :: Python :: 2.7', 'Topic :: Utilities' ], - setup_requires=[ - 'versiontools >= 1.8', - ], ) From cad5d946611e95350c0db741d164fed2d286b1e4 Mon Sep 17 00:00:00 2001 From: Bryan Veloso Date: Tue, 20 Mar 2012 02:39:36 -0700 Subject: [PATCH 42/88] I think this is fitting now. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 50753e7..c43c172 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ setup( zip_safe=False, include_package_data=True, classifiers=[ - 'Development Status :: 4 - Beta', + 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Framework :: Django', 'Intended Audience :: Developers', From c46a403baacf7786b5154b06782207a8b3a52c25 Mon Sep 17 00:00:00 2001 From: Bryan Veloso Date: Tue, 20 Mar 2012 02:44:53 -0700 Subject: [PATCH 43/88] Using Django's method of versioning. Also updating the docs' version. --- docs/conf.py | 4 ++-- imagekit/__init__.py | 32 +++++++++++++++++++++++++++++++- setup.py | 5 ++++- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index e37017b..1ba36ca 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ copyright = u'2011, Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & # built documents. # # The short X.Y version. -version = '1.1.0' +version = '2.0.0' # The full version, including alpha/beta/rc tags. -release = '1.1.0' +release = '2.0.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/imagekit/__init__.py b/imagekit/__init__.py index 1d7d570..1ce4055 100644 --- a/imagekit/__init__.py +++ b/imagekit/__init__.py @@ -1,4 +1,34 @@ __title__ = 'django-imagekit' __author__ = 'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett, Matthew Tretter, Eric Eldredge' -__version__ = (2, 0, 0, 'alpha', 1) +__version__ = (2, 0, 0, 'rc', 1) __license__ = 'BSD' + + +def get_version(version=None): + """Derives a PEP386-compliant version number from VERSION.""" + if version is None: + version = __version__ + assert len(version) == 5 + assert version[3] in ('alpha', 'beta', 'rc', 'final') + + # Now build the two parts of the version number: + # main = X.Y[.Z] + # sub = .devN - for pre-alpha releases + # | {a|b|c}N - for alpha, beta and rc releases + + parts = 2 if version[2] == 0 else 3 + main = '.'.join(str(x) for x in version[:parts]) + + sub = '' + if version[3] == 'alpha' and version[4] == 0: + # At the toplevel, this would cause an import loop. + from django.utils.version import get_svn_revision + svn_revision = get_svn_revision()[4:] + if svn_revision != 'unknown': + sub = '.dev%s' % svn_revision + + elif version[3] != 'final': + mapping = {'alpha': 'a', 'beta': 'b', 'rc': 'c'} + sub = mapping[version[3]] + str(version[4]) + + return main + sub diff --git a/setup.py b/setup.py index c43c172..6fdb8fb 100644 --- a/setup.py +++ b/setup.py @@ -11,9 +11,12 @@ if 'publish' in sys.argv: read = lambda filepath: codecs.open(filepath, 'r', 'utf-8').read() +# Dynamically calculate the version based on imagekit.VERSION. +version = __import__('imagekit').get_version() + setup( name='django-imagekit', - version=':versiontools:imagekit:', + version=version, description='Automated image processing for Django models.', long_description=read(os.path.join(os.path.dirname(__file__), 'README.rst')), author='Justin Driscoll', From bc7852be21cde5915250d6cb5562fedd844906d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Peignier?= Date: Tue, 20 Mar 2012 20:06:41 +0100 Subject: [PATCH 44/88] fix typo in celery backend --- imagekit/imagecache/celery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/imagekit/imagecache/celery.py b/imagekit/imagecache/celery.py index 7e3cf46..3013c66 100644 --- a/imagekit/imagecache/celery.py +++ b/imagekit/imagecache/celery.py @@ -30,7 +30,7 @@ class CeleryImageCacheBackend(PessimisticImageCacheBackend): try: from celery.task import task except: - raise InvalidImageCacheBackendError("Celery image cache backend requires either the 'celery' library") + raise InvalidImageCacheBackendError("Celery image cache backend requires the 'celery' library") if not getattr(CeleryImageCacheBackend, '_task', None): CeleryImageCacheBackend._task = task(generate) From 39b2feda5a07abb06c3a7d6798e2edb6a770869e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timothe=CC=81e=20Peignier?= Date: Fri, 23 Mar 2012 12:13:53 +0100 Subject: [PATCH 45/88] delete file before re-generating it to avoid ill named file --- imagekit/imagecache/celery.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/imagekit/imagecache/celery.py b/imagekit/imagecache/celery.py index 7e3cf46..daa2a58 100644 --- a/imagekit/imagecache/celery.py +++ b/imagekit/imagecache/celery.py @@ -10,7 +10,9 @@ def generate(model, pk, attr): except model.DoesNotExist: pass # The model was deleted since the task was scheduled. NEVER MIND! else: - getattr(instance, attr).generate(save=True) + field_file = getattr(instance, attr) + field_file.delete(save=False) + field_file.generate(save=True) class CeleryImageCacheBackend(PessimisticImageCacheBackend): From 70f80ba7fa942abf58afe0c153956b43de053f58 Mon Sep 17 00:00:00 2001 From: Bryan Veloso Date: Fri, 23 Mar 2012 14:32:52 -0700 Subject: [PATCH 46/88] Bumping an version number. --- imagekit/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/imagekit/__init__.py b/imagekit/__init__.py index 1ce4055..d525af2 100644 --- a/imagekit/__init__.py +++ b/imagekit/__init__.py @@ -1,6 +1,6 @@ __title__ = 'django-imagekit' __author__ = 'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett, Matthew Tretter, Eric Eldredge' -__version__ = (2, 0, 0, 'rc', 1) +__version__ = (2, 0, 0, 'final', 0) __license__ = 'BSD' From 2d02e02b8cfd3b45294c85efa310952effed809a Mon Sep 17 00:00:00 2001 From: Bryan Veloso Date: Fri, 23 Mar 2012 16:42:14 -0700 Subject: [PATCH 47/88] Intial pass of the 2.0 release notes. --- docs/changelog.rst | 66 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 742ad29..f27de82 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,68 @@ Changelog ========= +v2.0.0 +------ + +- Added the concept of cache state backends. Cache state backends assume + control of an image's CRUD actions from `ImageSpec` in versions past. The + default backend maintins the current behavior: invalidating an image and + deleting it, then validating that it creates the file if it doesn't already + exist. One can create custom cache state backends to control how their + images are cached (e.g., Celery, etc.). + + ImageKit ships with three built-in backends: + + - ``imagecache.base.PessimisticImageCacheBackend`` - A very safe image cache + backend. Guarantees that files will always be available, but at the cost + of hitting the storage backend. + - ``imagecache.base.NonValidatingImageCacheBackend`` - A backend that is + super optimistic about the existence of spec files. It will hit your file + storage much less frequently than the pessimistic backend, but it is + technically possible for a cache file to be missing after validation. + - ``imagecache.celery.CeleryImageCacheBackend`` - A pessimistic cache state + backend that uses celery to generate its spec images. Like + ``PessimisticCacheStateBackend``, this one checks to see if the file + exists on validation, so the storage is hit fairly frequently, but an + image is guaranteed to exist. However, while validation guarantees the + existence of *an* image, it does not necessarily guarantee that you will + get the correct image, as the spec may be pending regeneration. In other + words, while there are `generate` tasks in the queue, it is possible to + get a stale spec image. The tradeoff is that calling `invalidate()` + won't block to interact with file storage. + +- ``resize.Crop`` has been renamed to ``reszie.Fill``. Using ``resize.Crop`` + will throw a ``DeprecationWarning``. + +- New processors have been added: + + - ``crop.BasicCrop`` - Crop using provided box. + - ``crop.SmartCrop`` - Crop to provided size, trimming based on entropy. + - ``crop.TrimBorderColor`` - Trim the specified color from the specified + sides. + - ``resize.AddBorder`` - Add a border of specific color and size to an + image. + - ``resize.Resize`` - Scale to the provided dimensions (can distort). + - ``resize.ResizeToCover`` - Scale to the smallest size that will cover + the specified dimensions. Used internally by ``Fill`` and ``SmartFill``. + - ``resize.ResizeToFill`` - Scale to fill the provided dimensions, + trimming away excess using ``Crop``. + - ``resize.ResizeToFit`` - Scale to fit the provided dimensions. + - ``resize.ResizeCanvas`` - Takes an image an resizes the canvas, using a + specific background color if the new size is larger than the current + image. + - ``resize.SmartFill`` - Scale to fill the provided dimensions, trimming + away excess using ``SmartCrop``. + +- ``mat_color`` has been added as an arguemnt to the ``ResizeProcessor``. If + set, the target image size will be enforced and the specified color will be + used as background color to pad the image. + +- We now use `Tox`_ to automate testing. + +.. _`Tox`: http://pypi.python.org/pypi/tox + + v1.1.0 ------ @@ -15,6 +77,7 @@ v1.1.0 - The private ``_Resize`` class has been removed. + v1.0.3 ------ @@ -30,6 +93,7 @@ v1.0.3 - Fixed PIL zeroing out files when write mode is enabled. + v1.0.2 ------ @@ -42,6 +106,7 @@ v1.0.2 - Fixed a regression from the 0.4.x series in which ImageKit was unable to convert a PNG file in ``P`` or "palette" mode to JPEG. + v1.0.1 ------ @@ -51,6 +116,7 @@ v1.0.1 - Fixed the included admin template not being found when ImageKit was and the packaging of the included admin templates. + v1.0 ---- From e7fe6d1d980913bc6db664806f31c7aa36b97e4d Mon Sep 17 00:00:00 2001 From: Bryan Veloso Date: Fri, 23 Mar 2012 16:47:46 -0700 Subject: [PATCH 48/88] Spelling error. --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index f27de82..8e94df0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -31,7 +31,7 @@ v2.0.0 get a stale spec image. The tradeoff is that calling `invalidate()` won't block to interact with file storage. -- ``resize.Crop`` has been renamed to ``reszie.Fill``. Using ``resize.Crop`` +- ``resize.Crop`` has been renamed to ``resize.Fill``. Using ``resize.Crop`` will throw a ``DeprecationWarning``. - New processors have been added: From 8aafc3681ac54c63e886fa82d58c05796a2ec2d4 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Sat, 24 Mar 2012 18:40:00 -0400 Subject: [PATCH 49/88] Some changelog corrections --- docs/changelog.rst | 74 ++++++++++++++++++++++++---------------------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 8e94df0..9257870 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,58 +4,60 @@ Changelog v2.0.0 ------ -- Added the concept of cache state backends. Cache state backends assume - control of an image's CRUD actions from `ImageSpec` in versions past. The - default backend maintins the current behavior: invalidating an image and - deleting it, then validating that it creates the file if it doesn't already - exist. One can create custom cache state backends to control how their - images are cached (e.g., Celery, etc.). +- Added the concept of image cache backends. Image cache backends assume + control of validating and invalidating the cached images from `ImageSpec` in + versions past. The default backend maintins the current behavior: invalidating + an image deletes it, while validating checks whether the file exists and + creates the file if it doesn't. One can create custom image cache backends to + control how their images are cached (e.g., Celery, etc.). ImageKit ships with three built-in backends: - - ``imagecache.base.PessimisticImageCacheBackend`` - A very safe image cache - backend. Guarantees that files will always be available, but at the cost - of hitting the storage backend. - - ``imagecache.base.NonValidatingImageCacheBackend`` - A backend that is + - ``imagekit.imagecache.PessimisticImageCacheBackend`` - A very safe image + cache backend. Guarantees that files will always be available, but at the + cost of hitting the storage backend. + - ``imagekit.imagecache.NonValidatingImageCacheBackend`` - A backend that is super optimistic about the existence of spec files. It will hit your file storage much less frequently than the pessimistic backend, but it is technically possible for a cache file to be missing after validation. - - ``imagecache.celery.CeleryImageCacheBackend`` - A pessimistic cache state - backend that uses celery to generate its spec images. Like + - ``imagekit.imagecache.celery.CeleryImageCacheBackend`` - A pessimistic cache + state backend that uses celery to generate its spec images. Like ``PessimisticCacheStateBackend``, this one checks to see if the file exists on validation, so the storage is hit fairly frequently, but an image is guaranteed to exist. However, while validation guarantees the existence of *an* image, it does not necessarily guarantee that you will get the correct image, as the spec may be pending regeneration. In other - words, while there are `generate` tasks in the queue, it is possible to - get a stale spec image. The tradeoff is that calling `invalidate()` + words, while there are ``generate`` tasks in the queue, it is possible to + get a stale spec image. The tradeoff is that calling ``invalidate()`` won't block to interact with file storage. -- ``resize.Crop`` has been renamed to ``resize.Fill``. Using ``resize.Crop`` - will throw a ``DeprecationWarning``. +- Some of the processors have been renamed and several new ones have been added: -- New processors have been added: - - - ``crop.BasicCrop`` - Crop using provided box. - - ``crop.SmartCrop`` - Crop to provided size, trimming based on entropy. - - ``crop.TrimBorderColor`` - Trim the specified color from the specified - sides. - - ``resize.AddBorder`` - Add a border of specific color and size to an + - ``imagekit.processors.ResizeToFill`` - (previously + ``imagekit.processors.resize.Crop``) Scales the image to fill the provided + dimensions and then trims away the excess. + - ``imagekit.processors.ResizeToFit`` - (previously + ``imagekit.processors.resize.Fit``) Scale to fit the provided dimensions. + - ``imagekit.processors.SmartResize`` - Like ``ResizeToFill``, but crops using + entroy (``SmartCrop``) instead of an anchor argument. + - ``imagekit.processors.BasicCrop`` - Crop using provided box. + - ``imagekit.processors.SmartCrop`` - (previously + ``imagekit.processors.resize.SmartCrop``) Crop to provided size, trimming + based on entropy. + - ``imagekit.processors.TrimBorderColor`` - Trim the specified color from the + specified sides. + - ``imagekit.processors.AddBorder`` - Add a border of specific color and + thickness to an image. + - ``imagekit.processors.Resize`` - Scale to the provided dimensions (can distort). + - ``imagekit.processors.ResizeToCover`` - Scale to the smallest size that will + cover the specified dimensions. Used internally by ``Fill`` and + ``SmartFill``. + - ``imagekit.processors.ResizeCanvas`` - Takes an image an resizes the canvas, + using a specific background color if the new size is larger than the current image. - - ``resize.Resize`` - Scale to the provided dimensions (can distort). - - ``resize.ResizeToCover`` - Scale to the smallest size that will cover - the specified dimensions. Used internally by ``Fill`` and ``SmartFill``. - - ``resize.ResizeToFill`` - Scale to fill the provided dimensions, - trimming away excess using ``Crop``. - - ``resize.ResizeToFit`` - Scale to fit the provided dimensions. - - ``resize.ResizeCanvas`` - Takes an image an resizes the canvas, using a - specific background color if the new size is larger than the current - image. - - ``resize.SmartFill`` - Scale to fill the provided dimensions, trimming - away excess using ``SmartCrop``. -- ``mat_color`` has been added as an arguemnt to the ``ResizeProcessor``. If - set, the target image size will be enforced and the specified color will be +- ``mat_color`` has been added as an arguemnt to ``ResizeToFit``. If set, the + the target image size will be enforced and the specified color will be used as background color to pad the image. - We now use `Tox`_ to automate testing. From 479270e498a063408b6c576a4bc9c328b4faa3da Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Sat, 24 Mar 2012 19:03:24 -0400 Subject: [PATCH 50/88] Introduction to image cache backends --- README.rst | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 5a2ea01..4180705 100644 --- a/README.rst +++ b/README.rst @@ -51,6 +51,17 @@ an ImageFile-like object (just like with a normal Check out ``imagekit.models.fields.ImageSpecField`` for more information. +If you only want to save the processed image (without maintaining the original), +you can use a ``ProcessedImageField``:: + + from django.db import models + from imagekit.models.fields import ProcessedImageField + + class Photo(models.Model): + processed_image = ImageSpecField(format='JPEG', options={'quality': 90}) + +See the class documentation for details. + Processors ---------- @@ -78,7 +89,8 @@ The ``thumbnail`` property will now return a cropped image:: The original image is not modified; ``thumbnail`` is a new file that is the result of running the ``imagekit.processors.ResizeToFill`` processor on the -original. +original. (If you only need to save the processed image, and not the original, +pass processors to a ``ProcessedImageField`` instead of an ``ImageSpecField``.) The ``imagekit.processors`` module contains processors for many common image manipulations, like resizing, rotating, and color adjustments. However, @@ -122,6 +134,36 @@ AdminThumbnail can even use a custom template. For more information, see .. _`Django admin change list`: https://docs.djangoproject.com/en/dev/intro/tutorial02/#customize-the-admin-change-list +Image Cache Backends +-------------------- + +Whenever you access properties like ``url``, ``width`` and ``height`` of an +``ImageSpecField``, its cached image is validated; whenever you save a new image +to the ``ImageField`` your spec uses as a source, the spec image is invalidated. +The default way to validate a cache image is to check to see if the file exists +and, if not, generate a new one; the default way to invalidate the cache is to +delete the image. This is a very simple and straightforward way to handle cache +validation, but it has its drawbacks—for example, checking to see if the image +exists means frequently hitting the storage backend. + +Because of this, ImageKit allows you to define custom image cache backends. To +be a valid image cache backend, a class must implement three methods: +``validate``, ``invalidate``, and ``clear`` (which is called when the image is +no longer needed in any form, i.e. the model is deleted). Each of these methods +must accept a file object, but the internals are up to you. For example, you +could store the state (valid, invalid) of the cache in a database to avoid +filesystem access. You can then specify your image cache backend on a per-field +basis:: + + class Photo(models.Model): + ... + thumbnail = ImageSpecField(..., image_cache_backend=MyImageCacheBackend()) + +Or in your ``settings.py`` file if you want to use it as the default:: + + IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND = 'path.to.MyImageCacheBackend' + + Contributing ------------ From db4df4f82cd87a3147b394ab31585dea3587adfa Mon Sep 17 00:00:00 2001 From: Clay McClure Date: Tue, 27 Mar 2012 17:17:54 -0400 Subject: [PATCH 51/88] Add SpecFile.__unicode__ SpecFile is based after django.core.files.base.ContentFile, which lacks a __unicode__ method. This leads to an AttributeError when SpecFile.__repr__ is called. This is easily resolved by giving SpecFile a proper __unicode__ method. --- imagekit/generators.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/imagekit/generators.py b/imagekit/generators.py index a53bf09..39f64f4 100644 --- a/imagekit/generators.py +++ b/imagekit/generators.py @@ -4,6 +4,7 @@ import os from StringIO import StringIO from django.core.files.base import ContentFile +from django.utils.encoding import smart_str, smart_unicode from .processors import ProcessorPipeline, AutoConvert from .utils import img_to_fobj, open_image, \ @@ -25,7 +26,10 @@ class SpecFile(ContentFile): self.file.content_type = None def __str__(self): - return self.file.name + return smart_str(self.file.name or '') + + def __unicode__(self): + return smart_unicode(self.file.name or u'') class SpecFileGenerator(object): From 6e4a8d1b5870031738192b0b2590966926794be6 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Mon, 9 Apr 2012 21:25:46 -0400 Subject: [PATCH 52/88] Woah, globals --- imagekit/generators.py | 4 ++-- imagekit/models/fields/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/imagekit/generators.py b/imagekit/generators.py index 39f64f4..bdda2c1 100644 --- a/imagekit/generators.py +++ b/imagekit/generators.py @@ -33,11 +33,11 @@ class SpecFile(ContentFile): class SpecFileGenerator(object): - def __init__(self, processors=None, format=None, options={}, + def __init__(self, processors=None, format=None, options=None, autoconvert=True, storage=None): self.processors = processors self.format = format - self.options = options + self.options = options or {} self.autoconvert = autoconvert self.storage = storage diff --git a/imagekit/models/fields/__init__.py b/imagekit/models/fields/__init__.py index 505ea7e..990e5c3 100644 --- a/imagekit/models/fields/__init__.py +++ b/imagekit/models/fields/__init__.py @@ -15,7 +15,7 @@ class ImageSpecField(object): variants of uploaded images to your models. """ - def __init__(self, processors=None, format=None, options={}, + def __init__(self, processors=None, format=None, options=None, image_field=None, pre_cache=None, storage=None, cache_to=None, autoconvert=True, image_cache_backend=None): """ @@ -146,7 +146,7 @@ class ProcessedImageField(models.ImageField): """ attr_class = ProcessedImageFieldFile - def __init__(self, processors=None, format=None, options={}, + def __init__(self, processors=None, format=None, options=None, verbose_name=None, name=None, width_field=None, height_field=None, autoconvert=True, **kwargs): """ From 8044b97a33a51fce0cb365b4c9062cdd487698e7 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Thu, 19 Apr 2012 21:18:42 -0400 Subject: [PATCH 53/88] Extract suggest_extension util from generator --- imagekit/generators.py | 27 ++------------------------- imagekit/models/fields/__init__.py | 3 ++- imagekit/models/fields/files.py | 7 ++++--- imagekit/utils.py | 24 ++++++++++++++++++++++++ 4 files changed, 32 insertions(+), 29 deletions(-) diff --git a/imagekit/generators.py b/imagekit/generators.py index bdda2c1..a104772 100644 --- a/imagekit/generators.py +++ b/imagekit/generators.py @@ -7,9 +7,8 @@ from django.core.files.base import ContentFile from django.utils.encoding import smart_str, smart_unicode from .processors import ProcessorPipeline, AutoConvert -from .utils import img_to_fobj, open_image, \ - format_to_extension, extension_to_format, UnknownFormatError, \ - UnknownExtensionError +from .utils import (img_to_fobj, open_image, extension_to_format, + UnknownExtensionError) class SpecFile(ContentFile): @@ -76,28 +75,6 @@ class SpecFileGenerator(object): content = SpecFile(filename, imgfile.read()) return img, content - def suggest_extension(self, name): - original_extension = os.path.splitext(name)[1] - try: - suggested_extension = format_to_extension(self.format) - except UnknownFormatError: - extension = original_extension - else: - if suggested_extension.lower() == original_extension.lower(): - extension = original_extension - else: - try: - original_format = extension_to_format(original_extension) - except UnknownExtensionError: - extension = suggested_extension - else: - # If the formats match, give precedence to the original extension. - if self.format.lower() == original_format.lower(): - extension = original_extension - else: - extension = suggested_extension - return extension - def generate_file(self, filename, source_file, save=True): """ Generates a new image file by processing the source file and returns diff --git a/imagekit/models/fields/__init__.py b/imagekit/models/fields/__init__.py index 990e5c3..b04443f 100644 --- a/imagekit/models/fields/__init__.py +++ b/imagekit/models/fields/__init__.py @@ -7,6 +7,7 @@ from ...imagecache import get_default_image_cache_backend from ...generators import SpecFileGenerator from .files import ImageSpecFieldFile, ProcessedImageFieldFile from .utils import ImageSpecFileDescriptor, ImageKitMeta, BoundImageKitMeta +from ...utils import suggest_extension class ImageSpecField(object): @@ -169,7 +170,7 @@ class ProcessedImageField(models.ImageField): filename = os.path.normpath(self.storage.get_valid_name( os.path.basename(filename))) name, ext = os.path.splitext(filename) - ext = self.generator.suggest_extension(filename) + ext = suggest_extension(filename, self.generator.format) return u'%s%s' % (name, ext) diff --git a/imagekit/models/fields/files.py b/imagekit/models/fields/files.py index 1e1d3e4..153c69f 100644 --- a/imagekit/models/fields/files.py +++ b/imagekit/models/fields/files.py @@ -4,6 +4,8 @@ import datetime from django.db.models.fields.files import ImageField, ImageFieldFile from django.utils.encoding import force_unicode, smart_str +from ...utils import suggest_extension + class ImageSpecFieldFile(ImageFieldFile): def __init__(self, instance, field, attname): @@ -118,9 +120,8 @@ class ImageSpecFieldFile(ImageFieldFile): raise Exception('No cache_to or default_cache_to value' ' specified') if callable(cache_to): - suggested_extension = \ - self.field.generator.suggest_extension( - self.source_file.name) + suggested_extension = suggest_extension( + self.source_file.name, self.field.generator.format) new_filename = force_unicode( datetime.datetime.now().strftime( smart_str(cache_to(self.instance, diff --git a/imagekit/utils.py b/imagekit/utils.py index 305bdd4..5d21e71 100644 --- a/imagekit/utils.py +++ b/imagekit/utils.py @@ -1,3 +1,4 @@ +import os import tempfile import types @@ -165,3 +166,26 @@ def validate_app_cache(apps, force_revalidation=False): if force_revalidation: f.invalidate() f.validate() + + +def suggest_extension(name, format): + original_extension = os.path.splitext(name)[1] + try: + suggested_extension = format_to_extension(format) + except UnknownFormatError: + extension = original_extension + else: + if suggested_extension.lower() == original_extension.lower(): + extension = original_extension + else: + try: + original_format = extension_to_format(original_extension) + except UnknownExtensionError: + extension = suggested_extension + else: + # If the formats match, give precedence to the original extension. + if format.lower() == original_format.lower(): + extension = original_extension + else: + extension = suggested_extension + return extension From 7d5937ebe64d1f26f103df3040b15588bc4556a4 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Thu, 19 Apr 2012 21:26:42 -0400 Subject: [PATCH 54/88] Rename SpecFile and move it to utils --- imagekit/generators.py | 28 ++-------------------------- imagekit/utils.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/imagekit/generators.py b/imagekit/generators.py index a104772..e6cfb2f 100644 --- a/imagekit/generators.py +++ b/imagekit/generators.py @@ -1,36 +1,12 @@ -import mimetypes import os from StringIO import StringIO -from django.core.files.base import ContentFile -from django.utils.encoding import smart_str, smart_unicode - from .processors import ProcessorPipeline, AutoConvert -from .utils import (img_to_fobj, open_image, extension_to_format, +from .utils import (img_to_fobj, open_image, IKContentFile, extension_to_format, UnknownExtensionError) -class SpecFile(ContentFile): - """ - Wraps a ContentFile in a file-like object with a filename - and a content_type. - """ - def __init__(self, filename, content): - self.file = ContentFile(content) - self.file.name = filename - try: - self.file.content_type = mimetypes.guess_type(filename)[0] - except IndexError: - self.file.content_type = None - - def __str__(self): - return smart_str(self.file.name or '') - - def __unicode__(self): - return smart_unicode(self.file.name or u'') - - class SpecFileGenerator(object): def __init__(self, processors=None, format=None, options=None, autoconvert=True, storage=None): @@ -72,7 +48,7 @@ class SpecFileGenerator(object): options.items()) imgfile = img_to_fobj(img, format, **options) - content = SpecFile(filename, imgfile.read()) + content = IKContentFile(filename, imgfile.read()) return img, content def generate_file(self, filename, source_file, save=True): diff --git a/imagekit/utils.py b/imagekit/utils.py index 5d21e71..11f4a4d 100644 --- a/imagekit/utils.py +++ b/imagekit/utils.py @@ -1,13 +1,36 @@ import os +import mimetypes import tempfile import types +from django.core.files.base import ContentFile from django.db.models.loading import cache from django.utils.functional import wraps +from django.utils.encoding import smart_str, smart_unicode from imagekit.lib import Image, ImageFile +class IKContentFile(ContentFile): + """ + Wraps a ContentFile in a file-like object with a filename + and a content_type. + """ + def __init__(self, filename, content): + self.file = ContentFile(content) + self.file.name = filename + try: + self.file.content_type = mimetypes.guess_type(filename)[0] + except IndexError: + self.file.content_type = None + + def __str__(self): + return smart_str(self.file.name or '') + + def __unicode__(self): + return smart_unicode(self.file.name or u'') + + def img_to_fobj(img, format, **kwargs): tmp = tempfile.TemporaryFile() try: From e0c9708e63b0b312aab9b82c248875901847d8b3 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Thu, 19 Apr 2012 23:01:07 -0400 Subject: [PATCH 55/88] Extract reusable save_image function --- imagekit/generators.py | 12 ++------- imagekit/utils.py | 57 +++++++++++++++++++++++++++++------------- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/imagekit/generators.py b/imagekit/generators.py index e6cfb2f..4d24bce 100644 --- a/imagekit/generators.py +++ b/imagekit/generators.py @@ -1,8 +1,7 @@ import os - from StringIO import StringIO -from .processors import ProcessorPipeline, AutoConvert +from .processors import ProcessorPipeline from .utils import (img_to_fobj, open_image, IKContentFile, extension_to_format, UnknownExtensionError) @@ -38,14 +37,7 @@ class SpecFileGenerator(object): format = extension_to_format(extension) except UnknownExtensionError: pass - format = format or img.format or original_format or 'JPEG' - - # Run the AutoConvert processor - if self.autoconvert: - autoconvert_processor = AutoConvert(format) - img = autoconvert_processor.process(img) - options = dict(autoconvert_processor.save_kwargs.items() + \ - options.items()) + format = format or img.format or original_format or 'JPEG' imgfile = img_to_fobj(img, format, **options) content = IKContentFile(filename, imgfile.read()) diff --git a/imagekit/utils.py b/imagekit/utils.py index 11f4a4d..c54ff3f 100644 --- a/imagekit/utils.py +++ b/imagekit/utils.py @@ -8,7 +8,8 @@ from django.db.models.loading import cache from django.utils.functional import wraps from django.utils.encoding import smart_str, smart_unicode -from imagekit.lib import Image, ImageFile +from .lib import Image, ImageFile +from .processors import AutoConvert class IKContentFile(ContentFile): @@ -31,22 +32,8 @@ class IKContentFile(ContentFile): return smart_unicode(self.file.name or u'') -def img_to_fobj(img, format, **kwargs): - tmp = tempfile.TemporaryFile() - try: - img.save(tmp, format, **kwargs) - except IOError: - # PIL can have problems saving large JPEGs if MAXBLOCK isn't big enough, - # So if we have a problem saving, we temporarily increase it. See - # http://github.com/jdriscoll/django-imagekit/issues/50 - old_maxblock = ImageFile.MAXBLOCK - ImageFile.MAXBLOCK = img.size[0] * img.size[1] - try: - img.save(tmp, format, **kwargs) - finally: - ImageFile.MAXBLOCK = old_maxblock - tmp.seek(0) - return tmp +def img_to_fobj(img, format, autoconvert=True, **options): + return save_image(img, tempfile.TemporaryFile(), format, options, autoconvert) def get_spec_files(instance): @@ -212,3 +199,39 @@ def suggest_extension(name, format): else: extension = suggested_extension return extension + + +def save_image(img, outfile, format, options=None, autoconvert=True): + options = options or {} + + if autoconvert: + autoconvert_processor = AutoConvert(format) + img = autoconvert_processor.process(img) + options = dict(autoconvert_processor.save_kwargs.items() + + options.items()) + + # Attempt to reset the file pointer. + try: + outfile.seek(0) + except AttributeError: + pass + + try: + img.save(outfile, format, **options) + except IOError: + # PIL can have problems saving large JPEGs if MAXBLOCK isn't big enough, + # So if we have a problem saving, we temporarily increase it. See + # http://github.com/jdriscoll/django-imagekit/issues/50 + old_maxblock = ImageFile.MAXBLOCK + ImageFile.MAXBLOCK = img.size[0] * img.size[1] + try: + img.save(outfile, format, **options) + finally: + ImageFile.MAXBLOCK = old_maxblock + + try: + outfile.seek(0) + except AttributeError: + pass + + return outfile From 6f8a22c5bfc02930d869f8f5637b1d980a44fbb1 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Thu, 19 Apr 2012 23:35:49 -0400 Subject: [PATCH 56/88] Use StringIO instead of temp file --- imagekit/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/imagekit/utils.py b/imagekit/utils.py index c54ff3f..8cdaec1 100644 --- a/imagekit/utils.py +++ b/imagekit/utils.py @@ -1,6 +1,6 @@ import os import mimetypes -import tempfile +from StringIO import StringIO import types from django.core.files.base import ContentFile @@ -33,7 +33,7 @@ class IKContentFile(ContentFile): def img_to_fobj(img, format, autoconvert=True, **options): - return save_image(img, tempfile.TemporaryFile(), format, options, autoconvert) + return save_image(img, StringIO(), format, options, autoconvert) def get_spec_files(instance): From 5fdf9d6a91074ef41147eea9fd9767556368d2e0 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Thu, 19 Apr 2012 23:59:20 -0400 Subject: [PATCH 57/88] PIL bug workaround --- imagekit/processors/base.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/imagekit/processors/base.py b/imagekit/processors/base.py index afc0f4f..1be6736 100644 --- a/imagekit/processors/base.py +++ b/imagekit/processors/base.py @@ -208,6 +208,11 @@ class AutoConvert(object): # In other words, a P-format image converted to an # RGBA-formatted image by a processor and then saved as a # P-format image will give the expected results. + + # Work around a bug in PIL: split() doesn't check to see if + # img is loaded. + img.load() + alpha = img.split()[-1] mask = Image.eval(alpha, lambda a: 255 if a <= 128 else 0) img = img.convert('RGB').convert('P', palette=Image.ADAPTIVE, From b6f629d644f75651b90da9b0e07aa04c29f5227a Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 20 Apr 2012 01:51:52 -0400 Subject: [PATCH 58/88] Kill PIL's chattiness; fixes #91 --- imagekit/utils.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/imagekit/utils.py b/imagekit/utils.py index 8cdaec1..6a4d4a2 100644 --- a/imagekit/utils.py +++ b/imagekit/utils.py @@ -1,6 +1,7 @@ import os import mimetypes from StringIO import StringIO +import sys import types from django.core.files.base import ContentFile @@ -217,7 +218,8 @@ def save_image(img, outfile, format, options=None, autoconvert=True): pass try: - img.save(outfile, format, **options) + with quiet(): + img.save(outfile, format, **options) except IOError: # PIL can have problems saving large JPEGs if MAXBLOCK isn't big enough, # So if we have a problem saving, we temporarily increase it. See @@ -235,3 +237,20 @@ def save_image(img, outfile, format, options=None, autoconvert=True): pass return outfile + + +class quiet(object): + """ + A context manager for suppressing the stderr activity of PIL's C libraries. + Based on http://stackoverflow.com/a/978264/155370 + + """ + def __enter__(self): + self.stderr_fd = sys.__stderr__.fileno() + self.null_fd = os.open(os.devnull, os.O_RDWR) + self.old = os.dup(self.stderr_fd) + os.dup2(self.null_fd, self.stderr_fd) + + def __exit__(self, *args, **kwargs): + os.dup2(self.old, self.stderr_fd) + os.close(self.null_fd) From 222c9ba22a351c26284f79dbedcc2e64cd1d00e0 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 20 Apr 2012 21:23:06 -0400 Subject: [PATCH 59/88] Docstring for save_image --- imagekit/utils.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/imagekit/utils.py b/imagekit/utils.py index 6a4d4a2..9807f7a 100644 --- a/imagekit/utils.py +++ b/imagekit/utils.py @@ -203,6 +203,15 @@ def suggest_extension(name, format): def save_image(img, outfile, format, options=None, autoconvert=True): + """ + Wraps PIL's ``Image.save()`` method. There are two main benefits of using + this function over PIL's: + + 1. It gracefully handles the infamous "Suspension not allowed here" errors. + 2. It incorporates the AutoConvert processor, which will do some + common-sense processing given the target format. + + """ options = options or {} if autoconvert: From 667e265c9401206201c60e39cba92efe20aedd84 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 20 Apr 2012 21:37:43 -0400 Subject: [PATCH 60/88] Refactored AutoConvert into prepare_image Because of its need to return kwargs for ``Image.save()``, it never really fit the mold of a processor. --- imagekit/models/fields/__init__.py | 4 +- imagekit/processors/base.py | 93 -------------------------- imagekit/utils.py | 104 +++++++++++++++++++++++++++-- 3 files changed, 99 insertions(+), 102 deletions(-) diff --git a/imagekit/models/fields/__init__.py b/imagekit/models/fields/__init__.py index b04443f..eb7b8d4 100644 --- a/imagekit/models/fields/__init__.py +++ b/imagekit/models/fields/__init__.py @@ -48,8 +48,8 @@ class ImageSpecField(object): based on that format. if not, the extension of the original file will be passed. You do not have to use this extension, it's only a recommendation. - :param autoconvert: Specifies whether the AutoConvert processor - should be run before saving. + :param autoconvert: Specifies whether automatic conversion using + ``prepare_image()`` should be performed prior to saving. :param image_cache_backend: An object responsible for managing the state of cached files. Defaults to an instance of IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND diff --git a/imagekit/processors/base.py b/imagekit/processors/base.py index 1be6736..06239e2 100644 --- a/imagekit/processors/base.py +++ b/imagekit/processors/base.py @@ -1,10 +1,6 @@ from imagekit.lib import Image, ImageColor, ImageEnhance -RGBA_TRANSPARENCY_FORMATS = ['PNG'] -PALETTE_TRANSPARENCY_FORMATS = ['PNG', 'GIF'] - - class ProcessorPipeline(list): """ A :class:`list` of other processors. This class allows any object that @@ -173,95 +169,6 @@ class Transpose(object): return img -class AutoConvert(object): - """A processor that does some common-sense conversions based on the target - format. This includes things like preserving transparency and quantizing. - This processors is used automatically by ``ImageSpecField`` and - ``ProcessedImageField`` immediately before saving the image unless you - specify ``autoconvert=False``. - - """ - - def __init__(self, format): - self.format = format - - def process(self, img): - matte = False - self.save_kwargs = {} - self.rgba_ = img.mode == 'RGBA' - if self.rgba_: - if self.format in RGBA_TRANSPARENCY_FORMATS: - pass - elif self.format in PALETTE_TRANSPARENCY_FORMATS: - # If you're going from a format with alpha transparency to one - # with palette transparency, transparency values will be - # snapped: pixels that are more opaque than not will become - # fully opaque; pixels that are more transparent than not will - # become fully transparent. This will not produce a good-looking - # result if your image contains varying levels of opacity; in - # that case, you'll probably want to use a processor to matte - # the image on a solid color. The reason we don't matte by - # default is because not doing so allows processors to treat - # RGBA-format images as a super-type of P-format images: if you - # have an RGBA-format image with only a single transparent - # color, and save it as a GIF, it will retain its transparency. - # In other words, a P-format image converted to an - # RGBA-formatted image by a processor and then saved as a - # P-format image will give the expected results. - - # Work around a bug in PIL: split() doesn't check to see if - # img is loaded. - img.load() - - alpha = img.split()[-1] - mask = Image.eval(alpha, lambda a: 255 if a <= 128 else 0) - img = img.convert('RGB').convert('P', palette=Image.ADAPTIVE, - colors=255) - img.paste(255, mask) - self.save_kwargs['transparency'] = 255 - else: - # Simply converting an RGBA-format image to an RGB one creates a - # gross result, so we matte the image on a white background. If - # that's not what you want, that's fine: use a processor to deal - # with the transparency however you want. This is simply a - # sensible default that will always produce something that looks - # good. Or at least, it will look better than just a straight - # conversion. - matte = True - elif img.mode == 'P': - if self.format in PALETTE_TRANSPARENCY_FORMATS: - try: - self.save_kwargs['transparency'] = img.info['transparency'] - except KeyError: - pass - elif self.format in RGBA_TRANSPARENCY_FORMATS: - # Currently PIL doesn't support any RGBA-mode formats that - # aren't also P-mode formats, so this will never happen. - img = img.convert('RGBA') - else: - matte = True - else: - img = img.convert('RGB') - - # GIFs are always going to be in palette mode, so we can do a little - # optimization. Note that the RGBA sources also use adaptive - # quantization (above). Images that are already in P mode don't need - # any quantization because their colors are already limited. - if self.format == 'GIF': - img = img.convert('P', palette=Image.ADAPTIVE) - - if matte: - img = img.convert('RGBA') - bg = Image.new('RGBA', img.size, (255, 255, 255)) - bg.paste(img, img) - img = bg.convert('RGB') - - if self.format == 'JPEG': - self.save_kwargs['optimize'] = True - - return img - - class Anchor(object): """ Defines all the anchor points needed by the various processor classes. diff --git a/imagekit/utils.py b/imagekit/utils.py index 9807f7a..0890feb 100644 --- a/imagekit/utils.py +++ b/imagekit/utils.py @@ -10,7 +10,10 @@ from django.utils.functional import wraps from django.utils.encoding import smart_str, smart_unicode from .lib import Image, ImageFile -from .processors import AutoConvert + + +RGBA_TRANSPARENCY_FORMATS = ['PNG'] +PALETTE_TRANSPARENCY_FORMATS = ['PNG', 'GIF'] class IKContentFile(ContentFile): @@ -208,17 +211,15 @@ def save_image(img, outfile, format, options=None, autoconvert=True): this function over PIL's: 1. It gracefully handles the infamous "Suspension not allowed here" errors. - 2. It incorporates the AutoConvert processor, which will do some - common-sense processing given the target format. + 2. It prepares the image for saving using ``prepare_image()``, which will do + some common-sense processing given the target format. """ options = options or {} if autoconvert: - autoconvert_processor = AutoConvert(format) - img = autoconvert_processor.process(img) - options = dict(autoconvert_processor.save_kwargs.items() + - options.items()) + img, save_kwargs = prepare_image(img, format) + options = dict(save_kwargs.items() + options.items()) # Attempt to reset the file pointer. try: @@ -263,3 +264,92 @@ class quiet(object): def __exit__(self, *args, **kwargs): os.dup2(self.old, self.stderr_fd) os.close(self.null_fd) + + +def prepare_image(img, format): + """ + Prepares the image for saving to the provided format by doing some + common-sense conversions. This includes things like preserving transparency + and quantizing. This function is used automatically by ``save_image()`` + (and classes like ``ImageSpecField`` and ``ProcessedImageField``) + immediately before saving unless you specify ``autoconvert=False``. It is + provided as a utility for those doing their own processing. + + :param img: The image to prepare for saving. + :param format: The format that the image will be saved to. + + """ + matte = False + save_kwargs = {} + + if img.mode == 'RGBA': + if format in RGBA_TRANSPARENCY_FORMATS: + pass + elif format in PALETTE_TRANSPARENCY_FORMATS: + # If you're going from a format with alpha transparency to one + # with palette transparency, transparency values will be + # snapped: pixels that are more opaque than not will become + # fully opaque; pixels that are more transparent than not will + # become fully transparent. This will not produce a good-looking + # result if your image contains varying levels of opacity; in + # that case, you'll probably want to use a processor to matte + # the image on a solid color. The reason we don't matte by + # default is because not doing so allows processors to treat + # RGBA-format images as a super-type of P-format images: if you + # have an RGBA-format image with only a single transparent + # color, and save it as a GIF, it will retain its transparency. + # In other words, a P-format image converted to an + # RGBA-formatted image by a processor and then saved as a + # P-format image will give the expected results. + + # Work around a bug in PIL: split() doesn't check to see if + # img is loaded. + img.load() + + alpha = img.split()[-1] + mask = Image.eval(alpha, lambda a: 255 if a <= 128 else 0) + img = img.convert('RGB').convert('P', palette=Image.ADAPTIVE, + colors=255) + img.paste(255, mask) + save_kwargs['transparency'] = 255 + else: + # Simply converting an RGBA-format image to an RGB one creates a + # gross result, so we matte the image on a white background. If + # that's not what you want, that's fine: use a processor to deal + # with the transparency however you want. This is simply a + # sensible default that will always produce something that looks + # good. Or at least, it will look better than just a straight + # conversion. + matte = True + elif img.mode == 'P': + if format in PALETTE_TRANSPARENCY_FORMATS: + try: + save_kwargs['transparency'] = img.info['transparency'] + except KeyError: + pass + elif format in RGBA_TRANSPARENCY_FORMATS: + # Currently PIL doesn't support any RGBA-mode formats that + # aren't also P-mode formats, so this will never happen. + img = img.convert('RGBA') + else: + matte = True + else: + img = img.convert('RGB') + + # GIFs are always going to be in palette mode, so we can do a little + # optimization. Note that the RGBA sources also use adaptive + # quantization (above). Images that are already in P mode don't need + # any quantization because their colors are already limited. + if format == 'GIF': + img = img.convert('P', palette=Image.ADAPTIVE) + + if matte: + img = img.convert('RGBA') + bg = Image.new('RGBA', img.size, (255, 255, 255)) + bg.paste(img, img) + img = bg.convert('RGB') + + if format == 'JPEG': + save_kwargs['optimize'] = True + + return img, save_kwargs From af7c12cb684eb44ebdb0ea2f5eb420ca5b48f044 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 20 Apr 2012 21:43:59 -0400 Subject: [PATCH 61/88] Tell people to import fields from the models module --- README.rst | 6 +++--- docs/apireference.rst | 2 +- imagekit/models/__init__.py | 4 ++-- imagekit/models/fields/__init__.py | 2 +- tests/core/models.py | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.rst b/README.rst index 5a2ea01..3e6d21c 100644 --- a/README.rst +++ b/README.rst @@ -34,7 +34,7 @@ Much like ``django.db.models.ImageField``, Specs are defined as properties of a model class:: from django.db import models - from imagekit.models.fields import ImageSpecField + from imagekit.models import ImageSpecField class Photo(models.Model): original_image = models.ImageField(upload_to='photos') @@ -49,7 +49,7 @@ an ImageFile-like object (just like with a normal photo.original_image.url # > '/media/photos/birthday.tiff' photo.formatted_image.url # > '/media/cache/photos/birthday_formatted_image.jpeg' -Check out ``imagekit.models.fields.ImageSpecField`` for more information. +Check out ``imagekit.models.ImageSpecField`` for more information. Processors @@ -60,7 +60,7 @@ something to it, and return the result. By providing a list of processors to your spec, you can expose different versions of the original image:: from django.db import models - from imagekit.models.fields import ImageSpecField + from imagekit.models import ImageSpecField from imagekit.processors import ResizeToFill, Adjust class Photo(models.Model): diff --git a/docs/apireference.rst b/docs/apireference.rst index d4a2ed8..df4b5c7 100644 --- a/docs/apireference.rst +++ b/docs/apireference.rst @@ -5,7 +5,7 @@ API Reference :mod:`models` Module -------------------- -.. automodule:: imagekit.models.fields +.. automodule:: imagekit.models :members: diff --git a/imagekit/models/__init__.py b/imagekit/models/__init__.py index 97798e1..c5ef221 100644 --- a/imagekit/models/__init__.py +++ b/imagekit/models/__init__.py @@ -5,6 +5,6 @@ import warnings class ImageSpec(ImageSpecField): def __init__(self, *args, **kwargs): warnings.warn('ImageSpec has been moved to' - ' imagekit.models.fields.ImageSpecField. Please use that' - ' instead.', DeprecationWarning) + ' imagekit.models.ImageSpecField. Please use that instead.', + DeprecationWarning) super(ImageSpec, self).__init__(*args, **kwargs) diff --git a/imagekit/models/fields/__init__.py b/imagekit/models/fields/__init__.py index eb7b8d4..b2cceda 100644 --- a/imagekit/models/fields/__init__.py +++ b/imagekit/models/fields/__init__.py @@ -154,7 +154,7 @@ class ProcessedImageField(models.ImageField): The ProcessedImageField constructor accepts all of the arguments that the :class:`django.db.models.ImageField` constructor accepts, as well as the ``processors``, ``format``, and ``options`` arguments of - :class:`imagekit.models.fields.ImageSpecField`. + :class:`imagekit.models.ImageSpecField`. """ if 'quality' in kwargs: diff --git a/tests/core/models.py b/tests/core/models.py index 35ffffb..2c3d8e4 100644 --- a/tests/core/models.py +++ b/tests/core/models.py @@ -1,6 +1,6 @@ from django.db import models -from imagekit.models.fields import ImageSpecField +from imagekit.models import ImageSpecField from imagekit.processors import Adjust from imagekit.processors import ResizeToFill from imagekit.processors import SmartCrop From 7b34716d9e8f9a2deac9efcde85181418218e737 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 20 Apr 2012 23:26:16 -0400 Subject: [PATCH 62/88] Don't get extension of empty filename --- imagekit/generators.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/imagekit/generators.py b/imagekit/generators.py index 4d24bce..5c2f004 100644 --- a/imagekit/generators.py +++ b/imagekit/generators.py @@ -29,7 +29,7 @@ class SpecFileGenerator(object): # Determine the format. format = self.format - if not format: + if filename and not format: # Try to guess the format from the extension. extension = os.path.splitext(filename)[1].lower() if extension: @@ -37,7 +37,7 @@ class SpecFileGenerator(object): format = extension_to_format(extension) except UnknownExtensionError: pass - format = format or img.format or original_format or 'JPEG' + format = format or img.format or original_format or 'JPEG' imgfile = img_to_fobj(img, format, **options) content = IKContentFile(filename, imgfile.read()) From b466ff3723ebba28afef94839e9158ca88e69842 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 20 Apr 2012 23:27:35 -0400 Subject: [PATCH 63/88] Additional mimetype utils --- imagekit/utils.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/imagekit/utils.py b/imagekit/utils.py index 0890feb..5614b59 100644 --- a/imagekit/utils.py +++ b/imagekit/utils.py @@ -122,6 +122,19 @@ def _format_to_extension(format): return None +def extension_to_mimetype(ext): + try: + filename = 'a%s' % (ext or '') # guess_type requires a full filename, not just an extension + mimetype = mimetypes.guess_type(filename)[0] + except IndexError: + mimetype = None + return mimetype + + +def format_to_mimetype(format): + return extension_to_mimetype(format_to_extension(format)) + + def extension_to_format(extension): """Returns the format that corresponds to the provided extension. From 89eb05668e121c4853461b31a162049b942d778b Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Fri, 20 Apr 2012 23:30:30 -0400 Subject: [PATCH 64/88] IKContentFile accepts format hint --- imagekit/generators.py | 2 +- imagekit/utils.py | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/imagekit/generators.py b/imagekit/generators.py index 5c2f004..50d04ac 100644 --- a/imagekit/generators.py +++ b/imagekit/generators.py @@ -40,7 +40,7 @@ class SpecFileGenerator(object): format = format or img.format or original_format or 'JPEG' imgfile = img_to_fobj(img, format, **options) - content = IKContentFile(filename, imgfile.read()) + content = IKContentFile(filename, imgfile.read(), format=format) return img, content def generate_file(self, filename, source_file, save=True): diff --git a/imagekit/utils.py b/imagekit/utils.py index 5614b59..d175b65 100644 --- a/imagekit/utils.py +++ b/imagekit/utils.py @@ -18,16 +18,21 @@ PALETTE_TRANSPARENCY_FORMATS = ['PNG', 'GIF'] class IKContentFile(ContentFile): """ - Wraps a ContentFile in a file-like object with a filename - and a content_type. + Wraps a ContentFile in a file-like object with a filename and a + content_type. A PIL image format can be optionally be provided as a content + type hint. + """ - def __init__(self, filename, content): + def __init__(self, filename, content, format=None): self.file = ContentFile(content) self.file.name = filename - try: - self.file.content_type = mimetypes.guess_type(filename)[0] - except IndexError: - self.file.content_type = None + mimetype = getattr(self.file, 'content_type', None) + if format and not mimetype: + mimetype = format_to_mimetype(format) + if not mimetype: + ext = os.path.splitext(filename or '')[1] + mimetype = extension_to_mimetype(ext) + self.file.content_type = mimetype def __str__(self): return smart_str(self.file.name or '') From a071549ed02e0355b0e7152b55052db1d994373a Mon Sep 17 00:00:00 2001 From: Bryan Veloso Date: Tue, 24 Apr 2012 14:26:57 -0700 Subject: [PATCH 65/88] Adding @jezdez to AUTHORS. --- AUTHORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AUTHORS b/AUTHORS index 129596e..24968f0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -24,6 +24,7 @@ Contributors * `Madis Väin`_ * `Jan Sagemüller`_ * `Clay McClure`_ +* `Jannis Leidel`_ .. _Justin Driscoll: http://github.com/jdriscoll .. _HZDG: http://hzdg.com @@ -42,3 +43,4 @@ Contributors .. _Madis Väin: http://github.com/madisvain .. _Jan Sagemüller: https://github.com/version2 .. _Clay McClure: https://github.com/claymation +.. _Jannis Leidel: https://github.com/jezdez From ea5277e7fc4b5d082300a71576687a8806983c51 Mon Sep 17 00:00:00 2001 From: Bryan Veloso Date: Tue, 24 Apr 2012 22:52:06 -0700 Subject: [PATCH 66/88] Changelog link. --- README.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index a70f395..e1730b6 100644 --- a/README.rst +++ b/README.rst @@ -4,9 +4,10 @@ to your models. These variations are called "specs" and can include things like different sizes (e.g. thumbnails) and black and white versions. **For the complete documentation on the latest stable version of ImageKit, see** -`ImageKit on RTD`_. +`ImageKit on RTD`_. Our `changelog is also available`_. .. _`ImageKit on RTD`: http://django-imagekit.readthedocs.org +.. _`changelog is also available`: http://django-imagekit.readthedocs.org/en/latest/changelog.html Installation From 69b590257dccdac9a5df7efc2fad8769907e3295 Mon Sep 17 00:00:00 2001 From: Jeroen Dekkers Date: Sat, 28 Apr 2012 16:13:50 +0200 Subject: [PATCH 67/88] Do not leak file descriptor --- imagekit/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/imagekit/utils.py b/imagekit/utils.py index d175b65..4e680fe 100644 --- a/imagekit/utils.py +++ b/imagekit/utils.py @@ -282,6 +282,7 @@ class quiet(object): def __exit__(self, *args, **kwargs): os.dup2(self.old, self.stderr_fd) os.close(self.null_fd) + os.close(self.old) def prepare_image(img, format): From 322c96eec22c23a884b4dee392c4f26ce2ff3142 Mon Sep 17 00:00:00 2001 From: Bryan Veloso Date: Sat, 28 Apr 2012 11:32:22 -0700 Subject: [PATCH 68/88] Merge branch 'release/2.0.1' into develop * release/2.0.1: Bumping the version number. Changelog update. --- docs/changelog.rst | 6 ++++++ docs/conf.py | 4 ++-- imagekit/__init__.py | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9257870..34d3ef7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,12 @@ Changelog ========= +v2.0.1 +------ + +- Fixed a file descriptor leak in the `utils.quiet()` context manager. + + v2.0.0 ------ diff --git a/docs/conf.py b/docs/conf.py index 1ba36ca..580401c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ copyright = u'2011, Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & # built documents. # # The short X.Y version. -version = '2.0.0' +version = '2.0.1' # The full version, including alpha/beta/rc tags. -release = '2.0.0' +release = '2.0.1' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/imagekit/__init__.py b/imagekit/__init__.py index d525af2..55a5d04 100644 --- a/imagekit/__init__.py +++ b/imagekit/__init__.py @@ -1,6 +1,6 @@ __title__ = 'django-imagekit' __author__ = 'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett, Matthew Tretter, Eric Eldredge' -__version__ = (2, 0, 0, 'final', 0) +__version__ = (2, 0, 1, 'final', 0) __license__ = 'BSD' From 784afcc95d0712ff05407089e63112e22e3ad65e Mon Sep 17 00:00:00 2001 From: "German M. Bravo" Date: Fri, 4 May 2012 17:24:04 -0500 Subject: [PATCH 69/88] Simplified path join for the cache --- imagekit/models/fields/files.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/imagekit/models/fields/files.py b/imagekit/models/fields/files.py index 153c69f..e206377 100644 --- a/imagekit/models/fields/files.py +++ b/imagekit/models/fields/files.py @@ -100,7 +100,7 @@ class ImageSpecFieldFile(ImageFieldFile): filepath, basename = os.path.split(path) filename = os.path.splitext(basename)[0] new_name = '%s_%s%s' % (filename, specname, extension) - return os.path.join(os.path.join('cache', filepath), new_name) + return os.path.join('cache', filepath, new_name) @property def name(self): From 89f2aa7a7d8175f9b6144673d2f3a636bd9d9ad3 Mon Sep 17 00:00:00 2001 From: "German M. Bravo" Date: Fri, 4 May 2012 17:30:16 -0500 Subject: [PATCH 70/88] Reflections on images using RGBA --- imagekit/processors/base.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/imagekit/processors/base.py b/imagekit/processors/base.py index 06239e2..8320a3d 100644 --- a/imagekit/processors/base.py +++ b/imagekit/processors/base.py @@ -77,11 +77,11 @@ class Reflection(object): # Convert bgcolor string to RGB value. background_color = ImageColor.getrgb(self.background_color) # Handle palleted images. - img = img.convert('RGB') + img = img.convert('RGBA') # Copy orignial image and flip the orientation. reflection = img.copy().transpose(Image.FLIP_TOP_BOTTOM) # Create a new image filled with the bgcolor the same size. - background = Image.new("RGB", img.size, background_color) + background = Image.new("RGBA", img.size, background_color) # Calculate our alpha mask. start = int(255 - (255 * self.opacity)) # The start of our gradient. steps = int(255 * self.size) # The number of intermedite values. @@ -101,7 +101,7 @@ class Reflection(object): reflection = reflection.crop((0, 0, img.size[0], reflection_height)) # Create new image sized to hold both the original image and # the reflection. - composite = Image.new("RGB", (img.size[0], img.size[1] + reflection_height), background_color) + composite = Image.new("RGBA", (img.size[0], img.size[1] + reflection_height), background_color) # Paste the orignal image and the reflection into the composite image. composite.paste(img, (0, 0)) composite.paste(reflection, (0, img.size[1])) From c1b4c9bf719f185c259b47216fcc98f0097c04f8 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Sat, 12 May 2012 15:45:08 -0400 Subject: [PATCH 71/88] Use cStringIO if available --- imagekit/generators.py | 3 +-- imagekit/lib.py | 5 +++++ imagekit/utils.py | 3 +-- tests/core/tests.py | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/imagekit/generators.py b/imagekit/generators.py index 50d04ac..046f0c0 100644 --- a/imagekit/generators.py +++ b/imagekit/generators.py @@ -1,6 +1,5 @@ import os -from StringIO import StringIO - +from .lib import StringIO from .processors import ProcessorPipeline from .utils import (img_to_fobj, open_image, IKContentFile, extension_to_format, UnknownExtensionError) diff --git a/imagekit/lib.py b/imagekit/lib.py index efacb79..574e587 100644 --- a/imagekit/lib.py +++ b/imagekit/lib.py @@ -15,3 +15,8 @@ except ImportError: import ImageStat except ImportError: raise ImportError('ImageKit was unable to import the Python Imaging Library. Please confirm it`s installed and available on your current Python path.') + +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO diff --git a/imagekit/utils.py b/imagekit/utils.py index 4e680fe..4ffb556 100644 --- a/imagekit/utils.py +++ b/imagekit/utils.py @@ -1,6 +1,5 @@ import os import mimetypes -from StringIO import StringIO import sys import types @@ -9,7 +8,7 @@ from django.db.models.loading import cache from django.utils.functional import wraps from django.utils.encoding import smart_str, smart_unicode -from .lib import Image, ImageFile +from .lib import Image, ImageFile, StringIO RGBA_TRANSPARENCY_FORMATS = ['PNG'] diff --git a/tests/core/tests.py b/tests/core/tests.py index d3039e4..07b504a 100644 --- a/tests/core/tests.py +++ b/tests/core/tests.py @@ -2,11 +2,11 @@ from __future__ import with_statement import os import pickle -from StringIO import StringIO from django.test import TestCase from imagekit import utils +from imagekit.lib import StringIO from .models import (Photo, AbstractImageModel, ConcreteImageModel1, ConcreteImageModel2) from .testutils import generate_lenna, create_photo From 82c7d5e475d30b80c065c74b157766c21d9d7d51 Mon Sep 17 00:00:00 2001 From: Bryan Veloso Date: Tue, 29 May 2012 12:55:51 -0700 Subject: [PATCH 72/88] Adding Django 1.4 to tox. --- tox.ini | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tox.ini b/tox.ini index 8f2eadc..b1ab30b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,9 @@ [tox] envlist = + py27-django14, py27-django13, py27-django12, + py26-django14, py26-django13, py26-django12 @@ -10,6 +12,11 @@ changedir = tests setenv = PYTHONPATH = {toxinidir}/tests commands = django-admin.py test core --settings=settings +[testenv:py27-django14] +deps = + Django>=1.4 + Pillow + [testenv:py27-django13] deps = Django>=1.3,<=1.4 @@ -20,6 +27,11 @@ deps = Django>=1.2,<=1.3 Pillow +[testenv:py26-django14] +deps = + Django>=1.4 + Pillow + [testenv:py26-django13] deps = Django>=1.3,<=1.4 From 35343eaa9d741231b7a942b24f02990c5775a9dc Mon Sep 17 00:00:00 2001 From: Bryan Veloso Date: Tue, 29 May 2012 12:57:06 -0700 Subject: [PATCH 73/88] Adding basepython to the tox directives. --- tox.ini | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tox.ini b/tox.ini index b1ab30b..72bf206 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,7 @@ [tox] envlist = - py27-django14, - py27-django13, - py27-django12, - py26-django14, - py26-django13, - py26-django12 + py27-django14, py27-django13, py27-django12, + py26-django14, py26-django13, py26-django12 [testenv] changedir = tests @@ -13,31 +9,37 @@ setenv = PYTHONPATH = {toxinidir}/tests commands = django-admin.py test core --settings=settings [testenv:py27-django14] +basepython = python2.7 deps = Django>=1.4 Pillow [testenv:py27-django13] +basepython = python2.7 deps = Django>=1.3,<=1.4 Pillow [testenv:py27-django12] +basepython = python2.7 deps = Django>=1.2,<=1.3 Pillow [testenv:py26-django14] +basepython = python2.6 deps = Django>=1.4 Pillow [testenv:py26-django13] +basepython = python2.6 deps = Django>=1.3,<=1.4 Pillow [testenv:py26-django12] +basepython = python2.6 deps = Django>=1.2,<=1.3 Pillow From 6009bd418ab445e815474aeb9c772eb02c04792d Mon Sep 17 00:00:00 2001 From: Bryan Veloso Date: Tue, 29 May 2012 12:58:28 -0700 Subject: [PATCH 74/88] Adding a travis-ci configuration file. --- .travis.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..ec17c2d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: python +python: + - 2.7 +install: pip install tox --use-mirrors +script: tox -e py26-1.2.X,py27-1.2.X,py26-1.3.X,py27-1.3.X,py26,py27,docs +notifications: + irc: "irc.freenode.org#imagekit" From f2f6766b8629a6bd6d42197b50dc712a539a93f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timothe=CC=81e=20Peignier?= Date: Tue, 29 May 2012 22:05:37 +0200 Subject: [PATCH 75/88] add irc channel to docs and README --- README.rst | 6 ++++++ docs/index.rst | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/README.rst b/README.rst index e1730b6..3bb6e43 100644 --- a/README.rst +++ b/README.rst @@ -164,6 +164,12 @@ Or in your ``settings.py`` file if you want to use it as the default:: IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND = 'path.to.MyImageCacheBackend' +Community +--------- + +The official Freenode channel for ImageKit is `#imagekit `_. +You should always find some fine people to answer your questions +about ImageKit there. Contributing ------------ diff --git a/docs/index.rst b/docs/index.rst index 5634f21..2c06385 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -18,6 +18,14 @@ Authors .. include:: ../AUTHORS +Community +--------- + +The official Freenode channel for ImageKit is `#imagekit `_. +You should always find some fine people to answer your questions +about ImageKit there. + + Digging Deeper -------------- From 5ae961733cd7beee5588bf5c74b6e64ed8a8fac7 Mon Sep 17 00:00:00 2001 From: Bryan Veloso Date: Tue, 29 May 2012 13:14:52 -0700 Subject: [PATCH 76/88] Derp, forgot to change the tox command. --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index ec17c2d..5a5e2e7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,6 @@ language: python python: - 2.7 install: pip install tox --use-mirrors -script: tox -e py26-1.2.X,py27-1.2.X,py26-1.3.X,py27-1.3.X,py26,py27,docs +script: tox -e py27-django13,py27-django12,py26-django13,py27-django12 notifications: irc: "irc.freenode.org#imagekit" From 5e1757c1ee3456a0911323bc0811c16a0bc64f35 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Wed, 18 Jul 2012 17:21:21 -0400 Subject: [PATCH 77/88] Remove unused stuff --- tests/core/tests.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/core/tests.py b/tests/core/tests.py index 07b504a..eb42ba8 100644 --- a/tests/core/tests.py +++ b/tests/core/tests.py @@ -9,15 +9,10 @@ from imagekit import utils from imagekit.lib import StringIO from .models import (Photo, AbstractImageModel, ConcreteImageModel1, ConcreteImageModel2) -from .testutils import generate_lenna, create_photo +from .testutils import create_photo class IKTest(TestCase): - def generate_image(self): - tmp = tempfile.TemporaryFile() - Image.new('RGB', (800, 600)).save(tmp, 'JPEG') - tmp.seek(0) - return tmp def setUp(self): self.photo = create_photo('test.jpg') @@ -27,7 +22,6 @@ class IKTest(TestCase): """ filename = self.photo.thumbnail.file.name - thumbnail_timestamp = os.path.getmtime(filename) self.photo.save() self.assertTrue(self.photo.thumbnail.storage.exists(filename)) From 118f6e42064bd328c9ebf5834ae64341878ea4ec Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Wed, 18 Jul 2012 17:21:47 -0400 Subject: [PATCH 78/88] Create failing test to illustrate #97 --- tests/core/tests.py | 20 ++++++++++---------- tests/core/testutils.py | 10 +++++++++- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/tests/core/tests.py b/tests/core/tests.py index eb42ba8..b4884c8 100644 --- a/tests/core/tests.py +++ b/tests/core/tests.py @@ -1,15 +1,13 @@ from __future__ import with_statement import os -import pickle from django.test import TestCase from imagekit import utils -from imagekit.lib import StringIO from .models import (Photo, AbstractImageModel, ConcreteImageModel1, ConcreteImageModel2) -from .testutils import create_photo +from .testutils import create_photo, pickleback class IKTest(TestCase): @@ -66,15 +64,17 @@ class IKUtilsTest(TestCase): class PickleTest(TestCase): - def test_source_file(self): - ph = create_photo('pickletest.jpg') - pickled_model = StringIO() - pickle.dump(ph, pickled_model) - pickled_model.seek(0) - unpickled_model = pickle.load(pickled_model) + def test_model(self): + ph = pickleback(create_photo('pickletest.jpg')) # This isn't supposed to error. - unpickled_model.thumbnail.source_file + ph.thumbnail.source_file + + def test_field(self): + thumbnail = pickleback(create_photo('pickletest2.jpg').thumbnail) + + # This isn't supposed to error. + thumbnail.source_file class InheritanceTest(TestCase): diff --git a/tests/core/testutils.py b/tests/core/testutils.py index 27e0f52..4acc13c 100644 --- a/tests/core/testutils.py +++ b/tests/core/testutils.py @@ -3,8 +3,9 @@ import tempfile from django.core.files.base import ContentFile -from imagekit.lib import Image +from imagekit.lib import Image, StringIO from .models import Photo +import pickle def generate_lenna(): @@ -36,3 +37,10 @@ def create_instance(model_class, image_name): def create_photo(name): return create_instance(Photo, name) + + +def pickleback(obj): + pickled = StringIO() + pickle.dump(obj, pickled) + pickled.seek(0) + return pickle.load(pickled) From becee54c03d8f3b6553103b39a100574c28c8a81 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Wed, 18 Jul 2012 22:24:39 -0400 Subject: [PATCH 79/88] Fix pickling of ImageSpecFieldFile Code now passes the test added in 118f6e4. Hopefully this will address #97. --- imagekit/models/fields/files.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/imagekit/models/fields/files.py b/imagekit/models/fields/files.py index e206377..d6f3dc2 100644 --- a/imagekit/models/fields/files.py +++ b/imagekit/models/fields/files.py @@ -11,7 +11,6 @@ class ImageSpecFieldFile(ImageFieldFile): def __init__(self, instance, field, attname): super(ImageSpecFieldFile, self).__init__(instance, field, None) self.attname = attname - self.storage = self.field.storage or self.source_file.storage @property def source_file(self): @@ -145,6 +144,25 @@ class ImageSpecFieldFile(ImageFieldFile): # it at least that one time. pass + @property + def storage(self): + return getattr(self, '_storage', None) or self.field.storage or self.source_file.storage + + @storage.setter + def storage(self, storage): + self._storage = storage + + def __getstate__(self): + return dict( + attname=self.attname, + instance=self.instance, + ) + + def __setstate__(self, state): + self.attname = state['attname'] + self.instance = state['instance'] + self.field = getattr(self.instance.__class__, self.attname) + class ProcessedImageFieldFile(ImageFieldFile): def save(self, name, content, save=True): From c24746ea1a6bae802dd9a6eb193ba5572a157e6f Mon Sep 17 00:00:00 2001 From: Bryan Veloso Date: Thu, 19 Jul 2012 15:29:50 -0700 Subject: [PATCH 80/88] Code blocks. --- README.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.rst b/README.rst index 3bb6e43..65ec0d6 100644 --- a/README.rst +++ b/README.rst @@ -34,6 +34,8 @@ Adding Specs to a Model Much like ``django.db.models.ImageField``, Specs are defined as properties of a model class:: +.. code-block:: python + from django.db import models from imagekit.models import ImageSpecField @@ -46,6 +48,8 @@ Accessing the spec through a model instance will create the image and return an ImageFile-like object (just like with a normal ``django.db.models.ImageField``):: +.. code-block:: python + photo = Photo.objects.all()[0] photo.original_image.url # > '/media/photos/birthday.tiff' photo.formatted_image.url # > '/media/cache/photos/birthday_formatted_image.jpeg' @@ -55,6 +59,8 @@ Check out ``imagekit.models.ImageSpecField`` for more information. If you only want to save the processed image (without maintaining the original), you can use a ``ProcessedImageField``:: +.. code-block:: python + from django.db import models from imagekit.models.fields import ProcessedImageField @@ -71,6 +77,8 @@ The real power of ImageKit comes from processors. Processors take an image, do something to it, and return the result. By providing a list of processors to your spec, you can expose different versions of the original image:: +.. code-block:: python + from django.db import models from imagekit.models import ImageSpecField from imagekit.processors import ResizeToFill, Adjust @@ -83,6 +91,8 @@ your spec, you can expose different versions of the original image:: The ``thumbnail`` property will now return a cropped image:: +.. code-block:: python + photo = Photo.objects.all()[0] photo.thumbnail.url # > '/media/cache/photos/birthday_thumbnail.jpeg' photo.thumbnail.width # > 50 @@ -98,6 +108,8 @@ image manipulations, like resizing, rotating, and color adjustments. However, if they aren't up to the task, you can create your own. All you have to do is implement a ``process()`` method:: +.. code-block:: python + class Watermark(object): def process(self, image): # Code for adding the watermark goes here. @@ -117,6 +129,8 @@ for displaying specs (or even regular ImageFields) in the `Django admin change list`_. AdminThumbnail is used as a property on Django admin classes:: +.. code-block:: python + from django.contrib import admin from imagekit.admin import AdminThumbnail from .models import Photo @@ -156,12 +170,16 @@ could store the state (valid, invalid) of the cache in a database to avoid filesystem access. You can then specify your image cache backend on a per-field basis:: +.. code-block:: python + class Photo(models.Model): ... thumbnail = ImageSpecField(..., image_cache_backend=MyImageCacheBackend()) Or in your ``settings.py`` file if you want to use it as the default:: +.. code-block:: python + IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND = 'path.to.MyImageCacheBackend' Community From 70ab4a0cc0e52364c235aff29bdfea0dd1a17897 Mon Sep 17 00:00:00 2001 From: Bryan Veloso Date: Thu, 19 Jul 2012 15:31:08 -0700 Subject: [PATCH 81/88] Whoops! Messed that up. --- README.rst | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.rst b/README.rst index 65ec0d6..a584eba 100644 --- a/README.rst +++ b/README.rst @@ -32,7 +32,7 @@ Adding Specs to a Model ----------------------- Much like ``django.db.models.ImageField``, Specs are defined as properties -of a model class:: +of a model class: .. code-block:: python @@ -46,7 +46,7 @@ of a model class:: Accessing the spec through a model instance will create the image and return an ImageFile-like object (just like with a normal -``django.db.models.ImageField``):: +``django.db.models.ImageField``): .. code-block:: python @@ -57,7 +57,7 @@ an ImageFile-like object (just like with a normal Check out ``imagekit.models.ImageSpecField`` for more information. If you only want to save the processed image (without maintaining the original), -you can use a ``ProcessedImageField``:: +you can use a ``ProcessedImageField``: .. code-block:: python @@ -75,7 +75,7 @@ Processors The real power of ImageKit comes from processors. Processors take an image, do something to it, and return the result. By providing a list of processors to -your spec, you can expose different versions of the original image:: +your spec, you can expose different versions of the original image: .. code-block:: python @@ -89,7 +89,7 @@ your spec, you can expose different versions of the original image:: ResizeToFill(50, 50)], image_field='original_image', format='JPEG', options={'quality': 90}) -The ``thumbnail`` property will now return a cropped image:: +The ``thumbnail`` property will now return a cropped image: .. code-block:: python @@ -106,7 +106,7 @@ pass processors to a ``ProcessedImageField`` instead of an ``ImageSpecField``.) The ``imagekit.processors`` module contains processors for many common image manipulations, like resizing, rotating, and color adjustments. However, if they aren't up to the task, you can create your own. All you have to do is -implement a ``process()`` method:: +implement a ``process()`` method: .. code-block:: python @@ -127,7 +127,7 @@ Admin ImageKit also contains a class named ``imagekit.admin.AdminThumbnail`` for displaying specs (or even regular ImageFields) in the `Django admin change list`_. AdminThumbnail is used as a property on -Django admin classes:: +Django admin classes: .. code-block:: python @@ -168,7 +168,7 @@ no longer needed in any form, i.e. the model is deleted). Each of these methods must accept a file object, but the internals are up to you. For example, you could store the state (valid, invalid) of the cache in a database to avoid filesystem access. You can then specify your image cache backend on a per-field -basis:: +basis: .. code-block:: python @@ -176,7 +176,7 @@ basis:: ... thumbnail = ImageSpecField(..., image_cache_backend=MyImageCacheBackend()) -Or in your ``settings.py`` file if you want to use it as the default:: +Or in your ``settings.py`` file if you want to use it as the default: .. code-block:: python From 548fb65618dfce8aa43671f79231628a773a8f88 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Thu, 19 Jul 2012 20:01:40 -0400 Subject: [PATCH 82/88] Allow callables for AdminThumbnail image_field arg This allows images from related models to be displayed. Closes #138. --- imagekit/admin.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/imagekit/admin.py b/imagekit/admin.py index f764d3e..4466e6e 100644 --- a/imagekit/admin.py +++ b/imagekit/admin.py @@ -21,11 +21,14 @@ class AdminThumbnail(object): self.template = template def __call__(self, obj): - try: - thumbnail = getattr(obj, self.image_field) - except AttributeError: - raise Exception('The property %s is not defined on %s.' % \ - (self.image_field, obj.__class__.__name__)) + if callable(self.image_field): + thumbnail = self.image_field(obj) + else: + try: + thumbnail = getattr(obj, self.image_field) + except AttributeError: + raise Exception('The property %s is not defined on %s.' % \ + (self.image_field, obj.__class__.__name__)) original_image = getattr(thumbnail, 'source_file', None) or thumbnail template = self.template or 'imagekit/admin/thumbnail.html' From a196e00059cde3a306dbe13e79c2de38306e75fe Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Thu, 19 Jul 2012 21:03:15 -0400 Subject: [PATCH 83/88] Use django-appconf --- imagekit/conf.py | 5 +++++ imagekit/imagecache/__init__.py | 3 ++- imagekit/models/__init__.py | 1 + imagekit/settings.py | 5 ----- setup.py | 3 +++ 5 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 imagekit/conf.py delete mode 100644 imagekit/settings.py diff --git a/imagekit/conf.py b/imagekit/conf.py new file mode 100644 index 0000000..51ddf55 --- /dev/null +++ b/imagekit/conf.py @@ -0,0 +1,5 @@ +from appconf import AppConf + + +class ImageKitConf(AppConf): + DEFAULT_IMAGE_CACHE_BACKEND = 'imagekit.imagecache.PessimisticImageCacheBackend' diff --git a/imagekit/imagecache/__init__.py b/imagekit/imagecache/__init__.py index 3c582c8..cf98a9d 100644 --- a/imagekit/imagecache/__init__.py +++ b/imagekit/imagecache/__init__.py @@ -14,7 +14,8 @@ def get_default_image_cache_backend(): """ global _default_image_cache_backend if not _default_image_cache_backend: - from ..settings import DEFAULT_IMAGE_CACHE_BACKEND as import_path + from django.conf import settings + import_path = settings.IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND try: dot = import_path.rindex('.') except ValueError: diff --git a/imagekit/models/__init__.py b/imagekit/models/__init__.py index c5ef221..4207987 100644 --- a/imagekit/models/__init__.py +++ b/imagekit/models/__init__.py @@ -1,3 +1,4 @@ +from .. import conf from .fields import ImageSpecField, ProcessedImageField import warnings diff --git a/imagekit/settings.py b/imagekit/settings.py deleted file mode 100644 index d84030d..0000000 --- a/imagekit/settings.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.conf import settings - -DEFAULT_IMAGE_CACHE_BACKEND = getattr(settings, - 'IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND', - 'imagekit.imagecache.PessimisticImageCacheBackend') diff --git a/setup.py b/setup.py index 6fdb8fb..6f662f9 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,9 @@ setup( packages=find_packages(), zip_safe=False, include_package_data=True, + install_requires=[ + 'django-appconf>=0.5', + ], classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', From f3008f68b88b6543e27d968faf1a934a857ac3d4 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Mon, 23 Jul 2012 10:22:23 -0300 Subject: [PATCH 84/88] Add Google Group to README --- README.rst | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index a584eba..d22739a 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,3 @@ - ImageKit is a Django app that helps you to add variations of uploaded images to your models. These variations are called "specs" and can include things like different sizes (e.g. thumbnails) and black and white versions. @@ -182,12 +181,15 @@ Or in your ``settings.py`` file if you want to use it as the default: IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND = 'path.to.MyImageCacheBackend' + Community --------- -The official Freenode channel for ImageKit is `#imagekit `_. -You should always find some fine people to answer your questions -about ImageKit there. +Please use `the GitHub issue tracker `_ +to report bugs with django-imagekit. `A mailing list `_ +also exists to discuss the project and ask questions, as well as the official +`#imagekit `_ channel on Freenode. + Contributing ------------ From 23c6e9a70ed8c40f6c7c02ca0e7624a735208411 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Wed, 25 Jul 2012 22:19:42 -0400 Subject: [PATCH 85/88] Add __init__ to Reflection processor; closes #141 --- imagekit/processors/base.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/imagekit/processors/base.py b/imagekit/processors/base.py index 8320a3d..61c0e30 100644 --- a/imagekit/processors/base.py +++ b/imagekit/processors/base.py @@ -69,9 +69,10 @@ class Reflection(object): Creates an image with a reflection. """ - background_color = '#FFFFFF' - size = 0.0 - opacity = 0.6 + def __init__(self, background_color='#FFFFFF', size=0.0, opacity=0.6): + self.background_color = background_color + self.size = size + self.opacity = opacity def process(self, img): # Convert bgcolor string to RGB value. From c8733c4707591fc0c29aef0164c5688f31f17075 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Wed, 25 Jul 2012 23:04:21 -0400 Subject: [PATCH 86/88] Change how signals are used Signals are now connected without specifying the class and non-IK models are filtered out in the receivers. This is necessary because of a bug with how Django handles abstract models. Closes #126 --- imagekit/models/fields/__init__.py | 44 +++------------------------ imagekit/models/receivers.py | 48 ++++++++++++++++++++++++++++++ imagekit/utils.py | 8 +++++ 3 files changed, 60 insertions(+), 40 deletions(-) create mode 100644 imagekit/models/receivers.py diff --git a/imagekit/models/fields/__init__.py b/imagekit/models/fields/__init__.py index b2cceda..3e11e69 100644 --- a/imagekit/models/fields/__init__.py +++ b/imagekit/models/fields/__init__.py @@ -1,15 +1,18 @@ import os from django.db import models -from django.db.models.signals import post_init, post_save, post_delete from ...imagecache import get_default_image_cache_backend from ...generators import SpecFileGenerator from .files import ImageSpecFieldFile, ProcessedImageFieldFile +from ..receivers import configure_receivers from .utils import ImageSpecFileDescriptor, ImageKitMeta, BoundImageKitMeta from ...utils import suggest_extension +configure_receivers() + + class ImageSpecField(object): """ The heart and soul of the ImageKit library, ImageSpecField allows you to add @@ -91,51 +94,12 @@ class ImageSpecField(object): setattr(cls, '_ik', ik) ik.spec_fields.append(name) - # Connect to the signals only once for this class. - uid = '%s.%s' % (cls.__module__, cls.__name__) - post_init.connect(ImageSpecField._post_init_receiver, sender=cls, - dispatch_uid=uid) - post_save.connect(ImageSpecField._post_save_receiver, sender=cls, - dispatch_uid=uid) - post_delete.connect(ImageSpecField._post_delete_receiver, sender=cls, - dispatch_uid=uid) - # Register the field with the image_cache_backend try: self.image_cache_backend.register_field(cls, self, name) except AttributeError: pass - @staticmethod - def _post_save_receiver(sender, instance=None, created=False, raw=False, **kwargs): - if not raw: - old_hashes = instance._ik._source_hashes.copy() - new_hashes = ImageSpecField._update_source_hashes(instance) - for attname in instance._ik.spec_fields: - if old_hashes[attname] != new_hashes[attname]: - getattr(instance, attname).invalidate() - - @staticmethod - def _update_source_hashes(instance): - """ - Stores hashes of the source image files so that they can be compared - later to see whether the source image has changed (and therefore whether - the spec file needs to be regenerated). - - """ - instance._ik._source_hashes = dict((f.attname, hash(f.source_file)) \ - for f in instance._ik.spec_files) - return instance._ik._source_hashes - - @staticmethod - def _post_delete_receiver(sender, instance=None, **kwargs): - for spec_file in instance._ik.spec_files: - spec_file.clear() - - @staticmethod - def _post_init_receiver(sender, instance, **kwargs): - ImageSpecField._update_source_hashes(instance) - class ProcessedImageField(models.ImageField): """ diff --git a/imagekit/models/receivers.py b/imagekit/models/receivers.py new file mode 100644 index 0000000..da93a69 --- /dev/null +++ b/imagekit/models/receivers.py @@ -0,0 +1,48 @@ +from django.db.models.signals import post_init, post_save, post_delete +from ..utils import ik_model_receiver + + +def update_source_hashes(instance): + """ + Stores hashes of the source image files so that they can be compared + later to see whether the source image has changed (and therefore whether + the spec file needs to be regenerated). + + """ + instance._ik._source_hashes = dict((f.attname, hash(f.source_file)) \ + for f in instance._ik.spec_files) + return instance._ik._source_hashes + + +@ik_model_receiver +def post_save_receiver(sender, instance=None, created=False, raw=False, **kwargs): + if not raw: + old_hashes = instance._ik._source_hashes.copy() + new_hashes = update_source_hashes(instance) + for attname in instance._ik.spec_fields: + if old_hashes[attname] != new_hashes[attname]: + getattr(instance, attname).invalidate() + + +@ik_model_receiver +def post_delete_receiver(sender, instance=None, **kwargs): + for spec_file in instance._ik.spec_files: + spec_file.clear() + + +@ik_model_receiver +def post_init_receiver(sender, instance, **kwargs): + update_source_hashes(instance) + + +def configure_receivers(): + # Connect the signals. We have to listen to every model (not just those + # with IK fields) and filter in our receivers because of a Django issue with + # abstract base models. + # Related: + # https://github.com/jdriscoll/django-imagekit/issues/126 + # https://code.djangoproject.com/ticket/9318 + uid = 'ik_spec_field_receivers' + post_init.connect(post_init_receiver, dispatch_uid=uid) + post_save.connect(post_save_receiver, dispatch_uid=uid) + post_delete.connect(post_delete_receiver, dispatch_uid=uid) diff --git a/imagekit/utils.py b/imagekit/utils.py index 4ffb556..d1d607e 100644 --- a/imagekit/utils.py +++ b/imagekit/utils.py @@ -371,3 +371,11 @@ def prepare_image(img, format): save_kwargs['optimize'] = True return img, save_kwargs + + +def ik_model_receiver(fn): + @wraps(fn) + def receiver(sender, **kwargs): + if getattr(sender, '_ik', None): + fn(sender, **kwargs) + return receiver From 9cc86d597ea0c9f6bcd25e7a7af26a50dd31735f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timothe=CC=81e=20Peignier?= Date: Mon, 13 Aug 2012 11:47:20 +0200 Subject: [PATCH 87/88] fix API documentation --- docs/apireference.rst | 2 +- docs/conf.py | 2 +- requirements.txt | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/apireference.rst b/docs/apireference.rst index df4b5c7..d4a2ed8 100644 --- a/docs/apireference.rst +++ b/docs/apireference.rst @@ -5,7 +5,7 @@ API Reference :mod:`models` Module -------------------- -.. automodule:: imagekit.models +.. automodule:: imagekit.models.fields :members: diff --git a/docs/conf.py b/docs/conf.py index 580401c..9305a5c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,7 +16,7 @@ import sys, os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +sys.path.insert(0, os.path.abspath('..')) sys.path.append(os.path.abspath('_themes')) os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' diff --git a/requirements.txt b/requirements.txt index 6a3296e..5161716 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ -Django >= 1.3.1 +Django>=1.3.1 +django-appconf>=0.5 +PIL>=1.1.7 From e136957fc0406496ac1fea11b539a3bd3ce10ce6 Mon Sep 17 00:00:00 2001 From: Matthew Tretter Date: Tue, 28 Aug 2012 10:25:12 -0400 Subject: [PATCH 88/88] Fix docs typo; closes #147 --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index d22739a..041d02d 100644 --- a/README.rst +++ b/README.rst @@ -64,7 +64,7 @@ you can use a ``ProcessedImageField``: from imagekit.models.fields import ProcessedImageField class Photo(models.Model): - processed_image = ImageSpecField(format='JPEG', options={'quality': 90}) + processed_image = ProcessedImageField(format='JPEG', options={'quality': 90}) See the class documentation for details.