class-wp-image-editor-imagick.php 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954
  1. <?php
  2. /**
  3. * WordPress Imagick Image Editor
  4. *
  5. * @package WordPress
  6. * @subpackage Image_Editor
  7. */
  8. /**
  9. * WordPress Image Editor Class for Image Manipulation through Imagick PHP Module
  10. *
  11. * @since 3.5.0
  12. *
  13. * @see WP_Image_Editor
  14. */
  15. class WP_Image_Editor_Imagick extends WP_Image_Editor {
  16. /**
  17. * Imagick object.
  18. *
  19. * @var Imagick
  20. */
  21. protected $image;
  22. public function __destruct() {
  23. if ( $this->image instanceof Imagick ) {
  24. // We don't need the original in memory anymore.
  25. $this->image->clear();
  26. $this->image->destroy();
  27. }
  28. }
  29. /**
  30. * Checks to see if current environment supports Imagick.
  31. *
  32. * We require Imagick 2.2.0 or greater, based on whether the queryFormats()
  33. * method can be called statically.
  34. *
  35. * @since 3.5.0
  36. *
  37. * @param array $args
  38. * @return bool
  39. */
  40. public static function test( $args = array() ) {
  41. // First, test Imagick's extension and classes.
  42. if ( ! extension_loaded( 'imagick' ) || ! class_exists( 'Imagick', false ) || ! class_exists( 'ImagickPixel', false ) ) {
  43. return false;
  44. }
  45. if ( version_compare( phpversion( 'imagick' ), '2.2.0', '<' ) ) {
  46. return false;
  47. }
  48. $required_methods = array(
  49. 'clear',
  50. 'destroy',
  51. 'valid',
  52. 'getimage',
  53. 'writeimage',
  54. 'getimageblob',
  55. 'getimagegeometry',
  56. 'getimageformat',
  57. 'setimageformat',
  58. 'setimagecompression',
  59. 'setimagecompressionquality',
  60. 'setimagepage',
  61. 'setoption',
  62. 'scaleimage',
  63. 'cropimage',
  64. 'rotateimage',
  65. 'flipimage',
  66. 'flopimage',
  67. 'readimage',
  68. 'readimageblob',
  69. );
  70. // Now, test for deep requirements within Imagick.
  71. if ( ! defined( 'imagick::COMPRESSION_JPEG' ) ) {
  72. return false;
  73. }
  74. $class_methods = array_map( 'strtolower', get_class_methods( 'Imagick' ) );
  75. if ( array_diff( $required_methods, $class_methods ) ) {
  76. return false;
  77. }
  78. return true;
  79. }
  80. /**
  81. * Checks to see if editor supports the mime-type specified.
  82. *
  83. * @since 3.5.0
  84. *
  85. * @param string $mime_type
  86. * @return bool
  87. */
  88. public static function supports_mime_type( $mime_type ) {
  89. $imagick_extension = strtoupper( self::get_extension( $mime_type ) );
  90. if ( ! $imagick_extension ) {
  91. return false;
  92. }
  93. // setIteratorIndex is optional unless mime is an animated format.
  94. // Here, we just say no if you are missing it and aren't loading a jpeg.
  95. if ( ! method_exists( 'Imagick', 'setIteratorIndex' ) && 'image/jpeg' !== $mime_type ) {
  96. return false;
  97. }
  98. try {
  99. // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
  100. return ( (bool) @Imagick::queryFormats( $imagick_extension ) );
  101. } catch ( Exception $e ) {
  102. return false;
  103. }
  104. }
  105. /**
  106. * Loads image from $this->file into new Imagick Object.
  107. *
  108. * @since 3.5.0
  109. *
  110. * @return true|WP_Error True if loaded; WP_Error on failure.
  111. */
  112. public function load() {
  113. if ( $this->image instanceof Imagick ) {
  114. return true;
  115. }
  116. if ( ! is_file( $this->file ) && ! wp_is_stream( $this->file ) ) {
  117. return new WP_Error( 'error_loading_image', __( 'File does not exist?' ), $this->file );
  118. }
  119. /*
  120. * Even though Imagick uses less PHP memory than GD, set higher limit
  121. * for users that have low PHP.ini limits.
  122. */
  123. wp_raise_memory_limit( 'image' );
  124. try {
  125. $this->image = new Imagick();
  126. $file_extension = strtolower( pathinfo( $this->file, PATHINFO_EXTENSION ) );
  127. if ( 'pdf' === $file_extension ) {
  128. $pdf_loaded = $this->pdf_load_source();
  129. if ( is_wp_error( $pdf_loaded ) ) {
  130. return $pdf_loaded;
  131. }
  132. } else {
  133. if ( wp_is_stream( $this->file ) ) {
  134. // Due to reports of issues with streams with `Imagick::readImageFile()`, uses `Imagick::readImageBlob()` instead.
  135. $this->image->readImageBlob( file_get_contents( $this->file ), $this->file );
  136. } else {
  137. $this->image->readImage( $this->file );
  138. }
  139. }
  140. if ( ! $this->image->valid() ) {
  141. return new WP_Error( 'invalid_image', __( 'File is not an image.' ), $this->file );
  142. }
  143. // Select the first frame to handle animated images properly.
  144. if ( is_callable( array( $this->image, 'setIteratorIndex' ) ) ) {
  145. $this->image->setIteratorIndex( 0 );
  146. }
  147. $this->mime_type = $this->get_mime_type( $this->image->getImageFormat() );
  148. } catch ( Exception $e ) {
  149. return new WP_Error( 'invalid_image', $e->getMessage(), $this->file );
  150. }
  151. $updated_size = $this->update_size();
  152. if ( is_wp_error( $updated_size ) ) {
  153. return $updated_size;
  154. }
  155. return $this->set_quality();
  156. }
  157. /**
  158. * Sets Image Compression quality on a 1-100% scale.
  159. *
  160. * @since 3.5.0
  161. *
  162. * @param int $quality Compression Quality. Range: [1,100]
  163. * @return true|WP_Error True if set successfully; WP_Error on failure.
  164. */
  165. public function set_quality( $quality = null ) {
  166. $quality_result = parent::set_quality( $quality );
  167. if ( is_wp_error( $quality_result ) ) {
  168. return $quality_result;
  169. } else {
  170. $quality = $this->get_quality();
  171. }
  172. try {
  173. switch ( $this->mime_type ) {
  174. case 'image/jpeg':
  175. $this->image->setImageCompressionQuality( $quality );
  176. $this->image->setImageCompression( imagick::COMPRESSION_JPEG );
  177. break;
  178. case 'image/webp':
  179. $webp_info = wp_get_webp_info( $this->file );
  180. if ( 'lossless' === $webp_info['type'] ) {
  181. // Use WebP lossless settings.
  182. $this->image->setImageCompressionQuality( 100 );
  183. $this->image->setOption( 'webp:lossless', 'true' );
  184. } else {
  185. $this->image->setImageCompressionQuality( $quality );
  186. }
  187. break;
  188. default:
  189. $this->image->setImageCompressionQuality( $quality );
  190. }
  191. } catch ( Exception $e ) {
  192. return new WP_Error( 'image_quality_error', $e->getMessage() );
  193. }
  194. return true;
  195. }
  196. /**
  197. * Sets or updates current image size.
  198. *
  199. * @since 3.5.0
  200. *
  201. * @param int $width
  202. * @param int $height
  203. * @return true|WP_Error
  204. */
  205. protected function update_size( $width = null, $height = null ) {
  206. $size = null;
  207. if ( ! $width || ! $height ) {
  208. try {
  209. $size = $this->image->getImageGeometry();
  210. } catch ( Exception $e ) {
  211. return new WP_Error( 'invalid_image', __( 'Could not read image size.' ), $this->file );
  212. }
  213. }
  214. if ( ! $width ) {
  215. $width = $size['width'];
  216. }
  217. if ( ! $height ) {
  218. $height = $size['height'];
  219. }
  220. return parent::update_size( $width, $height );
  221. }
  222. /**
  223. * Resizes current image.
  224. *
  225. * At minimum, either a height or width must be provided.
  226. * If one of the two is set to null, the resize will
  227. * maintain aspect ratio according to the provided dimension.
  228. *
  229. * @since 3.5.0
  230. *
  231. * @param int|null $max_w Image width.
  232. * @param int|null $max_h Image height.
  233. * @param bool $crop
  234. * @return true|WP_Error
  235. */
  236. public function resize( $max_w, $max_h, $crop = false ) {
  237. if ( ( $this->size['width'] == $max_w ) && ( $this->size['height'] == $max_h ) ) {
  238. return true;
  239. }
  240. $dims = image_resize_dimensions( $this->size['width'], $this->size['height'], $max_w, $max_h, $crop );
  241. if ( ! $dims ) {
  242. return new WP_Error( 'error_getting_dimensions', __( 'Could not calculate resized image dimensions' ) );
  243. }
  244. list( $dst_x, $dst_y, $src_x, $src_y, $dst_w, $dst_h, $src_w, $src_h ) = $dims;
  245. if ( $crop ) {
  246. return $this->crop( $src_x, $src_y, $src_w, $src_h, $dst_w, $dst_h );
  247. }
  248. // Execute the resize.
  249. $thumb_result = $this->thumbnail_image( $dst_w, $dst_h );
  250. if ( is_wp_error( $thumb_result ) ) {
  251. return $thumb_result;
  252. }
  253. return $this->update_size( $dst_w, $dst_h );
  254. }
  255. /**
  256. * Efficiently resize the current image
  257. *
  258. * This is a WordPress specific implementation of Imagick::thumbnailImage(),
  259. * which resizes an image to given dimensions and removes any associated profiles.
  260. *
  261. * @since 4.5.0
  262. *
  263. * @param int $dst_w The destination width.
  264. * @param int $dst_h The destination height.
  265. * @param string $filter_name Optional. The Imagick filter to use when resizing. Default 'FILTER_TRIANGLE'.
  266. * @param bool $strip_meta Optional. Strip all profiles, excluding color profiles, from the image. Default true.
  267. * @return void|WP_Error
  268. */
  269. protected function thumbnail_image( $dst_w, $dst_h, $filter_name = 'FILTER_TRIANGLE', $strip_meta = true ) {
  270. $allowed_filters = array(
  271. 'FILTER_POINT',
  272. 'FILTER_BOX',
  273. 'FILTER_TRIANGLE',
  274. 'FILTER_HERMITE',
  275. 'FILTER_HANNING',
  276. 'FILTER_HAMMING',
  277. 'FILTER_BLACKMAN',
  278. 'FILTER_GAUSSIAN',
  279. 'FILTER_QUADRATIC',
  280. 'FILTER_CUBIC',
  281. 'FILTER_CATROM',
  282. 'FILTER_MITCHELL',
  283. 'FILTER_LANCZOS',
  284. 'FILTER_BESSEL',
  285. 'FILTER_SINC',
  286. );
  287. /**
  288. * Set the filter value if '$filter_name' name is in the allowed list and the related
  289. * Imagick constant is defined or fall back to the default filter.
  290. */
  291. if ( in_array( $filter_name, $allowed_filters, true ) && defined( 'Imagick::' . $filter_name ) ) {
  292. $filter = constant( 'Imagick::' . $filter_name );
  293. } else {
  294. $filter = defined( 'Imagick::FILTER_TRIANGLE' ) ? Imagick::FILTER_TRIANGLE : false;
  295. }
  296. /**
  297. * Filters whether to strip metadata from images when they're resized.
  298. *
  299. * This filter only applies when resizing using the Imagick editor since GD
  300. * always strips profiles by default.
  301. *
  302. * @since 4.5.0
  303. *
  304. * @param bool $strip_meta Whether to strip image metadata during resizing. Default true.
  305. */
  306. if ( apply_filters( 'image_strip_meta', $strip_meta ) ) {
  307. $this->strip_meta(); // Fail silently if not supported.
  308. }
  309. try {
  310. /*
  311. * To be more efficient, resample large images to 5x the destination size before resizing
  312. * whenever the output size is less that 1/3 of the original image size (1/3^2 ~= .111),
  313. * unless we would be resampling to a scale smaller than 128x128.
  314. */
  315. if ( is_callable( array( $this->image, 'sampleImage' ) ) ) {
  316. $resize_ratio = ( $dst_w / $this->size['width'] ) * ( $dst_h / $this->size['height'] );
  317. $sample_factor = 5;
  318. if ( $resize_ratio < .111 && ( $dst_w * $sample_factor > 128 && $dst_h * $sample_factor > 128 ) ) {
  319. $this->image->sampleImage( $dst_w * $sample_factor, $dst_h * $sample_factor );
  320. }
  321. }
  322. /*
  323. * Use resizeImage() when it's available and a valid filter value is set.
  324. * Otherwise, fall back to the scaleImage() method for resizing, which
  325. * results in better image quality over resizeImage() with default filter
  326. * settings and retains backward compatibility with pre 4.5 functionality.
  327. */
  328. if ( is_callable( array( $this->image, 'resizeImage' ) ) && $filter ) {
  329. $this->image->setOption( 'filter:support', '2.0' );
  330. $this->image->resizeImage( $dst_w, $dst_h, $filter, 1 );
  331. } else {
  332. $this->image->scaleImage( $dst_w, $dst_h );
  333. }
  334. // Set appropriate quality settings after resizing.
  335. if ( 'image/jpeg' === $this->mime_type ) {
  336. if ( is_callable( array( $this->image, 'unsharpMaskImage' ) ) ) {
  337. $this->image->unsharpMaskImage( 0.25, 0.25, 8, 0.065 );
  338. }
  339. $this->image->setOption( 'jpeg:fancy-upsampling', 'off' );
  340. }
  341. if ( 'image/png' === $this->mime_type ) {
  342. $this->image->setOption( 'png:compression-filter', '5' );
  343. $this->image->setOption( 'png:compression-level', '9' );
  344. $this->image->setOption( 'png:compression-strategy', '1' );
  345. $this->image->setOption( 'png:exclude-chunk', 'all' );
  346. }
  347. /*
  348. * If alpha channel is not defined, set it opaque.
  349. *
  350. * Note that Imagick::getImageAlphaChannel() is only available if Imagick
  351. * has been compiled against ImageMagick version 6.4.0 or newer.
  352. */
  353. if ( is_callable( array( $this->image, 'getImageAlphaChannel' ) )
  354. && is_callable( array( $this->image, 'setImageAlphaChannel' ) )
  355. && defined( 'Imagick::ALPHACHANNEL_UNDEFINED' )
  356. && defined( 'Imagick::ALPHACHANNEL_OPAQUE' )
  357. ) {
  358. if ( $this->image->getImageAlphaChannel() === Imagick::ALPHACHANNEL_UNDEFINED ) {
  359. $this->image->setImageAlphaChannel( Imagick::ALPHACHANNEL_OPAQUE );
  360. }
  361. }
  362. // Limit the bit depth of resized images to 8 bits per channel.
  363. if ( is_callable( array( $this->image, 'getImageDepth' ) ) && is_callable( array( $this->image, 'setImageDepth' ) ) ) {
  364. if ( 8 < $this->image->getImageDepth() ) {
  365. $this->image->setImageDepth( 8 );
  366. }
  367. }
  368. if ( is_callable( array( $this->image, 'setInterlaceScheme' ) ) && defined( 'Imagick::INTERLACE_NO' ) ) {
  369. $this->image->setInterlaceScheme( Imagick::INTERLACE_NO );
  370. }
  371. } catch ( Exception $e ) {
  372. return new WP_Error( 'image_resize_error', $e->getMessage() );
  373. }
  374. }
  375. /**
  376. * Create multiple smaller images from a single source.
  377. *
  378. * Attempts to create all sub-sizes and returns the meta data at the end. This
  379. * may result in the server running out of resources. When it fails there may be few
  380. * "orphaned" images left over as the meta data is never returned and saved.
  381. *
  382. * As of 5.3.0 the preferred way to do this is with `make_subsize()`. It creates
  383. * the new images one at a time and allows for the meta data to be saved after
  384. * each new image is created.
  385. *
  386. * @since 3.5.0
  387. *
  388. * @param array $sizes {
  389. * An array of image size data arrays.
  390. *
  391. * Either a height or width must be provided.
  392. * If one of the two is set to null, the resize will
  393. * maintain aspect ratio according to the provided dimension.
  394. *
  395. * @type array ...$0 {
  396. * Array of height, width values, and whether to crop.
  397. *
  398. * @type int $width Image width. Optional if `$height` is specified.
  399. * @type int $height Image height. Optional if `$width` is specified.
  400. * @type bool $crop Optional. Whether to crop the image. Default false.
  401. * }
  402. * }
  403. * @return array An array of resized images' metadata by size.
  404. */
  405. public function multi_resize( $sizes ) {
  406. $metadata = array();
  407. foreach ( $sizes as $size => $size_data ) {
  408. $meta = $this->make_subsize( $size_data );
  409. if ( ! is_wp_error( $meta ) ) {
  410. $metadata[ $size ] = $meta;
  411. }
  412. }
  413. return $metadata;
  414. }
  415. /**
  416. * Create an image sub-size and return the image meta data value for it.
  417. *
  418. * @since 5.3.0
  419. *
  420. * @param array $size_data {
  421. * Array of size data.
  422. *
  423. * @type int $width The maximum width in pixels.
  424. * @type int $height The maximum height in pixels.
  425. * @type bool $crop Whether to crop the image to exact dimensions.
  426. * }
  427. * @return array|WP_Error The image data array for inclusion in the `sizes` array in the image meta,
  428. * WP_Error object on error.
  429. */
  430. public function make_subsize( $size_data ) {
  431. if ( ! isset( $size_data['width'] ) && ! isset( $size_data['height'] ) ) {
  432. return new WP_Error( 'image_subsize_create_error', __( 'Cannot resize the image. Both width and height are not set.' ) );
  433. }
  434. $orig_size = $this->size;
  435. $orig_image = $this->image->getImage();
  436. if ( ! isset( $size_data['width'] ) ) {
  437. $size_data['width'] = null;
  438. }
  439. if ( ! isset( $size_data['height'] ) ) {
  440. $size_data['height'] = null;
  441. }
  442. if ( ! isset( $size_data['crop'] ) ) {
  443. $size_data['crop'] = false;
  444. }
  445. $resized = $this->resize( $size_data['width'], $size_data['height'], $size_data['crop'] );
  446. if ( is_wp_error( $resized ) ) {
  447. $saved = $resized;
  448. } else {
  449. $saved = $this->_save( $this->image );
  450. $this->image->clear();
  451. $this->image->destroy();
  452. $this->image = null;
  453. }
  454. $this->size = $orig_size;
  455. $this->image = $orig_image;
  456. if ( ! is_wp_error( $saved ) ) {
  457. unset( $saved['path'] );
  458. }
  459. return $saved;
  460. }
  461. /**
  462. * Crops Image.
  463. *
  464. * @since 3.5.0
  465. *
  466. * @param int $src_x The start x position to crop from.
  467. * @param int $src_y The start y position to crop from.
  468. * @param int $src_w The width to crop.
  469. * @param int $src_h The height to crop.
  470. * @param int $dst_w Optional. The destination width.
  471. * @param int $dst_h Optional. The destination height.
  472. * @param bool $src_abs Optional. If the source crop points are absolute.
  473. * @return true|WP_Error
  474. */
  475. public function crop( $src_x, $src_y, $src_w, $src_h, $dst_w = null, $dst_h = null, $src_abs = false ) {
  476. if ( $src_abs ) {
  477. $src_w -= $src_x;
  478. $src_h -= $src_y;
  479. }
  480. try {
  481. $this->image->cropImage( $src_w, $src_h, $src_x, $src_y );
  482. $this->image->setImagePage( $src_w, $src_h, 0, 0 );
  483. if ( $dst_w || $dst_h ) {
  484. // If destination width/height isn't specified,
  485. // use same as width/height from source.
  486. if ( ! $dst_w ) {
  487. $dst_w = $src_w;
  488. }
  489. if ( ! $dst_h ) {
  490. $dst_h = $src_h;
  491. }
  492. $thumb_result = $this->thumbnail_image( $dst_w, $dst_h );
  493. if ( is_wp_error( $thumb_result ) ) {
  494. return $thumb_result;
  495. }
  496. return $this->update_size();
  497. }
  498. } catch ( Exception $e ) {
  499. return new WP_Error( 'image_crop_error', $e->getMessage() );
  500. }
  501. return $this->update_size();
  502. }
  503. /**
  504. * Rotates current image counter-clockwise by $angle.
  505. *
  506. * @since 3.5.0
  507. *
  508. * @param float $angle
  509. * @return true|WP_Error
  510. */
  511. public function rotate( $angle ) {
  512. /**
  513. * $angle is 360-$angle because Imagick rotates clockwise
  514. * (GD rotates counter-clockwise)
  515. */
  516. try {
  517. $this->image->rotateImage( new ImagickPixel( 'none' ), 360 - $angle );
  518. // Normalize EXIF orientation data so that display is consistent across devices.
  519. if ( is_callable( array( $this->image, 'setImageOrientation' ) ) && defined( 'Imagick::ORIENTATION_TOPLEFT' ) ) {
  520. $this->image->setImageOrientation( Imagick::ORIENTATION_TOPLEFT );
  521. }
  522. // Since this changes the dimensions of the image, update the size.
  523. $result = $this->update_size();
  524. if ( is_wp_error( $result ) ) {
  525. return $result;
  526. }
  527. $this->image->setImagePage( $this->size['width'], $this->size['height'], 0, 0 );
  528. } catch ( Exception $e ) {
  529. return new WP_Error( 'image_rotate_error', $e->getMessage() );
  530. }
  531. return true;
  532. }
  533. /**
  534. * Flips current image.
  535. *
  536. * @since 3.5.0
  537. *
  538. * @param bool $horz Flip along Horizontal Axis
  539. * @param bool $vert Flip along Vertical Axis
  540. * @return true|WP_Error
  541. */
  542. public function flip( $horz, $vert ) {
  543. try {
  544. if ( $horz ) {
  545. $this->image->flipImage();
  546. }
  547. if ( $vert ) {
  548. $this->image->flopImage();
  549. }
  550. // Normalize EXIF orientation data so that display is consistent across devices.
  551. if ( is_callable( array( $this->image, 'setImageOrientation' ) ) && defined( 'Imagick::ORIENTATION_TOPLEFT' ) ) {
  552. $this->image->setImageOrientation( Imagick::ORIENTATION_TOPLEFT );
  553. }
  554. } catch ( Exception $e ) {
  555. return new WP_Error( 'image_flip_error', $e->getMessage() );
  556. }
  557. return true;
  558. }
  559. /**
  560. * Check if a JPEG image has EXIF Orientation tag and rotate it if needed.
  561. *
  562. * As ImageMagick copies the EXIF data to the flipped/rotated image, proceed only
  563. * if EXIF Orientation can be reset afterwards.
  564. *
  565. * @since 5.3.0
  566. *
  567. * @return bool|WP_Error True if the image was rotated. False if no EXIF data or if the image doesn't need rotation.
  568. * WP_Error if error while rotating.
  569. */
  570. public function maybe_exif_rotate() {
  571. if ( is_callable( array( $this->image, 'setImageOrientation' ) ) && defined( 'Imagick::ORIENTATION_TOPLEFT' ) ) {
  572. return parent::maybe_exif_rotate();
  573. } else {
  574. return new WP_Error( 'write_exif_error', __( 'The image cannot be rotated because the embedded meta data cannot be updated.' ) );
  575. }
  576. }
  577. /**
  578. * Saves current image to file.
  579. *
  580. * @since 3.5.0
  581. * @since 6.0.0 The `$filesize` value was added to the returned array.
  582. *
  583. * @param string $destfilename Optional. Destination filename. Default null.
  584. * @param string $mime_type Optional. The mime-type. Default null.
  585. * @return array|WP_Error {
  586. * Array on success or WP_Error if the file failed to save.
  587. *
  588. * @type string $path Path to the image file.
  589. * @type string $file Name of the image file.
  590. * @type int $width Image width.
  591. * @type int $height Image height.
  592. * @type string $mime-type The mime type of the image.
  593. * @type int $filesize File size of the image.
  594. * }
  595. */
  596. public function save( $destfilename = null, $mime_type = null ) {
  597. $saved = $this->_save( $this->image, $destfilename, $mime_type );
  598. if ( ! is_wp_error( $saved ) ) {
  599. $this->file = $saved['path'];
  600. $this->mime_type = $saved['mime-type'];
  601. try {
  602. $this->image->setImageFormat( strtoupper( $this->get_extension( $this->mime_type ) ) );
  603. } catch ( Exception $e ) {
  604. return new WP_Error( 'image_save_error', $e->getMessage(), $this->file );
  605. }
  606. }
  607. return $saved;
  608. }
  609. /**
  610. * @since 3.5.0
  611. * @since 6.0.0 The `$filesize` value was added to the returned array.
  612. *
  613. * @param Imagick $image
  614. * @param string $filename
  615. * @param string $mime_type
  616. * @return array|WP_Error {
  617. * Array on success or WP_Error if the file failed to save.
  618. *
  619. * @type string $path Path to the image file.
  620. * @type string $file Name of the image file.
  621. * @type int $width Image width.
  622. * @type int $height Image height.
  623. * @type string $mime-type The mime type of the image.
  624. * @type int $filesize File size of the image.
  625. * }
  626. */
  627. protected function _save( $image, $filename = null, $mime_type = null ) {
  628. list( $filename, $extension, $mime_type ) = $this->get_output_format( $filename, $mime_type );
  629. if ( ! $filename ) {
  630. $filename = $this->generate_filename( null, null, $extension );
  631. }
  632. try {
  633. // Store initial format.
  634. $orig_format = $this->image->getImageFormat();
  635. $this->image->setImageFormat( strtoupper( $this->get_extension( $mime_type ) ) );
  636. } catch ( Exception $e ) {
  637. return new WP_Error( 'image_save_error', $e->getMessage(), $filename );
  638. }
  639. $write_image_result = $this->write_image( $this->image, $filename );
  640. if ( is_wp_error( $write_image_result ) ) {
  641. return $write_image_result;
  642. }
  643. try {
  644. // Reset original format.
  645. $this->image->setImageFormat( $orig_format );
  646. } catch ( Exception $e ) {
  647. return new WP_Error( 'image_save_error', $e->getMessage(), $filename );
  648. }
  649. // Set correct file permissions.
  650. $stat = stat( dirname( $filename ) );
  651. $perms = $stat['mode'] & 0000666; // Same permissions as parent folder, strip off the executable bits.
  652. chmod( $filename, $perms );
  653. return array(
  654. 'path' => $filename,
  655. /** This filter is documented in wp-includes/class-wp-image-editor-gd.php */
  656. 'file' => wp_basename( apply_filters( 'image_make_intermediate_size', $filename ) ),
  657. 'width' => $this->size['width'],
  658. 'height' => $this->size['height'],
  659. 'mime-type' => $mime_type,
  660. 'filesize' => wp_filesize( $filename ),
  661. );
  662. }
  663. /**
  664. * Writes an image to a file or stream.
  665. *
  666. * @since 5.6.0
  667. *
  668. * @param Imagick $image
  669. * @param string $filename The destination filename or stream URL.
  670. * @return true|WP_Error
  671. */
  672. private function write_image( $image, $filename ) {
  673. if ( wp_is_stream( $filename ) ) {
  674. /*
  675. * Due to reports of issues with streams with `Imagick::writeImageFile()` and `Imagick::writeImage()`, copies the blob instead.
  676. * Checks for exact type due to: https://www.php.net/manual/en/function.file-put-contents.php
  677. */
  678. if ( file_put_contents( $filename, $image->getImageBlob() ) === false ) {
  679. return new WP_Error(
  680. 'image_save_error',
  681. sprintf(
  682. /* translators: %s: PHP function name. */
  683. __( '%s failed while writing image to stream.' ),
  684. '<code>file_put_contents()</code>'
  685. ),
  686. $filename
  687. );
  688. } else {
  689. return true;
  690. }
  691. } else {
  692. $dirname = dirname( $filename );
  693. if ( ! wp_mkdir_p( $dirname ) ) {
  694. return new WP_Error(
  695. 'image_save_error',
  696. sprintf(
  697. /* translators: %s: Directory path. */
  698. __( 'Unable to create directory %s. Is its parent directory writable by the server?' ),
  699. esc_html( $dirname )
  700. )
  701. );
  702. }
  703. try {
  704. return $image->writeImage( $filename );
  705. } catch ( Exception $e ) {
  706. return new WP_Error( 'image_save_error', $e->getMessage(), $filename );
  707. }
  708. }
  709. }
  710. /**
  711. * Streams current image to browser.
  712. *
  713. * @since 3.5.0
  714. *
  715. * @param string $mime_type The mime type of the image.
  716. * @return true|WP_Error True on success, WP_Error object on failure.
  717. */
  718. public function stream( $mime_type = null ) {
  719. list( $filename, $extension, $mime_type ) = $this->get_output_format( null, $mime_type );
  720. try {
  721. // Temporarily change format for stream.
  722. $this->image->setImageFormat( strtoupper( $extension ) );
  723. // Output stream of image content.
  724. header( "Content-Type: $mime_type" );
  725. print $this->image->getImageBlob();
  726. // Reset image to original format.
  727. $this->image->setImageFormat( $this->get_extension( $this->mime_type ) );
  728. } catch ( Exception $e ) {
  729. return new WP_Error( 'image_stream_error', $e->getMessage() );
  730. }
  731. return true;
  732. }
  733. /**
  734. * Strips all image meta except color profiles from an image.
  735. *
  736. * @since 4.5.0
  737. *
  738. * @return true|WP_Error True if stripping metadata was successful. WP_Error object on error.
  739. */
  740. protected function strip_meta() {
  741. if ( ! is_callable( array( $this->image, 'getImageProfiles' ) ) ) {
  742. return new WP_Error(
  743. 'image_strip_meta_error',
  744. sprintf(
  745. /* translators: %s: ImageMagick method name. */
  746. __( '%s is required to strip image meta.' ),
  747. '<code>Imagick::getImageProfiles()</code>'
  748. )
  749. );
  750. }
  751. if ( ! is_callable( array( $this->image, 'removeImageProfile' ) ) ) {
  752. return new WP_Error(
  753. 'image_strip_meta_error',
  754. sprintf(
  755. /* translators: %s: ImageMagick method name. */
  756. __( '%s is required to strip image meta.' ),
  757. '<code>Imagick::removeImageProfile()</code>'
  758. )
  759. );
  760. }
  761. /*
  762. * Protect a few profiles from being stripped for the following reasons:
  763. *
  764. * - icc: Color profile information
  765. * - icm: Color profile information
  766. * - iptc: Copyright data
  767. * - exif: Orientation data
  768. * - xmp: Rights usage data
  769. */
  770. $protected_profiles = array(
  771. 'icc',
  772. 'icm',
  773. 'iptc',
  774. 'exif',
  775. 'xmp',
  776. );
  777. try {
  778. // Strip profiles.
  779. foreach ( $this->image->getImageProfiles( '*', true ) as $key => $value ) {
  780. if ( ! in_array( $key, $protected_profiles, true ) ) {
  781. $this->image->removeImageProfile( $key );
  782. }
  783. }
  784. } catch ( Exception $e ) {
  785. return new WP_Error( 'image_strip_meta_error', $e->getMessage() );
  786. }
  787. return true;
  788. }
  789. /**
  790. * Sets up Imagick for PDF processing.
  791. * Increases rendering DPI and only loads first page.
  792. *
  793. * @since 4.7.0
  794. *
  795. * @return string|WP_Error File to load or WP_Error on failure.
  796. */
  797. protected function pdf_setup() {
  798. try {
  799. // By default, PDFs are rendered in a very low resolution.
  800. // We want the thumbnail to be readable, so increase the rendering DPI.
  801. $this->image->setResolution( 128, 128 );
  802. // Only load the first page.
  803. return $this->file . '[0]';
  804. } catch ( Exception $e ) {
  805. return new WP_Error( 'pdf_setup_failed', $e->getMessage(), $this->file );
  806. }
  807. }
  808. /**
  809. * Load the image produced by Ghostscript.
  810. *
  811. * Includes a workaround for a bug in Ghostscript 8.70 that prevents processing of some PDF files
  812. * when `use-cropbox` is set.
  813. *
  814. * @since 5.6.0
  815. *
  816. * @return true|WP_Error
  817. */
  818. protected function pdf_load_source() {
  819. $filename = $this->pdf_setup();
  820. if ( is_wp_error( $filename ) ) {
  821. return $filename;
  822. }
  823. try {
  824. // When generating thumbnails from cropped PDF pages, Imagemagick uses the uncropped
  825. // area (resulting in unnecessary whitespace) unless the following option is set.
  826. $this->image->setOption( 'pdf:use-cropbox', true );
  827. // Reading image after Imagick instantiation because `setResolution`
  828. // only applies correctly before the image is read.
  829. $this->image->readImage( $filename );
  830. } catch ( Exception $e ) {
  831. // Attempt to run `gs` without the `use-cropbox` option. See #48853.
  832. $this->image->setOption( 'pdf:use-cropbox', false );
  833. $this->image->readImage( $filename );
  834. }
  835. return true;
  836. }
  837. }