diff --git a/README.rst b/README.rst index d22739a6..041d02d0 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. diff --git a/docs/apireference.rst b/docs/apireference.rst index df4b5c7d..d4a2ed83 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/changelog.rst b/docs/changelog.rst index 34d3ef73..072d8e17 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,17 @@ Changelog ========= +v2.0.2 +------ + +- Fixed the pickling of ImageSpecFieldFile. +- Signals are now connected without specifying the class and non-IK models + are filitered out in the receivers. This is necessary beacuse of a bug + with how Django handles abstract models. +- Fixed a `ZeroDivisionError` in the Reflection processor. +- `cStringIO` is now used if it's available. +- Reflections on images now use RGBA instead of RGB. + v2.0.1 ------ diff --git a/docs/conf.py b/docs/conf.py index 580401c8..e0913b92 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,7 +16,7 @@ # 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' @@ -50,9 +50,9 @@ # built documents. # # The short X.Y version. -version = '2.0.1' +version = '2.0.2' # The full version, including alpha/beta/rc tags. -release = '2.0.1' +release = '2.0.2' # 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 55a5d048..9dd4ffe1 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, 1, 'final', 0) +__version__ = (2, 0, 2, 'final', 0) __license__ = 'BSD' diff --git a/imagekit/lib.py b/imagekit/lib.py index 574e5874..7aff93c2 100644 --- a/imagekit/lib.py +++ b/imagekit/lib.py @@ -2,7 +2,7 @@ # depending on the installation method used. try: from PIL import Image, ImageColor, ImageChops, ImageEnhance, ImageFile, \ - ImageFilter, ImageDraw, ImageStat + ImageFilter, ImageDraw, ImageStat, ImageOps, ImageFont except ImportError: try: import Image @@ -13,6 +13,8 @@ import ImageFilter import ImageDraw import ImageStat + import ImageOps + import ImageFont 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.') diff --git a/imagekit/processors/watermark.py b/imagekit/processors/watermark.py new file mode 100644 index 00000000..a5290e7b --- /dev/null +++ b/imagekit/processors/watermark.py @@ -0,0 +1,315 @@ +# -*- coding: utf-8 -*- +# +# watermark processors for django-imagekit +# some inspiration from http://code.activestate.com/recipes/362879-watermark-with-pil/ +# + +from abc import abstractmethod, ABCMeta +from imagekit.lib import Image +from imagekit.lib import ImageDraw, ImageFont, ImageColor, ImageEnhance +import warnings +import weakref + +__all__ = ('ImageWatermark', 'TextWatermark', 'ReverseWatermark') + + +def _process_coords(img_size, wm_size, coord_spec): + """ + Given the dimensions of the image and the watermark as (x,y) tuples and a + location specification, return the coordinates where the watermark should + be placed according to the specification in a (x,y) tuple. + + Specification can use pixels, percentage (provided as a string, such as + "30%"), or keywords such as top, bottom, center, left and right. + """ + (sh, sv) = coord_spec + if sh in ('top','bottom') or sv in ('left','right'): + # coords were written in wrong order, but there's an easy fix + (sv, sh) = coord_spec + + if isinstance(sh, basestring) and '%' in sh: + sh = int(img_size[0] * float(sh.rstrip("%")) / 100) + + if isinstance(sh, int) and sh < 0: + sh = img_size[0] - wm_size[0] + sh + + if sh == 'left': + sh = 0 + elif sh == 'center': + sh = (img_size[0] - wm_size[0]) / 2 + elif sh == 'right': + sh = img_size[0] - wm_size[0] + + + if isinstance(sv, basestring) and '%' in sv: + sv = int(img_size[1] * float(sv.rstrip("%")) / 100) + + if isinstance(sv, int) and sv < 0: + sv = img_size[1] - wm_size[1] + sv + + if sv == 'top': + sv = 0 + elif sv == 'center': + sv = (img_size[1] - wm_size[1]) / 2 + elif sv == 'bottom': + sv = img_size[1] - wm_size[1] + + return (sh, sv) + + +class AbstractWatermark(object): + """ + Base class for ``ImageWatermark`` and ``TextWatermark``. + + Some properties that are used in processors based on this class are: + + ``opacity`` may be specified as a float ranging from 0.0 to 1.0. + + ``position`` is a tuple containing coordinates for horizontal and + vertical axis. Instead of coordinates you may use strings such as "left", + "center", "right", "top" or "bottom". You may also specify percentage + values such as "70%". Negative values will count from the opposite + margin. As such, `('66%', 'bottom')` and `('-33%', 'bottom')` are + equivalent. + + ``scale`` can be a numeric scale factor or ``True``, in which case the + watermark will be scaled to fit the base image, using the mechanics from + ``ResizeToFit``. + + ``repeat`` specifies if the watermark should be repeated throughout the + base image. The repeat pattern will be influenced by both ``scale`` and + ``position``. + + ``cache_mark`` specifies if the watermark layer that is merged into the + images should be cached rather than calculated every time a processing + runs, allowing a trade of CPU time for memory usage. + """ + + __metaclass__ = ABCMeta + + @abstractmethod + def get_watermark(self): + return + + def _get_watermark(self): + if not self.cache_mark: + return self.get_watermark() + else: + # cache watermark and use it + if self.cache_get_wm is None: + wm = None + else: + wm = self.cache_get_wm() + + if wm is None: + wm = self.get_watermark() + self.cache_get_wm = weakref.ref(wm) + return wm + + + + def _fill_options(self, opacity=1.0, position=('center','center'), + repeat=False, scale=None, cache_mark=True): + """Store common properties""" + + self.opacity = opacity + self.position = position + self.repeat = repeat + self.scale = scale + self.cache_mark = cache_mark + if cache_mark: + self.cache_get_wm = None + + def process(self, img): + + # get watermark + wm = self._get_watermark() + wm_size = wm.size + + if self.scale: + if isinstance(self.scale, (int, float)) and self.scale != 1: + wm_size[0] *= self.scale + wm_size[1] *= self.scale + wm = wm.scale(wm_size) + elif self.scale == True: + from .resize import ResizeToFit + wm = ResizeToFit(width=img.size[0], height=img.size[1], + upscale=True).process(wm) + wm_size = wm.size + + + # prepare image for overlaying (ensure alpha channel) + if img.mode != 'RGBA': + img = img.convert('RGBA') + + # create a layer to place the watermark + layer = Image.new('RGBA', img.size, (0,0,0,0)) + coords = _process_coords(img.size, wm_size, self.position) + + if self.repeat: + sx = coords[0] % wm_size[0] - wm_size[0] + sy = coords[1] % wm_size[1] - wm_size[1] + for x in range(sx, img.size[0], wm_size[0]): + for y in range(sy, img.size[1], wm_size[1]): + layer.paste(wm, (x,y)) + else: + layer.paste(wm, coords) + + + if self.opacity < 1: + alpha = layer.split()[3] + alpha = ImageEnhance.Brightness(alpha).enhance(self.opacity) + layer.putalpha(alpha) + + # merge watermark layer + img = Image.composite(layer, img, layer) + + return img + + +class ImageWatermark(AbstractWatermark): + """ + Creates a watermark using an image. + + ``watermark`` is the path to the image to be overlaid on the processed + image, or a storage (File-like) object that allows accessing the image. + """ + + def get_watermark(self): + # open the image despite the format that the user provided for it + if self.watermark: + return self.watermark + if self.watermark_image: + return self.watermark_image + + if self.watermark_file: + return Image.open(self.watermark_file) + if self.watermark_path: + return Image.open(self.watermark_path) + + def __init__(self, watermark, **kwargs): + # fill in base defaults + defaults = dict(opacity=1.0) + defaults.update(kwargs) + self._fill_options(**defaults) + + # fill in specific settings + self.watermark = None + self.watermark_image = self.watermark_file = self.watermark_path = None + + # we accept PIL Image objects, file-like objects or file paths + if isinstance(watermark, Image.Image): + self.watermark_image = watermark + elif hasattr(watermark, "read") and callable(watermark.open): + self.watermark_file = watermark + elif isinstance(watermark, basestring): + self.watermark_path = watermark + else: + raise TypeError("watermark must be a PIL Image, file-like object or " + "a path") + + +class ReverseWatermark(ImageWatermark): + """ + Same as ImageWatermark but instead of putting the watermark in the image + being processed, puts the image being processed on the watermark. + """ + + def __init__(self, *args, **kwargs): + kwargs.update({'cache_mark': False}) + super(ReverseWatermark, self).__init__(*args, **kwargs) + + def get_watermark(self): + return self._img + + def process(self, img): + # we invoke the base process() method, but before we do, + # we switch get_watermark() and the `img` argument + self._img = img + watermark = super(ReverseWatermark, self).get_watermark() + final = super(ReverseWatermark, self).process(watermark) + del self._img + return final + + +class TextWatermark(AbstractWatermark): + """ + Adds a watermark to the image with the specified text. + + You may adjust the font, color and position. + + ``text_color`` may be a string in a format supported by PIL's + ``ImageColor``, as described in the handbook [1] or a tuple containing + values in the 0-255 range for (R, G, B). + + ``font`` can be specified as a path to a `TrueType` font or a tuple in + the form of (path, size). + + For details on ``scale``, ``position`` and ``repeat``, check the + documentation on ``AbstractWatermark``. + + [1]: http://www.pythonware.com/library/pil/handbook/imagecolor.htm + """ + + def __init__(self, text, font=None, text_color=None, **kwargs): + # fill in base defaults + defaults = dict(opacity=0.5) + defaults.update(kwargs) + self._fill_options(**defaults) + + # fill in specific settings + self.text = text + + if isinstance(font, basestring): + # `font` is a path to a font + fontpath = font + fontsize = 16 + + elif isinstance(font, tuple): + # `font` is a (path, size) tuple + fontpath = font[0] + fontsize = font[1] + + elif font is not None: + raise TypeError("Expected 'font' to be a path to a font file " + "or a tuple containing (path, size).") + + try: + if fontpath.endswith(".pil"): + # bitmap font (size doesn't matter) + self.font = ImageFont.load(fontpath) + else: + # truetype font (size matters) + self.font = ImageFont.truetype(fontpath, fontsize) + except: + warnings.warn("The specified font '%s' could not be loaded" % + (font,), RuntimeWarning) + font = None + + if not font: + self.font = ImageFont.load_default() + + if text_color is None: + # fallback to default + self.text_color = (255,255,255) + elif isinstance(text_color, basestring): + # string in a form ImageColor module can process + self.text_color = ImageColor.getrgb(text_color) + elif isinstance(text_color, tuple): + # tuple with (R,G,B) + # if it has (R,G,B,A), the alpha component seems to be ignored by PIL + # when rendering text + self.text_color = text_color + else: + raise TypeError("Expected `text_color` to be tuple or string.") + + self.font_size = self.font.getsize(text) + + + def get_watermark(self): + wm = Image.new("RGBA", self.font_size, (0,0,0,0)) + draw = ImageDraw.Draw(wm, "RGBA") + draw.text((0,0), self.text, font=self.font, + fill=self.text_color) + return wm + diff --git a/requirements.txt b/requirements.txt index 6a3296ec..5161716e 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 diff --git a/tests/core/tests.py b/tests/core/tests.py index b4884c83..a73915e7 100644 --- a/tests/core/tests.py +++ b/tests/core/tests.py @@ -52,15 +52,15 @@ def test_extension_to_format(self): self.assertEqual(utils.extension_to_format('.jpeg'), 'JPEG') self.assertEqual(utils.extension_to_format('.rgba'), 'SGI') - with self.assertRaises(utils.UnknownExtensionError): - utils.extension_to_format('.txt') + self.assertRaises(utils.UnknownExtensionError, + lambda: utils.extension_to_format('.txt')) def test_format_to_extension_no_init(self): self.assertEqual(utils.format_to_extension('PNG'), '.png') self.assertEqual(utils.format_to_extension('ICO'), '.ico') - with self.assertRaises(utils.UnknownFormatError): - utils.format_to_extension('TXT') + self.assertRaises(utils.UnknownFormatError, + lambda: utils.format_to_extension('TXT')) class PickleTest(TestCase): diff --git a/tox.ini b/tox.ini index 72bf2068..1ad1957d 100644 --- a/tox.ini +++ b/tox.ini @@ -11,35 +11,35 @@ commands = django-admin.py test core --settings=settings [testenv:py27-django14] basepython = python2.7 deps = - Django>=1.4 + Django>=1.4,<1.5 Pillow [testenv:py27-django13] basepython = python2.7 deps = - Django>=1.3,<=1.4 + Django>=1.3,<1.4 Pillow [testenv:py27-django12] basepython = python2.7 deps = - Django>=1.2,<=1.3 + Django>=1.2,<1.3 Pillow [testenv:py26-django14] basepython = python2.6 deps = - Django>=1.4 + Django>=1.4,<1.5 Pillow [testenv:py26-django13] basepython = python2.6 deps = - Django>=1.3,<=1.4 + Django>=1.3,<1.4 Pillow [testenv:py26-django12] basepython = python2.6 deps = - Django>=1.2,<=1.3 + Django>=1.2,<1.3 Pillow