Newer
Older
framework / system / Images / Handlers / ImageMagickHandler.php
@MGatner MGatner on 7 Sep 2021 13 KB Release v4.1.4
<?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\Images\Exceptions\ImageException;
use Config\Images;
use Exception;
use Imagick;

/**
 * Class ImageMagickHandler
 *
 * To make this library as compatible as possible with the broadest
 * number of installations, we do not use the Imagick extension,
 * but simply use the command line version.
 *
 * hmm - the width & height accessors at the end use the imagick extension.
 *
 * 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;

    /**
     * Constructor.
     *
     * @param Images $config
     *
     * @throws ImageException
     */
    public function __construct($config = null)
    {
        parent::__construct($config);

        // We should never see this, so can't test it
        // @codeCoverageIgnoreStart
        if (! (extension_loaded('imagick') || class_exists('Imagick'))) {
            throw ImageException::forMissingExtension('IMAGICK');
        }
        // @codeCoverageIgnoreEnd
    }

    /**
     * Handles the actual resizing of the image.
     *
     * @throws Exception
     *
     * @return ImageMagickHandler
     */
    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.
     *
     * @throws Exception
     *
     * @return bool|\CodeIgniter\Images\Handlers\ImageMagickHandler
     */
    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.
     *
     * @throws Exception
     *
     * @return $this
     */
    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
     *
     * @throws Exception
     *
     * @return $this
     */
    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.
     *
     * @throws Exception
     *
     * @return $this
     */
    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
    {
        $result = $this->process('-version');

        // The first line has the version in it...
        preg_match('/(ImageMagick\s[\S]+)/', $result[0], $matches);

        return str_replace('ImageMagick ', '', $matches[0]);
    }

    /**
     * Handles all of the grunt work of resizing, etc.
     *
     * @throws Exception
     *
     * @return array Lines of output from shell command
     */
    protected function process(string $action, int $quality = 100): array
    {
        // Do we have a vaild library path?
        if (empty($this->config->libraryPath)) {
            throw ImageException::forInvalidImageLibraryPath($this->config->libraryPath);
        }

        if ($action !== '-version') {
            $this->supportedFormatCheck();
        }

        if (! preg_match('/convert$/i', $this->config->libraryPath)) {
            $this->config->libraryPath = rtrim($this->config->libraryPath, '/') . '/convert';
        }

        $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();
     */
    public function save(?string $target = null, int $quality = 90): bool
    {
        $original = $target;
        $target   = empty($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.
     *
     * @throws Exception
     *
     * @return string
     */
    protected function getResourcePath()
    {
        if ($this->resource !== null) {
            return $this->resource;
        }

        $this->resource = WRITEPATH . 'cache/' . time() . '_' . 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
     *
     * @throws Exception
     */
    protected function ensureResource()
    {
        $this->getResourcePath();

        $this->supportedFormatCheck();
    }

    /**
     * Check if given image format is supported
     *
     * @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.
     *
     * @throws Exception
     */
    protected function _text(string $text, array $options = [])
    {
        $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'] = $options['vOffset'] * -1;
        }

        if ($options['hAlign'] === 'right') {
            $options['hOffset'] = $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; // @phpstan-ignore-line
            $yAxis = $yAxis >= 0 ? '+' . $yAxis : $yAxis; // @phpstan-ignore-line

            $cmd .= " -gravity {$gravity} -geometry {$xAxis}{$yAxis}"; // @phpstan-ignore-line
        }

        // 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;
        }
    }
}