Image.py 16.2 KB
Newer Older
1
# -*- coding: utf-8 -*-
Jean-Paul Smets's avatar
Jean-Paul Smets committed
2 3 4
##############################################################################
#
# Copyright (c) 2002 Nexedi SARL and Contributors. All Rights Reserved.
5
#                    Jean-Paul Smets-Solanes <jp@nexedi.com>
Jean-Paul Smets's avatar
Jean-Paul Smets committed
6
#
7 8 9
# Based on Photo by Ron Bickers
# Copyright (c) 2001 Logic Etc, Inc.  All rights reserved.
#
Jean-Paul Smets's avatar
Jean-Paul Smets committed
10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
# WARNING: This program as such is intended to be used by professional
# programmers who take the whole responsability of assessing all potential
# consequences resulting from its eventual inadequacies and bugs
# End users who are looking for a ready-to-use solution with commercial
# garantees and support are strongly adviced to contract a Free Software
# Service Company
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
#
##############################################################################

33
import string
34
import struct
35
import subprocess
36 37
from cStringIO import StringIO

Jean-Paul Smets's avatar
Jean-Paul Smets committed
38
from AccessControl import ClassSecurityInfo
39
from Acquisition import aq_base
Jean-Paul Smets's avatar
Jean-Paul Smets committed
40

41
from DocumentTemplate.DT_Util import html_quote
42
from Products.CMFCore.utils import _setCacheHeaders, _ViewEmulator
43
from Products.ERP5Type import Permissions, PropertySheet
44
from Products.ERP5Type.Utils import fill_args_from_request
45
from Products.ERP5.Document.File import File
Nicolas Delaby's avatar
Nicolas Delaby committed
46
from Products.ERP5.Document.Document import Document, ConversionError,\
47
                     VALID_TEXT_FORMAT_LIST, VALID_TRANSPARENT_IMAGE_FORMAT_LIST,\
48
                     DEFAULT_DISPLAY_ID_LIST, _MARKER
49
from os.path import splitext
50 51
from OFS.Image import Image as OFSImage
from OFS.Image import getImageInfo
52
from zLOG import LOG, WARNING
Jean-Paul Smets's avatar
Jean-Paul Smets committed
53

54 55
from Products.ERP5Type.ImageUtil import transformUrlToDataURI

56 57
# import mixin
from Products.ERP5.mixin.text_convertable import TextConvertableMixin
58

59 60
def getDefaultImageQuality(portal, format=None):
  preference_tool = portal.portal_preferences
61
  return preference_tool.getPreferredImageQuality()
62

63
class Image(TextConvertableMixin, File, OFSImage):
Kevin Deldycke's avatar
Kevin Deldycke committed
64
  """
65 66 67 68 69 70 71 72 73 74
    An Image is a File which contains image data. It supports
    various conversions of format, size, resolution through
    imagemagick. imagemagick was preferred due to its ability
    to support PDF files (incl. Adobe Illustrator) which make
    it very useful in the context of a graphic design shop.

    Image inherits from XMLObject and can be synchronized
    accross multiple sites.

    Subcontent: Image can only contain role information.
Kevin Deldycke's avatar
Kevin Deldycke committed
75

76 77 78 79 80 81 82
    TODO:
    * extend Image to support more image file formats,
      including Xara Xtreme (http://www.xaraxtreme.org/)
    * include upgrade methods so that previous images
      in ERP5 get upgraded automatically to new class
  """
  # CMF Type Definition
Kevin Deldycke's avatar
Kevin Deldycke committed
83 84 85
  meta_type = 'ERP5 Image'
  portal_type = 'Image'

86 87 88 89
  # Default attribute values
  width = 0
  height = 0

Kevin Deldycke's avatar
Kevin Deldycke committed
90 91
  # Declarative security
  security = ClassSecurityInfo()
92
  security.declareObjectProtected(Permissions.AccessContentsInformation)
Kevin Deldycke's avatar
Kevin Deldycke committed
93

94
  # Default Properties
