From: Benoît Pin Date: Fri, 22 Oct 2010 16:12:20 +0000 (+0200) Subject: Ajout du produit, sur la base du dépôt luxia r1390 : X-Git-Url: https://svn.cri.ensmp.fr/git/Photo.git/commitdiff_plain/a18ca54d896fa2f98cf2b7fb8955120ff5e0aa37 Ajout du produit, sur la base du dépôt luxia r1390 : URL : http://svn.luxia.fr/svn/labo/projects/zope/Photo/trunk Racine du dépôt : http://svn.luxia.fr/svn/labo UUID du dépôt : 7eb47c9a-6e02-46bb-968b-2b2bf1974b8d Révision : 1390 Type de nœud : répertoire Tâche programmée : normale Auteur de la dernière modification : pin Révision de la dernière modification : 1371 Date de la dernière modification: 2009-09-10 19:58:28 +0200 (Jeu 10 sep 2009) --- a18ca54d896fa2f98cf2b7fb8955120ff5e0aa37 diff --git a/Photo.py b/Photo.py new file mode 100755 index 0000000..2161eaf --- /dev/null +++ b/Photo.py @@ -0,0 +1,400 @@ +# -*- coding: utf-8 -*- +####################################################################################### +# Photo is a part of Plinn - http://plinn.org # +# Copyright (C) 2004-2007 Benoît PIN # +# # +# This program is free software; you can redistribute it and/or # +# modify it under the terms of the GNU General Public License # +# as published by the Free Software Foundation; either version 2 # +# of the License, or (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program; if not, write to the Free Software # +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # +####################################################################################### +""" Photo zope object + +$Id: Photo.py 1281 2009-08-13 10:44:40Z pin $ +$URL: http://svn.luxia.fr/svn/labo/projects/zope/Photo/trunk/Photo.py $ +""" + +from Globals import InitializeClass, DTMLFile +from AccessControl import ClassSecurityInfo +from AccessControl.Permissions import manage_properties, view +from metadata import Metadata +from TileSupport import TileSupport +from xmputils import TIFF_ORIENTATIONS +from BTrees.OOBTree import OOBTree +from cache import memoizedmethod + +from blobbases import Image, cookId, getImageInfo +import PIL.Image +import string +from math import floor +from types import StringType +from logging import getLogger +console = getLogger('Photo.Photo') + + + +def _strSize(size) : + return str(size[0]) + '_' + str(size[1]) + +def getNewSize(fullSize, maxNewSize) : + fullWidth, fullHeight = fullSize + maxWidth, maxHeight = maxNewSize + + widthRatio = float(maxWidth) / fullWidth + if int(fullHeight * widthRatio) > maxWidth : + heightRatio = float(maxHeight) / fullHeight + return (int(fullWidth * heightRatio) , maxHeight) + else : + return (maxWidth, int(fullHeight * widthRatio)) + + + + + + +class Photo(Image, TileSupport, Metadata): + "Photo éditable en ligne" + + meta_type = 'Photo' + + security = ClassSecurityInfo() + + manage_editForm = DTMLFile('dtml/photoEdit',globals(), + Kind='Photo', kind='photo') + manage_editForm._setName('manage_editForm') + manage = manage_main = manage_editForm + view_image_or_file = DTMLFile('dtml/photoView',globals()) + + manage_options=( + {'label':'Edit', 'action':'manage_main', + 'help':('OFSP','Image_Edit.stx')}, + {'label':'View', 'action':'view_image_or_file', + 'help':('OFSP','Image_View.stx')},) + Image.manage_options[2:] + + + filters = ['NEAREST', 'BILINEAR', 'BICUBIC', 'ANTIALIAS'] + + _properties = Image._properties[:2] + ( + {'id' : 'height', 'type' : 'int', 'mode' : 'w'}, + {'id' : 'width', 'type' : 'int', 'mode' : 'w'}, + {'id' : 'auto_update_thumb', 'type' : 'boolean', 'mode' : 'w'}, + {'id' : 'tiles_available', 'type' : 'int', 'mode' : 'r'}, + {'id' : 'thumb_height', 'type' : 'int', 'mode' : 'w'}, + {'id' : 'thumb_width', 'type' : 'int', 'mode' : 'w'}, + {'id' : 'prop_filter', + 'label' : 'Filter', + 'type' : 'selection', + 'select_variable' : 'filters', + 'mode' : 'w'}, + ) + + + security.declareProtected(manage_properties, 'manage_editProperties') + def manage_editProperties(self, REQUEST=None, no_refresh = 0, **kw): + "Save Changes and update the thumbnail" + Image.manage_changeProperties(self, REQUEST, **kw) + + if hasattr(self, 'thumbnail') and self.auto_update_thumb == 1 and no_refresh == 0 : + self.makeThumbnail() + + if REQUEST: + message="Saved changes." + return self.manage_propertiesForm(self,REQUEST, + manage_tabs_message=message) + + + def __init__(self, id, title, file, content_type='', precondition='', **kw) : + # 0 means: tiles are not generated + # 1 means: tiles are all generated + # 2 means: tiling is not available is this photo (deliberated choice of the owner) + # -1 means: no data tiles cannot be generated + self.tiles_available = 0 + super(Photo, self).__init__(id, title, file, content_type='', precondition='') + + self.auto_update_thumb = kw.get('auto_update_thumb', 1) + self.thumb_height = kw.get('thumb_height', 180) + self.thumb_width = kw.get('thumb_width', 120) + self.prop_filter = kw.get('prop_filter', 'ANTIALIAS') + + defaultBlankThumbnail = kw.get('defaultBlankThumbnail', None) + if defaultBlankThumbnail : + blankThumbnail = Image('thumbnail', '', + getattr(defaultBlankThumbnail, '_data', getattr(defaultBlankThumbnail, 'data', None))) + self.thumbnail = blankThumbnail + + self._methodResultsCache = OOBTree() + TileSupport.__init__(self) + + def update_data(self, file, content_type=None) : + super(Photo, self).update_data(file, content_type) + + if self.content_type != 'image/jpeg' and self.size : + raw = self.open('r') + im = PIL.Image.open(raw) + self.content_type = 'image/%s' % im.format.lower() + self.width, self.height = im.size + + if im.mode not in ('L', 'RGB'): + im = im.convert('RGB') + + jpeg_image = Image('jpeg_image', '', '', content_type='image/jpeg') + out = jpeg_image.open('w') + im.save(out, 'JPEG', quality=90) + jpeg_image.updateFormat(out.tell(), im.size, 'image/jpeg') + out.close() + self.jpeg_image = jpeg_image + + self._methodResultsCache = OOBTree() + self._v__methodResultsCache = OOBTree() + + self._tiles = OOBTree() + if self.tiles_available in [1, -1]: + self.tiles_available = 0 + + if hasattr(self, 'thumbnail') and self.auto_update_thumb == 1 : + self.makeThumbnail() + + + + def _getJpegBlob(self) : + if self.size : + if self.content_type == 'image/jpeg' : + return self.bdata + else : + return self.jpeg_image.bdata + else : + return None + + security.declareProtected(view, 'getJpegImage') + def getJpegImage(self, REQUEST, RESPONSE) : + """ return JPEG formated image """ + if self.content_type == 'image/jpeg' : + return self.index_html(REQUEST, RESPONSE) + elif self.jpeg_image : + return self.jpeg_image.index_html(REQUEST, RESPONSE) + + security.declareProtected(view, 'tiffOrientation') + @memoizedmethod() + def tiffOrientation(self) : + tiffOrientation = self.getXmpValue('tiff:Orientation') + if tiffOrientation : + return int(tiffOrientation) + else : + # TODO : falling back to legacy Exif metadata + return 1 + + def _rotateOrFlip(self, im) : + orientation = self.tiffOrientation() + rotation, flip = TIFF_ORIENTATIONS[orientation] + if rotation : + im = im.rotate(-rotation) + if flip : + im = im.transpose(PIL.Image.FLIP_LEFT_RIGHT) + return im + + @memoizedmethod('size', 'keepAspectRatio') + def _getResizedImage(self, size, keepAspectRatio) : + """ returns a resized version of the raw image. + """ + + fullSizeFile = self._getJpegBlob().open('r') + fullSizeImage = PIL.Image.open(fullSizeFile) + if fullSizeImage.mode not in ('L', 'RGB'): + fullSizeImage.convert('RGB') + fullSize = fullSizeImage.size + + if (keepAspectRatio) : + newSize = getNewSize(fullSize, size) + else : + newSize = size + + fullSizeImage.thumbnail(newSize, PIL.Image.ANTIALIAS) + fullSizeImage = self._rotateOrFlip(fullSizeImage) + + for hook in self._getAfterResizingHooks() : + hook(self, fullSizeImage) + + + resizedImage = Image(self.getId() + _strSize(size), 'resized copy of %s' % self.getId(), '') + out = resizedImage.open('w') + fullSizeImage.save(out, "JPEG", quality=90) + resizedImage.updateFormat(out.tell(), fullSizeImage.size, 'image/jpeg') + out.close() + return resizedImage + + def _getAfterResizingHooks(self) : + """ returns a list of hook scripts that are executed + after the image is resized. + """ + return [] + + + security.declarePrivate('makeThumbnail') + def makeThumbnail(self) : + "make a thumbnail from jpeg data" + b = self._getJpegBlob() + if b is not None : + # récupération des propriétés de redimentionnement + thumb_size = [] + if int(self.width) >= int(self.height) : + thumb_size.append(self.thumb_height) + thumb_size.append(self.thumb_width) + else : + thumb_size.append(self.thumb_width) + thumb_size.append(self.thumb_height) + thumb_size = tuple(thumb_size) + + if thumb_size[0] <= 1 or thumb_size[1] <= 1 : + thumb_size = (180, 180) + thumb_filter = getattr(PIL.Image, self.prop_filter, PIL.Image.ANTIALIAS) + + # create a thumbnail image file + original_file = b.open('r') + image = PIL.Image.open(original_file) + if image.mode not in ('L', 'RGB'): + image = image.convert('RGB') + + image.thumbnail(thumb_size, thumb_filter) + image = self._rotateOrFlip(image) + + thumbnail = Image('thumbnail', 'Thumbail', '', 'image/jpeg') + out = thumbnail.open('w') + image.save(out, "JPEG", quality=90) + thumbnail.updateFormat(out.tell(), image.size, 'image/jpeg') + out.close() + original_file.close() + self.thumbnail = thumbnail + return True + else : + return False + + security.declareProtected(view, 'getThumbnail') + def getThumbnail(self, REQUEST, RESPONSE) : + "Return the thumbnail image and create it before if it does not exist yet." + if not hasattr(self, 'thumbnail') : + self.makeThumbnail() + return self.thumbnail.index_html(REQUEST=REQUEST, RESPONSE=RESPONSE) + + security.declareProtected(view, 'getThumbnailSize') + def getThumbnailSize(self) : + """ return thumbnail size dict + """ + if not hasattr(self, 'thumbnail') : + if not self.width : + return {'height' : 0, 'width' : 0} + else : + thumbMaxFrame = [] + if int(self.width) >= int(self.height) : + thumbMaxFrame.append(self.thumb_height) + thumbMaxFrame.append(self.thumb_width) + else : + thumbMaxFrame.append(self.thumb_width) + thumbMaxFrame.append(self.thumb_height) + thumbMaxFrame = tuple(thumbMaxFrame) + + if thumbMaxFrame[0] <= 1 or thumbMaxFrame[1] <= 1 : + thumbMaxFrame = (180, 180) + + th = self.height * thumbMaxFrame[0] / float(self.width) + # resizing round limit is not 0.5 but seems to be strictly up to 0.75 + # TODO check algorithms + if th > floor(th) + 0.75 : + th = int(floor(th)) + 1 + else : + th = int(floor(th)) + + if th <= thumbMaxFrame[1] : + thumbSize = (thumbMaxFrame[0], th) + else : + tw = self.width * thumbMaxFrame[1] / float(self.height) + if tw > floor(tw) + 0.75 : + tw = int(floor(tw)) + 1 + else : + tw = int(floor(tw)) + thumbSize = (tw, thumbMaxFrame[1]) + + if self.tiffOrientation() <= 4 : + return {'width':thumbSize[0], 'height' : thumbSize[1]} + else : + return {'width':thumbSize[1], 'height' : thumbSize[0]} + + else : + return {'height' : self.thumbnail.height, 'width' :self.thumbnail.width} + + + security.declareProtected(view, 'getResizedImageSize') + def getResizedImageSize(self, REQUEST=None, size=(), keepAspectRatio=True, asXml=False) : + """ return the reel image size the after resizing """ + if not size : + size = REQUEST.SESSION.get('preferedImageSize', (600, 600)) + elif type(size) == StringType : + size = tuple([int(n) for n in size.split('_')]) + + resizedImage = self._getResizedImage(size, keepAspectRatio) + size = (resizedImage.width, resizedImage.height) + + if asXml : + REQUEST.RESPONSE.setHeader('content-type', 'text/xml; charset=utf-8') + return '%d%d' % size + else : + return size + + + security.declareProtected(view, 'getResizedImage') + def getResizedImage(self, REQUEST, RESPONSE, size=(), keepAspectRatio=True) : + """ + Return a volatile resized image. + The 'preferedImageSize' tuple (width, height) is looked up into SESSION data. + Default size is 600 x 600 px + """ + if not size : + size = REQUEST.SESSION.get('preferedImageSize', (600, 600)) + elif type(size) == StringType : + size = size.split('_') + if len(size) == 1 : + i = int(size[0]) + size = (i, i) + keepAspectRatio = True + else : + size = tuple([int(n) for n in size]) + + return self._getResizedImage(size, keepAspectRatio).index_html(REQUEST=REQUEST, RESPONSE=RESPONSE) + + +InitializeClass(Photo) + + +# Factories +def addPhoto(dispatcher, id, file='', title='', + precondition='', content_type='', REQUEST=None, **kw) : + """ + Add a new Photo object. + Creates a new Photo object 'id' with the contents of 'file'. + """ + id=str(id) + title=str(title) + content_type=str(content_type) + precondition=str(precondition) + + id, title = cookId(id, title, file) + parentContainer = dispatcher.Destination() + + parentContainer._setObject(id, Photo(id,title,file,content_type, precondition, **kw)) + + if REQUEST is not None: + try: url=dispatcher.DestinationURL() + except: url=REQUEST['URL1'] + REQUEST.RESPONSE.redirect('%s/manage_main' % url) + return id + +# creation form +addPhotoForm = DTMLFile('dtml/addPhotoForm', globals()) diff --git a/TODO.txt b/TODO.txt new file mode 100644 index 0000000..3795e3a --- /dev/null +++ b/TODO.txt @@ -0,0 +1,8 @@ +Migration vers nouvelle version + +Attributs à virer : +— _variants +— _metadata + +Attributs à ajouter +— self._methodResultsCache = OOBTree() \ No newline at end of file diff --git a/TileSupport.py b/TileSupport.py new file mode 100644 index 0000000..905b0f4 --- /dev/null +++ b/TileSupport.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +####################################################################################### +# Photo is a part of Plinn - http://plinn.org # +# Copyright (C) 2004-2007 Benoît PIN # +# # +# This program is free software; you can redistribute it and/or # +# modify it under the terms of the GNU General Public License # +# as published by the Free Software Foundation; either version 2 # +# of the License, or (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program; if not, write to the Free Software # +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # +####################################################################################### +""" Tile support module + +$Id: TileSupport.py 1371 2009-09-10 17:58:28Z pin $ +$URL: http://svn.luxia.fr/svn/labo/projects/zope/Photo/trunk/TileSupport.py $ +""" + +from AccessControl import ClassSecurityInfo +from AccessControl import Unauthorized +from AccessControl import getSecurityManager +from AccessControl.Permissions import view, change_images_and_files +from PIL import Image as PILImage +from math import ceil +from blobbases import Image +from xmputils import TIFF_ORIENTATIONS +from cache import memoizedmethod +from BTrees.OOBTree import OOBTree +from BTrees.IOBTree import IOBTree +from ppm import PPMFile +from threading import Lock +from subprocess import Popen, PIPE +from tempfile import TemporaryFile + +JPEG_ROTATE = 'jpegtran -rotate %d' +JPEG_FLIP = 'jpegtran -flip horizontal' + +def runOnce(lock): + """ Decorator. exit if already running """ + + def wrapper(f): + def method(*args, **kw): + if not lock.locked() : + lock.acquire() + try: + return f(*args, **kw) + finally: + lock.release() + else : + return False + return method + return wrapper + + + +class TileSupport : + """ Mixin class to generate tiles from image """ + + security = ClassSecurityInfo() + tileSize = 256 + tileGenerationLock = Lock() + + def __init__(self) : + self._tiles = OOBTree() + + security.declarePrivate('makeTilesAt') + @runOnce(tileGenerationLock) + def makeTilesAt(self, zoom): + """generates tiles at zoom level""" + + if self._tiles.has_key(zoom) : + return True + + assert zoom <= 1, "zoom arg must be <= 1 found: %s" % zoom + + ppm = self._getPPM() + if zoom < 1 : + ppm = ppm.resize(ratio=zoom) + + self._makeTilesAt(zoom, ppm) + return True + + def _getPPM(self) : + bf = self._getJpegBlob() + f = bf.open('r') + + orientation = self.tiffOrientation() + rotation, flip = TIFF_ORIENTATIONS[orientation] + + if rotation and flip : + tf = TemporaryFile(mode='w+') + pRot = Popen(JPEG_ROTATE % rotation + , stdin=f + , stdout=PIPE + , shell=True) + pFlip = Popen(JPEG_FLIP + , stdin=pRot.stdout + , stdout=tf + , shell=True) + pFlip.wait() + f.close() + tf.seek(0) + f = tf + + elif rotation : + tf = TemporaryFile(mode='w+') + pRot = Popen(JPEG_ROTATE % rotation + , stdin=f + , stdout=tf + , shell=True) + pRot.wait() + f.close() + tf.seek(0) + f = tf + + elif flip : + tf = TemporaryFile(mode='w+') + pFlip = Popen(JPEG_FLIP + , stdin=f + , stdout=tf + , shell=True) + pFlip.wait() + f.close() + tf.seek(0) + f = tf + + ppm = PPMFile(f, tileSize=self.tileSize) + f.close() + return ppm + + def _makeTilesAt(self, zoom, ppm): + hooks = self._getAfterTilingHooks() + self._tiles[zoom] = IOBTree() + bgColor = getattr(self, 'tiles_background_color', '#fff') + + for x in xrange(ppm.tilesX) : + self._tiles[zoom][x] = IOBTree() + for y in xrange(ppm.tilesY) : + tile = ppm.getTile(x, y) + for hook in hooks : + hook(self, tile) + + # fill with solid color + if min(tile.size) < self.tileSize : + blankTile = PILImage.new('RGB', (self.tileSize, self.tileSize), bgColor) + box = (0,0) + tile.size + blankTile.paste(tile, box) + tile = blankTile + + zImg = Image('tile', 'tile', '', content_type='image/jpeg') + out = zImg.open('w') + tile.save(out, 'JPEG', quality=90) + zImg.updateFormat(out.tell(), tile.size, 'image/jpeg') + out.close() + + self._tiles[zoom][x][y] = zImg + + def _getAfterTilingHooks(self) : + return [] + + + security.declareProtected(view, 'getAvailableZooms') + def getAvailableZooms(self): + zooms = list(self._tiles.keys()) + zooms.sort() + return zooms + + security.declareProtected(view, 'getTile') + def getTile(self, REQUEST, RESPONSE, zoom=1, x=0, y=0): + """ publishes tile + """ + zoom, x, y = float(zoom), int(x), int(y) + if not self._tiles.has_key(zoom) : + sm = getSecurityManager() + if not sm.checkPermission(change_images_and_files, self) : + raise Unauthorized("Tiling arbitrary zoom unauthorized") + if self.makeTilesAt(zoom) : + tile = self._tiles[zoom][x][y] + return tile.index_html(REQUEST=REQUEST, RESPONSE=RESPONSE) + else : + tile = self._tiles[zoom][x][y] + return tile.index_html(REQUEST=REQUEST, RESPONSE=RESPONSE) + diff --git a/__init__.py b/__init__.py new file mode 100755 index 0000000..2b1e375 --- /dev/null +++ b/__init__.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +####################################################################################### +# Photo is a part of Plinn - http://plinn.org # +# Copyright (C) 2004-2008 Beno”t PIN # +# # +# This program is free software; you can redistribute it and/or # +# modify it under the terms of the GNU General Public License # +# as published by the Free Software Foundation; either version 2 # +# of the License, or (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program; if not, write to the Free Software # +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # +####################################################################################### +""" Product for photos manipulation. + main features: + - keep origninal uploaded data + - automatic convertion to jpeg (if needed) from supported PIL image's formats + - resizing support + - metadata extraction + + experimental features: + - full psd support with Adobe photoshop (windows only, drived by pythoncom) + - tile support (to display a grid of tiles with a javascript web interface) + +$Id: __init__.py 949 2009-04-30 14:42:24Z pin $ +$URL: http://svn.luxia.fr/svn/labo/projects/zope/Photo/trunk/__init__.py $ +""" + +from AccessControl.Permissions import add_documents_images_and_files +import xmp_jpeg + +from Photo import Photo, addPhotoForm, addPhoto +import blobbases + +def initialize(registrar) : + registrar.registerClass( + Photo, + constructors = (addPhotoForm, addPhoto), + icon = 'dtml/photo_icon.gif' + ) + + registrar.registerClass( + blobbases.File, + permission = add_documents_images_and_files, + constructors = (('blobFileAdd', blobbases.manage_addFileForm), blobbases.manage_addFile), + icon='dtml/File_icon.gif' + ) + + registrar.registerClass( + blobbases.Image, + permission = add_documents_images_and_files, + constructors = (('blobImageAdd', blobbases.manage_addImageForm), blobbases.manage_addImage), + icon='dtml/Image_icon.gif' + ) diff --git a/blobbases.py b/blobbases.py new file mode 100755 index 0000000..9d2fb6f --- /dev/null +++ b/blobbases.py @@ -0,0 +1,812 @@ +# -*- coding: utf-8 -*- +############################################################################## +# This module is based on OFS.Image originaly copyrighted as: +# +# Copyright (c) 2002 Zope Corporation and Contributors. All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE +# +############################################################################## +"""Image object + +$Id: blobbases.py 949 2009-04-30 14:42:24Z pin $ +$URL: http://svn.luxia.fr/svn/labo/projects/zope/Photo/trunk/blobbases.py $ +""" + +import struct +from warnings import warn +from zope.contenttype import guess_content_type +from Globals import DTMLFile +from Globals import InitializeClass +from OFS.PropertyManager import PropertyManager +from AccessControl import ClassSecurityInfo +from AccessControl.Role import RoleManager +from AccessControl.Permissions import change_images_and_files +from AccessControl.Permissions import view_management_screens +from AccessControl.Permissions import view as View +from AccessControl.Permissions import ftp_access +from AccessControl.Permissions import delete_objects +from webdav.common import rfc1123_date +from webdav.Lockable import ResourceLockedError +from webdav.WriteLockInterface import WriteLockInterface +from OFS.SimpleItem import Item_w__name__ +from cStringIO import StringIO +from Globals import Persistent +from Acquisition import Implicit +from DateTime import DateTime +from OFS.Cache import Cacheable +from mimetools import choose_boundary +from ZPublisher import HTTPRangeSupport +from ZPublisher.HTTPRequest import FileUpload +from ZPublisher.Iterators import filestream_iterator +from zExceptions import Redirect +from cgi import escape +import transaction +from ZODB.blob import Blob + +CHUNK_SIZE = 1 << 16 + +manage_addFileForm=DTMLFile('dtml/imageAdd', globals(),Kind='File',kind='file') +def manage_addFile(self,id,file='',title='',precondition='', content_type='', + REQUEST=None): + """Add a new File object. + + Creates a new File object 'id' with the contents of 'file'""" + + id=str(id) + title=str(title) + content_type=str(content_type) + precondition=str(precondition) + + id, title = cookId(id, title, file) + + self=self.this() + self._setObject(id, File(id,title,file,content_type, precondition)) + + if REQUEST is not None: + REQUEST['RESPONSE'].redirect(self.absolute_url()+'/manage_main') + + +class File(Persistent, Implicit, PropertyManager, + RoleManager, Item_w__name__, Cacheable): + """A File object is a content object for arbitrary files.""" + + __implements__ = (WriteLockInterface, HTTPRangeSupport.HTTPRangeInterface) + meta_type='Blob File' + + security = ClassSecurityInfo() + security.declareObjectProtected(View) + + precondition='' + size=None + + manage_editForm =DTMLFile('dtml/fileEdit',globals(), + Kind='File',kind='file') + manage_editForm._setName('manage_editForm') + + security.declareProtected(view_management_screens, 'manage') + security.declareProtected(view_management_screens, 'manage_main') + manage=manage_main=manage_editForm + manage_uploadForm=manage_editForm + + manage_options=( + ( + {'label':'Edit', 'action':'manage_main', + 'help':('OFSP','File_Edit.stx')}, + {'label':'View', 'action':'', + 'help':('OFSP','File_View.stx')}, + ) + + PropertyManager.manage_options + + RoleManager.manage_options + + Item_w__name__.manage_options + + Cacheable.manage_options + ) + + _properties=({'id':'title', 'type': 'string'}, + {'id':'content_type', 'type':'string'}, + ) + + def __init__(self, id, title, file, content_type='', precondition=''): + self.__name__=id + self.title=title + self.precondition=precondition + self.uploaded_filename = cookId('', '', file)[0] + self.bdata = Blob() + + content_type=self._get_content_type(file, id, content_type) + self.update_data(file, content_type) + + security.declarePrivate('save') + def save(self, file): + bf = self.bdata.open('w') + bf.write(file.read()) + self.size = bf.tell() + bf.close() + + security.declarePrivate('open') + def open(self, mode='r'): + bf = self.bdata.open(mode) + return bf + + security.declarePrivate('updateSize') + def updateSize(self, size=None): + if size is None : + bf = self.open('r') + bf.seek(0,2) + self.size = bf.tell() + bf.close() + else : + self.size = size + + def _getLegacyData(self) : + warn("Accessing 'data' attribute may be inefficient with " + "this blob based file. You should refactor your product " + "by accessing data like: " + "f = self.open('r') " + "data = f.read()", + DeprecationWarning, stacklevel=2) + f = self.open() + data = f.read() + f.close() + return data + + def _setLegacyData(self, data) : + warn("Accessing 'data' attribute may be inefficient with " + "this blob based file. You should refactor your product " + "by accessing data like: " + "f = self.save(data)", + DeprecationWarning, stacklevel=2) + if isinstance(data, str) : + sio = StringIO() + sio.write(data) + sio.seek(0) + data = sio + self.save(data) + + data = property(_getLegacyData, _setLegacyData, + "Data Legacy attribute to ensure compatibility " + "with derived classes that access data by this way.") + + def id(self): + return self.__name__ + + def _if_modified_since_request_handler(self, REQUEST, RESPONSE): + # HTTP If-Modified-Since header handling: return True if + # we can handle this request by returning a 304 response + header=REQUEST.get_header('If-Modified-Since', None) + if header is not None: + header=header.split( ';')[0] + # Some proxies seem to send invalid date strings for this + # header. If the date string is not valid, we ignore it + # rather than raise an error to be generally consistent + # with common servers such as Apache (which can usually + # understand the screwy date string as a lucky side effect + # of the way they parse it). + # This happens to be what RFC2616 tells us to do in the face of an + # invalid date. + try: mod_since=long(DateTime(header).timeTime()) + except: mod_since=None + if mod_since is not None: + if self._p_mtime: + last_mod = long(self._p_mtime) + else: + last_mod = long(0) + if last_mod > 0 and last_mod <= mod_since: + RESPONSE.setHeader('Last-Modified', + rfc1123_date(self._p_mtime)) + RESPONSE.setHeader('Content-Type', self.content_type) + RESPONSE.setHeader('Accept-Ranges', 'bytes') + RESPONSE.setStatus(304) + return True + + def _range_request_handler(self, REQUEST, RESPONSE): + # HTTP Range header handling: return True if we've served a range + # chunk out of our data. + range = REQUEST.get_header('Range', None) + request_range = REQUEST.get_header('Request-Range', None) + if request_range is not None: + # Netscape 2 through 4 and MSIE 3 implement a draft version + # Later on, we need to serve a different mime-type as well. + range = request_range + if_range = REQUEST.get_header('If-Range', None) + if range is not None: + ranges = HTTPRangeSupport.parseRange(range) + + if if_range is not None: + # Only send ranges if the data isn't modified, otherwise send + # the whole object. Support both ETags and Last-Modified dates! + if len(if_range) > 1 and if_range[:2] == 'ts': + # ETag: + if if_range != self.http__etag(): + # Modified, so send a normal response. We delete + # the ranges, which causes us to skip to the 200 + # response. + ranges = None + else: + # Date + date = if_range.split( ';')[0] + try: mod_since=long(DateTime(date).timeTime()) + except: mod_since=None + if mod_since is not None: + if self._p_mtime: + last_mod = long(self._p_mtime) + else: + last_mod = long(0) + if last_mod > mod_since: + # Modified, so send a normal response. We delete + # the ranges, which causes us to skip to the 200 + # response. + ranges = None + + if ranges: + # Search for satisfiable ranges. + satisfiable = 0 + for start, end in ranges: + if start < self.size: + satisfiable = 1 + break + + if not satisfiable: + RESPONSE.setHeader('Content-Range', + 'bytes */%d' % self.size) + RESPONSE.setHeader('Accept-Ranges', 'bytes') + RESPONSE.setHeader('Last-Modified', + rfc1123_date(self._p_mtime)) + RESPONSE.setHeader('Content-Type', self.content_type) + RESPONSE.setHeader('Content-Length', self.size) + RESPONSE.setStatus(416) + return True + + ranges = HTTPRangeSupport.expandRanges(ranges, self.size) + + if len(ranges) == 1: + # Easy case, set extra header and return partial set. + start, end = ranges[0] + size = end - start + + RESPONSE.setHeader('Last-Modified', + rfc1123_date(self._p_mtime)) + RESPONSE.setHeader('Content-Type', self.content_type) + RESPONSE.setHeader('Content-Length', size) + RESPONSE.setHeader('Accept-Ranges', 'bytes') + RESPONSE.setHeader('Content-Range', + 'bytes %d-%d/%d' % (start, end - 1, self.size)) + RESPONSE.setStatus(206) # Partial content + + bf = self.open('r') + bf.seek(start) + RESPONSE.write(bf.read(size)) + bf.close() + return True + + else: + boundary = choose_boundary() + + # Calculate the content length + size = (8 + len(boundary) + # End marker length + len(ranges) * ( # Constant lenght per set + 49 + len(boundary) + len(self.content_type) + + len('%d' % self.size))) + for start, end in ranges: + # Variable length per set + size = (size + len('%d%d' % (start, end - 1)) + + end - start) + + + # Some clients implement an earlier draft of the spec, they + # will only accept x-byteranges. + draftprefix = (request_range is not None) and 'x-' or '' + + RESPONSE.setHeader('Content-Length', size) + RESPONSE.setHeader('Accept-Ranges', 'bytes') + RESPONSE.setHeader('Last-Modified', + rfc1123_date(self._p_mtime)) + RESPONSE.setHeader('Content-Type', + 'multipart/%sbyteranges; boundary=%s' % ( + draftprefix, boundary)) + RESPONSE.setStatus(206) # Partial content + + bf = self.open('r') + + for start, end in ranges: + RESPONSE.write('\r\n--%s\r\n' % boundary) + RESPONSE.write('Content-Type: %s\r\n' % + self.content_type) + RESPONSE.write( + 'Content-Range: bytes %d-%d/%d\r\n\r\n' % ( + start, end - 1, self.size)) + + + size = end - start + bf.seek(start) + RESPONSE.write(bf.read(size)) + + bf.close() + + RESPONSE.write('\r\n--%s--\r\n' % boundary) + return True + + security.declareProtected(View, 'index_html') + def index_html(self, REQUEST, RESPONSE): + """ + The default view of the contents of a File or Image. + + Returns the contents of the file or image. Also, sets the + Content-Type HTTP header to the objects content type. + """ + + if self._if_modified_since_request_handler(REQUEST, RESPONSE): + # we were able to handle this by returning a 304 + # unfortunately, because the HTTP cache manager uses the cache + # API, and because 304 responses are required to carry the Expires + # header for HTTP/1.1, we need to call ZCacheable_set here. + # This is nonsensical for caches other than the HTTP cache manager + # unfortunately. + self.ZCacheable_set(None) + return '' + + if self.precondition and hasattr(self, str(self.precondition)): + # Grab whatever precondition was defined and then + # execute it. The precondition will raise an exception + # if something violates its terms. + c=getattr(self, str(self.precondition)) + if hasattr(c,'isDocTemp') and c.isDocTemp: + c(REQUEST['PARENTS'][1],REQUEST) + else: + c() + + if self._range_request_handler(REQUEST, RESPONSE): + # we served a chunk of content in response to a range request. + return '' + + RESPONSE.setHeader('Last-Modified', rfc1123_date(self._p_mtime)) + RESPONSE.setHeader('Content-Type', self.content_type) + RESPONSE.setHeader('Content-Length', self.size) + RESPONSE.setHeader('Accept-Ranges', 'bytes') + + if self.ZCacheable_isCachingEnabled(): + result = self.ZCacheable_get(default=None) + if result is not None: + # We will always get None from RAMCacheManager and HTTP + # Accelerated Cache Manager but we will get + # something implementing the IStreamIterator interface + # from a "FileCacheManager" + return result + + self.ZCacheable_set(None) + + bf = self.open('r') + chunk = bf.read(CHUNK_SIZE) + while chunk : + RESPONSE.write(chunk) + chunk = bf.read(CHUNK_SIZE) + bf.close() + return '' + + security.declareProtected(View, 'view_image_or_file') + def view_image_or_file(self, URL1): + """ + The default view of the contents of the File or Image. + """ + raise Redirect, URL1 + + security.declareProtected(View, 'PrincipiaSearchSource') + def PrincipiaSearchSource(self): + """ Allow file objects to be searched. + """ + if self.content_type.startswith('text/'): + bf = self.open('r') + data = bf.read() + bf.close() + return data + return '' + + security.declarePrivate('update_data') + def update_data(self, file, content_type=None): + if isinstance(file, unicode): + raise TypeError('Data can only be str or file-like. ' + 'Unicode objects are expressly forbidden.') + elif isinstance(file, str) : + sio = StringIO() + sio.write(file) + sio.seek(0) + file = sio + + if content_type is not None: self.content_type=content_type + self.save(file) + self.ZCacheable_invalidate() + self.ZCacheable_set(None) + self.http__refreshEtag() + + security.declareProtected(change_images_and_files, 'manage_edit') + def manage_edit(self, title, content_type, precondition='', + filedata=None, REQUEST=None): + """ + Changes the title and content type attributes of the File or Image. + """ + if self.wl_isLocked(): + raise ResourceLockedError, "File is locked via WebDAV" + + self.title=str(title) + self.content_type=str(content_type) + if precondition: self.precondition=str(precondition) + elif self.precondition: del self.precondition + if filedata is not None: + self.update_data(filedata, content_type) + else: + self.ZCacheable_invalidate() + if REQUEST: + message="Saved changes." + return self.manage_main(self,REQUEST,manage_tabs_message=message) + + security.declareProtected(change_images_and_files, 'manage_upload') + def manage_upload(self,file='',REQUEST=None): + """ + Replaces the current contents of the File or Image object with file. + + The file or images contents are replaced with the contents of 'file'. + """ + if self.wl_isLocked(): + raise ResourceLockedError, "File is locked via WebDAV" + + content_type=self._get_content_type(file, self.__name__, + 'application/octet-stream') + self.update_data(file, content_type) + + if REQUEST: + message="Saved changes." + return self.manage_main(self,REQUEST,manage_tabs_message=message) + + def _get_content_type(self, file, id, content_type=None): + headers=getattr(file, 'headers', None) + if headers and headers.has_key('content-type'): + content_type=headers['content-type'] + else: + name = getattr(file, 'filename', self.uploaded_filename) or id + content_type, enc=guess_content_type(name, '', content_type) + return content_type + + security.declareProtected(delete_objects, 'DELETE') + + security.declareProtected(change_images_and_files, 'PUT') + def PUT(self, REQUEST, RESPONSE): + """Handle HTTP PUT requests""" + self.dav__init(REQUEST, RESPONSE) + self.dav__simpleifhandler(REQUEST, RESPONSE, refresh=1) + type=REQUEST.get_header('content-type', None) + + file=REQUEST['BODYFILE'] + + content_type = self._get_content_type(file, self.__name__, + type or self.content_type) + self.update_data(file, content_type) + + RESPONSE.setStatus(204) + return RESPONSE + + security.declareProtected(View, 'get_size') + def get_size(self): + """Get the size of a file or image. + + Returns the size of the file or image. + """ + size=self.size + if size is None : + bf = self.open('r') + bf.seek(0,2) + self.size = size = bf.tell() + bf.close() + return size + + # deprecated; use get_size! + getSize=get_size + + security.declareProtected(View, 'getContentType') + def getContentType(self): + """Get the content type of a file or image. + + Returns the content type (MIME type) of a file or image. + """ + return self.content_type + + + def __str__(self): return str(self.data) + def __len__(self): return 1 + + security.declareProtected(ftp_access, 'manage_FTPstat') + security.declareProtected(ftp_access, 'manage_FTPlist') + + security.declareProtected(ftp_access, 'manage_FTPget') + def manage_FTPget(self): + """Return body for ftp.""" + RESPONSE = self.REQUEST.RESPONSE + + if self.ZCacheable_isCachingEnabled(): + result = self.ZCacheable_get(default=None) + if result is not None: + # We will always get None from RAMCacheManager but we will get + # something implementing the IStreamIterator interface + # from FileCacheManager. + # the content-length is required here by HTTPResponse, even + # though FTP doesn't use it. + RESPONSE.setHeader('Content-Length', self.size) + return result + + bf = self.open('r') + data = bf.read() + bf.close() + RESPONSE.setBase(None) + return data + +manage_addImageForm=DTMLFile('dtml/imageAdd',globals(), + Kind='Image',kind='image') +def manage_addImage(self, id, file, title='', precondition='', content_type='', + REQUEST=None): + """ + Add a new Image object. + + Creates a new Image object 'id' with the contents of 'file'. + """ + + id=str(id) + title=str(title) + content_type=str(content_type) + precondition=str(precondition) + + id, title = cookId(id, title, file) + + self=self.this() + self._setObject(id, Image(id,title,file,content_type, precondition)) + + if REQUEST is not None: + try: url=self.DestinationURL() + except: url=REQUEST['URL1'] + REQUEST.RESPONSE.redirect('%s/manage_main' % url) + return id + + +def getImageInfo(file): + height = -1 + width = -1 + content_type = '' + + # handle GIFs + data = file.read(24) + size = len(data) + if (size >= 10) and data[:6] in ('GIF87a', 'GIF89a'): + # Check to see if content_type is correct + content_type = 'image/gif' + w, h = struct.unpack("= 24) and (data[:8] == '\211PNG\r\n\032\n') + and (data[12:16] == 'IHDR')): + content_type = 'image/png' + w, h = struct.unpack(">LL", data[16:24]) + width = int(w) + height = int(h) + + # Maybe this is for an older PNG version. + elif (size >= 16) and (data[:8] == '\211PNG\r\n\032\n'): + # Check to see if we have the right content type + content_type = 'image/png' + w, h = struct.unpack(">LL", data[8:16]) + width = int(w) + height = int(h) + + # handle JPEGs + elif (size >= 2) and (data[:2] == '\377\330'): + content_type = 'image/jpeg' + jpeg = file + jpeg.seek(0) + jpeg.read(2) + b = jpeg.read(1) + try: + while (b and ord(b) != 0xDA): + while (ord(b) != 0xFF): b = jpeg.read(1) + while (ord(b) == 0xFF): b = jpeg.read(1) + if (ord(b) >= 0xC0 and ord(b) <= 0xC3): + jpeg.read(3) + h, w = struct.unpack(">HH", jpeg.read(4)) + break + else: + jpeg.read(int(struct.unpack(">H", jpeg.read(2))[0])-2) + b = jpeg.read(1) + width = int(w) + height = int(h) + except: pass + + return content_type, width, height + + +class Image(File): + """Image objects can be GIF, PNG or JPEG and have the same methods + as File objects. Images also have a string representation that + renders an HTML 'IMG' tag. + """ + __implements__ = (WriteLockInterface,) + meta_type='Blob Image' + + security = ClassSecurityInfo() + security.declareObjectProtected(View) + + alt='' + height='' + width='' + + # FIXME: Redundant, already in base class + security.declareProtected(change_images_and_files, 'manage_edit') + security.declareProtected(change_images_and_files, 'manage_upload') + security.declareProtected(change_images_and_files, 'PUT') + security.declareProtected(View, 'index_html') + security.declareProtected(View, 'get_size') + security.declareProtected(View, 'getContentType') + security.declareProtected(ftp_access, 'manage_FTPstat') + security.declareProtected(ftp_access, 'manage_FTPlist') + security.declareProtected(ftp_access, 'manage_FTPget') + security.declareProtected(delete_objects, 'DELETE') + + _properties=({'id':'title', 'type': 'string'}, + {'id':'alt', 'type':'string'}, + {'id':'content_type', 'type':'string','mode':'w'}, + {'id':'height', 'type':'string'}, + {'id':'width', 'type':'string'}, + ) + + manage_options=( + ({'label':'Edit', 'action':'manage_main', + 'help':('OFSP','Image_Edit.stx')}, + {'label':'View', 'action':'view_image_or_file', + 'help':('OFSP','Image_View.stx')},) + + PropertyManager.manage_options + + RoleManager.manage_options + + Item_w__name__.manage_options + + Cacheable.manage_options + ) + + manage_editForm =DTMLFile('dtml/imageEdit',globals(), + Kind='Image',kind='image') + manage_editForm._setName('manage_editForm') + + security.declareProtected(View, 'view_image_or_file') + view_image_or_file =DTMLFile('dtml/imageView',globals()) + + security.declareProtected(view_management_screens, 'manage') + security.declareProtected(view_management_screens, 'manage_main') + manage=manage_main=manage_editForm + manage_uploadForm=manage_editForm + + security.declarePrivate('update_data') + def update_data(self, file, content_type=None): + super(Image, self).update_data(file, content_type) + self.updateFormat(size=self.size, content_type=content_type) + + security.declarePrivate('updateFormat') + def updateFormat(self, size=None, dimensions=None, content_type=None): + self.updateSize(size=size) + + if dimensions is None or content_type is None : + bf = self.open('r') + ct, width, height = getImageInfo(bf) + bf.close() + if ct: + content_type = ct + if width >= 0 and height >= 0: + self.width = width + self.height = height + + # Now we should have the correct content type, or still None + if content_type is not None: self.content_type = content_type + else : + self.width, self.height = dimensions + self.content_type = content_type + + def __str__(self): + return self.tag() + + security.declareProtected(View, 'tag') + def tag(self, height=None, width=None, alt=None, + scale=0, xscale=0, yscale=0, css_class=None, title=None, **args): + """ + Generate an HTML IMG tag for this image, with customization. + Arguments to self.tag() can be any valid attributes of an IMG tag. + 'src' will always be an absolute pathname, to prevent redundant + downloading of images. Defaults are applied intelligently for + 'height', 'width', and 'alt'. If specified, the 'scale', 'xscale', + and 'yscale' keyword arguments will be used to automatically adjust + the output height and width values of the image tag. + + Since 'class' is a Python reserved word, it cannot be passed in + directly in keyword arguments which is a problem if you are + trying to use 'tag()' to include a CSS class. The tag() method + will accept a 'css_class' argument that will be converted to + 'class' in the output tag to work around this. + """ + if height is None: height=self.height + if width is None: width=self.width + + # Auto-scaling support + xdelta = xscale or scale + ydelta = yscale or scale + + if xdelta and width: + width = str(int(round(int(width) * xdelta))) + if ydelta and height: + height = str(int(round(int(height) * ydelta))) + + result='' % result + + +def cookId(id, title, file): + if not id and hasattr(file,'filename'): + filename=file.filename + title=title or filename + id=filename[max(filename.rfind('/'), + filename.rfind('\\'), + filename.rfind(':'), + )+1:] + return id, title + +#class Pdata(Persistent, Implicit): +# # Wrapper for possibly large data +# +# next=None +# +# def __init__(self, data): +# self.data=data +# +# def __getslice__(self, i, j): +# return self.data[i:j] +# +# def __len__(self): +# data = str(self) +# return len(data) +# +# def __str__(self): +# next=self.next +# if next is None: return self.data +# +# r=[self.data] +# while next is not None: +# self=next +# r.append(self.data) +# next=self.next +# +# return ''.join(r) diff --git a/cache.py b/cache.py new file mode 100755 index 0000000..ad2a5ac --- /dev/null +++ b/cache.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +####################################################################################### +# Photo is a part of Plinn - http://plinn.org # +# Copyright (C) 2008 Benoît PIN # +# # +# This program is free software; you can redistribute it and/or # +# modify it under the terms of the GNU General Public License # +# as published by the Free Software Foundation; either version 2 # +# of the License, or (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program; if not, write to the Free Software # +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # +####################################################################################### +""" Memoization utils + +$Id: cache.py 400 2008-07-11 10:31:26Z pin $ +$URL: http://svn.luxia.fr/svn/labo/projects/zope/Photo/trunk/cache.py $ +""" + +import inspect +from BTrees.OOBTree import OOBTree + +def memoizedmethod(*indexes, **options) : + """ Used as decorator, this function stores result + of method m inside self._methodResultsCache or + self._v__methodResultsCache if volatile. + This decorator may be used inside a class which provides + a mapping object named _methodResultsCache and / or + _v__methodResultsCache. + + example : + + 1 - simple metdhod memoization + + @memoizedmethod() + def methodWithNoParameters(self): pass + + 2 - indexed memoisation: + Parameters names are passed to memoizedmethod are + evaluated to construct an indexed cache. + Names must be a subset of the memoized method signature. + + @memoizedmethod('arg1', 'arg2') + def methodWithParameters(self, arg1, arg2=None): pass + """ + volatile = options.get('volatile', False) + cacheVarName = '_methodResultsCache' + if volatile==True : + cacheVarName = '_v_%s' % cacheVarName + + def makeMemoizedMethod(m) : + methodname = m.__name__ + + if not indexes : + def memoizedMethod(self) : + if not hasattr(self, cacheVarName) : + setattr(self, cacheVarName, OOBTree()) + cache = getattr(self, cacheVarName) + if cache.has_key(methodname) : + return cache[methodname] + else : + res = m(self) + cache[methodname] = res + return res + + memoizedMethod.__name__ = methodname + memoizedMethod.__doc__ = m.__doc__ + return memoizedMethod + + else : + args, varargs, varkw, defaults = inspect.getargspec(m) + args = list(args) + if defaults is None : + defaults = [] + mandatoryargs = args[1:-len(defaults)] + optargs = args[-len(defaults):] + defaultValues = dict(zip([name for name in args[-len(defaults):]], [val for val in defaults])) + + indexPositions = [] + for index in indexes : + try : + indexPositions.append((index, args.index(index))) + except ValueError : + raise ValueError("%r argument is not in signature of %r" % (index, methodname)) + + if indexPositions : + indexPositions.sort(lambda a, b : cmp(a[1], b[1])) + + indexPositions = tuple(indexPositions) + + + def memoizedMethod(self, *args, **kw) : + # test if m if called by ZPublished + if len(args) < len(mandatoryargs) and hasattr(self, 'REQUEST') : + assert not kw + args = list(args) + get = lambda name : self.REQUEST[name] + for name in mandatoryargs : + try : + args.append(get(name)) + except KeyError : + exactOrAtLeast = defaults and 'exactly' or 'at least' + raise TypeError('%(methodname)s takes %(exactOrAtLeast)s %(mandatoryArgsLength)d argument (%(givenArgsLength)s given)' % \ + { 'methodname': methodname + , 'exactOrAtLeast': exactOrAtLeast + , 'mandatoryArgsLength': len(mandatoryargs) + , 'givenArgsLength': len(args)}) + + for name in optargs : + get = self.REQUEST.get + args.append(get(name, defaultValues[name])) + + args = tuple(args) + + if not hasattr(self, cacheVarName) : + setattr(self, cacheVarName, OOBTree()) + cache = getattr(self, cacheVarName) + if not cache.has_key(methodname) : + cache[methodname] = OOBTree() + + cache = cache[methodname] + index = aggregateIndex(indexPositions, args) + + if cache.has_key(index) : + return cache[index] + else : + res = m(self, *args, **kw) + cache[index] = res + return res + + memoizedMethod.__name__ = methodname + memoizedMethod.__doc__ = m.__doc__ + return memoizedMethod + + return makeMemoizedMethod + +def aggregateIndex(indexPositions, args): + ''' + Returns the index to be used when looking for or inserting + a cache entry. + view_name is a string. + local_keys is a mapping or None. + ''' + + agg_index = [] + + for name, pos in indexPositions : + val = args[pos-1] + agg_index.append((name, str(val))) + + return tuple(agg_index) diff --git a/dependencies.txt b/dependencies.txt new file mode 100755 index 0000000..ede10ef --- /dev/null +++ b/dependencies.txt @@ -0,0 +1,2 @@ +PIL - Python Imaging Library - 1.1.4 or later +http://www.pythonware.com/products/pil/ diff --git a/dtml/File_icon.gif b/dtml/File_icon.gif new file mode 100644 index 0000000..f0eb5bf Binary files /dev/null and b/dtml/File_icon.gif differ diff --git a/dtml/Image_icon.gif b/dtml/Image_icon.gif new file mode 100644 index 0000000..bf11d02 Binary files /dev/null and b/dtml/Image_icon.gif differ diff --git a/dtml/addPhotoForm.dtml b/dtml/addPhotoForm.dtml new file mode 100755 index 0000000..ecdfbdf --- /dev/null +++ b/dtml/addPhotoForm.dtml @@ -0,0 +1,58 @@ + + + + +

