Subversion Repositories web.active

Rev

Rev 1 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * ProcessWire ImageSizer with Engines for ProcessWire 3.x
 *
 * ImageSizer handles resizing of a single JPG, GIF, or PNG image using GD2
 * or another supported and configured engine. (Imagick, ImageMagick, Netpbm)
 *
 * Code for IPTC, auto rotation and sharpening by Horst Nogajski.
 * http://nogajski.de/
 *
 * Other user contributions as noted.
 *
 * Copyright (C) 2016-2019 by Horst Nogajski and Ryan Cramer
 * This file licensed under Mozilla Public License v2.0 http://mozilla.org/MPL/2.0/
 * 
 * @method bool resize($targetWidth, $targetHeight = 0)
 *
 */
class ImageSizer extends Wire {

    /**
     * @var ImageSizerEngine|null
     *
     */
    protected $engine = null;

    /**
     * Default/fallback image sizer engine name
     *
     * @var string
     *
     */
    protected $defaultEngineName = 'ImageSizerEngineGD';

    /**
     * @var string|null
     *
     */
    protected $filename = null;

    /**
     * @var array
     *
     */
    protected $initialOptions = array();

    /**
     * Known ImageSizer engine modules (class names) excluding ImageSizerEngineGD (the default)
     * 
     * Cached result of getEngines() call
     * 
     * @var null|array
     * 
     */
    static protected $knownEngines = null;

    /**
     * Names of engines that failed the supported() checks
     * 
     * @var array
     * 
     */
    protected $failedEngineNames = array();

    /**
     * Module/class name of engine that must only be used (for cases where you want to force a specific engine)
     * 
     * If the $options array contained a 'forceEngine' property, this contains the value.
     * 
     * @var string
     * 
     */
    protected $forceEngineName = '';

    /**
     * Whether or not ImageSizer has an engine ready to use
     * 
     * @var bool
     * 
     */
    protected $initialized = false;

    /**
     * @var null|false|array
     *
     */
    protected $inspectionResult = null;

    /**
     * Construct the ImageSizer for a single image
     * 
     * @param string $filename Filename to resize. Omit only if instantiating class for a getEngines() call.
     * @param array $options Initial options to the engine.
     *
     */
    public function __construct($filename = '', $options = array()) {
        if(!empty($options)) $this->setOptions($options); 
        if(!empty($filename)) $this->setFilename($filename);
    }
    
    /**
     * Get array of all available ImageSizer engine names in order of priority
     * 
     * Note that the returned value excludes the default engine (ImageSizerEngineGD).
     * 
     * @param bool $forceReload Specify true only if you want to prevent it from using cached result from previous call.
     * @return array of module names 
     * 
     */
    public function getEngines($forceReload = false) {
        
        if(!$forceReload && is_array(self::$knownEngines)) return self::$knownEngines;
        
        self::$knownEngines = array();
        
        $modules = $this->wire('modules');
        $engines = $modules->findByPrefix('ImageSizerEngine');
        $numEngines = count($engines);
        
        foreach($engines as $moduleName) {
            if(!$modules->isInstalled($moduleName)) continue;
            if($numEngines > 1) {
                $configData = $modules->getConfig($moduleName);
                $priority = isset($configData['enginePriority']) ? (int) $configData['enginePriority'] : 0;
                // multiply by 10 to ensure two priority 1 engines don't get mixed up with a priority 2 engine
                // for instance, two priority 1 engines become 10 and 11, rather than 1 and 2, as a priority 1
                // engine incremented to 2 could otherwise be confused with a priority 2 engine
                $priority *= 10;
                while(isset(self::$knownEngines[$priority])) {
                    $priority++;
                }
            } else {
                $priority = 0;
            }
            self::$knownEngines[$priority] = $moduleName;
        }
        
        if(count(self::$knownEngines) > 1) ksort(self::$knownEngines);
        self::$knownEngines[] = $this->defaultEngineName;
        
        return self::$knownEngines;
    }