Kevin Deldycke's avatar
Kevin Deldycke committed
95
  property_sheets = ( PropertySheet.Base
96
                    , PropertySheet.XMLObject
Kevin Deldycke's avatar
Kevin Deldycke committed
97 98
                    , PropertySheet.CategoryCore
                    , PropertySheet.DublinCore
99 100 101 102
                    , PropertySheet.Version
                    , PropertySheet.Reference
                    , PropertySheet.Document
                    , PropertySheet.Data
103 104 105
                    , PropertySheet.ExternalDocument
                    , PropertySheet.Url
                    , PropertySheet.Periodicity
Kevin Deldycke's avatar
Kevin Deldycke committed
106 107
                    )

108 109 110 111 112
  #
  # Original photo attributes
  #

  def _update_image_info(self):
Romain Courteaud's avatar
Romain Courteaud committed
113
    """
114 115 116 117 118 119 120
      This method tries to determine the content type of an image and
      its geometry. It uses currently OFS.Image for this purpose.
      However, this method is known to be too simplistic.

      TODO:
      - use image magick or PIL
    """
121
    self.size = len(self.data)
122
    content_type, width, height = getImageInfo(self.data)
123 124 125 126 127 128
    if not content_type:
      if self.size >= 30 and self.data[:2] == 'BM':
        header = struct.unpack('<III', self.data[14:26])
        if header[0] >= 12:
          content_type = 'image/x-bmp'
          width, height = header[1:]
129 130
    self.height = height
    self.width = width
131
    self._setContentType(content_type or 'application/unknown')
132

133 134 135 136 137 138 139 140 141 142 143 144 145 146 147
  def _upradeImage(self):
    """
      This method upgrades internal data structures is required
    """
    # Quick hack to maintain just enough compatibility for existing sites
    # Convert to new BTreeFolder2 based class
    if getattr(aq_base(self), '_count', None) is None:
      self._initBTrees()

    # Make sure old Image objects can still be accessed
    if not hasattr(aq_base(self), 'data') and hasattr(self, '_original'):
      self.data = self._original.data
      self.height = self._original.height
      self.width = self._original.width

148 149 150 151
    # Make sure old Image objects can still be accessed
    if not hasattr(aq_base(self), 'data') and hasattr(aq_base(self), '_data'):
      self.data = self._data

152
    # Make sure size is defined
153 154 155
    size = len(self.data)
    if getattr(aq_base(self), 'size', None) != size:
      self.size = size
156

157 158 159
  security.declareProtected(Permissions.AccessContentsInformation, 'getWidth')
  def getWidth(self):
    """
Fabien Morin's avatar
Fabien Morin committed
160
      Tries to get the width from the image data.
Romain Courteaud's avatar
Romain Courteaud committed
161
    """
162
    self._upradeImage()
Nicolas Delaby's avatar
Nicolas Delaby committed
163 164
    if self.hasData() and not self.width:
      self._update_image_info()
165
    return self.width
Romain Courteaud's avatar
Romain Courteaud committed
166

167 168
  security.declareProtected(Permissions.AccessContentsInformation, 'getHeight')
  def getHeight(self):
Jean-Paul Smets's avatar
Jean-Paul Smets committed
169
    """
170
      Tries to get the height from the image data.
Jean-Paul Smets's avatar
Jean-Paul Smets committed
171
    """
172
    self._upradeImage()
Nicolas Delaby's avatar
Nicolas Delaby committed
173 174
    if self.hasData() and not self.height:
      self._update_image_info()
175 176 177
    return self.height

  security.declareProtected(Permissions.AccessContentsInformation, 'getContentType')
178
  def getContentType(self, default=_MARKER):
179
    """Original photo content_type."""
180
    self._upradeImage()
181 182 183
    if self.hasData() and not self.hasContentType():
      self._update_image_info()
    if default is _MARKER:
184 185
      return self._baseGetContentType()
    else:
186
      return self._baseGetContentType(default)
187

188
  security.declareProtected(Permissions.AccessContentsInformation, 'displayIds')
