1<?php /** @noinspection PhpComposerExtensionStubsInspection */ 2 3 4namespace splitbrain\slika; 5 6/** 7 * Image processing adapter for PHP's libGD 8 */ 9class GdAdapter extends Adapter 10{ 11 /** @var resource libGD image */ 12 protected $image; 13 /** @var int width of the current image */ 14 protected $width = 0; 15 /** @var int height of the current image */ 16 protected $height = 0; 17 /** @var string the extension of the file we're working with */ 18 protected $extension; 19 20 21 /** @inheritDoc */ 22 public function __construct($imagepath, $options = []) 23 { 24 parent::__construct($imagepath, $options); 25 $this->image = $this->loadImage($imagepath); 26 } 27 28 /** 29 * Clean up 30 */ 31 public function __destruct() 32 { 33 // destroy the GD image resource (only needed on PHP < 8.0) 34 if (is_resource($this->image)) { 35 imagedestroy($this->image); 36 } 37 } 38 39 /** @inheritDoc 40 * @throws Exception 41 * @link https://gist.github.com/EionRobb/8e0c76178522bc963c75caa6a77d3d37#file-imagecreatefromstring_autorotate-php-L15 42 */ 43 public function autorotate() 44 { 45 if ($this->extension !== 'jpeg') { 46 return $this; 47 } 48 49 $orientation = 1; 50 51 if (function_exists('exif_read_data')) { 52 // use PHP's exif capablities 53 $exif = exif_read_data($this->imagepath); 54 if (!empty($exif['Orientation'])) { 55 $orientation = $exif['Orientation']; 56 } 57 } else { 58 // grep the exif info from the raw contents 59 // we read only the first 70k bytes 60 $data = file_get_contents($this->imagepath, false, null, 0, 70000); 61 if (preg_match('@\x12\x01\x03\x00\x01\x00\x00\x00(.)\x00\x00\x00@', $data, $matches)) { 62 // Little endian EXIF 63 $orientation = ord($matches[1]); 64 } else if (preg_match('@\x01\x12\x00\x03\x00\x00\x00\x01\x00(.)\x00\x00@', $data, $matches)) { 65 // Big endian EXIF 66 $orientation = ord($matches[1]); 67 } 68 } 69 70 return $this->rotate($orientation); 71 } 72 73 /** 74 * @inheritDoc 75 * @throws Exception 76 */ 77 public function rotate($orientation) 78 { 79 $orientation = (int)$orientation; 80 if ($orientation < 0 || $orientation > 8) { 81 throw new Exception('Unknown rotation given'); 82 } 83 84 if ($orientation <= 1) { 85 // no rotation wanted 86 return $this; 87 } 88 89 // fill color 90 $transparency = imagecolorallocatealpha($this->image, 0, 0, 0, 127); 91 92 // rotate 93 if (in_array($orientation, [3, 4])) { 94 $image = imagerotate($this->image, 180, $transparency); 95 } 96 if (in_array($orientation, [5, 6])) { 97 $image = imagerotate($this->image, -90, $transparency); 98 list($this->width, $this->height) = [$this->height, $this->width]; 99 } elseif (in_array($orientation, [7, 8])) { 100 $image = imagerotate($this->image, 90, $transparency); 101 list($this->width, $this->height) = [$this->height, $this->width]; 102 } 103 /** @var resource $image is now defined */ 104 105 // additionally flip 106 if (in_array($orientation, [2, 5, 7, 4])) { 107 imageflip($image, IMG_FLIP_HORIZONTAL); 108 } 109 110 $this->__destruct(); // destroy old image 111 $this->image = $image; 112 113 //keep png alpha channel if possible 114 if ($this->extension == 'png' && function_exists('imagesavealpha')) { 115 imagealphablending($this->image, false); 116 imagesavealpha($this->image, true); 117 } 118 119 return $this; 120 } 121 122 /** 123 * @inheritDoc 124 * @throws Exception 125 */ 126 public function resize($width, $height) 127 { 128 list($width, $height) = $this->boundingBox($width, $height); 129 $this->resizeOperation($width, $height); 130 return $this; 131 } 132 133 /** 134 * @inheritDoc 135 * @throws Exception 136 */ 137 public function crop($width, $height) 138 { 139 list($this->width, $this->height, $offsetX, $offsetY) = $this->cropPosition($width, $height); 140 $this->resizeOperation($width, $height, $offsetX, $offsetY); 141 return $this; 142 } 143 144 /** 145 * @inheritDoc 146 * @throws Exception 147 */ 148 public function save($path, $extension = '') 149 { 150 if ($extension === 'jpg') { 151 $extension = 'jpeg'; 152 } 153 if ($extension === '') { 154 $extension = $this->extension; 155 } 156 $saver = 'image' . $extension; 157 if (!function_exists($saver)) { 158 throw new Exception('Can not save image format ' . $extension); 159 } 160 161 if ($extension == 'jpeg') { 162 imagejpeg($this->image, $path, $this->options['quality']); 163 } else { 164 $saver($this->image, $path); 165 } 166 167 $this->__destruct(); 168 } 169 170 /** 171 * Initialize libGD on the given image 172 * 173 * @param string $path 174 * @return resource 175 * @throws Exception 176 */ 177 protected function loadImage($path) 178 { 179 // Figure out the file info 180 $info = getimagesize($path); 181 if ($info === false) { 182 throw new Exception('Failed to read image information'); 183 } 184 $this->width = $info[0]; 185 $this->height = $info[1]; 186 187 // what type of image is it? 188 $this->extension = image_type_to_extension($info[2], false); 189 $creator = 'imagecreatefrom' . $this->extension; 190 if (!function_exists($creator)) { 191 throw new Exception('Can not work with image format ' . $this->extension); 192 } 193 194 // create the GD instance 195 $image = @$creator($path); 196 197 if ($image === false) { 198 throw new Exception('Failed to load image wiht libGD'); 199 } 200 201 return $image; 202 } 203 204 /** 205 * Creates a new blank image to which we can copy 206 * 207 * Tries to set up alpha/transparency stuff correctly 208 * 209 * @param int $width 210 * @param int $height 211 * @return resource 212 * @throws Exception 213 */ 214 protected function createImage($width, $height) 215 { 216 // create a canvas to copy to, use truecolor if possible (except for gif) 217 $canvas = false; 218 if (function_exists('imagecreatetruecolor') && $this->extension != 'gif') { 219 $canvas = @imagecreatetruecolor($width, $height); 220 } 221 if (!$canvas) { 222 $canvas = @imagecreate($width, $height); 223 } 224 if (!$canvas) { 225 throw new Exception('Failed to create new canvas'); 226 } 227 228 //keep png alpha channel if possible 229 if ($this->extension == 'png' && function_exists('imagesavealpha')) { 230 imagealphablending($canvas, false); 231 imagesavealpha($canvas, true); 232 } 233 234 //keep gif transparent color if possible 235 if ($this->extension == 'gif') { 236 $this->keepGifTransparency($this->image, $canvas); 237 } 238 239 return $canvas; 240 } 241 242 /** 243 * Copy transparency from gif to gif 244 * 245 * If no transparency is found or the PHP does not support it, the canvas is filled with white 246 * 247 * @param resource $image Original image 248 * @param resource $canvas New, empty image 249 * @return void 250 */ 251 protected function keepGifTransparency($image, $canvas) 252 { 253 if (!function_exists('imagefill') || !function_exists('imagecolorallocate')) { 254 return; 255 } 256 257 try { 258 if (!function_exists('imagecolorsforindex') || !function_exists('imagecolortransparent')) { 259 throw new \Exception('missing alpha methods'); 260 } 261 262 $transcolorindex = @imagecolortransparent($image); 263 $transcolor = @imagecolorsforindex($image, $transcolorindex); 264 if (!$transcolor) { 265 // pre-PHP8 false is returned, in PHP8 an exception is thrown 266 throw new \ValueError('no valid alpha color'); 267 } 268 269 $transcolorindex = @imagecolorallocate( 270 $canvas, 271 $transcolor['red'], 272 $transcolor['green'], 273 $transcolor['blue'] 274 ); 275 @imagefill($canvas, 0, 0, $transcolorindex); 276 @imagecolortransparent($canvas, $transcolorindex); 277 278 } catch (\Throwable $ignored) { 279 //filling with white 280 $whitecolorindex = @imagecolorallocate($canvas, 255, 255, 255); 281 @imagefill($canvas, 0, 0, $whitecolorindex); 282 } 283 } 284 285 /** 286 * Calculate new size 287 * 288 * If widht and height are given, the new size will be fit within this bounding box. 289 * If only one value is given the other is adjusted to match according to the aspect ratio 290 * 291 * @param int $width width of the bounding box 292 * @param int $height height of the bounding box 293 * @return array (width, height) 294 * @throws Exception 295 */ 296 protected function boundingBox($width, $height) 297 { 298 $width = $this->cleanDimension($width, $this->width); 299 $height = $this->cleanDimension($height, $this->height); 300 301 if ($width == 0 && $height == 0) { 302 throw new Exception('You can not resize to 0x0'); 303 } 304 305 if (!$height) { 306 // adjust to match width 307 $height = round(($width * $this->height) / $this->width); 308 } else if (!$width) { 309 // adjust to match height 310 $width = round(($height * $this->width) / $this->height); 311 } else { 312 // fit into bounding box 313 $scale = min($width / $this->width, $height / $this->height); 314 $width = $this->width * $scale; 315 $height = $this->height * $scale; 316 } 317 318 return [$width, $height]; 319 } 320 321 /** 322 * Ensure the given Dimension is a proper pixel value 323 * 324 * When a percentage is given, the value is calculated based on the given original dimension 325 * 326 * @param int|string $dim New Dimension 327 * @param int $orig Original dimension 328 * @return int 329 */ 330 protected function cleanDimension($dim, $orig) 331 { 332 if ($dim && substr($dim, -1) == '%') { 333 $dim = round($orig * ((float)$dim / 100)); 334 } else { 335 $dim = (int)$dim; 336 } 337 338 return $dim; 339 } 340 341 /** 342 * Calculates crop position 343 * 344 * Given the wanted final size, this calculates which exact area needs to be cut 345 * from the original image to be then resized to the wanted dimensions. 346 * 347 * @param int $width 348 * @param int $height 349 * @return array (cropWidth, cropHeight, offsetX, offsetY) 350 * @throws Exception 351 */ 352 protected function cropPosition($width, $height) 353 { 354 if ($width == 0 && $height == 0) { 355 throw new Exception('You can not crop to 0x0'); 356 } 357 358 if (!$height) { 359 $height = $width; 360 } 361 362 if (!$width) { 363 $width = $height; 364 } 365 366 // calculate ratios 367 $oldRatio = $this->width / $this->height; 368 $newRatio = $width / $height; 369 370 // calulate new size 371 if ($newRatio >= 1) { 372 if ($newRatio > $oldRatio) { 373 $cropWidth = $this->width; 374 $cropHeight = (int)($this->width / $newRatio); 375 } else { 376 $cropWidth = (int)($this->height * $newRatio); 377 $cropHeight = $this->height; 378 } 379 } else { 380 if ($newRatio < $oldRatio) { 381 $cropWidth = (int)($this->height * $newRatio); 382 $cropHeight = $this->height; 383 } else { 384 $cropWidth = $this->width; 385 $cropHeight = (int)($this->width / $newRatio); 386 } 387 } 388 389 // calculate crop offset 390 $offsetX = (int)(($this->width - $cropWidth) / 2); 391 $offsetY = (int)(($this->height - $cropHeight) / 2); 392 393 return [$cropWidth, $cropHeight, $offsetX, $offsetY]; 394 } 395 396 /** 397 * resize or crop images using PHP's libGD support 398 * 399 * @param int $toWidth desired width 400 * @param int $toHeight desired height 401 * @param int $offsetX offset of crop centre 402 * @param int $offsetY offset of crop centre 403 * @throws Exception 404 */ 405 protected function resizeOperation($toWidth, $toHeight, $offsetX = 0, $offsetY = 0) 406 { 407 $newimg = $this->createImage($toWidth, $toHeight); 408 409 //try resampling first, fall back to resizing 410 if ( 411 !function_exists('imagecopyresampled') || 412 !@imagecopyresampled( 413 $newimg, 414 $this->image, 415 0, 416 0, 417 $offsetX, 418 $offsetY, 419 $toWidth, 420 $toHeight, 421 $this->width, 422 $this->height 423 ) 424 ) { 425 imagecopyresized( 426 $newimg, 427 $this->image, 428 0, 429 0, 430 $offsetX, 431 $offsetY, 432 $toWidth, 433 $toHeight, 434 $this->width, 435 $this->height 436 ); 437 } 438 439 // destroy original GD image ressource and replace with new one 440 $this->__destruct(); 441 $this->image = $newimg; 442 $this->width = $toWidth; 443 $this->height = $toHeight; 444 } 445 446} 447