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 PageimageVariations
 * 
 * Helper class for Pageimage that handles variation collection methods
 *
 * ProcessWire 3.x, Copyright 2022 by Ryan Cramer
 * https://processwire.com
 * 
 * @since 3.0.137
 * 
 */

class PageimageVariations extends Wire implements \IteratorAggregate, \Countable {

    /**
     * @var Pageimage
     * 
     */
    protected $pageimage;

    /**
     * @var Pagefiles|Pageimages
     * 
     */
    protected $pagefiles;

    /**
     * @var Pageimages|null
     * 
     */
    protected $variations = null;

    /**
     * Construct
     *
     * @param Pageimage $pageimage
     * 
     */
    public function __construct(Pageimage $pageimage) {
        $this->pageimage = $pageimage;
        $this->pagefiles = $pageimage->pagefiles;
        $pageimage->wire($this);
        parent::__construct();
    }

    #[\ReturnTypeWillChange] 
    public function getIterator() {
        return $this->find();
    }

    /**
     * Return a total or filtered count of variations
     * 
     * This method is also here to implement the Countable interface. 
     * 
     * @param array $options See options for find() method
     * @return int
     * 
     */
    #[\ReturnTypeWillChange] 
    public function count($options = array()) {
        if($this->variations) {
            $count = $this->variations->count();
        } else {
            $options['count'] = true;
            $count = $this->find($options);
        }
        return $count;
    }
    