+Select a file to upload from your local computer by clicking the +Browse button. +

+ +
+ + + + + + + + + + + + + + + + + +
+
+ Id +
+
+ +
+
+ Title +
+
+ +
+
+ File +
+
+ +
+ +
+ +
+
+
+ + + diff --git a/dtml/fileEdit.dtml b/dtml/fileEdit.dtml new file mode 100644 index 0000000..feb7e7e --- /dev/null +++ b/dtml/fileEdit.dtml @@ -0,0 +1,142 @@ + + + + +

+You can update the data for this file object using the form below. +Select a data file from your local computer by clicking the browse +button and click upload to update the contents of the +file. You may also edit the file content directly if the content is a +text type and small enough to be edited in a text area. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ Title +
+
+ +
+
+ Content Type +
+
+ +
+
+ Precondition +
+
+ +
+
+ + + + +
+
+
+ Last Modified +
+
+
+ +
+
+
+ File Size +
+
+
+ bytes +
+
+
+ + Locked by WebDAV + + + +
+
+
+
+ File Data +
+
+
+ +
+
+ + Locked by WebDAV + + + +
+
+
+ + + diff --git a/dtml/imageAdd.dtml b/dtml/imageAdd.dtml new file mode 100644 index 0000000..a773d54 --- /dev/null +++ b/dtml/imageAdd.dtml @@ -0,0 +1,61 @@ + + + + +

