Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.80% covered (warning)
89.80%
88 / 98
25.00% covered (danger)
25.00%
2 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
LCP_Optimize_Img_Tag
89.80% covered (warning)
89.80%
88 / 98
25.00% covered (danger)
25.00%
2 / 8
54.87
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 optimize_buffer
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 optimize_image
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
9.21
 add_responsive_image_attributes
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 get_aspect_ratio
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 get_srcset
90.62% covered (success)
90.62%
29 / 32
0.00% covered (danger)
0.00%
0 / 1
16.21
 reduce_widths
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 get_sizes
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
8.09
1<?php
2
3namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
4
5use Automattic\Jetpack\Image_CDN\Image_CDN;
6use Automattic\Jetpack\Image_CDN\Image_CDN_Core;
7use WP_HTML_Tag_Processor;
8
9class 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}