From e3f315c9c518bc4d458dd5d34d1b7790f32f9f2f Mon Sep 17 00:00:00 2001 From: sanjithsarkar Date: Fri, 2 Oct 2026 14:20:32 +0600 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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;