Image.py 16 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 48
                     VALID_TEXT_FORMAT_LIST, VALID_TRANSPARENT_IMAGE_FORMAT_LIST,\
                     DEFAULT_DISPLAY_ID_LIST, DEFAULT_IMAGE_QUALITY, _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
# import mixin
from Products.ERP5.mixin.text_convertable import TextConvertableMixin
56

57 58 59 60
def getDefaultImageQuality(portal, format=None):
  preference_tool = portal.portal_preferences
  return preference_tool.getPreference('preferred_image_quality', DEFAULT_IMAGE_QUALITY)

61
class Image(TextConvertableMixin, File, OFSImage):
Kevin Deldycke's avatar
Kevin Deldycke committed
62
  """
63 64 65 66 67 68 69 70 71 72
    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
73

74 75 76 77 78 79 80
    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
81 82 83
  meta_type = 'ERP5 Image'
  portal_type = 'Image'

84 85 86 87
  # Default attribute values
  width = 0
  height = 0

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

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

106 107 108 109 110
  #
  # Original photo attributes
  #

  def _update_image_info(self):
Romain Courteaud's avatar
Romain Courteaud committed
111
    """
112 113 114 115 116 117 118
      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
    """
119
    self.size = len(self.data)
120
    content_type, width, height = getImageInfo(self.data)
121 122 123 124 125 126
    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:]
127 128 129 130
    self.height = height
    self.width = width
    self._setContentType(content_type)

131 132 133 134 135 136 137 138 139 140 141 142 143 144 145
  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

146 147 148 149
    # 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

150
    # Make sure size is defined
151 152
    if (not hasattr(aq_base(self), 'size') or not self.size) and \
                      hasattr(aq_base(self), 'data'):
153 154
      self.size = len(self.data)

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

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

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

186
  security.declareProtected(Permissions.AccessContentsInformation, 'displayIds')
187
  def displayIds(self, exclude=('thumbnail',)):
Nicolas Delaby's avatar
Nicolas Delaby committed
188
    """Return list of display Ids."""
Nicolas Delaby's avatar
Nicolas Delaby committed
189
    id_list = list(DEFAULT_DISPLAY_ID_LIST)
Nicolas Delaby's avatar
Nicolas Delaby committed
190 191 192 193 194 195 196 197 198 199 200
    # 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
201

202
  security.declareProtected(Permissions.AccessContentsInformation, 'displayLinks')
203
  def displayLinks(self, exclude=('thumbnail',)):
Nicolas Delaby's avatar
Nicolas Delaby committed
204 205 206 207 208
    """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
209

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

237

238 239 240 241 242
  security.declarePrivate('_convertToText')
  def _convertToText(self, format):
    """
    Convert the image to text with portaltransforms
    """
Nicolas Delaby's avatar
Nicolas Delaby committed
243 244
    portal = self.getPortalObject()
    mime_type = portal.mimetypes_registry.lookupExtension('name.%s' % format)
245
    mime_type = str(mime_type)
246
    src_mimetype = self.getContentType()
Nicolas Delaby's avatar
Nicolas Delaby committed
247
    content = self.getData()
Nicolas Delaby's avatar
Nicolas Delaby committed
248
    portal_transforms = portal.portal_transforms
