Image.py 18.9 KB
Newer Older
Jean-Paul Smets's avatar
Jean-Paul Smets committed
1 2 3
##############################################################################
#
# Copyright (c) 2002 Nexedi SARL and Contributors. All Rights Reserved.
4
#                    Jean-Paul Smets-Solanes <jp@nexedi.com>
Jean-Paul Smets's avatar
Jean-Paul Smets committed
5
#
6 7 8
# Based on Photo by Ron Bickers
# Copyright (c) 2001 Logic Etc, Inc.  All rights reserved.
#
Jean-Paul Smets's avatar
Jean-Paul Smets committed
9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
# 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.
#
##############################################################################

32 33 34 35 36 37
import os
import string
import sys
import time
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
Jean-Paul Smets's avatar
Jean-Paul Smets committed
43
from Products.ERP5Type import Permissions, PropertySheet, Constraint, Interface
44
from Products.ERP5Type.Cache import CachingMethod
45
from Products.ERP5.Document.File import File
46 47
from Products.ERP5.Document.Document import ConversionError

48 49 50
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
51

52
from zLOG import LOG
Jean-Paul Smets's avatar
Jean-Paul Smets committed
53

54

55 56 57
default_displays_id_list = ('nano', 'micro', 'thumbnail',
                            'xsmall', 'small', 'medium',
                            'large', 'large', 'xlarge',)
58

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