189
  def displayIds(self, exclude=('thumbnail',)):
Nicolas Delaby's avatar
Nicolas Delaby committed
190
    """Return list of display Ids."""
Nicolas Delaby's avatar
Nicolas Delaby committed
191
    id_list = list(DEFAULT_DISPLAY_ID_LIST)
Nicolas Delaby's avatar
Nicolas Delaby committed
192 193 194 195 196 197 198 199 200 201 202
    # Exclude specified displays
    if exclude:
      for id in exclude:
        if id in id_list:
          id_list.remove(id)
    # Sort by desired photo surface area
    def getSurfaceArea(img):
      x, y = self.getSizeFromImageDisplay(img)
      return x * y
    id_list.sort(key=getSurfaceArea)
    return id_list
203

204
  security.declareProtected(Permissions.AccessContentsInformation, 'displayLinks')
205
  def displayLinks(self, exclude=('thumbnail',)):
Nicolas Delaby's avatar
Nicolas Delaby committed
206 207 208 209 210
    """Return list of HTML <a> tags for displays."""
    links = []
    for display in self.displayIds(exclude):
        links.append('<a href="%s?display=%s">%s</a>' % (self.REQUEST['URL'], display, display))
    return links
211

212
  security.declareProtected(Permissions.AccessContentsInformation, 'displayMap')
213
  def displayMap(self, exclude=None, format=None, quality=_MARKER,\
214
                                                              resolution=None):
Nicolas Delaby's avatar
Nicolas Delaby committed
215 216
    """Return list of displays with size info."""
    displays = []
217
    if quality is _MARKER:
218
      quality = self.getDefaultImageQuality(format)
Nicolas Delaby's avatar
Nicolas Delaby committed
219
    for id in self.displayIds(exclude):
220 221
      if self._isGenerated(id, format=format, quality=quality,\
                                                        resolution=resolution):
Nicolas Delaby's avatar
Nicolas Delaby committed
222 223 224 225 226 227
        photo_width = self._photos[(id,format)].width
        photo_height = self._photos[(id,format)].height
        bytes = self._photos[(id,format)]._size()
        age = self._photos[(id,format)]._age()
      else:
        (photo_width, photo_height, bytes, age) = (None, None, None, None)
Nicolas Delaby's avatar
Nicolas Delaby committed
228
      image_size = self.getSizeFromImageDisplay(id)
Nicolas Delaby's avatar
Nicolas Delaby committed
229
      displays.append({'id': id,
Nicolas Delaby's avatar
Nicolas Delaby committed
230 231
                        'width': image_size[0],
                        'height': image_size[1],
Nicolas Delaby's avatar
Nicolas Delaby committed
232 233 234 235 236 237
                        'photo_width': photo_width,
                        'photo_height': photo_height,
                        'bytes': bytes,
                        'age': age
                        })
    return displays
Kevin Deldycke's avatar
Kevin Deldycke committed
238

239

240 241 242 243 244
  security.declarePrivate('_convertToText')
  def _convertToText(self, format):
    """
    Convert the image to text with portaltransforms
    """
Nicolas Delaby's avatar
Nicolas Delaby committed
245 246
    portal = self.getPortalObject()
    mime_type = portal.mimetypes_registry.lookupExtension('name.%s' % format)
247
    mime_type = str(mime_type)
248
    src_mimetype = self.getContentType()
Nicolas Delaby's avatar
Nicolas Delaby committed
249
    content = self.getData()
Nicolas Delaby's avatar
Nicolas Delaby committed
250
    portal_transforms = portal.portal_transforms
251 252
    result = portal_transforms.convertToData(mime_type, content,
                                             object=self, context=self,
253
                                             filename=self.getFilename(),
254 255 256 257 258 259 260
                                             mimetype=src_mimetype)
    if result is None:
      # portal_transforms fails to convert.
      LOG('TextDocument.convert', WARNING,
          'portal_transforms failed to convert to %s: %r' % (mime_type, self))
      result = ''
    return mime_type, result
261

262
  # Conversion API
