from AccessControl import ClassSecurityInfo from Globals import InitializeClass from ExtensionClass import Base from Acquisition import Implicit, aq_parent from OFS.Traversable import Traversable from OFS.Cache import ChangeCacheSettingsPermission from Products.CMFCore import CMFCorePermissions from Products.CMFDefault.Image import Image import OFS.Image from BTrees.OOBTree import OOBTree from cgi import escape from cStringIO import StringIO import sys from zLOG import LOG, ERROR, INFO from imageengine import isPilAvailable, isConvertAvailable DEBUG=1 DEFAULT_QUALITY=100 if isPilAvailable: import PIL.Image from PIL.Image import NEAREST, BILINEAR, BICUBIC, ANTIALIAS # NEAREST (use nearest neighbour) # BILINEAR (linear interpolation in a 2x2 environment) # BICUBIC (cubic spline interpolation in a 4x4 environment) # ANTIALIAS (a high-quality downsampling filter) # antialiasing is the best algorithm for shrinking pictures RESIZING_ALGO = ANTIALIAS # PIL doesn't support antialiasing for rotating! ROTATING_ALGO = BICUBIC # transpose constants, taken from PIL.Image to maintain compatibilty FLIP_LEFT_RIGHT = 0 FLIP_TOP_BOTTOM = 1 ROTATE_90 = 2 ROTATE_180 = 3 ROTATE_270 = 4 TRANSPOSE_MAP = ( (FLIP_LEFT_RIGHT, "Flip around vertical axis"), (FLIP_TOP_BOTTOM, "Flip around horizontal axis"), (ROTATE_270, "Rotate 90 clockwise"), (ROTATE_180, "Rotate 180"), (ROTATE_90, "Rotate 90 counterclockwise"), ) factory_type_information = { 'id' : 'Photo', 'meta_type' : 'Photo', 'description' : 'Photos objects can be embedded in Portal documents.', 'icon' : 'photo_icon.gif', 'product' : 'CMFPhoto', 'factory' : 'addPhoto', 'immediate_view' : 'image_edit_form', 'actions' : ( { 'id' : 'view', 'name' : 'View', 'action' : 'photo_view', 'permissions' : (CMFCorePermissions.View, ) }, { 'id' : 'edit', 'name' : 'Properties', 'action' : 'portal_form/image_edit_form', 'permissions' : (CMFCorePermissions.ModifyPortalContent, ) }, { 'id' : 'transform', 'name' : 'Transform Image', 'action' : 'photo_transform', 'permissions' : (CMFCorePermissions.ModifyPortalContent, ) }, { 'id' : 'metadata', 'name' : 'Metadata', 'action' : 'portal_form/metadata_edit_form', 'permissions' : (CMFCorePermissions.ModifyPortalContent, ) } ) } def addPhoto( self , id , title='' , file='' , content_type='' , precondition='' , subject=() , description='' , contributors=() , effective_date=None , expiration_date=None , format='image/png' , language='' , rights='' ): """ Add an Photo """ # cookId sets the id and title if they are not explicity specified id, title = OFS.Image.cookId(id, title, file) self=self.this() # Instantiate the object and set its description. iobj = Photo( id, title, '', content_type, precondition, subject , description, contributors, effective_date, expiration_date , format, language, rights ) # Add the Photo instance to self self._setObject(id, iobj) # 'Upload' the photo. This is done now rather than in the # constructor because it's faster (see File.py.) self._getOb(id).manage_upload(file) class DynVariantWrapper(Base): """ provide a transparent wrapper from photo to dynvariant call it with url ${photo_url}/variant/${variant} """ def __of__(self, parent): return parent.Variants() class DynVariant(Implicit, Traversable): """ provide access to the variants """ def __init__(self): pass def __getitem__(self, name): if self.checkForVariant(name): return self.getPhoto(name).__of__(aq_parent(self)) else: return self class Photo(Image): """ Implements a Photo, a scalable image """ __implements__ = ( Image.__implements__ ,) meta_type = 'Photo' def __init__( self , id , title='' , file='' , content_type='' , precondition='' , subject=() , description='' , contributors=() , effective_date=None , expiration_date=None , format='image/png' , language='en-US' , rights='' ): Image.__init__(self, id, title, file, content_type, precondition, subject, description, contributors, effective_date, expiration_date, format, language, rights) self._photos = OOBTree() security = ClassSecurityInfo() # make image variants accesable via url variant=DynVariantWrapper() security.declareProtected(CMFCorePermissions.View, 'Variants') def Variants(self): # Returns a variants wrapper instance return DynVariant().__of__(self) security.declareProtected(CMFCorePermissions.View, 'getPhoto') def getPhoto(self,size): '''returns the Photo of the specified size''' return self._photos[size] security.declareProtected(CMFCorePermissions.View, 'getDisplays') def getDisplays(self): result = [] for name, size in self.photo_display_sizes().items(): if len(size) == 3: quality = size[2] else: quality = DEFAULT_QUALITY result.append({ 'name':name, 'label':'%s (%dx%d)' % (name, size[0], size[1]), 'size':(size[0],size[1]), 'quality' : quality }) result.sort(lambda d1,d2: cmp(d1['size'][0]*d1['size'][0],d2['size'][1]*d2['size'][1])) #sort ascending by size return result security.declareProtected(CMFCorePermissions.ModifyPortalContent, 'getTransforms') def getTransforms(self): return [{'name': method, 'label': name} for method, name in TRANSPOSE_MAP ] security.declarePrivate('checkForVariant') def checkForVariant(self, size): """Create variant if not there.""" if size in self.photo_display_sizes().keys(): # Create resized copy, if it doesnt already exist if not self._photos.has_key(size): self._photos[size] = OFS.Image.Image(size, size, self._resize(self.photo_display_sizes().get(size, (0,0)))) # a copy with a content type other than image/* exists, this # probably means that the last resize process failed. retry elif not self._photos[size].getContentType().startswith('image'): self._photos[size] = OFS.Image.Image(size, size, self._resize(self.photo_display_sizes().get(size, (0,0)))) return 1 else: return 0 security.declareProtected(CMFCorePermissions.View, 'index_html') def index_html(self, REQUEST, RESPONSE, size=None): """Return the image data.""" if self.checkForVariant(size): return self.getPhoto(size).index_html(REQUEST, RESPONSE) return Photo.inheritedAttribute('index_html')(self, REQUEST, RESPONSE) security.declareProtected(CMFCorePermissions.View, 'tag') def tag(self, height=None, width=None, alt=None, scale=0, xscale=0, yscale=0, css_class=None, title=None, size='original', **args): """ Return an HTML img tag (See OFS.Image)""" # Default values w=self.width h=self.height if height is None or width is None: if size in self.photo_display_sizes().keys(): if not self._photos.has_key(size): # This resized image isn't created yet. # Calculate a size for it x,y = self.photo_display_sizes().get(size) tmpw, tmph = self.width, self.height try: mirror, rotation = self.exif_orientation() if rotation == 90 or rotation == 270: tmpw, tmph = tmph, tmpw if tmpw > tmph: w = x h = int(round(1.0/(float(tmpw)/w/tmph))) else: h = y w = int(round(1.0/(float(tmph)/x/tmpw))) except ValueError: # OFS.Image only knows about png, jpeg and gif. # Other images like bmp will not have height and # width set, and will generate a ValueError here. # Everything will work, but the image-tag will render # with height and width attributes. w=None h=None else: # The resized image exist, get it's size photo = self._photos.get(size) w=photo.width h=photo.height if height is None: height=h if width is None: width=w # 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 security.declareProtected(CMFCorePermissions.ModifyPortalContent, 'doTransform') def doTransform(self, method, REQUEST=None): """ Transform an Image: FLIP_LEFT_RIGHT FLIP_TOP_BOTTOM ROTATE_90 (rotate counterclockwise) ROTATE_180 ROTATE_270 (rotate clockwise) """ image = StringIO() method = int(method) if isPilAvailable: img = PIL.Image.open(StringIO(str(self.data))) fmt = img.format img = img.transpose(method) img.save(image, fmt, quality=DEFAULT_QUALITY) elif isConvertAvailable: # fall back to convert if method in [ROTATE_90, ROTATE_180, ROTATE_270]: deg = 90 if method == ROTATE_180: deg = 180 elif method == ROTATE_270: deg = 270 image = self.callConvert(image, rotate=deg) elif method == FLIP_LEFT_RIGHT: image = self.callConvert(image, 'flop') elif method == FLIP_TOP_BOTTOM: image = self.callConvert(image, 'flip') else: raise ValueError, "Unknown method '%s'" % (method,) else: if DEBUG: raise Exception('Error in doTransform') self.update_data(image.getvalue()) if REQUEST: REQUEST.RESPONSE.redirect(self.absolute_url() + '/photo_transform') security.declarePrivate('callConvert') def callConvert(self, img_file_obj, *args, **kwargs): """ Convert an image using the 'convert' program img_file_obj is a StringIO instance """ command = "convert -quality %s" % DEFAULT_QUALITY # TODO check convert manual for argument precedence for arg in args: command += " -%s " % (arg,) for key, val in kwargs.items(): command += " -%s %s " % (key, val) command += " - -" # stdin & stdout as input & output if sys.platform == 'win32': from win32pipe import popen2 imgin, imgout = popen2(command, 'b') else: from popen2 import Popen3 convert=Popen3(command) imgout=convert.fromchild imgin=convert.tochild imgin.write(str(self.data)) imgin.close() img_file_obj.write(imgout.read()) imgout.close() #Wait for process to close if unix. Should check returnvalue for wait if sys.platform !='win32': convert.wait() img_file_obj.seek(0) return img_file_obj security.declarePrivate('update_data') def update_data(self, data, content_type=None, size=None): """ Update/upload image -> remove all copies """ self.clearCache() Image.update_data(self, data, content_type, size) def _resize(self, size, quality=DEFAULT_QUALITY): """Resize and resample photo.""" image = StringIO() width = size[0] height = size[1] if len(size) == 3 and quality == DEFAULT_QUALITY: quality = size[2] # check if picture needs to be rotated mirror, rotation = self.exif_orientation() if isPilAvailable: img = PIL.Image.open(StringIO(str(self.data))) fmt = img.format # Resize photo img.thumbnail((width, height), RESIZING_ALGO) if rotation: rotation = 360 - rotation img = img.rotate(rotation, ROTATING_ALGO) # Store copy in image buffer img.save(image, fmt, quality=quality) elif isConvertAvailable: geometry = "%sx%s" % (width, height) image = self.callConvert(image, rotate=rotation, geometry=geometry) else: if DEBUG: raise RuntimeError('Error in _resize: No image manipulation engine found! Pleas read the readme') return image security.declareProtected(CMFCorePermissions.View, 'getEXIF') def getEXIF(self): """ Extracts the exif metadata from the image and returns it as a hashtable """ import EXIF try: data = EXIF.process_file(StringIO(str(self.data))) except: data = {} if not data: data = {} keys = data.keys() keys.sort() result = {} for key in keys: if key in ('JPEGThumbnail', 'TIFFThumbnail'): continue try: result[key] = str(data[key].printable) except: pass return result security.declareProtected(CMFCorePermissions.View, 'exif_orientation') def exif_orientation(self): """XXX """ exif = self.getEXIF() mirror = 0; rotation = 0; if not exif.has_key('Image Orientation'): return (mirror, rotation) code = exif.get('Image Orientation') try: code = int(code) except ValueError: return (mirror, rotation) if code in (2, 4, 5, 7): mirror = 1 if code in (1, 2): rotation = 0 elif code in (3, 4): rotation = 180 elif code in (5, 6): rotation = 90 elif code in (7, 8): rotation = 270 return (mirror, rotation) security.declareProtected(ChangeCacheSettingsPermission, 'ZCacheable_setManagerId') def ZCacheable_setManagerId(self, manager_id, REQUEST=None): '''Changes the manager_id for this object. overridden because we must propagate the change to all variants''' for size in self._photos.keys(): variant = self.getPhoto(size).__of__(self) variant.ZCacheable_setManagerId(manager_id) return Photo.inheritedAttribute('ZCacheable_setManagerId')(self, manager_id, REQUEST) security.declareProtected(CMFCorePermissions.View, 'SearchableText') def SearchableText(self): """ Used by the catalog for basic full text indexing """ return "%s %s" % ( self.title_or_id(), self.description ) security.declareProtected(CMFCorePermissions.ManagePortal , 'clearCache') def clearCache(self): """Clears the internal cache and the zope cache and removes all resized variants """ self.ZCacheable_invalidate() self._photos = OOBTree() InitializeClass(Photo)