<?php namespace ProcessWire;

/**
 * ImageSizer Engine IMagick by Horst
 * 
 * @todo some class properties need phpdoc
 * 
 * @method imSaveReady($im, $filename) 
 *
 */
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));

		$imClone = null;
		if($this->wire()->hooks->isHooked('ImageSizerEngineIMagick::imSaveReady()')) {
			$imClone = clone $this->im; // make a copy before compressions take effect
			$this->imSaveReady($imClone, $srcFilename);
		}
		
		// 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 = $imClone ?: 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;
	}

	/**
	 * Called before saving of image
	 *
	 * @param resource $im
	 * @param string $filename Source filename
	 * @since 3.0.236
	 *
	 */
	protected function ___imSaveReady($im, $filename) { }
	
	/**
	 * 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");
		}
	}

}
