'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"); } } }