#isU', $content, $matches, PREG_SET_ORDER); foreach ($matches as $match) { $attrs = Utility::parse_attr($match[1]); if ( empty( $attrs['src'] ) ) { continue; } if ( false !== strpos( $attrs['src'], 'base64' ) || 0 === strpos( $attrs['src'], 'data:' ) ) { self::debug2( 'lazyload bypassed base64 img' ); continue; } if ( false !== strpos( $attrs['src'], '{' ) ) { self::debug2( 'image src has {} ' . $attrs['src'] ); continue; } // If the src contains VPI filename, then preload it. if ( ! Utility::str_hit_array( $attrs['src'], $vpi_files ) ) { continue; } self::debug2( 'VPI preload found and matched: ' . $attrs['src'] ); $this->_vpi_preload_list[] = $attrs['src']; // Add attributes fetchpriority="high" and decode="sync" // after WP 6.3.0 use: wp_img_tag_add_loading_optimization_attrs(). $new_html = []; $attrs[ 'fetchpriority' ] = 'high'; $attrs[ 'decoding' ] = 'sync'; // create html with new attributes. foreach ( $attrs as $k => $attr ) { $new_html[] = $k . '="' . $attr . '"'; } if ( $new_html ) { $vpi_fp_search[] = $match[1]; $vpi_fp_replace[] = implode( ' ', $new_html); } } // if VPI fetchpriority changes, do the replacement if ( $vpi_fp_search && $vpi_fp_replace ) { $this->content = str_replace( $vpi_fp_search, $vpi_fp_replace, $this->content ); } unset( $vpi_fp_search ); unset( $vpi_fp_replace ); } /** * Parse img src. * * @since 1.4 * @access private * @return array{0:array,1:array,2:array} All the src & related raw html list with placeholders. */ private function _parse_img() { /** * Exclude list. * * @since 1.5 * @since 2.7.1 Changed to array. */ $excludes = apply_filters( 'litespeed_media_lazy_img_excludes', $this->conf( Base::O_MEDIA_LAZY_EXC ) ); $cls_excludes = apply_filters( 'litespeed_media_lazy_img_cls_excludes', $this->conf( Base::O_MEDIA_LAZY_CLS_EXC ) ); $cls_excludes[] = 'skip-lazy'; // https://core.trac.wordpress.org/ticket/44427 $src_list = []; $html_list = []; $placeholder_list = []; $content = preg_replace( [ '##sU', '#]*)>.*#isU', '#]*)>.*#isU', // Remove script to avoid false matches and warnings, when image size detection is turned ON. ], '', $this->content ); /** * Exclude parent classes. * * @since 3.0 */ $parent_cls_exc = apply_filters( 'litespeed_media_lazy_img_parent_cls_excludes', $this->conf( Base::O_MEDIA_LAZY_PARENT_CLS_EXC ) ); if ( $parent_cls_exc ) { self::debug2( 'Lazyload Class excludes', $parent_cls_exc ); foreach ( $parent_cls_exc as $v ) { $content = preg_replace('#<(\w+) [^>]*class=(\'|")[^\'"]*' . preg_quote($v, '#') . '[^\'"]*\2[^>]*>.*#sU', '', $content); } } $add_missing_sizes = ( defined( 'LITESPEED_GUEST_OPTM' ) || $this->conf( Base::O_MEDIA_ADD_MISSING_SIZES ) ) && apply_filters( 'litespeed_media_add_missing_sizes', true ); preg_match_all( '#]+)/?>#isU', $content, $matches, PREG_SET_ORDER ); foreach ( $matches as $match ) { $attrs = Utility::parse_attr( $match[1] ); if ( empty( $attrs['src'] ) ) { continue; } /** * Add src validation to bypass base64 img src. * * @since 1.6 */ if ( false !== strpos( $attrs['src'], 'base64' ) || 0 === strpos( $attrs['src'], 'data:' ) ) { self::debug2( 'lazyload bypassed base64 img' ); continue; } self::debug2( 'lazyload found: ' . $attrs['src'] ); if ( ! empty( $attrs['data-no-lazy'] ) || ! empty( $attrs['data-skip-lazy'] ) || ! empty( $attrs['data-lazyloaded'] ) || ! empty( $attrs['data-src'] ) || ! empty( $attrs['data-srcset'] ) ) { self::debug2( 'bypassed' ); continue; } $hit = ! empty( $attrs['class'] ) ? Utility::str_hit_array( $attrs['class'], $cls_excludes ) : false; if ( $hit ) { self::debug2( 'lazyload image cls excludes [hit] ' . $hit ); continue; } /** * Exclude from lazyload by setting. * * @since 1.5 */ if ( $excludes && Utility::str_hit_array( $attrs['src'], $excludes ) ) { self::debug2( 'lazyload image exclude ' . $attrs['src'] ); continue; } /** * Excludes invalid image src from buddypress avatar crop. * * @see https://wordpress.org/support/topic/lazy-load-breaking-buddypress-upload-avatar-feature * @since 3.0 */ if ( false !== strpos( $attrs['src'], '{' ) ) { self::debug2( 'image src has {} ' . $attrs['src'] ); continue; } // to avoid multiple replacement. if ( in_array( $match[0], $html_list, true ) ) { continue; } // Add missing dimensions. if ( $add_missing_sizes ) { if ( empty( $attrs['width'] ) || 'auto' === $attrs['width'] || empty( $attrs['height'] ) || 'auto' === $attrs['height'] ) { self::debug( '⚠️ Missing sizes for image [src] ' . $attrs['src'] ); $dimensions = $this->_detect_dimensions( $attrs['src'] ); if ( $dimensions ) { $ori_width = $dimensions[0]; $ori_height = $dimensions[1]; // Calculate height based on width. if ( ! empty( $attrs['width'] ) && 'auto' !== $attrs['width'] ) { $ori_height = (int) ( ( $ori_height * (int) $attrs['width'] ) / max( 1, $ori_width ) ); } elseif ( ! empty( $attrs['height'] ) && 'auto' !== $attrs['height'] ) { $ori_width = (int) ( ( $ori_width * (int) $attrs['height'] ) / max( 1, $ori_height ) ); } // Remove existing width/height, then append new values $inner = Utility::remove_attr( $match[1], 'width' ); $inner = Utility::remove_attr( $inner, 'height' ); $new_html = ''; self::debug( 'Add missing sizes ' . $ori_width . 'x' . $ori_height . ' to ' . $attrs['src'] ); $this->content = str_replace( $match[0], $new_html, $this->content ); $match[0] = $new_html; $attrs['width'] = $ori_width; $attrs['height'] = $ori_height; } } } $placeholder = false; if ( ! empty( $attrs['width'] ) && 'auto' !== $attrs['width'] && ! empty( $attrs['height'] ) && 'auto' !== $attrs['height'] ) { $placeholder = (int) $attrs['width'] . 'x' . (int) $attrs['height']; } $src_list[] = $attrs['src']; $html_list[] = $match[0]; $placeholder_list[] = $placeholder; } return [ $src_list, $html_list, $placeholder_list ]; } /** * Detect the original sizes. * * @since 4.0 * * @param string $src Source URL/path. * @return array|false getimagesize array or false. */ private function _detect_dimensions( $src ) { $pathinfo = Utility::is_internal_file( $src ); if ( $pathinfo ) { $src = $pathinfo[0]; } elseif ( apply_filters( 'litespeed_media_ignore_remote_missing_sizes', false ) ) { return false; } if ( 0 === strpos( $src, '//' ) ) { $src = 'https:' . $src; } try { $sizes = getimagesize( $src ); } catch ( \Exception $e ) { return false; } if ( ! empty( $sizes[0] ) && ! empty( $sizes[1] ) ) { return $sizes; } return false; } /** * Parse iframe src. * * @since 1.4 * @access private * @return array All the related raw html list (full #isU', $content, $matches, PREG_SET_ORDER ); foreach ( $matches as $match ) { $attrs = Utility::parse_attr( $match[1] ); if ( empty( $attrs['src'] ) ) { continue; } self::debug2( 'found iframe: ' . $attrs['src'] ); if ( ! empty( $attrs['data-no-lazy'] ) || ! empty( $attrs['data-skip-lazy'] ) || ! empty( $attrs['data-lazyloaded'] ) || ! empty( $attrs['data-src'] ) ) { self::debug2( 'bypassed' ); continue; } $hit = ! empty( $attrs['class'] ) ? Utility::str_hit_array( $attrs['class'], $cls_excludes ) : false; if ( $hit ) { self::debug2( 'iframe lazyload cls excludes [hit] ' . $hit ); continue; } if ( apply_filters( 'litespeed_iframe_lazyload_exc', false, $attrs['src'] ) ) { self::debug2( 'bypassed by filter' ); continue; } // to avoid multiple replacement. if ( in_array( $match[0], $html_list, true ) ) { continue; } $html_list[] = $match[0]; } return $html_list; } /** * Replace image src to webp/avif in buffer. * * @since 1.6.2 * @access private * * @param string $content HTML content. * @return string Modified content. */ private function _replace_buffer_img_webp( $content ) { /** * Added custom element & attribute support. * * @since 2.2.2 */ $webp_ele_to_check = $this->conf( Base::O_IMG_OPTM_WEBP_ATTR ); foreach ( $webp_ele_to_check as $v ) { if ( ! $v || false === strpos( $v, '.' ) ) { self::debug2( 'buffer_webp no . attribute ' . $v ); continue; } self::debug2( 'buffer_webp attribute ' . $v ); $v = explode( '.', $v ); $attr = preg_quote( $v[1], '#' ); if ( $v[0] ) { $pattern = '#<' . preg_quote( $v[0], '#' ) . '([^>]+)' . $attr . '=([\'"])(.+)\2#iU'; } else { $pattern = '# ' . $attr . '=([\'"])(.+)\1#iU'; } preg_match_all( $pattern, $content, $matches ); foreach ( $matches[ $v[0] ? 3 : 2 ] as $k2 => $url ) { // Check if is a DATA-URI. if ( false !== strpos( $url, 'data:image' ) ) { continue; } $url2 = $this->replace_webp( $url ); if ( ! $url2 ) { continue; } if ( $v[0] ) { $html_snippet = sprintf( '<' . $v[0] . '%1$s' . $v[1] . '=%2$s', $matches[1][ $k2 ], $matches[2][ $k2 ] . $url2 . $matches[2][ $k2 ] ); } else { $html_snippet = sprintf( ' ' . $v[1] . '=%1$s', $matches[1][ $k2 ] . $url2 . $matches[1][ $k2 ] ); } $content = str_replace( $matches[0][ $k2 ], $html_snippet, $content ); } } // parse srcset. // todo: should apply this to cdn too. if ( ( defined( 'LITESPEED_GUEST_OPTM' ) || $this->conf( Base::O_IMG_OPTM_WEBP_REPLACE_SRCSET ) ) && $this->webp_support() ) { $content = Utility::srcset_replace( $content, [ $this, 'replace_webp' ] ); } // Replace background-image. if ( ( defined( 'LITESPEED_GUEST_OPTM' ) || $this->conf( Base::O_IMG_OPTM_WEBP ) ) && $this->webp_support() ) { $content = $this->replace_background_webp( $content ); } return $content; } /** * Replace background image in inline styles and JSON blobs. * * @since 4.0 * * @param string $content HTML content. * @return string Modified content. */ public function replace_background_webp( $content ) { self::debug2( 'Start replacing background WebP/AVIF.' ); // Handle Elementor's data-settings JSON encoded background-images. $content = $this->replace_urls_in_json( $content ); preg_match_all( '#url\(([^)]+)\)#iU', $content, $matches ); foreach ( $matches[1] as $k => $url ) { // Check if is a DATA-URI. if ( false !== strpos( $url, 'data:image' ) ) { continue; } /** * Support quotes in src `background-image: url('src')`. * * @since 2.9.3 */ $url = trim( $url, '\'"' ); // Fix Elementor's Slideshow unusual background images like style="background-image: url("https://xxxx.png");" if ( 0 === strpos( $url, '"' ) && '"' === substr( $url, -6 ) ) { $url = substr( $url, 6, -6 ); } $url2 = $this->replace_webp( $url ); if ( ! $url2 ) { continue; } $html_snippet = str_replace( $url, $url2, $matches[0][ $k ] ); $content = str_replace( $matches[0][ $k ], $html_snippet, $content ); } return $content; } /** * Replace images in json data settings attributes. * * @since 6.2 * * @param string $content HTML content to scan and modify. * @return string Modified content with replaced URLs inside JSON attributes. */ public function replace_urls_in_json( $content ) { $pattern = '/data-settings="(.*?)"/i'; $parent_class = $this; preg_match_all( $pattern, $content, $matches, PREG_SET_ORDER ); foreach ( $matches as $match ) { // Decode HTML entities in the JSON string. $json_string = html_entity_decode( $match[1] ); $json_data = \json_decode( $json_string, true ); if ( JSON_ERROR_NONE === json_last_error() && is_array( $json_data ) ) { $did_webp_replace = false; array_walk_recursive( $json_data, /** * Replace URLs in JSON data recursively. * * @param mixed $item Value (modified in place). * @param string $key Array key. */ function ( &$item, $key ) use ( &$did_webp_replace, $parent_class ) { if ( 'url' === $key ) { $item_image = $parent_class->replace_webp( $item ); if ( $item_image ) { $item = $item_image; $did_webp_replace = true; } } } ); if ( $did_webp_replace ) { $new_json_string = wp_json_encode( $json_data ); // Force double_encode; esc_attr() would leave a literal `"` intact and the browser decodes it back into a quote. $new_json_string = htmlspecialchars( $new_json_string, ENT_QUOTES, 'UTF-8', true ); // Replace the whole attribute, not the bare value, which may also appear in inline JS. Prefix sliced to keep original casing. $attr_prefix = substr( $match[0], 0, strlen( $match[0] ) - strlen( $match[1] ) - 1 ); $content = str_replace( $match[0], $attr_prefix . $new_json_string . '"', $content ); } } } return $content; } /** * Replace internal image src to webp or avif. * * @since 1.6.2 * @access public * * @param string $url Image URL. * @return string|false Replaced URL or false if not applicable. */ public function replace_webp( $url ) { if ( ! $this->webp_support() ) { self::debug2( 'No next generation format chosen in setting, bypassed' ); return false; } // Parse extension from URL path. $url_path = wp_parse_url( $url, PHP_URL_PATH ); $postfix = $url_path ? strtolower( pathinfo( $url_path, PATHINFO_EXTENSION ) ) : ''; // Only process image formats that can be converted to WebP/AVIF. if ( ! in_array( $postfix, [ 'jpg', 'jpeg', 'png', 'gif' ], true ) ) { self::debug2( 'not convertible format, bypassed' ); return false; } self::debug2( $this->_sys_format . ' replacing: ' . substr( $url, 0, 200 ) . ' [.' . $postfix . ']' ); /** * WebP/AVIF API hook. * NOTE: As $url may contain query strings, check filters which may parse_url before appending format. * * @since 2.9.5 * @see #751737 - API docs for WebP generation */ $ori_check = apply_filters( 'litespeed_media_check_ori', Utility::is_internal_file( $url ), $url ); if ( $ori_check ) { // check if has webp/avif file. $has_next = apply_filters( 'litespeed_media_check_webp', Utility::is_internal_file( $url, $this->_sys_format ), $url ); if ( $has_next ) { $url .= '.' . $this->_sys_format; } else { self::debug2( '-no WebP or AVIF file, bypassed' ); return false; } } else { self::debug2( '-no file, bypassed' ); return false; } self::debug2( '- replaced to: ' . $url ); return $url; } /** * Hook to wp_get_attachment_image_src. * * @since 1.6.2 * @access public * * @param array $img The URL, width, height array. * @return array */ public function webp_attach_img_src( $img ) { self::debug2( 'changing attach src: ' . $img[0] ); $url = $img ? $this->replace_webp( $img[0] ) : false; if ( $url ) { $img[0] = $url; } return $img; } /** * Try to replace img url. * * @since 1.6.2 * @access public * * @param string $url Image URL. * @return string */ public function webp_url( $url ) { $url2 = $url ? $this->replace_webp( $url ) : false; if ( $url2 ) { $url = $url2; } return $url; } /** * Hook to replace WP responsive images. * * @since 1.6.2 * @access public * * @param array $srcs Srcset array. * @return array */ public function webp_srcset( $srcs ) { if ( $srcs ) { foreach ( $srcs as $w => $data ) { $url = $this->replace_webp( $data['url'] ); if ( ! $url ) { continue; } $srcs[ $w ]['url'] = $url; } } return $srcs; } }