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