Skip to content
Open
22 changes: 22 additions & 0 deletions plugins/webp-uploads/deprecated.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,25 @@ function webp_uploads_media_setting_style(): void {
</style>
<?php
}

/**
* Updates the references of the featured image to the new image format if available.
*
* @since 1.0.0
* @deprecated 2.7.0 Featured images are now rewritten through the `wp_get_attachment_image`
* filter; see webp_uploads_filter_wp_get_attachment_image().
*
* @param string $html The current HTML markup of the featured image.
* @param int $post_id The current post ID where the featured image is requested.
* @param int $attachment_id The ID of the attachment image.
* @return string The updated HTML markup.
*/
function webp_uploads_update_featured_image( string $html, int $post_id, int $attachment_id ): string {
_deprecated_function( __FUNCTION__, 'Modern Image Formats 2.7.0', 'webp_uploads_filter_wp_get_attachment_image()' );

if ( webp_uploads_is_picture_element_enabled() ) {
return webp_uploads_wrap_image_in_picture( $html, 'post_thumbnail_html', $attachment_id );
}

return webp_uploads_img_tag_update_mime_type( $html, 'post_thumbnail_html', $attachment_id );
}
73 changes: 54 additions & 19 deletions plugins/webp-uploads/hooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,55 @@ function webp_uploads_filter_image_tag( string $filtered_image, string $context,
return $filtered_image;
}

/**
* Filters `wp_get_attachment_image` so `<img>` tags produced outside of post
* content (archive templates, page builders, custom loops, featured-image
* template calls) also use the preferred MIME type where available.
*
* Only rewrites the HTML `<img>` (or `<picture>` wrapper in picture-element
* mode). Sibling URL-returning functions such as `wp_get_attachment_image_url()`
* and `wp_get_attachment_image_src()` are intentionally left untouched, since
* their return values feed non-HTML contexts (OG tags, RSS, JSON) where
* silently substituting a modern format is unsafe.
*
* @since 2.7.0
*
* @param string $html HTML img element or empty string on failure.
* @param int $attachment_id Image attachment ID.
* @param string|int[] $size Requested image size.
* @param bool $icon Whether the image should fall back to a mime type icon.
* @param string[] $attr Array of attribute values for the image markup, keyed by attribute name.
* @return string The filtered HTML.
*/
function webp_uploads_filter_wp_get_attachment_image( string $html, int $attachment_id, $size, bool $icon, array $attr ): string {
if ( '' === $html || 0 === $attachment_id || true === $icon || ! webp_uploads_in_frontend_body() ) {
return $html;
}

/**
* Filters whether the Modern Image Formats plugin should rewrite an image returned by `wp_get_attachment_image()`.
*
* Returning false short-circuits the rewrite and preserves the original HTML. This gives
* integrators a surgical per-call opt-out in addition to `remove_filter()`.
*
* @since 2.7.0
*
* @param bool $should_filter Whether to apply modern-format rewriting. Default true.
* @param int $attachment_id Image attachment ID.
* @param string|int[] $size Requested image size.
* @param string[] $attr Attribute array passed to `wp_get_attachment_image()`.
*/
if ( ! apply_filters( 'webp_uploads_filter_wp_get_attachment_image', true, $attachment_id, $size, $attr ) ) {
return $html;
}

if ( webp_uploads_is_picture_element_enabled() ) {
return webp_uploads_wrap_image_in_picture( $html, 'wp_get_attachment_image', $attachment_id );
}

return webp_uploads_img_tag_update_mime_type( $html, 'wp_get_attachment_image', $attachment_id );
}

