<?php namespace ProcessWire;

/**
 * ProcessWire Pageimage
 *
 * #pw-summary Represents an image item attached to a page, typically via an Image Fieldtype. 
 * #pw-summary-variations A variation refers to an image that is based upon another (like a resized or cropped version for example). 
 * #pw-order-groups common,resize-and-crop,variations,other
 * #pw-use-constructor
 * #pw-body = 
 * Pageimage objects are usually contained by a `Pageimages` object, which is a type of `Pagefiles` and `WireArray`. 
 * In addition to the methods and properties below, you'll also want to look at `Pagefile` which this class inherits
 * several important methods and properties from. 
 * 
 * ~~~~~
 * // Example of outputting a thumbnail gallery of Pageimage objects
 * foreach($page->images as $image) {
 *   // $image and $thumb are both Pageimage objects
 *   $thumb = $image->size(200, 200);    
 *   echo "<a href='$image->url'>"; 
 *   echo "<img src='$thumb->url' alt='$image->description' />";
 *   echo "</a>";
 * }
 * ~~~~~
 * #pw-body
 * 
 * ProcessWire 3.x, Copyright 2020 by Ryan Cramer
 * https://processwire.com
 *
 * @property-read int $width Width of image, in pixels.
 * @property-read int $height Height of image, in pixels.
 * @property-read int $hidpiWidth HiDPI width of image, in pixels. #pw-internal
 * @property-read int $hidpiHeight HiDPI heigh of image, in pixels. #pw-internal
 * @property-read string $error Last image resizing error message, when applicable. #pw-group-resize-and-crop
 * @property-read Pageimage $original Reference to original $image, if this is a resized version. #pw-group-variations
 * @property-read array $focus Focus array contains 'top' (float), 'left' (float), 'zoom' (int), and 'default' (bool) properties.
 * @property-read string $focusStr Readable string containing focus information.
 * @property-read bool $hasFocus Does this image have custom focus settings? (i.e. $focus['default'] == true)
 * @property-read array $suffix Array containing file suffix(es).
 * @property-read string $suffixStr String of file suffix(es) separated by comma.
 * @property-read string $alt Convenient alias for the 'description' property, unless overridden (since 3.0.125).
 * @property-read string $src Convenient alias for the 'url' property, unless overridden (since 3.0.125).
 * @property-read PagefileExtra $webp Access webp version of image (since 3.0.132)
 * @property-read float $ratio Image ratio where 1.0 is square, >1 is wider than tall, >2 is twice as wide as well, <1 is taller than wide, etc. (since 3.0.154+)
 *
 * Properties inherited from Pagefile
 * ==================================
 * @property-read string $url URL to the file on the server.
 * @property-read string $httpUrl URL to the file on the server including scheme and hostname.
 * @property-read string $URL Same as $url property but with browser cache busting query string appended. #pw-group-other
 * @property-read string $HTTPURL Same as the cache-busting uppercase “URL” property, but includes scheme and hostname. #pw-group-other
 * @property-read string $filename Full disk path to the file on the server.
 * @property-read string $name Returns the filename without the path, same as the "basename" property.
 * @property-read string $hash Get a unique hash (for the page) representing this Pagefile.
 * @property-read array $tagsArray Get file tags as an array. #pw-group-tags @since 3.0.17
 * @property string $basename Returns the filename without the path.
 * @property string $description Value of the file’s description field (string), if enabled. Note you can also set this property directly.
 * @property string $tags Value of the file’s tags field (string), if enabled. #pw-group-tags
 * @property string $ext File’s extension (i.e. last 3 or so characters)
 * @property-read int $filesize File size (number of bytes).
 * @property int $modified Unix timestamp of when Pagefile (file, description or tags) was last modified. #pw-group-date-time
 * @property-read string $modifiedStr Readable date/time string of when Pagefile was last modified. #pw-group-date-time
 * @property-read int $mtime Unix timestamp of when file (only) was last modified. #pw-group-date-time
 * @property-read string $mtimeStr Readable date/time string when file (only) was last modified. #pw-group-date-time
 * @property int $created Unix timestamp of when file was created. #pw-group-date-time
 * @property-read string $createdStr Readable date/time string of when Pagefile was created #pw-group-date-time
 * @property string $filesizeStr File size as a formatted string, i.e. “123 Kb”.
 * @property Pagefiles $pagefiles The Pagefiles WireArray that contains this file. #pw-group-other
 * @property Page $page The Page object that this file is part of. #pw-group-other
 * @property Field $field The Field object that this file is part of. #pw-group-other
 * @property PageimageDebugInfo $debugInfo
 *
 * Hookable methods 
 * ================
 * @method bool|array isVariation($basename, $options = array())
 * @method Pageimage crop($x, $y, $width, $height, $options = array())
 * @method array rebuildVariations($mode = 0, array $suffix = array(), array $options = array())
 * @method install($filename)
 * @method render($markup = '', $options = array())
 * @method void createdVariation(Pageimage $image, array $data) Called after new image variation created (3.0.180+)
 *
 */

class Pageimage extends Pagefile {

	/**
	 * Reference to the collection of Pageimages that this Pageimage belongs to
	 * 
	 * @var Pageimages
	 *
	 */
	protected $pageimages; 

	/**
	 * Reference to the original image this variation was created from
	 *
	 * Applicable only if this image is a variation (resized version). It will be null in all other instances. 
	 * 
	 * @var Pageimage
	 *
	 */
	protected $original = null;

	/**
	 * Cached result of the variations() method
	 *
	 * Don't reference this directly, because it won't be loaded unless requested, instead use the variations() method
	 * 
	 * @var PageimageVariations
	 *
	 */
	private $variations = null; 

	/**
	 * Cached result of the getImageInfo() method
	 *
	 * Don't reference this directly, because it won't be loaded unless requested, instead use the getImageInfo() method
	 *
	 * @var array
	 * 
	 */
	private $imageInfo = array(
		'width' => 0, 
		'height' => 0, 
	);

	/**
	 * @var PageimageDebugInfo|null
	 *
	 */
	private $pageimageDebugInfo = null; 

	/**
	 * Last size error, if one occurred. 
	 * 
	 * @var string
	 *
	 */
	protected $error = '';

	/**
	 * Last used Pageimage::size() $options argument
	 * 
	 * @var array
	 * 
	 */
	protected $sizeOptions = array();

	/**
	 * Construct a new Pageimage
	 * 
	 * ~~~~~
	 * // Construct a new Pageimage, assumes that $page->images is a FieldtypeImage Field
	 * $pageimage = new Pageimage($page->images, '/path/to/file.png');
	 * ~~~~~
	 *
	 * @param Pageimages|Pagefiles $pagefiles 
	 * @param string $filename Full path and filename to this pagefile
	 * @throws WireException
	 *
	 */
	public function __construct(Pagefiles $pagefiles, $filename) {

		if(!$pagefiles instanceof Pageimages) throw new WireException("Pageimage::__construct requires instance of Pageimages"); 
		$pagefiles->wire($this);
		$this->pageimages = $pagefiles; 
		parent::__construct($pagefiles, $filename); 
	}

	/**
	 * When a Pageimage is cloned, we reset it's width and height to force them to reload in the clone
	 * 
	 * #pw-internal
	 *
	 */
	public function __clone() {
		$this->imageInfo['width'] = 0; 
		$this->imageInfo['height'] = 0;
		$this->pageimageDebugInfo = null;
		$this->variations = null;
		parent::__clone();
	}

	/**
	 * Return the web accessible URL to this image file
	 * 
	 * #pw-hooks
	 * 
	 * @return string
	 *
	 */
	public function url() {
		$hooks = $this->wire()->hooks;
		if($hooks->isHooked('Pagefile::url()') || $hooks->isHooked('Pageimage::url()')) { 
			return $this->__call('url', array()); 
		} else { 
			return $this->___url();
		}
	}

	/**
	 * Returns the full disk path to the image file
	 * 
	 * #pw-hooks
	 * 
	 * @return string
	 *
	 */
	public function filename() {
		$hooks = $this->wire()->hooks;
		if($hooks->isHooked('Pagefile::filename()') || $hooks->isHooked('Pageimage::filename()')) { 
			return $this->__call('filename', array()); 
		} else { 
			return $this->___filename();
		}
	}

