friendship ended with social-app. php is my new best friend
1<?php
2
3/**
4 * This file is part of the Nette Framework (https://nette.org)
5 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6 */
7
8declare(strict_types=1);
9
10namespace Nette\Utils;
11
12use Nette;
13use function is_array, is_int, is_string;
14use const IMG_BMP, IMG_FLIP_BOTH, IMG_FLIP_HORIZONTAL, IMG_FLIP_VERTICAL, IMG_GIF, IMG_JPG, IMG_PNG, IMG_WEBP, PATHINFO_EXTENSION;
15
16
17/**
18 * Basic manipulation with images. Supported types are JPEG, PNG, GIF, WEBP, AVIF and BMP.
19 *
20 * <code>
21 * $image = Image::fromFile('nette.jpg');
22 * $image->resize(150, 100);
23 * $image->sharpen();
24 * $image->send();
25 * </code>
26 *
27 * @method Image affine(array $affine, ?array $clip = null)
28 * @method void alphaBlending(bool $enable)
29 * @method void antialias(bool $enable)
30 * @method void arc(int $centerX, int $centerY, int $width, int $height, int $startAngle, int $endAngle, ImageColor $color)
31 * @method int colorAllocate(int $red, int $green, int $blue)
32 * @method int colorAllocateAlpha(int $red, int $green, int $blue, int $alpha)
33 * @method int colorAt(int $x, int $y)
34 * @method int colorClosest(int $red, int $green, int $blue)
35 * @method int colorClosestAlpha(int $red, int $green, int $blue, int $alpha)
36 * @method int colorClosestHWB(int $red, int $green, int $blue)
37 * @method void colorDeallocate(int $color)
38 * @method int colorExact(int $red, int $green, int $blue)
39 * @method int colorExactAlpha(int $red, int $green, int $blue, int $alpha)
40 * @method void colorMatch(Image $image2)
41 * @method int colorResolve(int $red, int $green, int $blue)
42 * @method int colorResolveAlpha(int $red, int $green, int $blue, int $alpha)
43 * @method void colorSet(int $index, int $red, int $green, int $blue, int $alpha = 0)
44 * @method array colorsForIndex(int $color)
45 * @method int colorsTotal()
46 * @method int colorTransparent(?int $color = null)
47 * @method void convolution(array $matrix, float $div, float $offset)
48 * @method void copy(Image $src, int $dstX, int $dstY, int $srcX, int $srcY, int $srcW, int $srcH)
49 * @method void copyMerge(Image $src, int $dstX, int $dstY, int $srcX, int $srcY, int $srcW, int $srcH, int $pct)
50 * @method void copyMergeGray(Image $src, int $dstX, int $dstY, int $srcX, int $srcY, int $srcW, int $srcH, int $pct)
51 * @method void copyResampled(Image $src, int $dstX, int $dstY, int $srcX, int $srcY, int $dstW, int $dstH, int $srcW, int $srcH)
52 * @method void copyResized(Image $src, int $dstX, int $dstY, int $srcX, int $srcY, int $dstW, int $dstH, int $srcW, int $srcH)
53 * @method Image cropAuto(int $mode = IMG_CROP_DEFAULT, float $threshold = .5, ?ImageColor $color = null)
54 * @method void ellipse(int $centerX, int $centerY, int $width, int $height, ImageColor $color)
55 * @method void fill(int $x, int $y, ImageColor $color)
56 * @method void filledArc(int $centerX, int $centerY, int $width, int $height, int $startAngle, int $endAngle, ImageColor $color, int $style)
57 * @method void filledEllipse(int $centerX, int $centerY, int $width, int $height, ImageColor $color)
58 * @method void filledPolygon(array $points, ImageColor $color)
59 * @method void filledRectangle(int $x1, int $y1, int $x2, int $y2, ImageColor $color)
60 * @method void fillToBorder(int $x, int $y, ImageColor $borderColor, ImageColor $color)
61 * @method void filter(int $filter, ...$args)
62 * @method void flip(int $mode)
63 * @method array ftText(float $size, float $angle, int $x, int $y, ImageColor $color, string $fontFile, string $text, array $options = [])
64 * @method void gammaCorrect(float $inputgamma, float $outputgamma)
65 * @method array getClip()
66 * @method int getInterpolation()
67 * @method int interlace(?bool $enable = null)
68 * @method bool isTrueColor()
69 * @method void layerEffect(int $effect)
70 * @method void line(int $x1, int $y1, int $x2, int $y2, ImageColor $color)
71 * @method void openPolygon(array $points, ImageColor $color)
72 * @method void paletteCopy(Image $source)
73 * @method void paletteToTrueColor()
74 * @method void polygon(array $points, ImageColor $color)
75 * @method void rectangle(int $x1, int $y1, int $x2, int $y2, ImageColor $color)
76 * @method mixed resolution(?int $resolutionX = null, ?int $resolutionY = null)
77 * @method Image rotate(float $angle, ImageColor $backgroundColor)
78 * @method void saveAlpha(bool $enable)
79 * @method Image scale(int $newWidth, int $newHeight = -1, int $mode = IMG_BILINEAR_FIXED)
80 * @method void setBrush(Image $brush)
81 * @method void setClip(int $x1, int $y1, int $x2, int $y2)
82 * @method void setInterpolation(int $method = IMG_BILINEAR_FIXED)
83 * @method void setPixel(int $x, int $y, ImageColor $color)
84 * @method void setStyle(array $style)
85 * @method void setThickness(int $thickness)
86 * @method void setTile(Image $tile)
87 * @method void trueColorToPalette(bool $dither, int $ncolors)
88 * @method array ttfText(float $size, float $angle, int $x, int $y, ImageColor $color, string $fontfile, string $text, array $options = [])
89 * @property-read positive-int $width
90 * @property-read positive-int $height
91 * @property-read \GdImage $imageResource
92 */
93class Image
94{
95 use Nette\SmartObject;
96
97 /** Prevent from getting resized to a bigger size than the original */
98 public const ShrinkOnly = 0b0001;
99
100 /** Resizes to a specified width and height without keeping aspect ratio */
101 public const Stretch = 0b0010;
102
103 /** Resizes to fit into a specified width and height and preserves aspect ratio */
104 public const OrSmaller = 0b0000;
105
106 /** Resizes while bounding the smaller dimension to the specified width or height and preserves aspect ratio */
107 public const OrBigger = 0b0100;
108
109 /** Resizes to the smallest possible size to completely cover specified width and height and reserves aspect ratio */
110 public const Cover = 0b1000;
111
112 /** @deprecated use Image::ShrinkOnly */
113 public const SHRINK_ONLY = self::ShrinkOnly;
114
115 /** @deprecated use Image::Stretch */
116 public const STRETCH = self::Stretch;
117
118 /** @deprecated use Image::OrSmaller */
119 public const FIT = self::OrSmaller;
120
121 /** @deprecated use Image::OrBigger */
122 public const FILL = self::OrBigger;
123
124 /** @deprecated use Image::Cover */
125 public const EXACT = self::Cover;
126
127 /** @deprecated use Image::EmptyGIF */
128 public const EMPTY_GIF = self::EmptyGIF;
129
130 /** image types */
131 public const
132 JPEG = ImageType::JPEG,
133 PNG = ImageType::PNG,
134 GIF = ImageType::GIF,
135 WEBP = ImageType::WEBP,
136 AVIF = ImageType::AVIF,
137 BMP = ImageType::BMP;
138
139 public const EmptyGIF = "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;";
140
141 private const Formats = [ImageType::JPEG => 'jpeg', ImageType::PNG => 'png', ImageType::GIF => 'gif', ImageType::WEBP => 'webp', ImageType::AVIF => 'avif', ImageType::BMP => 'bmp'];
142
143 private \GdImage $image;
144
145
146 /**
147 * Returns RGB color (0..255) and transparency (0..127).
148 * @deprecated use ImageColor::rgb()
149 */
150 public static function rgb(int $red, int $green, int $blue, int $transparency = 0): array
151 {
152 return [
153 'red' => max(0, min(255, $red)),
154 'green' => max(0, min(255, $green)),
155 'blue' => max(0, min(255, $blue)),
156 'alpha' => max(0, min(127, $transparency)),
157 ];
158 }
159
160
161 /**
162 * Reads an image from a file and returns its type in $type.
163 * @throws Nette\NotSupportedException if gd extension is not loaded
164 * @throws UnknownImageFileException if file not found or file type is not known
165 */
166 public static function fromFile(string $file, ?int &$type = null): static
167 {
168 self::ensureExtension();
169 $type = self::detectTypeFromFile($file);
170 if (!$type) {
171 throw new UnknownImageFileException(is_file($file) ? "Unknown type of file '$file'." : "File '$file' not found.");
172 }
173
174 return self::invokeSafe('imagecreatefrom' . self::Formats[$type], $file, "Unable to open file '$file'.", __METHOD__);
175 }
176
177
178 /**
179 * Reads an image from a string and returns its type in $type.
180 * @throws Nette\NotSupportedException if gd extension is not loaded
181 * @throws ImageException
182 */
183 public static function fromString(string $s, ?int &$type = null): static
184 {
185 self::ensureExtension();
186 $type = self::detectTypeFromString($s);
187 if (!$type) {
188 throw new UnknownImageFileException('Unknown type of image.');
189 }
190
191 return self::invokeSafe('imagecreatefromstring', $s, 'Unable to open image from string.', __METHOD__);
192 }
193
194
195 private static function invokeSafe(string $func, string $arg, string $message, string $callee): static
196 {
197 $errors = [];
198 $res = Callback::invokeSafe($func, [$arg], function (string $message) use (&$errors): void {
199 $errors[] = $message;
200 });
201
202 if (!$res) {
203 throw new ImageException($message . ' Errors: ' . implode(', ', $errors));
204 } elseif ($errors) {
205 trigger_error($callee . '(): ' . implode(', ', $errors), E_USER_WARNING);
206 }
207
208 return new static($res);
209 }
210
211
212 /**
213 * Creates a new true color image of the given dimensions. The default color is black.
214 * @param positive-int $width
215 * @param positive-int $height
216 * @throws Nette\NotSupportedException if gd extension is not loaded
217 */
218 public static function fromBlank(int $width, int $height, ImageColor|array|null $color = null): static
219 {
220 self::ensureExtension();
221 if ($width < 1 || $height < 1) {
222 throw new Nette\InvalidArgumentException('Image width and height must be greater than zero.');
223 }
224
225 $image = new static(imagecreatetruecolor($width, $height));
226 if ($color) {
227 $image->alphablending(false);
228 $image->filledrectangle(0, 0, $width - 1, $height - 1, $color);
229 $image->alphablending(true);
230 }
231
232 return $image;
233 }
234
235
236 /**
237 * Returns the type of image from file.
238 * @return ImageType::*|null
239 */
240 public static function detectTypeFromFile(string $file, &$width = null, &$height = null): ?int
241 {
242 [$width, $height, $type] = @getimagesize($file); // @ - files smaller than 12 bytes causes read error
243 return isset(self::Formats[$type]) ? $type : null;
244 }
245
246
247 /**
248 * Returns the type of image from string.
249 * @return ImageType::*|null
250 */
251 public static function detectTypeFromString(string $s, &$width = null, &$height = null): ?int
252 {
253 [$width, $height, $type] = @getimagesizefromstring($s); // @ - strings smaller than 12 bytes causes read error
254 return isset(self::Formats[$type]) ? $type : null;
255 }
256
257
258 /**
259 * Returns the file extension for the given image type.
260 * @param ImageType::* $type
261 * @return value-of<self::Formats>
262 */
263 public static function typeToExtension(int $type): string
264 {
265 if (!isset(self::Formats[$type])) {
266 throw new Nette\InvalidArgumentException("Unsupported image type '$type'.");
267 }
268
269 return self::Formats[$type];
270 }
271
272
273 /**
274 * Returns the image type for given file extension.
275 * @return ImageType::*
276 */
277 public static function extensionToType(string $extension): int
278 {
279 $extensions = array_flip(self::Formats) + ['jpg' => ImageType::JPEG];
280 $extension = strtolower($extension);
281 if (!isset($extensions[$extension])) {
282 throw new Nette\InvalidArgumentException("Unsupported file extension '$extension'.");
283 }
284
285 return $extensions[$extension];
286 }
287
288
289 /**
290 * Returns the mime type for the given image type.
291 * @param ImageType::* $type
292 */
293 public static function typeToMimeType(int $type): string
294 {
295 return 'image/' . self::typeToExtension($type);
296 }
297
298
299 /**
300 * @param ImageType::* $type
301 */
302 public static function isTypeSupported(int $type): bool
303 {
304 self::ensureExtension();
305 return (bool) (imagetypes() & match ($type) {
306 ImageType::JPEG => IMG_JPG,
307 ImageType::PNG => IMG_PNG,
308 ImageType::GIF => IMG_GIF,
309 ImageType::WEBP => IMG_WEBP,
310 ImageType::AVIF => 256, // IMG_AVIF,
311 ImageType::BMP => IMG_BMP,
312 default => 0,
313 });
314 }
315
316
317 /** @return ImageType[] */
318 public static function getSupportedTypes(): array
319 {
320 self::ensureExtension();
321 $flag = imagetypes();
322 return array_filter([
323 $flag & IMG_GIF ? ImageType::GIF : null,
324 $flag & IMG_JPG ? ImageType::JPEG : null,
325 $flag & IMG_PNG ? ImageType::PNG : null,
326 $flag & IMG_WEBP ? ImageType::WEBP : null,
327 $flag & 256 ? ImageType::AVIF : null, // IMG_AVIF
328 $flag & IMG_BMP ? ImageType::BMP : null,
329 ]);
330 }
331
332
333 /**
334 * Wraps GD image.
335 */
336 public function __construct(\GdImage $image)
337 {
338 $this->setImageResource($image);
339 imagesavealpha($image, true);
340 }
341
342
343 /**
344 * Returns image width.
345 * @return positive-int
346 */
347 public function getWidth(): int
348 {
349 return imagesx($this->image);
350 }
351
352
353 /**
354 * Returns image height.
355 * @return positive-int
356 */
357 public function getHeight(): int
358 {
359 return imagesy($this->image);
360 }
361
362
363 /**
364 * Sets image resource.
365 */
366 protected function setImageResource(\GdImage $image): static
367 {
368 $this->image = $image;
369 return $this;
370 }
371
372
373 /**
374 * Returns image GD resource.
375 */
376 public function getImageResource(): \GdImage
377 {
378 return $this->image;
379 }
380
381
382 /**
383 * Scales an image. Width and height accept pixels or percent.
384 * @param int-mask-of<self::OrSmaller|self::OrBigger|self::Stretch|self::Cover|self::ShrinkOnly> $mode
385 */
386 public function resize(int|string|null $width, int|string|null $height, int $mode = self::OrSmaller): static
387 {
388 if ($mode & self::Cover) {
389 return $this->resize($width, $height, self::OrBigger)->crop('50%', '50%', $width, $height);
390 }
391
392 [$newWidth, $newHeight] = static::calculateSize($this->getWidth(), $this->getHeight(), $width, $height, $mode);
393
394 if ($newWidth !== $this->getWidth() || $newHeight !== $this->getHeight()) { // resize
395 $newImage = static::fromBlank($newWidth, $newHeight, ImageColor::rgb(0, 0, 0, 0))->getImageResource();
396 imagecopyresampled(
397 $newImage,
398 $this->image,
399 0,
400 0,
401 0,
402 0,
403 $newWidth,
404 $newHeight,
405 $this->getWidth(),
406 $this->getHeight(),
407 );
408 $this->image = $newImage;
409 }
410
411 if ($width < 0 || $height < 0) {
412 imageflip($this->image, $width < 0 ? ($height < 0 ? IMG_FLIP_BOTH : IMG_FLIP_HORIZONTAL) : IMG_FLIP_VERTICAL);
413 }
414
415 return $this;
416 }
417
418
419 /**
420 * Calculates dimensions of resized image. Width and height accept pixels or percent.
421 * @param int-mask-of<self::OrSmaller|self::OrBigger|self::Stretch|self::Cover|self::ShrinkOnly> $mode
422 */
423 public static function calculateSize(
424 int $srcWidth,
425 int $srcHeight,
426 $newWidth,
427 $newHeight,
428 int $mode = self::OrSmaller,
429 ): array
430 {
431 if ($newWidth === null) {
432 } elseif (self::isPercent($newWidth)) {
433 $newWidth = (int) round($srcWidth / 100 * abs($newWidth));
434 $percents = true;
435 } else {
436 $newWidth = abs($newWidth);
437 }
438
439 if ($newHeight === null) {
440 } elseif (self::isPercent($newHeight)) {
441 $newHeight = (int) round($srcHeight / 100 * abs($newHeight));
442 $mode |= empty($percents) ? 0 : self::Stretch;
443 } else {
444 $newHeight = abs($newHeight);
445 }
446
447 if ($mode & self::Stretch) { // non-proportional
448 if (!$newWidth || !$newHeight) {
449 throw new Nette\InvalidArgumentException('For stretching must be both width and height specified.');
450 }
451
452 if ($mode & self::ShrinkOnly) {
453 $newWidth = min($srcWidth, $newWidth);
454 $newHeight = min($srcHeight, $newHeight);
455 }
456 } else { // proportional
457 if (!$newWidth && !$newHeight) {
458 throw new Nette\InvalidArgumentException('At least width or height must be specified.');
459 }
460
461 $scale = [];
462 if ($newWidth > 0) { // fit width
463 $scale[] = $newWidth / $srcWidth;
464 }
465
466 if ($newHeight > 0) { // fit height
467 $scale[] = $newHeight / $srcHeight;
468 }
469
470 if ($mode & self::OrBigger) {
471 $scale = [max($scale)];
472 }
473
474 if ($mode & self::ShrinkOnly) {
475 $scale[] = 1;
476 }
477
478 $scale = min($scale);
479 $newWidth = (int) round($srcWidth * $scale);
480 $newHeight = (int) round($srcHeight * $scale);
481 }
482
483 return [max($newWidth, 1), max($newHeight, 1)];
484 }
485
486
487 /**
488 * Crops image. Arguments accepts pixels or percent.
489 */
490 public function crop(int|string $left, int|string $top, int|string $width, int|string $height): static
491 {
492 [$r['x'], $r['y'], $r['width'], $r['height']]
493 = static::calculateCutout($this->getWidth(), $this->getHeight(), $left, $top, $width, $height);
494 if (gd_info()['GD Version'] === 'bundled (2.1.0 compatible)') {
495 $this->image = imagecrop($this->image, $r);
496 imagesavealpha($this->image, true);
497 } else {
498 $newImage = static::fromBlank($r['width'], $r['height'], ImageColor::rgb(0, 0, 0, 0))->getImageResource();
499 imagecopy($newImage, $this->image, 0, 0, $r['x'], $r['y'], $r['width'], $r['height']);
500 $this->image = $newImage;
501 }
502
503 return $this;
504 }
505
506
507 /**
508 * Calculates dimensions of cutout in image. Arguments accepts pixels or percent.
509 */
510 public static function calculateCutout(
511 int $srcWidth,
512 int $srcHeight,
513 int|string $left,
514 int|string $top,
515 int|string $newWidth,
516 int|string $newHeight,
517 ): array
518 {
519 if (self::isPercent($newWidth)) {
520 $newWidth = (int) round($srcWidth / 100 * $newWidth);
521 }
522
523 if (self::isPercent($newHeight)) {
524 $newHeight = (int) round($srcHeight / 100 * $newHeight);
525 }
526
527 if (self::isPercent($left)) {
528 $left = (int) round(($srcWidth - $newWidth) / 100 * $left);
529 }
530
531 if (self::isPercent($top)) {
532 $top = (int) round(($srcHeight - $newHeight) / 100 * $top);
533 }
534
535 if ($left < 0) {
536 $newWidth += $left;
537 $left = 0;
538 }
539
540 if ($top < 0) {
541 $newHeight += $top;
542 $top = 0;
543 }
544
545 $newWidth = min($newWidth, $srcWidth - $left);
546 $newHeight = min($newHeight, $srcHeight - $top);
547 return [$left, $top, $newWidth, $newHeight];
548 }
549
550
551 /**
552 * Sharpens image a little bit.
553 */
554 public function sharpen(): static
555 {
556 imageconvolution($this->image, [ // my magic numbers ;)
557 [-1, -1, -1],
558 [-1, 24, -1],
559 [-1, -1, -1],
560 ], 16, 0);
561 return $this;
562 }
563
564
565 /**
566 * Puts another image into this image. Left and top accepts pixels or percent.
567 * @param int<0, 100> $opacity 0..100
568 */
569 public function place(self $image, int|string $left = 0, int|string $top = 0, int $opacity = 100): static
570 {
571 $opacity = max(0, min(100, $opacity));
572 if ($opacity === 0) {
573 return $this;
574 }
575
576 $width = $image->getWidth();
577 $height = $image->getHeight();
578
579 if (self::isPercent($left)) {
580 $left = (int) round(($this->getWidth() - $width) / 100 * $left);
581 }
582
583 if (self::isPercent($top)) {
584 $top = (int) round(($this->getHeight() - $height) / 100 * $top);
585 }
586
587 $output = $input = $image->image;
588 if ($opacity < 100) {
589 $tbl = [];
590 for ($i = 0; $i < 128; $i++) {
591 $tbl[$i] = round(127 - (127 - $i) * $opacity / 100);
592 }
593
594 $output = imagecreatetruecolor($width, $height);
595 imagealphablending($output, false);
596 if (!$image->isTrueColor()) {
597 $input = $output;
598 imagefilledrectangle($output, 0, 0, $width, $height, imagecolorallocatealpha($output, 0, 0, 0, 127));
599 imagecopy($output, $image->image, 0, 0, 0, 0, $width, $height);
600 }
601
602 for ($x = 0; $x < $width; $x++) {
603 for ($y = 0; $y < $height; $y++) {
604 $c = \imagecolorat($input, $x, $y);
605 $c = ($c & 0xFFFFFF) + ($tbl[$c >> 24] << 24);
606 \imagesetpixel($output, $x, $y, $c);
607 }
608 }
609
610 imagealphablending($output, true);
611 }
612
613 imagecopy(
614 $this->image,
615 $output,
616 $left,
617 $top,
618 0,
619 0,
620 $width,
621 $height,
622 );
623 return $this;
624 }
625
626
627 /**
628 * Calculates the bounding box for a TrueType text. Returns keys left, top, width and height.
629 */
630 public static function calculateTextBox(
631 string $text,
632 string $fontFile,
633 float $size,
634 float $angle = 0,
635 array $options = [],
636 ): array
637 {
638 self::ensureExtension();
639 $box = imagettfbbox($size, $angle, $fontFile, $text, $options);
640 return [
641 'left' => $minX = min([$box[0], $box[2], $box[4], $box[6]]),
642 'top' => $minY = min([$box[1], $box[3], $box[5], $box[7]]),
643 'width' => max([$box[0], $box[2], $box[4], $box[6]]) - $minX + 1,
644 'height' => max([$box[1], $box[3], $box[5], $box[7]]) - $minY + 1,
645 ];
646 }
647
648
649 /**
650 * Draw a rectangle.
651 */
652 public function rectangleWH(int $x, int $y, int $width, int $height, ImageColor $color): void
653 {
654 if ($width !== 0 && $height !== 0) {
655 $this->rectangle($x, $y, $x + $width + ($width > 0 ? -1 : 1), $y + $height + ($height > 0 ? -1 : 1), $color);
656 }
657 }
658
659
660 /**
661 * Draw a filled rectangle.
662 */
663 public function filledRectangleWH(int $x, int $y, int $width, int $height, ImageColor $color): void
664 {
665 if ($width !== 0 && $height !== 0) {
666 $this->filledRectangle($x, $y, $x + $width + ($width > 0 ? -1 : 1), $y + $height + ($height > 0 ? -1 : 1), $color);
667 }
668 }
669
670
671 /**
672 * Saves image to the file. Quality is in the range 0..100 for JPEG (default 85), WEBP (default 80) and AVIF (default 30) and 0..9 for PNG (default 9).
673 * @param ImageType::*|null $type
674 * @throws ImageException
675 */
676 public function save(string $file, ?int $quality = null, ?int $type = null): void
677 {
678 $type ??= self::extensionToType(pathinfo($file, PATHINFO_EXTENSION));
679 $this->output($type, $quality, $file);
680 }
681
682
683 /**
684 * Outputs image to string. Quality is in the range 0..100 for JPEG (default 85), WEBP (default 80) and AVIF (default 30) and 0..9 for PNG (default 9).
685 * @param ImageType::* $type
686 */
687 public function toString(int $type = ImageType::JPEG, ?int $quality = null): string
688 {
689 return Helpers::capture(function () use ($type, $quality): void {
690 $this->output($type, $quality);
691 });
692 }
693
694
695 /**
696 * Outputs image to string.
697 */
698 public function __toString(): string
699 {
700 return $this->toString();
701 }
702
703
704 /**
705 * Outputs image to browser. Quality is in the range 0..100 for JPEG (default 85), WEBP (default 80) and AVIF (default 30) and 0..9 for PNG (default 9).
706 * @param ImageType::* $type
707 * @throws ImageException
708 */
709 public function send(int $type = ImageType::JPEG, ?int $quality = null): void
710 {
711 header('Content-Type: ' . self::typeToMimeType($type));
712 $this->output($type, $quality);
713 }
714
715
716 /**
717 * Outputs image to browser or file.
718 * @param ImageType::* $type
719 * @throws ImageException
720 */
721 private function output(int $type, ?int $quality, ?string $file = null): void
722 {
723 [$defQuality, $min, $max] = match ($type) {
724 ImageType::JPEG => [85, 0, 100],
725 ImageType::PNG => [9, 0, 9],
726 ImageType::GIF => [null, null, null],
727 ImageType::WEBP => [80, 0, 100],
728 ImageType::AVIF => [30, 0, 100],
729 ImageType::BMP => [null, null, null],
730 default => throw new Nette\InvalidArgumentException("Unsupported image type '$type'."),
731 };
732
733 $args = [$this->image, $file];
734 if ($defQuality !== null) {
735 $args[] = $quality === null ? $defQuality : max($min, min($max, $quality));
736 }
737
738 Callback::invokeSafe('image' . self::Formats[$type], $args, function (string $message) use ($file): void {
739 if ($file !== null) {
740 @unlink($file);
741 }
742 throw new ImageException($message);
743 });
744 }
745
746
747 /**
748 * Call to undefined method.
749 * @throws Nette\MemberAccessException
750 */
751 public function __call(string $name, array $args): mixed
752 {
753 $function = 'image' . $name;
754 if (!function_exists($function)) {
755 ObjectHelpers::strictCall(static::class, $name);
756 }
757
758 foreach ($args as $key => $value) {
759 if ($value instanceof self) {
760 $args[$key] = $value->getImageResource();
761
762 } elseif ($value instanceof ImageColor || (is_array($value) && isset($value['red']))) {
763 $args[$key] = $this->resolveColor($value);
764 }
765 }
766
767 $res = $function($this->image, ...$args);
768 return $res instanceof \GdImage
769 ? $this->setImageResource($res)
770 : $res;
771 }
772
773
774 public function __clone()
775 {
776 ob_start(fn() => '');
777 imagepng($this->image, null, 0);
778 $this->setImageResource(imagecreatefromstring(ob_get_clean()));
779 }
780
781
782 private static function isPercent(int|string &$num): bool
783 {
784 if (is_string($num) && str_ends_with($num, '%')) {
785 $num = (float) substr($num, 0, -1);
786 return true;
787 } elseif (is_int($num) || $num === (string) (int) $num) {
788 $num = (int) $num;
789 return false;
790 }
791
792 throw new Nette\InvalidArgumentException("Expected dimension in int|string, '$num' given.");
793 }
794
795
796 /**
797 * Prevents serialization.
798 */
799 public function __serialize(): array
800 {
801 throw new Nette\NotSupportedException('You cannot serialize or unserialize ' . self::class . ' instances.');
802 }
803
804
805 public function resolveColor(ImageColor|array $color): int
806 {
807 $color = $color instanceof ImageColor ? $color->toRGBA() : array_values($color);
808 return imagecolorallocatealpha($this->image, ...$color) ?: imagecolorresolvealpha($this->image, ...$color);
809 }
810
811
812 private static function ensureExtension(): void
813 {
814 if (!extension_loaded('gd')) {
815 throw new Nette\NotSupportedException('PHP extension GD is not loaded.');
816 }
817 }
818}