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 +577 -81 3.1.1 → 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 *
@@ -45,31 +48,89 @@
45 48 /**
46 49 * The initialize method.
47 50 */
48 51 public function init() {
49 -
50 52 parent::init();
51 53 add_filter( 'optml_content_images_tags', [ $this, 'process_image_tags' ], 1, 2 );
52 54
53 - if ( $this->settings->use_lazyload() ) {
54 - return;
55 + if ( ! $this->settings->use_lazyload() ) {
56 + add_filter( 'optml_tag_replace', [ $this, 'regular_tag_replace' ], 1, 6 );
55 57 }
56 -
57 - add_filter( 'optml_tag_replace', [ $this, 'regular_tag_replace' ], 1, 6 );
58 58 add_filter( 'image_downsize', [ $this, 'filter_image_downsize' ], PHP_INT_MAX, 3 );
59 - 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 );
60 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 + }
61 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;
62 115 }
63 116 /**
64 - * Called by hook to replace image tags in content.
117 + * Filter the attachment image attributes to add the srcset attribute with retina support.
65 118 *
66 - * @param string $content The content to process.
67 - * @param array $images List of image tags.
119 + * This is covering the case where the image has no sizes.
68 120 *
69 - * @return mixed
121 + * @param array $attr The attributes.
122 + * @param WP_Post $attachment The attachment.
123 + * @param string $size The size.
70 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 + }
71 131
132 +
72 133 /**
73 134 * Replace in given content the given image tags with video tags is image is gif
74 135 *
75 136 * @param string $image_url Image url to process.
@@ -81,15 +142,17 @@
81 142 public function img_to_video( $image_url, $image_tag, &$content ) {
82 143 if ( $this->settings->get( 'img_to_video' ) === 'disabled' ) {
83 144 return false;
84 145 }
85 - 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 ] ) ) ) {
86 148 return false;
87 149 }
88 150 $link_mp4 = apply_filters(
89 151 'optml_content_url',
90 152 $image_url,
91 - ['width' => 'auto',
153 + [
154 + 'width' => 'auto',
92 155 'height' => 'auto',
93 156 'format' => 'mp4',
94 157 ]
95 158 );
@@ -96,9 +159,10 @@
96 159
97 160 $link_png = apply_filters(
98 161 'optml_content_url',
99 162 $image_url,
100 - ['width' => 'auto',
163 + [
164 + 'width' => 'auto',
101 165 'height' => 'auto',
102 166 'quality' => 'eco',
103 167 ]
104 168 );
@@ -133,8 +197,11 @@
133 197 public function process_image_tags( $content, $images = [] ) {
134 198
135 199 $image_sizes = self::image_sizes();
136 200 $sizes2crop = self::size_to_crop();
201 + if ( OPTML_DEBUG ) {
202 + do_action( 'optml_log', 'Images tags to process: ' . print_r( $images, true ) );
203 + }
137 204 foreach ( $images[0] as $index => $tag ) {
138 205 $width = $height = false;
139 206 $crop = null;
140 207 $image_tag = $images['img_tag'][ $index ];
@@ -160,19 +227,12 @@
160 227 );
161 228 $images['img_url'][ $index ] = $new_src;
162 229 }
163 230 if ( ( apply_filters( 'optml_ignore_image_link', false, $src ) ||
164 - false !== strpos( $src, Optml_Config::$service_url ) ||
165 - ! $this->can_replace_url( $src ) ||
166 - ! $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 )
167 232 ) {
168 233 continue; // @codeCoverageIgnore
169 234 }
170 -
171 - if ( $this->img_to_video( $images['img_url'][ $index ], $images['img_tag'][ $index ], $content ) ) {
172 - continue;
173 - }
174 -
175 235 $resize = apply_filters( 'optml_default_crop', [] );
176 236
177 237 list( $width, $height, $resize ) = self::parse_dimensions_from_tag(
178 238 $images['img_tag'][ $index ],
@@ -192,15 +252,27 @@
192 252 $resize = $this->to_optml_crop( $crop );
193 253 }
194 254
195 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 +
196 264 $tmp = $this->strip_image_size_from_url( $tmp );
197 - $new_url = apply_filters( 'optml_content_url', $tmp, $optml_args );
198 265
199 - if ( $new_url === $tmp && ! Optml_Media_Offload::is_not_processed_image( $src ) ) {
200 - 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.
201 271 }
202 272
273 + $new_url = apply_filters( 'optml_content_url', $tmp, $optml_args );
274 +
203 275 $image_tag = str_replace(
204 276 [
205 277 'width="' . $width . '"',
206 278 'width=\"' . $width . '\"',
@@ -214,25 +286,59 @@
214 286 'height=\"' . $optml_args['height'] . '\"',
215 287 ],
216 288 $image_tag
217 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 ) {
218 292
219 - // If the image is in header or has a class excluded from lazyload, we need to do the regular replace.
220 - if ( $images['in_header'][ $index ] ) {
221 293 $image_tag = $this->regular_tag_replace( $image_tag, $images['img_url'][ $index ], $new_url, $optml_args, $is_slashed, $tag );
222 294 } else {
223 295 $image_tag = apply_filters( 'optml_tag_replace', $image_tag, $images['img_url'][ $index ], $new_url, $optml_args, $is_slashed, $tag );
224 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 + }
300 +
301 + if ( $priority = Links::is_preloaded( $image_id ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.Found
302 + Links::preload_tag( $image_tag, $priority );
303 + }
304 +
225 305 if ( strpos( $image_tag, 'decoding=' ) === false ) {
226 - $pattern = '/<img(.*?)/is';
227 - $replace = '<img decoding=async $1';
228 - $image_tag = preg_replace( $pattern, $replace, $image_tag );
306 + $image_tag = str_replace( 'data-opt-id=', 'decoding=async data-opt-id=', $image_tag );
229 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 + }
230 319 $content = str_replace( $images['img_tag'][ $index ], $image_tag, $content );
231 320 }
232 321 return $content;
233 322 }
234 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 + /**
235 341 * Check replacement is allowed for this tag.
236 342 *
237 343 * @param string $url Url.
238 344 * @param string $tag Html tag.
@@ -259,12 +365,16 @@
259 365 * @return array
260 366 */
261 367 private function parse_dimensions_from_tag( $tag, $image_sizes, $args = [] ) {
262 368 if ( preg_match( '#width=["|\']?([\d%]+)["|\']?#i', $tag, $width_string ) ) {
263 - $args['width'] = $width_string[1];
369 + if ( ctype_digit( $width_string[1] ) === true ) {
370 + $args['width'] = $width_string[1];
371 + }
264 372 }
265 373 if ( preg_match( '#height=["|\']?([\d%]+)["|\']?#i', $tag, $height_string ) ) {
266 - $args['height'] = $height_string[1];
374 + if ( ctype_digit( $height_string[1] ) === true ) {
375 + $args['height'] = $height_string[1];
376 + }
267 377 }
268 378 if ( preg_match( '#class=["|\']?[^"\']*size-([^"\'\s]+)[^"\']*["|\']?#i', $tag, $size ) ) {
269 379 $size = array_pop( $size );
270 380
@@ -294,24 +404,431 @@
294 404 *
295 405 * @return string
296 406 */
297 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 + }
298 411 $pattern = '/(?<!\/)' . preg_quote( $original_url, '/' ) . '/i';
299 412 $replace = $is_slashed ? addcslashes( $new_url, '/' ) : $new_url;
300 - self::$lazyload_skipped_images ++;
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;
301 458 return preg_replace( $pattern, $replace, $new_tag );
302 459 }
303 460
304 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 + /**
305 822 * Replace image URLs in the srcset attributes and in case there is a resize in action, also replace the sizes.
306 823 *
307 - * @param array $sources Array of image sources.
308 - * @param array $size_array Array of width and height values in pixels (in that order).
309 - * @param string $image_src The 'src' of the image.
310 - * @param array $image_meta The image meta data as returned by 'wp_get_attachment_metadata()'.
311 - * @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.
312 829 *
313 - * @return array
830 + * @return array|mixed
314 831 */
315 832 public function filter_srcset_attr( $sources = [], $size_array = [], $image_src = '', $image_meta = [], $attachment_id = 0 ) {
316 833 if ( ! is_array( $sources ) ) {
317 834 return $sources;
@@ -324,9 +841,13 @@
324 841 if ( count( $size_array ) === 2 ) {
325 842 $sizes = self::size_to_crop();
326 843 $cropping = isset( $sizes[ $size_array[0] . $size_array[1] ] ) ? $this->to_optml_crop( $sizes[ $size_array[0] . $size_array[1] ] ) : null;
327 844 }
328 -
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;
329 850 foreach ( $sources as $i => $source ) {
330 851 $url = $source['url'];
331 852 if ( Optml_Media_Offload::is_uploaded_image( $url ) ) {
332 853 continue;
@@ -361,11 +882,21 @@
361 882 $args['resize'] = $cropping;
362 883 } else {
363 884 $args['resize'] = $this->to_optml_crop( $file_crop );
364 885 }
886 + if ( $i > $max_width ) {
887 + $max_width = $i;
888 + $biggest_size = [ $args, $original_url ];
889 + }
365 890 $sources[ $i ]['url'] = apply_filters( 'optml_content_url', $original_url, $args );
891 +
366 892 }
367 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 + }
368 899 return $sources;
369 900 }
370 901
371 902 /**
@@ -370,12 +901,12 @@
370 901
371 902 /**
372 903 * Filters sizes attribute of the images.
373 904 *
374 - * @param array $sizes An array of media query breakpoints.
375 - * @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.
376 907 *
377 - * @return mixed An array of media query breakpoints.
908 + * @return string An array of media query breakpoints.
378 909 */
379 910 public function filter_sizes_attr( $sizes, $size ) {
380 911
381 912 if ( ! doing_filter( 'the_content' ) ) {
@@ -407,8 +938,11 @@
407 938 * @return array
408 939 */
409 940 public function filter_image_downsize( $image, $attachment_id, $size ) {
410 941
942 + if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
943 + return $image;
944 + }
411 945 $image_url = wp_get_attachment_url( $attachment_id );
412 946 if ( Optml_Media_Offload::is_uploaded_image( $image_url ) ) {
413 947 return $image;
414 948 }
@@ -416,47 +950,10 @@
416 950 return $image;
417 951 }
418 952
419 953 $image_meta = wp_get_attachment_metadata( $attachment_id );
420 - $image_args = self::image_sizes();
421 - // default size
422 - $sizes = [
423 - 'width' => isset( $image_meta['width'] ) ? intval( $image_meta['width'] ) : false,
424 - 'height' => isset( $image_meta['height'] ) ? intval( $image_meta['height'] ) : false,
425 - ];
954 + $sizes = $this->size_to_dimension( $size, $image_meta, $attachment_id );
426 955
427 - switch ( $size ) {
428 - case is_array( $size ):
429 - $width = isset( $size[0] ) ? (int) $size[0] : false;
430 - $height = isset( $size[1] ) ? (int) $size[1] : false;
431 - if ( ! $width || ! $height ) {
432 - break;
433 - }
434 - $image_resized = image_resize_dimensions( $sizes['width'], $sizes['height'], $width, $height );
435 - if ( $image_resized ) {
436 - $width = $image_resized[6];
437 - $height = $image_resized[7];
438 - } else {
439 - $width = $image_meta['width'];
440 - $height = $image_meta['height'];
441 - }
442 - list( $sizes['width'], $sizes['height'] ) = image_constrain_size_for_editor( $width, $height, $size );
443 -
444 - break;
445 - case 'full' !== $size && isset( $image_args[ $size ] ):
446 - $image_resized = image_resize_dimensions( $sizes['width'], $sizes['height'], $image_args[ $size ]['width'], $image_args[ $size ]['height'], $image_args[ $size ]['crop'] );
447 -
448 - if ( $image_resized ) { // This could be false when the requested image size is larger than the full-size image.
449 - $sizes['width'] = $image_resized[6];
450 - $sizes['height'] = $image_resized[7];
451 - }
452 -
453 - list( $sizes['width'], $sizes['height'] ) = image_constrain_size_for_editor( $sizes['width'], $sizes['height'], $size, 'display' );
454 -
455 - $sizes['resize'] = $this->to_optml_crop( $image_args[ $size ]['crop'] );
456 -
457 - break;
458 - }
459 956 $image_url = $this->strip_image_size_from_url( $image_url );
460 957
461 958 $new_url = apply_filters( 'optml_content_url', $image_url, $sizes );
462 959
@@ -499,6 +996,5 @@
499 996 public function __wakeup() {
500 997 // Unserializing instances of the class is forbidden.
501 998 _doing_it_wrong( __FUNCTION__, esc_html__( 'Cheatin&#8217; huh?', 'optimole-wp' ), '1.0.0' );
502 999 }
503 -
504 1000 }