| 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 |
declare (strict_types=1); |
| 8 |
namespace Packetery\Nette\Utils; |
| 9 |
|
| 10 |
use Packetery\Nette; |
| 11 |
/** |
| 12 |
* Basic manipulation with images. Supported types are JPEG, PNG, GIF, WEBP, AVIF and BMP. |
| 13 |
* |
| 14 |
* <code> |
| 15 |
* $image = Image::fromFile('nette.jpg'); |
| 16 |
* $image->resize(150, 100); |
| 17 |
* $image->sharpen(); |
| 18 |
* $image->send(); |
| 19 |
* </code> |
| 20 |
* |
| 21 |
* @method Image affine(array $affine, array $clip = null) |
| 22 |
* @method array affineMatrixConcat(array $m1, array $m2) |
| 23 |
* @method array affineMatrixGet(int $type, mixed $options = null) |
| 24 |
* @method void alphaBlending(bool $on) |
| 25 |
* @method void antialias(bool $on) |
| 26 |
* @method void arc($x, $y, $w, $h, $start, $end, $color) |
| 27 |
* @method void char(int $font, $x, $y, string $char, $color) |
| 28 |
* @method void charUp(int $font, $x, $y, string $char, $color) |
| 29 |
* @method int colorAllocate($red, $green, $blue) |
| 30 |
* @method int colorAllocateAlpha($red, $green, $blue, $alpha) |
| 31 |
* @method int colorAt($x, $y) |
| 32 |
* @method int colorClosest($red, $green, $blue) |
| 33 |
* @method int colorClosestAlpha($red, $green, $blue, $alpha) |
| 34 |
* @method int colorClosestHWB($red, $green, $blue) |
| 35 |
* @method void colorDeallocate($color) |
| 36 |
* @method int colorExact($red, $green, $blue) |
| 37 |
* @method int colorExactAlpha($red, $green, $blue, $alpha) |
| 38 |
* @method void colorMatch(Image $image2) |
| 39 |
* @method int colorResolve($red, $green, $blue) |
| 40 |
* @method int colorResolveAlpha($red, $green, $blue, $alpha) |
| 41 |
* @method void colorSet($index, $red, $green, $blue) |
| 42 |
* @method array colorsForIndex($index) |
| 43 |
* @method int colorsTotal() |
| 44 |
* @method int colorTransparent($color = null) |
| 45 |
* @method void convolution(array $matrix, float $div, float $offset) |
| 46 |
* @method void copy(Image $src, $dstX, $dstY, $srcX, $srcY, $srcW, $srcH) |
| 47 |
* @method void copyMerge(Image $src, $dstX, $dstY, $srcX, $srcY, $srcW, $srcH, $opacity) |
| 48 |
* @method void copyMergeGray(Image $src, $dstX, $dstY, $srcX, $srcY, $srcW, $srcH, $opacity) |
| 49 |
* @method void copyResampled(Image $src, $dstX, $dstY, $srcX, $srcY, $dstW, $dstH, $srcW, $srcH) |
| 50 |
* @method void copyResized(Image $src, $dstX, $dstY, $srcX, $srcY, $dstW, $dstH, $srcW, $srcH) |
| 51 |
* @method Image cropAuto(int $mode = -1, float $threshold = .5, int $color = -1) |
| 52 |
* @method void ellipse($cx, $cy, $w, $h, $color) |
| 53 |
* @method void fill($x, $y, $color) |
| 54 |
* @method void filledArc($cx, $cy, $w, $h, $s, $e, $color, $style) |
| 55 |
* @method void filledEllipse($cx, $cy, $w, $h, $color) |
| 56 |
* @method void filledPolygon(array $points, $numPoints, $color) |
| 57 |
* @method void filledRectangle($x1, $y1, $x2, $y2, $color) |
| 58 |
* @method void fillToBorder($x, $y, $border, $color) |
| 59 |
* @method void filter($filtertype) |
| 60 |
* @method void flip(int $mode) |
| 61 |
* @method array ftText($size, $angle, $x, $y, $col, string $fontFile, string $text, array $extrainfo = null) |
| 62 |
* @method void gammaCorrect(float $inputgamma, float $outputgamma) |
| 63 |
* @method array getClip() |
| 64 |
* @method int interlace($interlace = null) |
| 65 |
* @method bool isTrueColor() |
| 66 |
* @method void layerEffect($effect) |
| 67 |
* @method void line($x1, $y1, $x2, $y2, $color) |
| 68 |
* @method void openPolygon(array $points, int $num_points, int $color) |
| 69 |
* @method void paletteCopy(Image $source) |
| 70 |
* @method void paletteToTrueColor() |
| 71 |
* @method void polygon(array $points, $numPoints, $color) |
| 72 |
* @method array psText(string $text, $font, $size, $color, $backgroundColor, $x, $y, $space = null, $tightness = null, float $angle = null, $antialiasSteps = null) |
| 73 |
* @method void rectangle($x1, $y1, $x2, $y2, $col) |
| 74 |
* @method mixed resolution(int $res_x = null, int $res_y = null) |
| 75 |
* @method Image rotate(float $angle, $backgroundColor) |
| 76 |
* @method void saveAlpha(bool $saveflag) |
| 77 |
* @method Image scale(int $newWidth, int $newHeight = -1, int $mode = IMG_BILINEAR_FIXED) |
| 78 |
* @method void setBrush(Image $brush) |
| 79 |
* @method void setClip(int $x1, int $y1, int $x2, int $y2) |
| 80 |
* @method void setInterpolation(int $method = IMG_BILINEAR_FIXED) |
| 81 |
* @method void setPixel($x, $y, $color) |
| 82 |
* @method void setStyle(array $style) |
| 83 |
* @method void setThickness($thickness) |
| 84 |
* @method void setTile(Image $tile) |
| 85 |
* @method void string($font, $x, $y, string $s, $col) |
| 86 |
* @method void stringUp($font, $x, $y, string $s, $col) |
| 87 |
* @method void trueColorToPalette(bool $dither, $ncolors) |
| 88 |
* @method array ttfText($size, $angle, $x, $y, $color, string $fontfile, string $text) |
| 89 |
* @property-read int $width |
| 90 |
* @property-read int $height |
| 91 |
* @property-read resource|\GdImage $imageResource |
| 92 |
*/ |
| 93 |
class Image |
| 94 |
{ |
| 95 |
use \Packetery\Nette\SmartObject; |
| 96 |
/** {@link resize()} only shrinks images */ |
| 97 |
public const SHRINK_ONLY = 0b1; |
| 98 |
/** {@link resize()} will ignore aspect ratio */ |
| 99 |
public const STRETCH = 0b10; |
| 100 |
/** {@link resize()} fits in given area so its dimensions are less than or equal to the required dimensions */ |
| 101 |
public const FIT = 0b0; |
| 102 |
/** {@link resize()} fills given area so its dimensions are greater than or equal to the required dimensions */ |
| 103 |
public const FILL = 0b100; |
| 104 |
/** {@link resize()} fills given area exactly */ |
| 105 |
public const EXACT = 0b1000; |
| 106 |
/** image types */ |
| 107 |
public const JPEG = \IMAGETYPE_JPEG, PNG = \IMAGETYPE_PNG, GIF = \IMAGETYPE_GIF, WEBP = \IMAGETYPE_WEBP, AVIF = 19, BMP = \IMAGETYPE_BMP; |
| 108 |
public 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;"; |
| 109 |
private const Formats = [self::JPEG => 'jpeg', self::PNG => 'png', self::GIF => 'gif', self::WEBP => 'webp', self::AVIF => 'avif', self::BMP => 'bmp']; |
| 110 |
/** @var resource|\GdImage */ |
| 111 |
private $image; |
| 112 |
/** |
| 113 |
* Returns RGB color (0..255) and transparency (0..127). |
| 114 |
*/ |
| 115 |
public static function rgb(int $red, int $green, int $blue, int $transparency = 0) : array |
| 116 |
{ |
| 117 |
return ['red' => \max(0, \min(255, $red)), 'green' => \max(0, \min(255, $green)), 'blue' => \max(0, \min(255, $blue)), 'alpha' => \max(0, \min(127, $transparency))]; |
| 118 |
} |
| 119 |
/** |
| 120 |
* Reads an image from a file and returns its type in $type. |
| 121 |
* @throws \Packetery\Nette\NotSupportedException if gd extension is not loaded |
| 122 |
* @throws UnknownImageFileException if file not found or file type is not known |
| 123 |
* @return static |
| 124 |
*/ |
| 125 |
public static function fromFile(string $file, ?int &$type = null) |
| 126 |
{ |
| 127 |
if (!\extension_loaded('gd')) { |
| 128 |
throw new \Packetery\Nette\NotSupportedException('PHP extension GD is not loaded.'); |
| 129 |
} |
| 130 |
$type = self::detectTypeFromFile($file); |
| 131 |
if (!$type) { |
| 132 |
throw new UnknownImageFileException(\is_file($file) ? "Unknown type of file '{$file}'." : "File '{$file}' not found."); |
| 133 |
} |
| 134 |
return self::invokeSafe('imagecreatefrom' . self::Formats[$type], $file, "Unable to open file '{$file}'.", __METHOD__); |
| 135 |
} |
| 136 |
/** |
| 137 |
* Reads an image from a string and returns its type in $type. |
| 138 |
* @return static |
| 139 |
* @throws \Packetery\Nette\NotSupportedException if gd extension is not loaded |
| 140 |
* @throws ImageException |
| 141 |
*/ |
| 142 |
public static function fromString(string $s, ?int &$type = null) |
| 143 |
{ |
| 144 |
if (!\extension_loaded('gd')) { |
| 145 |
throw new \Packetery\Nette\NotSupportedException('PHP extension GD is not loaded.'); |
| 146 |
} |
| 147 |
$type = self::detectTypeFromString($s); |
| 148 |
if (!$type) { |
| 149 |
throw new UnknownImageFileException('Unknown type of image.'); |
| 150 |
} |
| 151 |
return self::invokeSafe('imagecreatefromstring', $s, 'Unable to open image from string.', __METHOD__); |
| 152 |
} |
| 153 |
private static function invokeSafe(string $func, string $arg, string $message, string $callee) : self |
| 154 |
{ |
| 155 |
$errors = []; |
| 156 |
$res = Callback::invokeSafe($func, [$arg], function (string $message) use(&$errors) : void { |
| 157 |
$errors[] = $message; |
| 158 |
}); |
| 159 |
if (!$res) { |
| 160 |
throw new ImageException($message . ' Errors: ' . \implode(', ', $errors)); |
| 161 |
} elseif ($errors) { |
| 162 |
\trigger_error($callee . '(): ' . \implode(', ', $errors), \E_USER_WARNING); |
| 163 |
} |
| 164 |
return new static($res); |
| 165 |
} |
| 166 |
/** |
| 167 |
* Creates a new true color image of the given dimensions. The default color is black. |
| 168 |
* @return static |
| 169 |
* @throws \Packetery\Nette\NotSupportedException if gd extension is not loaded |
| 170 |
*/ |
| 171 |
public static function fromBlank(int $width, int $height, ?array $color = null) |
| 172 |
{ |
| 173 |
if (!\extension_loaded('gd')) { |
| 174 |
throw new \Packetery\Nette\NotSupportedException('PHP extension GD is not loaded.'); |
| 175 |
} |
| 176 |
if ($width < 1 || $height < 1) { |
| 177 |
throw new \Packetery\Nette\InvalidArgumentException('Image width and height must be greater than zero.'); |
| 178 |
} |
| 179 |
$image = \imagecreatetruecolor($width, $height); |
| 180 |
if ($color) { |
| 181 |
$color += ['alpha' => 0]; |
| 182 |
$color = \imagecolorresolvealpha($image, $color['red'], $color['green'], $color['blue'], $color['alpha']); |
| 183 |
\imagealphablending($image, \false); |
| 184 |
\imagefilledrectangle($image, 0, 0, $width - 1, $height - 1, $color); |
| 185 |
\imagealphablending($image, \true); |
| 186 |
} |
| 187 |
return new static($image); |
| 188 |
} |
| 189 |
/** |
| 190 |
* Returns the type of image from file. |
| 191 |
*/ |
| 192 |
public static function detectTypeFromFile(string $file, &$width = null, &$height = null) : ?int |
| 193 |
{ |
| 194 |
[$width, $height, $type] = @\getimagesize($file); |
| 195 |
// @ - files smaller than 12 bytes causes read error |
| 196 |
return isset(self::Formats[$type]) ? $type : null; |
| 197 |
} |
| 198 |
/** |
| 199 |
* Returns the type of image from string. |
| 200 |
*/ |
| 201 |
public static function detectTypeFromString(string $s, &$width = null, &$height = null) : ?int |
| 202 |
{ |
| 203 |
[$width, $height, $type] = @\getimagesizefromstring($s); |
| 204 |
// @ - strings smaller than 12 bytes causes read error |
| 205 |
return isset(self::Formats[$type]) ? $type : null; |
| 206 |
} |
| 207 |
/** |
| 208 |
* Returns the file extension for the given `Image::XXX` constant. |
| 209 |
*/ |
| 210 |
public static function typeToExtension(int $type) : string |
| 211 |
{ |
| 212 |
if (!isset(self::Formats[$type])) { |
| 213 |
throw new \Packetery\Nette\InvalidArgumentException("Unsupported image type '{$type}'."); |
| 214 |
} |
| 215 |
return self::Formats[$type]; |
| 216 |
} |
| 217 |
/** |
| 218 |
* Returns the `Image::XXX` constant for given file extension. |
| 219 |
*/ |
| 220 |
public static function extensionToType(string $extension) : int |
| 221 |
{ |
| 222 |
$extensions = \array_flip(self::Formats) + ['jpg' => self::JPEG]; |
| 223 |
$extension = \strtolower($extension); |
| 224 |
if (!isset($extensions[$extension])) { |
| 225 |
throw new \Packetery\Nette\InvalidArgumentException("Unsupported file extension '{$extension}'."); |
| 226 |
} |
| 227 |
return $extensions[$extension]; |
| 228 |
} |
| 229 |
/** |
| 230 |
* Returns the mime type for the given `Image::XXX` constant. |
| 231 |
*/ |
| 232 |
public static function typeToMimeType(int $type) : string |
| 233 |
{ |
| 234 |
return 'image/' . self::typeToExtension($type); |
| 235 |
} |
| 236 |
/** |
| 237 |
* Wraps GD image. |
| 238 |
* @param resource|\GdImage $image |
| 239 |
*/ |
| 240 |
public function __construct($image) |
| 241 |
{ |
| 242 |
$this->setImageResource($image); |
| 243 |
\imagesavealpha($image, \true); |
| 244 |
} |
| 245 |
/** |
| 246 |
* Returns image width. |
| 247 |
*/ |
| 248 |
public function getWidth() : int |
| 249 |
{ |
| 250 |
return \imagesx($this->image); |
| 251 |
} |
| 252 |
/** |
| 253 |
* Returns image height. |
| 254 |
*/ |
| 255 |
public function getHeight() : int |
| 256 |
{ |
| 257 |
return \imagesy($this->image); |
| 258 |
} |
| 259 |
/** |
| 260 |
* Sets image resource. |
| 261 |
* @param resource|\GdImage $image |
| 262 |
* @return static |
| 263 |
*/ |
| 264 |
protected function setImageResource($image) |
| 265 |
{ |
| 266 |
if (!$image instanceof \GdImage && !(\is_resource($image) && \get_resource_type($image) === 'gd')) { |
| 267 |
throw new \Packetery\Nette\InvalidArgumentException('Image is not valid.'); |
| 268 |
} |
| 269 |
$this->image = $image; |
| 270 |
return $this; |
| 271 |
} |
| 272 |
/** |
| 273 |
* Returns image GD resource. |
| 274 |
* @return resource|\GdImage |
| 275 |
*/ |
| 276 |
public function getImageResource() |
| 277 |
{ |
| 278 |
return $this->image; |
| 279 |
} |
| 280 |
/** |
| 281 |
* Scales an image. |
| 282 |
* @param int|string|null $width in pixels or percent |
| 283 |
* @param int|string|null $height in pixels or percent |
| 284 |
* @return static |
| 285 |
*/ |
| 286 |
public function resize($width, $height, int $flags = self::FIT) |
| 287 |
{ |
| 288 |
if ($flags & self::EXACT) { |
| 289 |
return $this->resize($width, $height, self::FILL)->crop('50%', '50%', $width, $height); |
| 290 |
} |
| 291 |
[$newWidth, $newHeight] = static::calculateSize($this->getWidth(), $this->getHeight(), $width, $height, $flags); |
| 292 |
if ($newWidth !== $this->getWidth() || $newHeight !== $this->getHeight()) { |
| 293 |
// resize |
| 294 |
$newImage = static::fromBlank($newWidth, $newHeight, self::rgb(0, 0, 0, 127))->getImageResource(); |
| 295 |
\imagecopyresampled($newImage, $this->image, 0, 0, 0, 0, $newWidth, $newHeight, $this->getWidth(), $this->getHeight()); |
| 296 |
$this->image = $newImage; |
| 297 |
} |
| 298 |
if ($width < 0 || $height < 0) { |
| 299 |
\imageflip($this->image, $width < 0 ? $height < 0 ? \IMG_FLIP_BOTH : \IMG_FLIP_HORIZONTAL : \IMG_FLIP_VERTICAL); |
| 300 |
} |
| 301 |
return $this; |
| 302 |
} |
| 303 |
/** |
| 304 |
* Calculates dimensions of resized image. |
| 305 |
* @param int|string|null $newWidth in pixels or percent |
| 306 |
* @param int|string|null $newHeight in pixels or percent |
| 307 |
*/ |
| 308 |
public static function calculateSize(int $srcWidth, int $srcHeight, $newWidth, $newHeight, int $flags = self::FIT) : array |
| 309 |
{ |
| 310 |
if ($newWidth === null) { |
| 311 |
} elseif (self::isPercent($newWidth)) { |
| 312 |
$newWidth = (int) \round($srcWidth / 100 * \abs($newWidth)); |
| 313 |
$percents = \true; |
| 314 |
} else { |
| 315 |
$newWidth = \abs($newWidth); |
| 316 |
} |
| 317 |
if ($newHeight === null) { |
| 318 |
} elseif (self::isPercent($newHeight)) { |
| 319 |
$newHeight = (int) \round($srcHeight / 100 * \abs($newHeight)); |
| 320 |
$flags |= empty($percents) ? 0 : self::STRETCH; |
| 321 |
} else { |
| 322 |
$newHeight = \abs($newHeight); |
| 323 |
} |
| 324 |
if ($flags & self::STRETCH) { |
| 325 |
// non-proportional |
| 326 |
if (!$newWidth || !$newHeight) { |
| 327 |
throw new \Packetery\Nette\InvalidArgumentException('For stretching must be both width and height specified.'); |
| 328 |
} |
| 329 |
if ($flags & self::SHRINK_ONLY) { |
| 330 |
$newWidth = (int) \round($srcWidth * \min(1, $newWidth / $srcWidth)); |
| 331 |
$newHeight = (int) \round($srcHeight * \min(1, $newHeight / $srcHeight)); |
| 332 |
} |
| 333 |
} else { |
| 334 |
// proportional |
| 335 |
if (!$newWidth && !$newHeight) { |
| 336 |
throw new \Packetery\Nette\InvalidArgumentException('At least width or height must be specified.'); |
| 337 |
} |
| 338 |
$scale = []; |
| 339 |
if ($newWidth > 0) { |
| 340 |
// fit width |
| 341 |
$scale[] = $newWidth / $srcWidth; |
| 342 |
} |
| 343 |
if ($newHeight > 0) { |
| 344 |
// fit height |
| 345 |
$scale[] = $newHeight / $srcHeight; |
| 346 |
} |
| 347 |
if ($flags & self::FILL) { |
| 348 |
$scale = [\max($scale)]; |
| 349 |
} |
| 350 |
if ($flags & self::SHRINK_ONLY) { |
| 351 |
$scale[] = 1; |
| 352 |
} |
| 353 |
$scale = \min($scale); |
| 354 |
$newWidth = (int) \round($srcWidth * $scale); |
| 355 |
$newHeight = (int) \round($srcHeight * $scale); |
| 356 |
} |
| 357 |
return [\max($newWidth, 1), \max($newHeight, 1)]; |
| 358 |
} |
| 359 |
/** |
| 360 |
* Crops image. |
| 361 |
* @param int|string $left in pixels or percent |
| 362 |
* @param int|string $top in pixels or percent |
| 363 |
* @param int|string $width in pixels or percent |
| 364 |
* @param int|string $height in pixels or percent |
| 365 |
* @return static |
| 366 |
*/ |
| 367 |
public function crop($left, $top, $width, $height) |
| 368 |
{ |
| 369 |
[$r['x'], $r['y'], $r['width'], $r['height']] = static::calculateCutout($this->getWidth(), $this->getHeight(), $left, $top, $width, $height); |
| 370 |
if (\gd_info()['GD Version'] === 'bundled (2.1.0 compatible)') { |
| 371 |
$this->image = \imagecrop($this->image, $r); |
| 372 |
\imagesavealpha($this->image, \true); |
| 373 |
} else { |
| 374 |
$newImage = static::fromBlank($r['width'], $r['height'], self::RGB(0, 0, 0, 127))->getImageResource(); |
| 375 |
\imagecopy($newImage, $this->image, 0, 0, $r['x'], $r['y'], $r['width'], $r['height']); |
| 376 |
$this->image = $newImage; |
| 377 |
} |
| 378 |
return $this; |
| 379 |
} |
| 380 |
/** |
| 381 |
* Calculates dimensions of cutout in image. |
| 382 |
* @param int|string $left in pixels or percent |
| 383 |
* @param int|string $top in pixels or percent |
| 384 |
* @param int|string $newWidth in pixels or percent |
| 385 |
* @param int|string $newHeight in pixels or percent |
| 386 |
*/ |
| 387 |
public static function calculateCutout(int $srcWidth, int $srcHeight, $left, $top, $newWidth, $newHeight) : array |
| 388 |
{ |
| 389 |
if (self::isPercent($newWidth)) { |
| 390 |
$newWidth = (int) \round($srcWidth / 100 * $newWidth); |
| 391 |
} |
| 392 |
if (self::isPercent($newHeight)) { |
| 393 |
$newHeight = (int) \round($srcHeight / 100 * $newHeight); |
| 394 |
} |
| 395 |
if (self::isPercent($left)) { |
| 396 |
$left = (int) \round(($srcWidth - $newWidth) / 100 * $left); |
| 397 |
} |
| 398 |
if (self::isPercent($top)) { |
| 399 |
$top = (int) \round(($srcHeight - $newHeight) / 100 * $top); |
| 400 |
} |
| 401 |
if ($left < 0) { |
| 402 |
$newWidth += $left; |
| 403 |
$left = 0; |
| 404 |
} |
| 405 |
if ($top < 0) { |
| 406 |
$newHeight += $top; |
| 407 |
$top = 0; |
| 408 |
} |
| 409 |
$newWidth = \min($newWidth, $srcWidth - $left); |
| 410 |
$newHeight = \min($newHeight, $srcHeight - $top); |
| 411 |
return [$left, $top, $newWidth, $newHeight]; |
| 412 |
} |
| 413 |
/** |
| 414 |
* Sharpens image a little bit. |
| 415 |
* @return static |
| 416 |
*/ |
| 417 |
public function sharpen() |
| 418 |
{ |
| 419 |
\imageconvolution($this->image, [ |
| 420 |
// my magic numbers ;) |
| 421 |
[-1, -1, -1], |
| 422 |
[-1, 24, -1], |
| 423 |
[-1, -1, -1], |
| 424 |
], 16, 0); |
| 425 |
return $this; |
| 426 |
} |
| 427 |
/** |
| 428 |
* Puts another image into this image. |
| 429 |
* @param int|string $left in pixels or percent |
| 430 |
* @param int|string $top in pixels or percent |
| 431 |
* @param int $opacity 0..100 |
| 432 |
* @return static |
| 433 |
*/ |
| 434 |
public function place(self $image, $left = 0, $top = 0, int $opacity = 100) |
| 435 |
{ |
| 436 |
$opacity = \max(0, \min(100, $opacity)); |
| 437 |
if ($opacity === 0) { |
| 438 |
return $this; |
| 439 |
} |
| 440 |
$width = $image->getWidth(); |
| 441 |
$height = $image->getHeight(); |
| 442 |
if (self::isPercent($left)) { |
| 443 |
$left = (int) \round(($this->getWidth() - $width) / 100 * $left); |
| 444 |
} |
| 445 |
if (self::isPercent($top)) { |
| 446 |
$top = (int) \round(($this->getHeight() - $height) / 100 * $top); |
| 447 |
} |
| 448 |
$output = $input = $image->image; |
| 449 |
if ($opacity < 100) { |
| 450 |
$tbl = []; |
| 451 |
for ($i = 0; $i < 128; $i++) { |
| 452 |
$tbl[$i] = \round(127 - (127 - $i) * $opacity / 100); |
| 453 |
} |
| 454 |
$output = \imagecreatetruecolor($width, $height); |
| 455 |
\imagealphablending($output, \false); |
| 456 |
if (!$image->isTrueColor()) { |
| 457 |
$input = $output; |
| 458 |
\imagefilledrectangle($output, 0, 0, $width, $height, \imagecolorallocatealpha($output, 0, 0, 0, 127)); |
| 459 |
\imagecopy($output, $image->image, 0, 0, 0, 0, $width, $height); |
| 460 |
} |
| 461 |
for ($x = 0; $x < $width; $x++) { |
| 462 |
for ($y = 0; $y < $height; $y++) { |
| 463 |
$c = \imagecolorat($input, $x, $y); |
| 464 |
$c = ($c & 0xffffff) + ($tbl[$c >> 24] << 24); |
| 465 |
\imagesetpixel($output, $x, $y, $c); |
| 466 |
} |
| 467 |
} |
| 468 |
\imagealphablending($output, \true); |
| 469 |
} |
| 470 |
\imagecopy($this->image, $output, $left, $top, 0, 0, $width, $height); |
| 471 |
return $this; |
| 472 |
} |
| 473 |
/** |
| 474 |
* 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). |
| 475 |
* @throws ImageException |
| 476 |
*/ |
| 477 |
public function save(string $file, ?int $quality = null, ?int $type = null) : void |
| 478 |
{ |
| 479 |
$type = $type ?? self::extensionToType(\pathinfo($file, \PATHINFO_EXTENSION)); |
| 480 |
$this->output($type, $quality, $file); |
| 481 |
} |
| 482 |
/** |
| 483 |
* 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). |
| 484 |
*/ |
| 485 |
public function toString(int $type = self::JPEG, ?int $quality = null) : string |
| 486 |
{ |
| 487 |
return Helpers::capture(function () use($type, $quality) { |
| 488 |
$this->output($type, $quality); |
| 489 |
}); |
| 490 |
} |
| 491 |
/** |
| 492 |
* Outputs image to string. |
| 493 |
*/ |
| 494 |
public function __toString() : string |
| 495 |
{ |
| 496 |
try { |
| 497 |
return $this->toString(); |
| 498 |
} catch (\Throwable $e) { |
| 499 |
if (\func_num_args() || \PHP_VERSION_ID >= 70400) { |
| 500 |
throw $e; |
| 501 |
} |
| 502 |
\trigger_error('Exception in ' . __METHOD__ . "(): {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}", \E_USER_ERROR); |
| 503 |
return ''; |
| 504 |
} |
| 505 |
} |
| 506 |
/** |
| 507 |
* 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). |
| 508 |
* @throws ImageException |
| 509 |
*/ |
| 510 |
public function send(int $type = self::JPEG, ?int $quality = null) : void |
| 511 |
{ |
| 512 |
\header('Content-Type: ' . self::typeToMimeType($type)); |
| 513 |
$this->output($type, $quality); |
| 514 |
} |
| 515 |
/** |
| 516 |
* Outputs image to browser or file. |
| 517 |
* @throws ImageException |
| 518 |
*/ |
| 519 |
private function output(int $type, ?int $quality, ?string $file = null) : void |
| 520 |
{ |
| 521 |
switch ($type) { |
| 522 |
case self::JPEG: |
| 523 |
$quality = $quality === null ? 85 : \max(0, \min(100, $quality)); |
| 524 |
$success = @\imagejpeg($this->image, $file, $quality); |
| 525 |
// @ is escalated to exception |
| 526 |
break; |
| 527 |
case self::PNG: |
| 528 |
$quality = $quality === null ? 9 : \max(0, \min(9, $quality)); |
| 529 |
$success = @\imagepng($this->image, $file, $quality); |
| 530 |
// @ is escalated to exception |
| 531 |
break; |
| 532 |
case self::GIF: |
| 533 |
$success = @\imagegif($this->image, $file); |
| 534 |
// @ is escalated to exception |
| 535 |
break; |
| 536 |
case self::WEBP: |
| 537 |
$quality = $quality === null ? 80 : \max(0, \min(100, $quality)); |
| 538 |
$success = @\imagewebp($this->image, $file, $quality); |
| 539 |
// @ is escalated to exception |
| 540 |
break; |
| 541 |
case self::AVIF: |
| 542 |
$quality = $quality === null ? 30 : \max(0, \min(100, $quality)); |
| 543 |
$success = @\imageavif($this->image, $file, $quality); |
| 544 |
// @ is escalated to exception |
| 545 |
break; |
| 546 |
case self::BMP: |
| 547 |
$success = @\imagebmp($this->image, $file); |
| 548 |
// @ is escalated to exception |
| 549 |
break; |
| 550 |
default: |
| 551 |
throw new \Packetery\Nette\InvalidArgumentException("Unsupported image type '{$type}'."); |
| 552 |
} |
| 553 |
if (!$success) { |
| 554 |
throw new ImageException(Helpers::getLastError() ?: 'Unknown error'); |
| 555 |
} |
| 556 |
} |
| 557 |
/** |
| 558 |
* Call to undefined method. |
| 559 |
* @return mixed |
| 560 |
* @throws \Packetery\Nette\MemberAccessException |
| 561 |
*/ |
| 562 |
public function __call(string $name, array $args) |
| 563 |
{ |
| 564 |
$function = 'image' . $name; |
| 565 |
if (!\function_exists($function)) { |
| 566 |
ObjectHelpers::strictCall(static::class, $name); |
| 567 |
} |
| 568 |
foreach ($args as $key => $value) { |
| 569 |
if ($value instanceof self) { |
| 570 |
$args[$key] = $value->getImageResource(); |
| 571 |
} elseif (\is_array($value) && isset($value['red'])) { |
| 572 |
// rgb |
| 573 |
$args[$key] = \imagecolorallocatealpha($this->image, $value['red'], $value['green'], $value['blue'], $value['alpha']) ?: \imagecolorresolvealpha($this->image, $value['red'], $value['green'], $value['blue'], $value['alpha']); |
| 574 |
} |
| 575 |
} |
| 576 |
$res = $function($this->image, ...$args); |
| 577 |
return $res instanceof \GdImage || \is_resource($res) && \get_resource_type($res) === 'gd' ? $this->setImageResource($res) : $res; |
| 578 |
} |
| 579 |
public function __clone() |
| 580 |
{ |
| 581 |
\ob_start(function () { |
| 582 |
}); |
| 583 |
\imagepng($this->image, null, 0); |
| 584 |
$this->setImageResource(\imagecreatefromstring(\ob_get_clean())); |
| 585 |
} |
| 586 |
/** |
| 587 |
* @param int|string $num in pixels or percent |
| 588 |
*/ |
| 589 |
private static function isPercent(&$num) : bool |
| 590 |
{ |
| 591 |
if (\is_string($num) && \substr($num, -1) === '%') { |
| 592 |
$num = (float) \substr($num, 0, -1); |
| 593 |
return \true; |
| 594 |
} elseif (\is_int($num) || $num === (string) (int) $num) { |
| 595 |
$num = (int) $num; |
| 596 |
return \false; |
| 597 |
} |
| 598 |
throw new \Packetery\Nette\InvalidArgumentException("Expected dimension in int|string, '{$num}' given."); |
| 599 |
} |
| 600 |
/** |
| 601 |
* Prevents serialization. |
| 602 |
*/ |
| 603 |
public function __sleep() : array |
| 604 |
{ |
| 605 |
throw new \Packetery\Nette\NotSupportedException('You cannot serialize or unserialize ' . self::class . ' instances.'); |
| 606 |
} |
| 607 |
} |
| 608 |
|