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}