* * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ namespace CodeIgniter\Images\Handlers; use CodeIgniter\Images\Exceptions\ImageException; use Config\Images; use Imagick; use ImagickDraw; use ImagickDrawException; use ImagickException; use ImagickPixel; use ImagickPixelException; /** * Image handler for Imagick extension. */ class ImageMagickHandler extends BaseHandler { /** * Stores Imagick instance. * * @var Imagick|null */ protected $resource; /** * Constructor. * * @param Images $config * * @throws ImageException */ public function __construct($config = null) { parent::__construct($config); if (! extension_loaded('imagick')) { throw ImageException::forMissingExtension('IMAGICK'); // @codeCoverageIgnore } } /** * Loads the image for manipulation. * * @return void * * @throws ImageException */ protected function ensureResource() { if (! $this->resource instanceof Imagick) { // Verify that we have a valid image $this->image(); try { $this->resource = new Imagick(); $this->resource->readImage($this->image()->getPathname()); // Check for valid image if ($this->resource->getImageWidth() === 0 || $this->resource->getImageHeight() === 0) { throw ImageException::forInvalidImageCreate($this->image()->getPathname()); } $this->supportedFormatCheck(); } catch (ImagickException $e) { throw ImageException::forInvalidImageCreate($e->getMessage()); } } } /** * Handles all the grunt work of resizing, etc. * * @param string $action Type of action to perform * @param int $quality Quality setting for Imagick operations * * @return $this * * @throws ImageException */ protected function process(string $action, int $quality = 100) { $this->image(); $this->ensureResource(); try { switch ($action) { case 'resize': $this->resource->resizeImage( $this->width, $this->height, Imagick::FILTER_LANCZOS, 0, ); break; case 'crop': $width = $this->width; $height = $this->height; $xAxis = $this->xAxis ?? 0; $yAxis = $this->yAxis ?? 0; $this->resource->cropImage( $width, $height, $xAxis, $yAxis, ); // Reset canvas to cropped size $this->resource->setImagePage(0, 0, 0, 0); break; } // Handle transparency for supported image types if (in_array($this->image()->imageType, $this->supportTransparency, true) && $this->resource->getImageAlphaChannel() === Imagick::ALPHACHANNEL_UNDEFINED) { $this->resource->setImageAlphaChannel(Imagick::ALPHACHANNEL_OPAQUE); } } catch (ImagickException) { throw ImageException::forImageProcessFailed(); } return $this; } /** * Handles the actual resizing of the image. * * @return ImageMagickHandler * * @throws ImagickException */ public function _resize(bool $maintainRatio = false) { if ($maintainRatio) { // If maintaining a ratio, we need a custom approach $this->ensureResource(); // Use thumbnailImage which preserves an aspect ratio $this->resource->thumbnailImage($this->width, $this->height, true); return $this; } // Use the common process() method for normal resizing return $this->process('resize'); } /** * Crops the image. * * @return $this * * @throws ImagickException */ public function _crop() { // Use the common process() method for cropping $result = $this->process('crop'); // Handle a case where crop dimensions exceed the original image size if ($this->resource instanceof Imagick) { $imgWidth = $this->resource->getImageWidth(); $imgHeight = $this->resource->getImageHeight(); if ($this->xAxis >= $imgWidth || $this->yAxis >= $imgHeight) { // Create transparent background $background = new Imagick(); $background->newImage($this->width, $this->height, new ImagickPixel('transparent')); $background->setImageFormat($this->resource->getImageFormat()); // Composite our image on the background $background->compositeImage($this->resource, Imagick::COMPOSITE_OVER, 0, 0); // Replace our resource $this->resource = $background; } } return $result; } /** * Handles the rotation of an image resource. * Doesn't save the image, but replaces the current resource. * * @return $this * * @throws ImagickException */ protected function _rotate(int $angle) { $this->ensureResource(); // Create transparent background $this->resource->setImageBackgroundColor(new ImagickPixel('transparent')); $this->resource->rotateImage(new ImagickPixel('transparent'), $angle); // Reset canvas dimensions $this->resource->setImagePage($this->resource->getImageWidth(), $this->resource->getImageHeight(), 0, 0); return $this; } /** * Flattens transparencies, default white background * * @return $this * * @throws ImagickException|ImagickPixelException */ protected function _flatten(int $red = 255, int $green = 255, int $blue = 255) { $this->ensureResource(); // Create background $bg = new ImagickPixel("rgb({$red},{$green},{$blue})"); // Create a new canvas with the background color $canvas = new Imagick(); $canvas->newImage( $this->resource->getImageWidth(), $this->resource->getImageHeight(), $bg, $this->resource->getImageFormat(), ); // Composite our image on the background $canvas->compositeImage( $this->resource, Imagick::COMPOSITE_OVER, 0, 0, ); // Replace our resource with the flattened version $this->resource->clear(); $this->resource = $canvas; return $this; } /** * Flips an image along its vertical or horizontal axis. * * @return $this * * @throws ImagickException */ protected function _flip(string $direction) { $this->ensureResource(); if ($direction === 'horizontal') { $this->resource->flopImage(); } else { $this->resource->flipImage(); } return $this; } /** * Get a driver version * * @return string */ public function getVersion() { $version = Imagick::getVersion(); if (preg_match('/ImageMagick\s+(\d+\.\d+\.\d+)/', $version['versionString'], $matches)) { return $matches[1]; } return ''; } /** * Check if a given image format is supported * * @return void * * @throws ImageException */ protected function supportedFormatCheck() { if (! $this->resource instanceof Imagick) { return; } if ($this->image()->imageType === IMAGETYPE_WEBP && ! in_array('WEBP', Imagick::queryFormats(), true)) { throw ImageException::forInvalidImageCreate(lang('images.webpNotSupported')); } } /** * Saves any changes that have been made to the file. If no new filename is * provided, the existing image is overwritten; otherwise a copy of the * file is made at $target. * * Example: * $image->resize(100, 200, true) * ->save(); * * @param non-empty-string|null $target * * @throws ImagickException */ public function save(?string $target = null, int $quality = 90): bool { $original = $target; $target = ($target === null || $target === '') ? $this->image()->getPathname() : $target; // If no new resource has been created, then we're // simply copy the existing one. if (! $this->resource instanceof Imagick && $quality === 100) { if ($original === null) { return true; } $name = basename($target); $path = pathinfo($target, PATHINFO_DIRNAME); return $this->image()->copy($path, $name); } $this->ensureResource(); $this->resource->setImageCompressionQuality($quality); if ($target !== null) { $extension = pathinfo($target, PATHINFO_EXTENSION); $this->resource->setImageFormat($extension); } try { $result = $this->resource->writeImage($target); chmod($target, $this->filePermissions); $this->resource->clear(); $this->resource = null; return $result; } catch (ImagickException) { throw ImageException::forSaveFailed(); } } /** * Handler-specific method for overlaying text on an image. * * @throws ImagickDrawException|ImagickException|ImagickPixelException */ protected function _text(string $text, array $options = []) { $this->ensureResource(); $draw = new ImagickDraw(); if (isset($options['fontPath'])) { $draw->setFont($options['fontPath']); } if (isset($options['fontSize'])) { $draw->setFontSize($options['fontSize']); } if (isset($options['color'])) { $color = $options['color']; // Shorthand hex, #f00 if (strlen($color) === 3) { $color = implode('', array_map(str_repeat(...), str_split($color), [2, 2, 2])); } [$r, $g, $b] = sscanf("#{$color}", '#%02x%02x%02x'); $opacity = $options['opacity'] ?? 1.0; $draw->setFillColor(new ImagickPixel("rgba({$r},{$g},{$b},{$opacity})")); } // Calculate text positioning $imgWidth = $this->resource->getImageWidth(); $imgHeight = $this->resource->getImageHeight(); $xAxis = 0; $yAxis = 0; // Default padding $padding = $options['padding'] ?? 0; if (isset($options['hAlign'])) { $hOffset = $options['hOffset'] ?? 0; switch ($options['hAlign']) { case 'left': $xAxis = $hOffset + $padding; $draw->setTextAlignment(Imagick::ALIGN_LEFT); break; case 'center': $xAxis = $imgWidth / 2 + $hOffset; $draw->setTextAlignment(Imagick::ALIGN_CENTER); break; case 'right': $xAxis = $imgWidth - $hOffset - $padding; $draw->setTextAlignment(Imagick::ALIGN_RIGHT); break; } } if (isset($options['vAlign'])) { $vOffset = $options['vOffset'] ?? 0; switch ($options['vAlign']) { case 'top': $yAxis = $vOffset + $padding + ($options['fontSize'] ?? 16); break; case 'middle': $yAxis = $imgHeight / 2 + $vOffset; break; case 'bottom': // Note: Vertical offset is inverted for bottom alignment as per original implementation $yAxis = $vOffset < 0 ? $imgHeight + $vOffset - $padding : $imgHeight - $vOffset - $padding; break; } } if (isset($options['withShadow'])) { $shadow = clone $draw; if (isset($options['shadowColor'])) { $shadowColor = $options['shadowColor']; // Shorthand hex, #f00 if (strlen($shadowColor) === 3) { $shadowColor = implode('', array_map(str_repeat(...), str_split($shadowColor), [2, 2, 2])); } [$sr, $sg, $sb] = sscanf("#{$shadowColor}", '#%02x%02x%02x'); $shadow->setFillColor(new ImagickPixel("rgb({$sr},{$sg},{$sb})")); } else { $shadow->setFillColor(new ImagickPixel('rgba(0,0,0,0.5)')); } $offset = $options['shadowOffset'] ?? 3; $this->resource->annotateImage( $shadow, $xAxis + $offset, $yAxis + $offset, 0, $text, ); } // Draw the main text $this->resource->annotateImage( $draw, $xAxis, $yAxis, 0, $text, ); } /** * Return the width of an image. * * @return int * * @throws ImagickException */ public function _getWidth() { $this->ensureResource(); return $this->resource->getImageWidth(); } /** * Return the height of an image. * * @return int * * @throws ImagickException */ public function _getHeight() { $this->ensureResource(); return $this->resource->getImageHeight(); } /** * Reads the EXIF information from the image and modifies the orientation * so that displays correctly in the browser. This is especially an issue * with images taken by smartphones who always store the image up-right, * but set the orientation flag to display it correctly. * * @param bool $silent If true, will ignore exceptions when PHP doesn't support EXIF. * * @return $this */ public function reorient(bool $silent = false) { $orientation = $this->getEXIF('Orientation', $silent); return match ($orientation) { 2 => $this->flip('horizontal'), 3 => $this->rotate(180), 4 => $this->rotate(180)->flip('horizontal'), 5 => $this->rotate(90)->flip('horizontal'), 6 => $this->rotate(90), 7 => $this->rotate(270)->flip('horizontal'), 8 => $this->rotate(270), default => $this, }; } /** * Clears metadata from the image. * * @return $this * * @throws ImagickException */ public function clearMetadata(): static { $this->ensureResource(); $this->resource->stripImage(); return $this; } }