	/**
	 * Returns array of suffixes for this file, or true/false if this file has the given suffix.
	 * 
	 * When providing a suffix, this method can be thought of: hasSuffix(suffix)
	 * 
	 * #pw-group-other
	 * 
	 * @param string $s Optionally provide suffix to return true/false if file has the suffix
	 * @return array|bool Returns array of suffixes, or true|false if given a suffix in the arguments.
	 * 
	 */
	public function suffix($s = '') {
		$info = $this->isVariation(parent::get('basename')); 
		if(strlen($s)) {
			return $info ? in_array($s, $info['suffix']) : false;
		} else {
			return $info ? $info['suffix'] : array();
		}
	}

	/**
	 * Get or set focus area for crops to use
	 * 
	 * These settings are used by $this->size() calls that specify BOTH width AND height. Focus helps to 
	 * ensure that the important subject of the photo is not cropped out when the requested size proportion
	 * differs from the original image proportion. For example, not chopping off someone’s head in a photo.
	 * 
	 * Default behavior is to return an array containing "top" and "left" indexes, representing percentages
	 * from top and left. When arguments are specified, you are either setting the top/left percentages, or
	 * unsetting focus, or getting focus in different ways, described in arguments below. 
	 * 
	 * A zoom argument/property is also present here for future use, but not currently supported. 
	 * 
	 * #pw-group-other
	 * 
	 * @param null|float|int|array|false $top Omit to get focus array, or specify one of the following:
	 *   - GET: Omit all arguments to get focus array (default behavior). 
	 *   - GET: Specify boolean TRUE to return TRUE if focus data is present or FALSE if not. 
	 *   - GET: Specify integer 1 to make this method return pixel dimensions rather than percentages. 
	 *   - SET: Specify both $top and $left arguments to set (values assumed to be percentages).
	 *   - SET: Specify array containing "top" and "left" indexes to set (percentages). 
	 *   - SET: Specify array where index 0 is top and index 1 is left (percentages). 
	 *   - SET: Specify string in the format "top left", i.e. "25 70" or "top left zoom", i.e. "25 70 30" (percentages).
	 *   - SET: Specify CSV key=value string in the format "top=25%, left=70%, zoom=30%" in any order
	 *   - UNSET: Specify boolean false to remove any focus values. 
	 * @param null|float|int $left Set left value (when $top value is float|int) 
	 *   - This argument is only used when setting focus and should be omitted otherwise. 
	 * @param null|int $zoom Zoom percent (not currently supported)
	 * @return array|bool|Pageimage Returns one of the following: 
	 *   - When getting returns array containing top, left and default properties. 
	 *   - When TRUE was specified for the $top argument, it returns either TRUE (has focus) or FALSE (does not have). 
	 *   - When setting or unsetting returns $this.
	 * 
	 */
	public function focus($top = null, $left = null, $zoom = null) {
		
		if(is_string($top) && $left === null) { 
			if(strpos($top, '=')) {
				// SET string "top=25%, left=70%, zoom=0%"
				$a = array('top' => 50, 'left' => 50, 'zoom' => 0);
				$parts = explode(',', str_replace(array(' ', '%'), '', $top));
				foreach($parts as $part) {
					if(!strpos($part, '=')) continue;
					list($name, $pct) = explode('=', $part);
					$a[$name] = strpos($pct, '.') !== false ? (float) $pct : (int) $pct;
				}
				$top = $a; // for later setting by array
				unset($a);
			} else if(strpos($top, ' ')) {
				// SET string like "25 70 0" (representing "top left zoom")
				if(strpos($top, ' ') != strrpos($top, ' ')) {
					// with zoom
					list($top, $left, $zoom) = explode(' ', $top, 3);
				} else {
					// without zoom
					list($top, $left) = explode(' ', $top, 2);
					$zoom = 0;
				}
			}
		}
		
		if($top === null || $top === true || ($top === 1 && $left === null)) {
			// GET
			$focus = $this->filedata('focus');
			if(!is_array($focus) || empty($focus)) {
				// use default
				if($top === true) return false;
				$focus = array(
					'top' => 50, 
					'left' => 50,
					'zoom' => 0,
					'default' => true, 
					'str' => '50 50 0',
				);
			} else {
				// use custom
				if($top === true) return true;
				if(!isset($focus['zoom'])) $focus['zoom'] = 0;
				$focus['default'] = false;
				$focus['str'] = "$focus[top] $focus[left] $focus[zoom]";
			}
			if($top === 1) {
				// return pixel dimensions rather than percentages
				$centerX = ($focus['left'] / 100) * $this->width(); // i.e. (50 / 100) * 500 = 250;
				$centerY = ($focus['top'] / 100) * $this->height();
				$focus['left'] = $centerX;
				$focus['top'] = $centerY;
			}
			return $focus;
			
		} else if($top === false) {
			// UNSET
			$this->filedata(false, 'focus');
			
		} else if($left !== null) {
			// SET
			if(is_array($top)) {
				if(isset($top['left'])) {
					$left = $top['left'];
					$top = $top['top'];
					$zoom = isset($top['zoom']) ? $top['zoom'] : 0;
				} else {
					$top = $top[0];
					$left = $top[1];
					$zoom = isset($top[2]) ? $top[2] : 0;
				}
			}
			
			$top = (float) $top;
			$left = (float) $left;
			$zoom = (int) $zoom;
			
			if(((int) $top) == 50 && ((int) $left) == 50 && ($zoom < 2)) {
				// if matches defaults, then no reason to store in filedata
				$this->filedata(false, 'focus');
			} else {
				$this->filedata('focus', array(
					'top' => round($top, 1),
					'left' => round($left, 1),
					'zoom' => $zoom
				));
			}
		}
		
		return $this;
	}

	/**
	 * Set property
	 * 
	 * @param string $key
	 * @param mixed $value
	 * @return Pageimage|WireData
	 * 
	 */
	public function set($key, $value) {
		if($key === 'sizeOptions' && is_array($value)) {
			$this->sizeOptions = $value;
			return $this;
		} else {
			return parent::set($key, $value);
		}
	}
	
	/**
	 * Get a property from this Pageimage
	 * 
	 * #pw-internal
	 * 
	 * @param string $key
	 * @return mixed
	 *
	 */
	public function get($key) {
		switch($key) {
			case 'width':
			case 'height':
				$value = $this->$key();
				break;
			case 'hidpiWidth':
			case 'retinaWidth':
				$value = $this->hidpiWidth();
				break;
			case 'hidpiHeight':
			case 'retinaHeight':
				$value = $this->hidpiHeight();
				break;
			case 'original':
				$value = $this->getOriginal();
				break;
			case 'error':
				$value = $this->error;
				break;
			case 'focus':
				$value = $this->focus();
				break;
			case 'focusStr':
				$focus = $this->focus();
				$value = "top=$focus[top]%,left=$focus[left]%,zoom=$focus[zoom]%" . ($focus['default'] ? " (default)" : "");
				break;
			case 'hasFocus': 
				$value = $this->focus(true);
				break;
			case 'suffix':
				$value = $this->suffix();
				break;
			case 'suffixStr':
				$value = implode(',', $this->suffix());
				break;
			case 'alt':
				$value = parent::get('alt');
				if($value === null) $value = $this->description();
				break;
			case 'src':
				$value = parent::get('src');
				if($value === null) $value = $this->url();
				break;
			case 'webp':	
				$value = $this->webp();
				break;
			case 'hasWebp':	
				$value = $this->webp()->exists();
				break;
			case 'webpUrl': 
				$value = $this->webp()->url();
				break;
			case 'webpFilename': 
				$value = $this->webp()->filename();
				break;
			case 'debugInfo':
				if(!$this->pageimageDebugInfo) $this->pageimageDebugInfo = new PageimageDebugInfo($this);
				$value = $this->pageimageDebugInfo;
				break;
			case 'sizeOptions':	
				$value = $this->sizeOptions;
				break;
			case 'ratio':
				$value = $this->ratio();
				break;
			default: 
				$value = parent::get($key); 
		}
		return $value; 
	}

