1: <?php
2:
3: 4: 5: 6:
7:
8: namespace Nette\Utils;
9:
10: use Nette;
11:
12:
13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48: 49: 50: 51: 52: 53: 54: 55: 56: 57: 58: 59: 60: 61: 62: 63: 64: 65: 66: 67: 68: 69: 70: 71: 72: 73: 74: 75: 76: 77: 78: 79: 80: 81: 82: 83: 84: 85: 86: 87:
88: class Image
89: {
90: use Nette\SmartObject;
91:
92:
93: const SHRINK_ONLY = 0b0001;
94:
95:
96: const STRETCH = 0b0010;
97:
98:
99: const FIT = 0b0000;
100:
101:
102: const FILL = 0b0100;
103:
104:
105: const EXACT = 0b1000;
106:
107:
108: const
109: JPEG = IMAGETYPE_JPEG,
110: PNG = IMAGETYPE_PNG,
111: GIF = IMAGETYPE_GIF,
112: WEBP = 18;
113:
114: const EMPTY_GIF = "GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\x00\x00\x00!\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;";
115:
116: private static $formats = [self::JPEG => 'jpeg', self::PNG => 'png', self::GIF => 'gif', self::WEBP => 'webp'];
117:
118:
119: private $image;
120:
121:
122: 123: 124: 125: 126: 127: 128: 129:
130: public static function rgb($red, $green, $blue, $transparency = 0)
131: {
132: return [
133: 'red' => max(0, min(255, (int) $red)),
134: 'green' => max(0, min(255, (int) $green)),
135: 'blue' => max(0, min(255, (int) $blue)),
136: 'alpha' => max(0, min(127, (int) $transparency)),
137: ];
138: }
139:
140:
141: 142: 143: 144: 145: 146: 147: 148:
149: public static function fromFile($file, &$format = null)
150: {
151: if (!extension_loaded('gd')) {
152: throw new Nette\NotSupportedException('PHP extension GD is not loaded.');
153: }
154:
155: $format = @getimagesize($file)[2];
156: if (!$format && PHP_VERSION_ID < 70100 && @file_get_contents($file, false, null, 8, 4) === 'WEBP') {
157: $format = self::WEBP;
158: }
159: if (!isset(self::$formats[$format])) {
160: $format = null;
161: throw new UnknownImageFileException(is_file($file) ? "Unknown type of file '$file'." : "File '$file' not found.");
162: }
163: return new static(Callback::invokeSafe('imagecreatefrom' . self::$formats[$format], [$file], function ($message) {
164: throw new ImageException($message);
165: }));
166: }
167:
168:
169: 170: 171: 172: 173: 174: 175:
176: public static function fromString($s, &$format = null)
177: {
178: if (!extension_loaded('gd')) {
179: throw new Nette\NotSupportedException('PHP extension GD is not loaded.');
180: }
181:
182: if (func_num_args() > 1) {
183: $tmp = @getimagesizefromstring($s)[2];
184: $format = isset(self::$formats[$tmp]) ? $tmp : null;
185: }
186:
187: return new static(Callback::invokeSafe('imagecreatefromstring', [$s], function ($message) {
188: throw new ImageException($message);
189: }));
190: }
191:
192:
193: 194: 195: 196: 197: 198: 199:
200: public static function fromBlank($width, $height, $color = null)
201: {
202: if (!extension_loaded('gd')) {
203: throw new Nette\NotSupportedException('PHP extension GD is not loaded.');
204: }
205:
206: $width = (int) $width;
207: $height = (int) $height;
208: if ($width < 1 || $height < 1) {
209: throw new Nette\InvalidArgumentException('Image width and height must be greater than zero.');
210: }
211:
212: $image = imagecreatetruecolor($width, $height);
213: if (is_array($color)) {
214: $color += ['alpha' => 0];
215: $color = imagecolorresolvealpha($image, $color['red'], $color['green'], $color['blue'], $color['alpha']);
216: imagealphablending($image, false);
217: imagefilledrectangle($image, 0, 0, $width - 1, $height - 1, $color);
218: imagealphablending($image, true);
219: }
220: return new static($image);
221: }
222:
223:
224: 225: 226: 227:
228: public function __construct($image)
229: {
230: $this->setImageResource($image);
231: imagesavealpha($image, true);
232: }
233:
234:
235: 236: 237: 238:
239: public function getWidth()
240: {
241: return imagesx($this->image);
242: }
243:
244:
245: 246: 247: 248:
249: public function getHeight()
250: {
251: return imagesy($this->image);
252: }
253:
254:
255: 256: 257: 258: 259:
260: protected function setImageResource($image)
261: {
262: if (!is_resource($image) || get_resource_type($image) !== 'gd') {
263: throw new Nette\InvalidArgumentException('Image is not valid.');
264: }
265: $this->image = $image;
266: return $this;
267: }
268:
269:
270: 271: 272: 273:
274: public function getImageResource()
275: {
276: return $this->image;
277: }
278:
279:
280: 281: 282: 283: 284: 285: 286:
287: public function resize($width, $height, $flags = self::FIT)
288: {
289: if ($flags & self::EXACT) {
290: return $this->resize($width, $height, self::FILL)->crop('50%', '50%', $width, $height);
291: }
292:
293: list($newWidth, $newHeight) = static::calculateSize($this->getWidth(), $this->getHeight(), $width, $height, $flags);
294:
295: if ($newWidth !== $this->getWidth() || $newHeight !== $this->getHeight()) {
296: $newImage = static::fromBlank($newWidth, $newHeight, self::rgb(0, 0, 0, 127))->getImageResource();
297: imagecopyresampled(
298: $newImage, $this->image,
299: 0, 0, 0, 0,
300: $newWidth, $newHeight, $this->getWidth(), $this->getHeight()
301: );
302: $this->image = $newImage;
303: }
304:
305: if ($width < 0 || $height < 0) {
306: imageflip($this->image, $width < 0 ? ($height < 0 ? IMG_FLIP_BOTH : IMG_FLIP_HORIZONTAL) : IMG_FLIP_VERTICAL);
307: }
308: return $this;
309: }
310:
311:
312: 313: 314: 315: 316: 317: 318: 319: 320:
321: public static function calculateSize($srcWidth, $srcHeight, $newWidth, $newHeight, $flags = self::FIT)
322: {
323: if (is_string($newWidth) && substr($newWidth, -1) === '%') {
324: $newWidth = (int) round($srcWidth / 100 * abs(substr($newWidth, 0, -1)));
325: $percents = true;
326: } else {
327: $newWidth = (int) abs($newWidth);
328: }
329:
330: if (is_string($newHeight) && substr($newHeight, -1) === '%') {
331: $newHeight = (int) round($srcHeight / 100 * abs(substr($newHeight, 0, -1)));
332: $flags |= empty($percents) ? 0 : self::STRETCH;
333: } else {
334: $newHeight = (int) abs($newHeight);
335: }
336:
337: if ($flags & self::STRETCH) {
338: if (empty($newWidth) || empty($newHeight)) {
339: throw new Nette\InvalidArgumentException('For stretching must be both width and height specified.');
340: }
341:
342: if ($flags & self::SHRINK_ONLY) {
343: $newWidth = (int) round($srcWidth * min(1, $newWidth / $srcWidth));
344: $newHeight = (int) round($srcHeight * min(1, $newHeight / $srcHeight));
345: }
346:
347: } else {
348: if (empty($newWidth) && empty($newHeight)) {
349: throw new Nette\InvalidArgumentException('At least width or height must be specified.');
350: }
351:
352: $scale = [];
353: if ($newWidth > 0) {
354: $scale[] = $newWidth / $srcWidth;
355: }
356:
357: if ($newHeight > 0) {
358: $scale[] = $newHeight / $srcHeight;
359: }
360:
361: if ($flags & self::FILL) {
362: $scale = [max($scale)];
363: }
364:
365: if ($flags & self::SHRINK_ONLY) {
366: $scale[] = 1;
367: }
368:
369: $scale = min($scale);
370: $newWidth = (int) round($srcWidth * $scale);
371: $newHeight = (int) round($srcHeight * $scale);
372: }
373:
374: return [max($newWidth, 1), max($newHeight, 1)];
375: }
376:
377:
378: 379: 380: 381: 382: 383: 384: 385:
386: public function crop($left, $top, $width, $height)
387: {
388: list($r['x'], $r['y'], $r['width'], $r['height'])
389: = static::calculateCutout($this->getWidth(), $this->getHeight(), $left, $top, $width, $height);
390: if (PHP_VERSION_ID > 50611) {
391: $this->image = imagecrop($this->image, $r);
392: } else {
393: $newImage = static::fromBlank($r['width'], $r['height'], self::RGB(0, 0, 0, 127))->getImageResource();
394: imagecopy($newImage, $this->image, 0, 0, $r['x'], $r['y'], $r['width'], $r['height']);
395: $this->image = $newImage;
396: }
397: return $this;
398: }
399:
400:
401: 402: 403: 404: 405: 406: 407: 408: 409: 410:
411: public static function calculateCutout($srcWidth, $srcHeight, $left, $top, $newWidth, $newHeight)
412: {
413: if (is_string($newWidth) && substr($newWidth, -1) === '%') {
414: $newWidth = (int) round($srcWidth / 100 * substr($newWidth, 0, -1));
415: }
416: if (is_string($newHeight) && substr($newHeight, -1) === '%') {
417: $newHeight = (int) round($srcHeight / 100 * substr($newHeight, 0, -1));
418: }
419: if (is_string($left) && substr($left, -1) === '%') {
420: $left = (int) round(($srcWidth - $newWidth) / 100 * substr($left, 0, -1));
421: }
422: if (is_string($top) && substr($top, -1) === '%') {
423: $top = (int) round(($srcHeight - $newHeight) / 100 * substr($top, 0, -1));
424: }
425: if ($left < 0) {
426: $newWidth += $left;
427: $left = 0;
428: }
429: if ($top < 0) {
430: $newHeight += $top;
431: $top = 0;
432: }
433: $newWidth = min($newWidth, $srcWidth - $left);
434: $newHeight = min($newHeight, $srcHeight - $top);
435: return [$left, $top, $newWidth, $newHeight];
436: }
437:
438:
439: 440: 441: 442:
443: public function sharpen()
444: {
445: imageconvolution($this->image, [
446: [-1, -1, -1],
447: [-1, 24, -1],
448: [-1, -1, -1],
449: ], 16, 0);
450: return $this;
451: }
452:
453:
454: 455: 456: 457: 458: 459: 460: 461:
462: public function place(self $image, $left = 0, $top = 0, $opacity = 100)
463: {
464: $opacity = max(0, min(100, (int) $opacity));
465: if ($opacity === 0) {
466: return $this;
467: }
468:
469: $width = $image->getWidth();
470: $height = $image->getHeight();
471:
472: if (is_string($left) && substr($left, -1) === '%') {
473: $left = (int) round(($this->getWidth() - $width) / 100 * substr($left, 0, -1));
474: }
475:
476: if (is_string($top) && substr($top, -1) === '%') {
477: $top = (int) round(($this->getHeight() - $height) / 100 * substr($top, 0, -1));
478: }
479:
480: $output = $input = $image->image;
481: if ($opacity < 100) {
482: $tbl = [];
483: for ($i = 0; $i < 128; $i++) {
484: $tbl[$i] = round(127 - (127 - $i) * $opacity / 100);
485: }
486:
487: $output = imagecreatetruecolor($width, $height);
488: imagealphablending($output, false);
489: if (!$image->isTrueColor()) {
490: $input = $output;
491: imagefilledrectangle($output, 0, 0, $width, $height, imagecolorallocatealpha($output, 0, 0, 0, 127));
492: imagecopy($output, $image->image, 0, 0, 0, 0, $width, $height);
493: }
494: for ($x = 0; $x < $width; $x++) {
495: for ($y = 0; $y < $height; $y++) {
496: $c = \imagecolorat($input, $x, $y);
497: $c = ($c & 0xFFFFFF) + ($tbl[$c >> 24] << 24);
498: \imagesetpixel($output, $x, $y, $c);
499: }
500: }
501: imagealphablending($output, true);
502: }
503:
504: imagecopy(
505: $this->image, $output,
506: $left, $top, 0, 0, $width, $height
507: );
508: return $this;
509: }
510:
511:
512: 513: 514: 515: 516: 517: 518:
519: public function save($file = null, $quality = null, $type = null)
520: {
521: if ($type === null) {
522: if ($file === null) {
523: throw new Nette\InvalidArgumentException('Either the output file or type must be set.');
524: }
525: $extensions = array_flip(self::$formats) + ['jpg' => self::JPEG];
526: $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
527: if (!isset($extensions[$ext])) {
528: throw new Nette\InvalidArgumentException("Unsupported file extension '$ext'.");
529: }
530: $type = $extensions[$ext];
531: }
532:
533: switch ($type) {
534: case self::JPEG:
535: $quality = $quality === null ? 85 : max(0, min(100, (int) $quality));
536: return imagejpeg($this->image, $file, $quality);
537:
538: case self::PNG:
539: $quality = $quality === null ? 9 : max(0, min(9, (int) $quality));
540: return imagepng($this->image, $file, $quality);
541:
542: case self::GIF:
543: return imagegif($this->image, $file);
544:
545: case self::WEBP:
546: $quality = $quality === null ? 80 : max(0, min(100, (int) $quality));
547: return imagewebp($this->image, $file, $quality);
548:
549: default:
550: throw new Nette\InvalidArgumentException("Unsupported image type '$type'.");
551: }
552: }
553:
554:
555: 556: 557: 558: 559: 560:
561: public function toString($type = self::JPEG, $quality = null)
562: {
563: ob_start(function () {});
564: $this->save(null, $quality, $type);
565: return ob_get_clean();
566: }
567:
568:
569: 570: 571: 572:
573: public function __toString()
574: {
575: try {
576: return $this->toString();
577: } catch (\Exception $e) {
578: } catch (\Throwable $e) {
579: }
580: if (isset($e)) {
581: if (func_num_args()) {
582: throw $e;
583: }
584: trigger_error('Exception in ' . __METHOD__ . "(): {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}", E_USER_ERROR);
585: }
586: }
587:
588:
589: 590: 591: 592: 593: 594:
595: public function send($type = self::JPEG, $quality = null)
596: {
597: if (!isset(self::$formats[$type])) {
598: throw new Nette\InvalidArgumentException("Unsupported image type '$type'.");
599: }
600: header('Content-Type: image/' . self::$formats[$type]);
601: return $this->save(null, $quality, $type);
602: }
603:
604:
605: 606: 607: 608: 609: 610: 611: 612:
613: public function __call($name, $args)
614: {
615: $function = 'image' . $name;
616: if (!function_exists($function)) {
617: ObjectHelpers::strictCall(get_class($this), $name);
618: }
619:
620: foreach ($args as $key => $value) {
621: if ($value instanceof self) {
622: $args[$key] = $value->getImageResource();
623:
624: } elseif (is_array($value) && isset($value['red'])) {
625: $args[$key] = imagecolorallocatealpha(
626: $this->image,
627: $value['red'], $value['green'], $value['blue'], $value['alpha']
628: ) ?: imagecolorresolvealpha(
629: $this->image,
630: $value['red'], $value['green'], $value['blue'], $value['alpha']
631: );
632: }
633: }
634: $res = $function($this->image, ...$args);
635: return is_resource($res) && get_resource_type($res) === 'gd' ? $this->setImageResource($res) : $res;
636: }
637:
638:
639: public function __clone()
640: {
641: ob_start(function () {});
642: imagegd2($this->image);
643: $this->setImageResource(imagecreatefromstring(ob_get_clean()));
644: }
645:
646:
647: 648: 649:
650: public function __sleep()
651: {
652: throw new Nette\NotSupportedException('You cannot serialize or unserialize ' . self::class . ' instances.');
653: }
654: }
655: