787e4eea913011f5d4da757e1da0d6e8f83a3c5a
[Photo.git] / Photo.py
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> #
5 # #
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. #
10 # #
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. #
15 # #
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 #######################################################################################
20 """ Photo zope object
21
22
23
24 """
25
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
34
35 from blobbases import Image, cookId, getImageInfo
36 import PIL.Image
37 import string
38 from math import floor
39 from types import StringType
40 from logging import getLogger
41 console = getLogger('Photo.Photo')
42
43
44
45 def _strSize(size) :
46 return str(size[0]) + '_' + str(size[1])
47
48 def getNewSize(fullSize, maxNewSize) :
49 fullWidth, fullHeight = fullSize
50 maxWidth, maxHeight = maxNewSize
51
52 widthRatio = float(maxWidth) / fullWidth
53 if int(fullHeight * widthRatio) > maxWidth :
54 heightRatio = float(maxHeight) / fullHeight
55 return (int(fullWidth * heightRatio) , maxHeight)
56 else :
57 return (maxWidth, int(fullHeight * widthRatio))
58
59
60
61
62
63
64 class Photo(Image, TileSupport, Metadata):
65 "Photo éditable en ligne"
66
67 meta_type = 'Photo'
68
69 security = ClassSecurityInfo()
70
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())
76
77 manage_options=(
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:]
82
83
84 filters = ['NEAREST', 'BILINEAR', 'BICUBIC', 'ANTIALIAS']
85
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',
94 'label' : 'Filter',
95 'type' : 'selection',
96 'select_variable' : 'filters',
97 'mode' : 'w'},
98 )
99
100
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)
105
106 if hasattr(self, 'thumbnail') and self.auto_update_thumb == 1 and no_refresh == 0 :
107 self.makeThumbnail()
108
109 if REQUEST:
110 message="Saved changes."
111 return self.manage_propertiesForm(self,REQUEST,
112 manage_tabs_message=message)
113
114
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 self.auto_update_thumb = kw.get('auto_update_thumb', 1)
122 self.thumb_height = kw.get('thumb_height', 180)
123 self.thumb_width = kw.get('thumb_width', 180)
124 self.prop_filter = kw.get('prop_filter', 'ANTIALIAS')
125 super(Photo, self).__init__(id, title, file, content_type='', precondition='')
126
127 defaultBlankThumbnail = kw.get('defaultBlankThumbnail', None)
128 if defaultBlankThumbnail :
129 blankThumbnail = Image('thumbnail', '',
130 getattr(defaultBlankThumbnail, '_data', getattr(defaultBlankThumbnail, 'data', None)))
131 self.thumbnail = blankThumbnail
132
133 self._methodResultsCache = OOBTree()
134 TileSupport.__init__(self)
135
136 def update_data(self, file, content_type=None) :
137 super(Photo, self).update_data(file, content_type)
138
139 if self.content_type != 'image/jpeg' and self.size :
140 raw = self.open('r')
141 im = PIL.Image.open(raw)
142 self.content_type = 'image/%s' % im.format.lower()
143 self.width, self.height = im.size
144
145 if im.mode not in ('L', 'RGB'):
146 im = im.convert('RGB')
147
148 jpeg_image = Image('jpeg_image', '', '', content_type='image/jpeg')
149 out = jpeg_image.open('w')
150 im.save(out, 'JPEG', quality=90)
151 jpeg_image.updateFormat(out.tell(), im.size, 'image/jpeg')
152 out.close()
153 self.jpeg_image = jpeg_image
154
155 self._methodResultsCache = OOBTree()
156 self._v__methodResultsCache = OOBTree()
157
158 self._tiles = OOBTree()
159 if self.tiles_available in [1, -1]:
160 self.tiles_available = 0
161
162 if hasattr(self, 'thumbnail') and self.auto_update_thumb == 1 :
163 self.makeThumbnail()
164
165
166
167 def _getJpegBlob(self) :
168 if self.size :
169 if self.content_type == 'image/jpeg' :
170 return self.bdata
171 else :
172 return self.jpeg_image.bdata
173 else :
174 return None
175
176 security.declareProtected(view, 'getJpegImage')
177 def getJpegImage(self, REQUEST, RESPONSE) :
178 """ return JPEG formated image """
179 if self.content_type == 'image/jpeg' :
180 return self.index_html(REQUEST, RESPONSE)
181 elif self.jpeg_image :
182 return self.jpeg_image.index_html(REQUEST, RESPONSE)
183
184 security.declareProtected(view, 'tiffOrientation')
185 @memoizedmethod()
186 def tiffOrientation(self) :
187 tiffOrientation = self.getXmpValue('tiff:Orientation')
188 if tiffOrientation :
189 return int(tiffOrientation)
190 else :
191 # TODO : falling back to legacy Exif metadata
192 return 1
193
194 def _rotateOrFlip(self, im) :
195 orientation = self.tiffOrientation()
196 rotation, flip = TIFF_ORIENTATIONS.get(orientation, (0, False))
197 if rotation :
198 im = im.rotate(-rotation)
199 if flip :
200 im = im.transpose(PIL.Image.FLIP_LEFT_RIGHT)
201 return im
202
203 @memoizedmethod('size', 'keepAspectRatio')
204 def _getResizedImage(self, size, keepAspectRatio) :
205 """ returns a resized version of the raw image.
206 """
207
208 fullSizeFile = self._getJpegBlob().open('r')
209 fullSizeImage = PIL.Image.open(fullSizeFile)
210 if fullSizeImage.mode not in ('L', 'RGB'):
211 fullSizeImage.convert('RGB')
212 fullSize = fullSizeImage.size
213
214 if (keepAspectRatio) :
215 newSize = getNewSize(fullSize, size)
216 else :
217 newSize = size
218
219 fullSizeImage.thumbnail(newSize, PIL.Image.ANTIALIAS)
220 fullSizeImage = self._rotateOrFlip(fullSizeImage)
221
222 for hook in self._getAfterResizingHooks() :
223 hook(self, fullSizeImage)
224
225
226 resizedImage = Image(self.getId() + _strSize(size), 'resized copy of %s' % self.getId(), '')
227 out = resizedImage.open('w')
228 fullSizeImage.save(out, "JPEG", quality=90)
229 resizedImage.updateFormat(out.tell(), fullSizeImage.size, 'image/jpeg')
230 out.close()
231 return resizedImage
232
233 def _getAfterResizingHooks(self) :
234 """ returns a list of hook scripts that are executed
235 after the image is resized.
236 """
237 return []
238
239
240 security.declarePrivate('makeThumbnail')
241 def makeThumbnail(self) :
242 "make a thumbnail from jpeg data"
243 b = self._getJpegBlob()
244 if b is not None :
245 # récupération des propriétés de redimentionnement
246 thumb_size = []
247 if int(self.width) >= int(self.height) :
248 thumb_size.append(self.thumb_height)
249 thumb_size.append(self.thumb_width)
250 else :
251 thumb_size.append(self.thumb_width)
252 thumb_size.append(self.thumb_height)
253 thumb_size = tuple(thumb_size)
254
255 if thumb_size[0] <= 1 or thumb_size[1] <= 1 :
256 thumb_size = (180, 180)
257 thumb_filter = getattr(PIL.Image, self.prop_filter, PIL.Image.ANTIALIAS)
258
259 # create a thumbnail image file
260 original_file = b.open('r')
261 image = PIL.Image.open(original_file)
262 if image.mode not in ('L', 'RGB'):
263 image = image.convert('RGB')
264
265 image.thumbnail(thumb_size, thumb_filter)
266 image = self._rotateOrFlip(image)
267
268 thumbnail = Image('thumbnail', 'Thumbail', '', 'image/jpeg')
269 out = thumbnail.open('w')
270 image.save(out, "JPEG", quality=90)
271 thumbnail.updateFormat(out.tell(), image.size, 'image/jpeg')
272 out.close()
273 original_file.close()
274 self.thumbnail = thumbnail
275 return True
276 else :
277 return False
278
279 security.declareProtected(view, 'getThumbnail')
280 def getThumbnail(self, REQUEST, RESPONSE) :
281 "Return the thumbnail image and create it before if it does not exist yet."
282 if not hasattr(self, 'thumbnail') :
283 self.makeThumbnail()
284 return self.thumbnail.index_html(REQUEST=REQUEST, RESPONSE=RESPONSE)
285
286 security.declareProtected(view, 'getThumbnailSize')
287 def getThumbnailSize(self) :
288 """ return thumbnail size dict
289 """
290 if not hasattr(self, 'thumbnail') :
291 if not self.width :
292 return {'height' : 0, 'width' : 0}
293 else :
294 thumbMaxFrame = []
295 if int(self.width) >= int(self.height) :
296 thumbMaxFrame.append(self.thumb_height)
297 thumbMaxFrame.append(self.thumb_width)
298 else :
299 thumbMaxFrame.append(self.thumb_width)
300 thumbMaxFrame.append(self.thumb_height)
301 thumbMaxFrame = tuple(thumbMaxFrame)
302
303 if thumbMaxFrame[0] <= 1 or thumbMaxFrame[1] <= 1 :
304 thumbMaxFrame = (180, 180)
305
306 th = self.height * thumbMaxFrame[0] / float(self.width)
307 # resizing round limit is not 0.5 but seems to be strictly up to 0.75
308 # TODO check algorithms
309 if th > floor(th) + 0.75 :
310 th = int(floor(th)) + 1
311 else :
312 th = int(floor(th))
313
314 if th <= thumbMaxFrame[1] :
315 thumbSize = (thumbMaxFrame[0], th)
316 else :
317 tw = self.width * thumbMaxFrame[1] / float(self.height)
318 if tw > floor(tw) + 0.75 :
319 tw = int(floor(tw)) + 1
320 else :
321 tw = int(floor(tw))
322 thumbSize = (tw, thumbMaxFrame[1])
323
324 if self.tiffOrientation() <= 4 :
325 return {'width':thumbSize[0], 'height' : thumbSize[1]}
326 else :
327 return {'width':thumbSize[1], 'height' : thumbSize[0]}
328
329 else :
330 return {'height' : self.thumbnail.height, 'width' :self.thumbnail.width}
331
332
333 security.declareProtected(view, 'getResizedImageSize')
334 def getResizedImageSize(self, REQUEST=None, size=(), keepAspectRatio=True, asXml=False) :
335 """ return the reel image size the after resizing """
336 if not size :
337 size = REQUEST.SESSION.get('preferedImageSize', (600, 600))
338 elif type(size) == StringType :
339 size = tuple([int(n) for n in size.split('_')])
340
341 resizedImage = self._getResizedImage(size, keepAspectRatio)
342 size = (resizedImage.width, resizedImage.height)
343
344 if asXml :
345 REQUEST.RESPONSE.setHeader('content-type', 'text/xml; charset=utf-8')
346 return '<size><width>%d</width><height>%d</height></size>' % size
347 else :
348 return size
349
350
351 security.declareProtected(view, 'getResizedImage')
352 def getResizedImage(self, REQUEST, RESPONSE, size=(), keepAspectRatio=True) :
353 """
354 Return a volatile resized image.
355 The 'preferedImageSize' tuple (width, height) is looked up into SESSION data.
356 Default size is 600 x 600 px
357 """
358 if not size :
359 size = REQUEST.SESSION.get('preferedImageSize', (600, 600))
360 elif type(size) == StringType :
361 size = size.split('_')
362 if len(size) == 1 :
363 i = int(size[0])
364 size = (i, i)
365 keepAspectRatio = True
366 else :
367 size = tuple([int(n) for n in size])
368
369 return self._getResizedImage(size, keepAspectRatio).index_html(REQUEST=REQUEST, RESPONSE=RESPONSE)
370
371
372 InitializeClass(Photo)
373
374
375 # Factories
376 def addPhoto(dispatcher, id, file='', title='',
377 precondition='', content_type='', REQUEST=None, **kw) :
378 """
379 Add a new Photo object.
380 Creates a new Photo object 'id' with the contents of 'file'.
381 """
382 id=str(id)
383 title=str(title)
384 content_type=str(content_type)
385 precondition=str(precondition)
386
387 id, title = cookId(id, title, file)
388 parentContainer = dispatcher.Destination()
389
390 parentContainer._setObject(id, Photo(id,title,file,content_type, precondition, **kw))
391
392 if REQUEST is not None:
393 try: url=dispatcher.DestinationURL()
394 except: url=REQUEST['URL1']
395 REQUEST.RESPONSE.redirect('%s/manage_main' % url)
396 return id
397
398 # creation form
399 addPhotoForm = DTMLFile('dtml/addPhotoForm', globals())