Ajout d'un test pour getXmpAlt : il arrive que Lr3 produise des Alt vides.
[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 $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 $
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 super(Photo, self).__init__(id, title, file, content_type='', precondition='')
122
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')
127
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
133
134 self._methodResultsCache = OOBTree()
135 TileSupport.__init__(self)
136
137 def update_data(self, file, content_type=None) :
138 super(Photo, self).update_data(file, content_type)
139
140 if self.content_type != 'image/jpeg' and self.size :
141 raw = self.open('r')
142 im = PIL.Image.open(raw)
143 self.content_type = 'image/%s' % im.format.lower()
144 self.width, self.height = im.size
145
146 if im.mode not in ('L', 'RGB'):
147 im = im.convert('RGB')
148
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')
153 out.close()
154 self.jpeg_image = jpeg_image
155
156 self._methodResultsCache = OOBTree()
157 self._v__methodResultsCache = OOBTree()
158
159 self._tiles = OOBTree()
160 if self.tiles_available in [1, -1]:
161 self.tiles_available = 0
162
163 if hasattr(self, 'thumbnail') and self.auto_update_thumb == 1 :
164 self.makeThumbnail()
165
166
167
168 def _getJpegBlob(self) :
169 if self.size :
170 if self.content_type == 'image/jpeg' :
171 return self.bdata
172 else :
173 return self.jpeg_image.bdata
174 else :
175 return None
176
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)
184
185 security.declareProtected(view, 'tiffOrientation')
186 @memoizedmethod()
187 def tiffOrientation(self) :
188 tiffOrientation = self.getXmpValue('tiff:Orientation')
189 if tiffOrientation :
190 return int(tiffOrientation)
191 else :
192 # TODO : falling back to legacy Exif metadata
193 return 1
194
195 def _rotateOrFlip(self, im) :
196 orientation = self.tiffOrientation()
197 rotation, flip = TIFF_ORIENTATIONS.get(orientation, (0, False))
198 if rotation :
199 im = im.rotate(-rotation)
200 if flip :
201 im = im.transpose(PIL.Image.FLIP_LEFT_RIGHT)
202 return im
203
204 @memoizedmethod('size', 'keepAspectRatio')
205 def _getResizedImage(self, size, keepAspectRatio) :
206 """ returns a resized version of the raw image.
207 """
208
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
214
215 if (keepAspectRatio) :
216 newSize = getNewSize(fullSize, size)
217 else :
218 newSize = size
219
220 fullSizeImage.thumbnail(newSize, PIL.Image.ANTIALIAS)
221 fullSizeImage = self._rotateOrFlip(fullSizeImage)
222
223 for hook in self._getAfterResizingHooks() :
224 hook(self, fullSizeImage)
225
226
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')
231 out.close()
232 return resizedImage
233
234 def _getAfterResizingHooks(self) :
235 """ returns a list of hook scripts that are executed
236 after the image is resized.
237 """
238 return []
239
240
241 security.declarePrivate('makeThumbnail')
242 def makeThumbnail(self) :
243 "make a thumbnail from jpeg data"
244 b = self._getJpegBlob()
245 if b is not None :
246 # récupération des propriétés de redimentionnement
247 thumb_size = []
248 if int(self.width) >= int(self.height) :
249 thumb_size.append(self.thumb_height)
250 thumb_size.append(self.thumb_width)
251 else :
252 thumb_size.append(self.thumb_width)
253 thumb_size.append(self.thumb_height)
254 thumb_size = tuple(thumb_size)
255
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)
259
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')
265
266 image.thumbnail(thumb_size, thumb_filter)
267 image = self._rotateOrFlip(image)
268
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')
273 out.close()
274 original_file.close()
275 self.thumbnail = thumbnail
276 return True
277 else :
278 return False
279
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') :
284 self.makeThumbnail()
285 return self.thumbnail.index_html(REQUEST=REQUEST, RESPONSE=RESPONSE)
286
287 security.declareProtected(view, 'getThumbnailSize')
288 def getThumbnailSize(self) :
289 """ return thumbnail size dict
290 """
291 if not hasattr(self, 'thumbnail') :
292 if not self.width :
293 return {'height' : 0, 'width' : 0}
294 else :
295 thumbMaxFrame = []
296 if int(self.width) >= int(self.height) :
297 thumbMaxFrame.append(self.thumb_height)
298 thumbMaxFrame.append(self.thumb_width)
299 else :
300 thumbMaxFrame.append(self.thumb_width)
301 thumbMaxFrame.append(self.thumb_height)
302 thumbMaxFrame = tuple(thumbMaxFrame)
303
304 if thumbMaxFrame[0] <= 1 or thumbMaxFrame[1] <= 1 :
305 thumbMaxFrame = (180, 180)
306
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
312 else :
313 th = int(floor(th))
314
315 if th <= thumbMaxFrame[1] :
316 thumbSize = (thumbMaxFrame[0], th)
317 else :
318 tw = self.width * thumbMaxFrame[1] / float(self.height)
319 if tw > floor(tw) + 0.75 :
320 tw = int(floor(tw)) + 1
321 else :
322 tw = int(floor(tw))
323 thumbSize = (tw, thumbMaxFrame[1])
324
325 if self.tiffOrientation() <= 4 :
326 return {'width':thumbSize[0], 'height' : thumbSize[1]}
327 else :
328 return {'width':thumbSize[1], 'height' : thumbSize[0]}
329
330 else :
331 return {'height' : self.thumbnail.height, 'width' :self.thumbnail.width}
332
333
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 """
337 if not size :
338 size = REQUEST.SESSION.get('preferedImageSize', (600, 600))
339 elif type(size) == StringType :
340 size = tuple([int(n) for n in size.split('_')])
341
342 resizedImage = self._getResizedImage(size, keepAspectRatio)
343 size = (resizedImage.width, resizedImage.height)
344
345 if asXml :
346 REQUEST.RESPONSE.setHeader('content-type', 'text/xml; charset=utf-8')
347 return '<size><width>%d</width><height>%d</height></size>' % size
348 else :
349 return size
350
351
352 security.declareProtected(view, 'getResizedImage')
353 def getResizedImage(self, REQUEST, RESPONSE, size=(), keepAspectRatio=True) :
354 """
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
358 """
359 if not size :
360 size = REQUEST.SESSION.get('preferedImageSize', (600, 600))
361 elif type(size) == StringType :
362 size = size.split('_')
363 if len(size) == 1 :
364 i = int(size[0])
365 size = (i, i)
366 keepAspectRatio = True
367 else :
368 size = tuple([int(n) for n in size])
369
370 return self._getResizedImage(size, keepAspectRatio).index_html(REQUEST=REQUEST, RESPONSE=RESPONSE)
371
372
373 InitializeClass(Photo)
374
375
376 # Factories
377 def addPhoto(dispatcher, id, file='', title='',
378 precondition='', content_type='', REQUEST=None, **kw) :
379 """
380 Add a new Photo object.
381 Creates a new Photo object 'id' with the contents of 'file'.
382 """
383 id=str(id)
384 title=str(title)
385 content_type=str(content_type)
386 precondition=str(precondition)
387
388 id, title = cookId(id, title, file)
389 parentContainer = dispatcher.Destination()
390
391 parentContainer._setObject(id, Photo(id,title,file,content_type, precondition, **kw))
392
393 if REQUEST is not None:
394 try: url=dispatcher.DestinationURL()
395 except: url=REQUEST['URL1']
396 REQUEST.RESPONSE.redirect('%s/manage_main' % url)
397 return id
398
399 # creation form
400 addPhotoForm = DTMLFile('dtml/addPhotoForm', globals())