Subversion Repositories web.creative

Rev

Blame | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * ImageSizer Engine IMagick by Horst
 * 
 * @todo some class properties need phpdoc
 *
 */
class ImageSizerEngineIMagick extends ImageSizerEngine {
  
  public static function getModuleInfo() {
    return array(
      'title' => 'IMagick Image Sizer',
      'version' => 3,
      'summary' => "Upgrades image manipulations to use PHP's ImageMagick library when possible.",
      'author' => 'Horst Nogajski',
      'autoload' => false,
      'singular' => false,
    );
  }

  /**
   * The (main) IMagick bitimage handler for regular image variations, (JPEG PNG)
   * 
   * @var \IMagick|null
   * 
   */
  protected $im = null;
  
  /**
   * The (optionally) IMagick bitimage handler for additional WebP copies
   * 
   * @var \IMagick|null
   * 
   */
  protected $imWebp = null;

  /**
   * Static cache of formats and whether or not supported, as used by the supportsFormat() method (RJC)
   * 
   * @var array of ['FORMAT' => true|false]
   * 
   */
  static protected $formatSupport = array();

  // @todo the following need phpdoc
  protected $workspaceColorspace;
  protected $imageFormat;
  protected $imageColorspace;
  protected $imageMetadata;
  protected $imageDepth;
  protected $imageGamma;

  /**
   * @var bool
   * 
   */
  protected $hasICC;
  
  /**
   * @var bool
   *
   */
  protected $hasIPTC;
  
  /**
   * @var bool
   *
   */
  protected $hasEXIF;
  
  /**
   * @var bool
   *
   */
  protected $hasXMP;

  /**
   * Class constructor
   * 
   */
  public function __construct() {
    // set a lower default quality of 80, which is more like 90 in GD
    $this->setQuality(80);
    parent::__construct();
  }

  /**
   * Class destructor
   * 
   */
  public function __destruct() {
    $this->release();
  }

  /**
   * Release resources used by IMagick
   * 
   */
  protected function release() {
    if(is_object($this->im)) {
      $this->im->clear();
      $this->im->destroy();
    }
    if(is_object($this->imWebp)) {
      $this->imWebp->clear();
      $this->imWebp->destroy();
    }
  }

  /**
   * Get valid image source formats
   * 
   * @return array
   * 
   */
  protected function validSourceImageFormats() {
    // 2019/06/07: “PNG8” removed because some versions of ImageMagick have some bug, may be able to add back later
    return array('JPG', 'JPEG', 'PNG24', 'PNG', 'GIF', 'GIF87'); 
    //return array(
    //    'PNG', 'PNG8', 'PNG24',
    //    'JPG', 'JPEG',
    //    'GIF', 'GIF87'
    //);
  }

  /**
   * Get valid target image formats
   * 
   * @return array
   * 
   */
  protected function validTargetImageFormats() {
    $formats = $this->validSourceImageFormats();
    if($this->supportsFormat('WEBP')) $formats[] = 'WEBP';
    return $formats;
  }

  /**
   * Get library version string
   *
   * @return string Returns version string or blank string if not applicable/available
   * @since 3.0.138
   *
   */
  public function getLibraryVersion() {
    $a = \Imagick::getVersion();
    return "$a[versionString] n=$a[versionNumber]";
  }
  
  /**
   * Is the given image format supported by this IMagick for source and target? (RJC)
   *
   * @param string $format String like png, jpg, jpg, png8, png24, png24-trans, png24-alpha, etc.
   * @return bool
   *
   */
  public function supportsFormat($format) {
    if(strpos($format, '-')) list($format,) = explode('-', $format);
    $format = strtoupper($format);
    if(isset(self::$formatSupport[$format])) return self::$formatSupport[$format];
    try {
      $im = new \IMagick();
      $formats = $im->queryformats($format);
      $supported = count($formats) > 0;
    } catch(\Exception $e) {
      $supported = false;
    }
    self::$formatSupport[$format] = $supported;
    return $supported;
  }