/**
* Finds all the urls with *.jpg and *.jpeg extension and updates with *.webp version for the provided image
* for the specified image sizes, the *.webp references are stored inside of each size.
Expand Down Expand Up @@ -663,25 +712,6 @@ function webp_uploads_img_tag_update_mime_type( string $original_image, string $
return $image;
}

/**
* Updates the references of the featured image to the new image format if available, in the same way it
* occurs in the_content of a post.
*
* @since 1.0.0
*
* @param string $html The current HTML markup of the featured image.
* @param int $post_id The current post ID where the featured image is requested.
* @param int $attachment_id The ID of the attachment image.
* @return string The updated HTML markup.
*/
function webp_uploads_update_featured_image( string $html, int $post_id, int $attachment_id ): string {
if ( webp_uploads_is_picture_element_enabled() ) {
return webp_uploads_wrap_image_in_picture( $html, 'post_thumbnail_html', $attachment_id );
}
return webp_uploads_img_tag_update_mime_type( $html, 'post_thumbnail_html', $attachment_id );
}
add_filter( 'post_thumbnail_html', 'webp_uploads_update_featured_image', 10, 3 );

/**
* Returns an array of image size names that have secondary mime type output enabled. Core sizes and
* core theme sizes are enabled by default.
Expand Down Expand Up @@ -854,6 +884,11 @@ function webp_uploads_init(): void {
// Filter regular image tags.
add_filter( 'wp_content_img_tag', webp_uploads_is_picture_element_enabled() ? 'webp_uploads_wrap_image_in_picture' : 'webp_uploads_filter_image_tag', 10, 3 );

// Filter `<img>` tags produced by template tags, page builders, and any other code path that calls
// `wp_get_attachment_image()` directly. `the_post_thumbnail()` also routes through this, so it covers
// featured images previously handled by a dedicated `post_thumbnail_html` filter.
add_filter( 'wp_get_attachment_image', 'webp_uploads_filter_wp_get_attachment_image', 10, 5 );

// Filter blocks that may contain background images.
add_filter( 'render_block_core/cover', 'webp_uploads_filter_block_background_images', 10, 2 );
add_filter( 'render_block_core/group', 'webp_uploads_filter_block_background_images', 10, 2 );
Expand Down
48 changes: 47 additions & 1 deletion plugins/webp-uploads/picture-element.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,40 @@
* @return string The new image tag.
*/
function webp_uploads_wrap_image_in_picture( string $image, string $context, int $attachment_id ): string {
if ( ! in_array( $context, array( 'the_content', 'post_thumbnail_html', 'widget_block_content' ), true ) ) {
if ( ! in_array( $context, array( 'the_content', 'post_thumbnail_html', 'widget_block_content', 'wp_get_attachment_image' ), true ) ) {
return $image;
}

/*
* Idempotency: bail if this markup has already been processed, to avoid
* double-wrapping when more than one rewrite path fires on the same image.
*
* Two distinct cases are guarded:
*
* 1. The full `<picture>` string is passed in again.
* 2. Only the inner `<img>` is passed in again. This happens when a
* `<picture>` produced for a `wp_get_attachment_image()` call is embedded
* in post content: `wp_filter_content_tags()` then extracts that inner
* `<img>` and runs it back through this function via `wp_content_img_tag`.
* The surrounding `<picture>` is not visible at that point, so the wrapped
* `<img>` carries a `data-wp-picture-wrapped` marker (added below) to be
* recognised here.
*
* The markup is parsed with WP_HTML_Tag_Processor rather than matched as a
* raw substring, so a literal `<picture` or `data-wp-picture-wrapped` string
* appearing inside an attribute value (such as `alt` text) cannot trigger a
* false positive.
*/
$processor = new WP_HTML_Tag_Processor( $image );
while ( $processor->next_tag() ) {
if ( 'PICTURE' === $processor->get_tag() ) {
return $image;
}
if ( 'IMG' === $processor->get_tag() && null !== $processor->get_attribute( 'data-wp-picture-wrapped' ) ) {
return $image;
}
}

$original_file_mime_type = webp_uploads_get_attachment_file_mime_type( $attachment_id );
if ( '' === $original_file_mime_type ) {
return $image;
Expand Down Expand Up @@ -171,6 +201,22 @@ function webp_uploads_wrap_image_in_picture( string $image, string $context, int
}
}

// Never emit a `<picture>` with no `<source>` children: if every modern-format
// source failed to resolve (e.g. the attachment has no modern sub-sizes), return
// the original markup untouched instead of a pointless empty wrapper element.
if ( '' === $picture_sources ) {
return $image;
}

// Tag the inner `<img>` so a later rewrite pass (for example `wp_content_img_tag`
// once this markup is embedded in post content) recognises it as already wrapped
// and skips it. See the idempotency guard above.
$marker = new WP_HTML_Tag_Processor( $image );
if ( $marker->next_tag( array( 'tag_name' => 'IMG' ) ) ) {
$marker->set_attribute( 'data-wp-picture-wrapped', 'true' );
$image = $marker->get_updated_html();
}

return sprintf(
'<picture class="%s" style="display: contents;">%s%s</picture>',
esc_attr( 'wp-picture-' . $attachment_id ),
Expand Down
10 changes: 10 additions & 0 deletions plugins/webp-uploads/readme.txt

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typically we leave this to be update during the release, right? It gets populated automatically.

Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ By default, the Modern Image Formats plugin will only generate WebP versions of

== Changelog ==

= n.e.x.t =

**Enhancements**

* Serve modern image formats (WebP / AVIF) from `wp_get_attachment_image()` so that `<img>` tags produced by template tags, page builders, and custom loops use the preferred format — not only images inside `the_content`. Featured images continue to be rewritten through the same pipeline (via `the_post_thumbnail()` routing through `wp_get_attachment_image()`), so the dedicated `post_thumbnail_html` filter registration has been retired. In picture-element mode the inner `<img>` of a generated `<picture>` carries a `data-wp-picture-wrapped` attribute, which keeps the rewrite idempotent if the same markup is reprocessed later (for example by `wp_content_img_tag` once it is embedded in post content). ([523](https://github.com/WordPress/performance/issues/523))

**Deprecated**

* The `webp_uploads_update_featured_image()` function is deprecated. It was previously hooked on `post_thumbnail_html` to rewrite featured images; that responsibility now lives in the `wp_get_attachment_image` filter via `webp_uploads_filter_wp_get_attachment_image()`. The function still works as a thin wrapper and emits a deprecation notice. Third-party code that called it directly should switch to `webp_uploads_img_tag_update_mime_type()` or `webp_uploads_wrap_image_in_picture()`, or rely on the new `wp_get_attachment_image` filter.

= 2.6.1 =

**Bug Fixes**
Expand Down
98 changes: 93 additions & 5 deletions plugins/webp-uploads/tests/test-load.php
Original file line number Diff line number Diff line change
Expand Up @@ -1286,19 +1286,26 @@ static function () {
}

/**
* Test that the webp_uploads_update_featured_image function is hooked to the post_thumbnail_html filter.
* Featured images are now rewritten through the `wp_get_attachment_image`
* filter (since `the_post_thumbnail()` routes through `wp_get_attachment_image()`),
* so the direct `post_thumbnail_html` registration should no longer exist.
*
* @covers ::webp_uploads_init
*/
public function test_webp_uploads_update_featured_image_hooked_into_post_thumbnail_html(): void {
$this->assertSame( 10, has_filter( 'post_thumbnail_html', 'webp_uploads_update_featured_image' ) );
public function test_post_thumbnail_html_filter_is_not_registered_directly(): void {
$this->assertFalse( has_filter( 'post_thumbnail_html' ) );
$this->assertSame( 10, has_filter( 'wp_get_attachment_image', 'webp_uploads_filter_wp_get_attachment_image' ) );
}

/**
* Test that the featured image is not wrapped in a picture element.
*
* @covers ::webp_uploads_update_featured_image
* @covers ::webp_uploads_filter_wp_get_attachment_image
* @covers ::webp_uploads_img_tag_update_mime_type
*/
public function test_webp_uploads_update_featured_image_picture_element_disabled(): void {
$this->mock_frontend_body_hooks();

$attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/car.jpeg' );
$post_id = self::factory()->post->create();
set_post_thumbnail( $post_id, $attachment_id );
Expand All @@ -1310,12 +1317,13 @@ public function test_webp_uploads_update_featured_image_picture_element_disabled
/**
* Test that the featured image is wrapped in a picture element.
*
* @covers ::webp_uploads_update_featured_image
* @covers ::webp_uploads_filter_wp_get_attachment_image
* @covers ::webp_uploads_wrap_image_in_picture
*/
public function test_webp_uploads_update_featured_image_picture_element_enabled(): void {
update_option( 'perflab_generate_webp_and_jpeg', '1' );
$this->opt_in_to_picture_element();
$this->mock_frontend_body_hooks();

$attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/car.jpeg' );
$post_id = self::factory()->post->create();
Expand All @@ -1325,6 +1333,86 @@ public function test_webp_uploads_update_featured_image_picture_element_enabled(
$this->assertStringStartsWith( '<picture ', $featured_image );
}

/**
* @covers ::webp_uploads_filter_wp_get_attachment_image
*/
public function test_wp_get_attachment_image_is_rewritten_to_webp(): void {
$this->opt_in_to_jpeg_and_webp();
$this->mock_frontend_body_hooks();

$attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/leaves.jpg' );

$html = wp_get_attachment_image( $attachment_id, 'medium', false, array( 'class' => "wp-image-{$attachment_id}" ) );

$this->assertStringContainsString( '.webp', $html );
$this->assertStringNotContainsString( 'leaves.jpg', $html );

$processor = new WP_HTML_Tag_Processor( $html );
$this->assertTrue( $processor->next_tag( array( 'tag_name' => 'IMG' ) ) );
$this->assertFalse( $processor->next_tag( array( 'tag_name' => 'IMG' ) ), 'Only one IMG tag should be present.' );
}

/**
* @covers ::webp_uploads_filter_wp_get_attachment_image
*/
public function test_wp_get_attachment_image_opt_out_filter_returns_original_html(): void {
$this->opt_in_to_jpeg_and_webp();
$this->mock_frontend_body_hooks();
add_filter( 'webp_uploads_filter_wp_get_attachment_image', '__return_false' );

$attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/leaves.jpg' );

$html = wp_get_attachment_image( $attachment_id, 'medium', false, array( 'class' => "wp-image-{$attachment_id}" ) );

$this->assertStringContainsString( '.jpg', $html );
$this->assertStringNotContainsString( '.webp', $html );
}

/**
* @covers ::webp_uploads_filter_wp_get_attachment_image
*/
public function test_wp_get_attachment_image_unhook_restores_original_html(): void {
$this->opt_in_to_jpeg_and_webp();
$this->mock_frontend_body_hooks();
remove_filter( 'wp_get_attachment_image', 'webp_uploads_filter_wp_get_attachment_image', 10 );

$attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/leaves.jpg' );

$html = wp_get_attachment_image( $attachment_id, 'medium', false, array( 'class' => "wp-image-{$attachment_id}" ) );

$this->assertStringContainsString( '.jpg', $html );
$this->assertStringNotContainsString( '.webp', $html );
}

/**
* @covers ::webp_uploads_filter_wp_get_attachment_image
*/
public function test_wp_get_attachment_image_bails_outside_frontend_body(): void {
$this->opt_in_to_jpeg_and_webp();
// Intentionally do NOT call mock_frontend_body_hooks().

$attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/leaves.jpg' );

$html = wp_get_attachment_image( $attachment_id, 'medium', false, array( 'class' => "wp-image-{$attachment_id}" ) );

$this->assertStringContainsString( '.jpg', $html );
$this->assertStringNotContainsString( '.webp', $html );
}

/**
* @covers ::webp_uploads_filter_wp_get_attachment_image
*/
public function test_wp_get_attachment_image_bails_when_icon_placeholder_requested(): void {
$this->mock_frontend_body_hooks();

// Directly exercise the filter callback with $icon = true; expect unchanged HTML.
$html = '<img src="https://example.test/wp-content/uploads/2024/01/something.jpg">';
$this->assertSame(
$html,
webp_uploads_filter_wp_get_attachment_image( $html, 0, 'medium', true, array() )
);
}

/**
* Check if AVIF encoding is supported.
*
Expand Down
Loading
Loading