    /**
     * Get array of information for all ImageSizer engines (or optionally a specific ImageSizer engine)
     * 
     * Returns array of arrays indexed by engine name, each with the following: 
     * 
     *  - `name` (string): engine name
     *  - `title` (string): engine title
     *  - `class` (string): PHP class name for engine
     *  - `summary` (string): Single sentence summary of the engine
     *  - `author` (string): Authr name (if available) 
     *  - `moduleVersion` (string): Version of the module that powers this engine
     *  - `libraryVersion` (string): Version of the library that powers this engine
     *  - `sources` (array): Supported formats for source images it reads (i.e. JPG, JPEG, PNG, PNG24, GIF, GIF87, etc.)
     *  - `targets` (array): Supported formats for target images it creates (i.e. JPG, PNG, PNG24, WEBP, etc.)
     *  - `quality` (int): Current quality setting configured with the engine
     *  - `sharpening` (string): Current sharpening setting configured with the engine
     *  - `priority` (int): Engine priority (lower is higher priority)
     *  - `runOrder` (int): Order ImageSizer will try this engine in relative to others (lower runs first), derived from priority.
     * 
     * @param string $name Specify engine name to get info just for that engine or omit to get info for all engines (default)
     * @return array Array of arrays indexed by engine name, or if $name specified then just array of info for that engine. 
     *   Returns empty array on error. 
     * @since 3.0.138
     * 
     */
    public function getEngineInfo($name = '') {
        
        $infos = array();
        $engineNames = $name ? array($name) : $this->getEngines();
        $prefix = 'ImageSizerEngine';
        
        if($name && stripos($name, $prefix) === 0) {
            $name = str_replace($prefix, '', $name);
        }
        
        foreach($engineNames as $priority => $engineName) {
            $shortName = str_replace($prefix, '', $engineName);
            if($name && $shortName !== $name) continue;
            $engine = $this->getEngine($engineName);
            if(!$engine) continue;
            $info = $engine->getEngineInfo();
            $info['runOrder'] = $priority;  
            $infos[$shortName] = $info;
        }
    
        // if one engine requested reduce array to just that engine
        if($name) $infos = isset($infos[$name]) ? $infos[$name] : array();
        
        return $infos;
    }
    
    /**
     * Instantiate an ImageSizerEngine
     *
     * @param string $filename
     * @param array $options
     * @param null|array $inspectionResult
     * @return ImageSizerEngine|null
     * @throws WireException
     *
     */
    protected function newImageSizerEngine($filename = '', array $options = array(), $inspectionResult = null) {    
        
        if(empty($filename)) $filename = $this->filename;
        if(empty($options)) $options = $this->initialOptions;
        if(empty($inspectionResult)) $inspectionResult = $this->inspectionResult;
        
        if(empty($inspectionResult) && $filename && is_readable($filename)) {
            $imageInspector = new ImageInspector($filename);
            $this->wire($imageInspector); 
            $inspectionResult = $imageInspector->inspect($filename, true);
            $this->inspectionResult = $inspectionResult;
        }
        
        $engine = null;
        $bestFallbackEngine = null; // first engine that was supported but failed webp check
        $engineNames = $this->getEngines();
    
        // find first supported engine, according to knownEngines priority
        foreach($engineNames as $engineName) {
            
            if($this->forceEngineName && $engineName != $this->forceEngineName) continue;
            
            $e = $this->getEngine($engineName);
            if(!$e) continue;
            
            /** @var ImageSizerEngine $e */
            $e->prepare($filename, $options, $inspectionResult);    
            $supported = $e->supported();
            
            if($supported && !empty($options['webpAdd']) && !$e->supported('webp')) {
                // engine does not support requested webp extra image
                if(!$bestFallbackEngine) $bestFallbackEngine = $e;
                
            } else if($supported) {
                // found supported engine
                $engine = $e;
                break;
            } 
            
            $this->failedEngineNames[$engineName] = $engineName;
        }
        
        if(!$engine) { 
            // no engine found
            if($bestFallbackEngine) {
                // if there is a next best fallback, use it
                $engine = $bestFallbackEngine;
            } else {
                // otherwise fallback to default
                $engine = $this->newDefaultImageSizerEngine($filename, $options, $inspectionResult);
            }
        }
        
        return $engine;
    }