    /**
     * Given a file name (basename), return array of info if this is a variation for this instance’s file, or false if not.
     *
     * Returned array includes the following indexes:
     *
     * - `original` (string): Original basename
     * - `url` (string): URL to image
     * - `path` (string): Full path + filename to image
     * - `width` (int): Specified width in filename
     * - `height` (int): Specified height in filename
     * - `actualWidth` (int): Actual width when checked manually
     * - `actualHeight` (int): Acual height when checked manually
     * - `crop` (string): Cropping info string or blank if none
     * - `suffix` (array): Array of suffixes
     *
     * The following are only present if variation is based on another variation, and thus has a parent variation
     * image between it and the original:
     *
     * - `suffixAll` (array): Contains all suffixes including among parent variations
     * - `parent` (array): Variation info array of direct parent variation file
     *
     * @param string $basename Filename to check (basename, which excludes path)
     * @param array|bool $options Array of options to modify behavior, or boolean to only specify `allowSelf` option.
     *  - `allowSelf` (bool): When true, it will return variation info even if same as current Pageimage. (default=false)
     *  - `verbose` (bool): Return verbose array of info? If false, just returns basename (string) or false. (default=true)
     * @return bool|string|array Returns false if not a variation, or array (verbose) or string (non-verbose) of info if it is.
     *
     */
    public function getInfo($basename, $options = array()) {

        $defaults = array(
            'allowSelf' => false,
            'verbose' => true,
        );

        if(!is_array($options)) $options = array('allowSelf' => (bool) $options);
        $options = array_merge($defaults, $options);

        static $level = 0;
        $variationName = basename($basename);
        $originalName = $this->pageimage->basename;

        // that that everything from the beginning up to the first period is exactly the same
        // otherwise, they are different source files
        $test1 = substr($variationName, 0, strpos($variationName, '.'));
        $test2 = substr($originalName, 0, strpos($originalName, '.'));
        if($test1 !== $test2) return false;

        // remove extension from originalName
        $originalName = basename($originalName, "." . $this->pageimage->ext());

        // if originalName is already a variation filename, remove the variation info from it.
        // reduce to original name, i.e. all info after (and including) a period
        if(strpos($originalName, '.') && preg_match('/^([^.]+)\.(?:\d+x\d+|-[_a-z0-9]+)/', $originalName, $matches)) {
            $originalName = $matches[1];
        }

        // if file is the same as the original, then it's not a variation
        if(!$options['allowSelf'] && $variationName == $this->pageimage->basename) return false;

        // if file doesn't start with the original name then it's not a variation
        if(strpos($variationName, $originalName) !== 0) return false;

        // get down to the meat and the base
        // meat is the part of the filename containing variation info like dimensions, crop, suffix, etc.
        // base is the part before that, which may include parent meat
        $pos = strrpos($variationName, '.'); // get extension
        $ext = substr($variationName, $pos);
        $base = substr($variationName, 0, $pos); // get without extension
        $rpos = strrpos($base, '.'); // get last data chunk after dot

        if($rpos !== false) {
            $meat = substr($base, $rpos+1) . $ext; // the part of the filename we're interested in
            $base = substr($base, 0, $rpos); // the rest of the filename
            $parent = "$base." . $this->pageimage->ext();
        } else {
            $meat = $variationName;
            $parent = null;
        }

        // identify parent and any parent suffixes
        $suffixAll = array();
        if($options['verbose']) {
            while(($pos = strrpos($base, '.')) !== false) {
                $part = substr($base, $pos + 1);
                $base = substr($base, 0, $pos);
                while(($rpos = strrpos($part, '-')) !== false) {
                    $suffixAll[] = substr($part, $rpos + 1);
                    $part = substr($part, 0, $rpos);
                }
            }
        }

        // variation name with size dimensions and optionally suffix
        $re1 = '/^'  .
            '(\d+)x(\d+)' .                 // 50x50    
            '([pd]\d+x\d+|[a-z]{1,2})?' .   // nw or p30x40 or d30x40
            '(?:-([-_a-z0-9]+))?' .         // -suffix1 or -suffix1-suffix2, etc.
            '\.' . $this->pageimage->ext() .
            '$/';

        // variation name with suffix only
        $re2 = '/^' .
            '-([-_a-z0-9]+)' .              // suffix1 or suffix1-suffix2, etc. 
            '(?:\.' .                       // optional extras for dimensions/crop, starts with period
            '(\d+)x(\d+)' .                 // optional 50x50   
            '([pd]\d+x\d+|[a-z]{1,2})?' .   // nw or p30x40 or d30x40
            ')?' .
            '\.' . $this->pageimage->ext() .
            '$/';

        // if regex does not match, return false
        if(preg_match($re1, $meat, $matches)) {

            // this is a variation with dimensions, return array of info
            $width = (int) $matches[1];
            $height = (int) $matches[2];
            $crop = isset($matches[3]) ? $matches[3] : '';
            $suffix = isset($matches[4]) ? explode('-', $matches[4]) : array();

        } else if(preg_match($re2, $meat, $matches)) {

            // this is a variation only with suffix
            $width = isset($matches[2]) ? (int) $matches[2] : 0;
            $height = isset($matches[3]) ? (int) $matches[3] : 0;
            $crop = isset($matches[4]) ? $matches[4] : '';
            $suffix = explode('-', $matches[1]);

        } else {
            return false;
        }

        // if not in verbose mode, just return variation basename
        if(!$options['verbose']) return $variationName;

        $path = $this->pagefiles->path . $basename;
        $actualInfo = $this->pageimage->getImageInfo($path);

        $info = array(
            'name' => $basename,
            'url' => $this->pagefiles->url . $basename,
            'path' => $path,
            'original' => $originalName . '.' . $this->pageimage->ext(),
            'width' => $width,
            'height' => $height,
            'crop' => $crop,
            'suffix' => $suffix,
            'suffixAll' => array(), // present only when image has a parent variation
            'actualWidth' => $actualInfo['width'],
            'actualHeight' => $actualInfo['height'],
            'hidpiWidth' => $this->pageimage->hidpiWidth(0, $actualInfo['width']),
            'hidpiHeight' => $this->pageimage->hidpiWidth(0, $actualInfo['height']),
            'parentName' => '', // present only when image has a parent variation
            'parent' => null, // present only when image has a parent variation
            'webpUrl' => '',
            'webpPath' => '',
        );

        foreach($this->pageimage->extras() as $name => $extra) {
            
            if($extra->exists()) {
                $info["{$name}Url"] = $extra->url(false);
                $info["{$name}Path"] = $extra->filename();
                continue;
            } 
            
            $f = "$basename.$extra->ext"; // useSrcExt, i.e. file.png.webp
            if(is_readable($this->pagefiles->path . $f)) {
                $info["{$name}Url"] = $this->pagefiles->url . $f;
                $info["{$name}Path"] = $this->pagefiles->path . $f;
                continue;
            }
            
            $f = basename($basename, '.' . $this->pageimage->ext()) . ".$extra->ext";
            if(is_readable($this->pagefiles->path . $f)) {
                $info["{$name}Url"] = $this->pagefiles->url . $f;
                $info["{$name}Path"] = $this->pagefiles->path . $f;
                continue;
            }
        }

        if(empty($info['crop'])) {
            // attempt to extract crop info from suffix
            foreach($info['suffix'] as $key => $suffix) {
                if(strpos($suffix, 'cropx') === 0) {
                    $info['crop'] = ltrim($suffix, 'crop'); // i.e. x123y456
                }
            }
        }

        if($parent) {
            // suffixAll includes all parent suffix in addition to current suffix
            if(!$level) $info['suffixAll'] = array_unique(array_merge($info['suffix'], $suffixAll));
            // parent property is set with more variation info, when available
            $level++;
            $info['parentName'] = $parent;
            $info['parent'] = $this->getInfo($parent);
            $level--;
        } else {
            unset($info['parent'], $info['parentName'], $info['suffixAll']);
        }

        if(!$this->pageimage->__isset('original') && $info['original']) {
            $original = $this->pagefiles->get($info['original']);
            if($original) $this->pageimage->setOriginal($original);
        }

        return $info;
    }


