Image.py 21.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 151
  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

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

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

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

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

      # 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.
205
      image_size = self.getSizeFromImageDisplay(display)
206
      if (display is not None or resolution is not None or quality!=75 or format != ''\
207
                              or frame is not None) and image_size:
208 209 210 211 212
          try:
              mime, image = self.getConversion(display=display, format=format,
                                               quality=quality, resolution=resolution,
                                               frame=frame, image_size=image_size)
          except KeyError:
213
              # Generate photo on-the-fly
214 215 216
              mime, image = self._makeDisplayPhoto(display, format=format, quality=quality,
                                                   resolution=resolution, frame=frame,
                                                   image_size=image_size)
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
          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."""
263
      id_list = list(default_displays_id_list)
264 265
      # Exclude specified displays
      if exclude:
266 267 268
        for id in exclude:
          if id in id_list:
            id_list.remove(id)
269
      # Sort by desired photo surface area
270 271 272 273
      def getSurfaceArea(img):
        x, y = self.getSizeFromImageDisplay(img)
        return x * y
      id_list.sort(key=getSurfaceArea)
274
      return id_list
275 276 277 278 279 280 281 282 283 284 285 286 287 288

  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):
289
          if self._isGenerated(id, format=format, quality=quality, resolution=resolution):
290 291 292 293 294 295 296
              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,
297 298
                            'width': self.getSizeFromImageDisplay(id)[0],
                            'height': self.getSizeFromImageDisplay(id)[1],
299 300 301 302 303 304
                            'photo_width': photo_width,
                            'photo_height': photo_height,
                            'bytes': bytes,
                            'age': age
                            })
      return displays
Kevin Deldycke's avatar
Kevin Deldycke committed
305

306

307 308 309 310 311 312 313
  security.declarePrivate('_convertToText')
  def _convertToText(self, format):
    """
    Convert the image to text with portaltransforms
    """
    mime_type = getToolByName(self, 'mimetypes_registry').\
                                lookupExtension('name.%s' % format)
314
    mime_type = str(mime_type)
315 316 317 318 319 320
    src_mimetype = self.getContentType()
    content = '%s' % self.getData()
    if content is not None:
      portal_transforms = getToolByName(self, 'portal_transforms')
      result = portal_transforms.convertToData(mime_type, content,
                                               object=self, context=self,
321
                                               filename=self.getTitleOrId(),
322 323 324 325 326 327 328 329 330 331 332
                                               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
    else:
      # text_content is not set, return empty string instead of None
      return mime_type, ''

333 334
  # Conversion API
  security.declareProtected(Permissions.ModifyPortalContent, 'convert')
335
  def convert(self, format, display=None, quality=75, resolution=None, frame=None, **kw):
336
    """
337
    Implementation of conversion for Image files