  /**
   * Is IMagick supported? Is the current image(sub)format supported?
   *
   * @param string $action
   * @return bool
   *
   */
  public function supported($action = 'imageformat') {

    // first we check parts that are mandatory for all $actions
    if(!class_exists("\\IMagick")) return false;

    // and if it passes the mandatory requirements, we check particularly aspects here
    $supported = false;
    if($action === 'imageformat') {
      // compare current imagefile infos fetched from ImageInspector
      $requested = $this->getImageInfo(false);
      $supported = $this->supportsFormat($requested);
    } else if($action === 'webp') {
      $supported = $this->supportsFormat('WEBP');
    } else if($action === 'install') {
      $supported = true;
    }
    
    return $supported; 
  }


  /**
   * Process the image resize
   *
   * Processing is as follows:
   *    1. first do a check if the given image(type) can be processed, if not do an early return false
   *    2. than (try) to process all required steps, if one failes, return false
   *    3. if all is successful, finally return true
   *
   * @param string $srcFilename Source file
   * @param string $dstFilename Destination file
   * @param int $fullWidth Current width
   * @param int $fullHeight Current height
   * @param int $finalWidth Requested final width
   * @param int $finalHeight Requested final height
   * @return bool True if successful, false if not
   * @throws WireException
   *
   */
  protected function processResize($srcFilename, $dstFilename, $fullWidth, $fullHeight, $finalWidth, $finalHeight) {
    
    $this->setTimeLimit(120);
    
    // start image magick
    $this->im = new \IMagick();

    // set the working colorspace: COLORSPACE_RGB or COLORSPACE_SRGB    ( whats about COLORSPACE_GRAY ??)
    $this->workspaceColorspace = \Imagick::COLORSPACE_SRGB;
    $this->im->setColorspace($this->workspaceColorspace);

    if(!$this->im->readImage($srcFilename)) {  // actually we get a filecopy from origFilename to destFilename from PageImage
      $this->release();
      return false;
    }

    // check validity against image magick
    if(!$this->im->valid()) {
      $this->release();
      throw new WireException(sprintf($this->_("loaded file '%s' is not a valid image"), basename($srcFilename)));
    }

    // get image format
    $this->imageFormat = strtoupper($this->im->getImageFormat());

    // only for JPEGs and 24bit PNGs
    if(!in_array($this->imageFormat, $this->validSourceImageFormats())) {
      $this->release();
      return false;
    }

    // check validity against PW (this does not seem to be reachable due to above code, so commented it out —Ryan)
    // if(!in_array($this->imageFormat, $this->validSourceImageFormats())) {
    //  $this->release();
    //  throw new WireException(sprintf($this->_("loaded file '%s' is not in the list of valid images"), basename($dstFilename)));
    // }

    // check and retrieve different image parts and information: ICC, Colorspace, Colordepth, Metadata, etc
    $this->imageColorspace = $this->im->getImageColorspace();
    $this->workspaceColorspace = \Imagick::COLORSPACE_GRAY == $this->imageColorspace ? \Imagick::COLORSPACE_GRAY : $this->workspaceColorspace;
    $this->im->setColorspace($this->workspaceColorspace);
    $this->imageMetadata = $this->im->getImageProfiles('*');
    if(!is_array($this->imageMetadata)) $this->imageMetadata = array();
    $this->hasICC = array_key_exists('icc', $this->imageMetadata);
    $this->hasIPTC = array_key_exists('iptc', $this->imageMetadata);
    $this->hasEXIF = array_key_exists('exif', $this->imageMetadata);
    $this->hasXMP = array_key_exists('xmp', $this->imageMetadata);
    $this->imageType = $this->im->getImageType();
    $this->imageDepth = $this->im->getImageDepth();
    $this->imageGamma = $this->im->getImageGamma();
    
    if(0 == $this->imageGamma) {
      // we seem to running on a IMagick version that lacks some features,
      // at least the 'getImageGamma()', therefor we asume a Gamma of 2.2 here
      $this->imageGamma = 0.454545;
    }

    // remove not wanted / needed Metadata = this is the same behave as processed via GD-lib
    foreach(array_keys($this->imageMetadata) as $k) {
      #if('icc'==$k) continue;     // we keep embedded icc profiles
      #if('iptc' == $k) continue;  // we keep embedded iptc data
      #if('exif'==$k && $this->data['keepEXIF']) continue; // we have to keep exif data too
      #if('xmp'==$k && $this->data['keepXMP']) continue; // we have to keep xmp data too
      $this->im->profileImage("$k", null); // remove this one
    }

    $this->im->setImageDepth(16);

    $resetGamma = false;
    if($this->imageGamma && $this->imageGamma != 1) {
      $resetGamma = $this->im->gammaImage($this->imageGamma);
    }

    $orientations = null;
    if($this->autoRotation !== true) {
      $needRotation = false;
    } else if($this->checkOrientation($orientations) && (!empty($orientations[0]) || !empty($orientations[1]))) {
      $needRotation = true;
    } else {
      $needRotation = false;
    }

    if($this->rotate || $needRotation) { // @horst
      if($this->rotate) {
        $degrees = $this->rotate;
      } else if((is_float($orientations[0]) || is_int($orientations[0])) && $orientations[0] > -361 && $orientations[0] < 361) {
        $degrees = $orientations[0];
      } else {
        $degrees = false;
      }
      if($degrees !== false && !in_array($degrees, array(-360, 0, 360))) {
        $this->im->rotateImage(new \IMagickPixel('none'), $degrees);
        if(abs($degrees) == 90 || abs($degrees) == 270) {
          // we have to swap width & height now!
          $tmp = array($this->getWidth(), $this->getHeight());
          $this->setImageInfo($tmp[1], $tmp[0]);
        }
      }
    }

    if($this->flip || $needRotation) {
      $vertical = null;
      if($this->flip) {
        $vertical = $this->flip == 'v';
      } else if($orientations[1] > 0) {
        $vertical = $orientations[1] == 2;
      }
      if(!is_null($vertical)) {
        $res = $vertical ? $this->im->flipImage() : $this->im->flopImage();
        if(!$res) {
          $this->release();
          return false;
        }
      }
    }

    $zoom = $this->getFocusZoomPercent();
    if($zoom > 1) {
      // we need to configure a cropExtra call to respect the zoom factor
      $this->cropExtra = $this->getFocusZoomCropDimensions($zoom, $fullWidth, $fullHeight, $finalWidth, $finalHeight);
      $this->cropping = false;
    }

    if(is_array($this->cropExtra) && 4 == count($this->cropExtra)) { // crop before resize
      list($cropX, $cropY, $cropWidth, $cropHeight) = $this->cropExtra;
      #list($x, $y, $w, $h) = $this->cropExtra;
      if(!$this->im->cropImage($cropWidth, $cropHeight, $cropX, $cropY)) {
        $this->release();
        return false;
      }
      $this->im->setImagePage(0, 0, 0, 0);  //remove the canvas
      $this->setImageInfo($this->im->getImageWidth(), $this->im->getImageHeight());
    }

    $bgX = $bgY = 0;
    $bgWidth = $fullWidth;
    $bgHeight = $fullHeight;
    $resizemethod = $this->getResizeMethod($bgWidth, $bgHeight, $finalWidth, $finalHeight, $bgX, $bgY);

    if(0 == $resizemethod) {
      $this->sharpening = 'none';  // no need for sharpening because we use original copy without scaling
      // oh, do we need to save with more compression for JPEGs ??
      #return true;
      
    } else if(2 == $resizemethod) { // 2 = resize with aspect ratio
      if(!$this->im->resizeImage($finalWidth, $finalHeight, \Imagick::FILTER_LANCZOS, 1)) {
        $this->release();
        return false;
      }
      $this->setImageInfo($this->im->getImageWidth(), $this->im->getImageHeight());
      
    } else if(4 == $resizemethod) { // 4 = resize and crop from center with aspect ratio
      if(!$this->im->resizeImage($bgWidth, $bgHeight, \Imagick::FILTER_LANCZOS, 1)) {
        $this->release();
        return false;
      }
      $this->setImageInfo($this->im->getImageWidth(), $this->im->getImageHeight());
      if(!$this->im->cropImage($finalWidth, $finalHeight, $bgX, $bgY)) {
        $this->release();
        return false;
      }
      $this->im->setImagePage(0, 0, 0, 0);  //remove the canvas
      $this->setImageInfo($this->im->getImageWidth(), $this->im->getImageHeight());
    }

    if($this->sharpening && $this->sharpening != 'none') {
      $this->imSharpen($this->sharpening);
    }

    // optionally apply interlace bit to the final image. This will result in progressive JPEGs
    if($this->interlace && in_array(strtoupper($this->imageFormat), array('JPG', 'JPEG'))) {
      $this->im->setInterlaceScheme(\Imagick::INTERLACE_JPEG);
    }

    if(isset($resetGamma) && $this->imageGamma && $this->imageGamma != 1) {
      $this->im->gammaImage(1 / $this->imageGamma);
    }

    $this->im->setImageDepth(($this->imageDepth > 8 ? 8 : $this->imageDepth));
  
    // determine whether webp should be created as well (or on its own)
    $webpOnly = $this->webpOnly && $this->supported('webp');
    $webpAdd = $webpOnly || ($this->webpAdd && $this->supported('webp'));
    
    if($webpOnly) {
      // only a webp file will be created
      $this->imWebp = $this->im;
    } else {
      if($webpAdd) $this->imWebp = clone $this->im; // make a copy before compressions take effect
      $this->im->setImageFormat($this->imageFormat);
      $this->im->setImageType($this->imageType);
      if(in_array(strtoupper($this->imageFormat), array('JPG', 'JPEG'))) {
        $this->im->setImageCompression(\Imagick::COMPRESSION_JPEG);
        $this->im->setImageCompressionQuality($this->quality);
      } else if(in_array(strtoupper($this->imageFormat), array('PNG', 'PNG8', 'PNG24'))) {
        $this->im->setImageCompression(\Imagick::COMPRESSION_ZIP);
        $this->im->setImageCompressionQuality($this->quality);
      } else {
        $this->im->setImageCompression(\Imagick::COMPRESSION_UNDEFINED);
        $this->im->setImageCompressionQuality($this->quality);
      }

      // write to file
      if(file_exists($dstFilename)) $this->wire('files')->unlink($dstFilename);
      @clearstatcache(dirname($dstFilename));
      ##if(!$this->im->writeImage($this->destFilename)) {
      // We use this approach for saving so that it behaves the same like core ImageSizer with images that
      // have a wrong extension in their filename. When using writeImage() it automatically corrects the
      // mimetype to match the fileextension, <- we want to avoid this!
      if(!file_put_contents($dstFilename, $this->im)) {
        $this->release();
        return false;
      }
    }
      
    // set modified flag and delete optional webp dependency file
    $this->modified = true;
    $return = true;
    
    // if there is a corresponding webp file present, remove it
    $pathinfo = pathinfo($srcFilename);
    $webpFilename = $pathinfo['dirname'] . '/' . $pathinfo['filename'] . '.webp';
    if(file_exists($webpFilename)) $this->wire('files')->unlink($webpFilename);
      
    // optionally create a WebP dependency file
    if($webpAdd) {
      // prepare for webp output
      $this->imWebp->setImageFormat('webp');
      $this->imWebp->setImageCompressionQuality($this->webpQuality);
      $this->imWebp->setOption('webp:method', '6');
      //$this->imWebp->setOption('webp:lossless', 'true');                    // is this useful?
      //$this->imWebp->setImageAlphaChannel(imagick::ALPHACHANNEL_ACTIVATE);  // is this useful?
        //$this->imWebp->setBackgroundColor(new ImagickPixel('transparent'));   // is this useful?
      // save to file
      $return = $this->imWebp->writeImage($webpFilename);
    }

    // release and return to event-object
    $this->release();
    
    return $return;
  }
  
