1 # -*- coding: utf-8 -*-
2 #######################################################################################
3 # Photo is a part of Plinn - http://plinn.org #
4 # Copyright (C) 2004-2007 Benoît PIN <benoit.pin@ensmp.fr> #
6 # This program is free software; you can redistribute it and/or #
7 # modify it under the terms of the GNU General Public License #
8 # as published by the Free Software Foundation; either version 2 #
9 # of the License, or (at your option) any later version. #
11 # This program is distributed in the hope that it will be useful, #
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of #
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
14 # GNU General Public License for more details. #
16 # You should have received a copy of the GNU General Public License #
17 # along with this program; if not, write to the Free Software #
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #
19 #######################################################################################
22 $Id: Photo.py 1281 2009-08-13 10:44:40Z pin $
23 $URL: http://svn.luxia.fr/svn/labo/projects/zope/Photo/trunk/Photo.py $
26 from Globals
import InitializeClass
, DTMLFile
27 from AccessControl
import ClassSecurityInfo
28 from AccessControl
.Permissions
import manage_properties
, view
29 from metadata
import Metadata
30 from TileSupport
import TileSupport
31 from xmputils
import TIFF_ORIENTATIONS
32 from BTrees
.OOBTree
import OOBTree
33 from cache
import memoizedmethod
35 from blobbases
import Image
, cookId
, getImageInfo
38 from math
import floor
39 from types
import StringType
40 from logging
import getLogger
41 console
= getLogger('Photo.Photo')
46 return str(size
[0]) + '_' + str(size
[1])
48 def getNewSize(fullSize
, maxNewSize
) :
49 fullWidth
, fullHeight
= fullSize
50 maxWidth
, maxHeight
= maxNewSize
52 widthRatio
= float(maxWidth
) / fullWidth
53 if int(fullHeight
* widthRatio
) > maxWidth
:
54 heightRatio
= float(maxHeight
) / fullHeight
55 return (int(fullWidth
* heightRatio
) , maxHeight
)
57 return (maxWidth
, int(fullHeight
* widthRatio
))
64 class Photo(Image
, TileSupport
, Metadata
):
65 "Photo éditable en ligne"
69 security
= ClassSecurityInfo()
71 manage_editForm
= DTMLFile('dtml/photoEdit',globals(),
72 Kind
='Photo', kind
='photo')
73 manage_editForm
._setName
('manage_editForm')
74 manage
= manage_main
= manage_editForm
75 view_image_or_file
= DTMLFile('dtml/photoView',globals())
78 {'label':'Edit', 'action':'manage_main',
79 'help':('OFSP','Image_Edit.stx')},
80 {'label':'View', 'action':'view_image_or_file',
81 'help':('OFSP','Image_View.stx')},) + Image
.manage_options
[2:]
84 filters
= ['NEAREST', 'BILINEAR', 'BICUBIC', 'ANTIALIAS']
86 _properties
= Image
._properties
[:2] + (
87 {'id' : 'height', 'type' : 'int', 'mode' : 'w'},
88 {'id' : 'width', 'type' : 'int', 'mode' : 'w'},
89 {'id' : 'auto_update_thumb', 'type' : 'boolean', 'mode' : 'w'},
90 {'id' : 'tiles_available', 'type' : 'int', 'mode' : 'r'},
91 {'id' : 'thumb_height', 'type' : 'int', 'mode' : 'w'},
92 {'id' : 'thumb_width', 'type' : 'int', 'mode' : 'w'},
93 {'id' : 'prop_filter',
96 'select_variable' : 'filters',
101 security
.declareProtected(manage_properties
, 'manage_editProperties')
102 def manage_editProperties(self
, REQUEST
=None, no_refresh
= 0, **kw
):
103 "Save Changes and update the thumbnail"
104 Image
.manage_changeProperties(self
, REQUEST
, **kw
)
106 if hasattr(self
, 'thumbnail') and self
.auto_update_thumb
== 1 and no_refresh
== 0 :
110 message
="Saved changes."
111 return self
.manage_propertiesForm(self
,REQUEST
,
112 manage_tabs_message
=message
)
115 def __init__(self
, id, title
, file, content_type
='', precondition
='', **kw
) :
116 # 0 means: tiles are not generated
117 # 1 means: tiles are all generated
118 # 2 means: tiling is not available is this photo (deliberated choice of the owner)
119 # -1 means: no data tiles cannot be generated
120 self
.tiles_available
= 0
121 super(Photo
, self
).__init
__(id, title
, file, content_type
='', precondition
='')
123 self
.auto_update_thumb
= kw
.get('auto_update_thumb', 1)
124 self
.thumb_height
= kw
.get('thumb_height', 180)
125 self
.thumb_width
= kw
.get('thumb_width', 120)
126 self
.prop_filter
= kw
.get('prop_filter', 'ANTIALIAS')
128 defaultBlankThumbnail
= kw
.get('defaultBlankThumbnail', None)
129 if defaultBlankThumbnail
:
130 blankThumbnail
= Image('thumbnail', '',
131 getattr(defaultBlankThumbnail
, '_data', getattr(defaultBlankThumbnail
, 'data', None)))
132 self
.thumbnail
= blankThumbnail
134 self
._methodResultsCache
= OOBTree()
135 TileSupport
.__init
__(self
)
137 def update_data(self
, file, content_type
=None) :
138 super(Photo
, self
).update_data(file, content_type
)
140 if self
.content_type
!= 'image/jpeg' and self
.size
:
142 im
= PIL
.Image
.open(raw
)
143 self
.content_type
= 'image/%s' % im
.format
.lower()
144 self
.width
, self
.height
= im
.size
146 if im
.mode
not in ('L', 'RGB'):
147 im
= im
.convert('RGB')
149 jpeg_image
= Image('jpeg_image', '', '', content_type
='image/jpeg')
150 out
= jpeg_image
.open('w')
151 im
.save(out
, 'JPEG', quality
=90)
152 jpeg_image
.updateFormat(out
.tell(), im
.size
, 'image/jpeg')
154 self
.jpeg_image
= jpeg_image
156 self
._methodResultsCache
= OOBTree()
157 self
._v
__methodResultsCache
= OOBTree()
159 self
._tiles
= OOBTree()
160 if self
.tiles_available
in [1, -1]:
161 self
.tiles_available
= 0
163 if hasattr(self
, 'thumbnail') and self
.auto_update_thumb
== 1 :
168 def _getJpegBlob(self
) :
170 if self
.content_type
== 'image/jpeg' :
173 return self
.jpeg_image
.bdata
177 security
.declareProtected(view
, 'getJpegImage')
178 def getJpegImage(self
, REQUEST
, RESPONSE
) :
179 """ return JPEG formated image """
180 if self
.content_type
== 'image/jpeg' :
181 return self
.index_html(REQUEST
, RESPONSE
)
182 elif self
.jpeg_image
:
183 return self
.jpeg_image
.index_html(REQUEST
, RESPONSE
)
185 security
.declareProtected(view
, 'tiffOrientation')
187 def tiffOrientation(self
) :
188 tiffOrientation
= self
.getXmpValue('tiff:Orientation')
190 return int(tiffOrientation
)
192 # TODO : falling back to legacy Exif metadata
195 def _rotateOrFlip(self
, im
) :
196 orientation
= self
.tiffOrientation()
197 rotation
, flip
= TIFF_ORIENTATIONS
[orientation
]
199 im
= im
.rotate(-rotation
)
201 im
= im
.transpose(PIL
.Image
.FLIP_LEFT_RIGHT
)
204 @memoizedmethod('size', 'keepAspectRatio')
205 def _getResizedImage(self
, size
, keepAspectRatio
) :
206 """ returns a resized version of the raw image.
209 fullSizeFile
= self
._getJpegBlob
().open('r')
210 fullSizeImage
= PIL
.Image
.open(fullSizeFile
)
211 if fullSizeImage
.mode
not in ('L', 'RGB'):
212 fullSizeImage
.convert('RGB')
213 fullSize
= fullSizeImage
.size
215 if (keepAspectRatio
) :
216 newSize
= getNewSize(fullSize
, size
)
220 fullSizeImage
.thumbnail(newSize
, PIL
.Image
.ANTIALIAS
)
221 fullSizeImage
= self
._rotateOrFlip
(fullSizeImage
)
223 for hook
in self
._getAfterResizingHooks
() :
224 hook(self
, fullSizeImage
)
227 resizedImage
= Image(self
.getId() + _strSize(size
), 'resized copy of %s' % self
.getId(), '')
228 out
= resizedImage
.open('w')
229 fullSizeImage
.save(out
, "JPEG", quality
=90)
230 resizedImage
.updateFormat(out
.tell(), fullSizeImage
.size
, 'image/jpeg')
234 def _getAfterResizingHooks(self
) :
235 """ returns a list of hook scripts that are executed
236 after the image is resized.
241 security
.declarePrivate('makeThumbnail')
242 def makeThumbnail(self
) :
243 "make a thumbnail from jpeg data"
244 b
= self
._getJpegBlob
()
246 # récupération des propriétés de redimentionnement
248 if int(self
.width
) >= int(self
.height
) :
249 thumb_size
.append(self
.thumb_height
)
250 thumb_size
.append(self
.thumb_width
)
252 thumb_size
.append(self
.thumb_width
)
253 thumb_size
.append(self
.thumb_height
)
254 thumb_size
= tuple(thumb_size
)
256 if thumb_size
[0] <= 1 or thumb_size
[1] <= 1 :
257 thumb_size
= (180, 180)
258 thumb_filter
= getattr(PIL
.Image
, self
.prop_filter
, PIL
.Image
.ANTIALIAS
)
260 # create a thumbnail image file
261 original_file
= b
.open('r')
262 image
= PIL
.Image
.open(original_file
)
263 if image
.mode
not in ('L', 'RGB'):
264 image
= image
.convert('RGB')
266 image
.thumbnail(thumb_size
, thumb_filter
)
267 image
= self
._rotateOrFlip
(image
)
269 thumbnail
= Image('thumbnail', 'Thumbail', '', 'image/jpeg')
270 out
= thumbnail
.open('w')
271 image
.save(out
, "JPEG", quality
=90)
272 thumbnail
.updateFormat(out
.tell(), image
.size
, 'image/jpeg')
274 original_file
.close()
275 self
.thumbnail
= thumbnail
280 security
.declareProtected(view
, 'getThumbnail')
281 def getThumbnail(self
, REQUEST
, RESPONSE
) :
282 "Return the thumbnail image and create it before if it does not exist yet."
283 if not hasattr(self
, 'thumbnail') :
285 return self
.thumbnail
.index_html(REQUEST
=REQUEST
, RESPONSE
=RESPONSE
)
287 security
.declareProtected(view
, 'getThumbnailSize')
288 def getThumbnailSize(self
) :
289 """ return thumbnail size dict
291 if not hasattr(self
, 'thumbnail') :
293 return {'height' : 0, 'width' : 0}
296 if int(self
.width
) >= int(self
.height
) :
297 thumbMaxFrame
.append(self
.thumb_height
)
298 thumbMaxFrame
.append(self
.thumb_width
)
300 thumbMaxFrame
.append(self
.thumb_width
)
301 thumbMaxFrame
.append(self
.thumb_height
)
302 thumbMaxFrame
= tuple(thumbMaxFrame
)
304 if thumbMaxFrame
[0] <= 1 or thumbMaxFrame
[1] <= 1 :
305 thumbMaxFrame
= (180, 180)
307 th
= self
.height
* thumbMaxFrame
[0] / float(self
.width
)
308 # resizing round limit is not 0.5 but seems to be strictly up to 0.75
309 # TODO check algorithms
310 if th
> floor(th
) + 0.75 :
311 th
= int(floor(th
)) + 1
315 if th
<= thumbMaxFrame
[1] :
316 thumbSize
= (thumbMaxFrame
[0], th
)
318 tw
= self
.width
* thumbMaxFrame
[1] / float(self
.height
)
319 if tw
> floor(tw
) + 0.75 :
320 tw
= int(floor(tw
)) + 1
323 thumbSize
= (tw
, thumbMaxFrame
[1])
325 if self
.tiffOrientation() <= 4 :
326 return {'width':thumbSize
[0], 'height' : thumbSize
[1]}
328 return {'width':thumbSize
[1], 'height' : thumbSize
[0]}
331 return {'height' : self
.thumbnail
.height
, 'width' :self
.thumbnail
.width
}
334 security
.declareProtected(view
, 'getResizedImageSize')
335 def getResizedImageSize(self
, REQUEST
=None, size
=(), keepAspectRatio
=True, asXml
=False) :
336 """ return the reel image size the after resizing """
338 size
= REQUEST
.SESSION
.get('preferedImageSize', (600, 600))
339 elif type(size
) == StringType
:
340 size
= tuple([int(n
) for n
in size
.split('_')])
342 resizedImage
= self
._getResizedImage
(size
, keepAspectRatio
)
343 size
= (resizedImage
.width
, resizedImage
.height
)
346 REQUEST
.RESPONSE
.setHeader('content-type', 'text/xml; charset=utf-8')
347 return '<size><width>%d</width><height>%d</height></size>' % size
352 security
.declareProtected(view
, 'getResizedImage')
353 def getResizedImage(self
, REQUEST
, RESPONSE
, size
=(), keepAspectRatio
=True) :
355 Return a volatile resized image.
356 The 'preferedImageSize' tuple (width, height) is looked up into SESSION data.
357 Default size is 600 x 600 px
360 size
= REQUEST
.SESSION
.get('preferedImageSize', (600, 600))
361 elif type(size
) == StringType
:
362 size
= size
.split('_')
366 keepAspectRatio
= True
368 size
= tuple([int(n
) for n
in size
])
370 return self
._getResizedImage
(size
, keepAspectRatio
).index_html(REQUEST
=REQUEST
, RESPONSE
=RESPONSE
)
373 InitializeClass(Photo
)
377 def addPhoto(dispatcher
, id, file='', title
='',
378 precondition
='', content_type
='', REQUEST
=None, **kw
) :
380 Add a new Photo object.
381 Creates a new Photo object 'id' with the contents of 'file'.
385 content_type
=str(content_type
)
386 precondition
=str(precondition
)
388 id, title
= cookId(id, title
, file)
389 parentContainer
= dispatcher
.Destination()
391 parentContainer
._setObject
(id, Photo(id,title
,file,content_type
, precondition
, **kw
))
393 if REQUEST
is not None:
394 try: url
=dispatcher
.DestinationURL()
395 except: url
=REQUEST
['URL1']
396 REQUEST
.RESPONSE
.redirect('%s/manage_main' % url
)
400 addPhotoForm
= DTMLFile('dtml/addPhotoForm', globals())