Skip to main content

layout/display_list/
paint_timing_handler.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use std::collections::{HashMap, HashSet};
6
7use app_units::Au;
8use euclid::Rect;
9use layout_api::LCPCandidate;
10use servo_base::id::LCPCandidateID;
11use servo_geometry::FastLayoutTransform;
12use servo_url::ServoUrl;
13use style::dom::OpaqueNode;
14use webrender_api::units::{LayoutRect, LayoutSize};
15
16use crate::fragment_tree::Tag;
17use crate::query::transform_f32_rectangle;
18
19/// <https://w3c.github.io/paint-timing/#pending-image-record>
20/// Different struct from spec, but fulfulling the same purpose.
21struct PendingImageRecord {
22    /// The image element this record belongs to.
23    /// for <https://w3c.github.io/paint-timing/#pending-image-record-element>
24    tag: Option<Tag>,
25    /// The image rect (adjusted for object-fit/object-position).
26    bounds: LayoutRect,
27    /// The element's content box.
28    clip_rect: LayoutRect,
29    /// Cumulative transform to root space, computed at collection time.
30    transform: FastLayoutTransform,
31    /// The image URL. `None` for background images.
32    url: Option<ServoUrl>,
33    /// Intrinsic width, used for upscaling normalization.
34    natural_width: Option<Au>,
35    /// Intrinsic height, used for upscaling normalization.
36    natural_height: Option<Au>,
37}
38
39/// <https://w3c.github.io/paint-timing/#sec-recording-paint-timing>
40/// > Each Element has a set of owned text nodes, which is an ordered set of
41/// > Text nodes, initially empty.
42///
43/// This struct corresponds to an Element for accumulating set of owned text
44/// nodes by nearest ancestor box fragment's tag during display list building.
45struct TextRecord {
46    /// The tag of containing box fragment these texts belongs to.
47    tag: Tag,
48    /// <https://w3c.github.io/paint-timing/#set-of-owned-text-nodes>
49    /// Collection of border_boxes of all Text nodes accumulated
50    border_boxes: Vec<LayoutRect>,
51}
52
53enum LCPCandidateType<'a> {
54    Image(&'a PendingImageRecord),
55    Text,
56}
57
58pub(crate) struct PaintTimingHandler {
59    /// The rect of viewport.
60    viewport_rect: LayoutRect,
61    /// The document’s largest contentful paint size
62    lcp_size: f32,
63    /// Counter for generating unique LCP candidate UUIDs.
64    lcp_next_uuid: u64,
65    /// The LCP candidate, it may be a image or text.
66    lcp_candidate: Option<LCPCandidate>,
67    /// Flag to indicate if there is an update to LCP candidate.
68    /// This is used to avoid sending duplicate LCP candidates to `Paint`.
69    lcp_candidate_updated: bool,
70    /// The set of image nodes that have been reported as LCP candidates.
71    reported_image_nodes: HashSet<OpaqueNode>,
72    /// <https://w3c.github.io/paint-timing/#paintedImages>
73    painted_images: Vec<PendingImageRecord>,
74    /// The set of text nodes that have been reported as LCP candidates.
75    reported_text_nodes: HashSet<OpaqueNode>,
76    /// <https://w3c.github.io/paint-timing/#paintedTextNodes>
77    painted_text_nodes: HashMap<OpaqueNode, TextRecord>,
78}
79
80impl PaintTimingHandler {
81    pub(crate) fn new(viewport_size: LayoutSize) -> Self {
82        Self {
83            lcp_size: 0.0,
84            lcp_next_uuid: 0,
85            lcp_candidate: None,
86            lcp_candidate_updated: false,
87            viewport_rect: LayoutRect::from_size(viewport_size),
88            reported_image_nodes: HashSet::new(),
89            painted_images: Vec::new(),
90            reported_text_nodes: HashSet::new(),
91            painted_text_nodes: HashMap::new(),
92        }
93    }
94
95    #[allow(clippy::too_many_arguments)]
96    pub(crate) fn append_image_record(
97        &mut self,
98        tag: Option<Tag>,
99        bounds: LayoutRect,
100        clip_rect: LayoutRect,
101        transform: FastLayoutTransform,
102        url: Option<ServoUrl>,
103        natural_width: Option<Au>,
104        natural_height: Option<Au>,
105    ) {
106        self.painted_images.push(PendingImageRecord {
107            tag,
108            bounds,
109            clip_rect,
110            transform,
111            url,
112            natural_width,
113            natural_height,
114        });
115    }
116
117    pub(crate) fn accumulate_text_rect(
118        &mut self,
119        tag: Tag,
120        rect: LayoutRect,
121        transform: FastLayoutTransform,
122    ) {
123        let border_box = transform_f32_rectangle(rect.to_rect(), transform)
124            .unwrap_or_default()
125            .to_box2d();
126        self.painted_text_nodes
127            .entry(tag.node)
128            .and_modify(|record| record.border_boxes.push(border_box))
129            .or_insert(TextRecord {
130                tag,
131                border_boxes: vec![border_box],
132            });
133    }
134
135    // Returns true if has non-zero width and height values.
136    pub(crate) fn check_bounding_rect(&self, bounds: LayoutRect, clip_rect: LayoutRect) -> bool {
137        let clipped_rect = bounds
138            .intersection(&clip_rect)
139            .unwrap_or(LayoutRect::zero())
140            .to_rect();
141
142        let bounding_rect = clipped_rect
143            .intersection(&self.viewport_rect.to_rect().cast_unit())
144            .unwrap_or(Rect::zero());
145
146        !bounding_rect.is_empty()
147    }
148
149    /// <https://www.w3.org/TR/largest-contentful-paint/#sec-effective-visual-size>
150    fn effective_visual_size(
151        &self,
152        intersection_rect: LayoutRect,
153        candidate_type: LCPCandidateType<'_>,
154    ) -> Option<f32> {
155        // Step 1. Let width be intersectionRect's width, rounded up to the
156        // nearest integer.
157        // Step 2. Let height be intersectionRect's height, rounded up to the
158        // nearest integer.
159        // Step 3. Let size be width * height.
160        let mut size = intersection_rect.area();
161
162        // Step 4. Let root be document's browsing context's top-level browsing
163        // context's active document.
164        // Note: This is not needed as we already have the viewport rect.
165
166        // Step 5. Let rootWidth be root's visual viewport's width,
167        // excluding any scrollbars.
168        // Step 6. Let rootHeight be root's visual viewport's height excluding
169        // any scrollbars.
170        // Step 7. If size is equal to rootWidth times rootHeight, return null.
171        if size >= self.viewport_rect.area() {
172            return None;
173        }
174
175        // Step 8: If imageRequest is not null, run the following steps to
176        // adjust for image position and upscaling.
177        // Note: This is skipped for Text aka the case of null request from specs
178        if let LCPCandidateType::Image(record) = candidate_type {
179            // TODO Step 8.1: If imageRequest's response's content length in bytes
180            // is less than size * 0.004, then return null. (Not Implemented)
181
182            // Step 8.2: Let concreteDimensions be imageRequest's concrete object
183            // size within element.
184            // Step 8.3: Let visibleDimensions be concreteDimensions, adjusted for
185            // positioning by object-position or background-position and element's
186            // content box.
187            // Note: bounds are already adjusted for positioning and content box
188            let visible_dimensions = record
189                .bounds
190                .intersection(&record.clip_rect)
191                .unwrap_or(LayoutRect::zero());
192
193            // Step 8.4: Let clientContentRect be the smallest DOMRectReadOnly
194            // containing visibleDimensions with element's transforms applied.
195            let client_content_rect =
196                transform_f32_rectangle(visible_dimensions.to_rect(), record.transform)
197                    .unwrap_or_default();
198
199            // Step 8.5: Let intersectingClientContentRect be the intersection of
200            // clientContentRect with intersectionRect.
201            let intersecting_client_content_rect = client_content_rect
202                .intersection(&intersection_rect.to_rect())
203                .unwrap_or(Rect::zero());
204
205            // Step 8.6: Set width to intersectingClientContentRect's width,
206            // rounded up to the nearest integer.
207            // Step 8.7: Set height to intersectingClientContentRect's height,
208            // rounded up to the nearest integer.
209            // Step 8.8: Set size to width * height.
210            size = intersecting_client_content_rect.area();
211
212            // Step 8.9: Let naturalArea be imageRequest's natural width * imageRequest's natural height.
213            if let (Some(natural_width), Some(natural_height)) =
214                (record.natural_width, record.natural_height)
215            {
216                let natural_area = natural_width.to_f32_px() * natural_height.to_f32_px();
217
218                // Step 8.10: If naturalArea is 0, then return null.
219                if natural_area == 0.0 {
220                    return None;
221                }
222                // Step 8.11: Let boundingClientArea be clientContentRect's width *
223                // clientContentRect's height.
224                let bounding_client_area =
225                    client_content_rect.width() * client_content_rect.height();
226
227                // Step 8.12: Let scaleFactor be boundingClientArea / naturalArea.
228                let scale_factor = bounding_client_area / natural_area;
229
230                // Step 8.13: If scaleFactor is greater than 1, then divide size by scaleFactor.
231                if scale_factor > 1.0 {
232                    size /= scale_factor;
233                }
234            }
235        }
236
237        // Step 9: Return an effective visual size result with size set to size,
238        // width set to width, and height set to height.
239        Some(size)
240    }
241
242    /// <https://www.w3.org/TR/largest-contentful-paint/#compute-a-new-largest-contentful-paint-candidate>
243    #[servo_tracing::instrument(
244        name = "Compute New LCP Candidate",
245        skip_all,
246        fields(
247            image_count = self.painted_images.len(),
248            text_count = self.painted_text_nodes.len(),
249        )
250    )]
251    fn compute_new_lcp_candidate(&mut self) {
252        // Step 1. Let currentSize be currentCandidate’s size if
253        // currentCandidate is not null or 0 otherwise.
254        // Step 2. Let largestSize be currentSize.
255        let mut largest_size = self.lcp_size;
256
257        // Step 3. Let newCandidate be null.
258        let mut new_candidate = None;
259
260        // Step 4. For each record of paintedImages:
261        for record in std::mem::take(&mut self.painted_images) {
262            // Step 4.1. Let imageElement be record’s element.
263
264            // TODO Step 4.2. If imageElement is not exposed for paint timing,
265            // given document, continue.
266            // Step 4.3. Let intersectionRect be the value returned by the
267            // intersection rect algorithm using imageElement as the target
268            // and viewport as the root.
269            let intersection_rect =
270                transform_f32_rectangle(record.clip_rect.to_rect(), record.transform)
271                    .unwrap_or_default()
272                    .intersection(&self.viewport_rect.to_rect())
273                    .map(|rect| rect.to_box2d())
274                    .unwrap_or_default();
275
276            // Step 4.4. Let result be the effective visual size of imageElement
277            // given intersectionRect and record's request.
278            let result =
279                self.effective_visual_size(intersection_rect, LCPCandidateType::Image(&record));
280
281            // Step 4.5. If result is null, continue.
282            let Some(result) = result else {
283                continue;
284            };
285            // Step 4.6. If result's size is less than or equal to
286            // largestSize, continue.
287            if result <= largest_size {
288                continue;
289            }
290
291            // Step 4.7. Set largestSize to result’s size.
292            largest_size = result;
293
294            // Step 4.8. Set newCandidate to be a new largest contentful paint candidate ...
295            let uuid = self.lcp_next_uuid;
296            self.lcp_next_uuid += 1;
297            new_candidate = Some(LCPCandidate::new(
298                LCPCandidateID(uuid),
299                result as usize,
300                record.url,
301                record.tag.map(|tag| tag.node),
302            ));
303        }
304
305        // Step 5. For each textNode of paintedTextNodes,
306        for (_, record) in std::mem::take(&mut self.painted_text_nodes) {
307            // TODO Step 5.1. If textNode is not exposed for paint timing,
308            // given document, continue.
309            // TODO Step 5.2. If textNode has alpha channel value <=0 or
310            // opacity value <=0:
311            // Step 5.3. Let intersectionRect be the union of the border boxes of
312            // all Text nodes in textNode’s set of owned text nodes,
313            // intersected with the visual viewport.
314            let intersection_rect = record
315                .border_boxes
316                .into_iter()
317                .reduce(|a, b| a.union(&b))
318                .unwrap_or_default()
319                .intersection(&self.viewport_rect)
320                .unwrap_or_default();
321            // Step 5.4. Let result be the effective visual size of textNode
322            // given intersectionRect and null.
323            let result = self.effective_visual_size(intersection_rect, LCPCandidateType::Text);
324
325            // Step 5.5. If result is null, continue.
326            let Some(result) = result else {
327                continue;
328            };
329            // Step 5.6. If result's size is less than or equal to
330            // largestSize, continue.
331            if result <= largest_size {
332                continue;
333            }
334
335            // Step 5.7. Set largestSize to result’s size.
336            largest_size = result;
337
338            // Step 5.8. Set newCandidate to be a new largest contentful paint candidate ...
339            let uuid = self.lcp_next_uuid;
340            self.lcp_next_uuid += 1;
341            new_candidate = Some(LCPCandidate::new(
342                LCPCandidateID(uuid),
343                result as usize,
344                None,
345                Some(record.tag.node),
346            ));
347        }
348
349        // Step 6. If newCandidate is not null and currentSize is greater than 0:
350        // TODO Step 6.1. If newCandidate’s width minus currentCandidate’s
351        // width is less than or equal to 3, and newCandidate’s height minus
352        // currentCandidate’s height is less than or equal to 3, return null.
353        if new_candidate.is_some() {
354            self.lcp_size = largest_size;
355            self.lcp_candidate = new_candidate;
356            self.lcp_candidate_updated = true;
357        }
358
359        // Step 7. Return newCandidate.
360        // Note: We use flag lcp_candidate_updated for updating, needs revisit
361    }
362
363    /// <https://www.w3.org/TR/largest-contentful-paint/#sec-report-largest-contentful-paint>
364    fn report_largest_contentful_paint(&mut self, halt_lcp: bool) {
365        // Step 1. Let window be document’s relevant global object.
366        // Step 2. If either of window’s has dispatched scroll event or has
367        // dispatched input event is true, return.
368        if halt_lcp {
369            return;
370        }
371
372        // Step 3. Let newCandidate be the result of computing a new largest
373        // contentful paint candidate given document, paintedImages,
374        // paintedTextNodes, and document’s current largest contentful paint
375        // candidate.
376        self.compute_new_lcp_candidate();
377
378        // Step 4. If newCandidate is null, return.
379        // Step 5. Set document’s current largest contentful paint candidate to
380        // newCandidate.
381        // TODO: Make it return and store here following specs.
382        // Step 6. Let entry be the result of creating a LargestContentfulPaint
383        // entry with newCandidate, paintTimingInfo, and document.
384        // Step 7. Queue the PerformanceEntry entry.
385        // Note: Step 6-7 are handled in script.
386    }
387
388    /// <https://www.w3.org/TR/paint-timing/#mark-paint-timing>
389    #[servo_tracing::instrument(name = "Mark Paint Timing", skip_all, fields(halt_lcp = halt_lcp))]
390    pub(crate) fn mark_paint_timing(&mut self, halt_lcp: bool) {
391        // > From: <https://www.w3.org/TR/largest-contentful-paint/#sec-report-largest-contentful-paint>
392        // > Note: Each pending image record in paintedImages and text
393        // > element in paintedTextNodes will only be reported exactly
394        // > once, from mark paint timing, for the first paint where the
395        // > element is considered paintable (i.e. has opacity and
396        // > visibility) and contentful (i.e. image resource or blocking
397        // > fonts are sufficiently loaded).
398        self.painted_images.retain(|record| {
399            record
400                .tag
401                .is_none_or(|tag| self.reported_image_nodes.insert(tag.node))
402        });
403        self.painted_text_nodes
404            .retain(|node, _record| self.reported_text_nodes.insert(*node));
405
406        // Step 10. Let flushPaintTimings be the following steps:
407        // Step 10.3. Report largest contentful paint given document,
408        // paintTimingInfo, paintedImages and paintedTextNodes.
409        self.report_largest_contentful_paint(halt_lcp);
410    }
411
412    pub(crate) fn did_lcp_candidate_update(&self) -> bool {
413        self.lcp_candidate_updated
414    }
415
416    pub(crate) fn unset_lcp_candidate_updated(&mut self) {
417        self.lcp_candidate_updated = false;
418    }
419
420    pub(crate) fn largest_contentful_paint_candidate(&self) -> Option<LCPCandidate> {
421        self.lcp_candidate.clone()
422    }
423}