Skip to content

Commit

Permalink
Media: Add auto sizes for lazy-loaded images.
Browse files Browse the repository at this point in the history
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
- whatwg/html#8008

Props mukesh27, flixos90, joemcgill, westonruter, peterwilsoncc.
Fixes #61847.


git-svn-id: https://develop.svn.wordpress.org/trunk@59008 602fd350-edb4-49c9-b593-d223f7449a82
  • Loading branch information
joemcgill committed Sep 10, 2024
1 parent fc0d046 commit afeaf7e
Show file tree
Hide file tree
Showing 2 changed files with 344 additions and 1 deletion.
66 changes: 66 additions & 0 deletions src/wp-includes/media.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down
279 changes: 278 additions & 1 deletion tests/phpunit/tests/media.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' );

Expand Down Expand Up @@ -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' );

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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<string, mixed[]> 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<string, mixed>
*/
public function data_provider_to_test_wp_img_tag_add_auto_sizes() {
return array(
'expected_with_single_quoted_attributes' => array(
'input' => "<img src='https://example.com/foo-300x225.jpg' srcset='https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w' sizes='(max-width: 650px) 100vw, 650px' loading='lazy'>",
'expected' => "<img src='https://example.com/foo-300x225.jpg' srcset='https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w' sizes=\"auto, (max-width: 650px) 100vw, 650px\" loading='lazy'>",
),
'expected_with_data_sizes_attribute' => array(
'input' => '<img data-tshirt-sizes="S M L" src="https://example.com/foo-300x225.jpg" srcset="https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w" sizes="(max-width: 650px) 100vw, 650px" loading="lazy">',
'expected' => '<img data-tshirt-sizes="S M L" src="https://example.com/foo-300x225.jpg" srcset="https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w" sizes="auto, (max-width: 650px) 100vw, 650px" loading="lazy">',
),
'expected_with_data_sizes_attribute_already_present' => array(
'input' => '<img data-tshirt-sizes="S M L" src="https://example.com/foo-300x225.jpg" srcset="https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w" sizes="AUTO, (max-width: 650px) 100vw, 650px" loading="lazy">',
'expected' => '<img data-tshirt-sizes="S M L" src="https://example.com/foo-300x225.jpg" srcset="https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w" sizes="AUTO, (max-width: 650px) 100vw, 650px" loading="lazy">',
),
'not_expected_with_loading_lazy_in_attr_value' => array(
'input' => '<img src="https://example.com/foo-300x225.jpg" srcset="https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w" sizes="(max-width: 650px) 100vw, 650px" alt=\'This is the LCP image and it should not get loading="lazy"!\'>',
'expected' => '<img src="https://example.com/foo-300x225.jpg" srcset="https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w" sizes="(max-width: 650px) 100vw, 650px" alt=\'This is the LCP image and it should not get loading="lazy"!\'>',
),
'not_expected_with_data_loading_attribute_present' => array(
'input' => '<img src="https://example.com/foo-300x225.jpg" srcset="https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w" sizes="(max-width: 650px) 100vw, 650px" data-removed-loading="lazy">',
'expected' => '<img src="https://example.com/foo-300x225.jpg" srcset="https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w" sizes="(max-width: 650px) 100vw, 650px" data-removed-loading="lazy">',
),
'expected_when_attributes_have_spaces_after_them' => array(
'input' => '<img src = "https://example.com/foo-300x225.jpg" srcset = "https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w" sizes = "(max-width: 650px) 100vw, 650px" loading = "lazy">',
'expected' => '<img src = "https://example.com/foo-300x225.jpg" srcset = "https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w" sizes="auto, (max-width: 650px) 100vw, 650px" loading = "lazy">',
),
'expected_when_attributes_are_upper_case' => array(
'input' => '<IMG SRC="https://example.com/foo-300x225.jpg" SRCSET="https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w" SIZES="(max-width: 650px) 100vw, 650px" LOADING="LAZY">',
'expected' => '<IMG SRC="https://example.com/foo-300x225.jpg" SRCSET="https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w" sizes="auto, (max-width: 650px) 100vw, 650px" LOADING="LAZY">',
),
'expected_when_loading_lazy_lacks_quotes' => array(
'input' => '<img src="https://example.com/foo-300x225.jpg" srcset="https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w" sizes="(max-width: 650px) 100vw, 650px" loading=lazy>',
'expected' => '<img src="https://example.com/foo-300x225.jpg" srcset="https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w" sizes="auto, (max-width: 650px) 100vw, 650px" loading=lazy>',
),
'expected_when_loading_lazy_has_whitespace' => array(
'input' => '<img src="https://example.com/foo-300x225.jpg" srcset="https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w" sizes="(max-width: 650px) 100vw, 650px" loading=" lazy ">',
'expected' => '<img src="https://example.com/foo-300x225.jpg" srcset="https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w" sizes="auto, (max-width: 650px) 100vw, 650px" loading=" lazy ">',
),
'not_expected_when_sizes_auto_lacks_quotes' => array(
'input' => '<img src="https://example.com/foo-300x225.jpg" srcset="https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w" sizes=auto loading="lazy">',
'expected' => '<img src="https://example.com/foo-300x225.jpg" srcset="https://example.com/foo-300x225.jpg 300w, https://example.com/foo-1024x768.jpg 1024w, https://example.com/foo-768x576.jpg 768w, https://example.com/foo-1536x1152.jpg 1536w, https://example.com/foo-2048x1536.jpg 2048w" sizes=auto loading="lazy">',
),
);
}

/**
* @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.
*
Expand Down

0 comments on commit afeaf7e

Please sign in to comment.