    /**
     * Get all size variations of this image
     *
     * This is useful after a delete of an image (for example). This method can be used to track down all the
     * child files that also need to be deleted.
     *
     * @param array $options Optional, one or more options in an associative array of the following:
     *  - `info` (bool): when true, method returns variation info arrays rather than Pageimage objects (default=false).
     *  - `count` (bool): when true, only a count of variations is returned (default=false). 
     *  - `verbose` (bool|int): Return verbose array of info. If false, returns only filenames (default=true).
     *     This option does nothing unless the `info` option is true. Also note that if verbose is false, then all options
     *     following this one no longer apply (since it is no longer returning width/height info).
     *     When integer 1, returned info array also includes Pageimage variation options in 'pageimage' index of
     *     returned arrays (since 3.0.137).
     *  - `width` (int): only variations with given width will be returned
     *  - `height` (int): only variations with given height will be returned
     *  - `width>=` (int): only variations with width greater than or equal to given will be returned
     *  - `height>=` (int): only variations with height greater than or equal to given will be returned
     *  - `width<=` (int): only variations with width less than or equal to given will be returned
     *  - `height<=` (int): only variations with height less than or equal to given will be returned
     *  - `suffix` (string): only variations having the given suffix will be returned
     *  - `suffixes` (array): only variations having one of the given suffixes will be returned
     *  - `noSuffix` (string): exclude variations having this suffix
     *  - `noSuffixes` (array): exclude variations having any of these suffixes
     *  - `name` (string): only variations containing this text in filename will be returned (case insensitive)
     *  - `noName` (string): only variations NOT containing this text in filename will be returned (case insensitive)
     *  - `regexName` (string): only variations that match this PCRE regex will be returned
     * @return Pageimages|array|int Returns Pageimages array of Pageimage instances.
     *  Only returns regular array if provided `$options['info']` is true.
     *  Returns integer if count option is specified. 
     *
     */
    public function find(array $options = array()) {

        if(!is_null($this->variations) && empty($options)) return $this->variations;

        $defaults = array(
            'info' => false,
            'verbose' => true,
            'count' => false, 
        );

        $options = array_merge($defaults, $options);
        if($options['count']) {
            $options['verbose'] = false;
            $options['info'] = false;
        } else if(!$options['verbose'] && !$options['info']) {
            $options['verbose'] = true; // non-verbose only allowed if info==true
        }

        $variations = null;
        $dir = new \DirectoryIterator($this->pagefiles->path);
        $infos = array();
        $count = 0;
        
        if(!$options['info'] && !$options['count']) {
            $variations = $this->wire(new Pageimages($this->pagefiles->page));
        }

        // if suffix or noSuffix option contains space, convert it to suffixes or noSuffixes array option
        foreach(array('suffix', 'noSuffix') as $key) {
            if(!isset($options[$key])) continue;
            if(strpos(trim($options[$key]), ' ') === false) continue;
            $keyPlural = $key . 'es';
            $value = isset($options[$keyPlural]) ? $options[$keyPlural] : array();
            $options[$keyPlural] = array_merge($value, explode(' ', trim($options[$key])));
            unset($options[$key]);
        }

        foreach($dir as $file) {

            if($file->isDir() || $file->isDot()) continue;
            
            $info = $this->getInfo($file->getFilename(), array('verbose' => $options['verbose']));
            if(!$info) continue;
            
            if($options['info'] && !$options['verbose']) {
                $infos[] = $info;
                continue;
            }

            $allow = true;

            foreach($options as $option => $value) {
                switch($option) {
                    case 'width': $allow = $info['width'] == $value; break;
                    case 'width>=': $allow = $info['width'] >= $value; break;
                    case 'width<=': $allow = $info['width'] <= $value; break;
                    case 'height': $allow = $info['height'] == $value; break;
                    case 'height>=': $allow = $info['height'] >= $value; break;
                    case 'height<=': $allow = $info['height'] <= $value; break;
                    case 'name': $allow = stripos($file->getBasename(), $value) !== false; break;
                    case 'noName': $allow = stripos($file->getBasename(), $value) === false; break;
                    case 'regexName': $allow = preg_match($value, $file->getBasename()); break;
                    case 'suffix': $allow = in_array($value, $info['suffix']); break;
                    case 'noSuffix': $allow = !in_array($value, $info['suffix']); break;
                    case 'suffixes':
                        // any one of given suffixes will allow the variation
                        $allow = false;
                        foreach($value as $suffix) {
                            $allow = in_array($suffix, $info['suffix']);
                            if($allow) break;
                        }
                        break;
                    case 'noSuffixes':
                        // any one of the given suffixes will disallow the variation
                        $allow = true;
                        foreach($value as $noSuffix) {
                            if(!in_array($noSuffix, $info['suffix'])) continue;
                            $allow = false;
                            break;
                        }
                        break;
                }
                if(!$allow) break;
            }

            if(!$allow) continue;
            
            $basename = $file->getBasename();
            
            if($options['count']) {
                $count++;
                continue;
            }
            
            if(empty($options['info']) || $options['verbose'] === 1) {
                $pageimage = clone $this->pageimage;
                $pathname = $file->getPathname();
                if(DIRECTORY_SEPARATOR != '/') $pathname = str_replace(DIRECTORY_SEPARATOR, '/', $pathname);
                $pageimage->setFilename($pathname);
                $pageimage->setOriginal($this->pageimage);
                if($options['verbose'] === 1) {
                    $info['pageimage'] = $pageimage;
                } else if($variations) {
                    $variations->add($pageimage);
                }
            }
            if(!empty($options['info'])) {
                $infos[$basename] = $info;
            }
        }
        
        if($options['count']) return $count;
        if(!empty($options['info'])) return $infos;
        if(empty($options)) $this->variations = $variations;

        return $variations;
    }