  /**
   * Process rotate of an image
   *
   * @param string $srcFilename
   * @param string $dstFilename
   * @param int $degrees Clockwise degrees, i.e. 90, 180, 270, -90, -180, -270
   * @return bool
   *
   */
  protected function processRotate($srcFilename, $dstFilename, $degrees) {

    $success = false;
    $imagick = $this->getImagick($srcFilename);
    if($imagick->rotateImage(new \ImagickPixel('#00000000'), $degrees)) {
      $success = $this->processSave($imagick, $dstFilename); 
    }
    
    return $success;
  }

  /**
   * Process vertical or horizontal flip of an image
   *
   * @param string $srcFilename
   * @param string $dstFilename
   * @param string $flipType Specify vertical, horizontal or both
   * @return bool
   *
   */
  protected function processFlip($srcFilename, $dstFilename, $flipType) {
    
    $imagick = $this->getImagick($srcFilename); 
    
    if($flipType == 'vertical') {
      $success = $imagick->flipImage();
    } else if($flipType == 'horizontal') {
      $success = $imagick->flopImage();
    } else {
      $success = $imagick->flipImage() && $imagick->flopImage();
    }
    
    if($success) $success = $this->processSave($imagick, $dstFilename); 
    
    return $success;
  }

  /**
   * Reduce dimensions of image by half (using Imagick minifyImage method)
   * 
   * @param string $dstFilename If different from filename specified by setFilename()
   * @return bool
   * 
   */
  public function reduceByHalf($dstFilename = '') {
    $imagick = $this->getImagick($this->filename); 
    $success = $imagick->minifyImage();
    if($success) $success = $this->processSave($imagick, $dstFilename); 
    return $success;
  }