	/**
	 * Set image info (internal use)
	 * 
	 * #pw-internal
	 * 
	 * @param array $info
	 * 
	 */
	public function setImageInfo(array $info) {
		// width and height less than 0 indicate percentage rather than pixels
		if(isset($info['width']) && $info['width'] < 0) $info['width'] = abs($info['width']) . '%';
		if(isset($info['height']) && $info['height'] < 0) $info['height'] = abs($info['height']) . '%';
		$this->imageInfo = array_merge($this->imageInfo, $info);
	}
	
	/**
	 * Gets the image information with PHP’s getimagesize function and caches the result
	 * 
	 * #pw-internal
	 * 
	 * @param bool|string $reset Specify true to retrieve info fresh, or filename to check and return info for. 
	 *   When specifying a filename, the info is only returned (not populated with this object). 
	 * @return array
	 * 
	 */
	public function getImageInfo($reset = false) {

		$imageInfo = $this->imageInfo;
		$filename = is_string($reset) && file_exists($reset) ? $reset : ''; 
		$ext = $this->ext;
	
		if(!$reset && $imageInfo['width'] && !$filename) {
			return $imageInfo;
		}

		if($ext == 'svg') {
			$imageInfo = array_merge($imageInfo, $this->getImageInfoSVG($filename));
		} else {
			if($filename) {
				$info = @getimagesize($filename);
			} else {
				$info = @getimagesize($this->filename);
			}
			if((!$info || empty($info[0])) && !empty($this->sizeOptions['_width'])) {
				// on fail, fallback to size options that were requested for the image (if available)
				$imageInfo['width'] = $this->sizeOptions['_width'];
				$imageInfo['height'] = $this->sizeOptions['_height'];
			} else if($info) {
				$imageInfo['width'] = $info[0];
				$imageInfo['height'] = $info[1];
				if(function_exists('exif_read_data') && ($ext === 'jpg' || $ext === 'jpeg')) {
					$exif = $filename ? @exif_read_data($filename) : @exif_read_data($this->filename);
					if(!empty($exif['Orientation']) && (int) $exif['Orientation'] > 4) {
						// Image has portrait orientation so reverse width and height info
						$imageInfo['width'] = $info[1];
						$imageInfo['height'] = $info[0];
					}
				}
			}
		}
		
		if(!$filename) $this->imageInfo = $imageInfo;

		return $imageInfo; 
	}

	/**
	 * Gets the image info/size of an SVG
	 *
	 * Returned width and height values may be integers OR percentage strings.
	 *
	 * #pw-internal
	 *
	 * @param string $filename Optional filename to check
	 * @return array of width and height
	 *
	 */
	protected function getImageInfoSVG($filename = '') {
		$width = 0;
		$height = 0;
		if(!$filename) $filename = $this->filename;
		$xml = @file_get_contents($filename);
		
		if($xml) {
			$a = @simplexml_load_string($xml)->attributes();
			if((int) $a->width > 0) $width = (int) $a->width;
			if((int) $a->height > 0) $height = (int) $a->height;
			if((!$width || !$height) && $a->viewBox) {
				$values = explode(' ', $a->viewBox);
				if(count($values) === 4) {
					$width = (int) round($values[2]);
					$height = (int) round($values[3]);
				}
			}
		}
		
		if((!$width || !$height) && (extension_loaded('imagick') || class_exists('\IMagick'))) {
			try {
				$imagick = new \Imagick();
				$imagick->readImage($filename);
				$width = $imagick->getImageWidth();
				$height = $imagick->getImageHeight();
			} catch(\Exception $e) {
				// fallback to 100%
			}
		}
		
		if($width < 1) $width = '100%';
		if($height < 1) $height = '100%';
		
		return array(
			'width' => $width, 
			'height' => $height
		); 
	}
	
	/**
	 * Return an image (Pageimage) sized/cropped to the specified dimensions. 
	 * 
	 * `$thumb = $image->size($width, $height, $options);`
	 * 
	 * The default behavior of this method is to simply create and return a new resized version of the image,
	 * leaving the original in tact. Width and height of the target size are the the first two arguments. 
	 * The third argument called `$options` enables you to modify the default behavior of the size() method 
	 * in various ways. This method only creates the newly sized image once, and then it caches it. Future 
	 * calls simply refer back to the previously resized image. 
	 * 
	 * ~~~~~
	 * // Get an image to resize
	 * $image = $page->images->first();
	 * 
	 * // Create 400x300 thumbnail cropped to center
	 * $thumb = $image->size(400, 300); 
	 * 
	 * // Create thumbnail with cropping to top
	 * $thumb = $image->size(400, 300, 'north'); 
	 * 
	 * // Create thumbnail while specifying $options
	 * $thumb = $image->size(400, 300, [
	 *   'cropping' => 'north',
	 *   'quality' => 60, 
	 *   'upscaling' => false,
	 *   'sharpening' => 'medium'
	 * ]); 
	 * 
	 * // Output thumbnail
	 * echo "<img src='$thumb->url' />";
	 * 
	 * // Create image of size predefined in $config->imageSizes (3.0.151+)
	 * $photo = $image->size('landscape'); 
	 * ~~~~~
	 * 
	 * **About the $options argument**
	 * 
	 * - If given a *string*, it is assumed to be the value for the "cropping" option. 
	 * - If given an *integer*, it is assumed you are specifying a "quality" value (1-100). 
	 * - If given a *boolean*, it is assumed you are specifying whether or not you want to allow "upscaling".
	 * - If given an *array*, you may specify any of the options below together: 
	 * 
	 * **All available $options**
	 * 
	 *  - `quality` (int): Quality setting 1-100 (default=90, or as specified in /site/config.php).
	 *  - `upscaling` (bool): Allow image to be upscaled? (default=true).
	 *  - `cropping` (string|bool|array): Cropping mode, see possible values in "cropping" section below (default=true).
	 *  - `suffix` (string|array): Suffix word to identify the new image, or use array of words for multiple (default=none).
	 *  - `forceNew` (bool): Force re-creation of the image even if it already exists? (default=false).
	 *  - `sharpening` (string): Sharpening mode: "none", "soft", "medium", or "strong" (default=soft).
	 *  - `autoRotation` (bool): Automatically correct rotation of images that provide this info? (default=true)
	 *  - `rotate` (int): Rotate the image this many degrees, specify one of: 0, -270, -180, -90, 90, 180, or 270 (default=0).
	 *  - `flip` (string): To flip, specify either "vertical" or "horizontal" (default=none).
	 *  - `hidpi` (bool): Use HiDPI/retina pixel doubling? (default=false).
	 *  - `hidpiQuality` (bool): Quality setting for HiDPI (default=40, typically lower than regular quality setting). 
	 *  - `cleanFilename` (bool): Clean filename of historical resize information for shorter filenames? (default=false).
	 *  - `nameWidth` (int): Width to use for filename (default is to use specified $width argument).
	 *  - `nameHeight` (int): Height to use for filename (default is to use specified $height argument). 
	 *  - `focus` (bool): Should resizes that result in crop use focus area if available? (default=true). 
	 *     In order for focus to be applicable, resize must include both width and height. 
	 *  - `allowOriginal` (bool): Return original if already at width/height? May not be combined with other options. (default=false)
	 *  - `webpAdd` (bool): Also create a secondary .webp image variation? (default=false)
	 *  - `webpQuality` (int): Quality setting for extra webp images (default=90). 
	 * 
	 * **Possible values for "cropping" option**  
	 * 
	 *  - `true` (bool): Auto detect and allow use of focus (default).
	 *  - `false` (bool): Disallow cropping. 
	 *  - `center` (string): to crop to center of image.
	 *  - `x111y222` (string): to crop by pixels, 111px from left and 222px from top (replacing 111 and 222 with your values).
	 *  - `north` (string): Crop North (top), may also be just "n".
	 *  - `northwest` (string): Crop from Northwest (top left), may also be just "nw".
	 *  - `northeast` (string): Crop from Northeast (top right), may also be just "ne".
	 *  - `south` (string): Crop South (bottom), may also be just "s".
	 *  - `southwest` (string): Crop Southwest (bottom left), may also be just "sw".
	 *  - `southeast` (string): Crop Southeast (bottom right), may also be just "se".
	 *  - `west` (string): Crop West (left), may also be just "w".
	 *  - `east` (string): Crop East (right), may alos be just "e".
	 *  - `blank` (string): Specify a blank string to disallow cropping during resize.
	 *  - `array(111,222)` (array): Array of integers index 0 is left pixels and index 1 is top pixels.
	 *  - `array('11%','22%')` (array): Array of '%' appended strings where index 0 is left percent and index 1 is top percent.
	 * 
	 * **Note about "quality" and "upscaling" options** 
	 * 
	 * ProcessWire doesn't keep separate copies of images with different "quality" or "upscaling" values. 
	 * If you change these and a variation image at the existing dimensions already exists, then you'll still get the old version. 
	 * To clear out an old version of an image, use the `Pageimage::removeVariations()` method in this class before calling 
	 * size() with new quality or upscaling settings.
	 * 
	 * #pw-group-resize-and-crop
	 * #pw-group-common
	 * #pw-hooks
	 *
	 * @param int|string $width Target width of new image or (3.0.151+) specify prefined image size name
	 * @param int|array $height Target height of new image or (3.0.151+) options array if no height argument needed
	 * @param array|string|int $options Array of options to override default behavior: 
	 *  - Specify `array` of options as indicated in the section above. 
	 *  - Or you may specify type `string` containing "cropping" value.
	 *  - Or you may specify type `int` containing "quality" value.
	 *  - Or you may specify type `bool` containing "upscaling" value.
	 * @return Pageimage Returns a new Pageimage object that is a variation of the original. 
	 *  If the specified dimensions/options are the same as the original, then the original will be returned.
	 *
	 */
	public function size($width, $height = 0, $options = array()) {
		
		if(is_array($height)) {
			$options = $height;
			$height = 0;
		}
		
		if(!is_array($options)) {
			$options = $this->sizeOptionsToArray($options);
		}
		
		if(is_string($width) && $width && !ctype_digit($width)) {
			// named image size
			return $this->sizeName($width, $options);
		}

		if($this->wire()->hooks->isHooked('Pageimage::size()')) {
			$result = $this->__call('size', array($width, $height, $options)); 
		} else {  
			$result = $this->___size($width, $height, $options);
		}

		if($result && $result !== $this) {
			$options['_width'] = $width;
			$options['_height'] = $height;
			$result->set('sizeOptions', $options);
		}
		
		return $result;
	}

