Skip to content

Media: Use imageflip() in WP_Image_Editor_GD::flip(). - #13920

Open
sanjithsarkar wants to merge 9 commits into
WordPress:trunkfrom
sanjithsarkar:fix/66113-gd-imageflip
Open

sanjithsarkar wants to merge 9 commits into
WordPress:trunkfrom
sanjithsarkar:fix/66113-gd-imageflip

Conversation

@sanjithsarkar

@sanjithsarkar sanjithsarkar commented Oct 2, 2026 •

Copy link
Copy Markdown

Summary

WP_Image_Editor_GD::flip() mirrored images by passing negative source dimensions to imagecopyresampled(). With PHP 8.6's bundled GD 2.4.0 that produces a black image, so Tests_Image_Editor_GD::test_flip fails (Failed asserting that 0 is identical to 8434407). It now calls imageflip(), which flips in place and does not depend on how GD handles negative sizes. _flip_image_resource(), the deprecated GD-resource version, gets the same change.

The switch keeps the previous output. The old code always copied into a new truecolor image with alphablending=false, savealpha=true, GD's default resolution of 96 DPI and interlacing off, and both functions now produce the same result. Without the truecolor conversion, a flipped GIF or 8-bit PNG saved or streamed as WebP becomes an empty file, because imagewebp() rejects palette images.

The most common way to hit this bug is uploading a JPEG whose EXIF orientation is 2, 3 or 4. WP_Image_Editor::maybe_exif_rotate() calls flip() for those, so on PHP 8.6 the uploaded image comes out black.

Trac ticket: https://core.trac.wordpress.org/ticket/66113

What Changed

File Change
src/wp-includes/class-wp-image-editor-gd.php:441 flip(): replaces wp_imagecreatetruecolor() + imagecopyresampled() with negative $sw/$sh with imageflip(). Returns WP_Error if $this->image is not a GD image. Converts palette images to truecolor, resets the alpha settings, and resets the resolution to 96 DPI with interlacing off before flipping, including for flip( false, false ).
src/wp-admin/includes/image-edit.php:578 _flip_image_resource(): the same changes. Returns $img unchanged if it is not a GD image.
tests/phpunit/tests/image/editorGd.php New flip tests, listed below.
tests/phpunit/tests/image/functions.php Pixel, palette, alpha-saving, and resolution and interlace tests for _flip_image_resource(), skipped when GD is not available.

New tests:

Test Checks
test_flip_moves_every_pixel() A 3×2 image with a different color per pixel. For all 4 flag combinations, every pixel moves to its mirrored position.
test_flip_returns_error_for_invalid_image() flip() returns a WP_Error with the code image_flip_error when no valid image is loaded.
test_maybe_exif_rotate_flips_image() EXIF orientations 2, 3 and 4 through maybe_exif_rotate(), using test-image-upside-down.jpg. The size is unchanged and sampled pixels are mirrored.
test_flip_converts_palette_image_to_truecolor() A palette GIF becomes truecolor, and every pixel keeps its color at its mirrored position, for all 4 flag combinations.
test_flipped_palette_image_can_be_saved_as_webp() A flipped palette GIF saved as WebP is a valid, non-empty WebP file.
test_flip_keeps_alpha_channel() Pixels with different alpha values keep their alpha in memory and after saving as PNG.
test_flip_both_axes_matches_rotate_180() flip( true, true ) matches rotate( 180 ) pixel for pixel on canola.jpg.
test_flip_disables_alpha_blending_after_rotate() rotate() turns alpha blending on, and a later flip() turns it off again, as the old code did. Covers all 4 flag combinations, including no flip.
test_flip_resets_resolution_and_interlace() An image set to 300×150 DPI with interlacing on comes back at 96×96 DPI with interlacing off, for all 4 flag combinations, including no flip.
test_flip_image_resource() The same pixel check for _flip_image_resource().
test_flip_image_resource_converts_palette_image_to_truecolor() A 3×2 palette image from imagecreate() with a different color per pixel becomes truecolor, and every pixel keeps its color at its mirrored position, for all 4 flag combinations.
test_flip_image_resource_enables_alpha_saving() An image with alpha saving disabled keeps its alpha when the result is saved as PNG, for all 4 flag combinations, including no flip.
test_flip_image_resource_resets_resolution_and_interlace() The same resolution and interlace check for _flip_image_resource().

