You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

482 lines
14 KiB

<?php
/**
* 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\I18n\Time;
use CodeIgniter\Images\Exceptions\ImageException;
use Config\Images;
use Exception;
use Imagick;
/**
* Class ImageMagickHandler
*
* FIXME - This needs conversion & unit testing, to use the imagick extension
*/
class ImageMagickHandler extends BaseHandler
{
/**
* Stores image resource in memory.
*
* @var string|null
*/
protected $resource;
/**
* @param Images $config
*
* @throws ImageException
*/
public function __construct($config = null)
{
parent::__construct($config);
if (! (extension_loaded('imagick') || class_exists(Imagick::class))) {
throw ImageException::forMissingExtension('IMAGICK'); // @codeCoverageIgnore
}
$cmd = $this->config->libraryPath;
if ($cmd === '') {
throw ImageException::forInvalidImageLibraryPath($cmd);
}
if (preg_match('/convert$/i', $cmd) !== 1) {
$cmd = rtrim($cmd, '\/') . '/convert';
$this->config->libraryPath = $cmd;
}
if (! is_file($cmd)) {
throw ImageException::forInvalidImageLibraryPath($cmd);
}
}
/**
* Handles the actual resizing of the image.
*
* @return ImageMagickHandler
*
* @throws Exception
*/
public function _resize(bool $maintainRatio = false)
{
$source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname();
$destination = $this->getResourcePath();
$escape = '\\';
if (PHP_OS_FAMILY === 'Windows') {
$escape = '';
}
$action = $maintainRatio === true
? ' -resize ' . ($this->width ?? 0) . 'x' . ($this->height ?? 0) . ' "' . $source . '" "' . $destination . '"'
: ' -resize ' . ($this->width ?? 0) . 'x' . ($this->height ?? 0) . "{$escape}! \"" . $source . '" "' . $destination . '"';
$this->process($action);
return $this;
}
/**
* Crops the image.
*
* @return bool|ImageMagickHandler
*
* @throws Exception
*/
public function _crop()
{
$source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname();
$destination = $this->getResourcePath();
$extent = ' ';
if ($this->xAxis >= $this->width || $this->yAxis > $this->height) {
$extent = ' -background transparent -extent ' . ($this->width ?? 0) . 'x' . ($this->height ?? 0) . ' ';
}
$action = ' -crop ' . ($this->width ?? 0) . 'x' . ($this->height ?? 0) . '+' . ($this->xAxis ?? 0) . '+' . ($this->yAxis ?? 0) . $extent . escapeshellarg($source) . ' ' . escapeshellarg($destination);
$this->process($action);
return $this;
}
/**
* Handles the rotation of an image resource.
* Doesn't save the image, but replaces the current resource.
*
* @return $this
*
* @throws Exception
*/
protected function _rotate(int $angle)
{
$angle = '-rotate ' . $angle;
$source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname();
$destination = $this->getResourcePath();
$action = ' ' . $angle . ' ' . escapeshellarg($source) . ' ' . escapeshellarg($destination);
$this->process($action);
return $this;
}
/**
* Flattens transparencies, default white background
*
* @return $this
*
* @throws Exception
*/
protected function _flatten(int $red = 255, int $green = 255, int $blue = 255)
{
$flatten = "-background 'rgb({$red},{$green},{$blue})' -flatten";
$source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname();
$destination = $this->getResourcePath();
$action = ' ' . $flatten . ' ' . escapeshellarg($source) . ' ' . escapeshellarg($destination);
$this->process($action);
return $this;
}
/**
* Flips an image along it's vertical or horizontal axis.
*
* @return $this
*
* @throws Exception
*/
protected function _flip(string $direction)
{
$angle = $direction === 'horizontal' ? '-flop' : '-flip';
$source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname();
$destination = $this->getResourcePath();
$action = ' ' . $angle . ' ' . escapeshellarg($source) . ' ' . escapeshellarg($destination);
$this->process($action);
return $this;
}
/**
* Get driver version
*/
public function getVersion(): string
{
$versionString = $this->process('-version')[0];
preg_match('/ImageMagick\s(?P<version>[\S]+)/', $versionString, $matches);
return $matches['version'];
}
/**
* Handles all of the grunt work of resizing, etc.
*
* @return array Lines of output from shell command
*
* @throws Exception
*/
protected function process(string $action, int $quality = 100): array
{
if ($action !== '-version') {
$this->supportedFormatCheck();
}
$cmd = $this->config->libraryPath;
$cmd .= $action === '-version' ? ' ' . $action : ' -quality ' . $quality . ' ' . $action;
$retval = 1;
$output = [];
// exec() might be disabled
if (function_usable('exec')) {
@exec($cmd, $output, $retval);
}
// Did it work?
if ($retval > 0) {
throw ImageException::forImageProcessFailed();
}
return $output;
}
/**
* Saves any changes that have been made to 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
*/
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 (empty($this->resource) && $quality === 100) {
if ($original === null) {
return true;
}
$name = basename($target);
$path = pathinfo($target, PATHINFO_DIRNAME);
return $this->image()->copy($path, $name);
}
$this->ensureResource();
// Copy the file through ImageMagick so that it has
// a chance to convert file format.
$action = escapeshellarg($this->resource) . ' ' . escapeshellarg($target);
$this->process($action, $quality);
unlink($this->resource);
return true;
}
/**
* Get Image Resource
*
* This simply creates an image resource handle
* based on the type of image being processed.
* Since ImageMagick is used on the cli, we need to
* ensure we have a temporary file on the server
* that we can use.
*
* To ensure we can use all features, like transparency,
* during the process, we'll use a PNG as the temp file type.
*
* @return string
*
* @throws Exception
*/
protected function getResourcePath()
{
if ($this->resource !== null) {
return $this->resource;
}
$this->resource = WRITEPATH . 'cache/' . Time::now()->getTimestamp() . '_' . bin2hex(random_bytes(10)) . '.png';
$name = basename($this->resource);
$path = pathinfo($this->resource, PATHINFO_DIRNAME);
$this->image()->copy($path, $name);
return $this->resource;
}
/**
* Make the image resource object if needed
*
* @return void
*
* @throws Exception
*/
protected function ensureResource()
{
$this->getResourcePath();
$this->supportedFormatCheck();
}
/**
* Check if given image format is supported
*
* @return void
*
* @throws ImageException
*/
protected function supportedFormatCheck()
{
switch ($this->image()->imageType) {
case IMAGETYPE_WEBP:
if (! in_array('WEBP', Imagick::queryFormats(), true)) {
throw ImageException::forInvalidImageCreate(lang('images.webpNotSupported'));
}
break;
}
}
/**
* Handler-specific method for overlaying text on an image.
*
* @return void
*
* @throws Exception
*/
protected function _text(string $text, array $options = [])
{
$xAxis = 0;
$yAxis = 0;
$gravity = '';
$cmd = '';
// Reverse the vertical offset
// When the image is positioned at the bottom
// we don't want the vertical offset to push it
// further down. We want the reverse, so we'll
// invert the offset. Note: The horizontal
// offset flips itself automatically
if ($options['vAlign'] === 'bottom') {
$options['vOffset'] *= -1;
}
if ($options['hAlign'] === 'right') {
$options['hOffset'] *= -1;
}
// Font
if (! empty($options['fontPath'])) {
$cmd .= " -font '{$options['fontPath']}'";
}
if (isset($options['hAlign'], $options['vAlign'])) {
switch ($options['hAlign']) {
case 'left':
$xAxis = $options['hOffset'] + $options['padding'];
$yAxis = $options['vOffset'] + $options['padding'];
$gravity = $options['vAlign'] === 'top' ? 'NorthWest' : 'West';
if ($options['vAlign'] === 'bottom') {
$gravity = 'SouthWest';
$yAxis = $options['vOffset'] - $options['padding'];
}
break;
case 'center':
$xAxis = $options['hOffset'] + $options['padding'];
$yAxis = $options['vOffset'] + $options['padding'];
$gravity = $options['vAlign'] === 'top' ? 'North' : 'Center';
if ($options['vAlign'] === 'bottom') {
$yAxis = $options['vOffset'] - $options['padding'];
$gravity = 'South';
}
break;
case 'right':
$xAxis = $options['hOffset'] - $options['padding'];
$yAxis = $options['vOffset'] + $options['padding'];
$gravity = $options['vAlign'] === 'top' ? 'NorthEast' : 'East';
if ($options['vAlign'] === 'bottom') {
$gravity = 'SouthEast';
$yAxis = $options['vOffset'] - $options['padding'];
}
break;
}
$xAxis = $xAxis >= 0 ? '+' . $xAxis : $xAxis;
$yAxis = $yAxis >= 0 ? '+' . $yAxis : $yAxis;
$cmd .= " -gravity {$gravity} -geometry {$xAxis}{$yAxis}";
}
// Color
if (isset($options['color'])) {
[$r, $g, $b] = sscanf("#{$options['color']}", '#%02x%02x%02x');
$cmd .= " -fill 'rgba({$r},{$g},{$b},{$options['opacity']})'";
}
// Font Size - use points....
if (isset($options['fontSize'])) {
$cmd .= " -pointsize {$options['fontSize']}";
}
// Text
$cmd .= " -annotate 0 '{$text}'";
$source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname();
$destination = $this->getResourcePath();
$cmd = " '{$source}' {$cmd} '{$destination}'";
$this->process($cmd);
}
/**
* Return the width of an image.
*
* @return int
*/
public function _getWidth()
{
return imagesx(imagecreatefromstring(file_get_contents($this->resource)));
}
/**
* Return the height of an image.
*
* @return int
*/
public function _getHeight()
{
return imagesy(imagecreatefromstring(file_get_contents($this->resource)));
}
/**
* 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);
switch ($orientation) {
case 2:
return $this->flip('horizontal');
case 3:
return $this->rotate(180);
case 4:
return $this->rotate(180)->flip('horizontal');
case 5:
return $this->rotate(90)->flip('horizontal');
case 6:
return $this->rotate(90);
case 7:
return $this->rotate(270)->flip('horizontal');
case 8:
return $this->rotate(270);
default:
return $this;
}
}
}