Skip to content

Modern Image Formats: rewrite img tags from wp_get_attachment_image()#2451

Open
adamsilverstein wants to merge 8 commits into
WordPress:trunkfrom
adamsilverstein:issue-523-filter-wp-get-attachment-image
Open

Modern Image Formats: rewrite img tags from wp_get_attachment_image()#2451
adamsilverstein wants to merge 8 commits into
WordPress:trunkfrom
adamsilverstein:issue-523-filter-wp-get-attachment-image

Conversation

@adamsilverstein

@adamsilverstein adamsilverstein commented Apr 14, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #523. The webp-uploads plugin now rewrites <img> tags produced by wp_get_attachment_image() (and, transitively, by the_post_thumbnail() / get_the_post_thumbnail()) to serve modern formats (WebP / AVIF) when those sub-sizes are available.

Until this change, only images that flowed through the_content (via wp_content_img_tag) or featured images (via post_thumbnail_html) were rewritten. Any <img> built directly from a template tag, archive loop, page builder, or custom plugin stayed as its original JPEG. Weston documented the reproduction in #523.

Scope note: this intentionally rewrites every wp_get_attachment_image() caller by default, including output from other plugins. That is a deliberate change of posture from the narrower "don't touch other plugins' output" framing in #2299: serving modern formats everywhere they're available is the goal here. Callers that need the original markup can opt out per-call or wholesale (see below).

Changes

  • New filter callback webp_uploads_filter_wp_get_attachment_image() in plugins/webp-uploads/hooks.php. Hooks into wp_get_attachment_image at priority 10, dispatches to the existing rewriter pipeline (webp_uploads_img_tag_update_mime_type() in default mode, webp_uploads_wrap_image_in_picture() in picture-element mode), and guards with webp_uploads_in_frontend_body() + a new webp_uploads_filter_wp_get_attachment_image opt-out filter.
  • Removed direct post_thumbnail_html registration. the_post_thumbnail() routes through wp_get_attachment_image(), so the new filter covers featured images and the dedicated hook became redundant. webp_uploads_update_featured_image() is kept as a deprecated _deprecated_function() shim so third-party callers don't fatal.
  • Idempotency guard in webp_uploads_wrap_image_in_picture(). Prevents double-wrapping when the same image is processed by more than one rewrite path (e.g. a <picture> from wp_get_attachment_image() embedded in post content, whose inner <img> is then re-extracted by wp_content_img_tag). The inner <img> carries a data-wp-picture-wrapped marker, and the guard detects prior wrapping by parsing the markup with WP_HTML_Tag_Processor (checking the actual tag/attribute) rather than matching raw substrings. Empty <picture> wrappers (no resolvable <source>) are also skipped.
  • Extended the picture-element context whitelist to include the new wp_get_attachment_image context.
  • Intentionally out of scope: wp_get_attachment_image_url(), wp_get_attachment_image_src(), get_the_post_thumbnail_url() — URL-returning functions feed OG tags, RSS, JSON APIs, and other non-HTML consumers where silently substituting a modern format is unsafe.

Opt-out

Two supported paths:

```php
// Surgical, per-call.
add_filter( 'webp_uploads_filter_wp_get_attachment_image', '__return_false' );

// Wholesale.
remove_filter( 'wp_get_attachment_image', 'webp_uploads_filter_wp_get_attachment_image', 10 );
```

Test plan

  • PHPUnit webp-uploads testsuite passes in CI (added ~8 new tests covering default-mode rewrite, picture-mode wrap, idempotency, opt-out filter, `remove_filter` unhook, frontend-body guard, icon placeholder, and double-processing prevention for featured images).
  • Manual: upload a JPEG, set it as a post's featured image, render via `wp_get_attachment_image( get_post_thumbnail_id(), 'large' )` from `wp_body_open` — confirm `src` and `srcset` end in `.webp`.
  • Manual: same via `get_the_post_thumbnail()` — single ``, no duplicated URL fragments (proves double-processing fix).
  • Manual: toggle picture-element mode on — confirm `wp_get_attachment_image()` now returns a `` wrapper with a ``.
  • Manual: `remove_filter( 'wp_get_attachment_image', 'webp_uploads_filter_wp_get_attachment_image', 10 )` — confirm JPEG URL is restored.
  • Manual: confirm `wp_get_attachment_image_url()` still returns the original JPEG URL (OG / RSS consumers unaffected).

Until now the Modern Image Formats plugin only rewrote images that
flowed through `the_content` (via `wp_content_img_tag`) and featured
images (via `post_thumbnail_html`). Any `<img>` built by a direct call
to `wp_get_attachment_image()` — page builders, archive loops, custom
templates, and many plugins — was left as the original JPEG even when
WebP/AVIF sub-sizes were available.

