PluginProbe ʕ •ᴥ•ʔ
LiteSpeed Cache / 7.6.1
LiteSpeed Cache v7.6.1
trunk 1.0.15 1.9.1.1 2.9.9.2 3.6.4 4.6 5.7.0.1 6.5.4 7.0.0.1 7.0.1 7.1 7.2 7.3 7.3.0.1 7.4 7.5 7.5.0.1 7.6 7.6.1 7.6.2 7.7 7.8 7.8.0.1 7.8.1
litespeed-cache / src / media.cls.php
litespeed-cache / src Last commit date
cdn 7 months ago data_structure 7 months ago activation.cls.php 7 months ago admin-display.cls.php 7 months ago admin-settings.cls.php 7 months ago admin.cls.php 7 months ago api.cls.php 7 months ago avatar.cls.php 7 months ago base.cls.php 7 months ago cdn.cls.php 7 months ago cloud.cls.php 7 months ago conf.cls.php 7 months ago control.cls.php 7 months ago core.cls.php 7 months ago crawler-map.cls.php 7 months ago crawler.cls.php 7 months ago css.cls.php 7 months ago data.cls.php 7 months ago data.upgrade.func.php 7 months ago db-optm.cls.php 7 months ago debug2.cls.php 7 months ago doc.cls.php 7 months ago error.cls.php 7 months ago esi.cls.php 7 months ago file.cls.php 7 months ago gui.cls.php 7 months ago health.cls.php 7 months ago htaccess.cls.php 7 months ago img-optm.cls.php 7 months ago import.cls.php 7 months ago import.preset.cls.php 7 months ago lang.cls.php 7 months ago localization.cls.php 7 months ago media.cls.php 7 months ago metabox.cls.php 7 months ago object-cache-wp.cls.php 7 months ago object-cache.cls.php 7 months ago object.lib.php 7 months ago optimize.cls.php 7 months ago optimizer.cls.php 7 months ago placeholder.cls.php 7 months ago purge.cls.php 7 months ago report.cls.php 7 months ago rest.cls.php 7 months ago root.cls.php 7 months ago router.cls.php 7 months ago str.cls.php 7 months ago tag.cls.php 7 months ago task.cls.php 7 months ago tool.cls.php 7 months ago ucss.cls.php 7 months ago utility.cls.php 7 months ago vary.cls.php 7 months ago vpi.cls.php 7 months ago
media.cls.php
1398 lines
1 <?php
2 /**
3 * The class to operate media data.
4 *
5 * @package LiteSpeed
6 * @since 1.4
7 */
8
9 namespace LiteSpeed;
10
11 defined( 'WPINC' ) || exit();
12
13 /**
14 * Class Media
15 *
16 * Handles media-related optimizations like lazy loading, next-gen image replacement, and admin UI.
17 */
18 class Media extends Root {
19
20 const LOG_TAG = '📺';
21
22 const LIB_FILE_IMG_LAZYLOAD = 'assets/js/lazyload.min.js';
23
24 /**
25 * Current page buffer content.
26 *
27 * @var string
28 */
29 private $content;
30
31 /**
32 * WordPress uploads directory info.
33 *
34 * @var array
35 */
36 private $_wp_upload_dir;
37
38 /**
39 * List of VPI (viewport images) to preload in <head>.
40 *
41 * @var array
42 */
43 private $_vpi_preload_list = [];
44
45 /**
46 * The user-level next-gen format supported (''|webp|avif).
47 *
48 * @var string
49 */
50 private $_format = '';
51
52 /**
53 * The system-level chosen next-gen format (webp|avif).
54 *
55 * @var string
56 */
57 private $_sys_format = '';
58
59 /**
60 * Init.
61 *
62 * @since 1.4
63 */
64 public function __construct() {
65 self::debug2( 'init' );
66
67 $this->_wp_upload_dir = wp_upload_dir();
68 if ( $this->conf( Base::O_IMG_OPTM_WEBP ) ) {
69 $this->_sys_format = 'webp';
70 $this->_format = 'webp';
71 if ( 2 === $this->conf( Base::O_IMG_OPTM_WEBP ) ) {
72 $this->_sys_format = 'avif';
73 $this->_format = 'avif';
74 }
75 if ( ! $this->_browser_support_next_gen() ) {
76 $this->_format = '';
77 }
78 $this->_format = apply_filters( 'litespeed_next_gen_format', $this->_format );
79 }
80 }
81
82 /**
83 * Hooks after user init.
84 *
85 * @since 7.2
86 * @since 7.4 Add media replace original with scaled.
87 * @return void
88 */
89 public function after_user_init() {
90 // Hook to attachment delete action (PR#844, Issue#841) for AJAX del compatibility.
91 add_action( 'delete_attachment', array( $this, 'delete_attachment' ), 11, 2 );
92
93 // For big images, allow to replace original with scaled image.
94 if ( $this->conf( Base::O_MEDIA_AUTO_RESCALE_ORI ) ) {
95 // Added priority 9 to happen before other functions added.
96 add_filter( 'wp_update_attachment_metadata', array( $this, 'rescale_ori' ), 9, 2 );
97 }
98 }
99
100 /**
101 * Init optm features.
102 *
103 * @since 3.0
104 * @access public
105 * @return void
106 */
107 public function init() {
108 if ( is_admin() ) {
109 return;
110 }
111
112 // Due to ajax call doesn't send correct accept header, have to limit webp to HTML only.
113 if ( $this->webp_support() ) {
114 // Hook to srcset.
115 if ( function_exists( 'wp_calculate_image_srcset' ) ) {
116 add_filter( 'wp_calculate_image_srcset', array( $this, 'webp_srcset' ), 988 );
117 }
118 // Hook to mime icon
119 // add_filter( 'wp_get_attachment_image_src', array( $this, 'webp_attach_img_src' ), 988 );// todo: need to check why not
120 // add_filter( 'wp_get_attachment_url', array( $this, 'webp_url' ), 988 ); // disabled to avoid wp-admin display
121 }
122
123 if ( $this->conf( Base::O_MEDIA_LAZY ) && ! $this->cls( 'Metabox' )->setting( 'litespeed_no_image_lazy' ) ) {
124 self::debug( 'Suppress default WP lazyload' );
125 add_filter( 'wp_lazy_loading_enabled', '__return_false' );
126 }
127
128 /**
129 * Replace gravatar.
130 *
131 * @since 3.0
132 */
133 $this->cls( 'Avatar' );
134
135 add_filter( 'litespeed_buffer_finalize', array( $this, 'finalize' ), 4 );
136 add_filter( 'litespeed_optm_html_head', array( $this, 'finalize_head' ) );
137 }
138
139 /**
140 * Handle attachment create (rescale original).
141 *
142 * @param array $metadata Current meta array.
143 * @param int $attachment_id Attachment ID.
144 * @return array Modified metadata.
145 * @since 7.4
146 */
147 public function rescale_ori( $metadata, $attachment_id ) {
148 // Test if create and image was resized.
149 if ( $metadata && isset( $metadata['original_image'], $metadata['file'] ) && false !== strpos( $metadata['file'], '-scaled' ) ) {
150 // Get rescaled file name.
151 $path_exploded = explode( '/', strrev( $metadata['file'] ), 2 );
152 $rescaled_file_name = strrev( $path_exploded[0] );
153
154 // Create paths for images: resized and original.
155 $base_path = $this->_wp_upload_dir['basedir'] . $this->_wp_upload_dir['subdir'] . '/';
156 $rescaled_path = $base_path . $rescaled_file_name;
157 $new_path = $base_path . $metadata['original_image'];
158
159 // Change array file key.
160 $metadata['file'] = $this->_wp_upload_dir['subdir'] . '/' . $metadata['original_image'];
161 if ( 0 === strpos( $metadata['file'], '/' ) ) {
162 $metadata['file'] = substr( $metadata['file'], 1 );
163 }
164
165 // Delete array "original_image" key.
166 unset( $metadata['original_image'] );
167
168 if ( file_exists( $rescaled_path ) && file_exists( $new_path ) ) {
169 // Move rescaled to original using WP_Filesystem.
170 global $wp_filesystem;
171 if ( ! $wp_filesystem ) {
172 require_once ABSPATH . '/wp-admin/includes/file.php';
173 \WP_Filesystem();
174 }
175 if ( $wp_filesystem ) {
176 $wp_filesystem->move( $rescaled_path, $new_path, true );
177 }
178
179 // Update meta "_wp_attached_file".
180 update_post_meta( $attachment_id, '_wp_attached_file', $metadata['file'] );
181 }
182 }
183
184 return $metadata;
185 }
186
187 /**
188 * Add featured image and VPI preloads to head.
189 *
190 * @param string $content Current head HTML.
191 * @return string Modified head HTML.
192 */
193 public function finalize_head( $content ) {
194 // <link rel="preload" as="image" href="xx">
195 if ( $this->_vpi_preload_list ) {
196 foreach ( $this->_vpi_preload_list as $v ) {
197 $content .= '<link rel="preload" as="image" href="' . esc_url( Str::trim_quotes( $v ) ) . '">';
198 }
199 }
200 return $content;
201 }
202
203 /**
204 * Adjust WP default JPG quality.
205 *
206 * @since 3.0
207 * @access public
208 *
209 * @param int $quality Current quality.
210 * @return int Adjusted quality.
211 */
212 public function adjust_jpg_quality( $quality ) {
213 $v = $this->conf( Base::O_IMG_OPTM_JPG_QUALITY );
214
215 if ( $v ) {
216 return $v;
217 }
218
219 return $quality;
220 }
221
222 /**
223 * Register admin menu.
224 *
225 * @since 1.6.3
226 * @access public
227 * @return void
228 */
229 public function after_admin_init() {
230 /**
231 * JPG quality control.
232 *
233 * @since 3.0
234 */
235 add_filter( 'jpeg_quality', array( $this, 'adjust_jpg_quality' ) );
236
237 add_filter( 'manage_media_columns', array( $this, 'media_row_title' ) );
238 add_filter( 'manage_media_custom_column', array( $this, 'media_row_actions' ), 10, 2 );
239
240 add_action( 'litespeed_media_row', array( $this, 'media_row_con' ) );
241 }
242
243 /**
244 * Media delete action hook.
245 *
246 * @since 2.4.3
247 * @access public
248 *
249 * @param int $post_id Post ID.
250 * @return void
251 */
252 public static function delete_attachment( $post_id ) {
253 self::debug( 'delete_attachment [pid] ' . $post_id );
254 Img_Optm::cls()->reset_row( $post_id );
255 }
256
257 /**
258 * Return media file info if exists.
259 *
260 * This is for remote attachment plugins.
261 *
262 * @since 2.9.8
263 * @access public
264 *
265 * @param string $short_file_path Relative file path under uploads.
266 * @param int $post_id Post ID.
267 * @return array|false Array( url, md5, size ) or false.
268 */
269 public function info( $short_file_path, $post_id ) {
270 $short_file_path = wp_normalize_path( $short_file_path );
271 $basedir = $this->_wp_upload_dir['basedir'] . '/';
272 if ( 0 === strpos( $short_file_path, $basedir ) ) {
273 $short_file_path = substr( $short_file_path, strlen( $basedir ) );
274 }
275
276 $real_file = $basedir . $short_file_path;
277
278 if ( file_exists( $real_file ) ) {
279 return array(
280 'url' => $this->_wp_upload_dir['baseurl'] . '/' . $short_file_path,
281 'md5' => md5_file( $real_file ),
282 'size' => filesize( $real_file ),
283 );
284 }
285
286 /**
287 * WP Stateless compatibility #143 https://github.com/litespeedtech/lscache_wp/issues/143
288 *
289 * @since 2.9.8
290 * Should return array( 'url', 'md5', 'size' ).
291 */
292 $info = apply_filters( 'litespeed_media_info', [], $short_file_path, $post_id );
293 if ( ! empty( $info['url'] ) && ! empty( $info['md5'] ) && ! empty( $info['size'] ) ) {
294 return $info;
295 }
296
297 return false;
298 }
299
300 /**
301 * Delete media file.
302 *
303 * @since 2.9.8
304 * @access public
305 *
306 * @param string $short_file_path Relative file path under uploads.
307 * @param int $post_id Post ID.
308 * @return void
309 */
310 public function del( $short_file_path, $post_id ) {
311 $real_file = $this->_wp_upload_dir['basedir'] . '/' . $short_file_path;
312
313 if ( file_exists( $real_file ) ) {
314 wp_delete_file( $real_file );
315 self::debug( 'deleted ' . $real_file );
316 }
317
318 do_action( 'litespeed_media_del', $short_file_path, $post_id );
319 }
320
321 /**
322 * Rename media file.
323 *
324 * @since 2.9.8
325 * @access public
326 *
327 * @param string $short_file_path Old relative path.
328 * @param string $short_file_path_new New relative path.
329 * @param int $post_id Post ID.
330 * @return void
331 */
332 public function rename( $short_file_path, $short_file_path_new, $post_id ) {
333 $real_file = $this->_wp_upload_dir['basedir'] . '/' . $short_file_path;
334 $real_file_new = $this->_wp_upload_dir['basedir'] . '/' . $short_file_path_new;
335
336 if ( file_exists( $real_file ) ) {
337 global $wp_filesystem;
338 if ( ! $wp_filesystem ) {
339 require_once ABSPATH . '/wp-admin/includes/file.php';
340 \WP_Filesystem();
341 }
342 if ( $wp_filesystem ) {
343 $wp_filesystem->move( $real_file, $real_file_new, true );
344 }
345 self::debug( 'renamed ' . $real_file . ' to ' . $real_file_new );
346 }
347
348 do_action( 'litespeed_media_rename', $short_file_path, $short_file_path_new, $post_id );
349 }
350
351 /**
352 * Media Admin Menu -> Image Optimization Column Title.
353 *
354 * @since 1.6.3
355 * @access public
356 *
357 * @param array $posts_columns Existing columns.
358 * @return array Modified columns.
359 */
360 public function media_row_title( $posts_columns ) {
361 $posts_columns['imgoptm'] = esc_html__( 'LiteSpeed Optimization', 'litespeed-cache' );
362 return $posts_columns;
363 }
364
365 /**
366 * Media Admin Menu -> Image Optimization Column.
367 *
368 * @since 1.6.2
369 * @access public
370 *
371 * @param string $column_name Current column name.
372 * @param int $post_id Post ID.
373 * @return void
374 */
375 public function media_row_actions( $column_name, $post_id ) {
376 if ( 'imgoptm' !== $column_name ) {
377 return;
378 }
379
380 do_action( 'litespeed_media_row', $post_id );
381 }
382
383 /**
384 * Display image optimization info in the media list row.
385 *
386 * @since 3.0
387 *
388 * @param int $post_id Attachment post ID.
389 * @return void
390 */
391 public function media_row_con( $post_id ) {
392 $att_info = wp_get_attachment_metadata( $post_id );
393 if ( empty( $att_info['file'] ) ) {
394 return;
395 }
396
397 $short_path = $att_info['file'];
398
399 $size_meta = get_post_meta( $post_id, Img_Optm::DB_SIZE, true );
400
401 echo '<p>';
402 // Original image info.
403 if ( $size_meta && ! empty( $size_meta['ori_saved'] ) ) {
404 $percent = (int) ceil( ( (int) $size_meta['ori_saved'] * 100 ) / max( 1, (int) $size_meta['ori_total'] ) );
405
406 $extension = pathinfo( $short_path, PATHINFO_EXTENSION );
407 $bk_file = substr( $short_path, 0, -strlen( $extension ) ) . 'bk.' . $extension;
408 $bk_optm_file = substr( $short_path, 0, -strlen( $extension ) ) . 'bk.optm.' . $extension;
409
410 $link = Utility::build_url( Router::ACTION_IMG_OPTM, 'orig' . $post_id );
411 $desc = false;
412
413 $cls = '';
414
415 if ( $this->info( $bk_file, $post_id ) ) {
416 $curr_status = esc_html__( '(optm)', 'litespeed-cache' );
417 $desc = esc_attr__( 'Currently using optimized version of file.', 'litespeed-cache' ) . '&#10;' . esc_attr__( 'Click to switch to original (unoptimized) version.', 'litespeed-cache' );
418 } elseif ( $this->info( $bk_optm_file, $post_id ) ) {
419 $cls .= ' litespeed-warning';
420 $curr_status = esc_html__( '(non-optm)', 'litespeed-cache' );
421 $desc = esc_attr__( 'Currently using original (unoptimized) version of file.', 'litespeed-cache' ) . '&#10;' . esc_attr__( 'Click to switch to optimized version.', 'litespeed-cache' );
422 }
423
424 echo wp_kses_post(
425 GUI::pie_tiny(
426 $percent,
427 24,
428 sprintf(
429 esc_html__( 'Original file reduced by %1$s (%2$s)', 'litespeed-cache' ),
430 $percent . '%',
431 Utility::real_size( $size_meta['ori_saved'] )
432 ),
433 'left'
434 )
435 );
436
437 printf(
438 esc_html__( 'Orig saved %s', 'litespeed-cache' ),
439 (int) $percent . '%'
440 );
441
442 if ( $desc ) {
443 printf(
444 ' <a href="%1$s" class="litespeed-media-href %2$s" data-balloon-pos="left" data-balloon-break aria-label="%3$s">%4$s</a>',
445 esc_url( $link ),
446 esc_attr( $cls ),
447 wp_kses_post( $desc ),
448 esc_html( $curr_status )
449 );
450 } else {
451 printf(
452 ' <span class="litespeed-desc" data-balloon-pos="left" data-balloon-break aria-label="%1$s">%2$s</span>',
453 esc_attr__( 'Using optimized version of file. ', 'litespeed-cache' ) . '&#10;' . esc_attr__( 'No backup of original file exists.', 'litespeed-cache' ),
454 esc_html__( '(optm)', 'litespeed-cache' )
455 );
456 }
457 } elseif ( $size_meta && 0 === (int) $size_meta['ori_saved'] ) {
458 echo wp_kses_post( GUI::pie_tiny( 0, 24, esc_html__( 'Congratulation! Your file was already optimized', 'litespeed-cache' ), 'left' ) );
459 printf(
460 esc_html__( 'Orig %s', 'litespeed-cache' ),
461 '<span class="litespeed-desc">' . esc_html__( '(no savings)', 'litespeed-cache' ) . '</span>'
462 );
463 } else {
464 echo esc_html__( 'Orig', 'litespeed-cache' ) . '<span class="litespeed-left10">—</span>';
465 }
466 echo '</p>';
467
468 echo '<p>';
469 // WebP/AVIF info.
470 if ( $size_meta && $this->webp_support( true ) && ! empty( $size_meta[ $this->_sys_format . '_saved' ] ) ) {
471 $is_avif = 'avif' === $this->_sys_format;
472 $size_meta_saved = $size_meta[ $this->_sys_format . '_saved' ];
473 $size_meta_total = $size_meta[ $this->_sys_format . '_total' ];
474
475 $percent = ceil( ( $size_meta_saved * 100 ) / max( 1, $size_meta_total ) );
476
477 $link = Utility::build_url( Router::ACTION_IMG_OPTM, $this->_sys_format . $post_id );
478 $desc = false;
479
480 $cls = '';
481
482 if ( $this->info( $short_path . '.' . $this->_sys_format, $post_id ) ) {
483 $curr_status = esc_html__( '(optm)', 'litespeed-cache' );
484 $desc = $is_avif
485 ? esc_attr__( 'Currently using optimized version of AVIF file.', 'litespeed-cache' )
486 : esc_attr__( 'Currently using optimized version of WebP file.', 'litespeed-cache' );
487 $desc .= '&#10;' . esc_attr__( 'Click to switch to original (unoptimized) version.', 'litespeed-cache' );
488 } elseif ( $this->info( $short_path . '.optm.' . $this->_sys_format, $post_id ) ) {
489 $cls .= ' litespeed-warning';
490 $curr_status = esc_html__( '(non-optm)', 'litespeed-cache' );
491 $desc = $is_avif
492 ? esc_attr__( 'Currently using original (unoptimized) version of AVIF file.', 'litespeed-cache' )
493 : esc_attr__( 'Currently using original (unoptimized) version of WebP file.', 'litespeed-cache' );
494 $desc .= '&#10;' . esc_attr__( 'Click to switch to optimized version.', 'litespeed-cache' );
495 }
496
497 echo wp_kses_post(
498 GUI::pie_tiny(
499 $percent,
500 24,
501 sprintf(
502 $is_avif ? esc_html__( 'AVIF file reduced by %1$s (%2$s)', 'litespeed-cache' ) : esc_html__( 'WebP file reduced by %1$s (%2$s)', 'litespeed-cache' ),
503 $percent . '%',
504 Utility::real_size( $size_meta_saved )
505 ),
506 'left'
507 )
508 );
509 printf(
510 $is_avif ? esc_html__( 'AVIF saved %s', 'litespeed-cache' ) : esc_html__( 'WebP saved %s', 'litespeed-cache' ),
511 '<span>' . esc_html( $percent ) . '%</span>'
512 );
513
514 if ( $desc ) {
515 printf(
516 ' <a href="%1$s" class="litespeed-media-href %2$s" data-balloon-pos="left" data-balloon-break aria-label="%3$s">%4$s</a>',
517 esc_url( $link ),
518 esc_attr( $cls ),
519 wp_kses_post( $desc ),
520 esc_html( $curr_status )
521 );
522 } else {
523 printf(
524 ' <span class="litespeed-desc" data-balloon-pos="left" data-balloon-break aria-label="%1$s&#10;%2$s">%3$s</span>',
525 esc_attr__( 'Using optimized version of file. ', 'litespeed-cache' ),
526 $is_avif ? esc_attr__( 'No backup of unoptimized AVIF file exists.', 'litespeed-cache' ) : esc_attr__( 'No backup of unoptimized WebP file exists.', 'litespeed-cache' ),
527 esc_html__( '(optm)', 'litespeed-cache' )
528 );
529 }
530 } else {
531 echo esc_html( $this->next_gen_image_title() ) . '<span class="litespeed-left10">—</span>';
532 }
533
534 echo '</p>';
535
536 // Delete row btn.
537 if ( $size_meta ) {
538 printf(
539 '<div class="row-actions"><span class="delete"><a href="%1$s" class="">%2$s</a></span></div>',
540 esc_url( Utility::build_url( Router::ACTION_IMG_OPTM, Img_Optm::TYPE_RESET_ROW, false, null, array( 'id' => $post_id ) ) ),
541 esc_html__( 'Restore from backup', 'litespeed-cache' )
542 );
543 echo '</div>';
544 }
545 }
546
547 /**
548 * Get wp size info.
549 *
550 * NOTE: this is not used because it has to be after admin_init.
551 *
552 * @since 1.6.2
553 * @return array $sizes Data for all currently-registered image sizes.
554 */
555 public function get_image_sizes() {
556 global $_wp_additional_image_sizes;
557 $sizes = [];
558
559 foreach ( get_intermediate_image_sizes() as $_size ) {
560 if ( in_array( $_size, array( 'thumbnail', 'medium', 'medium_large', 'large' ), true ) ) {
561 $sizes[ $_size ]['width'] = get_option( $_size . '_size_w' );
562 $sizes[ $_size ]['height'] = get_option( $_size . '_size_h' );
563 $sizes[ $_size ]['crop'] = (bool) get_option( $_size . '_crop' );
564 } elseif ( isset( $_wp_additional_image_sizes[ $_size ] ) ) {
565 $sizes[ $_size ] = array(
566 'width' => $_wp_additional_image_sizes[ $_size ]['width'],
567 'height' => $_wp_additional_image_sizes[ $_size ]['height'],
568 'crop' => $_wp_additional_image_sizes[ $_size ]['crop'],
569 );
570 }
571 }
572
573 return $sizes;
574 }
575
576 /**
577 * Exclude role from optimization filter.
578 *
579 * @since 1.6.2
580 * @access public
581 *
582 * @param bool $sys_level Return system-level format if true.
583 * @return string Next-gen format name or empty string.
584 */
585 public function webp_support( $sys_level = false ) {
586 if ( $sys_level ) {
587 return $this->_sys_format;
588 }
589 return $this->_format; // User level next gen support.
590 }
591
592 /**
593 * Detect if browser supports next-gen format.
594 *
595 * @return bool
596 */
597 private function _browser_support_next_gen() {
598 $accept = isset( $_SERVER['HTTP_ACCEPT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT'] ) ) : '';
599 if ( $accept ) {
600 if ( false !== strpos( $accept, 'image/' . $this->_sys_format ) ) {
601 return true;
602 }
603 }
604
605 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
606 if ( $ua ) {
607 $user_agents = array( 'chrome-lighthouse', 'googlebot', 'page speed' );
608 foreach ( $user_agents as $user_agent ) {
609 if ( false !== stripos( $ua, $user_agent ) ) {
610 return true;
611 }
612 }
613
614 if ( preg_match( '/iPhone OS (\d+)_/i', $ua, $matches ) ) {
615 if ( $matches[1] >= 14 ) {
616 return true;
617 }
618 }
619
620 if ( preg_match( '/Firefox\/(\d+)/i', $ua, $matches ) ) {
621 if ( $matches[1] >= 65 ) {
622 return true;
623 }
624 }
625 }
626
627 return false;
628 }
629
630 /**
631 * Get next gen image title.
632 *
633 * @since 7.0
634 * @return string
635 */
636 public function next_gen_image_title() {
637 $next_gen_img = 'WebP';
638 if ( 2 === $this->conf( Base::O_IMG_OPTM_WEBP ) ) {
639 $next_gen_img = 'AVIF';
640 }
641 return $next_gen_img;
642 }
643
644 /**
645 * Run lazy load process.
646 * NOTE: As this is after cache finalized, can NOT set any cache control anymore.
647 *
648 * Only do for main page. Do NOT do for esi or dynamic content.
649 *
650 * @since 1.4
651 * @access public
652 *
653 * @param string $content Final buffer.
654 * @return string The buffer.
655 */
656 public function finalize( $content ) {
657 if ( defined( 'LITESPEED_NO_LAZY' ) ) {
658 self::debug2( 'bypass: NO_LAZY const' );
659 return $content;
660 }
661
662 if ( ! defined( 'LITESPEED_IS_HTML' ) ) {
663 self::debug2( 'bypass: Not frontend HTML type' );
664 return $content;
665 }
666
667 if ( ! Control::is_cacheable() ) {
668 self::debug( 'bypass: Not cacheable' );
669 return $content;
670 }
671
672 self::debug( 'finalize' );
673
674 $this->content = $content;
675 $this->_finalize();
676 return $this->content;
677 }
678
679 /**
680 * Run lazyload replacement for images in buffer.
681 *
682 * @since 1.4
683 * @access private
684 * @return void
685 */
686 private function _finalize() {
687 /**
688 * Use webp for optimized images.
689 *
690 * @since 1.6.2
691 */
692 if ( $this->webp_support() ) {
693 $this->content = $this->_replace_buffer_img_webp( $this->content );
694 }
695
696 /**
697 * Check if URI is excluded.
698 *
699 * @since 3.0
700 */
701 $excludes = $this->conf( Base::O_MEDIA_LAZY_URI_EXC );
702 if ( ! defined( 'LITESPEED_GUEST_OPTM' ) ) {
703 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
704 $result = $request_uri ? Utility::str_hit_array( $request_uri, $excludes ) : false;
705 if ( $result ) {
706 self::debug( 'bypass lazyload: hit URI Excludes setting: ' . $result );
707 return;
708 }
709 }
710
711 $cfg_lazy = ( defined( 'LITESPEED_GUEST_OPTM' ) || $this->conf( Base::O_MEDIA_LAZY ) ) && ! $this->cls( 'Metabox' )->setting( 'litespeed_no_image_lazy' );
712 $cfg_iframe_lazy = defined( 'LITESPEED_GUEST_OPTM' ) || $this->conf( Base::O_MEDIA_IFRAME_LAZY );
713 $cfg_js_delay = defined( 'LITESPEED_GUEST_OPTM' ) || 2 === $this->conf( Base::O_OPTM_JS_DEFER );
714 $cfg_trim_noscript = defined( 'LITESPEED_GUEST_OPTM' ) || $this->conf( Base::O_OPTM_NOSCRIPT_RM );
715 $cfg_vpi = defined( 'LITESPEED_GUEST_OPTM' ) || $this->conf( Base::O_MEDIA_VPI );
716
717 // Preload VPI.
718 if ( $cfg_vpi ) {
719 $this->_parse_img_for_preload();
720 }
721
722 if ( $cfg_lazy ) {
723 if ( $cfg_vpi ) {
724 add_filter( 'litespeed_media_lazy_img_excludes', array( $this->cls( 'Metabox' ), 'lazy_img_excludes' ) );
725 }
726 list( $src_list, $html_list, $placeholder_list ) = $this->_parse_img();
727 $html_list_ori = $html_list;
728 } else {
729 self::debug( 'lazyload disabled' );
730 }
731
732 // image lazy load.
733 if ( $cfg_lazy ) {
734 $__placeholder = Placeholder::cls();
735
736 foreach ( $html_list as $k => $v ) {
737 $size = $placeholder_list[ $k ];
738 $src = $src_list[ $k ];
739
740 $html_list[ $k ] = $__placeholder->replace( $v, $src, $size );
741 }
742 }
743
744 if ( $cfg_lazy ) {
745 $this->content = str_replace( $html_list_ori, $html_list, $this->content );
746 }
747
748 // iframe lazy load.
749 if ( $cfg_iframe_lazy ) {
750 $html_list = $this->_parse_iframe();
751 $html_list_ori = $html_list;
752
753 foreach ( $html_list as $k => $v ) {
754 $snippet = $cfg_trim_noscript ? '' : '<noscript>' . $v . '</noscript>';
755 if ( $cfg_js_delay ) {
756 $v = str_replace( ' src=', ' data-litespeed-src=', $v );
757 } else {
758 $v = str_replace( ' src=', ' data-src=', $v );
759 }
760 $v = str_replace( '<iframe ', '<iframe data-lazyloaded="1" src="about:blank" ', $v );
761 $snippet = $v . $snippet;
762
763 $html_list[ $k ] = $snippet;
764 }
765
766 $this->content = str_replace( $html_list_ori, $html_list, $this->content );
767 }
768
769 // Include lazyload lib js and init lazyload.
770 if ( $cfg_lazy || $cfg_iframe_lazy ) {
771 $lazy_lib = '<script data-no-optimize="1">window.lazyLoadOptions=Object.assign({},{threshold:' . apply_filters( 'litespeed_lazyload_threshold', 300 ) . '},window.lazyLoadOptions||{});' . File::read( LSCWP_DIR . self::LIB_FILE_IMG_LAZYLOAD ) . '</script>';
772 if ( $cfg_js_delay ) {
773 // Load JS delay lib.
774 if ( ! defined( 'LITESPEED_JS_DELAY_LIB_LOADED' ) ) {
775 define( 'LITESPEED_JS_DELAY_LIB_LOADED', true );
776 $lazy_lib .= '<script data-no-optimize="1">' . File::read( LSCWP_DIR . Optimize::LIB_FILE_JS_DELAY ) . '</script>';
777 }
778 }
779
780 $this->content = str_replace( '</body>', $lazy_lib . '</body>', $this->content );
781 }
782 }
783
784 /**
785 * Parse img src for VPI preload only.
786 * Note: Didn't reuse the _parse_img() because it contains replacement logic which is not needed for preload.
787 *
788 * @since 6.2
789 * @since 7.6 - Added attributes fetchpriority="high" and decode="sync" for VPI images.
790 * @return void
791 */
792 private function _parse_img_for_preload() {
793 // Load VPI setting.
794 $is_mobile = $this->_separate_mobile();
795 $vpi_files = $this->cls( 'Metabox' )->setting( $is_mobile ? VPI::POST_META_MOBILE : VPI::POST_META );
796 if ( $vpi_files ) {
797 $vpi_files = Utility::sanitize_lines( $vpi_files, 'basename' );
798 }
799 if ( ! $vpi_files ) {
800 return;
801 }
802 if ( ! $this->content ) {
803 return;
804 }
805
806 $content = preg_replace( array( '#<!--.*-->#sU', '#<noscript([^>]*)>.*</noscript>#isU' ), '', $this->content );
807 if ( ! $content ) {
808 return;
809 }
810
811 $vpi_fp_search = [];
812 $vpi_fp_replace = [];
813 preg_match_all('#<img\s+([^>]+)/?>#isU', $content, $matches, PREG_SET_ORDER);
814 foreach ($matches as $match) {
815 $attrs = Utility::parse_attr($match[1]);
816
817 if ( empty( $attrs['src'] ) ) {
818 continue;
819 }
820
821 if ( false !== strpos( $attrs['src'], 'base64' ) || 0 === strpos( $attrs['src'], 'data:' ) ) {
822 self::debug2( 'lazyload bypassed base64 img' );
823 continue;
824 }
825
826 if ( false !== strpos( $attrs['src'], '{' ) ) {
827 self::debug2( 'image src has {} ' . $attrs['src'] );
828 continue;
829 }
830
831 // If the src contains VPI filename, then preload it.
832 if ( ! Utility::str_hit_array( $attrs['src'], $vpi_files ) ) {
833 continue;
834 }
835
836 self::debug2( 'VPI preload found and matched: ' . $attrs['src'] );
837
838 $this->_vpi_preload_list[] = $attrs['src'];
839
840 // Add attributes fetchpriority="high" and decode="sync"
841 // after WP 6.3.0 use: wp_img_tag_add_loading_optimization_attrs().
842 $new_html = [];
843 $attrs[ 'fetchpriority' ] = 'high';
844 $attrs[ 'decoding' ] = 'sync';
845 // create html with new attributes.
846 foreach ( $attrs as $k => $attr ) {
847 $new_html[] = $k . '="' . $attr . '"';
848 }
849
850 if ( $new_html ) {
851 $vpi_fp_search[] = $match[1];
852 $vpi_fp_replace[] = implode( ' ', $new_html);
853 }
854 }
855
856 // if VPI fetchpriority changes, do the replacement
857 if ( $vpi_fp_search && $vpi_fp_replace ) {
858 $this->content = str_replace( $vpi_fp_search, $vpi_fp_replace, $this->content );
859 }
860 unset( $vpi_fp_search );
861 unset( $vpi_fp_replace );
862 }
863
864 /**
865 * Parse img src.
866 *
867 * @since 1.4
868 * @access private
869 * @return array{0:array,1:array,2:array} All the src & related raw html list with placeholders.
870 */
871 private function _parse_img() {
872 /**
873 * Exclude list.
874 *
875 * @since 1.5
876 * @since 2.7.1 Changed to array.
877 */
878 $excludes = apply_filters( 'litespeed_media_lazy_img_excludes', $this->conf( Base::O_MEDIA_LAZY_EXC ) );
879
880 $cls_excludes = apply_filters( 'litespeed_media_lazy_img_cls_excludes', $this->conf( Base::O_MEDIA_LAZY_CLS_EXC ) );
881 $cls_excludes[] = 'skip-lazy'; // https://core.trac.wordpress.org/ticket/44427
882
883 $src_list = [];
884 $html_list = [];
885 $placeholder_list = [];
886
887 $content = preg_replace(
888 array(
889 '#<!--.*-->#sU',
890 '#<noscript([^>]*)>.*</noscript>#isU',
891 '#<script([^>]*)>.*</script>#isU', // Remove script to avoid false matches and warnings, when image size detection is turned ON.
892 ),
893 '',
894 $this->content
895 );
896 /**
897 * Exclude parent classes.
898 *
899 * @since 3.0
900 */
901 $parent_cls_exc = apply_filters( 'litespeed_media_lazy_img_parent_cls_excludes', $this->conf( Base::O_MEDIA_LAZY_PARENT_CLS_EXC ) );
902 if ( $parent_cls_exc ) {
903 self::debug2( 'Lazyload Class excludes', $parent_cls_exc );
904 foreach ( $parent_cls_exc as $v ) {
905 $content = preg_replace('#<(\w+) [^>]*class=(\'|")[^\'"]*' . preg_quote($v, '#') . '[^\'"]*\2[^>]*>.*</\1>#sU', '', $content);
906 }
907 }
908
909 preg_match_all( '#<img\s+([^>]+)/?>#isU', $content, $matches, PREG_SET_ORDER );
910 foreach ( $matches as $match ) {
911 $attrs = Utility::parse_attr( $match[1] );
912
913 if ( empty( $attrs['src'] ) ) {
914 continue;
915 }
916
917 /**
918 * Add src validation to bypass base64 img src.
919 *
920 * @since 1.6
921 */
922 if ( false !== strpos( $attrs['src'], 'base64' ) || 0 === strpos( $attrs['src'], 'data:' ) ) {
923 self::debug2( 'lazyload bypassed base64 img' );
924 continue;
925 }
926
927 self::debug2( 'lazyload found: ' . $attrs['src'] );
928
929 if (
930 ! empty( $attrs['data-no-lazy'] ) ||
931 ! empty( $attrs['data-skip-lazy'] ) ||
932 ! empty( $attrs['data-lazyloaded'] ) ||
933 ! empty( $attrs['data-src'] ) ||
934 ! empty( $attrs['data-srcset'] )
935 ) {
936 self::debug2( 'bypassed' );
937 continue;
938 }
939
940 $hit = ! empty( $attrs['class'] ) ? Utility::str_hit_array( $attrs['class'], $cls_excludes ) : false;
941 if ( $hit ) {
942 self::debug2( 'lazyload image cls excludes [hit] ' . $hit );
943 continue;
944 }
945
946 /**
947 * Exclude from lazyload by setting.
948 *
949 * @since 1.5
950 */
951 if ( $excludes && Utility::str_hit_array( $attrs['src'], $excludes ) ) {
952 self::debug2( 'lazyload image exclude ' . $attrs['src'] );
953 continue;
954 }
955
956 /**
957 * Excludes invalid image src from buddypress avatar crop.
958 *
959 * @see https://wordpress.org/support/topic/lazy-load-breaking-buddypress-upload-avatar-feature
960 * @since 3.0
961 */
962 if ( false !== strpos( $attrs['src'], '{' ) ) {
963 self::debug2( 'image src has {} ' . $attrs['src'] );
964 continue;
965 }
966
967 // to avoid multiple replacement.
968 if ( in_array( $match[0], $html_list, true ) ) {
969 continue;
970 }
971
972 // Add missing dimensions.
973 if ( defined( 'LITESPEED_GUEST_OPTM' ) || $this->conf( Base::O_MEDIA_ADD_MISSING_SIZES ) ) {
974 if ( ! apply_filters( 'litespeed_media_add_missing_sizes', true ) ) {
975 self::debug2( 'add_missing_sizes bypassed via litespeed_media_add_missing_sizes filter' );
976 } elseif ( empty( $attrs['width'] ) || 'auto' === $attrs['width'] || empty( $attrs['height'] ) || 'auto' === $attrs['height'] ) {
977 self::debug( '⚠️ Missing sizes for image [src] ' . $attrs['src'] );
978 $dimensions = $this->_detect_dimensions( $attrs['src'] );
979 if ( $dimensions ) {
980 $ori_width = $dimensions[0];
981 $ori_height = $dimensions[1];
982 // Calculate height based on width.
983 if ( ! empty( $attrs['width'] ) && 'auto' !== $attrs['width'] ) {
984 $ori_height = (int) ( ( $ori_height * (int) $attrs['width'] ) / max( 1, $ori_width ) );
985 } elseif ( ! empty( $attrs['height'] ) && 'auto' !== $attrs['height'] ) {
986 $ori_width = (int) ( ( $ori_width * (int) $attrs['height'] ) / max( 1, $ori_height ) );
987 }
988
989 $attrs['width'] = $ori_width;
990 $attrs['height'] = $ori_height;
991 $new_html = preg_replace( '#\s+(width|height)=(["\'])[^\2]*?\2#', '', $match[0] );
992 $new_html = preg_replace(
993 '#<img\s+#i',
994 '<img width="' . Str::trim_quotes( $attrs['width'] ) . '" height="' . Str::trim_quotes( $attrs['height'] ) . '" ',
995 $new_html
996 );
997 self::debug( 'Add missing sizes ' . $attrs['width'] . 'x' . $attrs['height'] . ' to ' . $attrs['src'] );
998 $this->content = str_replace( $match[0], $new_html, $this->content );
999 $match[0] = $new_html;
1000 }
1001 }
1002 }
1003
1004 $placeholder = false;
1005 if ( ! empty( $attrs['width'] ) && 'auto' !== $attrs['width'] && ! empty( $attrs['height'] ) && 'auto' !== $attrs['height'] ) {
1006 $placeholder = (int) $attrs['width'] . 'x' . (int) $attrs['height'];
1007 }
1008
1009 $src_list[] = $attrs['src'];
1010 $html_list[] = $match[0];
1011 $placeholder_list[] = $placeholder;
1012 }
1013
1014 return array( $src_list, $html_list, $placeholder_list );
1015 }
1016
1017 /**
1018 * Detect the original sizes.
1019 *
1020 * @since 4.0
1021 *
1022 * @param string $src Source URL/path.
1023 * @return array|false getimagesize array or false.
1024 */
1025 private function _detect_dimensions( $src ) {
1026 $pathinfo = Utility::is_internal_file( $src );
1027 if ( $pathinfo ) {
1028 $src = $pathinfo[0];
1029 } elseif ( apply_filters( 'litespeed_media_ignore_remote_missing_sizes', false ) ) {
1030 return false;
1031 }
1032
1033 if ( 0 === strpos( $src, '//' ) ) {
1034 $src = 'https:' . $src;
1035 }
1036
1037 try {
1038 $sizes = getimagesize( $src );
1039 } catch ( \Exception $e ) {
1040 return false;
1041 }
1042
1043 if ( ! empty( $sizes[0] ) && ! empty( $sizes[1] ) ) {
1044 return $sizes;
1045 }
1046
1047 return false;
1048 }
1049
1050 /**
1051 * Parse iframe src.
1052 *
1053 * @since 1.4
1054 * @access private
1055 * @return array All the related raw html list (full <iframe> tags).
1056 */
1057 private function _parse_iframe() {
1058 $cls_excludes = apply_filters( 'litespeed_media_iframe_lazy_cls_excludes', $this->conf( Base::O_MEDIA_IFRAME_LAZY_CLS_EXC ) );
1059 $cls_excludes[] = 'skip-lazy'; // https://core.trac.wordpress.org/ticket/44427
1060
1061 $html_list = [];
1062
1063 $content = preg_replace( '#<!--.*-->#sU', '', $this->content );
1064
1065 /**
1066 * Exclude parent classes.
1067 *
1068 * @since 3.0
1069 */
1070 $parent_cls_exc = apply_filters( 'litespeed_media_iframe_lazy_parent_cls_excludes', $this->conf( Base::O_MEDIA_IFRAME_LAZY_PARENT_CLS_EXC ) );
1071 if ( $parent_cls_exc ) {
1072 self::debug2( 'Iframe Lazyload Class excludes', $parent_cls_exc );
1073 foreach ( $parent_cls_exc as $v ) {
1074 $content = preg_replace('#<(\w+) [^>]*class=(\'|")[^\'"]*' . preg_quote($v, '#') . '[^\'"]*\2[^>]*>.*</\1>#sU', '', $content);
1075 }
1076 }
1077
1078 preg_match_all( '#<iframe \s*([^>]+)></iframe>#isU', $content, $matches, PREG_SET_ORDER );
1079 foreach ( $matches as $match ) {
1080 $attrs = Utility::parse_attr( $match[1] );
1081
1082 if ( empty( $attrs['src'] ) ) {
1083 continue;
1084 }
1085
1086 self::debug2( 'found iframe: ' . $attrs['src'] );
1087
1088 if ( ! empty( $attrs['data-no-lazy'] ) || ! empty( $attrs['data-skip-lazy'] ) || ! empty( $attrs['data-lazyloaded'] ) || ! empty( $attrs['data-src'] ) ) {
1089 self::debug2( 'bypassed' );
1090 continue;
1091 }
1092
1093 $hit = ! empty( $attrs['class'] ) ? Utility::str_hit_array( $attrs['class'], $cls_excludes ) : false;
1094 if ( $hit ) {
1095 self::debug2( 'iframe lazyload cls excludes [hit] ' . $hit );
1096 continue;
1097 }
1098
1099 if ( apply_filters( 'litespeed_iframe_lazyload_exc', false, $attrs['src'] ) ) {
1100 self::debug2( 'bypassed by filter' );
1101 continue;
1102 }
1103
1104 // to avoid multiple replacement.
1105 if ( in_array( $match[0], $html_list, true ) ) {
1106 continue;
1107 }
1108
1109 $html_list[] = $match[0];
1110 }
1111
1112 return $html_list;
1113 }
1114
1115 /**
1116 * Replace image src to webp/avif in buffer.
1117 *
1118 * @since 1.6.2
1119 * @access private
1120 *
1121 * @param string $content HTML content.
1122 * @return string Modified content.
1123 */
1124 private function _replace_buffer_img_webp( $content ) {
1125 /**
1126 * Added custom element & attribute support.
1127 *
1128 * @since 2.2.2
1129 */
1130 $webp_ele_to_check = $this->conf( Base::O_IMG_OPTM_WEBP_ATTR );
1131
1132 foreach ( $webp_ele_to_check as $v ) {
1133 if ( ! $v || false === strpos( $v, '.' ) ) {
1134 self::debug2( 'buffer_webp no . attribute ' . $v );
1135 continue;
1136 }
1137
1138 self::debug2( 'buffer_webp attribute ' . $v );
1139
1140 $v = explode( '.', $v );
1141 $attr = preg_quote( $v[1], '#' );
1142 if ( $v[0] ) {
1143 $pattern = '#<' . preg_quote( $v[0], '#' ) . '([^>]+)' . $attr . '=([\'"])(.+)\2#iU';
1144 } else {
1145 $pattern = '# ' . $attr . '=([\'"])(.+)\1#iU';
1146 }
1147
1148 preg_match_all( $pattern, $content, $matches );
1149
1150 foreach ( $matches[ $v[0] ? 3 : 2 ] as $k2 => $url ) {
1151 // Check if is a DATA-URI.
1152 if ( false !== strpos( $url, 'data:image' ) ) {
1153 continue;
1154 }
1155
1156 $url2 = $this->replace_webp( $url );
1157 if ( ! $url2 ) {
1158 continue;
1159 }
1160
1161 if ( $v[0] ) {
1162 $html_snippet = sprintf( '<' . $v[0] . '%1$s' . $v[1] . '=%2$s', $matches[1][ $k2 ], $matches[2][ $k2 ] . $url2 . $matches[2][ $k2 ] );
1163 } else {
1164 $html_snippet = sprintf( ' ' . $v[1] . '=%1$s', $matches[1][ $k2 ] . $url2 . $matches[1][ $k2 ] );
1165 }
1166
1167 $content = str_replace( $matches[0][ $k2 ], $html_snippet, $content );
1168 }
1169 }
1170
1171 // parse srcset.
1172 // todo: should apply this to cdn too.
1173 if ( ( defined( 'LITESPEED_GUEST_OPTM' ) || $this->conf( Base::O_IMG_OPTM_WEBP_REPLACE_SRCSET ) ) && $this->webp_support() ) {
1174 $content = Utility::srcset_replace( $content, array( $this, 'replace_webp' ) );
1175 }
1176
1177 // Replace background-image.
1178 if ( ( defined( 'LITESPEED_GUEST_OPTM' ) || $this->conf( Base::O_IMG_OPTM_WEBP ) ) && $this->webp_support() ) {
1179 $content = $this->replace_background_webp( $content );
1180 }
1181
1182 return $content;
1183 }
1184
1185 /**
1186 * Replace background image in inline styles and JSON blobs.
1187 *
1188 * @since 4.0
1189 *
1190 * @param string $content HTML content.
1191 * @return string Modified content.
1192 */
1193 public function replace_background_webp( $content ) {
1194 self::debug2( 'Start replacing background WebP/AVIF.' );
1195
1196 // Handle Elementor's data-settings JSON encoded background-images.
1197 $content = $this->replace_urls_in_json( $content );
1198
1199 preg_match_all( '#url\(([^)]+)\)#iU', $content, $matches );
1200 foreach ( $matches[1] as $k => $url ) {
1201 // Check if is a DATA-URI.
1202 if ( false !== strpos( $url, 'data:image' ) ) {
1203 continue;
1204 }
1205
1206 /**
1207 * Support quotes in src `background-image: url('src')`.
1208 *
1209 * @since 2.9.3
1210 */
1211 $url = trim( $url, '\'"' );
1212
1213 // Fix Elementor's Slideshow unusual background images like style="background-image: url(&quot;https://xxxx.png&quot;);"
1214 if ( 0 === strpos( $url, '&quot;' ) && '&quot;' === substr( $url, -6 ) ) {
1215 $url = substr( $url, 6, -6 );
1216 }
1217
1218 $url2 = $this->replace_webp( $url );
1219 if ( ! $url2 ) {
1220 continue;
1221 }
1222
1223 $html_snippet = str_replace( $url, $url2, $matches[0][ $k ] );
1224 $content = str_replace( $matches[0][ $k ], $html_snippet, $content );
1225 }
1226
1227 return $content;
1228 }
1229
1230 /**
1231 * Replace images in json data settings attributes.
1232 *
1233 * @since 6.2
1234 *
1235 * @param string $content HTML content to scan and modify.
1236 * @return string Modified content with replaced URLs inside JSON attributes.
1237 */
1238 public function replace_urls_in_json( $content ) {
1239 $pattern = '/data-settings="(.*?)"/i';
1240 $parent_class = $this;
1241
1242 preg_match_all( $pattern, $content, $matches, PREG_SET_ORDER );
1243
1244 foreach ( $matches as $match ) {
1245 // Check if the string contains HTML entities.
1246 $is_encoded = preg_match( '/&quot;|&lt;|&gt;|&amp;|&apos;/', $match[1] );
1247
1248 // Decode HTML entities in the JSON string.
1249 $json_string = html_entity_decode( $match[1] );
1250
1251 $json_data = \json_decode( $json_string, true );
1252
1253 if ( JSON_ERROR_NONE === json_last_error() && is_array( $json_data ) ) {
1254 $did_webp_replace = false;
1255
1256 array_walk_recursive(
1257 $json_data,
1258 /**
1259 * Replace URLs in JSON data recursively.
1260 *
1261 * @param mixed $item Value (modified in place).
1262 * @param string $key Array key.
1263 */
1264 function ( &$item, $key ) use ( &$did_webp_replace, $parent_class ) {
1265 if ( 'url' === $key ) {
1266 $item_image = $parent_class->replace_webp( $item );
1267 if ( $item_image ) {
1268 $item = $item_image;
1269 $did_webp_replace = true;
1270 }
1271 }
1272 }
1273 );
1274
1275 if ( $did_webp_replace ) {
1276 // Re-encode the modified array back to a JSON string.
1277 $new_json_string = wp_json_encode( $json_data );
1278
1279 // Re-encode the JSON string to HTML entities only if it was originally encoded.
1280 if ( $is_encoded ) {
1281 $new_json_string = htmlspecialchars( $new_json_string, ENT_QUOTES | 0 ); // ENT_HTML401 for PHP>=5.4.
1282 }
1283
1284 // Replace the old JSON string in the content with the new, modified JSON string.
1285 $content = str_replace( $match[1], $new_json_string, $content );
1286 }
1287 }
1288 }
1289
1290 return $content;
1291 }
1292
1293 /**
1294 * Replace internal image src to webp or avif.
1295 *
1296 * @since 1.6.2
1297 * @access public
1298 *
1299 * @param string $url Image URL.
1300 * @return string|false Replaced URL or false if not applicable.
1301 */
1302 public function replace_webp( $url ) {
1303 if ( ! $this->webp_support() ) {
1304 self::debug2( 'No next generation format chosen in setting, bypassed' );
1305 return false;
1306 }
1307 self::debug2( $this->_sys_format . ' replacing: ' . substr( $url, 0, 200 ) );
1308
1309 if ( substr( $url, -5 ) === '.' . $this->_sys_format ) {
1310 self::debug2( 'already ' . $this->_sys_format );
1311 return false;
1312 }
1313
1314 /**
1315 * WebP/AVIF API hook.
1316 * NOTE: As $url may contain query strings, check filters which may parse_url before appending format.
1317 *
1318 * @since 2.9.5
1319 * @see #751737 - API docs for WebP generation
1320 */
1321 $ori_check = apply_filters( 'litespeed_media_check_ori', Utility::is_internal_file( $url ), $url );
1322 if ( $ori_check ) {
1323 // check if has webp/avif file.
1324 $has_next = apply_filters( 'litespeed_media_check_webp', Utility::is_internal_file( $url, $this->_sys_format ), $url );
1325 if ( $has_next ) {
1326 $url .= '.' . $this->_sys_format;
1327 } else {
1328 self::debug2( '-no WebP or AVIF file, bypassed' );
1329 return false;
1330 }
1331 } else {
1332 self::debug2( '-no file, bypassed' );
1333 return false;
1334 }
1335
1336 self::debug2( '- replaced to: ' . $url );
1337
1338 return $url;
1339 }
1340
1341 /**
1342 * Hook to wp_get_attachment_image_src.
1343 *
1344 * @since 1.6.2
1345 * @access public
1346 *
1347 * @param array $img The URL, width, height array.
1348 * @return array
1349 */
1350 public function webp_attach_img_src( $img ) {
1351 self::debug2( 'changing attach src: ' . $img[0] );
1352 $url = $img ? $this->replace_webp( $img[0] ) : false;
1353 if ( $url ) {
1354 $img[0] = $url;
1355 }
1356 return $img;
1357 }
1358
1359 /**
1360 * Try to replace img url.
1361 *
1362 * @since 1.6.2
1363 * @access public
1364 *
1365 * @param string $url Image URL.
1366 * @return string
1367 */
1368 public function webp_url( $url ) {
1369 $url2 = $url ? $this->replace_webp( $url ) : false;
1370 if ( $url2 ) {
1371 $url = $url2;
1372 }
1373 return $url;
1374 }
1375
1376 /**
1377 * Hook to replace WP responsive images.
1378 *
1379 * @since 1.6.2
1380 * @access public
1381 *
1382 * @param array $srcs Srcset array.
1383 * @return array
1384 */
1385 public function webp_srcset( $srcs ) {
1386 if ( $srcs ) {
1387 foreach ( $srcs as $w => $data ) {
1388 $url = $this->replace_webp( $data['url'] );
1389 if ( ! $url ) {
1390 continue;
1391 }
1392 $srcs[ $w ]['url'] = $url;
1393 }
1394 }
1395 return $srcs;
1396 }
1397 }
1398