Skip to content

Commit 0f6a8a8

Browse files
tijmenbruggemanrkoopmans
authored andcommitted
fix: resolve issue with mixed formats and where low resolution imgs weren not present in source
- look for largest width descriptor - if width descriptor is present, all srcsets should have the largest at most.
1 parent 52ef57b commit 0f6a8a8

2 files changed

Lines changed: 207 additions & 25 deletions

File tree

src/class-tiny-picture.php

Lines changed: 92 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ protected function get_image_srcsets( $html ) {
306306
// Trim whitespace
307307
$entry = trim( $entry );
308308

309-
// Split by whitespace to separate path and size descriptor
309+
// Split by whitespace to separate path and size/density descriptor
310310
$parts = preg_split( '/\s+/', $entry, 2 );
311311

312312
if ( count( $parts ) === 2 ) {
@@ -316,24 +316,32 @@ protected function get_image_srcsets( $html ) {
316316
'size' => $parts[1],
317317
);
318318
} elseif ( count( $parts ) === 1 ) {
319-
// We only have a path (unusual in srcset)
319+
// We only have a path, will be interpreted as pixel density 1x (unusual in srcset)
320320
$result[] = array(
321321
'path' => $parts[0],
322322
'size' => '',
323323
);
324324
}
325325
}
326326
}
327+
return $result;
328+
}
327329

