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:
117: const ENLARGE = 0;
118:
119: private static $formats = [self::JPEG => 'jpeg', self::PNG => 'png', self::GIF => 'gif', self::WEBP => 'webp'];
120:
121:
122: private $image;
123:
124:
125: 126: 127: 128: 129: 130: 131: 132:
133: public static function rgb($red, $green, $blue, $transparency = 0)
134: {
135: return [
136: 'red' => max(0, min(255, (int) $red)),
137: 'green' => max(0, min(255, (int) $green)),
138: 'blue' => max(0, min(255, (int) $blue)),
139: 'alpha' => max(0, min(127, (int) $transparency)),
140: ];
141: }
142:
143:
144: 145: 146: 147: 148: 149: 150: 151:
152: public static function fromFile($file, &$format = null)
153: {
154: if (!extension_loaded('gd')) {
155: throw new Nette\NotSupportedException('PHP extension GD is not loaded.');
156: }
157:
158: $format = @getimagesize($file)[2];
159: if (!$format && PHP_VERSION_ID < 70100 && @file_get_contents($file, false, null, 8, 4) === 'WEBP') {
160: $format = self::WEBP;
161: }
162: if (!isset(self::$formats[$format])) {
163: $format = null;
164: throw new UnknownImageFileException(is_file($file) ? "Unknown type of file '$file'." : "File '$file' not found.");
165: }
166: return new static(Callback::invokeSafe('imagecreatefrom' . self::$formats[$format], [$file], function ($message) {
167: throw new ImageException($message);
168: }));
169: }
170:
171:
172: 173: 174: 175: 176: 177: 178:
179: public static function fromString($s, &$format = null)
180: {
181: if (!extension_loaded('gd')) {
182: throw new Nette\NotSupportedException('PHP extension GD is not loaded.');
183: }
184:
185: if (func_num_args() > 1) {
186: $tmp = @getimagesizefromstring($s)[2];
187: $format = isset(self::$formats[$tmp]) ? $tmp : null;
188: }
189:
190: return new static(Callback::invokeSafe('imagecreatefromstring', [$s], function ($message) {
191: throw new ImageException($message);
192: }));
193: }
194:
195:
196: 197: 198: 199: 200: 201: 202:
203: public static function fromBlank($width, $height, $color = null)
204: {
205: if (!extension_loaded('gd')) {
206: throw new Nette\NotSupportedException('PHP extension GD is not loaded.');
207: }
208:
209: $width = (int) $width;
210: $height = (int) $height;
211: if ($width < 1 || $height < 1) {
212: throw new Nette\InvalidArgumentException('Image width and height must be greater than zero.');
213: }
214:
215: $image = imagecreatetruecolor($width, $height);
216: if (is_array($color)) {
217: $color += ['alpha' => 0];
218: $color = imagecolorresolvealpha($image, $color['red'], $color['green'], $color['blue'], $color['alpha']);
219: imagealphablending($image, false);
220: imagefilledrectangle($image, 0, 0, $width - 1, $height - 1, $color);
221: imagealphablending($image, true);
222: }
223: return new static($image);
224: }
225:
226:
227: 228: 229: 230:
231: public function __construct($image)
232: {
233: $this->setImageResource($image);
234: imagesavealpha($image, true);
235: }
236:
237:
238: 239: 240: 241:
242: public function getWidth()
243: {
244: return imagesx($this->image);
245: }
246:
247:
248: 249: 250: 251:
252: public function getHeight()
253: {
254: return imagesy($this->image);
255: }
256:
257:
258: 259: 260: 261: 262:
263: protected function setImageResource($image)
264: {
265: if (!is_resource($image) || get_resource_type($image) !== 'gd') {
266: throw new Nette\InvalidArgumentException('Image is not valid.');
267: }
268: $this->image = $image;
269: return $this;
270: }
271:
272:
273: 274: 275: 276:
277: public function getImageResource()
278: {
279: return $this->image;
280: }
281:
282:
283: 284: 285: 286: 287: 288: 289:
290: public function resize($width, $height, $flags = self::FIT)
291: {
292: if ($flags & self::EXACT) {
293: return $this->resize($width, $height, self::FILL)->crop('50%', '50%', $width, $height);
294: }
295:
296: list($newWidth, $newHeight) = static::calculateSize($this->getWidth(), $this->getHeight(), $width, $height, $flags);
297:
298: if ($newWidth !== $this->getWidth() || $newHeight !== $this->getHeight()) {
299: $newImage = static::fromBlank($newWidth, $newHeight, self::rgb(0, 0, 0, 127))->getImageResource();
300: imagecopyresampled(
301: $newImage, $this->image,
302: 0, 0, 0, 0,
303: $newWidth, $newHeight, $this->getWidth(), $this->getHeight()
304: );
305: $this->image = $newImage;
306: }
307:
308: if ($width < 0 || $height < 0) {
309: imageflip($this->image, $width < 0 ? ($height < 0 ? IMG_FLIP_BOTH : IMG_FLIP_HORIZONTAL) : IMG_FLIP_VERTICAL);
310: }
311: return $this;
312: }
313:
314:
315: 316: 317: 318: 319: 320: 321: 322: 323:
324: public static function calculateSize($srcWidth, $srcHeight, $newWidth, $newHeight, $flags = self::FIT)
325: {
326: if (is_string($newWidth) && substr($newWidth, -1) === '%') {
327: $newWidth = (int) round($srcWidth / 100 * abs(substr($newWidth, 0, -1)));
328: $percents = true;
329: } else {
330: $newWidth = (int) abs($newWidth);
331: }
332:
333: if (is_string($newHeight) && substr($newHeight, -1) === '%') {
334: $newHeight = (int) round($srcHeight / 100 * abs(substr($newHeight, 0, -1)));
335: $flags |= empty($percents) ? 0 : self::STRETCH;
336: } else {
337: $newHeight = (int) abs($newHeight);
338: }
339:
340: if ($flags & self::STRETCH) {
341: if (empty($newWidth) || empty($newHeight)) {
342: throw new Nette\InvalidArgumentException('For stretching must be both width and height specified.');
343: }
344:
345: if ($flags & self::SHRINK_ONLY) {
346: $newWidth = (int) round($srcWidth * min(1, $newWidth / $srcWidth));
347: $newHeight = (int) round($srcHeight * min(1, $newHeight / $srcHeight));
348: }
349:
350: } else {
351: if (empty($newWidth) && empty($newHeight)) {
352: throw new Nette\InvalidArgumentException('At least width or height must be specified.');
353: }
354:
355: $scale = [];
356: if ($newWidth > 0) {
357: $scale[] = $newWidth / $srcWidth;
358: }
359:
360: if ($newHeight > 0) {
361: $scale[] = $newHeight / $srcHeight;
362: }
363:
364: if ($flags & self::FILL) {
365: $scale = [max($scale)];
366: }
367:
368: if ($flags & self::SHRINK_ONLY) {
369: $scale[] = 1;
370: }
371:
372: $scale = min($scale);
373: $newWidth = (int) round($srcWidth * $scale);
374: $newHeight = (int) round($srcHeight * $scale);
375: }
376:
377: return [max($newWidth, 1), max($newHeight, 1)];
378: }
379:
380:
381: 382: 383: 384: 385: 386: 387: 388:
389: public function crop($left, $top, $width, $height)
390: {
391: list($r['x'], $r['y'], $r['width'], $r['height'])
392: = static::calculateCutout($this->getWidth(), $this->getHeight(), $left, $top, $width, $height);
393: if (PHP_VERSION_ID > 50611) {
394: $this->image = imagecrop($this->image, $r);
395: } else {
396: $newImage = static::fromBlank($r['width'], $r['height'], self::RGB(0, 0, 0, 127))->getImageResource();
397: imagecopy($newImage, $this->image, 0, 0, $r['x'], $r['y'], $r['width'], $r['height']);
398: $this->image = $newImage;
399: }
400: return $this;
401: }
402:
403:
404: 405: 406: 407: 408: 409: 410: 411: 412: 413:
414: public static function calculateCutout($srcWidth, $srcHeight, $left, $top, $newWidth, $newHeight)
415: {
416: if (is_string($newWidth) && substr($newWidth, -1) === '%') {
417: $newWidth = (int) round($srcWidth / 100 * substr($newWidth, 0, -1));
418: }
419: if (is_string($newHeight) && substr($newHeight, -1) === '%') {
420: $newHeight = (int) round($srcHeight / 100 * substr($newHeight, 0, -1));
421: }
422: if (is_string($left) && substr($left, -1) === '%') {
423: $left = (int) round(($srcWidth - $newWidth) / 100 * substr($left, 0, -1));
424: }
425: if (is_string($top) && substr($top, -1) === '%') {
426: $top = (int) round(($srcHeight - $newHeight) / 100 * substr($top, 0, -1));
427: }
428: if ($left < 0) {
429: $newWidth += $left;
430: $left = 0;
431: }
432: if ($top < 0) {
433: $newHeight += $top;
434: $top = 0;
435: }
436: $newWidth = min($newWidth, $srcWidth - $left);
437: $newHeight = min($newHeight, $srcHeight - $top);
438: return [$left, $top, $newWidth, $newHeight];
439: }
440:
441:
442: 443: 444: 445:
446: public function sharpen()
447: {
448: imageconvolution($this->image, [
449: [-1, -1, -1],
450: [-1, 24, -1],
451: [-1, -1, -1],
452: ], 16, 0);
453: return $this;
454: }
455:
456:
457: 458: 459: 460: 461: 462: 463: 464:
465: public function place(self $image, $left = 0, $top = 0, $opacity = 100)
466: {
467: $opacity = max(0, min(100, (int) $opacity));
468: if ($opacity === 0) {
469: return $this;
470: }
471:
472: $width = $image->getWidth();
473: $height = $image->getHeight();
474:
475: if (is_string($left) && substr($left, -1) === '%') {
476: $left = (int) round(($this->getWidth() - $width) / 100 * substr($left, 0, -1));
477: }
478:
479: if (is_string($top) && substr($top, -1) === '%') {
480: $top = (int) round(($this->getHeight() - $height) / 100 * substr($top, 0, -1));
481: }
482:
483: $output = $input = $image->image;
484: if ($opacity < 100) {
485: $tbl = [];
486: for ($i = 0; $i < 128; $i++) {
487: $tbl[$i] = round(127 - (127 - $i) * $opacity / 100);
488: }
489:
490: $output = imagecreatetruecolor($width, $height);
491: imagealphablending($output, false);
492: if (!$image->isTrueColor()) {
493: $input = $output;
494: imagefilledrectangle($output, 0, 0, $width, $height, imagecolorallocatealpha($output, 0, 0, 0, 127));
495: imagecopy($output, $image->image, 0, 0, 0, 0, $width, $height);
496: }
497: for ($x = 0; $x < $width; $x++) {
498: for ($y = 0; $y < $height; $y++) {
499: $c = \imagecolorat($input, $x, $y);
500: $c = ($c & 0xFFFFFF) + ($tbl[$c >> 24] << 24);
501: \imagesetpixel($output, $x, $y, $c);
502: }
503: }
504: imagealphablending($output, true);
505: }
506:
507: imagecopy(
508: $this->image, $output,
509: $left, $top, 0, 0, $width, $height
510: );
511: return $this;
512: }
513:
514:
515: 516: 517: 518: 519: 520: 521:
522: public function save($file = null, $quality = null, $type = null)
523: {
524: if ($type === null) {
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: ObjectMixin::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: