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