263
  def _convert(self, format, **kw):
264
    """
265
    Implementation of conversion for Image files
266
    """
267 268 269 270 271
    if format == 'svg' and self.getContentType()=='image/svg+xml':
      # SVG format is a textual data which can be returned as it is
      # so client (browser) can draw an image out of it
      return self.getContentType(), self.getData()

Nicolas Delaby's avatar
Nicolas Delaby committed
272
    if format in VALID_TEXT_FORMAT_LIST:
273 274 275
      try:
        return self.getConversion(format=format)
      except KeyError:
276
        mime_type, data = self._convertToText(format)
277
        data = aq_base(data)
278
        self.setConversion(data, mime=mime_type, format=format)
279
        return mime_type, data
280
    if not (format or kw):
281 282
      # User asked for original content
      return self.getContentType(), self.getData()
283 284 285
    image_size = self.getSizeFromImageDisplay(kw.get('display'))
    # store all keys usefull to convert or resize an image
    # 'display' parameter can be discarded
286 287 288
    quality = kw.get('quality', _MARKER)
    if quality is _MARKER:
      quality = self.getDefaultImageQuality(format)
289 290
    kw['format'] = format
    kw['quality'] = quality
291
    try:
292
      mime, image_data = self.getConversion(**kw)
293
    except KeyError:
294 295 296 297 298 299 300 301
      # we need to convert string representation (i.e. display=small) to a 
      # pixel (number of it = 128x128)
      kw['image_size'] = image_size    
      display = kw.pop('display', None)
      mime, image = self._makeDisplayPhoto(**kw)
      image_data = image.data
      # as image will always be requested through a display not by passing exact
      # pixels we need to restore this way in cache
302 303 304
      if display is not None:
        # only set if we have a real value
        kw['display'] = display
305 306 307
      image_size = kw.pop('image_size', None)
      self.setConversion(image_data, mime, **kw)
    return mime, image_data
308 309

  # Display
310
  security.declareProtected(Permissions.View, 'index_html')
311 312
  @fill_args_from_request('display', 'quality', 'resolution', 'frame')
  def index_html(self, REQUEST, *args, **kw):
Nicolas Delaby's avatar
Nicolas Delaby committed
313 314
    """Return the image data."""
    self._upradeImage()
315
    return Document.index_html(self, REQUEST, *args, **kw)
Kevin Deldycke's avatar
Kevin Deldycke committed
316

317 318 319
  #
  # Photo processing
  #
Kevin Deldycke's avatar
Kevin Deldycke committed
320

321
  def _resize(self, quality, width, height, format, resolution, frame):
Nicolas Delaby's avatar
Nicolas Delaby committed
322
    """Resize and resample photo."""
323
    parameter_list = ['convert', '-colorspace', 'sRGB',
324 325
                                 '-quality', str(quality),
                                 '-geometry', '%sx%s' % (width, height)]
326
    if format not in VALID_TRANSPARENT_IMAGE_FORMAT_LIST:
327 328 329
      # ImageMagick way to remove transparent that works with multiple
      # images. http://www.imagemagick.org/Usage/masking/#remove
      parameter_list += '-bordercolor', 'white', '-border', '0'
Nicolas Delaby's avatar
Nicolas Delaby committed
330
    if resolution:
331
      parameter_list += '-density', '%sx%s' % (resolution, resolution)
332
    if frame is not None:
Nicolas Delaby's avatar
Nicolas Delaby committed
333 334 335
      parameter_list.append('-[%s]' % frame)
    else:
      parameter_list.append('-')
336

Nicolas Delaby's avatar
Nicolas Delaby committed
337 338 339 340
    if format:
      parameter_list.append('%s:-' % format)
    else:
      parameter_list.append('-')
341

342 343 344 345
    data = str(self.getData())
    if self.getContentType() == "image/svg+xml":
      data = transformUrlToDataURI(data)

Nicolas Delaby's avatar
Nicolas Delaby committed
346 347 348 349 350
    process = subprocess.Popen(parameter_list,
                               stdin=subprocess.PIPE,
                               stdout=subprocess.PIPE,
                               stderr=subprocess.PIPE,
                               close_fds=True)
