Image.py 19.3 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.ERP5.Document.File import File
Nicolas Delaby's avatar
Nicolas Delaby committed
45 46
from Products.ERP5.Document.Document import Document, ConversionError,\
                                                         VALID_TEXT_FORMAT_LIST
47 48
from OFS.Image import Image as OFSImage
from OFS.Image import getImageInfo
49 50 51 52
try:
    from OFS.content_types import guess_content_type
except ImportError:
    from zope.contenttype import guess_content_type
53
from zLOG import LOG, WARNING
Jean-Paul Smets's avatar
Jean-Paul Smets committed
54

55
from Products.CMFCore.utils import getToolByName
56

Nicolas Delaby's avatar
Nicolas Delaby committed
57
DEFAULT_DISPLAY_ID_LIST = ('nano', 'micro', 'thumbnail',
58 59
                            'xsmall', 'small', 'medium',
                            'large', 'large', 'xlarge',)
60

Kevin Deldycke's avatar
Kevin Deldycke committed
61

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

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

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

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

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

107 108 109 110 111
  #
  # Original photo attributes
  #

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

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

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

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

156 157 158
  security.declareProtected(Permissions.AccessContentsInformation, 'getWidth')
  def getWidth(self):
    """
Fabien Morin's avatar
Fabien Morin committed
159
      Tries to get the width from the image data.
Romain Courteaud's avatar
Romain Courteaud committed
160
    """
161
    self._upradeImage()
162 163
    if self.get_size() and not self.width: self._update_image_info()
    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()
171 172 173 174 175 176
    if self.get_size() and not self.height: self._update_image_info()
    return self.height

  security.declareProtected(Permissions.AccessContentsInformation, 'getContentType')
  def getContentType(self, format=''):
    """Original photo content_type."""
177
    self._upradeImage()
178
    if self.get_size() and not self._baseGetContentType(): self._update_image_info()
179 180 181 182 183 184 185 186 187 188 189 190
    if format == '':
      return self._baseGetContentType()
    else:
      return guess_content_type('myfile.' + format)[0]

  #
  # Photo display methods
  #

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

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

Nicolas Delaby's avatar
Nicolas Delaby committed
199 200 201 202
    # display may be set from a cookie.
    image_size = self.getSizeFromImageDisplay(display)
    if (display is not None or resolution is not None or quality!=75 or format != ''\
                            or frame is not None) and image_size:
203 204
      kw = dict(display=display, format=format, quality=quality,
                resolution=resolution, frame=frame, image_size=image_size)
Nicolas Delaby's avatar
Nicolas Delaby committed
205
      try:
206
        mime, image = self.getConversion(**kw)
Nicolas Delaby's avatar
Nicolas Delaby committed
207 208
      except KeyError:
        # Generate photo on-the-fly
209 210
        mime, image = self._makeDisplayPhoto(**kw)
        self.setConversion(image, mime, **kw)
Nicolas Delaby's avatar
Nicolas Delaby committed
211 212 213 214 215 216 217 218 219 220 221 222 223
      width, height = (image.width, image.height)
      # Set cookie for chosen size
      if cookie:
        self.REQUEST.RESPONSE.setCookie('display', display, path="/")
    else:
      # TODO: Add support for on-the-fly resize?
      height = self.getHeight()
      width = self.getWidth()

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

Nicolas Delaby's avatar
Nicolas Delaby committed
225 226 227 228 229
    if alt is None:
      alt = getattr(self, 'title', '')
    if alt == '':
      alt = self.getId()
    result = '%s alt="%s"' % (result, html_quote(alt))
230

Nicolas Delaby's avatar
Nicolas Delaby committed
231 232
    if height:
      result = '%s height="%s"' % (result, height)
233

Nicolas Delaby's avatar
Nicolas Delaby committed
234 235
    if width:
      result = '%s width="%s"' % (result, width)
236

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

Nicolas Delaby's avatar
Nicolas Delaby committed
240 241
    if css_class is not None:
      result = '%s class="%s"' % (result, css_class)
242

Nicolas Delaby's avatar
Nicolas Delaby committed
243 244 245
    for key in kw.keys():
      value = kw.get(key)
      result = '%s %s="%s"' % (result, key, value)
246

Nicolas Delaby's avatar
Nicolas Delaby committed
247 248 249
    result = '%s />' % (result)

    return result
250 251

  def __str__(self):
Nicolas Delaby's avatar
Nicolas Delaby committed
252
    return self.tag()
253 254 255

  security.declareProtected('Access contents information', 'displayIds')
  def displayIds(self, exclude=('thumbnail',)):
