# HG changeset patch # User ymh # Date 1264095670 -3600 # Node ID 10b1f6d8a5d27d7e780781cdc3de539aaf0979e5 # Parent b77683731f252261337be90561e22e5f22c6ebda first debug version diff -r b77683731f25 -r 10b1f6d8a5d2 .hgignore --- a/.hgignore Wed Jan 20 12:41:13 2010 +0100 +++ b/.hgignore Thu Jan 21 18:41:10 2010 +0100 @@ -5,3 +5,4 @@ ^web/.htaccess$ ^web/blinkster/.htaccess$ ^web/blinkster/config.py$ +^web/static/photologue diff -r b77683731f25 -r 10b1f6d8a5d2 web/blinkster/__init__.py --- a/web/blinkster/__init__.py Wed Jan 20 12:41:13 2010 +0100 +++ b/web/blinkster/__init__.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,3 @@ +VERSION = (0,1) + +VERSION_STR = unicode(".".join(map(lambda i:"%02d" % (i,), VERSION))) \ No newline at end of file diff -r b77683731f25 -r 10b1f6d8a5d2 web/blinkster/config.py.tmpl --- a/web/blinkster/config.py.tmpl Wed Jan 20 12:41:13 2010 +0100 +++ b/web/blinkster/config.py.tmpl Thu Jan 21 18:41:10 2010 +0100 @@ -8,12 +8,12 @@ # Absolute path to the directory that holds media. # Example: "/home/media/media.lawrence.com/" -MEDIA_ROOT = '' +# MEDIA_ROOT = os.path.abspath(BASE_DIR + "../static/") # URL that handles the media served from MEDIA_ROOT. Make sure to use a # trailing slash if there is a path component (optional in other cases). # Examples: "http://media.lawrence.com", "http://example.com/media/" -MEDIA_URL = '' +MEDIA_URL = MEDIA_BASE_URL CONTENT_ROOT = MEDIA_ROOT + "media/content/" diff -r b77683731f25 -r 10b1f6d8a5d2 web/blinkster/ldt/views.py --- a/web/blinkster/ldt/views.py Wed Jan 20 12:41:13 2010 +0100 +++ b/web/blinkster/ldt/views.py Thu Jan 21 18:41:10 2010 +0100 @@ -3,7 +3,7 @@ from django.shortcuts import render_to_response from django.template import RequestContext from blinkster import settings -from blinkster import version +import blinkster from blinkster.ldt.forms import LdtImportForm from blinkster.ldt.forms import LdtProjectImportForm from blinkster.ldt.forms import SearchForm, LdtForm, reindexForm @@ -285,7 +285,7 @@ writer = MarkupWriter(resp, indent = u"yes") writer.startDocument() - writer.startElement(u"iri", attributes={u"version": unicode(version.VERSION)}) + writer.startElement(u"iri", attributes={u"version": blinkster.VERSION_STR}) writer.startElement(u"projects") for project in projects: diff -r b77683731f25 -r 10b1f6d8a5d2 web/blinkster/models.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/blinkster/models.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,52 @@ +import os + +from django.db import models +from django.contrib import admin +import uuid +import photologue.models +from django.conf import settings +from modeltranslation.utils import build_localized_fieldname + +def get_sid(): + return unicode(uuid.uuid1()) + +class Roi(models.Model): + + sid = models.CharField(max_length=36, unique=True, blank=False, null=False, db_index=True, editable=False, default=get_sid) + title = models.CharField(max_length=1024, unique=True, blank=False, null=False) + short_title = models.CharField(max_length=512, unique=True, blank=False, null=False) + description = models.TextField(blank=True, null=True) + loc = models.CharField(max_length=512, unique=False, blank=True, null=True) + loc_radius = models.FloatField(null=True) + photos = models.ForeignKey(photologue.models.Gallery, null=True) + + + def serialize_to_dict(self): + res = { + "sid": self.sid, + } + + for fieldname in ["title","short_title","description"]: + lang_dict = {} + for l in settings.LANGUAGES: + lang = l[0] + lfieldname = build_localized_fieldname(fieldname, lang) + lang_dict[lang] = self.__dict__[lfieldname] + res[fieldname] = lang_dict + + res["loc"] = {} + if self.loc: + loc_array = self.loc.split(",") + res["loc"] = { "type" : "Point", "coordinates": [float(loc_array[0]),float(loc_array[1])] } + + res["loc_radius"] = self.loc_radius + + res["photos"] = [] + + if self.photos: + for photo in self.photos.photos.all(): + res["photos"].append({"title":photo.title,"url":settings.WEB_URL.strip("/")+photo.image.url}) + + return res + +admin.site.register(Roi) \ No newline at end of file diff -r b77683731f25 -r 10b1f6d8a5d2 web/blinkster/settings.py --- a/web/blinkster/settings.py Wed Jan 20 12:41:13 2010 +0100 +++ b/web/blinkster/settings.py Thu Jan 21 18:41:10 2010 +0100 @@ -38,7 +38,7 @@ # Absolute path to the directory that holds media. # Example: "/home/media/media.lawrence.com/" -MEDIA_ROOT = '' +# MEDIA_ROOT = os.path.abspath(BASE_DIR + "../static/") # URL that handles the media served from MEDIA_ROOT. Make sure to use a # trailing slash if there is a path component (optional in other cases). @@ -73,7 +73,8 @@ # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". # Always use forward slashes, even on Windows. # Don't forget to use absolute paths, not relative paths. - os.path.join(BASE_DIR, "templates") + os.path.join(BASE_DIR, "templates"), + os.path.join(BASE_DIR,"..","lib", "photologue", "templates"), ) INSTALLED_APPS = ( @@ -83,7 +84,10 @@ 'django.contrib.sessions', 'django.contrib.sites', 'django.contrib.admin', + 'modeltranslation', + 'photologue', 'blinkster.ldt', + 'blinkster', ) TEMPLATE_CONTEXT_PROCESSORS = ( @@ -104,4 +108,14 @@ "__MACOSX", ) +gettext = lambda s: s +# IRIUSER_PAGE = True + +LANGUAGES = ( + ('fr', gettext('fr')), +) + +TRANSLATION_REGISTRY = "blinkster.translation" + + from config import * diff -r b77683731f25 -r 10b1f6d8a5d2 web/blinkster/templates/admin/base_site.html --- a/web/blinkster/templates/admin/base_site.html Wed Jan 20 12:41:13 2010 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,10 +0,0 @@ -{% extends "admin/base.html" %} -{% load i18n %} - -{% block title %}{{ title }} | {% trans 'Administration pocket film ' %}{% endblock %} - -{% block branding %} -

{% trans 'Administration experimentation Pocket Films' %}

