Image.py 16.8 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, DEFAULT_DISPLAY_ID_LIST, DEFAULT_QUALITY, _MARKER
48
from os.path import splitext
49 50
from OFS.Image import Image as OFSImage
from OFS.Image import getImageInfo
51
from zLOG import LOG, WARNING
Jean-Paul Smets's avatar
Jean-Paul Smets committed
52

53 54
# import mixin
from Products.ERP5.mixin.text_convertable import TextConvertableMixin
55
from Products.CMFCore.utils import getToolByName
56

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

70 71 72 73 74 75 76
    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
77 78 79
  meta_type = 'ERP5 Image'
  portal_type = 'Image'

80 81 82 83
  # Default attribute values
  width = 0
  height = 0

Kevin Deldycke's avatar
Kevin Deldycke committed
84 85
  # Declarative security
  security = ClassSecurityInfo()
86
  security.declareObjectProtected(Permissions.AccessContentsInformation)
Kevin Deldycke's avatar
Kevin Deldycke committed
87

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

102 103 104 105 106
  #
  # Original photo attributes
  #

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

127 128 129 130 131 132 133 134 135 136 137 138 139 140 141
  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

142 143 144 145
    # 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

146
    # Make sure size is defined
147 148
    if (not hasattr(aq_base(self), 'size') or not self.size) and \
                      hasattr(aq_base(self), 'data'):
149 150
      self.size = len(self.data)

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

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

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

  #
  # Photo display methods
  #

  security.declareProtected('View', 'tag')
  def tag(self, display=None, height=None, width=None, cookie=0,
188
                alt=None, css_class=None, format=None, quality=DEFAULT_QUALITY,
189
                resolution=None, frame=None, **kw):
Nicolas Delaby's avatar
Nicolas Delaby committed
190 191
    """Return HTML img tag."""
    self._upradeImage()
192

Nicolas Delaby's avatar
Nicolas Delaby committed
193 194 195
    # Get cookie if display is not specified.
    if display is None:
      display = self.REQUEST.cookies.get('display', None)
196

Nicolas Delaby's avatar
Nicolas Delaby committed
197 198
    # display may be set from a cookie.
    image_size = self.getSizeFromImageDisplay(display)
199 200 201 202 203 204
    convert_kw = dict(format=format, quality=quality, resolution=resolution,
                      frame=frame, image_size=image_size)
    try:
      mime, image = self.getConversion(**convert_kw)
    except KeyError:
      # Generate photo on-the-fly
205
      mime, image = self._makeDisplayPhoto(**convert_kw)
206 207 208 209 210
      self.setConversion(image, mime, **convert_kw)
    width, height = image.width, image.height
    # Set cookie for chosen size
    if cookie:
      self.REQUEST.RESPONSE.setCookie('display', display, path="/")
Nicolas Delaby's avatar
Nicolas Delaby committed
211 212 213 214 215

    if display:
      result = '<img src="%s?display=%s"' % (self.absolute_url(), display)
    else:
      result = '<img src="%s"' % (self.absolute_url())
216

Nicolas Delaby's avatar
Nicolas Delaby committed
217 218 219 220 221
    if alt is None:
      alt = getattr(self, 'title', '')
    if alt == '':
      alt = self.getId()
    result = '%s alt="%s"' % (result, html_quote(alt))
222

Nicolas Delaby's avatar
Nicolas Delaby committed
223 224
    if height:
      result = '%s height="%s"' % (result, height)
225

Nicolas Delaby's avatar
Nicolas Delaby committed
226 227
    if width:
      result = '%s width="%s"' % (result, width)
228

Nicolas Delaby's avatar
Nicolas Delaby committed
229 230
    if not 'border' in map(string.lower, kw.keys()):
      result = '%s border="0"' % (result)
231

Nicolas Delaby's avatar
Nicolas Delaby committed
232 233
    if css_class is not None:
      result = '%s class="%s"' % (result, css_class)
234

Nicolas Delaby's avatar
Nicolas Delaby committed
235 236 237
    for key in kw.keys():
      value = kw.get(key)
      result = '%s %s="%s"' % (result, key, value)
238