351 352 353
    try:
        # XXX: The only portable way is to pass what stdin.write can accept,
        #      which is a string for PIPE.
354
        image, err = process.communicate(data)
355 356 357 358 359
    finally:
        del process
    if image:
      return StringIO(image)
    raise ConversionError('Image conversion failed (%s).' % err)
360

361
  def _getDisplayData(self, format, quality, resolution, frame, image_size):
Nicolas Delaby's avatar
Nicolas Delaby committed
362
    """Return raw photo data for given display."""
363 364
    width, height = self._getAspectRatioSize(*image_size)
    if ((width, height) == image_size or (width, height) == (0, 0))\
365
       and quality == self.getDefaultImageQuality(format) and resolution is None and frame is None\
366 367 368 369 370
       and not format:
      # No resizing, no conversion, return raw image
      return self.getData()
    return self._resize(quality, width, height, format, resolution, frame)

371
  def _makeDisplayPhoto(self, format=None, quality=_MARKER,
372
                                 resolution=None, frame=None, image_size=None):
Nicolas Delaby's avatar
Nicolas Delaby committed
373
    """Create given display."""
374
    if quality is _MARKER:
375
      quality = self.getDefaultImageQuality(format)
376 377 378 379 380 381 382
    width, height = image_size
    base, ext = splitext(self.id)
    id = '%s_%s_%s.%s'% (base, width, height, ext,)
    image = OFSImage(id, self.getTitle(), 
                     self._getDisplayData(format, quality, resolution,
                                                            frame, image_size))
    return image.content_type, aq_base(image)
383 384

  def _getAspectRatioSize(self, width, height):
Nicolas Delaby's avatar
Nicolas Delaby committed
385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400
    """Return proportional dimensions within desired size."""
    img_width, img_height = (self.getWidth(), self.getHeight())
    if img_width == 0:
      return (0, 0)

    #XXX This is a temporary dirty fix!!!
    width = int(width)
    height = int(height)
    img_width = int(img_width)
    img_height = int(img_height)

    if height > img_height * width / img_width:
      height = img_height * width / img_width
    else:
      width =  img_width * height / img_height
    return (width, height)
401 402

  def _validImage(self):
Nicolas Delaby's avatar
Nicolas Delaby committed
403 404
    """At least see if it *might* be valid."""
    return self.getWidth() and self.getHeight() and self.getData() and self.getContentType()
405

406
  security.declareProtected(Permissions.AccessContentsInformation, 'getSizeFromImageDisplay')
407
  def getSizeFromImageDisplay(self, image_display):
Nicolas Delaby's avatar
Nicolas Delaby committed
408 409
    """Return the size for this image display,
       or dimension of this image.
410
    """
Nicolas Delaby's avatar
Nicolas Delaby committed
411
    if image_display in DEFAULT_DISPLAY_ID_LIST:
412 413
      preference_tool = self.getPortalObject().portal_preferences
      height_preference = 'preferred_%s_image_height' % (image_display,)
414 415 416
      width_preference = 'preferred_%s_image_width' % (image_display,)
      height = preference_tool.getPreference(height_preference)
      width = preference_tool.getPreference(width_preference)
417
      return (width, height)
Nicolas Delaby's avatar
Nicolas Delaby committed
418
    return self.getWidth(), self.getHeight()
419

420 421 422 423 424
  def _setFile(self, *args, **kw):
    """set the file content and reset image information.
    """
    File._setFile(self, *args, **kw)
    self._update_image_info()
425

426
  def PUT(self, REQUEST, RESPONSE):
427 428
    """set the file content by HTTP/FTP and reset image information.
    """
429
    File.PUT(self, REQUEST, RESPONSE)
430
    self._update_image_info()
431 432 433 434 435

  def getDefaultImageQuality(self, format=None):
    """
    Get default image quality for a format.
    """
436
    return getDefaultImageQuality(self.getPortalObject(), format)