330+
/**
331+
* Retrieves the sources from the <img> or <source> element
332+
*
333+
* @return array{path: string, size: string}[] The image sources
334+
*/
335+
private function get_image_src( $html ) {
328336
$source = $this::get_attribute_value( $html, 'src' );
329337
if ( ! empty( $source ) ) {
330338
// No srcset, but we have a src attribute
331-
$result[] = array(
339+
return array(
332340
'path' => $source,
333341
'size' => '',
334342
);
335343
}
336-
return $result;
344+
return array();
337345
}
338346

339347

@@ -347,14 +355,21 @@ protected function get_image_srcsets( $html ) {
347355
protected function create_alternative_sources( $original_source_html ) {
348356
$srcsets = $this->get_image_srcsets( $original_source_html );
349357
if ( empty( $srcsets ) ) {
358+
// no srcset, try src attribute
359+
$srcsets[] = $this->get_image_src( $original_source_html );
360+
}
361+
362+
if ( empty ( $srcsets ) ) {
350363
return array();
351364
}
352365

353366
$is_source_tag = (bool) preg_match( '#<source\b#i', $original_source_html );
354367

355368
$sources = array();
369+
$width_descriptor = $this->get_largest_width_descriptor( $srcsets );
370+
356371
foreach ( $this->valid_mimetypes as $mimetype ) {
357-
$srcset_parts = [];
372+
$srcset_parts = array();
358373

359374
foreach ( $srcsets as $srcset ) {
360375
$alt_source = $this->get_formatted_source( $srcset, $mimetype );
@@ -363,33 +378,88 @@ protected function create_alternative_sources( $original_source_html ) {
363378
}
364379
}
365380

366-
if ( ! empty( $srcset_parts ) ) {
367-
$source_attr_parts = array();
381+
if ( $width_descriptor && ! self::srcset_contains_width_descriptor( $srcset_parts, $width_descriptor ) ) {
382+
continue;
383+
}
384+
385+
if ( empty( $srcset_parts ) ) {
386+
continue;
387+
}
368388

369-
$srcset_attr = implode( ', ', $srcset_parts );
370-
$source_attr_parts['srcset'] = $srcset_attr;
389+
$source_attr_parts = array();
371390

372-
if ( $is_source_tag ) {
373-
foreach ( array( 'sizes', 'media', 'width', 'height' ) as $attr ) {
374-
$attr_value = $this->get_attribute_value( $original_source_html, $attr );
375-
if ( $attr_value ) {
376-
$source_attr_parts[ $attr ] = $attr_value;
377-
}
391+
$srcset_attr = implode( ', ', $srcset_parts );
392+
$source_attr_parts['srcset'] = $srcset_attr;
393+
394+
if ( $is_source_tag ) {
395+
foreach ( array( 'sizes', 'media', 'width', 'height' ) as $attr ) {
396+
$attr_value = $this->get_attribute_value( $original_source_html, $attr );
397+
if ( $attr_value ) {
398+
$source_attr_parts[ $attr ] = $attr_value;
378399
}
379400
}
401+
}
380402

381-
$source_attr_parts['type'] = $mimetype;
382-
$source_parts = array( '<source' );
383-
foreach ( $source_attr_parts as $source_attr_name => $source_attr_val ) {
384-
$source_parts[] = $source_attr_name . '="' . $source_attr_val . '"';
385-
}
386-
$source_parts[] = '/>';
387-
$sources[] = implode( ' ', $source_parts );
403+
$source_attr_parts['type'] = $mimetype;
404+
$source_parts = array( '<source' );
405+
foreach ( $source_attr_parts as $source_attr_name => $source_attr_val ) {
406+
$source_parts[] = $source_attr_name . '="' . $source_attr_val . '"';
388407
}
408+
$source_parts[] = '/>';
409+
$sources[] = implode( ' ', $source_parts );
389410
}
390411

391412
return $sources;
392413
}
414+
415+
/**
416+
* Returns the largest numeric width descriptor (e.g. 2000 from "2000w") found in the srcset data.
417+
*
418+
* @param array<array{path: string, size: string}> $srcsets
419+
* @return int
420+
*/
421+
public static function get_largest_width_descriptor( $srcsets ) {
422+
$largest = 0;
423+
424+
foreach ( $srcsets as $srcset ) {
425+
if ( empty( $srcset['size'] ) ) {
426+
continue;
427+
}
428+
429+
if ( preg_match( '/(\d+)w/', $srcset['size'], $matches ) ) {
430+
$width = (int) $matches[1];
431+
if ( $width > $largest ) {
432+
$largest = $width;
433+
}
434+
}
435+
}
436+
437+
return $largest;
438+
}
439+
440+
/**
441+
* Determines whether a srcset list contains the provided width descriptor.
442+
*
443+
* @param string[] $srcset_parts
444+
* @param int $width_descriptor
445+
* @return bool true if width is in srcset
446+
*/
447+
public static function srcset_contains_width_descriptor( $srcset_parts, $width_descriptor ) {
448+
if ( empty( $srcset_parts ) || $width_descriptor <= 0 ) {
449+
return false;
450+
}
451+
452+
$suffix = ' ' . $width_descriptor . 'w';
453+
$suffix_length = strlen( $suffix );
454+
455+
foreach ( $srcset_parts as $srcset_part ) {
456+
if ( substr( $srcset_part, -$suffix_length ) === $suffix ) {
457+
return true;
458+
}
459+
}
460+
461+
return false;
462+
}
393463
}
394464

395465
class Tiny_Picture_Source extends Tiny_Source_Base {

test/unit/TinyPictureTest.php

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,26 +164,84 @@ public function test_img_with_srcsets()
164164
$this->wp->createImage(1000, '2025/01', 'test-320w.webp');
165165

166166
$input = '<img srcset="/wp-content/uploads/2025/01/test-320w.jpg, /wp-content/uploads/2025/01/test-480w.jpg 1.5x, /wp-content/uploads/2025/01/test-640w.jpg 2x" src="/wp-content/uploads/2025/01/test-640w.jpg" />';
167-
$expected = '<picture><source srcset="/wp-content/uploads/2025/01/test-320w.webp, /wp-content/uploads/2025/01/test-480w.webp 1.5x, /wp-content/uploads/2025/01/test-640w.webp 2x, /wp-content/uploads/2025/01/test-640w.webp" type="image/webp" /><img srcset="/wp-content/uploads/2025/01/test-320w.jpg, /wp-content/uploads/2025/01/test-480w.jpg 1.5x, /wp-content/uploads/2025/01/test-640w.jpg 2x" src="/wp-content/uploads/2025/01/test-640w.jpg" /></picture>';
167+
$expected = '<picture><source srcset="/wp-content/uploads/2025/01/test-320w.webp, /wp-content/uploads/2025/01/test-480w.webp 1.5x, /wp-content/uploads/2025/01/test-640w.webp 2x" type="image/webp" /><img srcset="/wp-content/uploads/2025/01/test-320w.jpg, /wp-content/uploads/2025/01/test-480w.jpg 1.5x, /wp-content/uploads/2025/01/test-640w.jpg 2x" src="/wp-content/uploads/2025/01/test-640w.jpg" /></picture>';
168168
$output = $this->tiny_picture->replace_sources($input);
169169

170170
$this->assertEquals($expected, $output);
171171
}
172172

173+
public function test_get_largest_width_descriptor_returns_largest_value()
174+
{
175+
$srcsets = array(
176+
array('path' => '/wp-content/uploads/2025/01/test_320x320.jpg', 'size' => '320w'),
177+
array('path' => '/wp-content/uploads/2025/01/test_320x320.jpg', 'size' => '2000w'),
178+
array('path' => 'c', 'size' => '2x'), // this is effectively ignored because there are width descriptors
179+
);
180+
181+
$imgSource = new Tiny_Image_Source('<img srcset="/wp-content/uploads/2025/01/test-420w.jpg 420w, /wp-content/uploads/2025/01/test-650w.jpg 650w, /wp-content/uploads/2025/01/test.jpg 2000w" src="/wp-content/uploads/2025/01/test.jpg" />', '', array());
182+
$largest = Tiny_Image_Source::get_largest_width_descriptor($srcsets);
183+
184+
$this->assertEquals(2000, $largest);
185+
}
186+
187+
public function test_get_largest_width_descriptor_without_widths_returns_zero()
188+
{
189+
$srcsets = array(
190+
array('path' => '/wp-content/uploads/2025/01/test@1x.jpg', 'size' => '1x'),
191+
array('path' => '/wp-content/uploads/2025/01/test.jpg', 'size' => '2x'),
192+
);
193+
194+
$largest = Tiny_Image_Source::get_largest_width_descriptor($srcsets);
195+
196+
$this->assertSame(0, $largest);
197+
}
198+
199+
public function test_srcset_contains_width_descriptor_returns_true_when_present()
200+
{
201+
$parts = array(
202+
'/wp-content/uploads/2025/01/test-320w.webp 320w',
203+
'/wp-content/uploads/2025/01/test-640w.webp 640w',
204+
);
205+
206+
$this->assertTrue(Tiny_Image_Source::srcset_contains_width_descriptor($parts, 640));
207+
}
208+
209+
public function test_srcset_contains_width_descriptor_returns_false_when_missing()
210+
{
211+
$parts = array(
212+
'/wp-content/uploads/2025/01/test-320w.webp 320w',
213+
'/wp-content/uploads/2025/01/test-640w.webp 640w',
214+
);
215+
216+
$this->assertFalse(Tiny_Image_Source::srcset_contains_width_descriptor($parts, 1280));
217+
}
218+
219+
public function test_get_largest_width_no_descriptors()
220+
{
221+
$srcsets = array(
222+
array('path' => '/wp-content/uploads/2025/01/test.jpg', 'size' => ''),
223+
);
224+
225+
$largest = Tiny_Image_Source::get_largest_width_descriptor($srcsets);
226+
227+
$this->assertSame(0, $largest);
228+
}
229+
173230
public function test_picture_with_srcsets()
174231
{
175232
$this->wp->createImage(1000, '2025/01', 'test-640w.webp');
176233
$this->wp->createImage(1000, '2025/01', 'test-480w.webp');
177234
$this->wp->createImage(1000, '2025/01', 'test-320w.webp');
178235

179236
$input = '<picture><img srcset="/wp-content/uploads/2025/01/test-320w.jpg, /wp-content/uploads/2025/01/test-480w.jpg 1.5x, /wp-content/uploads/2025/01/test-640w.jpg 2x" src="/wp-content/uploads/2025/01/test-640w.jpg" /></picture>';
180-
$expected = '<picture><source srcset="/wp-content/uploads/2025/01/test-320w.webp, /wp-content/uploads/2025/01/test-480w.webp 1.5x, /wp-content/uploads/2025/01/test-640w.webp 2x, /wp-content/uploads/2025/01/test-640w.webp" type="image/webp" /><img srcset="/wp-content/uploads/2025/01/test-320w.jpg, /wp-content/uploads/2025/01/test-480w.jpg 1.5x, /wp-content/uploads/2025/01/test-640w.jpg 2x" src="/wp-content/uploads/2025/01/test-640w.jpg" /></picture>';
237+
$expected = '<picture><source srcset="/wp-content/uploads/2025/01/test-320w.webp, /wp-content/uploads/2025/01/test-480w.webp 1.5x, /wp-content/uploads/2025/01/test-640w.webp 2x" type="image/webp" /><img srcset="/wp-content/uploads/2025/01/test-320w.jpg, /wp-content/uploads/2025/01/test-480w.jpg 1.5x, /wp-content/uploads/2025/01/test-640w.jpg 2x" src="/wp-content/uploads/2025/01/test-640w.jpg" /></picture>';
181238
$output = $this->tiny_picture->replace_sources($input);
182239

183240
$this->assertEquals($expected, $output);
184241
}
185242

186-
public function test_picture_with_attributes() {
243+
public function test_picture_with_attributes()
244+
{
187245
$this->wp->createImage(1000, '2025/01', 'test-landscape.webp');
188246

189247
$input = '<picture><source srcset="/wp-content/uploads/2025/01/test-landscape.jpg" width="200" height="200" media="(width >= 600px)" /><img src="/wp-content/uploads/2025/01/test.jpg" /></picture>';
@@ -215,4 +273,58 @@ public function test_img_with_query_and_fragment_keeps_both()
215273

216274
$this->assertEquals($expected, $output);
217275
}
276+
277+
/**
278+
* scenario where there is only a low resolution variant for a certain image.
279+
* this can happen when credits or API decides that only low resolution image
280+
* is in a different format (this is resolved in 3.6.4)
281+
*/
282+
public function test_skip_low_res_if_largest_width_is_not_present()
283+
{
284+
$this->wp->createImage(37857, '2025/09', 'test_250x250.webp');
285+
286+
// largest size should exist otherwise we mark it as a incomplete sourceset
287+
$input = '<img src="/wp-content/uploads/2025/09/test.png" srcset="/wp-content/uploads/2025/09/test_250x250.png 350w, /wp-content/uploads/2025/09/test.png 2000w">';
288+
$output = $this->tiny_picture->replace_sources($input);
289+
290+
// no replacement should be done as there is only a 250x250 but no original
291+
$this->assertEquals($input, $output);
292+
}
293+
294+
/**
295+
* if the largest width is in a srcset, then we will use the alternative source
296+
*/
297+
public function test_largest_width_is_present_so_include_sourceset()
298+
{
299+
$this->wp->createImage(37857, '2025/09', 'test_250x250.webp');
300+
$this->wp->createImage(37857, '2025/09', 'test.webp');
301+
302+
// largest size should be present otherwise we mark it as a incomplete sourceset
303+
$input = '<img src="/wp-content/uploads/2025/09/test.png" srcset="/wp-content/uploads/2025/09/test_250x250.png 350w, /wp-content/uploads/2025/09/test.png 2000w">';
304+
$expected = '<picture><source srcset="/wp-content/uploads/2025/09/test_250x250.webp 350w, /wp-content/uploads/2025/09/test.webp 2000w" type="image/webp" /><img src="/wp-content/uploads/2025/09/test.png" srcset="/wp-content/uploads/2025/09/test_250x250.png 350w, /wp-content/uploads/2025/09/test.png 2000w"></picture>';
305+
$output = $this->tiny_picture->replace_sources($input);
306+
307+
// no replacement should be done as there is only a 250x250 but no original
308+
$this->assertSame($expected, $output);
309+
}
310+
311+
/**
312+
* if width and pd descriptors are present, then only width is applicable and
313+
* pd are effectively ignored
314+
*
315+
* 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.
316+
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/srcset#value
317+
*/
318+
public function test_mixed_descriptors_in_soruce()
319+
{
320+
$this->wp->createImage(37857, '2025/09', 'test_250x250.webp');
321+
$this->wp->createImage(37857, '2025/09', 'test.webp');
322+
323+
// this will show test_250x250.png but that would also happen on the original img
324+
$input = '<img src="/wp-content/uploads/2025/09/test.png" srcset="/wp-content/uploads/2025/09/test_250x250.png 350w, /wp-content/uploads/2025/09/test.png 2x">';
325+
$expected = '<picture><source srcset="/wp-content/uploads/2025/09/test_250x250.webp 350w, /wp-content/uploads/2025/09/test.png 2x" type="image/webp" /><img src="/wp-content/uploads/2025/09/test.png" srcset="/wp-content/uploads/2025/09/test_250x250.png 350w, /wp-content/uploads/2025/09/test.png 2x"></picture>';
326+
$output = $this->tiny_picture->replace_sources($input);
327+
328+
$this->assertSame($expected, $output);
329+
}
218330
}

0 commit comments

Comments
 (0)