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 +595 -81 2.5.7 → 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 ];
@@ -152,21 +226,13 @@
152 226 $image_tag
153 227 );
154 228 $images['img_url'][ $index ] = $new_src;
155 229 }
156 - 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 )
230 + if ( ( apply_filters( 'optml_ignore_image_link', false, $src ) ||
231 + ! $this->can_replace_tag( $images['img_url'][ $index ], $tag ) ) && ! Optml_Media_Offload::is_not_processed_image( $src )
160 232 ) {
161 -
162 233 continue; // @codeCoverageIgnore
163 234 }
164 -
165 - if ( $this->img_to_video( $images['img_url'][ $index ], $images['img_tag'][ $index ], $content ) ) {
166 - continue;
167 - }
168 -
169 235 $resize = apply_filters( 'optml_default_crop', [] );
170 236
171 237 list( $width, $height, $resize ) = self::parse_dimensions_from_tag(
172 238 $images['img_tag'][ $index ],
@@ -186,15 +252,27 @@
186 252 $resize = $this->to_optml_crop( $crop );
187 253 }
188 254
189 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 +
190 264 $tmp = $this->strip_image_size_from_url( $tmp );
191 - $new_url = apply_filters( 'optml_content_url', $tmp, $optml_args );
192 265
193 - if ( $new_url === $tmp ) {
194 - 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.
195 271 }
196 272
273 + $new_url = apply_filters( 'optml_content_url', $tmp, $optml_args );
274 +
197 275 $image_tag = str_replace(
198 276 [
199 277 'width="' . $width . '"',
200 278 'width=\"' . $width . '\"',
@@ -208,21 +286,59 @@
208 286 'height=\"' . $optml_args['height'] . '\"',
209 287 ],
210 288 $image_tag
211 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 ) {
212 292
213 - // If the image is in header or has a class excluded from lazyload, we need to do the regular replace.
214 - if ( $images['in_header'][ $index ] ) {
215 293 $image_tag = $this->regular_tag_replace( $image_tag, $images['img_url'][ $index ], $new_url, $optml_args, $is_slashed, $tag );
216 294 } else {
217 295 $image_tag = apply_filters( 'optml_tag_replace', $image_tag, $images['img_url'][ $index ], $new_url, $optml_args, $is_slashed, $tag );
218 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 + }
219 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 + }
220 319 $content = str_replace( $images['img_tag'][ $index ], $image_tag, $content );
221 320 }
222 321 return $content;
223 322 }
224 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 + /**
225 341 * Check replacement is allowed for this tag.
226 342 *
227 343 * @param string $url Url.
228 344 * @param string $tag Html tag.
@@ -249,12 +365,16 @@
249 365 * @return array
250 366 */
251 367 private function parse_dimensions_from_tag( $tag, $image_sizes, $args = [] ) {
252 368 if ( preg_match( '#width=["|\']?([\d%]+)["|\']?#i', $tag, $width_string ) ) {
253 - $args['width'] = $width_string[1];
369 + if ( ctype_digit( $width_string[1] ) === true ) {
370 + $args['width'] = $width_string[1];
371 + }
254 372 }
255 373 if ( preg_match( '#height=["|\']?([\d%]+)["|\']?#i', $tag, $height_string ) ) {
256 - $args['height'] = $height_string[1];
374 + if ( ctype_digit( $height_string[1] ) === true ) {
375 + $args['height'] = $height_string[1];
376 + }
257 377 }
258 378 if ( preg_match( '#class=["|\']?[^"\']*size-([^"\'\s]+)[^"\']*["|\']?#i', $tag, $size ) ) {
259 379 $size = array_pop( $size );
260 380
@@ -284,29 +404,439 @@
284 404 *
285 405 * @return string
286 406 */
287 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 + }
288 411 $pattern = '/(?<!\/)' . preg_quote( $original_url, '/' ) . '/i';
289 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;
290 458 return preg_replace( $pattern, $replace, $new_tag );
291 459 }
292 460
293 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 + /**
294 822 * Replace image URLs in the srcset attributes and in case there is a resize in action, also replace the sizes.
295 823 *
296 - * @param array $sources Array of image sources.
297 - * @param array $size_array Array of width and height values in pixels (in that order).
298 - * @param string $image_src The 'src' of the image.
299 - * @param array $image_meta The image meta data as returned by 'wp_get_attachment_metadata()'.
300 - * @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.
301 829 *
302 - * @return array
830 + * @return array|mixed
303 831 */
304 832 public function filter_srcset_attr( $sources = [], $size_array = [], $image_src = '', $image_meta = [], $attachment_id = 0 ) {
305 833 if ( ! is_array( $sources ) ) {
306 834 return $sources;
307 835 }
308 -
836 + if ( Optml_Media_Offload::is_uploaded_image( $image_src ) ) {
837 + return $sources;
838 + }
309 839 $original_url = null;
310 840 $cropping = null;
311 841 if ( count( $size_array ) === 2 ) {
312 842 $sizes = self::size_to_crop();
@@ -311,11 +841,18 @@
311 841 if ( count( $size_array ) === 2 ) {
312 842 $sizes = self::size_to_crop();
313 843 $cropping = isset( $sizes[ $size_array[0] . $size_array[1] ] ) ? $this->to_optml_crop( $sizes[ $size_array[0] . $size_array[1] ] ) : null;
314 844 }
315 -
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;
316 850 foreach ( $sources as $i => $source ) {
317 851 $url = $source['url'];
852 + if ( Optml_Media_Offload::is_uploaded_image( $url ) ) {
853 + continue;
854 + }
318 855 list( $width, $height, $file_crop ) = $this->parse_dimensions_from_filename( $url );
319 856
320 857 if ( empty( $width ) ) {
321 858 $width = $image_meta['width'];
@@ -345,11 +882,21 @@
345 882 $args['resize'] = $cropping;
346 883 } else {
347 884 $args['resize'] = $this->to_optml_crop( $file_crop );
348 885 }
886 + if ( $i > $max_width ) {
887 + $max_width = $i;
888 + $biggest_size = [ $args, $original_url ];
889 + }
349 890 $sources[ $i ]['url'] = apply_filters( 'optml_content_url', $original_url, $args );
891 +
350 892 }
351 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 + }
352 899 return $sources;
353 900 }
354 901
355 902 /**
@@ -354,12 +901,12 @@
354 901
355 902 /**
356 903 * Filters sizes attribute of the images.
357 904 *
358 - * @param array $sizes An array of media query breakpoints.
359 - * @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.
360 907 *
361 - * @return mixed An array of media query breakpoints.
908 + * @return string An array of media query breakpoints.
362 909 */
363 910 public function filter_sizes_attr( $sizes, $size ) {
364 911
365 912 if ( ! doing_filter( 'the_content' ) ) {
@@ -391,54 +938,22 @@
391 938 * @return array
392 939 */
393 940 public function filter_image_downsize( $image, $attachment_id, $size ) {
394 941
942 + if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
943 + return $image;
944 + }
395 945 $image_url = wp_get_attachment_url( $attachment_id );
396 -
946 + if ( Optml_Media_Offload::is_uploaded_image( $image_url ) ) {
947 + return $image;
948 + }
397 949 if ( $image_url === false ) {
398 950 return $image;
399 951 }
400 952
401 953 $image_meta = wp_get_attachment_metadata( $attachment_id );
402 - $image_args = self::image_sizes();
403 - // default size
404 - $sizes = [
405 - 'width' => isset( $image_meta['width'] ) ? intval( $image_meta['width'] ) : false,
406 - 'height' => isset( $image_meta['height'] ) ? intval( $image_meta['height'] ) : false,
407 - ];
954 + $sizes = $this->size_to_dimension( $size, $image_meta, $attachment_id );
408 955
409 - switch ( $size ) {
410 - case is_array( $size ):
411 - $width = isset( $size[0] ) ? (int) $size[0] : false;
412 - $height = isset( $size[1] ) ? (int) $size[1] : false;
413 - if ( ! $width || ! $height ) {
414 - break;
415 - }
416 - $image_resized = image_resize_dimensions( $sizes['width'], $sizes['height'], $width, $height );
417 - if ( $image_resized ) {
418 - $width = $image_resized[6];
419 - $height = $image_resized[7];
420 - } else {
421 - $width = $image_meta['width'];
422 - $height = $image_meta['height'];
423 - }
424 - list( $sizes['width'], $sizes['height'] ) = image_constrain_size_for_editor( $width, $height, $size );
425 -
426 - break;
427 - case 'full' !== $size && isset( $image_args[ $size ] ):
428 - $image_resized = image_resize_dimensions( $sizes['width'], $sizes['height'], $image_args[ $size ]['width'], $image_args[ $size ]['height'], $image_args[ $size ]['crop'] );
429 -
430 - if ( $image_resized ) { // This could be false when the requested image size is larger than the full-size image.
431 - $sizes['width'] = $image_resized[6];
432 - $sizes['height'] = $image_resized[7];
433 - }
434 -
435 - list( $sizes['width'], $sizes['height'] ) = image_constrain_size_for_editor( $sizes['width'], $sizes['height'], $size, 'display' );
436 -
437 - $sizes['resize'] = $this->to_optml_crop( $image_args[ $size ]['crop'] );
438 -
439 - break;
440 - }
441 956 $image_url = $this->strip_image_size_from_url( $image_url );
442 957
443 958 $new_url = apply_filters( 'optml_content_url', $image_url, $sizes );
444 959
@@ -481,6 +996,5 @@
481 996 public function __wakeup() {
482 997 // Unserializing instances of the class is forbidden.
483 998 _doing_it_wrong( __FUNCTION__, esc_html__( 'Cheatin&#8217; huh?', 'optimole-wp' ), '1.0.0' );
484 999 }
485 -
486 1000 }