Jean-Paul Smets's avatar
Jean-Paul Smets committed
61
class Image(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 84 85
  meta_type = 'ERP5 Image'
  portal_type = 'Image'
  isPortalContent = 1
  isRADContent = 1

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

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

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

108 109 110 111 112
  #
  # Original photo attributes
  #

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

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

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

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

152 153 154 155
  security.declareProtected(Permissions.AccessContentsInformation, 'getWidth')
  def getWidth(self):
    """
      Tries to get the width from the image data. 
Romain Courteaud's avatar
Romain Courteaud committed
156
    """
157
    self._upradeImage()
158 159
    if self.get_size() and not self.width: self._update_image_info()
    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()
167 168 169 170 171 172
    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."""
173
    self._upradeImage()
174
    if self.get_size() and not self._baseGetContentType(): self._update_image_info()
175 176 177 178 179 180 181 182 183 184 185 186
    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,
187
                resolution=None, frame=None, **kw):
188
      """Return HTML img tag."""
189
      self._upradeImage()
190 191 192 193 194 195

      # Get cookie if display is not specified.
      if display is None:
          display = self.REQUEST.cookies.get('display', None)

      # display may be set from a cookie.
196
      if (display is not None or resolution is not None or quality!=75 or format != ''\
197
                              or frame is not None) and self.getSizeFromImageDisplay(display):
198
          if not self.hasConversion(display=display, format=format,
199 200
                                    quality=quality, resolution=resolution,
                                    frame=frame):
201
              # Generate photo on-the-fly
202 203
              self._makeDisplayPhoto(display, format=format, quality=quality,
                                     resolution=resolution, frame=frame)
204
          mime, image = self.getConversion(display=display, format=format,
205 206
                                     quality=quality ,resolution=resolution,
                                     frame=frame)
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252
          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())

      if alt is None:
          alt = getattr(self, 'title', '')
      if alt == '':
          alt = self.getId()
      result = '%s alt="%s"' % (result, html_quote(alt))

      if height:
          result = '%s height="%s"' % (result, height)

      if width:
          result = '%s width="%s"' % (result, width)

      if not 'border' in map(string.lower, kw.keys()):
          result = '%s border="0"' % (result)

      if css_class is not None:
          result = '%s class="%s"' % (result, css_class)

      for key in kw.keys():
          value = kw.get(key)
          result = '%s %s="%s"' % (result, key, value)

      result = '%s />' % (result)

      return result

  def __str__(self):
      return self.tag()

  security.declareProtected('Access contents information', 'displayIds')
  def displayIds(self, exclude=('thumbnail',)):
      """Return list of display Ids."""
253
      id_list = list(default_displays_id_list)
254 255
      # Exclude specified displays
      if exclude:
256 257 258
        for id in exclude:
          if id in id_list:
            id_list.remove(id)
259
      # Sort by desired photo surface area
260 261
      id_list.sort(lambda x,y,d=self.getSizeFromImageDisplay: cmp(d(x)[0]*d(x)[1], d(y)[0]*d(y)[1]))
      return id_list
262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283

  security.declareProtected('Access contents information', 'displayLinks')
  def displayLinks(self, exclude=('thumbnail',)):
      """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

  security.declareProtected('Access contents information', 'displayMap')
  def displayMap(self, exclude=None, format='', quality=75, resolution=None):
      """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,
284 285
                            'width': self.getSizeFromImageDisplay(id)[0],
                            'height': self.getSizeFromImageDisplay(id)[1],
286 287 288 289 290 291
                            'photo_width': photo_width,
                            'photo_height': photo_height,
                            'bytes': bytes,
                            'age': age
                            })
      return displays
Kevin Deldycke's avatar
Kevin Deldycke committed
292

293 294 295

  # Conversion API
  security.declareProtected(Permissions.ModifyPortalContent, 'convert')
296
  def convert(self, format, display=None, quality=75, resolution=None, frame=None):
297 298 299 300 301
    """
    Implementation of conversion for PDF files
    """
    if format in ('text', 'txt', 'html', 'base_html', 'stripped-html'):
      return None, None
302
    if (display is not None or resolution is not None or quality != 75 or format != ''\
303
                            or frame is not None) and self.getSizeFromImageDisplay(display):
304
        if not self.hasConversion(display=display, format=format,
305 306
                                  quality=quality, resolution=resolution,
                                  frame=frame):
307
            # Generate photo on-the-fly
308 309
            self._makeDisplayPhoto(display, format=format, quality=quality,
                                   resolution=resolution, frame=frame)
310 311
        # Return resized image
        mime, image = self.getConversion(display=display, format=format,
312 313
                                         quality=quality ,resolution=resolution,
                                         frame=frame)
314 315 316 317
        return mime, image.data
    return self.getContentType(), self.getData()

  # Display
Kevin Deldycke's avatar
Kevin Deldycke committed
318
  security.declareProtected('View', 'index_html')
319 320
  def index_html(self, REQUEST, RESPONSE, display=None, format='', quality=75,
                       resolution=None, frame=None):
321
      """Return the image data."""
322
      self._upradeImage()
323

324 325
      _setCacheHeaders(_ViewEmulator().__of__(self), dict(display=display,
          format=format, quality=quality, resolution=resolution, frame=frame))
Jean-Paul Smets's avatar
Jean-Paul Smets committed
326

327
      # display may be set from a cookie (?)
328
      if (display is not None or resolution is not None or quality != 75 or format != ''\
329
                              or frame is not None) and self.getSizeFromImageDisplay(display):
330
          if not self.hasConversion(display=display, format=format,
331 332
                                    quality=quality, resolution=resolution,
                                    frame=frame):
333
              # Generate photo on-the-fly
334 335
              self._makeDisplayPhoto(display, format=format, quality=quality,
                                     resolution=resolution, frame=frame)
336 337
          # Return resized image
          mime, image = self.getConversion(display=display, format=format,
338 339
                                     quality=quality ,resolution=resolution,
                                     frame=frame)
340
          RESPONSE.setHeader('Content-Type', mime)
341
          return image.index_html(REQUEST, RESPONSE)
Kevin Deldycke's avatar
Kevin Deldycke committed
342

343 344
      # Return original image
      return OFSImage.index_html(self, REQUEST, RESPONSE)
Kevin Deldycke's avatar
Kevin Deldycke committed
345 346


347 348 349
  #
  # Photo processing
  #
Kevin Deldycke's avatar
Kevin Deldycke committed
350

351 352
  def _resize(self, display, width, height, quality=75, format='',
                    resolution=None, frame=None):
353 354
      """Resize and resample photo."""
      newimg = StringIO()
Kevin Deldycke's avatar
Kevin Deldycke committed
355

356 357 358 359 360 361 362 363 364 365 366 367
      # Prepare the format prefix
      if format:
        format = '%s:' % format
      else:
        format = ''

      # Prepare the frame suffix
      if frame is not None:
        frame = '[%s]' % frame
      else:
        frame = ''

368
      if sys.platform == 'win32':
369
          # XXX - Does win32 support pipe ?
370 371
          from win32pipe import popen2
          if resolution is None:
372 373
            imgin, imgout = popen2('convert -quality %s -geometry %sx%s -%s %s-'
                            % (quality, width, height, frame, format), 'b')
374
          else:
375 376
            imgin, imgout = popen2('convert -density %sx%s -quality %s -geometry %sx%s -%s %s-'
                            % (resolution, resolution, quality, width, height, frame, format), 'b')
Jean-Paul Smets's avatar
Jean-Paul Smets committed
377

378 379 380
      else:
          from popen2 import popen2
          if resolution is None:
381 382
            cmd = 'convert -quality %s -geometry %sx%s -%s %s-' % (
                    quality, width, height, frame, format)
383
          else:
384 385
            cmd = 'convert -density %sx%s -quality %s -geometry %sx%s -%s %s-' % (
                    resolution, resolution, quality, width, height, frame, format)
386
          imgout, imgin = popen2(cmd)
387

388 389 390 391 392 393 394 395 396 397 398
      def writeData(stream, data):
        if isinstance(data, str):
          stream.write(str(self.getData()))
        else:
          # 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())
399
      imgin.close()
400
      newimg.write(imgout.read())
Jean-Paul Smets's avatar
Jean-Paul Smets committed
401
      imgout.close()
402 403
      if not newimg.tell():
        raise ConversionError('Image conversion failed (empty file).')
404 405 406
      newimg.seek(0)
      return newimg

407
  def _getDisplayData(self, display, format='', quality=75, resolution=None, frame=None):
408
      """Return raw photo data for given display."""
409 410 411
      if display is None:
          (width, height) = (self.getWidth(), self.getHeight())
      else:
412
          (width, height) = self.getSizeFromImageDisplay(display)
413 414 415 416
      if width == 0 and height == 0:
          width = self.getWidth()
          height = self.getHeight()
      (width, height) = self._getAspectRatioSize(width, height)
Bartek Górny's avatar
Bartek Górny committed
417
      if (width, height) == (0, 0):return self.getData()
418 419
      return self._resize(display, width, height, quality, format=format,
                          resolution=resolution, frame=frame)
420

421
  def _getDisplayPhoto(self, display, format='', quality=75, resolution=None, frame=None):
422 423 424
      """Return photo object for given display."""
      try:
          base, ext = string.split(self.id, '.')
425
          id = base + '_' + display + '.' + ext
426
      except ValueError:
427
          id = self.id +'_'+ display
428
      image = OFSImage(id, self.getTitle(), self._getDisplayData(display, format=format,
429
                           quality=quality, resolution=resolution, frame=frame))
430 431
      return image

432
  def _makeDisplayPhoto(self, display, format='', quality=75, resolution=None, frame=None):
433
      """Create given display."""
434 435 436 437 438 439 440 441
      if not self.hasConversion(display=display, format=format, quality=quality,
                                resolution=resolution, frame=frame):
          image = self._getDisplayPhoto(display, format=format, quality=quality,
                                        resolution=resolution, frame=frame)
          self.setConversion(image, mime=image.content_type,
                                    display=display, format=format,
                                    quality=quality, resolution=resolution,
                                    frame=frame)
442 443 444 445

  def _getAspectRatioSize(self, width, height):
      """Return proportional dimensions within desired size."""
      img_width, img_height = (self.getWidth(), self.getHeight())
Bartek Górny's avatar
Bartek Górny committed
446 447
      if img_width == 0:
        return (0, 0)
448 449 450 451 452 453 454 455 456 457
      if height > img_height * width / img_width:
          height = img_height * width / img_width
      else:
          width =  img_width * height / img_height
      return (width, height)

  def _validImage(self):
      """At least see if it *might* be valid."""
      return self.getWidth() and self.getHeight() and self.getData() and self.getContentType()

458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477
  security.declareProtected('View', 'getSizeFromImageDisplay')
  def getSizeFromImageDisplay(self, image_display):
    """Retuns the size for this image display, or None if this image display name
    is not known.
    """
    def getDefaultDisplayAsDict():
      preference_tool = self.getPortalObject().portal_preferences
      defaultdisplays = dict()
      for id in default_displays_id_list:
        height_preference = 'preferred_%s_image_height' % (id)
        width_preferece = 'preferred_%s_image_width' % (id)
        size_list = (preference_tool.getPreference(height_preference),
                     preference_tool.getPreference(width_preferece))
        defaultdisplays.setdefault(id, size_list)
      return defaultdisplays
    Cached_getDefaultDisplayAsDict = CachingMethod(getDefaultDisplayAsDict,
                                                    id='Image_getDefaultDisplayAsDict',
                                                    cache_factory='erp5_ui_long')
    defaultdisplays = Cached_getDefaultDisplayAsDict()
    return defaultdisplays.get(image_display)
478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493

  #
  # 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):