Testing

Automated, with vendor/bin/phpunit --filter 'Tests_Image_Editor_GD|test_flip_image_resource'. To show that the tests catch the bug, I also ran them against trunk's old flip() and _flip_image_resource().

Code under test PHP 8.3 / bundled GD 2.1 PHP 8.6.0RC4 / bundled GD 2.4.0
trunk (old implementation) 4 errors: the invalid-image test, where trunk throws a TypeError 23 failures + 4 errors
this PR 72 tests: 71 pass, 1 skipped (an existing trunk test that requires PHP 8.6) 72/72 pass

On PHP 8.3, trunk passes every pixel, palette, alpha, EXIF and rotate test, so the output does not change on GD versions that were already working. The resolution and interlace tests pass on trunk with both PHP versions, because the old code always returned a new image with GD's defaults; they fail on this PR without the reset. On PHP 8.6, trunk fails the pixel, EXIF, palette, alpha, rotate-180 and _flip_image_resource() tests.

  • PHPUnit on PHP 8.3 and PHP 8.6.0RC4: results above.
  • Full vendor/bin/phpunit --group image on PHP 8.3: 358 tests, no failures, 12 skipped.
  • vendor/bin/phpcs on the four changed files: clean.
  • vendor/bin/phpstan analyse on the two changed source files: no errors.
  • Manual: in Media > Library, edit canola.jpg with "Flip vertical", save, then "Flip horizontal", and save. Each saved file matches the original mirrored on the expected axes, pixel for pixel within JPEG noise, and is not black.
  • Manual: upload tests/phpunit/data/images/test-image-upside-down.jpg (EXIF orientation 3). It comes out upright as test-image-upside-down-rotated-1.jpg, not black.
  • Manual: in Edit Image, flip tests/phpunit/data/images/test-image.gif and save it with an image_editor_output_format filter that maps GIF to WebP. The saved file is a valid, flipped 50×50 WebP (458 bytes), not empty.

Manual tests ran in the local Docker environment on PHP 8.6.0RC4 with bundled GD 2.4.0. The Docker image has Imagick installed, so a local mu-plugin filtered wp_image_editors to use only WP_Image_Editor_GD.

Two existing trunk bugs showed up during the GIF-to-WebP test. This PR does not change either one:

  • The output-format filter has to be limited to the Edit Image save. On upload, the GIF is converted to WebP before any flip runs, and imagewebp() writes an empty file for the palette image (see Possible Follow-up).
  • After the save, the attachment points to test-image-e….gif, but the file on disk is test-image-e….webp, so the image does not display. wp_save_image() stores $new_path (src/wp-admin/includes/image-edit.php:1075-1077) rather than the path the editor actually saved to. This happens on trunk too, whenever the output format changes the extension.

To reproduce on PHP 8.6: LOCAL_PHP=8.6-fpm npm run env:start, then npm run test:php -- --filter 'Tests_Image_Editor_GD|test_flip_image_resource'.

Implementation Notes

  • Axis mapping: $horz flips along the horizontal axis, which swaps top and bottom, so it maps to IMG_FLIP_VERTICAL. $vert maps to IMG_FLIP_HORIZONTAL, and both map to IMG_FLIP_BOTH. This matches the old $sy/$sx logic and WP_Image_Editor_Imagick::flip(). The pixel tests fail if the axes are swapped.
  • Palette images become truecolor, as before. imageflip() alone would keep palette images as palette images. That breaks saving or streaming them as WebP, since imagewebp() warns Palette image not supported by webp, still returns true, and writes a 0-byte file. imagepalettetotruecolor() runs before the flip, and also when no axis is flipped, because the old code converted in every case.
  • Alpha settings match the old output. The old code always returned an image with alphablending=false and savealpha=true. imageflip() keeps the image's current settings, which are alphablending=true after rotate(). Both settings are now reset right after the truecolor conversion, before the no-axis return, so flip( false, false ) matches the old output too.
  • Resolution and interlace match the old output. A new GD image starts at 96 DPI with interlacing off, and nothing in WP_Image_Editor_GD copies the source's values over, so the old flip always reset them, as rotate(), crop() and resize() still do. imageflip() keeps the source's values, so both functions call imageresolution( 96, 96 ) and imageinterlace( 0 ), also before the no-axis return. 0 is passed instead of false because PHPStan checks against PHP 7.4, where the parameter is typed as int; it behaves the same on PHP 8. Keeping the source's values would arguably be more correct, but that should be done for all operations at once.
  • Validation comes first in flip(): The is_gd_image() check is placed before the "no axes" early return. Otherwise flip( false, false ) with no image loaded would return true, while every other path returns WP_Error.
  • Error handling: image_flip_error used to cover a failed wp_imagecreatetruecolor(), for example when memory runs out. imageflip() only returns false for an invalid mode, which this code cannot pass. The WP_Error is kept for a false return, and for a failed palette conversion.
  • _flip_image_resource() changes nothing on failure: It cannot return WP_Error, because its only core caller (image_edit_apply_changes(), src/wp-admin/includes/image-edit.php:759) assigns the result to $image and keeps using it. Returning the input unchanged matches the old failure behavior and the "(maybe) flipped" wording in its docblock.
  • No function_exists( 'imageflip' ) guard: imageflip() has been in PHP since 5.5, with no conditional compilation, and every libgd version PHP 7.4+ accepts includes it.
  • Memory: the flip no longer allocates a second full-size image. For a 6000×4000 photo that is about 96 MB less peak memory.

Remaining Behavior Differences

  • The flip happens in place. In WP_Image_Editor_GD, $this->image is protected and nothing outside the class holds the old object, so this has no effect there. _flip_image_resource( $img, … ) now also changes the caller's $img. Before, it was left untouched on PHP 8 and destroyed on PHP < 8. The function has been deprecated since 3.5.0, and its one core caller reassigns the return value.
  • Exact pixels for fully transparent areas. The old resampling changed the hidden RGB values of fully transparent pixels. imageflip() keeps them, so saved files can differ by a few bytes. Nothing visible changes.
  • The transparent color is kept. The old code always returned a new image, so imagecolortransparent() came back as -1. The in-place flip keeps the source's transparent color, including for flip( false, false ). Pixels in memory and saved PNG and WebP files are identical to the old output. The one visible difference is a truecolor image with a transparent color set, such as an RGB PNG with a tRNS chunk, that is flipped and then saved as GIF: pixels matching that color now come out transparent, where the old code left them opaque. Core only saves a PNG as GIF if an image_editor_output_format filter maps it that way. Palette GIFs and PNGs decode to the same pixels as before, because after the truecolor conversion their transparent color matches no pixel. Resetting it is not straightforward, since imagecolortransparent( $img, -1 ) does not clear it on truecolor images with PHP 8.3's bundled GD.

Possible Follow-up

Keeping palette images as palette images after a flip would make saved 8-bit PNGs smaller. On the png-tests fixtures, abhishekfdd measured the truecolor round trip making them 1.5× to 2.7× larger (Core-66113, comment 8). That is existing trunk behavior. Doing that safely needs a palette-to-truecolor conversion in the WebP and AVIF branches of WP_Image_Editor_GD::_save() and stream() instead. That change would also fix an existing bug: a palette PNG saved as WebP without any flip already produces an empty file on trunk. I left it out to keep this fix small this close to the 7.2 RC.

Other Notes

Use of AI Tools

  • AI assistance: Yes
  • Tool(s): Claude Code
  • Model(s): Claude Opus 5.5
  • Used for: Investigating the review feedback on this PR and Core-66113, implementing the palette, alpha, and resolution and interlace changes, writing the tests, running them on PHP 8.3 and PHP 8.6 against both trunk and this PR, and drafting this description. The automated test results above come from those runs. I ran the manual browser tests myself on PHP 8.6.0RC4. Claude Code checked the saved files on disk (pixel comparison and file headers).

Replaces the imagecopyresampled() call that used negative source dimensions with imageflip(). The flip is now done in place, so no second full-size image is allocated, and the result no longer depends on how the GD build handles negative dimensions.

`flip()` now returns a `WP_Error` for an invalid GD image, even when no flip is requested. `_flip_image_resource()` gets the same change and still returns its input unchanged when nothing can be flipped.

Adds tests that check every pixel position for each flip mode, and a test for the invalid-image error path.

Props ...
Fixes #66113.
@github-actions

github-actions Bot commented Oct 2, 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.

Core Committers: Use this line as a base for the props when committing in SVN:

Props sanjith2023, desrosj, arshidkv12, dhruvang21, algorithmsunlocks.

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


Props Bot commands

You can trigger Props Bot actions by commenting on this PR:

  • @propsbot add <username> to give props to a GitHub user. Requires write access.
  • @propsbot remove <username> to leave a user out of the props. Requires write access.
  • @propsbot refresh to refresh this list. Adding the props-bot label works too.

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

Upgrade Develop Version Tests failed on all jobs at "Download WordPress" with `wp: command not found`, a transient wp-cli install failure unrelated to this change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@desrosj desrosj added the props-bot Adding this label triggers the Props Bot workflow for a PR. label Nov 3, 2026
@github-actions github-actions Bot removed the props-bot Adding this label triggers the Props Bot workflow for a PR. label Nov 3, 2026
@desrosj

desrosj commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

@propsbot add arshidkv12 dhruvang21

