Ticket #4115: contrib.thumbnails.3.patch

File contrib.thumbnails.3.patch, 24.1 KB (added by Chris Beaven, 18 years ago)

With tests (mostly Chris Moffitt's work)

  • django/contrib/thumbnails/base.py

     
     1from django.conf import settings
     2import os
     3from StringIO import StringIO
     4from PIL import Image
     5from exceptions import ThumbnailNoData, ThumbnailInvalidImage
     6from methods import scale
     7
     8__all__ = ('Thumbnail',)
     9
     10class Thumbnail(object):
     11    method = scale
     12    base_url = settings.MEDIA_URL
     13    root = settings.MEDIA_ROOT
     14
     15    def __init__(self, filename='', data=None, overwrite=False, size=None, jpeg_quality=None):
     16        self._data = data
     17        if size:
     18            self.size = size
     19        self.jpeg_quality = jpeg_quality or 75
     20        self.filename = filename
     21        self.overwrite = overwrite
     22        if data:
     23            # If data was given and the thumbnail does not already exist,
     24            # generate thumbnail image now.
     25            self.make_thumbnail(data)
     26
     27    def get_filename(self):
     28        return self._filename
     29    def set_filename(self, filename):
     30        if filename:
     31            filename = filename % {'x': self.size[0], 'y': self.size[1], 'method': self.method.__name__, 'jpeg_quality': self.jpeg_quality}
     32            filename = os.path.normpath(filename).lstrip(os.sep)
     33            if os.path.splitext(filename)[1] != '.jpg':
     34                filename.append('.jpg')
     35            filename = os.path.join(self.root, filename)
     36        self._filename = filename
     37    filename = property(get_filename, set_filename)
     38
     39    def get_thumbnail(self):
     40        if hasattr(self, '_thumbnail'):
     41            return self._thumbnail
     42        try:
     43            img = Image.open(self.filename)
     44        except IOError, msg:
     45            raise ThumbnailInvalidImage(msg)
     46        self._thumbnail = img
     47        return img
     48    thumbnail = property(get_thumbnail)
     49
     50    def make_thumbnail(self, data):
     51        if self.overwrite or not os.path.isfile(self.filename):
     52            try:
     53                original = Image.open(StringIO(data))
     54            except IOError, msg:
     55                raise ThumbnailInvalidImage(msg)
     56            self._original_image = original
     57            thumbnail = self.method()
     58            self._thumbnail = thumbnail
     59            if self.filename:
     60                try:
     61                    thumbnail.save(self.filename, "JPEG", quality=self.jpeg_quality, optimize=1)
     62                except IOError:
     63                    # Try again, without optimization (the JPEG library can't
     64                    # optimize an image which is larger than ImageFile.MAXBLOCK
     65                    # which is 64k by default)
     66                    thumbnail.save(self.filename, "JPEG", quality=self.jpeg_quality)
     67
     68    def get_url(self):
     69        if hasattr(self, '_url'):
     70            return self._url
     71        filename = self.filename
     72        if not os.path.isfile(filename):
     73            raise ThumbnailNoData
     74        url = os.path.normpath(filename[len(self.root):]).lstrip(os.sep)
     75        url = os.path.join(self.base_url, url)
     76        if os.sep != '/':
     77            url = url.replace(os.sep, '/')
     78        self._url = url
     79        return url
     80    url = property(get_url)
     81
     82    # Make the object will output it's url to Django templates.
     83    __str__ = get_url
     84
     85    def get_original_image(self):
     86        if hasattr(self, '_original_image'):
     87            return self._original_image
     88        raise ThumbnailNoData
     89    original_image = property(get_original_image)
     90
     91    def delete(self):
     92        if os.path.isfile(self.filename):
     93            os.remove(self.filename)
     94        self.filename = ''
  • django/contrib/thumbnails/__init__.py

     
     1from base import *
     2from exceptions import *
     3from methods import *
  • django/contrib/thumbnails/exceptions.py

     
     1class ThumbnailException(Exception):
     2    pass
     3
     4class ThumbnailNoData(ThumbnailException):
     5    pass
     6
     7class ThumbnailTooSmall(ThumbnailException):
     8    pass
     9
     10class ThumbnailInvalidImage(ThumbnailException):
     11    pass
  • django/contrib/thumbnails/methods.py

     
     1from PIL import Image
     2from exceptions import ThumbnailTooSmall
     3
     4
     5def scale(thumbnail):
     6    """ Normal PIL thumbnail """
     7    img = thumbnail.original_image
     8    size = thumbnail.size
     9    if img.size[0] < size[0] and img.size[1] < size[1]:
     10        raise ThumbnailTooSmall('Image should be at least %s wide or %s high' % size)
     11    img.thumbnail(size, Image.ANTIALIAS)
     12    return img
     13
     14
     15def crop(thumbnail):
     16    """ Crop the image down to the same ratio as `size` """
     17    img = thumbnail.original_image
     18    size = thumbnail.size
     19
     20    if img.size[0] < size[0] or img.size[1] < size[1]:
     21        raise ThumbnailTooSmall('Image must be at least %s wide and %s high' % size)
     22
     23    image_x, image_y = img.size
     24
     25    crop_ratio = size[0] / float(size[1])
     26    image_ratio = image_x / float(image_y)
     27
     28    if crop_ratio < image_ratio:
     29        # x needs to shrink
     30        top = 0
     31        bottom = image_y
     32        crop_width = int(image_y * crop_ratio)
     33        left = (image_x - crop_width) // 2
     34        right = left + crop_width
     35    else:
     36        # y needs to shrink
     37        left = 0
     38        right = image_x
     39        crop_height = int(image_x * crop_ratio)
     40        top = (image_y - crop_height) // 2
     41        bottom = top + crop_height
     42
     43    img = img.crop((left, top, right, bottom))
     44    return img.resize(size, Image.ANTIALIAS)
     45
     46
     47def squash(thumbnail):
     48    """ Resize the image down to exactly `size` (changes ratio) """
     49    img = thumbnail.original_image
     50    size = thumbnail.size
     51    if img.size[0] < size[0] or img.size[1] < size[1]:
     52        raise ThumbnailTooSmall('Image must be at least %s wide and %s high' % size)
     53    return img.resize(size, Image.ANTIALIAS)
  • django/contrib/thumbnails/templatetags/thumbnails.py

     
     1from django.template import Library
     2from django.contrib.thumbnails import Thumbnail, crop, squash, ThumbnailException
     3from django.conf import settings
     4from django.utils.html import escape
     5import os.path
     6import re
     7
     8register = Library()
     9
     10class ThumbnailCrop(Thumbnail):
     11    method = crop
     12
     13class ThumbnailSquash(Thumbnail):
     14    method = squash
     15
     16
     17#@register.filter
     18def thumbnail(file, size):
     19    return create_thumbnail(Thumbnail, file, size)
     20register.filter(thumbnail)
     21
     22
     23#@register.filter
     24def thumbnail_crop(file, size):
     25    return create_thumbnail(ThumbnailCrop, file, size)
     26register.filter(thumbnail_crop)
     27
     28
     29#@register.filter
     30def thumbnail_squash(file, size):
     31    return create_thumbnail(ThumbnailSquash, file, size)
     32register.filter(thumbnail_squash)
     33
     34
     35#@register.filter
     36def img_tag(thumbnail):
     37    if not thumbnail:
     38        return ''
     39    x, y = thumbnail.thumbnail.size
     40    url = escape(thumbnail.url)
     41    return '<img src="%s" width="%s" height="%s" />' % (url, x, y)
     42register.filter(img_tag)
     43
     44
     45re_size_string = re.compile('\d+')
     46def create_thumbnail(thumbnail_cls, file, size_string):
     47    """
     48    Creates a thumbnail image for the file (which must exist on MEDIA_ROOT)
     49    and returns a url to this image.
     50   
     51    If the thumbnail image is not found, an empty string will be returned.
     52    """
     53    # Define the size.
     54    bits = [int(bit) for bit in re_size_string.findall(size_string)]
     55    if len(bits) == 3:
     56        size = bits[:2]
     57        jpeg_quality = bits[2]
     58    elif len(bits) == 2:
     59        size = bits
     60        jpeg_quality = None
     61    else:
     62        return ''
     63   
     64    # Define the filename, then create the thumbnail object.
     65    basename, ext = os.path.splitext(file)
     66    thumbnail_filename = basename + '_%(x)sx%(y)s_%(method)s_q%(jpeg_quality)s' + ext
     67    original_filename = os.path.join(settings.MEDIA_ROOT, file)
     68   
     69    # See if the thumbnail exists already (and is newer than the
     70    # original filename).
     71    try:
     72        thumbnail = thumbnail_cls(thumbnail_filename, size=size, jpeg_quality=jpeg_quality)
     73        if os.path.getmtime(original_filename) > os.path.getmtime(thumbnail.filename):
     74            thumbnail.delete()
     75        else:
     76            return thumbnail
     77    except (ThumbnailException, OSError):
     78        # Couldn't get the thumbnail (or something else went wrong).
     79        pass
     80
     81    # Read the original file from disk.
     82    try:
     83        data = open(original_filename, 'rb').read()
     84    except OSError:
     85        # Couldn't read the original file.
     86        return ''
     87   
     88    # Generate the thumbnail.
     89    try:
     90        thumbnail = thumbnail_cls(thumbnail_filename, data, size=size, jpeg_quality=jpeg_quality)
     91    except ThumbnailException:
     92        return ''
     93
     94    return thumbnail
  • docs/thumbnails.txt

     
     1=========================
     2django.contrib.thumbnails
     3=========================
     4
     5The ``django.contrib.thumbnails`` package, part of the `"django.contrib" add-ons`_,
     6provides a way of thumbnailing images.
     7
     8It requires the Python Imaging Library (PIL_).
     9
     10.. _"django.contrib" add-ons: ../add_ons/
     11.. _PIL: http://www.pythonware.com/products/pil/
     12
     13Template filters
     14================
     15
     16To use these template filters, add ``'django.contrib.thumbnails'`` to your
     17``INSTALLED_APPS`` setting. Once you've done that, use
     18``{% load thumbnails %}`` in a template to give your template access to the
     19filters.
     20
     21The thumbnail creation filters, all very similar in behaviour, are:
     22
     23 * ``thumbnail``
     24 * ``thumbnail_crop``
     25 * ``thumbnail_squash``
     26
     27The only difference between them is the `Thumbnail methods`_ that they use.
     28
     29One other filter is provided as a helper to the most common use:
     30
     31 * ``img_tag``
     32
     33Using the thumbnail filters
     34---------------------------
     35
     36Usage::
     37
     38    <img src="{{ object.imagefield|thumbnail:"150x100" }}" />
     39
     40The filter is applied to a image field (not the url get from
     41``get_[field]_url`` method of the model). Supposing the imagefield filename is
     42``'image.jpg'``, it creates a thumbnailed image file proportionally resized
     43down to a maximum of 150 pixels wide and 100 pixels high called
     44``'image_150x100_scale_q75.jpg'`` in the same location as the original image
     45and returns the URL to this thumbnail image.
     46
     47The ``thumbnail_crop`` works exactly the same way but uses the crop method
     48(and the filename would be called ``'image_150x100_crop_q75.jpg'``). Similarly,
     49``thumbnail_squash`` resizes the image to exactly the dimensions given
     50(``'image_150x100_squash_q75.jpg'``).
     51
     52If the thumbnail filename already exists, it is only overwritten if the date of
     53the the original image file is newer than the thumbnail file.
     54
     55The ``q75`` refers to the JPEG quality of the thumbnail image. You can change
     56the quality by providing a third number to the filter::
     57
     58    {{ object.imagefield|thumbnail:"150x10 85" }}
     59
     60Rather than just outputting the url, you can reference any other properties
     61(see the ```Thumbnail`` object properties`_ section below for a complete
     62list)::
     63
     64    {% with object.imagefield|thumbnail:"150x100" as thumb %}
     65    <img src="{{ thumb.url }}" width="{{ thumb.thumbnail.size.0 }}" height="{{ thumb.thumbnail.size.1 }}" />
     66    {% endwith %}
     67
     68The above example is the most common case, and the ``img_tag`` filter is
     69provided to make that easier. The following example explains it's use::
     70
     71        {{ object.imagefield|thumbnail:"150x100"|img_tag }}
     72
     73Creating a thumbnail
     74====================
     75
     76The rest of this documentation deals with lower-level usage of thumbnails.
     77
     78To create a thumbnail object, simply call the ``Thumbnail`` class::
     79
     80    >>> from django.contrib.thumbnails import *
     81    >>> thumbnail = Thumbnail(filename, data, size=(100, 100))
     82
     83The thumbnail object takes the following arguments:
     84
     85    ================= =========================================================
     86     Argument          Description
     87    ================= =========================================================
     88
     89    ``filename``      A string containing the path and filename to use when
     90                      saving or retreiving this thumbnail image from disk
     91                      (relative to the ``Thumbnail`` object's ``root`` property
     92                      which defaults to ``settings.MEDIA_ROOT``).
     93
     94                      For advanced usage, see the ```Thumbnail```_ property
     95                      section.
     96
     97    ``data``          A string or stream of the original image object to be
     98                      thumbnailed. If not provided and a file matching the
     99                      thumbnail can not be found, ``TemplateNoData`` will be
     100                      raised.
     101
     102                      Example::
     103
     104                          >>> Thumbnail('%(method)/s%(x)sx%(y)s/test.jpg', size=(60, 40)).filename
     105                          '.../scale/60x40/test.jpg'
     106
     107    ``overwrite``     Set to ``True`` to overwrite the thumbnail with ``data``
     108                      even if an existing cached thumbnail is found. Defaults
     109                      to ``False``.
     110
     111    ``size``          The size for the thumbnail image. Required unless using a
     112                      subclass which provides a default ``size`` (see the
     113                      `Custom thumbnails`_ section below).
     114
     115    ``jpeg_quality``  Change the quality of the thumbnail image. The default
     116                      quality is 75. The PIL manual recommends that values
     117                      above 95 should be avoided.
     118
     119``Thumbnail`` object properties
     120===============================
     121
     122The thumbnail object which is created provides the following properties and
     123functions:
     124
     125``filename``
     126------------
     127
     128Reading this property returns the full path and filename to this thumbnail
     129image.
     130
     131When you set this property, the filename string you provide is internally
     132appended to the ``Thumbnail`` object's ``root`` property.
     133
     134You can use string formatting to generate the filename based on the
     135thumbnailing method and size:
     136
     137  * ``%(x)s`` for the thumbnail target width,
     138  * ``%(y)s`` for the thumbnail target height,
     139  * ``%(method)s`` for the thumbnailing method,
     140  * ``%(jpeg_quality)s`` for the JPEG quality.
     141
     142For example::
     143
     144    >>> Thumbnail('%(method)/s%(x)sx%(y)s/test.jpg', size=(60, 40)).filename
     145    '.../scale/60x40/test.jpg'
     146    # (where ... is settings.MEDIA_ROOT)
     147
     148Note: thumbnailed images are always saved as JPEG images, so if the filename
     149string does not end in `'.jpg'`, this will be automatically appended to the
     150thumbnail's filename.
     151
     152``original_image``
     153------------------
     154
     155This read-only property returns a PIL ``Image`` containing the original
     156image (passed in with ``data``).
     157
     158``thumbnail``
     159-------------
     160
     161This read-only property returns a PIL ``Image`` containing the thumbnail
     162image.
     163
     164``url``
     165-------
     166
     167This read-only property returns the full url this thumbnail image.
     168
     169It is generated by appending the parsed ``filename`` string to the
     170``Template`` object's ``base_url`` property.
     171
     172``delete()``
     173------------
     174
     175Call this function to delete the thumbnail file if it exists on the disk.
     176
     177Custom thumbnails
     178=================
     179
     180Similar to newforms, you can create a subclass to override the default
     181properties of the ``Thumbnail`` base class::
     182
     183    from django.contrib.thumbnails import Thumbnail
     184
     185    class MyThumbnail(Thumbnail):
     186        size = (100, 100)
     187
     188Here are the properties you can provide to your subclass:
     189
     190    =============== ===========================================================
     191     Property        Description
     192    =============== ===========================================================
     193
     194    ``size``        Default size for creating thumbnails (no default).
     195    ``base_url``    Base url for thumbnails (default is ``settings.MEDIA_URL``).
     196    ``root``        Base directory for thumbnails (default is
     197                    ``settings.MEDIA_ROOT``).
     198    ``method``      The thumbnailing funciton to use (default is ``scale``).
     199                    See the `Thumbnail methods`_ section below.
     200
     201Thumbnail methods
     202=================
     203
     204There are several thumbnailing methods available in
     205``django.contrib.thumbnails.methods``
     206
     207``crop()``
     208----------
     209
     210This method crops the image height or width to match the ratio of the thumbnail
     211``size`` and then resizes it down to exactly the dimensions of ``size``.
     212
     213It requires the original image to be both as wide and as high as ``size``.
     214
     215``scale()``
     216-----------
     217
     218This is the normal PIL scaling method of proportionally resizing the image down
     219to no greater than the thumbnail ``size`` dimensions.
     220
     221It requires the original image to be either as wide or as high as ``size``.
     222
     223``squash()``
     224------------
     225
     226This method resizes the image down to exactly the dimensions given. This will
     227potentially squash or stretch the image.
     228
     229It requires the original image to be both as wide and as high as ``size``.
     230
     231Making your own methods
     232-----------------------
     233
     234To make your own thumbnailing function, create a function which accepts one
     235parameter (``thumbnail``) and returns a PIL ``Image``.
     236
     237The ``thumbnail`` parameter will be a ``Thumbnail`` object, so you can use it
     238to get the original image (it will raise ``ThumbnailNoData`` if no data was
     239provided) and the thumbnail size::
     240
     241    img = thumbnail.original_image
     242    size = thumbnail.size
     243
     244Exceptions
     245==========
     246
     247The following exceptions (all found in ``django.contrib.thumbnails.exceptions``
     248and all subclasses of ``ThumbnailException``) could be raised when using the
     249``Thumbnail`` object:
     250
     251    =========================  ================================================
     252     Exception                  Reason
     253    =========================  ================================================
     254
     255    ``ThumbnailNoData``        Tried to get the ``original_image`` when no
     256                               ``data`` was provided or tried to get the
     257                               ``url`` when the file did not exist and no
     258                               ``data`` was provided.
     259    ``ThumbnailTooSmall``      The ``original_image`` was too small to
     260                               thumbnail using the given thumbnailing method.
     261    ``ThumbnailInvalidImage``  The ``data`` provided could not be decoded to
     262                               a valid image format (or more rarely, using
     263                               ``thumbnail`` to retreive an existing thumbnail
     264                               file from disk which could not be decoded to a
     265                               valid image format).
     266
     267Putting it all together
     268=======================
     269
     270Here is a snippet of an example view which receives an image file from the user
     271and saves a thumbnail of this image to a file named ``[userid].jpg``::
     272
     273    from django.contrib.thumbnails import Thumbnail, crop, ThumbnailException
     274
     275    class ProfileThumbnail(Thumbnail):
     276        size = (100, 100)
     277        method = crop
     278
     279    def profile_image(request, id):
     280        profile = get_object_or_404(Profile, pk=id)
     281        if request.method == 'POST':
     282            image = request.FILES.get('profile_image')
     283            profile.has_image = False
     284            if image:
     285                filename = str(profile.id)
     286                try:
     287                    thumbnail = ProfileThumbnail(filename, image['content'])
     288                    profile.has_image = True
     289                except ThumbnailException:
     290                    pass
     291            profile.save()
     292            return HttpResponseRedirect('../')
     293        ...
  • tests/regressiontests/thumbnails/tests.py

     
     1import unittest
     2#from django.template.defaultfilters import *
     3from regressiontests.thumbnails.models import Picture
     4from django.contrib.thumbnails.templatetags.thumbnails import *
     5from django.conf import settings
     6import os
     7from PIL import Image
     8
     9PIC_NAME = "thumbmnail-test-pic.jpg"
     10PIC_SIZE = (800, 600)
     11
     12class ThumbnailTest(unittest.TestCase):
     13    images_to_delete = []
     14   
     15    def testThumbnail(self):
     16        # Create the test image
     17        test_image_name = os.path.join(settings.MEDIA_ROOT, PIC_NAME)
     18        Image.new('RGB', PIC_SIZE).save(test_image_name, 'JPEG')
     19        self.images_to_delete.append(test_image_name)
     20       
     21        # Create a dummy picture model
     22        pic_model = Picture()
     23        pic_model.image = PIC_NAME
     24        pic_model.save()
     25
     26        # Create a thumbnail and verify the file exists where it should
     27        thumb = thumbnail(pic_model.image, "240x240")
     28        thumb_name = "%s_240x240_scale_q75.jpg" % os.path.splitext(PIC_NAME)[0]
     29        self.verify_image(thumb, thumb_name, (240, 180))
     30
     31        # Create a squashed image and verify the file exists where it should
     32        thumb = thumbnail_squash(pic_model.image, "240x240")
     33        thumb_name = "%s_240x240_squash_q75.jpg" % os.path.splitext(PIC_NAME)[0]
     34        self.verify_image(thumb, thumb_name, (240, 240))
     35
     36        # Create a cropped image and verify the file exists where it should
     37        thumb = thumbnail_crop(pic_model.image, "120x240")
     38        thumb_name = "%s_120x240_crop_q75.jpg" % os.path.splitext(PIC_NAME)[0]
     39        self.verify_image(thumb, thumb_name, (120, 240))
     40
     41    def verify_image(self, thumbnail, expected_filename, expected_size):
     42        # Verify the file exists
     43        expected_filename = os.path.join(settings.MEDIA_ROOT, expected_filename)
     44        self.assertEqual(os.path.isfile(expected_filename), True)
     45       
     46        # Remember to delete this image
     47        self.images_to_delete.append(expected_filename)
     48
     49        # Verify the cropped thumbnail has the expected dimensions
     50        im = Image.open(expected_filename)
     51        self.assertEqual(im.size, expected_size)
     52
     53        # Verify the correct url is returned
     54        tag = r'<img src="%s" width="%s" height="%s" />' % \
     55                (thumbnail.url, expected_size[0], expected_size[1])
     56        self.assertEqual(tag, img_tag(thumbnail))
     57
     58    def tearDown(self):
     59        """
     60        Remove all the files that have been created
     61        """
     62        for image in self.images_to_delete:
     63            os.remove(image)
     64
     65
     66if __name__ == '__main__':
     67    import doctest
     68    doctest.testmod()
  • tests/regressiontests/thumbnails/models.py

     
     1from django.db import models
     2from django.conf import settings
     3import tempfile
     4import os
     5import shutil
     6
     7class Picture(models.Model):
     8    image = models.ImageField(upload_to="/")
Back to Top