PluginProbe
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization / 4.2.8
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization v4.2.8
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 3.12.1 All 134 releases
optimole-wp / inc / tag_replacer.php

tag_replacer.php in Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization 4.2.8, at inc/tag_replacer.php

993 lines 33.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 use OptimoleWP\Preload\Links;
4
5 /**
6 * The class handles the img tag replacements.
7 *
8 * @package \Optml\Inc
9 * @author Optimole <friends@optimole.com>
10 */
11 final class Optml_Tag_Replacer extends Optml_App_Replacer {
12 use Optml_Normalizer;
13 use Optml_Validator;
14 use Optml_Dam_Offload_Utils;
15
16 /**
17 * Cached object instance.
18 *
19 * @var Optml_Tag_Replacer
20 */
21 protected static $instance = null;
22
23 /**
24 * The number of images skipped from lazyload.
25 *
26 * @var integer
27 */
28 public static $lazyload_skipped_images = 0;
29
30 /**
31 * Class instance method.
32 *
33 * @codeCoverageIgnore
34 * @static
35 * @since 1.0.0
36 * @access public
37 * @return Optml_Tag_Replacer
38 */
39 public static function instance() {
40 if ( null === self::$instance ) {
41 self::$instance = new self();
42 add_action( 'optml_replacer_setup', [ self::$instance, 'init' ] );
43 }
44
45 return self::$instance;
46 }
47
48 /**
49 * The initialize method.
50 */
51 public function init() {
52 parent::init();
53 add_filter( 'optml_content_images_tags', [ $this, 'process_image_tags' ], 1, 2 );
54
55 if ( ! $this->settings->use_lazyload() ) {
56 add_filter( 'optml_tag_replace', [ $this, 'regular_tag_replace' ], 1, 6 );
57 }
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 - 1, 5 );
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 }
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;
115 }
116 /**
117 * Filter the attachment image attributes to add the srcset attribute with retina support.
118 *
119 * This is covering the case where the image has no sizes.
120 *
121 * @param array $attr The attributes.
122 * @param WP_Post $attachment The attachment.
123 * @param string $size The size.
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 }
131
132
133 /**
134 * Replace in given content the given image tags with video tags is image is gif
135 *
136 * @param string $image_url Image url to process.
137 * @param string $image_tag The tag to replace.
138 * @param string $content The content to process.
139 *
140 * @return bool
141 */
142 public function img_to_video( $image_url, $image_tag, &$content ) {
143 if ( $this->settings->get( 'img_to_video' ) === 'disabled' ) {
144 return false;
145 }
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 ] ) ) ) {
148 return false;
149 }
150 $link_mp4 = apply_filters(
151 'optml_content_url',
152 $image_url,
153 [
154 'width' => 'auto',
155 'height' => 'auto',
156 'format' => 'mp4',
157 ]
158 );
159
160 $link_png = apply_filters(
161 'optml_content_url',
162 $image_url,
163 [
164 'width' => 'auto',
165 'height' => 'auto',
166 'quality' => 'eco',
167 ]
168 );
169
170 $video_tag = $image_tag;
171
172 $video_tag = str_replace(
173 [
174 'src=',
175 '<img',
176 '/>',
177 ],
178 [
179 'original-src=',
180 '<video autoplay muted loop playsinline poster="' . $link_png . '"',
181 '><source src="' . $link_mp4 . '" type="video/mp4"></video>',
182 ],
183 $video_tag
184 );
185 $content = str_replace( $image_tag, $video_tag, $content );
186 return true;
187 }
188
189 /**
190 * Method invoked by `optml_content_images_tags` filter.
191 *
192 * @param string $content The content to be processed.
193 * @param array $images A list of images.
194 *
195 * @return mixed
196 */
197 public function process_image_tags( $content, $images = [] ) {
198
199 $image_sizes = self::image_sizes();
200 $sizes2crop = self::size_to_crop();
201 if ( OPTML_DEBUG ) {
202 do_action( 'optml_log', 'Images tags to process: ' . print_r( $images, true ) );
203 }
204 foreach ( $images[0] as $index => $tag ) {
205 $width = $height = false;
206 $crop = null;
207 $image_tag = $images['img_tag'][ $index ];
208
209 $is_slashed = strpos( $images['img_url'][ $index ], '\/' ) !== false;
210
211 $src = $tmp = $is_slashed ? $this->strip_slashes( $images['img_url'][ $index ] ) : $images['img_url'][ $index ];
212
213 if ( strpos( $src, $this->upload_resource['content_path'] ) === 0 ) {
214 $src = $tmp = untrailingslashit( $this->upload_resource['content_host'] ) . $src;
215
216 $new_src = $is_slashed ? addcslashes( $src, '/' ) : $src;
217 $image_tag = str_replace(
218 [
219 '"' . $images['img_url'][ $index ],
220 "'" . $images['img_url'][ $index ],
221 ],
222 [
223 '"' . $new_src,
224 "'" . $new_src,
225 ],
226 $image_tag
227 );
228 $images['img_url'][ $index ] = $new_src;
229 }
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 )
232 ) {
233 continue; // @codeCoverageIgnore
234 }
235 $resize = apply_filters( 'optml_default_crop', [] );
236
237 list( $width, $height, $resize ) = self::parse_dimensions_from_tag(
238 $images['img_tag'][ $index ],
239 $image_sizes,
240 [
241 'width' => $width,
242 'height' => $height,
243 'resize' => $resize,
244 ]
245 );
246 if ( false === $width && false === $height ) {
247 list( $width, $height, $crop ) = $this->parse_dimensions_from_filename( $tmp );
248 }
249 if ( empty( $resize ) && isset( $sizes2crop[ $width . $height ] ) ) {
250 $resize = $this->to_optml_crop( $sizes2crop[ $width . $height ] );
251 } elseif ( $crop === true ) {
252 $resize = $this->to_optml_crop( $crop );
253 }
254
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
264 $tmp = $this->strip_image_size_from_url( $tmp );
265
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.
271 }
272
273 $new_url = apply_filters( 'optml_content_url', $tmp, $optml_args );
274
275 $image_tag = str_replace(
276 [
277 'width="' . $width . '"',
278 'width=\"' . $width . '\"',
279 'height="' . $height . '"',
280 'height=\"' . $height . '\"',
281 ],
282 [
283 'width="' . $optml_args['width'] . '"',
284 'width=\"' . $optml_args['width'] . '\"',
285 'height="' . $optml_args['height'] . '"',
286 'height=\"' . $optml_args['height'] . '\"',
287 ],
288 $image_tag
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 ) {
292
293 $image_tag = $this->regular_tag_replace( $image_tag, $images['img_url'][ $index ], $new_url, $optml_args, $is_slashed, $tag );
294 } else {
295 $image_tag = apply_filters( 'optml_tag_replace', $image_tag, $images['img_url'][ $index ], $new_url, $optml_args, $is_slashed, $tag );
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
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 }
319 $content = str_replace( $images['img_tag'][ $index ], $image_tag, $content );
320 }
321 return $content;
322 }
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 /**
341 * Check replacement is allowed for this tag.
342 *
343 * @param string $url Url.
344 * @param string $tag Html tag.
345 *
346 * @return bool We can replace?
347 */
348 public function can_replace_tag( $url, $tag = '' ) {
349 foreach ( self::possible_tag_flags() as $banned_string ) {
350 if ( strpos( $tag, $banned_string ) !== false ) {
351 self::$ignored_url_map[ crc32( $url ) ] = true;
352 return false;
353 }
354 }
355 return true;
356 }
357
358 /**
359 * Extract image dimensions from img tag.
360 *
361 * @param string $tag The HTML img tag.
362 * @param array $image_sizes WordPress supported image sizes.
363 * @param array $args Default args to use.
364 *
365 * @return array
366 */
367 private function parse_dimensions_from_tag( $tag, $image_sizes, $args = [] ) {
368 if ( preg_match( '#width=["|\']?([\d%]+)["|\']?#i', $tag, $width_string ) ) {
369 if ( ctype_digit( $width_string[1] ) === true ) {
370 $args['width'] = $width_string[1];
371 }
372 }
373 if ( preg_match( '#height=["|\']?([\d%]+)["|\']?#i', $tag, $height_string ) ) {
374 if ( ctype_digit( $height_string[1] ) === true ) {
375 $args['height'] = $height_string[1];
376 }
377 }
378 if ( preg_match( '#class=["|\']?[^"\']*size-([^"\'\s]+)[^"\']*["|\']?#i', $tag, $size ) ) {
379 $size = array_pop( $size );
380
381 if ( false === $args['width'] && false === $args['height'] && 'full' !== $size && array_key_exists( $size, $image_sizes ) ) {
382 $args['width'] = (int) $image_sizes[ $size ]['width'];
383 $args['height'] = (int) $image_sizes[ $size ]['height'];
384 }
385 if ( 'full' !== $size && array_key_exists( $size, $image_sizes ) ) {
386 $args['resize'] = $this->to_optml_crop( $image_sizes[ $size ]['crop'] );
387 }
388 } else {
389 $args['resize'] = apply_filters( 'optml_parse_resize_from_tag', [], $tag );
390 }
391
392 return [ $args['width'], $args['height'], $args['resize'] ];
393 }
394
395 /**
396 * Replaces the tags by default.
397 *
398 * @param string $new_tag The new tag.
399 * @param string $original_url The original URL.
400 * @param string $new_url The optimized URL.
401 * @param array $optml_args Options passed for URL optimization.
402 * @param bool $is_slashed Url needs to slashed.
403 * @param string $full_tag Full tag, wrapper included.
404 *
405 * @return string
406 */
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 }
411 $pattern = '/(?<!\/)' . preg_quote( $original_url, '/' ) . '/i';
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;
458 return preg_replace( $pattern, $replace, $new_tag );
459 }
460
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 $srcset_attr = $is_slashed ? 'srcset=\"' . addcslashes( $srcset_value, '"' ) . '\"' : 'srcset="' . $srcset_value . '"';
531
532 // Insert srcset attribute after the src attribute
533 $tag = preg_replace( '/(src=["\'][^"\']*["\'])/i', '$1 ' . $srcset_attr, $tag );
534 }
535
536 // Handle sizes attribute - skip if existing sizes contains calc() or complex formulas
537 if ( ! empty( $new_sizes_entries ) ) {
538 $should_skip_sizes = false;
539
540 if ( $has_existing_sizes ) {
541 // Check if existing sizes contains calc() or complex formulas
542 if ( preg_match( '/sizes=["\']([^"\']*)["\']/i', $tag, $matches ) ) {
543 $existing_sizes = $matches[1];
544 if ( $this->should_skip_sizes( $existing_sizes ) ) {
545 $should_skip_sizes = true;
546 if ( OPTML_DEBUG ) {
547 do_action( 'optml_log', 'Skipping sizes enhancement due to complex formula: ' . $existing_sizes );
548 }
549 }
550 }
551 }
552
553 if ( ! $should_skip_sizes ) {
554 // Filter sizes entries to ensure they're appropriate for the container
555 $filtered_sizes_entries = $this->filter_sizes_entries_for_container( $new_sizes_entries, $tag );
556
557 if ( ! empty( $filtered_sizes_entries ) ) {
558 if ( $has_existing_sizes ) {
559 // Enhance existing sizes
560 $tag = $this->enhance_existing_sizes( $tag, $filtered_sizes_entries, $is_slashed );
561 } else {
562 // Add new sizes attribute
563 $sizes_entries = array_unique( $filtered_sizes_entries );
564 // Sort ascending by breakpoint value (numeric)
565 usort(
566 $sizes_entries,
567 function ( $a, $b ) {
568 preg_match( '/max-width:\s*(\d+)/', $a, $ma );
569 preg_match( '/max-width:\s*(\d+)/', $b, $mb );
570 return ( $ma[1] ?? 0 ) - ( $mb[1] ?? 0 );
571 }
572 );
573
574 $sizes_value = implode( ', ', $sizes_entries );
575 $sizes_attr = $is_slashed ? 'sizes=\"' . addcslashes( $sizes_value, '"' ) . '\"' : 'sizes="' . $sizes_value . '"';
576
577 // Insert sizes attribute after srcset
578 $tag = preg_replace( '/(srcset=["\'][^"\']*["\'])/i', '$1 ' . $sizes_attr, $tag );
579 }
580 }
581 } else {
582 // Log that sizes were skipped due to complex formulas
583 if ( OPTML_DEBUG ) {
584 do_action( 'optml_log', 'Skipped adding sizes due to complex formula detection' );
585 }
586 }
587 }
588
589 if ( OPTML_DEBUG ) {
590 $action = $has_existing_srcset ? 'Enhanced' : 'Added';
591 do_action( 'optml_log', $action . ' srcset for image: ' . $new_url . ' with ' . count( $new_srcset_entries ) . ' new entries' );
592 }
593
594 return $tag;
595 }
596
597 /**
598 * Enhance existing srcset attribute by adding new entries.
599 *
600 * @param string $tag The image tag.
601 * @param array<int, string> $new_srcset_entries Array of new srcset entries to add.
602 * @param bool $is_slashed Whether the URL needs to be slashed.
603 *
604 * @return string The modified image tag with enhanced srcset.
605 */
606 public function enhance_existing_srcset( $tag, $new_srcset_entries, $is_slashed = false ) {
607 // Extract existing srcset value
608 if ( preg_match( '/srcset=["\']([^"\']*)["\']/i', $tag, $matches ) ) {
609 $existing_srcset = $matches[1];
610 $existing_entries = array_map( 'trim', explode( ',', $existing_srcset ) );
611
612 // Merge with new entries, avoiding duplicates
613 $all_entries = array_merge( $existing_entries, $new_srcset_entries );
614 $all_entries = array_unique( $all_entries );
615
616 // Sort by descriptor (width) for better organization
617 usort(
618 $all_entries,
619 function ( $a, $b ) {
620 preg_match( '/(\d+)w/', $a, $matches_a );
621 preg_match( '/(\d+)w/', $b, $matches_b );
622 $width_a = isset( $matches_a[1] ) ? (int) $matches_a[1] : 0;
623 $width_b = isset( $matches_b[1] ) ? (int) $matches_b[1] : 0;
624 return $width_a - $width_b;
625 }
626 );
627
628 $enhanced_srcset = implode( ', ', $all_entries );
629 $srcset_attr = $is_slashed ? 'srcset=\"' . addcslashes( $enhanced_srcset, '"' ) . '\"' : 'srcset="' . $enhanced_srcset . '"';
630
631 // Replace existing srcset
632 $tag = preg_replace( '/srcset=["\'][^"\']*["\']/i', $srcset_attr, $tag );
633 }
634
635 return $tag;
636 }
637
638 /**
639 * Enhance existing sizes attribute by adding new breakpoint entries.
640 *
641 * @param string $tag The image tag.
642 * @param array<int, string> $new_sizes_entries Array of new sizes entries to add.
643 * @param bool $is_slashed Whether the URL needs to be slashed.
644 *
645 * @return string The modified image tag with enhanced sizes.
646 */
647 public function enhance_existing_sizes( $tag, $new_sizes_entries, $is_slashed = false ) {
648 // Extract existing sizes value
649 if ( preg_match( '/sizes=["\']([^"\']*)["\']/i', $tag, $matches ) ) {
650 $existing_sizes = $matches[1];
651 $existing_entries = array_map( 'trim', explode( ',', $existing_sizes ) );
652
653 // Merge with new entries, avoiding duplicates
654 $all_entries = array_merge( $existing_entries, $new_sizes_entries );
655 $all_entries = array_unique( $all_entries );
656
657 // Sort by breakpoint (descending)
658 usort(
659 $all_entries,
660 function ( $a, $b ) {
661 preg_match( '/max-width:\s*(\d+)px/', $a, $matches_a );
662 preg_match( '/max-width:\s*(\d+)px/', $b, $matches_b );
663 $breakpoint_a = isset( $matches_a[1] ) ? (int) $matches_a[1] : 0;
664 $breakpoint_b = isset( $matches_b[1] ) ? (int) $matches_b[1] : 0;
665 return $breakpoint_b - $breakpoint_a; // Descending order
666 }
667 );
668
669 $enhanced_sizes = implode( ', ', $all_entries );
670 $sizes_attr = $is_slashed ? 'sizes=\"' . addcslashes( $enhanced_sizes, '"' ) . '\"' : 'sizes="' . $enhanced_sizes . '"';
671
672 // Replace existing sizes
673 $tag = preg_replace( '/sizes=["\'][^"\']*["\']/i', $sizes_attr, $tag );
674 }
675
676 return $tag;
677 }
678
679 /**
680 * Check if sizes attribute contains complex formulas that should not be modified.
681 *
682 * @param string $sizes_value The sizes attribute value.
683 *
684 * @return bool True if contains complex formulas, false otherwise.
685 */
686 public function should_skip_sizes( $sizes_value ) {
687 // Check for calc() functions
688 if ( strpos( $sizes_value, 'calc(' ) !== false ) {
689 return true;
690 }
691
692 // Check for complex mathematical operations
693 if ( preg_match( '/\d+%\s*[+\-*\/]|[+\-*\/]\s*\d+%|\d+px\s*[+\-*\/]|[+\-*\/]\s*\d+px/', $sizes_value ) ) {
694 return true;
695 }
696
697 // Check for viewport units with calculations
698 if ( preg_match( '/\d+vw\s*[+\-*\/]|\d+vh\s*[+\-*\/]|\d+vmin\s*[+\-*\/]|\d+vmax\s*[+\-*\/]/', $sizes_value ) ) {
699 return true;
700 }
701
702 // Check for complex CSS functions
703 if ( preg_match( '/(min|max|clamp)\(/', $sizes_value ) ) {
704 return true;
705 }
706
707 // Check for multiple percentage values with operations
708 if ( preg_match( '/\d+%\s*[+\-*\/]\s*\d+%/', $sizes_value ) ) {
709 return true;
710 }
711
712 return false;
713 }
714
715 /**
716 * Filter sizes entries to ensure they're appropriate for the container size.
717 * This prevents oversized images from being selected for small containers.
718 *
719 * @param array<int, string> $sizes_entries Array of sizes entries from JavaScript module.
720 * @param string $tag The image tag to extract container dimensions from.
721 *
722 * @return array<int, string> Filtered sizes entries appropriate for the container.
723 */
724 public function filter_sizes_entries_for_container( $sizes_entries, $tag ) {
725 if ( empty( $sizes_entries ) ) {
726 return [];
727 }
728
729 // Extract container dimensions from the image tag
730 $container_width = null;
731 $container_height = null;
732
733 // Try to get width and height from the tag
734 if ( preg_match( '/width=["\']?(\d+)["\']?/i', $tag, $width_match ) ) {
735 $container_width = (int) $width_match[1];
736 }
737 if ( preg_match( '/height=["\']?(\d+)["\']?/i', $tag, $height_match ) ) {
738 $container_height = (int) $height_match[1];
739 }
740
741 // If we can't determine container size, return all entries (fallback)
742 if ( ! $container_width || ! $container_height ) {
743 if ( OPTML_DEBUG ) {
744 do_action( 'optml_log', 'Cannot determine container size, using all sizes entries' );
745 }
746 return $sizes_entries;
747 }
748
749 // Calculate maximum reasonable size for this container
750 $max_reasonable_width = $container_width * 1.5; // Allow up to 1.5x the container size
751
752 $filtered_entries = [];
753
754 foreach ( $sizes_entries as $entry ) {
755 // Extract image size from the entry (e.g., "(max-width: 768px) 400px" -> 400)
756 if ( preg_match( '/\(max-width:\s*\d+px\)\s*(\d+)px/', $entry, $matches ) ) {
757 $image_width = (int) $matches[1];
758
759 // Only include entries where the image size is reasonable for the container
760 if ( $image_width <= $max_reasonable_width ) {
761 $filtered_entries[] = $entry;
762 } else {
763 if ( OPTML_DEBUG ) {
764 do_action( 'optml_log', "Filtered out oversized entry: {$entry} (container: {$container_width}px, image: {$image_width}px)" );
765 }
766 }
767 } else {
768 // If we can't parse the entry, include it (better safe than sorry)
769 $filtered_entries[] = $entry;
770 }
771 }
772
773 if ( OPTML_DEBUG ) {
774 do_action( 'optml_log', "Filtered sizes entries for container {$container_width}x{$container_height}: " . count( $filtered_entries ) . ' of ' . count( $sizes_entries ) . ' entries' );
775 }
776
777 return $filtered_entries;
778 }
779
780 /**
781 * Generate optimized URL for a specific size and DPR.
782 *
783 * @param string $original_url The original image URL.
784 * @param int $width The target width.
785 * @param int $height The target height.
786 * @param int $dpr The device pixel ratio.
787 *
788 * @return string The optimized URL or false on failure.
789 */
790 public function change_url_for_size( $original_url, $width, $height, $dpr = 1 ) {
791 // Assume w and h are always present - just replace them
792 // Updated regex to match w:auto, w:123, h:auto, h:456, etc.
793 $replacements = [
794 '/w:(?:auto|\d+)/' => 'w:' . $width,
795 '/h:(?:auto|\d+)/' => 'h:' . $height,
796 ];
797
798 // Handle DPR - replace if exists, add after w: if missing
799 if ( $dpr > 1 ) {
800 if ( strpos( $original_url, 'dpr:' ) !== false ) {
801 $replacements['/dpr:\d+/'] = 'dpr:' . $dpr;
802 } else {
803 // Add DPR after width parameter - use the same pattern as width replacement
804 $replacements['/(w:(?:auto|\d+))/'] = '$1/dpr:' . $dpr;
805 }
806 } else {
807 $replacements['/dpr:\d+\//'] = '';
808 }
809
810 return preg_replace( array_keys( $replacements ), array_values( $replacements ), $original_url );
811 }
812
813 /**
814 * Replace image URLs in the srcset attributes and in case there is a resize in action, also replace the sizes.
815 *
816 * @param array<int, array{url: string, descriptor: string, value: int}>|mixed $sources Array of image sources.
817 * @param array{0: int, 1: int}|int[] $size_array Array of width and height values in pixels (in that order).
818 * @param string $image_src The 'src' of the image.
819 * @param array<string, mixed> $image_meta The image meta data as returned by 'wp_get_attachment_metadata()'.
820 * @param int $attachment_id Image attachment ID or 0.
821 *
822 * @return array|mixed
823 */
824 public function filter_srcset_attr( $sources = [], $size_array = [], $image_src = '', $image_meta = [], $attachment_id = 0 ) {
825 if ( ! is_array( $sources ) ) {
826 return $sources;
827 }
828 if ( Optml_Media_Offload::is_uploaded_image( $image_src ) ) {
829 return $sources;
830 }
831 $original_url = null;
832 $cropping = null;
833 if ( count( $size_array ) === 2 ) {
834 $sizes = self::size_to_crop();
835 $cropping = isset( $sizes[ $size_array[0] . $size_array[1] ] ) ? $this->to_optml_crop( $sizes[ $size_array[0] . $size_array[1] ] ) : null;
836 }
837 if ( OPTML_DEBUG ) {
838 do_action( 'optml_log', 'sources: ' . print_r( $sources, true ) . ' size_array: ' . print_r( $size_array, true ) );
839 }
840 $biggest_size = [];
841 $max_width = 0;
842 foreach ( $sources as $i => $source ) {
843 $url = $source['url'];
844 if ( Optml_Media_Offload::is_uploaded_image( $url ) ) {
845 continue;
846 }
847 list( $width, $height, $file_crop ) = $this->parse_dimensions_from_filename( $url );
848
849 if ( empty( $width ) ) {
850 $width = $image_meta['width'];
851 }
852
853 if ( empty( $height ) ) {
854 $height = $image_meta['height'];
855 }
856
857 if ( $original_url === null ) {
858 if ( ! empty( $attachment_id ) ) {
859 $original_url = wp_get_attachment_url( $attachment_id );
860 } else {
861 $original_url = $this->strip_image_size_from_url( $source['url'] );
862 }
863 }
864 $args = [];
865 if ( 'w' === $source['descriptor'] ) {
866 if ( $height && ( $source['value'] === $width ) ) {
867 $args['width'] = $width;
868 $args['height'] = $height;
869 } else {
870 $args['width'] = $source['value'];
871 }
872 }
873 if ( $cropping !== null ) {
874 $args['resize'] = $cropping;
875 } else {
876 $args['resize'] = $this->to_optml_crop( $file_crop );
877 }
878 if ( $i > $max_width ) {
879 $max_width = $i;
880 $biggest_size = [ $args, $original_url ];
881 }
882 $sources[ $i ]['url'] = apply_filters( 'optml_content_url', $original_url, $args );
883
884 }
885
886 if ( $this->settings->get( 'retina_images' ) === 'enabled' && ! empty( $biggest_size ) ) {
887 $sources[ $max_width * 2 ]['url'] = apply_filters( 'optml_content_url', $biggest_size[1], array_merge( $biggest_size[0], [ 'dpr' => 2 ] ) );
888 $sources[ $max_width * 2 ]['value'] = 2;
889 $sources[ $max_width * 2 ]['descriptor'] = 'x';
890 }
891 return $sources;
892 }
893
894 /**
895 * Filters sizes attribute of the images.
896 *
897 * @param string $sizes An array of media query breakpoints.
898 * @param string|int[] $size Width and height of the image.
899 *
900 * @return string An array of media query breakpoints.
901 */
902 public function filter_sizes_attr( $sizes, $size ) {
903
904 if ( ! doing_filter( 'the_content' ) ) {
905 return $sizes;
906 }
907
908 $content_width = false;
909 if ( isset( $GLOBALS['content_width'] ) ) {
910 $content_width = $GLOBALS['content_width'];
911 }
912
913 if ( ! $content_width ) {
914 $content_width = 1000;
915 }
916 if ( is_array( $size ) && $size[0] < $content_width ) {
917 return $sizes;
918 }
919
920 return sprintf( '(max-width: %1$dpx) 100vw, %1$dpx', $content_width );
921 }
922
923 /**
924 * This filter will replace all the images retrieved via "wp_get_image" type of functions.
925 *
926 * @param array $image The filtered value.
927 * @param int $attachment_id The related attachment id.
928 * @param array|string $size This could be the name of the thumbnail size or an array of custom dimensions.
929 *
930 * @return array
931 */
932 public function filter_image_downsize( $image, $attachment_id, $size ) {
933
934 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
935 return $image;
936 }
937 $image_url = wp_get_attachment_url( $attachment_id );
938 if ( Optml_Media_Offload::is_uploaded_image( $image_url ) ) {
939 return $image;
940 }
941 if ( $image_url === false ) {
942 return $image;
943 }
944
945 $image_meta = wp_get_attachment_metadata( $attachment_id );
946 $sizes = $this->size_to_dimension( $size, $image_meta, $attachment_id );
947
948 $image_url = $this->strip_image_size_from_url( $image_url );
949
950 $new_url = apply_filters( 'optml_content_url', $image_url, $sizes );
951
952 if ( $new_url === $image_url ) {
953 return $image;
954 }
955
956 return [
957 $new_url,
958 $sizes['width'],
959 $sizes['height'],
960 $size === 'full',
961 ];
962 }
963
964 /**
965 * Throw error on object clone
966 *
967 * The whole idea of the singleton design pattern is that there is a single
968 * object therefore, we don't want the object to be cloned.
969 *
970 * @codeCoverageIgnore
971 * @access public
972 * @since 1.0.0
973 * @return void
974 */
975 public function __clone() {
976 // Cloning instances of the class is forbidden.
977 _doing_it_wrong( __FUNCTION__, esc_html__( 'Cheatin&#8217; huh?', 'optimole-wp' ), '1.0.0' );
978 }
979
980 /**
981 * Disable unserializing of the class
982 *
983 * @codeCoverageIgnore
984 * @access public
985 * @since 1.0.0
986 * @return void
987 */
988 public function __wakeup() {
989 // Unserializing instances of the class is forbidden.
990 _doing_it_wrong( __FUNCTION__, esc_html__( 'Cheatin&#8217; huh?', 'optimole-wp' ), '1.0.0' );
991 }
992 }
993