+Select a file to upload from your local computer by clicking the +Browse button. +

+ +
+ + + + + + + + + + + + + + + + + +
+
+ Id +
+
+ +
+
+ Title +
+
+ +
+
+ File +
+
+ +
+ +
+ +
+
+
+ + + diff --git a/dtml/imageEdit.dtml b/dtml/imageEdit.dtml new file mode 100644 index 0000000..6268783 --- /dev/null +++ b/dtml/imageEdit.dtml @@ -0,0 +1,131 @@ + + + + +

+You can update the data for this &dtml-kind; using the form below. +Select a data file from your local computer by clicking the browse +button and click upload to update the contents of the &dtml-kind;. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ Title +
+
+ +
+
+ Content Type +
+
+ +
+
+ Preview +
+
+ 250"> + + 250"> + + + + +
+
+ Last Modified +
+
+
+ +
+
+
+ File Size +
+
+
+ bytes +
+
+
+ + Locked by WebDAV + + + +
+
+
+ +
+ + + + + + + + + + + +
+
+
+ File Data +
+
+
+ +
+
+ + Locked by WebDAV + + + +
+
+
+ + diff --git a/dtml/imageView.dtml b/dtml/imageView.dtml new file mode 100644 index 0000000..5ec12d3 --- /dev/null +++ b/dtml/imageView.dtml @@ -0,0 +1,9 @@ + + + +

+ +

+ + + diff --git a/dtml/photoEdit.dtml b/dtml/photoEdit.dtml new file mode 100755 index 0000000..e15a679 --- /dev/null +++ b/dtml/photoEdit.dtml @@ -0,0 +1,126 @@ + + + + +

+You can update the data for this using the form below. +Select a data file from your local computer by clicking the browse +button and click upload to update the contents of the . +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ Title +
+
+ +
+
+ Content Type +
+
+ +
+
+ Preview +
+
+ preview of <dtml-var title_or_id> +
+
+ Last Modified +
+
+
+ +
+
+
+ File Size +
+
+
+ bytes +
+
+
+ + Locked by WebDAV + + + +
+
+
+ +
+ + + + + + + + + + + +
+
+
+ File Data +
+
+
+ +
+
+ + Locked by WebDAV + + + +
+
+
+ + diff --git a/dtml/photoView.dtml b/dtml/photoView.dtml new file mode 100755 index 0000000..8f70420 --- /dev/null +++ b/dtml/photoView.dtml @@ -0,0 +1,9 @@ + + + +

+ +