Nicolas Delaby's avatar
Nicolas Delaby committed
256
    """Return list of display Ids."""
Nicolas Delaby's avatar
Nicolas Delaby committed
257
    id_list = list(DEFAULT_DISPLAY_ID_LIST)
Nicolas Delaby's avatar
Nicolas Delaby committed
258 259 260 261 262 263 264 265 266 267 268
    # 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
269 270 271

  security.declareProtected('Access contents information', 'displayLinks')
  def displayLinks(self, exclude=('thumbnail',)):
Nicolas Delaby's avatar
Nicolas Delaby committed
272 273 274 275 276
    """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
277 278 279

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

300

301 302 303 304 305 306 307
  security.declarePrivate('_convertToText')
  def _convertToText(self, format):
    """
    Convert the image to text with portaltransforms
    """
    mime_type = getToolByName(self, 'mimetypes_registry').\
                                lookupExtension('name.%s' % format)
308
    mime_type = str(mime_type)
309 310
    src_mimetype = self.getContentType()
    content = '%s' % self.getData()
311 312 313 314 315 316 317 318 319 320 321
    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
322

323
  # Conversion API
324
  security.declareProtected(Permissions.AccessContentsInformation, 'convert')
325
  def convert(self, format, display=None, quality=75, resolution=None, frame=None, **kw):
326
    """
327
    Implementation of conversion for Image files
328
    """
Nicolas Delaby's avatar
Nicolas Delaby committed
329
    if format in VALID_TEXT_FORMAT_LIST:
330 331 332
      try:
        return self.getConversion(format=format)
      except KeyError:
333
        mime_type, data = self._convertToText(format)
334
        data = aq_base(data)
335
        self.setConversion(data, mime=mime_type, format=format)
336
        return mime_type, data
337
    image_size = self.getSizeFromImageDisplay(display)
338
    if (display is not None or resolution is not None or quality != 75 or format != ''\
339
                            or frame is not None) and image_size:
Julien Muchembled's avatar
Julien Muchembled committed
340
      kw = dict(display=display, format=format, quality=quality,
341
                resolution=resolution, frame=frame, image_size=image_size)
342
      try:
343
        mime, image = self.getConversion(**kw)
344
      except KeyError:
Julien Muchembled's avatar
Julien Muchembled committed
345
        mime, image = self._makeDisplayPhoto(**kw)
346
        self.setConversion(image, mime, **kw)
347
      return mime, image.data
348 349
    return self.getContentType(), self.getData()

350 351 352 353 354 355 356 357 358 359 360
  security.declareProtected(Permissions.View, 'getSearchableText')
  def getSearchableText(self, md=None):
    """
      Converts the content of the document to a textual representation.
    """
    mime, data = self.convert(format='txt')
    return str(data)

  # Compatibility with CMF Catalog
  SearchableText = getSearchableText

361
  # Display
Kevin Deldycke's avatar
Kevin Deldycke committed
362
  security.declareProtected('View', 'index_html')
363 364
  def index_html(self, REQUEST, RESPONSE, display=None, format='', quality=75,
                       resolution=None, frame=None):
Nicolas Delaby's avatar
Nicolas Delaby committed
365 366 367 368 369
    """Return the image data."""
    self._upradeImage()

    # display may be set from a cookie (?)
    image_size = self.getSizeFromImageDisplay(display)
370
    kw = dict(display=display, format=format, quality=quality,
371
              resolution=resolution, frame=frame, image_size=image_size)
372 373
    _setCacheHeaders(_ViewEmulator().__of__(self), kw)

Nicolas Delaby's avatar
Nicolas Delaby committed
374 375 376
    if (display is not None or resolution is not None or quality != 75 or format != ''\
                            or frame is not None) and image_size:
      try:
377
        mime, image = self.getConversion(**kw)
Nicolas Delaby's avatar
Nicolas Delaby committed
378 379
      except KeyError:
        # Generate photo on-the-fly
380 381
        mime, image = self._makeDisplayPhoto(**kw)
        self.setConversion(image, mime, **kw)
Nicolas Delaby's avatar
Nicolas Delaby committed
382 383 384 385 386
      RESPONSE.setHeader('Content-Type', mime)
      return image.index_html(REQUEST, RESPONSE)

    # Return original image
    return OFSImage.index_html(self, REQUEST, RESPONSE)
Kevin Deldycke's avatar
Kevin Deldycke committed
387 388


389 390 391
  #
  # Photo processing
  #
Kevin Deldycke's avatar
Kevin Deldycke committed
392

393 394
  def _resize(self, display, width, height, quality=75, format='',
                    resolution=None, frame=None):
Nicolas Delaby's avatar
Nicolas Delaby committed
395 396 397 398 399 400 401 402 403
    """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)])
404
    if frame is not None:
Nicolas Delaby's avatar
Nicolas Delaby committed
405 406 407
      parameter_list.append('-[%s]' % frame)
    else:
      parameter_list.append('-')
408

Nicolas Delaby's avatar
Nicolas Delaby committed
409 410 411 412
    if format:
      parameter_list.append('%s:-' % format)
    else:
      parameter_list.append('-')
413

Nicolas Delaby's avatar
Nicolas Delaby committed
414 415 416 417 418 419 420 421 422 423
    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()))
424
      else:
Nicolas Delaby's avatar
Nicolas Delaby committed
425 426 427 428 429 430 431 432 433 434 435 436 437 438
        # 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
439

440 441
  def _getDisplayData(self, display, format='', quality=75, resolution=None, frame=None,
                      image_size=None):
Nicolas Delaby's avatar
Nicolas Delaby committed
442 443 444 445 446 447 448 449 450 451 452 453 454 455
    """Return raw photo data for given display."""
    if display is None:
      (width, height) = (self.getWidth(), self.getHeight())
    elif image_size is None:
      (width, height) = self.getSizeFromImageDisplay(display)
    else:
      (width, height) = image_size
    if width == 0 and height == 0:
      width = self.getWidth()
      height = self.getHeight()
    (width, height) = self._getAspectRatioSize(width, height)
    if (width, height) == (0, 0):return self.getData()
    return self._resize(display, width, height, quality, format=format,
                        resolution=resolution, frame=frame)
456

457 458
  def _getDisplayPhoto(self, display, format='', quality=75, resolution=None, frame=None,
                       image_size=None):
Nicolas Delaby's avatar
Nicolas Delaby committed
459 460 461 462 463 464 465 466 467 468
    """Return photo object for given display."""
    try:
        base, ext = string.split(self.id, '.')
        id = base + '_' + display + '.' + ext
    except ValueError:
        id = self.id +'_'+ display
    image = OFSImage(id, self.getTitle(), self._getDisplayData(display, format=format,
                         quality=quality, resolution=resolution, frame=frame,
                         image_size=image_size))
    return image
469

470 471
  def _makeDisplayPhoto(self, display, format='', quality=75, resolution=None, frame=None,
                        image_size=None):
Nicolas Delaby's avatar
Nicolas Delaby committed
472 473 474 475 476
    """Create given display."""
    image = self._getDisplayPhoto(display, format=format, quality=quality,
                                           resolution=resolution, frame=frame,
                                           image_size=image_size)
    return (image.content_type, aq_base(image))
477 478

  def _getAspectRatioSize(self, width, height):
Nicolas Delaby's avatar
Nicolas Delaby committed
479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494
    """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)
495 496

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

500 501 502
  security.declareProtected('View', 'getSizeFromImageDisplay')
  def getSizeFromImageDisplay(self, image_display):
    """
503
    Return the size for this image display, or None if this image display name
504
    is not known.
505
    """
Nicolas Delaby's avatar
Nicolas Delaby committed
506
    if image_display in DEFAULT_DISPLAY_ID_LIST:
507 508
      preference_tool = self.getPortalObject().portal_preferences
      height_preference = 'preferred_%s_image_height' % (image_display,)
509 510 511
      width_preference = 'preferred_%s_image_width' % (image_display,)
      height = preference_tool.getPreference(height_preference)
      width = preference_tool.getPreference(width_preference)
512
      return (width, height)
513
    return None
514

515 516 517 518 519
  def _setFile(self, *args, **kw):
    """set the file content and reset image information.
    """
    File._setFile(self, *args, **kw)
    self._update_image_info()
520

521
  def PUT(self, REQUEST, RESPONSE):
522 523
    """set the file content by HTTP/FTP and reset image information.
    """
524
    File.PUT(self, REQUEST, RESPONSE)
525 526
    self._update_image_info()

527 528 529 530 531 532 533 534 535 536 537 538 539 540 541
  #
  # FTP/WebDAV support
  #

      #if hasattr(self, '_original'):
          ## Updating existing Photo
          #self._original.manage_upload(file, self.content_type())
          #if self._validImage():
              #self._makeDisplayPhotos()

  # Maybe needed
  #def manage_afterClone(self, item):

  # Maybe needed
  #def manage_afterAdd(self, item, container):