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}