wp/wp-includes/class-wp-image-editor-imagick.php
changeset 7 cf61fcea0001
parent 5 5e2f62d02dcd
child 9 177826044cd9
--- a/wp/wp-includes/class-wp-image-editor-imagick.php	Tue Jun 09 11:14:17 2015 +0000
+++ b/wp/wp-includes/class-wp-image-editor-imagick.php	Mon Oct 14 17:39:30 2019 +0200
@@ -10,15 +10,16 @@
  * WordPress Image Editor Class for Image Manipulation through Imagick PHP Module
  *
  * @since 3.5.0
- * @package WordPress
- * @subpackage Image_Editor
- * @uses WP_Image_Editor Extends class
+ *
+ * @see WP_Image_Editor
  */
 class WP_Image_Editor_Imagick extends WP_Image_Editor {
 	/**
+	 * Imagick object.
+	 *
 	 * @var Imagick
 	 */
-	protected $image; // Imagick Object
+	protected $image;
 
 	public function __destruct() {
 		if ( $this->image instanceof Imagick ) {
@@ -35,14 +36,16 @@
 	 * method can be called statically.
 	 *
 	 * @since 3.5.0
-	 * @access public
+	 *
+	 * @static
 	 *
-	 * @return boolean
+	 * @param array $args
+	 * @return bool
 	 */
 	public static function test( $args = array() ) {
 
 		// First, test Imagick's extension and classes.
-		if ( ! extension_loaded( 'imagick' ) || ! class_exists( 'Imagick' ) || ! class_exists( 'ImagickPixel' ) )
+		if ( ! extension_loaded( 'imagick' ) || ! class_exists( 'Imagick', false ) || ! class_exists( 'ImagickPixel', false ) )
 			return false;
 
 		if ( version_compare( phpversion( 'imagick' ), '2.2.0', '<' ) )
@@ -61,19 +64,28 @@
 			'setimagecompression',
 			'setimagecompressionquality',
 			'setimagepage',
+			'setoption',
 			'scaleimage',
 			'cropimage',
 			'rotateimage',
 			'flipimage',
 			'flopimage',
+			'readimage',
 		);
 
 		// Now, test for deep requirements within Imagick.
 		if ( ! defined( 'imagick::COMPRESSION_JPEG' ) )
 			return false;
 
-		if ( array_diff( $required_methods, get_class_methods( 'Imagick' ) ) )
+		$class_methods = array_map( 'strtolower', get_class_methods( 'Imagick' ) );
+		if ( array_diff( $required_methods, $class_methods ) ) {
 			return false;
+		}
+
+		// HHVM Imagick does not support loading from URL, so fail to allow fallback to GD.
+		if ( defined( 'HHVM_VERSION' ) && isset( $args['path'] ) && preg_match( '|^https?://|', $args['path'] ) ) {
+			return false;
+		}
 
 		return true;
 	}
@@ -82,10 +94,11 @@
 	 * Checks to see if editor supports the mime-type specified.
 	 *
 	 * @since 3.5.0
-	 * @access public
+	 *
+	 * @static
 	 *
 	 * @param string $mime_type
-	 * @return boolean
+	 * @return bool
 	 */
 	public static function supports_mime_type( $mime_type ) {
 		$imagick_extension = strtoupper( self::get_extension( $mime_type ) );
@@ -110,9 +123,8 @@
 	 * Loads image from $this->file into new Imagick Object.
 	 *
 	 * @since 3.5.0
-	 * @access protected
 	 *
-	 * @return boolean|WP_Error True if loaded; WP_Error on failure.
+	 * @return true|WP_Error True if loaded; WP_Error on failure.
 	 */
 	public function load() {
 		if ( $this->image instanceof Imagick )
@@ -121,14 +133,26 @@
 		if ( ! is_file( $this->file ) && ! preg_match( '|^https?://|', $this->file ) )
 			return new WP_Error( 'error_loading_image', __('File doesn&#8217;t exist?'), $this->file );
 
-		/** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */
-		// Even though Imagick uses less PHP memory than GD, set higher limit for users that have low PHP.ini limits
-		@ini_set( 'memory_limit', apply_filters( 'image_memory_limit', WP_MAX_MEMORY_LIMIT ) );
+		/*
+		 * Even though Imagick uses less PHP memory than GD, set higher limit
+		 * for users that have low PHP.ini limits.
+		 */
+		wp_raise_memory_limit( 'image' );
 
 		try {
-			$this->image = new Imagick( $this->file );
+			$this->image = new Imagick();
+			$file_extension = strtolower( pathinfo( $this->file, PATHINFO_EXTENSION ) );
+			$filename = $this->file;
 
-			if( ! $this->image->valid() )
+			if ( 'pdf' == $file_extension ) {
+				$filename = $this->pdf_setup();
+			}
+
+			// Reading image after Imagick instantiation because `setResolution`
+			// only applies correctly before the image is read.
+			$this->image->readImage( $filename );
+
+			if ( ! $this->image->valid() )
 				return new WP_Error( 'invalid_image', __('File is not an image.'), $this->file);
 
 			// Select the first frame to handle animated images properly
@@ -153,10 +177,9 @@
 	 * Sets Image Compression quality on a 1-100% scale.
 	 *
 	 * @since 3.5.0
-	 * @access public
 	 *
 	 * @param int $quality Compression Quality. Range: [1,100]
-	 * @return boolean|WP_Error True if set successfully; WP_Error on failure.
+	 * @return true|WP_Error True if set successfully; WP_Error on failure.
 	 */
 	public function set_quality( $quality = null ) {
 		$quality_result = parent::set_quality( $quality );
@@ -186,7 +209,6 @@
 	 * Sets or updates current image size.
 	 *
 	 * @since 3.5.0
-	 * @access protected
 	 *
 	 * @param int $width
 	 * @param int $height
@@ -200,7 +222,7 @@
 				$size = $this->image->getImageGeometry();
 			}
 			catch ( Exception $e ) {
-				return new WP_Error( 'invalid_image', __('Could not read image size'), $this->file );
+				return new WP_Error( 'invalid_image', __( 'Could not read image size.' ), $this->file );
 			}
 		}
 
@@ -221,12 +243,11 @@
 	 * maintain aspect ratio according to the provided dimension.
 	 *
 	 * @since 3.5.0
-	 * @access public
 	 *
 	 * @param  int|null $max_w Image width.
 	 * @param  int|null $max_h Image height.
-	 * @param  boolean  $crop
-	 * @return boolean|WP_Error
+	 * @param  bool     $crop
+	 * @return bool|WP_Error
 	 */
 	public function resize( $max_w, $max_h, $crop = false ) {
 		if ( ( $this->size['width'] == $max_w ) && ( $this->size['height'] == $max_h ) )
@@ -241,36 +262,166 @@
 			return $this->crop( $src_x, $src_y, $src_w, $src_h, $dst_w, $dst_h );
 		}
 
-		try {
-			/**
-			 * @TODO: Thumbnail is more efficient, given a newer version of Imagemagick.
-			 * $this->image->thumbnailImage( $dst_w, $dst_h );
-			 */
-			$this->image->scaleImage( $dst_w, $dst_h );
-		}
-		catch ( Exception $e ) {
-			return new WP_Error( 'image_resize_error', $e->getMessage() );
+		// Execute the resize
+		$thumb_result = $this->thumbnail_image( $dst_w, $dst_h );
+		if ( is_wp_error( $thumb_result ) ) {
+			return $thumb_result;
 		}
 
 		return $this->update_size( $dst_w, $dst_h );
 	}
 
 	/**
+	 * Efficiently resize the current image
+	 *
+	 * This is a WordPress specific implementation of Imagick::thumbnailImage(),
+	 * which resizes an image to given dimensions and removes any associated profiles.
+	 *
+	 * @since 4.5.0
+	 *
+	 * @param int    $dst_w       The destination width.
+	 * @param int    $dst_h       The destination height.
+	 * @param string $filter_name Optional. The Imagick filter to use when resizing. Default 'FILTER_TRIANGLE'.
+	 * @param bool   $strip_meta  Optional. Strip all profiles, excluding color profiles, from the image. Default true.
+	 * @return bool|WP_Error
+	 */
+	protected function thumbnail_image( $dst_w, $dst_h, $filter_name = 'FILTER_TRIANGLE', $strip_meta = true ) {
+		$allowed_filters = array(
+			'FILTER_POINT',
+			'FILTER_BOX',
+			'FILTER_TRIANGLE',
+			'FILTER_HERMITE',
+			'FILTER_HANNING',
+			'FILTER_HAMMING',
+			'FILTER_BLACKMAN',
+			'FILTER_GAUSSIAN',
+			'FILTER_QUADRATIC',
+			'FILTER_CUBIC',
+			'FILTER_CATROM',
+			'FILTER_MITCHELL',
+			'FILTER_LANCZOS',
+			'FILTER_BESSEL',
+			'FILTER_SINC',
+		);
+
+		/**
+		 * Set the filter value if '$filter_name' name is in our whitelist and the related
+		 * Imagick constant is defined or fall back to our default filter.
+		 */
+		if ( in_array( $filter_name, $allowed_filters ) && defined( 'Imagick::' . $filter_name ) ) {
+			$filter = constant( 'Imagick::' . $filter_name );
+		} else {
+			$filter = defined( 'Imagick::FILTER_TRIANGLE' ) ? Imagick::FILTER_TRIANGLE : false;
+		}
+
+		/**
+		 * Filters whether to strip metadata from images when they're resized.
+		 *
+		 * This filter only applies when resizing using the Imagick editor since GD
+		 * always strips profiles by default.
+		 *
+		 * @since 4.5.0
+		 *
+		 * @param bool $strip_meta Whether to strip image metadata during resizing. Default true.
+		 */
+		if ( apply_filters( 'image_strip_meta', $strip_meta ) ) {
+			$this->strip_meta(); // Fail silently if not supported.
+		}
+
+		try {
+			/*
+			 * To be more efficient, resample large images to 5x the destination size before resizing
+			 * whenever the output size is less that 1/3 of the original image size (1/3^2 ~= .111),
+			 * unless we would be resampling to a scale smaller than 128x128.
+			 */
+			if ( is_callable( array( $this->image, 'sampleImage' ) ) ) {
+				$resize_ratio = ( $dst_w / $this->size['width'] ) * ( $dst_h / $this->size['height'] );
+				$sample_factor = 5;
+
+				if ( $resize_ratio < .111 && ( $dst_w * $sample_factor > 128 && $dst_h * $sample_factor > 128 ) ) {
+					$this->image->sampleImage( $dst_w * $sample_factor, $dst_h * $sample_factor );
+				}
+			}
+
+			/*
+			 * Use resizeImage() when it's available and a valid filter value is set.
+			 * Otherwise, fall back to the scaleImage() method for resizing, which
+			 * results in better image quality over resizeImage() with default filter
+			 * settings and retains backward compatibility with pre 4.5 functionality.
+			 */
+			if ( is_callable( array( $this->image, 'resizeImage' ) ) && $filter ) {
+				$this->image->setOption( 'filter:support', '2.0' );
+				$this->image->resizeImage( $dst_w, $dst_h, $filter, 1 );
+			} else {
+				$this->image->scaleImage( $dst_w, $dst_h );
+			}
+
+			// Set appropriate quality settings after resizing.
+			if ( 'image/jpeg' == $this->mime_type ) {
+				if ( is_callable( array( $this->image, 'unsharpMaskImage' ) ) ) {
+					$this->image->unsharpMaskImage( 0.25, 0.25, 8, 0.065 );
+				}
+
+				$this->image->setOption( 'jpeg:fancy-upsampling', 'off' );
+			}
+
+			if ( 'image/png' === $this->mime_type ) {
+				$this->image->setOption( 'png:compression-filter', '5' );
+				$this->image->setOption( 'png:compression-level', '9' );
+				$this->image->setOption( 'png:compression-strategy', '1' );
+				$this->image->setOption( 'png:exclude-chunk', 'all' );
+			}
+
+			/*
+			 * If alpha channel is not defined, set it opaque.
+			 *
+			 * Note that Imagick::getImageAlphaChannel() is only available if Imagick
+			 * has been compiled against ImageMagick version 6.4.0 or newer.
+			 */
+			if ( is_callable( array( $this->image, 'getImageAlphaChannel' ) )
+				&& is_callable( array( $this->image, 'setImageAlphaChannel' ) )
+				&& defined( 'Imagick::ALPHACHANNEL_UNDEFINED' )
+				&& defined( 'Imagick::ALPHACHANNEL_OPAQUE' )
+			) {
+				if ( $this->image->getImageAlphaChannel() === Imagick::ALPHACHANNEL_UNDEFINED ) {
+					$this->image->setImageAlphaChannel( Imagick::ALPHACHANNEL_OPAQUE );
+				}
+			}
+
+			// Limit the bit depth of resized images to 8 bits per channel.
+			if ( is_callable( array( $this->image, 'getImageDepth' ) ) && is_callable( array( $this->image, 'setImageDepth' ) ) ) {
+				if ( 8 < $this->image->getImageDepth() ) {
+					$this->image->setImageDepth( 8 );
+				}
+			}
+
+			if ( is_callable( array( $this->image, 'setInterlaceScheme' ) ) && defined( 'Imagick::INTERLACE_NO' ) ) {
+				$this->image->setInterlaceScheme( Imagick::INTERLACE_NO );
+			}
+
+		}
+		catch ( Exception $e ) {
+			return new WP_Error( 'image_resize_error', $e->getMessage() );
+		}
+	}
+
+	/**
 	 * Resize multiple images from a single source.
 	 *
 	 * @since 3.5.0
-	 * @access public
 	 *
 	 * @param array $sizes {
-	 *     An array of image size arrays. Default sizes are 'small', 'medium', 'large'.
+	 *     An array of image size arrays. Default sizes are 'small', 'medium', 'medium_large', 'large'.
 	 *
 	 *     Either a height or width must be provided.
 	 *     If one of the two is set to null, the resize will
 	 *     maintain aspect ratio according to the provided dimension.
 	 *
 	 *     @type array $size {
-	 *         @type int  ['width']  Optional. Image width.
-	 *         @type int  ['height'] Optional. Image height.
+	 *         Array of height, width values, and whether to crop.
+	 *
+	 *         @type int  $width  Image width. Optional if `$height` is specified.
+	 *         @type int  $height Image height. Optional if `$width` is specified.
 	 *         @type bool $crop   Optional. Whether to crop the image. Default false.
 	 *     }
 	 * }
@@ -328,16 +479,15 @@
 	 * Crops Image.
 	 *
 	 * @since 3.5.0
-	 * @access public
 	 *
-	 * @param int $src_x The start x position to crop from.
-	 * @param int $src_y The start y position to crop from.
-	 * @param int $src_w The width to crop.
-	 * @param int $src_h The height to crop.
-	 * @param int $dst_w Optional. The destination width.
-	 * @param int $dst_h Optional. The destination height.
-	 * @param boolean $src_abs Optional. If the source crop points are absolute.
-	 * @return boolean|WP_Error
+	 * @param int  $src_x The start x position to crop from.
+	 * @param int  $src_y The start y position to crop from.
+	 * @param int  $src_w The width to crop.
+	 * @param int  $src_h The height to crop.
+	 * @param int  $dst_w Optional. The destination width.
+	 * @param int  $dst_h Optional. The destination height.
+	 * @param bool $src_abs Optional. If the source crop points are absolute.
+	 * @return bool|WP_Error
 	 */
 	public function crop( $src_x, $src_y, $src_w, $src_h, $dst_w = null, $dst_h = null, $src_abs = false ) {
 		if ( $src_abs ) {
@@ -357,7 +507,11 @@
 				if ( ! $dst_h )
 					$dst_h = $src_h;
 
-				$this->image->scaleImage( $dst_w, $dst_h );
+				$thumb_result = $this->thumbnail_image( $dst_w, $dst_h );
+				if ( is_wp_error( $thumb_result ) ) {
+					return $thumb_result;
+				}
+
 				return $this->update_size();
 			}
 		}
@@ -371,10 +525,9 @@
 	 * Rotates current image counter-clockwise by $angle.
 	 *
 	 * @since 3.5.0
-	 * @access public
 	 *
 	 * @param float $angle
-	 * @return boolean|WP_Error
+	 * @return true|WP_Error
 	 */
 	public function rotate( $angle ) {
 		/**
@@ -384,6 +537,11 @@
 		try {
 			$this->image->rotateImage( new ImagickPixel('none'), 360-$angle );
 
+			// Normalise Exif orientation data so that display is consistent across devices.
+			if ( is_callable( array( $this->image, 'setImageOrientation' ) ) && defined( 'Imagick::ORIENTATION_TOPLEFT' ) ) {
+				$this->image->setImageOrientation( Imagick::ORIENTATION_TOPLEFT );
+			}
+
 			// Since this changes the dimensions of the image, update the size.
 			$result = $this->update_size();
 			if ( is_wp_error( $result ) )
@@ -401,11 +559,10 @@
 	 * Flips current image.
 	 *
 	 * @since 3.5.0
-	 * @access public
 	 *
-	 * @param boolean $horz Flip along Horizontal Axis
-	 * @param boolean $vert Flip along Vertical Axis
-	 * @returns boolean|WP_Error
+	 * @param bool $horz Flip along Horizontal Axis
+	 * @param bool $vert Flip along Vertical Axis
+	 * @return true|WP_Error
 	 */
 	public function flip( $horz, $vert ) {
 		try {
@@ -425,7 +582,6 @@
 	 * Saves current image to file.
 	 *
 	 * @since 3.5.0
-	 * @access public
 	 *
 	 * @param string $destfilename
 	 * @param string $mime_type
@@ -449,6 +605,13 @@
 		return $saved;
 	}
 
+	/**
+	 *
+	 * @param Imagick $image
+	 * @param string $filename
+	 * @param string $mime_type
+	 * @return array|WP_Error
+	 */
 	protected function _save( $image, $filename = null, $mime_type = null ) {
 		list( $filename, $extension, $mime_type ) = $this->get_output_format( $filename, $mime_type );
 
@@ -488,10 +651,9 @@
 	 * Streams current image to browser.
 	 *
 	 * @since 3.5.0
-	 * @access public
 	 *
-	 * @param string $mime_type
-	 * @return boolean|WP_Error
+	 * @param string $mime_type The mime type of the image.
+	 * @return bool|WP_Error True on success, WP_Error object on failure.
 	 */
 	public function stream( $mime_type = null ) {
 		list( $filename, $extension, $mime_type ) = $this->get_output_format( null, $mime_type );
@@ -513,4 +675,78 @@
 
 		return true;
 	}
+
+	/**
+	 * Strips all image meta except color profiles from an image.
+	 *
+	 * @since 4.5.0
+	 *
+	 * @return true|WP_Error True if stripping metadata was successful. WP_Error object on error.
+	 */
+	protected function strip_meta() {
+
+		if ( ! is_callable( array( $this->image, 'getImageProfiles' ) ) ) {
+			/* translators: %s: ImageMagick method name */
+			return new WP_Error( 'image_strip_meta_error', sprintf( __( '%s is required to strip image meta.' ), '<code>Imagick::getImageProfiles()</code>' ) );
+		}
+
+		if ( ! is_callable( array( $this->image, 'removeImageProfile' ) ) ) {
+			/* translators: %s: ImageMagick method name */
+			return new WP_Error( 'image_strip_meta_error', sprintf( __( '%s is required to strip image meta.' ), '<code>Imagick::removeImageProfile()</code>' ) );
+		}
+
+		/*
+		 * Protect a few profiles from being stripped for the following reasons:
+		 *
+		 * - icc:  Color profile information
+		 * - icm:  Color profile information
+		 * - iptc: Copyright data
+		 * - exif: Orientation data
+		 * - xmp:  Rights usage data
+		 */
+		$protected_profiles = array(
+			'icc',
+			'icm',
+			'iptc',
+			'exif',
+			'xmp',
+		);
+
+		try {
+			// Strip profiles.
+			foreach ( $this->image->getImageProfiles( '*', true ) as $key => $value ) {
+				if ( ! in_array( $key, $protected_profiles ) ) {
+					$this->image->removeImageProfile( $key );
+				}
+			}
+
+		} catch ( Exception $e ) {
+			return new WP_Error( 'image_strip_meta_error', $e->getMessage() );
+		}
+
+		return true;
+	}
+
+	/**
+	 * Sets up Imagick for PDF processing.
+	 * Increases rendering DPI and only loads first page.
+	 *
+	 * @since 4.7.0
+	 *
+	 * @return string|WP_Error File to load or WP_Error on failure.
+	 */
+	protected function pdf_setup() {
+		try {
+			// By default, PDFs are rendered in a very low resolution.
+			// We want the thumbnail to be readable, so increase the rendering DPI.
+			$this->image->setResolution( 128, 128 );
+
+			// Only load the first page.
+			return $this->file . '[0]';
+		}
+		catch ( Exception $e ) {
+			return new WP_Error( 'pdf_setup_failed', $e->getMessage(), $this->file );
+		}
+	}
+
 }