PluginProbe
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization / trunk
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization vtrunk
4.2.14 4.2.13 4.2.12 4.2.11 4.2.10 4.2.9 4.2.8 4.2.7 4.2.6 4.2.5 2.5.5 2.5.6 2.5.7 3.0.0 3.0.1 3.1.0 3.1.1 3.1.2 3.1.3 3.10.0 3.11.0 3.11.1 3.11.2 3.11.3 3.12.0 All 135 releases
← All changes | inc/tag_replacer.php +585 -77 3.0.0 → trunk View file →
@@ -1,6 +1,8 @@
1 1 <?php
2 2
3 +use OptimoleWP\Preload\Links;
4 +
3 5 /**
4 6 * The class handles the img tag replacements.
5 7 *
6 8 * @package \Optml\Inc
@@ -8,8 +10,9 @@
8 10 */
9 11 final class Optml_Tag_Replacer extends Optml_App_Replacer {
10 12 use Optml_Normalizer;
11 13 use Optml_Validator;
14 + use Optml_Dam_Offload_Utils;
12 15
13 16 /**
14 17 * Cached object instance.
15 18 *
@@ -17,8 +20,15 @@
17 20 */
18 21 protected static $instance = null;
19 22
20 23 /**
24 + * The number of images skipped from lazyload.
25 + *
26 + * @var integer
27 + */
28 + public static $lazyload_skipped_images = 0;
29 +
30 + /**
21 31 * Class instance method.
22 32 *
23 33 * @codeCoverageIgnore
24 34 * @static
@@ -38,31 +48,89 @@
38 48 /**
39 49 * The initialize method.
40 50 */
41 51 public function init() {
42 -
43 52 parent::init();
44 53 add_filter( 'optml_content_images_tags', [ $this, 'process_image_tags' ], 1, 2 );
45 54
46 - if ( $this->settings->use_lazyload() ) {
47 - return;
55 + if ( ! $this->settings->use_lazyload() ) {
56 + add_filter( 'optml_tag_replace', [ $this, 'regular_tag_replace' ], 1, 6 );
48 57 }
49 -
50 - add_filter( 'optml_tag_replace', [ $this, 'regular_tag_replace' ], 1, 6 );
51 58 add_filter( 'image_downsize', [ $this, 'filter_image_downsize' ], PHP_INT_MAX, 3 );
52 - add_filter( 'wp_calculate_image_srcset', [ $this, 'filter_srcset_attr' ], PHP_INT_MAX, 5 );
59 + add_filter( 'wp_calculate_image_srcset', [ $this, 'filter_srcset_attr' ], PHP_INT_MAX - 1, 5 );
53 60 add_filter( 'wp_calculate_image_sizes', [ $this, 'filter_sizes_attr' ], 1, 2 );
61 + add_filter( 'wp_image_src_get_dimensions', [ $this, 'filter_image_src_get_dimensions' ], 99, 4 );
62 + if ( $this->settings->get( 'retina_images' ) === 'enabled' ) {
63 + add_filter( 'wp_get_attachment_image_attributes', [ $this, 'filter_attachment_image_attributes' ], 99, 3 );
64 + }
65 + }
54 66
67 + /**
68 + * We have to short-circuit the logic that adds width and height to the img tag.
69 + * It compares the URL basename, and the `file` param for each image.
70 + * This happens for any image that gets its size set non-explicitly
71 + * e.g. an image block with its size set from the sidebar to `thumbnail`).
72 + *
73 + * Optimole has a single basename for all image resizes in its URL.
74 + *
75 + * @param mixed $dimensions The dimensions of the image.
76 + * @param mixed $image_src The source of the image.
77 + * @param mixed $image_meta The meta of the image.
78 + * @param mixed $attachment_id The ID of the attachment.
79 + */
80 + public function filter_image_src_get_dimensions( $dimensions, $image_src, $image_meta, $attachment_id ) {
81 +
82 + list($width, $height) = $this->parse_dimension_from_optimized_url( $image_src );
83 +
84 + if ( false === $width || false === $height ) {
85 + return $dimensions;
86 + }
87 + $sizes = Optml_App_Replacer::image_sizes();
88 + if ( OPTML_DEBUG ) {
89 + do_action( 'optml_log', 'filter_image_src_get_dimensions: ' . $image_src . ' ' . $width . ' ' . $height . ' ' . print_r( $image_meta, true ) );
90 + }
91 + if ( $width === 'auto' && $height === 'auto' && isset( $image_meta['width'], $image_meta['height'] ) ) {
92 + return [
93 + $image_meta['width'],
94 + $image_meta['height'],
95 + ];
96 + }
97 + // If this is an image size. Return its dimensions.
98 + foreach ( $sizes as $size => $args ) {
99 + if ( (int) $args['width'] !== (int) $width ) {
100 + continue;
101 + }
102 +
103 + if ( (int) $args['height'] !== (int) $height ) {
104 + continue;
105 + }
106 +
107 + return [
108 + $args['width'],
109 + $args['height'],
110 + ];
111 + }
112 +
113 + // Fall-through with the original dimensions.
114 + return $dimensions;
55 115 }
56 116 /**
57 - * Called by hook to replace image tags in content.
117 + * Filter the attachment image attributes to add the srcset attribute with retina support.
58 118 *
59 - * @param string $content The content to process.
60 - * @param array $images List of image tags.
119 + * This is covering the case where the image has no sizes.
61 120 *
62 - * @return mixed
121 + * @param array $attr The attributes.
122 + * @param WP_Post $attachment The attachment.
123 + * @param string $size The size.
63 124 */
125 + public function filter_attachment_image_attributes( $attr, $attachment, $size ) {
126 + if ( ! isset( $attr['srcset'] ) && isset( $attr['src'] ) && strpos( $attr['src'], '/w:' ) !== false ) {
127 + $attr['srcset'] = str_replace( '/w:', '/dpr:2/w:', $attr['src'] ) . ' 2x';
128 + }
129 + return $attr;
130 + }
64 131
132 +
65 133 /**
66 134 * Replace in given content the given image tags with video tags is image is gif
67 135 *
68 136 * @param string $image_url Image url to process.
@@ -74,15 +142,17 @@
74 142 public function img_to_video( $image_url, $image_tag, &$content ) {
75 143 if ( $this->settings->get( 'img_to_video' ) === 'disabled' ) {
76 144 return false;
77 145 }
78 - if ( ! $this->is_valid_gif( $image_url ) ) {
146 +
147 + if ( false === Optml_Filters::should_do_image( $image_tag, apply_filters( 'optml_gif_to_video_flags', [ 'lazyload' => true, 'placeholder' => true, 'original-src' => true ] ) ) ) {
79 148 return false;
80 149 }
81 150 $link_mp4 = apply_filters(
82 151 'optml_content_url',
83 152 $image_url,
84 - ['width' => 'auto',
153 + [
154 + 'width' => 'auto',
85 155 'height' => 'auto',
86 156 'format' => 'mp4',
87 157 ]
88 158 );
@@ -89,9 +159,10 @@
89 159
90 160 $link_png = apply_filters(
91 161 'optml_content_url',
92 162 $image_url,
93 - ['width' => 'auto',
163 + [
164 + 'width' => 'auto',
94 165 'height' => 'auto',
95 166 'quality' => 'eco',
96 167 ]
97 168 );
@@ -126,8 +197,11 @@
126 197 public function process_image_tags( $content, $images = [] ) {
127 198
128 199 $image_sizes = self::image_sizes();
129 200 $sizes2crop = self::size_to_crop();
201 + if ( OPTML_DEBUG ) {
202 + do_action( 'optml_log', 'Images tags to process: ' . print_r( $images, true ) );
203 + }
130 204 foreach ( $images[0] as $index => $tag ) {
131 205 $width = $height = false;
132 206 $crop = null;
133 207 $image_tag = $images['img_tag'][ $index ];
@@ -153,19 +227,12 @@
153 227 );
154 228 $images['img_url'][ $index ] = $new_src;
155 229 }
156 230 if ( ( apply_filters( 'optml_ignore_image_link', false, $src ) ||
157 - false !== strpos( $src, Optml_Config::$service_url ) ||
158 - ! $this->can_replace_url( $src ) ||
159 - ! $this->can_replace_tag( $images['img_url'][ $index ], $tag ) ) && ! Optml_Media_Offload::is_not_processed_image( $src )
231 + ! $this->can_replace_tag( $images['img_url'][ $index ], $tag ) ) && ! Optml_Media_Offload::is_not_processed_image( $src )
160 232 ) {
161 233 continue; // @codeCoverageIgnore
162 234 }
163 -
164 - if ( $this->img_to_video( $images['img_url'][ $index ], $images['img_tag'][ $index ], $content ) ) {
165 - continue;
166 - }
167 -
168 235 $resize = apply_filters( 'optml_default_crop', [] );
169 236
170 237 list( $width, $height, $resize ) = self::parse_dimensions_from_tag(
171 238 $images['img_tag'][ $index ],
@@ -185,15 +252,27 @@
185 252 $resize = $this->to_optml_crop( $crop );
186 253 }
187 254
188 255 $optml_args = [ 'width' => $width, 'height' => $height, 'resize' => $resize ];
256 +
257 + $is_gif = $this->is_valid_gif( $images['img_url'][ $index ] );
258 + $should_lazy_gif = $is_gif ? $this->should_lazy_gif( $images['img_url'][ $index ], $optml_args ) : null;
259 +
260 + if ( $should_lazy_gif === true && $this->img_to_video( $images['img_url'][ $index ], $images['img_tag'][ $index ], $content ) ) {
261 + continue;
262 + }
263 +
189 264 $tmp = $this->strip_image_size_from_url( $tmp );
190 - $new_url = apply_filters( 'optml_content_url', $tmp, $optml_args );
191 265
192 - if ( $new_url === $tmp && ! Optml_Media_Offload::is_not_processed_image( $src ) ) {
193 - continue; // @codeCoverageIgnore
266 + $image_id = $this->get_id_by_url( $images['img_url'][ $index ] );
267 +
268 + if ( Optml_Manager::instance()->page_profiler->get_crop_status( $image_id ) && empty( $optml_args['resize'] ) ) {
269 + $optml_args['resize'] = $this->to_optml_crop( true );
270 + $optml_args['force'] = true; // here we need to force the url to be rebuild if the image is already using an Optimole URL.
194 271 }
195 272
273 + $new_url = apply_filters( 'optml_content_url', $tmp, $optml_args );
274 +
196 275 $image_tag = str_replace(
197 276 [
198 277 'width="' . $width . '"',
199 278 'width=\"' . $width . '\"',
@@ -207,21 +286,59 @@
207 286 'height=\"' . $optml_args['height'] . '\"',
208 287 ],
209 288 $image_tag
210 289 );
290 + // If the image is in header or has a class excluded from lazyload or is an excluded gif, we need to do the regular replace.
291 + if ( $images['in_no_script'][ $index ] || $should_lazy_gif === false ) {
211 292
212 - // If the image is in header or has a class excluded from lazyload, we need to do the regular replace.
213 - if ( $images['in_header'][ $index ] ) {
214 293 $image_tag = $this->regular_tag_replace( $image_tag, $images['img_url'][ $index ], $new_url, $optml_args, $is_slashed, $tag );
215 294 } else {
216 295 $image_tag = apply_filters( 'optml_tag_replace', $image_tag, $images['img_url'][ $index ], $new_url, $optml_args, $is_slashed, $tag );
217 296 }
297 + if ( strpos( $image_tag, 'data-opt-id=' ) === false ) {
298 + $image_tag = preg_replace( '/<img/im', '<img data-opt-id=' . $image_id . ' ', $image_tag );
299 + }
218 300
301 + if ( $priority = Links::is_preloaded( $image_id ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.Found
302 + Links::preload_tag( $image_tag, $priority );
303 + }
304 +
305 + if ( strpos( $image_tag, 'decoding=' ) === false ) {
306 + $image_tag = str_replace( 'data-opt-id=', 'decoding=async data-opt-id=', $image_tag );
307 + }
308 + if ( strpos( $image_tag, 'width=' ) === false || strpos( $image_tag, 'height=' ) === false ) {
309 + $missing_dimensions = Optml_Manager::instance()->page_profiler->get_missing_dimensions( $image_id );
310 + if ( ! empty( $missing_dimensions ) ) {
311 + if ( strpos( $image_tag, 'width=' ) === false ) {
312 + $image_tag = str_replace( 'data-opt-id=', 'width="' . $missing_dimensions['w'] . '" data-opt-id=', $image_tag );
313 + }
314 + if ( strpos( $image_tag, 'height=' ) === false ) {
315 + $image_tag = str_replace( 'data-opt-id=', 'height="' . $missing_dimensions['h'] . '" data-opt-id=', $image_tag );
316 + }
317 + }
318 + }
219 319 $content = str_replace( $images['img_tag'][ $index ], $image_tag, $content );
220 320 }
221 321 return $content;
222 322 }
223 323 /**
324 + * Check if we should lazyload a gif.
325 + *
326 + * @param string $url URL to check.
327 + * @param array $optml_args The width/height that we find for the image.
328 + * @return bool Should we lazyload the gif ?
329 + */
330 + public function should_lazy_gif( $url, $optml_args = [] ) {
331 +
332 + if ( strpos( $url, '/plugins/' ) !== false ) {
333 + return false;
334 + }
335 + if ( $this->is_valid_numeric( $optml_args['width'] ) && $this->is_valid_numeric( $optml_args['height'] ) && min( $optml_args['height'], $optml_args['width'] ) <= 20 ) {
336 + return false;
337 + }
338 + return true;
339 + }
340 + /**
224 341 * Check replacement is allowed for this tag.
225 342 *
226 343 * @param string $url Url.
227 344 * @param string $tag Html tag.
@@ -248,12 +365,16 @@
248 365 * @return array
249 366 */
250 367 private function parse_dimensions_from_tag( $tag, $image_sizes, $args = [] ) {
251 368 if ( preg_match( '#width=["|\']?([\d%]+)["|\']?#i', $tag, $width_string ) ) {
252 - $args['width'] = $width_string[1];
369 + if ( ctype_digit( $width_string[1] ) === true ) {
370 + $args['width'] = $width_string[1];
371 + }
253 372 }
254 373 if ( preg_match( '#height=["|\']?([\d%]+)["|\']?#i', $tag, $height_string ) ) {
255 - $args['height'] = $height_string[1];
374 + if ( ctype_digit( $height_string[1] ) === true ) {
375 + $args['height'] = $height_string[1];
376 + }
256 377 }
257 378 if ( preg_match( '#class=["|\']?[^"\']*size-([^"\'\s]+)[^"\']*["|\']?#i', $tag, $size ) ) {
258 379 $size = array_pop( $size );
259 380
@@ -283,23 +404,431 @@
283 404 *
284 405 * @return string
285 406 */
286 407 public function regular_tag_replace( $new_tag, $original_url, $new_url, $optml_args, $is_slashed = false, $full_tag = '' ) {
408 + if ( OPTML_DEBUG ) {
409 + do_action( 'optml_log', 'regular_tag_replace: ' . $original_url . ' ' . $new_url . $new_tag );
410 + }
287 411 $pattern = '/(?<!\/)' . preg_quote( $original_url, '/' ) . '/i';
288 412 $replace = $is_slashed ? addcslashes( $new_url, '/' ) : $new_url;
413 +
414 + // Get image ID for srcset enhancement
415 + $image_id = $this->get_id_by_url( $original_url );
416 +
417 + // Add missing srcset attributes based on measurements from JavaScript module
418 + $missing_srcsets = Optml_Manager::instance()->page_profiler->get_missing_srcsets( $image_id );
419 + if ( ! empty( $missing_srcsets ) ) {
420 + $new_tag = $this->add_missing_srcset_attributes( $new_tag, $missing_srcsets, $new_url, $is_slashed );
421 + }
422 +
423 + if ( $this->settings->get( 'lazyload' ) === 'enabled' && $this->settings->get( 'native_lazyload' ) === 'enabled'
424 + && apply_filters( 'optml_should_load_eager', '__return_true' ) && ! $this->is_valid_gif( $original_url ) ) {
425 + if ( strpos( $new_tag, 'loading=' ) === false ) {
426 + $new_tag = preg_replace( '/<img/im', $is_slashed ? '<img loading=\"eager\"' : '<img loading="eager"', $new_tag );
427 + } else {
428 + $new_tag = $is_slashed ? str_replace( 'loading=\"lazy\"', 'loading=\"eager\"', $new_tag ) : str_replace( 'loading="lazy"', 'loading="eager"', $new_tag );
429 + }
430 + }
431 +
432 + $no_viewport_data_available = true;
433 +
434 + if ( $this->settings->is_lazyload_type_viewport() ) {
435 + $image_id = $this->get_id_by_url( $original_url );
436 + $is_lcp_image = Optml_Manager::instance()->page_profiler->is_lcp_image_in_all_viewports( $image_id );
437 + $no_viewport_data_available = ! Optml_Manager::instance()->page_profiler->is_data_available();
438 + if ( OPTML_DEBUG ) {
439 + do_action( 'optml_log', 'Adding fetchpriority image is LCP ' . $original_url . '|' . $image_id );
440 + }
441 +
442 + if ( $is_lcp_image ) {
443 + $new_tag = preg_replace( '/<img/im', $is_slashed ? '<img fetchpriority=\"high\"' : '<img fetchpriority="high"', $new_tag );
444 + }
445 + }
446 +
447 + // If the image is between the first images we add the fetchpriority attribute to improve the LCP.
448 + if (
449 + $no_viewport_data_available &&
450 + $this->settings->is_lazyload_type_fixed() &&
451 + self::$lazyload_skipped_images < Optml_Lazyload_Replacer::get_skip_lazyload_limit() &&
452 + false === strpos( $new_tag, 'fetchpriority=' )
453 + ) {
454 + $new_tag = preg_replace( '/<img/im', $is_slashed ? '<img fetchpriority=\"high\"' : '<img fetchpriority="high"', $new_tag );
455 + }
456 +
457 + ++self::$lazyload_skipped_images;
289 458 return preg_replace( $pattern, $replace, $new_tag );
290 459 }
291 460
292 461 /**
462 + * Add or enhance srcset attributes to an image tag based on measurements from JavaScript module.
463 + *
464 + * @param string $tag The image tag.
465 + * @param array<array{w: int, h: int, s: string, d: int, b: int}> $missing_srcsets Array of missing srcset data from JavaScript module.
466 + * @param string $new_url The new image URL.
467 + * @param bool $is_slashed Whether the URL needs to be slashed.
468 + *
469 + * @return string The modified image tag with enhanced srcset attributes.
470 + */
471 + public function add_missing_srcset_attributes( $tag, $missing_srcsets, $new_url, $is_slashed = false ) {
472 + if ( OPTML_DEBUG ) {
473 + do_action( 'optml_log', 'add_missing_srcset_attributes: ' . $new_url . ' ' . print_r( $missing_srcsets, true ) );
474 + }
475 + if ( empty( $missing_srcsets ) || ! is_array( $missing_srcsets ) ) {
476 + return $tag;
477 + }
478 +
479 + // Check if srcset already exists
480 + $has_existing_srcset = strpos( $tag, 'srcset=' ) !== false;
481 + $has_existing_sizes = strpos( $tag, 'sizes=' ) !== false;
482 +
483 + // Build new srcset entries from missing srcset data
484 + $new_srcset_entries = [];
485 + $new_sizes_entries = [];
486 +
487 + foreach ( $missing_srcsets as $srcset_data ) {
488 + // Validate required fields
489 + if ( ! isset( $srcset_data['w'] ) || ! isset( $srcset_data['h'] ) || ! isset( $srcset_data['s'] ) ) {
490 + continue;
491 + }
492 +
493 + $width = (int) $srcset_data['w'];
494 + $height = (int) $srcset_data['h'];
495 + $descriptor = $srcset_data['s']; // e.g., "200w"
496 + $dpr = isset( $srcset_data['d'] ) ? (int) $srcset_data['d'] : 1;
497 + // If the retina images are not enabled, we don't need to add the retina srcset.
498 + if ( $dpr > 1 && $this->settings->get( 'retina_images' ) !== 'enabled' ) {
499 + continue;
500 + }
501 + $breakpoint = isset( $srcset_data['b'] ) ? (int) $srcset_data['b'] : 0;
502 +
503 + // Generate optimized URL for this size
504 + $optimized_url = $this->change_url_for_size( $new_url, $width, $height, $dpr );
505 +
506 + if ( $optimized_url ) {
507 + $escaped_url = esc_url( $optimized_url );
508 + if ( empty( $escaped_url ) ) {
509 + continue;
510 + }
511 + $new_srcset_entries[] = $escaped_url . ' ' . esc_attr( $descriptor );
512 +
513 + // Add sizes attribute entry for responsive breakpoints
514 + if ( $breakpoint > 0 ) {
515 + $new_sizes_entries[] = '(max-width: ' . $breakpoint . 'px) ' . $width . 'px';
516 + }
517 + }
518 + }
519 +
520 + if ( empty( $new_srcset_entries ) ) {
521 + return $tag;
522 + }
523 +
524 + if ( $has_existing_srcset ) {
525 + // Enhance existing srcset
526 + $tag = $this->enhance_existing_srcset( $tag, $new_srcset_entries, $is_slashed );
527 + } else {
528 + // Add new srcset attribute
529 + $srcset_value = implode( ', ', $new_srcset_entries );
530 + // Escape backreference metacharacters so this value can't be expanded by preg_replace() below.
531 + $srcset_value = addcslashes( $srcset_value, '\\$' );
532 + $srcset_attr = $is_slashed ? 'srcset=\"' . addcslashes( $srcset_value, '"' ) . '\"' : 'srcset="' . $srcset_value . '"';
533 +
534 + // Insert srcset attribute after the src attribute
535 + $tag = preg_replace( '/(src=["\'][^"\']*["\'])/i', '$1 ' . $srcset_attr, $tag );
536 + }
537 +
538 + // Handle sizes attribute - skip if existing sizes contains calc() or complex formulas
539 + if ( ! empty( $new_sizes_entries ) ) {
540 + $should_skip_sizes = false;
541 +
542 + if ( $has_existing_sizes ) {
543 + // Check if existing sizes contains calc() or complex formulas
544 + if ( preg_match( '/sizes=["\']([^"\']*)["\']/i', $tag, $matches ) ) {
545 + $existing_sizes = $matches[1];
546 + if ( $this->should_skip_sizes( $existing_sizes ) ) {
547 + $should_skip_sizes = true;
548 + if ( OPTML_DEBUG ) {
549 + do_action( 'optml_log', 'Skipping sizes enhancement due to complex formula: ' . $existing_sizes );
550 + }
551 + }
552 + }
553 + }
554 +
555 + if ( ! $should_skip_sizes ) {
556 + // Filter sizes entries to ensure they're appropriate for the container
557 + $filtered_sizes_entries = $this->filter_sizes_entries_for_container( $new_sizes_entries, $tag );
558 +
559 + if ( ! empty( $filtered_sizes_entries ) ) {
560 + if ( $has_existing_sizes ) {
561 + // Enhance existing sizes
562 + $tag = $this->enhance_existing_sizes( $tag, $filtered_sizes_entries, $is_slashed );
563 + } else {
564 + // Add new sizes attribute
565 + $sizes_entries = array_unique( $filtered_sizes_entries );
566 + // Sort ascending by breakpoint value (numeric)
567 + usort(
568 + $sizes_entries,
569 + function ( $a, $b ) {
570 + preg_match( '/max-width:\s*(\d+)/', $a, $ma );
571 + preg_match( '/max-width:\s*(\d+)/', $b, $mb );
572 + return ( $ma[1] ?? 0 ) - ( $mb[1] ?? 0 );
573 + }
574 + );
575 +
576 + $sizes_value = implode( ', ', $sizes_entries );
577 + // Escape backreference metacharacters before wrapping, see add_missing_srcset_attributes() above.
578 + $sizes_value = addcslashes( $sizes_value, '\\$' );
579 + $sizes_attr = $is_slashed ? 'sizes=\"' . addcslashes( $sizes_value, '"' ) . '\"' : 'sizes="' . $sizes_value . '"';
580 +
581 + // Insert sizes attribute after srcset
582 + $tag = preg_replace( '/(srcset=["\'][^"\']*["\'])/i', '$1 ' . $sizes_attr, $tag );
583 + }
584 + }
585 + } else {
586 + // Log that sizes were skipped due to complex formulas
587 + if ( OPTML_DEBUG ) {
588 + do_action( 'optml_log', 'Skipped adding sizes due to complex formula detection' );
589 + }
590 + }
591 + }
592 +
593 + if ( OPTML_DEBUG ) {
594 + $action = $has_existing_srcset ? 'Enhanced' : 'Added';
595 + do_action( 'optml_log', $action . ' srcset for image: ' . $new_url . ' with ' . count( $new_srcset_entries ) . ' new entries' );
596 + }
597 +
598 + return $tag;
599 + }
600 +
601 + /**
602 + * Enhance existing srcset attribute by adding new entries.
603 + *
604 + * @param string $tag The image tag.
605 + * @param array<int, string> $new_srcset_entries Array of new srcset entries to add.
606 + * @param bool $is_slashed Whether the URL needs to be slashed.
607 + *
608 + * @return string The modified image tag with enhanced srcset.
609 + */
610 + public function enhance_existing_srcset( $tag, $new_srcset_entries, $is_slashed = false ) {
611 + // Extract existing srcset value
612 + if ( preg_match( '/srcset=["\']([^"\']*)["\']/i', $tag, $matches ) ) {
613 + $existing_srcset = $matches[1];
614 + $existing_entries = array_map( 'trim', explode( ',', $existing_srcset ) );
615 +
616 + // Merge with new entries, avoiding duplicates
617 + $all_entries = array_merge( $existing_entries, $new_srcset_entries );
618 + $all_entries = array_unique( $all_entries );
619 +
620 + // Sort by descriptor (width) for better organization
621 + usort(
622 + $all_entries,
623 + function ( $a, $b ) {
624 + preg_match( '/(\d+)w/', $a, $matches_a );
625 + preg_match( '/(\d+)w/', $b, $matches_b );
626 + $width_a = isset( $matches_a[1] ) ? (int) $matches_a[1] : 0;
627 + $width_b = isset( $matches_b[1] ) ? (int) $matches_b[1] : 0;
628 + return $width_a - $width_b;
629 + }
630 + );
631 +
632 + $enhanced_srcset = implode( ', ', $all_entries );
633 + // Escape backreference metacharacters so this value can't be expanded by preg_replace() below.
634 + $enhanced_srcset = addcslashes( $enhanced_srcset, '\\$' );
635 + $srcset_attr = $is_slashed ? 'srcset=\"' . addcslashes( $enhanced_srcset, '"' ) . '\"' : 'srcset="' . $enhanced_srcset . '"';
636 +
637 + // Replace existing srcset
638 + $tag = preg_replace( '/srcset=["\'][^"\']*["\']/i', $srcset_attr, $tag );
639 + }
640 +
641 + return $tag;
642 + }
643 +
644 + /**
645 + * Enhance existing sizes attribute by adding new breakpoint entries.
646 + *
647 + * @param string $tag The image tag.
648 + * @param array<int, string> $new_sizes_entries Array of new sizes entries to add.
649 + * @param bool $is_slashed Whether the URL needs to be slashed.
650 + *
651 + * @return string The modified image tag with enhanced sizes.
652 + */
653 + public function enhance_existing_sizes( $tag, $new_sizes_entries, $is_slashed = false ) {
654 + // Extract existing sizes value
655 + if ( preg_match( '/sizes=["\']([^"\']*)["\']/i', $tag, $matches ) ) {
656 + $existing_sizes = $matches[1];
657 + $existing_entries = array_map( 'trim', explode( ',', $existing_sizes ) );
658 +
659 + // Merge with new entries, avoiding duplicates
660 + $all_entries = array_merge( $existing_entries, $new_sizes_entries );
661 + $all_entries = array_unique( $all_entries );
662 +
663 + // Sort by breakpoint (descending)
664 + usort(
665 + $all_entries,
666 + function ( $a, $b ) {
667 + preg_match( '/max-width:\s*(\d+)px/', $a, $matches_a );
668 + preg_match( '/max-width:\s*(\d+)px/', $b, $matches_b );
669 + $breakpoint_a = isset( $matches_a[1] ) ? (int) $matches_a[1] : 0;
670 + $breakpoint_b = isset( $matches_b[1] ) ? (int) $matches_b[1] : 0;
671 + return $breakpoint_b - $breakpoint_a; // Descending order
672 + }
673 + );
674 +
675 + $enhanced_sizes = implode( ', ', $all_entries );
676 + // Escape backreference metacharacters before wrapping, see enhance_existing_srcset() above.
677 + $enhanced_sizes = addcslashes( $enhanced_sizes, '\\$' );
678 + $sizes_attr = $is_slashed ? 'sizes=\"' . addcslashes( $enhanced_sizes, '"' ) . '\"' : 'sizes="' . $enhanced_sizes . '"';
679 +
680 + // Replace existing sizes
681 + $tag = preg_replace( '/sizes=["\'][^"\']*["\']/i', $sizes_attr, $tag );
682 + }
683 +
684 + return $tag;
685 + }
686 +
687 + /**
688 + * Check if sizes attribute contains complex formulas that should not be modified.
689 + *
690 + * @param string $sizes_value The sizes attribute value.
691 + *
692 + * @return bool True if contains complex formulas, false otherwise.
693 + */
694 + public function should_skip_sizes( $sizes_value ) {
695 + // Check for calc() functions
696 + if ( strpos( $sizes_value, 'calc(' ) !== false ) {
697 + return true;
698 + }
699 +
700 + // Check for complex mathematical operations
701 + if ( preg_match( '/\d+%\s*[+\-*\/]|[+\-*\/]\s*\d+%|\d+px\s*[+\-*\/]|[+\-*\/]\s*\d+px/', $sizes_value ) ) {
702 + return true;
703 + }
704 +
705 + // Check for viewport units with calculations
706 + if ( preg_match( '/\d+vw\s*[+\-*\/]|\d+vh\s*[+\-*\/]|\d+vmin\s*[+\-*\/]|\d+vmax\s*[+\-*\/]/', $sizes_value ) ) {
707 + return true;
708 + }
709 +
710 + // Check for complex CSS functions
711 + if ( preg_match( '/(min|max|clamp)\(/', $sizes_value ) ) {
712 + return true;
713 + }
714 +
715 + // Check for multiple percentage values with operations
716 + if ( preg_match( '/\d+%\s*[+\-*\/]\s*\d+%/', $sizes_value ) ) {
717 + return true;
718 + }
719 +
720 + return false;
721 + }
722 +
723 + /**
724 + * Filter sizes entries to ensure they're appropriate for the container size.
725 + * This prevents oversized images from being selected for small containers.
726 + *
727 + * @param array<int, string> $sizes_entries Array of sizes entries from JavaScript module.
728 + * @param string $tag The image tag to extract container dimensions from.
729 + *
730 + * @return array<int, string> Filtered sizes entries appropriate for the container.
731 + */
732 + public function filter_sizes_entries_for_container( $sizes_entries, $tag ) {
733 + if ( empty( $sizes_entries ) ) {
734 + return [];
735 + }
736 +
737 + // Extract container dimensions from the image tag
738 + $container_width = null;
739 + $container_height = null;
740 +
741 + // Try to get width and height from the tag
742 + if ( preg_match( '/width=["\']?(\d+)["\']?/i', $tag, $width_match ) ) {
743 + $container_width = (int) $width_match[1];
744 + }
745 + if ( preg_match( '/height=["\']?(\d+)["\']?/i', $tag, $height_match ) ) {
746 + $container_height = (int) $height_match[1];
747 + }
748 +
749 + // If we can't determine container size, return all entries (fallback)
750 + if ( ! $container_width || ! $container_height ) {
751 + if ( OPTML_DEBUG ) {
752 + do_action( 'optml_log', 'Cannot determine container size, using all sizes entries' );
753 + }
754 + return $sizes_entries;
755 + }
756 +
757 + // Calculate maximum reasonable size for this container
758 + $max_reasonable_width = $container_width * 1.5; // Allow up to 1.5x the container size
759 +
760 + $filtered_entries = [];
761 +
762 + foreach ( $sizes_entries as $entry ) {
763 + // Extract image size from the entry (e.g., "(max-width: 768px) 400px" -> 400)
764 + if ( preg_match( '/\(max-width:\s*\d+px\)\s*(\d+)px/', $entry, $matches ) ) {
765 + $image_width = (int) $matches[1];
766 +
767 + // Only include entries where the image size is reasonable for the container
768 + if ( $image_width <= $max_reasonable_width ) {
769 + $filtered_entries[] = $entry;
770 + } else {
771 + if ( OPTML_DEBUG ) {
772 + do_action( 'optml_log', "Filtered out oversized entry: {$entry} (container: {$container_width}px, image: {$image_width}px)" );
773 + }
774 + }
775 + } else {
776 + // If we can't parse the entry, include it (better safe than sorry)
777 + $filtered_entries[] = $entry;
778 + }
779 + }
780 +
781 + if ( OPTML_DEBUG ) {
782 + do_action( 'optml_log', "Filtered sizes entries for container {$container_width}x{$container_height}: " . count( $filtered_entries ) . ' of ' . count( $sizes_entries ) . ' entries' );
783 + }
784 +
785 + return $filtered_entries;
786 + }
787 +
788 + /**
789 + * Generate optimized URL for a specific size and DPR.
790 + *
791 + * @param string $original_url The original image URL.
792 + * @param int $width The target width.
793 + * @param int $height The target height.
794 + * @param int $dpr The device pixel ratio.
795 + *
796 + * @return string The optimized URL or false on failure.
797 + */
798 + public function change_url_for_size( $original_url, $width, $height, $dpr = 1 ) {
799 + // Assume w and h are always present - just replace them
800 + // Updated regex to match w:auto, w:123, h:auto, h:456, etc.
801 + $replacements = [
802 + '/w:(?:auto|\d+)/' => 'w:' . $width,
803 + '/h:(?:auto|\d+)/' => 'h:' . $height,
804 + ];
805 +
806 + // Handle DPR - replace if exists, add after w: if missing
807 + if ( $dpr > 1 ) {
808 + if ( strpos( $original_url, 'dpr:' ) !== false ) {
809 + $replacements['/dpr:\d+/'] = 'dpr:' . $dpr;
810 + } else {
811 + // Add DPR after width parameter - use the same pattern as width replacement
812 + $replacements['/(w:(?:auto|\d+))/'] = '$1/dpr:' . $dpr;
813 + }
814 + } else {
815 + $replacements['/dpr:\d+\//'] = '';
816 + }
817 +
818 + return preg_replace( array_keys( $replacements ), array_values( $replacements ), $original_url );
819 + }
820 +
821 + /**
293 822 * Replace image URLs in the srcset attributes and in case there is a resize in action, also replace the sizes.
294 823 *
295 - * @param array $sources Array of image sources.
296 - * @param array $size_array Array of width and height values in pixels (in that order).
297 - * @param string $image_src The 'src' of the image.
298 - * @param array $image_meta The image meta data as returned by 'wp_get_attachment_metadata()'.
299 - * @param int $attachment_id Image attachment ID.
824 + * @param array<int, array{url: string, descriptor: string, value: int}>|mixed $sources Array of image sources.
825 + * @param array{0: int, 1: int}|int[] $size_array Array of width and height values in pixels (in that order).
826 + * @param string $image_src The 'src' of the image.
827 + * @param array<string, mixed> $image_meta The image meta data as returned by 'wp_get_attachment_metadata()'.
828 + * @param int $attachment_id Image attachment ID or 0.
300 829 *
301 - * @return array
830 + * @return array|mixed
302 831 */
303 832 public function filter_srcset_attr( $sources = [], $size_array = [], $image_src = '', $image_meta = [], $attachment_id = 0 ) {
304 833 if ( ! is_array( $sources ) ) {
305 834 return $sources;
@@ -312,9 +841,13 @@
312 841 if ( count( $size_array ) === 2 ) {
313 842 $sizes = self::size_to_crop();
314 843 $cropping = isset( $sizes[ $size_array[0] . $size_array[1] ] ) ? $this->to_optml_crop( $sizes[ $size_array[0] . $size_array[1] ] ) : null;
315 844 }
316 -
845 + if ( OPTML_DEBUG ) {
846 + do_action( 'optml_log', 'sources: ' . print_r( $sources, true ) . ' size_array: ' . print_r( $size_array, true ) );
847 + }
848 + $biggest_size = [];
849 + $max_width = 0;
317 850 foreach ( $sources as $i => $source ) {
318 851 $url = $source['url'];
319 852 if ( Optml_Media_Offload::is_uploaded_image( $url ) ) {
320 853 continue;
@@ -349,11 +882,21 @@
349 882 $args['resize'] = $cropping;
350 883 } else {
351 884 $args['resize'] = $this->to_optml_crop( $file_crop );
352 885 }
886 + if ( $i > $max_width ) {
887 + $max_width = $i;
888 + $biggest_size = [ $args, $original_url ];
889 + }
353 890 $sources[ $i ]['url'] = apply_filters( 'optml_content_url', $original_url, $args );
891 +
354 892 }
355 893
894 + if ( $this->settings->get( 'retina_images' ) === 'enabled' && ! empty( $biggest_size ) ) {
895 + $sources[ $max_width * 2 ]['url'] = apply_filters( 'optml_content_url', $biggest_size[1], array_merge( $biggest_size[0], [ 'dpr' => 2 ] ) );
896 + $sources[ $max_width * 2 ]['value'] = 2;
897 + $sources[ $max_width * 2 ]['descriptor'] = 'x';
898 + }
356 899 return $sources;
357 900 }
358 901
359 902 /**
@@ -358,12 +901,12 @@
358 901
359 902 /**
360 903 * Filters sizes attribute of the images.
361 904 *
362 - * @param array $sizes An array of media query breakpoints.
363 - * @param array $size Width and height of the image.
905 + * @param string $sizes An array of media query breakpoints.
906 + * @param string|int[] $size Width and height of the image.
364 907 *
365 - * @return mixed An array of media query breakpoints.
908 + * @return string An array of media query breakpoints.
366 909 */
367 910 public function filter_sizes_attr( $sizes, $size ) {
368 911
369 912 if ( ! doing_filter( 'the_content' ) ) {
@@ -395,8 +938,11 @@
395 938 * @return array
396 939 */
397 940 public function filter_image_downsize( $image, $attachment_id, $size ) {
398 941
942 + if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
943 + return $image;
944 + }
399 945 $image_url = wp_get_attachment_url( $attachment_id );
400 946 if ( Optml_Media_Offload::is_uploaded_image( $image_url ) ) {
401 947 return $image;
402 948 }
@@ -404,47 +950,10 @@
404 950 return $image;
405 951 }
406 952
407 953 $image_meta = wp_get_attachment_metadata( $attachment_id );
408 - $image_args = self::image_sizes();
409 - // default size
410 - $sizes = [
411 - 'width' => isset( $image_meta['width'] ) ? intval( $image_meta['width'] ) : false,
412 - 'height' => isset( $image_meta['height'] ) ? intval( $image_meta['height'] ) : false,
413 - ];
954 + $sizes = $this->size_to_dimension( $size, $image_meta, $attachment_id );
414 955
415 - switch ( $size ) {
416 - case is_array( $size ):
417 - $width = isset( $size[0] ) ? (int) $size[0] : false;
418 - $height = isset( $size[1] ) ? (int) $size[1] : false;
419 - if ( ! $width || ! $height ) {
420 - break;
421 - }
422 - $image_resized = image_resize_dimensions( $sizes['width'], $sizes['height'], $width, $height );
423 - if ( $image_resized ) {
424 - $width = $image_resized[6];
425 - $height = $image_resized[7];
426 - } else {
427 - $width = $image_meta['width'];
428 - $height = $image_meta['height'];
429 - }
430 - list( $sizes['width'], $sizes['height'] ) = image_constrain_size_for_editor( $width, $height, $size );
431 -
432 - break;
433 - case 'full' !== $size && isset( $image_args[ $size ] ):
434 - $image_resized = image_resize_dimensions( $sizes['width'], $sizes['height'], $image_args[ $size ]['width'], $image_args[ $size ]['height'], $image_args[ $size ]['crop'] );
435 -
436 - if ( $image_resized ) { // This could be false when the requested image size is larger than the full-size image.
437 - $sizes['width'] = $image_resized[6];
438 - $sizes['height'] = $image_resized[7];
439 - }
440 -
441 - list( $sizes['width'], $sizes['height'] ) = image_constrain_size_for_editor( $sizes['width'], $sizes['height'], $size, 'display' );
442 -
443 - $sizes['resize'] = $this->to_optml_crop( $image_args[ $size ]['crop'] );
444 -
445 - break;
446 - }
447 956 $image_url = $this->strip_image_size_from_url( $image_url );
448 957
449 958 $new_url = apply_filters( 'optml_content_url', $image_url, $sizes );
450 959
@@ -487,6 +996,5 @@
487 996 public function __wakeup() {
488 997 // Unserializing instances of the class is forbidden.
489 998 _doing_it_wrong( __FUNCTION__, esc_html__( 'Cheatin&#8217; huh?', 'optimole-wp' ), '1.0.0' );
490 999 }
491 -
492 1000 }