From afeaf7e8ce7ad7e7030f1b95ef7d7515810a6429 Mon Sep 17 00:00:00 2001 From: Joe McGill Date: Tue, 10 Sep 2024 23:59:54 +0000 Subject: [PATCH] Media: Add auto sizes for lazy-loaded images. This implements the HTML spec for applying auto sizes to lazy-loaded images by prepending `auto` to the `sizes` attribute generated by WordPress if the image has a `loading` attribute set to `lazy`. For browser that support this HTML spec, the image's size value will be set to the concrete object size of the image. For browsers that don't support the spec, the word "auto" will be ignored when parsing the sizes value. References: - https://html.spec.whatwg.org/multipage/images.html#sizes-attributes - https://github.com/whatwg/html/pull/8008 Props mukesh27, flixos90, joemcgill, westonruter, peterwilsoncc. Fixes #61847. git-svn-id: https://develop.svn.wordpress.org/trunk@59008 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/media.php | 66 ++++++++ tests/phpunit/tests/media.php | 279 +++++++++++++++++++++++++++++++++- 2 files changed, 344 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 8a4277a2f57b9..82ea8f27edc09 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -1137,6 +1137,16 @@ function wp_get_attachment_image( $attachment_id, $size = 'thumbnail', $icon = f } } + // Adds 'auto' to the sizes attribute if applicable. + if ( + isset( $attr['loading'] ) && + 'lazy' === $attr['loading'] && + isset( $attr['sizes'] ) && + ! wp_sizes_attribute_includes_valid_auto( $attr['sizes'] ) + ) { + $attr['sizes'] = 'auto, ' . $attr['sizes']; + } + /** * Filters the list of attachment image attributes. * @@ -1917,6 +1927,9 @@ function wp_filter_content_tags( $content, $context = null ) { // Add loading optimization attributes if applicable. $filtered_image = wp_img_tag_add_loading_optimization_attrs( $filtered_image, $context ); + // Adds 'auto' to the sizes attribute if applicable. + $filtered_image = wp_img_tag_add_auto_sizes( $filtered_image ); + /** * Filters an img tag within the content for a given context. * @@ -1963,6 +1976,59 @@ function wp_filter_content_tags( $content, $context = null ) { return $content; } +/** + * Adds 'auto' to the sizes attribute to the image, if the image is lazy loaded and does not already include it. + * + * @since 6.7.0 + * + * @param string $image The image tag markup being filtered. + * @return string The filtered image tag markup. + */ +function wp_img_tag_add_auto_sizes( string $image ): string { + $processor = new WP_HTML_Tag_Processor( $image ); + + // Bail if there is no IMG tag. + if ( ! $processor->next_tag( array( 'tag_name' => 'IMG' ) ) ) { + return $image; + } + + // Bail early if the image is not lazy-loaded. + $value = $processor->get_attribute( 'loading' ); + if ( ! is_string( $value ) || 'lazy' !== strtolower( trim( $value, " \t\f\r\n" ) ) ) { + return $image; + } + + $sizes = $processor->get_attribute( 'sizes' ); + + // Bail early if the image is not responsive. + if ( ! is_string( $sizes ) ) { + return $image; + } + + // Don't add 'auto' to the sizes attribute if it already exists. + if ( wp_sizes_attribute_includes_valid_auto( $sizes ) ) { + return $image; + } + + $processor->set_attribute( 'sizes', "auto, $sizes" ); + return $processor->get_updated_html(); +} + +/** + * Checks whether the given 'sizes' attribute includes the 'auto' keyword as the first item in the list. + * + * Per the HTML spec, if present it must be the first entry. + * + * @since 6.7.0 + * + * @param string $sizes_attr The 'sizes' attribute value. + * @return bool True if the 'auto' keyword is present, false otherwise. + */ +function wp_sizes_attribute_includes_valid_auto( string $sizes_attr ): bool { + list( $first_size ) = explode( ',', $sizes_attr, 2 ); + return 'auto' === strtolower( trim( $first_size, " \t\f\r\n" ) ); +} + /** * Adds optimization attributes to an `img` HTML tag. * diff --git a/tests/phpunit/tests/media.php b/tests/phpunit/tests/media.php index 12d037961255e..d2edbc53747b1 100644 --- a/tests/phpunit/tests/media.php +++ b/tests/phpunit/tests/media.php @@ -2467,6 +2467,9 @@ public function test_wp_calculate_image_srcset_animated_gifs() { * @requires function imagejpeg */ public function test_wp_filter_content_tags_schemes() { + // Disable lazy loading attribute to not add the 'auto' keyword to the `sizes` attribute. + add_filter( 'wp_img_tag_add_loading_attr', '__return_false' ); + $image_meta = wp_get_attachment_metadata( self::$large_id ); $size_array = $this->get_image_size_array_from_meta( $image_meta, 'medium' ); @@ -2680,7 +2683,7 @@ public function test_wp_get_attachment_image_should_use_wp_get_attachment_metada 'src="' . $uploads_url . 'test-image-testsize-999x999.jpg" ' . 'class="attachment-testsize size-testsize" alt="" decoding="async" loading="lazy" ' . 'srcset="' . $uploads_url . 'test-image-testsize-999x999.jpg 999w, ' . $uploads_url . $basename . '-150x150.jpg 150w" ' . - 'sizes="(max-width: 999px) 100vw, 999px" />'; + 'sizes="auto, (max-width: 999px) 100vw, 999px" />'; $actual = wp_get_attachment_image( self::$large_id, 'testsize' ); @@ -5117,6 +5120,9 @@ static function ( $loading_attrs ) { } ); + // Do not calculate sizes attribute as it is irrelevant for this test. + add_filter( 'wp_calculate_image_sizes', '__return_false' ); + // Add shortcode that prints a large image, and a block type that wraps it. add_shortcode( 'full_image', @@ -6028,6 +6034,277 @@ static function ( $loading_attrs ) { ); } + /** + * Test generated markup for an image with lazy loading gets auto-sizes. + * + * @ticket 61847 + */ + public function test_image_with_lazy_loading_has_auto_sizes() { + $this->assertStringContainsString( + 'sizes="auto, ', + wp_get_attachment_image( self::$large_id, 'large', false, array( 'loading' => 'lazy' ) ), + 'Failed asserting that the sizes attribute for a lazy-loaded image includes "auto".' + ); + } + + /** + * Test generated markup for an image without lazy loading does not get auto-sizes. + * + * @ticket 61847 + */ + public function test_image_without_lazy_loading_does_not_have_auto_sizes() { + $this->assertStringNotContainsString( + 'sizes="auto, ', + wp_get_attachment_image( self::$large_id, 'large', false, array( 'loading' => false ) ), + 'Failed asserting that the sizes attribute for an image without lazy loading does not include "auto".' + ); + } + + /** + * Test content filtered markup with lazy loading gets auto-sizes. + * + * @ticket 61847 + * + * @covers ::wp_img_tag_add_auto_sizes + */ + public function test_content_image_with_lazy_loading_has_auto_sizes() { + // Force lazy loading attribute. + add_filter( 'wp_img_tag_add_loading_attr', '__return_true' ); + + $this->assertStringContainsString( + 'sizes="auto, (max-width: 1024px) 100vw, 1024px"', + wp_filter_content_tags( get_image_tag( self::$large_id, '', '', '', 'large' ) ), + 'Failed asserting that the sizes attribute for a content image with lazy loading includes "auto" with the expected sizes.' + ); + } + + /** + * Test content filtered markup without lazy loading does not get auto-sizes. + * + * @ticket 61847 + * + * @covers ::wp_img_tag_add_auto_sizes + */ + public function test_content_image_without_lazy_loading_does_not_have_auto_sizes() { + // Disable lazy loading attribute. + add_filter( 'wp_img_tag_add_loading_attr', '__return_false' ); + + $this->assertStringNotContainsString( + 'sizes="auto, ', + wp_filter_content_tags( get_image_tag( self::$large_id, '', '', '', 'large' ) ), + 'Failed asserting that the sizes attribute for a content image without lazy loading does not include "auto" with the expected sizes.' + ); + } + + /** + * Test generated markup for an image with 'auto' keyword already present in sizes does not receive it again. + * + * @ticket 61847 + * + * @covers ::wp_img_tag_add_auto_sizes + * @covers ::wp_sizes_attribute_includes_valid_auto + * + * @dataProvider data_image_with_existing_auto_sizes + * + * @param string $initial_sizes The initial sizes attribute to test. + * @param bool $expected_processed Whether the auto sizes should be processed or not. + */ + public function test_image_with_existing_auto_sizes_is_not_processed_again( string $initial_sizes, bool $expected_processed ) { + $image_tag = wp_get_attachment_image( + self::$large_id, + 'large', + false, + array( + // Force pre-existing 'sizes' attribute and lazy-loading. + 'sizes' => $initial_sizes, + 'loading' => 'lazy', + ) + ); + if ( $expected_processed ) { + $this->assertStringContainsString( + 'sizes="auto, ' . $initial_sizes . '"', + $image_tag, + 'Failed asserting that "auto" keyword is not added to sizes attribute when it already exists.' + ); + } else { + $this->assertStringContainsString( + 'sizes="' . $initial_sizes . '"', + $image_tag, + 'Failed asserting that "auto" keyword is not added to sizes attribute when it already exists.' + ); + } + } + + /** + * Test content filtered markup with 'auto' keyword already present in sizes does not receive it again. + * + * @ticket 61847 + * + * @covers ::wp_img_tag_add_auto_sizes + * @covers ::wp_sizes_attribute_includes_valid_auto + * + * @dataProvider data_image_with_existing_auto_sizes + * + * @param string $initial_sizes The initial sizes attribute to test. + * @param bool $expected_processed Whether the auto sizes should be processed or not. + */ + public function test_content_image_with_existing_auto_sizes_is_not_processed_again( string $initial_sizes, bool $expected_processed ) { + // Force lazy loading attribute. + add_filter( 'wp_img_tag_add_loading_attr', '__return_true' ); + + add_filter( + 'get_image_tag', + static function ( $html ) use ( $initial_sizes ) { + return str_replace( + '" />', + '" sizes="' . $initial_sizes . '" />', + $html + ); + } + ); + + $image_content = wp_filter_content_tags( get_image_tag( self::$large_id, '', '', '', 'large' ) ); + if ( $expected_processed ) { + $this->assertStringContainsString( + 'sizes="auto, ' . $initial_sizes . '"', + $image_content, + 'Failed asserting that "auto" keyword is not added to sizes attribute in filtered content when it already exists.' + ); + } else { + $this->assertStringContainsString( + 'sizes="' . $initial_sizes . '"', + $image_content, + 'Failed asserting that "auto" keyword is not added to sizes attribute in filtered content when it already exists.' + ); + } + } + + /** + * Returns data for the above test methods to assert correct behavior with a pre-existing sizes attribute. + * + * @return array Arguments for the test scenarios. + */ + public function data_image_with_existing_auto_sizes() { + return array( + 'not present' => array( + '(max-width: 1024px) 100vw, 1024px', + true, + ), + 'in beginning, without space' => array( + 'auto,(max-width: 1024px) 100vw, 1024px', + false, + ), + 'in beginning, with space' => array( + 'auto, (max-width: 1024px) 100vw, 1024px', + false, + ), + 'sole keyword' => array( + 'auto', + false, + ), + 'with space before' => array( + ' auto, (max-width: 1024px) 100vw, 1024px', + false, + ), + 'with uppercase' => array( + 'AUTO, (max-width: 1024px) 100vw, 1024px', + false, + ), + + /* + * The following scenarios technically include the 'auto' keyword, + * but it is in the wrong place, as per the HTML spec it must be + * the first entry in the list. + * Therefore in these invalid cases the 'auto' keyword should still + * be added to the beginning of the list. + */ + 'within, without space' => array( + '(max-width: 1024px) 100vw, auto,1024px', + true, + ), + 'within, with space' => array( + '(max-width: 1024px) 100vw, auto, 1024px', + true, + ), + 'at the end, without space' => array( + '(max-width: 1024px) 100vw,auto', + true, + ), + 'at the end, with space' => array( + '(max-width: 1024px) 100vw, auto', + true, + ), + ); + } + + /** + * Data provider for test_wp_img_tag_add_auto_sizes(). + * + * @return array + */ + public function data_provider_to_test_wp_img_tag_add_auto_sizes() { + return array( + 'expected_with_single_quoted_attributes' => array( + 'input' => "", + 'expected' => "", + ), + 'expected_with_data_sizes_attribute' => array( + 'input' => '', + 'expected' => '', + ), + 'expected_with_data_sizes_attribute_already_present' => array( + 'input' => '', + 'expected' => '', + ), + 'not_expected_with_loading_lazy_in_attr_value' => array( + 'input' => '\'This', + 'expected' => '\'This', + ), + 'not_expected_with_data_loading_attribute_present' => array( + 'input' => '', + 'expected' => '', + ), + 'expected_when_attributes_have_spaces_after_them' => array( + 'input' => '', + 'expected' => '', + ), + 'expected_when_attributes_are_upper_case' => array( + 'input' => '', + 'expected' => '', + ), + 'expected_when_loading_lazy_lacks_quotes' => array( + 'input' => '', + 'expected' => '', + ), + 'expected_when_loading_lazy_has_whitespace' => array( + 'input' => '', + 'expected' => '', + ), + 'not_expected_when_sizes_auto_lacks_quotes' => array( + 'input' => '', + 'expected' => '', + ), + ); + } + + /** + * @ticket 61847 + * + * @covers ::wp_img_tag_add_auto_sizes + * + * @dataProvider data_provider_to_test_wp_img_tag_add_auto_sizes + * + * @param string $input The input HTML string. + * @param string $expected The expected output HTML string. + */ + public function test_wp_img_tag_add_auto_sizes( string $input, string $expected ) { + $this->assertSame( + $expected, + wp_img_tag_add_auto_sizes( $input ), + 'Failed asserting that "auto" keyword is correctly added or not added to sizes attribute in the image tag.' + ); + } + /** * Helper method to keep track of the last context returned by the 'wp_get_attachment_image_context' filter. *