diff --git a/AUTHORS b/AUTHORS index ed2df28..0ae6234 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,20 +1,35 @@ -Original Author: +ImageKit was originally written by `Justin Driscoll`_. -* Justin Driscoll (jdriscoll) +The field-based API was written by the bright minds at HZDG_. + +Maintainers +~~~~~~~~~~~ + +* `Bryan Veloso`_ +* `Chris Drackett`_ +* `Greg Newman`_ + +Contributors +~~~~~~~~~~~~ + +* `Josh Ourisman`_ +* `Jonathan Slenders`_ +* `Matthew Tretter`_ +* `Eric Eldredge`_ +* `Chris McKenzie`_ +* `Markus Kaiserswerth`_ +* `Ryan Bagwell`_ -Maintainers: - -* Bryan Veloso (bryanveloso) -* Chris Drackett (chrisdrackett) -* Greg Newman (gregnewman) - - -Contributors: - -* Josh Ourisman (joshourisman) -* Jonathan Slenders (jonathanslenders) -* Matthew Tretter (matthewwithanm) -* Markus Kaiserswerth (mkai) -* Ryan Bagwell (ryanbagwell) - +.. _Justin Driscoll: http://github.com/jdriscoll +.. _HZDG: http://hzdg.com +.. _Bryan Veloso: http://github.com/bryanveloso +.. _Chris Drackett: http://github.com/chrisdrackett +.. _Greg Newman: http://github.com/gregnewman +.. _Josh Ourisman: http://github.com/joshourisman +.. _Jonathan Slenders: http://github.com/jonathanslenders +.. _Matthew Tretter: http://github.com/matthewwithanm +.. _Eric Eldredge: http://github.com/lettertwo +.. _Chris McKenzie: http://github.com/kenzic +.. _Ryan Bagwell: http://github.com/ryanbagwell +.. _Markus Kaiserswerth: http://github.com/mkai diff --git a/README.rst b/README.rst index e82b37e..c423623 100644 --- a/README.rst +++ b/README.rst @@ -1,147 +1,108 @@ -=============== -django-imagekit -=============== +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. -NOTE: This, the **class-based** version of ImageKit, has been discontinued. -ImageKit In 7 Steps -=================== +Installation +------------ -Step 1 -****** +1. ``pip install django-imagekit`` + (or clone the source and put the imagekit module on your path) +2. Add ``'imagekit'`` to your ``INSTALLED_APPS`` list in your project's settings.py -:: - $ pip install django-imagekit +Adding Specs to a Model +----------------------- -(or clone the source and put the imagekit module on your path) - -Step 2 -****** - -Add ImageKit to your models. - -:: - - # myapp/models.py +Much like ``django.db.models.ImageField``, Specs are defined as properties +of a model class:: from django.db import models - from imagekit.models import ImageModel + from imagekit.models import ImageSpec - class Photo(ImageModel): - name = models.CharField(max_length=100) + class Photo(models.Model): original_image = models.ImageField(upload_to='photos') - num_views = models.PositiveIntegerField(editable=False, default=0) + formatted_image = ImageSpec(image_field='original_image', format='JPEG', + quality=90) - class IKOptions: - # This inner class is where we define the ImageKit options for the model - spec_module = 'myapp.specs' - cache_dir = 'photos' - image_field = 'original_image' - save_count_as = 'num_views' +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``):: -Step 3 -****** + photo = Photo.objects.all()[0] + photo.original_image.url # > '/media/photos/birthday.tiff' + photo.formatted_image.url # > '/media/cache/photos/birthday_formatted_image.jpeg' -Create your specifications. - -:: - - # myapp/specs.py - - from imagekit.specs import ImageSpec - from imagekit import processors - - # first we define our thumbnail resize processor - class ResizeThumb(processors.Resize): - width = 100 - height = 75 - crop = True - - # now we define a display size resize processor - class ResizeDisplay(processors.Resize): - width = 600 - - # now let's create an adjustment processor to enhance the image at small sizes - class EnhanceThumb(processors.Adjustment): - contrast = 1.2 - sharpness = 1.1 - - # now we can define our thumbnail spec - class Thumbnail(ImageSpec): - quality = 90 # defaults to 70 - access_as = 'thumbnail_image' - pre_cache = True - processors = [ResizeThumb, EnhanceThumb] - - # and our display spec - class Display(ImageSpec): - quality = 90 # defaults to 70 - increment_count = True - processors = [ResizeDisplay] - -Step 4 -****** - -Flush the cache and pre-generate thumbnails (ImageKit has to be added to ``INSTALLED_APPS`` for management command to work). - -:: - - $ python manage.py ikflush myapp - -Step 5 -****** - -Use your new model in templates. - -:: - -
- {{ photo.name }} -
- -
- {{ photo.name }} -
- -
- {% for p in photos %} - {{ p.name }} - {% endfor %} -
- -Step 6 -****** - -Play with the API. - -:: - - >>> from myapp.models import Photo - >>> p = Photo.objects.all()[0] - - >>> p.display.url - u'/static/photos/myphoto_display.jpg' - >>> p.display.width - 600 - >>> p.display.height - 420 - >>> p.display.image - - >>> p.display.file - - >>> p.display.spec - - -Step 7 -****** - -Enjoy a nice beverage. - -:: - - from refrigerator import beer - - beer.enjoy() +Check out ``imagekit.models.ImageSpec`` for more information. +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:: + + from django.db import models + from imagekit.models import ImageSpec + from imagekit.processors import resize, Adjust + + class Photo(models.Model): + original_image = models.ImageField(upload_to='photos') + thumbnail = ImageSpec([Adjust(contrast=1.2, sharpness=1.1), + resize.Crop(50, 50)], image_field='original_image', + format='JPEG', quality=90) + +The ``thumbnail`` property will now return a cropped image:: + + photo = Photo.objects.all()[0] + photo.thumbnail.url # > '/media/cache/photos/birthday_thumbnail.jpeg' + photo.thumbnail.width # > 50 + 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.Crop`` processor on the +original. + +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:: + + class Watermark(object): + def process(self, image): + # Code for adding the watermark goes here. + return image + + class Photo(models.Model): + original_image = models.ImageField(upload_to='photos') + watermarked_image = ImageSpec([Watermark()], image_field='original_image', + format='JPEG', quality=90) + + +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:: + + from django.contrib import admin + from imagekit.admin import AdminThumbnail + from .models import Photo + + + class PhotoAdmin(admin.ModelAdmin): + list_display = ('__str__', 'admin_thumbnail') + admin_thumbnail = AdminThumbnail(image_field='thumbnail') + + + admin.site.register(Photo, PhotoAdmin) + +AdminThumbnail can even use a custom template. For more information, see +``imagekit.admin.AdminThumbnail``. + + +__ https://docs.djangoproject.com/en/dev/intro/tutorial02/#customize-the-admin-change-list +__ https://docs.djangoproject.com/en/dev/ref/contrib/admin/#django.contrib.admin.ModelAdmin.list_display diff --git a/docs/Makefile b/docs/Makefile index 1dc0357..5e9e72d 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -3,9 +3,9 @@ # You can set these variables from the command line. SPHINXOPTS = -SPHINXBUILD = sphinx-build +SPHINXBUILD = python $(shell which sphinx-build) PAPER = -BUILDDIR = _build +BUILDDIR = build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 @@ -105,7 +105,7 @@ latex: latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." - make -C $(BUILDDIR)/latex all-pdf + $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: diff --git a/docs/apireference.rst b/docs/apireference.rst new file mode 100644 index 0000000..c997026 --- /dev/null +++ b/docs/apireference.rst @@ -0,0 +1,26 @@ +API Reference +============= + + +:mod:`models` Module +-------------------- + +.. automodule:: imagekit.models + :members: + + +:mod:`processors` Module +------------------------ + +.. automodule:: imagekit.processors + :members: + +.. automodule:: imagekit.processors.resize + :members: + + +:mod:`admin` Module +-------------------- + +.. automodule:: imagekit.admin + :members: diff --git a/docs/conf.py b/docs/conf.py index 5b21fe1..a751d2c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # ImageKit documentation build configuration file, created by -# sphinx-quickstart on Fri Apr 1 12:20:11 2011. +# sphinx-quickstart on Sun Sep 25 17:05:55 2011. # # This file is execfile()d with the current directory set to its containing dir. # @@ -25,7 +25,7 @@ import sys, os # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.todo', 'sphinx.ext.viewcode'] +extensions = ['sphinx.ext.autodoc'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -41,16 +41,16 @@ master_doc = 'index' # General information about the project. project = u'ImageKit' -copyright = u'2011, Justin Driscoll, Bryan Veloso, Greg Newman & Chris Drackett' +copyright = u'2011, Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & Matthew Tretter' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '0.3.6' +version = '1.0alpha' # The full version, including alpha/beta/rc tags. -release = '0.3.6' +release = '1.0alpha' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -64,7 +64,7 @@ release = '0.3.6' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = [] # The reST default role (used for this markup: `text`) to use for all documents. #default_role = None @@ -153,7 +153,7 @@ html_static_path = ['_static'] #html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +html_show_copyright = False # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the @@ -169,17 +169,22 @@ htmlhelp_basename = 'ImageKitdoc' # -- Options for LaTeX output -------------------------------------------------- -# The paper size ('letter' or 'a4'). -#latex_paper_size = 'letter' +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). -#latex_font_size = '10pt' +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', +} # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ('index', 'ImageKit.tex', u'ImageKit Documentation', - u'Justin Driscoll, Bryan Veloso, Greg Newman \\& Chris Drackett', 'manual'), + u'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett \\& Matthew Tretter', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -196,9 +201,6 @@ latex_documents = [ # If true, show URL addresses after external links. #latex_show_urls = False -# Additional stuff for the LaTeX preamble. -#latex_preamble = '' - # Documents to append as an appendix to all manuals. #latex_appendices = [] @@ -212,7 +214,7 @@ latex_documents = [ # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'imagekit', u'ImageKit Documentation', - [u'Justin Driscoll, Bryan Veloso, Greg Newman & Chris Drackett'], 1) + [u'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & Matthew Tretter'], 1) ] # If true, show URL addresses after external links. @@ -225,7 +227,7 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'ImageKit', u'ImageKit Documentation', u'Justin Driscoll, Bryan Veloso, Greg Newman & Chris Drackett', + ('index', 'ImageKit', u'ImageKit Documentation', u'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & Matthew Tretter', 'ImageKit', 'One line description of project.', 'Miscellaneous'), ] @@ -238,12 +240,4 @@ texinfo_documents = [ # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' - -# Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = { - 'py': ('http://docs.python.org/', None), - 'django': ( - 'http://docs.djangoproject.com/en/1.3', - 'http://docs.djangoproject.com/en/1.3/_objects/' - ) -} +autoclass_content = 'both' diff --git a/docs/index.rst b/docs/index.rst index e66d8c6..01916fd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,17 +1,28 @@ -ImageKit -======== +Getting Started +=============== -ImageKit brings automated image processing to your Django models. With an API -similar to that of Django's :ref:`models.Meta ` options -class and the creation of a specification file, you'll be able to create an -infinite number of renditions for any image using any number of preprocessors. +.. include:: ../README.rst -Contents: + +Commands +-------- + +.. automodule:: imagekit.management.commands.ikflush + + +Authors +------- + +.. include:: ../AUTHORS + + +Digging Deeper +-------------- .. toctree:: - :maxdepth: 2 - tutorial + apireference + Indices and tables ================== @@ -19,4 +30,3 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` * :ref:`search` - diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..aad0d30 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,190 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\ImageKit.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\ImageKit.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +:end diff --git a/imagekit/__init__.py b/imagekit/__init__.py index 8993c70..c12553b 100644 --- a/imagekit/__init__.py +++ b/imagekit/__init__.py @@ -1,13 +1,5 @@ -""" -Django ImageKit - -:author: Justin Driscoll -:maintainer: Bryan Veloso -:license: BSD - -""" __title__ = 'django-imagekit' -__version__ = '0.4.1' -__build__ = 0x000400 -__author__ = 'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett' +__author__ = 'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett, Matthew Tretter, Eric Eldredge' +__version__ = (1, 0, 0, 'final', 0) +__build__ = 0x001000 __license__ = 'BSD' diff --git a/imagekit/admin.py b/imagekit/admin.py new file mode 100644 index 0000000..cc24d29 --- /dev/null +++ b/imagekit/admin.py @@ -0,0 +1,37 @@ +from django.utils.translation import ugettext_lazy as _ +from django.template.loader import render_to_string + + +class AdminThumbnail(object): + """ + A convenience utility for adding thumbnails to Django's admin change list. + + """ + short_description = _('Thumbnail') + allow_tags = True + + def __init__(self, image_field, template=None): + """ + :param image_field: The name of the ImageField or ImageSpec on the + model to use for the thumbnail. + :param template: The template with which to render the thumbnail + + """ + self.image_field = image_field + self.template = template + + def __call__(self, obj): + thumbnail = getattr(obj, self.image_field, None) + + if not thumbnail: + raise Exception('The property {0} is not defined on {1}.'.format( + obj, self.image_field)) + + original_image = getattr(thumbnail, 'source_file', None) or thumbnail + template = self.template or 'imagekit/admin/thumbnail.html' + + return render_to_string(template, { + 'model': obj, + 'thumbnail': thumbnail, + 'original_image': original_image, + }) diff --git a/imagekit/defaults.py b/imagekit/defaults.py deleted file mode 100644 index c3475f6..0000000 --- a/imagekit/defaults.py +++ /dev/null @@ -1,30 +0,0 @@ -""" Default ImageKit configuration """ - -from imagekit.specs import ImageSpec -from imagekit import processors - - -class ResizeThumbnail(processors.Resize): - width = 100 - height = 50 - crop = True - - -class EnhanceSmall(processors.Adjustment): - contrast = 1.2 - sharpness = 1.1 - - -class SampleReflection(processors.Reflection): - size = 0.5 - background_color = "#000000" - - -class PNGFormat(processors.Format): - format = 'PNG' - extension = 'png' - - -class DjangoAdminThumbnail(ImageSpec): - access_as = 'admin_thumbnail' - processors = [ResizeThumbnail, EnhanceSmall, SampleReflection, PNGFormat] diff --git a/imagekit/management/__init__.py b/imagekit/management/__init__.py index 8b13789..e69de29 100644 --- a/imagekit/management/__init__.py +++ b/imagekit/management/__init__.py @@ -1 +0,0 @@ - diff --git a/imagekit/management/commands/__init__.py b/imagekit/management/commands/__init__.py index 8b13789..e69de29 100644 --- a/imagekit/management/commands/__init__.py +++ b/imagekit/management/commands/__init__.py @@ -1 +0,0 @@ - diff --git a/imagekit/management/commands/ikflush.py b/imagekit/management/commands/ikflush.py index 792112f..d718992 100644 --- a/imagekit/management/commands/ikflush.py +++ b/imagekit/management/commands/ikflush.py @@ -1,8 +1,11 @@ +""" +Flushes and re-caches all images under ImageKit. + +""" from django.db.models.loading import cache -from django.core.management.base import BaseCommand, CommandError -from optparse import make_option -from imagekit.models import ImageModel -from imagekit.specs import ImageSpec +from django.core.management.base import BaseCommand + +from imagekit.utils import get_spec_files class Command(BaseCommand): @@ -16,22 +19,17 @@ class Command(BaseCommand): def flush_cache(apps, options): - """ Clears the image cache - - """ apps = [a.strip(',') for a in apps] if apps: for app_label in apps: app = cache.get_app(app_label) - models = [m for m in cache.get_models(app) if issubclass(m, ImageModel)] - for model in models: + for model in [m for m in cache.get_models(app)]: print 'Flushing cache for "%s.%s"' % (app_label, model.__name__) for obj in model.objects.order_by('-pk'): - for spec in model._ik.specs: - prop = getattr(obj, spec.name(), None) - if prop is not None: - prop._delete() - if spec.pre_cache: - prop._create() + for spec_file in get_spec_files(obj): + if spec_file is not None: + spec_file.delete(save=False) + if spec_file.field.pre_cache: + spec_file._create() else: - print 'Please specify on or more app names' + print 'Please specify one or more app names' diff --git a/imagekit/models.py b/imagekit/models.py old mode 100644 new mode 100755 index 48a3ce6..815a972 --- a/imagekit/models.py +++ b/imagekit/models.py @@ -1,156 +1,365 @@ +import os +import datetime +from StringIO import StringIO + from django.conf import settings from django.core.files.base import ContentFile from django.db import models -from django.db.models.base import ModelBase -from django.db.models.signals import post_delete -from django.utils.html import conditional_escape as escape -from django.utils.translation import ugettext_lazy as _ +from django.db.models.fields.files import ImageFieldFile +from django.db.models.signals import post_save, post_delete +from django.utils.encoding import force_unicode, smart_str -from imagekit import specs from imagekit.lib import Image, ImageFile -from imagekit.options import Options -from imagekit.utils import img_to_fobj +from imagekit.utils import img_to_fobj, get_spec_files, open_image +from imagekit.processors import ProcessorPipeline # Modify image file buffer size. ImageFile.MAXBLOCK = getattr(settings, 'PIL_IMAGEFILE_MAXBLOCK', 256 * 2 ** 10) -# Choice tuples for specifying the crop origin. -# These are provided for convenience. -CROP_HORZ_CHOICES = ( - (0, _('left')), - (1, _('center')), - (2, _('right')), -) -CROP_VERT_CHOICES = ( - (0, _('top')), - (1, _('center')), - (2, _('bottom')), -) +class _ImageSpecMixin(object): + def __init__(self, processors=None, quality=70, format=None): + self.processors = processors + self.quality = quality + self.format = format + + def process(self, image, file): + processors = ProcessorPipeline(self.processors) + return processors.process(image.copy()) -class ImageModelBase(ModelBase): - """ ImageModel metaclass - - This metaclass parses IKOptions and loads the specified specification - module. +class ImageSpec(_ImageSpecMixin): + """ + The heart and soul of the ImageKit library, ImageSpec allows you to add + variants of uploaded images to your models. """ - def __init__(cls, name, bases, attrs): - parents = [b for b in bases if isinstance(b, ImageModelBase)] - if not parents: + _upload_to_attr = 'cache_to' + + def __init__(self, processors=None, quality=70, format=None, + image_field=None, pre_cache=False, storage=None, cache_to=None): + """ + :param processors: A list of processors to run on the original image. + :param quality: The quality of the output image. This option is only + used for the JPEG format. + :param format: The format of the output file. If not provided, + ImageSpec will try to guess the appropriate format based on the + extension of the filename and the format of the input image. + :param image_field: The name of the model property that contains the + original image. + :param pre_cache: A boolean that specifies whether the image should + be generated immediately (True) or on demand (False). + :param storage: A Django storage system to use to save the generated + image. + :param cache_to: Specifies the filename to use when saving the image + cache file. This is modeled after ImageField's ``upload_to`` and + can be either a string (that specifies a directory) or a + callable (that returns a filepath). Callable values should + accept the following arguments: + + - instance -- The model instance this spec belongs to + - path -- The path of the original image + - specname -- the property name that the spec is bound to on + the model instance + - extension -- A recommended extension. If the format of the + spec is set explicitly, this suggestion will be + 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. + + """ + + _ImageSpecMixin.__init__(self, processors, quality=quality, + format=format) + self.image_field = image_field + self.pre_cache = pre_cache + self.storage = storage + self.cache_to = cache_to + + def contribute_to_class(self, cls, name): + setattr(cls, name, _ImageSpecDescriptor(self, name)) + try: + ik = getattr(cls, '_ik') + except AttributeError: + ik = type('ImageKitMeta', (object,), {'spec_file_names': []}) + setattr(cls, '_ik', ik) + ik.spec_file_names.append(name) + + # Connect to the signals only once for this class. + uid = '%s.%s' % (cls.__module__, cls.__name__) + post_save.connect(_post_save_handler, + sender=cls, + dispatch_uid='%s_save' % uid) + post_delete.connect(_post_delete_handler, + sender=cls, + dispatch_uid='%s.delete' % uid) + + +def _get_suggested_extension(name, format): + if format: + # Try to look up an extension by the format. + extensions = [k for k, v in Image.EXTENSION.iteritems() \ + if v == format.upper()] + else: + extensions = [] + original_extension = os.path.splitext(name)[1] + if not extensions or original_extension.lower() in extensions: + # If the original extension matches the format, use it. + extension = original_extension + else: + extension = extensions[0] + return extension + + +class _ImageSpecFileMixin(object): + def _process_content(self, filename, content): + img = open_image(content) + original_format = img.format + img = self.field.process(img, self) + + # Determine the format. + format = self.field.format + if not format: + if callable(getattr(self.field, self.field._upload_to_attr)): + # The extension is explicit, so assume they want the matching format. + extension = os.path.splitext(filename)[1].lower() + # Try to guess the format from the extension. + format = Image.EXTENSION.get(extension) + format = format or img.format or original_format or 'JPEG' + + if format != 'JPEG': + imgfile = img_to_fobj(img, format) + else: + imgfile = img_to_fobj(img, format, + quality=int(self.field.quality), + optimize=True) + content = ContentFile(imgfile.read()) + return img, content + + +class ImageSpecFile(_ImageSpecFileMixin, ImageFieldFile): + def __init__(self, instance, field, attname, source_file): + ImageFieldFile.__init__(self, instance, field, None) + self.storage = field.storage or source_file.storage + self.attname = attname + self.source_file = source_file + + 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._create(True) + return super(ImageFieldFile, self).file + + file = property(_get_file, ImageFieldFile._set_file, ImageFieldFile._del_file) + + @property + def url(self): + self._create(True) + return super(ImageFieldFile, self).url + + def _create(self, lazy=False): + """ + Creates a new image by running the processors on the source file. + + Keyword Arguments: + lazy -- True if an already-existing image should be returned; + False if a new image should be created and the existing + one overwritten. + + """ + if lazy and (getattr(self, '_file', None) or self.storage.exists(self.name)): return - user_opts = getattr(cls, 'IKOptions', None) - opts = Options(user_opts) - if not opts.specs: + + if self.source_file: # TODO: Should we error here or something if the source_file doesn't exist? + # Process the original image file. try: - module = __import__(opts.spec_module, {}, {}, ['']) - except ImportError: - raise ImportError('Unable to load imagekit config module: %s' \ - % opts.spec_module) - opts.specs.extend([spec for spec in module.__dict__.values() \ - if isinstance(spec, type) \ - and issubclass(spec, specs.ImageSpec) \ - and spec != specs.ImageSpec]) - for spec in opts.specs: - setattr(cls, spec.name(), specs.Descriptor(spec)) - setattr(cls, '_ik', opts) + fp = self.source_file.storage.open(self.source_file.name) + except IOError: + return + fp.seek(0) + fp = StringIO(fp.read()) + img, content = self._process_content(self.name, fp) + self.storage.save(self.name, content) -class ImageModel(models.Model): - """ Abstract base class implementing all core ImageKit functionality + def delete(self, save=False): + """ + Pulled almost verbatim from ``ImageFieldFile.delete()`` and + ``FieldFile.delete()`` but with the attempts to reset the instance + property removed. - Subclasses of ImageModel are augmented with accessors for each defined - image specification and can override the inner IKOptions class to customize - storage locations and other options. + """ + # Clear the image dimensions cache + if hasattr(self, '_dimensions_cache'): + del self._dimensions_cache - """ - __metaclass__ = ImageModelBase + # Only close the file if it's already open, which we know by the + # presence of self._file. + if hasattr(self, '_file'): + self.close() + del self.file - class Meta: - abstract = True + try: + self.storage.delete(self.name) + except (NotImplementedError, IOError): + pass - class IKOptions: + # Delete the filesize cache. + if hasattr(self, '_size'): + del self._size + self._committed = False + + if save: + self.instance.save() + + @property + def _suggested_extension(self): + return _get_suggested_extension(self.source_file.name, self.field.format) + + def _default_cache_to(self, instance, path, specname, extension): + """ + Determines the filename to use for the transformed image. Can be + overridden on a per-spec basis by setting the cache_to property on + the spec. + + """ + filepath, basename = os.path.split(path) + filename = os.path.splitext(basename)[0] + new_name = '{0}_{1}{2}'.format(filename, specname, extension) + return os.path.join(os.path.join('cache', filepath), new_name) + + @property + def name(self): + """ + Specifies the filename that the cached image will use. The user can + control this by providing a `cache_to` method to the ImageSpec. + + """ + filename = self.source_file.name + if filename: + cache_to = self.field.cache_to or self._default_cache_to + + if not cache_to: + raise Exception('No cache_to or default_cache_to value specified') + if callable(cache_to): + new_filename = force_unicode(datetime.datetime.now().strftime( \ + smart_str(cache_to(self.instance, self.source_file.name, + self.attname, self._suggested_extension)))) + else: + dir_name = os.path.normpath(force_unicode(datetime.datetime.now().strftime(smart_str(cache_to)))) + filename = os.path.normpath(os.path.basename(filename)) + new_filename = os.path.join(dir_name, filename) + + return new_filename + + @name.setter + def name(self, value): + # TODO: Figure out a better way to handle this. We really don't want + # to allow anybody to set the name, but ``File.__init__`` (which is + # called by ``ImageSpecFile.__init__``) does, so we have to allow it + # at least that one time. pass - def admin_thumbnail_view(self): - if not self._imgfield: - return None - prop = getattr(self, self._ik.admin_thumbnail_spec, None) - if prop is None: - return 'An "%s" image spec has not been defined.' % \ - self._ik.admin_thumbnail_spec + +class _ImageSpecDescriptor(object): + def __init__(self, field, attname): + self.attname = attname + self.field = field + + def _get_image_field_file(self, instance): + field_name = getattr(self.field, 'image_field', None) + if field_name: + field = getattr(instance, field_name) else: - if hasattr(self, 'get_absolute_url'): - return u'' % \ - (escape(self.get_absolute_url()), escape(prop.url)) + image_fields = [getattr(instance, f.attname) for f in \ + instance.__class__._meta.fields if \ + isinstance(f, models.ImageField)] + if len(image_fields) == 0: + raise Exception('{0} does not define any ImageFields, so your ' + '{1} ImageSpec has no image to act on.'.format( + instance.__class__.__name__, self.attname)) + elif len(image_fields) > 1: + raise Exception('{0} defines multiple ImageFields, but you have ' + 'not specified an image_field for your {1} ' + 'ImageSpec.'.format(instance.__class__.__name__, + self.attname)) else: - return u'' % \ - (escape(self._imgfield.url), escape(prop.url)) - admin_thumbnail_view.short_description = _('Thumbnail') - admin_thumbnail_view.allow_tags = True + field = image_fields[0] + return field - @property - def _imgfield(self): - return getattr(self, self._ik.image_field) - - @property - def _storage(self): - return getattr(self._ik, 'storage', self._imgfield.storage) - - def _clear_cache(self): - for spec in self._ik.specs: - prop = getattr(self, spec.name()) - prop._delete() - - def _pre_cache(self): - for spec in self._ik.specs: - if spec.pre_cache: - prop = getattr(self, spec.name()) - prop._create() - - def save_image(self, name, image, save=True, replace=True): - if self._imgfield and replace: - self._imgfield.delete(save=False) - if hasattr(image, 'read'): - data = image.read() + def __get__(self, instance, owner): + if instance is None: + return self.field else: - data = image - content = ContentFile(data) - self._imgfield.save(name, content, save) - - def save(self, clear_cache=True, *args, **kwargs): - super(ImageModel, self).save(*args, **kwargs) - - is_new_object = self._get_pk_val() is None - if is_new_object: - clear_cache = False - - if self._imgfield: - spec = self._ik.preprocessor_spec - if spec is not None: - newfile = self._imgfield.storage.open(str(self._imgfield)) - img = Image.open(newfile) - img, format = spec.process(img, self) - if format != 'JPEG': - imgfile = img_to_fobj(img, format) - else: - imgfile = img_to_fobj(img, format, - quality=int(spec.quality), - optimize=True) - content = ContentFile(imgfile.read()) - newfile.close() - name = str(self._imgfield) - self._imgfield.storage.delete(name) - self._imgfield.storage.save(name, content) - if self._imgfield: - if clear_cache: - self._clear_cache() - self._pre_cache() - - def clear_cache(self, **kwargs): - assert self._get_pk_val() is not None, "%s object can't be deleted because its %s attribute is set to None." % (self._meta.object_name, self._meta.pk.attname) - self._clear_cache() + img_spec_file = ImageSpecFile(instance, self.field, + self.attname, self._get_image_field_file(instance)) + setattr(instance, self.attname, img_spec_file) + return img_spec_file -post_delete.connect(ImageModel.clear_cache, sender=ImageModel) +def _post_save_handler(sender, instance=None, created=False, raw=False, **kwargs): + if raw: + return + spec_files = get_spec_files(instance) + for spec_file in spec_files: + if not created: + spec_file.delete(save=False) + if spec_file.field.pre_cache: + spec_file._create() + + +def _post_delete_handler(sender, instance=None, **kwargs): + assert instance._get_pk_val() is not None, "%s object can't be deleted because its %s attribute is set to None." % (instance._meta.object_name, instance._meta.pk.attname) + spec_files = get_spec_files(instance) + for spec_file in spec_files: + spec_file.delete(save=False) + + +class ProcessedImageFieldFile(ImageFieldFile, _ImageSpecFileMixin): + def save(self, name, content, save=True): + new_filename = self.field.generate_filename(self.instance, name) + img, content = self._process_content(new_filename, content) + return super(ProcessedImageFieldFile, self).save(name, content, save) + + +class ProcessedImageField(models.ImageField, _ImageSpecMixin): + """ + ProcessedImageField is an ImageField that runs processors on the uploaded + image *before* saving it to storage. This is in contrast to specs, which + maintain the original. Useful for coercing fileformats or keeping images + within a reasonable size. + + """ + _upload_to_attr = 'upload_to' + attr_class = ProcessedImageFieldFile + + def __init__(self, processors=None, quality=70, format=None, + verbose_name=None, name=None, width_field=None, height_field=None, + **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 ``quality`` arguments of + :class:`imagekit.models.ImageSpec`. + + """ + _ImageSpecMixin.__init__(self, processors, quality=quality, + format=format) + models.ImageField.__init__(self, verbose_name, name, width_field, + height_field, **kwargs) + + def get_filename(self, filename): + filename = os.path.normpath(self.storage.get_valid_name(os.path.basename(filename))) + name, ext = os.path.splitext(filename) + ext = _get_suggested_extension(filename, self.format) + return '{0}{1}'.format(name, ext) + + +try: + from south.modelsinspector import add_introspection_rules +except ImportError: + pass +else: + add_introspection_rules([], [r'^imagekit\.models\.ProcessedImageField$']) diff --git a/imagekit/options.py b/imagekit/options.py deleted file mode 100644 index f3d3820..0000000 --- a/imagekit/options.py +++ /dev/null @@ -1,26 +0,0 @@ -# Imagekit options -from imagekit import processors -from imagekit.specs import ImageSpec - - -class Options(object): - """ Class handling per-model imagekit options - - """ - image_field = 'image' - crop_horz_field = 'crop_horz' - crop_vert_field = 'crop_vert' - preprocessor_spec = None - cache_dir = 'cache' - save_count_as = None - cache_filename_fields = ['pk', ] - cache_filename_format = "%(filename)s_%(specname)s_%(original_extension)s.%(extension)s" - admin_thumbnail_spec = 'admin_thumbnail' - spec_module = 'imagekit.defaults' - specs = None - #storage = defaults to image_field.storage - - def __init__(self, opts): - for key, value in opts.__dict__.iteritems(): - setattr(self, key, value) - self.specs = list(self.specs or []) diff --git a/imagekit/processors.py b/imagekit/processors.py deleted file mode 100644 index 226fcfc..0000000 --- a/imagekit/processors.py +++ /dev/null @@ -1,178 +0,0 @@ -""" Imagekit Image "ImageProcessors" - -A processor defines a set of class variables (optional) and a class method -named "process" which processes the supplied image using the class properties -as settings. The process method can be overridden as well allowing user to -define their own effects/processes entirely. - -""" -from imagekit.lib import Image, ImageEnhance, ImageColor - - -class ImageProcessor(object): - """ Base image processor class """ - - @classmethod - def process(cls, img, fmt, obj): - return img, fmt - - -class Adjustment(ImageProcessor): - color = 1.0 - brightness = 1.0 - contrast = 1.0 - sharpness = 1.0 - - @classmethod - def process(cls, img, fmt, obj): - img = img.convert('RGB') - for name in ['Color', 'Brightness', 'Contrast', 'Sharpness']: - factor = getattr(cls, name.lower()) - if factor != 1.0: - try: - img = getattr(ImageEnhance, name)(img).enhance(factor) - except ValueError: - pass - return img, fmt - - -class Format(ImageProcessor): - format = 'JPEG' - extension = 'jpg' - - @classmethod - def process(cls, img, fmt, obj): - return img, cls.format - - -class Reflection(ImageProcessor): - background_color = '#FFFFFF' - size = 0.0 - opacity = 0.6 - - @classmethod - def process(cls, img, fmt, obj): - # convert bgcolor string to rgb value - background_color = ImageColor.getrgb(cls.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 * cls.opacity)) # The start of our gradient - steps = int(255 * cls.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] * cls.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])) - # Save the file as a JPEG - fmt = 'JPEG' - # return the image complete with reflection effect - return composite, fmt - - -class Resize(ImageProcessor): - width = None - height = None - crop = False - upscale = False - - @classmethod - def process(cls, img, fmt, obj): - cur_width, cur_height = img.size - if cls.crop: - crop_horz = getattr(obj, obj._ik.crop_horz_field, 1) - crop_vert = getattr(obj, obj._ik.crop_vert_field, 1) - ratio = max(float(cls.width) / cur_width, float(cls.height) / cur_height) - resize_x, resize_y = ((cur_width * ratio), (cur_height * ratio)) - crop_x, crop_y = (abs(cls.width - resize_x), abs(cls.height - resize_y)) - x_diff, y_diff = (int(crop_x / 2), int(crop_y / 2)) - box_left, box_right = { - 0: (0, cls.width), - 1: (int(x_diff), int(x_diff + cls.width)), - 2: (int(crop_x), int(resize_x)), - }[crop_horz] - box_upper, box_lower = { - 0: (0, cls.height), - 1: (int(y_diff), int(y_diff + cls.height)), - 2: (int(crop_y), int(resize_y)), - }[crop_vert] - box = (box_left, box_upper, box_right, box_lower) - img = img.resize((int(resize_x), int(resize_y)), Image.ANTIALIAS).crop(box) - else: - if not cls.width is None and not cls.height is None: - ratio = min(float(cls.width) / cur_width, - float(cls.height) / cur_height) - else: - if cls.width is None: - ratio = float(cls.height) / cur_height - else: - ratio = float(cls.width) / cur_width - new_dimensions = (int(round(cur_width * ratio)), - int(round(cur_height * ratio))) - if new_dimensions[0] > cur_width or \ - new_dimensions[1] > cur_height: - if not cls.upscale: - return img, fmt - img = img.resize(new_dimensions, Image.ANTIALIAS) - return img, fmt - - -class Transpose(ImageProcessor): - """ Rotates or flips the image - - Method should be one of the following strings: - - FLIP_LEFT RIGHT - - FLIP_TOP_BOTTOM - - ROTATE_90 - - ROTATE_270 - - ROTATE_180 - - auto - - If method is set to 'auto' the processor will attempt to rotate the image - according to the EXIF Orientation data. - - """ - EXIF_ORIENTATION_STEPS = { - 1: [], - 2: ['FLIP_LEFT_RIGHT'], - 3: ['ROTATE_180'], - 4: ['FLIP_TOP_BOTTOM'], - 5: ['ROTATE_270', 'FLIP_LEFT_RIGHT'], - 6: ['ROTATE_270'], - 7: ['ROTATE_90', 'FLIP_LEFT_RIGHT'], - 8: ['ROTATE_90'], - } - - method = 'auto' - - @classmethod - def process(cls, img, fmt, obj): - if cls.method == 'auto': - try: - orientation = Image.open(obj._imgfield.file)._getexif()[0x0112] - ops = cls.EXIF_ORIENTATION_STEPS[orientation] - except: - ops = [] - else: - ops = [cls.method] - for method in ops: - img = img.transpose(getattr(Image, method)) - return img, fmt diff --git a/imagekit/processors/__init__.py b/imagekit/processors/__init__.py new file mode 100644 index 0000000..72e7f8d --- /dev/null +++ b/imagekit/processors/__init__.py @@ -0,0 +1,172 @@ +""" +Imagekit image processors. + +A processor accepts an image, does some stuff, and returns the result. +Processors can do anything with the image you want, but their responsibilities +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 +from imagekit.processors import resize + + +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): + img = img.convert('RGB') + 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 + 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 AttributeError: + ops = [] + else: + ops = self.methods + for method in ops: + img = img.transpose(method) + return img diff --git a/imagekit/processors/resize.py b/imagekit/processors/resize.py new file mode 100644 index 0000000..e2788d5 --- /dev/null +++ b/imagekit/processors/resize.py @@ -0,0 +1,123 @@ +from imagekit.lib import Image + + +class _Resize(object): + width = None + height = None + + def __init__(self, width=None, height=None): + if width is not None: + self.width = width + if height is not None: + self.height = height + + def process(self, img): + raise NotImplementedError('process must be overridden by subclasses.') + + +class Crop(_Resize): + """ + Resizes an image , cropping it to the specified width and height. + + """ + 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): + """ + :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 + + """ + super(Crop, self).__init__(width, height) + self.anchor = anchor + + def process(self, img): + cur_width, cur_height = img.size + horizontal_anchor, vertical_anchor = Crop._ANCHOR_PTS[self.anchor or \ + Crop.CENTER] + ratio = max(float(self.width) / cur_width, float(self.height) / cur_height) + resize_x, resize_y = ((cur_width * ratio), (cur_height * ratio)) + crop_x, crop_y = (abs(self.width - resize_x), abs(self.height - resize_y)) + x_diff, y_diff = (int(crop_x / 2), int(crop_y / 2)) + box_left, box_right = { + 0: (0, self.width), + 0.5: (int(x_diff), int(x_diff + self.width)), + 1: (int(crop_x), int(resize_x)), + }[horizontal_anchor] + box_upper, box_lower = { + 0: (0, self.height), + 0.5: (int(y_diff), int(y_diff + self.height)), + 1: (int(crop_y), int(resize_y)), + }[vertical_anchor] + box = (box_left, box_upper, box_right, box_lower) + img = img.resize((int(resize_x), int(resize_y)), Image.ANTIALIAS).crop(box) + return img + + +class Fit(_Resize): + """ + Resizes an image to fit within the specified dimensions. + + """ + def __init__(self, width=None, height=None, upscale=None): + """ + :param width: The maximum width of the desired image. + :param height: The maximum height of the desired image. + :param upscale: A boolean value specifying whether the image should + be enlarged if its dimensions are smaller than the target + dimensions. + + """ + super(Fit, self).__init__(width, height) + self.upscale = upscale + + def process(self, img): + cur_width, cur_height = img.size + if not self.width is None and not self.height is None: + ratio = min(float(self.width) / cur_width, + float(self.height) / cur_height) + else: + if self.width is None: + ratio = float(self.height) / cur_height + else: + ratio = float(self.width) / cur_width + new_dimensions = (int(round(cur_width * ratio)), + int(round(cur_height * ratio))) + if new_dimensions[0] > cur_width or \ + new_dimensions[1] > cur_height: + if not self.upscale: + return img + img = img.resize(new_dimensions, Image.ANTIALIAS) + return img diff --git a/imagekit/specs.py b/imagekit/specs.py deleted file mode 100644 index 0a11ffe..0000000 --- a/imagekit/specs.py +++ /dev/null @@ -1,149 +0,0 @@ -""" ImageKit image specifications - -All imagekit specifications must inherit from the ImageSpec class. Models -inheriting from ImageModel will be modified with a descriptor/accessor for each -spec found. - -""" -import os -import cStringIO as StringIO - -from django.core.files.base import ContentFile - -from imagekit import processors -from imagekit.lib import Image -from imagekit.utils import img_to_fobj - - -class ImageSpec(object): - pre_cache = False - quality = 70 - increment_count = False - processors = [] - - @classmethod - def name(cls): - return getattr(cls, 'access_as', cls.__name__.lower()) - - @classmethod - def process(cls, image, obj): - fmt = image.format - img = image.copy() - for proc in cls.processors: - img, fmt = proc.process(img, fmt, obj) - img.format = fmt - return img, fmt - - -class Accessor(object): - def __init__(self, obj, spec): - self._img = None - self._fmt = None - self._obj = obj - self.spec = spec - - def _get_imgfile(self): - format = self._img.format or 'JPEG' - if format != 'JPEG': - imgfile = img_to_fobj(self._img, format) - else: - imgfile = img_to_fobj(self._img, format, - quality=int(self.spec.quality), - optimize=True) - return imgfile - - def _create(self): - if self._obj._imgfield: - if self._exists(): - return - # process the original image file - try: - fp = self._obj._imgfield.storage.open(self._obj._imgfield.name) - except IOError: - return - fp.seek(0) - fp = StringIO.StringIO(fp.read()) - self._img, self._fmt = self.spec.process(Image.open(fp), self._obj) - # save the new image to the cache - content = ContentFile(self._get_imgfile().read()) - self._obj._storage.save(self.name, content) - - def _delete(self): - if self._obj._imgfield: - try: - self._obj._storage.delete(self.name) - except (NotImplementedError, IOError): - return - - def _exists(self): - if self._obj._imgfield: - return self._obj._storage.exists(self.name) - - @property - def name(self): - if self._obj._imgfield.name: - filepath, basename = os.path.split(self._obj._imgfield.name) - filename, extension = os.path.splitext(basename) - original_extension = extension - for processor in self.spec.processors: - if issubclass(processor, processors.Format): - extension = processor.extension - filename_format_dict = {'filename': filename, - 'specname': self.spec.name(), - 'original_extension': original_extension, - 'extension': extension.lstrip('.')} - cache_filename_fields = self._obj._ik.cache_filename_fields - filename_format_dict.update(dict(zip( - cache_filename_fields, - [getattr(self._obj, field) for - field in cache_filename_fields]))) - cache_filename = self._obj._ik.cache_filename_format % \ - filename_format_dict - - if callable(self._obj._ik.cache_dir): - return self._obj._ik.cache_dir(self._obj, filepath, - cache_filename) - else: - return os.path.join(self._obj._ik.cache_dir, filepath, - cache_filename) - - @property - def url(self): - if not self.spec.pre_cache: - self._create() - if self.spec.increment_count: - fieldname = self._obj._ik.save_count_as - if fieldname is not None: - current_count = getattr(self._obj, fieldname) - setattr(self._obj, fieldname, current_count + 1) - self._obj.save(clear_cache=False) - return self._obj._storage.url(self.name) - - @property - def file(self): - self._create() - return self._obj._storage.open(self.name) - - @property - def image(self): - if not self._img: - self._create() - if not self._img: - self._img = Image.open(self.file) - return self._img - - @property - def width(self): - return self.image.size[0] - - @property - def height(self): - return self.image.size[1] - - -class Descriptor(object): - def __init__(self, spec): - self._spec = spec - - def __get__(self, obj, type=None): - return Accessor(obj, self._spec) diff --git a/imagekit/templates/imagekit/admin/thumbnail.html b/imagekit/templates/imagekit/admin/thumbnail.html new file mode 100644 index 0000000..6531391 --- /dev/null +++ b/imagekit/templates/imagekit/admin/thumbnail.html @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/imagekit/utils.py b/imagekit/utils.py index 90cafa2..637433e 100644 --- a/imagekit/utils.py +++ b/imagekit/utils.py @@ -1,6 +1,9 @@ -""" ImageKit utility functions """ - import tempfile +import types + +from django.utils.functional import wraps + +from imagekit.lib import Image def img_to_fobj(img, format, **kwargs): @@ -15,3 +18,34 @@ def img_to_fobj(img, format, **kwargs): img.save(tmp, format, **kwargs) tmp.seek(0) return tmp + + +def get_spec_files(instance): + try: + ik = getattr(instance, '_ik') + except AttributeError: + return [] + else: + return [getattr(instance, n) for n in ik.spec_file_names] + + +def open_image(target): + img = Image.open(target) + img.copy = types.MethodType(_wrap_copy(img.copy), img, img.__class__) + return img + + +def _wrap_copy(f): + @wraps(f) + def copy(self): + img = f() + try: + img.app = self.app + except AttributeError: + pass + try: + img._getexif = self._getexif + except AttributeError: + pass + return img + return copy diff --git a/setup.py b/setup.py index 2e3939f..579d7ee 100644 --- a/setup.py +++ b/setup.py @@ -1,34 +1,30 @@ #/usr/bin/env python +import codecs import os import sys -import imagekit -try: - from setuptools import setup -except ImportError: - from distutils.core import setup +from setuptools import setup, find_packages if 'publish' in sys.argv: os.system('python setup.py sdist upload') sys.exit() +read = lambda filepath: codecs.open(filepath, 'r', 'utf-8').read() + setup( name='django-imagekit', - version=imagekit.__version__, + version=':versiontools:imagekit:', description='Automated image processing for Django models.', + long_description=read(os.path.join(os.path.dirname(__file__), 'README.rst')), author='Justin Driscoll', author_email='justin@driscolldev.com', maintainer='Bryan Veloso', maintainer_email='bryan@revyver.com', license='BSD', url='http://github.com/jdriscoll/django-imagekit/', - packages=[ - 'imagekit', - 'imagekit.management', - 'imagekit.management.commands' - ], + packages=find_packages(), classifiers=[ - 'Development Status :: 3 - Alpha', + 'Development Status :: 4 - Beta', 'Environment :: Web Environment', 'Framework :: Django', 'Intended Audience :: Developers', @@ -38,5 +34,8 @@ setup( 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Topic :: Utilities' - ] + ], + setup_requires=[ + 'versiontools >= 1.8', + ], ) diff --git a/tests/core/models.py b/tests/core/models.py index 2f153bd..e69de29 100644 --- a/tests/core/models.py +++ b/tests/core/models.py @@ -1,14 +0,0 @@ -from django.db import models - -from imagekit.models import ImageModel - - -class TestPhoto(ImageModel): - """ - Minimal ImageModel class for testing. - - """ - image = models.ImageField(upload_to='images') - - class IKOptions: - spec_module = 'core.specs' diff --git a/tests/core/specs.py b/tests/core/specs.py deleted file mode 100644 index 5b38cc8..0000000 --- a/tests/core/specs.py +++ /dev/null @@ -1,28 +0,0 @@ -from imagekit import processors -from imagekit.specs import ImageSpec - - -class ResizeToWidth(processors.Resize): - width = 100 - -class ResizeToHeight(processors.Resize): - height = 100 - -class ResizeToFit(processors.Resize): - width = 100 - height = 100 - -class ResizeCropped(ResizeToFit): - crop = ('center', 'center') - -class TestResizeToWidth(ImageSpec): - access_as = 'to_width' - processors = [ResizeToWidth] - -class TestResizeToHeight(ImageSpec): - access_as = 'to_height' - processors = [ResizeToHeight] - -class TestResizeCropped(ImageSpec): - access_as = 'cropped' - processors = [ResizeCropped] diff --git a/tests/core/tests.py b/tests/core/tests.py index e96af80..dd83fce 100644 --- a/tests/core/tests.py +++ b/tests/core/tests.py @@ -1,14 +1,20 @@ import os import tempfile -import unittest -from django.conf import settings from django.core.files.base import ContentFile +from django.db import models from django.test import TestCase from imagekit.lib import Image +from imagekit.models import ImageSpec +from imagekit.processors import Adjust +from imagekit.processors.resize import Crop -from core.models import TestPhoto + +class Photo(models.Model): + original_image = models.ImageField(upload_to='photos') + thumbnail = ImageSpec([Adjust(contrast=1.2, sharpness=1.1), Crop(50, 50)], + image_field='original_image', format='JPEG', quality=90) class IKTest(TestCase): @@ -20,59 +26,30 @@ class IKTest(TestCase): return tmp def setUp(self): - self.p = TestPhoto() + self.photo = Photo() img = self.generate_image() - self.p.save_image('test.jpeg', ContentFile(img.read())) - self.p.save() + file = ContentFile(img.read()) + self.photo.original_image = file + self.photo.original_image.save('test.jpeg', file) + self.photo.save() img.close() def test_save_image(self): - img = self.generate_image() - path = self.p.image.path - self.p.save_image('test2.jpeg', ContentFile(img.read())) - self.failIf(os.path.isfile(path)) - path = self.p.image.path - img.seek(0) - self.p.save_image('test.jpeg', ContentFile(img.read())) - self.failIf(os.path.isfile(path)) - img.close() + photo = Photo.objects.get(id=self.photo.id) + self.assertTrue(os.path.isfile(photo.original_image.path)) def test_setup(self): - self.assertEqual(self.p.image.width, 800) - self.assertEqual(self.p.image.height, 600) + self.assertEqual(self.photo.original_image.width, 800) + self.assertEqual(self.photo.original_image.height, 600) - def test_to_width(self): - self.assertEqual(self.p.to_width.width, 100) - self.assertEqual(self.p.to_width.height, 75) + def test_thumbnail_creation(self): + photo = Photo.objects.get(id=self.photo.id) + self.assertTrue(os.path.isfile(photo.thumbnail.file.name)) - def test_to_height(self): - self.assertEqual(self.p.to_height.width, 133) - self.assertEqual(self.p.to_height.height, 100) + def test_thumbnail_size(self): + self.assertEqual(self.photo.thumbnail.width, 50) + self.assertEqual(self.photo.thumbnail.height, 50) - def test_crop(self): - self.assertEqual(self.p.cropped.width, 100) - self.assertEqual(self.p.cropped.height, 100) - - def test_url(self): - tup = (settings.MEDIA_URL, self.p._ik.cache_dir, - 'images/test_to_width.jpeg') - self.assertEqual(self.p.to_width.url, "%s%s/%s" % tup) - - def tearDown(self): - # ImageKit doesn't delete processed files unless you clear the cache. - # We also attempt to remove the cache directory as to not clutter up - # your filesystem. - self.p._clear_cache() - try: - os.removedirs(os.path.join(settings.MEDIA_ROOT, self.p._ik.cache_dir, 'images')) - except OSError: - pass - - # As of Django 1.3, FileFields no longer delete the underlying image - # when you delete the model. For the sanity of these tests, we have - # to do this ourselves. - path = self.p.image.path - os.remove(os.path.join(settings.MEDIA_ROOT, path)) - os.removedirs(os.path.join(settings.MEDIA_ROOT, 'images')) - self.p.delete() - self.failIf(os.path.isfile(path)) + def test_thumbnail_source_file(self): + self.assertEqual( + self.photo.thumbnail.source_file, self.photo.original_image) diff --git a/tests/settings.py b/tests/settings.py index 082adb9..f034e9c 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -8,10 +8,19 @@ BASE_PATH = os.path.abspath(os.path.dirname(__file__)) MEDIA_ROOT = os.path.normpath(os.path.join(BASE_PATH, 'media')) +# Django <= 1.2 DATABASE_ENGINE = 'sqlite3' DATABASE_NAME = 'imagekit.db' TEST_DATABASE_NAME = 'imagekit-test.db' +# Django >= 1.3 +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'imagekit.db', + }, +} + INSTALLED_APPS = [ 'django.contrib.auth', 'django.contrib.contenttypes',