diff --git a/modules/images/webp-uploads/load.php b/modules/images/webp-uploads/load.php index 3c8f915a90..e49da078fd 100644 --- a/modules/images/webp-uploads/load.php +++ b/modules/images/webp-uploads/load.php @@ -684,6 +684,151 @@ function webp_uploads_update_rest_attachment( WP_REST_Response $response, WP_Pos } add_filter( 'rest_prepare_attachment', 'webp_uploads_update_rest_attachment', 10, 3 ); +/** + * Adds sources to metadata for an attachment. + * + * @since n.e.x.t + * + * @param array $metadata Metadata of the attachment. + * @param array $valid_mime_transforms List of valid mime transforms for current image mime type. + * @param array $main_images Path of all main image files of all mime types. + * @param array $subsized_images Path of all subsized image file of all mime types. + * @return array Metadata with sources added. + */ +function webp_uploads_update_sources( $metadata, $valid_mime_transforms, $main_images, $subsized_images ) { + foreach ( $valid_mime_transforms as $targeted_mime ) { + // Make sure the path and file exists as those values are being accessed. + if ( ! isset( $main_images[ $targeted_mime ]['path'], $main_images[ $targeted_mime ]['file'] ) || ! file_exists( $main_images[ $targeted_mime ]['path'] ) ) { + continue; + } + + $image_directory = pathinfo( $main_images[ $targeted_mime ]['path'], PATHINFO_DIRNAME ); + + // Add sources to original image metadata. + $metadata['sources'][ $targeted_mime ] = array( + 'file' => $main_images[ $targeted_mime ]['file'], + 'filesize' => filesize( $main_images[ $targeted_mime ]['path'] ), + ); + + foreach ( $metadata['sizes'] as $size_name => $size_details ) { + if ( empty( $subsized_images[ $targeted_mime ][ $size_name ]['file'] ) ) { + continue; + } + + // Add sources to resized image metadata. + $subsize_path = path_join( $image_directory, $subsized_images[ $targeted_mime ][ $size_name ]['file'] ); + + if ( ! file_exists( $subsize_path ) ) { + continue; + } + + $metadata['sizes'][ $size_name ]['sources'][ $targeted_mime ] = array( + 'file' => $subsized_images[ $targeted_mime ][ $size_name ]['file'], + 'filesize' => filesize( $subsize_path ), + ); + } + } + + return $metadata; +} + +/** + * Creates additional image formats when original image is edited. + * + * @since n.e.x.t + * + * @param bool|null $override Value to return instead of saving. Default null. + * @param string $file_path Name of the file to be saved. + * @param WP_Image_Editor $editor The image editor instance. + * @param string $mime_type The mime type of the image. + * @param int $post_id Attachment post ID. + * @return bool|null Potentially modified $override value. + */ +function webp_uploads_update_image_onchange( $override, $file_path, $editor, $mime_type, $post_id ) { + if ( null !== $override ) { + return $override; + } + + $transforms = webp_uploads_get_upload_image_mime_transforms(); + if ( empty( $transforms[ $mime_type ] ) ) { + return $override; + } + + $mime_transforms = $transforms[ $mime_type ]; + // This variable allows to unhook the logic from within the closure without the need fo a function name. + $callback_executed = false; + add_filter( + 'wp_update_attachment_metadata', + function ( $metadata, $post_meta_id ) use ( $post_id, $file_path, $mime_type, $editor, $mime_transforms, &$callback_executed ) { + if ( $post_meta_id !== $post_id ) { + return $metadata; + } + + // This callback was already executed for this post, nothing to do at this point. + if ( $callback_executed ) { + return $metadata; + } + $callback_executed = true; + + // No sizes to be created. + if ( empty( $metadata['sizes'] ) ) { + return $metadata; + } + + $old_metadata = wp_get_attachment_metadata( $post_id ); + $resize_sizes = array(); + foreach ( $old_metadata['sizes'] as $size_name => $size_details ) { + if ( isset( $metadata['sizes'][ $size_name ] ) && ! empty( $metadata['sizes'][ $size_name ] ) && + $metadata['sizes'][ $size_name ]['file'] !== $old_metadata['sizes'][ $size_name ]['file'] ) { + $resize_sizes[ $size_name ] = $metadata['sizes'][ $size_name ]; + } + } + + $allowed_mimes = array_flip( wp_get_mime_types() ); + $original_directory = pathinfo( $file_path, PATHINFO_DIRNAME ); + $filename = pathinfo( $file_path, PATHINFO_FILENAME ); + $main_images = array(); + $subsized_images = array(); + foreach ( $mime_transforms as $targeted_mime ) { + if ( $targeted_mime === $mime_type ) { + $main_images[ $targeted_mime ] = array( + 'path' => $file_path, + 'file' => pathinfo( $file_path, PATHINFO_BASENAME ), + ); + $subsized_images[ $targeted_mime ] = $metadata['sizes']; + continue; + } + + if ( ! isset( $allowed_mimes[ $targeted_mime ] ) || ! is_string( $allowed_mimes[ $targeted_mime ] ) ) { + continue; + } + + if ( ! $editor::supports_mime_type( $targeted_mime ) ) { + continue; + } + + $extension = explode( '|', $allowed_mimes[ $targeted_mime ] ); + $destination = trailingslashit( $original_directory ) . "{$filename}.{$extension[0]}"; + $result = $editor->save( $destination, $targeted_mime ); + + if ( is_wp_error( $result ) ) { + continue; + } + + $main_images[ $targeted_mime ] = $result; + $subsized_images[ $targeted_mime ] = $editor->multi_resize( $resize_sizes ); + } + + return webp_uploads_update_sources( $metadata, $mime_transforms, $main_images, $subsized_images ); + }, + 10, + 2 + ); + + return $override; +} +add_filter( 'wp_save_image_editor_file', 'webp_uploads_update_image_onchange', 10, 7 ); + /** * Inspect if the current call to `wp_update_attachment_metadata()` was done from within the context * of an edit to an attachment either restore or other type of edit, in that case we perform operations diff --git a/phpcs.xml.dist b/phpcs.xml.dist index f542112bef..cfdc9b1708 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -19,6 +19,9 @@ ./tests + + module-i18n.php + tests/* diff --git a/tests/modules/images/webp-uploads/webp-uploads-test.php b/tests/modules/images/webp-uploads/webp-uploads-test.php index f1b9717df8..8a624ced51 100644 --- a/tests/modules/images/webp-uploads/webp-uploads-test.php +++ b/tests/modules/images/webp-uploads/webp-uploads-test.php @@ -873,15 +873,31 @@ public function it_should_restore_the_sources_array_from_the_backup_when_an_imag wp_restore_image( $attachment_id ); + $this->assertImageHasSource( $attachment_id, 'image/jpeg' ); + $this->assertImageHasSource( $attachment_id, 'image/webp' ); + $metadata = wp_get_attachment_metadata( $attachment_id ); - $this->assertArrayHasKey( 'sources', $metadata ); + $this->assertSame( $backup_sources['full-orig'], $metadata['sources'] ); $this->assertSame( $backup_sources, get_post_meta( $attachment_id, '_wp_attachment_backup_sources', true ) ); $backup_sizes = get_post_meta( $attachment_id, '_wp_attachment_backup_sizes', true ); - foreach ( $metadata['sizes'] as $size_name => $properties ) { - $this->assertArrayHasKey( 'sources', $backup_sizes[ $size_name . '-orig' ] ); - $this->assertSame( $backup_sizes[ $size_name . '-orig' ]['sources'], $properties['sources'] ); + foreach ( $backup_sizes as $size_name => $properties ) { + // We are only interested in the original filenames to be compared against the backup and restored values. + if ( false === strpos( $size_name, '-orig' ) ) { + $this->assertSizeNameIsHashed( '', $size_name, "{$size_name} is not a valid edited name" ); + continue; + } + + $size_name = str_replace( '-orig', '', $size_name ); + // Full name is verified above. + if ( 'full' === $size_name ) { + continue; + } + + $this->assertArrayHasKey( $size_name, $metadata['sizes'] ); + $this->assertArrayHasKey( 'sources', $metadata['sizes'][ $size_name ] ); + $this->assertSame( $properties['sources'], $metadata['sizes'][ $size_name ]['sources'] ); } } @@ -938,7 +954,17 @@ public function it_should_prevent_to_backup_the_full_size_image_if_only_the_thum $this->assertArrayHasKey( 'sources', $backup_sizes['thumbnail-orig'] ); $metadata = wp_get_attachment_metadata( $attachment_id ); - $this->assertArrayHasKey( 'sources', $metadata ); + + $this->assertImageHasSource( $attachment_id, 'image/jpeg' ); + $this->assertImageHasSource( $attachment_id, 'image/webp' ); + + $this->assertImageHasSizeSource( $attachment_id, 'thumbnail', 'image/jpeg' ); + $this->assertImageHasSizeSource( $attachment_id, 'thumbnail', 'image/webp' ); + + foreach ( $metadata['sizes'] as $size_name => $properties ) { + $this->assertImageHasSizeSource( $attachment_id, $size_name, 'image/jpeg' ); + $this->assertImageHasSizeSource( $attachment_id, $size_name, 'image/webp' ); + } } /** @@ -959,7 +985,12 @@ public function it_should_backup_the_image_when_all_images_except_the_thumbnail_ $this->assertArrayHasKey( 'full-orig', $backup_sources ); $this->assertSame( $metadata['sources'], $backup_sources['full-orig'] ); - $this->assertArrayNotHasKey( 'sources', wp_get_attachment_metadata( $attachment_id ), 'The sources attributes was not removed from the metadata.' ); + $updated_metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->assertArrayHasKey( 'sources', $updated_metadata ); + $this->assertNotSame( $metadata['sources'], $updated_metadata['sources'] ); + $this->assertImageHasSource( $attachment_id, 'image/jpeg' ); + $this->assertImageHasSource( $attachment_id, 'image/webp' ); $backup_sizes = get_post_meta( $attachment_id, '_wp_attachment_backup_sizes', true ); $this->assertIsArray( $backup_sizes ); @@ -973,6 +1004,39 @@ public function it_should_backup_the_image_when_all_images_except_the_thumbnail_ } } + /** + * Update source attributes when webp is edited. + * + * @test + */ + public function it_should_validate_source_attribute_update_when_webp_edited() { + $attachment_id = $this->factory->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/testdata/modules/images/leafs.jpg' ); + + $editor = new WP_Image_Edit( $attachment_id ); + $editor->crop( 1000, 200, 0, 0 )->save(); + $this->assertTrue( $editor->success() ); + + $this->assertImageHasSource( $attachment_id, 'image/webp' ); + $this->assertImageHasSource( $attachment_id, 'image/jpeg' ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->assertFileNameIsEdited( $metadata['sources']['image/webp']['file'] ); + $this->assertFileNameIsEdited( $metadata['sources']['image/jpeg']['file'] ); + + $this->assertArrayHasKey( 'sources', $metadata ); + $this->assertArrayHasKey( 'sizes', $metadata ); + + foreach ( $metadata['sizes'] as $size_name => $properties ) { + $this->assertArrayHasKey( 'sources', $properties ); + $this->assertImageHasSizeSource( $attachment_id, $size_name, 'image/webp' ); + $this->assertImageHasSizeSource( $attachment_id, $size_name, 'image/jpeg' ); + + $this->assertFileNameIsEdited( $properties['sources']['image/webp']['file'] ); + $this->assertFileNameIsEdited( $properties['sources']['image/jpeg']['file'] ); + } + } + /** * Allow the upload of a WebP image if at least one editor supports the format * @@ -1058,7 +1122,7 @@ public function it_should_u_se_the_next_available_hash_for_the_full_size_image_o remove_filter( 'wp_update_attachment_metadata', 'webp_uploads_update_attachment_metadata' ); $editor->rotate_right()->save(); - $this->assertRegExp( '/full-\d{13}/', webp_uploads_get_next_full_size_key_from_backup( $attachment_id ) ); + $this->assertSizeNameIsHashed( 'full', webp_uploads_get_next_full_size_key_from_backup( $attachment_id ) ); } /** @@ -1106,7 +1170,7 @@ public function it_should_store_the_metadata_on_the_next_available_hash() { $backup_sources_keys = array_keys( $backup_sources ); $this->assertSame( 'full-orig', reset( $backup_sources_keys ) ); - $this->assertRegExp( '/full-\d{13}/', end( $backup_sources_keys ) ); + $this->assertSizeNameIsHashed( 'full', end( $backup_sources_keys ) ); $this->assertSame( $sources, end( $backup_sources ) ); } diff --git a/tests/utils/TestCase/ImagesTestCase.php b/tests/utils/TestCase/ImagesTestCase.php index 0deb152bc9..cc44dec263 100644 --- a/tests/utils/TestCase/ImagesTestCase.php +++ b/tests/utils/TestCase/ImagesTestCase.php @@ -13,6 +13,9 @@ * @method void assertImageHasSizeSource( $attachment_id, $size, $mime_type, $message ) Asserts that the image has the appropriate source for the subsize. * @method void assertImageNotHasSource( $attachment_id, $mime_type, $message ) Asserts that the image doesn't have the appropriate source. * @method void assertImageNotHasSizeSource( $attachment_id, $size, $mime_type, $message ) Asserts that the image doesn't have the appropriate source for the subsize. + * @method void assertFileNameIsEdited( string $filename, string $message = '' ) Asserts that the provided file name was edited by WordPress contains an e{WITH_13_DIGITS} on the filename. + * @method void assertFileNameIsNotEdited( string $filename, string $message = '' ) Asserts that the provided file name was edited by WordPress contains an e{WITH_13_DIGITS} on the filename. + * @method void assertSizeNameIsHashed( string $size_name, string $hashed_size_name, string $message = '' ) Asserts that the provided size name is an edited name that contains a hash with digits. */ abstract class ImagesTestCase extends WP_UnitTestCase { @@ -76,4 +79,37 @@ public static function assertImageNotHasSizeSource( $attachment_id, $size, $mime self::assertThat( $attachment_id, $constraint, $message ); } + /** + * Asserts that the provided file name was edited by WordPress contains an e{WITH_13_DIGITS} on the filename. + * + * @param string $filename The name of the filename to be asserted. + * @param string $message The Error message used to display when the assertion fails. + * @return void + */ + public static function assertFileNameIsEdited( $filename, $message = '' ) { + self::assertRegExp( '/e\d{13}/', $filename, $message ); + } + + /** + * Asserts that the provided file name was edited by WordPress contains an e{WITH_13_DIGITS} on the filename. + * + * @param string $filename The name of the filename to be asserted. + * @param string $message The Error message used to display when the assertion fails. + * @return void + */ + public static function assertFileNameIsNotEdited( $filename, $message = '' ) { + self::assertNotRegExp( '/e\d{13}/', $filename, $message ); + } + + /** + * Asserts that the provided size name is an edited name that contains a hash with digits. + * + * @param string $size_name The size name we are looking for. + * @param string $hashed_size_name The current size name we are comparing against. + * @param string $message The Error message used to display when the assertion fails. + * @return void + */ + public static function assertSizeNameIsHashed( $size_name, $hashed_size_name, $message = '' ) { + self::assertRegExp( "/{$size_name}-\d{13}/", $hashed_size_name, $message ); + } }