Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
89.80% |
88 / 98 |
|
25.00% |
2 / 8 |
CRAP | |
0.00% |
0 / 1 |
| LCP_Optimize_Img_Tag | |
89.80% |
88 / 98 |
|
25.00% |
2 / 8 |
54.87 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| optimize_buffer | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
5.02 | |||
| optimize_image | |
86.36% |
19 / 22 |
|
0.00% |
0 / 1 |
9.21 | |||
| add_responsive_image_attributes | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
5.03 | |||
| get_aspect_ratio | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
5 | |||
| get_srcset | |
90.62% |
29 / 32 |
|
0.00% |
0 / 1 |
16.21 | |||
| reduce_widths | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
3.01 | |||
| get_sizes | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
8.09 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp; |
| 4 | |
| 5 | use Automattic\Jetpack\Image_CDN\Image_CDN; |
| 6 | use Automattic\Jetpack\Image_CDN\Image_CDN_Core; |
| 7 | use WP_HTML_Tag_Processor; |
| 8 | |
| 9 | class LCP_Optimize_Img_Tag { |
| 10 | |
| 11 | /** |
| 12 | * LCP data. |
| 13 | * |
| 14 | * @var array |
| 15 | */ |
| 16 | private $lcp_data; |
| 17 | |
| 18 | public function __construct( $lcp_data ) { |
| 19 | $this->lcp_data = $lcp_data; |
| 20 | } |
| 21 | |
| 22 | /** |
| 23 | * Optimize a viewport's LCP HTML. |
| 24 | * |
| 25 | * @param string $buffer The buffer/html to optimize. |
| 26 | * @return string The optimized buffer, or the original buffer if no optimization was needed |
| 27 | * |
| 28 | * @since 4.0.0 |
| 29 | */ |
| 30 | public function optimize_buffer( $buffer ) { |
| 31 | $optimization_util = new LCP_Optimization_Util( $this->lcp_data ); |
| 32 | if ( ! $optimization_util->can_optimize() ) { |
| 33 | return $buffer; |
| 34 | } |
| 35 | |
| 36 | // Defensive check to ensure the LCP HTML is not empty. |
| 37 | if ( empty( $this->lcp_data['html'] ) ) { |
| 38 | return $buffer; |
| 39 | } |
| 40 | |
| 41 | // Only optimize if the type is one we know how to handle. |
| 42 | if ( $this->lcp_data['type'] !== LCP::TYPE_IMAGE ) { |
| 43 | return $buffer; |
| 44 | } |
| 45 | |
| 46 | $buffer_processor = $optimization_util->find_element( $buffer ); |
| 47 | if ( ! $buffer_processor ) { |
| 48 | return $buffer; |
| 49 | } |
| 50 | |
| 51 | // Create the optimized tag with required attributes. |
| 52 | return $this->optimize_image( $buffer_processor ); |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * Optimize an image tag by adding required attributes. |
| 57 | * |
| 58 | * @param WP_HTML_Tag_Processor $buffer_processor The original HTML chunk of the page.. |
| 59 | * |
| 60 | * @return string The optimized buffer. |
| 61 | * |
| 62 | * @since 4.0.0 |
| 63 | */ |
| 64 | private function optimize_image( $buffer_processor ) { |
| 65 | // Check optimizations object for fetchpriority. |
| 66 | // If no optimizations object exists (old cloud response), default to applying. |
| 67 | $apply_fetchpriority = LCP_Optimization_Util::should_apply_optimization( $this->lcp_data, 'fetchpriority' ); |
| 68 | |
| 69 | if ( $apply_fetchpriority ) { |
| 70 | $buffer_processor->set_attribute( 'fetchpriority', 'high' ); |
| 71 | } |
| 72 | |
| 73 | // Check optimizations object for loading. |
| 74 | $apply_loading = LCP_Optimization_Util::should_apply_optimization( $this->lcp_data, 'loading' ); |
| 75 | |
| 76 | if ( $apply_loading ) { |
| 77 | $buffer_processor->set_attribute( 'loading', 'eager' ); |
| 78 | } |
| 79 | |
| 80 | $buffer_processor->set_attribute( 'data-jp-lcp-optimized', 'true' ); |
| 81 | |
| 82 | $image_url = $buffer_processor->get_attribute( 'src' ); |
| 83 | // Ensure the image URL is valid. |
| 84 | if ( ! wp_http_validate_url( $image_url ) ) { |
| 85 | return $buffer_processor->get_updated_html(); |
| 86 | } |
| 87 | |
| 88 | // If the image isn't photonized, it might be resized by WP. |
| 89 | // We need the original image URL, as we'll be generating |
| 90 | // the necessary sizes based on it. |
| 91 | if ( ! Image_CDN_Core::is_cdn_url( $image_url ) ) { |
| 92 | $image_url = Image_CDN::strip_image_dimensions_maybe( $image_url ); |
| 93 | } else { |
| 94 | // In case it's Photonized, we need to remove any size change arguments. |
| 95 | $image_url = remove_query_arg( array( 'w', 'h' ), $image_url ); |
| 96 | } |
| 97 | |
| 98 | // Additional validation after cleaning. |
| 99 | if ( ! wp_http_validate_url( $image_url ) ) { |
| 100 | return $buffer_processor->get_updated_html(); |
| 101 | } |
| 102 | |
| 103 | // Check optimizations object for cdnUrl. |
| 104 | $apply_cdn = LCP_Optimization_Util::should_apply_optimization( $this->lcp_data, 'cdnUrl' ); |
| 105 | |
| 106 | if ( $apply_cdn ) { |
| 107 | $buffer_processor->set_attribute( 'src', Image_CDN_Core::cdn_url( $image_url ) ); |
| 108 | } |
| 109 | |
| 110 | // Check optimizations object for srcset. |
| 111 | // The cloud sets srcset to false when custom focal points are detected |
| 112 | // (resize would use center-crop, losing the author's object-position). |
| 113 | $apply_srcset = LCP_Optimization_Util::should_apply_optimization( $this->lcp_data, 'srcset' ); |
| 114 | |
| 115 | // srcset uses CDN URLs internally (via Image_CDN_Core::cdn_url), so skip if CDN is disabled. |
| 116 | if ( $apply_srcset && $apply_cdn ) { |
| 117 | $this->add_responsive_image_attributes( $buffer_processor, $image_url ); |
| 118 | } |
| 119 | |
| 120 | return $buffer_processor->get_updated_html(); |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * Optimize an image tag by adding srcset and sizes attributes. |
| 125 | * |
| 126 | * @param WP_HTML_Tag_Processor $element The original image tag. |
| 127 | * @param string $image_url The image URL. |
| 128 | * @return WP_HTML_Tag_Processor The optimized image tag. |
| 129 | * |
| 130 | * @since 4.0.0 |
| 131 | */ |
| 132 | private function add_responsive_image_attributes( $element, $image_url ) { |
| 133 | // is_array() as well as empty(): empty( 'not-an-array' ) is false, so a string |
| 134 | // breakpoints field would reach the foreach in get_srcset() and get_sizes(). |
| 135 | if ( empty( $this->lcp_data['breakpoints'] ) || ! is_array( $this->lcp_data['breakpoints'] ) ) { |
| 136 | return $element; |
| 137 | } |
| 138 | |
| 139 | $srcset = $this->get_srcset( $image_url, $this->get_aspect_ratio( $element ) ); |
| 140 | if ( ! empty( $srcset ) ) { |
| 141 | $element->set_attribute( 'srcset', $srcset ); |
| 142 | } |
| 143 | |
| 144 | $sizes = $this->get_sizes(); |
| 145 | if ( ! empty( $sizes ) ) { |
| 146 | $element->set_attribute( 'sizes', $sizes ); |
| 147 | } |
| 148 | |
| 149 | return $element; |
| 150 | } |
| 151 | |
| 152 | /** |
| 153 | * Get the image's aspect ratio from its width and height attributes. |
| 154 | * |
| 155 | * @param WP_HTML_Tag_Processor $element The image tag. |
| 156 | * @return float|null Width divided by height, or null if either attribute is missing. |
| 157 | */ |
| 158 | private function get_aspect_ratio( $element ) { |
| 159 | $width = $element->get_attribute( 'width' ); |
| 160 | $height = $element->get_attribute( 'height' ); |
| 161 | |
| 162 | if ( ! is_numeric( $width ) || ! is_numeric( $height ) || (float) $width <= 0 || (float) $height <= 0 ) { |
| 163 | return null; |
| 164 | } |
| 165 | |
| 166 | return (float) $width / (float) $height; |
| 167 | } |
| 168 | |
| 169 | /** |
| 170 | * Get the srcsets for an image. |
| 171 | * |
| 172 | * @param string $original_url The original image URL. |
| 173 | * @param float|null $aspect_ratio The image's width divided by its height, if known. |
| 174 | * @return string The srcset for the image. |
| 175 | * |
| 176 | * @since 4.1.0 |
| 177 | */ |
| 178 | private function get_srcset( $original_url, $aspect_ratio = null ) { |
| 179 | $dimensions = array(); |
| 180 | foreach ( $this->lcp_data['breakpoints'] as $breakpoint ) { |
| 181 | // The field is optional; the background-image sibling has always carried |
| 182 | // this guard, and without it an absent one is a foreach() TypeError on |
| 183 | // wp_head. |
| 184 | if ( empty( $breakpoint['imageDimensions'] ) || ! is_array( $breakpoint['imageDimensions'] ) ) { |
| 185 | continue; |
| 186 | } |
| 187 | |
| 188 | foreach ( $breakpoint['imageDimensions'] as $breakpoint_dimensions ) { |
| 189 | if ( ! is_array( $breakpoint_dimensions ) ) { |
| 190 | continue; |
| 191 | } |
| 192 | |
| 193 | // isset() as well as is_numeric(): is_numeric( $absent ) is an undefined |
| 194 | // key warning before it is false. |
| 195 | if ( ! isset( $breakpoint_dimensions['width'] ) || ! isset( $breakpoint_dimensions['height'] ) ) { |
| 196 | continue; |
| 197 | } |
| 198 | |
| 199 | if ( ! is_numeric( $breakpoint_dimensions['width'] ) || ! is_numeric( $breakpoint_dimensions['height'] ) ) { |
| 200 | continue; |
| 201 | } |
| 202 | |
| 203 | $width = (int) $breakpoint_dimensions['width']; |
| 204 | $height = (int) $breakpoint_dimensions['height']; |
| 205 | |
| 206 | // Resize to the image's own proportions, covering the box: a box shaped differently |
| 207 | // (object-fit) would otherwise be cropped into the file, where CSS cannot undo it. |
| 208 | if ( $aspect_ratio ) { |
| 209 | $width = max( $width, (int) ceil( $height * $aspect_ratio ) ); |
| 210 | $height = (int) round( $width / $aspect_ratio ); |
| 211 | } |
| 212 | |
| 213 | $dimensions[ $width ] = $height; |
| 214 | |
| 215 | // If it's a Moto G Power, include a 1.75 DPR for accurate lighthouse representation of the optimized image. |
| 216 | if ( isset( $breakpoint['maxWidth'] ) && $breakpoint['maxWidth'] === 412 ) { |
| 217 | $dimensions[ (int) round( $width * 1.75 ) ] = round( $height * 1.75 ); |
| 218 | } |
| 219 | |
| 220 | // Include 2x DPR. |
| 221 | $dimensions[ $width * 2 ] = $height * 2; |
| 222 | |
| 223 | // If it's a mobile breakpoint, include 3x DPR. |
| 224 | if ( isset( $breakpoint['maxWidth'] ) && $breakpoint['maxWidth'] <= 480 ) { |
| 225 | $dimensions[ $width * 3 ] = $height * 3; |
| 226 | } |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | // Remove unnecessary widths to save some bytes in the HTML. |
| 231 | $reduced_widths = $this->reduce_widths( array_keys( $dimensions ) ); |
| 232 | |
| 233 | $srcset = array(); |
| 234 | foreach ( $reduced_widths as $width ) { |
| 235 | $srcset[] = Image_CDN_Core::cdn_url( |
| 236 | $original_url, |
| 237 | array( |
| 238 | 'resize' => array( $width, $dimensions[ $width ] ), |
| 239 | ) |
| 240 | ) . " {$width}w"; |
| 241 | } |
| 242 | |
| 243 | return implode( ', ', $srcset ); |
| 244 | } |
| 245 | |
| 246 | /** |
| 247 | * Remove any width if we have another higher width that is within 20px. |
| 248 | * |
| 249 | * @param array $widths The widths to reduce. |
| 250 | * @return array The reduced widths. |
| 251 | */ |
| 252 | private function reduce_widths( $widths ) { |
| 253 | rsort( $widths ); |
| 254 | $reduced_widths = array(); |
| 255 | $previous_width = 999999; |
| 256 | foreach ( $widths as $width ) { |
| 257 | if ( $previous_width - 20 < $width ) { |
| 258 | continue; |
| 259 | } |
| 260 | |
| 261 | $previous_width = $width; |
| 262 | $reduced_widths[] = $width; |
| 263 | } |
| 264 | return $reduced_widths; |
| 265 | } |
| 266 | |
| 267 | /** |
| 268 | * Get the sizes for an image. |
| 269 | * |
| 270 | * @return string The sizes for the image. |
| 271 | * |
| 272 | * @since 4.1.0 |
| 273 | */ |
| 274 | private function get_sizes() { |
| 275 | $sizes = array(); |
| 276 | foreach ( $this->lcp_data['breakpoints'] as $breakpoint ) { |
| 277 | // is_string() as well as isset(): an array widthValue is a TypeError from |
| 278 | // preg_match(), inside the output buffer callback. |
| 279 | if ( ! isset( $breakpoint['widthValue'] ) || ! is_string( $breakpoint['widthValue'] ) || ! preg_match( '/^[0-9]+(?:\.[0-9]+)?(?:px|vw)$/', $breakpoint['widthValue'] ) ) { |
| 280 | continue; |
| 281 | } |
| 282 | |
| 283 | // Make sure minWidth is valid if set. |
| 284 | if ( isset( $breakpoint['minWidth'] ) && ! is_numeric( $breakpoint['minWidth'] ) ) { |
| 285 | continue; |
| 286 | } |
| 287 | |
| 288 | $min_width_query = isset( $breakpoint['minWidth'] ) ? '(min-width: ' . $breakpoint['minWidth'] . 'px) ' : ''; |
| 289 | |
| 290 | $sizes[] = $min_width_query . $breakpoint['widthValue']; |
| 291 | } |
| 292 | |
| 293 | return implode( ', ', $sizes ); |
| 294 | } |
| 295 | } |