From 16a7a3dad539dc69b8c032e6f07b5d8b4383e10f Mon Sep 17 00:00:00 2001 From: Mikael Roos Date: Thu, 29 Jan 2015 21:16:32 +0100 Subject: [PATCH] * Now returns statuscode 500 when something fails #55. * Three different modes: strict, production, development #44. * Three files for all-in-one `imgs.php`, `imgp.php`, `imgd.php` #73. --- CImage.php | 3 + CRemoteImage.php | 19 + README.md | 5 +- create-img-single.bash | 37 +- webroot/img.php | 41 +- webroot/img_config.php | 2 +- .../{img_single_header.php => img_header.php} | 1 + webroot/imgd.php | 3886 +++++++++++++++++ webroot/imgp.php | 3886 +++++++++++++++++ webroot/imgs.php | 64 +- 10 files changed, 7906 insertions(+), 38 deletions(-) rename webroot/{img_single_header.php => img_header.php} (92%) create mode 100644 webroot/imgd.php create mode 100644 webroot/imgp.php diff --git a/CImage.php b/CImage.php index 7951984..5bb47b6 100644 --- a/CImage.php +++ b/CImage.php @@ -476,6 +476,9 @@ class CImage $cache = $this->saveFolder . "/remote/"; if (!is_dir($cache)) { + if (!is_writable($this->saveFolder)) { + throw new Exception("Can not create remote cache, cachefolder not writable."); + } mkdir($cache); $this->log("The remote cache does not exists, creating it."); } diff --git a/CRemoteImage.php b/CRemoteImage.php index ddef1ab..0812933 100644 --- a/CRemoteImage.php +++ b/CRemoteImage.php @@ -124,6 +124,23 @@ class CRemoteImage + /** + * Check if cache is writable or throw exception. + * + * @return $this + * + * @throws Exception if cahce folder is not writable. + */ + public function isCacheWritable() + { + if (!is_writable($this->saveFolder)) { + throw new Exception("Cache folder is not writable for downloaded files."); + } + return $this; + } + + + /** * Decide if the cache should be used or not before trying to download * a remote file. @@ -277,8 +294,10 @@ class CRemoteImage $this->status = $this->http->getStatus(); if ($this->status === 200) { + $this->isCacheWritable(); return $this->save(); } else if ($this->status === 304) { + $this->isCacheWritable(); return $this->updateCacheDetails(); } diff --git a/README.md b/README.md index b011ebc..9bd4f04 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,10 @@ Revision history v0.6.x (latest) -* Change name of script all-in-one to `webrrot/imgs.php` #73. +* Now returns statuscode 500 when something fails #55. +* Three different modes: strict, production, development #44. +* Three files for all-in-one `imgs.php`, `imgp.php`, `imgd.php` #73. +* Change name of script all-in-one to `webroot/imgs.php` #73. * Combine all code into one singel script, `webroot/img_single.php` #73. * Disallow hotlinking/leeching by configuration #46. * Alias-name is without extension #47. diff --git a/create-img-single.bash b/create-img-single.bash index 15632ec..c5ee72c 100755 --- a/create-img-single.bash +++ b/create-img-single.bash @@ -3,7 +3,9 @@ # # Paths and settings # -TARGET="webroot/imgs.php" +TARGET_D="webroot/imgd.php" +TARGET_P="webroot/imgp.php" +TARGET_S="webroot/imgs.php" NEWLINES="\n\n\n" @@ -29,36 +31,43 @@ fi # # Print out details on cache-directory # -$ECHO "Creating '$TARGET' by combining the following files:" +$ECHO "Creating '$TARGET_D', '$TARGET_P' and '$TARGET_S' by combining the following files:" $ECHO "\n" -$ECHO "\n webroot/img_single_header.php" +$ECHO "\n webroot/img_header.php" $ECHO "\n CHttpGet.php" $ECHO "\n CRemoteImage.php" $ECHO "\n CImage.php" $ECHO "\n webroot/img.php" $ECHO "\n" +$ECHO "\n'$TARGET_D' is for development mode." +$ECHO "\n'$TARGET_P' is for production mode (default mode)." +$ECHO "\n'$TARGET_S' is for strict mode." +$ECHO "\n" $ECHO "\nPress enter to continue. " read answer # -# Create the $TARGET file +# Create the $TARGET_? files # -cat webroot/img_single_header.php > $TARGET -$ECHO "$NEWLINES" >> $TARGET +cat webroot/img_header.php > $TARGET_P +cat webroot/img_header.php | sed "s|//'mode' => 'production'|'mode' => 'development'|" > $TARGET_D +cat webroot/img_header.php | sed "s|//'mode' => 'production'|'mode' => 'strict'|" > $TARGET_S -tail -n +2 CHttpGet.php >> $TARGET -$ECHO "$NEWLINES" >> $TARGET +$ECHO "$NEWLINES" | tee -a $TARGET_D $TARGET_P $TARGET_S > /dev/null -tail -n +2 CRemoteImage.php >> $TARGET -$ECHO "$NEWLINES" >> $TARGET +tail -n +2 CHttpGet.php | tee -a $TARGET_D $TARGET_P $TARGET_S > /dev/null +$ECHO "$NEWLINES" | tee -a $TARGET_D $TARGET_P $TARGET_S > /dev/null -tail -n +2 CImage.php >> $TARGET -$ECHO "$NEWLINES" >> $TARGET +tail -n +2 CRemoteImage.php | tee -a $TARGET_D $TARGET_P $TARGET_S > /dev/null +$ECHO "$NEWLINES" | tee -a $TARGET_D $TARGET_P $TARGET_S > /dev/null -tail -n +2 webroot/img.php >> $TARGET -$ECHO "$NEWLINES" >> $TARGET +tail -n +2 CImage.php | tee -a $TARGET_D $TARGET_P $TARGET_S > /dev/null +$ECHO "$NEWLINES" | tee -a $TARGET_D $TARGET_P $TARGET_S > /dev/null + +tail -n +2 webroot/img.php | tee -a $TARGET_D $TARGET_P $TARGET_S > /dev/null +$ECHO "$NEWLINES" | tee -a $TARGET_D $TARGET_P $TARGET_S > /dev/null $ECHO "\nDone." $ECHO "\n" diff --git a/webroot/img.php b/webroot/img.php index 9ee3ef0..b7dbf17 100644 --- a/webroot/img.php +++ b/webroot/img.php @@ -19,8 +19,16 @@ */ function errorPage($msg) { - header("HTTP/1.0 404 Not Found"); - die('img.php say 404: ' . $msg); + global $mode; + + header("HTTP/1.0 500 Internal Server Error"); + + if ($mode == 'development') { + die("[img.php] $msg"); + } else { + error_log("[img.php] $msg"); + die("HTTP/1.0 500 Internal Server Error"); + } } @@ -131,6 +139,13 @@ if (is_file($configFile)) { +/** +* verbose, v - do a verbose dump of what happens +*/ +$verbose = getDefined(array('verbose', 'v'), true, false); + + + /** * Set mode as strict, production or development. * Default is production environment. @@ -147,21 +162,32 @@ if (!extension_loaded('gd')) { // Specific settings for each mode if ($mode == 'strict') { + error_reporting(0); ini_set('display_errors', 0); + ini_set('log_errors', 1); + $verbose = false; } else if ($mode == 'production') { - error_reporting(0); + + error_reporting(-1); ini_set('display_errors', 0); + ini_set('log_errors', 1); + $verbose = false; } else if ($mode == 'development') { + error_reporting(-1); -ini_set('display_errors', 1); + ini_set('display_errors', 1); + ini_set('log_errors', 0); } else { errorPage("Unknown mode: $mode"); } +verbose("mode = $mode"); +verbose("error log = " . ini_get('error_log')); + /** @@ -177,13 +203,6 @@ if ($defaultTimezone) { -/** - * verbose, v - do a verbose dump of what happens - */ -$verbose = getDefined(array('verbose', 'v'), true, false); - - - /** * Check if passwords are configured, used and match. * Options decide themself if they require passwords to be used. diff --git a/webroot/img_config.php b/webroot/img_config.php index 08d4ad5..8780204 100644 --- a/webroot/img_config.php +++ b/webroot/img_config.php @@ -13,7 +13,7 @@ return array( * Default values: * mode: 'production' */ - //'mode' => 'production', // 'development', + //'mode' => 'production', // 'development', 'strict' diff --git a/webroot/img_single_header.php b/webroot/img_header.php similarity index 92% rename from webroot/img_single_header.php rename to webroot/img_header.php index ed9ecdd..2643dde 100644 --- a/webroot/img_single_header.php +++ b/webroot/img_header.php @@ -19,6 +19,7 @@ */ $config = array( + //'mode' => 'production', // 'production', 'development', 'strict' //'image_path' => __DIR__ . '/img/', //'cache_path' => __DIR__ . '/../cache/', //'alias_path' => __DIR__ . '/img/alias/', diff --git a/webroot/imgd.php b/webroot/imgd.php new file mode 100644 index 0000000..3469a77 --- /dev/null +++ b/webroot/imgd.php @@ -0,0 +1,3886 @@ + 'development', // 'production', 'development', 'strict' + //'image_path' => __DIR__ . '/img/', + //'cache_path' => __DIR__ . '/../cache/', + //'alias_path' => __DIR__ . '/img/alias/', + //'remote_allow' => true, + //'password' => false, // "secret-password", + +); + + + +/** + * Get a image from a remote server using HTTP GET and If-Modified-Since. + * + */ +class CHttpGet +{ + private $request = array(); + private $response = array(); + + + + /** + * Constructor + * + */ + public function __construct() + { + $this->request['header'] = array(); + } + + + + /** + * Set the url for the request. + * + * @param string $url + * + * @return $this + */ + public function setUrl($url) + { + $this->request['url'] = $url; + return $this; + } + + + + /** + * Set custom header field for the request. + * + * @param string $field + * @param string $value + * + * @return $this + */ + public function setHeader($field, $value) + { + $this->request['header'][] = "$field: $value"; + return $this; + } + + + + /** + * Set header fields for the request. + * + * @param string $field + * @param string $value + * + * @return $this + */ + public function parseHeader() + { + $header = explode("\r\n", rtrim($this->response['headerRaw'], "\r\n")); + $output = array(); + + if ('HTTP' === substr($header[0], 0, 4)) { + list($output['version'], $output['status']) = explode(' ', $header[0]); + unset($header[0]); + } + + foreach ($header as $entry) { + $pos = strpos($entry, ':'); + $output[trim(substr($entry, 0, $pos))] = trim(substr($entry, $pos + 1)); + } + + $this->response['header'] = $output; + return $this; + } + + + + /** + * Perform the request. + * + * @param boolean $debug set to true to dump headers. + * + * @return boolean + */ + public function doGet($debug = false) + { + $options = array( + CURLOPT_URL => $this->request['url'], + CURLOPT_HEADER => 1, + CURLOPT_HTTPHEADER => $this->request['header'], + CURLOPT_AUTOREFERER => true, + CURLOPT_RETURNTRANSFER => true, + CURLINFO_HEADER_OUT => $debug, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 5, + ); + + $ch = curl_init(); + curl_setopt_array($ch, $options); + $response = curl_exec($ch); + + if (!$response) { + return false; + } + + $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $this->response['headerRaw'] = substr($response, 0, $headerSize); + $this->response['body'] = substr($response, $headerSize); + + $this->parseHeader(); + + if ($debug) { + $info = curl_getinfo($ch); + echo "Request header
", var_dump($info['request_header']), "
"; + echo "Response header (raw)
", var_dump($this->response['headerRaw']), "
"; + echo "Response header (parsed)
", var_dump($this->response['header']), "
"; + } + + curl_close($ch); + return true; + } + + + + /** + * Get HTTP code of response. + * + * @return integer as HTTP status code or null if not available. + */ + public function getStatus() + { + return isset($this->response['header']['status']) + ? (int) $this->response['header']['status'] + : null; + } + + + + /** + * Get file modification time of response. + * + * @return int as timestamp. + */ + public function getLastModified() + { + return isset($this->response['header']['Last-Modified']) + ? strtotime($this->response['header']['Last-Modified']) + : null; + } + + + + /** + * Get content type. + * + * @return string as the content type or null if not existing or invalid. + */ + public function getContentType() + { + $type = isset($this->response['header']['Content-Type']) + ? $this->response['header']['Content-Type'] + : null; + + return preg_match('#[a-z]+/[a-z]+#', $type) + ? $type + : null; + } + + + + /** + * Get file modification time of response. + * + * @param mixed $default as default value (int seconds) if date is + * missing in response header. + * + * @return int as timestamp or $default if Date is missing in + * response header. + */ + public function getDate($default = false) + { + return isset($this->response['header']['Date']) + ? strtotime($this->response['header']['Date']) + : $default; + } + + + + /** + * Get max age of cachable item. + * + * @param mixed $default as default value if date is missing in response + * header. + * + * @return int as timestamp or false if not available. + */ + public function getMaxAge($default = false) + { + $cacheControl = isset($this->response['header']['Cache-Control']) + ? $this->response['header']['Cache-Control'] + : null; + + $maxAge = null; + if ($cacheControl) { + // max-age=2592000 + $part = explode('=', $cacheControl); + $maxAge = ($part[0] == "max-age") + ? (int) $part[1] + : null; + } + + if ($maxAge) { + return $maxAge; + } + + $expire = isset($this->response['header']['Expires']) + ? strtotime($this->response['header']['Expires']) + : null; + + return $expire ? $expire : $default; + } + + + + /** + * Get body of response. + * + * @return string as body. + */ + public function getBody() + { + return $this->response['body']; + } +} + + + +/** + * Get a image from a remote server using HTTP GET and If-Modified-Since. + * + */ +class CRemoteImage +{ + /** + * Path to cache files. + */ + private $saveFolder = null; + + + + /** + * Use cache or not. + */ + private $useCache = true; + + + + /** + * HTTP object to aid in download file. + */ + private $http; + + + + /** + * Status of the HTTP request. + */ + private $status; + + + + /** + * Defalt age for cached items 60*60*24*7. + */ + private $defaultMaxAge = 604800; + + + + /** + * Url of downloaded item. + */ + private $url; + + + + /** + * Base name of cache file for downloaded item. + */ + private $fileName; + + + + /** + * Filename for json-file with details of cached item. + */ + private $fileJson; + + + + /** + * Filename for image-file. + */ + private $fileImage; + + + + /** + * Cache details loaded from file. + */ + private $cache; + + + + /** + * Constructor + * + */ + public function __construct() + { + ; + } + + + /** + * Get status of last HTTP request. + * + * @return int as status + */ + public function getStatus() + { + return $this->status; + } + + + + /** + * Get JSON details for cache item. + * + * @return array with json details on cache. + */ + public function getDetails() + { + return $this->cache; + } + + + + /** + * Set the path to the cache directory. + * + * @param boolean $use true to use the cache and false to ignore cache. + * + * @return $this + */ + public function setCache($path) + { + $this->saveFolder = $path; + return $this; + } + + + + /** + * Check if cache is writable or throw exception. + * + * @return $this + * + * @throws Exception if cahce folder is not writable. + */ + public function isCacheWritable() + { + if (!is_writable($this->saveFolder)) { + throw new Exception("Cache folder is not writable for downloaded files."); + } + return $this; + } + + + + /** + * Decide if the cache should be used or not before trying to download + * a remote file. + * + * @param boolean $use true to use the cache and false to ignore cache. + * + * @return $this + */ + public function useCache($use = true) + { + $this->useCache = $use; + return $this; + } + + + + /** + * Translate a content type to a file extension. + * + * @param string $type a valid content type. + * + * @return string as file extension or false if no match. + */ + function contentTypeToFileExtension($type) { + $extension = array( + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + ); + + return isset($extension[$type]) + ? $extension[$type] + : false; + } + + + + /** + * Set header fields. + * + * @return $this + */ + function setHeaderFields() { + $this->http->setHeader("User-Agent", "CImage/0.6 (PHP/". phpversion() . " cURL)"); + $this->http->setHeader("Accept", "image/jpeg,image/png,image/gif"); + + if ($this->useCache) { + $this->http->setHeader("Cache-Control", "max-age=0"); + } else { + $this->http->setHeader("Cache-Control", "no-cache"); + $this->http->setHeader("Pragma", "no-cache"); + } + } + + + + /** + * Save downloaded resource to cache. + * + * @return string as path to saved file or false if not saved. + */ + function save() { + + $this->cache = array(); + $date = $this->http->getDate(time()); + $maxAge = $this->http->getMaxAge($this->defaultMaxAge); + $lastModified = $this->http->getLastModified(); + $type = $this->http->getContentType(); + $extension = $this->contentTypeToFileExtension($type); + + $this->cache['Date'] = gmdate("D, d M Y H:i:s T", $date); + $this->cache['Max-Age'] = $maxAge; + $this->cache['Content-Type'] = $type; + $this->cache['File-Extension'] = $extension; + + if ($lastModified) { + $this->cache['Last-Modified'] = gmdate("D, d M Y H:i:s T", $lastModified); + } + + if ($extension) { + + $this->fileImage = $this->fileName . "." . $extension; + + // Save only if body is a valid image + $body = $this->http->getBody(); + $img = imagecreatefromstring($body); + + if ($img !== false) { + file_put_contents($this->fileImage, $body); + file_put_contents($this->fileJson, json_encode($this->cache)); + return $this->fileImage; + } + } + + return false; + } + + + + /** + * Got a 304 and updates cache with new age. + * + * @return string as path to cached file. + */ + function updateCacheDetails() { + + $date = $this->http->getDate(time()); + $maxAge = $this->http->getMaxAge($this->defaultMaxAge); + $lastModified = $this->http->getLastModified(); + + $this->cache['Date'] = gmdate("D, d M Y H:i:s T", $date); + $this->cache['Max-Age'] = $maxAge; + + if ($lastModified) { + $this->cache['Last-Modified'] = gmdate("D, d M Y H:i:s T", $lastModified); + } + + file_put_contents($this->fileJson, json_encode($this->cache)); + return $this->fileImage; + } + + + + /** + * Download a remote file and keep a cache of downloaded files. + * + * @param string $url a remote url. + * + * @return string as path to downloaded file or false if failed. + */ + function download($url) { + + $this->http = new CHttpGet(); + $this->url = $url; + + // First check if the cache is valid and can be used + $this->loadCacheDetails(); + + if ($this->useCache) { + $src = $this->getCachedSource(); + if ($src) { + $this->status = 1; + return $src; + } + } + + // Do a HTTP request to download item + $this->setHeaderFields(); + $this->http->setUrl($this->url); + $this->http->doGet(); + + $this->status = $this->http->getStatus(); + if ($this->status === 200) { + $this->isCacheWritable(); + return $this->save(); + } else if ($this->status === 304) { + $this->isCacheWritable(); + return $this->updateCacheDetails(); + } + + return false; + } + + + + /** + * Get the path to the cached image file if the cache is valid. + * + * @return $this + */ + public function loadCacheDetails() + { + $cacheFile = str_replace(["/", ":", "#", ".", "?"], "-", $this->url); + $this->fileName = $this->saveFolder . $cacheFile; + $this->fileJson = $this->fileName . ".json"; + if (is_readable($this->fileJson)) { + $this->cache = json_decode(file_get_contents($this->fileJson), true); + } + } + + + + /** + * Get the path to the cached image file if the cache is valid. + * + * @return string as the path ot the image file or false if no cache. + */ + public function getCachedSource() + { + $this->fileImage = $this->fileName . "." . $this->cache['File-Extension']; + $imageExists = is_readable($this->fileImage); + + // Is cache valid? + $date = strtotime($this->cache['Date']); + $maxAge = $this->cache['Max-Age']; + $now = time(); + if ($imageExists && $date + $maxAge > $now) { + return $this->fileImage; + } + + // Prepare for a 304 if available + if ($imageExists && isset($this->cache['Last-Modified'])) { + $this->http->setHeader("If-Modified-Since", $this->cache['Last-Modified']); + } + + return false; + } +} + + + +/** + * Resize and crop images on the fly, store generated images in a cache. + * + * @author Mikael Roos mos@dbwebb.se + * @example http://dbwebb.se/opensource/cimage + * @link https://github.com/mosbth/cimage + */ +class CImage +{ + + /** + * Constants type of PNG image + */ + const PNG_GREYSCALE = 0; + const PNG_RGB = 2; + const PNG_RGB_PALETTE = 3; + const PNG_GREYSCALE_ALPHA = 4; + const PNG_RGB_ALPHA = 6; + + + + /** + * Constant for default image quality when not set + */ + const JPEG_QUALITY_DEFAULT = 60; + + + + /** + * Quality level for JPEG images. + */ + private $quality; + + + + /** + * Is the quality level set from external use (true) or is it default (false)? + */ + private $useQuality = false; + + + + /** + * Constant for default image quality when not set + */ + const PNG_COMPRESSION_DEFAULT = -1; + + + + /** + * Compression level for PNG images. + */ + private $compress; + + + + /** + * Is the compress level set from external use (true) or is it default (false)? + */ + private $useCompress = false; + + + + + /** + * Default background color, red, green, blue, alpha. + * + * @todo remake when upgrading to PHP 5.5 + */ + /* + const BACKGROUND_COLOR = array( + 'red' => 0, + 'green' => 0, + 'blue' => 0, + 'alpha' => null, + );*/ + + + + /** + * Default background color to use. + * + * @todo remake when upgrading to PHP 5.5 + */ + //private $bgColorDefault = self::BACKGROUND_COLOR; + private $bgColorDefault = array( + 'red' => 0, + 'green' => 0, + 'blue' => 0, + 'alpha' => null, + ); + + + /** + * Background color to use, specified as part of options. + */ + private $bgColor; + + + + /** + * Where to save the target file. + */ + private $saveFolder; + + + + /** + * The working image object. + */ + private $image; + + + + /** + * The root folder of images (only used in constructor to create $pathToImage?). + */ + private $imageFolder; + + + + /** + * Image filename, may include subdirectory, relative from $imageFolder + */ + private $imageSrc; + + + + /** + * Actual path to the image, $imageFolder . '/' . $imageSrc + */ + private $pathToImage; + + + + /** + * Original file extension + */ + private $fileExtension; + + + + /** + * File extension to use when saving image. + */ + private $extension; + + + + /** + * Output format, supports null (image) or json. + */ + private $outputFormat = null; + + + + /** + * Verbose mode to print out a trace and display the created image + */ + private $verbose = false; + + + + /** + * Keep a log/trace on what happens + */ + private $log = array(); + + + + /** + * Handle image as palette image + */ + private $palette; + + + + /** + * Target filename, with path, to save resulting image in. + */ + private $cacheFileName; + + + + /** + * Set a format to save image as, or null to use original format. + */ + private $saveAs; + + + /** + * Path to command for filter optimize, for example optipng or null. + */ + private $pngFilter; + + + + /** + * Path to command for deflate optimize, for example pngout or null. + */ + private $pngDeflate; + + + + /** + * Path to command to optimize jpeg images, for example jpegtran or null. + */ + private $jpegOptimize; + + + /** + * Image dimensions, calculated from loaded image. + */ + private $width; // Calculated from source image + private $height; // Calculated from source image + + + /** + * New image dimensions, incoming as argument or calculated. + */ + private $newWidth; + private $newWidthOrig; // Save original value + private $newHeight; + private $newHeightOrig; // Save original value + + + /** + * Change target height & width when different dpr, dpr 2 means double image dimensions. + */ + private $dpr = 1; + + + /** + * Always upscale images, even if they are smaller than target image. + */ + const UPSCALE_DEFAULT = true; + private $upscale = self::UPSCALE_DEFAULT; + + + + /** + * Array with details on how to crop, incoming as argument and calculated. + */ + public $crop; + public $cropOrig; // Save original value + + + /** + * String with details on how to do image convolution. String + * should map a key in the $convolvs array or be a string of + * 11 float values separated by comma. The first nine builds + * up the matrix, then divisor and last offset. + */ + private $convolve; + + + /** + * Custom convolution expressions, matrix 3x3, divisor and offset. + */ + private $convolves = array( + 'lighten' => '0,0,0, 0,12,0, 0,0,0, 9, 0', + 'darken' => '0,0,0, 0,6,0, 0,0,0, 9, 0', + 'sharpen' => '-1,-1,-1, -1,16,-1, -1,-1,-1, 8, 0', + 'sharpen-alt' => '0,-1,0, -1,5,-1, 0,-1,0, 1, 0', + 'emboss' => '1,1,-1, 1,3,-1, 1,-1,-1, 3, 0', + 'emboss-alt' => '-2,-1,0, -1,1,1, 0,1,2, 1, 0', + 'blur' => '1,1,1, 1,15,1, 1,1,1, 23, 0', + 'gblur' => '1,2,1, 2,4,2, 1,2,1, 16, 0', + 'edge' => '-1,-1,-1, -1,8,-1, -1,-1,-1, 9, 0', + 'edge-alt' => '0,1,0, 1,-4,1, 0,1,0, 1, 0', + 'draw' => '0,-1,0, -1,5,-1, 0,-1,0, 0, 0', + 'mean' => '1,1,1, 1,1,1, 1,1,1, 9, 0', + 'motion' => '1,0,0, 0,1,0, 0,0,1, 3, 0', + ); + + + /** + * Resize strategy to fill extra area with background color. + * True or false. + */ + private $fillToFit; + + + /** + * Used with option area to set which parts of the image to use. + */ + private $offset; + + + + /** + * Calculate target dimension for image when using fill-to-fit resize strategy. + */ + private $fillWidth; + private $fillHeight; + + + + /** + * Allow remote file download, default is to disallow remote file download. + */ + private $allowRemote = false; + + + + /** + * Pattern to recognize a remote file. + */ + //private $remotePattern = '#^[http|https]://#'; + private $remotePattern = '#^https?://#'; + + + + /** + * Use the cache if true, set to false to ignore the cached file. + */ + private $useCache = true; + + + /** + * Properties, the class is mutable and the method setOptions() + * decides (partly) what properties are created. + * + * @todo Clean up these and check if and how they are used + */ + + public $keepRatio; + public $cropToFit; + private $cropWidth; + private $cropHeight; + public $crop_x; + public $crop_y; + public $filters; + private $type; // Calculated from source image + private $attr; // Calculated from source image + private $useOriginal; // Use original image if possible + + + + + /** + * Constructor, can take arguments to init the object. + * + * @param string $imageSrc filename which may contain subdirectory. + * @param string $imageFolder path to root folder for images. + * @param string $saveFolder path to folder where to save the new file or null to skip saving. + * @param string $saveName name of target file when saveing. + */ + public function __construct($imageSrc = null, $imageFolder = null, $saveFolder = null, $saveName = null) + { + $this->setSource($imageSrc, $imageFolder); + $this->setTarget($saveFolder, $saveName); + } + + + + /** + * Set verbose mode. + * + * @param boolean $mode true or false to enable and disable verbose mode, + * default is true. + * + * @return $this + */ + public function setVerbose($mode = true) + { + $this->verbose = $mode; + return $this; + } + + + + /** + * Set save folder, base folder for saving cache files. + * + * @todo clean up how $this->saveFolder is used in other methods. + * + * @param string $path where to store cached files. + * + * @return $this + */ + public function setSaveFolder($path) + { + $this->saveFolder = $path; + return $this; + } + + + + /** + * Use cache or not. + * + * @todo clean up how $this->noCache is used in other methods. + * + * @param string $use true or false to use cache. + * + * @return $this + */ + public function useCache($use = true) + { + $this->useCache = $use; + return $this; + } + + + + /** + * Allow or disallow remote image download. + * + * @param boolean $allow true or false to enable and disable. + * @param string $pattern to use to detect if its a remote file. + * + * @return $this + */ + public function setRemoteDownload($allow, $pattern = null) + { + $this->allowRemote = $allow; + $this->remotePattern = $pattern ? $pattern : $this->remotePattern; + + $this->log("Set remote download to: " + . ($this->allowRemote ? "true" : "false") + . " using pattern " + . $this->remotePattern); + + return $this; + } + + + + /** + * Check if the image resource is a remote file or not. + * + * @param string $src check if src is remote. + * + * @return boolean true if $src is a remote file, else false. + */ + public function isRemoteSource($src) + { + $remote = preg_match($this->remotePattern, $src); + $this->log("Detected remote image: " . ($remote ? "true" : "false")); + return $remote; + } + + + + /** + * Check if file extension is valid as a file extension. + * + * @param string $extension of image file. + * + * @return $this + */ + private function checkFileExtension($extension) + { + $valid = array('jpg', 'jpeg', 'png', 'gif'); + + in_array(strtolower($extension), $valid) + or $this->raiseError('Not a valid file extension.'); + + return $this; + } + + + + /** + * Download a remote image and return path to its local copy. + * + * @param string $src remote path to image. + * + * @return string as path to downloaded remote source. + */ + public function downloadRemoteSource($src) + { + $remote = new CRemoteImage(); + $cache = $this->saveFolder . "/remote/"; + + if (!is_dir($cache)) { + if (!is_writable($this->saveFolder)) { + throw new Exception("Can not create remote cache, cachefolder not writable."); + } + mkdir($cache); + $this->log("The remote cache does not exists, creating it."); + } + + if (!is_writable($cache)) { + $this->log("The remote cache is not writable."); + } + + $remote->setCache($cache); + $remote->useCache($this->useCache); + $src = $remote->download($src); + + $this->log("Remote HTTP status: " . $remote->getStatus()); + $this->log("Remote item has local cached file: $src"); + $this->log("Remote details on cache:" . print_r($remote->getDetails(), true)); + + return $src; + } + + + + /** + * Set src file. + * + * @param string $src of image. + * @param string $dir as base directory where images are. + * + * @return $this + */ + public function setSource($src, $dir = null) + { + if (!isset($src)) { + return $this; + } + + if ($this->allowRemote && $this->isRemoteSource($src)) { + $src = $this->downloadRemoteSource($src); + $dir = null; + } + + if (!isset($dir)) { + $dir = dirname($src); + $src = basename($src); + } + + $this->imageSrc = ltrim($src, '/'); + $this->imageFolder = rtrim($dir, '/'); + $this->pathToImage = $this->imageFolder . '/' . $this->imageSrc; + $this->fileExtension = strtolower(pathinfo($this->pathToImage, PATHINFO_EXTENSION)); + //$this->extension = $this->fileExtension; + + $this->checkFileExtension($this->fileExtension); + + return $this; + } + + + + /** + * Set target file. + * + * @param string $src of target image. + * @param string $dir as base directory where images are stored. + * + * @return $this + */ + public function setTarget($src = null, $dir = null) + { + if (!(isset($src) && isset($dir))) { + return $this; + } + + $this->saveFolder = $dir; + $this->cacheFileName = $dir . '/' . $src; + + /* Allow readonly cache + is_writable($this->saveFolder) + or $this->raiseError('Target directory is not writable.'); + */ + + // Sanitize filename + $this->cacheFileName = preg_replace('/^a-zA-Z0-9\.-_/', '', $this->cacheFileName); + $this->log("The cache file name is: " . $this->cacheFileName); + + return $this; + } + + + + /** + * Set options to use when processing image. + * + * @param array $args used when processing image. + * + * @return $this + */ + public function setOptions($args) + { + $this->log("Set new options for processing image."); + + $defaults = array( + // Options for calculate dimensions + 'newWidth' => null, + 'newHeight' => null, + 'aspectRatio' => null, + 'keepRatio' => true, + 'cropToFit' => false, + 'fillToFit' => null, + 'crop' => null, //array('width'=>null, 'height'=>null, 'start_x'=>0, 'start_y'=>0), + 'area' => null, //'0,0,0,0', + 'upscale' => self::UPSCALE_DEFAULT, + + // Options for caching or using original + 'useCache' => true, + 'useOriginal' => true, + + // Pre-processing, before resizing is done + 'scale' => null, + 'rotateBefore' => null, + 'autoRotate' => false, + + // General options + 'bgColor' => null, + + // Post-processing, after resizing is done + 'palette' => null, + 'filters' => null, + 'sharpen' => null, + 'emboss' => null, + 'blur' => null, + 'convolve' => null, + 'rotateAfter' => null, + + // Output format + 'outputFormat' => null, + 'dpr' => 1, + + // Options for saving + //'quality' => null, + //'compress' => null, + //'saveAs' => null, + ); + + // Convert crop settings from string to array + if (isset($args['crop']) && !is_array($args['crop'])) { + $pices = explode(',', $args['crop']); + $args['crop'] = array( + 'width' => $pices[0], + 'height' => $pices[1], + 'start_x' => $pices[2], + 'start_y' => $pices[3], + ); + } + + // Convert area settings from string to array + if (isset($args['area']) && !is_array($args['area'])) { + $pices = explode(',', $args['area']); + $args['area'] = array( + 'top' => $pices[0], + 'right' => $pices[1], + 'bottom' => $pices[2], + 'left' => $pices[3], + ); + } + + // Convert filter settings from array of string to array of array + if (isset($args['filters']) && is_array($args['filters'])) { + foreach ($args['filters'] as $key => $filterStr) { + $parts = explode(',', $filterStr); + $filter = $this->mapFilter($parts[0]); + $filter['str'] = $filterStr; + for ($i=1; $i<=$filter['argc']; $i++) { + if (isset($parts[$i])) { + $filter["arg{$i}"] = $parts[$i]; + } else { + throw new Exception( + 'Missing arg to filter, review how many arguments are needed at + http://php.net/manual/en/function.imagefilter.php' + ); + } + } + $args['filters'][$key] = $filter; + } + } + + // Merge default arguments with incoming and set properties. + //$args = array_merge_recursive($defaults, $args); + $args = array_merge($defaults, $args); + foreach ($defaults as $key => $val) { + $this->{$key} = $args[$key]; + } + + if ($this->bgColor) { + $this->setDefaultBackgroundColor($this->bgColor); + } + + // Save original values to enable re-calculating + $this->newWidthOrig = $this->newWidth; + $this->newHeightOrig = $this->newHeight; + $this->cropOrig = $this->crop; + + return $this; + } + + + + /** + * Map filter name to PHP filter and id. + * + * @param string $name the name of the filter. + * + * @return array with filter settings + * @throws Exception + */ + private function mapFilter($name) + { + $map = array( + 'negate' => array('id'=>0, 'argc'=>0, 'type'=>IMG_FILTER_NEGATE), + 'grayscale' => array('id'=>1, 'argc'=>0, 'type'=>IMG_FILTER_GRAYSCALE), + 'brightness' => array('id'=>2, 'argc'=>1, 'type'=>IMG_FILTER_BRIGHTNESS), + 'contrast' => array('id'=>3, 'argc'=>1, 'type'=>IMG_FILTER_CONTRAST), + 'colorize' => array('id'=>4, 'argc'=>4, 'type'=>IMG_FILTER_COLORIZE), + 'edgedetect' => array('id'=>5, 'argc'=>0, 'type'=>IMG_FILTER_EDGEDETECT), + 'emboss' => array('id'=>6, 'argc'=>0, 'type'=>IMG_FILTER_EMBOSS), + 'gaussian_blur' => array('id'=>7, 'argc'=>0, 'type'=>IMG_FILTER_GAUSSIAN_BLUR), + 'selective_blur' => array('id'=>8, 'argc'=>0, 'type'=>IMG_FILTER_SELECTIVE_BLUR), + 'mean_removal' => array('id'=>9, 'argc'=>0, 'type'=>IMG_FILTER_MEAN_REMOVAL), + 'smooth' => array('id'=>10, 'argc'=>1, 'type'=>IMG_FILTER_SMOOTH), + 'pixelate' => array('id'=>11, 'argc'=>2, 'type'=>IMG_FILTER_PIXELATE), + ); + + if (isset($map[$name])) { + return $map[$name]; + } else { + throw new Exception('No such filter.'); + } + } + + + + /** + * Load image details from original image file. + * + * @param string $file the file to load or null to use $this->pathToImage. + * + * @return $this + * @throws Exception + */ + public function loadImageDetails($file = null) + { + $file = $file ? $file : $this->pathToImage; + + is_readable($file) + or $this->raiseError('Image file does not exist.'); + + // Get details on image + $info = list($this->width, $this->height, $this->type, $this->attr) = getimagesize($file); + !empty($info) or $this->raiseError("The file doesn't seem to be an image."); + + if ($this->verbose) { + $this->log("Image file: {$file}"); + $this->log("Image width x height (type): {$this->width} x {$this->height} ({$this->type})."); + $this->log("Image filesize: " . filesize($file) . " bytes."); + } + + return $this; + } + + + + /** + * Init new width and height and do some sanity checks on constraints, before any + * processing can be done. + * + * @return $this + * @throws Exception + */ + public function initDimensions() + { + $this->log("Init dimension (before) newWidth x newHeight is {$this->newWidth} x {$this->newHeight}."); + + // width as % + if ($this->newWidth[strlen($this->newWidth)-1] == '%') { + $this->newWidth = $this->width * substr($this->newWidth, 0, -1) / 100; + $this->log("Setting new width based on % to {$this->newWidth}"); + } + + // height as % + if ($this->newHeight[strlen($this->newHeight)-1] == '%') { + $this->newHeight = $this->height * substr($this->newHeight, 0, -1) / 100; + $this->log("Setting new height based on % to {$this->newHeight}"); + } + + is_null($this->aspectRatio) or is_numeric($this->aspectRatio) or $this->raiseError('Aspect ratio out of range'); + + // width & height from aspect ratio + if ($this->aspectRatio && is_null($this->newWidth) && is_null($this->newHeight)) { + if ($this->aspectRatio >= 1) { + $this->newWidth = $this->width; + $this->newHeight = $this->width / $this->aspectRatio; + $this->log("Setting new width & height based on width & aspect ratio (>=1) to (w x h) {$this->newWidth} x {$this->newHeight}"); + + } else { + $this->newHeight = $this->height; + $this->newWidth = $this->height * $this->aspectRatio; + $this->log("Setting new width & height based on width & aspect ratio (<1) to (w x h) {$this->newWidth} x {$this->newHeight}"); + } + + } elseif ($this->aspectRatio && is_null($this->newWidth)) { + $this->newWidth = $this->newHeight * $this->aspectRatio; + $this->log("Setting new width based on aspect ratio to {$this->newWidth}"); + + } elseif ($this->aspectRatio && is_null($this->newHeight)) { + $this->newHeight = $this->newWidth / $this->aspectRatio; + $this->log("Setting new height based on aspect ratio to {$this->newHeight}"); + } + + // Change width & height based on dpr + if ($this->dpr != 1) { + if (!is_null($this->newWidth)) { + $this->newWidth = round($this->newWidth * $this->dpr); + $this->log("Setting new width based on dpr={$this->dpr} - w={$this->newWidth}"); + } + if (!is_null($this->newHeight)) { + $this->newHeight = round($this->newHeight * $this->dpr); + $this->log("Setting new height based on dpr={$this->dpr} - h={$this->newHeight}"); + } + } + + // Check values to be within domain + is_null($this->newWidth) + or is_numeric($this->newWidth) + or $this->raiseError('Width not numeric'); + + is_null($this->newHeight) + or is_numeric($this->newHeight) + or $this->raiseError('Height not numeric'); + + $this->log("Init dimension (after) newWidth x newHeight is {$this->newWidth} x {$this->newHeight}."); + + return $this; + } + + + + /** + * Calculate new width and height of image, based on settings. + * + * @return $this + */ + public function calculateNewWidthAndHeight() + { + // Crop, use cropped width and height as base for calulations + $this->log("Calculate new width and height."); + $this->log("Original width x height is {$this->width} x {$this->height}."); + $this->log("Target dimension (before calculating) newWidth x newHeight is {$this->newWidth} x {$this->newHeight}."); + + // Check if there is an area to crop off + if (isset($this->area)) { + $this->offset['top'] = round($this->area['top'] / 100 * $this->height); + $this->offset['right'] = round($this->area['right'] / 100 * $this->width); + $this->offset['bottom'] = round($this->area['bottom'] / 100 * $this->height); + $this->offset['left'] = round($this->area['left'] / 100 * $this->width); + $this->offset['width'] = $this->width - $this->offset['left'] - $this->offset['right']; + $this->offset['height'] = $this->height - $this->offset['top'] - $this->offset['bottom']; + $this->width = $this->offset['width']; + $this->height = $this->offset['height']; + $this->log("The offset for the area to use is top {$this->area['top']}%, right {$this->area['right']}%, bottom {$this->area['bottom']}%, left {$this->area['left']}%."); + $this->log("The offset for the area to use is top {$this->offset['top']}px, right {$this->offset['right']}px, bottom {$this->offset['bottom']}px, left {$this->offset['left']}px, width {$this->offset['width']}px, height {$this->offset['height']}px."); + } + + $width = $this->width; + $height = $this->height; + + // Check if crop is set + if ($this->crop) { + $width = $this->crop['width'] = $this->crop['width'] <= 0 ? $this->width + $this->crop['width'] : $this->crop['width']; + $height = $this->crop['height'] = $this->crop['height'] <= 0 ? $this->height + $this->crop['height'] : $this->crop['height']; + + if ($this->crop['start_x'] == 'left') { + $this->crop['start_x'] = 0; + } elseif ($this->crop['start_x'] == 'right') { + $this->crop['start_x'] = $this->width - $width; + } elseif ($this->crop['start_x'] == 'center') { + $this->crop['start_x'] = round($this->width / 2) - round($width / 2); + } + + if ($this->crop['start_y'] == 'top') { + $this->crop['start_y'] = 0; + } elseif ($this->crop['start_y'] == 'bottom') { + $this->crop['start_y'] = $this->height - $height; + } elseif ($this->crop['start_y'] == 'center') { + $this->crop['start_y'] = round($this->height / 2) - round($height / 2); + } + + $this->log("Crop area is width {$width}px, height {$height}px, start_x {$this->crop['start_x']}px, start_y {$this->crop['start_y']}px."); + } + + // Calculate new width and height if keeping aspect-ratio. + if ($this->keepRatio) { + + $this->log("Keep aspect ratio."); + + // Crop-to-fit and both new width and height are set. + if (($this->cropToFit || $this->fillToFit) && isset($this->newWidth) && isset($this->newHeight)) { + + // Use newWidth and newHeigh as width/height, image should fit in box. + $this->log("Use newWidth and newHeigh as width/height, image should fit in box."); + + } elseif (isset($this->newWidth) && isset($this->newHeight)) { + + // Both new width and height are set. + // Use newWidth and newHeigh as max width/height, image should not be larger. + $ratioWidth = $width / $this->newWidth; + $ratioHeight = $height / $this->newHeight; + $ratio = ($ratioWidth > $ratioHeight) ? $ratioWidth : $ratioHeight; + $this->newWidth = round($width / $ratio); + $this->newHeight = round($height / $ratio); + $this->log("New width and height was set."); + + } elseif (isset($this->newWidth)) { + + // Use new width as max-width + $factor = (float)$this->newWidth / (float)$width; + $this->newHeight = round($factor * $height); + $this->log("New width was set."); + + } elseif (isset($this->newHeight)) { + + // Use new height as max-hight + $factor = (float)$this->newHeight / (float)$height; + $this->newWidth = round($factor * $width); + $this->log("New height was set."); + + } + + // Get image dimensions for pre-resize image. + if ($this->cropToFit || $this->fillToFit) { + + // Get relations of original & target image + $ratioWidth = $width / $this->newWidth; + $ratioHeight = $height / $this->newHeight; + + if ($this->cropToFit) { + + // Use newWidth and newHeigh as defined width/height, + // image should fit the area. + $this->log("Crop to fit."); + $ratio = ($ratioWidth < $ratioHeight) ? $ratioWidth : $ratioHeight; + $this->cropWidth = round($width / $ratio); + $this->cropHeight = round($height / $ratio); + $this->log("Crop width, height, ratio: $this->cropWidth x $this->cropHeight ($ratio)."); + + } else if ($this->fillToFit) { + + // Use newWidth and newHeigh as defined width/height, + // image should fit the area. + $this->log("Fill to fit."); + $ratio = ($ratioWidth < $ratioHeight) ? $ratioHeight : $ratioWidth; + $this->fillWidth = round($width / $ratio); + $this->fillHeight = round($height / $ratio); + $this->log("Fill width, height, ratio: $this->fillWidth x $this->fillHeight ($ratio)."); + } + } + } + + // Crop, ensure to set new width and height + if ($this->crop) { + $this->log("Crop."); + $this->newWidth = round(isset($this->newWidth) ? $this->newWidth : $this->crop['width']); + $this->newHeight = round(isset($this->newHeight) ? $this->newHeight : $this->crop['height']); + } + + // Fill to fit, ensure to set new width and height + /*if ($this->fillToFit) { + $this->log("FillToFit."); + $this->newWidth = round(isset($this->newWidth) ? $this->newWidth : $this->crop['width']); + $this->newHeight = round(isset($this->newHeight) ? $this->newHeight : $this->crop['height']); + }*/ + + // No new height or width is set, use existing measures. + $this->newWidth = round(isset($this->newWidth) ? $this->newWidth : $this->width); + $this->newHeight = round(isset($this->newHeight) ? $this->newHeight : $this->height); + $this->log("Calculated new width x height as {$this->newWidth} x {$this->newHeight}."); + + return $this; + } + + + + /** + * Re-calculate image dimensions when original image dimension has changed. + * + * @return $this + */ + public function reCalculateDimensions() + { + $this->log("Re-calculate image dimensions, newWidth x newHeigh was: " . $this->newWidth . " x " . $this->newHeight); + + $this->newWidth = $this->newWidthOrig; + $this->newHeight = $this->newHeightOrig; + $this->crop = $this->cropOrig; + + $this->initDimensions() + ->calculateNewWidthAndHeight(); + + return $this; + } + + + + /** + * Set extension for filename to save as. + * + * @param string $saveas extension to save image as + * + * @return $this + */ + public function setSaveAsExtension($saveAs = null) + { + if (isset($saveAs)) { + $saveAs = strtolower($saveAs); + $this->checkFileExtension($saveAs); + $this->saveAs = $saveAs; + $this->extension = $saveAs; + } + + $this->log("Prepare to save image using as: " . $this->extension); + + return $this; + } + + + + /** + * Set JPEG quality to use when saving image + * + * @param int $quality as the quality to set. + * + * @return $this + */ + public function setJpegQuality($quality = null) + { + if ($quality) { + $this->useQuality = true; + } + + $this->quality = isset($quality) + ? $quality + : self::JPEG_QUALITY_DEFAULT; + + (is_numeric($this->quality) and $this->quality > 0 and $this->quality <= 100) + or $this->raiseError('Quality not in range.'); + + $this->log("Setting JPEG quality to {$this->quality}."); + + return $this; + } + + + + /** + * Set PNG compressen algorithm to use when saving image + * + * @param int $compress as the algorithm to use. + * + * @return $this + */ + public function setPngCompression($compress = null) + { + if ($compress) { + $this->useCompress = true; + } + + $this->compress = isset($compress) + ? $compress + : self::PNG_COMPRESSION_DEFAULT; + + (is_numeric($this->compress) and $this->compress >= -1 and $this->compress <= 9) + or $this->raiseError('Quality not in range.'); + + $this->log("Setting PNG compression level to {$this->compress}."); + + return $this; + } + + + + /** + * Use original image if possible, check options which affects image processing. + * + * @param boolean $useOrig default is to use original if possible, else set to false. + * + * @return $this + */ + public function useOriginalIfPossible($useOrig = true) + { + if ($useOrig + && ($this->newWidth == $this->width) + && ($this->newHeight == $this->height) + && !$this->area + && !$this->crop + && !$this->cropToFit + && !$this->fillToFit + && !$this->filters + && !$this->sharpen + && !$this->emboss + && !$this->blur + && !$this->convolve + && !$this->palette + && !$this->useQuality + && !$this->useCompress + && !$this->saveAs + && !$this->rotateBefore + && !$this->rotateAfter + && !$this->autoRotate + && !$this->bgColor + && ($this->upscale === self::UPSCALE_DEFAULT) + ) { + $this->log("Using original image."); + $this->output($this->pathToImage); + } + + return $this; + } + + + + /** + * Generate filename to save file in cache. + * + * @param string $base as basepath for storing file. + * + * @return $this + */ + public function generateFilename($base) + { + $parts = pathinfo($this->pathToImage); + $cropToFit = $this->cropToFit ? '_cf' : null; + $fillToFit = $this->fillToFit ? '_ff' : null; + $crop_x = $this->crop_x ? "_x{$this->crop_x}" : null; + $crop_y = $this->crop_y ? "_y{$this->crop_y}" : null; + $scale = $this->scale ? "_s{$this->scale}" : null; + $bgColor = $this->bgColor ? "_bgc{$this->bgColor}" : null; + $quality = $this->quality ? "_q{$this->quality}" : null; + $compress = $this->compress ? "_co{$this->compress}" : null; + $rotateBefore = $this->rotateBefore ? "_rb{$this->rotateBefore}" : null; + $rotateAfter = $this->rotateAfter ? "_ra{$this->rotateAfter}" : null; + + $width = $this->newWidth; + $height = $this->newHeight; + + $offset = isset($this->offset) + ? '_o' . $this->offset['top'] . '-' . $this->offset['right'] . '-' . $this->offset['bottom'] . '-' . $this->offset['left'] + : null; + + $crop = $this->crop + ? '_c' . $this->crop['width'] . '-' . $this->crop['height'] . '-' . $this->crop['start_x'] . '-' . $this->crop['start_y'] + : null; + + $filters = null; + if (isset($this->filters)) { + foreach ($this->filters as $filter) { + if (is_array($filter)) { + $filters .= "_f{$filter['id']}"; + for ($i=1; $i<=$filter['argc']; $i++) { + $filters .= ":".$filter["arg{$i}"]; + } + } + } + } + + $sharpen = $this->sharpen ? 's' : null; + $emboss = $this->emboss ? 'e' : null; + $blur = $this->blur ? 'b' : null; + $palette = $this->palette ? 'p' : null; + + $autoRotate = $this->autoRotate ? 'ar' : null; + + $this->extension = isset($this->extension) + ? $this->extension + : $parts['extension']; + + $optimize = null; + if ($this->extension == 'jpeg' || $this->extension == 'jpg') { + $optimize = $this->jpegOptimize ? 'o' : null; + } elseif ($this->extension == 'png') { + $optimize .= $this->pngFilter ? 'f' : null; + $optimize .= $this->pngDeflate ? 'd' : null; + } + + $convolve = null; + if ($this->convolve) { + $convolve = '_conv' . preg_replace('/[^a-zA-Z0-9]/', '', $this->convolve); + } + + $upscale = null; + if ($this->upscale !== self::UPSCALE_DEFAULT) { + $upscale = '_nu'; + } + + $subdir = str_replace('/', '-', dirname($this->imageSrc)); + $subdir = ($subdir == '.') ? '_.' : $subdir; + $file = $subdir . '_' . $parts['filename'] . '_' . $width . '_' + . $height . $offset . $crop . $cropToFit . $fillToFit + . $crop_x . $crop_y . $upscale + . $quality . $filters . $sharpen . $emboss . $blur . $palette . $optimize + . $scale . $rotateBefore . $rotateAfter . $autoRotate . $bgColor . $convolve + . '.' . $this->extension; + + return $this->setTarget($file, $base); + } + + + + /** + * Use cached version of image, if possible. + * + * @param boolean $useCache is default true, set to false to avoid using cached object. + * + * @return $this + */ + public function useCacheIfPossible($useCache = true) + { + if ($useCache && is_readable($this->cacheFileName)) { + $fileTime = filemtime($this->pathToImage); + $cacheTime = filemtime($this->cacheFileName); + + if ($fileTime <= $cacheTime) { + if ($this->useCache) { + if ($this->verbose) { + $this->log("Use cached file."); + $this->log("Cached image filesize: " . filesize($this->cacheFileName) . " bytes."); + } + $this->output($this->cacheFileName, $this->outputFormat); + } else { + $this->log("Cache is valid but ignoring it by intention."); + } + } else { + $this->log("Original file is modified, ignoring cache."); + } + } else { + $this->log("Cachefile does not exists or ignoring it."); + } + + return $this; + } + + + + /** + * Error message when failing to load somehow corrupt image. + * + * @return void + * + */ + public function failedToLoad() + { + header("HTTP/1.0 404 Not Found"); + echo("CImage.php says 404: Fatal error when opening image.
"); + + switch ($this->fileExtension) { + case 'jpg': + case 'jpeg': + $this->image = imagecreatefromjpeg($this->pathToImage); + break; + + case 'gif': + $this->image = imagecreatefromgif($this->pathToImage); + break; + + case 'png': + $this->image = imagecreatefrompng($this->pathToImage); + break; + } + + exit(); + } + + + + /** + * Load image from disk. + * + * @param string $src of image. + * @param string $dir as base directory where images are. + * + * @return $this + * + */ + public function load($src = null, $dir = null) + { + if (isset($src)) { + $this->setSource($src, $dir); + } + + $this->log("Opening file as {$this->fileExtension}."); + + switch ($this->fileExtension) { + case 'jpg': + case 'jpeg': + $this->image = @imagecreatefromjpeg($this->pathToImage); + $this->image or $this->failedToLoad(); + break; + + case 'gif': + $this->image = @imagecreatefromgif($this->pathToImage); + $this->image or $this->failedToLoad(); + break; + + case 'png': + $this->image = @imagecreatefrompng($this->pathToImage); + $this->image or $this->failedToLoad(); + + $type = $this->getPngType(); + $hasFewColors = imagecolorstotal($this->image); + + if ($type == self::PNG_RGB_PALETTE || ($hasFewColors > 0 && $hasFewColors <= 256)) { + if ($this->verbose) { + $this->log("Handle this image as a palette image."); + } + $this->palette = true; + } + break; + + default: + $this->image = false; + throw new Exception('No support for this file extension.'); + } + + if ($this->verbose) { + $this->log("imageistruecolor() : " . (imageistruecolor($this->image) ? 'true' : 'false')); + $this->log("imagecolorstotal() : " . imagecolorstotal($this->image)); + $this->log("Number of colors in image = " . $this->colorsTotal($this->image)); + } + + return $this; + } + + + + /** + * Get the type of PNG image. + * + * @return int as the type of the png-image + * + */ + private function getPngType() + { + $pngType = ord(file_get_contents($this->pathToImage, false, null, 25, 1)); + + switch ($pngType) { + + case self::PNG_GREYSCALE: + $this->log("PNG is type 0, Greyscale."); + break; + + case self::PNG_RGB: + $this->log("PNG is type 2, RGB"); + break; + + case self::PNG_RGB_PALETTE: + $this->log("PNG is type 3, RGB with palette"); + break; + + case self::PNG_GREYSCALE_ALPHA: + $this->Log("PNG is type 4, Greyscale with alpha channel"); + break; + + case self::PNG_RGB_ALPHA: + $this->Log("PNG is type 6, RGB with alpha channel (PNG 32-bit)"); + break; + + default: + $this->Log("PNG is UNKNOWN type, is it really a PNG image?"); + } + + return $pngType; + } + + + + /** + * Calculate number of colors in an image. + * + * @param resource $im the image. + * + * @return int + */ + private function colorsTotal($im) + { + if (imageistruecolor($im)) { + $this->log("Colors as true color."); + $h = imagesy($im); + $w = imagesx($im); + $c = array(); + for ($x=0; $x < $w; $x++) { + for ($y=0; $y < $h; $y++) { + @$c['c'.imagecolorat($im, $x, $y)]++; + } + } + return count($c); + } else { + $this->log("Colors as palette."); + return imagecolorstotal($im); + } + } + + + + /** + * Preprocess image before rezising it. + * + * @return $this + */ + public function preResize() + { + $this->log("Pre-process before resizing"); + + // Rotate image + if ($this->rotateBefore) { + $this->log("Rotating image."); + $this->rotate($this->rotateBefore, $this->bgColor) + ->reCalculateDimensions(); + } + + // Auto-rotate image + if ($this->autoRotate) { + $this->log("Auto rotating image."); + $this->rotateExif() + ->reCalculateDimensions(); + } + + // Scale the original image before starting + if (isset($this->scale)) { + $this->log("Scale by {$this->scale}%"); + $newWidth = $this->width * $this->scale / 100; + $newHeight = $this->height * $this->scale / 100; + $img = $this->CreateImageKeepTransparency($newWidth, $newHeight); + imagecopyresampled($img, $this->image, 0, 0, 0, 0, $newWidth, $newHeight, $this->width, $this->height); + $this->image = $img; + $this->width = $newWidth; + $this->height = $newHeight; + } + + return $this; + } + + + + /** + * Resize and or crop the image. + * + * @return $this + */ + public function resize() + { + + $this->log("Starting to Resize()"); + $this->log("Upscale = '$this->upscale'"); + + // Only use a specified area of the image, $this->offset is defining the area to use + if (isset($this->offset)) { + + $this->log("Offset for area to use, cropping it width={$this->offset['width']}, height={$this->offset['height']}, start_x={$this->offset['left']}, start_y={$this->offset['top']}"); + $img = $this->CreateImageKeepTransparency($this->offset['width'], $this->offset['height']); + imagecopy($img, $this->image, 0, 0, $this->offset['left'], $this->offset['top'], $this->offset['width'], $this->offset['height']); + $this->image = $img; + $this->width = $this->offset['width']; + $this->height = $this->offset['height']; + } + + if ($this->crop) { + + // Do as crop, take only part of image + $this->log("Cropping area width={$this->crop['width']}, height={$this->crop['height']}, start_x={$this->crop['start_x']}, start_y={$this->crop['start_y']}"); + $img = $this->CreateImageKeepTransparency($this->crop['width'], $this->crop['height']); + imagecopy($img, $this->image, 0, 0, $this->crop['start_x'], $this->crop['start_y'], $this->crop['width'], $this->crop['height']); + $this->image = $img; + $this->width = $this->crop['width']; + $this->height = $this->crop['height']; + } + + if (!$this->upscale) { + // Consider rewriting the no-upscale code to fit within this if-statement, + // likely to be more readable code. + // The code is more or leass equal in below crop-to-fit, fill-to-fit and stretch + } + + if ($this->cropToFit) { + + // Resize by crop to fit + $this->log("Resizing using strategy - Crop to fit"); + + if (!$this->upscale && ($this->width < $this->newWidth || $this->height < $this->newHeight)) { + $this->log("Resizing - smaller image, do not upscale."); + + $cropX = round(($this->cropWidth/2) - ($this->newWidth/2)); + $cropY = round(($this->cropHeight/2) - ($this->newHeight/2)); + + $posX = 0; + $posY = 0; + + if ($this->newWidth > $this->width) { + $posX = round(($this->newWidth - $this->width) / 2); + } + + if ($this->newHeight > $this->height) { + $posY = round(($this->newHeight - $this->height) / 2); + } + + $imageResized = $this->CreateImageKeepTransparency($this->newWidth, $this->newHeight); + imagecopy($imageResized, $this->image, $posX, $posY, $cropX, $cropY, $this->newWidth, $this->newHeight); + } else { + $cropX = round(($this->cropWidth/2) - ($this->newWidth/2)); + $cropY = round(($this->cropHeight/2) - ($this->newHeight/2)); + $imgPreCrop = $this->CreateImageKeepTransparency($this->cropWidth, $this->cropHeight); + $imageResized = $this->CreateImageKeepTransparency($this->newWidth, $this->newHeight); + imagecopyresampled($imgPreCrop, $this->image, 0, 0, 0, 0, $this->cropWidth, $this->cropHeight, $this->width, $this->height); + imagecopy($imageResized, $imgPreCrop, 0, 0, $cropX, $cropY, $this->newWidth, $this->newHeight); + } + + $this->image = $imageResized; + $this->width = $this->newWidth; + $this->height = $this->newHeight; + + } else if ($this->fillToFit) { + + // Resize by fill to fit + $this->log("Resizing using strategy - Fill to fit"); + + $posX = 0; + $posY = 0; + + $ratioOrig = $this->width / $this->height; + $ratioNew = $this->newWidth / $this->newHeight; + + // Check ratio for landscape or portrait + if ($ratioOrig < $ratioNew) { + $posX = round(($this->newWidth - $this->fillWidth) / 2); + } else { + $posY = round(($this->newHeight - $this->fillHeight) / 2); + } + + if (!$this->upscale + && ($this->width < $this->newWidth || $this->height < $this->newHeight) + ) { + + $this->log("Resizing - smaller image, do not upscale."); + $posX = round(($this->fillWidth - $this->width) / 2); + $posY = round(($this->fillHeight - $this->height) / 2); + $imageResized = $this->CreateImageKeepTransparency($this->newWidth, $this->newHeight); + imagecopy($imageResized, $this->image, $posX, $posY, 0, 0, $this->fillWidth, $this->fillHeight); + + } else { + $imgPreFill = $this->CreateImageKeepTransparency($this->fillWidth, $this->fillHeight); + $imageResized = $this->CreateImageKeepTransparency($this->newWidth, $this->newHeight); + imagecopyresampled($imgPreFill, $this->image, 0, 0, 0, 0, $this->fillWidth, $this->fillHeight, $this->width, $this->height); + imagecopy($imageResized, $imgPreFill, $posX, $posY, 0, 0, $this->fillWidth, $this->fillHeight); + } + + $this->image = $imageResized; + $this->width = $this->newWidth; + $this->height = $this->newHeight; + + } else if (!($this->newWidth == $this->width && $this->newHeight == $this->height)) { + + // Resize it + $this->log("Resizing, new height and/or width"); + + if (!$this->upscale + && ($this->width < $this->newWidth || $this->height < $this->newHeight) + ) { + $this->log("Resizing - smaller image, do not upscale."); + + if (!$this->keepRatio) { + $this->log("Resizing - stretch to fit selected."); + + $posX = 0; + $posY = 0; + $cropX = 0; + $cropY = 0; + + if ($this->newWidth > $this->width && $this->newHeight > $this->height) { + $posX = round(($this->newWidth - $this->width) / 2); + $posY = round(($this->newHeight - $this->height) / 2); + } else if ($this->newWidth > $this->width) { + $posX = round(($this->newWidth - $this->width) / 2); + $cropY = round(($this->height - $this->newHeight) / 2); + } else if ($this->newHeight > $this->height) { + $posY = round(($this->newHeight - $this->height) / 2); + $cropX = round(($this->width - $this->newWidth) / 2); + } + + //$this->log("posX=$posX, posY=$posY, cropX=$cropX, cropY=$cropY."); + $imageResized = $this->CreateImageKeepTransparency($this->newWidth, $this->newHeight); + imagecopy($imageResized, $this->image, $posX, $posY, $cropX, $cropY, $this->newWidth, $this->newHeight); + $this->image = $imageResized; + $this->width = $this->newWidth; + $this->height = $this->newHeight; + } + } else { + $imageResized = $this->CreateImageKeepTransparency($this->newWidth, $this->newHeight); + imagecopyresampled($imageResized, $this->image, 0, 0, 0, 0, $this->newWidth, $this->newHeight, $this->width, $this->height); + $this->image = $imageResized; + $this->width = $this->newWidth; + $this->height = $this->newHeight; + } + } + + return $this; + } + + + + /** + * Postprocess image after rezising image. + * + * @return $this + */ + public function postResize() + { + $this->log("Post-process after resizing"); + + // Rotate image + if ($this->rotateAfter) { + $this->log("Rotating image."); + $this->rotate($this->rotateAfter, $this->bgColor); + } + + // Apply filters + if (isset($this->filters) && is_array($this->filters)) { + + foreach ($this->filters as $filter) { + $this->log("Applying filter {$filter['type']}."); + + switch ($filter['argc']) { + + case 0: + imagefilter($this->image, $filter['type']); + break; + + case 1: + imagefilter($this->image, $filter['type'], $filter['arg1']); + break; + + case 2: + imagefilter($this->image, $filter['type'], $filter['arg1'], $filter['arg2']); + break; + + case 3: + imagefilter($this->image, $filter['type'], $filter['arg1'], $filter['arg2'], $filter['arg3']); + break; + + case 4: + imagefilter($this->image, $filter['type'], $filter['arg1'], $filter['arg2'], $filter['arg3'], $filter['arg4']); + break; + } + } + } + + // Convert to palette image + if ($this->palette) { + $this->log("Converting to palette image."); + $this->trueColorToPalette(); + } + + // Blur the image + if ($this->blur) { + $this->log("Blur."); + $this->blurImage(); + } + + // Emboss the image + if ($this->emboss) { + $this->log("Emboss."); + $this->embossImage(); + } + + // Sharpen the image + if ($this->sharpen) { + $this->log("Sharpen."); + $this->sharpenImage(); + } + + // Custom convolution + if ($this->convolve) { + //$this->log("Convolve: " . $this->convolve); + $this->imageConvolution(); + } + + return $this; + } + + + + /** + * Rotate image using angle. + * + * @param float $angle to rotate image. + * @param int $anglebgColor to fill image with if needed. + * + * @return $this + */ + public function rotate($angle, $bgColor) + { + $this->log("Rotate image " . $angle . " degrees with filler color."); + + $color = $this->getBackgroundColor(); + $this->image = imagerotate($this->image, $angle, $color); + + $this->width = imagesx($this->image); + $this->height = imagesy($this->image); + + $this->log("New image dimension width x height: " . $this->width . " x " . $this->height); + + return $this; + } + + + + /** + * Rotate image using information in EXIF. + * + * @return $this + */ + public function rotateExif() + { + if (!in_array($this->fileExtension, array('jpg', 'jpeg'))) { + $this->log("Autorotate ignored, EXIF not supported by this filetype."); + return $this; + } + + $exif = exif_read_data($this->pathToImage); + + if (!empty($exif['Orientation'])) { + switch ($exif['Orientation']) { + case 3: + $this->log("Autorotate 180."); + $this->rotate(180, $this->bgColor); + break; + + case 6: + $this->log("Autorotate -90."); + $this->rotate(-90, $this->bgColor); + break; + + case 8: + $this->log("Autorotate 90."); + $this->rotate(90, $this->bgColor); + break; + + default: + $this->log("Autorotate ignored, unknown value as orientation."); + } + } else { + $this->log("Autorotate ignored, no orientation in EXIF."); + } + + return $this; + } + + + + /** + * Convert true color image to palette image, keeping alpha. + * http://stackoverflow.com/questions/5752514/how-to-convert-png-to-8-bit-png-using-php-gd-library + * + * @return void + */ + public function trueColorToPalette() + { + $img = imagecreatetruecolor($this->width, $this->height); + $bga = imagecolorallocatealpha($img, 0, 0, 0, 127); + imagecolortransparent($img, $bga); + imagefill($img, 0, 0, $bga); + imagecopy($img, $this->image, 0, 0, 0, 0, $this->width, $this->height); + imagetruecolortopalette($img, false, 255); + imagesavealpha($img, true); + + if (imageistruecolor($this->image)) { + $this->log("Matching colors with true color image."); + imagecolormatch($this->image, $img); + } + + $this->image = $img; + } + + + + /** + * Sharpen image using image convolution. + * + * @return $this + */ + public function sharpenImage() + { + $this->imageConvolution('sharpen'); + return $this; + } + + + + /** + * Emboss image using image convolution. + * + * @return $this + */ + public function embossImage() + { + $this->imageConvolution('emboss'); + return $this; + } + + + + /** + * Blur image using image convolution. + * + * @return $this + */ + public function blurImage() + { + $this->imageConvolution('blur'); + return $this; + } + + + + /** + * Create convolve expression and return arguments for image convolution. + * + * @param string $expression constant string which evaluates to a list of + * 11 numbers separated by komma or such a list. + * + * @return array as $matrix (3x3), $divisor and $offset + */ + public function createConvolveArguments($expression) + { + // Check of matching constant + if (isset($this->convolves[$expression])) { + $expression = $this->convolves[$expression]; + } + + $part = explode(',', $expression); + $this->log("Creating convolution expressen: $expression"); + + // Expect list of 11 numbers, split by , and build up arguments + if (count($part) != 11) { + throw new Exception( + "Missmatch in argument convolve. Expected comma-separated string with + 11 float values. Got $expression." + ); + } + + array_walk($part, function ($item, $key) { + if (!is_numeric($item)) { + throw new Exception("Argument to convolve expression should be float but is not."); + } + }); + + return array( + array( + array($part[0], $part[1], $part[2]), + array($part[3], $part[4], $part[5]), + array($part[6], $part[7], $part[8]), + ), + $part[9], + $part[10], + ); + } + + + + /** + * Add custom expressions (or overwrite existing) for image convolution. + * + * @param array $options Key value array with strings to be converted + * to convolution expressions. + * + * @return $this + */ + public function addConvolveExpressions($options) + { + $this->convolves = array_merge($this->convolves, $options); + return $this; + } + + + + /** + * Image convolution. + * + * @param string $options A string with 11 float separated by comma. + * + * @return $this + */ + public function imageConvolution($options = null) + { + // Use incoming options or use $this. + $options = $options ? $options : $this->convolve; + + // Treat incoming as string, split by + + $this->log("Convolution with '$options'"); + $options = explode(":", $options); + + // Check each option if it matches constant value + foreach ($options as $option) { + list($matrix, $divisor, $offset) = $this->createConvolveArguments($option); + imageconvolution($this->image, $matrix, $divisor, $offset); + } + + return $this; + } + + + + /** + * Set default background color between 000000-FFFFFF or if using + * alpha 00000000-FFFFFF7F. + * + * @param string $color as hex value. + * + * @return $this + */ + public function setDefaultBackgroundColor($color) + { + $this->log("Setting default background color to '$color'."); + + if (!(strlen($color) == 6 || strlen($color) == 8)) { + throw new Exception( + "Background color needs a hex value of 6 or 8 + digits. 000000-FFFFFF or 00000000-FFFFFF7F. + Current value was: '$color'." + ); + } + + $red = hexdec(substr($color, 0, 2)); + $green = hexdec(substr($color, 2, 2)); + $blue = hexdec(substr($color, 4, 2)); + + $alpha = (strlen($color) == 8) + ? hexdec(substr($color, 6, 2)) + : null; + + if (($red < 0 || $red > 255) + || ($green < 0 || $green > 255) + || ($blue < 0 || $blue > 255) + || ($alpha < 0 || $alpha > 127) + ) { + throw new Exception( + "Background color out of range. Red, green blue + should be 00-FF and alpha should be 00-7F. + Current value was: '$color'." + ); + } + + $this->bgColor = strtolower($color); + $this->bgColorDefault = array( + 'red' => $red, + 'green' => $green, + 'blue' => $blue, + 'alpha' => $alpha + ); + + return $this; + } + + + + /** + * Get the background color. + * + * @param resource $img the image to work with or null if using $this->image. + * + * @return color value or null if no background color is set. + */ + private function getBackgroundColor($img = null) + { + $img = isset($img) ? $img : $this->image; + + if ($this->bgColorDefault) { + + $red = $this->bgColorDefault['red']; + $green = $this->bgColorDefault['green']; + $blue = $this->bgColorDefault['blue']; + $alpha = $this->bgColorDefault['alpha']; + + if ($alpha) { + $color = imagecolorallocatealpha($img, $red, $green, $blue, $alpha); + } else { + $color = imagecolorallocate($img, $red, $green, $blue); + } + + return $color; + + } else { + return 0; + } + } + + + + /** + * Create a image and keep transparency for png and gifs. + * + * @param int $width of the new image. + * @param int $height of the new image. + * + * @return image resource. + */ + private function createImageKeepTransparency($width, $height) + { + $this->log("Creating a new working image width={$width}px, height={$height}px."); + $img = imagecreatetruecolor($width, $height); + imagealphablending($img, false); + imagesavealpha($img, true); + + if ($this->bgColorDefault) { + + $color = $this->getBackgroundColor($img); + imagefill($img, 0, 0, $color); + $this->Log("Filling image with background color."); + } + + return $img; + } + + + + /** + * Set optimizing and post-processing options. + * + * @param array $options with config for postprocessing with external tools. + * + * @return $this + */ + public function setPostProcessingOptions($options) + { + if (isset($options['jpeg_optimize']) && $options['jpeg_optimize']) { + $this->jpegOptimizeCmd = $options['jpeg_optimize_cmd']; + } else { + $this->jpegOptimizeCmd = null; + } + + if (isset($options['png_filter']) && $options['png_filter']) { + $this->pngFilterCmd = $options['png_filter_cmd']; + } else { + $this->pngFilterCmd = null; + } + + if (isset($options['png_deflate']) && $options['png_deflate']) { + $this->pngDeflateCmd = $options['png_deflate_cmd']; + } else { + $this->pngDeflateCmd = null; + } + + return $this; + } + + + + /** + * Save image. + * + * @param string $src as target filename. + * @param string $base as base directory where to store images. + * + * @return $this or false if no folder is set. + */ + public function save($src = null, $base = null) + { + if (isset($src)) { + $this->setTarget($src, $base); + } + + is_writable($this->saveFolder) + or $this->raiseError('Target directory is not writable.'); + + switch(strtolower($this->extension)) { + + case 'jpeg': + case 'jpg': + $this->Log("Saving image as JPEG to cache using quality = {$this->quality}."); + imagejpeg($this->image, $this->cacheFileName, $this->quality); + + // Use JPEG optimize if defined + if ($this->jpegOptimizeCmd) { + if ($this->verbose) { + clearstatcache(); + $this->log("Filesize before optimize: " . filesize($this->cacheFileName) . " bytes."); + } + $res = array(); + $cmd = $this->jpegOptimizeCmd . " -outfile $this->cacheFileName $this->cacheFileName"; + exec($cmd, $res); + $this->log($cmd); + $this->log($res); + } + break; + + case 'gif': + $this->Log("Saving image as GIF to cache."); + imagegif($this->image, $this->cacheFileName); + break; + + case 'png': + $this->Log("Saving image as PNG to cache using compression = {$this->compress}."); + + // Turn off alpha blending and set alpha flag + imagealphablending($this->image, false); + imagesavealpha($this->image, true); + imagepng($this->image, $this->cacheFileName, $this->compress); + + // Use external program to filter PNG, if defined + if ($this->pngFilterCmd) { + if ($this->verbose) { + clearstatcache(); + $this->Log("Filesize before filter optimize: " . filesize($this->cacheFileName) . " bytes."); + } + $res = array(); + $cmd = $this->pngFilterCmd . " $this->cacheFileName"; + exec($cmd, $res); + $this->Log($cmd); + $this->Log($res); + } + + // Use external program to deflate PNG, if defined + if ($this->pngDeflateCmd) { + if ($this->verbose) { + clearstatcache(); + $this->Log("Filesize before deflate optimize: " . filesize($this->cacheFileName) . " bytes."); + } + $res = array(); + $cmd = $this->pngDeflateCmd . " $this->cacheFileName"; + exec($cmd, $res); + $this->Log($cmd); + $this->Log($res); + } + break; + + default: + $this->RaiseError('No support for this file extension.'); + break; + } + + if ($this->verbose) { + clearstatcache(); + $this->log("Cached image filesize: " . filesize($this->cacheFileName) . " bytes."); + $this->log("imageistruecolor() : " . (imageistruecolor($this->image) ? 'true' : 'false')); + $this->log("imagecolorstotal() : " . imagecolorstotal($this->image)); + $this->log("Number of colors in image = " . $this->ColorsTotal($this->image)); + } + + return $this; + } + + + + /** + * Create a hard link, as an alias, to the cached file. + * + * @param string $alias where to store the link, + * filename without extension. + * + * @return $this + */ + public function linkToCacheFile($alias) + { + if ($alias === null) { + $this->log("Ignore creating alias."); + return $this; + } + + $alias = $alias . "." . $this->extension; + + if (is_readable($alias)) { + unlink($alias); + } + + $res = link($this->cacheFileName, $alias); + + if ($res) { + $this->log("Created an alias as: $alias"); + } else { + $this->log("Failed to create the alias: $alias"); + } + + return $this; + } + + + + /** + * Output image to browser using caching. + * + * @param string $file to read and output, default is to use $this->cacheFileName + * @param string $format set to json to output file as json object with details + * + * @return void + */ + public function output($file = null, $format = null) + { + if (is_null($file)) { + $file = $this->cacheFileName; + } + + if (is_null($format)) { + $format = $this->outputFormat; + } + + $this->log("Output format is: $format"); + + if (!$this->verbose && $format == 'json') { + header('Content-type: application/json'); + echo $this->json($file); + exit; + } + + $this->log("Outputting image: $file"); + + // Get image modification time + clearstatcache(); + $lastModified = filemtime($file); + $gmdate = gmdate("D, d M Y H:i:s", $lastModified); + + if (!$this->verbose) { + header('Last-Modified: ' . $gmdate . " GMT"); + } + + if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) == $lastModified) { + + if ($this->verbose) { + $this->log("304 not modified"); + $this->verboseOutput(); + exit; + } + + header("HTTP/1.0 304 Not Modified"); + + } else { + + if ($this->verbose) { + $this->log("Last modified: " . $gmdate . " GMT"); + $this->verboseOutput(); + exit; + } + + // Get details on image + $info = getimagesize($file); + !empty($info) or $this->raiseError("The file doesn't seem to be an image."); + $mime = $info['mime']; + + header('Content-type: ' . $mime); + readfile($file); + } + + exit; + } + + + + /** + * Create a JSON object from the image details. + * + * @param string $file the file to output. + * + * @return string json-encoded representation of the image. + */ + public function json($file = null) + { + $file = $file ? $file : $this->cacheFileName; + + $details = array(); + + clearstatcache(); + + $details['src'] = $this->imageSrc; + $lastModified = filemtime($this->pathToImage); + $details['srcGmdate'] = gmdate("D, d M Y H:i:s", $lastModified); + + $details['cache'] = basename($this->cacheFileName); + $lastModified = filemtime($this->cacheFileName); + $details['cacheGmdate'] = gmdate("D, d M Y H:i:s", $lastModified); + + $this->loadImageDetails($file); + + $details['filename'] = basename($file); + $details['width'] = $this->width; + $details['height'] = $this->height; + $details['aspectRatio'] = round($this->width / $this->height, 3); + $details['size'] = filesize($file); + + $this->load($file); + $details['colors'] = $this->colorsTotal($this->image); + + $options = null; + if (defined("JSON_PRETTY_PRINT") && defined("JSON_UNESCAPED_SLASHES")) { + $options = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES; + } + + return json_encode($details, $options); + } + + + + /** + * Log an event if verbose mode. + * + * @param string $message to log. + * + * @return this + */ + public function log($message) + { + if ($this->verbose) { + $this->log[] = $message; + } + + return $this; + } + + + + /** + * Do verbose output and print out the log and the actual images. + * + * @return void + */ + private function verboseOutput() + { + $log = null; + $this->log("As JSON: \n" . $this->json()); + $this->log("Memory peak: " . round(memory_get_peak_usage() /1024/1024) . "M"); + $this->log("Memory limit: " . ini_get('memory_limit')); + + $included = get_included_files(); + $this->log("Included files: " . count($included)); + + foreach ($this->log as $val) { + if (is_array($val)) { + foreach ($val as $val1) { + $log .= htmlentities($val1) . '
'; + } + } else { + $log .= htmlentities($val) . '
'; + } + } + + echo << + + +CImage verbose output + +

