Image.py 17.6 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
Jean-Paul Smets's avatar
Jean-Paul Smets committed
42
from Products.CMFCore.utils import _setCacheHeaders
Jean-Paul Smets's avatar
Jean-Paul Smets committed
43
from Products.ERP5Type import Permissions, PropertySheet, Constraint, Interface
44 45 46 47
from Products.ERP5.Document.File import File
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
48

49
from zLOG import LOG
Jean-Paul Smets's avatar
Jean-Paul Smets committed
50

51 52 53 54 55 56 57 58
# XXX This should be move to preferences
defaultdisplays = {'thumbnail' : (128,128),
                   'xsmall'    : (200,200),
                   'small'     : (320,320),
                   'medium'    : (480,480),
                   'large'     : (768,768),
                   'xlarge'    : (1024,1024)
                  }
Kevin Deldycke's avatar
Kevin Deldycke committed
59

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

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

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

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

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

109 110 111 112 113
  #
  # Original photo attributes
  #

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

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

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

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

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

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

      # 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.
197 198
      if (display is not None or resolution is not None or quality!=75 or format != ''\
                              or frame is not None) and defaultdisplays.has_key(display):
199
          if not self.hasConversion(display=display, format=format,
200 201
                                    quality=quality, resolution=resolution,
                                    frame=frame):
202
              # Generate photo on-the-fly
203 204
              self._makeDisplayPhoto(display, format=format, quality=quality,
                                     resolution=resolution, frame=frame)
205
          mime, image = self.getConversion(display=display, format=format,
206 207
                                     quality=quality ,resolution=resolution,
                                     frame=frame)
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 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292
          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."""
      ids = defaultdisplays.keys()
      # Exclude specified displays
      if exclude:
          for id in exclude:
              if id in ids:
                  ids.remove(id)
      # Sort by desired photo surface area
      ids.sort(lambda x,y,d=self._displays: cmp(d[x][0]*d[x][1], d[y][0]*d[y][1]))
      return ids

  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,
                            'width': defaultdisplays[id][0],
                            'height': defaultdisplays[id][1],
                            'photo_width': photo_width,
                            'photo_height': photo_height,
                            'bytes': bytes,
                            'age': age
                            })
      return displays
Kevin Deldycke's avatar
Kevin Deldycke committed
293

294 295 296

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

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

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

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

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


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

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

357 358 359 360 361 362 363 364 365 366 367 368
      # 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 = ''

369
      if sys.platform == 'win32':
370
          # XXX - Does win32 support pipe ?
371 372
          from win32pipe import popen2
          if resolution is None:
373 374
            imgin, imgout = popen2('convert -quality %s -geometry %sx%s -%s %s-'
                            % (quality, width, height, frame, format), 'b')
375
          else:
376 377
            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
378

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

390 391 392 393 394 395 396 397 398 399 400
      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())
401
      imgin.close()
402
      newimg.write(imgout.read())
403 404 405
      newimg.seek(0)
      return newimg

406
  def _getDisplayData(self, display, format='', quality=75, resolution=None, frame=None):
407
      """Return raw photo data for given display."""
408 409 410 411
      if display is None:
          (width, height) = (self.getWidth(), self.getHeight())
      else:
          (width, height) = defaultdisplays[display]
412 413 414 415
      if width == 0 and height == 0:
          width = self.getWidth()
          height = self.getHeight()
      (width, height) = self._getAspectRatioSize(width, height)
416 417
      return self._resize(display, width, height, quality, format=format,
                          resolution=resolution, frame=frame)
418

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

430
  def _makeDisplayPhoto(self, display, format='', quality=75, resolution=None, frame=None):
431
      """Create given display."""
432 433 434 435 436 437 438 439
      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)
440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469

  def _getAspectRatioSize(self, width, height):
      """Return proportional dimensions within desired size."""
      img_width, img_height = (self.getWidth(), self.getHeight())
      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()


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