  /**
   * Convert image to greyscale
   * 
   * @param string $dstFilename
   * @return bool
   * 
   */
  public function convertToGreyscale($dstFilename = '') {
    $imagick = $this->getImagick($this->filename); 
    $success = $imagick->transformImageColorspace(\imagick::COLORSPACE_GRAY);
    if($success) $success = $this->processSave($imagick, $dstFilename);
    return $success;
  }

  /**
   * Convert image to sepia
   * 
   * @param string $dstFilename
   * @param float|int $sepia Sepia threshold
   * @return bool
   * 
   */
  public function convertToSepia($dstFilename = '', $sepia = 55) {
    $sepia += 35; 
    $imagick = $this->getImagick($this->filename);
    $success = $imagick->sepiaToneImage((float) $sepia); 
    if($success) $success = $this->processSave($imagick, $dstFilename);
    return $success;
  }

  /**
   * Save action image to file
   * 
   * @param \IMagick $imagick
   * @param string $dstFilename
   * @return bool
   * 
   */
  protected function processSave(\IMagick $imagick, $dstFilename) {
    if(empty($dstFilename)) $dstFilename = $this->filename;
    $ext = strtolower(pathinfo($dstFilename, PATHINFO_EXTENSION)); 
    if(in_array($ext, array('jpg', 'jpeg'))) {
      if($this->interlace) {
        $imagick->setInterlaceScheme(\Imagick::INTERLACE_JPEG);
      }
    }
    $imagick->setImageCompressionQuality($this->quality);
    $fp = fopen($dstFilename, 'wb');
    if($fp === false) return false;
    $success = $imagick->writeImageFile($fp);
    fclose($fp);
    return $success;
  }