Add a new `wp_get_attachment_image` filter that dispatches to the
existing rewriter pipeline: `webp_uploads_img_tag_update_mime_type()`
in default mode, or `webp_uploads_wrap_image_in_picture()` when picture
element output is enabled. URL-returning functions
(`wp_get_attachment_image_url()`, `wp_get_attachment_image_src()`,
`get_the_post_thumbnail_url()`) are intentionally left untouched, since
their return values feed OG tags, RSS, JSON APIs, and other non-HTML
consumers where silent format substitution is unsafe.

Because `the_post_thumbnail()` routes through `wp_get_attachment_image()`,
the dedicated `post_thumbnail_html` registration is now redundant and
has been removed; `webp_uploads_update_featured_image()` is marked
`@deprecated` but kept in place for any third-party callers.

Also make `webp_uploads_wrap_image_in_picture()` idempotent so that
markup flowing through both `wp_get_attachment_image` and
`wp_content_img_tag` isn't double-wrapped, and extend the context
whitelist to include the new `wp_get_attachment_image` context.

Fixes WordPress#523.
@adamsilverstein adamsilverstein requested a review from b1ink0 as a code owner April 14, 2026 03:21
@github-actions

github-actions Bot commented Apr 14, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: adamsilverstein <adamsilverstein@git.wordpress.org>
Co-authored-by: MarcinDudekDev <myththrazz@git.wordpress.org>
Co-authored-by: westonruter <westonruter@git.wordpress.org>
Co-authored-by: jjgrainger <joegrainger@git.wordpress.org>
Co-authored-by: mxbclang <mxbclang@git.wordpress.org>
Co-authored-by: mukeshpanchal27 <mukesh27@git.wordpress.org>
Co-authored-by: felixarntz <flixos90@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

…tests

Subsequent test fixtures uploading leaves.jpg get renamed to leaves-NN.jpg
when leftover files remain on disk, which made the 'leaves.jpg' substring
assertion fail in CI. Match on '.jpg' instead — combined with the existing
'.webp' negative assertion, the intent (original format preserved) is
still verified.
@codecov

codecov Bot commented Apr 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 69.21%. Comparing base (b1608b2) to head (fd98de4).

Files with missing lines Patch % Lines
plugins/webp-uploads/deprecated.php 75.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##            trunk    #2451      +/-   ##
==========================================
+ Coverage   69.14%   69.21%   +0.06%     
==========================================
  Files          90       90              
  Lines        7723     7743      +20     