CImage Verbose Output

+
{$log}
+EOD; + } + + + + /** + * Raise error, enables to implement a selection of error methods. + * + * @param string $message the error message to display. + * + * @return void + * @throws Exception + */ + private function raiseError($message) + { + throw new Exception($message); + } +} + + + +/** + * Resize and crop images on the fly, store generated images in a cache. + * + * @author Mikael Roos mos@dbwebb.se + * @example http://dbwebb.se/opensource/cimage + * @link https://github.com/mosbth/cimage + * + */ + + + +/** + * Default configuration options, can be overridden in own config-file. + * + * @param string $msg to display. + * + * @return void + */ +function errorPage($msg) +{ + global $mode; + + header("HTTP/1.0 500 Internal Server Error"); + + if ($mode == 'development') { + die("[img.php] $msg"); + } else { + error_log("[img.php] $msg"); + die("HTTP/1.0 500 Internal Server Error"); + } +} + + + +/** + * Custom exception handler. + */ +set_exception_handler(function ($exception) { + errorPage("

img.php: Uncaught exception:

" . $exception->getMessage() . "

" . $exception->getTraceAsString(), "
"); +}); + + + +/** + * Get input from query string or return default value if not set. + * + * @param mixed $key as string or array of string values to look for in $_GET. + * @param mixed $default value to return when $key is not set in $_GET. + * + * @return mixed value from $_GET or default value. + */ +function get($key, $default = null) +{ + if (is_array($key)) { + foreach ($key as $val) { + if (isset($_GET[$val])) { + return $_GET[$val]; + } + } + } elseif (isset($_GET[$key])) { + return $_GET[$key]; + } + return $default; +} + + + +/** + * Get input from query string and set to $defined if defined or else $undefined. + * + * @param mixed $key as string or array of string values to look for in $_GET. + * @param mixed $defined value to return when $key is set in $_GET. + * @param mixed $undefined value to return when $key is not set in $_GET. + * + * @return mixed value as $defined or $undefined. + */ +function getDefined($key, $defined, $undefined) +{ + return get($key) === null ? $undefined : $defined; +} + + + +/** + * Get value from config array or default if key is not set in config array. + * + * @param string $key the key in the config array. + * @param mixed $default value to be default if $key is not set in config. + * + * @return mixed value as $config[$key] or $default. + */ +function getConfig($key, $default) +{ + global $config; + return isset($config[$key]) + ? $config[$key] + : $default; +} + + + +/** + * Log when verbose mode, when used without argument it returns the result. + * + * @param string $msg to log. + * + * @return void or array. + */ +function verbose($msg = null) +{ + global $verbose; + static $log = array(); + + if (!$verbose) { + return; + } + + if (is_null($msg)) { + return $log; + } + + $log[] = $msg; +} + + + +/** + * Get configuration options from file, if the file exists, else use $config + * if its defined or create an empty $config. + */ +$configFile = __DIR__.'/'.basename(__FILE__, '.php').'_config.php'; + +if (is_file($configFile)) { + $config = require $configFile; +} else if (!isset($config)) { + $config = array(); +} + + + +/** +* verbose, v - do a verbose dump of what happens +*/ +$verbose = getDefined(array('verbose', 'v'), true, false); + + + +/** + * Set mode as strict, production or development. + * Default is production environment. + */ +$mode = getConfig('mode', 'production'); + +// Settings for any mode +set_time_limit(20); +ini_set('gd.jpeg_ignore_warning', 1); + +if (!extension_loaded('gd')) { + errorPage("Extension gd is nod loaded."); +} + +// Specific settings for each mode +if ($mode == 'strict') { + + error_reporting(0); + ini_set('display_errors', 0); + ini_set('log_errors', 1); + $verbose = false; + +} else if ($mode == 'production') { + + error_reporting(-1); + ini_set('display_errors', 0); + ini_set('log_errors', 1); + $verbose = false; + +} else if ($mode == 'development') { + + error_reporting(-1); + ini_set('display_errors', 1); + ini_set('log_errors', 0); + +} else { + errorPage("Unknown mode: $mode"); +} + +verbose("mode = $mode"); +verbose("error log = " . ini_get('error_log')); + + + +/** + * Set default timezone if not set or if its set in the config-file. + */ +$defaultTimezone = getConfig('default_timezone', null); + +if ($defaultTimezone) { + date_default_timezone_set($defaultTimezone); +} else if (!ini_get('default_timezone')) { + date_default_timezone_set('UTC'); +} + + + +/** + * Check if passwords are configured, used and match. + * Options decide themself if they require passwords to be used. + */ +$pwdConfig = getConfig('password', false); +$pwd = get(array('password', 'pwd'), null); + +// Check if passwords match, if configured to use passwords +$passwordMatch = null; +if ($pwdConfig && $pwd) { + $passwordMatch = ($pwdConfig == $pwd); +} + +verbose("password match = $passwordMatch"); + + + +/** + * Prevent hotlinking, leeching, of images by controlling who access them + * from where. + * + */ +$allowHotlinking = getConfig('allow_hotlinking', true); +$hotlinkingWhitelist = getConfig('hotlinking_whitelist', array()); + +$serverName = isset($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : null; +$referer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : null; +$refererHost = parse_url($referer, PHP_URL_HOST); + +if (!$allowHotlinking) { + if ($passwordMatch) { + ; // Always allow when password match + } else if ($passwordMatch === false) { + errorPage("Hotlinking/leeching not allowed when password missmatch."); + } else if (!$referer) { + errorPage("Hotlinking/leeching not allowed and referer is missing."); + } else if (strcmp($serverName, $refererHost) == 0) { + ; // Allow when serverName matches refererHost + } else if (!empty($hotlinkingWhitelist)) { + + $allowedByWhitelist = false; + foreach ($hotlinkingWhitelist as $val) { + if (preg_match($val, $refererHost)) { + $allowedByWhitelist = true; + } + } + + if (!$allowedByWhitelist) { + errorPage("Hotlinking/leeching not allowed by whitelist."); + } + + } else { + errorPage("Hotlinking/leeching not allowed."); + } +} + +verbose("allow_hotlinking = $allowHotlinking"); +verbose("referer = $referer"); +verbose("referer host = $refererHost"); + + + +/** + * Get the source files. + */ +$autoloader = getConfig('autoloader', false); +$cimageClass = getConfig('cimage_class', false); + +if ($autoloader) { + require $autoloader; +} else if ($cimageClass) { + require $cimageClass; +} + + + +/** + * Create the class for the image. + */ +$img = new CImage(); +$img->setVerbose($verbose); + + + +/** + * Allow or disallow remote download of images from other servers. + * Passwords apply if used. + * + */ +$allowRemote = getConfig('remote_allow', false); + +if ($allowRemote && $passwordMatch !== false) { + $pattern = getConfig('remote_pattern', null); + $img->setRemoteDownload($allowRemote, $pattern); +} + + + +/** + * shortcut, sc - extend arguments with a constant value, defined + * in config-file. + */ +$shortcut = get(array('shortcut', 'sc'), null); +$shortcutConfig = getConfig('shortcut', array( + 'sepia' => "&f=grayscale&f0=brightness,-10&f1=contrast,-20&f2=colorize,120,60,0,0&sharpen", +)); + +verbose("shortcut = $shortcut"); + +if (isset($shortcut) + && isset($shortcutConfig[$shortcut])) { + + parse_str($shortcutConfig[$shortcut], $get); + verbose("shortcut-constant = {$shortcutConfig[$shortcut]}"); + $_GET = array_merge($_GET, $get); +} + + + +/** + * src - the source image file. + */ +$srcImage = get('src') + or errorPage('Must set src-attribute.'); + +// Check for valid/invalid characters +$imagePath = getConfig('image_path', __DIR__ . '/img/'); +$imagePathConstraint = getConfig('image_path_constraint', true); +$validFilename = getConfig('valid_filename', '#^[a-z0-9A-Z-/_\.:]+$#'); + +preg_match($validFilename, $srcImage) + or errorPage('Filename contains invalid characters.'); + +if ($allowRemote && $img->isRemoteSource($srcImage)) { + + // If source is a remote file, ignore local file checks. + +} else if ($imagePathConstraint) { + + // Check that the image is a file below the directory 'image_path'. + $pathToImage = realpath($imagePath . $srcImage); + $imageDir = realpath($imagePath); + + is_file($pathToImage) + or errorPage( + 'Source image is not a valid file, check the filename and that a + matching file exists on the filesystem.' + ); + + substr_compare($imageDir, $pathToImage, 0, strlen($imageDir)) == 0 + or errorPage( + 'Security constraint: Source image is not below the directory "image_path" + as specified in the config file img_config.php.' + ); +} + +verbose("src = $srcImage"); + + + +/** + * Manage size constants from config file, use constants to replace values + * for width and height. + */ +$sizeConstant = getConfig('size_constant', function () { + + // Set sizes to map constant to value, easier to use with width or height + $sizes = array( + 'w1' => 613, + 'w2' => 630, + ); + + // Add grid column width, useful for use as predefined size for width (or height). + $gridColumnWidth = 30; + $gridGutterWidth = 10; + $gridColumns = 24; + + for ($i = 1; $i <= $gridColumns; $i++) { + $sizes['c' . $i] = ($gridColumnWidth + $gridGutterWidth) * $i - $gridGutterWidth; + } + + return $sizes; +}); + +$sizes = call_user_func($sizeConstant); + + + +/** + * width, w - set target width, affecting the resulting image width, height and resize options + */ +$newWidth = get(array('width', 'w')); +$maxWidth = getConfig('max_width', 2000); + +// Check to replace predefined size +if (isset($sizes[$newWidth])) { + $newWidth = $sizes[$newWidth]; +} + +// Support width as % of original width +if ($newWidth[strlen($newWidth)-1] == '%') { + is_numeric(substr($newWidth, 0, -1)) + or errorPage('Width % not numeric.'); +} else { + is_null($newWidth) + or ($newWidth > 10 && $newWidth <= $maxWidth) + or errorPage('Width out of range.'); +} + +verbose("new width = $newWidth"); + + + +/** + * height, h - set target height, affecting the resulting image width, height and resize options + */ +$newHeight = get(array('height', 'h')); +$maxHeight = getConfig('max_height', 2000); + +// Check to replace predefined size +if (isset($sizes[$newHeight])) { + $newHeight = $sizes[$newHeight]; +} + +// height +if ($newHeight[strlen($newHeight)-1] == '%') { + is_numeric(substr($newHeight, 0, -1)) + or errorPage('Height % out of range.'); +} else { + is_null($newHeight) + or ($newHeight > 10 && $newHeight <= $maxHeight) + or errorPage('Hight out of range.'); +} + +verbose("new height = $newHeight"); + + + +/** + * aspect-ratio, ar - affecting the resulting image width, height and resize options + */ +$aspectRatio = get(array('aspect-ratio', 'ar')); +$aspectRatioConstant = getConfig('aspect_ratio_constant', function () { + return array( + '3:1' => 3/1, + '3:2' => 3/2, + '4:3' => 4/3, + '8:5' => 8/5, + '16:10' => 16/10, + '16:9' => 16/9, + 'golden' => 1.618, + ); +}); + +// Check to replace predefined aspect ratio +$aspectRatios = call_user_func($aspectRatioConstant); +$negateAspectRatio = ($aspectRatio[0] == '!') ? true : false; +$aspectRatio = $negateAspectRatio ? substr($aspectRatio, 1) : $aspectRatio; + +if (isset($aspectRatios[$aspectRatio])) { + $aspectRatio = $aspectRatios[$aspectRatio]; +} + +if ($negateAspectRatio) { + $aspectRatio = 1 / $aspectRatio; +} + +is_null($aspectRatio) + or is_numeric($aspectRatio) + or errorPage('Aspect ratio out of range'); + +verbose("aspect ratio = $aspectRatio"); + + + +/** + * crop-to-fit, cf - affecting the resulting image width, height and resize options + */ +$cropToFit = getDefined(array('crop-to-fit', 'cf'), true, false); + +verbose("crop to fit = $cropToFit"); + + + +/** + * Set default background color from config file. + */ +$backgroundColor = getConfig('background_color', null); + +if ($backgroundColor) { + $img->setDefaultBackgroundColor($backgroundColor); + verbose("Using default background_color = $backgroundColor"); +} + + + +/** + * bgColor - Default background color to use + */ +$bgColor = get(array('bgColor', 'bg-color', 'bgc'), null); + +verbose("bgColor = $bgColor"); + + + +/** + * fill-to-fit, ff - affecting the resulting image width, height and resize options + */ +$fillToFit = get(array('fill-to-fit', 'ff'), null); + +verbose("fill-to-fit = $fillToFit"); + +if ($fillToFit !== null) { + + if (!empty($fillToFit)) { + $bgColor = $fillToFit; + verbose("fillToFit changed bgColor to = $bgColor"); + } + + $fillToFit = true; + verbose("fill-to-fit (fixed) = $fillToFit"); +} + + + +/** + * no-ratio, nr, stretch - affecting the resulting image width, height and resize options + */ +$keepRatio = getDefined(array('no-ratio', 'nr', 'stretch'), false, true); + +verbose("keep ratio = $keepRatio"); + + + +/** + * crop, c - affecting the resulting image width, height and resize options + */ +$crop = get(array('crop', 'c')); + +verbose("crop = $crop"); + + + +/** + * area, a - affecting the resulting image width, height and resize options + */ +$area = get(array('area', 'a')); + +verbose("area = $area"); + + + +/** + * skip-original, so - skip the original image and always process a new image + */ +$useOriginal = getDefined(array('skip-original', 'so'), false, true); + +verbose("use original = $useOriginal"); + + + +/** + * no-cache, nc - skip the cached version and process and create a new version in cache. + */ +$useCache = getDefined(array('no-cache', 'nc'), false, true); + +verbose("use cache = $useCache"); + + + +/** + * quality, q - set level of quality for jpeg images + */ +$quality = get(array('quality', 'q')); + +is_null($quality) + or ($quality > 0 and $quality <= 100) + or errorPage('Quality out of range'); + +verbose("quality = $quality"); + + + +/** + * compress, co - what strategy to use when compressing png images + */ +$compress = get(array('compress', 'co')); + + +is_null($compress) + or ($compress > 0 and $compress <= 9) + or errorPage('Compress out of range'); + +verbose("compress = $compress"); + + + +/** + * save-as, sa - what type of image to save + */ +$saveAs = get(array('save-as', 'sa')); + +verbose("save as = $saveAs"); + + + +/** + * scale, s - Processing option, scale up or down the image prior actual resize + */ +$scale = get(array('scale', 's')); + +is_null($scale) + or ($scale >= 0 and $scale <= 400) + or errorPage('Scale out of range'); + +verbose("scale = $scale"); + + + +/** + * palette, p - Processing option, create a palette version of the image + */ +$palette = getDefined(array('palette', 'p'), true, false); + +verbose("palette = $palette"); + + + +/** + * sharpen - Processing option, post filter for sharpen effect + */ +$sharpen = getDefined('sharpen', true, null); + +verbose("sharpen = $sharpen"); + + + +/** + * emboss - Processing option, post filter for emboss effect + */ +$emboss = getDefined('emboss', true, null); + +verbose("emboss = $emboss"); + + + +/** + * blur - Processing option, post filter for blur effect + */ +$blur = getDefined('blur', true, null); + +verbose("blur = $blur"); + + + +/** + * rotateBefore - Rotate the image with an angle, before processing + */ +$rotateBefore = get(array('rotateBefore', 'rotate-before', 'rb')); + +is_null($rotateBefore) + or ($rotateBefore >= -360 and $rotateBefore <= 360) + or errorPage('RotateBefore out of range'); + +verbose("rotateBefore = $rotateBefore"); + + + +/** + * rotateAfter - Rotate the image with an angle, before processing + */ +$rotateAfter = get(array('rotateAfter', 'rotate-after', 'ra', 'rotate', 'r')); + +is_null($rotateAfter) + or ($rotateAfter >= -360 and $rotateAfter <= 360) + or errorPage('RotateBefore out of range'); + +verbose("rotateAfter = $rotateAfter"); + + + +/** + * autoRotate - Auto rotate based on EXIF information + */ +$autoRotate = getDefined(array('autoRotate', 'auto-rotate', 'aro'), true, false); + +verbose("autoRotate = $autoRotate"); + + + +/** + * filter, f, f0-f9 - Processing option, post filter for various effects using imagefilter() + */ +$filters = array(); +$filter = get(array('filter', 'f')); +if ($filter) { + $filters[] = $filter; +} + +for ($i = 0; $i < 10; $i++) { + $filter = get(array("filter{$i}", "f{$i}")); + if ($filter) { + $filters[] = $filter; + } +} + +verbose("filters = " . print_r($filters, 1)); + + + +/** + * json - output the image as a JSON object with details on the image. + */ +$outputFormat = getDefined('json', 'json', null); + +verbose("json = $outputFormat"); + + + +/** + * dpr - change to get larger image to easier support larger dpr, such as retina. + */ +$dpr = get(array('ppi', 'dpr', 'device-pixel-ratio'), 1); + +verbose("dpr = $dpr"); + + + +/** + * convolve - image convolution as in http://php.net/manual/en/function.imageconvolution.php + */ +$convolve = get('convolve', null); +$convolutionConstant = getConfig('convolution_constant', array()); + +// Check if the convolve is matching an existing constant +if ($convolve && isset($convolutionConstant)) { + $img->addConvolveExpressions($convolutionConstant); + verbose("convolve constant = " . print_r($convolutionConstant, 1)); +} + +verbose("convolve = " . print_r($convolve, 1)); + + + +/** + * no-upscale, nu - Do not upscale smaller image to larger dimension. + */ +$upscale = getDefined(array('no-upscale', 'nu'), false, true); + +verbose("upscale = $upscale"); + + + +/** + * Get details for post processing + */ +$postProcessing = getConfig('postprocessing', array( + 'png_filter' => false, + 'png_filter_cmd' => '/usr/local/bin/optipng -q', + + 'png_deflate' => false, + 'png_deflate_cmd' => '/usr/local/bin/pngout -q', + + 'jpeg_optimize' => false, + 'jpeg_optimize_cmd' => '/usr/local/bin/jpegtran -copy none -optimize', +)); + + + +/** + * alias - Save resulting image to another alias name. + * Password always apply, must be defined. + */ +$alias = get('alias', null); +$aliasPath = getConfig('alias_path', null); +$validAliasname = getConfig('valid_aliasname', '#^[a-z0-9A-Z-_]+$#'); +$aliasTarget = null; + +if ($alias && $aliasPath && $passwordMatch) { + + $aliasTarget = $aliasPath . $alias; + $useCache = false; + + is_writable($aliasPath) + or errorPage("Directory for alias is not writable."); + + preg_match($validAliasname, $alias) + or errorPage('Filename for alias contains invalid characters. Do not add extension.'); + +} else if ($alias) { + errorPage('Alias is not enabled in the config file or password not matching.'); +} + +verbose("alias = $alias"); + + + +/** + * Display image if verbose mode + */ +if ($verbose) { + $query = array(); + parse_str($_SERVER['QUERY_STRING'], $query); + unset($query['verbose']); + unset($query['v']); + unset($query['nocache']); + unset($query['nc']); + unset($query['json']); + $url1 = '?' . htmlentities(urldecode(http_build_query($query))); + $url2 = '?' . urldecode(http_build_query($query)); + echo <<$url1
+ +

+
+
+
+EOD;
+}
+
+
+
+/**
+ * Get the cachepath from config.
+ */
+$cachePath = getConfig('cache_path', __DIR__ . '/../cache/');
+
+
+
+/**
+ * Load, process and output the image
+ */
+$img->log("Incoming arguments: " . print_r(verbose(), 1))
+    ->setSaveFolder($cachePath)
+    ->useCache($useCache)
+    ->setSource($srcImage, $imagePath)
+    ->setOptions(
+        array(
+            // Options for calculate dimensions
+            'newWidth'  => $newWidth,
+            'newHeight' => $newHeight,
+            'aspectRatio' => $aspectRatio,
+            'keepRatio' => $keepRatio,
+            'cropToFit' => $cropToFit,
+            'fillToFit' => $fillToFit,
+            'crop'      => $crop,
+            'area'      => $area,
+            'upscale'   => $upscale,
+
+            // Pre-processing, before resizing is done
+            'scale'        => $scale,
+            'rotateBefore' => $rotateBefore,
+            'autoRotate'   => $autoRotate,
+
+            // General processing options
+            'bgColor'    => $bgColor,
+
+            // Post-processing, after resizing is done
+            'palette'   => $palette,
+            'filters'   => $filters,
+            'sharpen'   => $sharpen,
+            'emboss'    => $emboss,
+            'blur'      => $blur,
+            'convolve'  => $convolve,
+            'rotateAfter' => $rotateAfter,
+
+            // Output format
+            'outputFormat' => $outputFormat,
+            'dpr'          => $dpr,
+        )
+    )
+    ->loadImageDetails()
+    ->initDimensions()
+    ->calculateNewWidthAndHeight()
+    ->setSaveAsExtension($saveAs)
+    ->setJpegQuality($quality)
+    ->setPngCompression($compress)
+    ->useOriginalIfPossible($useOriginal)
+    ->generateFilename($cachePath)
+    ->useCacheIfPossible($useCache)
+    ->load()
+    ->preResize()
+    ->resize()
+    ->postResize()
+    ->setPostProcessingOptions($postProcessing)
+    ->save()
+    ->linkToCacheFile($aliasTarget)
+    ->output();
+
+
+
diff --git a/webroot/imgp.php b/webroot/imgp.php
new file mode 100644
index 0000000..932acf6
--- /dev/null
+++ b/webroot/imgp.php
@@ -0,0 +1,3886 @@
+ 'production', // 'production', 'development', 'strict'
+    //'image_path'   =>  __DIR__ . '/img/',
+    //'cache_path'   =>  __DIR__ . '/../cache/',
+    //'alias_path'   =>  __DIR__ . '/img/alias/',
+    //'remote_allow'    => true,
+    //'password' => false, // "secret-password",
+
+);
+
+
+
+/**
+ * Get a image from a remote server using HTTP GET and If-Modified-Since.
+ *
+ */
+class CHttpGet
+{
+    private $request  = array();
+    private $response = array();
+
+
+
+    /**
+    * Constructor
+    *
+    */
+    public function __construct()
+    {
+        $this->request['header'] = array();
+    }
+
+
+
+    /**
+     * Set the url for the request.
+     *
+     * @param string $url
+     *
+     * @return $this
+     */
+    public function setUrl($url)
+    {
+        $this->request['url'] = $url;
+        return $this;
+    }
+
+
+
+    /**
+     * Set custom header field for the request.
+     *
+     * @param string $field
+     * @param string $value
+     *
+     * @return $this
+     */
+    public function setHeader($field, $value)
+    {
+        $this->request['header'][] = "$field: $value";
+        return $this;
+    }
+
+
+
+    /**
+     * Set header fields for the request.
+     *
+     * @param string $field
+     * @param string $value
+     *
+     * @return $this
+     */
+    public function parseHeader()
+    {
+        $header = explode("\r\n", rtrim($this->response['headerRaw'], "\r\n"));
+        $output = array();
+
+        if ('HTTP' === substr($header[0], 0, 4)) {
+            list($output['version'], $output['status']) = explode(' ', $header[0]);
+            unset($header[0]);
+        }
+
+        foreach ($header as $entry) {
+            $pos = strpos($entry, ':');
+            $output[trim(substr($entry, 0, $pos))] = trim(substr($entry, $pos + 1));
+        }
+
+        $this->response['header'] = $output;
+        return $this;
+    }
+
+
+
+    /**
+     * Perform the request.
+     *
+     * @param boolean $debug set to true to dump headers.
+     *
+     * @return boolean
+     */
+    public function doGet($debug = false)
+    {
+        $options = array(
+            CURLOPT_URL             => $this->request['url'],
+            CURLOPT_HEADER          => 1,
+            CURLOPT_HTTPHEADER      => $this->request['header'],
+            CURLOPT_AUTOREFERER     => true,
+            CURLOPT_RETURNTRANSFER  => true,
+            CURLINFO_HEADER_OUT     => $debug,
+            CURLOPT_CONNECTTIMEOUT  => 5,
+            CURLOPT_TIMEOUT         => 5,
+        );
+
+        $ch = curl_init();
+        curl_setopt_array($ch, $options);
+        $response = curl_exec($ch);
+
+        if (!$response) {
+            return false;
+        }
+
+        $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
+        $this->response['headerRaw'] = substr($response, 0, $headerSize);
+        $this->response['body']      = substr($response, $headerSize);
+
+        $this->parseHeader();
+
+        if ($debug) {
+            $info = curl_getinfo($ch);
+            echo "Request header
", var_dump($info['request_header']), "
"; + echo "Response header (raw)
", var_dump($this->response['headerRaw']), "
"; + echo "Response header (parsed)
", var_dump($this->response['header']), "
"; + } + + curl_close($ch); + return true; + } + + + + /** + * Get HTTP code of response. + * + * @return integer as HTTP status code or null if not available. + */ + public function getStatus() + { + return isset($this->response['header']['status']) + ? (int) $this->response['header']['status'] + : null; + } + + + + /** + * Get file modification time of response. + * + * @return int as timestamp. + */ + public function getLastModified() + { + return isset($this->response['header']['Last-Modified']) + ? strtotime($this->response['header']['Last-Modified']) + : null; + } + + + + /** + * Get content type. + * + * @return string as the content type or null if not existing or invalid. + */ + public function getContentType() + { + $type = isset($this->response['header']['Content-Type']) + ? $this->response['header']['Content-Type'] + : null; + + return preg_match('#[a-z]+/[a-z]+#', $type) + ? $type + : null; + } + + + + /** + * Get file modification time of response. + * + * @param mixed $default as default value (int seconds) if date is + * missing in response header. + * + * @return int as timestamp or $default if Date is missing in + * response header. + */ + public function getDate($default = false) + { + return isset($this->response['header']['Date']) + ? strtotime($this->response['header']['Date']) + : $default; + } + + + + /** + * Get max age of cachable item. + * + * @param mixed $default as default value if date is missing in response + * header. + * + * @return int as timestamp or false if not available. + */ + public function getMaxAge($default = false) + { + $cacheControl = isset($this->response['header']['Cache-Control']) + ? $this->response['header']['Cache-Control'] + : null; + + $maxAge = null; + if ($cacheControl) { + // max-age=2592000 + $part = explode('=', $cacheControl); + $maxAge = ($part[0] == "max-age") + ? (int) $part[1] + : null; + } + + if ($maxAge) { + return $maxAge; + } + + $expire = isset($this->response['header']['Expires']) + ? strtotime($this->response['header']['Expires']) + : null; + + return $expire ? $expire : $default; + } + + + + /** + * Get body of response. + * + * @return string as body. + */ + public function getBody() + { + return $this->response['body']; + } +} + + + +/** + * Get a image from a remote server using HTTP GET and If-Modified-Since. + * + */ +class CRemoteImage +{ + /** + * Path to cache files. + */ + private $saveFolder = null; + + + + /** + * Use cache or not. + */ + private $useCache = true; + + + + /** + * HTTP object to aid in download file. + */ + private $http; + + + + /** + * Status of the HTTP request. + */ + private $status; + + + + /** + * Defalt age for cached items 60*60*24*7. + */ + private $defaultMaxAge = 604800; + + + + /** + * Url of downloaded item. + */ + private $url; + + + + /** + * Base name of cache file for downloaded item. + */ + private $fileName; + + + + /** + * Filename for json-file with details of cached item. + */ + private $fileJson; + + + + /** + * Filename for image-file. + */ + private $fileImage; + + + + /** + * Cache details loaded from file. + */ + private $cache; + + + + /** + * Constructor + * + */ + public function __construct() + { + ; + } + + + /** + * Get status of last HTTP request. + * + * @return int as status + */ + public function getStatus() + { + return $this->status; + } + + + + /** + * Get JSON details for cache item. + * + * @return array with json details on cache. + */ + public function getDetails() + { + return $this->cache; + } + + + + /** + * Set the path to the cache directory. + * + * @param boolean $use true to use the cache and false to ignore cache. + * + * @return $this + */ + public function setCache($path) + { + $this->saveFolder = $path; + return $this; + } + + + + /** + * Check if cache is writable or throw exception. + * + * @return $this + * + * @throws Exception if cahce folder is not writable. + */ + public function isCacheWritable() + { + if (!is_writable($this->saveFolder)) { + throw new Exception("Cache folder is not writable for downloaded files."); + } + return $this; + } + + + + /** + * Decide if the cache should be used or not before trying to download + * a remote file. + * + * @param boolean $use true to use the cache and false to ignore cache. + * + * @return $this + */ + public function useCache($use = true) + { + $this->useCache = $use; + return $this; + } + + + + /** + * Translate a content type to a file extension. + * + * @param string $type a valid content type. + * + * @return string as file extension or false if no match. + */ + function contentTypeToFileExtension($type) { + $extension = array( + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + ); + + return isset($extension[$type]) + ? $extension[$type] + : false; + } + + + + /** + * Set header fields. + * + * @return $this + */ + function setHeaderFields() { + $this->http->setHeader("User-Agent", "CImage/0.6 (PHP/". phpversion() . " cURL)"); + $this->http->setHeader("Accept", "image/jpeg,image/png,image/gif"); + + if ($this->useCache) { + $this->http->setHeader("Cache-Control", "max-age=0"); + } else { + $this->http->setHeader("Cache-Control", "no-cache"); + $this->http->setHeader("Pragma", "no-cache"); + } + } + + + + /** + * Save downloaded resource to cache. + * + * @return string as path to saved file or false if not saved. + */ + function save() { + + $this->cache = array(); + $date = $this->http->getDate(time()); + $maxAge = $this->http->getMaxAge($this->defaultMaxAge); + $lastModified = $this->http->getLastModified(); + $type = $this->http->getContentType(); + $extension = $this->contentTypeToFileExtension($type); + + $this->cache['Date'] = gmdate("D, d M Y H:i:s T", $date); + $this->cache['Max-Age'] = $maxAge; + $this->cache['Content-Type'] = $type; + $this->cache['File-Extension'] = $extension; + + if ($lastModified) { + $this->cache['Last-Modified'] = gmdate("D, d M Y H:i:s T", $lastModified); + } + + if ($extension) { + + $this->fileImage = $this->fileName . "." . $extension; + + // Save only if body is a valid image + $body = $this->http->getBody(); + $img = imagecreatefromstring($body); + + if ($img !== false) { + file_put_contents($this->fileImage, $body); + file_put_contents($this->fileJson, json_encode($this->cache)); + return $this->fileImage; + } + } + + return false; + } + + + + /** + * Got a 304 and updates cache with new age. + * + * @return string as path to cached file. + */ + function updateCacheDetails() { + + $date = $this->http->getDate(time()); + $maxAge = $this->http->getMaxAge($this->defaultMaxAge); + $lastModified = $this->http->getLastModified(); + + $this->cache['Date'] = gmdate("D, d M Y H:i:s T", $date); + $this->cache['Max-Age'] = $maxAge; + + if ($lastModified) { + $this->cache['Last-Modified'] = gmdate("D, d M Y H:i:s T", $lastModified); + } + + file_put_contents($this->fileJson, json_encode($this->cache)); + return $this->fileImage; + } + + + + /** + * Download a remote file and keep a cache of downloaded files. + * + * @param string $url a remote url. + * + * @return string as path to downloaded file or false if failed. + */ + function download($url) { + + $this->http = new CHttpGet(); + $this->url = $url; + + // First check if the cache is valid and can be used + $this->loadCacheDetails(); + + if ($this->useCache) { + $src = $this->getCachedSource(); + if ($src) { + $this->status = 1; + return $src; + } + } + + // Do a HTTP request to download item + $this->setHeaderFields(); + $this->http->setUrl($this->url); + $this->http->doGet(); + + $this->status = $this->http->getStatus(); + if ($this->status === 200) { + $this->isCacheWritable(); + return $this->save(); + } else if ($this->status === 304) { + $this->isCacheWritable(); + return $this->updateCacheDetails(); + } + + return false; + } + + + + /** + * Get the path to the cached image file if the cache is valid. + * + * @return $this + */ + public function loadCacheDetails() + { + $cacheFile = str_replace(["/", ":", "#", ".", "?"], "-", $this->url); + $this->fileName = $this->saveFolder . $cacheFile; + $this->fileJson = $this->fileName . ".json"; + if (is_readable($this->fileJson)) { + $this->cache = json_decode(file_get_contents($this->fileJson), true); + } + } + + + + /** + * Get the path to the cached image file if the cache is valid. + * + * @return string as the path ot the image file or false if no cache. + */ + public function getCachedSource() + { + $this->fileImage = $this->fileName . "." . $this->cache['File-Extension']; + $imageExists = is_readable($this->fileImage); + + // Is cache valid? + $date = strtotime($this->cache['Date']); + $maxAge = $this->cache['Max-Age']; + $now = time(); + if ($imageExists && $date + $maxAge > $now) { + return $this->fileImage; + } + + // Prepare for a 304 if available + if ($imageExists && isset($this->cache['Last-Modified'])) { + $this->http->setHeader("If-Modified-Since", $this->cache['Last-Modified']); + } + + return false; + } +} + + + +/** + * Resize and crop images on the fly, store generated images in a cache. + * + * @author Mikael Roos mos@dbwebb.se + * @example http://dbwebb.se/opensource/cimage + * @link https://github.com/mosbth/cimage + */ +class CImage +{ + + /** + * Constants type of PNG image + */ + const PNG_GREYSCALE = 0; + const PNG_RGB = 2; + const PNG_RGB_PALETTE = 3; + const PNG_GREYSCALE_ALPHA = 4; + const PNG_RGB_ALPHA = 6; + + + + /** + * Constant for default image quality when not set + */ + const JPEG_QUALITY_DEFAULT = 60; + + + + /** + * Quality level for JPEG images. + */ + private $quality; + + + + /** + * Is the quality level set from external use (true) or is it default (false)? + */ + private $useQuality = false; + + + + /** + * Constant for default image quality when not set + */ + const PNG_COMPRESSION_DEFAULT = -1; + + + + /** + * Compression level for PNG images. + */ + private $compress; + + + + /** + * Is the compress level set from external use (true) or is it default (false)? + */ + private $useCompress = false; + + + + + /** + * Default background color, red, green, blue, alpha. + * + * @todo remake when upgrading to PHP 5.5 + */ + /* + const BACKGROUND_COLOR = array( + 'red' => 0, + 'green' => 0, + 'blue' => 0, + 'alpha' => null, + );*/ + + + + /** + * Default background color to use. + * + * @todo remake when upgrading to PHP 5.5 + */ + //private $bgColorDefault = self::BACKGROUND_COLOR; + private $bgColorDefault = array( + 'red' => 0, + 'green' => 0, + 'blue' => 0, + 'alpha' => null, + ); + + + /** + * Background color to use, specified as part of options. + */ + private $bgColor; + + + + /** + * Where to save the target file. + */ + private $saveFolder; + + + + /** + * The working image object. + */ + private $image; + + + + /** + * The root folder of images (only used in constructor to create $pathToImage?). + */ + private $imageFolder; + + + + /** + * Image filename, may include subdirectory, relative from $imageFolder + */ + private $imageSrc; + + + + /** + * Actual path to the image, $imageFolder . '/' . $imageSrc + */ + private $pathToImage; + + + + /** + * Original file extension + */ + private $fileExtension; + + + + /** + * File extension to use when saving image. + */ + private $extension; + + + + /** + * Output format, supports null (image) or json. + */ + private $outputFormat = null; + + + + /** + * Verbose mode to print out a trace and display the created image + */ + private $verbose = false; + + + + /** + * Keep a log/trace on what happens + */ + private $log = array(); + + + + /** + * Handle image as palette image + */ + private $palette; + + + + /** + * Target filename, with path, to save resulting image in. + */ + private $cacheFileName; + + + + /** + * Set a format to save image as, or null to use original format. + */ + private $saveAs; + + + /** + * Path to command for filter optimize, for example optipng or null. + */ + private $pngFilter; + + + + /** + * Path to command for deflate optimize, for example pngout or null. + */ + private $pngDeflate; + + + + /** + * Path to command to optimize jpeg images, for example jpegtran or null. + */ + private $jpegOptimize; + + + /** + * Image dimensions, calculated from loaded image. + */ + private $width; // Calculated from source image + private $height; // Calculated from source image + + + /** + * New image dimensions, incoming as argument or calculated. + */ + private $newWidth; + private $newWidthOrig; // Save original value + private $newHeight; + private $newHeightOrig; // Save original value + + + /** + * Change target height & width when different dpr, dpr 2 means double image dimensions. + */ + private $dpr = 1; + + + /** + * Always upscale images, even if they are smaller than target image. + */ + const UPSCALE_DEFAULT = true; + private $upscale = self::UPSCALE_DEFAULT; + + + + /** + * Array with details on how to crop, incoming as argument and calculated. + */ + public $crop; + public $cropOrig; // Save original value + + + /** + * String with details on how to do image convolution. String + * should map a key in the $convolvs array or be a string of + * 11 float values separated by comma. The first nine builds + * up the matrix, then divisor and last offset. + */ + private $convolve; + + + /** + * Custom convolution expressions, matrix 3x3, divisor and offset. + */ + private $convolves = array( + 'lighten' => '0,0,0, 0,12,0, 0,0,0, 9, 0', + 'darken' => '0,0,0, 0,6,0, 0,0,0, 9, 0', + 'sharpen' => '-1,-1,-1, -1,16,-1, -1,-1,-1, 8, 0', + 'sharpen-alt' => '0,-1,0, -1,5,-1, 0,-1,0, 1, 0', + 'emboss' => '1,1,-1, 1,3,-1, 1,-1,-1, 3, 0', + 'emboss-alt' => '-2,-1,0, -1,1,1, 0,1,2, 1, 0', + 'blur' => '1,1,1, 1,15,1, 1,1,1, 23, 0', + 'gblur' => '1,2,1, 2,4,2, 1,2,1, 16, 0', + 'edge' => '-1,-1,-1, -1,8,-1, -1,-1,-1, 9, 0', + 'edge-alt' => '0,1,0, 1,-4,1, 0,1,0, 1, 0', + 'draw' => '0,-1,0, -1,5,-1, 0,-1,0, 0, 0', + 'mean' => '1,1,1, 1,1,1, 1,1,1, 9, 0', + 'motion' => '1,0,0, 0,1,0, 0,0,1, 3, 0', + ); + + + /** + * Resize strategy to fill extra area with background color. + * True or false. + */ + private $fillToFit; + + + /** + * Used with option area to set which parts of the image to use. + */ + private $offset; + + + + /** + * Calculate target dimension for image when using fill-to-fit resize strategy. + */ + private $fillWidth; + private $fillHeight; + + + + /** + * Allow remote file download, default is to disallow remote file download. + */ + private $allowRemote = false; + + + + /** + * Pattern to recognize a remote file. + */ + //private $remotePattern = '#^[http|https]://#'; + private $remotePattern = '#^https?://#'; + + + + /** + * Use the cache if true, set to false to ignore the cached file. + */ + private $useCache = true; + + + /** + * Properties, the class is mutable and the method setOptions() + * decides (partly) what properties are created. + * + * @todo Clean up these and check if and how they are used + */ + + public $keepRatio; + public $cropToFit; + private $cropWidth; + private $cropHeight; + public $crop_x; + public $crop_y; + public $filters; + private $type; // Calculated from source image + private $attr; // Calculated from source image + private $useOriginal; // Use original image if possible + + + + + /** + * Constructor, can take arguments to init the object. + * + * @param string $imageSrc filename which may contain subdirectory. + * @param string $imageFolder path to root folder for images. + * @param string $saveFolder path to folder where to save the new file or null to skip saving. + * @param string $saveName name of target file when saveing. + */ + public function __construct($imageSrc = null, $imageFolder = null, $saveFolder = null, $saveName = null) + { + $this->setSource($imageSrc, $imageFolder); + $this->setTarget($saveFolder, $saveName); + } + + + + /** + * Set verbose mode. + * + * @param boolean $mode true or false to enable and disable verbose mode, + * default is true. + * + * @return $this + */ + public function setVerbose($mode = true) + { + $this->verbose = $mode; + return $this; + } + + + + /** + * Set save folder, base folder for saving cache files. + * + * @todo clean up how $this->saveFolder is used in other methods. + * + * @param string $path where to store cached files. + * + * @return $this + */ + public function setSaveFolder($path) + { + $this->saveFolder = $path; + return $this; + } + + + + /** + * Use cache or not. + * + * @todo clean up how $this->noCache is used in other methods. + * + * @param string $use true or false to use cache. + * + * @return $this + */ + public function useCache($use = true) + { + $this->useCache = $use; + return $this; + } + + + + /** + * Allow or disallow remote image download. + * + * @param boolean $allow true or false to enable and disable. + * @param string $pattern to use to detect if its a remote file. + * + * @return $this + */ + public function setRemoteDownload($allow, $pattern = null) + { + $this->allowRemote = $allow; + $this->remotePattern = $pattern ? $pattern : $this->remotePattern; + + $this->log("Set remote download to: " + . ($this->allowRemote ? "true" : "false") + . " using pattern " + . $this->remotePattern); + + return $this; + } + + + + /** + * Check if the image resource is a remote file or not. + * + * @param string $src check if src is remote. + * + * @return boolean true if $src is a remote file, else false. + */ + public function isRemoteSource($src) + { + $remote = preg_match($this->remotePattern, $src); + $this->log("Detected remote image: " . ($remote ? "true" : "false")); + return $remote; + } + + + + /** + * Check if file extension is valid as a file extension. + * + * @param string $extension of image file. + * + * @return $this + */ + private function checkFileExtension($extension) + { + $valid = array('jpg', 'jpeg', 'png', 'gif'); + + in_array(strtolower($extension), $valid) + or $this->raiseError('Not a valid file extension.'); + + return $this; + } + + + + /** + * Download a remote image and return path to its local copy. + * + * @param string $src remote path to image. + * + * @return string as path to downloaded remote source. + */ + public function downloadRemoteSource($src) + { + $remote = new CRemoteImage(); + $cache = $this->saveFolder . "/remote/"; + + if (!is_dir($cache)) { + if (!is_writable($this->saveFolder)) { + throw new Exception("Can not create remote cache, cachefolder not writable."); + } + mkdir($cache); + $this->log("The remote cache does not exists, creating it."); + } + + if (!is_writable($cache)) { + $this->log("The remote cache is not writable."); + } + + $remote->setCache($cache); + $remote->useCache($this->useCache); + $src = $remote->download($src); + + $this->log("Remote HTTP status: " . $remote->getStatus()); + $this->log("Remote item has local cached file: $src"); + $this->log("Remote details on cache:" . print_r($remote->getDetails(), true)); + + return $src; + } + + + + /** + * Set src file. + * + * @param string $src of image. + * @param string $dir as base directory where images are. + * + * @return $this + */ + public function setSource($src, $dir = null) + { + if (!isset($src)) { + return $this; + } + + if ($this->allowRemote && $this->isRemoteSource($src)) { + $src = $this->downloadRemoteSource($src); + $dir = null; + } + + if (!isset($dir)) { + $dir = dirname($src); + $src = basename($src); + } + + $this->imageSrc = ltrim($src, '/'); + $this->imageFolder = rtrim($dir, '/'); + $this->pathToImage = $this->imageFolder . '/' . $this->imageSrc; + $this->fileExtension = strtolower(pathinfo($this->pathToImage, PATHINFO_EXTENSION)); + //$this->extension = $this->fileExtension; + + $this->checkFileExtension($this->fileExtension); + + return $this; + } + + + + /** + * Set target file. + * + * @param string $src of target image. + * @param string $dir as base directory where images are stored. + * + * @return $this + */ + public function setTarget($src = null, $dir = null) + { + if (!(isset($src) && isset($dir))) { + return $this; + } + + $this->saveFolder = $dir; + $this->cacheFileName = $dir . '/' . $src; + + /* Allow readonly cache + is_writable($this->saveFolder) + or $this->raiseError('Target directory is not writable.'); + */ + + // Sanitize filename + $this->cacheFileName = preg_replace('/^a-zA-Z0-9\.-_/', '', $this->cacheFileName); + $this->log("The cache file name is: " . $this->cacheFileName); + + return $this; + } + + + + /** + * Set options to use when processing image. + * + * @param array $args used when processing image. + * + * @return $this + */ + public function setOptions($args) + { + $this->log("Set new options for processing image."); + + $defaults = array( + // Options for calculate dimensions + 'newWidth' => null, + 'newHeight' => null, + 'aspectRatio' => null, + 'keepRatio' => true, + 'cropToFit' => false, + 'fillToFit' => null, + 'crop' => null, //array('width'=>null, 'height'=>null, 'start_x'=>0, 'start_y'=>0), + 'area' => null, //'0,0,0,0', + 'upscale' => self::UPSCALE_DEFAULT, + + // Options for caching or using original + 'useCache' => true, + 'useOriginal' => true, + + // Pre-processing, before resizing is done + 'scale' => null, + 'rotateBefore' => null, + 'autoRotate' => false, + + // General options + 'bgColor' => null, + + // Post-processing, after resizing is done + 'palette' => null, + 'filters' => null, + 'sharpen' => null, + 'emboss' => null, + 'blur' => null, + 'convolve' => null, + 'rotateAfter' => null, + + // Output format + 'outputFormat' => null, + 'dpr' => 1, + + // Options for saving + //'quality' => null, + //'compress' => null, + //'saveAs' => null, + ); + + // Convert crop settings from string to array + if (isset($args['crop']) && !is_array($args['crop'])) { + $pices = explode(',', $args['crop']); + $args['crop'] = array( + 'width' => $pices[0], + 'height' => $pices[1], + 'start_x' => $pices[2], + 'start_y' => $pices[3], + ); + } + + // Convert area settings from string to array + if (isset($args['area']) && !is_array($args['area'])) { + $pices = explode(',', $args['area']); + $args['area'] = array( + 'top' => $pices[0], + 'right' => $pices[1], + 'bottom' => $pices[2], + 'left' => $pices[3], + ); + } + + // Convert filter settings from array of string to array of array + if (isset($args['filters']) && is_array($args['filters'])) { + foreach ($args['filters'] as $key => $filterStr) { + $parts = explode(',', $filterStr); + $filter = $this->mapFilter($parts[0]); + $filter['str'] = $filterStr; + for ($i=1; $i<=$filter['argc']; $i++) { + if (isset($parts[$i])) { + $filter["arg{$i}"] = $parts[$i]; + } else { + throw new Exception( + 'Missing arg to filter, review how many arguments are needed at + http://php.net/manual/en/function.imagefilter.php' + ); + } + } + $args['filters'][$key] = $filter; + } + } + + // Merge default arguments with incoming and set properties. + //$args = array_merge_recursive($defaults, $args); + $args = array_merge($defaults, $args); + foreach ($defaults as $key => $val) { + $this->{$key} = $args[$key]; + } + + if ($this->bgColor) { + $this->setDefaultBackgroundColor($this->bgColor); + } + + // Save original values to enable re-calculating + $this->newWidthOrig = $this->newWidth; + $this->newHeightOrig = $this->newHeight; + $this->cropOrig = $this->crop; + + return $this; + } + + + + /** + * Map filter name to PHP filter and id. + * + * @param string $name the name of the filter. + * + * @return array with filter settings + * @throws Exception + */ + private function mapFilter($name) + { + $map = array( + 'negate' => array('id'=>0, 'argc'=>0, 'type'=>IMG_FILTER_NEGATE), + 'grayscale' => array('id'=>1, 'argc'=>0, 'type'=>IMG_FILTER_GRAYSCALE), + 'brightness' => array('id'=>2, 'argc'=>1, 'type'=>IMG_FILTER_BRIGHTNESS), + 'contrast' => array('id'=>3, 'argc'=>1, 'type'=>IMG_FILTER_CONTRAST), + 'colorize' => array('id'=>4, 'argc'=>4, 'type'=>IMG_FILTER_COLORIZE), + 'edgedetect' => array('id'=>5, 'argc'=>0, 'type'=>IMG_FILTER_EDGEDETECT), + 'emboss' => array('id'=>6, 'argc'=>0, 'type'=>IMG_FILTER_EMBOSS), + 'gaussian_blur' => array('id'=>7, 'argc'=>0, 'type'=>IMG_FILTER_GAUSSIAN_BLUR), + 'selective_blur' => array('id'=>8, 'argc'=>0, 'type'=>IMG_FILTER_SELECTIVE_BLUR), + 'mean_removal' => array('id'=>9, 'argc'=>0, 'type'=>IMG_FILTER_MEAN_REMOVAL), + 'smooth' => array('id'=>10, 'argc'=>1, 'type'=>IMG_FILTER_SMOOTH), + 'pixelate' => array('id'=>11, 'argc'=>2, 'type'=>IMG_FILTER_PIXELATE), + ); + + if (isset($map[$name])) { + return $map[$name]; + } else { + throw new Exception('No such filter.'); + } + } + + + + /** + * Load image details from original image file. + * + * @param string $file the file to load or null to use $this->pathToImage. + * + * @return $this + * @throws Exception + */ + public function loadImageDetails($file = null) + { + $file = $file ? $file : $this->pathToImage; + + is_readable($file) + or $this->raiseError('Image file does not exist.'); + + // Get details on image + $info = list($this->width, $this->height, $this->type, $this->attr) = getimagesize($file); + !empty($info) or $this->raiseError("The file doesn't seem to be an image."); + + if ($this->verbose) { + $this->log("Image file: {$file}"); + $this->log("Image width x height (type): {$this->width} x {$this->height} ({$this->type})."); + $this->log("Image filesize: " . filesize($file) . " bytes."); + } + + return $this; + } + + + + /** + * Init new width and height and do some sanity checks on constraints, before any + * processing can be done. + * + * @return $this + * @throws Exception + */ + public function initDimensions() + { + $this->log("Init dimension (before) newWidth x newHeight is {$this->newWidth} x {$this->newHeight}."); + + // width as % + if ($this->newWidth[strlen($this->newWidth)-1] == '%') { + $this->newWidth = $this->width * substr($this->newWidth, 0, -1) / 100; + $this->log("Setting new width based on % to {$this->newWidth}"); + } + + // height as % + if ($this->newHeight[strlen($this->newHeight)-1] == '%') { + $this->newHeight = $this->height * substr($this->newHeight, 0, -1) / 100; + $this->log("Setting new height based on % to {$this->newHeight}"); + } + + is_null($this->aspectRatio) or is_numeric($this->aspectRatio) or $this->raiseError('Aspect ratio out of range'); + + // width & height from aspect ratio + if ($this->aspectRatio && is_null($this->newWidth) && is_null($this->newHeight)) { + if ($this->aspectRatio >= 1) { + $this->newWidth = $this->width; + $this->newHeight = $this->width / $this->aspectRatio; + $this->log("Setting new width & height based on width & aspect ratio (>=1) to (w x h) {$this->newWidth} x {$this->newHeight}"); + + } else { + $this->newHeight = $this->height; + $this->newWidth = $this->height * $this->aspectRatio; + $this->log("Setting new width & height based on width & aspect ratio (<1) to (w x h) {$this->newWidth} x {$this->newHeight}"); + } + + } elseif ($this->aspectRatio && is_null($this->newWidth)) { + $this->newWidth = $this->newHeight * $this->aspectRatio; + $this->log("Setting new width based on aspect ratio to {$this->newWidth}"); + + } elseif ($this->aspectRatio && is_null($this->newHeight)) { + $this->newHeight = $this->newWidth / $this->aspectRatio; + $this->log("Setting new height based on aspect ratio to {$this->newHeight}"); + } + + // Change width & height based on dpr + if ($this->dpr != 1) { + if (!is_null($this->newWidth)) { + $this->newWidth = round($this->newWidth * $this->dpr); + $this->log("Setting new width based on dpr={$this->dpr} - w={$this->newWidth}"); + } + if (!is_null($this->newHeight)) { + $this->newHeight = round($this->newHeight * $this->dpr); + $this->log("Setting new height based on dpr={$this->dpr} - h={$this->newHeight}"); + } + } + + // Check values to be within domain + is_null($this->newWidth) + or is_numeric($this->newWidth) + or $this->raiseError('Width not numeric'); + + is_null($this->newHeight) + or is_numeric($this->newHeight) + or $this->raiseError('Height not numeric'); + + $this->log("Init dimension (after) newWidth x newHeight is {$this->newWidth} x {$this->newHeight}."); + + return $this; + } + + + + /** + * Calculate new width and height of image, based on settings. + * + * @return $this + */ + public function calculateNewWidthAndHeight() + { + // Crop, use cropped width and height as base for calulations + $this->log("Calculate new width and height."); + $this->log("Original width x height is {$this->width} x {$this->height}."); + $this->log("Target dimension (before calculating) newWidth x newHeight is {$this->newWidth} x {$this->newHeight}."); + + // Check if there is an area to crop off + if (isset($this->area)) { + $this->offset['top'] = round($this->area['top'] / 100 * $this->height); + $this->offset['right'] = round($this->area['right'] / 100 * $this->width); + $this->offset['bottom'] = round($this->area['bottom'] / 100 * $this->height); + $this->offset['left'] = round($this->area['left'] / 100 * $this->width); + $this->offset['width'] = $this->width - $this->offset['left'] - $this->offset['right']; + $this->offset['height'] = $this->height - $this->offset['top'] - $this->offset['bottom']; + $this->width = $this->offset['width']; + $this->height = $this->offset['height']; + $this->log("The offset for the area to use is top {$this->area['top']}%, right {$this->area['right']}%, bottom {$this->area['bottom']}%, left {$this->area['left']}%."); + $this->log("The offset for the area to use is top {$this->offset['top']}px, right {$this->offset['right']}px, bottom {$this->offset['bottom']}px, left {$this->offset['left']}px, width {$this->offset['width']}px, height {$this->offset['height']}px."); + } + + $width = $this->width; + $height = $this->height; + + // Check if crop is set + if ($this->crop) { + $width = $this->crop['width'] = $this->crop['width'] <= 0 ? $this->width + $this->crop['width'] : $this->crop['width']; + $height = $this->crop['height'] = $this->crop['height'] <= 0 ? $this->height + $this->crop['height'] : $this->crop['height']; + + if ($this->crop['start_x'] == 'left') { + $this->crop['start_x'] = 0; + } elseif ($this->crop['start_x'] == 'right') { + $this->crop['start_x'] = $this->width - $width; + } elseif ($this->crop['start_x'] == 'center') { + $this->crop['start_x'] = round($this->width / 2) - round($width / 2); + } + + if ($this->crop['start_y'] == 'top') { + $this->crop['start_y'] = 0; + } elseif ($this->crop['start_y'] == 'bottom') { + $this->crop['start_y'] = $this->height - $height; + } elseif ($this->crop['start_y'] == 'center') { + $this->crop['start_y'] = round($this->height / 2) - round($height / 2); + } + + $this->log("Crop area is width {$width}px, height {$height}px, start_x {$this->crop['start_x']}px, start_y {$this->crop['start_y']}px."); + } + + // Calculate new width and height if keeping aspect-ratio. + if ($this->keepRatio) { + + $this->log("Keep aspect ratio."); + + // Crop-to-fit and both new width and height are set. + if (($this->cropToFit || $this->fillToFit) && isset($this->newWidth) && isset($this->newHeight)) { + + // Use newWidth and newHeigh as width/height, image should fit in box. + $this->log("Use newWidth and newHeigh as width/height, image should fit in box."); + + } elseif (isset($this->newWidth) && isset($this->newHeight)) { + + // Both new width and height are set. + // Use newWidth and newHeigh as max width/height, image should not be larger. + $ratioWidth = $width / $this->newWidth; + $ratioHeight = $height / $this->newHeight; + $ratio = ($ratioWidth > $ratioHeight) ? $ratioWidth : $ratioHeight; + $this->newWidth = round($width / $ratio); + $this->newHeight = round($height / $ratio); + $this->log("New width and height was set."); + + } elseif (isset($this->newWidth)) { + + // Use new width as max-width + $factor = (float)$this->newWidth / (float)$width; + $this->newHeight = round($factor * $height); + $this->log("New width was set."); + + } elseif (isset($this->newHeight)) { + + // Use new height as max-hight + $factor = (float)$this->newHeight / (float)$height; + $this->newWidth = round($factor * $width); + $this->log("New height was set."); + + } + + // Get image dimensions for pre-resize image. + if ($this->cropToFit || $this->fillToFit) { + + // Get relations of original & target image + $ratioWidth = $width / $this->newWidth; + $ratioHeight = $height / $this->newHeight; + + if ($this->cropToFit) { + + // Use newWidth and newHeigh as defined width/height, + // image should fit the area. + $this->log("Crop to fit."); + $ratio = ($ratioWidth < $ratioHeight) ? $ratioWidth : $ratioHeight; + $this->cropWidth = round($width / $ratio); + $this->cropHeight = round($height / $ratio); + $this->log("Crop width, height, ratio: $this->cropWidth x $this->cropHeight ($ratio)."); + + } else if ($this->fillToFit) { + + // Use newWidth and newHeigh as defined width/height, + // image should fit the area. + $this->log("Fill to fit."); + $ratio = ($ratioWidth < $ratioHeight) ? $ratioHeight : $ratioWidth; + $this->fillWidth = round($width / $ratio); + $this->fillHeight = round($height / $ratio); + $this->log("Fill width, height, ratio: $this->fillWidth x $this->fillHeight ($ratio)."); + } + } + } + + // Crop, ensure to set new width and height + if ($this->crop) { + $this->log("Crop."); + $this->newWidth = round(isset($this->newWidth) ? $this->newWidth : $this->crop['width']); + $this->newHeight = round(isset($this->newHeight) ? $this->newHeight : $this->crop['height']); + } + + // Fill to fit, ensure to set new width and height + /*if ($this->fillToFit) { + $this->log("FillToFit."); + $this->newWidth = round(isset($this->newWidth) ? $this->newWidth : $this->crop['width']); + $this->newHeight = round(isset($this->newHeight) ? $this->newHeight : $this->crop['height']); + }*/ + + // No new height or width is set, use existing measures. + $this->newWidth = round(isset($this->newWidth) ? $this->newWidth : $this->width); + $this->newHeight = round(isset($this->newHeight) ? $this->newHeight : $this->height); + $this->log("Calculated new width x height as {$this->newWidth} x {$this->newHeight}."); + + return $this; + } + + + + /** + * Re-calculate image dimensions when original image dimension has changed. + * + * @return $this + */ + public function reCalculateDimensions() + { + $this->log("Re-calculate image dimensions, newWidth x newHeigh was: " . $this->newWidth . " x " . $this->newHeight); + + $this->newWidth = $this->newWidthOrig; + $this->newHeight = $this->newHeightOrig; + $this->crop = $this->cropOrig; + + $this->initDimensions() + ->calculateNewWidthAndHeight(); + + return $this; + } + + + + /** + * Set extension for filename to save as. + * + * @param string $saveas extension to save image as + * + * @return $this + */ + public function setSaveAsExtension($saveAs = null) + { + if (isset($saveAs)) { + $saveAs = strtolower($saveAs); + $this->checkFileExtension($saveAs); + $this->saveAs = $saveAs; + $this->extension = $saveAs; + } + + $this->log("Prepare to save image using as: " . $this->extension); + + return $this; + } + + + + /** + * Set JPEG quality to use when saving image + * + * @param int $quality as the quality to set. + * + * @return $this + */ + public function setJpegQuality($quality = null) + { + if ($quality) { + $this->useQuality = true; + } + + $this->quality = isset($quality) + ? $quality + : self::JPEG_QUALITY_DEFAULT; + + (is_numeric($this->quality) and $this->quality > 0 and $this->quality <= 100) + or $this->raiseError('Quality not in range.'); + + $this->log("Setting JPEG quality to {$this->quality}."); + + return $this; + } + + + + /** + * Set PNG compressen algorithm to use when saving image + * + * @param int $compress as the algorithm to use. + * + * @return $this + */ + public function setPngCompression($compress = null) + { + if ($compress) { + $this->useCompress = true; + } + + $this->compress = isset($compress) + ? $compress + : self::PNG_COMPRESSION_DEFAULT; + + (is_numeric($this->compress) and $this->compress >= -1 and $this->compress <= 9) + or $this->raiseError('Quality not in range.'); + + $this->log("Setting PNG compression level to {$this->compress}."); + + return $this; + } + + + + /** + * Use original image if possible, check options which affects image processing. + * + * @param boolean $useOrig default is to use original if possible, else set to false. + * + * @return $this + */ + public function useOriginalIfPossible($useOrig = true) + { + if ($useOrig + && ($this->newWidth == $this->width) + && ($this->newHeight == $this->height) + && !$this->area + && !$this->crop + && !$this->cropToFit + && !$this->fillToFit + && !$this->filters + && !$this->sharpen + && !$this->emboss + && !$this->blur + && !$this->convolve + && !$this->palette + && !$this->useQuality + && !$this->useCompress + && !$this->saveAs + && !$this->rotateBefore + && !$this->rotateAfter + && !$this->autoRotate + && !$this->bgColor + && ($this->upscale === self::UPSCALE_DEFAULT) + ) { + $this->log("Using original image."); + $this->output($this->pathToImage); + } + + return $this; + } + + + + /** + * Generate filename to save file in cache. + * + * @param string $base as basepath for storing file. + * + * @return $this + */ + public function generateFilename($base) + { + $parts = pathinfo($this->pathToImage); + $cropToFit = $this->cropToFit ? '_cf' : null; + $fillToFit = $this->fillToFit ? '_ff' : null; + $crop_x = $this->crop_x ? "_x{$this->crop_x}" : null; + $crop_y = $this->crop_y ? "_y{$this->crop_y}" : null; + $scale = $this->scale ? "_s{$this->scale}" : null; + $bgColor = $this->bgColor ? "_bgc{$this->bgColor}" : null; + $quality = $this->quality ? "_q{$this->quality}" : null; + $compress = $this->compress ? "_co{$this->compress}" : null; + $rotateBefore = $this->rotateBefore ? "_rb{$this->rotateBefore}" : null; + $rotateAfter = $this->rotateAfter ? "_ra{$this->rotateAfter}" : null; + + $width = $this->newWidth; + $height = $this->newHeight; + + $offset = isset($this->offset) + ? '_o' . $this->offset['top'] . '-' . $this->offset['right'] . '-' . $this->offset['bottom'] . '-' . $this->offset['left'] + : null; + + $crop = $this->crop + ? '_c' . $this->crop['width'] . '-' . $this->crop['height'] . '-' . $this->crop['start_x'] . '-' . $this->crop['start_y'] + : null; + + $filters = null; + if (isset($this->filters)) { + foreach ($this->filters as $filter) { + if (is_array($filter)) { + $filters .= "_f{$filter['id']}"; + for ($i=1; $i<=$filter['argc']; $i++) { + $filters .= ":".$filter["arg{$i}"]; + } + } + } + } + + $sharpen = $this->sharpen ? 's' : null; + $emboss = $this->emboss ? 'e' : null; + $blur = $this->blur ? 'b' : null; + $palette = $this->palette ? 'p' : null; + + $autoRotate = $this->autoRotate ? 'ar' : null; + + $this->extension = isset($this->extension) + ? $this->extension + : $parts['extension']; + + $optimize = null; + if ($this->extension == 'jpeg' || $this->extension == 'jpg') { + $optimize = $this->jpegOptimize ? 'o' : null; + } elseif ($this->extension == 'png') { + $optimize .= $this->pngFilter ? 'f' : null; + $optimize .= $this->pngDeflate ? 'd' : null; + } + + $convolve = null; + if ($this->convolve) { + $convolve = '_conv' . preg_replace('/[^a-zA-Z0-9]/', '', $this->convolve); + } + + $upscale = null; + if ($this->upscale !== self::UPSCALE_DEFAULT) { + $upscale = '_nu'; + } + + $subdir = str_replace('/', '-', dirname($this->imageSrc)); + $subdir = ($subdir == '.') ? '_.' : $subdir; + $file = $subdir . '_' . $parts['filename'] . '_' . $width . '_' + . $height . $offset . $crop . $cropToFit . $fillToFit + . $crop_x . $crop_y . $upscale + . $quality . $filters . $sharpen . $emboss . $blur . $palette . $optimize + . $scale . $rotateBefore . $rotateAfter . $autoRotate . $bgColor . $convolve + . '.' . $this->extension; + + return $this->setTarget($file, $base); + } + + + + /** + * Use cached version of image, if possible. + * + * @param boolean $useCache is default true, set to false to avoid using cached object. + * + * @return $this + */ + public function useCacheIfPossible($useCache = true) + { + if ($useCache && is_readable($this->cacheFileName)) { + $fileTime = filemtime($this->pathToImage); + $cacheTime = filemtime($this->cacheFileName); + + if ($fileTime <= $cacheTime) { + if ($this->useCache) { + if ($this->verbose) { + $this->log("Use cached file."); + $this->log("Cached image filesize: " . filesize($this->cacheFileName) . " bytes."); + } + $this->output($this->cacheFileName, $this->outputFormat); + } else { + $this->log("Cache is valid but ignoring it by intention."); + } + } else { + $this->log("Original file is modified, ignoring cache."); + } + } else { + $this->log("Cachefile does not exists or ignoring it."); + } + + return $this; + } + + + + /** + * Error message when failing to load somehow corrupt image. + * + * @return void + * + */ + public function failedToLoad() + { + header("HTTP/1.0 404 Not Found"); + echo("CImage.php says 404: Fatal error when opening image.
"); + + switch ($this->fileExtension) { + case 'jpg': + case 'jpeg': + $this->image = imagecreatefromjpeg($this->pathToImage); + break; + + case 'gif': + $this->image = imagecreatefromgif($this->pathToImage); + break; + + case 'png': + $this->image = imagecreatefrompng($this->pathToImage); + break; + } + + exit(); + } + + + + /** + * Load image from disk. + * + * @param string $src of image. + * @param string $dir as base directory where images are. + * + * @return $this + * + */ + public function load($src = null, $dir = null) + { + if (isset($src)) { + $this->setSource($src, $dir); + } + + $this->log("Opening file as {$this->fileExtension}."); + + switch ($this->fileExtension) { + case 'jpg': + case 'jpeg': + $this->image = @imagecreatefromjpeg($this->pathToImage); + $this->image or $this->failedToLoad(); + break; + + case 'gif': + $this->image = @imagecreatefromgif($this->pathToImage); + $this->image or $this->failedToLoad(); + break; + + case 'png': + $this->image = @imagecreatefrompng($this->pathToImage); + $this->image or $this->failedToLoad(); + + $type = $this->getPngType(); + $hasFewColors = imagecolorstotal($this->image); + + if ($type == self::PNG_RGB_PALETTE || ($hasFewColors > 0 && $hasFewColors <= 256)) { + if ($this->verbose) { + $this->log("Handle this image as a palette image."); + } + $this->palette = true; + } + break; + + default: + $this->image = false; + throw new Exception('No support for this file extension.'); + } + + if ($this->verbose) { + $this->log("imageistruecolor() : " . (imageistruecolor($this->image) ? 'true' : 'false')); + $this->log("imagecolorstotal() : " . imagecolorstotal($this->image)); + $this->log("Number of colors in image = " . $this->colorsTotal($this->image)); + } + + return $this; + } + + + + /** + * Get the type of PNG image. + * + * @return int as the type of the png-image + * + */ + private function getPngType() + { + $pngType = ord(file_get_contents($this->pathToImage, false, null, 25, 1)); + + switch ($pngType) { + + case self::PNG_GREYSCALE: + $this->log("PNG is type 0, Greyscale."); + break; + + case self::PNG_RGB: + $this->log("PNG is type 2, RGB"); + break; + + case self::PNG_RGB_PALETTE: + $this->log("PNG is type 3, RGB with palette"); + break; + + case self::PNG_GREYSCALE_ALPHA: + $this->Log("PNG is type 4, Greyscale with alpha channel"); + break; + + case self::PNG_RGB_ALPHA: + $this->Log("PNG is type 6, RGB with alpha channel (PNG 32-bit)"); + break; + + default: + $this->Log("PNG is UNKNOWN type, is it really a PNG image?"); + } + + return $pngType; + } + + + + /** + * Calculate number of colors in an image. + * + * @param resource $im the image. + * + * @return int + */ + private function colorsTotal($im) + { + if (imageistruecolor($im)) { + $this->log("Colors as true color."); + $h = imagesy($im); + $w = imagesx($im); + $c = array(); + for ($x=0; $x < $w; $x++) { + for ($y=0; $y < $h; $y++) { + @$c['c'.imagecolorat($im, $x, $y)]++; + } + } + return count($c); + } else { + $this->log("Colors as palette."); + return imagecolorstotal($im); + } + } + + + + /** + * Preprocess image before rezising it. + * + * @return $this + */ + public function preResize() + { + $this->log("Pre-process before resizing"); + + // Rotate image + if ($this->rotateBefore) { + $this->log("Rotating image."); + $this->rotate($this->rotateBefore, $this->bgColor) + ->reCalculateDimensions(); + } + + // Auto-rotate image + if ($this->autoRotate) { + $this->log("Auto rotating image."); + $this->rotateExif() + ->reCalculateDimensions(); + } + + // Scale the original image before starting + if (isset($this->scale)) { + $this->log("Scale by {$this->scale}%"); + $newWidth = $this->width * $this->scale / 100; + $newHeight = $this->height * $this->scale / 100; + $img = $this->CreateImageKeepTransparency($newWidth, $newHeight); + imagecopyresampled($img, $this->image, 0, 0, 0, 0, $newWidth, $newHeight, $this->width, $this->height); + $this->image = $img; + $this->width = $newWidth; + $this->height = $newHeight; + } + + return $this; + } + + + + /** + * Resize and or crop the image. + * + * @return $this + */ + public function resize() + { + + $this->log("Starting to Resize()"); + $this->log("Upscale = '$this->upscale'"); + + // Only use a specified area of the image, $this->offset is defining the area to use + if (isset($this->offset)) { + + $this->log("Offset for area to use, cropping it width={$this->offset['width']}, height={$this->offset['height']}, start_x={$this->offset['left']}, start_y={$this->offset['top']}"); + $img = $this->CreateImageKeepTransparency($this->offset['width'], $this->offset['height']); + imagecopy($img, $this->image, 0, 0, $this->offset['left'], $this->offset['top'], $this->offset['width'], $this->offset['height']); + $this->image = $img; + $this->width = $this->offset['width']; + $this->height = $this->offset['height']; + } + + if ($this->crop) { + + // Do as crop, take only part of image + $this->log("Cropping area width={$this->crop['width']}, height={$this->crop['height']}, start_x={$this->crop['start_x']}, start_y={$this->crop['start_y']}"); + $img = $this->CreateImageKeepTransparency($this->crop['width'], $this->crop['height']); + imagecopy($img, $this->image, 0, 0, $this->crop['start_x'], $this->crop['start_y'], $this->crop['width'], $this->crop['height']); + $this->image = $img; + $this->width = $this->crop['width']; + $this->height = $this->crop['height']; + } + + if (!$this->upscale) { + // Consider rewriting the no-upscale code to fit within this if-statement, + // likely to be more readable code. + // The code is more or leass equal in below crop-to-fit, fill-to-fit and stretch + } + + if ($this->cropToFit) { + + // Resize by crop to fit + $this->log("Resizing using strategy - Crop to fit"); + + if (!$this->upscale && ($this->width < $this->newWidth || $this->height < $this->newHeight)) { + $this->log("Resizing - smaller image, do not upscale."); + + $cropX = round(($this->cropWidth/2) - ($this->newWidth/2)); + $cropY = round(($this->cropHeight/2) - ($this->newHeight/2)); + + $posX = 0; + $posY = 0; + + if ($this->newWidth > $this->width) { + $posX = round(($this->newWidth - $this->width) / 2); + } + + if ($this->newHeight > $this->height) { + $posY = round(($this->newHeight - $this->height) / 2); + } + + $imageResized = $this->CreateImageKeepTransparency($this->newWidth, $this->newHeight); + imagecopy($imageResized, $this->image, $posX, $posY, $cropX, $cropY, $this->newWidth, $this->newHeight); + } else { + $cropX = round(($this->cropWidth/2) - ($this->newWidth/2)); + $cropY = round(($this->cropHeight/2) - ($this->newHeight/2)); + $imgPreCrop = $this->CreateImageKeepTransparency($this->cropWidth, $this->cropHeight); + $imageResized = $this->CreateImageKeepTransparency($this->newWidth, $this->newHeight); + imagecopyresampled($imgPreCrop, $this->image, 0, 0, 0, 0, $this->cropWidth, $this->cropHeight, $this->width, $this->height); + imagecopy($imageResized, $imgPreCrop, 0, 0, $cropX, $cropY, $this->newWidth, $this->newHeight); + } + + $this->image = $imageResized; + $this->width = $this->newWidth; + $this->height = $this->newHeight; + + } else if ($this->fillToFit) { + + // Resize by fill to fit + $this->log("Resizing using strategy - Fill to fit"); + + $posX = 0; + $posY = 0; + + $ratioOrig = $this->width / $this->height; + $ratioNew = $this->newWidth / $this->newHeight; + + // Check ratio for landscape or portrait + if ($ratioOrig < $ratioNew) { + $posX = round(($this->newWidth - $this->fillWidth) / 2); + } else { + $posY = round(($this->newHeight - $this->fillHeight) / 2); + } + + if (!$this->upscale + && ($this->width < $this->newWidth || $this->height < $this->newHeight) + ) { + + $this->log("Resizing - smaller image, do not upscale."); + $posX = round(($this->fillWidth - $this->width) / 2); + $posY = round(($this->fillHeight - $this->height) / 2); + $imageResized = $this->CreateImageKeepTransparency($this->newWidth, $this->newHeight); + imagecopy($imageResized, $this->image, $posX, $posY, 0, 0, $this->fillWidth, $this->fillHeight); + + } else { + $imgPreFill = $this->CreateImageKeepTransparency($this->fillWidth, $this->fillHeight); + $imageResized = $this->CreateImageKeepTransparency($this->newWidth, $this->newHeight); + imagecopyresampled($imgPreFill, $this->image, 0, 0, 0, 0, $this->fillWidth, $this->fillHeight, $this->width, $this->height); + imagecopy($imageResized, $imgPreFill, $posX, $posY, 0, 0, $this->fillWidth, $this->fillHeight); + } + + $this->image = $imageResized; + $this->width = $this->newWidth; + $this->height = $this->newHeight; + + } else if (!($this->newWidth == $this->width && $this->newHeight == $this->height)) { + + // Resize it + $this->log("Resizing, new height and/or width"); + + if (!$this->upscale + && ($this->width < $this->newWidth || $this->height < $this->newHeight) + ) { + $this->log("Resizing - smaller image, do not upscale."); + + if (!$this->keepRatio) { + $this->log("Resizing - stretch to fit selected."); + + $posX = 0; + $posY = 0; + $cropX = 0; + $cropY = 0; + + if ($this->newWidth > $this->width && $this->newHeight > $this->height) { + $posX = round(($this->newWidth - $this->width) / 2); + $posY = round(($this->newHeight - $this->height) / 2); + } else if ($this->newWidth > $this->width) { + $posX = round(($this->newWidth - $this->width) / 2); + $cropY = round(($this->height - $this->newHeight) / 2); + } else if ($this->newHeight > $this->height) { + $posY = round(($this->newHeight - $this->height) / 2); + $cropX = round(($this->width - $this->newWidth) / 2); + } + + //$this->log("posX=$posX, posY=$posY, cropX=$cropX, cropY=$cropY."); + $imageResized = $this->CreateImageKeepTransparency($this->newWidth, $this->newHeight); + imagecopy($imageResized, $this->image, $posX, $posY, $cropX, $cropY, $this->newWidth, $this->newHeight); + $this->image = $imageResized; + $this->width = $this->newWidth; + $this->height = $this->newHeight; + } + } else { + $imageResized = $this->CreateImageKeepTransparency($this->newWidth, $this->newHeight); + imagecopyresampled($imageResized, $this->image, 0, 0, 0, 0, $this->newWidth, $this->newHeight, $this->width, $this->height); + $this->image = $imageResized; + $this->width = $this->newWidth; + $this->height = $this->newHeight; + } + } + + return $this; + } + + + + /** + * Postprocess image after rezising image. + * + * @return $this + */ + public function postResize() + { + $this->log("Post-process after resizing"); + + // Rotate image + if ($this->rotateAfter) { + $this->log("Rotating image."); + $this->rotate($this->rotateAfter, $this->bgColor); + } + + // Apply filters + if (isset($this->filters) && is_array($this->filters)) { + + foreach ($this->filters as $filter) { + $this->log("Applying filter {$filter['type']}."); + + switch ($filter['argc']) { + + case 0: + imagefilter($this->image, $filter['type']); + break; + + case 1: + imagefilter($this->image, $filter['type'], $filter['arg1']); + break; + + case 2: + imagefilter($this->image, $filter['type'], $filter['arg1'], $filter['arg2']); + break; + + case 3: + imagefilter($this->image, $filter['type'], $filter['arg1'], $filter['arg2'], $filter['arg3']); + break; + + case 4: + imagefilter($this->image, $filter['type'], $filter['arg1'], $filter['arg2'], $filter['arg3'], $filter['arg4']); + break; + } + } + } + + // Convert to palette image + if ($this->palette) { + $this->log("Converting to palette image."); + $this->trueColorToPalette(); + } + + // Blur the image + if ($this->blur) { + $this->log("Blur."); + $this->blurImage(); + } + + // Emboss the image + if ($this->emboss) { + $this->log("Emboss."); + $this->embossImage(); + } + + // Sharpen the image + if ($this->sharpen) { + $this->log("Sharpen."); + $this->sharpenImage(); + } + + // Custom convolution + if ($this->convolve) { + //$this->log("Convolve: " . $this->convolve); + $this->imageConvolution(); + } + + return $this; + } + + + + /** + * Rotate image using angle. + * + * @param float $angle to rotate image. + * @param int $anglebgColor to fill image with if needed. + * + * @return $this + */ + public function rotate($angle, $bgColor) + { + $this->log("Rotate image " . $angle . " degrees with filler color."); + + $color = $this->getBackgroundColor(); + $this->image = imagerotate($this->image, $angle, $color); + + $this->width = imagesx($this->image); + $this->height = imagesy($this->image); + + $this->log("New image dimension width x height: " . $this->width . " x " . $this->height); + + return $this; + } + + + + /** + * Rotate image using information in EXIF. + * + * @return $this + */ + public function rotateExif() + { + if (!in_array($this->fileExtension, array('jpg', 'jpeg'))) { + $this->log("Autorotate ignored, EXIF not supported by this filetype."); + return $this; + } + + $exif = exif_read_data($this->pathToImage); + + if (!empty($exif['Orientation'])) { + switch ($exif['Orientation']) { + case 3: + $this->log("Autorotate 180."); + $this->rotate(180, $this->bgColor); + break; + + case 6: + $this->log("Autorotate -90."); + $this->rotate(-90, $this->bgColor); + break; + + case 8: + $this->log("Autorotate 90."); + $this->rotate(90, $this->bgColor); + break; + + default: + $this->log("Autorotate ignored, unknown value as orientation."); + } + } else { + $this->log("Autorotate ignored, no orientation in EXIF."); + } + + return $this; + } + + + + /** + * Convert true color image to palette image, keeping alpha. + * http://stackoverflow.com/questions/5752514/how-to-convert-png-to-8-bit-png-using-php-gd-library + * + * @return void + */ + public function trueColorToPalette() + { + $img = imagecreatetruecolor($this->width, $this->height); + $bga = imagecolorallocatealpha($img, 0, 0, 0, 127); + imagecolortransparent($img, $bga); + imagefill($img, 0, 0, $bga); + imagecopy($img, $this->image, 0, 0, 0, 0, $this->width, $this->height); + imagetruecolortopalette($img, false, 255); + imagesavealpha($img, true); + + if (imageistruecolor($this->image)) { + $this->log("Matching colors with true color image."); + imagecolormatch($this->image, $img); + } + + $this->image = $img; + } + + + + /** + * Sharpen image using image convolution. + * + * @return $this + */ + public function sharpenImage() + { + $this->imageConvolution('sharpen'); + return $this; + } + + + + /** + * Emboss image using image convolution. + * + * @return $this + */ + public function embossImage() + { + $this->imageConvolution('emboss'); + return $this; + } + + + + /** + * Blur image using image convolution. + * + * @return $this + */ + public function blurImage() + { + $this->imageConvolution('blur'); + return $this; + } + + + + /** + * Create convolve expression and return arguments for image convolution. + * + * @param string $expression constant string which evaluates to a list of + * 11 numbers separated by komma or such a list. + * + * @return array as $matrix (3x3), $divisor and $offset + */ + public function createConvolveArguments($expression) + { + // Check of matching constant + if (isset($this->convolves[$expression])) { + $expression = $this->convolves[$expression]; + } + + $part = explode(',', $expression); + $this->log("Creating convolution expressen: $expression"); + + // Expect list of 11 numbers, split by , and build up arguments + if (count($part) != 11) { + throw new Exception( + "Missmatch in argument convolve. Expected comma-separated string with + 11 float values. Got $expression." + ); + } + + array_walk($part, function ($item, $key) { + if (!is_numeric($item)) { + throw new Exception("Argument to convolve expression should be float but is not."); + } + }); + + return array( + array( + array($part[0], $part[1], $part[2]), + array($part[3], $part[4], $part[5]), + array($part[6], $part[7], $part[8]), + ), + $part[9], + $part[10], + ); + } + + + + /** + * Add custom expressions (or overwrite existing) for image convolution. + * + * @param array $options Key value array with strings to be converted + * to convolution expressions. + * + * @return $this + */ + public function addConvolveExpressions($options) + { + $this->convolves = array_merge($this->convolves, $options); + return $this; + } + + + + /** + * Image convolution. + * + * @param string $options A string with 11 float separated by comma. + * + * @return $this + */ + public function imageConvolution($options = null) + { + // Use incoming options or use $this. + $options = $options ? $options : $this->convolve; + + // Treat incoming as string, split by + + $this->log("Convolution with '$options'"); + $options = explode(":", $options); + + // Check each option if it matches constant value + foreach ($options as $option) { + list($matrix, $divisor, $offset) = $this->createConvolveArguments($option); + imageconvolution($this->image, $matrix, $divisor, $offset); + } + + return $this; + } + + + + /** + * Set default background color between 000000-FFFFFF or if using + * alpha 00000000-FFFFFF7F. + * + * @param string $color as hex value. + * + * @return $this + */ + public function setDefaultBackgroundColor($color) + { + $this->log("Setting default background color to '$color'."); + + if (!(strlen($color) == 6 || strlen($color) == 8)) { + throw new Exception( + "Background color needs a hex value of 6 or 8 + digits. 000000-FFFFFF or 00000000-FFFFFF7F. + Current value was: '$color'." + ); + } + + $red = hexdec(substr($color, 0, 2)); + $green = hexdec(substr($color, 2, 2)); + $blue = hexdec(substr($color, 4, 2)); + + $alpha = (strlen($color) == 8) + ? hexdec(substr($color, 6, 2)) + : null; + + if (($red < 0 || $red > 255) + || ($green < 0 || $green > 255) + || ($blue < 0 || $blue > 255) + || ($alpha < 0 || $alpha > 127) + ) { + throw new Exception( + "Background color out of range. Red, green blue + should be 00-FF and alpha should be 00-7F. + Current value was: '$color'." + ); + } + + $this->bgColor = strtolower($color); + $this->bgColorDefault = array( + 'red' => $red, + 'green' => $green, + 'blue' => $blue, + 'alpha' => $alpha + ); + + return $this; + } + + + + /** + * Get the background color. + * + * @param resource $img the image to work with or null if using $this->image. + * + * @return color value or null if no background color is set. + */ + private function getBackgroundColor($img = null) + { + $img = isset($img) ? $img : $this->image; + + if ($this->bgColorDefault) { + + $red = $this->bgColorDefault['red']; + $green = $this->bgColorDefault['green']; + $blue = $this->bgColorDefault['blue']; + $alpha = $this->bgColorDefault['alpha']; + + if ($alpha) { + $color = imagecolorallocatealpha($img, $red, $green, $blue, $alpha); + } else { + $color = imagecolorallocate($img, $red, $green, $blue); + } + + return $color; + + } else { + return 0; + } + } + + + + /** + * Create a image and keep transparency for png and gifs. + * + * @param int $width of the new image. + * @param int $height of the new image. + * + * @return image resource. + */ + private function createImageKeepTransparency($width, $height) + { + $this->log("Creating a new working image width={$width}px, height={$height}px."); + $img = imagecreatetruecolor($width, $height); + imagealphablending($img, false); + imagesavealpha($img, true); + + if ($this->bgColorDefault) { + + $color = $this->getBackgroundColor($img); + imagefill($img, 0, 0, $color); + $this->Log("Filling image with background color."); + } + + return $img; + } + + + + /** + * Set optimizing and post-processing options. + * + * @param array $options with config for postprocessing with external tools. + * + * @return $this + */ + public function setPostProcessingOptions($options) + { + if (isset($options['jpeg_optimize']) && $options['jpeg_optimize']) { + $this->jpegOptimizeCmd = $options['jpeg_optimize_cmd']; + } else { + $this->jpegOptimizeCmd = null; + } + + if (isset($options['png_filter']) && $options['png_filter']) { + $this->pngFilterCmd = $options['png_filter_cmd']; + } else { + $this->pngFilterCmd = null; + } + + if (isset($options['png_deflate']) && $options['png_deflate']) { + $this->pngDeflateCmd = $options['png_deflate_cmd']; + } else { + $this->pngDeflateCmd = null; + } + + return $this; + } + + + + /** + * Save image. + * + * @param string $src as target filename. + * @param string $base as base directory where to store images. + * + * @return $this or false if no folder is set. + */ + public function save($src = null, $base = null) + { + if (isset($src)) { + $this->setTarget($src, $base); + } + + is_writable($this->saveFolder) + or $this->raiseError('Target directory is not writable.'); + + switch(strtolower($this->extension)) { + + case 'jpeg': + case 'jpg': + $this->Log("Saving image as JPEG to cache using quality = {$this->quality}."); + imagejpeg($this->image, $this->cacheFileName, $this->quality); + + // Use JPEG optimize if defined + if ($this->jpegOptimizeCmd) { + if ($this->verbose) { + clearstatcache(); + $this->log("Filesize before optimize: " . filesize($this->cacheFileName) . " bytes."); + } + $res = array(); + $cmd = $this->jpegOptimizeCmd . " -outfile $this->cacheFileName $this->cacheFileName"; + exec($cmd, $res); + $this->log($cmd); + $this->log($res); + } + break; + + case 'gif': + $this->Log("Saving image as GIF to cache."); + imagegif($this->image, $this->cacheFileName); + break; + + case 'png': + $this->Log("Saving image as PNG to cache using compression = {$this->compress}."); + + // Turn off alpha blending and set alpha flag + imagealphablending($this->image, false); + imagesavealpha($this->image, true); + imagepng($this->image, $this->cacheFileName, $this->compress); + + // Use external program to filter PNG, if defined + if ($this->pngFilterCmd) { + if ($this->verbose) { + clearstatcache(); + $this->Log("Filesize before filter optimize: " . filesize($this->cacheFileName) . " bytes."); + } + $res = array(); + $cmd = $this->pngFilterCmd . " $this->cacheFileName"; + exec($cmd, $res); + $this->Log($cmd); + $this->Log($res); + } + + // Use external program to deflate PNG, if defined + if ($this->pngDeflateCmd) { + if ($this->verbose) { + clearstatcache(); + $this->Log("Filesize before deflate optimize: " . filesize($this->cacheFileName) . " bytes."); + } + $res = array(); + $cmd = $this->pngDeflateCmd . " $this->cacheFileName"; + exec($cmd, $res); + $this->Log($cmd); + $this->Log($res); + } + break; + + default: + $this->RaiseError('No support for this file extension.'); + break; + } + + if ($this->verbose) { + clearstatcache(); + $this->log("Cached image filesize: " . filesize($this->cacheFileName) . " bytes."); + $this->log("imageistruecolor() : " . (imageistruecolor($this->image) ? 'true' : 'false')); + $this->log("imagecolorstotal() : " . imagecolorstotal($this->image)); + $this->log("Number of colors in image = " . $this->ColorsTotal($this->image)); + } + + return $this; + } + + + + /** + * Create a hard link, as an alias, to the cached file. + * + * @param string $alias where to store the link, + * filename without extension. + * + * @return $this + */ + public function linkToCacheFile($alias) + { + if ($alias === null) { + $this->log("Ignore creating alias."); + return $this; + } + + $alias = $alias . "." . $this->extension; + + if (is_readable($alias)) { + unlink($alias); + } + + $res = link($this->cacheFileName, $alias); + + if ($res) { + $this->log("Created an alias as: $alias"); + } else { + $this->log("Failed to create the alias: $alias"); + } + + return $this; + } + + + + /** + * Output image to browser using caching. + * + * @param string $file to read and output, default is to use $this->cacheFileName + * @param string $format set to json to output file as json object with details + * + * @return void + */ + public function output($file = null, $format = null) + { + if (is_null($file)) { + $file = $this->cacheFileName; + } + + if (is_null($format)) { + $format = $this->outputFormat; + } + + $this->log("Output format is: $format"); + + if (!$this->verbose && $format == 'json') { + header('Content-type: application/json'); + echo $this->json($file); + exit; + } + + $this->log("Outputting image: $file"); + + // Get image modification time + clearstatcache(); + $lastModified = filemtime($file); + $gmdate = gmdate("D, d M Y H:i:s", $lastModified); + + if (!$this->verbose) { + header('Last-Modified: ' . $gmdate . " GMT"); + } + + if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) == $lastModified) { + + if ($this->verbose) { + $this->log("304 not modified"); + $this->verboseOutput(); + exit; + } + + header("HTTP/1.0 304 Not Modified"); + + } else { + + if ($this->verbose) { + $this->log("Last modified: " . $gmdate . " GMT"); + $this->verboseOutput(); + exit; + } + + // Get details on image + $info = getimagesize($file); + !empty($info) or $this->raiseError("The file doesn't seem to be an image."); + $mime = $info['mime']; + + header('Content-type: ' . $mime); + readfile($file); + } + + exit; + } + + + + /** + * Create a JSON object from the image details. + * + * @param string $file the file to output. + * + * @return string json-encoded representation of the image. + */ + public function json($file = null) + { + $file = $file ? $file : $this->cacheFileName; + + $details = array(); + + clearstatcache(); + + $details['src'] = $this->imageSrc; + $lastModified = filemtime($this->pathToImage); + $details['srcGmdate'] = gmdate("D, d M Y H:i:s", $lastModified); + + $details['cache'] = basename($this->cacheFileName); + $lastModified = filemtime($this->cacheFileName); + $details['cacheGmdate'] = gmdate("D, d M Y H:i:s", $lastModified); + + $this->loadImageDetails($file); + + $details['filename'] = basename($file); + $details['width'] = $this->width; + $details['height'] = $this->height; + $details['aspectRatio'] = round($this->width / $this->height, 3); + $details['size'] = filesize($file); + + $this->load($file); + $details['colors'] = $this->colorsTotal($this->image); + + $options = null; + if (defined("JSON_PRETTY_PRINT") && defined("JSON_UNESCAPED_SLASHES")) { + $options = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES; + } + + return json_encode($details, $options); + } + + + + /** + * Log an event if verbose mode. + * + * @param string $message to log. + * + * @return this + */ + public function log($message) + { + if ($this->verbose) { + $this->log[] = $message; + } + + return $this; + } + + + + /** + * Do verbose output and print out the log and the actual images. + * + * @return void + */ + private function verboseOutput() + { + $log = null; + $this->log("As JSON: \n" . $this->json()); + $this->log("Memory peak: " . round(memory_get_peak_usage() /1024/1024) . "M"); + $this->log("Memory limit: " . ini_get('memory_limit')); + + $included = get_included_files(); + $this->log("Included files: " . count($included)); + + foreach ($this->log as $val) { + if (is_array($val)) { + foreach ($val as $val1) { + $log .= htmlentities($val1) . '
'; + } + } else { + $log .= htmlentities($val) . '
'; + } + } + + echo << + + +CImage verbose output + +

CImage Verbose Output

+
{$log}
+EOD; + } + + + + /** + * Raise error, enables to implement a selection of error methods. + * + * @param string $message the error message to display. + * + * @return void + * @throws Exception + */ + private function raiseError($message) + { + throw new Exception($message); + } +} + + + +/** + * Resize and crop images on the fly, store generated images in a cache. + * + * @author Mikael Roos mos@dbwebb.se + * @example http://dbwebb.se/opensource/cimage + * @link https://github.com/mosbth/cimage + * + */ + + + +/** + * Default configuration options, can be overridden in own config-file. + * + * @param string $msg to display. + * + * @return void + */ +function errorPage($msg) +{ + global $mode; + + header("HTTP/1.0 500 Internal Server Error"); + + if ($mode == 'development') { + die("[img.php] $msg"); + } else { + error_log("[img.php] $msg"); + die("HTTP/1.0 500 Internal Server Error"); + } +} + + + +/** + * Custom exception handler. + */ +set_exception_handler(function ($exception) { + errorPage("

img.php: Uncaught exception:

" . $exception->getMessage() . "

" . $exception->getTraceAsString(), "
"); +}); + + + +/** + * Get input from query string or return default value if not set. + * + * @param mixed $key as string or array of string values to look for in $_GET. + * @param mixed $default value to return when $key is not set in $_GET. + * + * @return mixed value from $_GET or default value. + */ +function get($key, $default = null) +{ + if (is_array($key)) { + foreach ($key as $val) { + if (isset($_GET[$val])) { + return $_GET[$val]; + } + } + } elseif (isset($_GET[$key])) { + return $_GET[$key]; + } + return $default; +} + + + +/** + * Get input from query string and set to $defined if defined or else $undefined. + * + * @param mixed $key as string or array of string values to look for in $_GET. + * @param mixed $defined value to return when $key is set in $_GET. + * @param mixed $undefined value to return when $key is not set in $_GET. + * + * @return mixed value as $defined or $undefined. + */ +function getDefined($key, $defined, $undefined) +{ + return get($key) === null ? $undefined : $defined; +} + + + +/** + * Get value from config array or default if key is not set in config array. + * + * @param string $key the key in the config array. + * @param mixed $default value to be default if $key is not set in config. + * + * @return mixed value as $config[$key] or $default. + */ +function getConfig($key, $default) +{ + global $config; + return isset($config[$key]) + ? $config[$key] + : $default; +} + + + +/** + * Log when verbose mode, when used without argument it returns the result. + * + * @param string $msg to log. + * + * @return void or array. + */ +function verbose($msg = null) +{ + global $verbose; + static $log = array(); + + if (!$verbose) { + return; + } + + if (is_null($msg)) { + return $log; + } + + $log[] = $msg; +} + + + +/** + * Get configuration options from file, if the file exists, else use $config + * if its defined or create an empty $config. + */ +$configFile = __DIR__.'/'.basename(__FILE__, '.php').'_config.php'; + +if (is_file($configFile)) { + $config = require $configFile; +} else if (!isset($config)) { + $config = array(); +} + + + +/** +* verbose, v - do a verbose dump of what happens +*/ +$verbose = getDefined(array('verbose', 'v'), true, false); + + + +/** + * Set mode as strict, production or development. + * Default is production environment. + */ +$mode = getConfig('mode', 'production'); + +// Settings for any mode +set_time_limit(20); +ini_set('gd.jpeg_ignore_warning', 1); + +if (!extension_loaded('gd')) { + errorPage("Extension gd is nod loaded."); +} + +// Specific settings for each mode +if ($mode == 'strict') { + + error_reporting(0); + ini_set('display_errors', 0); + ini_set('log_errors', 1); + $verbose = false; + +} else if ($mode == 'production') { + + error_reporting(-1); + ini_set('display_errors', 0); + ini_set('log_errors', 1); + $verbose = false; + +} else if ($mode == 'development') { + + error_reporting(-1); + ini_set('display_errors', 1); + ini_set('log_errors', 0); + +} else { + errorPage("Unknown mode: $mode"); +} + +verbose("mode = $mode"); +verbose("error log = " . ini_get('error_log')); + + + +/** + * Set default timezone if not set or if its set in the config-file. + */ +$defaultTimezone = getConfig('default_timezone', null); + +if ($defaultTimezone) { + date_default_timezone_set($defaultTimezone); +} else if (!ini_get('default_timezone')) { + date_default_timezone_set('UTC'); +} + + + +/** + * Check if passwords are configured, used and match. + * Options decide themself if they require passwords to be used. + */ +$pwdConfig = getConfig('password', false); +$pwd = get(array('password', 'pwd'), null); + +// Check if passwords match, if configured to use passwords +$passwordMatch = null; +if ($pwdConfig && $pwd) { + $passwordMatch = ($pwdConfig == $pwd); +} + +verbose("password match = $passwordMatch"); + + + +/** + * Prevent hotlinking, leeching, of images by controlling who access them + * from where. + * + */ +$allowHotlinking = getConfig('allow_hotlinking', true); +$hotlinkingWhitelist = getConfig('hotlinking_whitelist', array()); + +$serverName = isset($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : null; +$referer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : null; +$refererHost = parse_url($referer, PHP_URL_HOST); + +if (!$allowHotlinking) { + if ($passwordMatch) { + ; // Always allow when password match + } else if ($passwordMatch === false) { + errorPage("Hotlinking/leeching not allowed when password missmatch."); + } else if (!$referer) { + errorPage("Hotlinking/leeching not allowed and referer is missing."); + } else if (strcmp($serverName, $refererHost) == 0) { + ; // Allow when serverName matches refererHost + } else if (!empty($hotlinkingWhitelist)) { + + $allowedByWhitelist = false; + foreach ($hotlinkingWhitelist as $val) { + if (preg_match($val, $refererHost)) { + $allowedByWhitelist = true; + } + } + + if (!$allowedByWhitelist) { + errorPage("Hotlinking/leeching not allowed by whitelist."); + } + + } else { + errorPage("Hotlinking/leeching not allowed."); + } +} + +verbose("allow_hotlinking = $allowHotlinking"); +verbose("referer = $referer"); +verbose("referer host = $refererHost"); + + + +/** + * Get the source files. + */ +$autoloader = getConfig('autoloader', false); +$cimageClass = getConfig('cimage_class', false); + +if ($autoloader) { + require $autoloader; +} else if ($cimageClass) { + require $cimageClass; +} + + + +/** + * Create the class for the image. + */ +$img = new CImage(); +$img->setVerbose($verbose); + + + +/** + * Allow or disallow remote download of images from other servers. + * Passwords apply if used. + * + */ +$allowRemote = getConfig('remote_allow', false); + +if ($allowRemote && $passwordMatch !== false) { + $pattern = getConfig('remote_pattern', null); + $img->setRemoteDownload($allowRemote, $pattern); +} + + + +/** + * shortcut, sc - extend arguments with a constant value, defined + * in config-file. + */ +$shortcut = get(array('shortcut', 'sc'), null); +$shortcutConfig = getConfig('shortcut', array( + 'sepia' => "&f=grayscale&f0=brightness,-10&f1=contrast,-20&f2=colorize,120,60,0,0&sharpen", +)); + +verbose("shortcut = $shortcut"); + +if (isset($shortcut) + && isset($shortcutConfig[$shortcut])) { + + parse_str($shortcutConfig[$shortcut], $get); + verbose("shortcut-constant = {$shortcutConfig[$shortcut]}"); + $_GET = array_merge($_GET, $get); +} + + + +/** + * src - the source image file. + */ +$srcImage = get('src') + or errorPage('Must set src-attribute.'); + +// Check for valid/invalid characters +$imagePath = getConfig('image_path', __DIR__ . '/img/'); +$imagePathConstraint = getConfig('image_path_constraint', true); +$validFilename = getConfig('valid_filename', '#^[a-z0-9A-Z-/_\.:]+$#'); + +preg_match($validFilename, $srcImage) + or errorPage('Filename contains invalid characters.'); + +if ($allowRemote && $img->isRemoteSource($srcImage)) { + + // If source is a remote file, ignore local file checks. + +} else if ($imagePathConstraint) { + + // Check that the image is a file below the directory 'image_path'. + $pathToImage = realpath($imagePath . $srcImage); + $imageDir = realpath($imagePath); + + is_file($pathToImage) + or errorPage( + 'Source image is not a valid file, check the filename and that a + matching file exists on the filesystem.' + ); + + substr_compare($imageDir, $pathToImage, 0, strlen($imageDir)) == 0 + or errorPage( + 'Security constraint: Source image is not below the directory "image_path" + as specified in the config file img_config.php.' + ); +} + +verbose("src = $srcImage"); + + + +/** + * Manage size constants from config file, use constants to replace values + * for width and height. + */ +$sizeConstant = getConfig('size_constant', function () { + + // Set sizes to map constant to value, easier to use with width or height + $sizes = array( + 'w1' => 613, + 'w2' => 630, + ); + + // Add grid column width, useful for use as predefined size for width (or height). + $gridColumnWidth = 30; + $gridGutterWidth = 10; + $gridColumns = 24; + + for ($i = 1; $i <= $gridColumns; $i++) { + $sizes['c' . $i] = ($gridColumnWidth + $gridGutterWidth) * $i - $gridGutterWidth; + } + + return $sizes; +}); + +$sizes = call_user_func($sizeConstant); + + + +/** + * width, w - set target width, affecting the resulting image width, height and resize options + */ +$newWidth = get(array('width', 'w')); +$maxWidth = getConfig('max_width', 2000); + +// Check to replace predefined size +if (isset($sizes[$newWidth])) { + $newWidth = $sizes[$newWidth]; +} + +// Support width as % of original width +if ($newWidth[strlen($newWidth)-1] == '%') { + is_numeric(substr($newWidth, 0, -1)) + or errorPage('Width % not numeric.'); +} else { + is_null($newWidth) + or ($newWidth > 10 && $newWidth <= $maxWidth) + or errorPage('Width out of range.'); +} + +verbose("new width = $newWidth"); + + + +/** + * height, h - set target height, affecting the resulting image width, height and resize options + */ +$newHeight = get(array('height', 'h')); +$maxHeight = getConfig('max_height', 2000); + +// Check to replace predefined size +if (isset($sizes[$newHeight])) { + $newHeight = $sizes[$newHeight]; +} + +// height +if ($newHeight[strlen($newHeight)-1] == '%') { + is_numeric(substr($newHeight, 0, -1)) + or errorPage('Height % out of range.'); +} else { + is_null($newHeight) + or ($newHeight > 10 && $newHeight <= $maxHeight) + or errorPage('Hight out of range.'); +} + +verbose("new height = $newHeight"); + + + +/** + * aspect-ratio, ar - affecting the resulting image width, height and resize options + */ +$aspectRatio = get(array('aspect-ratio', 'ar')); +$aspectRatioConstant = getConfig('aspect_ratio_constant', function () { + return array( + '3:1' => 3/1, + '3:2' => 3/2, + '4:3' => 4/3, + '8:5' => 8/5, + '16:10' => 16/10, + '16:9' => 16/9, + 'golden' => 1.618, + ); +}); + +// Check to replace predefined aspect ratio +$aspectRatios = call_user_func($aspectRatioConstant); +$negateAspectRatio = ($aspectRatio[0] == '!') ? true : false; +$aspectRatio = $negateAspectRatio ? substr($aspectRatio, 1) : $aspectRatio; + +if (isset($aspectRatios[$aspectRatio])) { + $aspectRatio = $aspectRatios[$aspectRatio]; +} + +if ($negateAspectRatio) { + $aspectRatio = 1 / $aspectRatio; +} + +is_null($aspectRatio) + or is_numeric($aspectRatio) + or errorPage('Aspect ratio out of range'); + +verbose("aspect ratio = $aspectRatio"); + + + +/** + * crop-to-fit, cf - affecting the resulting image width, height and resize options + */ +$cropToFit = getDefined(array('crop-to-fit', 'cf'), true, false); + +verbose("crop to fit = $cropToFit"); + + + +/** + * Set default background color from config file. + */ +$backgroundColor = getConfig('background_color', null); + +if ($backgroundColor) { + $img->setDefaultBackgroundColor($backgroundColor); + verbose("Using default background_color = $backgroundColor"); +} + + + +/** + * bgColor - Default background color to use + */ +$bgColor = get(array('bgColor', 'bg-color', 'bgc'), null); + +verbose("bgColor = $bgColor"); + + + +/** + * fill-to-fit, ff - affecting the resulting image width, height and resize options + */ +$fillToFit = get(array('fill-to-fit', 'ff'), null); + +verbose("fill-to-fit = $fillToFit"); + +if ($fillToFit !== null) { + + if (!empty($fillToFit)) { + $bgColor = $fillToFit; + verbose("fillToFit changed bgColor to = $bgColor"); + } + + $fillToFit = true; + verbose("fill-to-fit (fixed) = $fillToFit"); +} + + + +/** + * no-ratio, nr, stretch - affecting the resulting image width, height and resize options + */ +$keepRatio = getDefined(array('no-ratio', 'nr', 'stretch'), false, true); + +verbose("keep ratio = $keepRatio"); + + + +/** + * crop, c - affecting the resulting image width, height and resize options + */ +$crop = get(array('crop', 'c')); + +verbose("crop = $crop"); + + + +/** + * area, a - affecting the resulting image width, height and resize options + */ +$area = get(array('area', 'a')); + +verbose("area = $area"); + + + +/** + * skip-original, so - skip the original image and always process a new image + */ +$useOriginal = getDefined(array('skip-original', 'so'), false, true); + +verbose("use original = $useOriginal"); + + + +/** + * no-cache, nc - skip the cached version and process and create a new version in cache. + */ +$useCache = getDefined(array('no-cache', 'nc'), false, true); + +verbose("use cache = $useCache"); + + + +/** + * quality, q - set level of quality for jpeg images + */ +$quality = get(array('quality', 'q')); + +is_null($quality) + or ($quality > 0 and $quality <= 100) + or errorPage('Quality out of range'); + +verbose("quality = $quality"); + + + +/** + * compress, co - what strategy to use when compressing png images + */ +$compress = get(array('compress', 'co')); + + +is_null($compress) + or ($compress > 0 and $compress <= 9) + or errorPage('Compress out of range'); + +verbose("compress = $compress"); + + + +/** + * save-as, sa - what type of image to save + */ +$saveAs = get(array('save-as', 'sa')); + +verbose("save as = $saveAs"); + + + +/** + * scale, s - Processing option, scale up or down the image prior actual resize + */ +$scale = get(array('scale', 's')); + +is_null($scale) + or ($scale >= 0 and $scale <= 400) + or errorPage('Scale out of range'); + +verbose("scale = $scale"); + + + +/** + * palette, p - Processing option, create a palette version of the image + */ +$palette = getDefined(array('palette', 'p'), true, false); + +verbose("palette = $palette"); + + + +/** + * sharpen - Processing option, post filter for sharpen effect + */ +$sharpen = getDefined('sharpen', true, null); + +verbose("sharpen = $sharpen"); + + + +/** + * emboss - Processing option, post filter for emboss effect + */ +$emboss = getDefined('emboss', true, null); + +verbose("emboss = $emboss"); + + + +/** + * blur - Processing option, post filter for blur effect + */ +$blur = getDefined('blur', true, null); + +verbose("blur = $blur"); + + + +/** + * rotateBefore - Rotate the image with an angle, before processing + */ +$rotateBefore = get(array('rotateBefore', 'rotate-before', 'rb')); + +is_null($rotateBefore) + or ($rotateBefore >= -360 and $rotateBefore <= 360) + or errorPage('RotateBefore out of range'); + +verbose("rotateBefore = $rotateBefore"); + + + +/** + * rotateAfter - Rotate the image with an angle, before processing + */ +$rotateAfter = get(array('rotateAfter', 'rotate-after', 'ra', 'rotate', 'r')); + +is_null($rotateAfter) + or ($rotateAfter >= -360 and $rotateAfter <= 360) + or errorPage('RotateBefore out of range'); + +verbose("rotateAfter = $rotateAfter"); + + + +/** + * autoRotate - Auto rotate based on EXIF information + */ +$autoRotate = getDefined(array('autoRotate', 'auto-rotate', 'aro'), true, false); + +verbose("autoRotate = $autoRotate"); + + + +/** + * filter, f, f0-f9 - Processing option, post filter for various effects using imagefilter() + */ +$filters = array(); +$filter = get(array('filter', 'f')); +if ($filter) { + $filters[] = $filter; +} + +for ($i = 0; $i < 10; $i++) { + $filter = get(array("filter{$i}", "f{$i}")); + if ($filter) { + $filters[] = $filter; + } +} + +verbose("filters = " . print_r($filters, 1)); + + + +/** + * json - output the image as a JSON object with details on the image. + */ +$outputFormat = getDefined('json', 'json', null); + +verbose("json = $outputFormat"); + + + +/** + * dpr - change to get larger image to easier support larger dpr, such as retina. + */ +$dpr = get(array('ppi', 'dpr', 'device-pixel-ratio'), 1); + +verbose("dpr = $dpr"); + + + +/** + * convolve - image convolution as in http://php.net/manual/en/function.imageconvolution.php + */ +$convolve = get('convolve', null); +$convolutionConstant = getConfig('convolution_constant', array()); + +// Check if the convolve is matching an existing constant +if ($convolve && isset($convolutionConstant)) { + $img->addConvolveExpressions($convolutionConstant); + verbose("convolve constant = " . print_r($convolutionConstant, 1)); +} + +verbose("convolve = " . print_r($convolve, 1)); + + + +/** + * no-upscale, nu - Do not upscale smaller image to larger dimension. + */ +$upscale = getDefined(array('no-upscale', 'nu'), false, true); + +verbose("upscale = $upscale"); + + + +/** + * Get details for post processing + */ +$postProcessing = getConfig('postprocessing', array( + 'png_filter' => false, + 'png_filter_cmd' => '/usr/local/bin/optipng -q', + + 'png_deflate' => false, + 'png_deflate_cmd' => '/usr/local/bin/pngout -q', + + 'jpeg_optimize' => false, + 'jpeg_optimize_cmd' => '/usr/local/bin/jpegtran -copy none -optimize', +)); + + + +/** + * alias - Save resulting image to another alias name. + * Password always apply, must be defined. + */ +$alias = get('alias', null); +$aliasPath = getConfig('alias_path', null); +$validAliasname = getConfig('valid_aliasname', '#^[a-z0-9A-Z-_]+$#'); +$aliasTarget = null; + +if ($alias && $aliasPath && $passwordMatch) { + + $aliasTarget = $aliasPath . $alias; + $useCache = false; + + is_writable($aliasPath) + or errorPage("Directory for alias is not writable."); + + preg_match($validAliasname, $alias) + or errorPage('Filename for alias contains invalid characters. Do not add extension.'); + +} else if ($alias) { + errorPage('Alias is not enabled in the config file or password not matching.'); +} + +verbose("alias = $alias"); + + + +/** + * Display image if verbose mode + */ +if ($verbose) { + $query = array(); + parse_str($_SERVER['QUERY_STRING'], $query); + unset($query['verbose']); + unset($query['v']); + unset($query['nocache']); + unset($query['nc']); + unset($query['json']); + $url1 = '?' . htmlentities(urldecode(http_build_query($query))); + $url2 = '?' . urldecode(http_build_query($query)); + echo <<$url1
+ +

+
+
+
+EOD;
+}
+
+
+
+/**
+ * Get the cachepath from config.
+ */
+$cachePath = getConfig('cache_path', __DIR__ . '/../cache/');
+
+
+
+/**
+ * Load, process and output the image
+ */
+$img->log("Incoming arguments: " . print_r(verbose(), 1))
+    ->setSaveFolder($cachePath)
+    ->useCache($useCache)
+    ->setSource($srcImage, $imagePath)
+    ->setOptions(
+        array(
+            // Options for calculate dimensions
+            'newWidth'  => $newWidth,
+            'newHeight' => $newHeight,
+            'aspectRatio' => $aspectRatio,
+            'keepRatio' => $keepRatio,
+            'cropToFit' => $cropToFit,
+            'fillToFit' => $fillToFit,
+            'crop'      => $crop,
+            'area'      => $area,
+            'upscale'   => $upscale,
+
+            // Pre-processing, before resizing is done
+            'scale'        => $scale,
+            'rotateBefore' => $rotateBefore,
+            'autoRotate'   => $autoRotate,
+
+            // General processing options
+            'bgColor'    => $bgColor,
+
+            // Post-processing, after resizing is done
+            'palette'   => $palette,
+            'filters'   => $filters,
+            'sharpen'   => $sharpen,
+            'emboss'    => $emboss,
+            'blur'      => $blur,
+            'convolve'  => $convolve,
+            'rotateAfter' => $rotateAfter,
+
+            // Output format
+            'outputFormat' => $outputFormat,
+            'dpr'          => $dpr,
+        )
+    )
+    ->loadImageDetails()
+    ->initDimensions()
+    ->calculateNewWidthAndHeight()
+    ->setSaveAsExtension($saveAs)
+    ->setJpegQuality($quality)
+    ->setPngCompression($compress)
+    ->useOriginalIfPossible($useOriginal)
+    ->generateFilename($cachePath)
+    ->useCacheIfPossible($useCache)
+    ->load()
+    ->preResize()
+    ->resize()
+    ->postResize()
+    ->setPostProcessingOptions($postProcessing)
+    ->save()
+    ->linkToCacheFile($aliasTarget)
+    ->output();
+
+
+
diff --git a/webroot/imgs.php b/webroot/imgs.php
index 26b6cb2..b3d76e1 100644
--- a/webroot/imgs.php
+++ b/webroot/imgs.php
@@ -19,6 +19,7 @@
  */
 $config = array(
 
+    'mode' => 'strict', // 'production', 'development', 'strict'
     //'image_path'   =>  __DIR__ . '/img/',
     //'cache_path'   =>  __DIR__ . '/../cache/',
     //'alias_path'   =>  __DIR__ . '/img/alias/',
@@ -396,6 +397,23 @@ class CRemoteImage
 
 
 
+    /**
+     * Check if cache is writable or throw exception.
+     *
+     * @return $this
+     *
+     * @throws Exception if cahce folder is not writable.
+     */
+    public function isCacheWritable()
+    {
+        if (!is_writable($this->saveFolder)) {
+            throw new Exception("Cache folder is not writable for downloaded files.");
+        }
+        return $this;
+    }
+
+
+
     /**
      * Decide if the cache should be used or not before trying to download
      * a remote file.
@@ -549,8 +567,10 @@ class CRemoteImage
 
         $this->status = $this->http->getStatus();
         if ($this->status === 200) {
+            $this->isCacheWritable();
             return $this->save();
         } else if ($this->status === 304) {
+            $this->isCacheWritable();
             return $this->updateCacheDetails();
         }
 
@@ -1082,6 +1102,9 @@ class CImage
         $cache  = $this->saveFolder . "/remote/";
 
         if (!is_dir($cache)) {
+            if (!is_writable($this->saveFolder)) {
+                throw new Exception("Can not create remote cache, cachefolder not writable.");
+            }
             mkdir($cache);
             $this->log("The remote cache does not exists, creating it.");
         }
@@ -2979,8 +3002,16 @@ EOD;
  */
 function errorPage($msg)
 {
-    header("HTTP/1.0 404 Not Found");
-    die('img.php say 404: ' . $msg);
+    global $mode;
+
+    header("HTTP/1.0 500 Internal Server Error");
+
+    if ($mode == 'development') {
+        die("[img.php] $msg");
+    } else {
+        error_log("[img.php] $msg");
+        die("HTTP/1.0 500 Internal Server Error");
+    }
 }
 
 
@@ -3091,6 +3122,13 @@ if (is_file($configFile)) {
 
 
 
+/**
+* verbose, v - do a verbose dump of what happens
+*/
+$verbose = getDefined(array('verbose', 'v'), true, false);
+
+
+
 /**
  * Set mode as strict, production or development.
  * Default is production environment.
@@ -3107,21 +3145,32 @@ if (!extension_loaded('gd')) {
 
 // Specific settings for each mode
 if ($mode == 'strict') {
+
     error_reporting(0);
     ini_set('display_errors', 0);
+    ini_set('log_errors', 1);
+    $verbose = false;
 
 } else if ($mode == 'production') {
-    error_reporting(0);
+
+    error_reporting(-1);
     ini_set('display_errors', 0);
+    ini_set('log_errors', 1);
+    $verbose = false;
 
 } else if ($mode == 'development') {
+
     error_reporting(-1);
-ini_set('display_errors', 1);
+    ini_set('display_errors', 1);
+    ini_set('log_errors', 0);
 
 } else {
     errorPage("Unknown mode: $mode");
 }
 
+verbose("mode = $mode");
+verbose("error log = " . ini_get('error_log'));
+
 
 
 /**
@@ -3137,13 +3186,6 @@ if ($defaultTimezone) {
 
 
 
-/**
- * verbose, v - do a verbose dump of what happens
- */
-$verbose = getDefined(array('verbose', 'v'), true, false);
-
-
-
 /**
  * Check if passwords are configured, used and match.
  * Options decide themself if they require passwords to be used.