Nicolas Delaby's avatar
Nicolas Delaby committed
239 240 241
    result = '%s />' % (result)

    return result
242 243

  def __str__(self):
Nicolas Delaby's avatar
Nicolas Delaby committed
244
    return self.tag()
245 246 247

  security.declareProtected('Access contents information', 'displayIds')
  def displayIds(self, exclude=('thumbnail',)):
Nicolas Delaby's avatar
Nicolas Delaby committed
248
    """Return list of display Ids."""
Nicolas Delaby's avatar
Nicolas Delaby committed
249
    id_list = list(DEFAULT_DISPLAY_ID_LIST)
Nicolas Delaby's avatar
Nicolas Delaby committed
250 251 252 253 254 255 256 257 258 259 260
    # 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
261 262 263

  security.declareProtected('Access contents information', 'displayLinks')
  def displayLinks(self, exclude=('thumbnail',)):
Nicolas Delaby's avatar
Nicolas Delaby committed
264 265 266 267 268
    """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
269 270

  security.declareProtected('Access contents information', 'displayMap')
271
  def displayMap(self, exclude=None, format=None, quality=DEFAULT_QUALITY,\
272
                                                              resolution=None):
Nicolas Delaby's avatar
Nicolas Delaby committed
273 274 275
    """Return list of displays with size info."""
    displays = []
    for id in self.displayIds(exclude):
276 277
      if self._isGenerated(id, format=format, quality=quality,\
                                                        resolution=resolution):
Nicolas Delaby's avatar
Nicolas Delaby committed
278 279 280 281 282 283
        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
284
      image_size = self.getSizeFromImageDisplay(id)
Nicolas Delaby's avatar
Nicolas Delaby committed
285
      displays.append({'id': id,
Nicolas Delaby's avatar
Nicolas Delaby committed
286 287
                        'width': image_size[0],
                        'height': image_size[1],
Nicolas Delaby's avatar
Nicolas Delaby committed
288 289 290 291 292 293
                        'photo_width': photo_width,
                        'photo_height': photo_height,
                        'bytes': bytes,
                        'age': age
                        })
    return displays
Kevin Deldycke's avatar
Kevin Deldycke committed
294

295

296 297 298 299 300 301 302
  security.declarePrivate('_convertToText')
  def _convertToText(self, format):
    """
    Convert the image to text with portaltransforms
    """
    mime_type = getToolByName(self, 'mimetypes_registry').\
                                lookupExtension('name.%s' % format)
303
    mime_type = str(mime_type)
304
    src_mimetype = self.getContentType()
Nicolas Delaby's avatar
Nicolas Delaby committed
305
    content = self.getData()
306 307 308 309 310 311 312 313 314 315 316
    portal_transforms = getToolByName(self, 'portal_transforms')
    result = portal_transforms.convertToData(mime_type, content,
                                             object=self, context=self,
                                             filename=self.getTitleOrId(),
                                             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
317

318
  # Conversion API
319
  def _convert(self, format, **kw):
320
    """
321
    Implementation of conversion for Image files