-{% endblock %} - -{% block nav-global %}{% endblock %} diff -r b77683731f25 -r 10b1f6d8a5d2 web/blinkster/templates/admin/index.html --- a/web/blinkster/templates/admin/index.html Wed Jan 20 12:41:13 2010 +0100 +++ b/web/blinkster/templates/admin/index.html Thu Jan 21 18:41:10 2010 +0100 @@ -1,7 +1,11 @@ {% extends "admin/base_site.html" %} {% load i18n %} -{% block stylesheet %}{% load adminmedia %}{% admin_media_prefix %}css/dashboard.css{% endblock %} +{% block extrastyle %} +{{ block.super }} + + +{% endblock %} {% block coltype %}colMS{% endblock %} @@ -16,7 +20,7 @@ {% for app in app_list %}
- + {% for model in app.models %} {% if model.perms.change %} @@ -42,36 +46,36 @@ {% endfor %}
-
{% blocktrans with app.name as name %}{{ name }}{% endblocktrans %}{% blocktrans with app.name as name %}{{ name }}{% endblocktrans %}
- - - - - - - +
Import
- Import an ldt/import content -   -
- Generate ldt -   + + + + + - - - - + + - - + + + + + + - -
Import
+ Import an ldt/import content +  
- Import a project -   +
+ Generate ldt +  
+ Import a project +   +
Reindex  
+
@@ -93,7 +97,19 @@ {% else %} {% endif %} @@ -101,12 +117,11 @@ {% endblock %} {% block footer %} -
+ {% endblock %} - diff -r b77683731f25 -r 10b1f6d8a5d2 web/blinkster/translation.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/blinkster/translation.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,7 @@ +from modeltranslation.translator import translator, TranslationOptions +from blinkster.models import Roi + +class RoiTranslationOption(TranslationOptions): + fields = ('title', 'short_title', 'description',) + +translator.register(Roi, RoiTranslationOption) diff -r b77683731f25 -r 10b1f6d8a5d2 web/blinkster/urls.py --- a/web/blinkster/urls.py Wed Jan 20 12:41:13 2010 +0100 +++ b/web/blinkster/urls.py Thu Jan 21 18:41:10 2010 +0100 @@ -38,6 +38,7 @@ (r'^ldt/project/(?P.*)$', 'blinkster.ldt.views.ldtProject'), (r'^ldt/projectslist/(?P.*)$', 'blinkster.ldt.views.getProjectFromContentId'), #(r'^.*(?Pflvplayer|mp3player|ClearExternalAllBlue)\.swf$','django.views.generic.simple.redirect_to', {'url':blinkster.settings.BASE_URL+'/static/swf/ldt/pkg/%(content)s.swf'}), - + (r'^roi/list/$', 'blinkster.views.roi'), + (r'^photologue/', include('photologue.urls')), ) diff -r b77683731f25 -r 10b1f6d8a5d2 web/blinkster/utils/context_processors.py --- a/web/blinkster/utils/context_processors.py Wed Jan 20 12:41:13 2010 +0100 +++ b/web/blinkster/utils/context_processors.py Thu Jan 21 18:41:10 2010 +0100 @@ -1,8 +1,8 @@ import blinkster.settings; -import blinkster.version; +import blinkster; def version(request): - return {'VERSION': blinkster.version.VERSION } + return {'VERSION': blinkster.VERSION_STR } def base(request): return {'BASE_URL': blinkster.settings.BASE_URL } diff -r b77683731f25 -r 10b1f6d8a5d2 web/blinkster/version.py --- a/web/blinkster/version.py Wed Jan 20 12:41:13 2010 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1 +0,0 @@ -VERSION = "00.00" diff -r b77683731f25 -r 10b1f6d8a5d2 web/blinkster/views.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/blinkster/views.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,21 @@ +# Create your views here. +from django.http import HttpResponse, HttpResponseRedirect +from django.shortcuts import render_to_response +from django.core.urlresolvers import reverse +from django.template import RequestContext, TemplateDoesNotExist +from django.template.loader import get_template +from django.core import serializers +from django.core.serializers.json import DjangoJSONEncoder +from django.utils import simplejson + +from blinkster.models import Roi +import blinkster + +def roi(request): + response = HttpResponse(content_type="application/json; charset=utf-8") + objs = { + "version" : blinkster.VERSION, + "rois" : [roi.serialize_to_dict() for roi in Roi.objects.all()] + } + simplejson.dump(objs, response, cls=DjangoJSONEncoder,ensure_ascii=False, indent=4) + return response diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/modeltranslation/__init__.py diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/modeltranslation/admin.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/modeltranslation/admin.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +from copy import deepcopy + +from django.conf import settings +from django.contrib import admin +from django.contrib.contenttypes.models import ContentType +from django.forms import widgets +from django import forms, template +from django.forms.fields import MultiValueField +from django.shortcuts import get_object_or_404, render_to_response +from django.utils.safestring import mark_safe + +from modeltranslation.translator import translator + + +class TranslationAdmin(admin.ModelAdmin): + + def patch_translation_field(self, db_field, field, **kwargs): + trans_opts = translator.get_options_for_model(self.model) + + # Hide the original field by making it non-editable. + if db_field.name in trans_opts.fields: + db_field.editable = False + + # For every localized field copy the widget from the original field + if db_field.name in trans_opts.localized_fieldnames_rev: + orig_fieldname = trans_opts.localized_fieldnames_rev[db_field.name] + orig_formfield = self.formfield_for_dbfield(self.model._meta.get_field(orig_fieldname), **kwargs) + + # In case the original form field was required, make the default + # translation field required instead. + if db_field.language == settings.LANGUAGES[0][0] and orig_formfield.required: + orig_formfield.required = False + field.required = True + + field.widget = deepcopy(orig_formfield.widget) + + def formfield_for_dbfield(self, db_field, **kwargs): + # Call the baseclass function to get the formfield + field = super(TranslationAdmin, self).formfield_for_dbfield(db_field, **kwargs) + self.patch_translation_field(db_field, field, **kwargs) + return field + + #def save_model(self, request, obj, form, change): + #""" + #Given a model instance save it to the database. + + #Because each translated field is wrapped with a descriptor to return + #the translated fields value (determined by the current language) we + #cannot set the field directly. + #To bypass the descriptor the assignment is done using the __dict__ + #of the object. + #""" + #trans_opts = translator.get_options_for_model(self.model) + #for field_name in trans_opts.fields: + ## Bypass the descriptor applied to the original field by setting + ## it's value via the __dict__ (which doesn't call the descriptor). + #obj.__dict__[field_name] = form.cleaned_data[field_name] + + ## Call the baseclass method + #super(TranslationAdmin, self).save_model(request, obj, form, change) + + +class TranslationTabularInline(admin.TabularInline): + + def patch_translation_field(self, db_field, field, **kwargs): + trans_opts = translator.get_options_for_model(self.model) + + # Hide the original field by making it non-editable. + if db_field.name in trans_opts.fields: + db_field.editable = False + + # For every localized field copy the widget from the original field + if db_field.name in trans_opts.localized_fieldnames_rev: + orig_fieldname = trans_opts.localized_fieldnames_rev[db_field.name] + orig_formfield = self.formfield_for_dbfield(self.model._meta.get_field(orig_fieldname), **kwargs) + + # In case the original form field was required, make the default + # translation field required instead. + if db_field.language == settings.LANGUAGES[0][0] and orig_formfield.required: + orig_formfield.required = False + field.required = True + + field.widget = deepcopy(orig_formfield.widget) + + def formfield_for_dbfield(self, db_field, **kwargs): + # Call the baseclass function to get the formfield + field = super(TranslationTabularInline, self).formfield_for_dbfield(db_field, **kwargs) + self.patch_translation_field(db_field, field, **kwargs) + return field + + +class TranslationStackedInline(admin.StackedInline): + + def patch_translation_field(self, db_field, field, **kwargs): + trans_opts = translator.get_options_for_model(self.model) + + # Hide the original field by making it non-editable. + if db_field.name in trans_opts.fields: + db_field.editable = False + + # For every localized field copy the widget from the original field + if db_field.name in trans_opts.localized_fieldnames_rev: + orig_fieldname = trans_opts.localized_fieldnames_rev[db_field.name] + orig_formfield = self.formfield_for_dbfield(self.model._meta.get_field(orig_fieldname), **kwargs) + + # In case the original form field was required, make the default + # translation field required instead. + if db_field.language == settings.LANGUAGES[0][0] and orig_formfield.required: + orig_formfield.required = False + field.required = True + + field.widget = deepcopy(orig_formfield.widget) + + def formfield_for_dbfield(self, db_field, **kwargs): + # Call the baseclass function to get the formfield + field = super(TranslationStackedInline, self).formfield_for_dbfield(db_field, **kwargs) + self.patch_translation_field(db_field, field, **kwargs) + return field + \ No newline at end of file diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/modeltranslation/fields.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/modeltranslation/fields.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,98 @@ + +from django.conf import settings +from django.db.models.fields import Field, CharField +from django.utils.translation import get_language + +from modeltranslation.utils import build_localized_fieldname + +class TranslationField(Field): + """ + The translation field functions as a proxy to the original field which is + wrapped. + + For every field defined in the model's ``TranslationOptions`` localized + versions of that field are added to the model depending on the languages + given in ``settings.LANGUAGES``. + + If for example there is a model ``News`` with a field ``title`` which is + registered for translation and the ``settings.LANGUAGES`` contains the + ``de`` and ``en`` languages, the fields ``title_de`` and ``title_en`` will + be added to the model class. These fields are realized using this + descriptor. + + The translation field needs to know which language it contains therefore + that needs to be specified when the field is created. + """ + def __init__(self, translated_field, language, *args, **kwargs): + # Store the originally wrapped field for later + self.translated_field = translated_field + self.language = language + + # Update the dict of this field with the content of the original one + # This might be a bit radical?! Seems to work though... + self.__dict__.update(translated_field.__dict__) + + # Translation are always optional (for now - maybe add some parameters + # to the translation options for configuring this) + self.null = True + self.blank = True + + # Adjust the name of this field to reflect the language + self.attname = build_localized_fieldname(translated_field.name, language) + self.name = self.attname + + # Copy the verbose name and append a language suffix (will e.g. in the + # admin). This might be a proxy function so we have to check that here. + if hasattr(translated_field.verbose_name, '_proxy____unicode_cast'): + verbose_name = translated_field.verbose_name._proxy____unicode_cast() + else: + verbose_name = translated_field.verbose_name + self.verbose_name = '%s [%s]' % (verbose_name, language) + + def pre_save(self, model_instance, add): + val = super(TranslationField, self).pre_save(model_instance, add) + if get_language() == self.language and not add: + # Rule is: 3. Assigning a value to a translation field of the default language + # also updates the original field + model_instance.__dict__[self.translated_field.name] = val + #setattr(model_instance, self.attname, orig_val) + # Also return the original value + #return orig_val + return val + + #def get_attname(self): + #return self.attname + + def get_internal_type(self): + return self.translated_field.get_internal_type() + + def contribute_to_class(self, cls, name): + + super(TranslationField, self).contribute_to_class(cls, name) + + #setattr(cls, 'get_%s_display' % self.name, curry(cls._get_FIELD_display, field=self)) + +#class CurrentLanguageField(CharField): + #def __init__(self, **kwargs): + #super(CurrentLanguageField, self).__init__(null=True, max_length=5, **kwargs) + + #def contribute_to_class(self, cls, name): + #super(CurrentLanguageField, self).contribute_to_class(cls, name) + #registry = CurrentLanguageFieldRegistry() + #registry.add_field(cls, self) + + +#class CurrentLanguageFieldRegistry(object): + #_registry = {} + + #def add_field(self, model, field): + #reg = self.__class__._registry.setdefault(model, []) + #reg.append(field) + + #def get_fields(self, model): + #return self.__class__._registry.get(model, []) + + #def __contains__(self, model): + #return model in self.__class__._registry + + \ No newline at end of file diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/modeltranslation/management/__init__.py diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/modeltranslation/management/commands/__init__.py diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/modeltranslation/management/commands/update_translation_fields.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/modeltranslation/management/commands/update_translation_fields.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,25 @@ + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError, NoArgsCommand + +from modeltranslation.translator import translator +from modeltranslation.utils import build_localized_fieldname + +class Command(NoArgsCommand): + help = 'Updates the default translation fields of all or the specified'\ + 'translated application using the value of the original field.' + # args = '[app_name]' + + def handle(self, **options): + default_lang = settings.LANGUAGES[0][0] + print "Using default language:", default_lang + + for model, trans_opts in translator._registry.items(): + print "Updating data of model '%s'" % model + for obj in model.objects.all(): + for fieldname in trans_opts.fields: + def_lang_fieldname = build_localized_fieldname(fieldname, default_lang) + # print "setting %s from %s to %s." % (def_lang_fieldname, fieldname, obj.__dict__[fieldname]) + if not getattr(obj, def_lang_fieldname): + setattr(obj, def_lang_fieldname, obj.__dict__[fieldname]) + obj.save() diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/modeltranslation/middleware.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/modeltranslation/middleware.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,24 @@ +# from django.db.models import signals +# from django.utils.functional import curry + +#class TranslationMiddleware(object): + #def process_request(self, request): + #if hasattr(request, 'LANGUAGE_CODE'): + #print "TranslationMiddleware: preferred lang=", request.LANGUAGE_CODE + #update_lang = curry(self.update_lang, request.LANGUAGE_CODE) + #signals.pre_save.connect(update_lang, dispatch_uid=request, weak=False) + #else: + #print "TranslationMiddleware: no lang" + #pass + + + #def update_lang(self, lang, sender, instance, **kwargs): + #registry = registration.FieldRegistry() + #if sender in registry: + #for field in registry.get_fields(sender): + #setattr(instance, field.name, lang) + + #def process_response(self, request, response): + #print "response:", dir(response) + #signals.pre_save.disconnect(dispatch_uid=request) + #return response \ No newline at end of file diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/modeltranslation/models.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/modeltranslation/models.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,25 @@ + +from django.db import models +from django.conf import settings + +from modeltranslation.translator import translator + +# Every model registered with the modeltranslation.translator.translator +# is patched to contain additional localized versions for every +# field specified in the model's translation options. + +# Import the project's global "translation.py" which registers model +# classes and their translation options with the translator object. +# This requires an extra settings entry, because I see no other way +# to determine the module name of the project +try: + translation_mod = __import__(settings.TRANSLATION_REGISTRY, {}, {}, ['']) +except ImportError, exc: + print "No translation.py found in the project directory." + #raise ImportError("No translation.py found in the project directory.") + +# After importing all translation modules, all translation classes are +# registered with the translator. +translated_app_names = ', '.join(t.__name__ for t in translator._registry.keys()) +print "modeltranslation: registered %d applications for translation (%s)." % (len(translator._registry), + translated_app_names) diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/modeltranslation/tests.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/modeltranslation/tests.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,253 @@ + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.test import TestCase +from django.contrib.auth.models import User +from django.utils.translation import get_language +from django.utils.translation import trans_real +from django.utils.thread_support import currentThread + +from modeltranslation import translator + +class TestModel(models.Model): + title = models.CharField(max_length=255) + text = models.TextField(null=True) + +class TestTranslationOptions(translator.TranslationOptions): + fields = ('title', 'text',) + +translator.translator.register(TestModel, TestTranslationOptions) + +class ModelTranslationTest(TestCase): + "Basic tests for the modeltranslation application." + + urls = 'modeltranslation.testurls' + + def setUp(self): + trans_real.activate("de") + + def tearDown(self): + trans_real.deactivate() + + + def test_registration(self): + self.client.post('/set_language/', data={'language': 'de'}) + #self.client.session['django_language'] = 'de-de' + #self.client.cookies[settings.LANGUAGE_COOKIE_NAME] = 'de-de' + + langs = tuple(l[0] for l in settings.LANGUAGES) + self.failUnlessEqual(2, len(langs)) + self.failUnless('de' in langs) + self.failUnless('en' in langs) + self.failUnless(translator.translator) + + # Check that only one model is registered for translation + self.failUnlessEqual(len(translator.translator._registry), 1) + + # Try to unregister a model that is not registered + self.assertRaises(translator.NotRegistered, translator.translator.unregister, User) + + # Try to get options for a model that is not registered + self.assertRaises(translator.NotRegistered, translator.translator.get_options_for_model, User) + + + def test_translated_models(self): + # First create an instance of the test model to play with + inst = TestModel.objects.create(title="Testtitle", text="Testtext") + field_names = dir(inst) + self.failUnless('id' in field_names) + self.failUnless('title' in field_names) + self.failUnless('text' in field_names) + self.failUnless('title_de' in field_names) + self.failUnless('title_en' in field_names) + self.failUnless('text_de' in field_names) + self.failUnless('text_en' in field_names) + + inst.delete() + + def test_set_translation(self): + self.failUnlessEqual(get_language(), "de") + # First create an instance of the test model to play with + title1_de = "title de" + title1_en = "title en" + title2_de = "title2 de" + inst1 = TestModel(title_en=title1_en, text="Testtext") + inst1.title = title1_de + inst2 = TestModel(title=title2_de, text="Testtext") + inst1.save() + inst2.save() + + self.failUnlessEqual(inst1.title, title1_de) + self.failUnlessEqual(inst1.title_en, title1_en) + + self.failUnlessEqual(inst2.title, title2_de) + self.failUnlessEqual(inst2.title_en, None) + + del inst1 + del inst2 + + # Check that the translation fields are correctly saved and provide the + # correct value when retrieving them again. + n = TestModel.objects.get(title=title1_de) + self.failUnlessEqual(n.title, title1_de) + self.failUnlessEqual(n.title_en, title1_en) + + def test_titleonly(self): + title1_de = "title de" + n = TestModel.objects.create(title=title1_de) + self.failUnlessEqual(n.title, title1_de) + # Because the original field "title" was specified in the constructor + # it is directly passed into the instance's __dict__ and the descriptor + # which updates the associated default translation field is not called + # and the default translation will be None. + self.failUnlessEqual(n.title_de, None) + self.failUnlessEqual(n.title_en, None) + + # Now assign the title, that triggers the descriptor and the default + # translation field is updated + n.title = title1_de + self.failUnlessEqual(n.title, title1_de) + self.failUnlessEqual(n.title_de, title1_de) + self.failUnlessEqual(n.title_en, None) + + def test_rule1(self): + """ + Rule 1: Reading the value from the original field returns the value in + translated to the current language. + """ + title1_de = "title de" + title1_en = "title en" + text_de = "Dies ist ein deutscher Satz" + text_en = "This is an english sentence" + + # Test 1. + n = TestModel.objects.create(title_de=title1_de, title_en=title1_en, + text_de=text_de, text_en=text_en) + n.save() + + # language is set to "de" at this point + self.failUnlessEqual(get_language(), "de") + self.failUnlessEqual(n.title, title1_de) + self.failUnlessEqual(n.title_de, title1_de) + self.failUnlessEqual(n.title_en, title1_en) + self.failUnlessEqual(n.text, text_de) + self.failUnlessEqual(n.text_de, text_de) + self.failUnlessEqual(n.text_en, text_en) + # Now switch to "en" + trans_real.activate("en") + self.failUnlessEqual(get_language(), "en") + # Title should now be return the english one (just by switching the + # language) + self.failUnlessEqual(n.title, title1_en) + self.failUnlessEqual(n.text, text_en) + + n = TestModel.objects.create(title_de=title1_de, title_en=title1_en, + text_de=text_de, text_en=text_en) + n.save() + # language is set to "en" at this point + self.failUnlessEqual(n.title, title1_en) + self.failUnlessEqual(n.title_de, title1_de) + self.failUnlessEqual(n.title_en, title1_en) + self.failUnlessEqual(n.text, text_en) + self.failUnlessEqual(n.text_de, text_de) + self.failUnlessEqual(n.text_en, text_en) + trans_real.activate("de") + self.failUnlessEqual(get_language(), "de") + self.failUnlessEqual(n.title, title1_de) + self.failUnlessEqual(n.text, text_de) + trans_real.deactivate() + + + def test_rule2(self): + """ + Rule 2: Assigning a value to the original field also updates the value + in the associated translation field of the default language + """ + self.failUnlessEqual(get_language(), "de") + title1_de = "title de" + title1_en = "title en" + n = TestModel.objects.create(title_de=title1_de, title_en=title1_en) + self.failUnlessEqual(n.title, title1_de) + self.failUnlessEqual(n.title_de, title1_de) + self.failUnlessEqual(n.title_en, title1_en) + + title2 = "Neuer Titel" + n.title = title2 + n.save() + self.failUnlessEqual(n.title, title2) + self.failUnlessEqual(n.title, n.title_de) + + trans_real.activate("en") + self.failUnlessEqual(get_language(), "en") + title3 = "new title" + + n.title = title3 + n.title_de = title1_de + n.save() + self.failUnlessEqual(n.title, title3) + self.failUnlessEqual(n.title, n.title_en) + self.failUnlessEqual(title1_de, n.title_de) + + trans_real.deactivate() + + + def test_rule3(self): + """ + Rule 3: Assigning a value to a translation field of the default language + also updates the original field - note that the value of the original + field will not be updated until the model instance is saved. + """ + title1_de = "title de" + title1_en = "title en" + n = TestModel.objects.create(title_de=title1_de, title_en=title1_en) + self.failUnlessEqual(get_language(), "de") + self.failUnlessEqual(n.title, title1_de) + self.failUnlessEqual(n.title_de, title1_de) + self.failUnlessEqual(n.title_en, title1_en) + + n.title_de = "Neuer Titel" + n.save() + self.failUnlessEqual(n.title, n.title_de) + + # Now switch to "en" + trans_real.activate("en") + self.failUnlessEqual(get_language(), "en") + n.title_en = "New title" + # the n.title field is not updated before the instance is saved + n.save() + self.failUnlessEqual(n.title, n.title_en) + trans_real.deactivate() + + + def test_rule4(self): + """ + Rule 4: If both fields - the original and the translation field of the + default language - are updated at the same time, the translation field + wins. + """ + self.failUnlessEqual(get_language(), "de") + title1_de = "title de" + title1_en = "title en" + n = TestModel.objects.create(title_de=title1_de, title_en=title1_en) + self.failUnlessEqual(n.title, title1_de) + self.failUnlessEqual(n.title_de, title1_de) + self.failUnlessEqual(n.title_en, title1_en) + + title2_de = "neu de" + title2_en = "new en" + title_foo = "foo" + n.title = title_foo + n.title_de = title2_de + n.title_en = title2_en + n.save() + self.failUnlessEqual(n.title, title2_de) + self.failUnlessEqual(n.title_de, title2_de) + self.failUnlessEqual(n.title_en, title2_en) + + n.title = title_foo + n.save() + self.failUnlessEqual(n.title, title_foo) + self.failUnlessEqual(n.title_de, title_foo) + self.failUnlessEqual(n.title_en, title2_en) + \ No newline at end of file diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/modeltranslation/testurls.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/modeltranslation/testurls.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,13 @@ + +from django.conf.urls.defaults import * +from django.contrib import admin +from django.views.generic.simple import direct_to_template + +urlpatterns = patterns('', + + url(r'^set_language/$', + 'django.views.i18n.set_language', + {}, + name='set_language'), + +) \ No newline at end of file diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/modeltranslation/translator.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/modeltranslation/translator.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,177 @@ +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.db.models import signals +from django.db.models.base import ModelBase +from django.utils.functional import curry + +from modeltranslation.fields import TranslationField +from modeltranslation.utils import TranslationFieldDescriptor, build_localized_fieldname + +class AlreadyRegistered(Exception): + pass + +class NotRegistered(Exception): + pass + +class TranslationOptions(object): + """ + The TranslationOptions object is used to specify the fields to translate. + + The options are registered in combination with a model class at the + ``modeltranslation.translator.translator`` instance. + + It caches the content type of the translated model for faster lookup later + on. + """ + def __init__(self, *args, **kwargs): + # self.translation_model = None + self.model_ct = None + self.localized_fieldnames = list() + +#def get_localized_fieldnames(model): + +def add_localized_fields(model): + """ + Monkey patchs the original model class to provide additional fields for + every language. Only do that for fields which are defined in the + translation options of the model. + + Returns a dict mapping the original fieldname to a list containing the names + of the localized fields created for the original field. + """ + localized_fields = dict() + translation_opts = translator.get_options_for_model(model) + for field_name in translation_opts.fields: + localized_fields[field_name] = list() + for l in settings.LANGUAGES: + # Construct the name for the localized field + localized_field_name = build_localized_fieldname(field_name, l[0]) + # Check if the model already has a field by that name + if hasattr(model, localized_field_name): + raise ValueError("Error adding translation field. The model "\ + "'%s' already contains a field named '%s'. "\ + % (instance.__class__.__name__, localized_field_name)) + + # This approach implements the translation fields as full valid + # django model fields and therefore adds them via add_to_class + localized_field = model.add_to_class(localized_field_name, + TranslationField(model._meta.get_field(field_name), + l[0])) + localized_fields[field_name].append(localized_field_name) + + + return localized_fields + # model.add_to_class('current_language', CurrentLanguageField()) + +#def translated_model_initialized(field_names, instance, **kwargs): + #print "translated_model_initialized instance:", instance, ", field:", field_names + #for field_name in field_names: + #initial_val = getattr(instance, field_name) + #print " field: %s, initialval: %s" % (field_name, initial_val) + #setattr(instance.__class__, field_name, TranslationFieldDescriptor(field_name, + #initial_val)) +#def translated_model_initializing(sender, args, kwargs, **signal_kwargs): + #print "translated_model_initializing", sender, args, kwargs + #trans_opts = translator.get_options_for_model(sender) + #for field_name in trans_opts.fields: + #setattr(sender, field_name, TranslationFieldDescriptor(field_name)) + + +class Translator(object): + """ + A Translator object encapsulates an instance of a translator. Models are + registered with the Translator using the register() method. + """ + def __init__(self): + self._registry = {} # model_class class -> translation_opts instance + + def register(self, model_or_iterable, translation_opts, **options): + """ + Registers the given model(s) with the given translation options. + + The model(s) should be Model classes, not instances. + + If a model is already registered for translation, this will raise + AlreadyRegistered. + """ + # Don't import the humongous validation code unless required + if translation_opts and settings.DEBUG: + from django.contrib.admin.validation import validate + else: + validate = lambda model, adminclass: None + + #if not translation_opts: + #translation_opts = TranslationOptions + if isinstance(model_or_iterable, ModelBase): + model_or_iterable = [model_or_iterable] + + for model in model_or_iterable: + if model in self._registry: + raise AlreadyRegistered('The model %s is already registered for translation' % model.__name__) + + # If we got **options then dynamically construct a subclass of + # translation_opts with those **options. + if options: + # For reasons I don't quite understand, without a __module__ + # the created class appears to "live" in the wrong place, + # which causes issues later on. + options['__module__'] = __name__ + translation_opts = type("%sAdmin" % model.__name__, (translation_opts,), options) + + # Validate (which might be a no-op) + #validate(translation_opts, model) + + # Store the translation class associated to the model + self._registry[model] = translation_opts + + # Get the content type of the original model and store it on the + # translation options for faster lookup later on. + translation_opts.model_ct = ContentType.objects.get_for_model(model) + + # Add the localized fields to the model and store the names of these + # fields in the model's translation options for faster lookup later + # on. + translation_opts.localized_fieldnames = add_localized_fields(model) + + # Create a reverse dict mapping the localized_fieldnames to the + # original fieldname + rev_dict = dict() + for orig_name, loc_names in translation_opts.localized_fieldnames.items(): + for ln in loc_names: + rev_dict[ln] = orig_name + + translation_opts.localized_fieldnames_rev = rev_dict + + # print "Applying descriptor field for model %s" % model + for field_name in translation_opts.fields: + setattr(model, field_name, TranslationFieldDescriptor(field_name)) + + #signals.pre_init.connect(translated_model_initializing, sender=model, weak=False) + + def unregister(self, model_or_iterable): + """ + Unregisters the given model(s). + + If a model isn't already registered, this will raise NotRegistered. + """ + if isinstance(model_or_iterable, ModelBase): + model_or_iterable = [model_or_iterable] + for model in model_or_iterable: + if model not in self._registry: + raise NotRegistered('The model "%s" is not registered for translation' % model.__name__) + del self._registry[model] + + def get_options_for_model(self, model): + """ + Returns the translation options for the given ``model``. If the + ``model`` is not registered a ``NotRegistered`` exception is raised. + """ + try: + return self._registry[model] + except KeyError: + raise NotRegistered('The model "%s" is not registered for translation' % model.__name__) + + +# This global object represents the singleton translator object +translator = Translator() diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/modeltranslation/utils.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/modeltranslation/utils.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,103 @@ +from django.db import models +from django.conf import settings +from django.core.exceptions import ValidationError +from django.contrib.contenttypes.models import ContentType +from django.utils.translation import get_language + +class TranslationFieldDescriptor(object): + """ + A descriptor used for the original translated field. + """ + def __init__(self, name, initial_val=""): + """ + The ``name`` is the name of the field (which is not available in the + descriptor by default - this is Python behaviour). + """ + self.name = name + self.val = initial_val + + def __set__(self, instance, value): + # print "Descriptor.__set__%s %s %s.%s: %s" % (id(instance), id(self), type(instance), self.name, value) + lang = get_language() + loc_field_name = build_localized_fieldname(self.name, lang) + + # also update the translation field of the current language + setattr(instance, loc_field_name, value) + + # update the original field via the __dict__ to prevent calling the + # descriptor + instance.__dict__[self.name] = value + + + def __get__(self, instance, owner): + # print "Descriptor.__get__%s %s %s.%s: %s" % (id(instance), id(self), type(instance), self.name, self.val) + if not instance: + raise ValueError(u"Translation field '%s' can only be "\ + "accessed via an instance not via "\ + "a class." % self.name) + + lang = get_language() + loc_field_name = build_localized_fieldname(self.name, lang) + if hasattr(instance, loc_field_name): + return getattr(instance, loc_field_name) or instance.__dict__[self.name] + return instance.__dict__[self.name] + + +#def create_model(name, fields=None, app_label='', module='', options=None, admin_opts=None): + #""" + #Create specified model. + #This is taken from http://code.djangoproject.com/wiki/DynamicModels + #""" + #class Meta: + ## Using type('Meta', ...) gives a dictproxy error during model creation + #pass + + #if app_label: + ## app_label must be set using the Meta inner class + #setattr(Meta, 'app_label', app_label) + + ## Update Meta with any options that were provided + #if options is not None: + #for key, value in options.iteritems(): + #setattr(Meta, key, value) + + ## Set up a dictionary to simulate declarations within a class + #attrs = {'__module__': module, 'Meta': Meta} + + ## Add in any fields that were provided + #if fields: + #attrs.update(fields) + + ## Create the class, which automatically triggers ModelBase processing + #model = type(name, (models.Model,), attrs) + + ## Create an Admin class if admin options were provided + #if admin_opts is not None: + #class Admin(admin.ModelAdmin): + #pass + #for key, value in admin_opts: + #setattr(Admin, key, value) + #admin.site.register(model, Admin) + + #return model + + +def copy_field(field): + """Instantiate a new field, with all of the values from the old one, except the + to and to_field in the case of related fields. + + This taken from http://www.djangosnippets.org/snippets/442/ + """ + base_kw = dict([(n, getattr(field,n, '_null')) for n in models.fields.Field.__init__.im_func.func_code.co_varnames]) + if isinstance(field, models.fields.related.RelatedField): + rel = base_kw.get('rel') + rel_kw = dict([(n, getattr(rel,n, '_null')) for n in rel.__init__.im_func.func_code.co_varnames]) + if isinstance(field, models.fields.related.ForeignKey): + base_kw['to_field'] = rel_kw.pop('field_name') + base_kw.update(rel_kw) + base_kw.pop('self') + return field.__class__(**base_kw) + + +def build_localized_fieldname(field_name, lang): + return '%s_%s' % (field_name, lang) diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/modeltranslation/views.py diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/__init__.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,1 @@ +VERSION = (2, 2) \ No newline at end of file diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/admin.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/admin.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,64 @@ +""" Newforms Admin configuration for Photologue + +""" +from django.contrib import admin +from models import * + +class GalleryAdmin(admin.ModelAdmin): + list_display = ('title', 'date_added', 'photo_count', 'is_public') + list_filter = ['date_added', 'is_public'] + date_hierarchy = 'date_added' + prepopulated_fields = {'title_slug': ('title',)} + filter_horizontal = ('photos',) + +class PhotoAdmin(admin.ModelAdmin): + list_display = ('title', 'date_taken', 'date_added', 'is_public', 'tags', 'view_count', 'admin_thumbnail') + list_filter = ['date_added', 'is_public'] + search_fields = ['title', 'caption'] + list_per_page = 10 + prepopulated_fields = {'title_slug': ('title',)} + +class PhotoEffectAdmin(admin.ModelAdmin): + list_display = ('name', 'description', 'color', 'brightness', 'contrast', 'sharpness', 'filters', 'admin_sample') + fieldsets = ( + (None, { + 'fields': ('name', 'description') + }), + ('Adjustments', { + 'fields': ('color', 'brightness', 'contrast', 'sharpness') + }), + ('Filters', { + 'fields': ('filters',) + }), + ('Reflection', { + 'fields': ('reflection_size', 'reflection_strength', 'background_color') + }), + ('Transpose', { + 'fields': ('transpose_method',) + }), + ) + +class PhotoSizeAdmin(admin.ModelAdmin): + list_display = ('name', 'width', 'height', 'crop', 'pre_cache', 'effect', 'increment_count') + fieldsets = ( + (None, { + 'fields': ('name', 'width', 'height', 'quality') + }), + ('Options', { + 'fields': ('upscale', 'crop', 'pre_cache', 'increment_count') + }), + ('Enhancements', { + 'fields': ('effect', 'watermark',) + }), + ) + +class WatermarkAdmin(admin.ModelAdmin): + list_display = ('name', 'opacity', 'style') + + +admin.site.register(Gallery, GalleryAdmin) +admin.site.register(GalleryUpload) +admin.site.register(Photo, PhotoAdmin) +admin.site.register(PhotoEffect, PhotoEffectAdmin) +admin.site.register(PhotoSize, PhotoSizeAdmin) +admin.site.register(Watermark, WatermarkAdmin) \ No newline at end of file diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/locale/pl/LC_MESSAGES/django.mo Binary file web/lib/photologue/locale/pl/LC_MESSAGES/django.mo has changed diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/locale/pl/LC_MESSAGES/django.po --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/locale/pl/LC_MESSAGES/django.po Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,419 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: Photologue Preview 2\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2008-07-22 23:05+0200\n" +"PO-Revision-Date: 2008-07-22 23:08+0100\n" +"Last-Translator: Jakub Wiśniowski \n" +"Language-Team: Jakub Wiśniowski \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\\n\n" +"X-Poedit-Language: Polish\n" +"X-Poedit-Country: POLAND\n" +"X-Poedit-SourceCharset: utf-8\n" + +#: models.py:32 +msgid "Photologue was unable to import the Python Imaging Library. Please confirm it`s installed and available on your current Python path." +msgstr "Photologue nie był w stanie zaimportować Python Imaging Library. Upewnij się, że pakiet ten jest zainstalowany i znajduje się w ścieżce dostępnej dla Pythona." + +#: models.py:38 +msgid "Separate tags with spaces, put quotes around multiple-word tags." +msgstr "Rozdziel tagi spacjami, ujmij w cudzysłowy tagi złożone z wielu słów." + +#: models.py:47 +msgid "Django-tagging was not found, tags will be treated as plain text." +msgstr "Django-tagging nie zostało znalezione. Tagi będą traktowane jako czysty tekst." + +#: models.py:64 +msgid "Very Low" +msgstr "Bardzo niska" + +#: models.py:65 +msgid "Low" +msgstr "Niska" + +#: models.py:66 +msgid "Medium-Low" +msgstr "Niższa średnia" + +#: models.py:67 +msgid "Medium" +msgstr "Średnia" + +#: models.py:68 +msgid "Medium-High" +msgstr "Wyższa średnia" + +#: models.py:69 +msgid "High" +msgstr "Wysoka" + +#: models.py:70 +msgid "Very High" +msgstr "Bardzo wysoka" + +#: models.py:75 +msgid "Top" +msgstr "Góra" + +#: models.py:76 +msgid "Right" +msgstr "Prawo" + +#: models.py:77 +msgid "Bottom" +msgstr "Dół" + +#: models.py:78 +msgid "Left" +msgstr "Lewo" + +#: models.py:79 +msgid "Center (Default)" +msgstr "Środek (Domyślnie)" + +#: models.py:83 +msgid "Flip left to right" +msgstr "Odbij w poziomie" + +#: models.py:84 +msgid "Flip top to bottom" +msgstr "Odbij w pionie" + +#: models.py:85 +msgid "Rotate 90 degrees counter-clockwise" +msgstr "Odwróć 90 stopni w lewo" + +#: models.py:86 +msgid "Rotate 90 degrees clockwise" +msgstr "Odwróć 90 stopni w prawo" + +#: models.py:87 +msgid "Rotate 180 degrees" +msgstr "Obróć o 180 stopni" + +#: models.py:91 +msgid "Tile" +msgstr "Kafelki" + +#: models.py:92 +msgid "Scale" +msgstr "Skaluj" + +#: models.py:102 +#, python-format +msgid "Chain multiple filters using the following pattern \"FILTER_ONE->FILTER_TWO->FILTER_THREE\". Image filters will be applied in order. The following filter are available: %s." +msgstr "Połącz wiele filtrów używając następującego wzorca: \"FILTR_PIERWSZY->FILTR_DRUGI->FILTR_TRZECI\". Filtry obrazów będą zastosowane w kolejności. Dostępne są następujące filtry: %s." + +#: models.py:107 +msgid "date published" +msgstr "data publikacji" + +#: models.py:108 +#: models.py:164 +#: models.py:448 +msgid "title" +msgstr "tytuł" + +#: models.py:109 +msgid "title slug" +msgstr "tytuł - slug " + +#: models.py:110 +msgid "A \"slug\" is a unique URL-friendly title for an object." +msgstr "\"Slug\" jest unikalnym, zgodnym z formatem dla URL-i tytułem obiektu." + +#: models.py:111 +#: models.py:166 +#: models.py:483 +msgid "description" +msgstr "opis" + +#: models.py:112 +#: models.py:167 +#: models.py:453 +msgid "is public" +msgstr "jest publiczna" + +#: models.py:113 +msgid "Public galleries will be displayed in the default views." +msgstr "Galerie publiczne będą wyświetlana w domyślnych widokach." + +#: models.py:114 +#: models.py:460 +msgid "photos" +msgstr "zdjęcia" + +#: models.py:116 +#: models.py:168 +#: models.py:454 +msgid "tags" +msgstr "tagi" + +#: models.py:121 +msgid "gallery" +msgstr "galeria" + +#: models.py:122 +msgid "galleries" +msgstr "galerie" + +#: models.py:155 +msgid "count" +msgstr "ilość" + +#: models.py:162 +msgid "images file (.zip)" +msgstr "plik z obrazami (.zip)" + +#: models.py:163 +msgid "Select a .zip file of images to upload into a new Gallery." +msgstr "Wybierz plik .zip zawierający zdjęcia które chcesz załadować do nowej Galerii." + +#: models.py:164 +msgid "All photos in the gallery will be given a title made up of the gallery title + a sequential number." +msgstr "Wszystkie " + +#: models.py:165 +#: models.py:451 +msgid "caption" +msgstr "podpis" + +#: models.py:165 +msgid "Caption will be added to all photos." +msgstr "Podpis będzie dodany do wszystkich zdjęć." + +#: models.py:166 +msgid "A description of this Gallery." +msgstr "Opis tej Galerii." + +#: models.py:167 +msgid "Uncheck this to make the uploaded gallery and included photographs private." +msgstr "Odznacz aby uczynić wrzucaną galerię oraz zawarte w niej zdjęcia prywatnymi." + +#: models.py:171 +msgid "gallery upload" +msgstr "wrzucona galeria" + +#: models.py:172 +msgid "gallery uploads" +msgstr "wrzucone galerie" + +#: models.py:228 +#: models.py:594 +msgid "image" +msgstr "obraz" + +#: models.py:229 +msgid "date taken" +msgstr "data wykonania" + +#: models.py:231 +msgid "crop from" +msgstr "obetnij z" + +#: models.py:232 +msgid "effect" +msgstr "efekt" + +#: models.py:250 +msgid "An \"admin_thumbnail\" photo size has not been defined." +msgstr "Rozmiar zdjęcia \"admin_thumbnail\" nie został zdefiniowany." + +#: models.py:258 +msgid "Thumbnail" +msgstr "Miniaturka" + +#: models.py:449 +msgid "slug" +msgstr "slug" + +#: models.py:452 +msgid "date added" +msgstr "data dodania" + +#: models.py:453 +msgid "Public photographs will be displayed in the default views." +msgstr "Publiczne zdjęcia będą wyświetlane w domyślnych widokach." + +#: models.py:459 +msgid "photo" +msgstr "zdjęcie" + +#: models.py:482 +#: models.py:608 +msgid "name" +msgstr "nazwa" + +#: models.py:554 +msgid "rotate or flip" +msgstr "obróć lub odbij" + +#: models.py:555 +#: models.py:562 +msgid "color" +msgstr "kolor" + +#: models.py:555 +msgid "A factor of 0.0 gives a black and white image, a factor of 1.0 gives the original image." +msgstr "Współczynnik 0.0 daje czarno-biały obraz, współczynnik 1.0 daje obraz oryginalny." + +#: models.py:556 +msgid "brightness" +msgstr "jasność" + +#: models.py:556 +msgid "A factor of 0.0 gives a black image, a factor of 1.0 gives the original image." +msgstr "Współczynnik 0.0 daje czarny obraz, współczynnik 1.0 daje obraz oryginalny." + +#: models.py:557 +msgid "contrast" +msgstr "kontrast" + +#: models.py:557 +msgid "A factor of 0.0 gives a solid grey image, a factor of 1.0 gives the original image." +msgstr "Współczynnik 0.0 daje jednolity szary obraz, współczynnik 1.0 daje obraz oryginalny." + +#: models.py:558 +msgid "sharpness" +msgstr "ostrość" + +#: models.py:558 +msgid "A factor of 0.0 gives a blurred image, a factor of 1.0 gives the original image." +msgstr "Współczynnik 0.0 daje rozmazany obraz, współczynnik 1.0 daje obraz oryginalny." + +#: models.py:559 +msgid "filters" +msgstr "filtry" + +#: models.py:560 +msgid "size" +msgstr "rozmiar" + +#: models.py:560 +msgid "The height of the reflection as a percentage of the orignal image. A factor of 0.0 adds no reflection, a factor of 1.0 adds a reflection equal to the height of the orignal image." +msgstr "Wysokość odbicia jako procent oryginalnego obrazu. Współczynnik 0.0 nie dodaje odbicia, współczynnik 1.0 dodaje odbicie równe wysokości oryginalnego obrazu." + +#: models.py:561 +msgid "strength" +msgstr "intensywność" + +#: models.py:565 +#: models.py:616 +msgid "photo effect" +msgstr "efekt zdjęcia" + +#: models.py:566 +msgid "photo effects" +msgstr "efekty zdjęć" + +#: models.py:595 +msgid "style" +msgstr "styl" + +#: models.py:596 +msgid "opacity" +msgstr "przeźroczystość" + +#: models.py:596 +msgid "The opacity of the overlay." +msgstr "Poziom przezroczystości" + +#: models.py:599 +msgid "watermark" +msgstr "znak wodny" + +#: models.py:600 +msgid "watermarks" +msgstr "znaki wodne" + +#: models.py:608 +msgid "Photo size name should contain only letters, numbers and underscores. Examples: \"thumbnail\", \"display\", \"small\", \"main_page_widget\"." +msgstr "Nazwa rozmiaru zdjęcia powinna zawierać tylko litery, cyfry i podkreślenia. Przykłady: \"miniatura\", \"wystawa\", \"male\", \"widget_strony_glownej\"." + +#: models.py:609 +msgid "width" +msgstr "szerokość" + +#: models.py:609 +msgid "If width is set to \"0\" the image will be scaled to the supplied height." +msgstr "Jeśli szerokość jest ustawiona na \"0\" to obraz będzie skalowany do podanej wysokości." + +#: models.py:610 +msgid "height" +msgstr "wysokość" + +#: models.py:610 +msgid "If height is set to \"0\" the image will be scaled to the supplied width" +msgstr "Jeśli wysokość jest ustawiona na \"0\" to obraz będzie skalowany do podanej szerokości." + +#: models.py:611 +msgid "quality" +msgstr "jakość" + +#: models.py:611 +msgid "JPEG image quality." +msgstr "Jakość obrazu JPEG" + +#: models.py:612 +msgid "upscale images?" +msgstr "skalować obrazy w górę?" + +#: models.py:612 +msgid "If selected the image will be scaled up if necessary to fit the supplied dimensions. Cropped sizes will be upscaled regardless of this setting." +msgstr "Jeśli zaznaczone to obraz będzie skalowany w górę tak aby pasował do podanych wymiarów. Obcinane rozmiary będą skalowane niezależnie od tego ustawienia." + +#: models.py:613 +msgid "crop to fit?" +msgstr "przyciąć aby pasował?" + +#: models.py:613 +msgid "If selected the image will be scaled and cropped to fit the supplied dimensions." +msgstr "Jeśli zaznaczone to obraz będzie skalowany i przycinany tak aby pasował do podanych wymiarów." + +#: models.py:614 +msgid "pre-cache?" +msgstr "wstępnie cachować?" + +#: models.py:614 +msgid "If selected this photo size will be pre-cached as photos are added." +msgstr "Jesli zaznaczone to ten rozmiar zdjęć będzie wstępnie cachowany przy dodawaniu zdjęć." + +#: models.py:615 +msgid "increment view count?" +msgstr "zwiększyć licznik odsłon?" + +#: models.py:615 +msgid "If selected the image's \"view_count\" will be incremented when this photo size is displayed." +msgstr "Jeśli zaznaczone to \"licznik_odslon\" będzie zwiększany gdy ten rozmiar zdjęcia będzie wyświetlany." + +#: models.py:617 +msgid "watermark image" +msgstr "oznacz kluczem wodnym" + +#: models.py:621 +msgid "photo size" +msgstr "rozmiar zdjęcia" + +#: models.py:622 +msgid "photo sizes" +msgstr "rozmiary zdjęć" + +#: models.py:640 +msgid "A PhotoSize must have a positive height or width." +msgstr "PhotoSize musi mieć dodatnią wysokość i szerokość." + +#~ msgid "Leave to size the image to the set height" +#~ msgstr "Ustaw aby przeskalować obraz do wybranej wysokości" +#~ msgid "Leave to size the image to the set width" +#~ msgstr "Ustaw aby przeskalować obraz do wybranej szerokości" +#~ msgid "original image" +#~ msgstr "oryginalny obraz" + diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/management/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/management/__init__.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,1 @@ + diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/management/commands/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/management/commands/__init__.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,37 @@ +from photologue.models import PhotoSize + +def get_response(msg, func=int, default=None): + while True: + resp = raw_input(msg) + if not resp and default is not None: + return default + try: + return func(resp) + except: + print 'Invalid input.' + +def create_photosize(name, width=0, height=0, crop=False, pre_cache=False, increment_count=False): + try: + size = PhotoSize.objects.get(name=name) + exists = True + except PhotoSize.DoesNotExist: + size = PhotoSize(name=name) + exists = False + if exists: + msg = 'A "%s" photo size already exists. Do you want to replace it? (yes, no):' % name + if not get_response(msg, lambda inp: inp == 'yes', False): + return + print '\nWe will now define the "%s" photo size:\n' % size + w = get_response('Width (in pixels):', lambda inp: int(inp), width) + h = get_response('Height (in pixels):', lambda inp: int(inp), height) + c = get_response('Crop to fit? (yes, no):', lambda inp: inp == 'yes', crop) + p = get_response('Pre-cache? (yes, no):', lambda inp: inp == 'yes', pre_cache) + i = get_response('Increment count? (yes, no):', lambda inp: inp == 'yes', increment_count) + size.width = w + size.height = h + size.crop = c + size.pre_cache = p + size.increment_count = i + size.save() + print '\nA "%s" photo size has been created.\n' % name + return size diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/management/commands/plcache.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/management/commands/plcache.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,43 @@ +from django.core.management.base import BaseCommand, CommandError +from optparse import make_option +from photologue.models import PhotoSize, ImageModel + +class Command(BaseCommand): + option_list = BaseCommand.option_list + ( + make_option('--reset', '-r', action='store_true', dest='reset', help='Reset photo cache before generating'), + ) + + help = ('Manages Photologue cache file for the given sizes.') + args = '[sizes]' + + requires_model_validation = True + can_import_settings = True + + def handle(self, *args, **options): + return create_cache(args, options) + +def create_cache(sizes, options): + """ + Creates the cache for the given files + """ + reset = options.get('reset', None) + + size_list = [size.strip(' ,') for size in sizes] + + if len(size_list) < 1: + sizes = PhotoSize.objects.filter(pre_cache=True) + else: + sizes = PhotoSize.objects.filter(name__in=size_list) + + if not len(sizes): + raise CommandError('No photo sizes were found.') + + print 'Caching photos, this may take a while...' + + for cls in ImageModel.__subclasses__(): + for photosize in sizes: + print 'Cacheing %s size images' % photosize.name + for obj in cls.objects.all(): + if reset: + obj.remove_size(photosize) + obj.create_size(photosize) diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/management/commands/plcreatesize.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/management/commands/plcreatesize.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,13 @@ +from django.core.management.base import BaseCommand, CommandError +from photologue.management.commands import create_photosize + +class Command(BaseCommand): + help = ('Creates a new Photologue photo size interactively.') + requires_model_validation = True + can_import_settings = True + + def handle(self, *args, **options): + create_size(args[0]) + +def create_size(size): + create_photosize(size) diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/management/commands/plflush.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/management/commands/plflush.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,35 @@ +from django.core.management.base import BaseCommand, CommandError +from optparse import make_option +from photologue.models import PhotoSize, ImageModel + +class Command(BaseCommand): + help = ('Clears the Photologue cache for the given sizes.') + args = '[sizes]' + + requires_model_validation = True + can_import_settings = True + + def handle(self, *args, **options): + return create_cache(args, options) + +def create_cache(sizes, options): + """ + Clears the cache for the given files + """ + size_list = [size.strip(' ,') for size in sizes] + + if len(size_list) < 1: + sizes = PhotoSize.objects.all() + else: + sizes = PhotoSize.objects.filter(name__in=size_list) + + if not len(sizes): + raise CommandError('No photo sizes were found.') + + print 'Flushing cache...' + + for cls in ImageModel.__subclasses__(): + for photosize in sizes: + print 'Flushing %s size images' % photosize.name + for obj in cls.objects.all(): + obj.remove_size(photosize) diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/management/commands/plinit.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/management/commands/plinit.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,30 @@ +from django.core.management.base import BaseCommand, CommandError +from photologue.management.commands import get_response, create_photosize +from photologue.models import PhotoEffect + +class Command(BaseCommand): + help = ('Prompts the user to set up the default photo sizes required by Photologue.') + requires_model_validation = True + can_import_settings = True + + def handle(self, *args, **kwargs): + return init(*args, **kwargs) + +def init(*args, **kwargs): + msg = '\nPhotologue requires a specific photo size to display thumbnail previews in the Django admin application.\nWould you like to generate this size now? (yes, no):' + if get_response(msg, lambda inp: inp == 'yes', False): + admin_thumbnail = create_photosize('admin_thumbnail', width=100, height=75, crop=True, pre_cache=True) + msg = 'Would you like to apply a sample enhancement effect to your admin thumbnails? (yes, no):' + if get_response(msg, lambda inp: inp == 'yes', False): + effect, created = PhotoEffect.objects.get_or_create(name='Enhance Thumbnail', description="Increases sharpness and contrast. Works well for smaller image sizes such as thumbnails.", contrast=1.2, sharpness=1.3) + admin_thumbnail.effect = effect + admin_thumbnail.save() + msg = '\nPhotologue comes with a set of templates for setting up a complete photo gallery. These templates require you to define both a "thumbnail" and "display" size.\nWould you like to define them now? (yes, no):' + if get_response(msg, lambda inp: inp == 'yes', False): + thumbnail = create_photosize('thumbnail', width=100, height=75) + display = create_photosize('display', width=400, increment_count=True) + msg = 'Would you like to apply a sample reflection effect to your display images? (yes, no):' + if get_response(msg, lambda inp: inp == 'yes', False): + effect, created = PhotoEffect.objects.get_or_create(name='Display Reflection', description="Generates a reflection with a white background", reflection_size=0.4) + display.effect = effect + display.save() \ No newline at end of file diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/models.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/models.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,725 @@ +import os +import random +import shutil +import zipfile + +from datetime import datetime +from inspect import isclass + +from django.db import models +from django.db.models.signals import post_init +from django.conf import settings +from django.core.files.base import ContentFile +from django.core.urlresolvers import reverse +from django.template.defaultfilters import slugify +from django.utils.functional import curry +from django.utils.translation import ugettext_lazy as _ + +# Required PIL classes may or may not be available from the root namespace +# depending on the installation method used. +try: + import Image + import ImageFile + import ImageFilter + import ImageEnhance +except ImportError: + try: + from PIL import Image + from PIL import ImageFile + from PIL import ImageFilter + from PIL import ImageEnhance + except ImportError: + raise ImportError('Photologue was unable to import the Python Imaging Library. Please confirm it`s installed and available on your current Python path.') + +# attempt to load the django-tagging TagField from default location, +# otherwise we substitude a dummy TagField. +try: + from tagging.fields import TagField + tagfield_help_text = _('Separate tags with spaces, put quotes around multiple-word tags.') +except ImportError: + class TagField(models.CharField): + def __init__(self, **kwargs): + default_kwargs = {'max_length': 255, 'blank': True} + default_kwargs.update(kwargs) + super(TagField, self).__init__(**default_kwargs) + def get_internal_type(self): + return 'CharField' + tagfield_help_text = _('Django-tagging was not found, tags will be treated as plain text.') + +from utils import EXIF +from utils.reflection import add_reflection +from utils.watermark import apply_watermark + +# Path to sample image +SAMPLE_IMAGE_PATH = getattr(settings, 'SAMPLE_IMAGE_PATH', os.path.join(os.path.dirname(__file__), 'res', 'sample.jpg')) # os.path.join(settings.PROJECT_PATH, 'photologue', 'res', 'sample.jpg' + +# Modify image file buffer size. +ImageFile.MAXBLOCK = getattr(settings, 'PHOTOLOGUE_MAXBLOCK', 256 * 2 ** 10) + +# Photologue image path relative to media root +PHOTOLOGUE_DIR = getattr(settings, 'PHOTOLOGUE_DIR', 'photologue') + +# Look for user function to define file paths +PHOTOLOGUE_PATH = getattr(settings, 'PHOTOLOGUE_PATH', None) +if PHOTOLOGUE_PATH is not None: + if callable(PHOTOLOGUE_PATH): + get_storage_path = PHOTOLOGUE_PATH + else: + parts = PHOTOLOGUE_PATH.split('.') + module_name = '.'.join(parts[:-1]) + module = __import__(module_name) + get_storage_path = getattr(module, parts[-1]) +else: + def get_storage_path(instance, filename): + return os.path.join(PHOTOLOGUE_DIR, 'photos', filename) + +# Quality options for JPEG images +JPEG_QUALITY_CHOICES = ( + (30, _('Very Low')), + (40, _('Low')), + (50, _('Medium-Low')), + (60, _('Medium')), + (70, _('Medium-High')), + (80, _('High')), + (90, _('Very High')), +) + +# choices for new crop_anchor field in Photo +CROP_ANCHOR_CHOICES = ( + ('top', _('Top')), + ('right', _('Right')), + ('bottom', _('Bottom')), + ('left', _('Left')), + ('center', _('Center (Default)')), +) + +IMAGE_TRANSPOSE_CHOICES = ( + ('FLIP_LEFT_RIGHT', _('Flip left to right')), + ('FLIP_TOP_BOTTOM', _('Flip top to bottom')), + ('ROTATE_90', _('Rotate 90 degrees counter-clockwise')), + ('ROTATE_270', _('Rotate 90 degrees clockwise')), + ('ROTATE_180', _('Rotate 180 degrees')), +) + +WATERMARK_STYLE_CHOICES = ( + ('tile', _('Tile')), + ('scale', _('Scale')), +) + +# Prepare a list of image filters +filter_names = [] +for n in dir(ImageFilter): + klass = getattr(ImageFilter, n) + if isclass(klass) and issubclass(klass, ImageFilter.BuiltinFilter) and \ + hasattr(klass, 'name'): + filter_names.append(klass.__name__) +IMAGE_FILTERS_HELP_TEXT = _('Chain multiple filters using the following pattern "FILTER_ONE->FILTER_TWO->FILTER_THREE". Image filters will be applied in order. The following filters are available: %s.' % (', '.join(filter_names))) + + +class Gallery(models.Model): + date_added = models.DateTimeField(_('date published'), default=datetime.now) + title = models.CharField(_('title'), max_length=100, unique=True) + title_slug = models.SlugField(_('title slug'), unique=True, + help_text=_('A "slug" is a unique URL-friendly title for an object.')) + description = models.TextField(_('description'), blank=True) + is_public = models.BooleanField(_('is public'), default=True, + help_text=_('Public galleries will be displayed in the default views.')) + photos = models.ManyToManyField('Photo', related_name='galleries', verbose_name=_('photos'), + null=True, blank=True) + tags = TagField(help_text=tagfield_help_text, verbose_name=_('tags')) + + class Meta: + ordering = ['-date_added'] + get_latest_by = 'date_added' + verbose_name = _('gallery') + verbose_name_plural = _('galleries') + + def __unicode__(self): + return self.title + + def __str__(self): + return self.__unicode__() + + def get_absolute_url(self): + return reverse('pl-gallery', args=[self.title_slug]) + + def latest(self, limit=0, public=True): + if limit == 0: + limit = self.photo_count() + if public: + return self.public()[:limit] + else: + return self.photos.all()[:limit] + + def sample(self, count=0, public=True): + if count == 0 or count > self.photo_count(): + count = self.photo_count() + if public: + photo_set = self.public() + else: + photo_set = self.photos.all() + return random.sample(photo_set, count) + + def photo_count(self, public=True): + if public: + return self.public().count() + else: + return self.photos.all().count() + photo_count.short_description = _('count') + + def public(self): + return self.photos.filter(is_public=True) + + +class GalleryUpload(models.Model): + zip_file = models.FileField(_('images file (.zip)'), upload_to=PHOTOLOGUE_DIR+"/temp", + help_text=_('Select a .zip file of images to upload into a new Gallery.')) + gallery = models.ForeignKey(Gallery, null=True, blank=True, help_text=_('Select a gallery to add these images to. leave this empty to create a new gallery from the supplied title.')) + title = models.CharField(_('title'), max_length=75, help_text=_('All photos in the gallery will be given a title made up of the gallery title + a sequential number.')) + caption = models.TextField(_('caption'), blank=True, help_text=_('Caption will be added to all photos.')) + description = models.TextField(_('description'), blank=True, help_text=_('A description of this Gallery.')) + is_public = models.BooleanField(_('is public'), default=True, help_text=_('Uncheck this to make the uploaded gallery and included photographs private.')) + tags = models.CharField(max_length=255, blank=True, help_text=tagfield_help_text, verbose_name=_('tags')) + + class Meta: + verbose_name = _('gallery upload') + verbose_name_plural = _('gallery uploads') + + def save(self, *args, **kwargs): + super(GalleryUpload, self).save(*args, **kwargs) + gallery = self.process_zipfile() + super(GalleryUpload, self).delete() + return gallery + + def process_zipfile(self): + if os.path.isfile(self.zip_file.path): + # TODO: implement try-except here + zip = zipfile.ZipFile(self.zip_file.path) + bad_file = zip.testzip() + if bad_file: + raise Exception('"%s" in the .zip archive is corrupt.' % bad_file) + count = 1 + if self.gallery: + gallery = self.gallery + else: + gallery = Gallery.objects.create(title=self.title, + title_slug=slugify(self.title), + description=self.description, + is_public=self.is_public, + tags=self.tags) + from cStringIO import StringIO + for filename in zip.namelist(): + if filename.startswith('__'): # do not process meta files + continue + data = zip.read(filename) + if len(data): + try: + # the following is taken from django.newforms.fields.ImageField: + # load() is the only method that can spot a truncated JPEG, + # but it cannot be called sanely after verify() + trial_image = Image.open(StringIO(data)) + trial_image.load() + # verify() is the only method that can spot a corrupt PNG, + # but it must be called immediately after the constructor + trial_image = Image.open(StringIO(data)) + trial_image.verify() + except Exception: + # if a "bad" file is found we just skip it. + continue + while 1: + title = ' '.join([self.title, str(count)]) + slug = slugify(title) + try: + p = Photo.objects.get(title_slug=slug) + except Photo.DoesNotExist: + photo = Photo(title=title, + title_slug=slug, + caption=self.caption, + is_public=self.is_public, + tags=self.tags) + photo.image.save(filename, ContentFile(data)) + gallery.photos.add(photo) + count = count + 1 + break + count = count + 1 + zip.close() + return gallery + + +class ImageModel(models.Model): + image = models.ImageField(_('image'), upload_to=get_storage_path) + date_taken = models.DateTimeField(_('date taken'), null=True, blank=True, editable=False) + view_count = models.PositiveIntegerField(default=0, editable=False) + crop_from = models.CharField(_('crop from'), blank=True, max_length=10, default='center', choices=CROP_ANCHOR_CHOICES) + effect = models.ForeignKey('PhotoEffect', null=True, blank=True, related_name="%(class)s_related", verbose_name=_('effect')) + + class Meta: + abstract = True + + @property + def EXIF(self): + try: + return EXIF.process_file(open(self.image.path, 'rb')) + except: + try: + return EXIF.process_file(open(self.image.path, 'rb'), details=False) + except: + return {} + + def admin_thumbnail(self): + func = getattr(self, 'get_admin_thumbnail_url', None) + if func is None: + return _('An "admin_thumbnail" photo size has not been defined.') + else: + if hasattr(self, 'get_absolute_url'): + return u'' % \ + (self.get_absolute_url(), func()) + else: + return u'' % \ + (self.image.url, func()) + admin_thumbnail.short_description = _('Thumbnail') + admin_thumbnail.allow_tags = True + + def cache_path(self): + return os.path.join(os.path.dirname(self.image.path), "cache") + + def cache_url(self): + return '/'.join([os.path.dirname(self.image.url), "cache"]) + + def image_filename(self): + return os.path.basename(self.image.path) + + def _get_filename_for_size(self, size): + size = getattr(size, 'name', size) + base, ext = os.path.splitext(self.image_filename()) + return ''.join([base, '_', size, ext]) + + def _get_SIZE_photosize(self, size): + return PhotoSizeCache().sizes.get(size) + + def _get_SIZE_size(self, size): + photosize = PhotoSizeCache().sizes.get(size) + if not self.size_exists(photosize): + self.create_size(photosize) + return Image.open(self._get_SIZE_filename(size)).size + + def _get_SIZE_url(self, size): + photosize = PhotoSizeCache().sizes.get(size) + if not self.size_exists(photosize): + self.create_size(photosize) + if photosize.increment_count: + self.increment_count() + return '/'.join([self.cache_url(), self._get_filename_for_size(photosize.name)]) + + def _get_SIZE_filename(self, size): + photosize = PhotoSizeCache().sizes.get(size) + return os.path.join(self.cache_path(), + self._get_filename_for_size(photosize.name)) + + def increment_count(self): + self.view_count += 1 + models.Model.save(self) + + def add_accessor_methods(self, *args, **kwargs): + for size in PhotoSizeCache().sizes.keys(): + setattr(self, 'get_%s_size' % size, + curry(self._get_SIZE_size, size=size)) + setattr(self, 'get_%s_photosize' % size, + curry(self._get_SIZE_photosize, size=size)) + setattr(self, 'get_%s_url' % size, + curry(self._get_SIZE_url, size=size)) + setattr(self, 'get_%s_filename' % size, + curry(self._get_SIZE_filename, size=size)) + + def size_exists(self, photosize): + func = getattr(self, "get_%s_filename" % photosize.name, None) + if func is not None: + if os.path.isfile(func()): + return True + return False + + def resize_image(self, im, photosize): + cur_width, cur_height = im.size + new_width, new_height = photosize.size + if photosize.crop: + ratio = max(float(new_width)/cur_width,float(new_height)/cur_height) + x = (cur_width * ratio) + y = (cur_height * ratio) + xd = abs(new_width - x) + yd = abs(new_height - y) + x_diff = int(xd / 2) + y_diff = int(yd / 2) + if self.crop_from == 'top': + box = (int(x_diff), 0, int(x_diff+new_width), new_height) + elif self.crop_from == 'left': + box = (0, int(y_diff), new_width, int(y_diff+new_height)) + elif self.crop_from == 'bottom': + box = (int(x_diff), int(yd), int(x_diff+new_width), int(y)) # y - yd = new_height + elif self.crop_from == 'right': + box = (int(xd), int(y_diff), int(x), int(y_diff+new_height)) # x - xd = new_width + else: + box = (int(x_diff), int(y_diff), int(x_diff+new_width), int(y_diff+new_height)) + im = im.resize((int(x), int(y)), Image.ANTIALIAS).crop(box) + else: + if not new_width == 0 and not new_height == 0: + ratio = min(float(new_width)/cur_width, + float(new_height)/cur_height) + else: + if new_width == 0: + ratio = float(new_height)/cur_height + else: + ratio = float(new_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 photosize.upscale: + return im + im = im.resize(new_dimensions, Image.ANTIALIAS) + return im + + def create_size(self, photosize): + if self.size_exists(photosize): + return + if not os.path.isdir(self.cache_path()): + os.makedirs(self.cache_path()) + try: + im = Image.open(self.image.path) + except IOError: + return + # Save the original format + im_format = im.format + # Apply effect if found + if self.effect is not None: + im = self.effect.pre_process(im) + elif photosize.effect is not None: + im = photosize.effect.pre_process(im) + # Resize/crop image + if im.size != photosize.size and photosize.size != (0, 0): + im = self.resize_image(im, photosize) + # Apply watermark if found + if photosize.watermark is not None: + im = photosize.watermark.post_process(im) + # Apply effect if found + if self.effect is not None: + im = self.effect.post_process(im) + elif photosize.effect is not None: + im = photosize.effect.post_process(im) + # Save file + im_filename = getattr(self, "get_%s_filename" % photosize.name)() + try: + if im_format != 'JPEG': + try: + im.save(im_filename) + return + except KeyError: + pass + im.save(im_filename, 'JPEG', quality=int(photosize.quality), optimize=True) + except IOError, e: + if os.path.isfile(im_filename): + os.unlink(im_filename) + raise e + + def remove_size(self, photosize, remove_dirs=True): + if not self.size_exists(photosize): + return + filename = getattr(self, "get_%s_filename" % photosize.name)() + if os.path.isfile(filename): + os.remove(filename) + if remove_dirs: + self.remove_cache_dirs() + + def clear_cache(self): + cache = PhotoSizeCache() + for photosize in cache.sizes.values(): + self.remove_size(photosize, False) + self.remove_cache_dirs() + + def pre_cache(self): + cache = PhotoSizeCache() + for photosize in cache.sizes.values(): + if photosize.pre_cache: + self.create_size(photosize) + + def remove_cache_dirs(self): + try: + os.removedirs(self.cache_path()) + except: + pass + + def save(self, *args, **kwargs): + if self.date_taken is None: + try: + exif_date = self.EXIF.get('EXIF DateTimeOriginal', None) + if exif_date is not None: + d, t = str.split(exif_date.values) + year, month, day = d.split(':') + hour, minute, second = t.split(':') + self.date_taken = datetime(int(year), int(month), int(day), + int(hour), int(minute), int(second)) + except: + pass + if self.date_taken is None: + self.date_taken = datetime.now() + if self._get_pk_val(): + self.clear_cache() + super(ImageModel, self).save(*args, **kwargs) + self.pre_cache() + + def delete(self): + 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() + super(ImageModel, self).delete() + + +class Photo(ImageModel): + title = models.CharField(_('title'), max_length=100, unique=True) + title_slug = models.SlugField(_('slug'), unique=True, + help_text=('A "slug" is a unique URL-friendly title for an object.')) + caption = models.TextField(_('caption'), blank=True) + date_added = models.DateTimeField(_('date added'), default=datetime.now, editable=False) + is_public = models.BooleanField(_('is public'), default=True, help_text=_('Public photographs will be displayed in the default views.')) + tags = TagField(help_text=tagfield_help_text, verbose_name=_('tags')) + + class Meta: + ordering = ['-date_added'] + get_latest_by = 'date_added' + verbose_name = _("photo") + verbose_name_plural = _("photos") + + def __unicode__(self): + return self.title + + def __str__(self): + return self.__unicode__() + + def save(self, *args, **kwargs): + if self.title_slug is None: + self.title_slug = slugify(self.title) + super(Photo, self).save(*args, **kwargs) + + def get_absolute_url(self): + return reverse('pl-photo', args=[self.title_slug]) + + def public_galleries(self): + """Return the public galleries to which this photo belongs.""" + return self.galleries.filter(is_public=True) + + def get_previous_in_gallery(self, gallery): + try: + return self.get_previous_by_date_added(galleries__exact=gallery, + is_public=True) + except Photo.DoesNotExist: + return None + + def get_next_in_gallery(self, gallery): + try: + return self.get_next_by_date_added(galleries__exact=gallery, + is_public=True) + except Photo.DoesNotExist: + return None + + +class BaseEffect(models.Model): + name = models.CharField(_('name'), max_length=30, unique=True) + description = models.TextField(_('description'), blank=True) + + class Meta: + abstract = True + + def sample_dir(self): + return os.path.join(settings.MEDIA_ROOT, PHOTOLOGUE_DIR, 'samples') + + def sample_url(self): + return settings.MEDIA_URL + '/'.join([PHOTOLOGUE_DIR, 'samples', '%s %s.jpg' % (self.name.lower(), 'sample')]) + + def sample_filename(self): + return os.path.join(self.sample_dir(), '%s %s.jpg' % (self.name.lower(), 'sample')) + + def create_sample(self): + if not os.path.isdir(self.sample_dir()): + os.makedirs(self.sample_dir()) + try: + im = Image.open(SAMPLE_IMAGE_PATH) + except IOError: + raise IOError('Photologue was unable to open the sample image: %s.' % SAMPLE_IMAGE_PATH) + im = self.process(im) + im.save(self.sample_filename(), 'JPEG', quality=90, optimize=True) + + def admin_sample(self): + return u'' % self.sample_url() + admin_sample.short_description = 'Sample' + admin_sample.allow_tags = True + + def pre_process(self, im): + return im + + def post_process(self, im): + return im + + def process(self, im): + im = self.pre_process(im) + im = self.post_process(im) + return im + + def __unicode__(self): + return self.name + + def __str__(self): + return self.__unicode__() + + def save(self, *args, **kwargs): + try: + os.remove(self.sample_filename()) + except: + pass + models.Model.save(self, *args, **kwargs) + self.create_sample() + for size in self.photo_sizes.all(): + size.clear_cache() + # try to clear all related subclasses of ImageModel + for prop in [prop for prop in dir(self) if prop[-8:] == '_related']: + for obj in getattr(self, prop).all(): + obj.clear_cache() + obj.pre_cache() + + def delete(self): + try: + os.remove(self.sample_filename()) + except: + pass + models.Model.delete(self) + + +class PhotoEffect(BaseEffect): + """ A pre-defined effect to apply to photos """ + transpose_method = models.CharField(_('rotate or flip'), max_length=15, blank=True, choices=IMAGE_TRANSPOSE_CHOICES) + color = models.FloatField(_('color'), default=1.0, help_text=_("A factor of 0.0 gives a black and white image, a factor of 1.0 gives the original image.")) + brightness = models.FloatField(_('brightness'), default=1.0, help_text=_("A factor of 0.0 gives a black image, a factor of 1.0 gives the original image.")) + contrast = models.FloatField(_('contrast'), default=1.0, help_text=_("A factor of 0.0 gives a solid grey image, a factor of 1.0 gives the original image.")) + sharpness = models.FloatField(_('sharpness'), default=1.0, help_text=_("A factor of 0.0 gives a blurred image, a factor of 1.0 gives the original image.")) + filters = models.CharField(_('filters'), max_length=200, blank=True, help_text=_(IMAGE_FILTERS_HELP_TEXT)) + reflection_size = models.FloatField(_('size'), default=0, help_text=_("The height of the reflection as a percentage of the orignal image. A factor of 0.0 adds no reflection, a factor of 1.0 adds a reflection equal to the height of the orignal image.")) + reflection_strength = models.FloatField(_('strength'), default=0.6, help_text=_("The initial opacity of the reflection gradient.")) + background_color = models.CharField(_('color'), max_length=7, default="#FFFFFF", help_text=_("The background color of the reflection gradient. Set this to match the background color of your page.")) + + class Meta: + verbose_name = _("photo effect") + verbose_name_plural = _("photo effects") + + def pre_process(self, im): + if self.transpose_method != '': + method = getattr(Image, self.transpose_method) + im = im.transpose(method) + if im.mode != 'RGB' and im.mode != 'RGBA': + return im + for name in ['Color', 'Brightness', 'Contrast', 'Sharpness']: + factor = getattr(self, name.lower()) + if factor != 1.0: + im = getattr(ImageEnhance, name)(im).enhance(factor) + for name in self.filters.split('->'): + image_filter = getattr(ImageFilter, name.upper(), None) + if image_filter is not None: + try: + im = im.filter(image_filter) + except ValueError: + pass + return im + + def post_process(self, im): + if self.reflection_size != 0.0: + im = add_reflection(im, bgcolor=self.background_color, amount=self.reflection_size, opacity=self.reflection_strength) + return im + + +class Watermark(BaseEffect): + image = models.ImageField(_('image'), upload_to=PHOTOLOGUE_DIR+"/watermarks") + style = models.CharField(_('style'), max_length=5, choices=WATERMARK_STYLE_CHOICES, default='scale') + opacity = models.FloatField(_('opacity'), default=1, help_text=_("The opacity of the overlay.")) + + class Meta: + verbose_name = _('watermark') + verbose_name_plural = _('watermarks') + + def post_process(self, im): + mark = Image.open(self.image.path) + return apply_watermark(im, mark, self.style, self.opacity) + + +class PhotoSize(models.Model): + name = models.CharField(_('name'), max_length=20, unique=True, help_text=_('Photo size name should contain only letters, numbers and underscores. Examples: "thumbnail", "display", "small", "main_page_widget".')) + width = models.PositiveIntegerField(_('width'), default=0, help_text=_('If width is set to "0" the image will be scaled to the supplied height.')) + height = models.PositiveIntegerField(_('height'), default=0, help_text=_('If height is set to "0" the image will be scaled to the supplied width')) + quality = models.PositiveIntegerField(_('quality'), choices=JPEG_QUALITY_CHOICES, default=70, help_text=_('JPEG image quality.')) + upscale = models.BooleanField(_('upscale images?'), default=False, help_text=_('If selected the image will be scaled up if necessary to fit the supplied dimensions. Cropped sizes will be upscaled regardless of this setting.')) + crop = models.BooleanField(_('crop to fit?'), default=False, help_text=_('If selected the image will be scaled and cropped to fit the supplied dimensions.')) + pre_cache = models.BooleanField(_('pre-cache?'), default=False, help_text=_('If selected this photo size will be pre-cached as photos are added.')) + increment_count = models.BooleanField(_('increment view count?'), default=False, help_text=_('If selected the image\'s "view_count" will be incremented when this photo size is displayed.')) + effect = models.ForeignKey('PhotoEffect', null=True, blank=True, related_name='photo_sizes', verbose_name=_('photo effect')) + watermark = models.ForeignKey('Watermark', null=True, blank=True, related_name='photo_sizes', verbose_name=_('watermark image')) + + class Meta: + ordering = ['width', 'height'] + verbose_name = _('photo size') + verbose_name_plural = _('photo sizes') + + def __unicode__(self): + return self.name + + def __str__(self): + return self.__unicode__() + + def clear_cache(self): + for cls in ImageModel.__subclasses__(): + for obj in cls.objects.all(): + obj.remove_size(self) + if self.pre_cache: + obj.create_size(self) + PhotoSizeCache().reset() + + def save(self, *args, **kwargs): + if self.crop is True: + if self.width == 0 or self.height == 0: + raise ValueError("PhotoSize width and/or height can not be zero if crop=True.") + super(PhotoSize, self).save(*args, **kwargs) + PhotoSizeCache().reset() + self.clear_cache() + + def delete(self): + 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() + super(PhotoSize, self).delete() + + def _get_size(self): + return (self.width, self.height) + def _set_size(self, value): + self.width, self.height = value + size = property(_get_size, _set_size) + + +class PhotoSizeCache(object): + __state = {"sizes": {}} + + def __init__(self): + self.__dict__ = self.__state + if not len(self.sizes): + sizes = PhotoSize.objects.all() + for size in sizes: + self.sizes[size.name] = size + + def reset(self): + self.sizes = {} + + +# Set up the accessor methods +def add_methods(sender, instance, signal, *args, **kwargs): + """ Adds methods to access sized images (urls, paths) + + after the Photo model's __init__ function completes, + this method calls "add_accessor_methods" on each instance. + """ + if hasattr(instance, 'add_accessor_methods'): + instance.add_accessor_methods() + +# connect the add_accessor_methods function to the post_init signal +post_init.connect(add_methods) diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/res/sample.jpg Binary file web/lib/photologue/res/sample.jpg has changed diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/res/test_landscape.jpg Binary file web/lib/photologue/res/test_landscape.jpg has changed diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/res/test_portrait.jpg Binary file web/lib/photologue/res/test_portrait.jpg has changed diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/res/test_square.jpg Binary file web/lib/photologue/res/test_square.jpg has changed diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/templates/photologue/gallery_archive.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/templates/photologue/gallery_archive.html Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,26 @@ +{% extends "photologue/root.html" %} + +{% block title %}Latest Photo Galleries{% endblock %} + +{% block content %} + +

