lib/image.php
<?php

/*

	Copyright (c) 2009-2015 F3::Factory/Bong Cosca, All rights reserved.

	This file is part of the Fat-Free Framework (http://fatfreeframework.com).

	This 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 3 of the License, or later.

	Fat-Free Framework 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 Fat-Free Framework.  If not, see <http://www.gnu.org/licenses/>.

*/

//! Image manipulation tools
class Image {

	//@{ Messages
	const
		E_Color='Invalid color specified: %s',
		E_File='File not found',
		E_Font='CAPTCHA font not found',
		E_Length='Invalid CAPTCHA length: %s';
	//@}

	//@{ Positional cues
	const
		POS_Left=1,
		POS_Center=2,
		POS_Right=4,
		POS_Top=8,
		POS_Middle=16,
		POS_Bottom=32;
	//@}

	protected
		//! Source filename
		$file,
		//! Image resource
		$data,
		//! Enable/disable history
		$flag=FALSE,
		//! Filter count
		$count=0;

	/**
	*	Convert RGB hex triad to array
	*	@return array|FALSE
	*	@param $color int
	**/
	function rgb($color) {
		$hex=str_pad($hex=dechex($color),$color<4096?3:6,'0',STR_PAD_LEFT);
		if (($len=strlen($hex))>6)
			user_error(sprintf(self::E_Color,'0x'.$hex),E_USER_ERROR);
		$color=str_split($hex,$len/3);
		foreach ($color as &$hue) {
			$hue=hexdec(str_repeat($hue,6/$len));
			unset($hue);
		}
		return $color;
	}

	/**
	*	Invert image
	*	@return object
	**/
	function invert() {
		imagefilter($this->data,IMG_FILTER_NEGATE);
		return $this->save();
	}

	/**
	*	Adjust brightness (range:-255 to 255)
	*	@return object
	*	@param $level int
	**/
	function brightness($level) {
		imagefilter($this->data,IMG_FILTER_BRIGHTNESS,$level);
		return $this->save();
	}

	/**
	*	Adjust contrast (range:-100 to 100)
	*	@return object
	*	@param $level int
	**/
	function contrast($level) {
		imagefilter($this->data,IMG_FILTER_CONTRAST,$level);
		return $this->save();
	}

	/**
	*	Convert to grayscale
	*	@return object
	**/
	function grayscale() {
		imagefilter($this->data,IMG_FILTER_GRAYSCALE);
		return $this->save();
	}

	/**
	*	Adjust smoothness
	*	@return object
	*	@param $level int
	**/
	function smooth($level) {
		imagefilter($this->data,IMG_FILTER_SMOOTH,$level);
		return $this->save();
	}

	/**
	*	Emboss the image
	*	@return object
	**/
	function emboss() {
		imagefilter($this->data,IMG_FILTER_EMBOSS);
		return $this->save();
	}

	/**
	*	Apply sepia effect
	*	@return object
	**/
	function sepia() {
		imagefilter($this->data,IMG_FILTER_GRAYSCALE);
		imagefilter($this->data,IMG_FILTER_COLORIZE,90,60,45);
		return $this->save();
	}

	/**
	*	Pixelate the image
	*	@return object
	*	@param $size int
	**/
	function pixelate($size) {
		imagefilter($this->data,IMG_FILTER_PIXELATE,$size,TRUE);
		return $this->save();
	}

	/**
	*	Blur the image using Gaussian filter
	*	@return object
	*	@param $selective bool
	**/
	function blur($selective=FALSE) {
		imagefilter($this->data,
			$selective?IMG_FILTER_SELECTIVE_BLUR:IMG_FILTER_GAUSSIAN_BLUR);
		return $this->save();
	}

	/**
	*	Apply sketch effect
	*	@return object
	**/
	function sketch() {
		imagefilter($this->data,IMG_FILTER_MEAN_REMOVAL);
		return $this->save();
	}

	/**
	*	Flip on horizontal axis
	*	@return object
	**/
	function hflip() {
		$tmp=imagecreatetruecolor(
			$width=$this->width(),$height=$this->height());
		imagesavealpha($tmp,TRUE);
		imagefill($tmp,0,0,IMG_COLOR_TRANSPARENT);
		imagecopyresampled($tmp,$this->data,
			0,0,$width-1,0,$width,$height,-$width,$height);
		imagedestroy($this->data);
		$this->data=$tmp;
		return $this->save();
	}

	/**
	*	Flip on vertical axis
	*	@return object
	**/
	function vflip() {
		$tmp=imagecreatetruecolor(
			$width=$this->width(),$height=$this->height());
		imagesavealpha($tmp,TRUE);
		imagefill($tmp,0,0,IMG_COLOR_TRANSPARENT);
		imagecopyresampled($tmp,$this->data,
			0,0,0,$height-1,$width,$height,$width,-$height);
		imagedestroy($this->data);
		$this->data=$tmp;
		return $this->save();
	}