322
    """
Nicolas Delaby's avatar
Nicolas Delaby committed
323
    if format in VALID_TEXT_FORMAT_LIST:
324 325 326
      try:
        return self.getConversion(format=format)
      except KeyError:
327
        mime_type, data = self._convertToText(format)
328
        data = aq_base(data)
329
        self.setConversion(data, mime=mime_type, format=format)
330
        return mime_type, data
331 332 333 334 335 336 337 338 339 340 341 342 343 344 345
    image_size = self.getSizeFromImageDisplay(kw.get('display'))
    # store all keys usefull to convert or resize an image
    # 'display' parameter can be discarded
    convert_kw = {'quality': kw.get('quality', DEFAULT_QUALITY),
                  'resolution': kw.get('resolution'),
                  'frame': kw.get('frame'),
                  'image_size': image_size,
                  'format': format,
                 }
    try:
      mime, image = self.getConversion(**convert_kw)
    except KeyError:
      mime, image = self._makeDisplayPhoto(**convert_kw)
      self.setConversion(image, mime, **convert_kw)
    return mime, image.data
346 347

  # Display
Kevin Deldycke's avatar
Kevin Deldycke committed
348
  security.declareProtected('View', 'index_html')
349 350
  @fill_args_from_request('display', 'quality', 'resolution', 'frame')
  def index_html(self, REQUEST, *args, **kw):
Nicolas Delaby's avatar
Nicolas Delaby committed
351 352
    """Return the image data."""
    self._upradeImage()
353
    return Document.index_html(self, REQUEST, *args, **kw)
Kevin Deldycke's avatar
Kevin Deldycke committed
354

355 356 357
  #
  # Photo processing
  #
Kevin Deldycke's avatar
Kevin Deldycke committed
358

359
  def _resize(self, quality, width, height, format, resolution, frame):
Nicolas Delaby's avatar
Nicolas Delaby committed
360 361 362 363 364 365 366 367 368
    """Resize and resample photo."""
    newimg = StringIO()

    parameter_list = ['convert']
    parameter_list.extend(['-colorspace', 'RGB'])
    if resolution:
      parameter_list.extend(['-density', '%sx%s' % (resolution, resolution)])
    parameter_list.extend(['-quality', str(quality)])
    parameter_list.extend(['-geometry', '%sx%s' % (width, height)])
369
    if frame is not None:
Nicolas Delaby's avatar
Nicolas Delaby committed
370 371 372
      parameter_list.append('-[%s]' % frame)
    else:
      parameter_list.append('-')
373

Nicolas Delaby's avatar
Nicolas Delaby committed
374 375 376 377
    if format:
      parameter_list.append('%s:-' % format)
    else:
      parameter_list.append('-')
378

Nicolas Delaby's avatar
Nicolas Delaby committed
379 380 381 382 383 384 385 386 387 388
    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()))
389
      else:
Nicolas Delaby's avatar
Nicolas Delaby committed
390 391 392 393 394 395 396 397 398 399 400 401 402 403
        # 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
404

405
  def _getDisplayData(self, format, quality, resolution, frame, image_size):
Nicolas Delaby's avatar
Nicolas Delaby committed
406
    """Return raw photo data for given display."""
407 408 409 410 411 412 413 414 415 416
    width, height = self._getAspectRatioSize(*image_size)
    if ((width, height) == image_size or (width, height) == (0, 0))\
       and quality == DEFAULT_QUALITY and resolution is None and frame is None\
       and not format:
      # No resizing, no conversion, return raw image
      return self.getData()
    return self._resize(quality, width, height, format, resolution, frame)

  def _makeDisplayPhoto(self, format=None, quality=DEFAULT_QUALITY,
                                 resolution=None, frame=None, image_size=None):
Nicolas Delaby's avatar
Nicolas Delaby committed
417
    """Create given display."""
418 419 420 421 422 423 424
    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)
425 426

  def _getAspectRatioSize(self, width, height):
Nicolas Delaby's avatar
Nicolas Delaby committed
427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442
    """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)
443 444

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

448 449
  security.declareProtected('View', 'getSizeFromImageDisplay')
  def getSizeFromImageDisplay(self, image_display):
Nicolas Delaby's avatar
Nicolas Delaby committed
450 451
    """Return the size for this image display,
       or dimension of this image.
452
    """
Nicolas Delaby's avatar
Nicolas Delaby committed
453
    if image_display in DEFAULT_DISPLAY_ID_LIST:
454 455
      preference_tool = self.getPortalObject().portal_preferences
      height_preference = 'preferred_%s_image_height' % (image_display,)
456 457 458
      width_preference = 'preferred_%s_image_width' % (image_display,)
      height = preference_tool.getPreference(height_preference)
      width = preference_tool.getPreference(width_preference)
459
      return (width, height)
Nicolas Delaby's avatar
Nicolas Delaby committed
460
    return self.getWidth(), self.getHeight()
461

462 463 464 465 466
  def _setFile(self, *args, **kw):
    """set the file content and reset image information.
    """
    File._setFile(self, *args, **kw)
    self._update_image_info()
467

468
  def PUT(self, REQUEST, RESPONSE):
469 470
    """set the file content by HTTP/FTP and reset image information.
    """
471
    File.PUT(self, REQUEST, RESPONSE)
472 473
    self._update_image_info()