From 29ea4c6a4c4b31b4a2d70add71c8c2d77e5cc097 Mon Sep 17 00:00:00 2001 From: Andrew Ozz Date: Fri, 20 Sep 2019 18:21:57 +0000 Subject: [PATCH] Media/Upload: rotate images on upload according to EXIF Orientation. Props msaggiorato, wpdavis, markoheijnen, dhuyvetter, msaggiorato, n7studios, triplejumper12, pbiron, mikeschroder, joemcgill, azaozz. Fixes #14459. Built from https://develop.svn.wordpress.org/trunk@46202 git-svn-id: http://core.svn.wordpress.org/trunk@46014 1a063a9b-81f0-0310-95a4-ce76da25c4cd --- wp-admin/includes/image.php | 96 ++++++++++++++++--- wp-includes/class-wp-image-editor-imagick.php | 27 +++++- wp-includes/class-wp-image-editor.php | 78 +++++++++++++++ wp-includes/version.php | 2 +- 4 files changed, 187 insertions(+), 16 deletions(-) diff --git a/wp-admin/includes/image.php b/wp-admin/includes/image.php index bcdc2411f0..12f38049dc 100644 --- a/wp-admin/includes/image.php +++ b/wp-admin/includes/image.php @@ -158,6 +158,37 @@ function wp_update_image_subsizes( $attachment_id ) { return _wp_make_subsizes( $missing_sizes, $image_file, $image_meta, $attachment_id ); } +/** + * Updates the attached file and image meta data when the original image was edited. + * + * @since 5.3.0 + * @access private + * + * @param array $saved_data The data retirned from WP_Image_Editor after successfully saving an image. + * @param string $original_file Path to the original file. + * @param array $image_meta The image meta data. + * @param int $attachment_id The attachment post ID. + * @return array The updated image meta data. + */ +function _wp_image_meta_replace_original( $saved_data, $original_file, $image_meta, $attachment_id ) { + $new_file = $saved_data['path']; + + // Update the attached file meta. + update_attached_file( $attachment_id, $new_file ); + + // Width and height of the new image. + $image_meta['width'] = $saved_data['width']; + $image_meta['height'] = $saved_data['height']; + + // Make the file path relative to the upload dir. + $image_meta['file'] = _wp_relative_upload_path( $new_file ); + + // Store the original image file name in image_meta. + $image_meta['original_image'] = wp_basename( $original_file ); + + return $image_meta; +} + /** * Creates image sub-sizes, adds the new data to the image meta `sizes` array, and updates the image metadata. * @@ -222,30 +253,58 @@ function wp_create_image_subsizes( $file, $attachment_id ) { // Resize the image $resized = $editor->resize( $threshold, $threshold ); + $rotated = null; + + // If there is EXIF data, rotate according to EXIF Orientation. + if ( ! is_wp_error( $resized ) && is_array( $exif_meta ) ) { + $resized = $editor->maybe_exif_rotate(); + $rotated = $resized; + } if ( ! is_wp_error( $resized ) ) { - // TODO: EXIF rotate here. - // By default the editor will append `{width}x{height}` to the file name of the resized image. - // Better to append the threshold size instead so the image file name would be like "my-image-2560.jpg" - // and not look like a "regular" sub-size. + // Append the threshold size to the image file name. It will look like "my-image-2560.jpg". // This doesn't affect the sub-sizes names as they are generated from the original image (for best quality). $saved = $editor->save( $editor->generate_filename( $threshold ) ); if ( ! is_wp_error( $saved ) ) { - $new_file = $saved['path']; + $image_meta = _wp_image_meta_replace_original( $saved, $file, $image_meta, $attachment_id ); - // Update the attached file meta. - update_attached_file( $attachment_id, $new_file ); + // If the image was rotated update the stored EXIF data. + if ( true === $rotated && ! empty( $image_meta['image_meta']['orientation'] ) ) { + $image_meta['image_meta']['orientation'] = 1; + } + } else { + // TODO: handle errors. + } + } else { + // TODO: handle errors. + } + } elseif ( ! empty( $exif_meta['orientation'] ) && (int) $exif_meta['orientation'] !== 1 ) { + // Rotate the whole original image if there is EXIF data and "orientation" is not 1. - // Width and height of the new image. - $image_meta['width'] = $saved['width']; - $image_meta['height'] = $saved['height']; + $editor = wp_get_image_editor( $file ); - // Make the file path relative to the upload dir. - $image_meta['file'] = _wp_relative_upload_path( $new_file ); + if ( is_wp_error( $editor ) ) { + // This image cannot be edited. + return $image_meta; + } - // Store the original image file name in image_meta. - $image_meta['original_image'] = wp_basename( $file ); + // Rotate the image + $rotated = $editor->maybe_exif_rotate(); + + if ( true === $rotated ) { + // Append `-rotated` to the image file name. + $saved = $editor->save( $editor->generate_filename( 'rotated' ) ); + + if ( ! is_wp_error( $saved ) ) { + $image_meta = _wp_image_meta_replace_original( $saved, $file, $image_meta, $attachment_id ); + + // Update the stored EXIF data. + if ( ! empty( $image_meta['image_meta']['orientation'] ) ) { + $image_meta['image_meta']['orientation'] = 1; + } + } else { + // TODO: handle errors. } } } @@ -327,6 +386,15 @@ function _wp_make_subsizes( $new_sizes, $file, $image_meta, $attachment_id ) { return $image_meta; } + // If stored EXIF data exists, rotate the source image before creating sub-sizes. + if ( ! empty( $image_meta['image_meta'] ) ) { + $rotated = $editor->maybe_exif_rotate(); + + if ( is_wp_error( $rotated ) ) { + // TODO: handle errors. + } + } + if ( method_exists( $editor, 'make_subsize' ) ) { foreach ( $new_sizes as $new_size_name => $new_size_data ) { $new_size_meta = $editor->make_subsize( $new_size_data ); diff --git a/wp-includes/class-wp-image-editor-imagick.php b/wp-includes/class-wp-image-editor-imagick.php index eb5ee112fb..1d119f4609 100644 --- a/wp-includes/class-wp-image-editor-imagick.php +++ b/wp-includes/class-wp-image-editor-imagick.php @@ -566,7 +566,7 @@ class WP_Image_Editor_Imagick extends WP_Image_Editor { try { $this->image->rotateImage( new ImagickPixel( 'none' ), 360 - $angle ); - // Normalise Exif orientation data so that display is consistent across devices. + // 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 ); } @@ -602,12 +602,37 @@ class WP_Image_Editor_Imagick extends WP_Image_Editor { if ( $vert ) { $this->image->flopImage(); } + + // 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 ); + } } catch ( Exception $e ) { return new WP_Error( 'image_flip_error', $e->getMessage() ); } + return true; } + /** + * Check if a JPEG image has EXIF Orientation tag and rotate it if needed. + * + * As ImageMagick copies the EXIF data to the flipped/rotated image, proceed only + * if EXIF Orientation can be reset afterwards. + * + * @since 5.3.0 + * + * @return bool|WP_Error True if the image was rotated. False if no EXIF data or if the image doesn't need rotation. + * WP_Error if error while rotating. + */ + public function maybe_exif_rotate() { + if ( is_callable( array( $this->image, 'setImageOrientation' ) ) && defined( 'Imagick::ORIENTATION_TOPLEFT' ) ) { + return parent::maybe_exif_rotate(); + } else { + return new WP_Error( 'write_exif_error', __( 'The image cannot be rotated because the embedded meta data cannot be updated.' ) ); + } + } + /** * Saves current image to file. * diff --git a/wp-includes/class-wp-image-editor.php b/wp-includes/class-wp-image-editor.php index 392be576cb..6b3a6d544c 100644 --- a/wp-includes/class-wp-image-editor.php +++ b/wp-includes/class-wp-image-editor.php @@ -384,6 +384,84 @@ abstract class WP_Image_Editor { return "{$this->size['width']}x{$this->size['height']}"; } + /** + * Check if a JPEG image has EXIF Orientation tag and rotate it if needed. + * + * @since 5.3.0 + * + * @return bool|WP_Error True if the image was rotated. False if not rotated (no EXIF data or the image doesn't need to be rotated). + * WP_Error if error while rotating. + */ + public function maybe_exif_rotate() { + $orientation = null; + + if ( is_callable( 'exif_read_data' ) && 'image/jpeg' === $this->mime_type ) { + $exif_data = @exif_read_data( $this->file ); + + if ( ! empty( $exif_data['Orientation'] ) ) { + $orientation = (int) $exif_data['Orientation']; + } + } + + /** + * Filters the `$orientation` value to correct it before rotating or to prevemnt rotating the image. + * + * @since 5.3.0 + * + * @param int $orientation EXIF Orientation value as retrieved from the image file. + * @param string $file Path to the image file. + */ + $orientation = apply_filters( 'wp_image_maybe_exif_rotate', $orientation, $this->file ); + + if ( ! $orientation || $orientation === 1 ) { + return false; + } + + switch ( $orientation ) { + case 2: + // Flip horizontally. + $result = $this->flip( true, false ); + break; + case 3: + // Rotate 180 degrees or flip horizontally and vertically. + // Flipping seems faster/uses less resources. + $result = $this->flip( true, true ); + break; + case 4: + // Flip vertically. + $result = $this->flip( false, true ); + break; + case 5: + // Rotate 90 degrees counter-clockwise and flip vertically. + $result = $this->rotate( 90 ); + + if ( ! is_wp_error( $result ) ) { + $result = $this->flip( false, true ); + } + + break; + case 6: + // Rotate 90 degrees clockwise (270 counter-clockwise). + $result = $this->rotate( 270 ); + break; + case 7: + // Rotate 90 degrees counter-clockwise and flip horizontally. + $result = $this->rotate( 90 ); + + if ( ! is_wp_error( $result ) ) { + $result = $this->flip( true, false ); + } + + break; + case 8: + // Rotate 90 degrees counter-clockwise. + $result = $this->rotate( 90 ); + break; + } + + return $result; + } + /** * Either calls editor's save function or handles file as a stream. * diff --git a/wp-includes/version.php b/wp-includes/version.php index 12b11c984b..3cde54333b 100644 --- a/wp-includes/version.php +++ b/wp-includes/version.php @@ -13,7 +13,7 @@ * * @global string $wp_version */ -$wp_version = '5.3-alpha-46201'; +$wp_version = '5.3-alpha-46202'; /** * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.