338 339
    """
    if format in ('text', 'txt', 'html', 'base_html', 'stripped-html'):
340
      if not self.hasConversion(format=format):
341
        mime_type, data = self._convertToText(format)
342
        data = aq_base(data)
343
        self.setConversion(data, mime=mime_type, format=format)
344 345 346
      else:
        mime_type, data = self.getConversion(format=format)
      return mime_type, data
347
    image_size = self.getSizeFromImageDisplay(display)
348
    if (display is not None or resolution is not None or quality != 75 or format != ''\
349
                            or frame is not None) and image_size:
350 351 352
      if not self.hasConversion(display=display, format=format,
                                quality=quality, resolution=resolution,
                                frame=frame, image_size=image_size):
353 354 355
        mime, image = self._makeDisplayPhoto(display, format=format, quality=quality,
                                             resolution=resolution, frame=frame,
                                             image_size=image_size)
356 357 358 359 360 361 362
        self.setConversion(image, mime, format=format, quality=quality,
                           resolution=resolution, frame=frame,
                           image_size=image_size)
      else:
        mime, image = self.getConversion(display=display, format=format,
                                         quality=quality, resolution=resolution,
                                         frame=frame, image_size=image_size)
363
      return mime, image.data
364 365
    return self.getContentType(), self.getData()

366 367 368 369 370 371 372 373 374 375 376
  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

377
  # Display
Kevin Deldycke's avatar
Kevin Deldycke committed
378
  security.declareProtected('View', 'index_html')
379 380
  def index_html(self, REQUEST, RESPONSE, display=None, format='', quality=75,
                       resolution=None, frame=None):
381
      """Return the image data."""
382
      self._upradeImage()
383

384 385
      _setCacheHeaders(_ViewEmulator().__of__(self), dict(display=display,
          format=format, quality=quality, resolution=resolution, frame=frame))
Jean-Paul Smets's avatar
Jean-Paul Smets committed
386

387
      # display may be set from a cookie (?)
388
      image_size = self.getSizeFromImageDisplay(display)
389
      if (display is not None or resolution is not None or quality != 75 or format != ''\
390
                              or frame is not None) and image_size:
391 392 393 394 395
          try:
              mime, image = self.getConversion(display=display, format=format,
                                               quality=quality, resolution=resolution,
                                               frame=frame, image_size=image_size)
          except KeyError:
396
              # Generate photo on-the-fly
397 398 399
              mime, image = self._makeDisplayPhoto(display, format=format, quality=quality,
                                                   resolution=resolution, frame=frame,
                                                   image_size=image_size)
400
          RESPONSE.setHeader('Content-Type', mime)
401
          return image.index_html(REQUEST, RESPONSE)
Kevin Deldycke's avatar
Kevin Deldycke committed
402

403 404
      # Return original image
      return OFSImage.index_html(self, REQUEST, RESPONSE)
Kevin Deldycke's avatar
Kevin Deldycke committed
405 406


407 408 409
  #
  # Photo processing
  #
Kevin Deldycke's avatar
Kevin Deldycke committed
410

411 412
  def _resize(self, display, width, height, quality=75, format='',
                    resolution=None, frame=None):
413 414
      """Resize and resample photo."""
      newimg = StringIO()
415 416
      
      parameter_list = ['convert']
417
      parameter_list.extend(['-colorspace', 'RGB'])
418 419 420 421 422 423
      if resolution:
        parameter_list.extend(['-density', '%sx%s' % (resolution, resolution)])
      parameter_list.extend(['-quality', str(quality)])
      parameter_list.extend(['-geometry', '%sx%s' % (width, height)])
      if frame:
        parameter_list.append('-[%s]' % frame)
424
      else:
425
        parameter_list.append('-')
426

427 428
      if format:
        parameter_list.append('%s:-' % format)
429
      else:
430
        parameter_list.append('-')
Jean-Paul Smets's avatar
Jean-Paul Smets committed
431

432 433 434
      process = subprocess.Popen(parameter_list,
                                 stdin=subprocess.PIPE,
                                 stdout=subprocess.PIPE,
435
                                 stderr=subprocess.PIPE,
436
                                 close_fds=True)
437
      imgin, imgout, err = process.stdin, process.stdout, process.stderr
438

439 440 441 442 443 444 445 446 447 448 449
      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())
450
      imgin.close()
451
      newimg.write(imgout.read())
Jean-Paul Smets's avatar
Jean-Paul Smets committed
452
      imgout.close()
453
      if not newimg.tell():
454
        raise ConversionError('Image conversion failed (%s).' % err.read())
455 456 457
      newimg.seek(0)
      return newimg

458 459
  def _getDisplayData(self, display, format='', quality=75, resolution=None, frame=None,
                      image_size=None):
460
      """Return raw photo data for given display."""
461
      if display is None:
462 463 464
        (width, height) = (self.getWidth(), self.getHeight())
      elif image_size is None:
        (width, height) = self.getSizeFromImageDisplay(display)
465
      else:
466
        (width, height) = image_size
467
      if width == 0 and height == 0:
468 469
        width = self.getWidth()
        height = self.getHeight()
470
      (width, height) = self._getAspectRatioSize(width, height)
Bartek Górny's avatar
Bartek Górny committed
471
      if (width, height) == (0, 0):return self.getData()
472 473
      return self._resize(display, width, height, quality, format=format,
                          resolution=resolution, frame=frame)
474

475 476
  def _getDisplayPhoto(self, display, format='', quality=75, resolution=None, frame=None,
                       image_size=None):
477 478 479
      """Return photo object for given display."""
      try:
          base, ext = string.split(self.id, '.')
480
          id = base + '_' + display + '.' + ext
481
      except ValueError:
482
          id = self.id +'_'+ display
483
      image = OFSImage(id, self.getTitle(), self._getDisplayData(display, format=format,
484 485
                           quality=quality, resolution=resolution, frame=frame,
                           image_size=image_size))
486 487
      return image

488 489
  def _makeDisplayPhoto(self, display, format='', quality=75, resolution=None, frame=None,
                        image_size=None):
490
      """Create given display."""
491 492 493 494 495 496 497 498
      image = self._getDisplayPhoto(display, format=format, quality=quality,
                                             resolution=resolution, frame=frame,
                                             image_size=image_size)
      self.setConversion(image, mime=image.content_type,
                                display=display, format=format,
                                quality=quality, resolution=resolution,
                                frame=frame, image_size=image_size)
      return (image.content_type, aq_base(image))
499 500 501 502

  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
503 504
      if img_width == 0:
        return (0, 0)
505 506 507 508 509 510 511

      #XXX This is a temporary dirty fix!!!
      width = int(width)
      height = int(height)
      img_width = int(img_width)
      img_height = int(img_height)

512 513 514 515 516 517 518 519 520 521
      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()

522 523 524
  security.declareProtected('View', 'getSizeFromImageDisplay')
  def getSizeFromImageDisplay(self, image_display):
    """
525 526 527 528 529 530 531 532 533 534 535
    Return the size for this image display, or None if this image display name
    is not known. If the preference is not set, (0, 0) is returned.
    """
    if image_display in default_displays_id_list:
      preference_tool = self.getPortalObject().portal_preferences
      height_preference = 'preferred_%s_image_height' % (image_display,)
      width_preferece = 'preferred_%s_image_width' % (image_display,)
      height = preference_tool.getPreference(height_preference, 0)
      width = preference_tool.getPreference(width_preferece, 0)
      return (height, width)
    return None
536

537 538 539 540 541
  def _setFile(self, *args, **kw):
    """set the file content and reset image information.
    """
    File._setFile(self, *args, **kw)
    self._update_image_info()
542

543
  def PUT(self, REQUEST, RESPONSE):
544 545
    """set the file content by HTTP/FTP and reset image information.
    """
546
    File.PUT(self, REQUEST, RESPONSE)
547 548
    self._update_image_info()

549 550 551 552 553 554 555 556 557 558 559 560 561 562 563
  #
  # 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):