Latest Photo Galleries

+ +{% if latest %} + {% for gallery in latest %} + + {% endfor %} +{% else %} +

No galleries were found.

+{% endif %} + +

View all galleries.

+ +{% endblock %} diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/templates/photologue/gallery_archive_day.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/templates/photologue/gallery_archive_day.html Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,26 @@ +{% extends "photologue/root.html" %} + +{% block title %}Galleries for {{ day|date }}{% endblock %} + +{% block content %} + +

Galleries for {{ day|date }}

+ +{% if object_list %} + {% for gallery in object_list %} + + {% endfor %} +{% else %} +

No galleries were found.

+{% endif %} + +

View all galleries.

+ +{% endblock %} diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/templates/photologue/gallery_archive_month.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/templates/photologue/gallery_archive_month.html Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,26 @@ +{% extends "photologue/root.html" %} + +{% block title %}Galleries for {{ month|date:"F Y" }}{% endblock %} + +{% block content %} + +

Galleries for {{ month|date:"F Y" }}

+ +{% if object_list %} + {% for gallery in object_list %} + + {% endfor %} +{% else %} +

No galleries were found.

+{% endif %} + +

View all galleries.

+ +{% endblock %} diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/templates/photologue/gallery_archive_year.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/templates/photologue/gallery_archive_year.html Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,16 @@ +{% extends "photologue/root.html" %} + +{% block title %}Galleries for {{ year }}{% endblock %} + +{% block content %} + +

