diff --git a/src/js/_enqueues/vendor/plupload/handlers.js b/src/js/_enqueues/vendor/plupload/handlers.js index fa602daf43..c7e3c253d3 100644 --- a/src/js/_enqueues/vendor/plupload/handlers.js +++ b/src/js/_enqueues/vendor/plupload/handlers.js @@ -486,7 +486,7 @@ jQuery( document ).ready( function( $ ) { times = tryAgainCount[ file.id ]; - if ( times && times > 4 ) { + if ( times && times > 8 ) { /* * The file may have been uploaded and attachment post created, * but post-processing and resizing failed... diff --git a/src/js/_enqueues/vendor/plupload/wp-plupload.js b/src/js/_enqueues/vendor/plupload/wp-plupload.js index 0fdebf77d1..217b3c09e2 100644 --- a/src/js/_enqueues/vendor/plupload/wp-plupload.js +++ b/src/js/_enqueues/vendor/plupload/wp-plupload.js @@ -138,7 +138,7 @@ window.wp = window.wp || {}; times = tryAgainCount[ file.id ]; - if ( times && times > 4 ) { + if ( times && times > 8 ) { /* * The file may have been uploaded and attachment post created, * but post-processing and resizing failed... diff --git a/src/wp-admin/includes/image.php b/src/wp-admin/includes/image.php index 1a9c9e2e9c..98689672b3 100644 --- a/src/wp-admin/includes/image.php +++ b/src/wp-admin/includes/image.php @@ -77,16 +77,23 @@ function wp_crop_image( $src, $src_x, $src_y, $src_w, $src_h, $dst_w, $dst_h, $s * Registered sub-sizes that are larger than the image are skipped. * * @since 5.3.0 + * @since 6.1.0 The $mime_type parameter was added. * - * @param int $attachment_id The image attachment post ID. + * @param int $attachment_id The image attachment post ID. + * @param string $mime_type Optional. The mime type to check for missing sizes. Default is the primary image mime. * @return array[] Associative array of arrays of image sub-size information for * missing image sizes, keyed by image size name. */ -function wp_get_missing_image_subsizes( $attachment_id ) { +function wp_get_missing_image_subsizes( $attachment_id, $mime_type = '' ) { if ( ! wp_attachment_is_image( $attachment_id ) ) { return array(); } + $primary_mime_type = get_post_mime_type( get_post( $attachment_id ) ); + if ( ! $mime_type ) { + $mime_type = $primary_mime_type; + } + $registered_sizes = wp_get_registered_image_subsizes(); $image_meta = wp_get_attachment_metadata( $attachment_id ); @@ -129,19 +136,38 @@ function wp_get_missing_image_subsizes( $attachment_id ) { * However we keep the old sub-sizes with the previous dimensions * as the image may have been used in an older post. */ - $missing_sizes = array_diff_key( $possible_sizes, $image_meta['sizes'] ); + $missing_sizes = array(); + foreach ( $possible_sizes as $size_name => $size_data ) { + if ( ! isset( $image_meta['sizes'][ $size_name ] ) ) { + $missing_sizes[ $size_name ] = $size_data; + continue; + } + + if ( ( isset( $size_data['mime-type'] ) && $size_data['mime-type'] === $mime_type ) || isset( $size_data['sources'][ $mime_type ] ) ) { + continue; + } + + $missing_sizes[ $size_name ] = $size_data; + } + + // Filter secondary mime types to those sizes that are enabled. + if ( $primary_mime_type !== $mime_type ) { + $missing_sizes = _wp_filter_image_sizes_additional_mime_type_support( $missing_sizes, $attachment_id ); + } /** * Filters the array of missing image sub-sizes for an uploaded image. * * @since 5.3.0 + * @since 6.1.0 The $mime_type filter parameter was added. * * @param array[] $missing_sizes Associative array of arrays of image sub-size information for * missing image sizes, keyed by image size name. * @param array $image_meta The image meta data. * @param int $attachment_id The image attachment post ID. + * @param string $mime_type The image mime type to get missing sizes for. */ - return apply_filters( 'wp_get_missing_image_subsizes', $missing_sizes, $image_meta, $attachment_id ); + return apply_filters( 'wp_get_missing_image_subsizes', $missing_sizes, $image_meta, $attachment_id, $mime_type ); } /** @@ -149,6 +175,7 @@ function wp_get_missing_image_subsizes( $attachment_id ) { * create them and update the image meta data. * * @since 5.3.0 + * @since 6.1.0 Now supports additional mime types, creating the additional sub-sizes and 'full' sized images. * * @param int $attachment_id The image attachment post ID. * @return array|WP_Error The updated image meta data array or WP_Error object @@ -167,14 +194,33 @@ function wp_update_image_subsizes( $attachment_id ) { return new WP_Error( 'invalid_attachment', __( 'The attached file cannot be found.' ) ); } } else { - $missing_sizes = wp_get_missing_image_subsizes( $attachment_id ); + // Get the primary and additional mime types to generate. + list( $primary_mime_type, $additional_mime_types ) = _wp_get_primary_and_additional_mime_types( $image_file, $attachment_id ); - if ( empty( $missing_sizes ) ) { - return $image_meta; + // Generate missing 'full' image files for additional mime types. + if ( ! empty( $additional_mime_types ) ) { + if ( isset( $image_meta['sources'] ) ) { + $missing_mime_types = array_diff( $additional_mime_types, array_keys( $image_meta['sources'] ) ); + } else { + $missing_mime_types = $additional_mime_types; + } + if ( ! empty( $missing_mime_types ) ) { + $image_meta = _wp_make_additional_mime_types( $missing_mime_types, $image_file, $image_meta, $attachment_id ); + } } - // This also updates the image meta. - $image_meta = _wp_make_subsizes( $missing_sizes, $image_file, $image_meta, $attachment_id ); + // Generate missing image sub-sizes for each mime type. + $all_mime_types = array_merge( array( $primary_mime_type ), $additional_mime_types ); + foreach ( $all_mime_types as $mime_type ) { + $missing_sizes = wp_get_missing_image_subsizes( $attachment_id, $mime_type ); + + if ( empty( $missing_sizes ) ) { + continue; + } + + // This also updates the image meta. + $image_meta = _wp_make_subsizes( $missing_sizes, $image_file, $image_meta, $attachment_id, $mime_type ); + } } /** This filter is documented in wp-admin/includes/image.php */ @@ -222,12 +268,13 @@ function _wp_image_meta_replace_original( $saved_data, $original_file, $image_me } /** - * Creates image sub-sizes, adds the new data to the image meta `sizes` array, and updates the image metadata. + * Creates image mime variations and sub-sizes, adds the new data to the image meta `sizes` array, and updates the image metadata. * * Intended for use after an image is uploaded. Saves/updates the image metadata after each * sub-size is created. If there was an error, it is added to the returned image metadata array. * * @since 5.3.0 + * @since 6.1.0 Generates sub-sizes in alternate mime types based on the `wp_image_mime_transforms` filter. * * @param string $file Full path to the image file. * @param int $attachment_id Attachment ID to process. @@ -248,6 +295,7 @@ function wp_create_image_subsizes( $file, $attachment_id ) { 'file' => _wp_relative_upload_path( $file ), 'filesize' => wp_filesize( $file ), 'sizes' => array(), + 'sources' => array(), ); // Fetch additional metadata from EXIF/IPTC. @@ -257,9 +305,112 @@ function wp_create_image_subsizes( $file, $attachment_id ) { $image_meta['image_meta'] = $exif_meta; } - // Do not scale (large) PNG images. May result in sub-sizes that have greater file size than the original. See #48736. - if ( 'image/png' !== $imagesize['mime'] ) { + // Get the primary and additional mime types to generate. + list( $primary_mime_type, $additional_mime_types ) = _wp_get_primary_and_additional_mime_types( $file, $attachment_id ); + list( $editor, $resized, $rotated ) = _wp_maybe_scale_and_rotate_image( $file, $attachment_id, $imagesize, $exif_meta, $primary_mime_type ); + if ( is_wp_error( $editor ) ) { + return $image_meta; + } + $suffix = _wp_get_image_suffix( $resized, $rotated ); + + // Save image only if either it was modified or if the primary mime type is different from the original. + if ( ! empty( $suffix ) || $primary_mime_type !== $imagesize['mime'] ) { + $saved = $editor->save( $editor->generate_filename( $suffix ) ); + + if ( ! is_wp_error( $saved ) ) { + $image_meta = _wp_image_meta_replace_original( $saved, $file, $image_meta, $attachment_id ); + + // 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: Log errors. + } + } + + // Set 'sources' for the primary mime type. + $image_meta['sources'][ $primary_mime_type ] = _wp_get_sources_from_meta( $image_meta ); + + /* + * Initial save of the new metadata. + * At this point the file was uploaded and moved to the uploads directory + * but the image sub-sizes haven't been created yet and the `sizes` array is empty. + */ + wp_update_attachment_metadata( $attachment_id, $image_meta ); + + if ( ! empty( $additional_mime_types ) ) { + // Use the original file's exif_meta orientation information for secondary mime generation. + $saved_orientation = $image_meta['image_meta']['orientation']; + $image_meta['image_meta']['orientation'] = $exif_meta['orientation']; + $image_meta = _wp_make_additional_mime_types( $additional_mime_types, $file, $image_meta, $attachment_id ); + $image_meta['image_meta']['orientation'] = $saved_orientation; + + } + + $new_sizes = wp_get_registered_image_subsizes(); + + /** + * Filters the image sizes automatically generated when uploading an image. + * + * @since 2.9.0 + * @since 4.4.0 Added the `$image_meta` argument. + * @since 5.3.0 Added the `$attachment_id` argument. + * + * @param array $new_sizes Associative array of image sizes to be created. + * @param array $image_meta The image meta data: width, height, file, sizes, etc. + * @param int $attachment_id The attachment post ID for the image. + */ + $new_sizes = apply_filters( 'intermediate_image_sizes_advanced', $new_sizes, $image_meta, $attachment_id ); + + $image_meta = _wp_make_subsizes( $new_sizes, $file, $image_meta, $attachment_id, $primary_mime_type ); + + // Filter secondary mime types to those sizes that are enabled. + $new_sizes = _wp_filter_image_sizes_additional_mime_type_support( $new_sizes, $attachment_id ); + + foreach ( $additional_mime_types as $additional_mime_type ) { + $image_meta = _wp_make_subsizes( $new_sizes, $file, $image_meta, $attachment_id, $additional_mime_type ); + } + + return $image_meta; +} + +/** + * Returns a WP_Image_Editor instance where the image file has been scaled and rotated as necessary. + * + * @since 6.1.0 + * @access private + * + * @param string $file Full path to the image file. + * @param int $attachment_id Attachment ID. + * @param array $imagesize { + * Indexed array of the image width and height in pixels. + * + * @type int $0 The image width. + * @type int $1 The image height. + * } + * @param array|null $exif_meta EXIF metadata if extracted from the image file. + * @param string $mime_type Output mime type. + * @return array Array with three entries: The WP_Image_Editor instance, whether the image was resized, and whether the + * image was rotated (booleans). Each entry can alternatively be a WP_Error in case something went wrong. + */ +function _wp_maybe_scale_and_rotate_image( $file, $attachment_id, $imagesize, $exif_meta, $mime_type ) { + $resized = false; + $rotated = false; + + $editor = wp_get_image_editor( $file, array( 'mime_type' => $mime_type ) ); + if ( is_wp_error( $editor ) ) { + // This image cannot be edited. + return array( $editor, $resized, $rotated ); + } + + if ( ! empty( $mime_type ) ) { + $editor->set_output_mime_type( $mime_type ); + } + + // Do not scale (large) PNG images. May result in sub-sizes that have greater file size than the original. See #48736. + if ( 'image/png' !== $mime_type ) { /** * Filters the "BIG image" threshold value. * @@ -285,96 +436,65 @@ function wp_create_image_subsizes( $file, $attachment_id ) { // If the original image's dimensions are over the threshold, // scale the image and use it as the "full" size. - if ( $threshold && ( $image_meta['width'] > $threshold || $image_meta['height'] > $threshold ) ) { - $editor = wp_get_image_editor( $file ); - - if ( is_wp_error( $editor ) ) { - // This image cannot be edited. - return $image_meta; - } - + if ( $threshold && ( $imagesize[0] > $threshold || $imagesize[1] > $threshold ) ) { // 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 ) ) { - // Append "-scaled" to the image file name. It will look like "my_image-scaled.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( 'scaled' ) ); - - if ( ! is_wp_error( $saved ) ) { - $image_meta = _wp_image_meta_replace_original( $saved, $file, $image_meta, $attachment_id ); - - // 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: Log errors. - } - } else { - // TODO: Log errors. + $rotated = $editor->maybe_exif_rotate(); } } elseif ( ! empty( $exif_meta['orientation'] ) && 1 !== (int) $exif_meta['orientation'] ) { // Rotate the whole original image if there is EXIF data and "orientation" is not 1. - - $editor = wp_get_image_editor( $file ); - - if ( is_wp_error( $editor ) ) { - // This image cannot be edited. - return $image_meta; - } - - // 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: Log errors. - } - } } } - /* - * Initial save of the new metadata. - * At this point the file was uploaded and moved to the uploads directory - * but the image sub-sizes haven't been created yet and the `sizes` array is empty. - */ - wp_update_attachment_metadata( $attachment_id, $image_meta ); + return array( $editor, $resized, $rotated ); +} - $new_sizes = wp_get_registered_image_subsizes(); +/** + * Gets the suffix to use for image files based on resizing and rotating. + * + * @since 6.1.0 + * @access private + * + * @param bool|WP_Error Whether the image was resized, or an error if resizing failed. + * @param bool|WP_Error Whether the image was rotated, or an error if rotating failed. + * @return string The suffix to use for the file name, or empty string if none. + */ +function _wp_get_image_suffix( $resized, $rotated ) { + if ( $resized && ! is_wp_error( $resized ) ) { + // Append "-scaled" to the image file name. It will look like "my_image-scaled.jpg". + // This doesn't affect the sub-sizes names as they are generated from the original image (for best quality). + return 'scaled'; + } - /** - * Filters the image sizes automatically generated when uploading an image. - * - * @since 2.9.0 - * @since 4.4.0 Added the `$image_meta` argument. - * @since 5.3.0 Added the `$attachment_id` argument. - * - * @param array $new_sizes Associative array of image sizes to be created. - * @param array $image_meta The image meta data: width, height, file, sizes, etc. - * @param int $attachment_id The attachment post ID for the image. - */ - $new_sizes = apply_filters( 'intermediate_image_sizes_advanced', $new_sizes, $image_meta, $attachment_id ); + if ( true === $rotated ) { + // Append `-rotated` to the image file name. + return 'rotated'; + } - return _wp_make_subsizes( $new_sizes, $file, $image_meta, $attachment_id ); + if ( is_wp_error( $resized ) || is_wp_error( $rotated ) ) { + // TODO: Log errors. + } + return ''; +} + +/** + * Gets a sources array element from a meta. + * + * @since 6.1.0 + * @access private + * + * @param array $meta The meta to get the source from. + * @return array The source array element. + */ +function _wp_get_sources_from_meta( $meta ) { + return array( + 'file' => isset( $meta['file'] ) ? wp_basename( $meta['file'] ) : '', + 'filesize' => isset( $meta['filesize'] ) ? $meta['filesize'] : wp_filesize( $meta['path'] ), + ); } /** @@ -384,20 +504,26 @@ function wp_create_image_subsizes( $file, $attachment_id ) { * Errors are stored in the returned image metadata array. * * @since 5.3.0 + * @since 6.1.0 The $mime_type parameter was added. * @access private * - * @param array $new_sizes Array defining what sizes to create. - * @param string $file Full path to the image file. - * @param array $image_meta The attachment meta data array. - * @param int $attachment_id Attachment ID to process. + * @param array $new_sizes Array defining what sizes to create. + * @param string $file Full path to the image file. + * @param array $image_meta The attachment meta data array. + * @param int $attachment_id Attachment ID to process. + * @param string $mime_type Optional. The mime type to check for missing sizes. Default is the image mime of $file. * @return array The attachment meta data with updated `sizes` array. Includes an array of errors encountered while resizing. */ -function _wp_make_subsizes( $new_sizes, $file, $image_meta, $attachment_id ) { +function _wp_make_subsizes( $new_sizes, $file, $image_meta, $attachment_id, $mime_type = '' ) { if ( empty( $image_meta ) || ! is_array( $image_meta ) ) { // Not an image attachment. return array(); } + if ( ! $mime_type ) { + $mime_type = wp_get_image_mime( $file ); + } + // Check if any of the new sizes already exist. if ( isset( $image_meta['sizes'] ) && is_array( $image_meta['sizes'] ) ) { foreach ( $image_meta['sizes'] as $size_name => $size_meta ) { @@ -407,7 +533,11 @@ function _wp_make_subsizes( $new_sizes, $file, $image_meta, $attachment_id ) { * To change the behavior, unset changed/mismatched sizes in the `sizes` array in image meta. */ if ( array_key_exists( $size_name, $new_sizes ) ) { - unset( $new_sizes[ $size_name ] ); + // Unset the size if it is either the required mime type already exists either as main mime type or + // within sources. + if ( $size_meta['mime-type'] === $mime_type || isset( $size_meta['sources'][ $mime_type ] ) ) { + unset( $new_sizes[ $size_name ] ); + } } } } else { @@ -433,13 +563,15 @@ function _wp_make_subsizes( $new_sizes, $file, $image_meta, $attachment_id ) { $new_sizes = array_filter( array_merge( $priority, $new_sizes ) ); - $editor = wp_get_image_editor( $file ); + $editor = wp_get_image_editor( $file, array( 'mime_type' => $mime_type ) ); if ( is_wp_error( $editor ) ) { // The image cannot be edited. return $image_meta; } + $editor->set_output_mime_type( $mime_type ); + // If stored EXIF data exists, rotate the source image before creating sub-sizes. if ( ! empty( $image_meta['image_meta'] ) ) { $rotated = $editor->maybe_exif_rotate(); @@ -457,7 +589,22 @@ function _wp_make_subsizes( $new_sizes, $file, $image_meta, $attachment_id ) { // TODO: Log errors. } else { // Save the size meta value. - $image_meta['sizes'][ $new_size_name ] = $new_size_meta; + if ( ! isset( $image_meta['sizes'][ $new_size_name ] ) ) { + $image_meta['sizes'][ $new_size_name ] = $new_size_meta; + } else { + // Remove any newly generated images that are larger than the primary mime type. + $new_size = isset( $new_size_meta['filesize'] ) ? $new_size_meta['filesize'] : 0; + $primary_size = isset( $image_meta['sizes'][ $new_size_name ]['filesize'] ) ? $image_meta['sizes'][ $new_size_name ]['filesize'] : 0; + + if ( $new_size && $primary_size && $new_size >= $primary_size ) { + wp_delete_file( dirname( $file ) . '/' . $new_size_meta['file'] ); + continue; + } + } + if ( ! isset( $image_meta['sizes'][ $new_size_name ]['sources'] ) ) { + $image_meta['sizes'][ $new_size_name ]['sources'] = array(); + } + $image_meta['sizes'][ $new_size_name ]['sources'][ $mime_type ] = _wp_get_sources_from_meta( $new_size_meta ); wp_update_attachment_metadata( $attachment_id, $image_meta ); } } @@ -466,7 +613,26 @@ function _wp_make_subsizes( $new_sizes, $file, $image_meta, $attachment_id ) { $created_sizes = $editor->multi_resize( $new_sizes ); if ( ! empty( $created_sizes ) ) { - $image_meta['sizes'] = array_merge( $image_meta['sizes'], $created_sizes ); + foreach ( $created_sizes as $created_size_name => $created_size_meta ) { + + // Primary mime type is set in 'sizes' array. + if ( ! isset( $image_meta['sizes'][ $created_size_name ] ) ) { + $image_meta['sizes'][ $created_size_name ] = $created_size_meta; + } else { + // Remove any newly generated images that are larger than the primary mime type. + $new_size = isset( $created_size_meta['filesize'] ) ? $created_size_meta['filesize'] : 0; + $primary_size = isset( $image_meta['sizes'][ $created_size_name ]['filesize'] ) ? $image_meta['sizes'][ $created_size_name ]['filesize'] : 0; + + if ( $new_size && $primary_size && $new_size >= $primary_size ) { + wp_delete_file( dirname( $file ) . '/' . $created_size_meta['file'] ); + continue; + } + } + if ( ! isset( $image_meta['sizes'][ $created_size_name ]['sources'] ) ) { + $image_meta['sizes'][ $created_size_name ]['sources'] = array(); + } + $image_meta['sizes'][ $created_size_name ]['sources'][ $mime_type ] = _wp_get_sources_from_meta( $new_size_meta ); + } wp_update_attachment_metadata( $attachment_id, $image_meta ); } } @@ -474,6 +640,125 @@ function _wp_make_subsizes( $new_sizes, $file, $image_meta, $attachment_id ) { return $image_meta; } +/** + * Filters the list of image size objects that support secondary mime type output. + * + * @since 6.1.0 + * + * @param array $sizes Associative array of image sizes. + * @param int $attachment_id Attachment ID. + * @return array $sizes Filtered $sizes with only those that support secondary mime type output. + */ +function _wp_filter_image_sizes_additional_mime_type_support( $sizes, $attachment_id ) { + + // Include only the core sizes that do not rely on add_image_size(). Additional image sizes are opt-in. + $enabled_sizes = array( + 'thumbnail' => true, + 'medium' => true, + 'medium_large' => true, + 'large' => true, + 'post-thumbnail' => true, + ); + + /** + * Filter the sizes that support secondary mime type output. Developers can use this + * to control the output of additional mime type sub-sized images. + * + * @since 6.1.0 + * + * @param array $enabled_sizes Map of size names and whether they support secondary mime type output. + * @param int $attachment_id Attachment ID. + */ + $enabled_sizes = apply_filters( 'wp_image_sizes_with_additional_mime_type_support', $enabled_sizes, $attachment_id ); + + // Filter supported sizes to only include enabled sizes. + return array_intersect_key( $sizes, array_filter( $enabled_sizes ) ); +} + +/** + * Low-level function to create full-size images in additional mime types. + * + * Updates the image meta after each mime type image is created. + * + * @since 6.1.0 + * @access private + * + * @param array $new_mime_types Array defining what mime types to create. + * @param string $file Full path to the image file. + * @param array $image_meta The attachment meta data array. + * @param int $attachment_id Attachment ID to process. + * @return array The attachment meta data with updated `sizes` array. Includes an array of errors encountered while resizing. + */ +function _wp_make_additional_mime_types( $new_mime_types, $file, $image_meta, $attachment_id ) { + $imagesize = array( + $image_meta['width'], + $image_meta['height'], + ); + $exif_meta = isset( $image_meta['image_meta'] ) ? $image_meta['image_meta'] : null; + $original_file_size = isset( $image_meta['filesize'] ) ? $image_meta['filesize'] : wp_filesize( $file ); + + foreach ( $new_mime_types as $mime_type ) { + list( $editor, $resized, $rotated ) = _wp_maybe_scale_and_rotate_image( $file, $attachment_id, $imagesize, $exif_meta, $mime_type ); + if ( is_wp_error( $editor ) ) { + // The image cannot be edited. + continue; + } + + $suffix = _wp_get_image_suffix( $resized, $rotated ); + $extension = wp_get_default_extension_for_mime_type( $mime_type ); + + $saved = $editor->save( $editor->generate_filename( $suffix, null, $extension ) ); + + if ( is_wp_error( $saved ) ) { + // TODO: Log errors. + } else { + // If the saved image is larger than the original, discard it. + $filesize = isset( $saved['filesize'] ) ? $saved['filesize'] : wp_filesize( $saved['path'] ); + if ( $filesize && $original_file_size && $filesize > $original_file_size ) { + wp_delete_file( $saved['path'] ); + continue; + } + $image_meta['sources'][ $mime_type ] = _wp_get_sources_from_meta( $saved ); + wp_update_attachment_metadata( $attachment_id, $image_meta ); + } + } + + return $image_meta; +} + + +/** + * Check if an image belongs to an attachment. + * + * @since 6.1.0 + * @access private + * + * @param string $filename Full path to the image file. + * @param int $attachment_id Attachment ID to check. + * @return bool True if the image belongs to the attachment, false otherwise. + */ +function _wp_image_belongs_to_attachment( $filename, $attachment_id ) { + $meta_data = wp_get_attachment_metadata( $attachment_id ); + + if ( ! isset( $image_meta['sizes'] ) ) { + return false; + } + $sizes = $image_meta['sizes']; + foreach ( $sizes as $size ) { + if ( $size['file'] === $filename ) { + return true; + } + if ( isset( $size['sources'] ) && is_array( $size['sources'] ) ) { + foreach ( $size['sources'] as $source ) { + if ( $source['file'] === $filename ) { + return true; + } + } + } + } + return false; +} + /** * Generate attachment meta data and create image sub-sizes for images. * @@ -630,7 +915,7 @@ function wp_generate_attachment_metadata( $attachment_id, $file ) { wp_update_attachment_metadata( $attachment_id, $metadata ); // Create sub-sizes saving the image meta after each. - $metadata = _wp_make_subsizes( $merged_sizes, $image_file, $metadata, $attachment_id ); + $metadata = _wp_make_subsizes( $merged_sizes, $image_file, $metadata, $attachment_id, '' ); } } } @@ -1157,3 +1442,97 @@ function _copy_image_file( $attachment_id ) { return $dst_file; } + +/** + * Returns an array with the list of valid mime types that a specific mime type should be converted into. + * For example an `image/jpeg` should be converted into an `image/jpeg` and `image/webp`. The first type + * is considered the primary output type for this image. + * + * Called for each uploaded image to determine the list of mime types that should be converted into. Then, + * called again for each image size as they are generated to check if the image should be converted into the mime type + * for that size. + * + * @since 6.1.0 + * + * @param int $attachment_id The attachment ID. + * @return array An array of valid mime types, where the key is the source file mime type and the list of mime types to + * generate. + */ +function wp_upload_image_mime_transforms( $attachment_id ) { + $default_image_mime_transforms = array( + 'image/jpeg' => array( 'image/jpeg', 'image/webp' ), + 'image/webp' => array( 'image/webp', 'image/jpeg' ), + ); + $image_mime_transforms = $default_image_mime_transforms; + + /** + * Filter the output mime types for a given input mime type and image size. + * + * @since 6.1.0 + * + * @param array $image_mime_transforms A map with the valid mime transforms where the key is the source file mime type + * and the value is one or more mime file types to generate. + * @param int $attachment_id The ID of the attachment where the hook was dispatched. + */ + $image_mime_transforms = apply_filters( 'wp_upload_image_mime_transforms', $image_mime_transforms, $attachment_id ); + + if ( ! is_array( $image_mime_transforms ) ) { + return $default_image_mime_transforms; + } + + return array_map( + function( $transforms_list ) { + return (array) $transforms_list; + }, + $image_mime_transforms + ); +} + +/** + * Extract the primary and additional mime output types for an image from the $image_mime_transforms. + * + * @since 6.1.0 + * @access private + * + * @param string $file Full path to the image file. + * @param int $attachment_id Attachment ID to process. + * @return array An array with two entries, the primary mime type and the list of additional mime types. + */ +function _wp_get_primary_and_additional_mime_types( $file, $attachment_id ) { + $image_mime_transforms = wp_upload_image_mime_transforms( $attachment_id ); + $original_mime_type = wp_get_image_mime( $file ); + $output_mime_types = isset( $image_mime_transforms[ $original_mime_type ] ) ? $image_mime_transforms[ $original_mime_type ] : array( $original_mime_type ); + + // Exclude any output mime types that the system doesn't support. + $output_mime_types = array_values( + array_filter( + $output_mime_types, + function( $mime_type ) { + return wp_image_editor_supports( + array( + 'mime_type' => $mime_type, + ) + ); + } + ) + ); + + // Handle an empty value for $output_mime_types: only output the original type. + if ( empty( $output_mime_types ) ) { + return array( $original_mime_type, array() ); + } + + // Use original mime type as primary mime type, or alternatively the first one. + $primary_mime_type_key = array_search( $original_mime_type, $output_mime_types, true ); + if ( false === $primary_mime_type_key ) { + $primary_mime_type_key = 0; + } + // Split output mime types into primary mime type and additional mime types. + $additional_mime_types = $output_mime_types; + list( $primary_mime_type ) = array_splice( $additional_mime_types, $primary_mime_type_key, 1 ); + + return array( + $primary_mime_type, + $additional_mime_types, + ); +} diff --git a/src/wp-includes/class-wp-image-editor.php b/src/wp-includes/class-wp-image-editor.php index caa3092d36..ccf43402a6 100644 --- a/src/wp-includes/class-wp-image-editor.php +++ b/src/wp-includes/class-wp-image-editor.php @@ -334,6 +334,11 @@ abstract class WP_Image_Editor { protected function get_output_format( $filename = null, $mime_type = null ) { $new_ext = null; + // If no mime type is passed but output mime type is set, use that. + if ( ! $mime_type && ! empty( $this->output_mime_type ) ) { + $mime_type = $this->output_mime_type; + } + // By default, assume specified type takes priority. if ( $mime_type ) { $new_ext = $this->get_extension( $mime_type ); @@ -425,18 +430,25 @@ abstract class WP_Image_Editor { } /** - * Builds an output filename based on current file, and adding proper suffix + * Builds an output filename based on current file, and adding proper suffix. * * @since 3.5.0 + * @since 6.1.0 Skips adding a suffix when set to an empty string. When the + * file extension being generated doesn't match the image file extension, + * add the extension to the suffix * - * @param string $suffix - * @param string $dest_path - * @param string $extension - * @return string filename + * @param string $suffix Optional. Suffix to add to the filename. The default null + * will result in a 'widthxheight' suffix. Passing + * an empty string will result in no suffix. + * @param string $dest_path Optional. The path to save the file to. The default null + * will use the image file path. + * @param string $extension Optional. The file extension to use. The default null + * will use the image file extension. + * @return string filename The generated file name. */ public function generate_filename( $suffix = null, $dest_path = null, $extension = null ) { // $suffix will be appended to the destination filename, just before the extension. - if ( ! $suffix ) { + if ( null === $suffix ) { $suffix = $this->get_suffix(); } @@ -457,7 +469,21 @@ abstract class WP_Image_Editor { } } - return trailingslashit( $dir ) . "{$name}-{$suffix}.{$new_ext}"; + if ( empty( $suffix ) ) { + $suffix = ''; + } else { + $suffix = "-{$suffix}"; + } + + // When the file extension being generated doesn't match the image file extension, + // add the extension to the suffix to ensure a unique file name. Prevents + // name conflicts when a single image type can have multiple extensions, + // eg. .jpg, .jpeg and .jpe are all valid JPEG extensions. + if ( ! empty( $extension ) && $extension !== $ext ) { + $suffix .= "-{$ext}"; + } + + return trailingslashit( $dir ) . "{$name}{$suffix}.{$new_ext}"; } /** @@ -637,5 +663,28 @@ abstract class WP_Image_Editor { return wp_get_default_extension_for_mime_type( $mime_type ); } -} + /** + * Set the editor output mime type, useful when outputting alternate mime types. + * + * Track that the mime type is set with the mime type set flag. + * + * @since 6.1.0 + * + * @param string $output_mime_type The mime type to set. + */ + public function set_output_mime_type( $output_mime_type ) { + $this->output_mime_type = $output_mime_type; + } + + /** + * Reset the mime type to the original file mime type. + * + * Reset the mime type set flag. + * + * @since 6.1.0 + */ + public function reset_output_mime_type() { + $this->output_mime_type = $this->mime_type; + } +} diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index aaf811d2d0..51c49f83d2 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -1852,6 +1852,11 @@ function wp_filter_content_tags( $content, $context = null ) { $filtered_image = wp_img_tag_add_decoding_attr( $filtered_image, $context ); } + // Use alternate mime types when specified and available. + if ( $attachment_id > 0 && _wp_in_front_end_context() ) { + $filtered_image = wp_image_use_alternate_mime_types( $filtered_image, $context, $attachment_id ); + } + /** * Filters an img tag within the content for a given context. * @@ -1898,6 +1903,117 @@ function wp_filter_content_tags( $content, $context = null ) { return $content; } +/** + * Use alternate mime type images in the front end content output when available. + * + * @since 6.1.0 + * + * @param string $image The HTML `img` tag where the attribute should be added. + * @param string $context Additional context to pass to the filters. + * @param int $attachment_id The attachment ID. + * @return string Converted `img` tag with `loading` attribute added. + */ +function wp_image_use_alternate_mime_types( $image, $context, $attachment_id ) { + $metadata = wp_get_attachment_metadata( $attachment_id ); + if ( empty( $metadata['file'] ) ) { + return $image; + } + + // Only alter images with a `sources` attribute + if ( empty( $metadata['sources'] ) ) { + return $image; + }; + + $target_mimes = array( 'image/webp', 'image/jpeg' ); + + /** + * Filter the content image mime type output selection and order. + * + * When outputting images in the content, the first mime type available will be used. + * + * @since 6.1.0 + * + * @param array $target_mimes The image output mime type and order. Default is array( 'image/webp', 'image/jpeg' ). + * @param int $attachment_id The attachment ID. + * @param string $context Additional context to pass to the filters. + * @return array The filtered output mime type and order. Return an empty array to skip mime type substitution. + */ + $target_mimes = apply_filters( 'wp_content_image_mimes', $target_mimes, $attachment_id, $context ); + + if ( false === $target_mimes ) { + return $image; + } + + // Find the appropriate size for the provided URL in the first available mime type. + foreach ( $target_mimes as $target_mime ) { + // Handle full size image replacement. + if ( ! empty( $metadata['sources'][ $target_mime ]['file'] ) ) { + $src_filename = wp_basename( $metadata['file'] ); + + // This is the same MIME type as the original, so the entire $target_mime can be skipped. + // Since it is already the preferred MIME type, the entire loop can be cancelled. + if ( $metadata['sources'][ $target_mime ]['file'] === $src_filename ) { + break; + } + + $image = str_replace( $src_filename, $metadata['sources'][ $target_mime ]['file'], $image ); + + // The full size was replaced, so unset this entirely here so that in the next iteration it is no longer + // considered, simply for a small performance optimization. + unset( $metadata['sources'] ); + } + + // Go through each image size and replace with the first available mime type version. + foreach ( $metadata['sizes'] as $name => $size_data ) { + // Check if size has an original file. + if ( empty( $size_data['file'] ) ) { + continue; + } + + // Check if size has a source in the desired mime type. + if ( empty( $size_data['sources'][ $target_mime ]['file'] ) ) { + continue; + } + + $src_filename = wp_basename( $size_data['file'] ); + + // This is the same MIME type as the original, so the entire $target_mime can be skipped. + // Since it is already the preferred MIME type, the entire loop can be cancelled. + if ( $size_data['sources'][ $target_mime ]['file'] === $src_filename ) { + break 2; + } + + // Found a match, replace with the new filename. + $image = str_replace( $src_filename, $size_data['sources'][ $target_mime ]['file'], $image ); + + // This size was replaced, so unset this entirely here so that in the next iteration it is no longer + // considered, simply for a small performance optimization. + unset( $metadata['sizes'][ $name ] ); + } + } + return $image; +} + +/** + * Check if execution is currently in the front end content context, outside of . + * + * @since 6.1.0 + * @access private + * + * @return bool True if in the front end content context, false otherwise. + */ +function _wp_in_front_end_context() { + global $wp_query; + + // Check if this request is generally outside (or before) any frontend context. + if ( ! isset( $wp_query ) || defined( 'XMLRPC_REQUEST' ) || defined( 'REST_REQUEST' ) || is_feed() ) { + return false; + } + + // Check if we're anywhere before the 'wp_head' action has completed. + return did_action( 'template_redirect' ) && ! doing_action( 'wp_head' ); +} + /** * Adds `loading` attribute to an `img` HTML tag. * diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 9689e008ea..c7dcc13b8d 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -6481,13 +6481,28 @@ function wp_delete_attachment_files( $post_id, $meta, $backup_sizes, $file ) { $intermediate_dir = path_join( $uploadpath['basedir'], dirname( $file ) ); foreach ( $meta['sizes'] as $size => $sizeinfo ) { - $intermediate_file = str_replace( wp_basename( $file ), $sizeinfo['file'], $file ); - if ( ! empty( $intermediate_file ) ) { - $intermediate_file = path_join( $uploadpath['basedir'], $intermediate_file ); + // Check for alternate size mime types in the sizeinfo['sources'] array to delete. + if ( isset( $sizeinfo['sources'] ) && is_array( $sizeinfo['sources'] ) ) { + foreach ( $sizeinfo['sources'] as $mime => $properties ) { + $intermediate_file = str_replace( wp_basename( $file ), $properties['file'], $file ); + if ( ! empty( $intermediate_file ) ) { + $intermediate_file = path_join( $uploadpath['basedir'], $intermediate_file ); + if ( ! wp_delete_file_from_directory( $intermediate_file, $intermediate_dir ) ) { + $deleted = false; + } + } + } + } else { + // Otherwise, delete files from the sizeinfo data. + $intermediate_file = str_replace( wp_basename( $file ), $sizeinfo['file'], $file ); - if ( ! wp_delete_file_from_directory( $intermediate_file, $intermediate_dir ) ) { - $deleted = false; + if ( ! empty( $intermediate_file ) ) { + $intermediate_file = path_join( $uploadpath['basedir'], $intermediate_file ); + + if ( ! wp_delete_file_from_directory( $intermediate_file, $intermediate_dir ) ) { + $deleted = false; + } } } } @@ -6509,24 +6524,58 @@ function wp_delete_attachment_files( $post_id, $meta, $backup_sizes, $file ) { } } - if ( is_array( $backup_sizes ) ) { - $del_dir = path_join( $uploadpath['basedir'], dirname( $meta['file'] ) ); - - foreach ( $backup_sizes as $size ) { - $del_file = path_join( dirname( $meta['file'] ), $size['file'] ); - - if ( ! empty( $del_file ) ) { - $del_file = path_join( $uploadpath['basedir'], $del_file ); - - if ( ! wp_delete_file_from_directory( $del_file, $del_dir ) ) { - $deleted = false; - } + // Delete the full size images from 'sources' if available, or the root file. + if ( isset( $meta['sources'] ) && is_array( $meta['sources'] ) ) { + $sources = $meta['sources']; + $intermediate_dir = path_join( $uploadpath['basedir'], dirname( $file ) ); + foreach ( $sources as $mime => $properties ) { + if ( ! is_array( $properties ) || empty( $properties['file'] ) ) { + continue; } + $intermediate_file = str_replace( wp_basename( $file ), $properties['file'], $file ); + if ( ! wp_delete_file_from_directory( $intermediate_file, $intermediate_dir ) ) { + $deleted = false; + } + } + } else { + if ( ! wp_delete_file_from_directory( $file, $uploadpath['basedir'] ) ) { + $deleted = false; } } - if ( ! wp_delete_file_from_directory( $file, $uploadpath['basedir'] ) ) { - $deleted = false; + if ( is_array( $backup_sizes ) ) { + + $del_dir = path_join( $uploadpath['basedir'], dirname( $meta['file'] ) ); + // Delete the root (edited) file which was not deleted above. + if ( ! wp_delete_file_from_directory( $file, $uploadpath['basedir'] ) ) { + $deleted = false; + } + foreach ( $backup_sizes as $size ) { + // Delete files from 'sources' data if available, otherwise from 'sizes' data. + if ( isset( $meta['sources'] ) && is_array( $meta['sources'] ) ) { + // Delete any backup images stored in the 'sources' array. + if ( isset( $size['sources'] ) && is_array( $size['sources'] ) ) { + foreach ( $size['sources'] as $mime => $properties ) { + $del_file = path_join( dirname( $meta['file'] ), $properties['file'] ); + if ( ! empty( $del_file ) ) { + $del_file = path_join( $uploadpath['basedir'], $del_file ); + if ( ! wp_delete_file_from_directory( $del_file, $del_dir ) ) { + $deleted = false; + } + } + } + } + } else { + $del_file = path_join( dirname( $meta['file'] ), $size['file'] ); + + if ( ! empty( $del_file ) ) { + $del_file = path_join( $uploadpath['basedir'], $del_file ); + if ( ! wp_delete_file_from_directory( $del_file, $del_dir ) ) { + $deleted = false; + } + } + } + } } return $deleted; diff --git a/tests/phpunit/data/images/test-image-rotated-90ccw.jpg b/tests/phpunit/data/images/test-image-rotated-90ccw.jpg new file mode 100644 index 0000000000..b579b7f9ab Binary files /dev/null and b/tests/phpunit/data/images/test-image-rotated-90ccw.jpg differ diff --git a/tests/phpunit/data/images/test-image-rotated-90cw.webp b/tests/phpunit/data/images/test-image-rotated-90cw.webp new file mode 100644 index 0000000000..82e77bed08 Binary files /dev/null and b/tests/phpunit/data/images/test-image-rotated-90cw.webp differ diff --git a/tests/phpunit/data/images/test-image.jpeg b/tests/phpunit/data/images/test-image.jpeg new file mode 100644 index 0000000000..534aac1d6b Binary files /dev/null and b/tests/phpunit/data/images/test-image.jpeg differ diff --git a/tests/phpunit/tests/image/editor.php b/tests/phpunit/tests/image/editor.php index 487dad0664..c051ffec2b 100644 --- a/tests/phpunit/tests/image/editor.php +++ b/tests/phpunit/tests/image/editor.php @@ -131,6 +131,7 @@ class Tests_Image_Editor extends WP_Image_UnitTestCase { $this->assertSame( 86, $editor->get_quality(), 'Output image format is WEBP. Quality setting for it should be 86.' ); // Removing PNG to WEBP conversion on save. Quality setting should reset to the default. + $editor->reset_output_mime_type(); remove_filter( 'image_editor_output_format', array( $this, 'image_editor_output_formats' ) ); $editor->save(); $this->assertSame( 82, $editor->get_quality(), 'After removing image conversion quality setting should reset to the default of 82.' ); @@ -154,6 +155,7 @@ class Tests_Image_Editor extends WP_Image_UnitTestCase { $this->assertSame( 42, $editor->get_quality(), 'Image conversion from JPEG to WEBP. Filtered WEBP quality shoild be 42.' ); // After removing the conversion the quality setting should reset to the filtered value for the original image type, JPEG. + $editor->reset_output_mime_type(); remove_filter( 'image_editor_output_format', array( $this, 'image_editor_output_formats' ) ); $editor->save(); $this->assertSame( @@ -226,10 +228,10 @@ class Tests_Image_Editor extends WP_Image_UnitTestCase { $this->assertSame( trailingslashit( realpath( get_temp_dir() ) ), trailingslashit( realpath( dirname( $editor->generate_filename( null, get_temp_dir() ) ) ) ) ); // Test with a suffix only. - $this->assertSame( 'canola-100x50.png', wp_basename( $editor->generate_filename( null, null, 'png' ) ) ); + $this->assertSame( 'canola-100x50-jpg.png', wp_basename( $editor->generate_filename( null, null, 'png' ) ) ); // Combo! - $this->assertSame( trailingslashit( realpath( get_temp_dir() ) ) . 'canola-new.png', $editor->generate_filename( 'new', realpath( get_temp_dir() ), 'png' ) ); + $this->assertSame( trailingslashit( realpath( get_temp_dir() ) ) . 'canola-new-jpg.png', $editor->generate_filename( 'new', realpath( get_temp_dir() ), 'png' ) ); // Test with a stream destination. $this->assertSame( 'file://testing/path/canola-100x50.jpg', $editor->generate_filename( null, 'file://testing/path' ) ); @@ -362,4 +364,404 @@ class Tests_Image_Editor extends WP_Image_UnitTestCase { ); } + /** + * Test creating the original image mime type when the image is uploaded. + * + * @ticket 55443 + * + * @dataProvider provider_image_with_default_behaviors_during_upload + */ + public function it_should_create_the_original_image_mime_type_when_the_image_is_uploaded( $file_location, $expected_mime, $targeted_mime ) { + $attachment_id = $this->factory->attachment->create_upload_object( $file_location ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->assertIsArray( $metadata ); + foreach ( $metadata['sizes'] as $size_name => $properties ) { + $this->assertArrayHasKey( 'sources', $properties ); + $this->assertIsArray( $properties['sources'] ); + $this->assertArrayHasKey( $expected_mime, $properties['sources'] ); + $this->assertArrayHasKey( 'filesize', $properties['sources'][ $expected_mime ] ); + $this->assertArrayHasKey( 'file', $properties['sources'][ $expected_mime ] ); + $this->assertArrayHasKey( $targeted_mime, $properties['sources'] ); + $this->assertArrayHasKey( 'filesize', $properties['sources'][ $targeted_mime ] ); + $this->assertArrayHasKey( 'file', $properties['sources'][ $targeted_mime ] ); + } + } + + /** + * Data provider for it_should_create_the_original_image_mime_type_when_the_image_is_uploaded. + */ + public function provider_image_with_default_behaviors_during_upload() { + yield 'JPEG image' => array( + DIR_TESTDATA . '/images/test-image.jpg', + 'image/jpeg', + 'image/webp', + ); + + yield 'WebP image' => array( + DIR_TESTDATA . '/images/webp-lossy.webp', + 'image/webp', + 'image/jpeg', + ); + } + + /** + * Test Do not create the sources property if no transform is provided. + * + * @ticket 55443 + */ + public function it_should_not_create_the_sources_property_if_no_transform_is_provided() { + add_filter( 'wp_upload_image_mime_transforms', '__return_empty_array' ); + + $attachment_id = $this->factory->attachment->create_upload_object( + DIR_TESTDATA . '/images/test-image.jpg' + ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->assertIsArray( $metadata ); + foreach ( $metadata['sizes'] as $size_name => $properties ) { + $this->assertArrayNotHasKey( 'sources', $properties ); + } + } + + /** + * Test creating the sources property when no transform is available. + * + * @ticket 55443 + */ + public function it_should_create_the_sources_property_when_no_transform_is_available() { + add_filter( + 'wp_upload_image_mime_transforms', + function () { + return array( 'image/jpeg' => array() ); + } + ); + + $attachment_id = $this->factory->attachment->create_upload_object( + DIR_TESTDATA . '/images/test-image.jpg' + ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->assertIsArray( $metadata ); + foreach ( $metadata['sizes'] as $size_name => $properties ) { + $this->assertArrayHasKey( 'sources', $properties ); + $this->assertIsArray( $properties['sources'] ); + $this->assertArrayHasKey( 'image/jpeg', $properties['sources'] ); + $this->assertArrayHasKey( 'filesize', $properties['sources']['image/jpeg'] ); + $this->assertArrayHasKey( 'file', $properties['sources']['image/jpeg'] ); + $this->assertArrayNotHasKey( 'image/webp', $properties['sources'] ); + } + } + + /** + * Test not creating the sources property if the mime is not specified on the transforms images. + * + * @ticket 55443 + */ + public function it_should_not_create_the_sources_property_if_the_mime_is_not_specified_on_the_transforms_images() { + add_filter( + 'wp_upload_image_mime_transforms', + function () { + return array( 'image/jpeg' => array() ); + } + ); + + $attachment_id = $this->factory->attachment->create_upload_object( + DIR_TESTDATA . '/images/webp-lossy.webp' + ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->assertIsArray( $metadata ); + foreach ( $metadata['sizes'] as $size_name => $properties ) { + $this->assertArrayNotHasKey( 'sources', $properties ); + } + } + + + /** + * Test creating a WebP version with all the required properties. + * + * @ticket 55443 + */ + public function it_should_create_a_webp_version_with_all_the_required_properties() { + $attachment_id = $this->factory->attachment->create_upload_object( + DIR_TESTDATA . '/images/test-image.jpg' + ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertArrayHasKey( 'sources', $metadata['sizes']['thumbnail'] ); + $this->assertArrayHasKey( 'image/jpeg', $metadata['sizes']['thumbnail']['sources'] ); + $this->assertArrayHasKey( 'filesize', $metadata['sizes']['thumbnail']['sources']['image/jpeg'] ); + $this->assertArrayHasKey( 'file', $metadata['sizes']['thumbnail']['sources']['image/jpeg'] ); + $this->assertArrayHasKey( 'image/webp', $metadata['sizes']['thumbnail']['sources'] ); + $this->assertArrayHasKey( 'filesize', $metadata['sizes']['thumbnail']['sources']['image/webp'] ); + $this->assertArrayHasKey( 'file', $metadata['sizes']['thumbnail']['sources']['image/webp'] ); + $this->assertStringEndsNotWith( '.jpeg', $metadata['sizes']['thumbnail']['sources']['image/webp']['file'] ); + $this->assertStringEndsWith( '.webp', $metadata['sizes']['thumbnail']['sources']['image/webp']['file'] ); + } + + /** + * Test removing `scaled` suffix from the generated filename. + * + * @ticket 55443 + */ + public function it_should_remove_scaled_suffix_from_the_generated_filename() { + // The leafs image is 1080 pixels wide with this filter we ensure a -scaled version is created. + add_filter( + 'big_image_size_threshold', + function () { + return 850; + } + ); + + $attachment_id = $this->factory->attachment->create_upload_object( + DIR_TESTDATA . '/images/test-image.jpg' + ); + $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertStringEndsWith( '-scaled.jpg', get_attached_file( $attachment_id ) ); + $this->assertArrayHasKey( 'image/webp', $metadata['sizes']['medium']['sources'] ); + $this->assertStringEndsNotWith( '-scaled.webp', $metadata['sizes']['medium']['sources']['image/webp']['file'] ); + $this->assertStringEndsWith( '-300x200.webp', $metadata['sizes']['medium']['sources']['image/webp']['file'] ); + } + + /** + * Test removing the generated webp images when the attachment is deleted. + * + * @ticket 55443 + */ + public function it_should_remove_the_generated_webp_images_when_the_attachment_is_deleted() { + // Make sure no editor is available. + $attachment_id = $this->factory->attachment->create_upload_object( + DIR_TESTDATA . '/images/test-image.jpg' + ); + + $file = get_attached_file( $attachment_id, true ); + $dirname = pathinfo( $file, PATHINFO_DIRNAME ); + + $this->assertIsString( $file ); + $this->assertFileExists( $file ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + $sizes = array( 'thumbnail', 'medium' ); + + foreach ( $sizes as $size_name ) { + $this->assertArrayHasKey( 'image/webp', $metadata['sizes'][ $size_name ]['sources'] ); + $this->assertArrayHasKey( 'file', $metadata['sizes'][ $size_name ]['sources']['image/webp'] ); + $this->assertFileExists( + path_join( $dirname, $metadata['sizes'][ $size_name ]['sources']['image/webp']['file'] ) + ); + } + + wp_delete_attachment( $attachment_id ); + + foreach ( $sizes as $size_name ) { + $this->assertFileDoesNotExist( + path_join( $dirname, $metadata['sizes'][ $size_name ]['sources']['image/webp']['file'] ) + ); + } + } + + /** + * Test removing the attached WebP version if the attachment is force deleted but empty trash day is not defined. + * + * @ticket 55443 + */ + public function it_should_remove_the_attached_webp_version_if_the_attachment_is_force_deleted_but_empty_trash_day_is_not_defined() { + // Make sure no editor is available. + $attachment_id = $this->factory->attachment->create_upload_object( + DIR_TESTDATA . '/images/test-image.jpg' + ); + + $file = get_attached_file( $attachment_id, true ); + $dirname = pathinfo( $file, PATHINFO_DIRNAME ); + + $this->assertIsString( $file ); + $this->assertFileExists( $file ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->assertFileExists( + path_join( $dirname, $metadata['sizes']['thumbnail']['sources']['image/webp']['file'] ) + ); + + wp_delete_attachment( $attachment_id, true ); + + $this->assertFileDoesNotExist( + path_join( $dirname, $metadata['sizes']['thumbnail']['sources']['image/webp']['file'] ) + ); + } + + /** + * Test removing the WebP version of the image if the image is force deleted and empty trash days is set to zero. + * + * @ticket 55443 + */ + public function it_should_remove_the_webp_version_of_the_image_if_the_image_is_force_deleted_and_empty_trash_days_is_set_to_zero() { + // Make sure no editor is available. + $attachment_id = $this->factory->attachment->create_upload_object( + DIR_TESTDATA . '/images/test-image.jpg' + ); + + $file = get_attached_file( $attachment_id, true ); + $dirname = pathinfo( $file, PATHINFO_DIRNAME ); + + $this->assertIsString( $file ); + $this->assertFileExists( $file ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->assertFileExists( + path_join( $dirname, $metadata['sizes']['thumbnail']['sources']['image/webp']['file'] ) + ); + + define( 'EMPTY_TRASH_DAYS', 0 ); + + wp_delete_attachment( $attachment_id, true ); + + $this->assertFileDoesNotExist( + path_join( $dirname, $metadata['sizes']['thumbnail']['sources']['image/webp']['file'] ) + ); + } + + /** + * Test avoiding the change of URLs of images that are not part of the media library. + * + * @ticket 55443 + */ + public function it_should_avoid_the_change_of_urls_of_images_that_are_not_part_of_the_media_library() { + $paragraph = '

Donec accumsan, sapien et , id commodo nisi sapien et est. Mauris nisl odio, iaculis vitae pellentesque nec.

'; + + $this->assertSame( $paragraph, webp_uploads_update_image_references( $paragraph ) ); + } + + /** + * Test avoiding replacing not existing attachment IDs. + * + * @ticket 55443 + */ + public function it_should_avoid_replacing_not_existing_attachment_i_ds() { + $paragraph = '

Donec accumsan, sapien et , id commodo nisi sapien et est. Mauris nisl odio, iaculis vitae pellentesque nec.

'; + + $this->assertSame( $paragraph, webp_uploads_update_image_references( $paragraph ) ); + } + + /** + * Test preventing replacing a WebP image. + * + * @ticket 55443 + */ + public function it_should_test_preventing_replacing_a_webp_image() { + $attachment_id = $this->factory->attachment->create_upload_object( + DIR_TESTDATA . '/images/webp-lossy.webp' + ); + + $tag = wp_get_attachment_image( $attachment_id, 'medium', false, array( 'class' => "wp-image-{$attachment_id}" ) ); + + $this->assertSame( $tag, webp_uploads_img_tag_update_mime_type( $tag, 'the_content', $attachment_id ) ); + } + + /** + * Test preventing replacing a jpg image if the image does not have the target class name. + * + * @ticket 55443 + */ + public function it_should_test_preventing_replacing_a_jpg_image_if_the_image_does_not_have_the_target_class_name() { + $attachment_id = $this->factory->attachment->create_upload_object( + DIR_TESTDATA . '/images/test-image.jpg' + ); + + $tag = wp_get_attachment_image( $attachment_id, 'medium' ); + + $this->assertSame( $tag, webp_uploads_update_image_references( $tag ) ); + } + + /** + * Test replacing the references to a JPG image to a WebP version. + * + * @dataProvider provider_replace_images_with_different_extensions + * + * @ticket 55443 + */ + public function it_should_replace_the_references_to_a_jpg_image_to_a_webp_version( $image_path ) { + $attachment_id = $this->factory->attachment->create_upload_object( $image_path ); + + $tag = wp_get_attachment_image( $attachment_id, 'medium', false, array( 'class' => "wp-image-{$attachment_id}" ) ); + $expected_tag = $tag; + $metadata = wp_get_attachment_metadata( $attachment_id ); + foreach ( $metadata['sizes'] as $size => $properties ) { + $expected_tag = str_replace( $properties['sources']['image/jpeg']['file'], $properties['sources']['image/webp']['file'], $expected_tag ); + } + + $this->assertNotEmpty( $expected_tag ); + $this->assertNotSame( $tag, $expected_tag ); + $this->assertSame( $expected_tag, webp_uploads_img_tag_update_mime_type( $tag, 'the_content', $attachment_id ) ); + } + + public function provider_replace_images_with_different_extensions() { + yield 'An image with a .jpg extension' => array( DIR_TESTDATA . '/images/test-image.jpg' ); + yield 'An image with a .jpeg extension' => array( DIR_TESTDATA . '/images/test-image.jpeg' ); + } + + /** + * Test the full image size from the original mime type. + * + * @ticket 55443 + */ + public function it_should_contain_the_full_image_size_from_the_original_mime() { + $attachment_id = $this->factory->attachment->create_upload_object( + DIR_TESTDATA . '/images/test-image.jpg' + ); + + $tag = wp_get_attachment_image( $attachment_id, 'full', false, array( 'class' => "wp-image-{$attachment_id}" ) ); + + $expected = array( + 'ext' => 'jpg', + 'type' => 'image/jpeg', + ); + $this->assertSame( $expected, wp_check_filetype( get_attached_file( $attachment_id ) ) ); + $this->assertContains( wp_basename( get_attached_file( $attachment_id ) ), webp_uploads_img_tag_update_mime_type( $tag, 'the_content', $attachment_id ) ); + } + + /** + * Test preventing replacing an image with no available sources. + * + * @ticket 55443 + */ + public function it_should_prevent_replacing_an_image_with_no_available_sources() { + add_filter( 'wp_upload_image_mime_transforms', '__return_empty_array' ); + + $attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/test-image.jpg' ); + + $tag = wp_get_attachment_image( $attachment_id, 'full', false, array( 'class' => "wp-image-{$attachment_id}" ) ); + $this->assertSame( $tag, webp_uploads_img_tag_update_mime_type( $tag, 'the_content', $attachment_id ) ); + } + + /** + * Test preventing update not supported images with no available sources. + * + * @dataProvider provider_it_should_prevent_update_not_supported_images_with_no_available_sources + * + * @ticket 55443 + */ + public function it_should_prevent_update_not_supported_images_with_no_available_sources( $image_path ) { + $attachment_id = $this->factory->attachment->create_upload_object( $image_path ); + + $this->assertIsNumeric( $attachment_id ); + $tag = wp_get_attachment_image( $attachment_id, 'full', false, array( 'class' => "wp-image-{$attachment_id}" ) ); + + $this->assertSame( $tag, webp_uploads_img_tag_update_mime_type( $tag, 'the_content', $attachment_id ) ); + } + + /** + * Data provider for it_should_prevent_update_not_supported_images_with_no_available_sources. + */ + public function provider_it_should_prevent_update_not_supported_images_with_no_available_sources() { + yield 'PNG image' => array( DIR_TESTDATA . '/images/test-image.png' ); + yield 'GIFT image' => array( DIR_TESTDATA . '/images/test-image.gif' ); + } + } diff --git a/tests/phpunit/tests/image/functions.php b/tests/phpunit/tests/image/functions.php index 86d559145e..cb5a7971c9 100644 --- a/tests/phpunit/tests/image/functions.php +++ b/tests/phpunit/tests/image/functions.php @@ -639,6 +639,9 @@ class Tests_Image_Functions extends WP_UnitTestCase { $this->markTestSkipped( 'Rendering PDFs is not supported on this system.' ); } + // Use legacy JPEG output. + add_filter( 'wp_upload_image_mime_transforms', '__return_empty_array' ); + $orig_file = DIR_TESTDATA . '/images/wordpress-gsoc-flyer.pdf'; $test_file = get_temp_dir() . 'wordpress-gsoc-flyer.pdf'; copy( $orig_file, $test_file ); @@ -677,6 +680,12 @@ class Tests_Image_Functions extends WP_UnitTestCase { 'height' => 300, 'mime-type' => 'image/jpeg', 'filesize' => wp_filesize( $temp_dir . 'wordpress-gsoc-flyer-pdf-232x300.jpg' ), + 'sources' => array( + 'image/jpeg' => array( + 'file' => 'wordpress-gsoc-flyer-pdf-232x300.jpg', + 'filesize' => wp_filesize( $temp_dir . 'wordpress-gsoc-flyer-pdf-232x300.jpg' ), + ), + ), ), 'large' => array( 'file' => 'wordpress-gsoc-flyer-pdf-791x1024.jpg', @@ -684,6 +693,12 @@ class Tests_Image_Functions extends WP_UnitTestCase { 'height' => 1024, 'mime-type' => 'image/jpeg', 'filesize' => wp_filesize( $temp_dir . 'wordpress-gsoc-flyer-pdf-791x1024.jpg' ), + 'sources' => array( + 'image/jpeg' => array( + 'file' => 'wordpress-gsoc-flyer-pdf-791x1024.jpg', + 'filesize' => wp_filesize( $temp_dir . 'wordpress-gsoc-flyer-pdf-791x1024.jpg' ), + ), + ), ), 'thumbnail' => array( 'file' => 'wordpress-gsoc-flyer-pdf-116x150.jpg', @@ -691,6 +706,12 @@ class Tests_Image_Functions extends WP_UnitTestCase { 'height' => 150, 'mime-type' => 'image/jpeg', 'filesize' => wp_filesize( $temp_dir . 'wordpress-gsoc-flyer-pdf-116x150.jpg' ), + 'sources' => array( + 'image/jpeg' => array( + 'file' => 'wordpress-gsoc-flyer-pdf-116x150.jpg', + 'filesize' => wp_filesize( $temp_dir . 'wordpress-gsoc-flyer-pdf-116x150.jpg' ), + ), + ), ), ), 'filesize' => wp_filesize( $test_file ), @@ -702,6 +723,7 @@ class Tests_Image_Functions extends WP_UnitTestCase { foreach ( $metadata['sizes'] as $size ) { unlink( $temp_dir . $size['file'] ); } + remove_filter( 'wp_upload_image_mime_transforms', '__return_empty_array' ); } /** @@ -716,6 +738,9 @@ class Tests_Image_Functions extends WP_UnitTestCase { update_option( 'medium_crop', 1 ); + // Use legacy JPEG output. + add_filter( 'wp_upload_image_mime_transforms', '__return_empty_array' ); + $orig_file = DIR_TESTDATA . '/images/wordpress-gsoc-flyer.pdf'; $test_file = get_temp_dir() . 'wordpress-gsoc-flyer.pdf'; copy( $orig_file, $test_file ); @@ -754,6 +779,12 @@ class Tests_Image_Functions extends WP_UnitTestCase { 'height' => 300, 'mime-type' => 'image/jpeg', 'filesize' => wp_filesize( $temp_dir . 'wordpress-gsoc-flyer-pdf-300x300.jpg' ), + 'sources' => array( + 'image/jpeg' => array( + 'file' => 'wordpress-gsoc-flyer-pdf-300x300.jpg', + 'filesize' => wp_filesize( $temp_dir . 'wordpress-gsoc-flyer-pdf-300x300.jpg' ), + ), + ), ), 'large' => array( 'file' => 'wordpress-gsoc-flyer-pdf-791x1024.jpg', @@ -761,6 +792,13 @@ class Tests_Image_Functions extends WP_UnitTestCase { 'height' => 1024, 'mime-type' => 'image/jpeg', 'filesize' => wp_filesize( $temp_dir . 'wordpress-gsoc-flyer-pdf-791x1024.jpg' ), + 'sources' => array( + 'image/jpeg' => array( + 'file' => 'wordpress-gsoc-flyer-pdf-791x1024.jpg', + 'filesize' => wp_filesize( $temp_dir . 'wordpress-gsoc-flyer-pdf-791x1024.jpg' ), + ), + ), + ), 'thumbnail' => array( 'file' => 'wordpress-gsoc-flyer-pdf-116x150.jpg', @@ -768,6 +806,12 @@ class Tests_Image_Functions extends WP_UnitTestCase { 'height' => 150, 'mime-type' => 'image/jpeg', 'filesize' => wp_filesize( $temp_dir . 'wordpress-gsoc-flyer-pdf-116x150.jpg' ), + 'sources' => array( + 'image/jpeg' => array( + 'file' => 'wordpress-gsoc-flyer-pdf-116x150.jpg', + 'filesize' => wp_filesize( $temp_dir . 'wordpress-gsoc-flyer-pdf-116x150.jpg' ), + ), + ), ), ), 'filesize' => wp_filesize( $test_file ), @@ -779,6 +823,8 @@ class Tests_Image_Functions extends WP_UnitTestCase { foreach ( $metadata['sizes'] as $size ) { unlink( $temp_dir . $size['file'] ); } + remove_filter( 'wp_upload_image_mime_transforms', '__return_empty_array' ); + } /** @@ -789,6 +835,9 @@ class Tests_Image_Functions extends WP_UnitTestCase { $this->markTestSkipped( 'Rendering PDFs is not supported on this system.' ); } + // Use legacy JPEG output. + add_filter( 'wp_upload_image_mime_transforms', '__return_empty_array' ); + $orig_file = DIR_TESTDATA . '/images/wordpress-gsoc-flyer.pdf'; $test_file = get_temp_dir() . 'wordpress-gsoc-flyer.pdf'; copy( $orig_file, $test_file ); @@ -821,6 +870,12 @@ class Tests_Image_Functions extends WP_UnitTestCase { 'height' => 100, 'mime-type' => 'image/jpeg', 'filesize' => wp_filesize( $temp_dir . 'wordpress-gsoc-flyer-pdf-77x100.jpg' ), + 'sources' => array( + 'image/jpeg' => array( + 'file' => 'wordpress-gsoc-flyer-pdf-77x100.jpg', + 'filesize' => wp_filesize( $temp_dir . 'wordpress-gsoc-flyer-pdf-77x100.jpg' ), + ), + ), ); // Different environments produce slightly different filesize results. @@ -836,6 +891,7 @@ class Tests_Image_Functions extends WP_UnitTestCase { foreach ( $metadata['sizes'] as $size ) { unlink( $temp_dir . $size['file'] ); } + remove_filter( 'wp_upload_image_mime_transforms', '__return_empty_array' ); } public function filter_fallback_intermediate_image_sizes( $fallback_sizes, $metadata ) { @@ -1026,4 +1082,431 @@ class Tests_Image_Functions extends WP_UnitTestCase { ), ); } + + /** + * @ticket 55443 + */ + public function test_wp_upload_image_mime_transforms_generates_webp_and_jpeg_for_both_by_default() { + $result = wp_upload_image_mime_transforms( 42 ); + $this->assertArrayHasKey( 'image/jpeg', $result ); + $this->assertArrayHasKey( 'image/webp', $result ); + $this->assertSameSets( array( 'image/jpeg', 'image/webp' ), $result['image/jpeg'] ); + $this->assertSameSets( array( 'image/jpeg', 'image/webp' ), $result['image/webp'] ); + } + + /** + * @ticket 55443 + */ + public function test_wp_upload_image_mime_transforms_filter_always_use_webp_instead_of_jpeg() { + add_filter( + 'wp_upload_image_mime_transforms', + function( $transforms ) { + // Ensure JPG only results in WebP files. + $transforms['image/jpeg'] = array( 'image/webp' ); + // Unset WebP since it does not need any transformation in that case. + unset( $transforms['image/webp'] ); + return $transforms; + } + ); + + $result = wp_upload_image_mime_transforms( 42 ); + $this->assertArrayHasKey( 'image/jpeg', $result ); + $this->assertArrayNotHasKey( 'image/webp', $result ); + $this->assertSameSets( array( 'image/webp' ), $result['image/jpeg'] ); + } + + /** + * @ticket 55443 + */ + public function test_wp_upload_image_mime_transforms_filter_receives_parameters() { + $attachment_id = null; + add_filter( + 'wp_upload_image_mime_transforms', + function( $transforms, $param1 ) use ( &$attachment_id ) { + $attachment_id = $param1; + return $transforms; + }, + 10, + 2 + ); + + wp_upload_image_mime_transforms( 23 ); + $this->assertSame( 23, $attachment_id ); + } + + /** + * @ticket 55443 + */ + public function test_wp_upload_image_mime_transforms_filter_with_empty_array() { + add_filter( 'wp_upload_image_mime_transforms', '__return_empty_array' ); + $result = wp_upload_image_mime_transforms( 42 ); + $this->assertSame( array(), $result ); + } + + /** + * @ticket 55443 + */ + public function test_wp_upload_image_mime_transforms_filter_with_invalid_usage() { + $default = wp_upload_image_mime_transforms( 42 ); + + add_filter( 'wp_upload_image_mime_transforms', '__return_false' ); + $result = wp_upload_image_mime_transforms( 42 ); + $this->assertSame( $default, $result ); + } + + /** + * @ticket 55443 + */ + public function test__wp_get_primary_and_additional_mime_types_default() { + $jpeg_file = DIR_TESTDATA . '/images/test-image-large.jpg'; + + list( $primary_mime_type, $additional_mime_types ) = _wp_get_primary_and_additional_mime_types( $jpeg_file, 42 ); + $this->assertSame( 'image/jpeg', $primary_mime_type ); + + // WebP may not be supported by the server, in which case it will be stripped from the results. + if ( wp_image_editor_supports( array( 'mime_type' => 'image/webp' ) ) ) { + $this->assertSame( array( 'image/webp' ), $additional_mime_types ); + } else { + $this->assertSame( array(), $additional_mime_types ); + } + } + + /** + * @ticket 55443 + */ + public function test__wp_get_primary_and_additional_mime_types_prefer_original_mime() { + $jpeg_file = DIR_TESTDATA . '/images/test-image-large.jpg'; + + // Set 'image/jpeg' only as secondary output MIME type. + // Still, because it is the original, it should be chosen as primary over 'image/webp'. + add_filter( + 'wp_upload_image_mime_transforms', + function( $transforms ) { + $transforms['image/jpeg'] = array( 'image/webp', 'image/jpeg' ); + return $transforms; + } + ); + + list( $primary_mime_type, $additional_mime_types ) = _wp_get_primary_and_additional_mime_types( $jpeg_file, 42 ); + $this->assertSame( 'image/jpeg', $primary_mime_type ); + + // WebP may not be supported by the server, in which case it will be stripped from the results. + if ( wp_image_editor_supports( array( 'mime_type' => 'image/webp' ) ) ) { + $this->assertSame( array( 'image/webp' ), $additional_mime_types ); + } else { + $this->assertSame( array(), $additional_mime_types ); + } + } + + /** + * @ticket 55443 + */ + public function test__wp_get_primary_and_additional_mime_types_use_original_mime_when_no_transformation_rules() { + $jpeg_file = DIR_TESTDATA . '/images/test-image-large.jpg'; + + // Strip all transformation rules. + add_filter( 'wp_upload_image_mime_transforms', '__return_empty_array' ); + + list( $primary_mime_type, $additional_mime_types ) = _wp_get_primary_and_additional_mime_types( $jpeg_file, 42 ); + $this->assertSame( 'image/jpeg', $primary_mime_type ); + $this->assertSame( array(), $additional_mime_types ); + } + + /** + * @ticket 55443 + */ + public function test__wp_get_primary_and_additional_mime_types_different_output_mime() { + $jpeg_file = DIR_TESTDATA . '/images/test-image-large.jpg'; + + // Set 'image/webp' as the only output MIME type. + // In that case, JPEG is not generated at all, so WebP becomes the primary MIME type. + add_filter( + 'wp_upload_image_mime_transforms', + function( $transforms ) { + $transforms['image/jpeg'] = array( 'image/webp' ); + return $transforms; + } + ); + + list( $primary_mime_type, $additional_mime_types ) = _wp_get_primary_and_additional_mime_types( $jpeg_file, 42 ); + + // WebP may not be supported by the server, in which case it will fall back to the original MIME type. + if ( wp_image_editor_supports( array( 'mime_type' => 'image/webp' ) ) ) { + $this->assertSame( 'image/webp', $primary_mime_type ); + } else { + $this->assertSame( 'image/jpeg', $primary_mime_type ); + } + + $this->assertSame( array(), $additional_mime_types ); + } + + /** + * @ticket 55443 + */ + public function test__wp_get_primary_and_additional_mime_types_different_output_mimes() { + $jpeg_file = DIR_TESTDATA . '/images/test-image-large.jpg'; + + // Set 'image/webp' and 'image/avif' as output MIME types. + // In that case, JPEG is not generated at all, with WebP being the primary MIME type and AVIF the secondary. + add_filter( + 'wp_upload_image_mime_transforms', + function( $transforms ) { + $transforms['image/jpeg'] = array( 'image/webp', 'image/avif' ); + return $transforms; + } + ); + + list( $primary_mime_type, $additional_mime_types ) = _wp_get_primary_and_additional_mime_types( $jpeg_file, 42 ); + + // WebP may not be supported by the server, in which case it will fall back to the original MIME type. + if ( wp_image_editor_supports( array( 'mime_type' => 'image/webp' ) ) ) { + $this->assertSame( 'image/webp', $primary_mime_type ); + } else { + $this->assertSame( 'image/jpeg', $primary_mime_type ); + } + + // AVIF may not be supported by the server, in which case it will be stripped from the results. + if ( wp_image_editor_supports( array( 'mime_type' => 'image/avif' ) ) ) { + $this->assertSame( array( 'image/avif' ), $additional_mime_types ); + } else { + $this->assertSame( array(), $additional_mime_types ); + } + } + + /** + * @ticket 55443 + * @dataProvider data__wp_filter_image_sizes_additional_mime_type_support + */ + public function test__wp_filter_image_sizes_additional_mime_type_support( $input_size_data, $filter_callback, $expected_size_names ) { + remove_all_filters( 'wp_image_sizes_with_additional_mime_type_support' ); + if ( $filter_callback ) { + add_filter( 'wp_image_sizes_with_additional_mime_type_support', $filter_callback ); + } + + $expected_size_data = array_intersect_key( $input_size_data, array_flip( $expected_size_names ) ); + + $output_size_data = _wp_filter_image_sizes_additional_mime_type_support( $input_size_data, 42 ); + $this->assertEqualSetsWithIndex( $expected_size_data, $output_size_data ); + } + + public function data__wp_filter_image_sizes_additional_mime_type_support() { + $thumbnail_data = array( + 'width' => 150, + 'height' => 150, + 'crop' => true, + ); + $medium_data = array( + 'width' => 300, + 'height' => 300, + 'crop' => false, + ); + $medium_large_data = array( + 'width' => 768, + 'height' => 0, + 'crop' => false, + ); + $large_data = array( + 'width' => 1024, + 'height' => 1024, + 'crop' => false, + ); + $custom_data = array( + 'width' => 512, + 'height' => 512, + 'crop' => true, + ); + + return array( + array( + array( + 'thumbnail' => $thumbnail_data, + 'medium' => $medium_data, + 'medium_large' => $medium_large_data, + 'large' => $large_data, + ), + null, + array( 'thumbnail', 'medium', 'medium_large', 'large' ), + ), + array( + array( + 'thumbnail' => $thumbnail_data, + 'medium' => $medium_data, + 'custom' => $custom_data, + ), + null, + array( 'thumbnail', 'medium' ), + ), + array( + array( + 'thumbnail' => $thumbnail_data, + 'medium' => $medium_data, + 'medium_large' => $medium_large_data, + 'large' => $large_data, + ), + function( $enabled_sizes ) { + unset( $enabled_sizes['medium_large'], $enabled_sizes['large'] ); + return $enabled_sizes; + }, + array( 'thumbnail', 'medium' ), + ), + array( + array( + 'thumbnail' => $thumbnail_data, + 'medium' => $medium_data, + 'medium_large' => $medium_large_data, + 'large' => $large_data, + ), + function( $enabled_sizes ) { + $enabled_sizes['medium_large'] = false; + $enabled_sizes['large'] = false; + return $enabled_sizes; + }, + array( 'thumbnail', 'medium' ), + ), + array( + array( + 'thumbnail' => $thumbnail_data, + 'medium' => $medium_data, + 'custom' => $custom_data, + ), + function( $enabled_sizes ) { + unset( $enabled_sizes['medium'] ); + $enabled_sizes['custom'] = true; + return $enabled_sizes; + }, + array( 'thumbnail', 'custom' ), + ), + ); + } + + /** + * Test the `_wp_maybe_scale_and_rotate_image()` function. + * + * @dataProvider data_test__wp_maybe_scale_and_rotate_image + * + * @ticket 55443 + */ + public function test__wp_maybe_scale_and_rotate_image( $file, $imagesize, $mime_type, $expected ) { + if ( ! wp_image_editor_supports( array( 'mime_type' => $mime_type ) ) ) { + $this->markTestSkipped( sprintf( 'This test requires %s support.', $mime_type ) ); + } + + $attributes = array( 'post_mime_type' => $mime_type ); + $attachment_id = $this->factory->attachment->create_object( $file, 0, $attributes ); + $exif_meta = wp_read_image_metadata( $file ); + + list( $editor, $resized, $rotated ) = _wp_maybe_scale_and_rotate_image( $file, $attachment_id, $imagesize, $exif_meta, $mime_type ); + + $this->assertSame( $expected['rotated'], $rotated ); + $this->assertSame( $expected['resized'], $resized ); + $this->assertSame( $expected['size'], $editor->get_size() ); + } + + /** + * Data provider for the `test__wp_maybe_scale_and_rotate_image()` test. + * + * @return array + */ + public function data_test__wp_maybe_scale_and_rotate_image() { + return array( + + // Image that will be scaled. + array( + DIR_TESTDATA . '/images/test-image-large.jpg', + array( 3000, 2250 ), + 'image/jpeg', + array( + 'rotated' => false, + 'resized' => true, + 'size' => array( + 'width' => 2560, + 'height' => 1920, + ), + ), + ), + + // Image that will not be scaled. + array( + DIR_TESTDATA . '/images/canola.jpg', + array( 640, 480 ), + 'image/jpeg', + array( + 'rotated' => false, + 'resized' => false, + 'size' => array( + 'width' => 640, + 'height' => 480, + ), + ), + ), + + // Image that will be flipped. + array( + DIR_TESTDATA . '/images/test-image-upside-down.jpg', + array( 600, 450 ), + 'image/jpeg', + array( + 'rotated' => true, + 'resized' => false, + 'size' => array( + 'width' => 600, + 'height' => 450, + ), + ), + ), + + // Image that will be rotated. + array( + DIR_TESTDATA . '/images/test-image-rotated-90ccw.jpg', + array( 1200, 1800 ), + 'image/jpeg', + array( + 'rotated' => true, + 'resized' => false, + 'size' => array( + 'width' => 1800, + 'height' => 1200, + ), + ), + ), + + // Image that will not be rotated - WebP Exif is not supported in PHP. + array( + DIR_TESTDATA . '/images/test-image-rotated-90cw.webp', + array( 1024, 768 ), + 'image/webp', + array( + 'rotated' => false, + 'resized' => false, + 'size' => array( + 'width' => 1024, + 'height' => 768, + ), + ), + ), + + ); + } + + /** + * Test the `_wp_get_image_suffix()` function. + * @dataProvider data_test__wp_get_image_suffix + * + * @ticket 55443 + */ + public function test__wp_get_image_suffix( $resized, $rotated, $expected ) { + $this->assertSame( $expected, _wp_get_image_suffix( $resized, $rotated ) ); + } + + /** + * Data provider for the `test__wp_get_image_suffix()` test. + */ + public function data_test__wp_get_image_suffix() { + return array( + array( false, false, '' ), + array( true, false, 'scaled' ), + array( false, true, 'rotated' ), + array( true, true, 'scaled' ), + ); + } } diff --git a/tests/phpunit/tests/media.php b/tests/phpunit/tests/media.php index 0341170721..592b19e328 100644 --- a/tests/phpunit/tests/media.php +++ b/tests/phpunit/tests/media.php @@ -2251,11 +2251,14 @@ EOF; // Do not add width, height, and loading. add_filter( 'wp_img_tag_add_width_and_height_attr', '__return_false' ); add_filter( 'wp_img_tag_add_loading_attr', '__return_false' ); + add_filter( 'wp_content_image_mimes', '__return_empty_array' ); $this->assertSame( $content_filtered, wp_filter_content_tags( $content_unfiltered ) ); remove_filter( 'wp_img_tag_add_width_and_height_attr', '__return_false' ); remove_filter( 'wp_img_tag_add_loading_attr', '__return_false' ); + remove_filter( 'wp_content_image_mimes', '__return_empty_array' ); + } /** @@ -2289,9 +2292,12 @@ EOF; $img = wp_img_tag_add_loading_attr( $img, 'test' ); $img = wp_img_tag_add_decoding_attr( $img, 'the_content' ); $img = preg_replace( '|]+) />|', '', $img ); + add_filter( 'wp_content_image_mimes', '__return_empty_array' ); // The content filter should return the image unchanged. $this->assertSame( $img, wp_filter_content_tags( $img ) ); + + remove_filter( 'wp_content_image_mimes', '__return_empty_array' ); } /** @@ -2361,6 +2367,7 @@ EOF; add_filter( 'wp_img_tag_add_width_and_height_attr', '__return_false' ); add_filter( 'wp_img_tag_add_srcset_and_sizes_attr', '__return_false' ); add_filter( 'wp_img_tag_add_decoding_attr', '__return_false' ); + add_filter( 'wp_content_image_mimes', '__return_empty_array' ); add_filter( 'wp_content_img_tag', @@ -2423,6 +2430,7 @@ EOF; * @requires function imagejpeg */ public function test_wp_filter_content_tags_schemes() { + add_filter( 'wp_content_image_mimes', '__return_empty_array' ); $image_meta = wp_get_attachment_metadata( self::$large_id ); $size_array = $this->get_image_size_array_from_meta( $image_meta, 'medium' ); @@ -2468,6 +2476,7 @@ EOF; $actual = wp_filter_content_tags( $unfiltered ); $this->assertSame( $expected, $actual ); + remove_filter( 'wp_content_image_mimes', '__return_empty_array' ); } /** @@ -2961,11 +2970,13 @@ EOF; // Do not add loading, srcset, and sizes. add_filter( 'wp_img_tag_add_loading_attr', '__return_false' ); add_filter( 'wp_img_tag_add_srcset_and_sizes_attr', '__return_false' ); + add_filter( 'wp_content_image_mimes', '__return_empty_array' ); $this->assertSame( $content_filtered, wp_filter_content_tags( $content_unfiltered ) ); remove_filter( 'wp_img_tag_add_loading_attr', '__return_false' ); remove_filter( 'wp_img_tag_add_srcset_and_sizes_attr', '__return_false' ); + remove_filter( 'wp_content_image_mimes', '__return_empty_array' ); } /** @@ -3041,11 +3052,13 @@ EOF; // Do not add width, height, srcset, and sizes. add_filter( 'wp_img_tag_add_width_and_height_attr', '__return_false' ); add_filter( 'wp_img_tag_add_srcset_and_sizes_attr', '__return_false' ); + add_filter( 'wp_content_image_mimes', '__return_empty_array' ); $this->assertSame( $content_filtered, wp_filter_content_tags( $content_unfiltered ) ); remove_filter( 'wp_img_tag_add_width_and_height_attr', '__return_false' ); remove_filter( 'wp_img_tag_add_srcset_and_sizes_attr', '__return_false' ); + remove_filter( 'wp_content_image_mimes', '__return_empty_array' ); } /** @@ -3074,9 +3087,13 @@ EOF; // Enable globally for all tags. add_filter( 'wp_lazy_loading_enabled', '__return_true' ); + add_filter( 'wp_content_image_mimes', '__return_empty_array' ); + $this->assertSame( $content_filtered, wp_filter_content_tags( $content_unfiltered ) ); remove_filter( 'wp_lazy_loading_enabled', '__return_true' ); remove_filter( 'wp_img_tag_add_srcset_and_sizes_attr', '__return_false' ); + remove_filter( 'wp_content_image_mimes', '__return_empty_array' ); + } /** @@ -3101,9 +3118,12 @@ EOF; // Disable globally for all tags. add_filter( 'wp_lazy_loading_enabled', '__return_false' ); + add_filter( 'wp_content_image_mimes', '__return_empty_array' ); + $this->assertSame( $content, wp_filter_content_tags( $content ) ); remove_filter( 'wp_lazy_loading_enabled', '__return_false' ); remove_filter( 'wp_img_tag_add_srcset_and_sizes_attr', '__return_false' ); + remove_filter( 'wp_content_image_mimes', '__return_empty_array' ); } /** @@ -3529,6 +3549,7 @@ EOF; */ function test_wp_filter_content_tags_with_wp_get_loading_attr_default() { global $wp_query, $wp_the_query; + add_filter( 'wp_content_image_mimes', '__return_empty_array' ); $img1 = get_image_tag( self::$large_id, '', '', '', 'large' ); $iframe1 = ''; @@ -3564,6 +3585,7 @@ EOF; $content_filtered = wp_filter_content_tags( $content_unfiltered, 'the_content' ); remove_filter( 'wp_img_tag_add_srcset_and_sizes_attr', '__return_false' ); } + remove_filter( 'wp_content_image_mimes', '__return_empty_array' ); // After filtering, the first image should not be lazy-loaded while the other ones should be. $this->assertSame( $content_expected, $content_filtered ); @@ -3613,6 +3635,169 @@ EOF; // Clean up the above filter. remove_filter( 'wp_omit_loading_attr_threshold', '__return_null', 100 ); } + + /** + * @ticket 55443 + */ + public function test_wp_image_use_alternate_mime_types_replaces_jpg_with_webp_where_available() { + if ( ! wp_image_editor_supports( array( 'mime_type' => 'image/webp' ) ) ) { + $this->markTestSkipped( 'This test requires WebP support.' ); + } + + // The attachment $large_id is a JPEG image, so it gets WebP files generated by default. + $tag = wp_get_attachment_image( self::$large_id, 'full' ); + $expected_tag = $tag; + + $metadata = wp_get_attachment_metadata( self::$large_id ); + foreach ( $metadata['sizes'] as $size => $properties ) { + // Some sizes may not have WebP if the WebP file is larger than the JPEG for the size. + if ( ! isset( $properties['sources']['image/webp'] ) ) { + continue; + } + $expected_tag = str_replace( $properties['sources']['image/jpeg']['file'], $properties['sources']['image/webp']['file'], $expected_tag ); + } + // Same applies to the full size. + if ( isset( $metadata['sources']['image/webp'] ) ) { + $expected_tag = str_replace( $metadata['sources']['image/jpeg']['file'], $metadata['sources']['image/webp']['file'], $expected_tag ); + } + + $this->assertNotSame( $tag, $expected_tag ); + $this->assertSame( $expected_tag, wp_image_use_alternate_mime_types( $tag, 'the_content', self::$large_id ) ); + } + + /** + * @ticket 55443 + */ + public function test_wp_image_use_alternate_mime_types_does_not_replace_jpg_when_webp_is_not_available() { + if ( ! wp_image_editor_supports( array( 'mime_type' => 'image/webp' ) ) ) { + $this->markTestSkipped( 'This test requires WebP support.' ); + } + + // The attachment $large_id is a JPEG image, so it gets WebP files generated by default. + $tag = wp_get_attachment_image( self::$large_id, 'full' ); + + // Update attachment metadata as if the image had no WebP available for any sub-sizes and the full size. + $metadata = wp_get_attachment_metadata( self::$large_id ); + foreach ( $metadata['sizes'] as $size => $properties ) { + unset( $metadata['sizes'][ $size ]['sources']['image/webp'] ); + } + unset( $metadata['sources']['image/webp'] ); + wp_update_attachment_metadata( self::$large_id, $metadata ); + + $this->assertSame( $tag, wp_image_use_alternate_mime_types( $tag, 'the_content', self::$large_id ) ); + } + + /** + * @ticket 55443 + */ + public function test_wp_image_use_alternate_mime_types_still_replaces_jpg_subsizes_when_webp_is_not_available_for_full_size() { + if ( ! wp_image_editor_supports( array( 'mime_type' => 'image/webp' ) ) ) { + $this->markTestSkipped( 'This test requires WebP support.' ); + } + + // The attachment $large_id is a JPEG image, so it gets WebP files generated by default. + $tag = wp_get_attachment_image( self::$large_id, 'full' ); + $expected_tag = $tag; + + // Update attachment metadata as if the image had no WebP available for the full size. + $metadata = wp_get_attachment_metadata( self::$large_id ); + unset( $metadata['sources']['image/webp'] ); + wp_update_attachment_metadata( self::$large_id, $metadata ); + + foreach ( $metadata['sizes'] as $size => $properties ) { + // Some sizes may not have WebP if the WebP file is larger than the JPEG for the size. + if ( ! isset( $properties['sources']['image/webp'] ) ) { + continue; + } + $expected_tag = str_replace( $properties['sources']['image/jpeg']['file'], $properties['sources']['image/webp']['file'], $expected_tag ); + } + + $this->assertNotSame( $tag, $expected_tag ); + $this->assertSame( $expected_tag, wp_image_use_alternate_mime_types( $tag, 'the_content', self::$large_id ) ); + } + + /** + * @ticket 55443 + */ + public function test_wp_image_use_alternate_mime_types_respects_wp_content_image_mimes_filter() { + if ( ! wp_image_editor_supports( array( 'mime_type' => 'image/webp' ) ) ) { + $this->markTestSkipped( 'This test requires WebP support.' ); + } + + // The attachment $large_id is a JPEG image, so it gets WebP files generated by default. + $tag = wp_get_attachment_image( self::$large_id, 'full' ); + + // Invalid filter value results in no changes to content. + add_filter( 'wp_content_image_mimes', '__return_false' ); + $this->assertSame( $tag, wp_image_use_alternate_mime_types( $tag, 'the_content', self::$large_id ) ); + + // Empty array results in no changes to content. + add_filter( 'wp_content_image_mimes', '__return_empty_array' ); + $this->assertSame( $tag, wp_image_use_alternate_mime_types( $tag, 'the_content', self::$large_id ) ); + + // Preferring JPEG over WebP results in no changes to content. + add_filter( + 'wp_content_image_mimes', + function() { + return array( 'image/jpeg', 'image/webp' ); + } + ); + $this->assertSame( $tag, wp_image_use_alternate_mime_types( $tag, 'the_content', self::$large_id ) ); + } + + /** + * @ticket 55443 + */ + public function test__wp_in_front_end_context_without_wp_query() { + unset( $GLOBALS['wp_query'] ); + + $this->assertFalse( _wp_in_front_end_context() ); + } + + /** + * @ticket 55443 + */ + public function test__wp_in_front_end_context_with_feed() { + remove_all_actions( 'template_redirect' ); + do_action( 'template_redirect' ); + $GLOBALS['wp_query']->is_feed = true; + + $this->assertFalse( _wp_in_front_end_context() ); + } + + /** + * @ticket 55443 + */ + public function test__wp_in_front_end_context_before_and_after_template_redirect() { + $result = _wp_in_front_end_context(); + + remove_all_actions( 'template_redirect' ); + do_action( 'template_redirect' ); + + $this->assertFalse( $result ); + $this->assertTrue( _wp_in_front_end_context() ); + } + + /** + * @ticket 55443 + */ + public function test__wp_in_front_end_context_within_wp_head() { + remove_all_actions( 'template_redirect' ); + do_action( 'template_redirect' ); + + // Call function within a 'wp_head' callback. + remove_all_actions( 'wp_head' ); + $result = null; + add_action( + 'wp_head', + function() use ( &$result ) { + $result = _wp_in_front_end_context(); + } + ); + do_action( 'wp_head' ); + + $this->assertFalse( $result ); + } } /**