    /**
     * Get the default/fallback ImageSizer engine
     * 
     * @param string $filename
     * @param array $options
     * @param null|array $inspectionResult
     * @return ImageSizerEngine|null
     * @throws WireException
     * 
     */
    protected function newDefaultImageSizerEngine($filename = '', $options = array(), $inspectionResult = null) {
        
        if(empty($filename)) $filename = $this->filename;
        if(empty($options)) $options = $this->initialOptions;
        if(empty($inspectionResult)) $inspectionResult = $this->inspectionResult;
        
        if($this->forceEngineName && $this->forceEngineName != $this->defaultEngineName) {
            throw new WireException("Forced engine $this->forceEngineName is not supported for this operation");
        }
        
        $engineClass = __NAMESPACE__ . "\\$this->defaultEngineName";
        /** @var ImageSizerEngine $engine */
        $engine = new $engineClass();
        $this->wire($engine);
        $engine->prepare($filename, $options, $inspectionResult);
        if(!$engine->supported()) $engine = null;
        
        return $engine;
    }

    /**
     * Resize the image proportionally to the given width/height
     *
     * @param int $targetWidth Target width in pixels, or 0 for proportional to height
     * @param int $targetHeight Target height in pixels, or 0 for proportional to width. Optional-if not specified, 0
     *     is assumed.
     *
     * @return bool True if the resize was successful, false if not
     * @throws WireException when not enough memory to load image or missing required data
     *
     */
    public function ___resize($targetWidth, $targetHeight = 0) {
        
        $engine = $this->getEngine();
        $success = $engine->resize($targetWidth, $targetHeight);
        
        if(!$success) {
            // fallback to GD
            $success = $this->resizeFallback($targetWidth, $targetHeight);
        }
        
        return $success;
    }

    /**
     * GD is the fallback ImageEngine, it gets invoked if there is no other Engine defined,
     *
     * or if a defined Engine is not available,
     * or if an invoked Engine failes with the image manipulation.
     *
     * @param mixed $targetWidth
     * @param mixed $targetHeight
     *
     * @return bool
     *
     */
    protected function resizeFallback($targetWidth, $targetHeight = 0) {
        $engine = $this->newDefaultImageSizerEngine();
        $success = false;
        if($engine->supported()) $success = $engine->resize($targetWidth, $targetHeight);
        return $success;
    }

    /**
     * Set the filename 
     * 
     * @param $filename
     * @return $this
     * 
     */
    public function setFilename($filename) {
        $this->filename = $filename;
        return $this;
    }

    /**
     * Force the use of a specific engine
     * 
     * @param $engineName Module name of engine you want to force
     * @return $this
     * 
     */
    public function setForceEngine($engineName) {
        $this->forceEngineName = $engineName;
        return $this;
    }

    /**
     * Set multiple resize options
     * 
     * @param array $options
     * @return $this
     * 
     */
    public function setOptions(array $options) {
        if(isset($options['forceEngine'])) {
            $this->setForceEngine($options['forceEngine']);
            unset($options['forceEngine']);
        }
        $this->initialOptions = array_merge($this->initialOptions, $options);
        if($this->engine) $this->engine->setOptions($this->initialOptions);
        return $this;
    }

    /**
     * Set whether a modification was made
     * 
     * @param $modified
     * @return $this
     * 
     */
    public function setModified($modified) {
        if($this->engine) $this->engine->setModified($modified ? true : false);
        return $this;
    }

    public function setAutoRotation($value = true) { return $this->setOptions(array('autoRotation', $value)); }
    public function setCropExtra($value) { return $this->setOptions(array('cropExtra', $value)); }
    public function setCropping($cropping = true) { return $this->setOptions(array('cropping', $cropping)); }
    public function setDefaultGamma($value = 2.2) { return $this->setOptions(array('defaultGamma', $value)); }
    public function setFlip($flip) { return $this->setOptions(array('flip', $flip)); }
    public function setHidpi($hidpi = true) { return $this->setOptions(array('hidpi', $hidpi)); }
    public function setQuality($n) { return $this->setOptions(array('quality', $n)); }
    public function setRotate($degrees) { return $this->setOptions(array('rotate', $degrees)); }
    public function setScale($scale) { return $this->setOptions(array('scale', $scale)); }
    public function setSharpening($value) { return $this->setOptions(array('sharpening', $value)); }
    public function setTimeLimit($value = 30) { return $this->setOptions(array('timeLimit', $value)); }
    public function setUpscaling($value = true) { return $this->setOptions(array('upscaling', $value)); }
    public function setUseUSM($value = true) { return $this->setOptions(array('useUSM', $value)); }
    public function getWidth() { 
        $image = $this->getEngine()->get('image');
        return $image['width']; 
    }
    public function getHeight() { 
        $image = $this->getEngine()->get('image');
        return $image['height']; 
    }
    public function getFilename() { return $this->getEngine()->getFilename(); }
    public function getExtension() { return $this->getEngine()->getExtension(); }
    public function getImageType() { return $this->getEngine()->getImageType(); }
    public function isModified() { return $this->getEngine()->getModified(); }
    public function getOptions() { return $this->getEngine()->getOptions(); }
    public function getFailedEngineNames() { return $this->failedEngineNames; }