Galleries for {{ year }}

+ + +

View all galleries.

+ +{% endblock %} diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/templates/photologue/gallery_detail.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/templates/photologue/gallery_detail.html Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,19 @@ +{% extends "photologue/root.html" %} + +{% block title %}{{ object.title }}{% endblock %} + +{% block content %} + +

{{ object.title }}

+

Originally published {{ object.date_added|date:"l, F jS, Y" }}

+{% if object.description %}

{{ object.description }}

{% endif %} + +

View all galleries

+ +{% endblock %} diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/templates/photologue/gallery_list.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/templates/photologue/gallery_list.html Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,31 @@ +{% extends "photologue/root.html" %} + +{% block title %}All Galleries{% endblock %} + +{% block content %} + +

All galleries

+ +{% if object_list %} + {% for gallery in object_list %} + + {% endfor %} +{% else %} +

No galleries were found.

+{% endif %} + +{% if is_paginated %} +

{{ hits }} galleries total.

+
+

{% if has_previous %}Previous | {% endif %} page {{ page }} of {{ pages }} {% if has_next %}| Next{% endif %}

+
+{% endif %} + +{% endblock %} diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/templates/photologue/photo_archive.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/templates/photologue/photo_archive.html Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,20 @@ +{% extends "photologue/root.html" %} + +{% block title %}Latest Photos{% endblock %} + +{% block content %} + +

