. // // Copyright A Beautiful Site, LLC. // // Source: https://github.com/claviska/SimpleImage // // Licensed under the MIT license // namespace claviska; use Exception; use GdImage; use League\ColorExtractor\Color; use League\ColorExtractor\ColorExtractor; use League\ColorExtractor\Palette; /** * A PHP class that makes working with images as simple as possible. */ class SimpleImage { public const ERR_FILE_NOT_FOUND = 1; public const ERR_FONT_FILE = 2; public const ERR_FREETYPE_NOT_ENABLED = 3; public const ERR_GD_NOT_ENABLED = 4; public const ERR_INVALID_COLOR = 5; public const ERR_INVALID_DATA_URI = 6; public const ERR_INVALID_IMAGE = 7; public const ERR_LIB_NOT_LOADED = 8; public const ERR_UNSUPPORTED_FORMAT = 9; public const ERR_WEBP_NOT_ENABLED = 10; public const ERR_WRITE = 11; public const ERR_INVALID_FLAG = 12; protected array $flags; protected $image = null; protected string $mimeType; protected null|array|false $exif = null; ////////////////////////////////////////////////////////////////////////////////////////////////// // Magic methods ////////////////////////////////////////////////////////////////////////////////////////////////// /** * Creates a new SimpleImage object. * * @param string $image An image file or a data URI to load. * @param array $flags Optional override of default flags. * * @throws Exception Thrown if the GD library is not found; file|URI or image data is invalid. */ public function __construct(string $image = '', array $flags = []) { // Check for the required GD extension if (extension_loaded('gd')) { // Ignore JPEG warnings that cause imagecreatefromjpeg() to fail ini_set('gd.jpeg_ignore_warning', '1'); } else { throw new Exception('Required extension GD is not loaded.', self::ERR_GD_NOT_ENABLED); } // Associative array of flags. $this->flags = [ 'sslVerify' => true, // Skip SSL peer validation ]; // Override default flag values. foreach ($flags as $flag => $value) { $this->setFlag($flag, $value); } // Load an image through the constructor if (preg_match('/^data:(.*?);/', $image)) { $this->fromDataUri($image); } elseif ($image) { $this->fromFile($image); } } /** * Destroys the image resource. */ public function __destruct() { $this->reset(); } ////////////////////////////////////////////////////////////////////////////////////////////////// // Helper functions ////////////////////////////////////////////////////////////////////////////////////////////////// /** * Checks if the SimpleImage object has loaded an image. */ public function hasImage(): bool { return $this->image instanceof GdImage; } /** * Destroys the image resource. */ public function reset(): static { if ($this->hasImage()) { imagedestroy($this->image); } return $this; } /** * Set flag value. * * @param string $flag Name of the flag to set. * @param bool $value State of the flag. * * @throws Exception Thrown if flag does not exist (no default value). */ public function setFlag(string $flag, bool $value): void { // Throw if flag does not exist if (! in_array($flag, array_keys($this->flags))) { throw new Exception('Invalid flag.', self::ERR_INVALID_FLAG); } // Set flag value by name $this->flags[$flag] = $value; } /** * Get flag value. * * @param string $flag Name of the flag to get. */ public function getFlag(string $flag): ?bool { return in_array($flag, array_keys($this->flags)) ? $this->flags[$flag] : null; } ////////////////////////////////////////////////////////////////////////////////////////////////// // Loaders ////////////////////////////////////////////////////////////////////////////////////////////////// /** * Loads an image from a data URI. * * @param string $uri A data URI. * @return SimpleImage * * @throws Exception Thrown if URI or image data is invalid. */ public function fromDataUri(string $uri): static { // Basic formatting check preg_match('/^data:(.*?);/', $uri, $matches); if (! count($matches)) { throw new Exception('Invalid data URI.', self::ERR_INVALID_DATA_URI); } // Determine mime type $this->mimeType = $matches[1]; if (! preg_match('/^image\/(gif|jpeg|png)$/', $this->mimeType)) { throw new Exception( 'Unsupported format: '.$this->mimeType, self::ERR_UNSUPPORTED_FORMAT ); } // Get image data $uri = base64_decode(strval(preg_replace('/^data:(.*?);base64,/', '', $uri))); $this->image = imagecreatefromstring($uri); if (! $this->image) { throw new Exception('Invalid image data.', self::ERR_INVALID_IMAGE); } return $this; } /** * Loads an image from a file. * * @param string $file The image file to load. * @return SimpleImage * * @throws Exception Thrown if file or image data is invalid. */ public function fromFile(string $file): static { // Set fopen options. $sslVerify = $this->getFlag('sslVerify'); // Don't perform peer validation when true $opts = [ 'ssl' => [ 'verify_peer' => $sslVerify, 'verify_peer_name' => $sslVerify, ], ]; // Check if the file exists and is readable. $file = @file_get_contents($file, false, stream_context_create($opts)); if ($file === false) { throw new Exception("File not found: $file", self::ERR_FILE_NOT_FOUND); } // Create image object from string $this->image = imagecreatefromstring($file); // Get image info $info = @getimagesizefromstring($file); if ($info === false) { throw new Exception("Invalid image file: $file", self::ERR_INVALID_IMAGE); } $this->mimeType = $info['mime']; if (! $this->image) { throw new Exception('Unsupported format: '.$this->mimeType, self::ERR_UNSUPPORTED_FORMAT); } switch($this->mimeType) { case 'image/gif': // Copy the gif over to a true color image to preserve its transparency. This is a // workaround to prevent imagepalettetotruecolor() from borking transparency. $width = imagesx($this->image); $height = imagesx($this->image); $gif = imagecreatetruecolor((int) $width, (int) $height); $alpha = imagecolorallocatealpha($gif, 0, 0, 0, 127); imagecolortransparent($gif, $alpha ?: null); imagefill($gif, 0, 0, $alpha); imagecopy($this->image, $gif, 0, 0, 0, 0, $width, $height); imagedestroy($gif); break; case 'image/jpeg': // Load exif data from JPEG images if (function_exists('exif_read_data')) { $this->exif = @exif_read_data('data://image/jpeg;base64,'.base64_encode($file)); } break; } // Convert pallete images to true color images imagepalettetotruecolor($this->image); return $this; } /** * Creates a new image. * * @param int $width The width of the image. * @param int $height The height of the image. * @param string|array $color Optional fill color for the new image (default 'transparent'). * @return SimpleImage * * @throws Exception */ public function fromNew(int $width, int $height, string|array $color = 'transparent'): static { $this->image = imagecreatetruecolor($width, $height); // Use PNG for dynamically created images because it's lossless and supports transparency $this->mimeType = 'image/png'; // Fill the image with color $this->fill($color); return $this; } /** * Creates a new image from a string. * * @param string $string The raw image data as a string. * @return SimpleImage * * @throws Exception * * @example * $string = file_get_contents('image.jpg'); */ public function fromString(string $string): SimpleImage|static { return $this->fromFile('data://;base64,'.base64_encode($string)); } ////////////////////////////////////////////////////////////////////////////////////////////////// // Savers ////////////////////////////////////////////////////////////////////////////////////////////////// /** * Generates an image. * * @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type). * @param array|int $options Array or Image quality as a percentage (default 100). * @return array Returns an array containing the image data and mime type ['data' => '', 'mimeType' => '']. * * @throws Exception Thrown when WEBP support is not enabled or unsupported format. */ public function generate(string|null $mimeType = null, array|int $options = 100): array { // Format defaults to the original mime type $mimeType = $mimeType ?: $this->mimeType; $quality = null; // allow $options to be an int for backwards compatibility to v3 if (is_int($options)) { $quality = $options; $options = []; } // get quality if passed as an option if (is_array($options) && array_key_exists('quality', $options)) { $quality = intval($options['quality']); } // Ensure quality is a valid integer if ($quality === null) { $quality = 100; } $quality = (int) round(self::keepWithin((int) $quality, 0, 100)); $alpha = true; // get alpha if passed as an option if (is_array($options) && array_key_exists('alpha', $options)) { $alpha = boolval($options['alpha']); } $interlace = null; // keep the same // get interlace if passed as an option if (is_array($options) && array_key_exists('interlace', $options)) { $interlace = boolval($options['interlace']); } // get raw stream from image* functions in providing no path $file = null; // Capture output ob_start(); // Generate the image switch($mimeType) { case 'image/gif': imagesavealpha($this->image, $alpha); imagegif($this->image, $file); break; case 'image/jpeg': imageinterlace($this->image, $interlace); imagejpeg($this->image, $file, $quality); break; case 'image/png': $filters = -1; // imagepng default // get filters if passed as an option if (is_array($options) && array_key_exists('filters', $options)) { $filters = intval($options['filters']); } // compression param is called quality in imagepng but that would be // misleading in context of SimpleImage $compression = -1; // defaults to zlib default which is 6 // get compression if passed as an option if (is_array($options) && array_key_exists('compression', $options)) { $compression = intval($options['compression']); } if ($compression !== -1) { $compression = (int) round(self::keepWithin($compression, 0, 10)); } imagesavealpha($this->image, $alpha); imagepng($this->image, $file, $compression, $filters); break; case 'image/webp': // Not all versions of PHP will have webp support enabled if (! function_exists('imagewebp')) { throw new Exception( 'WEBP support is not enabled in your version of PHP.', self::ERR_WEBP_NOT_ENABLED ); } // useless but recommended, see https://www.php.net/manual/en/function.imagesavealpha.php imagesavealpha($this->image, $alpha); imagewebp($this->image, $file, $quality); break; case 'image/bmp': case 'image/x-ms-bmp': case 'image/x-windows-bmp': // Not all versions of PHP support bmp if (! function_exists('imagebmp')) { throw new Exception( 'BMP support is not available in your version of PHP.', self::ERR_UNSUPPORTED_FORMAT ); } $compression = true; // imagebmp default // get compression if passed as an option if (is_array($options) && array_key_exists('compression', $options)) { $compression = is_int($options['compression']) ? $options['compression'] > 0 : boolval($options['compression']); } imageinterlace($this->image, $interlace); imagebmp($this->image, $file, $compression); break; case 'image/avif': // Not all versions of PHP support avif if (! function_exists('imageavif')) { throw new Exception( 'AVIF support is not available in your version of PHP.', self::ERR_UNSUPPORTED_FORMAT ); } $speed = -1; // imageavif default // get speed if passed as an option if (is_array($options) && array_key_exists('speed', $options)) { $speed = intval($options['speed']); $speed = self::keepWithin($speed, 0, 10); } // useless but recommended, see https://www.php.net/manual/en/function.imagesavealpha.php imagesavealpha($this->image, $alpha); imageavif($this->image, $file, $quality, $speed); break; default: throw new Exception('Unsupported format: '.$mimeType, self::ERR_UNSUPPORTED_FORMAT); } // Stop capturing $data = ob_get_contents(); ob_end_clean(); return [ 'data' => $data, 'mimeType' => $mimeType, ]; } /** * Generates a data URI. * * @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type). * @param array|int $options Array or Image quality as a percentage (default 100). * @return string Returns a string containing a data URI. * * @throws Exception */ public function toDataUri(string|null $mimeType = null, array|int $options = 100): string { $image = $this->generate($mimeType, $options); return 'data:'.$image['mimeType'].';base64,'.base64_encode($image['data']); } /** * Forces the image to be downloaded to the clients machine. Must be called before any output is sent to the screen. * * @param string $filename The filename (without path) to send to the client (e.g. 'image.jpeg'). * @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type). * @param array|int $options Array or Image quality as a percentage (default 100). * @return SimpleImage * * @throws Exception */ public function toDownload(string $filename, string|null $mimeType = null, array|int $options = 100): static { $image = $this->generate($mimeType, $options); // Set download headers header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); header('Content-Description: File Transfer'); header('Content-Length: '.strlen($image['data'])); header('Content-Transfer-Encoding: Binary'); header('Content-Type: application/octet-stream'); header("Content-Disposition: attachment; filename=\"$filename\""); echo $image['data']; return $this; } /** * Writes the image to a file. * * @param string $file The image format to output as a mime type (defaults to the original mime type). * @param string|null $mimeType Image quality as a percentage (default 100). * @param array|int $options Array or Image quality as a percentage (default 100). * @return SimpleImage * * @throws Exception Thrown if failed write to file. */ public function toFile(string $file, string|null $mimeType = null, array|int $options = 100): static { $image = $this->generate($mimeType, $options); // Save the image to file if (! file_put_contents($file, $image['data'])) { throw new Exception("Failed to write image to file: $file", self::ERR_WRITE); } return $this; } /** * Outputs the image to the screen. Must be called before any output is sent to the screen. * * @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type). * @param array|int $options Array or Image quality as a percentage (default 100). * @return SimpleImage * * @throws Exception */ public function toScreen(string|null $mimeType = null, array|int $options = 100): static { $image = $this->generate($mimeType, $options); // Output the image to stdout header('Content-Type: '.$image['mimeType']); echo $image['data']; return $this; } /** * Generates an image string. * * @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type). * @param array|int $options Array or Image quality as a percentage (default 100). * * @throws Exception */ public function toString(string|null $mimeType = null, array|int $options = 100): string { return $this->generate($mimeType, $options)['data']; } ////////////////////////////////////////////////////////////////////////////////////////////////// // Utilities ////////////////////////////////////////////////////////////////////////////////////////////////// /** * Ensures a numeric value is always within the min and max range. * * @param int|float $value A numeric value to test. * @param int|float $min The minimum allowed value. * @param int|float $max The maximum allowed value. */ protected static function keepWithin(int|float $value, int|float $min, int|float $max): int|float { if ($value < $min) { return $min; } if ($value > $max) { return $max; } return $value; } /** * Gets the image's current aspect ratio. * * @return float|int Returns the aspect ratio as a float. */ public function getAspectRatio(): float|int { return $this->getWidth() / $this->getHeight(); } /** * Gets the image's exif data. * * @return array|null Returns an array of exif data or null if no data is available. */ public function getExif(): ?array { // returns null if exif value is falsy: null, false or empty array. return $this->exif ?: null; } /** * Gets the image's current height. */ public function getHeight(): int { return (int) imagesy($this->image); } /** * Gets the mime type of the loaded image. */ public function getMimeType(): string { return $this->mimeType; } /** * Gets the image's current orientation. * * @return string One of the values: 'landscape', 'portrait', or 'square' */ public function getOrientation(): string { $width = $this->getWidth(); $height = $this->getHeight(); if ($width > $height) { return 'landscape'; } if ($width < $height) { return 'portrait'; } return 'square'; } /** * Gets the resolution of the image * * @return array|bool The resolution as an array of integers: [96, 96] */ public function getResolution(): bool|array { return imageresolution($this->image); } /** * Gets the image's current width. */ public function getWidth(): int { return (int) imagesx($this->image); } ////////////////////////////////////////////////////////////////////////////////////////////////// // Manipulation ////////////////////////////////////////////////////////////////////////////////////////////////// /** * Same as PHP's imagecopymerge, but works with transparent images. Used internally for overlay. * * @param GdImage $dstIm Destination image. * @param GdImage $srcIm Source image. * @param int $dstX x-coordinate of destination point. * @param int $dstY y-coordinate of destination point. * @param int $srcX x-coordinate of source point. * @param int $srcY y-coordinate of source point. * @param int $srcW Source width. * @param int $srcH Source height. * @return bool true if success. */ protected static function imageCopyMergeAlpha(GdImage $dstIm, GdImage $srcIm, int $dstX, int $dstY, int $srcX, int $srcY, int $srcW, int $srcH, int $pct): bool { // Are we merging with transparency? if ($pct < 100) { // Disable alpha blending and "colorize" the image using a transparent color imagealphablending($srcIm, false); imagefilter($srcIm, IMG_FILTER_COLORIZE, 0, 0, 0, round(127 * ((100 - $pct) / 100))); } imagecopy($dstIm, $srcIm, $dstX, $dstY, $srcX, $srcY, $srcW, $srcH); return true; } /** * Rotates an image so the orientation will be correct based on its exif data. It is safe to call * this method on images that don't have exif data (no changes will be made). * * @return SimpleImage * * @throws Exception */ public function autoOrient(): static { $exif = $this->getExif(); if (! $exif || ! isset($exif['Orientation'])) { return $this; } switch($exif['Orientation']) { case 1: // Do nothing! break; case 2: // Flip horizontally $this->flip('x'); break; case 3: // Rotate 180 degrees $this->rotate(180); break; case 4: // Flip vertically $this->flip('y'); break; case 5: // Rotate 90 degrees clockwise and flip vertically $this->flip('y')->rotate(90); break; case 6: // Rotate 90 clockwise $this->rotate(90); break; case 7: // Rotate 90 clockwise and flip horizontally $this->flip('x')->rotate(90); break; case 8: // Rotate 90 counterclockwise $this->rotate(-90); break; } return $this; } /** * Proportionally resize the image to fit inside a specific width and height. * * @param int $maxWidth The maximum width the image can be. * @param int $maxHeight The maximum height the image can be. * @return SimpleImage */ public function bestFit(int $maxWidth, int $maxHeight): static { // If the image already fits, there's nothing to do if ($this->getWidth() <= $maxWidth && $this->getHeight() <= $maxHeight) { return $this; } // Calculate max width or height based on orientation if ($this->getOrientation() === 'portrait') { $height = $maxHeight; $width = (int) round($maxHeight * $this->getAspectRatio()); } else { $width = $maxWidth; $height = (int) round($maxWidth / $this->getAspectRatio()); } // Reduce to max width if ($width > $maxWidth) { $width = $maxWidth; $height = (int) round($width / $this->getAspectRatio()); } // Reduce to max height if ($height > $maxHeight) { $height = $maxHeight; $width = (int) round($height * $this->getAspectRatio()); } return $this->resize($width, $height); } /** * Crop the image. * * @param int|float $x1 Top left x coordinate. * @param int|float $y1 Top left y coordinate. * @param int|float $x2 Bottom right x coordinate. * @param int|float $y2 Bottom right x coordinate. * @return SimpleImage */ public function crop(int|float $x1, int|float $y1, int|float $x2, int|float $y2): static { // Keep crop within image dimensions $x1 = self::keepWithin($x1, 0, $this->getWidth()); $x2 = self::keepWithin($x2, 0, $this->getWidth()); $y1 = self::keepWithin($y1, 0, $this->getHeight()); $y2 = self::keepWithin($y2, 0, $this->getHeight()); // Avoid using native imagecrop() because of a bug with PNG transparency $dstW = abs($x2 - $x1); $dstH = abs($y2 - $y1); $newImage = imagecreatetruecolor((int) $dstW, (int) $dstH); $transparentColor = imagecolorallocatealpha($newImage, 0, 0, 0, 127); imagecolortransparent($newImage, $transparentColor ?: null); imagefill($newImage, 0, 0, $transparentColor); // Crop it imagecopyresampled( $newImage, $this->image, 0, 0, (int) round(min($x1, $x2)), (int) round(min($y1, $y2)), (int) $dstW, (int) $dstH, (int) $dstW, (int) $dstH ); // Swap out the new image $this->image = $newImage; return $this; } /** * Applies a duotone filter to the image. * * @param string|array $lightColor The lightest color in the duotone. * @param string|array $darkColor The darkest color in the duotone. * @return SimpleImage * * @throws Exception */ public function duotone(string|array $lightColor, string|array $darkColor): static { $lightColor = self::normalizeColor($lightColor); $darkColor = self::normalizeColor($darkColor); // Calculate averages between light and dark colors $redAvg = $lightColor['red'] - $darkColor['red']; $greenAvg = $lightColor['green'] - $darkColor['green']; $blueAvg = $lightColor['blue'] - $darkColor['blue']; // Create a matrix of all possible duotone colors based on gray values $pixels = []; for ($i = 0; $i <= 255; $i++) { $grayAvg = $i / 255; $pixels['red'][$i] = $darkColor['red'] + $grayAvg * $redAvg; $pixels['green'][$i] = $darkColor['green'] + $grayAvg * $greenAvg; $pixels['blue'][$i] = $darkColor['blue'] + $grayAvg * $blueAvg; } // Apply the filter pixel by pixel for ($x = 0; $x < $this->getWidth(); $x++) { for ($y = 0; $y < $this->getHeight(); $y++) { $rgb = $this->getColorAt($x, $y); $gray = min(255, round(0.299 * $rgb['red'] + 0.114 * $rgb['blue'] + 0.587 * $rgb['green'])); $this->dot($x, $y, [ 'red' => $pixels['red'][$gray], 'green' => $pixels['green'][$gray], 'blue' => $pixels['blue'][$gray], ]); } } return $this; } /** * Proportionally resize the image to a specific width. * * @param int $width The width to resize the image to. * @return SimpleImage * *@deprecated * This method was deprecated in version 3.2.2 and will be removed in version 4.0. * Please use `resize(null, $height)` instead. */ public function fitToWidth(int $width): static { return $this->resize($width); } /** * Flip the image horizontally or vertically. * * @param string $direction The direction to flip: x|y|both. * @return SimpleImage */ public function flip(string $direction): static { match ($direction) { 'x' => imageflip($this->image, IMG_FLIP_HORIZONTAL), 'y' => imageflip($this->image, IMG_FLIP_VERTICAL), 'both' => imageflip($this->image, IMG_FLIP_BOTH), default => $this, }; return $this; } /** * Reduces the image to a maximum number of colors. * * @param int $max The maximum number of colors to use. * @param bool $dither Whether or not to use a dithering effect (default true). * @return SimpleImage */ public function maxColors(int $max, bool $dither = true): static { imagetruecolortopalette($this->image, $dither, max(1, $max)); return $this; } /** * Place an image on top of the current image. * * @param string|SimpleImage $overlay The image to overlay. This can be a filename, a data URI, or a SimpleImage object. * @param string $anchor The anchor point: 'center', 'top', 'bottom', 'left', 'right', 'top left', 'top right', 'bottom left', 'bottom right' (default 'center'). * @param float|int $opacity The opacity level of the overlay 0-1 (default 1). * @param int $xOffset Horizontal offset in pixels (default 0). * @param int $yOffset Vertical offset in pixels (default 0). * @param bool $calculateOffsetFromEdge Calculate Offset referring to the edges of the image (default false). * @return SimpleImage * * @throws Exception */ public function overlay(string|SimpleImage $overlay, string $anchor = 'center', float|int $opacity = 1, int $xOffset = 0, int $yOffset = 0, bool $calculateOffsetFromEdge = false): static { // Load overlay image if (! ($overlay instanceof SimpleImage)) { $overlay = new SimpleImage($overlay); } // Convert opacity $opacity = (int) round(self::keepWithin($opacity, 0, 1) * 100); // Get available space $spaceX = $this->getWidth() - $overlay->getWidth(); $spaceY = $this->getHeight() - $overlay->getHeight(); // Set default center $x = (int) round(($spaceX / 2) + ($calculateOffsetFromEdge ? 0 : $xOffset)); $y = (int) round(($spaceY / 2) + ($calculateOffsetFromEdge ? 0 : $yOffset)); // Determine if top|bottom if (str_contains($anchor, 'top')) { $y = $yOffset; } elseif (str_contains($anchor, 'bottom')) { $y = $spaceY + ($calculateOffsetFromEdge ? -$yOffset : $yOffset); } // Determine if left|right if (str_contains($anchor, 'left')) { $x = $xOffset; } elseif (str_contains($anchor, 'right')) { $x = $spaceX + ($calculateOffsetFromEdge ? -$xOffset : $xOffset); } // Perform the overlay self::imageCopyMergeAlpha( $this->image, $overlay->image, $x, $y, 0, 0, $overlay->getWidth(), $overlay->getHeight(), $opacity ); return $this; } /** * Resize an image to the specified dimensions. If only one dimension is specified, the image will be resized proportionally. * * @param int|null $width The new image width. * @param int|null $height The new image height. * @return SimpleImage */ public function resize(int|null $width = null, int|null $height = null): static { // No dimensions specified if (! $width && ! $height) { return $this; } // Resize to width if ($width && ! $height) { $height = (int) round($width / $this->getAspectRatio()); } // Resize to height if (! $width && $height) { $width = (int) round($height * $this->getAspectRatio()); } // If the dimensions are the same, there's no need to resize if ($this->getWidth() === $width && $this->getHeight() === $height) { return $this; } // We can't use imagescale because it doesn't seem to preserve transparency properly. The // workaround is to create a new truecolor image, allocate a transparent color, and copy the // image over to it using imagecopyresampled. $newImage = imagecreatetruecolor($width, $height); $transparentColor = imagecolorallocatealpha($newImage, 0, 0, 0, 127); imagecolortransparent($newImage, $transparentColor); imagefill($newImage, 0, 0, $transparentColor); imagecopyresampled( $newImage, $this->image, 0, 0, 0, 0, $width, $height, $this->getWidth(), $this->getHeight() ); // Swap out the new image $this->image = $newImage; return $this; } /** * Sets an image's resolution, as per https://www.php.net/manual/en/function.imageresolution.php * * @param int $res_x The horizontal resolution in DPI. * @param int|null $res_y The vertical resolution in DPI * @return SimpleImage */ public function resolution(int $res_x, int|null $res_y = null): static { if (is_null($res_y)) { imageresolution($this->image, $res_x); } else { imageresolution($this->image, $res_x, $res_y); } return $this; } /** * Rotates the image. * * @param int $angle The angle of rotation (-360 - 360). * @param string|array $backgroundColor The background color to use for the uncovered zone area after rotation (default 'transparent'). * @return SimpleImage * * @throws Exception */ public function rotate(int $angle, string|array $backgroundColor = 'transparent'): static { // Rotate the image on a canvas with the desired background color $backgroundColor = $this->allocateColor($backgroundColor); $this->image = imagerotate( $this->image, -(self::keepWithin($angle, -360, 360)), $backgroundColor ); imagecolortransparent($this->image, imagecolorallocatealpha($this->image, 0, 0, 0, 127)); return $this; } /** * Adds text to the image. * * @param string $text The desired text. * @param array $options * An array of options. * - fontFile* (string) - The TrueType (or compatible) font file to use. * - size (integer) - The size of the font in pixels (default 12). * - color (string|array) - The text color (default black). * - anchor (string) - The anchor point: 'center', 'top', 'bottom', 'left', 'right', 'top left', 'top right', 'bottom left', 'bottom right' (default 'center'). * - xOffset (integer) - The horizontal offset in pixels (default 0). * - yOffset (integer) - The vertical offset in pixels (default 0). * - shadow (array) - Text shadow params. * - x* (integer) - Horizontal offset in pixels. * - y* (integer) - Vertical offset in pixels. * - color* (string|array) - The text shadow color. * - $calculateOffsetFromEdge (bool) - Calculate offsets from the edge of the image (default false). * - $baselineAlign (bool) - Align the text font with the baseline. (default true). * @param array|null $boundary * If passed, this variable will contain an array with coordinates that surround the text: [x1, y1, x2, y2, width, height]. * This can be used for calculating the text's position after it gets added to the image. * @return SimpleImage * * @throws Exception */ public function text(string $text, array $options, array|null &$boundary = null): static { // Check for freetype support if (! function_exists('imagettftext')) { throw new Exception( 'Freetype support is not enabled in your version of PHP.', self::ERR_FREETYPE_NOT_ENABLED ); } // Default options $options = array_merge([ 'fontFile' => null, 'size' => 12, 'color' => 'black', 'anchor' => 'center', 'xOffset' => 0, 'yOffset' => 0, 'shadow' => null, 'calculateOffsetFromEdge' => false, 'baselineAlign' => true, ], $options); // Extract and normalize options $fontFile = $options['fontFile']; $size = ($options['size'] / 96) * 72; // Convert px to pt (72pt per inch, 96px per inch) $color = $this->allocateColor($options['color']); $anchor = $options['anchor']; $xOffset = $options['xOffset']; $yOffset = $options['yOffset']; $calculateOffsetFromEdge = $options['calculateOffsetFromEdge']; $baselineAlign = $options['baselineAlign']; $angle = 0; // Calculate the bounding box dimensions // // Since imagettfbox() returns a bounding box from the text's baseline, we can end up with // different heights for different strings of the same font size. For example, 'type' will often // be taller than 'text' because the former has a descending letter. // // To compensate for this, we created a temporary bounding box to measure the maximum height // that the font used can occupy. Based on this, we can adjust the text vertically so that it // appears inside the box with a good consistency. // // See: https://github.com/claviska/SimpleImage/issues/165 // $boxText = imagettfbbox($size, $angle, $fontFile, $text); if (! $boxText) { throw new Exception("Unable to load font file: $fontFile", self::ERR_FONT_FILE); } $boxWidth = abs($boxText[4] - $boxText[0]); $boxHeight = abs($boxText[5] - $boxText[1]); // Calculate Offset referring to the edges of the image. // Just invert the value for bottom|right; if ($calculateOffsetFromEdge) { if (str_contains($anchor, 'bottom')) { $yOffset *= -1; } if (str_contains($anchor, 'right')) { $xOffset *= -1; } } // Align the text font with the baseline. // I use $yOffset to inject the vertical alignment correction value. if ($baselineAlign) { // Create a temporary box to obtain the maximum height that this font can use. $boxFull = imagettfbbox($size, $angle, $fontFile, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'); // Based on the maximum height, the text is aligned. if (str_contains($anchor, 'bottom')) { $yOffset -= $boxFull[1]; } elseif (str_contains($anchor, 'top')) { $yOffset += abs($boxFull[5]) - $boxHeight; } else { // center $boxFullHeight = abs($boxFull[1]) + abs($boxFull[5]); $yOffset += ($boxFullHeight / 2) - ($boxHeight / 2) - abs($boxFull[1]); } } else { // Prevents fonts rendered outside the box boundary from being cut. // Example: 'Scriptina' font, some letters invade the space of the previous or subsequent letter. $yOffset -= $boxText[1]; } // Prevents fonts rendered outside the box boundary from being cut. // Example: 'Scriptina' font, some letters invade the space of the previous or subsequent letter. $xOffset -= $boxText[0]; // Determine position switch($anchor) { case 'top left': $x = $xOffset; $y = $yOffset + $boxHeight; break; case 'top right': $x = $this->getWidth() - $boxWidth + $xOffset; $y = $yOffset + $boxHeight; break; case 'top': $x = ($this->getWidth() / 2) - ($boxWidth / 2) + $xOffset; $y = $yOffset + $boxHeight; break; case 'bottom left': $x = $xOffset; $y = $this->getHeight() + $yOffset; break; case 'bottom right': $x = $this->getWidth() - $boxWidth + $xOffset; $y = $this->getHeight() + $yOffset; break; case 'bottom': $x = ($this->getWidth() / 2) - ($boxWidth / 2) + $xOffset; $y = $this->getHeight() + $yOffset; break; case 'left': $x = $xOffset; $y = ($this->getHeight() / 2) - (($boxHeight / 2) - $boxHeight) + $yOffset; break; case 'right': $x = $this->getWidth() - $boxWidth + $xOffset; $y = ($this->getHeight() / 2) - (($boxHeight / 2) - $boxHeight) + $yOffset; break; default: // center $x = ($this->getWidth() / 2) - ($boxWidth / 2) + $xOffset; $y = ($this->getHeight() / 2) - (($boxHeight / 2) - $boxHeight) + $yOffset; break; } $x = (int) round($x); $y = (int) round($y); // Pass the boundary back by reference $boundary = [ 'x1' => $x + $boxText[0], 'y1' => $y + $boxText[1] - $boxHeight, // $y is the baseline, not the top! 'x2' => $x + $boxWidth + $boxText[0], 'y2' => $y + $boxText[1], 'width' => $boxWidth, 'height' => $boxHeight, ]; // Text shadow if (is_array($options['shadow'])) { imagettftext( $this->image, $size, $angle, $x + $options['shadow']['x'], $y + $options['shadow']['y'], $this->allocateColor($options['shadow']['color']), $fontFile, $text ); } // Draw the text imagettftext($this->image, $size, $angle, $x, $y, $color, $fontFile, $text); return $this; } /** * Adds text with a line break to the image. * * @param string $text The desired text. * @param array $options * An array of options. * - fontFile* (string) - The TrueType (or compatible) font file to use. * - size (integer) - The size of the font in pixels (default 12). * - color (string|array) - The text color (default black). * - anchor (string) - The anchor point: 'center', 'top', 'bottom', 'left', 'right', 'top left', 'top right', 'bottom left', 'bottom right' (default 'center'). * - xOffset (integer) - The horizontal offset in pixels (default 0). Has no effect when anchor is 'center'. * - yOffset (integer) - The vertical offset in pixels (default 0). Has no effect when anchor is 'center'. * - shadow (array) - Text shadow params. * - x* (integer) - Horizontal offset in pixels. * - y* (integer) - Vertical offset in pixels. * - color* (string|array) - The text shadow color. * - $calculateOffsetFromEdge (bool) - Calculate offsets from the edge of the image (default false). * - width (int) - Width of text box (default image width). * - align (string) - How to align text: 'left', 'right', 'center', 'justify' (default 'left'). * - leading (float) - Increase/decrease spacing between lines of text (default 0). * - opacity (float) - The opacity level of the text 0-1 (default 1). * @return SimpleImage * * @throws Exception */ public function textBox(string $text, array $options): static { // default width of image $maxWidth = $this->getWidth(); // Default options $options = array_merge([ 'fontFile' => null, 'size' => 12, 'color' => 'black', 'anchor' => 'center', 'xOffset' => 0, 'yOffset' => 0, 'shadow' => null, 'calculateOffsetFromEdge' => false, 'width' => $maxWidth, 'align' => 'left', 'leading' => 0, 'opacity' => 1, ], $options); // Extract and normalize options $fontFile = $options['fontFile']; $fontSize = $fontSizePx = $options['size']; $fontSize = ($fontSize / 96) * 72; // Convert px to pt (72pt per inch, 96px per inch) $color = $options['color']; $anchor = $options['anchor']; $xOffset = $options['xOffset']; $yOffset = $options['yOffset']; $shadow = $options['shadow']; $calculateOffsetFromEdge = $options['calculateOffsetFromEdge']; $maxWidth = intval($options['width']); $leading = $options['leading']; $leading = self::keepWithin($leading, ($fontSizePx * -1), $leading); $opacity = $options['opacity']; $align = $options['align']; if ($align == 'right') { $align = 'top right'; } elseif ($align == 'center') { $align = 'top'; } elseif ($align == 'justify') { $align = 'justify'; } else { $align = 'top left'; } [$lines, $isLastLine, $lastLineHeight] = self::textSeparateLines($text, $fontFile, $fontSize, $maxWidth); $maxHeight = (int) round(((is_countable($lines) ? count($lines) : 0) - 1) * ($fontSizePx * 1.2 + $leading) + $lastLineHeight); $imageText = new SimpleImage(); $imageText->fromNew($maxWidth, $maxHeight); // Align left/center/right if ($align != 'justify') { foreach ($lines as $key => $line) { if ($align == 'top') { $line = trim($line); } // If is justify = 'center' $imageText->text($line, ['fontFile' => $fontFile, 'size' => $fontSizePx, 'color' => $color, 'anchor' => $align, 'xOffset' => 0, 'yOffset' => $key * ($fontSizePx * 1.2 + $leading), 'shadow' => $shadow, 'calculateOffsetFromEdge' => true]); } // Justify } else { foreach ($lines as $keyLine => $line) { // Check if there are spaces at the beginning of the sentence $spaces = 0; if (preg_match("/^\s+/", $line, $match)) { // Count spaces $spaces = strlen($match[0]); $line = ltrim($line); } // Separate words $words = preg_split("/\s+/", $line); // Include spaces with the first word $words[0] = str_repeat(' ', $spaces).$words[0]; // Calculates the space occupied by all words $wordsSize = []; foreach ($words as $key => $word) { $wordBox = imagettfbbox($fontSize, 0, $fontFile, $word); $wordWidth = abs($wordBox[4] - $wordBox[0]); $wordsSize[$key] = $wordWidth; } $wordsSizeTotal = array_sum($wordsSize); // Calculates the required space between words $countWords = count($words); $wordSpacing = 0; if ($countWords > 1) { $wordSpacing = ($maxWidth - $wordsSizeTotal) / ($countWords - 1); $wordSpacing = round($wordSpacing, 3); } $xOffsetJustify = 0; foreach ($words as $key => $word) { if ($isLastLine[$keyLine]) { if ($key < (count($words) - 1)) { continue; } $word = $line; } $imageText->text($word, ['fontFile' => $fontFile, 'size' => $fontSizePx, 'color' => $color, 'anchor' => 'top left', 'xOffset' => $xOffsetJustify, 'yOffset' => $keyLine * ($fontSizePx * 1.2 + $leading), 'shadow' => $shadow, 'calculateOffsetFromEdge' => true] ); // Calculate offset for next word $xOffsetJustify += $wordsSize[$key] + $wordSpacing; } } } $this->overlay($imageText, $anchor, $opacity, $xOffset, $yOffset, $calculateOffsetFromEdge); return $this; } /** * Receives a text and breaks into LINES. */ private function textSeparateLines(string $text, string $fontFile, float $fontSize, int $maxWidth): array { $lines = []; $words = self::textSeparateWords($text); $countWords = count($words) - 1; $lines[0] = ''; $lineKey = 0; $isLastLine = []; for ($i = 0; $i < $countWords; $i++) { $word = $words[$i]; $isLastLine[$lineKey] = false; if ($word === PHP_EOL) { $isLastLine[$lineKey] = true; $lineKey++; $lines[$lineKey] = ''; continue; } $lineBox = imagettfbbox($fontSize, 0, $fontFile, $lines[$lineKey].$word); if (abs($lineBox[4] - $lineBox[0]) < $maxWidth) { $lines[$lineKey] .= $word.' '; } else { $lineKey++; $lines[$lineKey] = $word.' '; } } $isLastLine[$lineKey] = true; // Exclude space of right $lines = array_map('rtrim', $lines); // Calculate height of last line $boxFull = imagettfbbox($fontSize, 0, $fontFile, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'); $lineBox = imagettfbbox($fontSize, 0, $fontFile, $lines[$lineKey]); // Height of last line = ascender of $boxFull + descender of $lineBox $lastLineHeight = abs($lineBox[1]) + abs($boxFull[5]); return [$lines, $isLastLine, $lastLineHeight]; } /** * Receives a text and breaks into WORD / SPACE / NEW LINE. */ private function textSeparateWords(string $text): array { // Normalizes line break $text = strval(preg_replace('/(\r\n|\n|\r)/', PHP_EOL, $text)); $text = explode(PHP_EOL, $text); $newText = []; foreach ($text as $line) { $newText = array_merge($newText, explode(' ', $line), [PHP_EOL]); } return $newText; } /** * Creates a thumbnail image. This function attempts to get the image as close to the provided * dimensions as possible, then crops the remaining overflow to force the desired size. Useful * for generating thumbnail images. * * @param int $width The thumbnail width. * @param int $height The thumbnail height. * @param string $anchor The anchor point: 'center', 'top', 'bottom', 'left', 'right', 'top left', 'top right', 'bottom left', 'bottom right' (default 'center'). * @return SimpleImage */ public function thumbnail(int $width, int $height, string $anchor = 'center'): SimpleImage|static { // Determine aspect ratios $currentRatio = $this->getHeight() / $this->getWidth(); $targetRatio = $height / $width; // Fit to height/width if ($targetRatio > $currentRatio) { $this->resize(null, $height); } else { $this->resize($width); } switch($anchor) { case 'top': $x1 = floor(($this->getWidth() / 2) - ($width / 2)); $x2 = $width + $x1; $y1 = 0; $y2 = $height; break; case 'bottom': $x1 = floor(($this->getWidth() / 2) - ($width / 2)); $x2 = $width + $x1; $y1 = $this->getHeight() - $height; $y2 = $this->getHeight(); break; case 'left': $x1 = 0; $x2 = $width; $y1 = floor(($this->getHeight() / 2) - ($height / 2)); $y2 = $height + $y1; break; case 'right': $x1 = $this->getWidth() - $width; $x2 = $this->getWidth(); $y1 = floor(($this->getHeight() / 2) - ($height / 2)); $y2 = $height + $y1; break; case 'top left': $x1 = 0; $x2 = $width; $y1 = 0; $y2 = $height; break; case 'top right': $x1 = $this->getWidth() - $width; $x2 = $this->getWidth(); $y1 = 0; $y2 = $height; break; case 'bottom left': $x1 = 0; $x2 = $width; $y1 = $this->getHeight() - $height; $y2 = $this->getHeight(); break; case 'bottom right': $x1 = $this->getWidth() - $width; $x2 = $this->getWidth(); $y1 = $this->getHeight() - $height; $y2 = $this->getHeight(); break; default: $x1 = floor(($this->getWidth() / 2) - ($width / 2)); $x2 = $width + $x1; $y1 = floor(($this->getHeight() / 2) - ($height / 2)); $y2 = $height + $y1; break; } // Return the cropped thumbnail image return $this->crop($x1, $y1, $x2, $y2); } ////////////////////////////////////////////////////////////////////////////////////////////////// // Drawing ////////////////////////////////////////////////////////////////////////////////////////////////// /** * Draws an arc. * * @param int $x The x coordinate of the arc's center. * @param int $y The y coordinate of the arc's center. * @param int $width The width of the arc. * @param int $height The height of the arc. * @param int $start The start of the arc in degrees. * @param int $end The end of the arc in degrees. * @param string|array $color The arc color. * @param int|string $thickness Line thickness in pixels or 'filled' (default 1). * @return SimpleImage * * @throws Exception */ public function arc(int $x, int $y, int $width, int $height, int $start, int $end, string|array $color, int|string $thickness = 1): static { // Allocate the color $tempColor = $this->allocateColor($color); imagesetthickness($this->image, 1); // Draw an arc if ($thickness === 'filled') { imagefilledarc($this->image, $x, $y, $width, $height, $start, $end, $tempColor, IMG_ARC_PIE); } elseif ($thickness === 1) { imagearc($this->image, $x, $y, $width, $height, $start, $end, $tempColor); } else { // New temp image $tempImage = new SimpleImage(); $tempImage->fromNew($this->getWidth(), $this->getHeight()); // Draw a large ellipse filled with $color (+$thickness pixels) $tempColor = $tempImage->allocateColor($color); imagefilledarc($tempImage->image, $x, $y, $width + $thickness, $height + $thickness, $start, $end, $tempColor, IMG_ARC_PIE); // Draw a smaller ellipse filled with red|blue (-$thickness pixels) $tempColor = (self::normalizeColor($color)['red'] == 255) ? 'blue' : 'red'; $tempColor = $tempImage->allocateColor($tempColor); imagefilledarc($tempImage->image, $x, $y, $width - $thickness, $height - $thickness, $start, $end, $tempColor, IMG_ARC_PIE); // Replace the color of the smaller ellipse with 'transparent' $tempImage->excludeInsideColor($x, $y, $color); // Apply the temp image $this->overlay($tempImage); } return $this; } /** * Draws a border around the image. * * @param string|array $color The border color. * @param int $thickness The thickness of the border (default 1). * @return SimpleImage * * @throws Exception */ public function border(string|array $color, int $thickness = 1): static { $x1 = -1; $y1 = 0; $x2 = $this->getWidth(); $y2 = $this->getHeight() - 1; $color = $this->allocateColor($color); imagesetthickness($this->image, $thickness * 2); imagerectangle($this->image, $x1, $y1, $x2, $y2, $color); return $this; } /** * Draws a single pixel dot. * * @param int $x The x coordinate of the dot. * @param int $y The y coordinate of the dot. * @param string|array $color The dot color. * @return SimpleImage * * @throws Exception */ public function dot(int $x, int $y, string|array $color): static { $color = $this->allocateColor($color); imagesetpixel($this->image, $x, $y, $color); return $this; } /** * Draws an ellipse. * * @param int $x The x coordinate of the center. * @param int $y The y coordinate of the center. * @param int $width The ellipse width. * @param int $height The ellipse height. * @param string|array $color The ellipse color. * @param string|int|array $thickness Line thickness in pixels or 'filled' (default 1). * @return SimpleImage * * @throws Exception */ public function ellipse(int $x, int $y, int $width, int $height, string|array $color, string|int|array $thickness = 1): static { // Allocate the color $tempColor = $this->allocateColor($color); imagesetthickness($this->image, 1); // Draw an ellipse if ($thickness == 'filled') { imagefilledellipse($this->image, $x, $y, $width, $height, $tempColor); } elseif ($thickness === 1) { imageellipse($this->image, $x, $y, $width, $height, $tempColor); } else { // New temp image $tempImage = new SimpleImage(); $tempImage->fromNew($this->getWidth(), $this->getHeight()); // Draw a large ellipse filled with $color (+$thickness pixels) $tempColor = $tempImage->allocateColor($color); imagefilledellipse($tempImage->image, $x, $y, $width + $thickness, $height + $thickness, $tempColor); // Draw a smaller ellipse filled with red|blue (-$thickness pixels) $tempColor = (self::normalizeColor($color)['red'] == 255) ? 'blue' : 'red'; $tempColor = $tempImage->allocateColor($tempColor); imagefilledellipse($tempImage->image, $x, $y, $width - $thickness, $height - $thickness, $tempColor); // Replace the color of the smaller ellipse with 'transparent' $tempImage->excludeInsideColor($x, $y, $color); // Apply the temp image $this->overlay($tempImage); } return $this; } /** * Fills the image with a solid color. * * @param string|array $color The fill color. * @return SimpleImage * * @throws Exception */ public function fill(string|array $color): static { // Draw a filled rectangle over the entire image $this->rectangle(0, 0, $this->getWidth(), $this->getHeight(), 'white', 'filled'); // Now flood it with the appropriate color $color = $this->allocateColor($color); imagefill($this->image, 0, 0, $color); return $this; } /** * Draws a line. * * @param int $x1 The x coordinate for the first point. * @param int $y1 The y coordinate for the first point. * @param int $x2 The x coordinate for the second point. * @param int $y2 The y coordinate for the second point. * @param string|array $color The line color. * @param int $thickness The line thickness (default 1). * @return SimpleImage * * @throws Exception */ public function line(int $x1, int $y1, int $x2, int $y2, string|array $color, int $thickness = 1): static { // Allocate the color $color = $this->allocateColor($color); // Draw a line imagesetthickness($this->image, $thickness); imageline($this->image, $x1, $y1, $x2, $y2, $color); return $this; } /** * Draws a polygon. * * @param array $vertices * The polygon's vertices in an array of x/y arrays. * Example: * [ * ['x' => x1, 'y' => y1], * ['x' => x2, 'y' => y2], * ['x' => xN, 'y' => yN] * ] * @param string|array $color The polygon color. * @param string|int|array $thickness Line thickness in pixels or 'filled' (default 1). * @return SimpleImage * * @throws Exception */ public function polygon(array $vertices, string|array $color, string|int|array $thickness = 1): static { // Allocate the color $color = $this->allocateColor($color); // Convert [['x' => x1, 'y' => x1], ['x' => x1, 'y' => y2], ...] to [x1, y1, x2, y2, ...] $points = []; foreach ($vertices as $vals) { $points[] = $vals['x']; $points[] = $vals['y']; } // Draw a polygon if ($thickness == 'filled') { imagesetthickness($this->image, 1); imagefilledpolygon($this->image, $points, count($vertices), $color); } else { imagesetthickness($this->image, $thickness); imagepolygon($this->image, $points, count($vertices), $color); } return $this; } /** * Draws a rectangle. * * @param int $x1 The upper left x coordinate. * @param int $y1 The upper left y coordinate. * @param int $x2 The bottom right x coordinate. * @param int $y2 The bottom right y coordinate. * @param string|array $color The rectangle color. * @param string|int|array $thickness Line thickness in pixels or 'filled' (default 1). * @return SimpleImage * * @throws Exception */ public function rectangle(int $x1, int $y1, int $x2, int $y2, string|array $color, string|int|array $thickness = 1): static { // Allocate the color $color = $this->allocateColor($color); // Draw a rectangle if ($thickness == 'filled') { imagesetthickness($this->image, 1); imagefilledrectangle($this->image, $x1, $y1, $x2, $y2, $color); } else { imagesetthickness($this->image, $thickness); imagerectangle($this->image, $x1, $y1, $x2, $y2, $color); } return $this; } /** * Draws a rounded rectangle. * * @param int $x1 The upper left x coordinate. * @param int $y1 The upper left y coordinate. * @param int $x2 The bottom right x coordinate. * @param int $y2 The bottom right y coordinate. * @param int $radius The border radius in pixels. * @param string|array $color The rectangle color. * @param string|int|array $thickness Line thickness in pixels or 'filled' (default 1). * @return SimpleImage * * @throws Exception */ public function roundedRectangle(int $x1, int $y1, int $x2, int $y2, int $radius, string|array $color, string|int|array $thickness = 1): static { if ($thickness == 'filled') { // Draw the filled rectangle without edges $this->rectangle($x1 + $radius + 1, $y1, $x2 - $radius - 1, $y2, $color, 'filled'); $this->rectangle($x1, $y1 + $radius + 1, $x1 + $radius, $y2 - $radius - 1, $color, 'filled'); $this->rectangle($x2 - $radius, $y1 + $radius + 1, $x2, $y2 - $radius - 1, $color, 'filled'); // Fill in the edges with arcs $this->arc($x1 + $radius, $y1 + $radius, $radius * 2, $radius * 2, 180, 270, $color, 'filled'); $this->arc($x2 - $radius, $y1 + $radius, $radius * 2, $radius * 2, 270, 360, $color, 'filled'); $this->arc($x1 + $radius, $y2 - $radius, $radius * 2, $radius * 2, 90, 180, $color, 'filled'); $this->arc($x2 - $radius, $y2 - $radius, $radius * 2, $radius * 2, 360, 90, $color, 'filled'); } else { $offset = $thickness / 2; $x1 -= $offset; $x2 += $offset; $y1 -= $offset; $y2 += $offset; $radius = self::keepWithin($radius, 0, min(($x2 - $x1) / 2, ($y2 - $y1) / 2) - 1); $radius = (int) floor($radius); $thickness = self::keepWithin($thickness, 1, min(($x2 - $x1) / 2, ($y2 - $y1) / 2)); // New temp image $tempImage = new SimpleImage(); $tempImage->fromNew($this->getWidth(), $this->getHeight()); // Draw a large rectangle filled with $color $tempImage->roundedRectangle($x1, $y1, $x2, $y2, $radius, $color, 'filled'); // Draw a smaller rectangle filled with red|blue (-$thickness pixels on each side) $tempColor = (self::normalizeColor($color)['red'] == 255) ? 'blue' : 'red'; $radius = $radius - $thickness; $radius = self::keepWithin($radius, 0, $radius); $tempImage->roundedRectangle( $x1 + $thickness, $y1 + $thickness, $x2 - $thickness, $y2 - $thickness, $radius, $tempColor, 'filled' ); // Replace the color of the smaller rectangle with 'transparent' $tempImage->excludeInsideColor(($x2 + $x1) / 2, ($y2 + $y1) / 2, $color); // Apply the temp image $this->overlay($tempImage); } return $this; } /** * Exclude inside color. * Used for roundedRectangle(), ellipse() and arc() * * @param int $x certer x of rectangle. * @param int $y certer y of rectangle. * @param string|array $borderColor The color of border. * * @throws Exception */ private function excludeInsideColor(int $x, int $y, string|array $borderColor): static { $borderColor = $this->allocateColor($borderColor); $transparent = $this->allocateColor('transparent'); imagefilltoborder($this->image, $x, $y, $borderColor, $transparent); return $this; } ////////////////////////////////////////////////////////////////////////////////////////////////// // Filters ////////////////////////////////////////////////////////////////////////////////////////////////// /** * Applies the blur filter. * * @param string $type The blur algorithm to use: 'selective', 'gaussian' (default 'gaussian'). * @param int $passes The number of time to apply the filter, enhancing the effect (default 1). * @return SimpleImage */ public function blur(string $type = 'selective', int $passes = 1): static { $filter = $type === 'gaussian' ? IMG_FILTER_GAUSSIAN_BLUR : IMG_FILTER_SELECTIVE_BLUR; for ($i = 0; $i < $passes; $i++) { imagefilter($this->image, $filter); } return $this; } /** * Applies the brightness filter to brighten the image. * * @param int $percentage Percentage to brighten the image (0 - 100). * @return SimpleImage */ public function brighten(int $percentage): static { $percentage = self::keepWithin(255 * $percentage / 100, 0, 255); imagefilter($this->image, IMG_FILTER_BRIGHTNESS, $percentage); return $this; } /** * Applies the colorize filter. * * @param string|array $color The filter color. * @return SimpleImage * * @throws Exception */ public function colorize(string|array $color): static { $color = self::normalizeColor($color); imagefilter( $this->image, IMG_FILTER_COLORIZE, $color['red'], $color['green'], $color['blue'], 127 - ($color['alpha'] * 127) ); return $this; } /** * Applies the contrast filter. * * @param int $percentage Percentage to adjust (-100 - 100). * @return SimpleImage */ public function contrast(int $percentage): static { imagefilter($this->image, IMG_FILTER_CONTRAST, self::keepWithin($percentage, -100, 100)); return $this; } /** * Applies the brightness filter to darken the image. * * @param int $percentage Percentage to darken the image (0 - 100). * @return SimpleImage */ public function darken(int $percentage): static { $percentage = self::keepWithin(255 * $percentage / 100, 0, 255); imagefilter($this->image, IMG_FILTER_BRIGHTNESS, -$percentage); return $this; } /** * Applies the desaturate (grayscale) filter. * * @return SimpleImage */ public function desaturate(): static { imagefilter($this->image, IMG_FILTER_GRAYSCALE); return $this; } /** * Applies the edge detect filter. * * @return SimpleImage */ public function edgeDetect(): static { imagefilter($this->image, IMG_FILTER_EDGEDETECT); return $this; } /** * Applies the emboss filter. * * @return SimpleImage */ public function emboss(): static { imagefilter($this->image, IMG_FILTER_EMBOSS); return $this; } /** * Inverts the image's colors. * * @return SimpleImage */ public function invert(): static { imagefilter($this->image, IMG_FILTER_NEGATE); return $this; } /** * Changes the image's opacity level. * * @param float $opacity The desired opacity level (0 - 1). * @return SimpleImage * * @throws Exception */ public function opacity(float $opacity): static { // Create a transparent image $newImage = new SimpleImage(); $newImage->fromNew($this->getWidth(), $this->getHeight()); // Copy the current image (with opacity) onto the transparent image self::imageCopyMergeAlpha( $newImage->image, $this->image, 0, 0, 0, 0, $this->getWidth(), $this->getHeight(), (int) round(self::keepWithin($opacity, 0, 1) * 100) ); return $this; } /** * Applies the pixelate filter. * * @param int $size The size of the blocks in pixels (default 10). * @return SimpleImage */ public function pixelate(int $size = 10): static { imagefilter($this->image, IMG_FILTER_PIXELATE, $size, true); return $this; } /** * Simulates a sepia effect by desaturating the image and applying a sepia tone. * * @return SimpleImage */ public function sepia(): static { imagefilter($this->image, IMG_FILTER_GRAYSCALE); imagefilter($this->image, IMG_FILTER_COLORIZE, 70, 35, 0); return $this; } /** * Sharpens the image. * * @param int $amount Sharpening amount (default 50). * @return SimpleImage */ public function sharpen(int $amount = 50): static { // Normalize amount $amount = max(1, min(100, $amount)) / 100; $sharpen = [ [-1, -1, -1], [-1, 8 / $amount, -1], [-1, -1, -1], ]; $divisor = array_sum(array_map('array_sum', $sharpen)); imageconvolution($this->image, $sharpen, $divisor, 0); return $this; } /** * Applies the mean remove filter to produce a sketch effect. * * @return SimpleImage */ public function sketch(): static { imagefilter($this->image, IMG_FILTER_MEAN_REMOVAL); return $this; } ////////////////////////////////////////////////////////////////////////////////////////////////// // Color utilities ////////////////////////////////////////////////////////////////////////////////////////////////// /** * Converts a "friendly color" into a color identifier for use with GD's image functions. * * @param string|array $color The color to allocate. * * @throws Exception */ protected function allocateColor(string|array $color): int { $color = self::normalizeColor($color); // Was this color already allocated? $index = imagecolorexactalpha( $this->image, $color['red'], $color['green'], $color['blue'], (int) (127 - ($color['alpha'] * 127)) ); if ($index > -1) { // Yes, return this color index return $index; } // Allocate a new color index return imagecolorallocatealpha( $this->image, $color['red'], $color['green'], $color['blue'], 127 - ($color['alpha'] * 127) ); } /** * Adjusts a color by increasing/decreasing red/green/blue/alpha values independently. * * @param string|array $color The color to adjust. * @param int $red Red adjustment (-255 - 255). * @param int $green Green adjustment (-255 - 255). * @param int $blue Blue adjustment (-255 - 255). * @param int $alpha Alpha adjustment (-1 - 1). * @return int[] An RGBA color array. * * @throws Exception */ public static function adjustColor(string|array $color, int $red, int $green, int $blue, int $alpha): array { // Normalize to RGBA $color = self::normalizeColor($color); // Adjust each channel return self::normalizeColor([ 'red' => $color['red'] + $red, 'green' => $color['green'] + $green, 'blue' => $color['blue'] + $blue, 'alpha' => $color['alpha'] + $alpha, ]); } /** * Darkens a color. * * @param string|array $color The color to darken. * @param int $amount Amount to darken (0 - 255). * @return int[] An RGBA color array. * * @throws Exception */ public static function darkenColor(string|array $color, int $amount): array { return self::adjustColor($color, -$amount, -$amount, -$amount, 0); } /** * Extracts colors from an image like a human would do.™ This method requires the third-party * library \League\ColorExtractor. If you're using Composer, it will be installed for you * automatically. * * @param int $count The max number of colors to extract (default 5). * @param string|array|null $backgroundColor * By default any pixel with alpha value greater than zero will * be discarded. This is because transparent colors are not perceived as is. For example, fully * transparent black would be seen white on a white background. So if you want to take * transparency into account, you have to specify a default background color. * @return int[] An array of RGBA colors arrays. * * @throws Exception Thrown if library \League\ColorExtractor is missing. */ public function extractColors(int $count = 5, string|array|null $backgroundColor = null): array { // Check for required library if (! class_exists('\\'.ColorExtractor::class)) { throw new Exception( 'Required library \League\ColorExtractor is missing.', self::ERR_LIB_NOT_LOADED ); } // Convert background color to an integer value if ($backgroundColor) { $backgroundColor = self::normalizeColor($backgroundColor); $backgroundColor = Color::fromRgbToInt([ 'r' => $backgroundColor['red'], 'g' => $backgroundColor['green'], 'b' => $backgroundColor['blue'], ]); } // Extract colors from the image $palette = Palette::fromGD($this->image, $backgroundColor); $extractor = new ColorExtractor($palette); $colors = $extractor->extract($count); // Convert colors to an RGBA color array foreach ($colors as $key => $value) { $colors[$key] = self::normalizeColor(Color::fromIntToHex($value)); } return $colors; } /** * Gets the RGBA value of a single pixel. * * @param int $x The horizontal position of the pixel. * @param int $y The vertical position of the pixel. * @return bool|int[] An RGBA color array or false if the x/y position is off the canvas. */ public function getColorAt(int $x, int $y): array|bool { // Coordinates must be on the canvas if ($x < 0 || $x > $this->getWidth() || $y < 0 || $y > $this->getHeight()) { return false; } // Get the color of this pixel and convert it to RGBA $color = imagecolorat($this->image, $x, $y); $rgba = imagecolorsforindex($this->image, $color); $rgba['alpha'] = 127 - ($color >> 24) & 0xFF; return $rgba; } /** * Lightens a color. * * @param string|array $color The color to lighten. * @param int $amount Amount to lighten (0 - 255). * @return int[] An RGBA color array. * * @throws Exception */ public static function lightenColor(string|array $color, int $amount): array { return self::adjustColor($color, $amount, $amount, $amount, 0); } /** * Normalizes a hex or array color value to a well-formatted RGBA array. * * @param string|array $color * A CSS color name, hex string, or an array [red, green, blue, alpha]. * You can pipe alpha transparency through hex strings and color names. For example: * #fff|0.50 <-- 50% white * red|0.25 <-- 25% red * @return array [red, green, blue, alpha]. * * @throws Exception Thrown if color value is invalid. */ public static function normalizeColor(string|array $color): array { // 140 CSS color names and hex values $cssColors = [ 'aliceblue' => '#f0f8ff', 'antiquewhite' => '#faebd7', 'aqua' => '#00ffff', 'aquamarine' => '#7fffd4', 'azure' => '#f0ffff', 'beige' => '#f5f5dc', 'bisque' => '#ffe4c4', 'black' => '#000000', 'blanchedalmond' => '#ffebcd', 'blue' => '#0000ff', 'blueviolet' => '#8a2be2', 'brown' => '#a52a2a', 'burlywood' => '#deb887', 'cadetblue' => '#5f9ea0', 'chartreuse' => '#7fff00', 'chocolate' => '#d2691e', 'coral' => '#ff7f50', 'cornflowerblue' => '#6495ed', 'cornsilk' => '#fff8dc', 'crimson' => '#dc143c', 'cyan' => '#00ffff', 'darkblue' => '#00008b', 'darkcyan' => '#008b8b', 'darkgoldenrod' => '#b8860b', 'darkgray' => '#a9a9a9', 'darkgrey' => '#a9a9a9', 'darkgreen' => '#006400', 'darkkhaki' => '#bdb76b', 'darkmagenta' => '#8b008b', 'darkolivegreen' => '#556b2f', 'darkorange' => '#ff8c00', 'darkorchid' => '#9932cc', 'darkred' => '#8b0000', 'darksalmon' => '#e9967a', 'darkseagreen' => '#8fbc8f', 'darkslateblue' => '#483d8b', 'darkslategray' => '#2f4f4f', 'darkslategrey' => '#2f4f4f', 'darkturquoise' => '#00ced1', 'darkviolet' => '#9400d3', 'deeppink' => '#ff1493', 'deepskyblue' => '#00bfff', 'dimgray' => '#696969', 'dimgrey' => '#696969', 'dodgerblue' => '#1e90ff', 'firebrick' => '#b22222', 'floralwhite' => '#fffaf0', 'forestgreen' => '#228b22', 'fuchsia' => '#ff00ff', 'gainsboro' => '#dcdcdc', 'ghostwhite' => '#f8f8ff', 'gold' => '#ffd700', 'goldenrod' => '#daa520', 'gray' => '#808080', 'grey' => '#808080', 'green' => '#008000', 'greenyellow' => '#adff2f', 'honeydew' => '#f0fff0', 'hotpink' => '#ff69b4', 'indianred ' => '#cd5c5c', 'indigo ' => '#4b0082', 'ivory' => '#fffff0', 'khaki' => '#f0e68c', 'lavender' => '#e6e6fa', 'lavenderblush' => '#fff0f5', 'lawngreen' => '#7cfc00', 'lemonchiffon' => '#fffacd', 'lightblue' => '#add8e6', 'lightcoral' => '#f08080', 'lightcyan' => '#e0ffff', 'lightgoldenrodyellow' => '#fafad2', 'lightgray' => '#d3d3d3', 'lightgrey' => '#d3d3d3', 'lightgreen' => '#90ee90', 'lightpink' => '#ffb6c1', 'lightsalmon' => '#ffa07a', 'lightseagreen' => '#20b2aa', 'lightskyblue' => '#87cefa', 'lightslategray' => '#778899', 'lightslategrey' => '#778899', 'lightsteelblue' => '#b0c4de', 'lightyellow' => '#ffffe0', 'lime' => '#00ff00', 'limegreen' => '#32cd32', 'linen' => '#faf0e6', 'magenta' => '#ff00ff', 'maroon' => '#800000', 'mediumaquamarine' => '#66cdaa', 'mediumblue' => '#0000cd', 'mediumorchid' => '#ba55d3', 'mediumpurple' => '#9370db', 'mediumseagreen' => '#3cb371', 'mediumslateblue' => '#7b68ee', 'mediumspringgreen' => '#00fa9a', 'mediumturquoise' => '#48d1cc', 'mediumvioletred' => '#c71585', 'midnightblue' => '#191970', 'mintcream' => '#f5fffa', 'mistyrose' => '#ffe4e1', 'moccasin' => '#ffe4b5', 'navajowhite' => '#ffdead', 'navy' => '#000080', 'oldlace' => '#fdf5e6', 'olive' => '#808000', 'olivedrab' => '#6b8e23', 'orange' => '#ffa500', 'orangered' => '#ff4500', 'orchid' => '#da70d6', 'palegoldenrod' => '#eee8aa', 'palegreen' => '#98fb98', 'paleturquoise' => '#afeeee', 'palevioletred' => '#db7093', 'papayawhip' => '#ffefd5', 'peachpuff' => '#ffdab9', 'peru' => '#cd853f', 'pink' => '#ffc0cb', 'plum' => '#dda0dd', 'powderblue' => '#b0e0e6', 'purple' => '#800080', 'rebeccapurple' => '#663399', 'red' => '#ff0000', 'rosybrown' => '#bc8f8f', 'royalblue' => '#4169e1', 'saddlebrown' => '#8b4513', 'salmon' => '#fa8072', 'sandybrown' => '#f4a460', 'seagreen' => '#2e8b57', 'seashell' => '#fff5ee', 'sienna' => '#a0522d', 'silver' => '#c0c0c0', 'skyblue' => '#87ceeb', 'slateblue' => '#6a5acd', 'slategray' => '#708090', 'slategrey' => '#708090', 'snow' => '#fffafa', 'springgreen' => '#00ff7f', 'steelblue' => '#4682b4', 'tan' => '#d2b48c', 'teal' => '#008080', 'thistle' => '#d8bfd8', 'tomato' => '#ff6347', 'turquoise' => '#40e0d0', 'violet' => '#ee82ee', 'wheat' => '#f5deb3', 'white' => '#ffffff', 'whitesmoke' => '#f5f5f5', 'yellow' => '#ffff00', 'yellowgreen' => '#9acd32', ]; // Parse alpha from '#fff|.5' and 'white|.5' if (is_string($color) && strstr($color, '|')) { $color = explode('|', $color); $alpha = (float) $color[1]; $color = trim($color[0]); } else { $alpha = 1; } // Translate CSS color names to hex values if (is_string($color) && array_key_exists(strtolower($color), $cssColors)) { $color = $cssColors[strtolower($color)]; } // Translate transparent keyword to a transparent color if ($color === 'transparent') { $color = ['red' => 0, 'green' => 0, 'blue' => 0, 'alpha' => 0]; } // Convert hex values to RGBA if (is_string($color)) { // Remove # $hex = strval(preg_replace('/^#/', '', $color)); // Support short and standard hex codes if (strlen($hex) === 3 || strlen($hex) === 4) { [$red, $green, $blue] = [ $hex[0].$hex[0], $hex[1].$hex[1], $hex[2].$hex[2], ]; if (strlen($hex) === 4) { $alpha = hexdec($hex[3]) / 255; } } elseif (strlen($hex) === 6 || strlen($hex) === 8) { [$red, $green, $blue] = [ $hex[0].$hex[1], $hex[2].$hex[3], $hex[4].$hex[5], ]; if (strlen($hex) === 8) { $alpha = hexdec($hex[6].$hex[7]) / 255; } } else { throw new Exception("Invalid color value: $color", self::ERR_INVALID_COLOR); } // Turn color into an array $color = [ 'red' => hexdec($red), 'green' => hexdec($green), 'blue' => hexdec($blue), 'alpha' => $alpha, ]; } // Enforce color value ranges if (is_array($color)) { // RGB default to 0 $color['red'] ??= 0; $color['green'] ??= 0; $color['blue'] ??= 0; // Alpha defaults to 1 $color['alpha'] ??= 1; return [ 'red' => (int) self::keepWithin((int) $color['red'], 0, 255), 'green' => (int) self::keepWithin((int) $color['green'], 0, 255), 'blue' => (int) self::keepWithin((int) $color['blue'], 0, 255), 'alpha' => self::keepWithin($color['alpha'], 0, 1), ]; } throw new Exception("Invalid color value: $color", self::ERR_INVALID_COLOR); } }