249 250
    result = portal_transforms.convertToData(mime_type, content,
                                             object=self, context=self,
251
                                             filename=self.getFilename(),
252 253 254 255 256 257 258
                                             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
259

260
  # Conversion API
261
  def _convert(self, format, **kw):
262
    """
263
    Implementation of conversion for Image files
264
    """
Nicolas Delaby's avatar
Nicolas Delaby committed
265
    if format in VALID_TEXT_FORMAT_LIST:
266 267 268
      try:
        return self.getConversion(format=format)
      except KeyError:
269
        mime_type, data = self._convertToText(format)
270
        data = aq_base(data)
271
        self.setConversion(data, mime=mime_type, format=format)
272
        return mime_type, data
273
    if not (format or kw):
274 275
      # User asked for original content
      return self.getContentType(), self.getData()
276 277 278
    image_size = self.getSizeFromImageDisplay(kw.get('display'))
    # store all keys usefull to convert or resize an image
    # 'display' parameter can be discarded
279 280 281
    quality = kw.get('quality', _MARKER)
    if quality is _MARKER:
      quality = self.getDefaultImageQuality(format)
282 283
    kw['format'] = format
    kw['quality'] = quality
284
    try:
285
      mime, image_data = self.getConversion(**kw)
286
    except KeyError:
287 288 289 290 291 292 293 294 295 296 297 298
      # 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
      kw['display'] = display
      image_size = kw.pop('image_size', None)
      self.setConversion(image_data, mime, **kw)
    return mime, image_data
299 300

  # Display
301
  security.declareProtected(Permissions.View, 'index_html')
302 303
  @fill_args_from_request('display', 'quality', 'resolution', 'frame')
  def index_html(self, REQUEST, *args, **kw):
Nicolas Delaby's avatar
Nicolas Delaby committed
304 305
    """Return the image data."""
    self._upradeImage()
306
    return Document.index_html(self, REQUEST, *args, **kw)
Kevin Deldycke's avatar
Kevin Deldycke committed
307

308 309 310
  #
  # Photo processing
  #
Kevin Deldycke's avatar
Kevin Deldycke committed
311

312
  def _resize(self, quality, width, height, format, resolution, frame):
Nicolas Delaby's avatar
Nicolas Delaby committed
313 314 315 316 317
    """Resize and resample photo."""
    newimg = StringIO()

    parameter_list = ['convert']
    parameter_list.extend(['-colorspace', 'RGB'])
318
    if format not in VALID_TRANSPARENT_IMAGE_FORMAT_LIST:
319
      parameter_list.extend(['-alpha', 'off'])
Nicolas Delaby's avatar
Nicolas Delaby committed
320 321 322 323
    if resolution:
      parameter_list.extend(['-density', '%sx%s' % (resolution, resolution)])
    parameter_list.extend(['-quality', str(quality)])
    parameter_list.extend(['-geometry', '%sx%s' % (width, height)])
324
    if frame is not None:
Nicolas Delaby's avatar
Nicolas Delaby committed
325 326 327
      parameter_list.append('-[%s]' % frame)
    else:
      parameter_list.append('-')
328

Nicolas Delaby's avatar
Nicolas Delaby committed
329 330 331 332
    if format:
      parameter_list.append('%s:-' % format)
    else:
      parameter_list.append('-')
333

Nicolas Delaby's avatar
Nicolas Delaby committed
334 335 336 337 338 339 340 341 342 343
    process = subprocess.Popen(parameter_list,
                               stdin=subprocess.PIPE,
                               stdout=subprocess.PIPE,
                               stderr=subprocess.PIPE,
                               close_fds=True)
    imgin, imgout, err = process.stdin, process.stdout, process.stderr

    def writeData(stream, data):
      if isinstance(data, str):
        stream.write(str(self.getData()))
344
      else:
Nicolas Delaby's avatar
Nicolas Delaby committed
345 346 347 348 349 350 351 352 353 354 355 356 357 358
        # Use PData structure to prevent
        # consuming too much memory
        while data is not None:
          stream.write(data.data)
          data = data.next

    writeData(imgin, self.getData())
    imgin.close()
    newimg.write(imgout.read())
    imgout.close()
    if not newimg.tell():
      raise ConversionError('Image conversion failed (%s).' % err.read())
    newimg.seek(0)
    return newimg
359

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

370
  def _makeDisplayPhoto(self, format=None, quality=_MARKER,
371
                                 resolution=None, frame=None, image_size=None):
Nicolas Delaby's avatar
Nicolas Delaby committed
372
    """Create given display."""
373
    if quality is _MARKER:
374
      quality = self.getDefaultImageQuality(format)
375 376 377 378 379 380 381
    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)
382 383

  def _getAspectRatioSize(self, width, height):
Nicolas Delaby's avatar
Nicolas Delaby committed
384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399
    """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)
400 401

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

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

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

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

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