==========================================
+ Hits         5340     5359      +19     
- Misses       2383     2384       +1     
Flag Coverage Δ
multisite 69.21% <96.00%> (+0.06%) ⬆️
single 35.60% <64.00%> (+0.10%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@adamsilverstein adamsilverstein added [Type] Enhancement A suggestion for improvement of an existing feature [Plugin] Modern Image Formats Issues for the Modern Image Formats plugin (formerly WebP Uploads) labels Apr 14, 2026
@adamsilverstein adamsilverstein self-assigned this Apr 14, 2026
Featured images are now rewritten through the wp_get_attachment_image
filter (via webp_uploads_filter_wp_get_attachment_image()), making this
function obsolete. Document the removal as a breaking change in the
changelog for third-party callers, who should switch to
webp_uploads_img_tag_update_mime_type() or webp_uploads_wrap_image_in_picture()
directly, or rely on the new wp_get_attachment_image filter.
@adamsilverstein adamsilverstein added this to the webp-uploads n.e.x.t milestone Apr 14, 2026
@MarcinDudekDev

Copy link
Copy Markdown

Took a proper look at this and ran it on a real WooCommerce site (around 500 products, picture mode + AVIF). Good direction, the wp_get_attachment_image() gap is worth closing. Found three things though, two of them I'd treat as blockers.

webp_uploads_update_featured_image() gets deleted, not deprecated. The PR description says @deprecated, but commit 785e945 removes the function outright. It's public and @since 1.0.0, so any third-party code calling it will fatal. I'd keep it as a thin _deprecated_function() shim for a release or two.

Nested <picture>. This one produces invalid HTML. When a <picture> from wp_get_attachment_image() ends up in post content, wp_filter_content_tags() pulls the inner <img> back out and runs it through wp_content_img_tag again, so webp_uploads_wrap_image_in_picture() wraps it a second time. The stripos('<picture') guard never catches it because it only ever sees the bare <img>. I reproduced it on the live site - any in-content wp_get_attachment_image() call with a wp-image-{ID} class double-wraps. What worked for me: tag the inner <img> with data-wp-picture-wrapped and bail on that marker.

Empty <picture> wrappers. Attachments with no modern sub-size still get wrapped - <picture><img></picture> with zero <source>. Small thing, but worth bailing when there's no source to add.

One more thing, not a bug - the filter rewrites every wp_get_attachment_image() caller by default, which kind of reverses the "let's not touch other plugins' output" point from #2299. Might be worth saying explicitly in the description that this is intentional now.

I put fixes for all three on a branch, with tests. 148/148 green, and the double-wrap regression test fails without the fix so it actually catches it. Also validated end to end on the demo site. Branch: https://github.com/MarcinDudekDev/performance/tree/fix/2451-review-followups - happy to open it as a PR against this branch if that's easier.

AI assistance: Yes
Tool(s): Claude Code (Anthropic)
Model(s): Claude Opus 4.7
Used for: reviewing this PR, drafting the fix branch and tests. I reviewed and tested all of it myself.

@adamsilverstein

Copy link
Copy Markdown
Member Author

Took a proper look at this and ran it on a real WooCommerce site (around 500 products, picture mode + AVIF). Good direction, the wp_get_attachment_image() gap is worth closing. Found three things though, two of them I'd treat as blockers.

@MarcinDudekDev Thanks for reviewing and testing. I will bring in your changes, thanks for providing those.

MarcinDudekDev and others added 3 commits May 15, 2026 09:34
…ted shim

PR WordPress#2451 removed the public function webp_uploads_update_featured_image()
outright. It was hooked on `post_thumbnail_html` and is documented `@since
1.0.0`, so deleting it without a deprecation cycle fatal-errors any
third-party code that still calls it directly.

Restore it in deprecated.php as a thin wrapper that emits a deprecation
notice via _deprecated_function() and delegates to the current pipeline
(webp_uploads_wrap_image_in_picture() / webp_uploads_img_tag_update_mime_type()).
It is no longer registered as a `post_thumbnail_html` filter -- featured
images are handled by the new `wp_get_attachment_image` filter -- so this
restores backward compatibility without double-processing.

readme.txt: move the entry from "Breaking Changes" to "Deprecated".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two robustness fixes to webp_uploads_wrap_image_in_picture().

Nested <picture> (invalid HTML)
------------------------------
When a <picture> produced for a wp_get_attachment_image() call is embedded
in post content, wp_filter_content_tags() extracts the inner <img> and runs
it back through this function via `wp_content_img_tag`. The existing
`stripos( $image, '<picture' )` guard never sees the wrapper, because only
the bare inner <img> substring is passed -- so the image is wrapped twice,
producing nested <picture><picture>...</picture></picture>.

The inner <img> is now tagged with a `data-wp-picture-wrapped` attribute,
and the idempotency guard bails when that marker is present. Context
detection (doing_filter('the_content')) was considered but rejected: it
would skip page-builder images that render during `the_content`. The marker
is documented in the readme changelog.

Empty <picture> wrapper
-----------------------
When no modern-format source resolves for an attachment, the function still
emitted `<picture><img></picture>` with no <source> children. It now returns
the original <img> when $picture_sources is empty.

Adds three tests: a regression test for the in-content double-wrap (embed in
content, run `the_content`, assert exactly one <picture>), an empty-wrapper
test, and a test for the deprecated webp_uploads_update_featured_image() shim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…get-attachment-image

# Conflicts:
#	plugins/webp-uploads/tests/test-load.php

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.

Comment thread plugins/webp-uploads/hooks.php Outdated
Comment on lines +50 to +51
false !== stripos( $image, '<picture' ) ||
false !== stripos( $image, 'data-wp-picture-wrapped' )

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.

Is this safe? What if the markup has data-wp-picture-wrapped appearing elsewhere in the string? For example, <img src="foo.jpg" alt="I love the data-wp-picture-wrapped attribute!">. Granted, this is unlikely.

To be more robust, this should use WP_HTML_Tag_Processor to figure out whether it has been previously processed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good idea!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in fd98de4. The guard now parses the markup with WP_HTML_Tag_Processor and checks the actual IMG tag's data-wp-picture-wrapped attribute (and bails on a PICTURE tag), so a literal data-wp-picture-wrapped or <picture string appearing inside an attribute value like alt text can no longer cause a false positive.

adamsilverstein and others added 2 commits June 13, 2026 20:14
Co-authored-by: Weston Ruter <westonruter@gmail.com>
…match

The idempotency guard in webp_uploads_wrap_image_in_picture() matched the
raw `<picture` and `data-wp-picture-wrapped` strings anywhere in the markup,
so a literal occurrence inside an attribute value (such as alt text) could
falsely flag an image as already wrapped and skip the rewrite. Parse the
markup with WP_HTML_Tag_Processor instead and check the actual tag name and
the inner img's data-wp-picture-wrapped attribute.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Plugin] Modern Image Formats Issues for the Modern Image Formats plugin (formerly WebP Uploads) [Type] Enhancement A suggestion for improvement of an existing feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consider also replacing JPEG images with WebP images in img tags controlled by code

3 participants