    /**
     * Get the current ImageSizerEngine
     * 
     * @param string $engineName Optionally specify a specific engine name to get a new instance of that engine
     *   When used, returned engine is in an unprepared state (no filename assigned, etc.). Since 3.0.138.
     * @return ImageSizerEngine|null Returns ImageSizerEngine or null only if requested $engineName is not found. 
     *   If no $engineName is specified this method may return an existing instance from a previous call. 
     * @throws WireException
     * 
     */
    public function getEngine($engineName = '') {

        if($engineName) {
            if($engineName === $this->defaultEngineName) {
                $engineClass = __NAMESPACE__ . "\\$engineName";
                $engine = $this->wire(new $engineClass());
            } else {
                $engine = $this->wire('modules')->get($engineName);
            }
            return $engine;
        }
        
        if($this->engine) return $this->engine;
        
        if(empty($this->filename)) {
            throw new WireException('No file to process: please call setFilename($file) before calling other methods');
        }

        $imageInspector = new ImageInspector($this->filename);
        $this->inspectionResult = $imageInspector->inspect($this->filename, true);
        $this->engine = $this->newImageSizerEngine($this->filename, $this->initialOptions, $this->inspectionResult);
        // set the engine, and check if the engine is ready to use
        if(!$this->engine) {
            throw new WireException('There seems to be no support for the GD image library on your host?');
        }

        return $this->engine;
    }
    
    public function __get($key) { return $this->getEngine()->__get($key); }

    /**
     * ImageInformation from Image Inspector in short form or full RawInfoData
     *
     * @param bool $rawData
     * @return string|array
     *
     */
    public function getImageInfo($rawData = false) {
    
        $this->getEngine();
        if($rawData) return $this->inspectionResult;
        $imageType = $this->inspectionResult['info']['imageType'];
        $type = '';
        $indexed = '';
        $trans = '';
        $animated = '';
        
        switch($imageType) {
            case \IMAGETYPE_JPEG:
                $type = 'jpg';
                $indexed = '';
                $trans = '';
                $animated = '';
                break;
            case \IMAGETYPE_GIF:
                $type = 'gif';
                $indexed = '';
                $trans = $this->inspectionResult['info']['trans'] ? '-trans' : '';
                $animated = $this->inspectionResult['info']['animated'] ? '-anim' : '';
                break;
            case \IMAGETYPE_PNG:
                $type = 'png';
                $indexed = 'Indexed' == $this->inspectionResult['info']['colspace'] ? '8' : '24';
                $trans = $this->inspectionResult['info']['alpha'] ? '-alpha' : '';
                $trans = is_array($this->inspectionResult['info']['trans']) ? '-trans' : $trans;
                $animated = '';
                break;
        }
        
        return $type . $indexed . $trans . $animated;
    }

    /**
     * Given an unknown cropping value, return the validated internal representation of it
     *
     * Needed for backwards compatibility.
     *
     * @param string|bool|array $cropping
     *
     * @return string|bool
     *
     */
    static public function croppingValue($cropping) {
        return ImageSizerEngine::croppingValue($cropping);
    }

    /**
     * Given an unknown cropping value, return the string representation of it
     *
     * Okay for use in filenames.
     * Needed for backwards compatibility.
     *
     * @param string|bool|array $cropping
     *
     * @return string
     *
     */
    static public function croppingValueStr($cropping) {
        return ImageSizerEngine::croppingValueStr($cropping);
    }

    /**
     * Given an unknown sharpening value, return the string representation of it
     *
     * Okay for use in filenames. Method added by @horst
     * Needed for backwards compatibility.
     *
     * @param string|bool $value
     * @param bool $short
     *
     * @return string
     *
     */
    static public function sharpeningValueStr($value, $short = false) {
        return ImageSizerEngine::sharpeningValueStr($value, $short);
    }