    /**
     * Rebuilds variations of this image
     *
     * By default, this excludes crops and images with suffixes, but can be overridden with the `$mode` and `$suffix` arguments.
     *
     * **Options for $mode argument**
     *
     * - `0` (int): Rebuild only non-suffix, non-crop variations, and those w/suffix specified in $suffix argument. ($suffix is INCLUSION list)
     * - `1` (int): Rebuild all non-suffix variations, and those w/suffix specifed in $suffix argument. ($suffix is INCLUSION list)
     * - `2` (int): Rebuild all variations, except those with suffix specified in $suffix argument. ($suffix is EXCLUSION list)
     * - `3` (int): Rebuild only variations specified in the $suffix argument. ($suffix is ONLY-INCLUSION list)
     * - `4` (int): Rebuild only non-proportional, non-crop variations (variations that specify both width and height)
     *
     * Mode 0 is the only truly safe mode, as in any other mode there are possibilities that the resulting
     * rebuild of the variation may not be exactly what was intended. The issues with other modes primarily
     * arise when the suffix means something about the technical details of the produced image, or when
     * rebuilding variations that include crops from an original image that has since changed dimensions or crops.
     *
     * @param int $mode See the options for $mode argument above (default=0).
     * @param array $suffix Optional argument to specify suffixes to include or exclude (according to $mode).
     * @param array $options See $options for `Pageimage::size()` for details.
     * @return array Returns an associative array with with the following indexes:
     *  - `rebuilt` (array): Names of files that were rebuilt.
     *  - `skipped` (array): Names of files that were skipped.
     *  - `errors` (array): Names of files that had errors.
     *  - `reasons` (array): Reasons why files were skipped or had errors, associative array indexed by file name.
     *
     */
    public function rebuild($mode = 0, array $suffix = array(), array $options = array()) {
        
        $files = $this->wire()->files;

        $skipped = array();
        $rebuilt = array();
        $errors = array();
        $reasons = array();
        $options['forceNew'] = true;

        foreach($this->find(array('info' => true)) as $info) {

            $o = $options;
            unset($o['cropping']);
            $skip = false;
            $name = $info['name'];
            $hadWebp = false;

            if($info['crop'] && !$mode) {
                // skip crops when mode is 0
                $reasons[$name] = "$name: Crop is $info[crop] and mode is 0";
                $skip = true;

            } else if(count($info['suffix'])) {
                // check suffixes 
                foreach($info['suffix'] as $k => $s) {
                    if($s === 'hidpi') {
                        // allow hidpi to passthru
                        $o['hidpi'] = true;
                    } else if($s == 'is') {
                        // this is a known core suffix that we allow
                    } else if(strpos($s, 'cropx') === 0) {
                        // skip cropx suffix (already known from $info[crop])
                        unset($info['suffix'][$k]);
                        continue;
                    } else if(strpos($s, 'pid') === 0 && preg_match('/^pid\d+$/', $s)) {
                        // allow pid123 to pass through 
                    } else if(in_array($s, $suffix)) {
                        // suffix is one provided in $suffix argument
                        if($mode == 2) {
                            // mode 2 where $suffix is an exclusion list
                            $skip = true;
                            $reasons[$name] = "$name: Suffix '$s' is one provided in exclusion list (mode==true)";
                        } else {
                            // allowed suffix
                        }
                    } else {
                        // image has suffix not specified in $suffix argument
                        if($mode == 0 || $mode == 1 || $mode == 3) {
                            $skip = true;
                            $reasons[$name] = "$name: Image has suffix '$s' not provided in allowed list: " . implode(', ', $suffix);
                        }
                    }
                }
            }

            if($mode == 4 && ($info['width'] == 0 || $info['height'] == 0)) {
                // skip images that don't specify both width and height
                $skip = true;
            }

            if($skip) {
                $skipped[] = $name;
                continue;
            }

            // rebuild the variation
            $o['forceNew'] = true;
            $o['suffix'] = $info['suffix'];

            if(is_file($info['path'])) {
                $files->unlink($info['path'], true);
                if(!empty($info['webpPath']) && $files->exists($info['webpPath'])) {
                    $files->unlink($info['webpPath'], true);
                    $hadWebp = true;
                }
            }

            /*
            if(!$info['width'] && $info['actualWidth']) {
                $info['width'] = $info['actualWidth'];
                $o['nameWidth'] = 0;
            }
            if(!$info['height'] && $info['actualHeight']) {
                $info['height'] = $info['actualHeight'];
                $o['nameHeight'] = 0;
            }
            */

            if($info['crop'] && preg_match('/^x(\d+)y(\d+)$/', $info['crop'], $matches)) {
                // dimensional cropping info contained in filename
                $cropX = (int) $matches[1];
                $cropY = (int) $matches[2];
                $variation = $this->pageimage->crop($cropX, $cropY, $info['width'], $info['height'], $o);

            } else if($info['crop']) {
                // direct cropping info contained in filename
                $options['cropping'] = $info['crop'];
                $variation = $this->pageimage->size($info['width'], $info['height'], $o);

            } else if($this->pageimage->hasFocus) {
                // crop to focus area, which the size() method will determine on its own
                $variation = $this->pageimage->size($info['width'], $info['height'], $o);

            } else {
                // no crop, no focus, just resize
                $variation = $this->pageimage->size($info['width'], $info['height'], $o);
            }

            if($variation) {
                if($variation->name != $name) {
                    $files->rename($variation->filename(), $info['path']);
                    $variation->data('basename', $name);
                }
                $rebuilt[] = $name;
                if($hadWebp) {
                    // forces create of webp version
                    $webpName = basename($variation->webp()->url());
                    if($webpName) $rebuilt[] = $webpName;
                }
            } else {
                $errors[] = $name;
            }
        }

        return array(
            'rebuilt' => $rebuilt,
            'skipped' => $skipped,
            'reasons' => $reasons,
            'errors' => $errors
        );
    }

