diff --git a/README.rst b/README.rst index 4180705..a70f395 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. If you only want to save the processed image (without maintaining the original), you can use a ``ProcessedImageField``:: @@ -71,7 +71,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/generators.py b/imagekit/generators.py index a53bf09..50d04ac 100644 --- a/imagekit/generators.py +++ b/imagekit/generators.py @@ -1,39 +1,17 @@ -import mimetypes import os - from StringIO import StringIO -from django.core.files.base import ContentFile - -from .processors import ProcessorPipeline, AutoConvert -from .utils import img_to_fobj, open_image, \ - format_to_extension, extension_to_format, UnknownFormatError, \ - 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 +from .processors import ProcessorPipeline +from .utils import (img_to_fobj, open_image, IKContentFile, extension_to_format, + UnknownExtensionError) 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 @@ -51,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: @@ -61,39 +39,10 @@ class SpecFileGenerator(object): 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()) - imgfile = img_to_fobj(img, format, **options) - content = SpecFile(filename, imgfile.read()) + content = IKContentFile(filename, imgfile.read(), format=format) 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/__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 505ea7e..b2cceda 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): @@ -15,7 +16,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): """ @@ -47,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 @@ -146,14 +147,14 @@ 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): """ 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: @@ -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/processors/base.py b/imagekit/processors/base.py index afc0f4f..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,90 +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. - 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 305bdd4..d175b65 100644 --- a/imagekit/utils.py +++ b/imagekit/utils.py @@ -1,28 +1,48 @@ -import tempfile +import os +import mimetypes +from StringIO import StringIO +import sys 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 +from .lib import Image, ImageFile -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 +RGBA_TRANSPARENCY_FORMATS = ['PNG'] +PALETTE_TRANSPARENCY_FORMATS = ['PNG', 'GIF'] + + +class IKContentFile(ContentFile): + """ + 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, format=None): + self.file = ContentFile(content) + self.file.name = filename + 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 '') + + def __unicode__(self): + return smart_unicode(self.file.name or u'') + + +def img_to_fobj(img, format, autoconvert=True, **options): + return save_image(img, StringIO(), format, options, autoconvert) def get_spec_files(instance): @@ -107,6 +127,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. @@ -165,3 +198,176 @@ 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 + + +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 prepares the image for saving using ``prepare_image()``, which will do + some common-sense processing given the target format. + + """ + options = options or {} + + if autoconvert: + img, save_kwargs = prepare_image(img, format) + options = dict(save_kwargs.items() + options.items()) + + # Attempt to reset the file pointer. + try: + outfile.seek(0) + except AttributeError: + pass + + try: + 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 + # 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 + + +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) + + +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 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