  /**
   * Get instance of Imagick
   * 
   * @param string $filename Optional filename to read
   * @return \Imagick
   * @throws WireException
   * 
   */
  public function getImagick($filename = '') {
    $imagick = new \Imagick();
    if($filename) {
      if(!$imagick->readImage($filename)) {
        throw new WireException("Imagick unable to load file: " . basename($filename));
      }
    }
    return $imagick;
  }
  
  /**
   * Sharpen the image
   * 
   * @param string $mode May be none|string|medium|soft
   * @return bool
   * 
   */
  protected function imSharpen($mode) {
    if('none' == $mode) return true;
    $mp = intval($this->finalHeight * $this->finalWidth);
    if($mp > 1440000) {
      switch($mode) {
        case 'strong':
          $m = array(0, 0.5, 4.6, 0.03);
          break;
        case 'medium':
          $m = array(0, 0.5, 3.0, 0.04);
          break;
        case 'soft':
        default:
          $m = array(0, 0.5, 2.3, 0.07);
          break;
      }
    } else if($mp > 480000) {
      switch($mode) {
        case 'strong':
          $m = array(0, 0.5, 3.0, 0.04);
          break;
        case 'medium':
          $m = array(0, 0.5, 2.3, 0.06);
          break;
        case 'soft':
        default:
          $m = array(0, 0.5, 1.8, 0.08);
          break;
      }
    } else {
      switch($mode) {
        case 'strong':
          $m = array(0, 0.5, 2.0, 0.06);
          break;
        case 'medium':
          $m = array(0, 0.5, 1.7, 0.08);
          break;
        case 'soft':
        default:
          $m = array(0, 0.5, 1.2, 0.1);
          break;
      }
    }
    $this->im->unsharpMaskImage($m[0], $m[1], $m[2], $m[3]);
    $this->modified = true;
    return true;
  }

  /**
   * Module install
   * 
   * @throws WireException
   * 
   */
  public function ___install() {
    if(!$this->supported('install')) {
      throw new WireException("This module requires that you have PHP's IMagick (Image Magick) extension installed");
    }
  }

}