	/**
	*	Crop the image
	*	@return object
	*	@param $x1 int
	*	@param $y1 int
	*	@param $x2 int
	*	@param $y2 int
	**/
	function crop($x1,$y1,$x2,$y2) {
		$tmp=imagecreatetruecolor($width=$x2-$x1+1,$height=$y2-$y1+1);
		imagesavealpha($tmp,TRUE);
		imagefill($tmp,0,0,IMG_COLOR_TRANSPARENT);
		imagecopyresampled($tmp,$this->data,
			0,0,$x1,$y1,$width,$height,$width,$height);
		imagedestroy($this->data);
		$this->data=$tmp;
		return $this->save();
	}

	/**
	*	Resize image (Maintain aspect ratio); Crop relative to center
	*	if flag is enabled; Enlargement allowed if flag is enabled
	*	@return object
	*	@param $width int
	*	@param $height int
	*	@param $crop bool
	*	@param $enlarge bool
	**/
	function resize($width,$height,$crop=TRUE,$enlarge=TRUE) {
		// Adjust dimensions; retain aspect ratio
		$ratio=($origw=imagesx($this->data))/($origh=imagesy($this->data));
		if (!$crop) {
			if ($width/$ratio<=$height)
				$height=$width/$ratio;
			else
				$width=$height*$ratio;
		}
		if (!$enlarge) {
			$width=min($origw,$width);
			$height=min($origh,$height);
		}
		// Create blank image
		$tmp=imagecreatetruecolor($width,$height);
		imagesavealpha($tmp,TRUE);
		imagefill($tmp,0,0,IMG_COLOR_TRANSPARENT);
		// Resize
		if ($crop) {
			if ($width/$ratio<=$height) {
				$cropw=$origh*$width/$height;
				imagecopyresampled($tmp,$this->data,
					0,0,($origw-$cropw)/2,0,$width,$height,$cropw,$origh);
			}
			else {
				$croph=$origw*$height/$width;
				imagecopyresampled($tmp,$this->data,
					0,0,0,($origh-$croph)/2,$width,$height,$origw,$croph);
			}
		}
		else
			imagecopyresampled($tmp,$this->data,
				0,0,0,0,$width,$height,$origw,$origh);
		imagedestroy($this->data);
		$this->data=$tmp;
		return $this->save();
	}

	/**
	*	Rotate image
	*	@return object
	*	@param $angle int
	**/
	function rotate($angle) {
		$this->data=imagerotate($this->data,$angle,
			imagecolorallocatealpha($this->data,0,0,0,127));
		imagesavealpha($this->data,TRUE);
		return $this->save();
	}

	/**
	*	Apply an image overlay
	*	@return object
	*	@param $img object
	*	@param $align int|array
	*	@param $alpha int
	**/
	function overlay(Image $img,$align=NULL,$alpha=100) {
		if (is_null($align))
			$align=self::POS_Right|self::POS_Bottom;
		if (is_array($align)) {
			list($posx,$posy)=$align;
			$align = 0;
		}
		$ovr=imagecreatefromstring($img->dump());
		imagesavealpha($ovr,TRUE);
		$imgw=$this->width();
		$imgh=$this->height();
		$ovrw=imagesx($ovr);
		$ovrh=imagesy($ovr);
		if ($align & self::POS_Left)
			$posx=0;
		if ($align & self::POS_Center)
			$posx=($imgw-$ovrw)/2;
		if ($align & self::POS_Right)
			$posx=$imgw-$ovrw;
		if ($align & self::POS_Top)
			$posy=0;
		if ($align & self::POS_Middle)
			$posy=($imgh-$ovrh)/2;
		if ($align & self::POS_Bottom)
			$posy=$imgh-$ovrh;
		if (empty($posx))
			$posx=0;
		if (empty($posy))
			$posy=0;
		if ($alpha==100)
			imagecopy($this->data,$ovr,$posx,$posy,0,0,$ovrw,$ovrh);
		else {
			$cut=imagecreatetruecolor($ovrw,$ovrh);
			imagecopy($cut,$this->data,0,0,$posx,$posy,$ovrw,$ovrh);
			imagecopy($cut,$ovr,0,0,0,0,$ovrw,$ovrh);
			imagecopymerge($this->data,
				$cut,$posx,$posy,0,0,$ovrw,$ovrh,$alpha);
		}
		return $this->save();
	}