+ + + diff --git a/dtml/photo_icon.gif b/dtml/photo_icon.gif new file mode 100644 index 0000000..bde5168 Binary files /dev/null and b/dtml/photo_icon.gif differ diff --git a/dtml/testMenu.dtml b/dtml/testMenu.dtml new file mode 100755 index 0000000..7050197 --- /dev/null +++ b/dtml/testMenu.dtml @@ -0,0 +1,93 @@ + + + + + + + + +
+ + &dtml-meta_type; + + + + + + Root Folder + + + + + + +
+ + +&dtml-meta_type; + +&dtml-id; + + + + + + + + + + +
+ + + © Zope Corporation + + +
+ + Refresh + +
+ + + + + + +
+
+
+Logged in as    +
+ + +  +
+
+
+
+ + + diff --git a/exif.py b/exif.py new file mode 100755 index 0000000..2050dbe --- /dev/null +++ b/exif.py @@ -0,0 +1,374 @@ +# -*- coding: utf-8 -*- +####################################################################################### +# Photo is a part of Plinn - http://plinn.org # +# Copyright © 2008 Benoît PIN # +# # +# This program is free software; you can redistribute it and/or # +# modify it under the terms of the GNU General Public License # +# as published by the Free Software Foundation; either version 2 # +# of the License, or (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program; if not, write to the Free Software # +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # +####################################################################################### +""" Exif version 2.2 read/write module. + +$Id: exif.py 360 2008-02-21 09:17:32Z pin $ +$URL: http://svn.luxia.fr/svn/labo/projects/zope/Photo/trunk/exif.py $ +""" + +TYPES_SIZES = { + 1: 1 # BYTE An 8-bit unsigned integer., + , 2: 1 # ASCII An 8-bit byte containing one 7-bit ASCII code. The final byte is terminated with NULL., + , 3: 2 # SHORT A 16-bit (2-byte) unsigned integer, + , 4: 4 # LONG A 32-bit (4-byte) unsigned integer, + , 5: 8 # RATIONAL Two LONGs. The first LONG is the numerator and the second LONG expresses the denominator., + , 7: 1 # UNDEFINED An 8-bit byte that can take any value depending on the field definition, + , 9: 4 # SLONG A 32-bit (4-byte) signed integer (2's complement notation), + , 10 : 8 # SRATIONAL Two SLONGs. The first SLONG is the numerator and the second SLONG is the denominator. +} + +# tags for parsing metadata +Exif_IFD_POINTER = 0x8769 +GPS_INFO_IFD_POINTER = 0x8825 +INTEROPERABILITY_IFD_POINTER = 0xA005 + +# tags to get thumbnail +COMPRESSION_SCHEME = 0x103 +COMPRESSION_SCHEME_TYPES = {1:'image/bmp', 6:'image/jpeg'} +OFFSET_TO_JPEG_SOI = 0x201 +BYTES_OF_JPEG_DATA = 0x202 +STRIPOFFSETS = 0x111 +STRIPBYTECOUNTS = 0x117 + +# constants for writing +INTEROPERABILITY_FIELD_LENGTH = 12 +POINTER_TAGS = { Exif_IFD_POINTER:True + , GPS_INFO_IFD_POINTER:True + , INTEROPERABILITY_IFD_POINTER:True} + + +class Exif(dict) : + + def __init__(self, f) : + # File Headers are 8 bytes as defined in the TIFF standard. + self.f = f + + byteOrder = f.read(2) + self.byteOrder = byteOrder + + if byteOrder == 'MM' : + r16 = self.r16 = lambda:ib16(f.read(2)) + r32 = self.r32 = lambda:ib32(f.read(4)) + elif byteOrder == 'II' : + r16 = self.r16 = lambda:il16(f.read(2)) + r32 = self.r32 = lambda:il32(f.read(4)) + else : + raise ValueError, "Unkwnown byte order: %r" % byteOrder + + assert r16() == 0x002A, "Incorrect exif header" + + self.tagReaders = { + 1: lambda c : [ord(f.read(1)) for i in xrange(c)] + , 2: lambda c : f.read(c) + , 3: lambda c : [r16() for i in xrange(c)] + , 4: lambda c : [r32() for i in xrange(c)] + , 5: lambda c : [(r32(), r32()) for i in xrange(c)] + , 7: lambda c : f.read(c) + , 9: lambda c : [r32() for i in xrange(c)] + , 10: lambda c : [(r32(), r32()) for i in xrange(c)] + } + + self.tagInfos = {} + self.mergedTagInfos = {} + self.gpsTagInfos = {} + + ifd0Offset = r32() + + ifd1Offset = self._loadTagsInfo(ifd0Offset, 'IFD0') + others = [(lambda:self[Exif_IFD_POINTER], 'Exif'), + (lambda:self.get(GPS_INFO_IFD_POINTER), 'GPS'), + (lambda:self.get(INTEROPERABILITY_IFD_POINTER), 'Interoperability'), + (lambda:ifd1Offset, 'IFD1')] + + self.ifdnames = ['IFD0'] + + for startfunc, ifdname in others : + start = startfunc() + if start : + ret = self._loadTagsInfo(start, ifdname) + assert ret == 0 + self.ifdnames.append(ifdname) + + + def _loadTagsInfo(self, start, ifdname) : + r16, r32 = self.r16, self.r32 + + self.f.seek(start) + + numberOfFields = r16() + ifdInfos = self.tagInfos[ifdname] = {} + + for i in xrange(numberOfFields) : + # 12 bytes of the field Interoperability + tag = r16() + typ = r16() + count = r32() + + ts = TYPES_SIZES[typ] + size = ts * count + + # In cases where the value fits in 4 bytes, + # the value itself is recorded. + # If the value is smaller than 4 bytes, the value is + # stored in the 4-byte area starting from the left. + if size <= 4 : + offsetIsValue = True + offset = self.tagReaders[typ](count) + if count == 1: + offset = offset[0] + noise = self.f.read(4 - size) + else : + offsetIsValue = False + offset = r32() + + ifdInfos[tag] = (typ, count, offset, offsetIsValue) + + if ifdname == 'GPS' : + self.gpsTagInfos.update(ifdInfos) + else : + self.mergedTagInfos.update(ifdInfos) + + # return nexf ifd offset + return r32() + + def getThumbnail(self) : + if hasattr(self, 'ifd1Offset') : + comp = self[COMPRESSION_SCHEME] + if comp == 6 : + # TODO : handle uncompressed thumbnails + mime = COMPRESSION_SCHEME_TYPES.get(comp, 'unknown') + start = self[OFFSET_TO_JPEG_SOI] + count = self[BYTES_OF_JPEG_DATA] + f = self.f + f.seek(start) + data = f.read(count) + return data, mime + else : + return None + else : + return None + + + + # + # dict interface + # + def keys(self) : + return self.mergedTagInfos.keys() + + def has_key(self, key) : + return self.mergedTagInfos.has_key(key) + + __contains__ = has_key # necessary ? + + def __getitem__(self, key) : + typ, count, offset, offsetIsValue = self.mergedTagInfos[key] + if offsetIsValue : + return offset + else : + self.f.seek(offset) + value = self.tagReaders[typ](count) + if count == 1: + return value[0] + else : + return value + + def get(self, key) : + if self.has_key(key): + return self[key] + else : + return None + + def getIFDNames(self) : + return self.ifdnames + + + def getIFDTags(self, name) : + tags = [tag for tag in self.tagInfos[name].keys()] + tags.sort() + return tags + + + def save(self, out) : + byteOrder = self.byteOrder + + if byteOrder == 'MM' : + w16 = self.w16 = lambda i : out.write(ob16(i)) + w32 = self.w32 = lambda i : out.write(ob32(i)) + elif byteOrder == 'II' : + w16 = self.w16 = lambda i : out.write(ol16(i)) + w32 = self.w32 = lambda i : out.write(ol32(i)) + + tagWriters = { + 1: lambda l : [out.write(chr(i)) for i in l] + , 2: lambda l : out.write(l) + , 3: lambda l : [w16(i) for i in l] + , 4: lambda l : [w32(i) for i in l] + , 5: lambda l : [(w32(i[0]), w32(i[1])) for i in l] + , 7: lambda l : out.write(l) + , 9: lambda l : [w32(i) for i in l] + , 10: lambda l : [(w32(i[0]), w32(i[1])) for i in l] + } + + + # tiff header + out.write(self.byteOrder) + w16(0x002A) + tags = self.keys() + r32(8) # offset of IFD0 + ifdStarts = {} + pointerTags = [] + isPtrTag = POINTER_TAGS.has_key + + for ifdname in self.getIFDName() : + ifdInfos = self.tagInfos[name] + tags = ifdInfos.keys() + tags.sort() + + ifdStarts[ifdname] = out.tell() + + tiffOffset = ifdStarts[ifdname] + INTEROPERABILITY_FIELD_LENGTH * len(tags) + 4 + moreThan4bytesValuesTags = [] + + for tag, info in ifdInfos.items() : + if isPtrTag(tag) : + pointerTags.append((tag, out.tell())) + typ, count, offset, offsetIsValue = info + + w16(tag) + w16(typ) + w32(count) + + ts = TYPES_SIZES[typ] + size = ts * count + + if size <= 4 : + if count == 1 : offset = [offset] + tagWriters[typ](offset) + + # padding + for i in range(4 - size) : out.write('\0') + else : + w32(tiffOffset) + tiffOffset += size + moreThan4bytesValuesTags.append(tag) + + for tag in moreThan4bytesValuesTags : + typ, count, offset, offsetIsValue = ifdInfos[tag] + self.f.seek(offset) + size = TYPES_SIZES[typ] * count + out.write(self.f.read(size)) + + # write place-holder for next ifd offset (updated later) + r32(0) + + +def ib16(c): + return ord(c[1]) + (ord(c[0])<<8) +def ob16(i) : + return chr(i >> 8 & 255) + chr(i & 255) + +def ib32(c): + return ord(c[3]) + (ord(c[2])<<8) + (ord(c[1])<<16) + (ord(c[0])<<24) +def ob32(c): + return chr(i >> 24 & 0xff) + chr(i >> 16 & 0xff) + chr(i >> 8 & 0xff) + chr(i & 0xff) + + +def il16(c): + return ord(c[0]) + (ord(c[1])<<8) +def ol16(i): + return chr(i&255) + chr(i>>8&255) + +def il32(c): + return ord(c[0]) + (ord(c[1])<<8) + (ord(c[2])<<16) + (ord(c[3])<<24) +def ol32(i): + return chr(i&255) + chr(i>>8&255) + chr(i>>16&255) + chr(i>>24&255) + + + + +def testRead(*paths) : + from PIL.Image import open as imgopen + from standards.exif import TAGS + from cStringIO import StringIO + + import os + paths = list(paths) + paths.extend(['testimages/%s'%name for name in os.listdir('testimages') \ + if name.endswith('.jpg') and \ + not name.endswith('_thumb.jpg')]) + + for path in paths : + print '------------' + print path + print '------------' + im = imgopen(path) + applist = im.applist + exifBlock = [a[1] for a in applist if a[0] == 'APP1' and a[1].startswith("Exif\x00\x00")][0] + exif = exifBlock[6:] + sio = StringIO(exif) + + e = Exif(sio) + for name in e.getIFDNames() : + print '%s: ' %name + for tag in e.getIFDTags(name) : + print hex(tag), TAGS.get(tag), e[tag] + print + + thumb = e.getThumbnail() + if thumb is not None : + data, mime = thumb + out = open('%s_thumb.jpg' % path[:-4], 'w') + out.write(data) + out.close() + +def testWrite(*paths) : + from PIL.Image import open as imgopen + from standards.exif import TAGS + from cStringIO import StringIO + +# import os +# paths = list(paths) +# paths.extend(['testimages/%s'%name for name in os.listdir('testimages') \ +# if name.endswith('.jpg') and \ +# not name.endswith('_thumb.jpg')]) + + for path in paths : + print '------------' + print path + print '------------' + im = imgopen(path) + applist = im.applist + exifBlock = [a[1] for a in applist if a[0] == 'APP1' and a[1].startswith("Exif\x00\x00")][0] + exif = exifBlock[6:] + from cStringIO import StringIO + sio = StringIO(exif) + + e = Exif(sio) + + out = StringIO() + e.save(out) + out.seek(0) + print '%r' % out.read() + + +if __name__ == '__main__' : + testRead('testMM.jpg', 'testII.jpg') + #testWrite('testMM.jpg', 'testII.jpg') diff --git a/license.txt b/license.txt new file mode 100755 index 0000000..4025d75 --- /dev/null +++ b/license.txt @@ -0,0 +1,345 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc. + 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Library General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Library General +Public License instead of this License. diff --git a/metadata.py b/metadata.py new file mode 100755 index 0000000..96a44aa --- /dev/null +++ b/metadata.py @@ -0,0 +1,337 @@ +# -*- coding: utf-8 -*- +####################################################################################### +# Photo is a part of Plinn - http://plinn.org # +# Copyright © 2004-2008 Benoît PIN # +# # +# This program is free software; you can redistribute it and/or # +# modify it under the terms of the GNU General Public License # +# as published by the Free Software Foundation; either version 2 # +# of the License, or (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program; if not, write to the Free Software # +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # +####################################################################################### +""" Photo metadata read / write module + +$Id: metadata.py 1272 2009-08-11 08:57:35Z pin $ +$URL: http://svn.luxia.fr/svn/labo/projects/zope/Photo/trunk/metadata.py $ +""" + +from AccessControl import ClassSecurityInfo +from Globals import InitializeClass +from AccessControl.Permissions import view +from ZODB.interfaces import BlobError +from ZODB.utils import cp +from OFS.Image import File +from xmp import XMP +from logging import getLogger +from cache import memoizedmethod +from libxml2 import parseDoc +from standards.xmp import accessors as xmpAccessors +import xmputils +from types import TupleType +from subprocess import Popen, PIPE +from Products.PortalTransforms.libtransforms.utils import bin_search, \ + MissingBinary + +XPATH_EMPTY_TAGS = "//node()[name()!='' and not(node()) and not(@*)]" +console = getLogger('Photo.metadata') + +try : + XMPDUMP = 'xmpdump' + XMPLOAD = 'xmpload' + bin_search(XMPDUMP) + bin_search(XMPLOAD) + xmpIO_OK = True +except MissingBinary : + xmpIO_OK = False + console.warn("xmpdump or xmpload not available.") + +class Metadata : + """ Photo metadata read / write mixin """ + + security = ClassSecurityInfo() + + + # + # reading api + # + + security.declarePrivate('getXMP') + if xmpIO_OK : + @memoizedmethod() + def getXMP(self): + """returns xmp metadata packet with xmpdump call + """ + if self.size : + blob_file_path = self.bdata._current_filename() + dumpcmd = '%s %s' % (XMPDUMP, blob_file_path) + p = Popen(dumpcmd, stdout=PIPE, stderr=PIPE, stdin=PIPE, shell=True) + xmp, err = p.communicate() + if err : + raise SystemError, err + return xmp + + else : + @memoizedmethod() + def getXMP(self): + """returns xmp metadata packet with XMP object + """ + xmp = None + if self.size : + try : + bf = self.open('r') + x = XMP(bf, content_type=self.content_type) + xmp = x.getXMP() + except NotImplementedError : + pass + + return xmp + + security.declareProtected(view, 'getXmpFile') + def getXmpFile(self, REQUEST): + """returns the xmp packet over http. + """ + xmp = self.getXMP() + if xmp is not None : + return File('xmp', 'xmp', xmp, content_type='text/xml').index_html(REQUEST, REQUEST.RESPONSE) + else : + return None + + security.declarePrivate('getXmpBag') + def getXmpBag(self, name, root, index=None) : + index = self.getXmpPathIndex() + if index : + path = '/'.join(filter(None, ['rdf:RDF/rdf:Description', root, name])) + node = index.get(path) + + if node : + values = xmputils.getBagValues(node.element) + return values + return tuple() + + security.declarePrivate('getXmpSeq') + def getXmpSeq(self, name, root) : + index = self.getXmpPathIndex() + if index : + path = '/'.join(filter(None, ['rdf:RDF/rdf:Description', root, name])) + node = index.get(path) + + if node : + values = xmputils.getSeqValues(node.element) + return values + return tuple() + + security.declarePrivate('getXmpAlt') + def getXmpAlt(self, name, root) : + index = self.getXmpPathIndex() + if index : + path = '/'.join(filter(None, ['rdf:RDF/rdf:Description', root, name])) + node = index.get(path) + + if node : + firstLi = node['rdf:Alt/rdf:li'] + assert firstLi.unique, "More than one rdf:Alt (localisation not yet supported)" + return firstLi.element.content + return '' + + security.declarePrivate('getXmpProp') + def getXmpProp(self, name, root): + index = self.getXmpPathIndex() + if index : + path = '/'.join(filter(None, ['rdf:RDF/rdf:Description', root, name])) + node = index.get(path) + if node : + return node.element.content + return '' + + + security.declarePrivate('getXmpPathIndex') + @memoizedmethod(volatile=True) + def getXmpPathIndex(self): + xmp = self.getXMP() + if xmp : + d = parseDoc(xmp) + index = xmputils.getPathIndex(d) + return index + + security.declarePrivate('getXmpValue') + def getXmpValue(self, name): + """ returns pythonic version of xmp property """ + info = xmpAccessors[name] + root = info['root'] + rdfType = info['rdfType'].capitalize() + methName = 'getXmp%s' % rdfType + meth = getattr(self.aq_base, methName) + return meth(name, root) + + + security.declareProtected(view, 'getXmpField') + def getXmpField(self, name): + """ returns data formated for a html form field """ + editableValue = self.getXmpValue(name) + if type(editableValue) == TupleType : + editableValue = ', '.join(editableValue) + return {'id' : name.replace(':', '_'), + 'value' : editableValue} + + + # + # writing api + # + + security.declarePrivate('setXMP') + if xmpIO_OK : + def setXMP(self, xmp): + """setXMP with xmpload call + """ + if self.size : + blob = self.bdata + if blob.readers : + raise BlobError("Already opened for reading.") + + if blob._p_blob_uncommitted is None: + filename = blob._create_uncommitted_file() + uncommitted = file(filename, 'w') + cp(file(blob._p_blob_committed, 'rb'), uncommitted) + uncommitted.close() + else : + filename = blob._p_blob_uncommitted + + loadcmd = '%s %s' % (XMPLOAD, filename) + p = Popen(loadcmd, stdin=PIPE, stderr=PIPE, shell=True) + p.stdin.write(xmp) + p.stdin.close() + p.wait() + err = p.stderr.read() + if err : + raise SystemError, err + + f = file(filename) + f.seek(0,2) + self.updateSize(size=f.tell()) + f.close() + self.bdata._p_changed = True + + + # purge caches + try : del self._methodResultsCache['getXMP'] + except KeyError : pass + + for name in ('getXmpPathIndex',) : + try : + del self._v__methodResultsCache[name] + except (AttributeError, KeyError): + continue + + self.ZCacheable_invalidate() + self.ZCacheable_set(None) + self.http__refreshEtag() + + else : + def setXMP(self, xmp): + """setXMP with XMP object + """ + if self.size : + bf = self.open('r+') + x = XMP(bf, content_type=self.content_type) + x.setXMP(xmp) + x.save() + self.updateSize(size=bf.tell()) + + # don't call update_data + self.ZCacheable_invalidate() + self.ZCacheable_set(None) + self.http__refreshEtag() + + # purge caches + try : del self._methodResultsCache['getXMP'] + except KeyError : pass + for name in ('getXmpPathIndex', ) : + try : + del self._v__methodResultsCache[name] + except (AttributeError, KeyError): + continue + + + + security.declarePrivate('setXmpField') + def setXmpFields(self, **kw): + xmp = self.getXMP() + if xmp : + doc = parseDoc(xmp) + else : + doc = xmputils.createEmptyXmpDoc() + + index = xmputils.getPathIndex(doc) + + pathPrefix = 'rdf:RDF/rdf:Description' + preferedNsDeclaration = 'rdf:RDF/rdf:Description' + + for id, value in kw.items() : + name = id.replace('_', ':') + info = xmpAccessors.get(name) + if not info : continue + root = info['root'] + rdfType = info['rdfType'] + path = '/'.join([p for p in [pathPrefix, root, name] if p]) + + Metadata._setXmpField(index + , path + , rdfType + , name + , value + , preferedNsDeclaration) + + # clean empty tags without attributes + context = doc.xpathNewContext() + nodeset = context.xpathEval(XPATH_EMPTY_TAGS) + while nodeset : + for n in nodeset : + n.unlinkNode() + n.freeNode() + nodeset = context.xpathEval(XPATH_EMPTY_TAGS) + + + + xmp = doc.serialize('utf-8') + # remove header + xmp = xmp.split('?>', 1)[1].lstrip('\n') + self.setXMP(xmp) + + @staticmethod + def _setXmpField(index, path, rdfType, name, value, preferedNsDeclaration) : + if rdfType in ('Bag', 'Seq') : + value = value.replace(';', ',') + value = value.split(',') + value = [item.strip() for item in value] + value = filter(None, value) + + if value : + # edit + xmpPropIndex = index.getOrCreate(path + , rdfType + , preferedNsDeclaration) + if rdfType == 'prop' : + xmpPropIndex.element.setContent(value) + else : + #rdfPrefix = index.getDocumentNs()['http://www.w3.org/1999/02/22-rdf-syntax-ns#'] + func = getattr(xmputils, 'createRDF%s' % rdfType) + newNode = func(name, value, index) + oldNode = xmpPropIndex.element + oldNode.replaceNode(newNode) + else : + # delete + xmpPropIndex = index.get(path) + if xmpPropIndex is not None : + xmpPropIndex.element.unlinkNode() + xmpPropIndex.element.freeNode() + + +InitializeClass(Metadata) diff --git a/migration/__init__.py b/migration/__init__.py new file mode 100644 index 0000000..4287ca8 --- /dev/null +++ b/migration/__init__.py @@ -0,0 +1 @@ +# \ No newline at end of file diff --git a/migration/from2to3.py b/migration/from2to3.py new file mode 100644 index 0000000..13882ca --- /dev/null +++ b/migration/from2to3.py @@ -0,0 +1,30 @@ +from BTrees.OOBTree import OOBTree +from BTrees.IOBTree import IOBTree + +def migrate(p) : + if hasattr(p, '_variants') : + delattr(p, '_variants') + + if not hasattr(p, 'tiles_available') : + p.tiles_available = 0 + + + if hasattr(p, '_methodResultsCache') and p._methodResultsCache.has_key('_getTile'): + p._tiles = OOBTree() + for args, value in p._methodResultsCache['_getTile'].items() : + args = dict(args) + zoom = float(args['zoom']) + x = int(args['x']) + y = int(args['y']) + + if not p._tiles.has_key(zoom) : + p._tiles[zoom] = IOBTree() + if not p._tiles[zoom].has_key(x) : + p._tiles[zoom][x] = IOBTree() + + p._tiles[zoom][x][y] = value + del p._methodResultsCache['_getTile'] + + elif not hasattr(p, '_tiles'): + p._tiles = OOBTree() + p.tiles_available = 0 diff --git a/migration/toblob.py b/migration/toblob.py new file mode 100755 index 0000000..e1fff2e --- /dev/null +++ b/migration/toblob.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +""" +$Id: toblob.py 909 2009-04-20 13:38:47Z pin $ +$URL: http://svn.luxia.fr/svn/labo/projects/zope/Photo/trunk/migration/toblob.py $ +Script de migration du stockage du fichier depuis l'attribut 'data' +vers l'attribut de type blob 'bdata'. +IMPORTANT : +les lignes 144 à 147 de blobbases.py doivent être commentéés +avant exécution. + +147 | # data = property(_getLegacyData, _setLegacyData, +148 | # "Data Legacy attribute to ensure compatibility " +149 | # "with derived classes that access data by this way.") + +""" + +from ZODB.blob import Blob + +def migrate(self) : + if hasattr(self.aq_base, 'data') : + data = str(self.data) + self.bdata = Blob() + bf = self.bdata.open('w') + bf.write(data) + bf.close() + delattr(self, 'data') + return True + else : + assert hasattr(self.aq_base, 'bdata') + return False + \ No newline at end of file diff --git a/ppm.py b/ppm.py new file mode 100755 index 0000000..55a824b --- /dev/null +++ b/ppm.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +#################################################### +# Copyright © 2009 Luxia SAS. All rights reserved. # +# # +# Contributors: # +# - Benoît Pin # +#################################################### +""" PPM File support module + +$Id: ppm.py 1276 2009-08-11 16:38:02Z pin $ +$URL: http://svn.luxia.fr/svn/labo/projects/zope/Photo/trunk/ppm.py $ +""" + +from subprocess import Popen, PIPE +from tempfile import TemporaryFile +import os +from math import ceil +from PIL.Image import open as imgopen +from PIL.Image import fromstring +from PIL.Image import ANTIALIAS +from cStringIO import StringIO + +DGJPEG = 'djpeg' +RESIZING_TILE_SIZE = 1024 + +class PPMFile(object) : + + def __init__(self, f, tileSize=256, isRaw=False) : + # convert jpeg -> ppm with djpeg + if not isRaw : + # print 'djpeg' + self.fp = TemporaryFile(mode='w+') + p = Popen(DGJPEG, stdin=f, stdout=self.fp, stderr=PIPE, shell=True) + p.wait() + err = p.stderr.read() + if err : + raise SystemError, err + else : + self.fp = f + + # get image specs with PIL + self.fp.seek(0) + im = imgopen(self.fp) + decoder, region, offset, parameters = im.tile[0] + x, y, width, height = region + del im + assert decoder == 'raw' + mode = parameters[0] + assert mode in ('RGB', 'L'), "Unsupported mode %s" % mode + + if mode == 'RGB' : + sampleSize = 3 + elif mode == 'L' : + sampleSize = 1 + + self.width = width + self.height = height + self.offset = offset + self.mode = parameters[0] + self.sampleSize = sampleSize + self._setTileSize(tileSize) + + def _setTileSize(self, tileSize) : + self.tileSize = tileSize + self.tilesX = int(ceil(float(self.width) / self.tileSize)) + self.tilesY = int(ceil(float(self.height) / self.tileSize)) + + def getTile(self, xt, yt) : + f = self.fp + ss = self.sampleSize + x = xt * self.tileSize + y = yt * self.tileSize + start = (self.width * y + x) * ss + self.offset + + tw = th = self.tileSize + + bw = self.width - x + if bw < self.tileSize : + tw = bw + bh = self.height - y + if bh < self.tileSize : + th = bh + + assert tw > 0 and th > 0, "Tile requested out of image." + + size = (tw, th) + tll = tw * ss + jump = (self.width - tw) * ss + + f.seek(start) + data = StringIO() + + for line in xrange(size[1]) : + data.write(f.read(tll)) + f.seek(jump, 1) + + data.seek(0) + im = fromstring(self.mode, size, data.read()) + return im + + def getTileSequence(self): + seq = [] + for y in xrange(self.tilesY) : + for x in xrange(self.tilesX) : + seq.append((x, y)) + return seq + + def resize(self, ratio=None, maxLength=None) : + if ratio and maxLength : + raise AttributeError("'ratio' and 'size' are mutually exclusive.") + if maxLength : + maxFullLength = max(self.width, self.height) + ratio = float(maxLength) / maxFullLength + + tileSizeBak = self.tileSize + + self._setTileSize(RESIZING_TILE_SIZE) + + width = height = 0 + # cumul des arrondis + width = int(round(self.tileSize * ratio)) * (self.tilesX -1) + width += int(round((self.width - self.tileSize * (self.tilesX -1)) * ratio)) + + height = int(round(self.tileSize * ratio)) * (self.tilesY -1) + height += int(round((self.height - self.tileSize * (self.tilesY -1)) * ratio)) + + magic = self.mode == 'RGB' and 6 or 5 + head = 'P%d %d %d 255\n' % (magic, width, height) + offset = len(head) + + out = TemporaryFile(mode='w+') + out.write(head) + + ss = self.sampleSize + rTll = int(round(self.tileSize * ratio)) + + for x, y in self.getTileSequence() : + # print 'resize', (x,y) + tile = self.getTile(x,y) + tileSize = tile.size + size = map(lambda l : int(round(l * ratio)), tileSize) + + if size[0] and size[1] : + resized = tile.resize(size, ANTIALIAS) + data = resized.tostring() + + start = (y * width + x) * ss * rTll + offset + jump = (width - size[0]) * ss + + out.seek(start) + tll = size[0] * ss + + # écriture dans le bon ordre (c'est quand même plus agréable à l'œil) + for l in xrange(size[1]) : + lineData = data[l*tll:(l+1)*tll] + out.write(lineData) + out.seek(jump, 1) + + out.seek(0,2) + length = out.tell() + assert length - len(head) == width * height * ss, (length - len(head), width * height * ss) + out.seek(0) + + self._setTileSize(tileSizeBak) + return PPMFile(out, tileSize=tileSizeBak, isRaw=True) + + def getImage(self) : + self.fp.seek(0) + return imgopen(self.fp) + + def __del__(self) : + self.fp.close() + + +if __name__ == '__main__' : + f = open('/Users/pinbe/Desktop/Chauve_souris.jpg') + try : + ppm = PPMFile(f, tileSize=256) + rppm = ppm.resize(maxLength=800) + im = rppm.getImage() + im.show() + for x, y in ppm.getTileSequence() : + im = ppm.getTile(x, y) + im.save('testoutput/%d_%d.jpg' % (x, y), 'JPEG', quality=90) + finally : + f.close() diff --git a/readme.txt b/readme.txt new file mode 100755 index 0000000..80b2183 --- /dev/null +++ b/readme.txt @@ -0,0 +1,22 @@ +Photo product extends the default Zope Image product and add image resizing support. + +Thumbnail Support + + You can get a thumbnail copy of a Photo instance by +adding '/getThumbnail' in the request url. +This thumbnail returned is a persistent standard +Zope Image instance. You can set thumbnail's creation +parameters (height, width, filter and auto refreshing) +into the Photo's property sheet. + +Volatile resizing support + + You can also get a volatile copy of the Photo instance +by adding '/getResizedImage' in the request url. The +default size is (800, 600) px. There is two ways to customize this size : + +- pass an optional parameter size = (width, height) to 'getResizedImage' + +- set the session variable named 'preferedImageSize' with a tuple (width, height) + +note : the request var name must be 'SESSION' (default value of Session Data Manager). \ No newline at end of file diff --git a/standards/__init__.py b/standards/__init__.py new file mode 100755 index 0000000..4287ca8 --- /dev/null +++ b/standards/__init__.py @@ -0,0 +1 @@ +# \ No newline at end of file diff --git a/standards/bridges/__init__.py b/standards/bridges/__init__.py new file mode 100755 index 0000000..3c499ec --- /dev/null +++ b/standards/bridges/__init__.py @@ -0,0 +1 @@ +from _bridges import xmp2exif, exif2xmp \ No newline at end of file diff --git a/standards/bridges/_bridges.py b/standards/bridges/_bridges.py new file mode 100755 index 0000000..b637a82 --- /dev/null +++ b/standards/bridges/_bridges.py @@ -0,0 +1,16 @@ +from os.path import join +from Globals import package_home +home = package_home(globals()) + +f = file(join(home, 'xmp_exif.csv')) +lines = f.readlines() +f.close() +xmp2exif = {} +exif2xmp = {} + +for l in [l for l in lines if not l.startswith('#')] : + fields = [f.strip() for f in l.split(';')] + assert len(fields) == 2, "%s malformed at line: '%s')" % (path, l) + xmpName, exifTag = fields + xmp2exif[xmpName] = exifTag + exif2xmp[exifTag] = xmpName diff --git a/standards/bridges/xmp_exif.csv b/standards/bridges/xmp_exif.csv new file mode 100644 index 0000000..316c98f --- /dev/null +++ b/standards/bridges/xmp_exif.csv @@ -0,0 +1,104 @@ +#xmp field; exif tag +tiff:ImageWidth;0x100 +tiff:ImageLength;0x101 +tiff:BitsPerSample;0x102 +tiff:Compression;0x103 +tiff:PhotometricInterpretation;0x106 +tiff:Orientation;0x112 +tiff:SamplesPerPixel;0x115 +tiff:PlanarConfiguration;0x11C +tiff:YCbCrSubSampling;0x212 +tiff:YCbCrPositioning;0x213 +tiff:XResolution;0x11A +tiff:YResolution;0x11B +tiff:ResolutionUnit;0x128 +tiff:TransferFunction;0x12D +tiff:WhitePoint;0x13E +tiff:PrimaryChromaticities;0x13F +tiff:YCbCrCoefficients;0x211 +tiff:ReferenceBlackWhite;0x214 +tiff:DateTime;0x9290 +tiff:ImageDescription;0x10E +tiff:Make;0x10F +tiff:Model;0x110 +tiff:Software;0x131 +tiff:Artist;0x13B +tiff:Copyright;0x8298 +exif:ExifVersion;0x9000 +exif:FlashpixVersion;0xA000 +exif:ColorSpace;0xA001 +exif:ComponentsConfiguration;0x9101 +exif:CompressedBitsPerPixel;0x9102 +exif:PixelXDimension;0xA002 +exif:PixelYDimension;0xA003 +exif:UserComment;0x9286 +exif:RelatedSoundFile;0xA004 +exif:DateTimeOriginal;0x9291 +exif:DateTimeDigitized;0x9292 +exif:ExposureTime;0x829A +exif:FNumber;0x829D +exif:ExposureProgram;0x8822 +exif:SpectralSensitivity;0x8824 +exif:ISOSpeedRatings;0x8827 +exif:OECF;0x8828 +exif:ShutterSpeedValue;0x9201 +exif:ApertureValue;0x9202 +exif:BrightnessValue;0x9203 +exif:ExposureBiasValue;0x9204 +exif:MaxApertureValue;0x9205 +exif:SubjectDistance;0x9206 +exif:MeteringMode;0x9207 +exif:LightSource;0x9208 +exif:Flash;0x9209 +exif:FocalLength;0x920A +exif:SubjectArea;0x9214 +exif:FlashEnergy;0xA20B +exif:SpatialFrequencyResponse;0xA20C +exif:FocalPlaneXResolution;0xA20E +exif:FocalPlaneYResolution;0xA20F +exif:FocalPlaneResolutionUnit;0xA210 +exif:SubjectLocation;0xA214 +exif:ExposureIndex;0xA215 +exif:SensingMethod;0xA217 +exif:FileSource;0xA300 +exif:SceneType;0xA301 +exif:CFAPattern;0xA302 +exif:CustomRendered;0xA401 +exif:ExposureMode;0xA402 +exif:WhiteBalance;0xA403 +exif:DigitalZoomRatio;0xA404 +exif:FocalLengthIn35mmFilm;0xA405 +exif:SceneCaptureType;0xA406 +exif:GainControl;0xA407 +exif:Contrast;0xA408 +exif:Saturation;0xA409 +exif:Sharpness;0xA40A +exif:DeviceSettingDescription;0xA40B +exif:SubjectDistanceRange;0xA40C +exif:ImageUniqueID;0xA420 +exif:GPSVersionID;0x00 +exif:GPSLatitude;0x01 +exif:GPSLongitude;0x03 +exif:GPSAltitudeRef;0x5 +exif:GPSAltitude;0x06 +exif:GPSTimeStamp;0x1D +exif:GPSSatellites;0x08 +exif:GPSStatus;0x09 +exif:GPSMeasureMode;0x0A +exif:GPSDOP;0x0B +exif:GPSSpeedRef;0x0C +exif:GPSSpeed;0x0D +exif:GPSTrackRef;0x0E +exif:GPSTrack;0x0F +exif:GPSImgDirectionRef;0x10 +exif:GPSImgDirection;0x11 +exif:GPSMapDatum;0x12 +exif:GPSDestLatitude;0x13 +exif:GPSDestLongitude;0x15 +exif:GPSDestBearingRef;0x17 +exif:GPSDestBearing;0x18 +exif:GPSDestDistanceRef;0x19 +exif:GPSDestDistance;0x1A +exif:GPSProcessingMethod;0x1B +exif:GPSAreaInformation;0x1C +exif:GPSDifferential;0x1E diff --git a/standards/exif/0thIFDExifPrivateTags.csv b/standards/exif/0thIFDExifPrivateTags.csv new file mode 100644 index 0000000..57c939a --- /dev/null +++ b/standards/exif/0thIFDExifPrivateTags.csv @@ -0,0 +1,60 @@ +# Table 15 Tag Support Levels (2) - 0th IFD Exif Private Tags +# Tag Name Field Name ; Tag ID ; Uncompressed ; Compressed ; +# Dec ; Hex ; Chunky ; Planar ; YCC ; +Exposure time ExposureTime ; 33434 ; 829A ; R ; R ; R ; R ; +F number FNumber ; 33437 ; 829D ; O ; O ; O ; O ; +Exposure program ExposureProgram ; 34850 ; 8822 ; O ; O ; O ; O ; +Spectral sensitivity SpectralSensitivity ; 34852 ; 8824 ; O ; O ; O ; O ; +ISO speed ratings ISOSpeedRatings ; 34855 ; 8827 ; O ; O ; O ; O ; +Optoelectric coefficient OECF ; 34856 ; 8828 ; O ; O ; O ; O ; +Exif Version ExifVersion ; 36864 ; 9000 ; M ; M ; M ; M ; +Date and time original image was generated DateTimeOriginal ; 36867 ; 9003 ; O ; O ; O ; O ; +Date and time image was made digital data DateTimeDigitized ; 36868 ; 9004 ; O ; O ; O ; O ; +Meaning of each component ComponentsConfiguration ; 37121 ; 9101 ; N ; N ; N ; M ; +Image compression mode CompressedBitsPerPixel ; 37122 ; 9102 ; N ; N ; N ; O ; +Shutter speed ShutterSpeedValue ; 37377 ; 9201 ; O ; O ; O ; O ; +Aperture ApertureValue ; 37378 ; 9202 ; O ; O ; O ; O ; +Brightness BrightnessValue ; 37379 ; 9203 ; O ; O ; O ; O ; +Exposure bias ExposureBiasValue ; 37380 ; 9204 ; O ; O ; O ; O ; +Maximum lens aperture MaxApertureValue ; 37381 ; 9205 ; O ; O ; O ; O ; +Subject distance SubjectDistance ; 37382 ; 9206 ; O ; O ; O ; O ; +Metering mode MeteringMode ; 37383 ; 9207 ; O ; O ; O ; O ; +Light source LightSource ; 37384 ; 9208 ; O ; O ; O ; O ; +Flash Flash ; 37385 ; 9209 ; R ; R ; R ; R ; +Lens focal length FocalLength ; 37386 ; 920A ; O ; O ; O ; O ; +Subject area SubjectArea ; 37396 ; 9214 ; O ; O ; O ; O ; +Manufacturer notes MakerNote ; 37500 ; 927C ; O ; O ; O ; O ; +User comments UserComment ; 37510 ; 9286 ; O ; O ; O ; O ; +DateTime subseconds SubSecTime ; 37520 ; 9290 ; O ; O ; O ; O ; +DateTimeOriginal subseconds SubSecTimeOriginal ; 37521 ; 9291 ; O ; O ; O ; O ; +DateTimeDigitized subseconds SubSecTimeDigitized ; 37522 ; 9292 ; O ; O ; O ; O ; +Supported Flashpix version FlashpixVersion ; 40960 ; A000 ; M ; M ; M ; M ; +Color space information ColorSpace ; 40961 ; A001 ; M ; M ; M ; M ; +Valid image width PixelXDimension ; 40962 ; A002 ; N ; N ; N ; M ; +Valid image height PixelYDimension ; 40963 ; A003 ; N ; N ; N ; M ; +Related audio file RelatedSoundFile ; 40964 ; A004 ; O ; O ; O ; O ; +Interoperability tag Interoperability IFD Pointer ; 40965 ; A005 ; N ; N ; N ; O ; +Flash energy FlashEnergy ; 41483 ; A20B ; O ; O ; O ; O ; +Spatial frequency response SpatialFrequencyResponse ; 41484 ; A20C ; O ; O ; O ; O ; +Focal plane X resolution FocalPlaneXResolution ; 41486 ; A20E ; O ; O ; O ; O ; +Focal plane Y resolution FocalPlaneYResolution ; 41487 ; A20F ; O ; O ; O ; O ; +Focal plane resolution unit FocalPlaneResolutionUnit ; 41488 ; A210 ; O ; O ; O ; O ; +Subject location SubjectLocation ; 41492 ; A214 ; O ; O ; O ; O ; +Exposure index ExposureIndex ; 41493 ; A215 ; O ; O ; O ; O ; +Sensing method SensingMethod ; 41495 ; A217 ; O ; O ; O ; O ; +File source FileSource ; 41728 ; A300 ; O ; O ; O ; O ; +Scene type SceneType ; 41729 ; A301 ; O ; O ; O ; O ; +CFA pattern CFAPattern ; 41730 ; A302 ; O ; O ; O ; O ; +Custom image processing CustomRendered ; 41985 ; A401 ; O ; O ; O ; O ; +Exposure mode ExposureMode ; 41986 ; A402 ; R ; R ; R ; R ; +White balance WhiteBalance ; 41987 ; A403 ; R ; R ; R ; R ; +Digital zoom ratio DigitalZoomRatio ; 41988 ; A404 ; O ; O ; O ; O ; +Focal length in 35 mm film FocalLengthIn35mmFilm ; 41989 ; A405 ; O ; O ; O ; O ; +Scene capture type SceneCaptureType ; 41990 ; A406 ; R ; R ; R ; R ; +Gain control GainControl ; 41991 ; A407 ; O ; O ; O ; O ; +Contrast Contrast ; 41992 ; A408 ; O ; O ; O ; O ; +Saturation Saturation ; 41993 ; A409 ; O ; O ; O ; O ; +Sharpness Sharpness ; 41994 ; A40A ; O ; O ; O ; O ; +Device settings description DeviceSettingDescription ; 41995 ; A40B ; O ; O ; O ; O ; +Subject distance range SubjectDistanceRange ; 41996 ; A40C ; O ; O ; O ; O ; +Unique image ID ImageUniqueID ; 42016 ; A420 ; O ; O ; O ; O ; diff --git a/standards/exif/0thIFDGPSInfoTags.csv b/standards/exif/0thIFDGPSInfoTags.csv new file mode 100644 index 0000000..909cf17 --- /dev/null +++ b/standards/exif/0thIFDGPSInfoTags.csv @@ -0,0 +1,34 @@ +# Table 16 Tag Support Levels (3) - 0th IFD GPS Info Tags +# Tag Name Field Name ; Tag ID ; Uncompressed ; Comp-r essed ; +# Dec ; Hex ; Chunky ; Planar ; YCC ; +GPS tag version GPSVersionID ; 0 ; 0 ; O ; O ; O ; O ; +North or South Latitude GPSLatitudeRef ; 1 ; 1 ; O ; O ; O ; O ; +Latitude GPSLatitude ; 2 ; 2 ; O ; O ; O ; O ; +East or West Longitude GPSLongitudeRef ; 3 ; 3 ; O ; O ; O ; O ; +Longitude GPSLongitude ; 4 ; 4 ; O ; O ; O ; O ; +Altitude reference GPSAltitudeRef ; 5 ; 5 ; O ; O ; O ; O ; +Altitude GPSAltitude ; 6 ; 6 ; O ; O ; O ; O ; +GPS time (atomic clock) GPSTimeStamp ; 7 ; 7 ; O ; O ; O ; O ; +GPS satellites used for measurement GPSSatellites ; 8 ; 8 ; O ; O ; O ; O ; +GPS receiver status GPSStatus ; 9 ; 9 ; O ; O ; O ; O ; +GPS measurement mode GPSMeasureMode ; 10 ; A ; O ; O ; O ; O ; +Measurement precision GPSDOP ; 11 ; B ; O ; O ; O ; O ; +Speed unit GPSSpeedRef ; 12 ; C ; O ; O ; O ; O ; +Speed of GPS receiver GPSSpeed ; 13 ; D ; O ; O ; O ; O ; +Reference for direction of movement GPSTrackRef ; 14 ; E ; O ; O ; O ; O ; +Direction of movement GPSTrack ; 15 ; F ; O ; O ; O ; O ; +Reference for direction of image GPSImgDirectionRef ; 16 ; 10 ; O ; O ; O ; O ; +Direction of image GPSImgDirection ; 17 ; 11 ; O ; O ; O ; O ; +Geodetic survey data used GPSMapDatum ; 18 ; 12 ; O ; O ; O ; O ; +Reference for latitude of destination GPSDestLatitudeRef ; 19 ; 13 ; O ; O ; O ; O ; +Latitude of destination GPSDestLatitude ; 20 ; 14 ; O ; O ; O ; O ; +Reference for longitude of destination GPSDestLongitudeRef ; 21 ; 15 ; O ; O ; O ; O ; +Longitude of destination GPSDestLongitude ; 22 ; 16 ; O ; O ; O ; O ; +Reference for bearing of destination GPSDestBearingRef ; 23 ; 17 ; O ; O ; O ; O ; +Bearing of destination GPSDestBearing ; 24 ; 18 ; O ; O ; O ; O ; +Reference for distance to destination GPSDestDistanceRef ; 25 ; 19 ; O ; O ; O ; O ; +Distance to destination GPSDestDistance ; 26 ; 1A ; O ; O ; O ; O ; +Name of GPS processing method GPSProcessingMethod ; 27 ; 1B ; O ; O ; O ; O ; +Name of GPS area GPSAreaInformation ; 28 ; 1C ; O ; O ; O ; O ; +GPS date GPSDateStamp ; 29 ; 1D ; O ; O ; O ; O ; +GPS differential correction GPSDifferential ; 30 ; 1E ; O ; O ; O ; O ; diff --git a/standards/exif/0thIFDInteroperabilityTag.csv b/standards/exif/0thIFDInteroperabilityTag.csv new file mode 100644 index 0000000..85c63eb Binary files /dev/null and b/standards/exif/0thIFDInteroperabilityTag.csv differ diff --git a/standards/exif/0thIFDTIFFTags.csv b/standards/exif/0thIFDTIFFTags.csv new file mode 100644 index 0000000..6635dfa --- /dev/null +++ b/standards/exif/0thIFDTIFFTags.csv @@ -0,0 +1,35 @@ +# Table 14 Tag Support Levels (1) - 0th IFD TIFF Tags +# Tag Name Field Name ; Tag ID ; Uncompressed ; Compresse d ; +# Dec ; Hex ; Chunky ; Planar ; YCC ; +Image width ImageWidth ; 256 ; 100 ; M ; M ; M ; J ; +Image height ImageLength ; 257 ; 101 ; M ; M ; M ; J ; +Number of bits per component BitsPerSample ; 258 ; 102 ; M ; M ; M ; J ; +Compression scheme Compression ; 259 ; 103 ; M ; M ; M ; J ; +Pixel composition PhotometricInterpretation ; 262 ; 106 ; M ; M ; M ; N ; +Image title ImageDescription ; 270 ; 10E ; R ; R ; R ; R ; +Manufacturer of image input equipment Make ; 271 ; 10F ; R ; R ; R ; R ; +Model of image input equipment Model ; 272 ; 110 ; R ; R ; R ; R ; +Image data location StripOffsets ; 273 ; 111 ; M ; M ; M ; N ; +Orientation of image Orientation ; 274 ; 112 ; R ; R ; R ; R ; +Number of components SamplesPerPixel ; 277 ; 115 ; M ; M ; M ; J ; +Number of rows per strip RowsPerStrip ; 278 ; 116 ; M ; M ; M ; N ; +Bytes per compressed strip StripByteCounts ; 279 ; 117 ; M ; M ; M ; N ; +Image resolution in width direction XResolution ; 282 ; 11A ; M ; M ; M ; M ; +Image resolution in height direction YResolution ; 283 ; 11B ; M ; M ; M ; M ; +Image data arrangement PlanarConfiguration ; 284 ; 11C ; O ; M ; O ; J ; +Unit of X and Y resolution ResolutionUnit ; 296 ; 128 ; M ; M ; M ; M ; +Transfer function TransferFunction ; 301 ; 12D ; R ; R ; R ; R ; +Software used Software ; 305 ; 131 ; O ; O ; O ; O ; +File change date and time DateTime ; 306 ; 132 ; R ; R ; R ; R ; +Person who created the image Artist ; 315 ; 13B ; O ; O ; O ; O ; +White point chromaticity WhitePoint ; 318 ; 13E ; O ; O ; O ; O ; +Chromaticities of primaries PrimaryChromaticities ; 319 ; 13F ; O ; O ; O ; O ; +Offset to JPEG SOI JPEGInterchangeFormat ; 513 ; 201 ; N ; N ; N ; N ; +Bytes of JPEG data JPEGInterchangeFormatLength ; 514 ; 202 ; N ; N ; N ; N ; +Color space transformation matrix coefficients YCbCrCoefficients ; 529 ; 211 ; N ; N ; O ; O ; +Subsampling ratio of Y to C YCbCrSubSampling ; 530 ; 212 ; N ; N ; M ; J ; +Y and C positioning YCbCrPositioning ; 531 ; 213 ; N ; N ; M ; M ; +Pair of black and white reference values ReferenceBlackWhite ; 532 ; 214 ; O ; O ; O ; O ; +Copyright holder Copyright ; 33432 ; 8298 ; O ; O ; O ; O ; +Exif tag Exif IFD Pointer ; 34665 ; 8769 ; M ; M ; M ; M ; +GPS tag GPSInfo IFD Pointer ; 34853 ; 8825 ; O ; O ; O ; O ; diff --git a/standards/exif/1stIFDTIFFTag.csv b/standards/exif/1stIFDTIFFTag.csv new file mode 100644 index 0000000..7bdd80b --- /dev/null +++ b/standards/exif/1stIFDTIFFTag.csv @@ -0,0 +1,35 @@ +# Table 18 Tag Support Levels (5) - 1st IFD TIFF Tag +# Tag Name Field Name ; Tag ID ; Uncompressed ; Comp-ressed ; +# Dec ; Hex ; Chunky ; Planar ; YCC ; +Image width ImageWidth ; 256 ; 100 ; M ; M ; M ; J ; +Image height ImageLength ; 257 ; 101 ; M ; M ; M ; J ; +Number of bits per component BitsPerSample ; 258 ; 102 ; M ; M ; M ; J ; +Compression scheme Compression ; 259 ; 103 ; M ; M ; M ; M ; +Pixel composition PhotometricInterpretation ; 262 ; 106 ; M ; M ; M ; J ; +Image title ImageDescription ; 270 ; 10E ; O ; O ; O ; O ; +Manufacturer of image input equipment Make ; 271 ; 10F ; O ; O ; O ; O ; +Model of image input equipment Model ; 272 ; 110 ; O ; O ; O ; O ; +Image data location StripOffsets ; 273 ; 111 ; M ; M ; M ; N ; +Orientation of image Orientation ; 274 ; 112 ; O ; O ; O ; O ; +Number of components SamplesPerPixel ; 277 ; 115 ; M ; M ; M ; J ; +Number of rows per strip RowsPerStrip ; 278 ; 116 ; M ; M ; M ; N ; +Bytes per compressed strip StripByteCounts ; 279 ; 117 ; M ; M ; M ; N ; +Image resolution in width direction XResolution ; 282 ; 11A ; M ; M ; M ; M ; +Image resolution in height direction YResolution ; 283 ; 11B ; M ; M ; M ; M ; +Image data arrangement PlanarConfiguration ; 284 ; 11C ; O ; M ; O ; J ; +Unit of X and Y resolution ResolutionUnit ; 296 ; 128 ; M ; M ; M ; M ; +Transfer function TransferFunction ; 301 ; 12D ; O ; O ; O ; O ; +Software used Software ; 305 ; 131 ; O ; O ; O ; O ; +File change date and time DateTime ; 306 ; 132 ; O ; O ; O ; O ; +Person who created the image Artist ; 315 ; 13B ; O ; O ; O ; O ; +White point chromaticity WhitePoint ; 318 ; 13E ; O ; O ; O ; O ; +Chromaticities of primaries PrimaryChromaticities ; 319 ; 13F ; O ; O ; O ; O ; +Offset to JPEG SOI JPEGInterchangeFormat ; 513 ; 201 ; N ; N ; N ; M ; +Bytes of JPEG data JPEGInterchangeFormatLength ; 514 ; 202 ; N ; N ; N ; M ; +Color space transformation matrix coefficients YCbCrCoefficients ; 529 ; 211 ; N ; N ; O ; O ; +Subsampling ratio of Y to C YCbCrSubSampling ; 530 ; 212 ; N ; N ; M ; J ; +Y and C positioning YCbCrPositioning ; 531 ; 213 ; N ; N ; O ; O ; +Pair of black and white reference values ReferenceBlackWhite ; 532 ; 214 ; O ; O ; O ; O ; +Copyright holder Copyright ; 33432 ; 8298 ; O ; O ; O ; O ; +Exif tag Exif IFD Pointer ; 34665 ; 8769 ; O ; O ; O ; O ; +GPS tag GPSInfo IFD Pointer ; 34853 ; 8825 ; O ; O ; O ; O ; diff --git a/standards/exif/__init__.py b/standards/exif/__init__.py new file mode 100755 index 0000000..6d00259 --- /dev/null +++ b/standards/exif/__init__.py @@ -0,0 +1 @@ +from _exif_tags import TAGS, TAG_TYPES diff --git a/standards/exif/_exif_tags.py b/standards/exif/_exif_tags.py new file mode 100755 index 0000000..afb6836 --- /dev/null +++ b/standards/exif/_exif_tags.py @@ -0,0 +1,43 @@ +""" Exif tags based on JEITA CP-3451 Exif Version 2.2 specification tables. + +$Id: _exif_tags.py 360 2008-02-21 09:17:32Z pin $ +$URL: http://svn.luxia.fr/svn/labo/projects/zope/Photo/trunk/standards/exif/_exif_tags.py $ +""" +from os.path import join +from Globals import package_home +home = package_home(globals()) + +files = [ +# 'gpsA.csv' + 'ifdA.csv' + , 'ifdB.csv' + , 'ifdC.csv' + , 'ifdD.csv' + , 'ifdE.csv' + , 'ifdF.csv' + , 'ifdG.csv' + , 'ifdH.csv' + , 'tiffA.csv' + , 'tiffB.csv' + , 'tiffC.csv' + , 'tiffD.csv' + , 'hand_added.csv' +] + + +TAGS = {} +TAG_TYPES = {} + +for name in files : + f = file(join(home, name)) + lines = f.readlines() + f.close() + for l in [l for l in lines if not l.startswith('#')] : + fields = [f.strip() for f in l.split(';')] + assert len(fields) == 7, "%s malformed at line: '%s')" % (path, l) + tagName, fieldName, tagIdDec, noise, typ, count, noise = fields + tagId = int(tagIdDec) + if TAGS.has_key(tagId) : + raise ValueError, "%x tag is already defined" % tagId + TAGS[tagId] = (fieldName, tagName) + TAG_TYPES[tagId] = (typ, count) diff --git a/standards/exif/gpsA.csv b/standards/exif/gpsA.csv new file mode 100644 index 0000000..55122f0 --- /dev/null +++ b/standards/exif/gpsA.csv @@ -0,0 +1,31 @@ +GPS tag version ; GPSVersionID ; 0 ; 0 ; BYTE ; 4 ; +North or South Latitude ; GPSLatitudeRef ; 1 ; 1 ; ASCII ; 2 ; +Latitude ; GPSLatitude ; 2 ; 2 ; RATIONAL ; 3 ; +East or West Longitude ; GPSLongitudeRef ; 3 ; 3 ; ASCII ; 2 ; +Longitude ; GPSLongitude ; 4 ; 4 ; RATIONAL ; 3 ; +Altitude reference ; GPSAltitudeRef ; 5 ; 5 ; BYTE ; 1 ; +Altitude ; GPSAltitude ; 6 ; 6 ; RATIONAL ; 1 ; +GPS time (atomic clock) ; GPSTimeStamp ; 7 ; 7 ; RATIONAL ; 3 ; +GPS satellites used for measurement ; GPSSatellites ; 8 ; 8 ; ASCII ; Any ; +GPS receiver status ; GPSStatus ; 9 ; 9 ; ASCII ; 2 ; +GPS measurement mode ; GPSMeasureMode ; 10 ; A ; ASCII ; 2 ; +Measurement precision ; GPSDOP ; 11 ; B ; RATIONAL ; 1 ; +Speed unit ; GPSSpeedRef ; 12 ; C ; ASCII ; 2 ; +Speed of GPS receiver ; GPSSpeed ; 13 ; D ; RATIONAL ; 1 ; +Reference for direction of movement ; GPSTrackRef ; 14 ; E ; ASCII ; 2 ; +Direction of movement ; GPSTrack ; 15 ; F ; RATIONAL ; 1 ; +Reference for direction of image ; GPSImgDirectionRef ; 16 ; 10 ; ASCII ; 2 ; +Direction of image ; GPSImgDirection ; 17 ; 11 ; RATIONAL ; 1 ; +Geodetic survey data used ; GPSMapDatum ; 18 ; 12 ; ASCII ; Any ; +Reference for latitude of destination ; GPSDestLatitudeRef ; 19 ; 13 ; ASCII ; 2 ; +Latitude of destination ; GPSDestLatitude ; 20 ; 14 ; RATIONAL ; 3 ; +Reference for longitude of destination ; GPSDestLongitudeRef ; 21 ; 15 ; ASCII ; 2 ; +Longitude of destination ; GPSDestLongitude ; 22 ; 16 ; RATIONAL ; 3 ; +Reference for bearing of destination ; GPSDestBearingRef ; 23 ; 17 ; ASCII ; 2 ; +Bearing of destination ; GPSDestBearing ; 24 ; 18 ; RATIONAL ; 1 ; +Reference for distance to destination ; GPSDestDistanceRef ; 25 ; 19 ; ASCII ; 2 ; +Distance to destination ; GPSDestDistance ; 26 ; 1A ; RATIONAL ; 1 ; +Name of GPS processing method ; GPSProcessingMethod ; 27 ; 1B ; UNDEFINED ; Any ; +Name of GPS area ; GPSAreaInformation ; 28 ; 1C ; UNDEFINED ; Any ; +GPS date ; GPSDateStamp ; 29 ; 1D ; ASCII ; 11 ; +GPS differential correction ; GPSDifferential ; 30 ; 1E ; SHORT ; 1 ; diff --git a/standards/exif/hand_added.csv b/standards/exif/hand_added.csv new file mode 100644 index 0000000..e4b9ef0 --- /dev/null +++ b/standards/exif/hand_added.csv @@ -0,0 +1,6 @@ +Exif tag ; Exif IFD Pointer ; 34665 ; 8769 ; LONG ; 1 ; +Interoperability tag ; Interoperability IFD Pointer ; 40965 ; A005 ; LONG ; 1 ; +Related Image Width ; RelatedImageWidth ; 4097 ; 1001 ; SHORT ; 1 ; +Related Image Height ; RelatedImageHeight ; 4098 ; 1002 ; SHORT ; 1 ; +Interoperability Identification ; InteroperabilityIndex ; 1 ; 1 ; ASCII ; Any ; +Interoperability Version ; InteroperabilityVersion ; 2 ; 2 ; UNDEFINED ; Any ; diff --git a/standards/exif/ifdA.csv b/standards/exif/ifdA.csv new file mode 100644 index 0000000..3f510f8 --- /dev/null +++ b/standards/exif/ifdA.csv @@ -0,0 +1,2 @@ +Exif version ; ExifVersion ; 36864 ; 9000 ; UNDEFINED ; 4 ; +Supported Flashpix version ; FlashpixVersion ; 40960 ; A000 ; UNDEFINED ; 4 ; diff --git a/standards/exif/ifdB.csv b/standards/exif/ifdB.csv new file mode 100644 index 0000000..7c0f76f --- /dev/null +++ b/standards/exif/ifdB.csv @@ -0,0 +1 @@ +Color space information ; ColorSpace ; 40961 ; A001 ; SHORT ; 1 ; diff --git a/standards/exif/ifdC.csv b/standards/exif/ifdC.csv new file mode 100644 index 0000000..93235e8 --- /dev/null +++ b/standards/exif/ifdC.csv @@ -0,0 +1,4 @@ + Meaning of each component ; ComponentsConfiguration ; 37121 ; 9101 ; UNDEFINED ; 4 ; + Image compression mode ; CompressedBitsPerPixel ; 37122 ; 9102 ; RATIONAL ; 1 ; + Valid image width ; PixelXDimension ; 40962 ; A002 ; SHORT or LONG ; 1 ; + Valid image height ; PixelYDimension ; 40963 ; A003 ; SHORT or LONG ; 1 ; diff --git a/standards/exif/ifdD.csv b/standards/exif/ifdD.csv new file mode 100644 index 0000000..44ddd41 --- /dev/null +++ b/standards/exif/ifdD.csv @@ -0,0 +1,2 @@ +Manufacturer notes ; MakerNote ; 37500 ; 927C ; UNDEFINED ; Any ; +User comments ; UserComment ; 37510 ; 9286 ; UNDEFINED ; Any ; diff --git a/standards/exif/ifdE.csv b/standards/exif/ifdE.csv new file mode 100644 index 0000000..afb3cee --- /dev/null +++ b/standards/exif/ifdE.csv @@ -0,0 +1 @@ +Related audio file ; RelatedSoundFile ; 40964 ; A004 ; ASCII ; 13 ; diff --git a/standards/exif/ifdF.csv b/standards/exif/ifdF.csv new file mode 100644 index 0000000..8d32732 --- /dev/null +++ b/standards/exif/ifdF.csv @@ -0,0 +1,5 @@ + Date and time of original data generation ; DateTimeOriginal ; 36867 ; 9003 ; ASCII ; 20 ; + Date and time of digital data generation ; DateTimeDigitized ; 36868 ; 9004 ; ASCII ; 20 ; + DateTime subseconds ; SubSecTime ; 37520 ; 9290 ; ASCII ; Any ; + DateTimeOriginal subseconds ; SubSecTimeOriginal ; 37521 ; 9291 ; ASCII ; Any ; + DateTimeDigitized subseconds ; SubSecTimeDigitized ; 37522 ; 9292 ; ASCII ; Any ; diff --git a/standards/exif/ifdG.csv b/standards/exif/ifdG.csv new file mode 100644 index 0000000..fca9336 --- /dev/null +++ b/standards/exif/ifdG.csv @@ -0,0 +1,40 @@ +Exposure time ; ExposureTime ; 33434 ; 829A ; RATIONAL ; 1 ; +F number ; FNumber ; 33437 ; 829D ; RATIONAL ; 1 ; +Exposure program ; ExposureProgram ; 34850 ; 8822 ; SHORT ; 1 ; +Spectral sensitivity ; SpectralSensitivity ; 34852 ; 8824 ; ASCII ; Any ; +ISO speed rating ; ISOSpeedRatings ; 34855 ; 8827 ; SHORT ; Any ; +Optoelectric conversion factor ; OECF ; 34856 ; 8828 ; UNDEFINED ; Any ; +Shutter speed ; ShutterSpeedValue ; 37377 ; 9201 ; SRATIONAL ; 1 ; +Aperture ; ApertureValue ; 37378 ; 9202 ; RATIONAL ; 1 ; +Brightness ; BrightnessValue ; 37379 ; 9203 ; SRATIONAL ; 1 ; +Exposure bias ; ExposureBiasValue ; 37380 ; 9204 ; SRATIONAL ; 1 ; +Maximum lens aperture ; MaxApertureValue ; 37381 ; 9205 ; RATIONAL ; 1 ; +Subject distance ; SubjectDistance ; 37382 ; 9206 ; RATIONAL ; 1 ; +Metering mode ; MeteringMode ; 37383 ; 9207 ; SHORT ; 1 ; +Light source ; LightSource ; 37384 ; 9208 ; SHORT ; 1 ; +Flash ; Flash ; 37385 ; 9209 ; SHORT ; 1 ; +Lens focal length ; FocalLength ; 37386 ; 920A ; RATIONAL ; 1 ; +Subject area ; SubjectArea ; 37396 ; 9214 ; SHORT ; 2 or 3 or 4 ; +Flash energy ; FlashEnergy ; 41483 ; A20B ; RATIONAL ; 1 ; +Spatial frequency response ; SpatialFrequencyResponse ; 41484 ; A20C ; UNDEFINED ; Any ; +Focal plane X resolution ; FocalPlaneXResolution ; 41486 ; A20E ; RATIONAL ; 1 ; +Focal plane Y resolution ; FocalPlaneYResolution ; 41487 ; A20F ; RATIONAL ; 1 ; +Focal plane resolution unit ; FocalPlaneResolutionUnit ; 41488 ; A210 ; SHORT ; 1 ; +Subject location ; SubjectLocation ; 41492 ; A214 ; SHORT ; 2 ; +Exposure index ; ExposureIndex ; 41493 ; A215 ; RATIONAL ; 1 ; +Sensing method ; SensingMethod ; 41495 ; A217 ; SHORT ; 1 ; +File source ; FileSource ; 41728 ; A300 ; UNDEFINED ; 1 ; +Scene type ; SceneType ; 41729 ; A301 ; UNDEFINED ; 1 ; +CFA pattern ; CFAPattern ; 41730 ; A302 ; UNDEFINED ; Any ; +Custom image processing ; CustomRendered ; 41985 ; A401 ; SHORT ; 1 ; +Exposure mode ; ExposureMode ; 41986 ; A402 ; SHORT ; 1 ; +White balance ; WhiteBalance ; 41987 ; A403 ; SHORT ; 1 ; +Digital zoom ratio ; DigitalZoomRatio ; 41988 ; A404 ; RATIONAL ; 1 ; +Focal length in 35 mm film ; FocalLengthIn35mmFilm ; 41989 ; A405 ; SHORT ; 1 ; +Scene capture type ; SceneCaptureType ; 41990 ; A406 ; SHORT ; 1 ; +Gain control ; GainControl ; 41991 ; A407 ; RATIONAL ; 1 ; +Contrast ; Contrast ; 41992 ; A408 ; SHORT ; 1 ; +Saturation ; Saturation ; 41993 ; A409 ; SHORT ; 1 ; +Sharpness ; Sharpness ; 41994 ; A40A ; SHORT ; 1 ; +Device settings description ; DeviceSettingDescription ; 41995 ; A40B ; UNDEFINED ; Any ; +Subject distance range ; SubjectDistanceRange ; 41996 ; A40C ; SHORT ; 1 ; diff --git a/standards/exif/ifdH.csv b/standards/exif/ifdH.csv new file mode 100644 index 0000000..20ade7c --- /dev/null +++ b/standards/exif/ifdH.csv @@ -0,0 +1 @@ +Unique image ID ; ImageUniqueID ; 42016 ; A420 ; ASCII ; 33 ; diff --git a/standards/exif/tiffA.csv b/standards/exif/tiffA.csv new file mode 100644 index 0000000..2ca0ac7 --- /dev/null +++ b/standards/exif/tiffA.csv @@ -0,0 +1,14 @@ +# Tags relating to image data structure +Image width ; ImageWidth ; 256 ; 100 ; SHORT or LONG ; 1 ; +Image height ; ImageLength ; 257 ; 101 ; SHORT or LONG ; 1 ; +Number of bits per component ; BitsPerSample ; 258 ; 102 ; SHORT ; 3 ; +Compression scheme ; Compression ; 259 ; 103 ; SHORT ; 1 ; +Pixel composition ; PhotometricInterpretation ; 262 ; 106 ; SHORT ; 1 ; +Orientation of image ; Orientation ; 274 ; 112 ; SHORT ; 1 ; +Number of components ; SamplesPerPixel ; 277 ; 115 ; SHORT ; 1 ; +Image data arrangement ; PlanarConfiguration ; 284 ; 11C ; SHORT ; 1 ; +Subsampling ratio of Y to C ; YCbCrSubSampling ; 530 ; 212 ; SHORT ; 2 ; +Y and C positioning ; YCbCrPositioning ; 531 ; 213 ; SHORT ; 1 ; +Image resolution in width direction ; XResolution ; 282 ; 11A ; RATIONAL ; 1 ; +Image resolution in height direction ; YResolution ; 283 ; 11B ; RATIONAL ; 1 ; +Unit of X and Y resolution ; ResolutionUnit ; 296 ; 128 ; SHORT ; 1 ; diff --git a/standards/exif/tiffB.csv b/standards/exif/tiffB.csv new file mode 100644 index 0000000..bdfd08a --- /dev/null +++ b/standards/exif/tiffB.csv @@ -0,0 +1,6 @@ +# Tags relating to recording offset +Image data location ; StripOffsets ; 273 ; 111 ; SHORT or LONG ; *S ; +Number of rows per strip ; RowsPerStrip ; 278 ; 116 ; SHORT or LONG ; 1 ; +Bytes per compressed strip ; StripByteCounts ; 279 ; 117 ; SHORT or LONG ; *S ; +Offset to JPEG SOI ; JPEGInterchangeFormat ; 513 ; 201 ; LONG ; 1 ; +Bytes of JPEG data ; JPEGInterchangeFormatLength ; 514 ; 202 ; LONG ; 1 ; \ No newline at end of file diff --git a/standards/exif/tiffC.csv b/standards/exif/tiffC.csv new file mode 100644 index 0000000..a141392 --- /dev/null +++ b/standards/exif/tiffC.csv @@ -0,0 +1,6 @@ +# Tags relating to image data characteristics +Transfer function ; TransferFunction ; 301 ; 12D ; SHORT ; 3 * 256 ; +White point chromaticity ; WhitePoint ; 318 ; 13E ; RATIONAL ; 2 ; +Chromaticities of primaries ; PrimaryChromaticities ; 319 ; 13F ; RATIONAL ; 6 ; +Color space transformation matrix coefficients ; YCbCrCoefficients ; 529 ; 211 ; RATIONAL ; 3 ; +Pair of black and white reference values ; ReferenceBlackWhite ; 532 ; 214 ; RATIONAL ; 6 ; diff --git a/standards/exif/tiffD.csv b/standards/exif/tiffD.csv new file mode 100644 index 0000000..dc020a6 --- /dev/null +++ b/standards/exif/tiffD.csv @@ -0,0 +1,8 @@ +# Other tags +File change date and time ; DateTime ; 306 ; 132 ; ASCII ; 20 ; +Image title ; ImageDescription ; 270 ; 10E ; ASCII ; Any ; +Image input equipment manufacturer ; Make ; 271 ; 10F ; ASCII ; Any ; +Image input equipment model ; Model ; 272 ; 110 ; ASCII ; Any ; +Software used ; Software ; 305 ; 131 ; ASCII ; Any ; +Person who created the image ; Artist ; 315 ; 13B ; ASCII ; Any ; +Copyright holder ; Copyright ; 33432 ; 8298 ; ASCII ; Any ; diff --git a/standards/xmp/__init__.py b/standards/xmp/__init__.py new file mode 100755 index 0000000..604685f --- /dev/null +++ b/standards/xmp/__init__.py @@ -0,0 +1,42 @@ +from os.path import join +from Globals import package_home +from _namespaces import namespaces + +home = package_home(globals()) + +f = file(join(home, 'accessors.csv')) +lines = f.readlines() +f.close() + +accessors = {} +accessorIds = {} +rdfKwnowTypes = {'Seq':True, 'prop':True, 'Alt':True, 'Bag':True} + +prefix2Ns = dict([item[::-1] for item in namespaces.items()]) + +for l in [l for l in lines if not l.startswith('#')] : + fields = [f.strip() for f in l.split(',')] + + if not filter(None, fields) : continue + + cat, caption, name, root, rdfType = fields + + accessor = { 'id' : name.split(':')[1] + , 'root' : root + , 'rdfType' : rdfType + , 'namespace' : prefix2Ns.get(name.split(':')[0]) + } + + assert not accessors.has_key(name), "Duplicate definition for %r" % name + assert name.count(':') <=1, "Ambiguous name %r" % name + assert not accessorIds.has_key(accessor['id']), "Ambiguous name: %r" % name + assert rdfKwnowTypes.has_key(rdfType), "Unknown rdf type: %r" % rdfType + if rdfType == 'prop' : + assert prefix2Ns.has_key(name.split(':')[0]), \ + "Attribute name %r don't match a known namespace prefix" % name + + accessors[name] = accessor + accessorIds[accessor['id']] = True + + +__all__ = ('namespaces', 'prefix2Ns', 'accessors') diff --git a/standards/xmp/_namespaces.py b/standards/xmp/_namespaces.py new file mode 100755 index 0000000..78d9324 --- /dev/null +++ b/standards/xmp/_namespaces.py @@ -0,0 +1,24 @@ +""" +$Id: _namespaces.py 1251 2009-08-03 08:42:09Z pin $ +$URL: http://svn.luxia.fr/svn/labo/projects/zope/Photo/trunk/standards/xmp/_namespaces.py $ +""" +namespaces = { + 'http://purl.org/dc/elements/1.1/' : 'dc' + ,'http://ns.adobe.com/xap/1.0/' : 'xmp' + ,'http://ns.adobe.com/xap/1.0/rights/' : 'xmpRights' + ,'http://ns.adobe.com/xap/1.0/mm/' : 'xmpMM' + ,'http://ns.adobe.com/xap/1.0/bj/' : 'xmpBJ' + ,'http://ns.adobe.com/xap/1.0/t/pg/' : 'xmpTPg' + ,'http://ns.adobe.com/xmp/1.0/DynamicMedia/' : 'xmpDM' + ,'http://ns.adobe.com/pdf/1.3/' : 'pdf' + ,'http://ns.adobe.com/photoshop/1.0/' : 'photoshop' + ,'http://ns.adobe.com/camera-raw-settings/1.0/' : 'crs' + ,'http://ns.adobe.com/tiff/1.0/' : 'tiff' + ,'http://ns.adobe.com/exif/1.0/' : 'exif' + ,'http://ns.adobe.com/exif/1.0/aux/' : 'aux' + ,'adobe:ns:meta/' : 'x' + ,'http://www.w3.org/1999/02/22-rdf-syntax-ns#' : 'rdf' + ,'http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/' : 'Iptc4xmpCore' + #'http://ns.adobe.com/xap/1.0/' : 'xap' + #,'http://ns.adobe.com/xap/1.0/rights/' : 'xapRights' +} \ No newline at end of file diff --git a/standards/xmp/accessors.csv b/standards/xmp/accessors.csv new file mode 100644 index 0000000..6710d8a --- /dev/null +++ b/standards/xmp/accessors.csv @@ -0,0 +1,43 @@ +#Catégorie,Libellé,nom,root (from rdf:Description),Type RDF +Contact,Créateur,dc:creator,,Seq +Contact,Fonction,photoshop:AuthorsPosition,,prop +Contact,Adresse,Iptc4xmpCore:CiAdrExtadr,Iptc4xmpCore:CreatorContactInfo,prop +Contact,Ville,Iptc4xmpCore:CiAdrCity,Iptc4xmpCore:CreatorContactInfo,prop +Contact,Région,Iptc4xmpCore:CiAdrRegion,Iptc4xmpCore:CreatorContactInfo,prop +Contact,Code postal,Iptc4xmpCore:CiAdrPcode,Iptc4xmpCore:CreatorContactInfo,prop +Contact,Pays,Iptc4xmpCore:CiAdrCtry,Iptc4xmpCore:CreatorContactInfo,prop +Contact,Téléphone,Iptc4xmpCore:CiTelWork,Iptc4xmpCore:CreatorContactInfo,prop +Contact,Adresse électronique,Iptc4xmpCore:CiEmailWork,Iptc4xmpCore:CreatorContactInfo,prop +Contact,Site internet,Iptc4xmpCore:CiUrlWork,Iptc4xmpCore:CreatorContactInfo,prop +,,,, +Contenu,Titre,photoshop:Headline,,prop +Contenu,Légende,dc:description,,Alt +Contenu,Mots-clefs,dc:subject,,Bag +Contenu,Code sujet IPTC,Iptc4xmpCore:SubjectCode,,Bag +Contenu,Auteur de la description,photoshop:CaptionWriter,,prop +Contenu,Catégorie,photoshop:Category,,prop +Contenu,Autres catégories,photoshop:SupplementalCategories,,Bag +,,,, +Image,Date de création,photoshop:DateCreated,,prop +Image,Catégorie intellectuelle,Iptc4xmpCore:IntellectualGenre,,prop +Image,Scène,Iptc4xmpCore:Scene,,Bag +Image,Emplacement,Iptc4xmpCore:Location,,prop +Image,Ville,photoshop:City,,prop +Image,Région,photoshop:State,,prop +Image,Pays,photoshop:Country,,prop +Image,Code pays ISO,Iptc4xmpCore:CountryCode,,prop +,,,, +État,Titre,dc:title,,Alt +État,Identifiant de la fonction,photoshop:TransmissionReference,,prop +État,Instructions,photoshop:Instructions,,prop +État,Fournisseur,photoshop:Credit,,prop +État,Source,photoshop:Source,,prop +,,,, +Copyright,État du copyright,xmpRights:Marked,,prop +Copyright,Copyright,dc:rights,,Alt +Copyright,Condit. d'utilis.,xmpRights:UsageTerms,,Alt +Copyright,URL info copyright,xmpRights:WebStatement,,prop +,,,, +Exif,Date/heure origin.,exif:DateTimeOriginal,,prop +,,,, +Tiff,Orientation,tiff:Orientation,,prop \ No newline at end of file diff --git a/version.txt b/version.txt new file mode 100755 index 0000000..9f55b2c --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +3.0 diff --git a/xmp.py b/xmp.py new file mode 100755 index 0000000..da36695 --- /dev/null +++ b/xmp.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +####################################################################################### +# Photo is a part of Plinn - http://plinn.org # +# Copyright © 2008 Benoît PIN # +# # +# This program is free software; you can redistribute it and/or # +# modify it under the terms of the GNU General Public License # +# as published by the Free Software Foundation; either version 2 # +# of the License, or (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program; if not, write to the Free Software # +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # +####################################################################################### +# $Id: xmp.py 354 2008-02-13 13:30:53Z pin $ +# $URL: http://svn.luxia.fr/svn/labo/projects/zope/Photo/trunk/xmp.py $ + +from types import StringTypes +from logging import getLogger +import re +console = getLogger('Photo.xmp') + +class XMP(object) : + XMP_HEADER = u'' + XMP_HEADER_PATTERN = u'''<\?xpacket begin=['"]\ufeff['"] id=['"]W5M0MpCehiHzreSzNTczkc9d['"][^\?]*\?>''' + XMP_PADDING_LINE = u'\u0020' * 63 + u'\n' + XMP_TRAILER = u'' + + _readers = {} + _writers = {} + + + + def __init__(self, file, content_type='image/jpeg', encoding='utf-8') : + try : + self.reader = self._readers[content_type] + except KeyError: + raise NotImplementedError, "%r content type not supported by XMP" % content_type + + try : + self.writer = self._writers[content_type] + except KeyError : + self.writer = None + console.info('XMP file opened on read-only mode.') + + self.file = file + self.encoding = encoding + self.xmp = None + self._open() + + + def __del__(self) : + try : + self.file.close() + except : + pass + + + def _open(self) : + + if type(self.file) in StringTypes : + self.file = file(self.file) + + packet = self.reader(self.file) + + if packet is not None : + # tests / unwrap + reEncodedHeader = re.compile(self.XMP_HEADER_PATTERN.encode(self.encoding)) + m = reEncodedHeader.match(packet) + assert m is not None, "No xmp header found" + xmp = packet[m.end():] + + trailer = self.XMP_TRAILER[:-6].encode(self.encoding) # TODO handle read-only mode + trailerPos = xmp.find(trailer) + assert trailerPos != -1, "No xmp trailer found" + + xmp = xmp[:trailerPos] + xmp = xmp.strip() + self.xmp = xmp + else : + self.xmp = None + + def save(self, f=None): + original = self.file + if f : + if type(f) in StringTypes : + new = file(f, 'w') + else : + new = f + elif f is None : + new = self.file + + self.writer(original, new, self.xmp) + + + def getXMP(self) : + return self.xmp + + + def setXMP(self, xmp) : + self.xmp = xmp + + # + # xmp utils + # + + @staticmethod + def getXmpPadding(size) : + # size of trailer in kB + return (XMP.XMP_PADDING_LINE * 32 * size) + + + @staticmethod + def genXMPPacket(uXmpData, paddingSize): + packet = u'' + + packet += XMP.XMP_HEADER + packet += uXmpData + packet += XMP.getXmpPadding(paddingSize) + packet += XMP.XMP_TRAILER + + return packet + + + + # + # content type registry stuff + # + + + @classmethod + def registerReader(cls, content_type, reader) : + cls._readers[content_type] = reader + + @classmethod + def registerWriter(cls, content_type, writer) : + cls._writers[content_type] = writer + + @classmethod + def registerWrapper(cls, content_type, wrapper) : + """ Registers specific wrapper to prepare data + for embedding xmp into specific content_type file. + """ + pass + + + +def test() : + from xml.dom.minidom import parse + data = parse('new.xmp').documentElement.toxml() + + def test1() : + original = 'original.jpg' + modified = 'modified.jpg' + + x = XMP(original) + x.setXMP(data) + x.save(modified) + + def test2() : + from cStringIO import StringIO + sio = StringIO() + sio.write(file('modified.jpg').read()) + sio.seek(0) + + x = XMP(sio) + x.setXMP(data) + x.save() + + f2 = open('modified2.jpg', 'w') + f2.write(sio.read()) + f2.close() + + + test1() + test2() + + + +if __name__ == '__main__' : + test() diff --git a/xmp_jpeg.py b/xmp_jpeg.py new file mode 100755 index 0000000..5c894ae --- /dev/null +++ b/xmp_jpeg.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- +####################################################################################### +# Photo is a part of Plinn - http://plinn.org # +# Copyright (C) 2008 Benoît PIN # +# # +# This program is free software; you can redistribute it and/or # +# modify it under the terms of the GNU General Public License # +# as published by the Free Software Foundation; either version 2 # +# of the License, or (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program; if not, write to the Free Software # +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # +####################################################################################### +""" Jpeg plugin for xmp read/write support. +$Id: xmp_jpeg.py 999 2009-05-11 14:43:44Z pin $ +$URL: http://svn.luxia.fr/svn/labo/projects/zope/Photo/trunk/xmp_jpeg.py $ +""" + +from xmp import XMP +from types import StringType + +class JpegXmpIO(object): + + JPEG_XMP_LEADIN = 'http://ns.adobe.com/xap/1.0/\x00' + JPEG_XMP_LEADIN_LENGTH = len(JPEG_XMP_LEADIN) + + MARKERS = { + 0xFFC0: ("SOF0", "Baseline DCT", True), + 0xFFC1: ("SOF1", "Extended Sequential DCT", True), + 0xFFC2: ("SOF2", "Progressive DCT", True), + 0xFFC3: ("SOF3", "Spatial lossless", True), + 0xFFC4: ("DHT", "Define Huffman table", True), + 0xFFC5: ("SOF5", "Differential sequential DCT", True), + 0xFFC6: ("SOF6", "Differential progressive DCT", True), + 0xFFC7: ("SOF7", "Differential spatial", True), + 0xFFC8: ("JPG", "Extension", False), + 0xFFC9: ("SOF9", "Extended sequential DCT (AC)", True), + 0xFFCA: ("SOF10", "Progressive DCT (AC)", True), + 0xFFCB: ("SOF11", "Spatial lossless DCT (AC)", True), + 0xFFCC: ("DAC", "Define arithmetic coding conditioning", True), + 0xFFCD: ("SOF13", "Differential sequential DCT (AC)", True), + 0xFFCE: ("SOF14", "Differential progressive DCT (AC)", True), + 0xFFCF: ("SOF15", "Differential spatial (AC)", True), + 0xFFD0: ("RST0", "Restart 0", False), + 0xFFD1: ("RST1", "Restart 1", False), + 0xFFD2: ("RST2", "Restart 2", False), + 0xFFD3: ("RST3", "Restart 3", False), + 0xFFD4: ("RST4", "Restart 4", False), + 0xFFD5: ("RST5", "Restart 5", False), + 0xFFD6: ("RST6", "Restart 6", False), + 0xFFD7: ("RST7", "Restart 7", False), + 0xFFD8: ("SOI", "Start of image", False), + 0xFFD9: ("EOI", "End of image", False), + 0xFFDA: ("SOS", "Start of scan", True), + 0xFFDB: ("DQT", "Define quantization table", True), + 0xFFDC: ("DNL", "Define number of lines", True), + 0xFFDD: ("DRI", "Define restart interval", True), + 0xFFDE: ("DHP", "Define hierarchical progression", True), + 0xFFDF: ("EXP", "Expand reference component", True), + 0xFFE0: ("APP0", "Application segment 0", True), + 0xFFE1: ("APP1", "Application segment 1", True), + 0xFFE2: ("APP2", "Application segment 2", True), + 0xFFE3: ("APP3", "Application segment 3", True), + 0xFFE4: ("APP4", "Application segment 4", True), + 0xFFE5: ("APP5", "Application segment 5", True), + 0xFFE6: ("APP6", "Application segment 6", True), + 0xFFE7: ("APP7", "Application segment 7", True), + 0xFFE8: ("APP8", "Application segment 8", True), + 0xFFE9: ("APP9", "Application segment 9", True), + 0xFFEA: ("APP10", "Application segment 10", True), + 0xFFEB: ("APP11", "Application segment 11", True), + 0xFFEC: ("APP12", "Application segment 12", True), + 0xFFED: ("APP13", "Application segment 13", True), + 0xFFEE: ("APP14", "Application segment 14", True), + 0xFFEF: ("APP15", "Application segment 15", True), + 0xFFF0: ("JPG0", "Extension 0", False), + 0xFFF1: ("JPG1", "Extension 1", False), + 0xFFF2: ("JPG2", "Extension 2", False), + 0xFFF3: ("JPG3", "Extension 3", False), + 0xFFF4: ("JPG4", "Extension 4", False), + 0xFFF5: ("JPG5", "Extension 5", False), + 0xFFF6: ("JPG6", "Extension 6", False), + 0xFFF7: ("JPG7", "Extension 7", False), + 0xFFF8: ("JPG8", "Extension 8", False), + 0xFFF9: ("JPG9", "Extension 9", False), + 0xFFFA: ("JPG10", "Extension 10", False), + 0xFFFB: ("JPG11", "Extension 11", False), + 0xFFFC: ("JPG12", "Extension 12", False), + 0xFFFD: ("JPG13", "Extension 13", False), + 0xFFFE: ("COM", "Comment", True) + } + + + @staticmethod + def i16(c,o=0): + return ord(c[o+1]) + (ord(c[o])<<8) + + @staticmethod + def getBlockInfo(marker, f): + start = f.tell() + length = JpegXmpIO.i16(f.read(2)) + + markerInfo = JpegXmpIO.MARKERS[marker] + blockInfo = { 'name' : markerInfo[0] + , 'description' : markerInfo[1] + , 'start' : start + , 'length' : length} + + jump = start + length + f.seek(jump) + + return blockInfo + + @staticmethod + def getBlockInfos(f) : + f.seek(0) + s = f.read(1) + + blockInfos = [] + + while 1: + s = s + f.read(1) + i = JpegXmpIO.i16(s) + + if JpegXmpIO.MARKERS.has_key(i): + name, desciption, handle = JpegXmpIO.MARKERS[i] + + if handle: + blockInfo = JpegXmpIO.getBlockInfo(i, f) + blockInfos.append(blockInfo) + if i == 0xFFDA: # start of scan + break + s = f.read(1) + elif i == 0 or i == 65535: + # padded marker or junk; move on + s = "\xff" + + return blockInfos + + + @staticmethod + def genJpegXmpBlock(uXmpData, paddingSize=2) : + block = u'' + + block += JpegXmpIO.JPEG_XMP_LEADIN + block += XMP.genXMPPacket(uXmpData, paddingSize) + # utf-8 mandatory in jpeg files (xmp specification) + block = block.encode('utf-8') + + length = len(block) + 2 + + # TODO : reduce padding size if this assertion occurs + assert length <= 0xfffd, "Jpeg block too long: %d (max: 0xfffd)" % hex(length) + + chrlength = chr(length >> 8 & 0xff) + chr(length & 0xff) + + block = chrlength + block + + return block + + + + @staticmethod + def read(f) : + + blockInfos = JpegXmpIO.getBlockInfos(f) + app1BlockInfos = [b for b in blockInfos if b['name'] == 'APP1'] + + xmpBlocks = [] + + for info in app1BlockInfos : + f.seek(info['start']) + data = f.read(info['length'])[2:] + if data.startswith(JpegXmpIO.JPEG_XMP_LEADIN) : + xmpBlocks.append(data) + + assert len(xmpBlocks) <= 1, "Multiple xmp block data is not yet supported." + + if len(xmpBlocks) == 1 : + data = xmpBlocks[0] + packet = data[len(JpegXmpIO.JPEG_XMP_LEADIN):] + return packet + else : + return None + + @staticmethod + def write(original, new, uxmp) : + + blockInfos = JpegXmpIO.getBlockInfos(original) + app1BlockInfos = [b for b in blockInfos if b['name'] == 'APP1'] + + xmpBlockInfos = [] + + for info in app1BlockInfos : + original.seek(info['start']) + lead = original.read(JpegXmpIO.JPEG_XMP_LEADIN_LENGTH+2)[2:] + if lead == JpegXmpIO.JPEG_XMP_LEADIN : + xmpBlockInfos.append(info) + + + assert len(xmpBlockInfos) <= 1, "Multiple xmp block data is not yet supported." + + if isinstance(uxmp, StringType) : + uxmp = unicode(uxmp, 'utf-8') + + if len(xmpBlockInfos) == 0 : + blockInfo = [b for b in blockInfos if b['name'] == 'APP13'] + + if not blockInfo : + blockInfo = [b for b in blockInfos if b['name'] == 'APP1'] + + if not blockInfo : + blockInfo = [b for b in blockInfos if b['name'] == 'APP0'] + + if not blockInfo : raise ValueError, "No suitable place to write xmp segment" + + info = blockInfo[0] + print 'create xmp after: %s' % info['name'] + + original.seek(0) + before = original.read(info['start'] + info['length']) + after = original.read() + + jpegBlock = '\xFF\xE1' + JpegXmpIO.genJpegXmpBlock(uxmp) + + else : + info = xmpBlockInfos[0] + + original.seek(0) + before = original.read(info['start']) + + original.seek(info['start'] + info['length']) + after = original.read() + + jpegBlock = JpegXmpIO.genJpegXmpBlock(uxmp) + + new.seek(0) + new.write(before) + new.write(jpegBlock) + new.write(after) + + # if original == new : + # new.seek(0) + # else : + # new.close() + # original.close() + + +XMP.registerReader('image/jpeg', JpegXmpIO.read) +XMP.registerWriter('image/jpeg', JpegXmpIO.write) diff --git a/xmputils.py b/xmputils.py new file mode 100755 index 0000000..4fd24bf --- /dev/null +++ b/xmputils.py @@ -0,0 +1,354 @@ +# -*- coding: utf-8 -*- +####################################################################################### +# Photo is a part of Plinn - http://plinn.org # +# Copyright (C) 2008 Benoît PIN # +# # +# This program is free software; you can redistribute it and/or # +# modify it under the terms of the GNU General Public License # +# as published by the Free Software Foundation; either version 2 # +# of the License, or (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program; if not, write to the Free Software # +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # +####################################################################################### +""" XMP generation utilities. + +$Id: xmputils.py 1293 2009-08-14 16:48:18Z pin $ +$URL: http://svn.luxia.fr/svn/labo/projects/zope/Photo/trunk/xmputils.py $ +""" + +from libxml2 import newNode, parseDoc, treeError +# prefix <-> namespaces mappings as defined in the official xmp documentation +from standards.xmp import namespaces as xmpNs2Prefix +from standards.xmp import prefix2Ns as xmpPrefix2Ns + +TIFF_ORIENTATIONS = {1 : (0, False) + ,2 : (0, True) + ,3 : (180, False) + ,4 : (180, True) + ,5 : (90, True) + ,6 : (90, False) + ,7 : (270, True) + ,8 : (270, False)} + +def _getRDFArrayValues(node, arrayType): + values = [] + for element in iterElementChilds(node): + if element.name == arrayType and element.ns().content == 'http://www.w3.org/1999/02/22-rdf-syntax-ns#' : + for value in iterElementChilds(element): + if value.name == 'li': + values.append(value.content) + return tuple(values) + else : + raise ValueError("No %s found" % arrayType ) + +def getBagValues(node): + return _getRDFArrayValues(node, 'Bag') + +def getSeqValues(node): + return _getRDFArrayValues(node, 'Seq') + + +def createRDFAlt(surrounded, defaultText, rootIndex): + """ + returns (as libxml2 node): + + + defaultText + + + """ + docNs = rootIndex.getDocumentNs() + rdfPrefix = docNs['http://www.w3.org/1999/02/22-rdf-syntax-ns#'] + normalizedPrefix, name = surrounded.split(':') + ns = xmpPrefix2Ns[normalizedPrefix] + actualPrefix = docNs[ns] + + surrounded = newNode('%s:%s' % (actualPrefix, name)) + alt = newNode('%s:Alt' % rdfPrefix) + li = newNode('%s:li' % rdfPrefix) + li.newProp('xml:lang', 'x-default') + li.setContent(defaultText) + + reduce(lambda a, b: a.addChild(b), (surrounded, alt, li)) + + return surrounded + + +def createRDFBag(surrounded, values, rootIndex): + """ + returns (as libxml2 node): + + + values[0] + ... + values[n] + + + """ + return _createRDFArray(surrounded, values, False, rootIndex) + +def createRDFSeq(surrounded, values, rootIndex): + """ + returns (as libxml2 node): + + + values[0] + ... + values[n] + + + """ + return _createRDFArray(surrounded, values, True, rootIndex) + +def _createRDFArray(surrounded, values, ordered, rootIndex): + docNs = rootIndex.getDocumentNs() + rdfPrefix = docNs['http://www.w3.org/1999/02/22-rdf-syntax-ns#'] + normalizedPrefix, name = surrounded.split(':') + ns = xmpPrefix2Ns[normalizedPrefix] + actualPrefix = docNs[ns] + + + surrounded = newNode('%s:%s' % (actualPrefix, name)) + if ordered is True : + array = newNode('%s:Seq' % rdfPrefix) + elif ordered is False : + array = newNode('%s:Bag' % rdfPrefix) + else : + raise ValueError("'ordered' parameter must be a boolean value") + + surrounded.addChild(array) + + for v in values : + li = newNode('%s:li' % rdfPrefix) + li.setContent(v) + array.addChild(li) + + return surrounded + +def createEmptyXmpDoc() : + emptyDocument = """ + + + + + + """ + d = parseDoc(emptyDocument) + return d + +def getPathIndex(doc) : + root = doc.getRootElement() + index = PathIndex(root) + return index + + +class PathIndex : + """\ + Class used to provide a convenient tree access to xmp properties by paths. + Issues about namespaces and prefixes are normalized during the object + instanciation. Ns prefixes used to access elements are those recommended in the + official xmp documentation from Adobe. + """ + + def __init__(self, element, parent=None) : + self.unique = True + self.element = element + self.parent = parent + + elementNs = element.ns().content + elementPrefix = element.ns().name + recommendedPrefix = xmpNs2Prefix.get(elementNs, elementPrefix) + + self.name = '%s:%s' % (recommendedPrefix, element.name) + self.namespace = elementNs + self.prefix = elementPrefix + self._index = {} + + for prop in iterElementProperties(element) : + self.addChildIndex(prop) + + for child in iterElementChilds(element) : + self.addChildIndex(child) + + if self.parent is None: + self.nsDeclarations = self._namespaceDeclarations() + + def addChildIndex(self, child) : + ns = child.ns() + if not ns : + return + + childNs = ns.content + childPrefix = ns.name + childRecommendedPrefix = xmpNs2Prefix.get(childNs, childPrefix) + childName = '%s:%s' % (childRecommendedPrefix, child.name) + + if not self._index.has_key(childName) : + self._index[childName] = PathIndex(child, parent=self) + else : + childIndex = self._index[childName] + childIndex.unique = False + for prop in iterElementProperties(child) : + childIndex.addChildIndex(prop) + + for c in iterElementChilds(child) : + childIndex.addChildIndex(c) + + self._index[childName].parent = self + return self._index[childName] + + def _namespaceDeclarations(self) : + """\ + returns ns / prefix pairs as found in xmp packet + """ + namespaces = {} + namespaces[self.namespace] = self.prefix + for child in self._index.values() : + for namespace, prefix in child._namespaceDeclarations().items() : + if namespaces.has_key(namespace) : + assert namespaces[namespace] == prefix, \ + "using several prefix for the same namespace is forbidden "\ + "in this implementation" + else : + namespaces[namespace] = prefix + return namespaces + + def getDocumentNs(self) : + root = self.getRootIndex() + return root.nsDeclarations + + def exists(self, path) : + o = self + for part in path.split('/') : + if o._index.has_key(part) : + o = o._index[part] + else : + return False + return True + + def __getitem__(self, path) : + o = self + try : + for part in path.split('/') : + if part == '.' : + continue + elif part == '..' : + o = o.parent + o = o._index[part] + except ValueError : + raise KeyError, path + return o + + def get(self, path, default=None) : + try : + return self[path] + except KeyError : + return default + + def getRootIndex(self) : + root = self + while root.parent is not None : + root = root.parent + return root + + def createChildAndIndex(self, name, rdfType, nsDeclarationElement) : + recommandedPrefix, name = name.split(':', 1) + + if rdfType == 'prop' : + try : + node = self.element.newProp(name, '') + except treeError : + raise ValueError, (self.element, name) + else : + node = newNode(name) + self.element.addChild(node) + + # bind namespace to new node + uri = xmpPrefix2Ns[recommandedPrefix] + docNamespaces = self.getDocumentNs() + if not docNamespaces.has_key(uri) : + try : + ns = nsDeclarationElement.newNs(uri, recommandedPrefix) + except treeError : + raise ValueError, (uri, prefix, self.element, list(nsDeclarationElement.nsDefs())) + docNamespaces[uri] = recommandedPrefix + else : + actualPrefix = docNamespaces[uri] + try : + ns = self.element.searchNs(None, actualPrefix) + except treeError: + # cas d'un xmp verbeux : le nouvel élément n'est pas ajouté + # dans le rdf:Description du ns correspondant + # (après tout, ce n'est pas une obligation) + # => on ajoute le ns + ns = nsDeclarationElement.newNs(uri, actualPrefix) + + + node.setNs(ns) + return self.addChildIndex(node) + + def getOrCreate(self, path, rdfType, preferedNsDeclaration='rdf:RDF/rdf:Description') : + parts = path.split('/') + + if not parts : + return self + + name = parts[-1] + parts = parts[:-1] + root = self.getRootIndex() + nsDeclarationElement = root[preferedNsDeclaration].element + + parent = self + for p in parts : + child = parent._index.get(p, None) + if child is None : + child = parent.createChildAndIndex(p, None, nsDeclarationElement) + parent = child + + child = parent._index.get(name, None) + if child is None : + child = parent.createChildAndIndex(name, rdfType, nsDeclarationElement) + + return child + + def __str__(self) : + out = [] + pr = out.append + path = [self.name] + parent = self.parent + while parent : + path.append(parent.name) + parent = parent.parent + path.reverse() + path = '/'.join(path) + pr(path) + pr(self.name) + pr(self.namespace) + pr(str(self.unique)) + pr('-------') + + for child in self._index.values() : + pr(str(child)) + + return '\n'.join(out) + +def iterElementChilds(parent) : + child = parent.children + while child : + if child.type == 'element' : + yield child + child = child.next + +def iterElementProperties(element) : + prop = element.properties + while prop : + if prop.type == 'attribute' : + yield prop + prop = prop.next diff --git a/xslt/xmp_merge_descriptions.xsl b/xslt/xmp_merge_descriptions.xsl new file mode 100644 index 0000000..5d7ddd8 --- /dev/null +++ b/xslt/xmp_merge_descriptions.xsl @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +