Skip to content

Fixes palette-based PNG uploads failing original full-size AVIF/WebP conversion under GD #2024

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions plugins/webp-uploads/hooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -803,3 +803,77 @@
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.
Copy link
Member

Choose a reason for hiding this comment

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

I wonder if this fix belongs in core itself sice we inherently support this type of conversion with a simple filter. If we don't fix automatically, at the very least, we should inform the user why the upload failed and how to fix it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I located the Trac ticket: #59339 "Conversion to webp causes fatal error when original image is a palette image (as opposed to truecolor)"

Should we post an update there and start the discussion on where to go next?

* 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();

Check warning on line 820 in plugins/webp-uploads/hooks.php

View check run for this annotation

Codecov / codecov/patch

plugins/webp-uploads/hooks.php#L820

Added line #L820 was not covered by tests
}
if ( ! isset( $file['tmp_name'], $file['name'] ) ) {
return $file;

Check warning on line 823 in plugins/webp-uploads/hooks.php

View check run for this annotation

Codecov / codecov/patch

plugins/webp-uploads/hooks.php#L823

Added line #L823 was not covered by tests
}
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;

Check warning on line 830 in plugins/webp-uploads/hooks.php

View check run for this annotation

Codecov / codecov/patch

plugins/webp-uploads/hooks.php#L829-L830

Added lines #L829 - L830 were not covered by tests
}

$editor = wp_get_image_editor( $file['tmp_name'] );

if ( is_wp_error( $editor ) || ! $editor instanceof WP_Image_Editor_GD ) {
return $file;

Check warning on line 836 in plugins/webp-uploads/hooks.php

View check run for this annotation

Codecov / codecov/patch

plugins/webp-uploads/hooks.php#L836

Added line #L836 was not covered by tests
}

// Check if required GD functions exist.
if (
! function_exists( 'imagecreatefrompng' ) ||
Copy link
Member

Choose a reason for hiding this comment

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

+1 to defensive programming; however all of these functions have been around since PHP4/5 as as far as I know should be available to all WordPress installs with GD. So maybe we can remove this check?

Copy link
Member

Choose a reason for hiding this comment

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

That would address the code coverage issue as well, if it is impossible to create a scenario where any of these functions don't exist.

Copy link
Member

Choose a reason for hiding this comment

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

Oh, but actually will these functions be available if GD is not installed? If PHP is not compiled with --with-gd then I don't think they will.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If PHP is not compiled with GD, then these functions will not be available. I compiled PHP with and without GD and tested it; the version without GD did not have these functions.
Screenshot 2025-06-10 at 8 33 05 PM

! function_exists( 'imageistruecolor' ) ||
! function_exists( 'imagealphablending' ) ||
! function_exists( 'imagesavealpha' ) ||
! function_exists( 'imagepalettetotruecolor' ) ||
! function_exists( 'imagepng' ) ||
! function_exists( 'imagedestroy' )
) {
return $file;

Check warning on line 849 in plugins/webp-uploads/hooks.php

View check run for this annotation

Codecov / codecov/patch

plugins/webp-uploads/hooks.php#L849

Added line #L849 was not covered by tests
}

$image = imagecreatefrompng( $file['tmp_name'] );

// Check if the image was created successfully.
if ( false === $image ) {
return $file;

Check warning on line 856 in plugins/webp-uploads/hooks.php

View check run for this annotation

Codecov / codecov/patch

plugins/webp-uploads/hooks.php#L856

Added line #L856 was not covered by tests
}

// 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;
Copy link
Member

Choose a reason for hiding this comment

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

Should we set $file['name'] to the new file name before returning $file?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If you're asking whether the conversion has changed the name of the file, it has not - we simply overwrote the same file with the converted version.

However, if you're suggesting that users should see a different filename when an image is converted to truecolor, we could add a suffix like -truecolor to the filename. But I'm not sure whether we should do that, since this conversion is meant to be a transparent internal optimization rather than a user-visible change.

}
add_filter( 'wp_handle_upload_prefilter', 'webp_uploads_convert_palette_png_to_truecolor' );
Copy link
Member

Choose a reason for hiding this comment

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

You're already using this in the test, but wouldn't it make sense to also add the same function as a hook callback to wp_handle_sideload_prefilter?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, it makes sense. At first, I thought wp_handle_sideload and media_handle_sideload, which trigger the wp_handle_sideload_prefilter, were not used in core, but after some digging, I found that core does trigger this filter.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in 8ad675c

add_filter( 'wp_handle_sideload_prefilter', 'webp_uploads_convert_palette_png_to_truecolor' );

Check warning on line 879 in plugins/webp-uploads/hooks.php

View check run for this annotation

Codecov / codecov/patch

plugins/webp-uploads/hooks.php#L878-L879

Added lines #L878 - L879 were not covered by tests
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
112 changes: 112 additions & 0 deletions plugins/webp-uploads/tests/test-load.php
Original file line number Diff line number Diff line change
Expand Up @@ -1116,4 +1116,116 @@ 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,
),
);
}
}