553 lines
15 KiB
PHP
553 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* This file is part of CodeIgniter 4 framework.
|
|
*
|
|
* (c) CodeIgniter Foundation <admin@codeigniter.com>
|
|
*
|
|
* 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;
|
|
}
|
|
}
|