PluginProbe
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization / 4.2.12
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization v4.2.12
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.12, at inc/tag_replacer.php

1,001 lines 34.2 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 // 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 /**
822 * Replace image URLs in the srcset attributes and in case there is a resize in action, also replace the sizes.
823 *
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.
829 *
830 * @return array|mixed
831 */
832 public function filter_srcset_attr( $sources = [], $size_array = [], $image_src = '', $image_meta = [], $attachment_id = 0 ) {
833 if ( ! is_array( $sources ) ) {
834 return $sources;
835 }
836 if ( Optml_Media_Offload::is_uploaded_image( $image_src ) ) {
837 return $sources;
838 }
839 $original_url = null;
840 $cropping = null;
841 if ( count( $size_array ) === 2 ) {
842 $sizes = self::size_to_crop();
843 $cropping = isset( $sizes[ $size_array[0] . $size_array[1] ] ) ? $this->to_optml_crop( $sizes[ $size_array[0] . $size_array[1] ] ) : null;
844 }
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;
850 foreach ( $sources as $i => $source ) {
851 $url = $source['url'];
852 if ( Optml_Media_Offload::is_uploaded_image( $url ) ) {
853 continue;
854 }
855 list( $width, $height, $file_crop ) = $this->parse_dimensions_from_filename( $url );
856
857 if ( empty( $width ) ) {
858 $width = $image_meta['width'];
859 }
860
861 if ( empty( $height ) ) {
862 $height = $image_meta['height'];
863 }
864
865 if ( $original_url === null ) {
866 if ( ! empty( $attachment_id ) ) {
867 $original_url = wp_get_attachment_url( $attachment_id );
868 } else {
869 $original_url = $this->strip_image_size_from_url( $source['url'] );
870 }
871 }
872 $args = [];
873 if ( 'w' === $source['descriptor'] ) {
874 if ( $height && ( $source['value'] === $width ) ) {
875 $args['width'] = $width;
876 $args['height'] = $height;
877 } else {
878 $args['width'] = $source['value'];
879 }
880 }
881 if ( $cropping !== null ) {
882 $args['resize'] = $cropping;
883 } else {
884 $args['resize'] = $this->to_optml_crop( $file_crop );
885 }
886 if ( $i > $max_width ) {
887 $max_width = $i;
888 $biggest_size = [ $args, $original_url ];
889 }
890 $sources[ $i ]['url'] = apply_filters( 'optml_content_url', $original_url, $args );
891
892 }
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 }
899 return $sources;
900 }
901
902 /**
903 * Filters sizes attribute of the images.
904 *
905 * @param string $sizes An array of media query breakpoints.
906 * @param string|int[] $size Width and height of the image.
907 *
908 * @return string An array of media query breakpoints.
909 */
910 public function filter_sizes_attr( $sizes, $size ) {
911
912 if ( ! doing_filter( 'the_content' ) ) {
913 return $sizes;
914 }
915
916 $content_width = false;
917 if ( isset( $GLOBALS['content_width'] ) ) {
918 $content_width = $GLOBALS['content_width'];
919 }
920
921 if ( ! $content_width ) {
922 $content_width = 1000;
923 }
924 if ( is_array( $size ) && $size[0] < $content_width ) {
925 return $sizes;
926 }
927
928 return sprintf( '(max-width: %1$dpx) 100vw, %1$dpx', $content_width );
929 }
930
931 /**
932 * This filter will replace all the images retrieved via "wp_get_image" type of functions.
933 *
934 * @param array $image The filtered value.
935 * @param int $attachment_id The related attachment id.
936 * @param array|string $size This could be the name of the thumbnail size or an array of custom dimensions.
937 *
938 * @return array
939 */
940 public function filter_image_downsize( $image, $attachment_id, $size ) {
941
942 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
943 return $image;
944 }
945 $image_url = wp_get_attachment_url( $attachment_id );
946 if ( Optml_Media_Offload::is_uploaded_image( $image_url ) ) {
947 return $image;
948 }
949 if ( $image_url === false ) {
950 return $image;
951 }
952
953 $image_meta = wp_get_attachment_metadata( $attachment_id );
954 $sizes = $this->size_to_dimension( $size, $image_meta, $attachment_id );
955
956 $image_url = $this->strip_image_size_from_url( $image_url );
957
958 $new_url = apply_filters( 'optml_content_url', $image_url, $sizes );
959
960 if ( $new_url === $image_url ) {
961 return $image;
962 }
963
964 return [
965 $new_url,
966 $sizes['width'],
967 $sizes['height'],
968 $size === 'full',
969 ];
970 }
971
972 /**
973 * Throw error on object clone
974 *
975 * The whole idea of the singleton design pattern is that there is a single
976 * object therefore, we don't want the object to be cloned.
977 *
978 * @codeCoverageIgnore
979 * @access public
980 * @since 1.0.0
981 * @return void
982 */
983 public function __clone() {
984 // Cloning instances of the class is forbidden.
985 _doing_it_wrong( __FUNCTION__, esc_html__( 'Cheatin&#8217; huh?', 'optimole-wp' ), '1.0.0' );
986 }
987
988 /**
989 * Disable unserializing of the class
990 *
991 * @codeCoverageIgnore
992 * @access public
993 * @since 1.0.0
994 * @return void
995 */
996 public function __wakeup() {
997 // Unserializing instances of the class is forbidden.
998 _doing_it_wrong( __FUNCTION__, esc_html__( 'Cheatin&#8217; huh?', 'optimole-wp' ), '1.0.0' );
999 }
1000 }
1001