    /**
     * Check orientation (@horst)
     *
     * @param mixed $image Pageimage or filename
     * @param mixed $correctionArray Null or array by reference
     * @return bool|null
     *
     */
    static public function imageIsRotated($image, &$correctionArray = null) {
        if($image instanceof Pageimage) {
            $filename = $image->filename;
        } else if(is_readable($image)) {
            $filename = $image;
        } else {
            return null;
        }
        // first value is rotation-degree and second value is flip-mode: 0=NONE | 1=HORIZONTAL | 2=VERTICAL
        $corrections = array(
            '1' => array(0, 0),
            '2' => array(0, 1),
            '3' => array(180, 0),
            '4' => array(0, 2),
            '5' => array(90, 1),    // OLD: 270
            '6' => array(90, 0),    // OLD: 270
            '7' => array(270, 1),   // OLD: 90
            '8' => array(270, 0)    // OLD: 90
        );
        if(!function_exists('exif_read_data')) return null;
        $exif = @exif_read_data($filename, 'IFD0');
        if(!is_array($exif)
            || !isset($exif['Orientation'])
            || !in_array(strval($exif['Orientation']), array_keys($corrections))
        ) {
            return null;
        }
        $correctionArray = $corrections[strval($exif['Orientation'])];
        return $correctionArray[0] > 0;
    }

    /**
     * Check if GIF-image is animated or not (@horst)
     *
     * @param mixed $image Pageimage or filename
     * @return bool|null
     *
     */
    static public function imageIsAnimatedGif($image) {
        if($image instanceof Pageimage) {
            $filename = $image->filename;
        } elseif(is_readable($image)) {
            $filename = $image;
        } else {
            return null;
        }
        $info = getimagesize($filename);
        if(\IMAGETYPE_GIF != $info[2]) return false;
        if(ImageSizerEngineGD::checkMemoryForImage(array($info[0], $info[1]))) {
            return (bool) preg_match('/\x00\x21\xF9\x04.{4}\x00(\x2C|\x21)/s', file_get_contents($filename));
        }
        // we have not enough free memory to load the complete image at once, so we do it in chunks
        if(!($fh = @fopen($filename, 'rb'))) {
            return null;
        }
        $count = 0;
        while(!feof($fh) && $count < 2) {
            $chunk = fread($fh, 1024 * 100); //read 100kb at a time
            $count += preg_match_all('#\x00\x21\xF9\x04.{4}\x00[\x2C\x21]#s', $chunk, $matches);
        }
        fclose($fh);
        return $count > 1;
    }

    /**
     * Possibility to clean IPTC data, also for original images (@horst)
     *
     * @param mixed $image Pageimage or filename
     *
     * @return mixed|null|bool
     *
     */
    static public function imageResetIPTC($image) {
        $wire = null;
        if($image instanceof Pageimage) {
            $wire = $image;
            $filename = $image->filename;
        } else if(is_readable($image)) {
            $filename = $image;
        } else {
            return null;
        }
        $sizer = new ImageSizerEngineGD();
        if($wire) $wire->wire($sizer);
        $result = false !== $sizer->writeBackIPTC($filename) ? true : false;
        return $result;
    }
    
    /**
     * Rotate image by given degrees
     *
     * @param int $degrees
     * @return bool
     *
     */
    public function rotate($degrees) {
        return $this->getEngine()->rotate($degrees);
    }

    /**
     * Flip image vertically
     *
     * @return bool
     *
     */
    public function flipVertical() {
        return $this->getEngine()->flipVertical();
    }

    /**
     * Flip image horizontally
     *
     * @return bool
     *
     */
    public function flipHorizontal() {
        return $this->getEngine()->flipHorizontal();
    }

    /**
     * Flip both vertically and horizontally
     *
     * @return bool
     *
     */
    public function flipBoth() {
        return $this->getEngine()->flipBoth();
    }

    /**
     * Convert image to greyscale (black and white)
     *
     * @return bool
     *
     */
    public function convertToGreyscale() {
        return $this->getEngine()->convertToGreyscale();
    }

    /**
     * Convert image to sepia tone
     *
     * @param int $sepia Sepia amount
     * @return bool
     *
     */
    public function convertToSepia($sepia = 55) {
        return $this->getEngine()->convertToSepia('', $sepia);
    }


}