Latest Photos

+ +{% if latest %} + {% for photo in latest %} + + {% endfor %} +{% else %} +

No photos were found.

+{% endif %} +

View all photographs

+ +{% endblock %} diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/templates/photologue/photo_archive_day.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/templates/photologue/photo_archive_day.html Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,20 @@ +{% extends "photologue/root.html" %} + +{% block title %}Photos for {{ day|date }}{% endblock %} + +{% block content %} + +

Photos for {{ day|date }}

+ +{% if object_list %} + {% for photo in object_list %} + + {% endfor %} +{% else %} +

No photos were found.

+{% endif %} +

View all photographs

+ +{% endblock %} diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/templates/photologue/photo_archive_month.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/templates/photologue/photo_archive_month.html Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,20 @@ +{% extends "photologue/root.html" %} + +{% block title %}Photos for {{ month|date:"F Y" }}{% endblock %} + +{% block content %} + +

Photos for {{ month|date:"F Y" }}

+ +{% if object_list %} + {% for photo in object_list %} + + {% endfor %} +{% else %} +

No photos were found.

+{% endif %} +

View all photographs

+ +{% endblock %} diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/templates/photologue/photo_archive_year.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/templates/photologue/photo_archive_year.html Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,14 @@ +{% extends "photologue/root.html" %} + +{% block title %}Galleries for {{ year }}{% endblock %} + +{% block content %} + +

Photos for {{ year }}

+ + +{% endblock %} diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/templates/photologue/photo_detail.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/templates/photologue/photo_detail.html Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,23 @@ +{% extends "photologue/root.html" %} + +{% load photologue_tags %} + +{% block title %}{{ object.title }}{% endblock %} + +{% block content %} + +

{{ object.title }}

+ +{% if object.public_galleries %} +

This photo is found in the following galleries:

+
    +{% for gallery in object.public_galleries %} +
  1. {%previous_in_gallery object gallery%} {{ gallery.title }} {%next_in_gallery object gallery%}
  2. +{% endfor %} +
+{% endif %} + +{% endblock %} diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/templates/photologue/photo_list.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/templates/photologue/photo_list.html Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,26 @@ +{% extends "photologue/root.html" %} + +{% block title %}All Photos{% endblock %} + +{% block content %} + +

All Photos

+ +{% if object_list %} + {% for photo in object_list %} + + {% endfor %} +{% else %} +

No photos were found.

+{% endif %} + +{% if is_paginated %} +

{{ hits }} photos total.

+
+

{% if has_previous %}Previous | {% endif %} page {{ page }} of {{ pages }} {% if has_next %}| Next{% endif %}

