diff --git a/src/class-tiny-compress-client.php b/src/class-tiny-compress-client.php
index e6e7897..5bbf8c9 100644
--- a/src/class-tiny-compress-client.php
+++ b/src/class-tiny-compress-client.php
@@ -88,7 +88,7 @@ protected function validate() {
}
}
- protected function compress( $input, $resize_opts, $preserve_opts, $convert_opts ) {
+ protected function compress( $input, $resize_opts, $preserve_opts, $convert_to ) {
try {
$this->last_error_code = 0;
$this->set_request_options( \Tinify\Tinify::getClient() );
@@ -121,8 +121,7 @@ protected function compress( $input, $resize_opts, $preserve_opts, $convert_opts
$buffer = $compress_result->toBuffer();
$result = array( $buffer, $meta, null );
- if ( isset( $convert_opts['convert'] ) && true == $convert_opts['convert'] ) {
- $convert_to = $convert_opts['convert_to'];
+ if ( count( $convert_to ) > 0 ) {
$convert_source = $source->convert( array(
'type' => $convert_to,
) );
diff --git a/src/class-tiny-compress-fopen.php b/src/class-tiny-compress-fopen.php
index 0d2b751..4e7a9cd 100644
--- a/src/class-tiny-compress-fopen.php
+++ b/src/class-tiny-compress-fopen.php
@@ -82,7 +82,7 @@ protected function validate() {
}
}
- protected function compress( $input, $resize_opts, $preserve_opts, $convert_opts ) {
+ protected function compress( $input, $resize_opts, $preserve_opts, $convert_to ) {
$params = $this->request_options( 'POST', $input );
list($details, $headers, $status_code) = $this->request( $params );
@@ -146,8 +146,7 @@ protected function compress( $input, $resize_opts, $preserve_opts, $convert_opts
$convert = null;
- if ( isset( $convert_opts['convert'] ) && true === $convert_opts['convert'] ) {
- $convert_to = $convert_opts['convert_to'];
+ if ( count( $convert_to ) > 0 ) {
$convert_params = $this->request_options(
'POST',
array(
diff --git a/src/class-tiny-compress.php b/src/class-tiny-compress.php
index fc07cdd..931d14b 100644
--- a/src/class-tiny-compress.php
+++ b/src/class-tiny-compress.php
@@ -99,14 +99,14 @@ public function get_status() {
* @param [type] $file
* @param array $resize_opts
* @param array $preserve_opts
- * @param array{ convert: bool, convert_to: string } conversion options
+ * @param array{ string } conversion options
* @return void
*/
public function compress_file(
$file,
$resize_opts = array(),
$preserve_opts = array(),
- $convert_opts = array()
+ $convert_to = array()
) {
if ( $this->get_key() == null ) {
throw new Tiny_Exception( self::KEY_MISSING, 'KeyError' );
@@ -131,7 +131,7 @@ public function compress_file(
$file_data,
$resize_opts,
$preserve_opts,
- $convert_opts
+ $convert_to
);
} catch ( Tiny_Exception $err ) {
$this->call_after_compress_callback();
@@ -172,7 +172,7 @@ protected abstract function compress(
$input,
$resize_options,
$preserve_options,
- $convert_opts
+ $convert_to
);
protected static function identifier() {
diff --git a/src/class-tiny-image.php b/src/class-tiny-image.php
index ee9e522..918664e 100644
--- a/src/class-tiny-image.php
+++ b/src/class-tiny-image.php
@@ -21,6 +21,7 @@
class Tiny_Image {
const ORIGINAL = 0;
+ /** @var Tiny_Settings */
private $settings;
private $id;
private $name;
@@ -179,7 +180,6 @@ public function compress() {
$success = 0;
$failed = 0;
- $compressor = $this->settings->get_compressor();
$active_tinify_sizes = $this->settings->get_active_tinify_sizes();
if ( $this->settings->get_conversion_enabled() ) {
@@ -191,20 +191,28 @@ public function compress() {
$unprocessed_sizes = $this->filter_image_sizes( 'uncompressed', $active_tinify_sizes );
}
+ $compressor = $this->settings->get_compressor();
+ $convert_to = $this->convert_to();
+
foreach ( $unprocessed_sizes as $size_name => $size ) {
if ( ! $size->is_duplicate() ) {
$size->add_tiny_meta_start();
$this->update_tiny_post_meta();
$resize = $this->settings->get_resize_options( $size_name );
$preserve = $this->settings->get_preserve_options( $size_name );
- $convert_opts = $this->settings->get_conversion_options();
try {
$response = $compressor->compress_file(
$size->filename,
$resize,
$preserve,
- $convert_opts
+ $convert_to
);
+
+ // ensure that all conversion are in the same format as the first one
+ $convert_to = isset( $response['convert'] ) ?
+ array( $response['convert']['type'] ) :
+ $convert_to;
+
$size->add_tiny_meta( $response );
$success++;
} catch ( Tiny_Exception $e ) {
@@ -243,17 +251,19 @@ public function compress_retina( $size_name, $path ) {
if ( ! isset( $this->sizes[ $size_name ] ) ) {
$this->sizes[ $size_name ] = new Tiny_Image_Size( $path );
}
+
$size = $this->sizes[ $size_name ];
+ $compressor = $this->settings->get_compressor();
+ $convert_to = $this->convert_to();
+
if ( ! $size->has_been_compressed() ) {
$size->add_tiny_meta_start();
$this->update_tiny_post_meta();
- $compressor = $this->settings->get_compressor();
$preserve = $this->settings->get_preserve_options( $size_name );
- $conversion = $this->settings->get_conversion_options();
try {
- $response = $compressor->compress_file( $path, false, $preserve, $conversion );
+ $response = $compressor->compress_file( $path, false, $preserve, $convert_to );
$size->add_tiny_meta( $response );
} catch ( Tiny_Exception $e ) {
$size->add_tiny_meta_error( $e );
@@ -472,6 +482,37 @@ public function can_be_converted() {
return $this->settings->get_conversion_enabled() && $this->file_type_allowed();
}
+ /**
+ * Get the targeted conversion.
+ * If original is already converted, then we use the originals' mimetype.
+ * If nothing is converted yet, we use the settings conversion settings.
+ *
+ * @since 3.6.4
+ *
+ * @return array{string} mimetypes to which the image should be converted to
+ */
+ private function convert_to() {
+ $convert_settings = $this->settings->get_conversion_options();
+ if ( ! $convert_settings['convert'] ) {
+ // conversion is off so return no mimetypes to convert to
+ return array();
+ }
+
+ if ( isset( $this->sizes[ self::ORIGINAL ] ) ) {
+ // original is not in sizes so mimetypes are open
+ return $convert_settings['convert_to'];
+ }
+
+ $original_img_size = $this->sizes[ self::ORIGINAL ];
+ if ( $original_img_size->converted() ) {
+ // original has been convert so use that mimetype to convert to
+ return array( $original_img_size->meta['convert']['type'] );
+ }
+
+ return $convert_settings['convert_to'];
+
+ }
+
/**
* Marks the image as compressed without actually compressing it.
*
diff --git a/src/class-tiny-picture.php b/src/class-tiny-picture.php
index fbb05ea..3544096 100644
--- a/src/class-tiny-picture.php
+++ b/src/class-tiny-picture.php
@@ -306,7 +306,7 @@ protected function get_image_srcsets( $html ) {
// Trim whitespace
$entry = trim( $entry );
- // Split by whitespace to separate path and size descriptor
+ // Split by whitespace to separate path and size/density descriptor
$parts = preg_split( '/\s+/', $entry, 2 );
if ( count( $parts ) === 2 ) {
@@ -316,7 +316,8 @@ protected function get_image_srcsets( $html ) {
'size' => $parts[1],
);
} elseif ( count( $parts ) === 1 ) {
- // We only have a path (unusual in srcset)
+ // We only have a path, will be interpreted as pixel
+ // density 1x (unusual in srcset)
$result[] = array(
'path' => $parts[0],
'size' => '',
@@ -324,16 +325,24 @@ protected function get_image_srcsets( $html ) {
}
}
}
+ return $result;
+ }
+ /**
+ * Retrieves the sources from the
or element
+ *
+ * @return array{path: string, size: string}[] The image sources
+ */
+ private function get_image_src( $html ) {
$source = $this::get_attribute_value( $html, 'src' );
if ( ! empty( $source ) ) {
// No srcset, but we have a src attribute
- $result[] = array(
+ return array(
'path' => $source,
'size' => '',
);
}
- return $result;
+ return array();
}
@@ -346,6 +355,11 @@ protected function get_image_srcsets( $html ) {
*/
protected function create_alternative_sources( $original_source_html ) {
$srcsets = $this->get_image_srcsets( $original_source_html );
+ if ( empty( $srcsets ) ) {
+ // no srcset, try src attribute
+ $srcsets[] = $this->get_image_src( $original_source_html );
+ }
+
if ( empty( $srcsets ) ) {
return array();
}
@@ -353,8 +367,10 @@ protected function create_alternative_sources( $original_source_html ) {
$is_source_tag = (bool) preg_match( '#get_largest_width_descriptor( $srcsets );
+
foreach ( $this->valid_mimetypes as $mimetype ) {
- $srcset_parts = [];
+ $srcset_parts = array();
foreach ( $srcsets as $srcset ) {
$alt_source = $this->get_formatted_source( $srcset, $mimetype );
@@ -363,32 +379,92 @@ protected function create_alternative_sources( $original_source_html ) {
}
}
- if ( ! empty( $srcset_parts ) ) {
- $source_attr_parts = array();
+ if ( $width_descriptor &&
+ ! self::srcset_contains_width_descriptor(
+ $srcset_parts,
+ $width_descriptor
+ ) ) {
+ continue;
+ }
- $srcset_attr = implode( ', ', $srcset_parts );
- $source_attr_parts['srcset'] = $srcset_attr;
+ if ( empty( $srcset_parts ) ) {
+ continue;
+ }
- if ( $is_source_tag ) {
- foreach ( array( 'sizes', 'media', 'width', 'height' ) as $attr ) {
- $attr_value = $this->get_attribute_value( $original_source_html, $attr );
- if ( $attr_value ) {
- $source_attr_parts[ $attr ] = $attr_value;
- }
+ $source_attr_parts = array();
+
+ $srcset_attr = implode( ', ', $srcset_parts );
+ $source_attr_parts['srcset'] = $srcset_attr;
+
+ if ( $is_source_tag ) {
+ foreach ( array( 'sizes', 'media', 'width', 'height' ) as $attr ) {
+ $attr_value = $this->get_attribute_value( $original_source_html, $attr );
+ if ( $attr_value ) {
+ $source_attr_parts[ $attr ] = $attr_value;
}
}
+ }
+
+ $source_attr_parts['type'] = $mimetype;
+ $source_parts = array( ' $source_attr_val ) {
+ $source_parts[] = $source_attr_name . '="' . $source_attr_val . '"';
+ }
+ $source_parts[] = '/>';
+ $sources[] = implode( ' ', $source_parts );
+ }// End foreach().
- $source_attr_parts['type'] = $mimetype;
- $source_parts = array( ' $source_attr_val ) {
- $source_parts[] = $source_attr_name . '="' . $source_attr_val . '"';
+ return $sources;
+ }
+
+ /**
+ * Returns the largest numeric width descriptor
+ * (e.g. 2000 from "2000w") found in the srcset data.
+ *
+ * @param array $srcsets
+ * @return int
+ */
+ public static function get_largest_width_descriptor( $srcsets ) {
+ $largest = 0;
+
+ foreach ( $srcsets as $srcset ) {
+ if ( empty( $srcset['size'] ) ) {
+ continue;
+ }
+
+ if ( preg_match( '/(\d+)w/', $srcset['size'], $matches ) ) {
+ $width = (int) $matches[1];
+ if ( $width > $largest ) {
+ $largest = $width;
}
- $source_parts[] = '/>';
- $sources[] = implode( ' ', $source_parts );
}
}
- return $sources;
+ return $largest;
+ }
+
+ /**
+ * Determines whether a srcset list contains the provided width descriptor.
+ *
+ * @param string[] $srcset_parts
+ * @param int $width_descriptor
+ * @return bool true if width is in srcset
+ */
+ public static function srcset_contains_width_descriptor( $srcset_parts, $width_descriptor ) {
+ if ( empty( $srcset_parts ) || $width_descriptor <= 0 ) {
+ return false;
+ }
+
+ $suffix = ' ' . $width_descriptor . 'w';
+ $suffix_length = strlen( $suffix );
+
+ foreach ( $srcset_parts as $srcset_part ) {
+ if ( substr( $srcset_part, -$suffix_length ) === $suffix ) {
+ return true;
+ }
+ }
+
+ return false;
}
}
diff --git a/src/class-tiny-settings.php b/src/class-tiny-settings.php
index 86dd71d..83bfa4e 100644
--- a/src/class-tiny-settings.php
+++ b/src/class-tiny-settings.php
@@ -239,6 +239,12 @@ protected static function get_intermediate_size( $size ) {
return array( null, null );
}
+ /**
+ * Retrieves image sizes as a map of size and width, height and tinify meta data
+ * The first entry will always be '0', aka the original uploaded image.
+ *
+ * @return array{string: array{width: int|null, height: int|null, tinify: array{}}} $sizes
+ */
public function get_sizes() {
if ( is_array( $this->sizes ) ) {
return $this->sizes;
@@ -374,7 +380,7 @@ public function get_resize_options( $size_name ) {
/**
* Retrieves the configured settings for conversion.
*
- * @return array{ convert: bool, convert_to: string } The conversion options.
+ * @return array{ convert: bool, convert_to: array{string} } The conversion options.
*/
public function get_conversion_options() {
return array(
diff --git a/test/unit/TinyCliTest.php b/test/unit/TinyCliTest.php
index aae0c74..d46639a 100644
--- a/test/unit/TinyCliTest.php
+++ b/test/unit/TinyCliTest.php
@@ -56,13 +56,7 @@ public function test_will_compress_attachments_given_in_params()
'file' => "vfs://root/wp-content/uploads/2025/07/test.png",
'resize' => false,
'preserve' => array(),
- 'convert_opts' => array(
- 'convert' => false,
- 'convert_to' => array(
- 'image/avif',
- 'image/webp'
- )
- ),
+ 'convert_to' => array(),
);
$mockCompressor->expects($this->once())
->method('compress_file')
@@ -70,7 +64,7 @@ public function test_will_compress_attachments_given_in_params()
$expected['file'],
$expected['resize'],
$expected['preserve'],
- $expected['convert_opts']
+ $expected['convert_to']
);
$settings->set_compressor($mockCompressor);
diff --git a/test/unit/TinyImageTest.php b/test/unit/TinyImageTest.php
index 4448c28..78f5c76 100644
--- a/test/unit/TinyImageTest.php
+++ b/test/unit/TinyImageTest.php
@@ -176,7 +176,7 @@ public function test_update_tiny_post_data_should_call_do_action() {
* In case a customer has already compressed a couple of images and then turns
* on the conversion feature.
*/
-public function test_compressed_images_can_be_converted() {
+ public function test_compressed_images_can_be_converted() {
// Enable conversion and all image sizes
$this->wp->addOption('tinypng_conversion_enabled', true);
$this->wp->addOption('tinypng_convert_to', 'smallest');
@@ -194,4 +194,70 @@ public function test_compressed_images_can_be_converted() {
$this->assertCount(4, $unconverted_sizes, 'All 4 sizes should be converted');
$this->assertCount(4, $unprocessed_sizes, 'All sizes should be processed');
}
+
+ /**
+ * Test conversion to see if follow-up conversion will be done with the same mimetype
+ */
+ public function test_conversion_same_mimetype()
+ {
+ $this->wp->addOption('tinypng_convert_format', array(
+ 'convert' => 'on',
+ 'convert_to' => 'smallest',
+ ));
+ $this->wp->addOption('tinypng_sizes', array(
+ Tiny_Image::ORIGINAL => 'on',
+ 'thumbnail' => 'on',
+ ));
+ $this->wp->createImages(array(
+ 'thumbnail' => 1000,
+ ));
+ $this->wp->stub('get_post_mime_type', function () {
+ return 'image/png';
+ });
+
+ $metadata = $this->wp->getTestMetadata();
+ $settings = new Tiny_Settings();
+
+ // create a mock compressor to spy on calls
+ $mock_compressor = $this->createMock(Tiny_Compress::class);
+
+ // we expect for all sizes a webp
+ $converted_type = 'image/webp';
+ $responses = array(
+ array(
+ 'input' => array('size' => 1000),
+ 'output' => array('size' => 800, 'type' => 'image/png'),
+ 'convert' => array('type' => $converted_type, 'size' => 750, 'path' => 'vfs://root/converted-1.webp'),
+ ),
+ array(
+ 'input' => array('size' => 1000),
+ 'output' => array('size' => 780, 'type' => 'image/png'),
+ 'convert' => array('type' => $converted_type, 'size' => 720, 'path' => 'vfs://root/converted-2.webp'),
+ ),
+ );
+
+ $compress_calls = array();
+ $mock_compressor->expects($this->exactly(2))
+ ->method('compress_file')
+ ->willReturnCallback(function ($file, $resize, $preserve, $convert_to) use (&$compress_calls, &$responses) {
+ $compress_calls[] = array(
+ 'file' => $file,
+ 'convert_to' => $convert_to,
+ );
+ return array_shift($responses);
+ });
+ $settings->set_compressor($mock_compressor);
+
+ $tinyimg = new Tiny_Image($settings, 999, $metadata);
+ $tinyimg->compress();
+
+ // should have been 2 calls to our mock 'compress_file'
+ $this->assertCount(2, $compress_calls);
+
+ // first call would have been width all mimetypes
+ $this->assertEquals(array('image/avif', 'image/webp'), $compress_calls[0]['convert_to']);
+
+ // second call should be only with image/webp because first call was a image/webp
+ $this->assertEquals(array('image/webp'), $compress_calls[1]['convert_to']);
+ }
}
diff --git a/test/unit/TinyPictureTest.php b/test/unit/TinyPictureTest.php
index c974e07..956c353 100644
--- a/test/unit/TinyPictureTest.php
+++ b/test/unit/TinyPictureTest.php
@@ -164,12 +164,69 @@ public function test_img_with_srcsets()
$this->wp->createImage(1000, '2025/01', 'test-320w.webp');
$input = '
';
- $expected = '
';
+ $expected = '
';
$output = $this->tiny_picture->replace_sources($input);
$this->assertEquals($expected, $output);
}
+ public function test_get_largest_width_descriptor_returns_largest_value()
+ {
+ $srcsets = array(
+ array('path' => '/wp-content/uploads/2025/01/test_320x320.jpg', 'size' => '320w'),
+ array('path' => '/wp-content/uploads/2025/01/test_320x320.jpg', 'size' => '2000w'),
+ array('path' => 'c', 'size' => '2x'), // this is effectively ignored because there are width descriptors
+ );
+
+ $imgSource = new Tiny_Image_Source('
', '', array());
+ $largest = Tiny_Image_Source::get_largest_width_descriptor($srcsets);
+
+ $this->assertEquals(2000, $largest);
+ }
+
+ public function test_get_largest_width_descriptor_without_widths_returns_zero()
+ {
+ $srcsets = array(
+ array('path' => '/wp-content/uploads/2025/01/test@1x.jpg', 'size' => '1x'),
+ array('path' => '/wp-content/uploads/2025/01/test.jpg', 'size' => '2x'),
+ );
+
+ $largest = Tiny_Image_Source::get_largest_width_descriptor($srcsets);
+
+ $this->assertSame(0, $largest);
+ }
+
+ public function test_srcset_contains_width_descriptor_returns_true_when_present()
+ {
+ $parts = array(
+ '/wp-content/uploads/2025/01/test-320w.webp 320w',
+ '/wp-content/uploads/2025/01/test-640w.webp 640w',
+ );
+
+ $this->assertTrue(Tiny_Image_Source::srcset_contains_width_descriptor($parts, 640));
+ }
+
+ public function test_srcset_contains_width_descriptor_returns_false_when_missing()
+ {
+ $parts = array(
+ '/wp-content/uploads/2025/01/test-320w.webp 320w',
+ '/wp-content/uploads/2025/01/test-640w.webp 640w',
+ );
+
+ $this->assertFalse(Tiny_Image_Source::srcset_contains_width_descriptor($parts, 1280));
+ }
+
+ public function test_get_largest_width_no_descriptors()
+ {
+ $srcsets = array(
+ array('path' => '/wp-content/uploads/2025/01/test.jpg', 'size' => ''),
+ );
+
+ $largest = Tiny_Image_Source::get_largest_width_descriptor($srcsets);
+
+ $this->assertSame(0, $largest);
+ }
+
public function test_picture_with_srcsets()
{
$this->wp->createImage(1000, '2025/01', 'test-640w.webp');
@@ -177,13 +234,14 @@ public function test_picture_with_srcsets()
$this->wp->createImage(1000, '2025/01', 'test-320w.webp');
$input = '
';
- $expected = '
';
+ $expected = '
';
$output = $this->tiny_picture->replace_sources($input);
$this->assertEquals($expected, $output);
}
- public function test_picture_with_attributes() {
+ public function test_picture_with_attributes()
+ {
$this->wp->createImage(1000, '2025/01', 'test-landscape.webp');
$input = '
';
@@ -215,4 +273,58 @@ public function test_img_with_query_and_fragment_keeps_both()
$this->assertEquals($expected, $output);
}
+
+ /**
+ * scenario where there is only a low resolution variant for a certain image.
+ * this can happen when credits or API decides that only low resolution image
+ * is in a different format (this is resolved in 3.6.4)
+ */
+ public function test_skip_low_res_if_largest_width_is_not_present()
+ {
+ $this->wp->createImage(37857, '2025/09', 'test_250x250.webp');
+
+ // largest size should exist otherwise we mark it as a incomplete sourceset
+ $input = '
';
+ $output = $this->tiny_picture->replace_sources($input);
+
+ // no replacement should be done as there is only a 250x250 but no original
+ $this->assertEquals($input, $output);
+ }
+
+ /**
+ * if the largest width is in a srcset, then we will use the alternative source
+ */
+ public function test_largest_width_is_present_so_include_sourceset()
+ {
+ $this->wp->createImage(37857, '2025/09', 'test_250x250.webp');
+ $this->wp->createImage(37857, '2025/09', 'test.webp');
+
+ // largest size should be present otherwise we mark it as a incomplete sourceset
+ $input = '
';
+ $expected = '
';
+ $output = $this->tiny_picture->replace_sources($input);
+
+ // no replacement should be done as there is only a 250x250 but no original
+ $this->assertSame($expected, $output);
+ }
+
+ /**
+ * if width and pd descriptors are present, then only width is applicable and
+ * pd are effectively ignored
+ *
+ * Note that if any resource in a srcset is described with a "w" descriptor, all resources within that srcset must also be described with "w" descriptors, and the image element's src is not considered a candidate.
+ * https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/srcset#value
+ */
+ public function test_mixed_descriptors_in_source()
+ {
+ $this->wp->createImage(37857, '2025/09', 'test_250x250.webp');
+ $this->wp->createImage(37857, '2025/09', 'test.webp');
+
+ // this will show test_250x250.png but that would also happen on the original img
+ $input = '
';
+ $expected = '
';
+ $output = $this->tiny_picture->replace_sources($input);
+
+ $this->assertSame($expected, $output);
+ }
}