diff --git a/plugins/webp-uploads/hooks.php b/plugins/webp-uploads/hooks.php index 98a27aa485..fd8c6833af 100644 --- a/plugins/webp-uploads/hooks.php +++ b/plugins/webp-uploads/hooks.php @@ -803,3 +803,77 @@ function webp_uploads_enable_additional_mime_type_support_for_all_sizes( array $ return $allowed_sizes; } add_filter( 'webp_uploads_image_sizes_with_additional_mime_type_support', 'webp_uploads_enable_additional_mime_type_support_for_all_sizes' ); + +/** + * Converts palette PNG images to truecolor PNG images. + * + * GD cannot convert palette-based PNG to WebP/AVIF formats, causing conversion failures. + * This function detects and converts palette PNG to truecolor during upload. + * + * @since n.e.x.t + * + * @param array<string, mixed>|mixed $file The uploaded file data. + * @return array<string, mixed> The modified file data. + */ +function webp_uploads_convert_palette_png_to_truecolor( $file ): array { + if ( ! is_array( $file ) ) { + $file = array(); + } + if ( ! isset( $file['tmp_name'], $file['name'] ) ) { + return $file; + } + if ( isset( $file['type'] ) && is_string( $file['type'] ) ) { + if ( 'image/png' !== strtolower( $file['type'] ) ) { + return $file; + } + } elseif ( 'image/png' !== wp_check_filetype( $file['name'] )['type'] ) { + return $file; + } + + $editor = wp_get_image_editor( $file['tmp_name'] ); + + if ( is_wp_error( $editor ) || ! $editor instanceof WP_Image_Editor_GD ) { + return $file; + } + + // Check if required GD functions exist. + if ( + ! function_exists( 'imagecreatefrompng' ) || + ! function_exists( 'imageistruecolor' ) || + ! function_exists( 'imagealphablending' ) || + ! function_exists( 'imagesavealpha' ) || + ! function_exists( 'imagepalettetotruecolor' ) || + ! function_exists( 'imagepng' ) || + ! function_exists( 'imagedestroy' ) + ) { + return $file; + } + + $image = imagecreatefrompng( $file['tmp_name'] ); + + // Check if the image was created successfully. + if ( false === $image ) { + return $file; + } + + // Check if the image is already truecolor. + if ( imageistruecolor( $image ) ) { + imagedestroy( $image ); + return $file; + } + + // Preserve transparency. + imagealphablending( $image, false ); + imagesavealpha( $image, true ); + + // Convert the palette to truecolor. + if ( imagepalettetotruecolor( $image ) ) { + // Overwrite the upload with the new truecolor PNG. + imagepng( $image, $file['tmp_name'] ); + } + imagedestroy( $image ); + + return $file; +} +add_filter( 'wp_handle_upload_prefilter', 'webp_uploads_convert_palette_png_to_truecolor' ); +add_filter( 'wp_handle_sideload_prefilter', 'webp_uploads_convert_palette_png_to_truecolor' ); diff --git a/plugins/webp-uploads/tests/data/images/dice-palette.png b/plugins/webp-uploads/tests/data/images/dice-palette.png new file mode 100644 index 0000000000..e269d0996a Binary files /dev/null and b/plugins/webp-uploads/tests/data/images/dice-palette.png differ diff --git a/plugins/webp-uploads/tests/test-load.php b/plugins/webp-uploads/tests/test-load.php index b78048f78b..0459060bb8 100644 --- a/plugins/webp-uploads/tests/test-load.php +++ b/plugins/webp-uploads/tests/test-load.php @@ -1116,4 +1116,152 @@ public function test_that_it_should_convert_webp_to_avif_on_upload(): void { } wp_delete_attachment( $attachment_id ); } + + /** + * Tests that the `webp_uploads_convert_palette_png_to_truecolor` function is hooked to the upload filters. + */ + public function test_webp_uploads_convert_palette_png_to_truecolor_hooks(): void { + $this->assertSame( 10, has_filter( 'wp_handle_upload_prefilter', 'webp_uploads_convert_palette_png_to_truecolor' ) ); + $this->assertSame( 10, has_filter( 'wp_handle_sideload_prefilter', 'webp_uploads_convert_palette_png_to_truecolor' ) ); + } + + /** + * Tests converting a palette PNG to a truecolor PNG. + * + * @dataProvider data_to_test_webp_uploads_convert_palette_png_to_truecolor + * + * @covers ::webp_uploads_convert_palette_png_to_truecolor + * + * @param string|null $image_path The path to the image file to test. + * @param bool $expect_changed Whether the png should be converted to truecolor. + */ + public function test_webp_uploads_convert_palette_png_to_truecolor( ?string $image_path, bool $expect_changed ): void { + add_filter( + 'wp_image_editors', + static function () { + return array( 'WP_Image_Editor_GD' ); + } + ); + + // Temp file will be copied and unlinked by WordPress core during sideload processing. + $tmp_file = wp_tempnam(); + copy( $image_path, $tmp_file ); + $file = array( + 'name' => basename( $image_path ), + 'tmp_name' => $tmp_file, + 'type' => wp_check_filetype( $image_path )['type'], + 'size' => filesize( $tmp_file ), + 'error' => UPLOAD_ERR_OK, + ); + + // Store the original file hash and the original file size for later comparison. + $original_file_hash = isset( $file['tmp_name'] ) ? md5_file( $file['tmp_name'] ) : ''; + $original_file_size = (int) filesize( $file['tmp_name'] ); + + // This will trigger the `wp_handle_sideload_prefilter` filter. + $attachment_id = media_handle_sideload( $file ); + + try { + $this->assertIsNumeric( $attachment_id ); + + // For getting a original image path for computation of the file hash. + $meta = wp_get_attachment_metadata( $attachment_id ); + $upload_dir = wp_get_upload_dir(); + $path = null; + if ( isset( $meta['original_image'], $meta['file'] ) ) { + $path = path_join( + $upload_dir['basedir'], + dirname( $meta['file'] ) . '/' . $meta['original_image'] + ); + } + $this->assertNotNull( $path ); + $this->assertFileExists( $path ); + + // Hash will be modified if the image was converted to truecolor. + $modified_file_hash = md5_file( $path ); + + if ( ! $expect_changed ) { + $this->assertSame( $original_file_hash, $modified_file_hash ); + } else { + $this->assertNotSame( $original_file_hash, $modified_file_hash ); + $img = imagecreatefrompng( $path ); + $this->assertTrue( imageistruecolor( $img ) ); + imagedestroy( $img ); + + // Make sure the image converted to modern image format is not 0 bytes. + $modern_image_format_path = get_attached_file( $attachment_id ); + $this->assertNotFalse( $modern_image_format_path ); + $this->assertFileExists( $modern_image_format_path ); + $modern_image_format_filesize = (int) filesize( $modern_image_format_path ); + $this->assertGreaterThan( 0, $modern_image_format_filesize ); + + // Ensure the file size of the converted image is less than or equal to the original indexed PNG file size. + $this->assertLessThanOrEqual( $original_file_size, $modern_image_format_filesize ); + } + } finally { + wp_delete_attachment( $attachment_id ); + } + } + + /** + * Data provider for `test_webp_uploads_convert_palette_png_to_truecolor`. + * + * @return array<string, mixed> Returns an array of test cases. + */ + public function data_to_test_webp_uploads_convert_palette_png_to_truecolor(): array { + $non_palette_png = TESTS_PLUGIN_DIR . '/tests/data/images/dice.png'; + $palette_png = TESTS_PLUGIN_DIR . '/tests/data/images/dice-palette.png'; + $test_jpg = TESTS_PLUGIN_DIR . '/tests/data/images/leaves.jpg'; + + return array( + 'wrong_extension' => array( + 'image_path' => $test_jpg, + 'expected_changed' => false, + ), + 'non_palette_png' => array( + 'image_path' => $non_palette_png, + 'expected_changed' => false, + ), + 'palette_png' => array( + 'image_path' => $palette_png, + 'expected_changed' => true, + ), + ); + } + + /** + * Tests the webp_uploads_convert_palette_png_to_truecolor function with various conditions. + * + * @covers ::webp_uploads_convert_palette_png_to_truecolor + */ + public function test_webp_uploads_convert_palette_png_to_truecolor_conditions(): void { + $this->assertSameSets( array(), webp_uploads_convert_palette_png_to_truecolor( 'test' ) ); + $this->assertSameSets( array(), webp_uploads_convert_palette_png_to_truecolor( array() ) ); + + $file = array( + 'tmp_name' => TESTS_PLUGIN_DIR . '/tests/data/images/leaves.jpg', + 'name' => 'leaves.jpg', + 'type' => 'image/jpeg', + ); + $this->assertSameSets( $file, webp_uploads_convert_palette_png_to_truecolor( $file ) ); + + $file = array( + 'tmp_name' => TESTS_PLUGIN_DIR . '/tests/data/images/leaves.jpg', + 'name' => 'leaves.jpg', + ); + $this->assertSameSets( $file, webp_uploads_convert_palette_png_to_truecolor( $file ) ); + + add_filter( + 'wp_image_editors', + static function () { + return array(); + } + ); + $file = array( + 'tmp_name' => TESTS_PLUGIN_DIR . '/tests/data/images/dice-palette.png', + 'name' => 'dice-palette.png', + 'type' => 'image/png', + ); + $this->assertSameSets( $file, webp_uploads_convert_palette_png_to_truecolor( $file ) ); + } }