diff --git a/src/wp-admin/includes/image-edit.php b/src/wp-admin/includes/image-edit.php index 2f6bc25740e2d..90ccab282283a 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 Switched to using imageflip() instead of imagecopyresampled() with negative dimensions. * @deprecated 3.5.0 Use WP_Image_Editor::flip() * @see WP_Image_Editor::flip() * @@ -577,25 +578,43 @@ 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( $img ) ) { + return $img; + } - if ( is_gd_image( $dst ) ) { - $sx = $vert ? ( $w - 1 ) : 0; - $sy = $horz ? ( $h - 1 ) : 0; - $sw = $vert ? -$w : $w; - $sh = $horz ? -$h : $h; + // Keep returning a truecolor image, like the previous implementation did. imagewebp() rejects palette images. + if ( ! imageistruecolor( $img ) && ! imagepalettetotruecolor( $img ) ) { + 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 ); - } + // Match the alpha settings of the previous implementation's copy, including when no axis is flipped. + imagealphablending( $img, false ); + imagesavealpha( $img, true ); - $img = $dst; - } + /* + * 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, 0 ); + + if ( ! $horz && ! $vert ) { + return $img; } + if ( $horz && $vert ) { + $mode = IMG_FLIP_BOTH; + } elseif ( $horz ) { + $mode = IMG_FLIP_VERTICAL; + } else { + $mode = IMG_FLIP_HORIZONTAL; + } + + 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..10eed8178b938 100644 --- a/src/wp-includes/class-wp-image-editor-gd.php +++ b/src/wp-includes/class-wp-image-editor-gd.php @@ -432,31 +432,59 @@ public function rotate( $angle ) { * Flips current image. * * @since 3.5.0 + * @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. * @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( $this->image ) ) { + return new WP_Error( 'image_flip_error', __( 'Image flip failed.' ), $this->file ); + } - if ( is_gd_image( $dst ) ) { - $sx = $vert ? ( $w - 1 ) : 0; - $sy = $horz ? ( $h - 1 ) : 0; - $sw = $vert ? -$w : $w; - $sh = $horz ? -$h : $h; + /* + * 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 ( 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 ); - } + /* + * 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 ); - $this->image = $dst; + /* + * 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, 0 ); - return true; - } + if ( ! $horz && ! $vert ) { + return true; + } + + if ( $horz && $vert ) { + $mode = IMG_FLIP_BOTH; + } elseif ( $horz ) { + $mode = IMG_FLIP_VERTICAL; + } else { + $mode = IMG_FLIP_HORIZONTAL; + } + + 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 1df7f5a570be0..a949baa411ac7 100644 --- a/tests/phpunit/tests/image/editorGd.php +++ b/tests/phpunit/tests/image/editorGd.php @@ -587,6 +587,498 @@ 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_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_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' ); + $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 ); + + // 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 ); + + $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 with every combination of flip axes. + * + * @return array[] + */ + public static function data_flip_axes() { + 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_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_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 ); + + $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 + * + * @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 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 ), + '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_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_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 + * + * @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_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_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 + * + * @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_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( $horz, $vert ) ); + + $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.' ); + } + + /** + * 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 647c96d6e761f..2a42b1ae745b9 100644 --- a/tests/phpunit/tests/image/functions.php +++ b/tests/phpunit/tests/image/functions.php @@ -1203,6 +1203,205 @@ public static 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 + * + * @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( $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 static 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 ), + ); + } + + /** + * 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 + * + * @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'; + + $w = 3; + $h = 2; + $color = static function ( $x, $y ) { + return ( ( $x * 40 + 10 ) << 16 ) | ( ( $y * 90 + 20 ) << 8 ) | 0x77; + }; + + $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 ) ); + } + } + + $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.' ); + + 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)." + ); + } + } + } + + /** + * 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.' ); + } + + /** + * 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.' ); + } } /**