	/**
	*	Generate identicon
	*	@return object
	*	@param $str string
	*	@param $size int
	*	@param $blocks int
	**/
	function identicon($str,$size=64,$blocks=4) {
		$sprites=array(
			array(.5,1,1,0,1,1),
			array(.5,0,1,0,.5,1,0,1),
			array(.5,0,1,0,1,1,.5,1,1,.5),
			array(0,.5,.5,0,1,.5,.5,1,.5,.5),
			array(0,.5,1,0,1,1,0,1,1,.5),
			array(1,0,1,1,.5,1,1,.5,.5,.5),
			array(0,0,1,0,1,.5,0,0,.5,1,0,1),
			array(0,0,.5,0,1,.5,.5,1,0,1,.5,.5),
			array(.5,0,.5,.5,1,.5,1,1,.5,1,.5,.5,0,.5),
			array(0,0,1,0,.5,.5,1,.5,.5,1,.5,.5,0,1),
			array(0,.5,.5,1,1,.5,.5,0,1,0,1,1,0,1),
			array(.5,0,1,0,1,1,.5,1,1,.75,.5,.5,1,.25),
			array(0,.5,.5,0,.5,.5,1,0,1,.5,.5,1,.5,.5,0,1),
			array(0,0,1,0,1,1,0,1,1,.5,.5,.25,.5,.75,0,.5,.5,.25),
			array(0,.5,.5,.5,.5,0,1,0,.5,.5,1,.5,.5,1,.5,.5,0,1),
			array(0,0,1,0,.5,.5,.5,0,0,.5,1,.5,.5,1,.5,.5,0,1)
		);
		$hash=sha1($str);
		$this->data=imagecreatetruecolor($size,$size);
		list($r,$g,$b)=$this->rgb(hexdec(substr($hash,-3)));
		$fg=imagecolorallocate($this->data,$r,$g,$b);
		imagefill($this->data,0,0,IMG_COLOR_TRANSPARENT);
		$ctr=count($sprites);
		$dim=$blocks*floor($size/$blocks)*2/$blocks;
		for ($j=0,$y=ceil($blocks/2);$j<$y;$j++)
			for ($i=$j,$x=$blocks-1-$j;$i<$x;$i++) {
				$sprite=imagecreatetruecolor($dim,$dim);
				imagefill($sprite,0,0,IMG_COLOR_TRANSPARENT);
				if ($block=$sprites[
					hexdec($hash[($j*$blocks+$i)*2])%$ctr]) {
					for ($k=0,$pts=count($block);$k<$pts;$k++)
						$block[$k]*=$dim;
					imagefilledpolygon($sprite,$block,$pts/2,$fg);
				}
				$sprite=imagerotate($sprite,
					90*(hexdec($hash[($j*$blocks+$i)*2+1])%4),
					imagecolorallocatealpha($sprite,0,0,0,127));
				for ($k=0;$k<4;$k++) {
					imagecopyresampled($this->data,$sprite,
						$i*$dim/2,$j*$dim/2,0,0,$dim/2,$dim/2,$dim,$dim);
					$this->data=imagerotate($this->data,90,
						imagecolorallocatealpha($this->data,0,0,0,127));
				}
				imagedestroy($sprite);
			}
		imagesavealpha($this->data,TRUE);
		return $this->save();
	}

	/**
	*	Generate CAPTCHA image
	*	@return object|FALSE
	*	@param $font string
	*	@param $size int
	*	@param $len int
	*	@param $key string
	*	@param $path string
	*	@param $fg int
	*	@param $bg int
	**/
	function captcha($font,$size=24,$len=5,
		$key=NULL,$path='',$fg=0xFFFFFF,$bg=0x000000) {
		if ((!$ssl=extension_loaded('openssl')) && ($len<4 || $len>13)) {
			user_error(sprintf(self::E_Length,$len),E_USER_ERROR);
			return FALSE;
		}
		$fw=Base::instance();
		foreach ($fw->split($path?:$fw->get('UI').';./') as $dir)
			if (is_file($path=$dir.$font)) {
				$seed=strtoupper(substr(
					$ssl?bin2hex(openssl_random_pseudo_bytes($len)):uniqid(),
					-$len));
				$block=$size*3;
				$tmp=array();
				for ($i=0,$width=0,$height=0;$i<$len;$i++) {
					// Process at 2x magnification
					$box=imagettfbbox($size*2,0,$path,$seed[$i]);
					$w=$box[2]-$box[0];
					$h=$box[1]-$box[5];
					$char=imagecreatetruecolor($block,$block);
					imagefill($char,0,0,$bg);
					imagettftext($char,$size*2,0,
						($block-$w)/2,$block-($block-$h)/2,
						$fg,$path,$seed[$i]);
					$char=imagerotate($char,mt_rand(-30,30),
						imagecolorallocatealpha($char,0,0,0,127));
					// Reduce to normal size
					$tmp[$i]=imagecreatetruecolor(
						($w=imagesx($char))/2,($h=imagesy($char))/2);
					imagefill($tmp[$i],0,0,IMG_COLOR_TRANSPARENT);
					imagecopyresampled($tmp[$i],
						$char,0,0,0,0,$w/2,$h/2,$w,$h);
					imagedestroy($char);
					$width+=$i+1<$len?$block/2:$w/2;
					$height=max($height,$h/2);
				}
				$this->data=imagecreatetruecolor($width,$height);
				imagefill($this->data,0,0,IMG_COLOR_TRANSPARENT);
				for ($i=0;$i<$len;$i++) {
					imagecopy($this->data,$tmp[$i],
						$i*$block/2,($height-imagesy($tmp[$i]))/2,0,0,
						imagesx($tmp[$i]),imagesy($tmp[$i]));
					imagedestroy($tmp[$i]);
				}
				imagesavealpha($this->data,TRUE);
				if ($key)
					$fw->set($key,$seed);
				return $this->save();
			}
		user_error(self::E_Font,E_USER_ERROR);
		return FALSE;
	}

