From e3f315c9c518bc4d458dd5d34d1b7790f32f9f2f Mon Sep 17 00:00:00 2001 From: sanjithsarkar Date: Fri, 2 Oct 2026 14:20:32 +0600 Subject: [PATCH 1/9] Media: Use imageflip() in WP_Image_Editor_GD::flip(). 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. --- src/wp-admin/includes/image-edit.php | 28 +++--- src/wp-includes/class-wp-image-editor-gd.php | 32 +++---- tests/phpunit/tests/image/editorGd.php | 92 ++++++++++++++++++++ tests/phpunit/tests/image/functions.php | 67 ++++++++++++++ 4 files changed, 188 insertions(+), 31 deletions(-) diff --git a/src/wp-admin/includes/image-edit.php b/src/wp-admin/includes/image-edit.php index 2f6bc25740e2d..4808cebbe89b4 100644 --- a/src/wp-admin/includes/image-edit.php +++ b/src/wp-admin/includes/image-edit.php @@ -565,6 +565,7 @@ function _rotate_image_resource( $img, $angle ) { * Flips an image resource. Internal use only. * * @since 2.9.0 + * @since 7.2.0 Uses imageflip() instead of imagecopyresampled() with negative dimensions. * @deprecated 3.5.0 Use WP_Image_Editor::flip() * @see WP_Image_Editor::flip() * @@ -577,23 +578,20 @@ function _rotate_image_resource( $img, $angle ) { function _flip_image_resource( $img, $horz, $vert ) { _deprecated_function( __FUNCTION__, '3.5.0', 'WP_Image_Editor::flip()' ); - $w = imagesx( $img ); - $h = imagesy( $img ); - $dst = wp_imagecreatetruecolor( $w, $h ); - - if ( is_gd_image( $dst ) ) { - $sx = $vert ? ( $w - 1 ) : 0; - $sy = $horz ? ( $h - 1 ) : 0; - $sw = $vert ? -$w : $w; - $sh = $horz ? -$h : $h; + if ( ! $horz && ! $vert ) { + return $img; + } - if ( imagecopyresampled( $dst, $img, 0, 0, $sx, $sy, $w, $h, $sw, $sh ) ) { - if ( PHP_VERSION_ID < 80000 ) { // imagedestroy() has no effect as of PHP 8.0. - imagedestroy( $img ); - } + if ( $horz && $vert ) { + $mode = IMG_FLIP_BOTH; + } elseif ( $horz ) { + $mode = IMG_FLIP_VERTICAL; + } else { + $mode = IMG_FLIP_HORIZONTAL; + } - $img = $dst; - } + if ( is_gd_image( $img ) ) { + imageflip( $img, $mode ); } return $img; diff --git a/src/wp-includes/class-wp-image-editor-gd.php b/src/wp-includes/class-wp-image-editor-gd.php index 34c0453ff693a..3167ea5420536 100644 --- a/src/wp-includes/class-wp-image-editor-gd.php +++ b/src/wp-includes/class-wp-image-editor-gd.php @@ -432,31 +432,31 @@ public function rotate( $angle ) { * Flips current image. * * @since 3.5.0 + * @since 7.2.0 Uses imageflip() instead of imagecopyresampled() with negative dimensions. * * @param bool $horz Flip along Horizontal Axis. * @param bool $vert Flip along Vertical Axis. * @return true|WP_Error */ public function flip( $horz, $vert ) { - $w = $this->size['width']; - $h = $this->size['height']; - $dst = wp_imagecreatetruecolor( $w, $h ); - - if ( is_gd_image( $dst ) ) { - $sx = $vert ? ( $w - 1 ) : 0; - $sy = $horz ? ( $h - 1 ) : 0; - $sw = $vert ? -$w : $w; - $sh = $horz ? -$h : $h; + if ( ! is_gd_image( $this->image ) ) { + return new WP_Error( 'image_flip_error', __( 'Image flip failed.' ), $this->file ); + } - if ( imagecopyresampled( $dst, $this->image, 0, 0, $sx, $sy, $w, $h, $sw, $sh ) ) { - if ( PHP_VERSION_ID < 80000 ) { // imagedestroy() has no effect as of PHP 8.0. - imagedestroy( $this->image ); - } + if ( ! $horz && ! $vert ) { + return true; + } - $this->image = $dst; + if ( $horz && $vert ) { + $mode = IMG_FLIP_BOTH; + } elseif ( $horz ) { + $mode = IMG_FLIP_VERTICAL; + } else { + $mode = IMG_FLIP_HORIZONTAL; + } - return true; - } + if ( imageflip( $this->image, $mode ) ) { + return true; } return new WP_Error( 'image_flip_error', __( 'Image flip failed.' ), $this->file ); diff --git a/tests/phpunit/tests/image/editorGd.php b/tests/phpunit/tests/image/editorGd.php index ac0e8268390c2..b78acb652cf28 100644 --- a/tests/phpunit/tests/image/editorGd.php +++ b/tests/phpunit/tests/image/editorGd.php @@ -586,6 +586,98 @@ public function test_flip() { $this->assertSame( $color_top_left, imagecolorat( $property->getValue( $gd_image_editor ), 0, 99 ) ); } + /** + * Tests that flip() moves every pixel to its mirrored position. + * + * A small non-square image with a distinct color per pixel is used so that + * mixed up axes, off-by-one shifts and duplicated edges are all detected. + * + * @ticket 66113 + * + * @dataProvider data_flip_moves_every_pixel + * + * @param bool $horz Whether to flip along the horizontal axis. + * @param bool $vert Whether to flip along the vertical axis. + */ + public function test_flip_moves_every_pixel( $horz, $vert ) { + $w = 3; + $h = 2; + $color = static function ( $x, $y ) { + return ( ( $x * 40 + 10 ) << 16 ) | ( ( $y * 90 + 20 ) << 8 ) | 0x77; + }; + + $image = imagecreatetruecolor( $w, $h ); + for ( $y = 0; $y < $h; $y++ ) { + for ( $x = 0; $x < $w; $x++ ) { + imagesetpixel( $image, $x, $y, $color( $x, $y ) ); + } + } + + $gd_image_editor = new WP_Image_Editor_GD( DIR_TESTDATA . '/images/gradient-square.jpg' ); + + $property = new ReflectionProperty( $gd_image_editor, 'image' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $property->setValue( $gd_image_editor, $image ); + + $this->assertTrue( $gd_image_editor->flip( $horz, $vert ) ); + + $flipped = $property->getValue( $gd_image_editor ); + + $this->assertSame( $w, imagesx( $flipped ), 'The width should not change.' ); + $this->assertSame( $h, imagesy( $flipped ), 'The height should not change.' ); + + for ( $y = 0; $y < $h; $y++ ) { + for ( $x = 0; $x < $w; $x++ ) { + $source_x = $vert ? $w - 1 - $x : $x; + $source_y = $horz ? $h - 1 - $y : $y; + + $this->assertSame( + $color( $source_x, $source_y ), + imagecolorat( $flipped, $x, $y ), + "Pixel ($source_x,$source_y) did not move to ($x,$y)." + ); + } + } + } + + /** + * Data provider for test_flip_moves_every_pixel(). + * + * @return array[] + */ + public function data_flip_moves_every_pixel() { + return array( + 'no flip' => array( false, false ), + 'vertical flip' => array( true, false ), + 'horizontal flip' => array( false, true ), + 'both' => array( true, true ), + ); + } + + /** + * Tests that flip() returns a WP_Error when no valid GD image is loaded. + * + * @ticket 66113 + * + * @dataProvider data_flip_moves_every_pixel + * + * @param bool $horz Whether to flip along the horizontal axis. + * @param bool $vert Whether to flip along the vertical axis. + */ + public function test_flip_returns_error_for_invalid_image( $horz, $vert ) { + $gd_image_editor = new WP_Image_Editor_GD( DIR_TESTDATA . '/images/gradient-square.jpg' ); + + $property = new ReflectionProperty( $gd_image_editor, 'image' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $property->setValue( $gd_image_editor, false ); + + $this->assertWPError( $gd_image_editor->flip( $horz, $vert ) ); + } + /** * Tests that an image created with WP_Image_Editor_GD preserves alpha with no resizing. * diff --git a/tests/phpunit/tests/image/functions.php b/tests/phpunit/tests/image/functions.php index 72492544779af..3ce741e48d393 100644 --- a/tests/phpunit/tests/image/functions.php +++ b/tests/phpunit/tests/image/functions.php @@ -1187,4 +1187,71 @@ public function data_wp_exif_frac2dec() { ), ); } + + /** + * Tests that _flip_image_resource() mirrors every pixel for each flip mode. + * + * A small non-square image with a distinct color per pixel is used so that + * mixed up axes, off-by-one shifts and duplicated edges are all detected. + * + * @ticket 66113 + * + * @expectedDeprecated _flip_image_resource + * + * @dataProvider data_flip_image_resource + * + * @covers ::_flip_image_resource + * + * @param bool $horz Whether to flip along the horizontal axis. + * @param bool $vert Whether to flip along the vertical axis. + */ + public function test_flip_image_resource( $horz, $vert ) { + require_once ABSPATH . 'wp-admin/includes/image-edit.php'; + + $w = 3; + $h = 2; + $color = static function ( $x, $y ) { + return ( ( $x * 40 + 10 ) << 16 ) | ( ( $y * 90 + 20 ) << 8 ) | 0x77; + }; + + $image = imagecreatetruecolor( $w, $h ); + for ( $y = 0; $y < $h; $y++ ) { + for ( $x = 0; $x < $w; $x++ ) { + imagesetpixel( $image, $x, $y, $color( $x, $y ) ); + } + } + + $flipped = _flip_image_resource( $image, $horz, $vert ); + + $this->assertTrue( is_gd_image( $flipped ), 'A GD image should be returned.' ); + $this->assertSame( $w, imagesx( $flipped ), 'The width should not change.' ); + $this->assertSame( $h, imagesy( $flipped ), 'The height should not change.' ); + + for ( $y = 0; $y < $h; $y++ ) { + for ( $x = 0; $x < $w; $x++ ) { + $source_x = $vert ? $w - 1 - $x : $x; + $source_y = $horz ? $h - 1 - $y : $y; + + $this->assertSame( + $color( $source_x, $source_y ), + imagecolorat( $flipped, $x, $y ), + "Pixel ($source_x,$source_y) did not move to ($x,$y)." + ); + } + } + } + + /** + * Data provider for test_flip_image_resource(). + * + * @return array[] + */ + public function data_flip_image_resource() { + return array( + 'no flip' => array( false, false ), + 'vertical flip' => array( true, false ), + 'horizontal flip' => array( false, true ), + 'both' => array( true, true ), + ); + } } From 086e1d9531b273977293f106ac07eb645a74d777 Mon Sep 17 00:00:00 2001 From: sanjithsarkar Date: Fri, 2 Oct 2026 14:36:52 +0600 Subject: [PATCH 2/9] Build/Test Tools: Re-trigger CI. 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 From 85eb6d26c99f8611a2fff6d3502f8c236fc4c130 Mon Sep 17 00:00:00 2001 From: sanjithsarkar Date: Wed, 4 Nov 2026 10:19:53 +0600 Subject: [PATCH 3/9] Media: Keep previous flip output for palette images and alpha settings. 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. --- src/wp-admin/includes/image-edit.php | 14 +- src/wp-includes/class-wp-image-editor-gd.php | 18 + tests/phpunit/tests/image/editorGd.php | 361 ++++++++++++++++++- tests/phpunit/tests/image/functions.php | 48 +++ 4 files changed, 438 insertions(+), 3 deletions(-) diff --git a/src/wp-admin/includes/image-edit.php b/src/wp-admin/includes/image-edit.php index 4808cebbe89b4..3d837a0f3c38c 100644 --- a/src/wp-admin/includes/image-edit.php +++ b/src/wp-admin/includes/image-edit.php @@ -578,6 +578,15 @@ function _rotate_image_resource( $img, $angle ) { function _flip_image_resource( $img, $horz, $vert ) { _deprecated_function( __FUNCTION__, '3.5.0', 'WP_Image_Editor::flip()' ); + if ( ! is_gd_image( $img ) ) { + return $img; + } + + // Keep returning a truecolor image, like the previous implementation did. imagewebp() rejects palette images. + if ( ! imageistruecolor( $img ) && ! imagepalettetotruecolor( $img ) ) { + return $img; + } + if ( ! $horz && ! $vert ) { return $img; } @@ -590,8 +599,9 @@ function _flip_image_resource( $img, $horz, $vert ) { $mode = IMG_FLIP_HORIZONTAL; } - if ( is_gd_image( $img ) ) { - imageflip( $img, $mode ); + if ( imageflip( $img, $mode ) ) { + imagealphablending( $img, false ); + imagesavealpha( $img, true ); } return $img; diff --git a/src/wp-includes/class-wp-image-editor-gd.php b/src/wp-includes/class-wp-image-editor-gd.php index 3167ea5420536..b637ef457d2dd 100644 --- a/src/wp-includes/class-wp-image-editor-gd.php +++ b/src/wp-includes/class-wp-image-editor-gd.php @@ -443,6 +443,16 @@ public function flip( $horz, $vert ) { return new WP_Error( 'image_flip_error', __( 'Image flip failed.' ), $this->file ); } + /* + * imageflip() works in place and keeps palette images as palette images. + * The previous implementation always copied into a new truecolor image, + * and saving or streaming as WebP relies on that, since imagewebp() + * rejects palette images. Convert first to keep that behavior. + */ + if ( ! imageistruecolor( $this->image ) && ! imagepalettetotruecolor( $this->image ) ) { + return new WP_Error( 'image_flip_error', __( 'Image flip failed.' ), $this->file ); + } + if ( ! $horz && ! $vert ) { return true; } @@ -456,6 +466,14 @@ public function flip( $horz, $vert ) { } if ( imageflip( $this->image, $mode ) ) { + /* + * imageflip() works in place and keeps the current alpha settings, which + * rotate() leaves with blending enabled. Reset them to what the previous + * implementation always produced. + */ + imagealphablending( $this->image, false ); + imagesavealpha( $this->image, true ); + return true; } diff --git a/tests/phpunit/tests/image/editorGd.php b/tests/phpunit/tests/image/editorGd.php index db8c3ef801c3b..d940210b5c80b 100644 --- a/tests/phpunit/tests/image/editorGd.php +++ b/tests/phpunit/tests/image/editorGd.php @@ -614,6 +614,7 @@ public function test_flip_moves_every_pixel( $horz, $vert ) { } $gd_image_editor = new WP_Image_Editor_GD( DIR_TESTDATA . '/images/gradient-square.jpg' ); + $gd_image_editor->load(); $property = new ReflectionProperty( $gd_image_editor, 'image' ); if ( PHP_VERSION_ID < 80100 ) { @@ -621,6 +622,19 @@ public function test_flip_moves_every_pixel( $horz, $vert ) { } $property->setValue( $gd_image_editor, $image ); + // Keep the stored size in sync with the replaced image. + $size_property = new ReflectionProperty( $gd_image_editor, 'size' ); + if ( PHP_VERSION_ID < 80100 ) { + $size_property->setAccessible( true ); + } + $size_property->setValue( + $gd_image_editor, + array( + 'width' => $w, + 'height' => $h, + ) + ); + $this->assertTrue( $gd_image_editor->flip( $horz, $vert ) ); $flipped = $property->getValue( $gd_image_editor ); @@ -668,6 +682,7 @@ public function data_flip_moves_every_pixel() { */ public function test_flip_returns_error_for_invalid_image( $horz, $vert ) { $gd_image_editor = new WP_Image_Editor_GD( DIR_TESTDATA . '/images/gradient-square.jpg' ); + $gd_image_editor->load(); $property = new ReflectionProperty( $gd_image_editor, 'image' ); if ( PHP_VERSION_ID < 80100 ) { @@ -675,7 +690,351 @@ public function test_flip_returns_error_for_invalid_image( $horz, $vert ) { } $property->setValue( $gd_image_editor, false ); - $this->assertWPError( $gd_image_editor->flip( $horz, $vert ) ); + $result = $gd_image_editor->flip( $horz, $vert ); + + $this->assertWPError( $result, 'flip() should return a WP_Error when no valid image is loaded.' ); + $this->assertSame( 'image_flip_error', $result->get_error_code(), 'The error code should be image_flip_error.' ); + } + + /** + * Tests that maybe_exif_rotate() flips images whose EXIF orientation requires a flip. + * + * This is the most common way to reach flip(): uploading a mirrored or + * upside-down JPEG. The image is only flipped, so its size does not change + * and every pixel must end up at its mirrored position. + * + * @ticket 66113 + * + * @dataProvider data_maybe_exif_rotate_flips_image + * + * @requires function exif_read_data + * + * @covers WP_Image_Editor::maybe_exif_rotate + * @covers WP_Image_Editor_GD::flip + * + * @param int $orientation EXIF orientation value. + * @param bool $mirror_x Whether the image should be mirrored left to right. + * @param bool $mirror_y Whether the image should be mirrored top to bottom. + */ + public function test_maybe_exif_rotate_flips_image( $orientation, $mirror_x, $mirror_y ) { + $file = DIR_TESTDATA . '/images/test-image-upside-down.jpg'; + + // The fixture has orientation 3. Override it to cover the other flip-only orientations. + $filter = static function () use ( $orientation ) { + return $orientation; + }; + add_filter( 'wp_image_maybe_exif_rotate', $filter ); + + $gd_image_editor = new WP_Image_Editor_GD( $file ); + $gd_image_editor->load(); + + $property = new ReflectionProperty( $gd_image_editor, 'image' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + + $original = $property->getValue( $gd_image_editor ); + $w = imagesx( $original ); + $h = imagesy( $original ); + + // Snapshot a grid of pixels, since flip() modifies the image in place. + $points = array(); + foreach ( array( 0, (int) ( $w / 3 ), (int) ( $w / 2 ), $w - 1 ) as $x ) { + foreach ( array( 0, (int) ( $h / 3 ), (int) ( $h / 2 ), $h - 1 ) as $y ) { + $points[] = array( $x, $y, imagecolorat( $original, $x, $y ) ); + } + } + + $result = $gd_image_editor->maybe_exif_rotate(); + + remove_filter( 'wp_image_maybe_exif_rotate', $filter ); + + $this->assertTrue( $result, 'maybe_exif_rotate() should return true.' ); + $this->assertSame( + array( + 'width' => $w, + 'height' => $h, + ), + $gd_image_editor->get_size(), + 'Flipping should not change the size.' + ); + + $flipped = $property->getValue( $gd_image_editor ); + + foreach ( $points as list( $x, $y, $color ) ) { + $target_x = $mirror_x ? $w - 1 - $x : $x; + $target_y = $mirror_y ? $h - 1 - $y : $y; + + $this->assertSame( + $color, + imagecolorat( $flipped, $target_x, $target_y ), + "Pixel ($x,$y) did not move to ($target_x,$target_y)." + ); + } + } + + /** + * Data provider for test_maybe_exif_rotate_flips_image(). + * + * @return array[] + */ + public function data_maybe_exif_rotate_flips_image() { + return array( + 'orientation 2: mirrored horizontally' => array( 2, true, false ), + 'orientation 3: upside down' => array( 3, true, true ), + 'orientation 4: mirrored vertically' => array( 4, false, true ), + ); + } + + /** + * Tests that flip() converts palette images to truecolor, like the previous implementation. + * + * The previous implementation always copied into a new truecolor image. + * Code that saves or streams the flipped image as WebP relies on that, + * since imagewebp() rejects palette images. + * + * @ticket 66113 + * + * @dataProvider data_flip_moves_every_pixel + * + * @covers WP_Image_Editor_GD::flip + * + * @param bool $horz Whether to flip along the horizontal axis. + * @param bool $vert Whether to flip along the vertical axis. + */ + public function test_flip_converts_palette_image_to_truecolor( $horz, $vert ) { + $gd_image_editor = new WP_Image_Editor_GD( DIR_TESTDATA . '/images/test-image.gif' ); + $gd_image_editor->load(); + + $property = new ReflectionProperty( $gd_image_editor, 'image' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + + $image = $property->getValue( $gd_image_editor ); + $w = imagesx( $image ); + $h = imagesy( $image ); + + $this->assertFalse( imageistruecolor( $image ), 'The fixture should be a palette image.' ); + + // Snapshot every pixel as a truecolor value, since flip() modifies the image in place. + $colors = array(); + for ( $y = 0; $y < $h; $y++ ) { + for ( $x = 0; $x < $w; $x++ ) { + $rgba = imagecolorsforindex( $image, imagecolorat( $image, $x, $y ) ); + + $colors[ $y ][ $x ] = ( $rgba['alpha'] << 24 ) | ( $rgba['red'] << 16 ) | ( $rgba['green'] << 8 ) | $rgba['blue']; + } + } + + $this->assertTrue( $gd_image_editor->flip( $horz, $vert ) ); + + $flipped = $property->getValue( $gd_image_editor ); + + $this->assertTrue( imageistruecolor( $flipped ), 'The flipped image should be a truecolor image.' ); + + for ( $y = 0; $y < $h; $y++ ) { + for ( $x = 0; $x < $w; $x++ ) { + $source_x = $vert ? $w - 1 - $x : $x; + $source_y = $horz ? $h - 1 - $y : $y; + + $this->assertSame( + $colors[ $source_y ][ $source_x ], + imagecolorat( $flipped, $x, $y ), + "Pixel ($source_x,$source_y) did not move to ($x,$y)." + ); + } + } + } + + /** + * Tests that a flipped palette image can be saved as WebP. + * + * imagewebp() rejects palette images with a warning and writes an empty file, + * so this fails unless flip() returns a truecolor image. + * + * @ticket 66113 + * + * @requires function imagewebp + * + * @covers WP_Image_Editor_GD::flip + * @covers WP_Image_Editor_GD::save + */ + public function test_flipped_palette_image_can_be_saved_as_webp() { + if ( ! WP_Image_Editor_GD::supports_mime_type( 'image/webp' ) ) { + $this->markTestSkipped( 'This GD build does not support WebP.' ); + } + + $gd_image_editor = new WP_Image_Editor_GD( DIR_TESTDATA . '/images/test-image.gif' ); + $gd_image_editor->load(); + + $this->assertTrue( $gd_image_editor->flip( true, false ) ); + + $save_to_file = get_temp_dir() . wp_unique_filename( get_temp_dir(), 'flipped-palette.webp' ); + $saved = $gd_image_editor->save( $save_to_file, 'image/webp' ); + + $this->assertNotWPError( $saved, 'Saving the flipped image as WebP should not fail.' ); + + $size = wp_getimagesize( $saved['path'] ); + $filesize = filesize( $saved['path'] ); + + unlink( $saved['path'] ); + + $this->assertGreaterThan( 0, $filesize, 'The saved WebP file should not be empty.' ); + $this->assertIsArray( $size, 'The saved WebP file should be a valid image.' ); + $this->assertSame( 'image/webp', $size['mime'], 'The saved file should be a WebP image.' ); + $this->assertSame( array( 50, 50 ), array( $size[0], $size[1] ), 'The saved image size should not change.' ); + } + + /** + * Tests that flip() keeps the alpha channel of every pixel, in memory and after saving as PNG. + * + * Fully transparent pixels are left out on purpose: their hidden RGB value + * is not visible and is not guaranteed to survive saving. + * + * @ticket 66113 + * + * @dataProvider data_flip_moves_every_pixel + * + * @covers WP_Image_Editor_GD::flip + * + * @param bool $horz Whether to flip along the horizontal axis. + * @param bool $vert Whether to flip along the vertical axis. + */ + public function test_flip_keeps_alpha_channel( $horz, $vert ) { + $w = 3; + $h = 2; + $color = static function ( $x, $y ) { + // Alpha values from 0 (opaque) to 125, all distinct. + $alpha = ( $y * 3 + $x ) * 25; + + return ( $alpha << 24 ) | ( ( $x * 40 + 10 ) << 16 ) | ( ( $y * 90 + 20 ) << 8 ) | 0x77; + }; + + $image = imagecreatetruecolor( $w, $h ); + imagealphablending( $image, false ); + imagesavealpha( $image, true ); + for ( $y = 0; $y < $h; $y++ ) { + for ( $x = 0; $x < $w; $x++ ) { + imagesetpixel( $image, $x, $y, $color( $x, $y ) ); + } + } + + $source_file = get_temp_dir() . wp_unique_filename( get_temp_dir(), 'flip-alpha.png' ); + imagepng( $image, $source_file ); + + $gd_image_editor = new WP_Image_Editor_GD( $source_file ); + $loaded = $gd_image_editor->load(); + + unlink( $source_file ); + + $this->assertTrue( $loaded, 'The test image should load.' ); + $this->assertTrue( $gd_image_editor->flip( $horz, $vert ) ); + + $property = new ReflectionProperty( $gd_image_editor, 'image' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $flipped = $property->getValue( $gd_image_editor ); + + $save_to_file = get_temp_dir() . wp_unique_filename( get_temp_dir(), 'flipped-alpha.png' ); + $saved = $gd_image_editor->save( $save_to_file, 'image/png' ); + + $this->assertNotWPError( $saved, 'Saving the flipped image as PNG should not fail.' ); + + $reloaded = imagecreatefrompng( $saved['path'] ); + unlink( $saved['path'] ); + + for ( $y = 0; $y < $h; $y++ ) { + for ( $x = 0; $x < $w; $x++ ) { + $source_x = $vert ? $w - 1 - $x : $x; + $source_y = $horz ? $h - 1 - $y : $y; + $expected = $color( $source_x, $source_y ); + + $this->assertSame( + $expected, + imagecolorat( $flipped, $x, $y ), + "Pixel ($source_x,$source_y) did not move to ($x,$y) with its alpha in memory." + ); + $this->assertSame( + $expected, + imagecolorat( $reloaded, $x, $y ), + "Pixel ($source_x,$source_y) did not move to ($x,$y) with its alpha after saving." + ); + } + } + } + + /** + * Tests that flipping along both axes gives the same image as rotating by 180 degrees. + * + * @ticket 66113 + * + * @covers WP_Image_Editor_GD::flip + */ + public function test_flip_both_axes_matches_rotate_180() { + $file = DIR_TESTDATA . '/images/canola.jpg'; + + $flipped_editor = new WP_Image_Editor_GD( $file ); + $flipped_editor->load(); + $this->assertTrue( $flipped_editor->flip( true, true ) ); + + $rotated_editor = new WP_Image_Editor_GD( $file ); + $rotated_editor->load(); + $this->assertTrue( $rotated_editor->rotate( 180 ) ); + + $this->assertSame( $rotated_editor->get_size(), $flipped_editor->get_size(), 'The sizes should match.' ); + + $property = new ReflectionProperty( WP_Image_Editor_GD::class, 'image' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $flipped = $property->getValue( $flipped_editor ); + $rotated = $property->getValue( $rotated_editor ); + + $w = imagesx( $flipped ); + $h = imagesy( $flipped ); + + // Count mismatches instead of asserting each pixel, since the image is large. + $mismatches = 0; + for ( $y = 0; $y < $h; $y++ ) { + for ( $x = 0; $x < $w; $x++ ) { + if ( imagecolorat( $flipped, $x, $y ) !== imagecolorat( $rotated, $x, $y ) ) { + ++$mismatches; + } + } + } + + $this->assertSame( 0, $mismatches, 'Every pixel should match the image rotated by 180 degrees.' ); + } + + /** + * Tests that flip() disables alpha blending, including after rotate() enabled it. + * + * Alpha blending only affects drawing onto the image, so it is detected by + * drawing a fully transparent pixel: with blending disabled, it replaces the + * existing pixel instead of being blended into it. + * + * @ticket 66113 + * + * @covers WP_Image_Editor_GD::flip + */ + public function test_flip_disables_alpha_blending_after_rotate() { + $gd_image_editor = new WP_Image_Editor_GD( DIR_TESTDATA . '/images/gradient-square.jpg' ); + $gd_image_editor->load(); + + $this->assertTrue( $gd_image_editor->rotate( 90 ) ); + $this->assertTrue( $gd_image_editor->flip( true, false ) ); + + $property = new ReflectionProperty( $gd_image_editor, 'image' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $image = $property->getValue( $gd_image_editor ); + + imagesetpixel( $image, 0, 0, imagecolorallocatealpha( $image, 0, 0, 0, 127 ) ); + + $this->assertSame( 127, ( imagecolorat( $image, 0, 0 ) >> 24 ) & 0x7F, 'Alpha blending should be disabled after flipping.' ); } /** diff --git a/tests/phpunit/tests/image/functions.php b/tests/phpunit/tests/image/functions.php index 58eb19dce5e75..f51d72cb6a8eb 100644 --- a/tests/phpunit/tests/image/functions.php +++ b/tests/phpunit/tests/image/functions.php @@ -1212,6 +1212,8 @@ public static function data_wp_exif_frac2dec() { * * @ticket 66113 * + * @requires extension gd + * * @expectedDeprecated _flip_image_resource * * @dataProvider data_flip_image_resource @@ -1270,6 +1272,52 @@ public function data_flip_image_resource() { 'both' => array( true, true ), ); } + + /** + * Tests that _flip_image_resource() returns a truecolor image for a palette image. + * + * The previous implementation always copied into a new truecolor image. + * + * @ticket 66113 + * + * @requires extension gd + * + * @expectedDeprecated _flip_image_resource + * + * @dataProvider data_flip_image_resource + * + * @covers ::_flip_image_resource + * + * @param bool $horz Whether to flip along the horizontal axis. + * @param bool $vert Whether to flip along the vertical axis. + */ + 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.' ); + } } /** From 5125d15f84e65c112a8af42db52c74e9d863a04c Mon Sep 17 00:00:00 2001 From: sanjithsarkar Date: Thu, 5 Nov 2026 09:43:21 +0600 Subject: [PATCH 4/9] Media: Normalize alpha settings when flip has no axis to flip. `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. --- src/wp-admin/includes/image-edit.php | 9 ++--- src/wp-includes/class-wp-image-editor-gd.php | 16 ++++----- tests/phpunit/tests/image/editorGd.php | 9 +++-- tests/phpunit/tests/image/functions.php | 38 ++++++++++++++++++++ 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/src/wp-admin/includes/image-edit.php b/src/wp-admin/includes/image-edit.php index 3d837a0f3c38c..e81e15e0e62d0 100644 --- a/src/wp-admin/includes/image-edit.php +++ b/src/wp-admin/includes/image-edit.php @@ -587,6 +587,10 @@ function _flip_image_resource( $img, $horz, $vert ) { return $img; } + // Match the alpha settings of the previous implementation's copy, including when no axis is flipped. + imagealphablending( $img, false ); + imagesavealpha( $img, true ); + if ( ! $horz && ! $vert ) { return $img; } @@ -599,10 +603,7 @@ function _flip_image_resource( $img, $horz, $vert ) { $mode = IMG_FLIP_HORIZONTAL; } - if ( imageflip( $img, $mode ) ) { - imagealphablending( $img, false ); - imagesavealpha( $img, true ); - } + imageflip( $img, $mode ); return $img; } diff --git a/src/wp-includes/class-wp-image-editor-gd.php b/src/wp-includes/class-wp-image-editor-gd.php index b637ef457d2dd..9bc1df765ad69 100644 --- a/src/wp-includes/class-wp-image-editor-gd.php +++ b/src/wp-includes/class-wp-image-editor-gd.php @@ -453,6 +453,14 @@ public function flip( $horz, $vert ) { return new WP_Error( 'image_flip_error', __( 'Image flip failed.' ), $this->file ); } + /* + * imageflip() also keeps the current alpha settings, which rotate() leaves + * with blending enabled. Reset them to what the previous implementation + * always produced, including when no axis is flipped. + */ + imagealphablending( $this->image, false ); + imagesavealpha( $this->image, true ); + if ( ! $horz && ! $vert ) { return true; } @@ -466,14 +474,6 @@ public function flip( $horz, $vert ) { } if ( imageflip( $this->image, $mode ) ) { - /* - * imageflip() works in place and keeps the current alpha settings, which - * rotate() leaves with blending enabled. Reset them to what the previous - * implementation always produced. - */ - imagealphablending( $this->image, false ); - imagesavealpha( $this->image, true ); - return true; } diff --git a/tests/phpunit/tests/image/editorGd.php b/tests/phpunit/tests/image/editorGd.php index d940210b5c80b..ba8eb239050f1 100644 --- a/tests/phpunit/tests/image/editorGd.php +++ b/tests/phpunit/tests/image/editorGd.php @@ -1017,14 +1017,19 @@ public function test_flip_both_axes_matches_rotate_180() { * * @ticket 66113 * + * @dataProvider data_flip_moves_every_pixel + * * @covers WP_Image_Editor_GD::flip + * + * @param bool $horz Whether to flip along the horizontal axis. + * @param bool $vert Whether to flip along the vertical axis. */ - public function test_flip_disables_alpha_blending_after_rotate() { + public function test_flip_disables_alpha_blending_after_rotate( $horz, $vert ) { $gd_image_editor = new WP_Image_Editor_GD( DIR_TESTDATA . '/images/gradient-square.jpg' ); $gd_image_editor->load(); $this->assertTrue( $gd_image_editor->rotate( 90 ) ); - $this->assertTrue( $gd_image_editor->flip( true, false ) ); + $this->assertTrue( $gd_image_editor->flip( $horz, $vert ) ); $property = new ReflectionProperty( $gd_image_editor, 'image' ); if ( PHP_VERSION_ID < 80100 ) { diff --git a/tests/phpunit/tests/image/functions.php b/tests/phpunit/tests/image/functions.php index f51d72cb6a8eb..ce8e24a39355a 100644 --- a/tests/phpunit/tests/image/functions.php +++ b/tests/phpunit/tests/image/functions.php @@ -1318,6 +1318,44 @@ public function test_flip_image_resource_converts_palette_image_to_truecolor( $h $this->assertSame( $expected, imagecolorat( $flipped, $target_x, $target_y ), 'The bottom-left pixel did not move to its mirrored position.' ); } + + /** + * Tests that _flip_image_resource() returns an image that keeps its alpha channel when saved as PNG. + * + * The previous implementation always returned a new image with alpha saving + * enabled, even if the input had it disabled and no axis was flipped. + * + * @ticket 66113 + * + * @requires extension gd + * + * @expectedDeprecated _flip_image_resource + * + * @dataProvider data_flip_image_resource + * + * @covers ::_flip_image_resource + * + * @param bool $horz Whether to flip along the horizontal axis. + * @param bool $vert Whether to flip along the vertical axis. + */ + public function test_flip_image_resource_enables_alpha_saving( $horz, $vert ) { + require_once ABSPATH . 'wp-admin/includes/image-edit.php'; + + // A single half-transparent pixel, so its position does not change when flipped. + $image = imagecreatetruecolor( 1, 1 ); + imagealphablending( $image, false ); + imagesavealpha( $image, false ); + imagesetpixel( $image, 0, 0, imagecolorallocatealpha( $image, 200, 100, 50, 64 ) ); + + $flipped = _flip_image_resource( $image, $horz, $vert ); + + $file = get_temp_dir() . wp_unique_filename( get_temp_dir(), 'flip-image-resource-alpha.png' ); + imagepng( $flipped, $file ); + $reloaded = imagecreatefrompng( $file ); + unlink( $file ); + + $this->assertSame( 64, ( imagecolorat( $reloaded, 0, 0 ) >> 24 ) & 0x7F, 'The alpha channel should be kept when saving as PNG.' ); + } } /** From 5adb4190977933966f9a4d403f0265815744bac3 Mon Sep 17 00:00:00 2001 From: sanjithsarkar Date: Thu, 5 Nov 2026 09:58:40 +0600 Subject: [PATCH 5/9] Tests: Declare new flip data providers as static. Matches the convention used by the other data providers in these files. See #66113. --- tests/phpunit/tests/image/editorGd.php | 4 ++-- tests/phpunit/tests/image/functions.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/tests/image/editorGd.php b/tests/phpunit/tests/image/editorGd.php index ba8eb239050f1..ef12b3cae8038 100644 --- a/tests/phpunit/tests/image/editorGd.php +++ b/tests/phpunit/tests/image/editorGd.php @@ -661,7 +661,7 @@ public function test_flip_moves_every_pixel( $horz, $vert ) { * * @return array[] */ - public function data_flip_moves_every_pixel() { + public static function data_flip_moves_every_pixel() { return array( 'no flip' => array( false, false ), 'vertical flip' => array( true, false ), @@ -778,7 +778,7 @@ public function test_maybe_exif_rotate_flips_image( $orientation, $mirror_x, $mi * * @return array[] */ - public function data_maybe_exif_rotate_flips_image() { + public static function data_maybe_exif_rotate_flips_image() { return array( 'orientation 2: mirrored horizontally' => array( 2, true, false ), 'orientation 3: upside down' => array( 3, true, true ), diff --git a/tests/phpunit/tests/image/functions.php b/tests/phpunit/tests/image/functions.php index ce8e24a39355a..1bd75404bbf7b 100644 --- a/tests/phpunit/tests/image/functions.php +++ b/tests/phpunit/tests/image/functions.php @@ -1264,7 +1264,7 @@ public function test_flip_image_resource( $horz, $vert ) { * * @return array[] */ - public function data_flip_image_resource() { + public static function data_flip_image_resource() { return array( 'no flip' => array( false, false ), 'vertical flip' => array( true, false ), From 50b71d403734560010a692e2abb5f8e9c41cd056 Mon Sep 17 00:00:00 2001 From: sanjithsarkar Date: Sat, 7 Nov 2026 04:01:56 +0600 Subject: [PATCH 6/9] Media: Reset resolution and interlace after flip, and address test feedback. `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. --- src/wp-admin/includes/image-edit.php | 12 +++- src/wp-includes/class-wp-image-editor-gd.php | 12 +++- tests/phpunit/tests/image/editorGd.php | 58 ++++++++++++--- tests/phpunit/tests/image/functions.php | 76 ++++++++++++++++---- 4 files changed, 130 insertions(+), 28 deletions(-) diff --git a/src/wp-admin/includes/image-edit.php b/src/wp-admin/includes/image-edit.php index e81e15e0e62d0..717307036f42c 100644 --- a/src/wp-admin/includes/image-edit.php +++ b/src/wp-admin/includes/image-edit.php @@ -565,7 +565,7 @@ function _rotate_image_resource( $img, $angle ) { * Flips an image resource. Internal use only. * * @since 2.9.0 - * @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. * @deprecated 3.5.0 Use WP_Image_Editor::flip() * @see WP_Image_Editor::flip() * @@ -591,6 +591,16 @@ function _flip_image_resource( $img, $horz, $vert ) { imagealphablending( $img, false ); 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 ); + if ( ! $horz && ! $vert ) { return $img; } diff --git a/src/wp-includes/class-wp-image-editor-gd.php b/src/wp-includes/class-wp-image-editor-gd.php index 9bc1df765ad69..05162b87401e7 100644 --- a/src/wp-includes/class-wp-image-editor-gd.php +++ b/src/wp-includes/class-wp-image-editor-gd.php @@ -432,7 +432,7 @@ public function rotate( $angle ) { * Flips current image. * * @since 3.5.0 - * @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. * * @param bool $horz Flip along Horizontal Axis. * @param bool $vert Flip along Vertical Axis. @@ -461,6 +461,16 @@ public function flip( $horz, $vert ) { imagealphablending( $this->image, false ); imagesavealpha( $this->image, true ); + /* + * 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 ); + if ( ! $horz && ! $vert ) { return true; } diff --git a/tests/phpunit/tests/image/editorGd.php b/tests/phpunit/tests/image/editorGd.php index be1bbdfb2d53b..a949baa411ac7 100644 --- a/tests/phpunit/tests/image/editorGd.php +++ b/tests/phpunit/tests/image/editorGd.php @@ -595,7 +595,9 @@ public function test_flip() { * * @ticket 66113 * - * @dataProvider data_flip_moves_every_pixel + * @dataProvider data_flip_axes + * + * @covers WP_Image_Editor_GD::flip * * @param bool $horz Whether to flip along the horizontal axis. * @param bool $vert Whether to flip along the vertical axis. @@ -658,11 +660,11 @@ public function test_flip_moves_every_pixel( $horz, $vert ) { } /** - * Data provider for test_flip_moves_every_pixel(). + * Data provider with every combination of flip axes. * * @return array[] */ - public static function data_flip_moves_every_pixel() { + public static function data_flip_axes() { return array( 'no flip' => array( false, false ), 'vertical flip' => array( true, false ), @@ -676,7 +678,9 @@ public static function data_flip_moves_every_pixel() { * * @ticket 66113 * - * @dataProvider data_flip_moves_every_pixel + * @dataProvider data_flip_axes + * + * @covers WP_Image_Editor_GD::flip * * @param bool $horz Whether to flip along the horizontal axis. * @param bool $vert Whether to flip along the vertical axis. @@ -708,8 +712,6 @@ public function test_flip_returns_error_for_invalid_image( $horz, $vert ) { * * @dataProvider data_maybe_exif_rotate_flips_image * - * @requires function exif_read_data - * * @covers WP_Image_Editor::maybe_exif_rotate * @covers WP_Image_Editor_GD::flip * @@ -796,7 +798,7 @@ public static function data_maybe_exif_rotate_flips_image() { * * @ticket 66113 * - * @dataProvider data_flip_moves_every_pixel + * @dataProvider data_flip_axes * * @covers WP_Image_Editor_GD::flip * @@ -856,8 +858,6 @@ public function test_flip_converts_palette_image_to_truecolor( $horz, $vert ) { * * @ticket 66113 * - * @requires function imagewebp - * * @covers WP_Image_Editor_GD::flip * @covers WP_Image_Editor_GD::save */ @@ -895,7 +895,7 @@ public function test_flipped_palette_image_can_be_saved_as_webp() { * * @ticket 66113 * - * @dataProvider data_flip_moves_every_pixel + * @dataProvider data_flip_axes * * @covers WP_Image_Editor_GD::flip * @@ -1018,7 +1018,7 @@ public function test_flip_both_axes_matches_rotate_180() { * * @ticket 66113 * - * @dataProvider data_flip_moves_every_pixel + * @dataProvider data_flip_axes * * @covers WP_Image_Editor_GD::flip * @@ -1043,6 +1043,42 @@ public function test_flip_disables_alpha_blending_after_rotate( $horz, $vert ) { $this->assertSame( 127, ( imagecolorat( $image, 0, 0 ) >> 24 ) & 0x7F, 'Alpha blending should be disabled after flipping.' ); } + /** + * Tests that flip() resets the resolution and interlace setting to GD's defaults. + * + * The previous implementation always copied into a new image, which starts + * at 96 DPI with interlacing disabled, like rotate(), crop() and resize(). + * + * @ticket 66113 + * + * @dataProvider data_flip_axes + * + * @covers WP_Image_Editor_GD::flip + * + * @param bool $horz Whether to flip along the horizontal axis. + * @param bool $vert Whether to flip along the vertical axis. + */ + public function test_flip_resets_resolution_and_interlace( $horz, $vert ) { + $gd_image_editor = new WP_Image_Editor_GD( DIR_TESTDATA . '/images/gradient-square.jpg' ); + $gd_image_editor->load(); + + $property = new ReflectionProperty( $gd_image_editor, 'image' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $image = $property->getValue( $gd_image_editor ); + + imageresolution( $image, 300, 150 ); + imageinterlace( $image, true ); + + $this->assertTrue( $gd_image_editor->flip( $horz, $vert ) ); + + $flipped = $property->getValue( $gd_image_editor ); + + $this->assertSame( array( 96, 96 ), imageresolution( $flipped ), 'The resolution should be reset to 96 DPI.' ); + $this->assertFalse( (bool) imageinterlace( $flipped ), 'Interlacing should be disabled.' ); + } + /** * Tests that an image created with WP_Image_Editor_GD preserves alpha with no resizing. * diff --git a/tests/phpunit/tests/image/functions.php b/tests/phpunit/tests/image/functions.php index 1bd75404bbf7b..2a42b1ae745b9 100644 --- a/tests/phpunit/tests/image/functions.php +++ b/tests/phpunit/tests/image/functions.php @@ -1277,6 +1277,8 @@ public static function data_flip_image_resource() { * Tests that _flip_image_resource() returns a truecolor image for a palette image. * * The previous implementation always copied into a new truecolor image. + * A small palette image with a distinct color per pixel is used, so every + * pixel can be checked at its mirrored position. * * @ticket 66113 * @@ -1294,29 +1296,40 @@ public static function data_flip_image_resource() { 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 ); + $w = 3; + $h = 2; + $color = static function ( $x, $y ) { + return ( ( $x * 40 + 10 ) << 16 ) | ( ( $y * 90 + 20 ) << 8 ) | 0x77; + }; - $this->assertFalse( imageistruecolor( $image ), 'The fixture should be a palette image.' ); + $image = imagecreate( $w, $h ); + for ( $y = 0; $y < $h; $y++ ) { + for ( $x = 0; $x < $w; $x++ ) { + $rgb = $color( $x, $y ); + imagesetpixel( $image, $x, $y, imagecolorallocate( $image, ( $rgb >> 16 ) & 0xFF, ( $rgb >> 8 ) & 0xFF, $rgb & 0xFF ) ); + } + } - $top_left = imagecolorsforindex( $image, imagecolorat( $image, 0, 0 ) ); - $bottom_left = imagecolorsforindex( $image, imagecolorat( $image, 0, $h - 1 ) ); + $this->assertFalse( imageistruecolor( $image ), 'The test image should be a palette image.' ); $flipped = _flip_image_resource( $image, $horz, $vert ); $this->assertTrue( imageistruecolor( $flipped ), 'The flipped image should be a truecolor image.' ); + $this->assertSame( $w, imagesx( $flipped ), 'The width should not change.' ); + $this->assertSame( $h, imagesy( $flipped ), 'The height should not change.' ); - $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']; + for ( $y = 0; $y < $h; $y++ ) { + for ( $x = 0; $x < $w; $x++ ) { + $source_x = $vert ? $w - 1 - $x : $x; + $source_y = $horz ? $h - 1 - $y : $y; - $this->assertSame( $expected, imagecolorat( $flipped, $target_x, $target_y ), 'The bottom-left pixel did not move to its mirrored position.' ); + $this->assertSame( + $color( $source_x, $source_y ), + imagecolorat( $flipped, $x, $y ), + "Pixel ($source_x,$source_y) did not move to ($x,$y)." + ); + } + } } /** @@ -1356,6 +1369,39 @@ public function test_flip_image_resource_enables_alpha_saving( $horz, $vert ) { $this->assertSame( 64, ( imagecolorat( $reloaded, 0, 0 ) >> 24 ) & 0x7F, 'The alpha channel should be kept when saving as PNG.' ); } + + /** + * Tests that _flip_image_resource() resets the resolution and interlace setting to GD's defaults. + * + * The previous implementation always copied into a new image, which starts + * at 96 DPI with interlacing disabled, like _rotate_image_resource() and + * _crop_image_resource(). + * + * @ticket 66113 + * + * @requires extension gd + * + * @expectedDeprecated _flip_image_resource + * + * @dataProvider data_flip_image_resource + * + * @covers ::_flip_image_resource + * + * @param bool $horz Whether to flip along the horizontal axis. + * @param bool $vert Whether to flip along the vertical axis. + */ + public function test_flip_image_resource_resets_resolution_and_interlace( $horz, $vert ) { + require_once ABSPATH . 'wp-admin/includes/image-edit.php'; + + $image = imagecreatetruecolor( 3, 2 ); + imageresolution( $image, 300, 150 ); + imageinterlace( $image, true ); + + $flipped = _flip_image_resource( $image, $horz, $vert ); + + $this->assertSame( array( 96, 96 ), imageresolution( $flipped ), 'The resolution should be reset to 96 DPI.' ); + $this->assertFalse( (bool) imageinterlace( $flipped ), 'Interlacing should be disabled.' ); + } } /** From 60063226d4ae91da18fd90f3037daea360d22147 Mon Sep 17 00:00:00 2001 From: sanjithsarkar Date: Sat, 7 Nov 2026 04:28:20 +0600 Subject: [PATCH 7/9] Media: Pass an integer to imageinterlace() in the flip functions. 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. --- src/wp-admin/includes/image-edit.php | 2 +- src/wp-includes/class-wp-image-editor-gd.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/includes/image-edit.php b/src/wp-admin/includes/image-edit.php index 717307036f42c..90ccab282283a 100644 --- a/src/wp-admin/includes/image-edit.php +++ b/src/wp-admin/includes/image-edit.php @@ -599,7 +599,7 @@ function _flip_image_resource( $img, $horz, $vert ) { * @link https://core.trac.wordpress.org/ticket/66113 */ imageresolution( $img, 96, 96 ); - imageinterlace( $img, false ); + imageinterlace( $img, 0 ); if ( ! $horz && ! $vert ) { return $img; diff --git a/src/wp-includes/class-wp-image-editor-gd.php b/src/wp-includes/class-wp-image-editor-gd.php index 05162b87401e7..10eed8178b938 100644 --- a/src/wp-includes/class-wp-image-editor-gd.php +++ b/src/wp-includes/class-wp-image-editor-gd.php @@ -469,7 +469,7 @@ public function flip( $horz, $vert ) { * @link https://core.trac.wordpress.org/ticket/66113 */ imageresolution( $this->image, 96, 96 ); - imageinterlace( $this->image, false ); + imageinterlace( $this->image, 0 ); if ( ! $horz && ! $vert ) { return true; From 0c1b1f53809c3eb27ad1f03b1cc8e8dc28dca817 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:17:15 -0500 Subject: [PATCH 8/9] Tests: Use the image editor property helpers in the new flip tests. Replace the inline `ReflectionProperty` setup in the new `WP_Image_Editor_GD::flip()` tests with the `get_property_value()` and `set_property_value()` helpers that were added to `WP_Image_UnitTestCase` in [65353]. Follow-up to [65353]. See #66113. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011Y86gx1T9qpKZuJWesSGiZ --- tests/phpunit/tests/image/editorGd.php | 67 ++++++-------------------- 1 file changed, 15 insertions(+), 52 deletions(-) diff --git a/tests/phpunit/tests/image/editorGd.php b/tests/phpunit/tests/image/editorGd.php index f80d90d055aae..705037836c5bc 100644 --- a/tests/phpunit/tests/image/editorGd.php +++ b/tests/phpunit/tests/image/editorGd.php @@ -609,19 +609,12 @@ public function test_flip_moves_every_pixel( $horz, $vert ) { $gd_image_editor = new WP_Image_Editor_GD( DIR_TESTDATA . '/images/gradient-square.jpg' ); $gd_image_editor->load(); - $property = new ReflectionProperty( $gd_image_editor, 'image' ); - if ( PHP_VERSION_ID < 80100 ) { - $property->setAccessible( true ); - } - $property->setValue( $gd_image_editor, $image ); + $this->set_property_value( $gd_image_editor, 'image', $image ); // Keep the stored size in sync with the replaced image. - $size_property = new ReflectionProperty( $gd_image_editor, 'size' ); - if ( PHP_VERSION_ID < 80100 ) { - $size_property->setAccessible( true ); - } - $size_property->setValue( + $this->set_property_value( $gd_image_editor, + 'size', array( 'width' => $w, 'height' => $h, @@ -630,7 +623,7 @@ public function test_flip_moves_every_pixel( $horz, $vert ) { $this->assertTrue( $gd_image_editor->flip( $horz, $vert ) ); - $flipped = $property->getValue( $gd_image_editor ); + $flipped = $this->get_property_value( $gd_image_editor ); $this->assertSame( $w, imagesx( $flipped ), 'The width should not change.' ); $this->assertSame( $h, imagesy( $flipped ), 'The height should not change.' ); @@ -679,11 +672,7 @@ public function test_flip_returns_error_for_invalid_image( $horz, $vert ) { $gd_image_editor = new WP_Image_Editor_GD( DIR_TESTDATA . '/images/gradient-square.jpg' ); $gd_image_editor->load(); - $property = new ReflectionProperty( $gd_image_editor, 'image' ); - if ( PHP_VERSION_ID < 80100 ) { - $property->setAccessible( true ); - } - $property->setValue( $gd_image_editor, false ); + $this->set_property_value( $gd_image_editor, 'image', false ); $result = $gd_image_editor->flip( $horz, $vert ); @@ -721,12 +710,7 @@ public function test_maybe_exif_rotate_flips_image( $orientation, $mirror_x, $mi $gd_image_editor = new WP_Image_Editor_GD( $file ); $gd_image_editor->load(); - $property = new ReflectionProperty( $gd_image_editor, 'image' ); - if ( PHP_VERSION_ID < 80100 ) { - $property->setAccessible( true ); - } - - $original = $property->getValue( $gd_image_editor ); + $original = $this->get_property_value( $gd_image_editor ); $w = imagesx( $original ); $h = imagesy( $original ); @@ -752,7 +736,7 @@ public function test_maybe_exif_rotate_flips_image( $orientation, $mirror_x, $mi 'Flipping should not change the size.' ); - $flipped = $property->getValue( $gd_image_editor ); + $flipped = $this->get_property_value( $gd_image_editor ); foreach ( $points as list( $x, $y, $color ) ) { $target_x = $mirror_x ? $w - 1 - $x : $x; @@ -799,12 +783,7 @@ public function test_flip_converts_palette_image_to_truecolor( $horz, $vert ) { $gd_image_editor = new WP_Image_Editor_GD( DIR_TESTDATA . '/images/test-image.gif' ); $gd_image_editor->load(); - $property = new ReflectionProperty( $gd_image_editor, 'image' ); - if ( PHP_VERSION_ID < 80100 ) { - $property->setAccessible( true ); - } - - $image = $property->getValue( $gd_image_editor ); + $image = $this->get_property_value( $gd_image_editor ); $w = imagesx( $image ); $h = imagesy( $image ); @@ -822,7 +801,7 @@ public function test_flip_converts_palette_image_to_truecolor( $horz, $vert ) { $this->assertTrue( $gd_image_editor->flip( $horz, $vert ) ); - $flipped = $property->getValue( $gd_image_editor ); + $flipped = $this->get_property_value( $gd_image_editor ); $this->assertTrue( imageistruecolor( $flipped ), 'The flipped image should be a truecolor image.' ); @@ -922,11 +901,7 @@ public function test_flip_keeps_alpha_channel( $horz, $vert ) { $this->assertTrue( $loaded, 'The test image should load.' ); $this->assertTrue( $gd_image_editor->flip( $horz, $vert ) ); - $property = new ReflectionProperty( $gd_image_editor, 'image' ); - if ( PHP_VERSION_ID < 80100 ) { - $property->setAccessible( true ); - } - $flipped = $property->getValue( $gd_image_editor ); + $flipped = $this->get_property_value( $gd_image_editor ); $save_to_file = get_temp_dir() . wp_unique_filename( get_temp_dir(), 'flipped-alpha.png' ); $saved = $gd_image_editor->save( $save_to_file, 'image/png' ); @@ -976,12 +951,8 @@ public function test_flip_both_axes_matches_rotate_180() { $this->assertSame( $rotated_editor->get_size(), $flipped_editor->get_size(), 'The sizes should match.' ); - $property = new ReflectionProperty( WP_Image_Editor_GD::class, 'image' ); - if ( PHP_VERSION_ID < 80100 ) { - $property->setAccessible( true ); - } - $flipped = $property->getValue( $flipped_editor ); - $rotated = $property->getValue( $rotated_editor ); + $flipped = $this->get_property_value( $flipped_editor ); + $rotated = $this->get_property_value( $rotated_editor ); $w = imagesx( $flipped ); $h = imagesy( $flipped ); @@ -1022,11 +993,7 @@ public function test_flip_disables_alpha_blending_after_rotate( $horz, $vert ) { $this->assertTrue( $gd_image_editor->rotate( 90 ) ); $this->assertTrue( $gd_image_editor->flip( $horz, $vert ) ); - $property = new ReflectionProperty( $gd_image_editor, 'image' ); - if ( PHP_VERSION_ID < 80100 ) { - $property->setAccessible( true ); - } - $image = $property->getValue( $gd_image_editor ); + $image = $this->get_property_value( $gd_image_editor ); imagesetpixel( $image, 0, 0, imagecolorallocatealpha( $image, 0, 0, 0, 127 ) ); @@ -1052,18 +1019,14 @@ public function test_flip_resets_resolution_and_interlace( $horz, $vert ) { $gd_image_editor = new WP_Image_Editor_GD( DIR_TESTDATA . '/images/gradient-square.jpg' ); $gd_image_editor->load(); - $property = new ReflectionProperty( $gd_image_editor, 'image' ); - if ( PHP_VERSION_ID < 80100 ) { - $property->setAccessible( true ); - } - $image = $property->getValue( $gd_image_editor ); + $image = $this->get_property_value( $gd_image_editor ); imageresolution( $image, 300, 150 ); imageinterlace( $image, true ); $this->assertTrue( $gd_image_editor->flip( $horz, $vert ) ); - $flipped = $property->getValue( $gd_image_editor ); + $flipped = $this->get_property_value( $gd_image_editor ); $this->assertSame( array( 96, 96 ), imageresolution( $flipped ), 'The resolution should be reset to 96 DPI.' ); $this->assertFalse( (bool) imageinterlace( $flipped ), 'Interlacing should be disabled.' ); From b6f6119f6cebad37c58cc777737247a3c1d916b5 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers <359867+desrosj@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:27:56 -0500 Subject: [PATCH 9/9] Tests: Cover alpha blending after `_flip_image_resource()`. The previous implementation always returned a new image with alpha blending disabled. `_flip_image_resource()` now flips in place and disables blending explicitly, but no test failed when that reset was removed: the alpha saving test disables blending before flipping, and the pixel test does not check it. New truecolor images have alpha blending enabled, so the new test flips one for every axis combination and checks that a fully transparent pixel replaces the existing pixel instead of being blended into it. See #66113. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011Y86gx1T9qpKZuJWesSGiZ --- tests/phpunit/tests/image/functions.php | 36 +++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/phpunit/tests/image/functions.php b/tests/phpunit/tests/image/functions.php index 2a42b1ae745b9..d55937eadf1f3 100644 --- a/tests/phpunit/tests/image/functions.php +++ b/tests/phpunit/tests/image/functions.php @@ -1370,6 +1370,42 @@ public function test_flip_image_resource_enables_alpha_saving( $horz, $vert ) { $this->assertSame( 64, ( imagecolorat( $reloaded, 0, 0 ) >> 24 ) & 0x7F, 'The alpha channel should be kept when saving as PNG.' ); } + /** + * Tests that _flip_image_resource() returns an image with alpha blending disabled. + * + * The previous implementation always returned a new image with alpha blending + * disabled, even if the input had it enabled and no axis was flipped. + * + * Alpha blending only affects drawing onto the image, so it is detected by + * drawing a fully transparent pixel: with blending disabled, it replaces the + * existing pixel instead of being blended into it. + * + * @ticket 66113 + * + * @requires extension gd + * + * @expectedDeprecated _flip_image_resource + * + * @dataProvider data_flip_image_resource + * + * @covers ::_flip_image_resource + * + * @param bool $horz Whether to flip along the horizontal axis. + * @param bool $vert Whether to flip along the vertical axis. + */ + public function test_flip_image_resource_disables_alpha_blending( $horz, $vert ) { + require_once ABSPATH . 'wp-admin/includes/image-edit.php'; + + // New truecolor images have alpha blending enabled. + $image = imagecreatetruecolor( 3, 2 ); + + $flipped = _flip_image_resource( $image, $horz, $vert ); + + imagesetpixel( $flipped, 0, 0, imagecolorallocatealpha( $flipped, 0, 0, 0, 127 ) ); + + $this->assertSame( 127, ( imagecolorat( $flipped, 0, 0 ) >> 24 ) & 0x7F, 'Alpha blending should be disabled after flipping.' ); + } + /** * Tests that _flip_image_resource() resets the resolution and interlace setting to GD's defaults. *