	/**
	 * Hookable version of size() with implementation
	 *	
	 * See comments for size() method above.
	 * 
	 * #pw-internal
	 * 
	 * @param int $width
	 * @param int $height
	 * @param array|string|int $options
	 * @return Pageimage
	 *
	 */
	protected function ___size($width, $height, $options) {

		$this->error = '';
		if($this->ext === 'svg') return $this; 
		if(!is_array($options)) $options = $this->sizeOptionsToArray($options);
		
		// originally requested options
		$requestOptions = $options;
	
		// default options
		$defaultOptions = array(
			'upscaling' => true,
			'cropping' => true,
			'interlace' => false, 
			'sharpening' => 'soft',
			'quality' => 90,
			'hidpiQuality' => 40, 
			'webpQuality' => 90,
			'webpAdd' => false,
			'webpName' => '', // use this for the webp file basename rather than mirroring from the jpg/png
			'webpOnly' => false, // only keep the webp version (requires webpAdd option)
			'suffix' => array(), // can be array of suffixes or string of 1 suffix
			'forceNew' => false,  // force it to create new image even if already exists
			'hidpi' => false, 
			'cleanFilename' => false, // clean filename of historial resize information
			'rotate' => 0,
			'flip' => '', 
			'nameWidth' => null, // override width to use for filename, int when populated
			'nameHeight' => null,  // override height to use for filename, int when populated
			'focus' => true, // allow single dimension resizes to use focus area?
			'zoom' => null, // zoom override, used only if focus is applicable, int when populated
			'allowOriginal' => false, // Return original image if already at requested dimensions? (must be only specified option)
		);

		$files = $this->wire()->files;
		$config = $this->wire()->config;
		
		$debug = $config->debug;
		$configOptions = $config->imageSizerOptions; 
		$webpOptions = $config->webpOptions;
		$createdVariationHookData = null; // populated as array only when new variation created (for createdVariation hook)
		
		if(!empty($webpOptions['quality'])) $defaultOptions['webpQuality'] = $webpOptions['quality'];
		
		if(!is_array($configOptions)) $configOptions = array();
		$options = array_merge($defaultOptions, $configOptions, $options); 
		if($options['cropping'] === 1) $options['cropping'] = true;

		$width = (int) $width;
		$height = (int) $height;
		
		if($options['allowOriginal'] && count($requestOptions) === 1) {
			if((!$width || $this->width() == $width) && (!$height || $this->height() == $height)) {
				// return original image if already at requested width/height
				return $this;
			}
		}
	
		if($options['cropping'] === true 
			&& empty($options['cropExtra']) 
			&& $options['focus'] && $this->hasFocus 
			&& $width && $height) {
			// crop to focus area
			$focus = $this->focus();
			if(is_int($options['zoom'])) $focus['zoom'] = $options['zoom']; // override
			$options['cropping'] = array("$focus[left]%", "$focus[top]%", "$focus[zoom]"); 
			$crop = ''; // do not add suffix	
			
		} else if(is_string($options['cropping'])
			&& strpos($options['cropping'], 'x') === 0
			&& preg_match('/^x(\d+)[yx](\d+)/', $options['cropping'], $matches)) {
			$options['cropping'] = true; 
			$options['cropExtra'] = array((int) $matches[1], (int) $matches[2], $width, $height); 
			$crop = '';
			
		} else {
			$crop = ImageSizer::croppingValueStr($options['cropping']);
		}
	
		if(!is_array($options['suffix'])) {
			// convert to array
			$options['suffix'] = empty($options['suffix']) ? array() : explode(' ', $options['suffix']); 
		}

		if($options['rotate'] && !in_array(abs((int) $options['rotate']), array(90, 180, 270))) {
			$options['rotate'] = 0;
		}
		if($options['rotate']) {
			$options['suffix'][] = ($options['rotate'] > 0 ? "rot" : "tor") . abs($options['rotate']);
		}
		if($options['flip']) {
			$options['suffix'][] = strtolower(substr($options['flip'], 0, 1)) == 'v' ? 'flipv' : 'fliph';
		}
		
		$suffixStr = '';
		if(!empty($options['suffix'])) {
			$suffix = $options['suffix'];
			sort($suffix); 
			foreach($suffix as $key => $s) {
				$s = strtolower($this->wire()->sanitizer->fieldName($s)); 
				if(empty($s)) {
					unset($suffix[$key]);
				} else {
					$suffix[$key] = $s;
				}
			}
			if(count($suffix)) $suffixStr = '-' . implode('-', $suffix); 
		}
		
		if($options['hidpi']) {
			$suffixStr .= '-hidpi';
			if($options['hidpiQuality']) $options['quality'] = $options['hidpiQuality'];
		}

		$originalName = $this->basename();
		// determine basename without extension, i.e. myfile
		$basename = basename($originalName, "." . $this->ext()); 
		$originalSize = $debug ? @filesize($this->filename) : 0;
		
		if($options['cleanFilename'] && strpos($basename, '.') !== false) {
			$basename = substr($basename, 0, strpos($basename, '.')); 
		}
		
		// filename uses requested width/height unless another specified via nameWidth or nameHeight options
		$nameWidth = is_int($options['nameWidth']) ? $options['nameWidth'] : $width;
		$nameHeight = is_int($options['nameHeight']) ? $options['nameHeight'] : $height;
		
		// i.e. myfile.100x100.jpg or myfile.100x100nw-suffix1-suffix2.jpg
		$basenameNoExt = $basename . '.' . $nameWidth . 'x' . $nameHeight . $crop . $suffixStr;  // basename without ext
		$basename = $basenameNoExt . '.' . $this->ext(); // basename with ext
		
		$filenameUnvalidated = '';
		$filenameUnvalidatedWebp = '';
		
		$filenameFinal = $this->pagefiles->path() . $basename;
		$filenameFinalExists = file_exists($filenameFinal);

		if(!empty($options['webpName'])) {
			$filenameFinalWebp = $this->pagefiles->path() . basename($options['webpName'], '.webp') . '.webp';
		} else if(!empty($webpOptions['useSrcExt'])) {
			$filenameFinalWebp = $this->pagefiles->path() . $basename . '.webp'; // file.jpg.webp
		} else {
			$filenameFinalWebp = $this->pagefiles->path() . $basenameNoExt . '.webp'; // file.webp
		}
		
		// force new creation if requested webp copy doesn't exist, (regardless if regular variation exists or not)
		if($options['webpAdd'] && !file_exists($filenameFinalWebp)) $options['forceNew'] = true;
		
		// create a new resize if it doesn't already exist or forceNew option is set
		if(!$filenameFinalExists && !file_exists($this->filename())) {
			// no original file exists to create variation from 
			$this->error = "Original image does not exist to create size variation: " . $this->url();
			
		} else if(!$filenameFinalExists || $options['forceNew']) {

			// filenameUnvalidated is temporary filename used for resize
			$tempDir = $this->pagefiles->page->filesManager()->getTempPath();
			$filenameUnvalidated = $tempDir . $basename;
			$filenameUnvalidatedWebp = $tempDir . $basenameNoExt . '.webp';
			
			if($filenameFinalExists && $options['forceNew']) $files->unlink($filenameFinal, true);
			if(file_exists($filenameFinalWebp) && $options['forceNew']) $files->unlink($filenameFinalWebp, true);
			
			if(file_exists($filenameUnvalidated)) $files->unlink($filenameUnvalidated, true);
			if(file_exists($filenameUnvalidatedWebp)) $files->unlink($filenameUnvalidatedWebp, true);

			if($files->copy($this->filename(), $filenameUnvalidated)) {
				try { 
					
					$timer = $debug ? Debug::timer() : null;
					
					/** @var ImageSizer $sizer */
					$sizer = $this->wire(new ImageSizer($filenameUnvalidated, $options));
					
					/** @var ImageSizerEngine $engine */
					$engine = $sizer->getEngine();

					/* if the current engine installation does not support webp, modify the options param */
					if(!empty($options['webpAdd']) && !$engine->supported('webp')) {
						// no engines support webp
						$options['webpAdd'] = false;
						$options['webpOnly'] = false;
						$engine->setOptions($options);
					}

					// allow for ImageSizerEngine module settings for quality and sharpening to override system defaults
					// when they are not specified as an option to this resize() method
					$engineConfigData = $engine->getConfigData();
					if(!empty($engineConfigData)) {
						if(!empty($engineConfigData['quality']) && empty($options['hidpi']) && empty($requestOptions['quality'])) {
							$engine->setQuality($engineConfigData['quality']);
							$options['quality'] = $engineConfigData['quality'];
						}
						if(!empty($engineConfigData['sharpening']) && empty($requestOptions['sharpening'])) {
							$engine->setSharpening($engineConfigData['sharpening']);
							$options['sharpening'] = $engineConfigData['sharpening'];
						}
					}
					
					if($sizer->resize($width, $height)) {
						if($options['webpAdd'] && $options['webpOnly']) {
							if(is_file($filenameUnvalidated)) $files->unlink($filenameUnvalidated);
						} else {
							clearstatcache();
							if(!$files->rename($filenameUnvalidated, $filenameFinal)) {
								if($files->exists($filenameFinal)) {
									// potential race condition: another request won
								} else {
									$this->error = "Rename failed: $filenameUnvalidated => $filenameFinal";
								}
							}
						}
						if($options['webpAdd'] && file_exists($filenameUnvalidatedWebp)) { 
							$files->rename($filenameUnvalidatedWebp, $filenameFinalWebp);
						}
					} else {
						$this->error = "ImageSizer::resize($width, $height) failed for $filenameUnvalidated";
					}
					
					if($debug && empty($options['webpOnly'])) $this->wire()->log->save('image-sizer',
						str_replace('ImageSizerEngine', '', $sizer->getEngine()) . ' ' . 
						($this->error ? "FAILED Resize: " : "Resized: ") . "$originalName => " . basename($filenameFinal) . " " . 
						"({$width}x{$height}) " . Debug::timer($timer) . " secs $originalSize => " . filesize($filenameFinal) . " bytes " . 
						"(quality=$options[quality], sharpening=$options[sharpening]) "
					);
					
					if(!$this->error) {
						$createdVariationHookData = array(
							'width' => $width,
							'height' => $height,
							'options' => $options,
							'filenameUnvalidated' => $filenameUnvalidated,
							'filenameFinal' => $filenameFinal,
							'filenameUnvalidatedWebp' => $filenameUnvalidatedWebp,
							'filenameFinalWebp' => $filenameFinalWebp,
						);
					}
					
				} catch(\Exception $e) {
					$this->trackException($e, false); 
					$this->error = $e->getMessage(); 
				}
			} else {
				$this->error = "Unable to copy $this->filename => $filenameUnvalidated"; 
			}
		}

		$pageimage = clone $this; 

		// if desired, user can check for property of $pageimage->error to see if an error occurred. 
		// if an error occurred, that error property will be populated with details
		if($this->error) { 
			// error condition: unlink copied files
			if($filenameFinal && $files->exists($filenameFinal)) $files->unlink($filenameFinal, true);
			if($filenameUnvalidated && $files->exists($filenameUnvalidated)) $files->unlink($filenameUnvalidated);
			if($filenameFinalWebp && $files->exists($filenameFinalWebp)) $files->unlink($filenameFinalWebp, true);
			if($filenameUnvalidatedWebp && $files->exists($filenameUnvalidatedWebp)) $files->unlink($filenameUnvalidatedWebp);

			// we also tell PW about it for logging and/or admin purposes
			$this->error($this->error);
			$logError = str_replace($config->paths('root'), $config->urls('root'), $filenameFinal)  . " - $this->error";
			$this->wire()->log->save('image-sizer', $logError);
		}

		$pageimage->setFilename($filenameFinal); 	
		$pageimage->setOriginal($this); 
		
		if($createdVariationHookData) $this->createdVariation($pageimage, $createdVariationHookData); 

		return $pageimage; 
	}