Adding contributors from the initial PR (#13549).

@desrosj desrosj added the props-bot Adding this label triggers the Props Bot workflow for a PR. label Nov 3, 2026
@github-actions github-actions Bot removed the props-bot Adding this label triggers the Props Bot workflow for a PR. label Nov 3, 2026
@desrosj

desrosj commented Nov 3, 2026

Copy link
Copy Markdown
Member

@propsbot add arshidkv12

@desrosj

desrosj commented Nov 3, 2026

Copy link
Copy Markdown
Member

@propsbot add dhruvang21

@desrosj

desrosj commented Nov 3, 2026

Copy link
Copy Markdown
Member

@propsbot add RumanCodes

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The changes remove palette-to-truecolor conversion from both flip paths, risking empty WebP output, and the new tests fail when GD is unavailable.

3 open issues
What changed in this PR

This PR replaces negative-dimension image copying with in-place imageflip() calls in WordPress’s GD image editor and deprecated resource helper, reducing bitmap allocation and avoiding GD-dependent mirroring behavior.

Changes:

  • Preserve axis mapping while switching both flip implementations to imageflip().
  • Add explicit no-op handling and invalid-image validation.
  • Add pixel-level tests for all four flag combinations and editor error handling.
File Description
tests/​phpunit/​tests/​image/​functions.php Adds pixel-level tests for the deprecated flip helper.
tests/​phpunit/​tests/​image/​editorGd.php Tests flip modes and invalid-image handling.
src/​wp-includes/​class-wp-image-editor-gd.php Uses in-place flipping with validation and no-op handling.
src/​wp-admin/​includes/​image-edit.php Updates the deprecated helper to use in-place flipping.

Review effort: Balanced

Comment thread src/wp-admin/includes/image-edit.php Resolved
Comment thread tests/phpunit/tests/image/functions.php Resolved

@desrosj desrosj left a comment

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.

Thanks for this, @sanjithsarkar!

When you have a chance, could you please link your WordPress.org user account with your GitHub one? You'll find instructions on the Make Core blog. This ensures you'll rightly receive attribution for you contributions in the release.

Since Trac is still the canonical source of truth for this repository (it's a mirror of the SVN repository), conversation often happens there. I added a comment with feedback on Core-66113, but I could not mention you by username because I'm not clear what that is.

Also, could you kindly add a disclaimer describing whether you used AI tools and how? Using AI is totally fine, but you must disclose these details so others can understand the surrounding context they were created with.

It looks like there are still several side effects that this switch will cause that are not described or accounted for in full. There are a few additional considerations I noted in Core-66113 that Copilot has also mentioned here.

These need to be thought through carefully, at a minimum should receive new tests, and possibly may require code to preserve the current behavior instead. Looking at the Testing checklist from the PR description, this is how Claude mapped each to the actual test changes:

# Checklist item Covered? By what
1 phpunit --filter Tests_Image_Editor_GD passes ✅ In CI (not a test itself) The PHP 8.6 CI job runs them, and the GD flip tests pass on 8.6.0RC2
2 test_flip_image_resource passes ✅ In CI (not a test itself) Same CI run
3 Flip vertically in Edit Image and save → top/bottom swap ⚠️ Partly test_flip_moves_every_pixel "vertical flip" case (true, false) checks the pixels from flip(). It doesn't cover the Edit Image path (image_edit_apply_changes()) or saving.
4 Flip horizontally → left/right swap ⚠️ Partly Same test, "horizontal flip" case (false, true). Same gap.
5 Flip both → same as a 180° rotation ⚠️ Partly The "both" case checks that every pixel is mirrored on both axes, which is mathematically a 180° rotation. Nothing compares the result with rotate( 180 ).
6 Upload a JPEG with EXIF orientation 2, 3 or 4 → upright ❌ No No test calls maybe_exif_rotate() or uses an EXIF-oriented image. This is the upload path.
7 flip( false, false ) → true, image untouched ✅ Yes The "no flip" case asserts true and checks every pixel is unchanged
8 flip() without a valid GD image → WP_Error image_flip_error ⚠️ Partly test_flip_returns_error_for_invalid_image asserts a WP_Error, but never checks the error code is image_flip_error.
9 Flip a PNG with transparency → alpha kept ❌ No The test images are opaque truecolor images from imagecreatetruecolor(). Nothing checks alpha.
10 phpcs clean on the changed files ✅ Yes, in CI (not a unit test) "Coding standards / PHP checks" and "Check PHP compatibility" both pass

The switch to `imageflip()` changed two side effects of the previous
`imagecopyresampled()` implementation:

- Palette images stayed palette images. The old code always copied into a
  new truecolor image, and saving or streaming the result as WebP relies on
  that: `imagewebp()` rejects palette images and writes an empty file.
  `WP_Image_Editor_GD::flip()` and `_flip_image_resource()` now convert
  palette images to truecolor first, including when no axis is flipped.
- Alpha blending stayed enabled after `rotate()`. Reset it to the
  `alphablending=false`, `savealpha=true` state the old code produced.

Tests:

- Set the editor size in the pixel tests so they run against the previous
  implementation and fail on PHP 8.6 / GD 2.4.0.
- Cover `maybe_exif_rotate()` for EXIF orientations 2, 3 and 4.
- Check palette to truecolor conversion for both functions, saving a
  flipped palette image as WebP, alpha on every pixel in memory and after
  saving as PNG, flipping both axes against `rotate( 180 )`, alpha blending
  after `rotate()`, and the `image_flip_error` code.
- Skip `test_flip_image_resource()` when GD is unavailable.

Results with `--filter 'Tests_Image_Editor_GD|test_flip_image_resource'`:

- PHP 8.3 / GD 2.1: 54/54 pass. The previous implementation passes every
  palette, alpha and rotate test, so the output is unchanged.
- PHP 8.6.0RC4 / GD 2.4.0: 54/54 pass. The previous implementation has 20
  failures and 4 errors.

See #66113.
@sanjithsarkar

Copy link
Copy Markdown
Author

@propsbot refresh

@sanjithsarkar

sanjithsarkar commented Nov 4, 2026 •

Copy link
Copy Markdown
Author

Thanks @desrosj! I've addressed everything in 85eb6d2. The palette and alpha behavior now matches the old code, there are tests for each item from Core-66113, and the PR description has an AI disclosure and PHP 8.3 and 8.6 results. Details are in my reply on Trac: https://core.trac.wordpress.org/ticket/66113#comment:26. My WordPress.org username is @sanjith2023.

@sanjithsarkar
sanjithsarkar requested a review from desrosj November 4, 2026 04:58
@desrosj desrosj added the props-bot Adding this label triggers the Props Bot workflow for a PR. label Nov 4, 2026
@github-actions github-actions Bot removed the props-bot Adding this label triggers the Props Bot workflow for a PR. label Nov 4, 2026
@desrosj
desrosj requested a balanced review from Copilot November 4, 2026 16:31

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

No-axis flip paths fail to normalize alpha settings, causing incorrect blending after rotation and loss of transparency in PNG output.

2 open issues
3 resolved since last review

Review effort: Balanced

Comment thread src/wp-admin/includes/image-edit.php Resolved
`WP_Image_Editor_GD::flip()` and `_flip_image_resource()` returned early
when neither axis was flipped, before resetting the alpha settings. The
previous implementation always returned an image with
`alphablending=false` and `savealpha=true`, so:

- A no-axis flip after `rotate()` left alpha blending enabled.
- `_flip_image_resource()` could return an image with alpha saving
  disabled, dropping transparency in PNG output.

Set the alpha settings before the early return in both functions, and
cover the no-axis case in the alpha blending and PNG alpha tests.

See #66113.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Three new data providers are non-static despite using no instance state and should be declared public static to match the established test convention.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Declare data providers static

tests/​phpunit/​tests/​image/​editorGd.php:664

The three new data providers are non-static, unlike the existing providers in tests/phpunit/tests/image/editorGd.php:448,500 and tests/phpunit/tests/image/functions.php:56,1102. Declare them public static to follow the established convention; none uses instance state. Apply the same change at tests/phpunit/tests/image/editorGd.php:781 and tests/phpunit/tests/image/functions.php:1267.

2 resolved since last review

Review effort: Balanced

Matches the convention used by the other data providers in these files.

See #66113.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Approval recommended

No active review issues remain for the GD image-flip fix.

No open issues 🎉

Review effort: Balanced

@desrosj desrosj left a comment

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.

I think this is getting close, @sanjithsarkar. Thanks for working through this.

Comment thread src/wp-admin/includes/image-edit.php Outdated
* Flips an image resource. Internal use only.
*
* @since 2.9.0
* @since 7.2.0 Uses imageflip() instead of imagecopyresampled() with negative dimensions.

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.

Suggested change
* @since 7.2.0 Uses imageflip() instead of imagecopyresampled() with negative dimensions.
* @since 7.2.0 Switched to using imageflip() instead of imagecopyresampled() with negative dimensions.

*/
imagealphablending( $this->image, false );
imagesavealpha( $this->image, true );

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.

It looks like there's one more change in how images are handled with GD.

Today in trunk on PHP 8.5, every flipped image is reset to GD's default DPI value of 96 and interlacing is turned off. This is because every GD operation produces a new image.

  • All new images in GD start with the default values.
  • Nothing in WP_Image_Editor_GD calls imageresolution() to carry the sources values over.
  • Resize and rotate operations also return a new image using the defaults.

In this PR the image now flips in place, which preserves the source file's values. While this is arguably better and more correct, the current behavior should be preserved after these changes until this can be properly considered and all operations can be updated to behave the same.

This should ensure that flip operations perform the same:

Suggested change
/*
* imageflip() changes the image in place, so the image keeps its current resolution and interlace setting.
* rotate(), crop() and resize() copy into a new image, which resets them to GD's defaults: 96 DPI and no
* interlacing. Reset them here too, so a flip gives the same result as the other edits.
*
* @link https://core.trac.wordpress.org/ticket/66113
*/
imageresolution( $this->image, 96, 96 );
imageinterlace( $this->image, false );

}
// Match the alpha settings of the previous implementation's copy, including when no axis is flipped.
imagealphablending( $img, false );
imagesavealpha( $img, true );

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.

The same consideration noted for WP_Image_Editor_GD applies here as well:

Suggested change
imagesavealpha( $img, true );
imagesavealpha( $img, true );
/*
* imageflip() changes the image in place, so the image keeps its current resolution and interlace setting.
* _rotate_image_resource() and _crop_image_resource() copy into a new image, which resets them to GD's defaults:
* 96 DPI and no interlacing. Reset them here too, so a flip gives the same result as the other edits.
*
* @link https://core.trac.wordpress.org/ticket/66113
*/
imageresolution( $img, 96, 96 );
imageinterlace( $img, false );

Comment thread tests/phpunit/tests/image/editorGd.php Outdated
Comment on lines +709 to +710
*
* @requires function exif_read_data

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.

Suggested change
*
* @requires function exif_read_data

The orientation here comes from the wp_image_maybe_exif_rotate filter, which is applied whether or not EXIF is available. So this could needlessly skip the test.

Comment thread tests/phpunit/tests/image/editorGd.php Outdated
Comment on lines +858 to +859
* @requires function imagewebp
*

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.

Suggested change
* @requires function imagewebp
*

The supports_mime_type() check within the test is sufficient to handle scenarios where WebP is not supported.

Comment thread tests/phpunit/tests/image/editorGd.php Outdated
*
* @ticket 66113
*
* @dataProvider data_flip_moves_every_pixel

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.

Suggested change
* @dataProvider data_flip_moves_every_pixel
* @dataProvider data_flip_axes

Comment thread tests/phpunit/tests/image/editorGd.php Outdated
*
* @ticket 66113
*
* @dataProvider data_flip_moves_every_pixel

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.

Suggested change
* @dataProvider data_flip_moves_every_pixel
* @dataProvider data_flip_axes

Comment thread tests/phpunit/tests/image/editorGd.php Outdated
*
* @ticket 66113
*
* @dataProvider data_flip_moves_every_pixel

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.

Suggested change
* @dataProvider data_flip_moves_every_pixel
* @dataProvider data_flip_axes

Comment on lines +619 to +622
$property = new ReflectionProperty( $gd_image_editor, 'image' );
if ( PHP_VERSION_ID < 80100 ) {
$property->setAccessible( true );
}

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.

It would be nice to abstract this out into a private method as there are a ton of these. But this is fine for this PR because it follows the pattern throughout the rest of the class.

@sanjithsarkar could you open a new Trac ticket to follow up with this optimization later?

Comment on lines +1294 to +1320
public function test_flip_image_resource_converts_palette_image_to_truecolor( $horz, $vert ) {
require_once ABSPATH . 'wp-admin/includes/image-edit.php';

$image = imagecreatefromgif( DIR_TESTDATA . '/images/test-image.gif' );
$w = imagesx( $image );
$h = imagesy( $image );

$this->assertFalse( imageistruecolor( $image ), 'The fixture should be a palette image.' );

$top_left = imagecolorsforindex( $image, imagecolorat( $image, 0, 0 ) );
$bottom_left = imagecolorsforindex( $image, imagecolorat( $image, 0, $h - 1 ) );

$flipped = _flip_image_resource( $image, $horz, $vert );

$this->assertTrue( imageistruecolor( $flipped ), 'The flipped image should be a truecolor image.' );

$target_x = $vert ? $w - 1 : 0;
$target_y = $horz ? $h - 1 : 0;
$expected = ( $top_left['alpha'] << 24 ) | ( $top_left['red'] << 16 ) | ( $top_left['green'] << 8 ) | $top_left['blue'];

$this->assertSame( $expected, imagecolorat( $flipped, $target_x, $target_y ), 'The top-left pixel did not move to its mirrored position.' );

$target_y = $horz ? 0 : $h - 1;
$expected = ( $bottom_left['alpha'] << 24 ) | ( $bottom_left['red'] << 16 ) | ( $bottom_left['green'] << 8 ) | $bottom_left['blue'];

$this->assertSame( $expected, imagecolorat( $flipped, $target_x, $target_y ), 'The bottom-left pixel did not move to its mirrored position.' );
}

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.

In test_flip_image_resource_converts_palette_image_to_truecolor(), the top-left and bottom-left pixels are compared with their mirrored positions. However, all four corners of test-image.gif have the same color, an opaque near-white (rgb(254, 254, 254)).

Image

Because of that, the two mirroring assertions pass even when the image is flipped along the wrong axis or not flipped at all. The truecolor check looks correct.

Could you build the image in the test instead, like the other pixel tests? A small palette image from imagecreate() with a different color in every pixel can't look the same after any flip, so every pixel can be checked at its mirrored position, the same way test_flip_converts_palette_image_to_truecolor() does.

desrosj and others added 2 commits November 6, 2026 16:22
…edback.

`imageflip()` changes the image in place, so a flipped image kept the
source's resolution and interlace setting. The previous implementation
copied into a new image, which resets them to GD's defaults (96 DPI, no
interlacing), like `rotate()`, `crop()` and `resize()` still do. Reset
them in `WP_Image_Editor_GD::flip()` and `_flip_image_resource()`,
including when no axis is flipped.

