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 34
import os
import string
35
import struct
36 37
import sys
import time
38
import subprocess
39 40
from cStringIO import StringIO

Jean-Paul Smets's avatar
Jean-Paul Smets committed
41
from AccessControl import ClassSecurityInfo
42
from Acquisition import aq_base
Jean-Paul Smets's avatar
Jean-Paul Smets committed
43

44
from DocumentTemplate.DT_Util import html_quote
45
from Products.CMFCore.utils import _setCacheHeaders, _ViewEmulator
46
from Products.ERP5Type import Permissions, PropertySheet, Constraint, interfaces
47
from Products.ERP5.Document.File import File
48 49
from Products.ERP5.Document.Document import ConversionError

50 51 52
from OFS.Image import Image as OFSImage
from OFS.Image import getImageInfo
from OFS.content_types import guess_content_type
Jean-Paul Smets's avatar
Jean-Paul Smets committed
53

54
from zLOG import LOG, WARNING
Jean-Paul Smets's avatar
Jean-Paul Smets committed
55

56
from Products.CMFCore.utils import getToolByName
57

58 59 60
default_displays_id_list = ('nano', 'micro', 'thumbnail',
                            'xsmall', 'small', 'medium',
                            'large', 'large', 'xlarge',)
61

62
default_formats = ['jpg', 'jpeg', 'png', 'gif', 'pnm', 'ppm']
Kevin Deldycke's avatar
Kevin Deldycke committed
63

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

77 78 79 80 81 82 83
    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
84 85 86 87 88
  meta_type = 'ERP5 Image'
  portal_type = 'Image'
  isPortalContent = 1
  isRADContent = 1

89 90 91 92
  # Default attribute values
  width = 0
  height = 0

Kevin Deldycke's avatar
Kevin Deldycke committed
93 94
  # Declarative security
  security = ClassSecurityInfo()
95
  security.declareObjectProtected(Permissions.AccessContentsInformation)
Kevin Deldycke's avatar
Kevin Deldycke committed
96

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

111 112 113 114 115
  #
  # Original photo attributes
  #

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

136 137 138 139 140 141 142 143 144 145 146 147 148 149 150
  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

151 152 153 154
    # 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

155
    # Make sure size is defined
156 157
    if (not hasattr(aq_base(self), 'size') or not self.size) and \
                      hasattr(aq_base(self), 'data'):
158 159
      self.size = len(self.data)

160 161 162 163
  security.declareProtected(Permissions.AccessContentsInformation, 'getWidth')
  def getWidth(self):
    """
      Tries to get the width from the image data. 
Romain Courteaud's avatar
Romain Courteaud committed
164
    """
165
    self._upradeImage()
166 167
    if self.get_size() and not self.width: self._update_image_info()
    return self.width
Romain Courteaud's avatar
Romain Courteaud committed
168

169 170
  security.declareProtected(Permissions.AccessContentsInformation, 'getHeight')
  def getHeight(self):
Jean-Paul Smets's avatar
Jean-Paul Smets committed
171
    """
172
      Tries to get the height from the image data.
Jean-Paul Smets's avatar
Jean-Paul Smets committed
173
    """
174
    self._upradeImage()
175 176 177 178 179 180
    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."""
181
    self._upradeImage()
182
    if self.get_size() and not self._baseGetContentType(): self._update_image_info()
183 184 185 186 187 188 189 190 191 192 193 194
    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,
195
                resolution=None, frame=None, **kw):
Nicolas Delaby's avatar
Nicolas Delaby committed
196 197
    """Return HTML img tag."""
    self._upradeImage()
198

Nicolas Delaby's avatar
Nicolas Delaby committed
199 200 201
    # Get cookie if display is not specified.
    if display is None:
      display = self.REQUEST.cookies.get('display', None)
202

Nicolas Delaby's avatar
Nicolas Delaby committed
203 204 205 206
    # 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:
207 208
      kw = dict(display=display, format=format, quality=quality,
                resolution=resolution, frame=frame, image_size=image_size)
Nicolas Delaby's avatar
Nicolas Delaby committed
209
      try:
210
        mime, image = self.getConversion(**kw)
Nicolas Delaby's avatar
Nicolas Delaby committed
211 212
      except KeyError:
        # Generate photo on-the-fly
213 214
        mime, image = self._makeDisplayPhoto(**kw)
        self.setConversion(image, mime, **kw)
Nicolas Delaby's avatar
Nicolas Delaby committed
215 216 217 218 219 220 221 222 223 224 225 226 227
      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())
228

Nicolas Delaby's avatar
Nicolas Delaby committed
229 230 231 232 233
    if alt is None:
      alt = getattr(self, 'title', '')
    if alt == '':
      alt = self.getId()
    result = '%s alt="%s"' % (result, html_quote(alt))
234

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

Nicolas Delaby's avatar
Nicolas Delaby committed
238 239
    if width:
      result = '%s width="%s"' % (result, width)
240

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

Nicolas Delaby's avatar
Nicolas Delaby committed
244 245
    if css_class is not None:
      result = '%s class="%s"' % (result, css_class)
246

Nicolas Delaby's avatar
Nicolas Delaby committed
247 248 249
    for key in kw.keys():
      value = kw.get(key)
      result = '%s %s="%s"' % (result, key, value)
250

Nicolas Delaby's avatar
Nicolas Delaby committed
251 252 253
    result = '%s />' % (result)

    return result
254 255

  def __str__(self):
Nicolas Delaby's avatar
Nicolas Delaby committed
256
    return self.tag()
257 258 259

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

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

  security.declareProtected('Access contents information', 'displayMap')
  def displayMap(self, exclude=None, format='', quality=75, resolution=None):
Nicolas Delaby's avatar
Nicolas Delaby committed
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302
    """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
303

304

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

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

354 355 356 357 358 359 360 361 362 363 364
  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

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

    # display may be set from a cookie (?)
    image_size = self.getSizeFromImageDisplay(display)
374 375 376 377
    kw = dict(display=display, format=format, quality=quality,
              resolution=resolution, frame=frame, image_size=image_size)
    _setCacheHeaders(_ViewEmulator().__of__(self), kw)

Nicolas Delaby's avatar
Nicolas Delaby committed
378 379 380
    if (display is not None or resolution is not None or quality != 75 or format != ''\
                            or frame is not None) and image_size:
      try:
381
        mime, image = self.getConversion(**kw)
Nicolas Delaby's avatar
Nicolas Delaby committed
382 383
      except KeyError:
        # Generate photo on-the-fly
384 385
        mime, image = self._makeDisplayPhoto(**kw)
        self.setConversion(image, mime, **kw)
Nicolas Delaby's avatar
Nicolas Delaby committed
386 387 388 389 390
      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
391 392


393 394 395
  #
  # Photo processing
  #
Kevin Deldycke's avatar
Kevin Deldycke committed
396

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

Nicolas Delaby's avatar
Nicolas Delaby committed
413 414 415 416 417 418 419 420 421 422 423 424 425 426 427
    if format:
      parameter_list.append('%s:-' % format)
    else:
      parameter_list.append('-')

    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()))
428
      else:
Nicolas Delaby's avatar
Nicolas Delaby committed
429 430 431 432 433 434 435 436 437 438 439 440 441 442
        # 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
443

444 445
  def _getDisplayData(self, display, format='', quality=75, resolution=None, frame=None,
                      image_size=None):
Nicolas Delaby's avatar
Nicolas Delaby committed
446 447 448 449 450 451 452 453 454 455 456 457 458 459
    """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)
460

461 462
  def _getDisplayPhoto(self, display, format='', quality=75, resolution=None, frame=None,
                       image_size=None):
Nicolas Delaby's avatar
Nicolas Delaby committed
463 464 465 466 467 468 469 470 471 472
    """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
473

474 475
  def _makeDisplayPhoto(self, display, format='', quality=75, resolution=None, frame=None,
                        image_size=None):
Nicolas Delaby's avatar
Nicolas Delaby committed
476 477 478 479 480
    """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))
481 482

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

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

504 505 506
  security.declareProtected('View', 'getSizeFromImageDisplay')
  def getSizeFromImageDisplay(self, image_display):
    """
507
    Return the size for this image display, or None if this image display name
508
    is not known.
509 510 511 512
    """
    if image_display in default_displays_id_list:
      preference_tool = self.getPortalObject().portal_preferences
      height_preference = 'preferred_%s_image_height' % (image_display,)
513 514 515
      width_preference = 'preferred_%s_image_width' % (image_display,)
      height = preference_tool.getPreference(height_preference)
      width = preference_tool.getPreference(width_preference)
516 517
      return (height, width)
    return None
518

519 520 521 522 523
  def _setFile(self, *args, **kw):
    """set the file content and reset image information.
    """
    File._setFile(self, *args, **kw)
    self._update_image_info()
524

525
  def PUT(self, REQUEST, RESPONSE):
526 527
    """set the file content by HTTP/FTP and reset image information.
    """
528
    File.PUT(self, REQUEST, RESPONSE)
529 530
    self._update_image_info()

531 532 533 534 535 536 537 538 539 540 541 542 543 544 545
  #
  # 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):