	protected function sizeOptionsToArray($options) {
		if(is_array($options)) return $options;
		if(is_string($options)) {
			// optionally allow a string to be specified with crop direction, for shorter syntax
			if(strpos($options, ',') !== false) $options = explode(',', $options); // 30,40
			$options = array('cropping' => $options);
		} else if(is_int($options)) {
			// optionally allow an integer to be specified with quality, for shorter syntax
			$options = array('quality' => $options);
		} else if(is_bool($options)) {
			// optionally allow a boolean to be specified with upscaling toggle on/off
			$options = array('upscaling' => $options);
		} else {
			// unknown options type
			$options = array();
		}
		return $options;
	}
	
	/**
	 * Same as size() but with width/height assumed to be hidpi width/height
	 * 
	 * #pw-internal
	 * 
	 * @param int|string $width
	 * @param int|array $height
	 * @param array $options See options in size() method. 
	 * @return Pageimage
	 *
	 */
	public function hidpiSize($width, $height = 0, $options = array()) {
		if(is_array($height)) {
			$height['hidpi'] = true;
		} else {
			$options['hidpi'] = true;
		}
		return $this->size($width, $height, $options);
	}

	/**
	 * Return image of size indicated by predefined setting
	 * 
	 * Settings for predefined sizes can be specified in `$config->imageSizes` array. 
	 * Each named item in this array must contain at least 'width' and 'height, but can also
	 * contain any other option from the `Pageimage::size()` argument `$options`. 
	 * 
	 * @param string $name Image size name
	 * @param array $options Optionally add or override options defined for size. 
	 * @return Pageimage
	 * @since 3.0.151
	 * @throws WireException If given a $name that is not present in $config->imageSizes
	 * 
	 */
	public function sizeName($name, array $options = array()) {
		$sizes = $this->wire()->config->imageSizes; 
		if(!isset($sizes[$name])) throw new WireException("Unknown image size '$name' (not in \$config->imageSizes)"); 
		$size = $sizes[$name];
		$options = array_merge($size, $options);
		unset($options['width'], $options['height']); 
		if(!isset($size['width'])) $size['width'] = 0;
		if(!isset($size['height'])) $size['height'] = 0;
		return $this->size((int) $size['width'], (int) $size['height'], $options);
	}

	/**
	 * Create a crop and return it as a new Pageimage.
	 * 
	 * ~~~~~
	 * // Create a crop starting 100 pixels from left, 200 pixels from top
	 * // at 150 pixels wide and 100 pixels tall
	 * $image = $page->images->first();
	 * $crop = $image->crop(100, 200, 150, 100); 
	 * 
	 * // Output the crop
	 * echo "<img src='$crop->url' />";
	 * ~~~~~
	 * 
	 * #pw-group-resize-and-crop
	 * 
	 * @param int $x Starting X position (left) in pixels
	 * @param int $y Starting Y position (top) in pixels
	 * @param int $width Width of crop in pixels
	 * @param int $height Height of crop in pixels
	 * @param array $options See options array for `Pageimage::size()` method. 
	 *   Avoid setting crop properties in $options since we are overriding them.
	 * @return Pageimage
	 *
	 */
	public function ___crop($x, $y, $width, $height, $options = array()) {
		
		$x = (int) $x;
		$y = (int) $y;
		$width = (int) $width;
		$height = (int) $height;
		
		if(empty($options['suffix'])) {
			$options['suffix'] = array();
		} else if(!is_array($options['suffix'])) {
			$options['suffix'] = array($options['suffix']); 
		}
		
		$options['suffix'][] = "cropx{$x}y{$y}"; 
		$options['cropExtra'] = array($x, $y, $width, $height);
		$options['cleanFilename'] = true; 
		
		return $this->size($width, $height, $options);
	}

	/**
	 * Return the width of this image OR return an image sized with a given width (and proportional height).
	 *
	 * - If given a width, it'll return a new Pageimage object sized to that width. 
	 * - If not given a width, it'll return the current width of this Pageimage.
	 * 
	 * #pw-group-resize-and-crop
	 * #pw-group-common
	 * 
	 * ~~~~~
	 * // Get width of image
	 * $px = $image->width();
	 * 
	 * // Create a new variation at 200px width
	 * $thumb = $image->width(200); 
	 * ~~~~~
	 *
	 * @param int $n Optional width if you are creating a new size. 
	 * @param array|string|int|bool $options See `Pageimage::size()` $options argument for details. 
	 * @return int|Pageimage Returns width (in px) when given no arguments, or Pageimage when given a width argument.
	 *
	 */
	public function width($n = 0, $options = array()) {
		if($n) return $this->size($n, 0, $options); 	
		$info = $this->getImageInfo();
		return $info['width']; 
	}

	/**
	 * Return the height of this image OR return an image sized with a given height (and proportional width).
	 *
	 * - If given a height, it'll return a new Pageimage object sized to that height. 
	 * - If not given a height, it'll return the height of this Pageimage.
	 * 
	 * #pw-group-resize-and-crop
	 * #pw-group-common
	 * 
	 * ~~~~~
	 * // Get height of image
	 * $px = $image->height();
	 *
	 * // Create a new variation at 200px height
	 * $thumb = $image->height(200);
	 * ~~~~~
	 *
	 * @param int $n Optional height if you are creating a new size.
	 * @param array|string|int|bool $options See `Pageimage::size()` $options argument for details. 
	 * @return int|Pageimage Returns height (in px) when given no arguments, or Pageimage when given a height argument.
	 *
	 */
	public function height($n = 0, $options = array()) {
		if($n) return $this->size(0, $n, $options); 	
		$info = $this->getImageInfo();
		return $info['height']; 
	}

	/**
	 * Return width for hidpi/retina use, or resize an image for desired hidpi width
	 * 
	 * If the $width argument is omitted or provided as a float, hidpi width (int) is returned (default scale=0.5)
	 * If $width is provided (int) then a new Pageimage is returned at that width x 2 (for hidpi use).
	 * 
	 * #pw-internal
	 * 
	 * @param int|float $width Specify int to return resized image for hidpi, or float (or omit) to return current width at hidpi.
	 * @param array $options Optional options for use when resizing, see size() method for details.
	 * 	Or you may specify an int as if you want to return a hidpi width and want to calculate with that width rather than current image width.
	 * @return int|Pageimage|string
	 * 
	 */	
	public function hidpiWidth($width = 0, $options = array()) {
		
		if(is_string($width)) {
			if(ctype_digit("$width")) {
				$width = (int) $width;
			} else if($width === "100%") {
				return $this;
			} else if(ctype_digit(str_replace(".", "", $width))) {
				$width = (float) $width;
			}
		}
		
		if(is_float($width) || $width < 1) {
			// return hidpi width intended: scale omitted or provided in $width argument
			$scale = $width;
			if(!$scale || $scale < 0 || $scale > 1) $scale = 0.5;
			if($options === "100%") return $options;
			$width = is_array($options) ? 0 : (int) $options;
			if($width < 1) $width = $this->width();
			if($width === "100%") return $width;
			return ceil($width * $scale); 
		} else if($width && is_int($width)) {
			// resize intended
			if(!is_array($options)) $options = array();
			return $this->hidpiSize((int) $width, 0, $options);
		}
		
		return 0; // not possible to reach, but pleases the inspection
	}

	/**
	 * Return height for hidpi/retina use, or resize an image for desired hidpi height
	 *
	 * If the $height argument is omitted or provided as a float, hidpi height (int) is returned (default scale=0.5)
	 * If $height is provided (int) then a new Pageimage is returned at that height x 2 (for hidpi use).
	 * 
	 * #pw-internal
	 *
	 * @param int|float $height Specify int to return resized image for hidpi, or float (or omit) to return current width at hidpi.
	 * @param array|int $options Optional options for use when resizing, see size() method for details.
	 * 	Or you may specify an int as if you want to return a hidpi height and want to calculate with that height rather than current image height.
	 * @return int|Pageimage
	 *
	 */	
	public function hidpiHeight($height = 0, $options = array()) {
		if(is_float($height) || $height < 1) {
			// return hidpi height intended: scale omitted or provided in $height argument
			$scale = $height;
			if(!$scale || $scale < 0 || $scale > 1) $scale = 0.5;
			$height = is_array($options) ? 0 : (int) $options;
			if($height < 1) $height = $this->height();
			return ceil($height * $scale);
		} else if($height) {
			// resize intended
			if(!is_array($options)) $options = array();
			return $this->hidpiSize(0, (int) $height, $options);
		}
		return 0; // not possible to reach but pleases the inspection
	}

	/**
	 * Return an image no larger than the given width.
	 *
	 * If source image is equal to or smaller than the requested dimension, 
	 * then it will remain that way and the source image is returned (not a copy).
	 * 
	 * If the source image is larger than the requested dimension, then a new copy
	 * will be returned at the requested dimension.
	 * 
	 * #pw-group-resize-and-crop
	 *
 	 * @param int $n Maximum width
	 * @param array $options See `Pageimage::size()` method for options
	 * @return Pageimage
	 *
	 */
	public function maxWidth($n, array $options = array()) {
		$options['upscaling'] = false;
		if($this->width() > $n) return $this->width($n, $options); 
		return $this;
	}

	/**
	 * Return an image no larger than the given height.
	 *
	 * If source image is equal to or smaller than the requested dimension, 
	 * then it will remain that way and the source image is returned (not a copy).
	 * 
	 * If the source image is larger than the requested dimension, then a new copy
	 * will be returned at the requested dimension.
	 * 
	 * #pw-group-resize-and-crop
	 *
 	 * @param int $n Maximum height
	 * @param array $options See `Pageimage::size()` method for options
	 * @return Pageimage
	 *
	 */
	public function maxHeight($n, array $options = array()) {
		$options['upscaling'] = false;
		if($this->height() > $n) return $this->height($n, $options); 
		return $this;
	}