Tests:
- Add resolution and interlace tests for both functions.
- Build the palette image in the `_flip_image_resource()` palette test,
  so every pixel is checked at its mirrored position.
- Rename the `data_flip_moves_every_pixel` provider to `data_flip_axes`.
- Add missing `@covers` annotations.
- Remove unneeded `@requires` annotations from the EXIF and WebP tests.

See #66113.
@github-actions

This comment has been minimized.

PHPStan checks against PHP 7.4, where the parameter is typed as int. Passing 0 behaves the same as false on PHP 8.

See #66113.
@sanjithsarkar

Copy link
Copy Markdown
Author

Thanks @desrosj! The latest feedback is addressed in 50b71d4 and 6006322:

  • flip() and _flip_image_resource() now reset the resolution to 96 DPI and turn interlacing off, also for flip( false, false ), so they match the previous output. imageinterlace() gets 0 rather than false because PHPStan checks against PHP 7.4's int signature.
  • New tests: test_flip_resets_resolution_and_interlace() and test_flip_image_resource_resets_resolution_and_interlace().
  • Updated the @since wording, renamed the data provider to data_flip_axes, and removed the unneeded @requires lines.
  • test_flip_image_resource_converts_palette_image_to_truecolor() now builds a palette image with imagecreate() and a different color in every pixel, and checks each pixel at its mirrored position.

I opened Core-66584 for the reflection helper follow-up. The PR description is updated as well.

@sanjithsarkar
sanjithsarkar requested a review from desrosj November 6, 2026 23:05
@sanjithsarkar

Copy link
Copy Markdown
Author

I ran the manual tests in the browser on PHP 8.6.0RC4 (bundled GD 2.4.0), with the GD editor forced: Edit Image flip vertical and horizontal, an EXIF orientation 3 upload, and a flipped GIF saved as WebP. All three work, and the results are in the updated description.

Two existing trunk issues showed up during the GIF-to-WebP test, and this PR doesn't change either one. They're also in the description:

  • A palette GIF converted to WebP on upload becomes an empty file, because imagewebp() rejects palette images.
  • After saving from Edit Image with an output format that changes the extension, the attachment points to .gif, but the file on disk is .webp. wp_save_image() stores $new_path rather than the path the editor actually saved to.

I can open separate tickets for these if that's useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants