Image.py 14.7 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.WorkflowCore import WorkflowMethod
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 97 98
  property_sheets = ( PropertySheet.Base
                    , PropertySheet.CategoryCore
                    , PropertySheet.DublinCore
99 100 101 102
                    , PropertySheet.Version
                    , PropertySheet.Reference
                    , PropertySheet.Document
                    , PropertySheet.Data
Kevin Deldycke's avatar
Kevin Deldycke committed
103 104
                    )

105 106 107 108 109
  #
  # Original photo attributes
  #

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

124 125 126 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

    # Make sure size is defined
    if not hasattr(aq_base(self), 'size') or not self.size:
      self.size = len(self.data)

143 144 145 146
  security.declareProtected(Permissions.AccessContentsInformation, 'getWidth')
  def getWidth(self):
    """
      Tries to get the width from the image data. 
Romain Courteaud's avatar
Romain Courteaud committed
147
    """
148
    self._upradeImage()
149 150
    if self.get_size() and not self.width: self._update_image_info()
    return self.width
Romain Courteaud's avatar
Romain Courteaud committed
151

152 153
  security.declareProtected(Permissions.AccessContentsInformation, 'getHeight')
  def getHeight(self):
Jean-Paul Smets's avatar
Jean-Paul Smets committed
154
    """
155
      Tries to get the height from the image data.
Jean-Paul Smets's avatar
Jean-Paul Smets committed
156
    """
157
    self._upradeImage()
158 159 160 161 162 163
    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."""
164
    self._upradeImage()
165 166 167 168 169 170 171 172 173 174 175 176 177 178
    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,
                resolution=None, **kw):
      """Return HTML img tag."""
179
      self._upradeImage()
180 181 182 183 184 185

      # 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.
186 187
      if (display is not None or resolution is not None or quality!=75 or format!='')\
                                               and defaultdisplays.has_key(display):
188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 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 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
          if not self.hasConversion(display=display, format=format,
                                    quality=quality, resolution=resolution):
              # Generate photo on-the-fly
              self._makeDisplayPhoto(display, 1, format=format, quality=quality, resolution=resolution)
          mime, image = self.getConversion(display=display, format=format,
                                     quality=quality ,resolution=resolution)
          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
279 280

  security.declareProtected('View', 'index_html')
281 282
  def index_html(self, REQUEST, RESPONSE, display=None, format='', quality=75, resolution=None):
      """Return the image data."""
283
      self._upradeImage()
284

285
      # display may be set from a cookie (?)
286 287
      if (display is not None or resolution is not None or quality!=75 or format!='')\
                                               and defaultdisplays.has_key(display):
288 289 290 291 292 293 294 295
          if not self.hasConversion(display=display, format=format,
                                    quality=quality,resolution=resolution):
              # Generate photo on-the-fly
              self._makeDisplayPhoto(display, 1, format=format, quality=quality,resolution=resolution)
          # Return resized image
          mime, image = self.getConversion(display=display, format=format,
                                     quality=quality ,resolution=resolution)
          return image.index_html(REQUEST, RESPONSE)
Kevin Deldycke's avatar
Kevin Deldycke committed
296

297 298
      # Return original image
      return OFSImage.index_html(self, REQUEST, RESPONSE)
Kevin Deldycke's avatar
Kevin Deldycke committed
299 300


301 302 303
  #
  # Photo processing
  #
Kevin Deldycke's avatar
Kevin Deldycke committed
304

305 306 307
  def _resize(self, display, width, height, quality=75, format='', resolution=None):
      """Resize and resample photo."""
      newimg = StringIO()
308
      os.putenv('TMPDIR', '/tmp') # because if we run zope as root, we have /root/tmp here and convert goes crazy
Kevin Deldycke's avatar
Kevin Deldycke committed
309

310 311 312
      if sys.platform == 'win32':
          from win32pipe import popen2
          if resolution is None:
313 314
            imgin, imgout = popen2('convert -quality %s -geometry %sx%s - -'
                                  % (quality, width, height), 'b')
315
          else:
316 317
            imgin, imgout = popen2('convert -density %sx%s -quality %s -geometry %sx%s - -'
                                  % (resolution, resolution, quality, width, height), 'b')
Jean-Paul Smets's avatar
Jean-Paul Smets committed
318

319 320 321
      else:
          from popen2 import popen2
          if resolution is None:
322 323
            imgout, imgin = popen2('convert -quality %s -geometry %sx%s - -'
                                  % (quality, width, height))
324 325
          else:
            LOG('Resolution',0,str(resolution))
326 327
            cmd = 'convert -density %sx%s -quality %s -geometry %sx%s - -' % (resolution, resolution, quality, width, height)
            imgout, imgin = popen2(cmd)
328 329 330

      imgin.write(str(self.getData()))
      imgin.close()
331
      newimg.write(imgout.read())
332 333 334 335 336
      newimg.seek(0)
      return newimg

  def _getDisplayData(self, display, format='', quality=75, resolution=None):
      """Return raw photo data for given display."""
337 338 339 340
      if display is None:
          (width, height) = (self.getWidth(), self.getHeight())
      else:
          (width, height) = defaultdisplays[display]
341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394
      if width == 0 and height == 0:
          width = self.getWidth()
          height = self.getHeight()
      (width, height) = self._getAspectRatioSize(width, height)
      return self._resize(display, width, height, quality, format=format, resolution=resolution)

  def _getDisplayPhoto(self, display, format='', quality=75, resolution=None):
      """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))
      return image

  def _makeDisplayPhoto(self, display, force=0, format='', quality=75, resolution=None):
      """Create given display."""
      if not self.hasConversion(display=display, format=format, quality=quality,resolution=resolution) or force:
          image = self._getDisplayPhoto(display, format=format, quality=quality, resolution=resolution)
          self.setConversion(image,  mime=image.content_type,
                                     display=display, format=format,
                                     quality=quality ,resolution=resolution)

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