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 #######################################################################################
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 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
='')
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
133 self
._methodResultsCache
= OOBTree()
134 TileSupport
.__init
__(self
)
136 def update_data(self
, file, content_type
=None) :
137 super(Photo
, self
).update_data(file, content_type
)
139 if self
.content_type
!= 'image/jpeg' and self
.size
:
141 im
= PIL
.Image
.open(raw
)
142 self
.content_type
= 'image/%s' % im
.format
.lower()
143 self
.width
, self
.height
= im
.size
145 if im
.mode
not in ('L', 'RGB'):
146 im
= im
.convert('RGB')
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')
153 self
.jpeg_image
= jpeg_image
155 self
._methodResultsCache
= OOBTree()
156 self
._v
__methodResultsCache
= OOBTree()
158 self
._tiles
= OOBTree()
159 if self
.tiles_available
in [1, -1]:
160 self
.tiles_available
= 0
162 if hasattr(self
, 'thumbnail') and self
.auto_update_thumb
== 1 :
167 def _getJpegBlob(self
) :
169 if self
.content_type
== 'image/jpeg' :
172 return self
.jpeg_image
.bdata
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
)
184 security
.declareProtected(view
, 'tiffOrientation')
186 def tiffOrientation(self
) :
187 tiffOrientation
= self
.getXmpValue('tiff:Orientation')
189 return int(tiffOrientation
)
191 # TODO : falling back to legacy Exif metadata
194 def _rotateOrFlip(self
, im
) :
195 orientation
= self
.tiffOrientation()
196 rotation
, flip
= TIFF_ORIENTATIONS
.get(orientation
, (0, False))
198 im
= im
.rotate(-rotation
)
200 im
= im
.transpose(PIL
.Image
.FLIP_LEFT_RIGHT
)
203 @memoizedmethod('size', 'keepAspectRatio')
204 def _getResizedImage(self
, size
, keepAspectRatio
) :
205 """ returns a resized version of the raw image.
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
214 if (keepAspectRatio
) :
215 newSize
= getNewSize(fullSize
, size
)
219 fullSizeImage
.thumbnail(newSize
, PIL
.Image
.ANTIALIAS
)
220 fullSizeImage
= self
._rotateOrFlip
(fullSizeImage
)
222 for hook
in self
._getAfterResizingHooks
() :
223 hook(self
, fullSizeImage
)
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')
233 def _getAfterResizingHooks(self
) :
234 """ returns a list of hook scripts that are executed
235 after the image is resized.
240 security
.declarePrivate('makeThumbnail')
241 def makeThumbnail(self
) :
242 "make a thumbnail from jpeg data"
243 b
= self
._getJpegBlob
()
245 # récupération des propriétés de redimentionnement
247 if int(self
.width
) >= int(self
.height
) :
248 thumb_size
.append(self
.thumb_height
)
249 thumb_size
.append(self
.thumb_width
)
251 thumb_size
.append(self
.thumb_width
)
252 thumb_size
.append(self
.thumb_height
)
253 thumb_size
= tuple(thumb_size
)
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
)
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')
265 image
.thumbnail(thumb_size
, thumb_filter
)
266 image
= self
._rotateOrFlip
(image
)
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')
273 original_file
.close()
274 self
.thumbnail
= thumbnail
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') :
284 return self
.thumbnail
.index_html(REQUEST
=REQUEST
, RESPONSE
=RESPONSE
)
286 security
.declareProtected(view
, 'getThumbnailSize')
287 def getThumbnailSize(self
) :
288 """ return thumbnail size dict
290 if not hasattr(self
, 'thumbnail') :
292 return {'height' : 0, 'width' : 0}
295 if int(self
.width
) >= int(self
.height
) :
296 thumbMaxFrame
.append(self
.thumb_height
)
297 thumbMaxFrame
.append(self
.thumb_width
)
299 thumbMaxFrame
.append(self
.thumb_width
)
300 thumbMaxFrame
.append(self
.thumb_height
)
301 thumbMaxFrame
= tuple(thumbMaxFrame
)
303 if thumbMaxFrame
[0] <= 1 or thumbMaxFrame
[1] <= 1 :
304 thumbMaxFrame
= (180, 180)
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
314 if th
<= thumbMaxFrame
[1] :
315 thumbSize
= (thumbMaxFrame
[0], th
)
317 tw
= self
.width
* thumbMaxFrame
[1] / float(self
.height
)
318 if tw
> floor(tw
) + 0.75 :
319 tw
= int(floor(tw
)) + 1
322 thumbSize
= (tw
, thumbMaxFrame
[1])
324 if self
.tiffOrientation() <= 4 :
325 return {'width':thumbSize
[0], 'height' : thumbSize
[1]}
327 return {'width':thumbSize
[1], 'height' : thumbSize
[0]}
330 return {'height' : self
.thumbnail
.height
, 'width' :self
.thumbnail
.width
}
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 """
337 size
= REQUEST
.SESSION
.get('preferedImageSize', (600, 600))
338 elif type(size
) == StringType
:
339 size
= tuple([int(n
) for n
in size
.split('_')])
341 resizedImage
= self
._getResizedImage
(size
, keepAspectRatio
)
342 size
= (resizedImage
.width
, resizedImage
.height
)
345 REQUEST
.RESPONSE
.setHeader('content-type', 'text/xml; charset=utf-8')
346 return '<size><width>%d</width><height>%d</height></size>' % size
351 security
.declareProtected(view
, 'getResizedImage')
352 def getResizedImage(self
, REQUEST
, RESPONSE
, size
=(), keepAspectRatio
=True) :
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
359 size
= REQUEST
.SESSION
.get('preferedImageSize', (600, 600))
360 elif type(size
) == StringType
:
361 size
= size
.split('_')
365 keepAspectRatio
= True
367 size
= tuple([int(n
) for n
in size
])
369 return self
._getResizedImage
(size
, keepAspectRatio
).index_html(REQUEST
=REQUEST
, RESPONSE
=RESPONSE
)
372 InitializeClass(Photo
)
376 def addPhoto(dispatcher
, id, file='', title
='',
377 precondition
='', content_type
='', REQUEST
=None, **kw
) :
379 Add a new Photo object.
380 Creates a new Photo object 'id' with the contents of 'file'.
384 content_type
=str(content_type
)
385 precondition
=str(precondition
)
387 id, title
= cookId(id, title
, file)
388 parentContainer
= dispatcher
.Destination()
390 parentContainer
._setObject
(id, Photo(id,title
,file,content_type
, precondition
, **kw
))
392 if REQUEST
is not None:
393 try: url
=dispatcher
.DestinationURL()
394 except: url
=REQUEST
['URL1']
395 REQUEST
.RESPONSE
.redirect('%s/manage_main' % url
)
399 addPhotoForm
= DTMLFile('dtml/addPhotoForm', globals())