+
+{% endif %} + +{% endblock %} diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/templates/photologue/root.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/templates/photologue/root.html Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,1 @@ +{% extends "base.html" %} \ No newline at end of file diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/templatetags/__init__.py diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/templatetags/photologue_tags.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/templatetags/photologue_tags.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,17 @@ +from django import template + +register = template.Library() + +@register.simple_tag +def next_in_gallery(photo, gallery): + next = photo.get_next_in_gallery(gallery) + if next: + return '' % (next.title, next.get_absolute_url(), next.get_thumbnail_url()) + return "" + +@register.simple_tag +def previous_in_gallery(photo, gallery): + prev = photo.get_previous_in_gallery(gallery) + if prev: + return '' % (prev.title, prev.get_absolute_url(), prev.get_thumbnail_url()) + return "" diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/tests.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/tests.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,218 @@ +import os +import unittest +from django.conf import settings +from django.core.files.base import ContentFile +from django.test import TestCase + +from models import * + +# Path to sample image +RES_DIR = os.path.join(os.path.dirname(__file__), 'res') +LANDSCAPE_IMAGE_PATH = os.path.join(RES_DIR, 'test_landscape.jpg') +PORTRAIT_IMAGE_PATH = os.path.join(RES_DIR, 'test_portrait.jpg') +SQUARE_IMAGE_PATH = os.path.join(RES_DIR, 'test_square.jpg') + + +class TestPhoto(ImageModel): + """ Minimal ImageModel class for testing """ + name = models.CharField(max_length=30) + + +class PLTest(TestCase): + """ Base TestCase class """ + def setUp(self): + self.s = PhotoSize(name='test', width=100, height=100) + self.s.save() + self.pl = TestPhoto(name='landscape') + self.pl.image.save(os.path.basename(LANDSCAPE_IMAGE_PATH), + ContentFile(open(LANDSCAPE_IMAGE_PATH, 'rb').read())) + self.pl.save() + + def tearDown(self): + path = self.pl.image.path + self.pl.delete() + self.failIf(os.path.isfile(path)) + self.s.delete() + + +class PhotoTest(PLTest): + def test_new_photo(self): + self.assertEqual(TestPhoto.objects.count(), 1) + self.failUnless(os.path.isfile(self.pl.image.path)) + self.assertEqual(os.path.getsize(self.pl.image.path), + os.path.getsize(LANDSCAPE_IMAGE_PATH)) + + #def test_exif(self): + # self.assert_(len(self.pl.EXIF.keys()) > 0) + + def test_paths(self): + self.assertEqual(os.path.normpath(str(self.pl.cache_path())).lower(), + os.path.normpath(os.path.join(settings.MEDIA_ROOT, + PHOTOLOGUE_DIR, + 'photos', + 'cache')).lower()) + self.assertEqual(self.pl.cache_url(), + settings.MEDIA_URL + PHOTOLOGUE_DIR + '/photos/cache') + + def test_count(self): + for i in range(5): + self.pl.get_test_url() + self.assertEquals(self.pl.view_count, 0) + self.s.increment_count = True + self.s.save() + for i in range(5): + self.pl.get_test_url() + self.assertEquals(self.pl.view_count, 5) + + def test_precache(self): + # set the thumbnail photo size to pre-cache + self.s.pre_cache = True + self.s.save() + # make sure it created the file + self.failUnless(os.path.isfile(self.pl.get_test_filename())) + self.s.pre_cache = False + self.s.save() + # clear the cache and make sure the file's deleted + self.pl.clear_cache() + self.failIf(os.path.isfile(self.pl.get_test_filename())) + + def test_accessor_methods(self): + self.assertEquals(self.pl.get_test_photosize(), self.s) + self.assertEquals(self.pl.get_test_size(), + Image.open(self.pl.get_test_filename()).size) + self.assertEquals(self.pl.get_test_url(), + self.pl.cache_url() + '/' + \ + self.pl._get_filename_for_size(self.s)) + self.assertEquals(self.pl.get_test_filename(), + os.path.join(self.pl.cache_path(), + self.pl._get_filename_for_size(self.s))) + + +class ImageResizeTest(PLTest): + def setUp(self): + super(ImageResizeTest, self).setUp() + self.pp = TestPhoto(name='portrait') + self.pp.image.save(os.path.basename(PORTRAIT_IMAGE_PATH), + ContentFile(open(PORTRAIT_IMAGE_PATH, 'rb').read())) + self.pp.save() + self.ps = TestPhoto(name='square') + self.ps.image.save(os.path.basename(SQUARE_IMAGE_PATH), + ContentFile(open(SQUARE_IMAGE_PATH, 'rb').read())) + self.ps.save() + + def tearDown(self): + super(ImageResizeTest, self).tearDown() + self.pp.delete() + self.ps.delete() + + def test_resize_to_fit(self): + self.assertEquals(self.pl.get_test_size(), (100, 75)) + self.assertEquals(self.pp.get_test_size(), (75, 100)) + self.assertEquals(self.ps.get_test_size(), (100, 100)) + + def test_resize_to_fit_width(self): + self.s.size = (100, 0) + self.s.save() + self.assertEquals(self.pl.get_test_size(), (100, 75)) + self.assertEquals(self.pp.get_test_size(), (100, 133)) + self.assertEquals(self.ps.get_test_size(), (100, 100)) + + def test_resize_to_fit_width_enlarge(self): + self.s.size = (400, 0) + self.s.upscale = True + self.s.save() + self.assertEquals(self.pl.get_test_size(), (400, 300)) + self.assertEquals(self.pp.get_test_size(), (400, 533)) + self.assertEquals(self.ps.get_test_size(), (400, 400)) + + def test_resize_to_fit_height(self): + self.s.size = (0, 100) + self.s.save() + self.assertEquals(self.pl.get_test_size(), (133, 100)) + self.assertEquals(self.pp.get_test_size(), (75, 100)) + self.assertEquals(self.ps.get_test_size(), (100, 100)) + + def test_resize_to_fit_height_enlarge(self): + self.s.size = (0, 400) + self.s.upscale = True + self.s.save() + self.assertEquals(self.pl.get_test_size(), (533, 400)) + self.assertEquals(self.pp.get_test_size(), (300, 400)) + self.assertEquals(self.ps.get_test_size(), (400, 400)) + + def test_resize_and_crop(self): + self.s.crop = True + self.s.save() + self.assertEquals(self.pl.get_test_size(), self.s.size) + self.assertEquals(self.pp.get_test_size(), self.s.size) + self.assertEquals(self.ps.get_test_size(), self.s.size) + + def test_resize_rounding_to_fit(self): + self.s.size = (113, 113) + self.s.save() + self.assertEquals(self.pl.get_test_size(), (113, 85)) + self.assertEquals(self.pp.get_test_size(), (85, 113)) + self.assertEquals(self.ps.get_test_size(), (113, 113)) + + def test_resize_rounding_cropped(self): + self.s.size = (113, 113) + self.s.crop = True + self.s.save() + self.assertEquals(self.pl.get_test_size(), self.s.size) + self.assertEquals(self.pp.get_test_size(), self.s.size) + self.assertEquals(self.ps.get_test_size(), self.s.size) + + def test_resize_one_dimension_width(self): + self.s.size = (100, 150) + self.s.save() + self.assertEquals(self.pl.get_test_size(), (100, 75)) + + def test_resize_one_dimension_height(self): + self.s.size = (200, 75) + self.s.save() + self.assertEquals(self.pl.get_test_size(), (100, 75)) + + def test_resize_no_upscale(self): + self.s.size = (1000, 1000) + self.s.save() + self.assertEquals(self.pl.get_test_size(), (200, 150)) + + def test_resize_no_upscale_mixed_height(self): + self.s.size = (400, 75) + self.s.save() + self.assertEquals(self.pl.get_test_size(), (100, 75)) + + def test_resize_no_upscale_mixed_width(self): + self.s.size = (100, 300) + self.s.save() + self.assertEquals(self.pl.get_test_size(), (100, 75)) + + def test_resize_no_upscale_crop(self): + self.s.size = (1000, 1000) + self.s.crop = True + self.s.save() + self.assertEquals(self.pl.get_test_size(), (1000, 1000)) + + def test_resize_upscale(self): + self.s.size = (1000, 1000) + self.s.upscale = True + self.s.save() + self.assertEquals(self.pl.get_test_size(), (1000, 750)) + self.assertEquals(self.pp.get_test_size(), (750, 1000)) + self.assertEquals(self.ps.get_test_size(), (1000, 1000)) + + +class PhotoEffectTest(PLTest): + def test(self): + effect = PhotoEffect(name='test') + im = Image.open(self.pl.image.path) + self.assert_(isinstance(effect.pre_process(im), Image.Image)) + self.assert_(isinstance(effect.post_process(im), Image.Image)) + self.assert_(isinstance(effect.process(im), Image.Image)) + + +class PhotoSizeCacheTest(PLTest): + def test(self): + cache = PhotoSizeCache() + self.assertEqual(cache.sizes['test'], self.s) + diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/urls.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/urls.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,36 @@ +from django.conf import settings +from django.conf.urls.defaults import * +from models import * + +# Number of random images from the gallery to display. +SAMPLE_SIZE = ":%s" % getattr(settings, 'GALLERY_SAMPLE_SIZE', 5) + +# galleries +gallery_args = {'date_field': 'date_added', 'allow_empty': True, 'queryset': Gallery.objects.filter(is_public=True), 'extra_context':{'sample_size':SAMPLE_SIZE}} +urlpatterns = patterns('django.views.generic.date_based', + url(r'^gallery/(?P\d{4})/(?P[a-z]{3})/(?P\w{1,2})/(?P[\-\d\w]+)/$', 'object_detail', {'date_field': 'date_added', 'slug_field': 'title_slug', 'queryset': Gallery.objects.filter(is_public=True), 'extra_context':{'sample_size':SAMPLE_SIZE}}, name='pl-gallery-detail'), + url(r'^gallery/(?P\d{4})/(?P[a-z]{3})/(?P\w{1,2})/$', 'archive_day', gallery_args, name='pl-gallery-archive-day'), + url(r'^gallery/(?P\d{4})/(?P[a-z]{3})/$', 'archive_month', gallery_args, name='pl-gallery-archive-month'), + url(r'^gallery/(?P\d{4})/$', 'archive_year', gallery_args, name='pl-gallery-archive-year'), + url(r'^gallery/?$', 'archive_index', gallery_args, name='pl-gallery-archive'), +) +urlpatterns += patterns('django.views.generic.list_detail', + url(r'^gallery/(?P[\-\d\w]+)/$', 'object_detail', {'slug_field': 'title_slug', 'queryset': Gallery.objects.filter(is_public=True), 'extra_context':{'sample_size':SAMPLE_SIZE}}, name='pl-gallery'), + url(r'^gallery/page/(?P[0-9]+)/$', 'object_list', {'queryset': Gallery.objects.filter(is_public=True), 'allow_empty': True, 'paginate_by': 5, 'extra_context':{'sample_size':SAMPLE_SIZE}}, name='pl-gallery-list'), +) + +# photographs +photo_args = {'date_field': 'date_added', 'allow_empty': True, 'queryset': Photo.objects.filter(is_public=True)} +urlpatterns += patterns('django.views.generic.date_based', + url(r'^photo/(?P\d{4})/(?P[a-z]{3})/(?P\w{1,2})/(?P[\-\d\w]+)/$', 'object_detail', {'date_field': 'date_added', 'slug_field': 'title_slug', 'queryset': Photo.objects.filter(is_public=True)}, name='pl-photo-detail'), + url(r'^photo/(?P\d{4})/(?P[a-z]{3})/(?P\w{1,2})/$', 'archive_day', photo_args, name='pl-photo-archive-day'), + url(r'^photo/(?P\d{4})/(?P[a-z]{3})/$', 'archive_month', photo_args, name='pl-photo-archive-month'), + url(r'^photo/(?P\d{4})/$', 'archive_year', photo_args, name='pl-photo-archive-year'), + url(r'^photo/$', 'archive_index', photo_args, name='pl-photo-archive'), +) +urlpatterns += patterns('django.views.generic.list_detail', + url(r'^photo/(?P[\-\d\w]+)/$', 'object_detail', {'slug_field': 'title_slug', 'queryset': Photo.objects.filter(is_public=True)}, name='pl-photo'), + url(r'^photo/page/(?P[0-9]+)/$', 'object_list', {'queryset': Photo.objects.filter(is_public=True), 'allow_empty': True, 'paginate_by': 20}, name='pl-photo-list'), +) + + diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/utils/EXIF.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/utils/EXIF.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,1568 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Library to extract EXIF information from digital camera image files +# http://sourceforge.net/projects/exif-py/ +# +# VERSION 1.0.7 +# +# To use this library call with: +# f = open(path_name, 'rb') +# tags = EXIF.process_file(f) +# +# To ignore makerNote tags, pass the -q or --quick +# command line arguments, or as +# f = open(path_name, 'rb') +# tags = EXIF.process_file(f, details=False) +# +# To stop processing after a certain tag is retrieved, +# pass the -t TAG or --stop-tag TAG argument, or as +# f = open(path_name, 'rb') +# tags = EXIF.process_file(f, stop_tag='TAG') +# +# where TAG is a valid tag name, ex 'DateTimeOriginal' +# +# These are useful when you are retrieving a large list of images +# +# Returned tags will be a dictionary mapping names of EXIF tags to their +# values in the file named by path_name. You can process the tags +# as you wish. In particular, you can iterate through all the tags with: +# for tag in tags.keys(): +# if tag not in ('JPEGThumbnail', 'TIFFThumbnail', 'Filename', +# 'EXIF MakerNote'): +# print "Key: %s, value %s" % (tag, tags[tag]) +# (This code uses the if statement to avoid printing out a few of the +# tags that tend to be long or boring.) +# +# The tags dictionary will include keys for all of the usual EXIF +# tags, and will also include keys for Makernotes used by some +# cameras, for which we have a good specification. +# +# Note that the dictionary keys are the IFD name followed by the +# tag name. For example: +# 'EXIF DateTimeOriginal', 'Image Orientation', 'MakerNote FocusMode' +# +# Copyright (c) 2002-2007 Gene Cash All rights reserved +# Copyright (c) 2007 Ianaré Sévi All rights reserved +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided +# with the distribution. +# +# 3. Neither the name of the authors nor the names of its contributors +# may be used to endorse or promote products derived from this +# software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# +# ----- See 'changes.txt' file for all contributors and changes ----- # +# + + +# Don't throw an exception when given an out of range character. +def make_string(seq): + str = "" + for c in seq: + # Screen out non-printing characters + if 32 <= c and c < 256: + str += chr(c) + # If no printing chars + if not str: + return seq + return str + +# Special version to deal with the code in the first 8 bytes of a user comment. +def make_string_uc(seq): + code = seq[0:8] + seq = seq[8:] + # Of course, this is only correct if ASCII, and the standard explicitly + # allows JIS and Unicode. + return make_string(seq) + +# field type descriptions as (length, abbreviation, full name) tuples +FIELD_TYPES = ( + (0, 'X', 'Proprietary'), # no such type + (1, 'B', 'Byte'), + (1, 'A', 'ASCII'), + (2, 'S', 'Short'), + (4, 'L', 'Long'), + (8, 'R', 'Ratio'), + (1, 'SB', 'Signed Byte'), + (1, 'U', 'Undefined'), + (2, 'SS', 'Signed Short'), + (4, 'SL', 'Signed Long'), + (8, 'SR', 'Signed Ratio'), + ) + +# dictionary of main EXIF tag names +# first element of tuple is tag name, optional second element is +# another dictionary giving names to values +EXIF_TAGS = { + 0x0100: ('ImageWidth', ), + 0x0101: ('ImageLength', ), + 0x0102: ('BitsPerSample', ), + 0x0103: ('Compression', + {1: 'Uncompressed TIFF', + 6: 'JPEG Compressed'}), + 0x0106: ('PhotometricInterpretation', ), + 0x010A: ('FillOrder', ), + 0x010D: ('DocumentName', ), + 0x010E: ('ImageDescription', ), + 0x010F: ('Make', ), + 0x0110: ('Model', ), + 0x0111: ('StripOffsets', ), + 0x0112: ('Orientation', + {1: 'Horizontal (normal)', + 2: 'Mirrored horizontal', + 3: 'Rotated 180', + 4: 'Mirrored vertical', + 5: 'Mirrored horizontal then rotated 90 CCW', + 6: 'Rotated 90 CW', + 7: 'Mirrored horizontal then rotated 90 CW', + 8: 'Rotated 90 CCW'}), + 0x0115: ('SamplesPerPixel', ), + 0x0116: ('RowsPerStrip', ), + 0x0117: ('StripByteCounts', ), + 0x011A: ('XResolution', ), + 0x011B: ('YResolution', ), + 0x011C: ('PlanarConfiguration', ), + 0x0128: ('ResolutionUnit', + {1: 'Not Absolute', + 2: 'Pixels/Inch', + 3: 'Pixels/Centimeter'}), + 0x012D: ('TransferFunction', ), + 0x0131: ('Software', ), + 0x0132: ('DateTime', ), + 0x013B: ('Artist', ), + 0x013E: ('WhitePoint', ), + 0x013F: ('PrimaryChromaticities', ), + 0x0156: ('TransferRange', ), + 0x0200: ('JPEGProc', ), + 0x0201: ('JPEGInterchangeFormat', ), + 0x0202: ('JPEGInterchangeFormatLength', ), + 0x0211: ('YCbCrCoefficients', ), + 0x0212: ('YCbCrSubSampling', ), + 0x0213: ('YCbCrPositioning', ), + 0x0214: ('ReferenceBlackWhite', ), + 0x828D: ('CFARepeatPatternDim', ), + 0x828E: ('CFAPattern', ), + 0x828F: ('BatteryLevel', ), + 0x8298: ('Copyright', ), + 0x829A: ('ExposureTime', ), + 0x829D: ('FNumber', ), + 0x83BB: ('IPTC/NAA', ), + 0x8769: ('ExifOffset', ), + 0x8773: ('InterColorProfile', ), + 0x8822: ('ExposureProgram', + {0: 'Unidentified', + 1: 'Manual', + 2: 'Program Normal', + 3: 'Aperture Priority', + 4: 'Shutter Priority', + 5: 'Program Creative', + 6: 'Program Action', + 7: 'Portrait Mode', + 8: 'Landscape Mode'}), + 0x8824: ('SpectralSensitivity', ), + 0x8825: ('GPSInfo', ), + 0x8827: ('ISOSpeedRatings', ), + 0x8828: ('OECF', ), + # print as string + 0x9000: ('ExifVersion', make_string), + 0x9003: ('DateTimeOriginal', ), + 0x9004: ('DateTimeDigitized', ), + 0x9101: ('ComponentsConfiguration', + {0: '', + 1: 'Y', + 2: 'Cb', + 3: 'Cr', + 4: 'Red', + 5: 'Green', + 6: 'Blue'}), + 0x9102: ('CompressedBitsPerPixel', ), + 0x9201: ('ShutterSpeedValue', ), + 0x9202: ('ApertureValue', ), + 0x9203: ('BrightnessValue', ), + 0x9204: ('ExposureBiasValue', ), + 0x9205: ('MaxApertureValue', ), + 0x9206: ('SubjectDistance', ), + 0x9207: ('MeteringMode', + {0: 'Unidentified', + 1: 'Average', + 2: 'CenterWeightedAverage', + 3: 'Spot', + 4: 'MultiSpot'}), + 0x9208: ('LightSource', + {0: 'Unknown', + 1: 'Daylight', + 2: 'Fluorescent', + 3: 'Tungsten', + 10: 'Flash', + 17: 'Standard Light A', + 18: 'Standard Light B', + 19: 'Standard Light C', + 20: 'D55', + 21: 'D65', + 22: 'D75', + 255: 'Other'}), + 0x9209: ('Flash', {0: 'No', + 1: 'Fired', + 5: 'Fired (?)', # no return sensed + 7: 'Fired (!)', # return sensed + 9: 'Fill Fired', + 13: 'Fill Fired (?)', + 15: 'Fill Fired (!)', + 16: 'Off', + 24: 'Auto Off', + 25: 'Auto Fired', + 29: 'Auto Fired (?)', + 31: 'Auto Fired (!)', + 32: 'Not Available'}), + 0x920A: ('FocalLength', ), + 0x9214: ('SubjectArea', ), + 0x927C: ('MakerNote', ), + # print as string + 0x9286: ('UserComment', make_string_uc), # First 8 bytes gives coding system e.g. ASCII vs. JIS vs Unicode + 0x9290: ('SubSecTime', ), + 0x9291: ('SubSecTimeOriginal', ), + 0x9292: ('SubSecTimeDigitized', ), + # print as string + 0xA000: ('FlashPixVersion', make_string), + 0xA001: ('ColorSpace', ), + 0xA002: ('ExifImageWidth', ), + 0xA003: ('ExifImageLength', ), + 0xA005: ('InteroperabilityOffset', ), + 0xA20B: ('FlashEnergy', ), # 0x920B in TIFF/EP + 0xA20C: ('SpatialFrequencyResponse', ), # 0x920C - - + 0xA20E: ('FocalPlaneXResolution', ), # 0x920E - - + 0xA20F: ('FocalPlaneYResolution', ), # 0x920F - - + 0xA210: ('FocalPlaneResolutionUnit', ), # 0x9210 - - + 0xA214: ('SubjectLocation', ), # 0x9214 - - + 0xA215: ('ExposureIndex', ), # 0x9215 - - + 0xA217: ('SensingMethod', ), # 0x9217 - - + 0xA300: ('FileSource', + {3: 'Digital Camera'}), + 0xA301: ('SceneType', + {1: 'Directly Photographed'}), + 0xA302: ('CVAPattern', ), + 0xA401: ('CustomRendered', ), + 0xA402: ('ExposureMode', + {0: 'Auto Exposure', + 1: 'Manual Exposure', + 2: 'Auto Bracket'}), + 0xA403: ('WhiteBalance', + {0: 'Auto', + 1: 'Manual'}), + 0xA404: ('DigitalZoomRatio', ), + 0xA405: ('FocalLengthIn35mm', ), + 0xA406: ('SceneCaptureType', ), + 0xA407: ('GainControl', ), + 0xA408: ('Contrast', ), + 0xA409: ('Saturation', ), + 0xA40A: ('Sharpness', ), + 0xA40C: ('SubjectDistanceRange', ), + } + +# interoperability tags +INTR_TAGS = { + 0x0001: ('InteroperabilityIndex', ), + 0x0002: ('InteroperabilityVersion', ), + 0x1000: ('RelatedImageFileFormat', ), + 0x1001: ('RelatedImageWidth', ), + 0x1002: ('RelatedImageLength', ), + } + +# GPS tags (not used yet, haven't seen camera with GPS) +GPS_TAGS = { + 0x0000: ('GPSVersionID', ), + 0x0001: ('GPSLatitudeRef', ), + 0x0002: ('GPSLatitude', ), + 0x0003: ('GPSLongitudeRef', ), + 0x0004: ('GPSLongitude', ), + 0x0005: ('GPSAltitudeRef', ), + 0x0006: ('GPSAltitude', ), + 0x0007: ('GPSTimeStamp', ), + 0x0008: ('GPSSatellites', ), + 0x0009: ('GPSStatus', ), + 0x000A: ('GPSMeasureMode', ), + 0x000B: ('GPSDOP', ), + 0x000C: ('GPSSpeedRef', ), + 0x000D: ('GPSSpeed', ), + 0x000E: ('GPSTrackRef', ), + 0x000F: ('GPSTrack', ), + 0x0010: ('GPSImgDirectionRef', ), + 0x0011: ('GPSImgDirection', ), + 0x0012: ('GPSMapDatum', ), + 0x0013: ('GPSDestLatitudeRef', ), + 0x0014: ('GPSDestLatitude', ), + 0x0015: ('GPSDestLongitudeRef', ), + 0x0016: ('GPSDestLongitude', ), + 0x0017: ('GPSDestBearingRef', ), + 0x0018: ('GPSDestBearing', ), + 0x0019: ('GPSDestDistanceRef', ), + 0x001A: ('GPSDestDistance', ), + } + +# Ignore these tags when quick processing +# 0x927C is MakerNote Tags +# 0x9286 is user comment +IGNORE_TAGS=(0x9286, 0x927C) + +# http://tomtia.plala.jp/DigitalCamera/MakerNote/index.asp +def nikon_ev_bias(seq): + # First digit seems to be in steps of 1/6 EV. + # Does the third value mean the step size? It is usually 6, + # but it is 12 for the ExposureDifference. + # + if seq == [252, 1, 6, 0]: + return "-2/3 EV" + if seq == [253, 1, 6, 0]: + return "-1/2 EV" + if seq == [254, 1, 6, 0]: + return "-1/3 EV" + if seq == [0, 1, 6, 0]: + return "0 EV" + if seq == [2, 1, 6, 0]: + return "+1/3 EV" + if seq == [3, 1, 6, 0]: + return "+1/2 EV" + if seq == [4, 1, 6, 0]: + return "+2/3 EV" + # Handle combinations not in the table. + a = seq[0] + # Causes headaches for the +/- logic, so special case it. + if a == 0: + return "0 EV" + if a > 127: + a = 256 - a + ret_str = "-" + else: + ret_str = "+" + b = seq[2] # Assume third value means the step size + whole = a / b + a = a % b + if whole != 0: + ret_str = ret_str + str(whole) + " " + if a == 0: + ret_str = ret_str + "EV" + else: + r = Ratio(a, b) + ret_str = ret_str + r.__repr__() + " EV" + return ret_str + +# Nikon E99x MakerNote Tags +MAKERNOTE_NIKON_NEWER_TAGS={ + 0x0001: ('MakernoteVersion', make_string), # Sometimes binary + 0x0002: ('ISOSetting', ), + 0x0003: ('ColorMode', ), + 0x0004: ('Quality', ), + 0x0005: ('Whitebalance', ), + 0x0006: ('ImageSharpening', ), + 0x0007: ('FocusMode', ), + 0x0008: ('FlashSetting', ), + 0x0009: ('AutoFlashMode', ), + 0x000B: ('WhiteBalanceBias', ), + 0x000C: ('WhiteBalanceRBCoeff', ), + 0x000D: ('ProgramShift', nikon_ev_bias), + # Nearly the same as the other EV vals, but step size is 1/12 EV (?) + 0x000E: ('ExposureDifference', nikon_ev_bias), + 0x000F: ('ISOSelection', ), + 0x0011: ('NikonPreview', ), + 0x0012: ('FlashCompensation', nikon_ev_bias), + 0x0013: ('ISOSpeedRequested', ), + 0x0016: ('PhotoCornerCoordinates', ), + # 0x0017: Unknown, but most likely an EV value + 0x0018: ('FlashBracketCompensationApplied', nikon_ev_bias), + 0x0019: ('AEBracketCompensationApplied', ), + 0x001A: ('ImageProcessing', ), + 0x0080: ('ImageAdjustment', ), + 0x0081: ('ToneCompensation', ), + 0x0082: ('AuxiliaryLens', ), + 0x0083: ('LensType', ), + 0x0084: ('LensMinMaxFocalMaxAperture', ), + 0x0085: ('ManualFocusDistance', ), + 0x0086: ('DigitalZoomFactor', ), + 0x0087: ('FlashMode', + {0x00: 'Did Not Fire', + 0x01: 'Fired, Manual', + 0x07: 'Fired, External', + 0x08: 'Fired, Commander Mode ', + 0x09: 'Fired, TTL Mode'}), + 0x0088: ('AFFocusPosition', + {0x0000: 'Center', + 0x0100: 'Top', + 0x0200: 'Bottom', + 0x0300: 'Left', + 0x0400: 'Right'}), + 0x0089: ('BracketingMode', + {0x00: 'Single frame, no bracketing', + 0x01: 'Continuous, no bracketing', + 0x02: 'Timer, no bracketing', + 0x10: 'Single frame, exposure bracketing', + 0x11: 'Continuous, exposure bracketing', + 0x12: 'Timer, exposure bracketing', + 0x40: 'Single frame, white balance bracketing', + 0x41: 'Continuous, white balance bracketing', + 0x42: 'Timer, white balance bracketing'}), + 0x008A: ('AutoBracketRelease', ), + 0x008B: ('LensFStops', ), + 0x008C: ('NEFCurve2', ), + 0x008D: ('ColorMode', ), + 0x008F: ('SceneMode', ), + 0x0090: ('LightingType', ), + 0x0091: ('ShotInfo', ), # First 4 bytes are probably a version number in ASCII + 0x0092: ('HueAdjustment', ), + # 0x0093: ('SaturationAdjustment', ), + 0x0094: ('Saturation', # Name conflict with 0x00AA !! + {-3: 'B&W', + -2: '-2', + -1: '-1', + 0: '0', + 1: '1', + 2: '2'}), + 0x0095: ('NoiseReduction', ), + 0x0096: ('NEFCurve2', ), + 0x0097: ('ColorBalance', ), + 0x0098: ('LensData', ), # First 4 bytes are a version number in ASCII + 0x0099: ('RawImageCenter', ), + 0x009A: ('SensorPixelSize', ), + 0x009C: ('Scene Assist', ), + 0x00A0: ('SerialNumber', ), + 0x00A2: ('ImageDataSize', ), + # A4: In NEF, looks like a 4 byte ASCII version number + 0x00A5: ('ImageCount', ), + 0x00A6: ('DeletedImageCount', ), + 0x00A7: ('TotalShutterReleases', ), + # A8: ExposureMode? JPG: First 4 bytes are probably a version number in ASCII + # But in a sample NEF, its 8 zeros, then the string "NORMAL" + 0x00A9: ('ImageOptimization', ), + 0x00AA: ('Saturation', ), + 0x00AB: ('DigitalVariProgram', ), + 0x00AC: ('ImageStabilization', ), + 0x00AD: ('Responsive AF', ), # 'AFResponse' + 0x0010: ('DataDump', ), + } + +MAKERNOTE_NIKON_OLDER_TAGS = { + 0x0003: ('Quality', + {1: 'VGA Basic', + 2: 'VGA Normal', + 3: 'VGA Fine', + 4: 'SXGA Basic', + 5: 'SXGA Normal', + 6: 'SXGA Fine'}), + 0x0004: ('ColorMode', + {1: 'Color', + 2: 'Monochrome'}), + 0x0005: ('ImageAdjustment', + {0: 'Normal', + 1: 'Bright+', + 2: 'Bright-', + 3: 'Contrast+', + 4: 'Contrast-'}), + 0x0006: ('CCDSpeed', + {0: 'ISO 80', + 2: 'ISO 160', + 4: 'ISO 320', + 5: 'ISO 100'}), + 0x0007: ('WhiteBalance', + {0: 'Auto', + 1: 'Preset', + 2: 'Daylight', + 3: 'Incandescent', + 4: 'Fluorescent', + 5: 'Cloudy', + 6: 'Speed Light'}), + } + +# decode Olympus SpecialMode tag in MakerNote +def olympus_special_mode(v): + a={ + 0: 'Normal', + 1: 'Unknown', + 2: 'Fast', + 3: 'Panorama'} + b={ + 0: 'Non-panoramic', + 1: 'Left to right', + 2: 'Right to left', + 3: 'Bottom to top', + 4: 'Top to bottom'} + if v[0] not in a or v[2] not in b: + return v + return '%s - sequence %d - %s' % (a[v[0]], v[1], b[v[2]]) + +MAKERNOTE_OLYMPUS_TAGS={ + # ah HAH! those sneeeeeaky bastids! this is how they get past the fact + # that a JPEG thumbnail is not allowed in an uncompressed TIFF file + 0x0100: ('JPEGThumbnail', ), + 0x0200: ('SpecialMode', olympus_special_mode), + 0x0201: ('JPEGQual', + {1: 'SQ', + 2: 'HQ', + 3: 'SHQ'}), + 0x0202: ('Macro', + {0: 'Normal', + 1: 'Macro', + 2: 'SuperMacro'}), + 0x0203: ('BWMode', + {0: 'Off', + 1: 'On'}), + 0x0204: ('DigitalZoom', ), + 0x0205: ('FocalPlaneDiagonal', ), + 0x0206: ('LensDistortionParams', ), + 0x0207: ('SoftwareRelease', ), + 0x0208: ('PictureInfo', ), + 0x0209: ('CameraID', make_string), # print as string + 0x0F00: ('DataDump', ), + 0x0300: ('PreCaptureFrames', ), + 0x0404: ('SerialNumber', ), + 0x1000: ('ShutterSpeedValue', ), + 0x1001: ('ISOValue', ), + 0x1002: ('ApertureValue', ), + 0x1003: ('BrightnessValue', ), + 0x1004: ('FlashMode', ), + 0x1004: ('FlashMode', + {2: 'On', + 3: 'Off'}), + 0x1005: ('FlashDevice', + {0: 'None', + 1: 'Internal', + 4: 'External', + 5: 'Internal + External'}), + 0x1006: ('ExposureCompensation', ), + 0x1007: ('SensorTemperature', ), + 0x1008: ('LensTemperature', ), + 0x100b: ('FocusMode', + {0: 'Auto', + 1: 'Manual'}), + 0x1017: ('RedBalance', ), + 0x1018: ('BlueBalance', ), + 0x101a: ('SerialNumber', ), + 0x1023: ('FlashExposureComp', ), + 0x1026: ('ExternalFlashBounce', + {0: 'No', + 1: 'Yes'}), + 0x1027: ('ExternalFlashZoom', ), + 0x1028: ('ExternalFlashMode', ), + 0x1029: ('Contrast int16u', + {0: 'High', + 1: 'Normal', + 2: 'Low'}), + 0x102a: ('SharpnessFactor', ), + 0x102b: ('ColorControl', ), + 0x102c: ('ValidBits', ), + 0x102d: ('CoringFilter', ), + 0x102e: ('OlympusImageWidth', ), + 0x102f: ('OlympusImageHeight', ), + 0x1034: ('CompressionRatio', ), + 0x1035: ('PreviewImageValid', + {0: 'No', + 1: 'Yes'}), + 0x1036: ('PreviewImageStart', ), + 0x1037: ('PreviewImageLength', ), + 0x1039: ('CCDScanMode', + {0: 'Interlaced', + 1: 'Progressive'}), + 0x103a: ('NoiseReduction', + {0: 'Off', + 1: 'On'}), + 0x103b: ('InfinityLensStep', ), + 0x103c: ('NearLensStep', ), + + # TODO - these need extra definitions + # http://search.cpan.org/src/EXIFTOOL/Image-ExifTool-6.90/html/TagNames/Olympus.html + 0x2010: ('Equipment', ), + 0x2020: ('CameraSettings', ), + 0x2030: ('RawDevelopment', ), + 0x2040: ('ImageProcessing', ), + 0x2050: ('FocusInfo', ), + 0x3000: ('RawInfo ', ), + } + +# 0x2020 CameraSettings +MAKERNOTE_OLYMPUS_TAG_0x2020={ + 0x0100: ('PreviewImageValid', + {0: 'No', + 1: 'Yes'}), + 0x0101: ('PreviewImageStart', ), + 0x0102: ('PreviewImageLength', ), + 0x0200: ('ExposureMode', { + 1: 'Manual', + 2: 'Program', + 3: 'Aperture-priority AE', + 4: 'Shutter speed priority AE', + 5: 'Program-shift'}), + 0x0201: ('AELock', + {0: 'Off', + 1: 'On'}), + 0x0202: ('MeteringMode', + {2: 'Center Weighted', + 3: 'Spot', + 5: 'ESP', + 261: 'Pattern+AF', + 515: 'Spot+Highlight control', + 1027: 'Spot+Shadow control'}), + 0x0300: ('MacroMode', + {0: 'Off', + 1: 'On'}), + 0x0301: ('FocusMode', + {0: 'Single AF', + 1: 'Sequential shooting AF', + 2: 'Continuous AF', + 3: 'Multi AF', + 10: 'MF'}), + 0x0302: ('FocusProcess', + {0: 'AF Not Used', + 1: 'AF Used'}), + 0x0303: ('AFSearch', + {0: 'Not Ready', + 1: 'Ready'}), + 0x0304: ('AFAreas', ), + 0x0401: ('FlashExposureCompensation', ), + 0x0500: ('WhiteBalance2', + {0: 'Auto', + 16: '7500K (Fine Weather with Shade)', + 17: '6000K (Cloudy)', + 18: '5300K (Fine Weather)', + 20: '3000K (Tungsten light)', + 21: '3600K (Tungsten light-like)', + 33: '6600K (Daylight fluorescent)', + 34: '4500K (Neutral white fluorescent)', + 35: '4000K (Cool white fluorescent)', + 48: '3600K (Tungsten light-like)', + 256: 'Custom WB 1', + 257: 'Custom WB 2', + 258: 'Custom WB 3', + 259: 'Custom WB 4', + 512: 'Custom WB 5400K', + 513: 'Custom WB 2900K', + 514: 'Custom WB 8000K', }), + 0x0501: ('WhiteBalanceTemperature', ), + 0x0502: ('WhiteBalanceBracket', ), + 0x0503: ('CustomSaturation', ), # (3 numbers: 1. CS Value, 2. Min, 3. Max) + 0x0504: ('ModifiedSaturation', + {0: 'Off', + 1: 'CM1 (Red Enhance)', + 2: 'CM2 (Green Enhance)', + 3: 'CM3 (Blue Enhance)', + 4: 'CM4 (Skin Tones)'}), + 0x0505: ('ContrastSetting', ), # (3 numbers: 1. Contrast, 2. Min, 3. Max) + 0x0506: ('SharpnessSetting', ), # (3 numbers: 1. Sharpness, 2. Min, 3. Max) + 0x0507: ('ColorSpace', + {0: 'sRGB', + 1: 'Adobe RGB', + 2: 'Pro Photo RGB'}), + 0x0509: ('SceneMode', + {0: 'Standard', + 6: 'Auto', + 7: 'Sport', + 8: 'Portrait', + 9: 'Landscape+Portrait', + 10: 'Landscape', + 11: 'Night scene', + 13: 'Panorama', + 16: 'Landscape+Portrait', + 17: 'Night+Portrait', + 19: 'Fireworks', + 20: 'Sunset', + 22: 'Macro', + 25: 'Documents', + 26: 'Museum', + 28: 'Beach&Snow', + 30: 'Candle', + 35: 'Underwater Wide1', + 36: 'Underwater Macro', + 39: 'High Key', + 40: 'Digital Image Stabilization', + 44: 'Underwater Wide2', + 45: 'Low Key', + 46: 'Children', + 48: 'Nature Macro'}), + 0x050a: ('NoiseReduction', + {0: 'Off', + 1: 'Noise Reduction', + 2: 'Noise Filter', + 3: 'Noise Reduction + Noise Filter', + 4: 'Noise Filter (ISO Boost)', + 5: 'Noise Reduction + Noise Filter (ISO Boost)'}), + 0x050b: ('DistortionCorrection', + {0: 'Off', + 1: 'On'}), + 0x050c: ('ShadingCompensation', + {0: 'Off', + 1: 'On'}), + 0x050d: ('CompressionFactor', ), + 0x050f: ('Gradation', + {'-1 -1 1': 'Low Key', + '0 -1 1': 'Normal', + '1 -1 1': 'High Key'}), + 0x0520: ('PictureMode', + {1: 'Vivid', + 2: 'Natural', + 3: 'Muted', + 256: 'Monotone', + 512: 'Sepia'}), + 0x0521: ('PictureModeSaturation', ), + 0x0522: ('PictureModeHue?', ), + 0x0523: ('PictureModeContrast', ), + 0x0524: ('PictureModeSharpness', ), + 0x0525: ('PictureModeBWFilter', + {0: 'n/a', + 1: 'Neutral', + 2: 'Yellow', + 3: 'Orange', + 4: 'Red', + 5: 'Green'}), + 0x0526: ('PictureModeTone', + {0: 'n/a', + 1: 'Neutral', + 2: 'Sepia', + 3: 'Blue', + 4: 'Purple', + 5: 'Green'}), + 0x0600: ('Sequence', ), # 2 or 3 numbers: 1. Mode, 2. Shot number, 3. Mode bits + 0x0601: ('PanoramaMode', ), # (2 numbers: 1. Mode, 2. Shot number) + 0x0603: ('ImageQuality2', + {1: 'SQ', + 2: 'HQ', + 3: 'SHQ', + 4: 'RAW'}), + 0x0901: ('ManometerReading', ), + } + + +MAKERNOTE_CASIO_TAGS={ + 0x0001: ('RecordingMode', + {1: 'Single Shutter', + 2: 'Panorama', + 3: 'Night Scene', + 4: 'Portrait', + 5: 'Landscape'}), + 0x0002: ('Quality', + {1: 'Economy', + 2: 'Normal', + 3: 'Fine'}), + 0x0003: ('FocusingMode', + {2: 'Macro', + 3: 'Auto Focus', + 4: 'Manual Focus', + 5: 'Infinity'}), + 0x0004: ('FlashMode', + {1: 'Auto', + 2: 'On', + 3: 'Off', + 4: 'Red Eye Reduction'}), + 0x0005: ('FlashIntensity', + {11: 'Weak', + 13: 'Normal', + 15: 'Strong'}), + 0x0006: ('Object Distance', ), + 0x0007: ('WhiteBalance', + {1: 'Auto', + 2: 'Tungsten', + 3: 'Daylight', + 4: 'Fluorescent', + 5: 'Shade', + 129: 'Manual'}), + 0x000B: ('Sharpness', + {0: 'Normal', + 1: 'Soft', + 2: 'Hard'}), + 0x000C: ('Contrast', + {0: 'Normal', + 1: 'Low', + 2: 'High'}), + 0x000D: ('Saturation', + {0: 'Normal', + 1: 'Low', + 2: 'High'}), + 0x0014: ('CCDSpeed', + {64: 'Normal', + 80: 'Normal', + 100: 'High', + 125: '+1.0', + 244: '+3.0', + 250: '+2.0'}), + } + +MAKERNOTE_FUJIFILM_TAGS={ + 0x0000: ('NoteVersion', make_string), + 0x1000: ('Quality', ), + 0x1001: ('Sharpness', + {1: 'Soft', + 2: 'Soft', + 3: 'Normal', + 4: 'Hard', + 5: 'Hard'}), + 0x1002: ('WhiteBalance', + {0: 'Auto', + 256: 'Daylight', + 512: 'Cloudy', + 768: 'DaylightColor-Fluorescent', + 769: 'DaywhiteColor-Fluorescent', + 770: 'White-Fluorescent', + 1024: 'Incandescent', + 3840: 'Custom'}), + 0x1003: ('Color', + {0: 'Normal', + 256: 'High', + 512: 'Low'}), + 0x1004: ('Tone', + {0: 'Normal', + 256: 'High', + 512: 'Low'}), + 0x1010: ('FlashMode', + {0: 'Auto', + 1: 'On', + 2: 'Off', + 3: 'Red Eye Reduction'}), + 0x1011: ('FlashStrength', ), + 0x1020: ('Macro', + {0: 'Off', + 1: 'On'}), + 0x1021: ('FocusMode', + {0: 'Auto', + 1: 'Manual'}), + 0x1030: ('SlowSync', + {0: 'Off', + 1: 'On'}), + 0x1031: ('PictureMode', + {0: 'Auto', + 1: 'Portrait', + 2: 'Landscape', + 4: 'Sports', + 5: 'Night', + 6: 'Program AE', + 256: 'Aperture Priority AE', + 512: 'Shutter Priority AE', + 768: 'Manual Exposure'}), + 0x1100: ('MotorOrBracket', + {0: 'Off', + 1: 'On'}), + 0x1300: ('BlurWarning', + {0: 'Off', + 1: 'On'}), + 0x1301: ('FocusWarning', + {0: 'Off', + 1: 'On'}), + 0x1302: ('AEWarning', + {0: 'Off', + 1: 'On'}), + } + +MAKERNOTE_CANON_TAGS = { + 0x0006: ('ImageType', ), + 0x0007: ('FirmwareVersion', ), + 0x0008: ('ImageNumber', ), + 0x0009: ('OwnerName', ), + } + +# this is in element offset, name, optional value dictionary format +MAKERNOTE_CANON_TAG_0x001 = { + 1: ('Macromode', + {1: 'Macro', + 2: 'Normal'}), + 2: ('SelfTimer', ), + 3: ('Quality', + {2: 'Normal', + 3: 'Fine', + 5: 'Superfine'}), + 4: ('FlashMode', + {0: 'Flash Not Fired', + 1: 'Auto', + 2: 'On', + 3: 'Red-Eye Reduction', + 4: 'Slow Synchro', + 5: 'Auto + Red-Eye Reduction', + 6: 'On + Red-Eye Reduction', + 16: 'external flash'}), + 5: ('ContinuousDriveMode', + {0: 'Single Or Timer', + 1: 'Continuous'}), + 7: ('FocusMode', + {0: 'One-Shot', + 1: 'AI Servo', + 2: 'AI Focus', + 3: 'MF', + 4: 'Single', + 5: 'Continuous', + 6: 'MF'}), + 10: ('ImageSize', + {0: 'Large', + 1: 'Medium', + 2: 'Small'}), + 11: ('EasyShootingMode', + {0: 'Full Auto', + 1: 'Manual', + 2: 'Landscape', + 3: 'Fast Shutter', + 4: 'Slow Shutter', + 5: 'Night', + 6: 'B&W', + 7: 'Sepia', + 8: 'Portrait', + 9: 'Sports', + 10: 'Macro/Close-Up', + 11: 'Pan Focus'}), + 12: ('DigitalZoom', + {0: 'None', + 1: '2x', + 2: '4x'}), + 13: ('Contrast', + {0xFFFF: 'Low', + 0: 'Normal', + 1: 'High'}), + 14: ('Saturation', + {0xFFFF: 'Low', + 0: 'Normal', + 1: 'High'}), + 15: ('Sharpness', + {0xFFFF: 'Low', + 0: 'Normal', + 1: 'High'}), + 16: ('ISO', + {0: 'See ISOSpeedRatings Tag', + 15: 'Auto', + 16: '50', + 17: '100', + 18: '200', + 19: '400'}), + 17: ('MeteringMode', + {3: 'Evaluative', + 4: 'Partial', + 5: 'Center-weighted'}), + 18: ('FocusType', + {0: 'Manual', + 1: 'Auto', + 3: 'Close-Up (Macro)', + 8: 'Locked (Pan Mode)'}), + 19: ('AFPointSelected', + {0x3000: 'None (MF)', + 0x3001: 'Auto-Selected', + 0x3002: 'Right', + 0x3003: 'Center', + 0x3004: 'Left'}), + 20: ('ExposureMode', + {0: 'Easy Shooting', + 1: 'Program', + 2: 'Tv-priority', + 3: 'Av-priority', + 4: 'Manual', + 5: 'A-DEP'}), + 23: ('LongFocalLengthOfLensInFocalUnits', ), + 24: ('ShortFocalLengthOfLensInFocalUnits', ), + 25: ('FocalUnitsPerMM', ), + 28: ('FlashActivity', + {0: 'Did Not Fire', + 1: 'Fired'}), + 29: ('FlashDetails', + {14: 'External E-TTL', + 13: 'Internal Flash', + 11: 'FP Sync Used', + 7: '2nd("Rear")-Curtain Sync Used', + 4: 'FP Sync Enabled'}), + 32: ('FocusMode', + {0: 'Single', + 1: 'Continuous'}), + } + +MAKERNOTE_CANON_TAG_0x004 = { + 7: ('WhiteBalance', + {0: 'Auto', + 1: 'Sunny', + 2: 'Cloudy', + 3: 'Tungsten', + 4: 'Fluorescent', + 5: 'Flash', + 6: 'Custom'}), + 9: ('SequenceNumber', ), + 14: ('AFPointUsed', ), + 15: ('FlashBias', + {0XFFC0: '-2 EV', + 0XFFCC: '-1.67 EV', + 0XFFD0: '-1.50 EV', + 0XFFD4: '-1.33 EV', + 0XFFE0: '-1 EV', + 0XFFEC: '-0.67 EV', + 0XFFF0: '-0.50 EV', + 0XFFF4: '-0.33 EV', + 0X0000: '0 EV', + 0X000C: '0.33 EV', + 0X0010: '0.50 EV', + 0X0014: '0.67 EV', + 0X0020: '1 EV', + 0X002C: '1.33 EV', + 0X0030: '1.50 EV', + 0X0034: '1.67 EV', + 0X0040: '2 EV'}), + 19: ('SubjectDistance', ), + } + +# extract multibyte integer in Motorola format (little endian) +def s2n_motorola(str): + x = 0 + for c in str: + x = (x << 8) | ord(c) + return x + +# extract multibyte integer in Intel format (big endian) +def s2n_intel(str): + x = 0 + y = 0L + for c in str: + x = x | (ord(c) << y) + y = y + 8 + return x + +# ratio object that eventually will be able to reduce itself to lowest +# common denominator for printing +def gcd(a, b): + if b == 0: + return a + else: + return gcd(b, a % b) + +class Ratio: + def __init__(self, num, den): + self.num = num + self.den = den + + def __repr__(self): + self.reduce() + if self.den == 1: + return str(self.num) + return '%d/%d' % (self.num, self.den) + + def reduce(self): + div = gcd(self.num, self.den) + if div > 1: + self.num = self.num / div + self.den = self.den / div + +# for ease of dealing with tags +class IFD_Tag: + def __init__(self, printable, tag, field_type, values, field_offset, + field_length): + # printable version of data + self.printable = printable + # tag ID number + self.tag = tag + # field type as index into FIELD_TYPES + self.field_type = field_type + # offset of start of field in bytes from beginning of IFD + self.field_offset = field_offset + # length of data field in bytes + self.field_length = field_length + # either a string or array of data items + self.values = values + + def __str__(self): + return self.printable + + def __repr__(self): + return '(0x%04X) %s=%s @ %d' % (self.tag, + FIELD_TYPES[self.field_type][2], + self.printable, + self.field_offset) + +# class that handles an EXIF header +class EXIF_header: + def __init__(self, file, endian, offset, fake_exif, debug=0): + self.file = file + self.endian = endian + self.offset = offset + self.fake_exif = fake_exif + self.debug = debug + self.tags = {} + + # convert slice to integer, based on sign and endian flags + # usually this offset is assumed to be relative to the beginning of the + # start of the EXIF information. For some cameras that use relative tags, + # this offset may be relative to some other starting point. + def s2n(self, offset, length, signed=0): + self.file.seek(self.offset+offset) + slice=self.file.read(length) + if self.endian == 'I': + val=s2n_intel(slice) + else: + val=s2n_motorola(slice) + # Sign extension ? + if signed: + msb=1L << (8*length-1) + if val & msb: + val=val-(msb << 1) + return val + + # convert offset to string + def n2s(self, offset, length): + s = '' + for dummy in range(length): + if self.endian == 'I': + s = s + chr(offset & 0xFF) + else: + s = chr(offset & 0xFF) + s + offset = offset >> 8 + return s + + # return first IFD + def first_IFD(self): + return self.s2n(4, 4) + + # return pointer to next IFD + def next_IFD(self, ifd): + entries=self.s2n(ifd, 2) + return self.s2n(ifd+2+12*entries, 4) + + # return list of IFDs in header + def list_IFDs(self): + i=self.first_IFD() + a=[] + while i: + a.append(i) + i=self.next_IFD(i) + return a + + # return list of entries in this IFD + def dump_IFD(self, ifd, ifd_name, dict=EXIF_TAGS, relative=0, stop_tag='UNDEF'): + entries=self.s2n(ifd, 2) + for i in range(entries): + # entry is index of start of this IFD in the file + entry = ifd + 2 + 12 * i + tag = self.s2n(entry, 2) + + # get tag name early to avoid errors, help debug + tag_entry = dict.get(tag) + if tag_entry: + tag_name = tag_entry[0] + else: + tag_name = 'Tag 0x%04X' % tag + + # ignore certain tags for faster processing + if not (not detailed and tag in IGNORE_TAGS): + field_type = self.s2n(entry + 2, 2) + if not 0 < field_type < len(FIELD_TYPES): + # unknown field type + raise ValueError('unknown type %d in tag 0x%04X' % (field_type, tag)) + typelen = FIELD_TYPES[field_type][0] + count = self.s2n(entry + 4, 4) + offset = entry + 8 + if count * typelen > 4: + # offset is not the value; it's a pointer to the value + # if relative we set things up so s2n will seek to the right + # place when it adds self.offset. Note that this 'relative' + # is for the Nikon type 3 makernote. Other cameras may use + # other relative offsets, which would have to be computed here + # slightly differently. + if relative: + tmp_offset = self.s2n(offset, 4) + offset = tmp_offset + ifd - self.offset + 4 + if self.fake_exif: + offset = offset + 18 + else: + offset = self.s2n(offset, 4) + field_offset = offset + if field_type == 2: + # special case: null-terminated ASCII string + if count != 0: + self.file.seek(self.offset + offset) + values = self.file.read(count) + values = values.strip().replace('\x00', '') + else: + values = '' + else: + values = [] + signed = (field_type in [6, 8, 9, 10]) + for dummy in range(count): + if field_type in (5, 10): + # a ratio + value = Ratio(self.s2n(offset, 4, signed), + self.s2n(offset + 4, 4, signed)) + else: + value = self.s2n(offset, typelen, signed) + values.append(value) + offset = offset + typelen + # now "values" is either a string or an array + if count == 1 and field_type != 2: + printable=str(values[0]) + else: + printable=str(values) + # compute printable version of values + if tag_entry: + if len(tag_entry) != 1: + # optional 2nd tag element is present + if callable(tag_entry[1]): + # call mapping function + printable = tag_entry[1](values) + else: + printable = '' + for i in values: + # use lookup table for this tag + printable += tag_entry[1].get(i, repr(i)) + + self.tags[ifd_name + ' ' + tag_name] = IFD_Tag(printable, tag, + field_type, + values, field_offset, + count * typelen) + if self.debug: + print ' debug: %s: %s' % (tag_name, + repr(self.tags[ifd_name + ' ' + tag_name])) + + if tag_name == stop_tag: + break + + # extract uncompressed TIFF thumbnail (like pulling teeth) + # we take advantage of the pre-existing layout in the thumbnail IFD as + # much as possible + def extract_TIFF_thumbnail(self, thumb_ifd): + entries = self.s2n(thumb_ifd, 2) + # this is header plus offset to IFD ... + if self.endian == 'M': + tiff = 'MM\x00*\x00\x00\x00\x08' + else: + tiff = 'II*\x00\x08\x00\x00\x00' + # ... plus thumbnail IFD data plus a null "next IFD" pointer + self.file.seek(self.offset+thumb_ifd) + tiff += self.file.read(entries*12+2)+'\x00\x00\x00\x00' + + # fix up large value offset pointers into data area + for i in range(entries): + entry = thumb_ifd + 2 + 12 * i + tag = self.s2n(entry, 2) + field_type = self.s2n(entry+2, 2) + typelen = FIELD_TYPES[field_type][0] + count = self.s2n(entry+4, 4) + oldoff = self.s2n(entry+8, 4) + # start of the 4-byte pointer area in entry + ptr = i * 12 + 18 + # remember strip offsets location + if tag == 0x0111: + strip_off = ptr + strip_len = count * typelen + # is it in the data area? + if count * typelen > 4: + # update offset pointer (nasty "strings are immutable" crap) + # should be able to say "tiff[ptr:ptr+4]=newoff" + newoff = len(tiff) + tiff = tiff[:ptr] + self.n2s(newoff, 4) + tiff[ptr+4:] + # remember strip offsets location + if tag == 0x0111: + strip_off = newoff + strip_len = 4 + # get original data and store it + self.file.seek(self.offset + oldoff) + tiff += self.file.read(count * typelen) + + # add pixel strips and update strip offset info + old_offsets = self.tags['Thumbnail StripOffsets'].values + old_counts = self.tags['Thumbnail StripByteCounts'].values + for i in range(len(old_offsets)): + # update offset pointer (more nasty "strings are immutable" crap) + offset = self.n2s(len(tiff), strip_len) + tiff = tiff[:strip_off] + offset + tiff[strip_off + strip_len:] + strip_off += strip_len + # add pixel strip to end + self.file.seek(self.offset + old_offsets[i]) + tiff += self.file.read(old_counts[i]) + + self.tags['TIFFThumbnail'] = tiff + + # decode all the camera-specific MakerNote formats + + # Note is the data that comprises this MakerNote. The MakerNote will + # likely have pointers in it that point to other parts of the file. We'll + # use self.offset as the starting point for most of those pointers, since + # they are relative to the beginning of the file. + # + # If the MakerNote is in a newer format, it may use relative addressing + # within the MakerNote. In that case we'll use relative addresses for the + # pointers. + # + # As an aside: it's not just to be annoying that the manufacturers use + # relative offsets. It's so that if the makernote has to be moved by the + # picture software all of the offsets don't have to be adjusted. Overall, + # this is probably the right strategy for makernotes, though the spec is + # ambiguous. (The spec does not appear to imagine that makernotes would + # follow EXIF format internally. Once they did, it's ambiguous whether + # the offsets should be from the header at the start of all the EXIF info, + # or from the header at the start of the makernote.) + def decode_maker_note(self): + note = self.tags['EXIF MakerNote'] + make = self.tags['Image Make'].printable + # model = self.tags['Image Model'].printable # unused + + # Nikon + # The maker note usually starts with the word Nikon, followed by the + # type of the makernote (1 or 2, as a short). If the word Nikon is + # not at the start of the makernote, it's probably type 2, since some + # cameras work that way. + if make in ('NIKON', 'NIKON CORPORATION'): + if note.values[0:7] == [78, 105, 107, 111, 110, 0, 1]: + if self.debug: + print "Looks like a type 1 Nikon MakerNote." + self.dump_IFD(note.field_offset+8, 'MakerNote', + dict=MAKERNOTE_NIKON_OLDER_TAGS) + elif note.values[0:7] == [78, 105, 107, 111, 110, 0, 2]: + if self.debug: + print "Looks like a labeled type 2 Nikon MakerNote" + if note.values[12:14] != [0, 42] and note.values[12:14] != [42L, 0L]: + raise ValueError("Missing marker tag '42' in MakerNote.") + # skip the Makernote label and the TIFF header + self.dump_IFD(note.field_offset+10+8, 'MakerNote', + dict=MAKERNOTE_NIKON_NEWER_TAGS, relative=1) + else: + # E99x or D1 + if self.debug: + print "Looks like an unlabeled type 2 Nikon MakerNote" + self.dump_IFD(note.field_offset, 'MakerNote', + dict=MAKERNOTE_NIKON_NEWER_TAGS) + return + + # Olympus + if make.startswith('OLYMPUS'): + self.dump_IFD(note.field_offset+8, 'MakerNote', + dict=MAKERNOTE_OLYMPUS_TAGS) + # TODO + #for i in (('MakerNote Tag 0x2020', MAKERNOTE_OLYMPUS_TAG_0x2020),): + # self.decode_olympus_tag(self.tags[i[0]].values, i[1]) + #return + + # Casio + if make == 'Casio': + self.dump_IFD(note.field_offset, 'MakerNote', + dict=MAKERNOTE_CASIO_TAGS) + return + + # Fujifilm + if make == 'FUJIFILM': + # bug: everything else is "Motorola" endian, but the MakerNote + # is "Intel" endian + endian = self.endian + self.endian = 'I' + # bug: IFD offsets are from beginning of MakerNote, not + # beginning of file header + offset = self.offset + self.offset += note.field_offset + # process note with bogus values (note is actually at offset 12) + self.dump_IFD(12, 'MakerNote', dict=MAKERNOTE_FUJIFILM_TAGS) + # reset to correct values + self.endian = endian + self.offset = offset + return + + # Canon + if make == 'Canon': + self.dump_IFD(note.field_offset, 'MakerNote', + dict=MAKERNOTE_CANON_TAGS) + for i in (('MakerNote Tag 0x0001', MAKERNOTE_CANON_TAG_0x001), + ('MakerNote Tag 0x0004', MAKERNOTE_CANON_TAG_0x004)): + self.canon_decode_tag(self.tags[i[0]].values, i[1]) + return + + # decode Olympus MakerNote tag based on offset within tag + def olympus_decode_tag(self, value, dict): + pass + + # decode Canon MakerNote tag based on offset within tag + # see http://www.burren.cx/david/canon.html by David Burren + def canon_decode_tag(self, value, dict): + for i in range(1, len(value)): + x=dict.get(i, ('Unknown', )) + if self.debug: + print i, x + name=x[0] + if len(x) > 1: + val=x[1].get(value[i], 'Unknown') + else: + val=value[i] + # it's not a real IFD Tag but we fake one to make everybody + # happy. this will have a "proprietary" type + self.tags['MakerNote '+name]=IFD_Tag(str(val), None, 0, None, + None, None) + +# process an image file (expects an open file object) +# this is the function that has to deal with all the arbitrary nasty bits +# of the EXIF standard +def process_file(f, stop_tag='UNDEF', details=True, debug=False): + # yah it's cheesy... + global detailed + detailed = details + + # by default do not fake an EXIF beginning + fake_exif = 0 + + # determine whether it's a JPEG or TIFF + data = f.read(12) + if data[0:4] in ['II*\x00', 'MM\x00*']: + # it's a TIFF file + f.seek(0) + endian = f.read(1) + f.read(1) + offset = 0 + elif data[0:2] == '\xFF\xD8': + # it's a JPEG file + while data[2] == '\xFF' and data[6:10] in ('JFIF', 'JFXX', 'OLYM', 'Phot'): + length = ord(data[4])*256+ord(data[5]) + f.read(length-8) + # fake an EXIF beginning of file + data = '\xFF\x00'+f.read(10) + fake_exif = 1 + if data[2] == '\xFF' and data[6:10] == 'Exif': + # detected EXIF header + offset = f.tell() + endian = f.read(1) + else: + # no EXIF information + return {} + else: + # file format not recognized + return {} + + # deal with the EXIF info we found + if debug: + print {'I': 'Intel', 'M': 'Motorola'}[endian], 'format' + hdr = EXIF_header(f, endian, offset, fake_exif, debug) + ifd_list = hdr.list_IFDs() + ctr = 0 + for i in ifd_list: + if ctr == 0: + IFD_name = 'Image' + elif ctr == 1: + IFD_name = 'Thumbnail' + thumb_ifd = i + else: + IFD_name = 'IFD %d' % ctr + if debug: + print ' IFD %d (%s) at offset %d:' % (ctr, IFD_name, i) + hdr.dump_IFD(i, IFD_name, stop_tag=stop_tag) + # EXIF IFD + exif_off = hdr.tags.get(IFD_name+' ExifOffset') + if exif_off: + if debug: + print ' EXIF SubIFD at offset %d:' % exif_off.values[0] + hdr.dump_IFD(exif_off.values[0], 'EXIF', stop_tag=stop_tag) + # Interoperability IFD contained in EXIF IFD + intr_off = hdr.tags.get('EXIF SubIFD InteroperabilityOffset') + if intr_off: + if debug: + print ' EXIF Interoperability SubSubIFD at offset %d:' \ + % intr_off.values[0] + hdr.dump_IFD(intr_off.values[0], 'EXIF Interoperability', + dict=INTR_TAGS, stop_tag=stop_tag) + # GPS IFD + gps_off = hdr.tags.get(IFD_name+' GPSInfo') + if gps_off: + if debug: + print ' GPS SubIFD at offset %d:' % gps_off.values[0] + hdr.dump_IFD(gps_off.values[0], 'GPS', dict=GPS_TAGS, stop_tag=stop_tag) + ctr += 1 + + # extract uncompressed TIFF thumbnail + thumb = hdr.tags.get('Thumbnail Compression') + if thumb and thumb.printable == 'Uncompressed TIFF': + hdr.extract_TIFF_thumbnail(thumb_ifd) + + # JPEG thumbnail (thankfully the JPEG data is stored as a unit) + thumb_off = hdr.tags.get('Thumbnail JPEGInterchangeFormat') + if thumb_off: + f.seek(offset+thumb_off.values[0]) + size = hdr.tags['Thumbnail JPEGInterchangeFormatLength'].values[0] + hdr.tags['JPEGThumbnail'] = f.read(size) + + # deal with MakerNote contained in EXIF IFD + if 'EXIF MakerNote' in hdr.tags and detailed: + hdr.decode_maker_note() + + # Sometimes in a TIFF file, a JPEG thumbnail is hidden in the MakerNote + # since it's not allowed in a uncompressed TIFF IFD + if 'JPEGThumbnail' not in hdr.tags: + thumb_off=hdr.tags.get('MakerNote JPEGThumbnail') + if thumb_off: + f.seek(offset+thumb_off.values[0]) + hdr.tags['JPEGThumbnail']=file.read(thumb_off.field_length) + + return hdr.tags + + +# show command line usage +def usage(exit_status): + msg = 'Usage: EXIF.py [OPTIONS] file1 [file2 ...]\n' + msg += 'Extract EXIF information from digital camera image files.\n\nOptions:\n' + msg += '-q --quick Do not process MakerNotes.\n' + msg += '-t TAG --stop-tag TAG Stop processing when this tag is retrieved.\n' + msg += '-d --debug Run in debug mode.\n' + print msg + sys.exit(exit_status) + +# library test/debug function (dump given files) +if __name__ == '__main__': + import sys + import getopt + + # parse command line options/arguments + try: + opts, args = getopt.getopt(sys.argv[1:], "hqdt:v", ["help", "quick", "debug", "stop-tag="]) + except getopt.GetoptError: + usage(2) + if args == []: + usage(2) + detailed = True + stop_tag = 'UNDEF' + debug = False + for o, a in opts: + if o in ("-h", "--help"): + usage(0) + if o in ("-q", "--quick"): + detailed = False + if o in ("-t", "--stop-tag"): + stop_tag = a + if o in ("-d", "--debug"): + debug = True + + # output info for each file + for filename in args: + try: + file=open(filename, 'rb') + except: + print "'%s' is unreadable\n"%filename + continue + print filename + ':' + # get the tags + data = process_file(file, stop_tag=stop_tag, details=detailed, debug=debug) + if not data: + print 'No EXIF information found' + continue + + x=data.keys() + x.sort() + for i in x: + if i in ('JPEGThumbnail', 'TIFFThumbnail'): + continue + try: + print ' %s (%s): %s' % \ + (i, FIELD_TYPES[data[i].field_type][2], data[i].printable) + except: + print 'error', i, '"', data[i], '"' + if 'JPEGThumbnail' in data: + print 'File has JPEG thumbnail' + print + diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/utils/__init__.py diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/utils/reflection.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/utils/reflection.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,92 @@ +""" Function for generating web 2.0 style image reflection effects. + +Copyright (c) 2007, Justin C. Driscoll +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + 3. Neither the name of reflection.py nor the names of its contributors may be used + to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" + +try: + import Image + import ImageColor +except ImportError: + try: + from PIL import Image + from PIL import ImageColor + except ImportError: + raise ImportError("The Python Imaging Library was not found.") + + +def add_reflection(im, bgcolor="#00000", amount=0.4, opacity=0.6): + """ Returns the supplied PIL Image (im) with a reflection effect + + bgcolor The background color of the reflection gradient + amount The height of the reflection as a percentage of the orignal image + opacity The initial opacity of the reflection gradient + + Originally written for the Photologue image management system for Django + and Based on the original concept by Bernd Schlapsi + + """ + # convert bgcolor string to rgb value + background_color = ImageColor.getrgb(bgcolor) + + # copy orignial image and flip the orientation + reflection = im.copy().transpose(Image.FLIP_TOP_BOTTOM) + + # create a new image filled with the bgcolor the same size + background = Image.new("RGB", im.size, background_color) + + # calculate our alpha mask + start = int(255 - (255 * opacity)) # The start of our gradient + steps = int(255 * amount) # 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(im.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(im.size[1] * amount) + reflection = reflection.crop((0, 0, im.size[0], reflection_height)) + + # create new image sized to hold both the original image and the reflection + composite = Image.new("RGB", (im.size[0], im.size[1]+reflection_height), background_color) + + # paste the orignal image and the reflection into the composite image + composite.paste(im, (0, 0)) + composite.paste(reflection, (0, im.size[1])) + + # return the image complete with reflection effect + return composite diff -r b77683731f25 -r 10b1f6d8a5d2 web/lib/photologue/utils/watermark.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/lib/photologue/utils/watermark.py Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,64 @@ +""" Function for applying watermarks to images. + +Original found here: +http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/362879 + +""" + +try: + import Image + import ImageEnhance +except ImportError: + try: + from PIL import Image + from PIL import ImageEnhance + except ImportError: + raise ImportError("The Python Imaging Library was not found.") + +def reduce_opacity(im, opacity): + """Returns an image with reduced opacity.""" + assert opacity >= 0 and opacity <= 1 + if im.mode != 'RGBA': + im = im.convert('RGBA') + else: + im = im.copy() + alpha = im.split()[3] + alpha = ImageEnhance.Brightness(alpha).enhance(opacity) + im.putalpha(alpha) + return im + +def apply_watermark(im, mark, position, opacity=1): + """Adds a watermark to an image.""" + if opacity < 1: + mark = reduce_opacity(mark, opacity) + if im.mode != 'RGBA': + im = im.convert('RGBA') + # create a transparent layer the size of the image and draw the + # watermark in that layer. + layer = Image.new('RGBA', im.size, (0,0,0,0)) + if position == 'tile': + for y in range(0, im.size[1], mark.size[1]): + for x in range(0, im.size[0], mark.size[0]): + layer.paste(mark, (x, y)) + elif position == 'scale': + # scale, but preserve the aspect ratio + ratio = min( + float(im.size[0]) / mark.size[0], float(im.size[1]) / mark.size[1]) + w = int(mark.size[0] * ratio) + h = int(mark.size[1] * ratio) + mark = mark.resize((w, h)) + layer.paste(mark, ((im.size[0] - w) / 2, (im.size[1] - h) / 2)) + else: + layer.paste(mark, position) + # composite the watermark with the layer + return Image.composite(layer, im, layer) + +def test(): + im = Image.open('test.png') + mark = Image.open('overlay.png') + watermark(im, mark, 'tile', 0.5).show() + watermark(im, mark, 'scale', 1.0).show() + watermark(im, mark, (100, 100), 0.5).show() + +if __name__ == '__main__': + test() diff -r b77683731f25 -r 10b1f6d8a5d2 web/static/css/admin_style.css --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/static/css/admin_style.css Thu Jan 21 18:41:10 2010 +0100 @@ -0,0 +1,84 @@ +#admin_footer +{ +clear: both; +margin: 0; +padding: .5em; +/*background-color: #ddd;*/ +border-top: 1px solid gray; +} + +.version +{ + text-align: right; + color: white; + font-size:9px; +} + +.version a:link +{ + text-decoration: none; + color: white; + text-decoration: bold; + font-weight: bold; + border-bottom-style: none; +} +.version a:hover +{ + color: #2c8084; + text-decoration: none; + font-weight: bold; + border-bottom-width: 0px; + border-bottom-style: none; +} + +.version a:active +{ + color: white; + text-decoration: none; + font-weight: bold; + border-bottom-style: none; +} + +.version a:visited +{ + color: white; + font-weight: bold; + border-bottom-style: none; +} + +.small +{ + font-size:9px; +} + +.footer_img +{ + float: left; +} + +.footer_img img +{ + padding-left: 10px; + padding-right: 10px; +} + +.footer_img a +{ + color: white; +} + +.footer_img a:link +{ + color: white; +} + +.footer_img a:hover +{ + color: white; +} + +.footer_img a:visited +{ + color: white; +} + diff -r b77683731f25 -r 10b1f6d8a5d2 web/static/css/style.css --- a/web/static/css/style.css Wed Jan 20 12:41:13 2010 +0100 +++ b/web/static/css/style.css Thu Jan 21 18:41:10 2010 +0100 @@ -164,6 +164,15 @@ border-top: 1px solid gray; } +#admin_footer +{ +clear: both; +margin: 0; +padding: .5em; +/*background-color: #ddd;*/ +border-top: 1px solid gray; +} + .version { text-align: right;