├── .gitignore ├── LICENSE.md ├── README.md ├── composer.json ├── src ├── Exception │ └── CurlException.php └── Optimizer.php └── tests ├── files ├── Layer_12.png ├── Snake_River.jpg └── blog.txt └── imageOptimizeTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | composer.lock 3 | grumphp.yml -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) Artisans Web 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Image optimization using PHP 2 | 3 | This library helps you to compress JPGs, PNGs, GIFs images on the fly. Apart from this package, you don't need to install any additional software or package to perform optimization task. 4 | 5 | ## Installation 6 | 7 | You can install the package via composer: 8 | 9 | ```bash 10 | composer require artisansweb/image-optimizer 11 | ``` 12 | 13 | Under the hood, this package uses [resmush.it](http://resmush.it) service to compress the images. Alternatively, package using native PHP functions - [imagecreatefromjpeg](https://www.php.net/manual/en/function.imagecreatefromjpeg.php), [imagecreatefrompng](https://www.php.net/manual/en/function.imagecreatefrompng.php), [imagecreatefromgif](https://www.php.net/manual/en/function.imagecreatefromgif.php), [imagejpeg](https://www.php.net/manual/en/function.imagejpeg.php). 14 | 15 | ## Usage 16 | 17 | This package is straight-forward to use. All you need to do is pass source path of your image. 18 | 19 | ```php 20 | use ArtisansWeb\Optimizer; 21 | 22 | $img = new Optimizer(); 23 | 24 | $source = 'SOURCE_PATH_OF_IMAGE'; 25 | $img->optimize($source); 26 | ``` 27 | 28 | Above code will optimize the image and replace the original image with the optimized version. 29 | 30 | Optionally, you can also pass destination path where optimized version will stored. 31 | 32 | ```php 33 | $source = 'SOURCE_PATH_OF_IMAGE'; 34 | $destination = 'DESTINATION_PATH_OF_IMAGE'; 35 | $img->optimize($source, $destination); 36 | ``` 37 | 38 | Recommeded way of using this code is on image upload. The user should optimize image on upload which will result in better performance. 39 | 40 | Let's say you want to store optimized version in the 'images' folder. You can use the below code for this purpose. 41 | 42 | ```php 43 | optimize('images/'.$_FILES['file']['name']); 52 | } 53 | ?> 54 | 55 |
56 | 57 | 58 |
59 | ``` 60 | 61 | ## License 62 | 63 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 64 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "artisansweb/image-optimizer", 3 | "description": "Optimize the images on the fly.", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Sajid", 9 | "email": "sajid@artisansweb.net" 10 | } 11 | ], 12 | "autoload": { 13 | "psr-4": { 14 | "ArtisansWeb\\": "src/" 15 | } 16 | }, 17 | "require": { 18 | "guzzlehttp/guzzle": "^6.5" 19 | }, 20 | "require-dev": { 21 | "phpro/grumphp": "^0.17.2", 22 | "squizlabs/php_codesniffer": "^3.5", 23 | "phpunit/phpunit": "8.5" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Exception/CurlException.php: -------------------------------------------------------------------------------- 1 | is_curl_enabled = false; 35 | } 36 | 37 | $this->root_dir = dirname(dirname(__FILE__)); 38 | 39 | $this->start = microtime(true); 40 | 41 | $this->system_max_execution_time = ini_get('max_execution_time'); 42 | } 43 | 44 | /** 45 | * Build an request array out of source file. 46 | */ 47 | public function buildRequest() 48 | { 49 | if (!$this->is_curl_enabled) { 50 | return array( 51 | 'multipart' => array( 52 | array( 53 | 'name' => "files", 54 | 'contents' => fopen($this->source, 'r'), 55 | 'filename' => pathinfo($this->source)['basename'], 56 | 'headers' => array('Content-Type' => $this->mime) 57 | ) 58 | ) 59 | ); 60 | } else { 61 | $info = pathinfo($this->source); 62 | $name = $info['basename']; 63 | $output = new \CURLFile($this->source, $this->mime, $name); 64 | return array( 65 | "files" => $output, 66 | ); 67 | } 68 | } 69 | 70 | /** 71 | * Check if the source file is an image only. 72 | */ 73 | public function isValidFile() 74 | { 75 | $this->mime = mime_content_type($this->source); 76 | 77 | // check if source is allowed image format 78 | if (!in_array($this->mime, $this->allowed_mime_types)) { 79 | return false; 80 | } 81 | 82 | return true; 83 | } 84 | 85 | /** 86 | * Check if file has a valid extension. 87 | * @param string $file - file path which extension needs to check. 88 | */ 89 | public function isValidExtension($file = '') 90 | { 91 | $ext = pathinfo($file, PATHINFO_EXTENSION); 92 | if (!in_array(strtolower($ext), $this->allowed_file_extensions)) { 93 | return false; 94 | } 95 | 96 | return true; 97 | } 98 | 99 | /** 100 | * Optimize the image using reSmush.it service. 101 | * 102 | * @param string $source - source file path 103 | * @param string $destination - destination file path 104 | */ 105 | public function optimize($source = '', $destination = '') 106 | { 107 | $this->source = $this->destination = $source; 108 | 109 | if (!empty($destination)) { 110 | // check if destination file extension is valid 111 | if (!$this->isValidExtension($destination)) { 112 | $this->logErrorMessage("Destination file ($destination) does not have a valid extension."); 113 | return false; 114 | } 115 | 116 | $this->destination = $this->generateUniqueFilename($destination); 117 | } 118 | 119 | // check if source file exists 120 | if (!file_exists($this->source)) { 121 | $this->logErrorMessage("Source file ($this->source) does not exist."); 122 | return false; 123 | } 124 | 125 | if (!$this->isValidFile()) { 126 | $this->logErrorMessage("Source file ($this->source) does not have a valid extension."); 127 | return false; 128 | } 129 | 130 | // file size must be below 5MB 131 | if (filesize($this->source) >= 5242880) { 132 | $this->logErrorMessage("Source file ($this->source) exceeded maximum allowed size limit of 5MB."); 133 | return false; 134 | } 135 | 136 | try { 137 | if (!$this->is_curl_enabled) { 138 | throw new CurlException("cURL is not enabled. Use fallback method."); 139 | } 140 | 141 | $this->resetMaxExecutionTimeIfRequired(); 142 | 143 | $data = $this->buildRequest($this->source); 144 | 145 | $ch = curl_init(); 146 | curl_setopt($ch, CURLOPT_URL, $this->api_endpoint.'?qlty='.$this->qlty); 147 | curl_setopt($ch, CURLOPT_POST, 1); 148 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 149 | curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); 150 | curl_setopt($ch, CURLOPT_POSTFIELDS, $data); 151 | $result = curl_exec($ch); 152 | if (curl_errno($ch)) { 153 | throw new \Exception(curl_error($ch)); 154 | } 155 | curl_close($ch); 156 | 157 | $arr_result = json_decode($result); 158 | 159 | // Maybe server is not online. Use fallback method. 160 | if (empty($arr_result)) { 161 | throw new \Exception("Error Processing Request."); 162 | } 163 | 164 | if (property_exists($arr_result, 'dest')) { 165 | $this->storeOnFilesystem($arr_result); 166 | } else { 167 | throw new \Exception("Response does not contain compressed file URL."); 168 | } 169 | } catch (CurlException $e) { 170 | //Use guzzle http now 171 | $this->useGuzzleHTTPClient(); 172 | } catch (\Exception $e) { 173 | // print the error message if you want to debug API error. for e.g. echo $e->getMessage(); 174 | $this->qlty = 85; 175 | $this->compressImage(); 176 | } 177 | } 178 | 179 | /** 180 | * Use Guzzle HTTP client to interact with resmush.it api 181 | */ 182 | public function useGuzzleHTTPClient() 183 | { 184 | $this->resetMaxExecutionTimeIfRequired(); 185 | 186 | try { 187 | $client = new \GuzzleHttp\Client(["base_uri" => $this->api_endpoint]); 188 | 189 | $data = $this->buildRequest($this->source); 190 | 191 | $response = $client->request('POST', "?qlty=".$this->qlty, $data); 192 | 193 | if (200 == $response->getStatusCode()) { 194 | $response = $response->getBody(); 195 | 196 | if (!empty($response)) { 197 | $arr_result = json_decode($response); 198 | if (property_exists($arr_result, 'dest')) { 199 | $this->storeOnFilesystem($arr_result); 200 | } else { 201 | throw new \Exception("Response does not contain compressed file URL."); 202 | } 203 | } else { 204 | throw new \Exception("Error Processing Request."); 205 | } 206 | } else { 207 | throw new \Exception("Status code is not 200."); 208 | } 209 | } catch (\Exception $e) { 210 | $this->qlty = 85; 211 | $this->compressImage(); 212 | } 213 | } 214 | 215 | /** 216 | * Store the optimized file at the destination. 217 | * 218 | * @param array $arr_result - response returned by reSmush.it and contains the optimized version in 'dest' property 219 | */ 220 | public function storeOnFilesystem($arr_result) 221 | { 222 | $this->resetMaxExecutionTimeIfRequired(); 223 | 224 | $fp = fopen($this->destination, 'wb'); 225 | 226 | if (!$this->is_curl_enabled) { 227 | $client = new \GuzzleHttp\Client(); 228 | $request = $client->get($arr_result->dest, ['sink' => $fp]); 229 | } else { 230 | $ch = curl_init($arr_result->dest); 231 | curl_setopt($ch, CURLOPT_FILE, $fp); 232 | curl_setopt($ch, CURLOPT_HEADER, 0); 233 | curl_exec($ch); 234 | curl_close($ch); 235 | } 236 | 237 | fclose($fp); 238 | } 239 | 240 | /** 241 | * Optimize image using PHP native functions if reSmush.it service get failed. 242 | */ 243 | public function compressImage() 244 | { 245 | $this->resetMaxExecutionTimeIfRequired(); 246 | 247 | switch ($this->mime) { 248 | case 'image/jpeg': 249 | $image = imagecreatefromjpeg($this->source); 250 | break; 251 | case 'image/png': 252 | $image = imagecreatefrompng($this->source); 253 | break; 254 | case 'image/gif': 255 | $image = imagecreatefromgif($this->source); 256 | break; 257 | default: 258 | $image = imagecreatefromjpeg($this->source); 259 | } 260 | 261 | // Save image on disk. 262 | imagejpeg($image, $this->destination, $this->qlty); 263 | } 264 | 265 | /** 266 | * Generate a unique filename for specified directory. 267 | * @param string $file: path of a file 268 | */ 269 | public function generateUniqueFilename($file = '') 270 | { 271 | $dir = pathinfo($file, PATHINFO_DIRNAME); 272 | $ext = pathinfo($file, PATHINFO_EXTENSION); 273 | $filename = pathinfo($file, PATHINFO_BASENAME); 274 | if ($ext) { 275 | $ext = '.' . $ext; 276 | } 277 | 278 | $number = ''; 279 | while (file_exists($dir . "/$filename")) { 280 | $new_number = (int) $number + 1; 281 | if ('' == "$number$ext") { 282 | $filename = "$filename-" . $new_number; 283 | } else { 284 | $filename = str_replace(array("-$number$ext", "$number$ext"), '-' . $new_number . $ext, $filename); 285 | } 286 | $number = $new_number; 287 | } 288 | 289 | return $dir.'/'.$filename; 290 | } 291 | 292 | /** 293 | * Log the error message. 294 | * @param string $message: Message which needs to log in debug.log 295 | */ 296 | public function logErrorMessage($message = '') 297 | { 298 | 299 | $log_file = $this->root_dir.'/debug.log'; 300 | 301 | if (!file_exists($log_file)) { 302 | touch($log_file); 303 | } 304 | 305 | if (!empty($message)) { 306 | $message = date('[d/M/Y H:i:s]').' '.$message.PHP_EOL; 307 | error_log($message, 3, $log_file); 308 | } 309 | } 310 | 311 | /** 312 | * Reset max_execution_time if system's execution time is about to expire. It will resume the operation. 313 | */ 314 | public function resetMaxExecutionTimeIfRequired() 315 | { 316 | $now = microtime(true); 317 | if (($now - $this->start) >= ($this->system_max_execution_time - 10)) { 318 | $this->start = $now; 319 | ini_set('max_execution_time', $this->system_max_execution_time); 320 | } 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /tests/files/Layer_12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artisansweb/image-optimizer/b5a1ad2a720fd1cd12bc1fc3d49416cbfe01c103/tests/files/Layer_12.png -------------------------------------------------------------------------------- /tests/files/Snake_River.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artisansweb/image-optimizer/b5a1ad2a720fd1cd12bc1fc3d49416cbfe01c103/tests/files/Snake_River.jpg -------------------------------------------------------------------------------- /tests/files/blog.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artisansweb/image-optimizer/b5a1ad2a720fd1cd12bc1fc3d49416cbfe01c103/tests/files/blog.txt -------------------------------------------------------------------------------- /tests/imageOptimizeTest.php: -------------------------------------------------------------------------------- 1 | test_obj = new ArtisansWeb\Optimizer(); 13 | $this->root_dir = dirname(__FILE__); 14 | } 15 | 16 | /** @test */ 17 | public function fileNotExists() 18 | { 19 | $file = $this->root_dir. '/files/Layer_121.png'; 20 | $this->assertFalse($this->test_obj->optimize($file)); 21 | } 22 | 23 | /** @test */ 24 | public function invalidFile() 25 | { 26 | $file = $this->root_dir. '/files/blog.txt'; 27 | $this->assertFalse($this->test_obj->optimize($file)); 28 | } 29 | 30 | /** @test */ 31 | public function filesizeExceeded() 32 | { 33 | $file = $this->root_dir. '/files/Snake_River.jpg'; 34 | $this->assertFalse($this->test_obj->optimize($file)); 35 | } 36 | 37 | /** @test */ 38 | public function wrongDestinationFileExtension() 39 | { 40 | $file = $this->root_dir. '/files/Layer_121.png'; 41 | $destination = $this->root_dir. '/files/blog.txt'; 42 | $this->assertFalse($this->test_obj->optimize($file, $destination)); 43 | } 44 | 45 | /** @test */ 46 | public function generateUniqueFilename() 47 | { 48 | $file = $this->root_dir. '/files/Layer_12.png'; 49 | $destination = $this->root_dir. '/files/Snake_River.jpg'; 50 | $actual_file = $this->root_dir. '/files/Snake_River-1.jpg'; 51 | $this->test_obj->optimize($file, $destination); 52 | $this->assertFileExists($actual_file); 53 | } 54 | } 55 | --------------------------------------------------------------------------------