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); + } }