	/**
	 * Return an image no larger than the given width and height
	 * 
	 * #pw-group-resize-and-crop
	 * 
	 * @param int $width Max allowed width
	 * @param int $height Max allowed height
	 * @param array $options See `Pageimage::size()` method for options, or these additional options:
	 *  - `allowOriginal` (bool): Allow original image to be returned if already within max requested dimensions? (default=false)
	 * @return Pageimage
	 * 
	 */
	public function maxSize($width, $height, $options = array()) {
		
		$defaults = array(
			'allowOriginal' => false,
			'upscaling' => false,
			'cropping' => false
		);
		
		$options = array_merge($defaults, $options);
		$adjustedWidth = $width < 1 || $this->width() <= $width ? 0 : $width;
		$adjustedHeight = $height < 1 || $this->height() <= $height ? 0 : $height;

		// if already within maxSize dimensions then do nothing
		if(!$adjustedWidth && !$adjustedHeight) {
			if($options['allowOriginal']) return $this; // image already within target
			$adjustedWidth = $width;
			$options['nameHeight'] = $height;
		} else if(!$adjustedWidth) {
			$options['nameWidth'] = $width;
		} else if(!$adjustedHeight) {
			$options['nameHeight'] = $height;
		}
		
		if($this->wire()->config->installed > 1513336849) { 
			// New installations from 2017-12-15 forward use an "ms" suffix for images from maxSize() method
			$suffix = isset($options['suffix']) ? $options['suffix'] : array();
			if(!is_array($suffix)) $suffix = array();
			$suffix[] = 'ms';
			$options['suffix'] = $suffix;
		}
		
		return $this->size($adjustedWidth, $adjustedHeight, $options);
	}

	/**
	 * Get ratio of width divided by height
	 * 
	 * @param int $precision Optionally specify a value >2 for custom precision (default=2) 3.0.211+
	 * @return float
	 * @since 3.0.154
	 * 
	 */
	public function ratio($precision = 2) {
		$width = $this->width();
		$height = $this->height();
		if($width === $height) return 1.0;
		$ratio = $width / $height;
		$ratio = round($ratio, max(2, (int) $precision));
		if($ratio > 99.99) $ratio = 99.99; // max allowed width>height ratio
		if($ratio < 0.01) $ratio = 0.01; // min allowed height>width ratio
		return $ratio;
	}

	/**
	 * Get the PageimageVariations helper instancd
	 * 
	 * #pw-internal
	 * 
	 * @return PageimageVariations
	 * 
	 */
	public function variations() {
		if($this->variations === null) $this->variations = new PageimageVariations($this);
		return $this->variations;
	}

	/**
	 * 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. 
	 * 
	 * #pw-group-variations
	 *
	 * @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).
	 *  - `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 Returns Pageimages array of Pageimage instances. 
	 *  Only returns regular array if provided `$options['info']` is true.
	 *
	 */
	public function getVariations(array $options = array()) {
		return $this->variations()->find($options);
	}

	/**
	 * 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. 
	 *
	 * #pw-group-variations
	 * 
	 * @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 ___rebuildVariations($mode = 0, array $suffix = array(), array $options = array()) {
		return $this->variations()->rebuild($mode, $suffix, $options);
	}

	/**
	 * 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
	 * 
	 * #pw-group-variations
	 * 
	 * @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 ___isVariation($basename, $options = array()) {
		return $this->variations()->getInfo($basename, $options);
	}

	/**
	 * Delete all the alternate sizes associated with this Pageimage
	 * 
	 * #pw-group-variations
	 *
	 * @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 PageImageVariations|array Returns $this by default, or array of deleted filenames if the `getFiles` option is specified
	 *
	 */
	public function removeVariations(array $options = array()) {
		return $this->variations()->remove($options);
	}

	/**
	 * Hook called after successful creation of image variation
	 * 
	 * @param Pageimage $image The variation image that was created
	 * @param array $data Verbose associative array of data used to create the variation 
	 * @since 3.0.180
	 * 
	 */
	protected function ___createdVariation(Pageimage $image, array $data) { }

	/**
	 * Identify this Pageimage as a variation, by setting the Pageimage it was resized from.
	 * 
	 * #pw-group-variations
	 *
	 * @param Pageimage $image
	 * @return $this
	 *
	 */
	public function setOriginal(Pageimage $image) {
		$this->original = $image; 
		return $this; 
	}

	/**
	 * If this image is a variation, return the original, otherwise return null.
	 * 
	 * #pw-group-variations
	 *
	 * @return Pageimage|null
	 *
	 */
	public function getOriginal() {
		if($this->original) return $this->original; 
		$info = $this->isVariation($this->basename(), true); 
		if($info === false) return null;
		$this->original = $this->pagefiles->get($info['original']); 
		return $this->original;
	}

	/**
	 * Delete the physical file(s) associated with this Pagefile
	 * 
	 * #pw-internal Public API should use delete method from Pageimages
	 *
	 */
	public function unlink() {
		parent::unlink();
		$this->removeVariations();
		return $this; 
	}

	/**
	 * Copy this Pageimage and any of its variations to another path
	 * 
	 * #pw-internal
	 *
	 * @param string $path
	 * @return bool True if successful
	 *
	 */
	public function copyToPath($path) {
		$files = $this->wire()->files;
		if(parent::copyToPath($path)) {
			foreach($this->getVariations() as $variation) {
				/** @var Pageimage $variation */
				if(!is_file($variation->filename)) continue;
				$files->copy($variation->filename, $path); 
			}
			return true; 
		}
		return false; 
	}

