diff --git a/src/wp-admin/includes/image.php b/src/wp-admin/includes/image.php index 935c613d561e9..b5d0949762582 100644 --- a/src/wp-admin/includes/image.php +++ b/src/wp-admin/includes/image.php @@ -412,6 +412,66 @@ function wp_create_image_subsizes( $file, $attachment_id ) { return _wp_make_subsizes( $new_sizes, $file, $image_meta, $attachment_id ); } +/** + * Adds explicit filename suffixes for sub-size requests that would otherwise collide. + * + * When multiple requested sizes share the same target dimensions, cropped variants + * can produce different images while still resolving to the same default filename + * based on those width and height values. In that case, add a crop-specific suffix + * to the cropped variants only. + * + * @since 7.1.0 + * @access private + * + * @param array $new_sizes Registered image sub-sizes keyed by size name. + * @return array + */ +function _wp_add_subsize_suffixes( $new_sizes ) { + $sizes_by_dimensions = array(); + + foreach ( $new_sizes as $size_name => $size_data ) { + $width = isset( $size_data['width'] ) ? (int) $size_data['width'] : 0; + $height = isset( $size_data['height'] ) ? (int) $size_data['height'] : 0; + + $sizes_by_dimensions[ "{$width}x{$height}" ][] = $size_name; + } + + foreach ( $sizes_by_dimensions as $dimensions => $size_names ) { + if ( count( $size_names ) < 2 ) { + continue; + } + + $used_suffixes = array(); + + foreach ( $size_names as $size_name ) { + $crop = $new_sizes[ $size_name ]['crop'] ?? false; + + if ( false === $crop ) { + continue; + } + + $crop_suffix = 'crop'; + + if ( is_array( $crop ) ) { + $crop_suffix .= '-' . implode( '-', array_map( 'sanitize_key', $crop ) ); + } + + $suffix = "{$dimensions}-{$crop_suffix}"; + $index = 2; + + while ( in_array( $suffix, $used_suffixes, true ) ) { + $suffix = "{$dimensions}-{$crop_suffix}-{$index}"; + ++$index; + } + + $new_sizes[ $size_name ]['suffix'] = $suffix; + $used_suffixes[] = $suffix; + } + } + + return $new_sizes; +} + /** * Low-level function to create image sub-sizes. * @@ -467,9 +527,9 @@ function _wp_make_subsizes( $new_sizes, $file, $image_meta, $attachment_id ) { ); $new_sizes = array_filter( array_merge( $priority, $new_sizes ) ); + $new_sizes = _wp_add_subsize_suffixes( $new_sizes ); $editor = wp_get_image_editor( $file ); - if ( is_wp_error( $editor ) ) { // The image cannot be edited. return $image_meta; diff --git a/src/wp-includes/class-wp-image-editor-gd.php b/src/wp-includes/class-wp-image-editor-gd.php index 3d93b5bd8a2c1..13faed65a3a24 100644 --- a/src/wp-includes/class-wp-image-editor-gd.php +++ b/src/wp-includes/class-wp-image-editor-gd.php @@ -329,13 +329,19 @@ public function make_subsize( $size_data ) { if ( is_wp_error( $resized ) ) { $saved = $resized; } else { - $saved = $this->_save( $resized ); + $filename = null; - if ( PHP_VERSION_ID < 80000 ) { // imagedestroy() has no effect as of PHP 8.0. + if ( ! empty( $size_data['suffix'] ) ) { + $filename = $this->generate_filename( $size_data['suffix'] ); + } + + $saved = $this->_save( $resized, $filename ); + + if ( PHP_VERSION_ID < 80000 ) { + // imagedestroy() has no effect as of PHP 8.0. imagedestroy( $resized ); } } - $this->size = $orig_size; if ( ! is_wp_error( $saved ) ) { diff --git a/src/wp-includes/class-wp-image-editor-imagick.php b/src/wp-includes/class-wp-image-editor-imagick.php index 2cb3a694c567b..cdee242b3dd58 100644 --- a/src/wp-includes/class-wp-image-editor-imagick.php +++ b/src/wp-includes/class-wp-image-editor-imagick.php @@ -694,7 +694,13 @@ public function make_subsize( $size_data ) { if ( is_wp_error( $resized ) ) { $saved = $resized; } else { - $saved = $this->_save( $this->image ); + $filename = null; + + if ( ! empty( $size_data['suffix'] ) ) { + $filename = $this->generate_filename( $size_data['suffix'] ); + } + + $saved = $this->_save( $this->image, $filename ); $this->image->clear(); $this->image->destroy(); diff --git a/tests/phpunit/tests/media.php b/tests/phpunit/tests/media.php index c3e118ee718d5..5690b61696ac7 100644 --- a/tests/phpunit/tests/media.php +++ b/tests/phpunit/tests/media.php @@ -5798,7 +5798,6 @@ public function test_wp_generate_attachment_metadata_doesnt_generate_sizes_for_1 $temp_dir = get_temp_dir(); $file = $temp_dir . '/test-square-150.jpg'; copy( DIR_TESTDATA . '/images/test-square-150.jpg', $file ); - $attachment_id = self::factory()->attachment->create_object( array( 'post_mime_type' => 'image/jpeg', @@ -5829,6 +5828,58 @@ public function test_wp_generate_attachment_metadata_doesnt_generate_sizes_for_1 ); } + /** + * Tests that sizes with matching dimensions but different crop modes get distinct filenames. + * + * @ticket 62388 + */ + public function test_wp_generate_attachment_metadata_adds_unique_suffixes_for_duplicate_cropped_sizes() { + $temp_dir = get_temp_dir(); + $file = $temp_dir . '/test-image-62388.jpg'; + copy( DIR_TESTDATA . '/images/33772.jpg', $file ); + + $attachment_id = self::factory()->attachment->create_object( + array( + 'post_mime_type' => 'image/jpeg', + 'file' => $file, + ) + ); + + add_filter( 'intermediate_image_sizes_advanced', array( $this, 'filter_duplicate_cropped_subsizes' ) ); + + $metadata = wp_generate_attachment_metadata( $attachment_id, $file ); + + remove_filter( 'intermediate_image_sizes_advanced', array( $this, 'filter_duplicate_cropped_subsizes' ) ); + + $this->assertArrayHasKey( 'cropped_default', $metadata['sizes'] ); + $this->assertArrayHasKey( 'cropped_right_bottom', $metadata['sizes'] ); + $this->assertNotSame( $metadata['sizes']['cropped_default']['file'], $metadata['sizes']['cropped_right_bottom']['file'] ); + $this->assertStringContainsString( '150x150-crop', $metadata['sizes']['cropped_default']['file'] ); + $this->assertStringContainsString( '150x150-crop-right-bottom', $metadata['sizes']['cropped_right_bottom']['file'] ); + $this->assertFileExists( dirname( $file ) . '/' . $metadata['sizes']['cropped_default']['file'] ); + $this->assertFileExists( dirname( $file ) . '/' . $metadata['sizes']['cropped_right_bottom']['file'] ); + } + + /** + * Filters generated sub-sizes for testing duplicate dimension crops. + * + * @return array[] + */ + public function filter_duplicate_cropped_subsizes() { + return array( + 'cropped_default' => array( + 'width' => 150, + 'height' => 150, + 'crop' => true, + ), + 'cropped_right_bottom' => array( + 'width' => 150, + 'height' => 150, + 'crop' => array( 'right', 'bottom' ), + ), + ); + } + /** * Tests that `wp_get_attachment_image()` uses the correct default context. *