    /**
     * Delete all the alternate sizes associated with this Pageimage
     *
     * @param array $options See options for getVariations() method to limit what variations are removed, plus these:
     *  - `dryRun` (bool): Do not remove now and instead only return the filenames of variations that would be deleted (default=false).
     *  - `getFiles` (bool): Return deleted filenames? Also assumed if the test option is used (default=false).
     * @return $this|array Returns $this by default, or array of deleted filenames if the `getFiles` option is specified
     *
     */
    public function remove(array $options = array()) {

        $defaults = array(
            'dryRun' => false,
            'getFiles' => false
        );

        $variations = $this->find($options);
        if(!empty($options['dryrun'])) $defaults['dryRun'] = $options['dryrun']; // case insurance
        $options = array_merge($defaults, $options); // placement after getVariations() intended

        /** @var WireFileTools $files */
        $files = $this->wire('files');
        $deletedFiles = array();
        
        $this->removeExtras($this->pageimage, $deletedFiles, $options);

        foreach($variations as $variation) {
            /** @var Pageimage $variation */
            $filename = $variation->filename;
            if(!is_file($filename)) continue;
            if($options['dryRun']) {
                $success = true;
            } else {
                $success = $files->unlink($filename, true);
            }
            if($success) $deletedFiles[] = $filename;
            $this->removeExtras($variation, $deletedFiles, $options);
        }

        if(!$options['dryRun']) $this->variations = null;

        return ($options['dryRun'] || $options['getFiles'] ? $deletedFiles : $this);
    }

    /**
     * Remove extras
     * 
     * @param Pageimage $pageimage
     * @param array $deletedFiles
     * @param array $options See options for remove() method
     * 
     */
    protected function removeExtras(Pageimage $pageimage, array &$deletedFiles, array $options) {
        foreach($pageimage->extras() as $extra) {
            if(!$extra->exists()) {
                // nothing to do
            } else if(!empty($options['dryRun'])) {
                $deletedFiles[] = $extra->filename();
            } else if($extra->unlink()) {
                $deletedFiles[] = $extra->filename();
            }
        }
    }
}