artabro/wire/modules/Image/ImageSizerEngineIMagick/ImageSizerEngineIMagick.module

662 lines
19 KiB
Text
Raw Normal View History

2024-08-27 11:35:37 +02:00
<?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");
}
}
}