	/**
	 * Render markup for this image (optionally using a provided markup template string and/or image size options)
	 * 
	 * Given template string can contain any of the placeholders, which will be replaced: 
	 *  - `{url}` or `{src}` Image URL (typically used for src attribute)
	 *  - `{httpUrl}` File URL with scheme and hostname (alternate for src attribute)
	 *  - `{URL}` Same as url but with cache busting query string
	 *  - `{HTTPURL}` Same as httpUrl but with cache busting query string
	 *  - `{description}` or `{alt}` Image description (typically used in alt attribute)
	 *  - `{tags}` File tags (might be useful in class attribute)
	 *  - `{width}` Width of image
	 *  - `{height}` Height of image
	 *  - `{hidpiWidth}` HiDPI width of image
	 *  - `{hidpiHeight}` HiDPI height of image
	 *  - `{ext}` File extension
	 *  - `{original.name}` Replace “name” with any of the properties above to refer to original image.
	 *     If there is no original image then these just refer back to the current image. 
	 * 
	 * ~~~~~
	 * $image = $page->images->first();
	 * if($image) {
	 *   // default output
	 *   echo $image->render(); 
	 * 
	 *   // custom output
	 *   echo $image->render("<img class='pw-image' src='{url}' alt='{alt}'>");
	 * 
	 *   // custom output with options
	 *   echo $image->render("<img src='{url}' alt='{alt}'>", [ 'width' => 300 ]);
	 *
	 *   // options can go in first argument if you prefer
	 *   echo $image->render([ 'width' => 300, 'height' => 200 ]);
	 *
	 *   // if only width/height are needed, they can also be specified as a string (1st or 2nd arg)
	 *   echo $image->render('300x200');
	 * 
	 *   // custom output with link to original/full-size and square crop of 300x300 for thumbnail
	 *   echo $image->render([
	 *     'markup' => "<a href='{original.url}'><img src='{url}' alt='{alt}'></a>",
	 *     'width' => 300,
	 *     'height' => 300
	 *   ]);
	 * }
	 * ~~~~~
	 * 
	 * @param string|array $markup Markup template string or optional $options array if you do not want the template string here.
	 * @param array|string $options Optionally resize image with these options sent to size() method:
	 *  - `width` (int): Target width or 0 for current image size (or proportional if height specified).
	 *  - `height` (int): Target height or 0 for current image size (or proportional if width specified).
	 *  - `markup` (string): Markup template string (same as $markup argument), or omit for default (same as $markup argument).
	 *  - `link` (bool): Link image to original size? Though you may prefer to do this with your own $markup (see examples). (default=false)
	 *  - `alt` (string): Text to use for “alt” attribute (default=text from image description).
	 *  - `class` (string): Text to use for “class” attribute, if `{class}` present in markup (default='').
	 *  - Plus any option available to the $options argument on the `Pageimage::size()` method. 
	 *  - If you only need width and/or height, you can specify a width x height string, i.e. 123x456 (use 0 for proportional).
	 * @return string
	 * @see Pageimages::render()
	 * @since 3.0.126
	 * 
	 */
	public function ___render($markup = '', $options = array()) {
		
		if(is_array($markup) || ($markup && strpos($markup, '}') === false)) {
			$options = $markup;
			$markup = isset($options['markup']) ? $options['markup'] : '';
		} 
		
		if(empty($markup)) {
			// provide default markup
			if(empty($options['class'])) {
				$markup = "<img src='{url}' alt='{alt}' />";
			} else {
				$markup = "<img src='{url}' alt='{alt}' class='{class}' />";
			}
		}
		
		if(is_string($options)) {
			if(ctype_digit(str_ireplace('x', '', $options))) {
				if(stripos($options, 'x') === false) $options .= 'x0';
				list($w, $h) = explode('x', strtolower($options));
				$options = array('width' => (int) $w, 'height' => (int) $h);
			} else {
				$options = array();
			}
		}

		$sanitizer = $this->wire()->sanitizer;
		$image = $this;
		$original = null;
		$replacements = array();
		$properties = array(
			'url', 'src', 'httpUrl', 'URL', 'HTTPURL',
			'description', 'alt', 'tags', 'ext', 'class',
			'width', 'height', 'hidpiWidth', 'hidpiHeight',
		);
		
		if(!empty($options['width']) || !empty($options['height'])) {
			$w = isset($options['width']) ? (int) $options['width'] : 0;
			$h = isset($options['height']) ? (int) $options['height'] : 0;
			$original = $this;
			$image = $this->size($w, $h, $options);
		}
		
		if(!empty($options['link']) && strpos($markup, '<a ') === false) {
			$markup = "<a href='{original.url}'>$markup</a>";
		}
		
		foreach($properties as $property) {
			$tag = '{' . $property . '}';
			if(strpos($markup, $tag) === false) continue;
			if(($property === 'alt' || $property === 'class') && isset($options[$property])) {
				$value = $sanitizer->entities($options[$property]);
			} else {
				$value = $sanitizer->entities1($image->get($property));
			}
			$replacements[$tag] = $value;
		}
		
		if(strpos($markup, '{class}')) {
			$class = isset($options['class']) ? $sanitizer->entities($options['class']) : 'pw-pageimage';
			$replacements["{class}"] = $class; 
		}
		
		if(strpos($markup, '{original.') !== false) {
			if(!$original) $original = $image->getOriginal();
			if(!$original) $original = $image;
			foreach($properties as $property) {
				$tag = '{original.' . $property . '}';
				if(strpos($markup, $tag) === false) continue;
				$value = $sanitizer->entities1($original->get($property));
				$replacements[$tag] = $value;
			}
		}
		
		return str_replace(array_keys($replacements), array_values($replacements), $markup);
	}
		
	/**
	 * Install this Pagefile
	 *
	 * Implies copying the file to the correct location (if not already there), and populating it's name
	 * 
	 * #pw-internal
	 *
	 * @param string $filename Full path and filename of file to install
	 * @throws WireException
	 *
	 */
	protected function ___install($filename) {
		parent::___install($filename); 
		if(!$this->width()) {
			parent::unlink();
			throw new WireException($this->_('Unable to install invalid image')); 
		}
	}

	/**
	 * Get WebP "extra" version of this Pageimage
	 *
	 * @return PagefileExtra
	 * @since 3.0.132
	 *
	 */
	public function webp() {
		$webp = $this->extras('webp');
		if(!$webp) {
			$webp = new PagefileExtra($this, 'webp');
			$webp->setArray($this->wire()->config->webpOptions);
			$this->extras('webp', $webp);
			$webp->addHookAfter('create', $this, 'hookWebpCreate'); 
		}
		return $webp;
	}

	/**
	 * Hook to PageimageExtra (.webp) create method
	 * 
	 * #pw-internal
	 * 
	 * @param HookEvent $event
	 * 
	 */
	public function hookWebpCreate(HookEvent $event) {
		$original = $this->original;
		/** @var PagefileExtra $webp */
		$webp = $event->object;
		$webp->unlink();
		if($original && isset($this->sizeOptions['_width'])) {
			// we are in an image resized from an original
			$options = $this->sizeOptions;
			$width = $options['_width'];
			$height = $options['_height'];
		} else {
			// we are the original
			// create a file with same name as original but with .webp extension
			$original = $this;
			$options = array(
				'allowOriginal' => false, 
				'webpName' => $webp->useSrcExt ? $this->basename() : basename($this->basename(), ".$this->ext"),
				'webpOnly' => true
			);
			$width = $this->width;
			$height = 0;
		}
		$options['webpAdd'] = true;
		try {
			$original->size($width, $height, $options);
		} catch(\Exception $e) {
			$this->error = ($this->error ? "$this->error - " : "") . $e->getMessage();
		}
		
		$error = $this->error;
		$event->return = empty($error); 
	}
	
	/**
	 * Get all extras, add an extra, or get an extra 
	 *
	 * #pw-internal
	 *
	 * @param string $name
	 * @param PagefileExtra $value
	 * @return PagefileExtra[]
	 * @since 3.0.132
	 *
	 */
	public function extras($name = null, PagefileExtra $value = null) {
		if($name) return parent::extras($name, $value); 
		$extras = parent::extras();
		$extras['webp'] = $this->webp();
		return $extras;
	}
	
	/**
	 * Rename this file
	 *
	 * Remember to follow this up with a `$page->save()` for the page that the file lives on.
	 *
	 * #pw-group-manipulation
	 *
	 * @param string $basename New name to use. Must be just the file basename (no path).
	 * @return string|bool Returns new name (basename) on success, or boolean false if rename failed.
	 *
	 */
	public function rename($basename) {
		
		$variations = $this->getVariations();
		$oldBasename = $this->basename;
		$newBasename = parent::rename($basename);
		
		if($newBasename === false) return false;
		
		$ext = '.' . $this->ext();
		$oldName = basename($oldBasename, $ext);
		$newName = basename($newBasename, $ext); 
		
		foreach($variations as $pageimage) {
			/** @var Pageimage $pageimage */
			if(strpos($pageimage->basename, $oldName) !== 0) continue;
			$newVariationName = $newName . substr($pageimage->basename, strlen($oldName));
			$pageimage->rename($newVariationName);
		}
		
		return $newBasename;
	}
	
	/**
	 * Replace file with another
	 *
	 * Should be followed up with a save() to ensure related properties are also committed to DB.
	 *
	 * #pw-internal
	 *
	 * @param string $filename File to replace current one with
	 * @param bool $move Move given $filename rather than copy? (default=true)
	 * @return bool
	 * @throws WireException
	 * @since 3.0.154
	 *
	 */
	public function replaceFile($filename, $move = true) {
		if(!parent::replaceFile($filename, $move)) return false;
		$this->getImageInfo(true);
		return true;
	}

	public function __isset($key) {
		if($key === 'original') return $this->original !== null;
		return parent::__isset($key);
	}

	/**
	 * Basic debug info
	 * 
	 * @return array
	 * 
	 */
	public function __debugInfo() {
		return $this->debugInfo->getBasicDebugInfo();
	}

	/**
	 * Verbose debug info (via @horst)
	 * 
	 * Optionally with individual options array.
	 *
	 * @param array $options The individual options you also passes with your image variation creation
	 * @param string $returnType 'string'|'array'|'object', default is 'string' and returns markup or plain text
	 * @return array|object|string
	 * @since 3.0.132
	 *
	 */
	public function getDebugInfo($options = array(), $returnType = 'string') {
		return $this->debugInfo->getVerboseDebugInfo($options, $returnType);
	}

	/**
	 * Get debug info from parent class
	 * 
	 * #pw-internal
	 * 
	 * @return array
	 * @since 3.0.132
	 * 
	 */
	public function _parentDebugInfo() {
		return parent::__debugInfo();
	}

}