	/**
	*	Return image width
	*	@return int
	**/
	function width() {
		return imagesx($this->data);
	}

	/**
	*	Return image height
	*	@return int
	**/
	function height() {
		return imagesy($this->data);
	}

	/**
	*	Send image to HTTP client
	*	@return NULL
	**/
	function render() {
		$args=func_get_args();
		$format=$args?array_shift($args):'png';
		if (PHP_SAPI!='cli') {
			header('Content-Type: image/'.$format);
			header('X-Powered-By: '.Base::instance()->get('PACKAGE'));
		}
		call_user_func_array('image'.$format,
			array_merge(array($this->data),$args));
	}

	/**
	*	Return image as a string
	*	@return string
	**/
	function dump() {
		$args=func_get_args();
		$format=$args?array_shift($args):'png';
		ob_start();
		call_user_func_array('image'.$format,
			array_merge(array($this->data),$args));
		return ob_get_clean();
	}

	/**
	*	Save current state
	*	@return object
	**/
	function save() {
		$fw=Base::instance();
		if ($this->flag) {
			if (!is_dir($dir=$fw->get('TEMP')))
				mkdir($dir,Base::MODE,TRUE);
			$this->count++;
			$fw->write($dir.'/'.
				$fw->hash($fw->get('ROOT').$fw->get('BASE')).'.'.
				$fw->hash($this->file).'-'.$this->count.'.png',
				$this->dump());
		}
		return $this;
	}

	/**
	*	Revert to specified state
	*	@return object
	*	@param $state int
	**/
	function restore($state=1) {
		$fw=Base::instance();
		if ($this->flag && is_file($file=($path=$fw->get('TEMP').
			$fw->hash($fw->get('ROOT').$fw->get('BASE')).'.'.
			$fw->hash($this->file).'-').$state.'.png')) {
			if (is_resource($this->data))
				imagedestroy($this->data);
			$this->data=imagecreatefromstring($fw->read($file));
			imagesavealpha($this->data,TRUE);
			foreach (glob($path.'*.png',GLOB_NOSORT) as $match)
				if (preg_match('/-(\d+)\.png/',$match,$parts) &&
					$parts[1]>$state)
					@unlink($match);
			$this->count=$state;
		}
		return $this;
	}

	/**
	*	Undo most recently applied filter
	*	@return object
	**/
	function undo() {
		if ($this->flag) {
			if ($this->count)
				$this->count--;
			return $this->restore($this->count);
		}
		return $this;
	}

	/**
	*	Load string
	*	@return object
	*	@param $str string
	**/
	function load($str) {
		$this->data=imagecreatefromstring($str);
		imagesavealpha($this->data,TRUE);
		$this->save();
		return $this;
	}

	/**
	*	Instantiate image
	*	@param $file string
	*	@param $flag bool
	*	@param $path string
	**/
	function __construct($file=NULL,$flag=FALSE,$path=NULL) {
		$this->flag=$flag;
		if ($file) {
			$fw=Base::instance();
			// Create image from file
			$this->file=$file;
			if (!isset($path))
				$path=$fw->get('UI').';./';
			foreach ($fw->split($path,FALSE) as $dir)
				if (is_file($dir.$file))
					return $this->load($fw->read($dir.$file));
			user_error(self::E_File,E_USER_ERROR);
		}
	}

	/**
	*	Wrap-up
	*	@return NULL
	**/
	function __destruct() {
		if (is_resource($this->data)) {
			imagedestroy($this->data);
			$fw=Base::instance();
			$path=$fw->get('TEMP').
				$fw->hash($fw->get('ROOT').$fw->get('BASE')).'.'.
				$fw->hash($this->file);
			if ($glob=@glob($path.'*.png',GLOB_NOSORT))
				foreach ($glob as $match)
					if (preg_match('/-(\d+)\.png/',$match))
						@unlink($match);
		}
	}

}
