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(Image $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: for ($i = 0; $i < 128; $i++) {
486: $tbl[$i] = round(127 - (127 - $i) * $opacity / 100);
487: }
488:
489: $output = imagecreatetruecolor($width, $height);
490: imagealphablending($output, false);
491: if (!$image->isTrueColor()) {
492: $input = $output;
493: imagefilledrectangle($output, 0, 0, $width, $height, imagecolorallocatealpha($output, 0, 0, 0, 127));
494: imagecopy($output, $image->image, 0, 0, 0, 0, $width, $height);
495: }
496: for ($x = 0; $x < $width; $x++) {
497: for ($y = 0; $y < $height; $y++) {
498: $c = \imagecolorat($input, $x, $y);
499: $c = ($c & 0xFFFFFF) + ($tbl[$c >> 24] << 24);
500: \imagesetpixel($output, $x, $y, $c);
501: }
502: }
503: imagealphablending($output, true);
504: }
505:
506: imagecopy(
507: $this->image, $output,
508: $left, $top, 0, 0, $width, $height
509: );
510: return $this;
511: }
512:
513:
514: 515: 516: 517: 518: 519: 520:
521: public function save($file = null, $quality = null, $type = null)
522: {
523: if ($type === null) {
524: $extensions = array_flip(self::$formats) + ['jpg' => self::JPEG];
525: $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
526: if (!isset($extensions[$ext])) {
527: throw new Nette\InvalidArgumentException("Unsupported file extension '$ext'.");
528: }
529: $type = $extensions[$ext];
530: }
531:
532: switch ($type) {
533: case self::JPEG:
534: $quality = $quality === null ? 85 : max(0, min(100, (int) $quality));
535: return imagejpeg($this->image, $file, $quality);
536:
537: case self::PNG:
538: $quality = $quality === null ? 9 : max(0, min(9, (int) $quality));
539: return imagepng($this->image, $file, $quality);
540:
541: case self::GIF:
542: return imagegif($this->image, $file);
543:
544: case self::WEBP:
545: $quality = $quality === null ? 80 : max(0, min(100, (int) $quality));
546: return imagewebp($this->image, $file, $quality);
547:
548: default:
549: throw new Nette\InvalidArgumentException("Unsupported image type '$type'.");
550: }
551: }
552:
553:
554: 555: 556: 557: 558: 559:
560: public function toString($type = self::JPEG, $quality = null)
561: {
562: ob_start(function () {});
563: $this->save(null, $quality, $type);
564: return ob_get_clean();
565: }
566:
567:
568: 569: 570: 571:
572: public function __toString()
573: {
574: try {
575: return $this->toString();
576: } catch (\Exception $e) {
577: } catch (\Throwable $e) {
578: }
579: if (isset($e)) {
580: if (func_num_args()) {
581: throw $e;
582: }
583: trigger_error('Exception in ' . __METHOD__ . "(): {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}", E_USER_ERROR);
584: }
585: }
586:
587:
588: 589: 590: 591: 592: 593:
594: public function send($type = self::JPEG, $quality = null)
595: {
596: if (!isset(self::$formats[$type])) {
597: throw new Nette\InvalidArgumentException("Unsupported image type '$type'.");
598: }
599: header('Content-Type: image/' . self::$formats[$type]);
600: return $this->save(null, $quality, $type);
601: }
602:
603:
604: 605: 606: 607: 608: 609: 610: 611:
612: public function __call($name, $args)
613: {
614: $function = 'image' . $name;
615: if (!function_exists($function)) {
616: ObjectMixin::strictCall(get_class($this), $name);
617: }
618:
619: foreach ($args as $key => $value) {
620: if ($value instanceof self) {
621: $args[$key] = $value->getImageResource();
622:
623: } elseif (is_array($value) && isset($value['red'])) {
624: $args[$key] = imagecolorallocatealpha(
625: $this->image,
626: $value['red'], $value['green'], $value['blue'], $value['alpha']
627: ) ?: imagecolorresolvealpha(
628: $this->image,
629: $value['red'], $value['green'], $value['blue'], $value['alpha']
630: );
631: }
632: }
633: $res = $function($this->image, ...$args);
634: return is_resource($res) && get_resource_type($res) === 'gd' ? $this->setImageResource($res) : $res;
635: }
636:
637:
638: public function __clone()
639: {
640: ob_start(function () {});
641: imagegd2($this->image);
642: $this->setImageResource(imagecreatefromstring(ob_get_clean()));
643: }
644:
645:
646: 647: 648:
649: public function __sleep()
650: {
651: throw new Nette\NotSupportedException('You cannot serialize or unserialize ' . self::class . ' instances.');
652: }
653: }
654: