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 paint_api::display_list::PaintTimingReport;
11use servo_arc::Arc as ServoArc;
12use servo_base::id::LCPCandidateID;
13use servo_geometry::FastLayoutTransform;
14use servo_url::ServoUrl;
15use style::dom::OpaqueNode;
16use style::properties::ComputedValues;
17use webrender_api::units::{LayoutRect, LayoutSize};
18
19use crate::fragment_tree::Tag;
20use crate::query::transform_f32_rectangle;
21
22/// <https://w3c.github.io/paint-timing/#pending-image-record>
23/// Different struct from spec, but fulfulling the same purpose.
24struct PendingImageRecord {
25 /// The image element this record belongs to.
26 /// for <https://w3c.github.io/paint-timing/#pending-image-record-element>
27 tag: Option<Tag>,
28 /// The image rect (adjusted for object-fit/object-position).
29 bounds: LayoutRect,
30 /// The element's content box.
31 clip_rect: LayoutRect,
32 /// Cumulative transform to root space, computed at collection time.
33 transform: FastLayoutTransform,
34 /// The image URL. `None` for background images.
35 url: Option<ServoUrl>,
36 /// Intrinsic width, used for upscaling normalization.
37 natural_width: Option<Au>,
38 /// Intrinsic height, used for upscaling normalization.
39 natural_height: Option<Au>,
40}
41
42/// <https://w3c.github.io/paint-timing/#sec-recording-paint-timing>
43/// > Each Element has a set of owned text nodes, which is an ordered set of
44/// > Text nodes, initially empty.
45///
46/// This struct corresponds to an Element for accumulating set of owned text
47/// nodes by nearest ancestor box fragment's tag during display list building.
48struct TextRecord {
49 /// The tag of containing box fragment these texts belongs to.
50 tag: Tag,
51 /// <https://w3c.github.io/paint-timing/#set-of-owned-text-nodes>
52 /// Collection of border_boxes of all Text nodes accumulated
53 border_boxes: Vec<LayoutRect>,
54 /// The containing element's computed style
55 style: ServoArc<ComputedValues>,
56}
57
58enum LCPCandidateType<'a> {
59 Image(&'a PendingImageRecord),
60 Text,
61}
62
63pub(crate) struct PaintTimingHandler {
64 /// The rect of viewport.
65 viewport_rect: LayoutRect,
66 /// Whether the current display list contains paintable items.
67 is_document_paintable: bool,
68 /// Whether the current display list contains contentful items.
69 is_document_contentful: bool,
70 /// <https://www.w3.org/TR/paint-timing/#set-of-previously-reported-paints>
71 previously_reported_paints: PaintTimingReport,
72 /// Counter for generating unique LCP candidate UUIDs.
73 lcp_next_uuid: u64,
74 /// The LCP candidate, it may be a image or text.
75 lcp_candidate: Option<LCPCandidate>,
76 /// The set of image nodes that have been reported as LCP candidates.
77 reported_image_nodes: HashSet<OpaqueNode>,
78 /// <https://www.w3.org/TR/paint-timing/#images-pending-rendering>
79 images_pending_rendering: Vec<PendingImageRecord>,
80 /// <https://www.w3.org/TR/paint-timing/#set-of-elements-with-rendered-text>
81 /// The set of text nodes that have been reported as LCP candidates.
82 elements_with_rendered_text: HashSet<OpaqueNode>,
83 /// The set of pending text nodes that will fight for LCP candidate.
84 elements_with_pending_rendered_text: HashMap<OpaqueNode, TextRecord>,
85}
86
87impl PaintTimingHandler {
88 pub(crate) fn new(viewport_size: LayoutSize) -> Self {
89 Self {
90 is_document_paintable: false,
91 is_document_contentful: false,
92 previously_reported_paints: PaintTimingReport::default(),
93 lcp_next_uuid: 0,
94 lcp_candidate: None,
95 viewport_rect: LayoutRect::from_size(viewport_size),
96 reported_image_nodes: HashSet::new(),
97 images_pending_rendering: Vec::new(),
98 elements_with_rendered_text: HashSet::new(),
99 elements_with_pending_rendered_text: HashMap::new(),
100 }
101 }
102
103 /// Marks the current display list as containing a paintable item.
104 pub(crate) fn mark_document_is_paintable(&mut self) {
105 self.is_document_paintable = true;
106 }
107
108 /// Marks the current display list as containing a contentful item.
109 pub(crate) fn mark_document_is_contentful(&mut self) {
110 self.is_document_contentful = true;
111 }
112
113 #[expect(clippy::too_many_arguments)]
114 pub(crate) fn append_image_record(
115 &mut self,
116 tag: Option<Tag>,
117 bounds: LayoutRect,
118 clip_rect: LayoutRect,
119 transform: FastLayoutTransform,
120 url: Option<ServoUrl>,
121 natural_width: Option<Au>,
122 natural_height: Option<Au>,
123 ) {
124 self.images_pending_rendering.push(PendingImageRecord {
125 tag,
126 bounds,
127 clip_rect,
128 transform,
129 url,
130 natural_width,
131 natural_height,
132 });
133 }
134
135 pub(crate) fn accumulate_text_rect(
136 &mut self,
137 tag: Tag,
138 rect: LayoutRect,
139 transform: FastLayoutTransform,
140 style: &ServoArc<ComputedValues>,
141 ) {
142 let border_box = transform_f32_rectangle(rect.to_rect(), transform)
143 .unwrap_or_default()
144 .to_box2d();
145 self.elements_with_pending_rendered_text
146 .entry(tag.node)
147 .and_modify(|record| {
148 record.border_boxes.push(border_box);
149 })
150 .or_insert(TextRecord {
151 tag,
152 border_boxes: vec![border_box],
153 style: ServoArc::clone(style),
154 });
155 }
156
157 /// <https://www.w3.org/TR/paint-timing/#paintable-bounding-rect>
158 fn paintable_bounding_rect(&self, bounds: LayoutRect) -> LayoutRect {
159 bounds.intersection(&self.viewport_rect).unwrap_or_default()
160 }
161
162 /// <https://www.w3.org/TR/paint-timing/#paintable>
163 pub(crate) fn paintable(&self, bounds: LayoutRect, opacity: f32) -> bool {
164 // An element el is paintable when all of the following apply:
165 // > el is being rendered.
166 // > el’s used visibility is visible.
167 // Note: Above conditions are met, as we selectively call this API.
168
169 // > el and all of its ancestors' used opacity is greater than zero.
170 if opacity <= 0.0 {
171 return false;
172 }
173
174 // > el’s paintable bounding rect intersects with the scrolling area of the document.
175 !self.paintable_bounding_rect(bounds).is_empty()
176 }
177
178 /// Marks wether the document is having paintable element
179 ///
180 /// <https://www.w3.org/TR/paint-timing/#paintable>
181 pub(crate) fn check_if_paintable(&mut self, bounds: LayoutRect, opacity: f32) {
182 if self.paintable(bounds, opacity) {
183 self.mark_document_is_paintable();
184 }
185 }
186
187 /// <https://www.w3.org/TR/largest-contentful-paint/#sec-effective-visual-size>
188 fn effective_visual_size(
189 &self,
190 intersection_rect: LayoutRect,
191 candidate_type: LCPCandidateType<'_>,
192 ) -> Option<f32> {
193 // Step 1. Let width be intersectionRect's width, rounded up to the
194 // nearest integer.
195 // Step 2. Let height be intersectionRect's height, rounded up to the
196 // nearest integer.
197 // Step 3. Let size be width * height.
198 let mut size = intersection_rect.area();
199
200 // Step 4. Let root be document's browsing context's top-level browsing
201 // context's active document.
202 // Note: This is not needed as we already have the viewport rect.
203
204 // Step 5. Let rootWidth be root's visual viewport's width,
205 // excluding any scrollbars.
206 // Step 6. Let rootHeight be root's visual viewport's height excluding
207 // any scrollbars.
208 // Step 7. If size is equal to rootWidth times rootHeight, return null.
209 if size >= self.viewport_rect.area() {
210 return None;
211 }
212
213 // Step 8: If imageRequest is not null, run the following steps to
214 // adjust for image position and upscaling.
215 // Note: This is skipped for Text aka the case of null request from specs
216 if let LCPCandidateType::Image(record) = candidate_type {
217 // TODO Step 8.1: If imageRequest's response's content length in bytes
218 // is less than size * 0.004, then return null. (Not Implemented)
219
220 // Step 8.2: Let concreteDimensions be imageRequest's concrete object
221 // size within element.
222 // Step 8.3: Let visibleDimensions be concreteDimensions, adjusted for
223 // positioning by object-position or background-position and element's
224 // content box.
225 // Note: bounds are already adjusted for positioning and content box
226 let visible_dimensions = record
227 .bounds
228 .intersection(&record.clip_rect)
229 .unwrap_or(LayoutRect::zero());
230
231 // Step 8.4: Let clientContentRect be the smallest DOMRectReadOnly
232 // containing visibleDimensions with element's transforms applied.
233 let client_content_rect =
234 transform_f32_rectangle(visible_dimensions.to_rect(), record.transform)
235 .unwrap_or_default();
236
237 // Step 8.5: Let intersectingClientContentRect be the intersection of
238 // clientContentRect with intersectionRect.
239 let intersecting_client_content_rect = client_content_rect
240 .intersection(&intersection_rect.to_rect())
241 .unwrap_or(Rect::zero());
242
243 // Step 8.6: Set width to intersectingClientContentRect's width,
244 // rounded up to the nearest integer.
245 // Step 8.7: Set height to intersectingClientContentRect's height,
246 // rounded up to the nearest integer.
247 // Step 8.8: Set size to width * height.
248 size = intersecting_client_content_rect.area();
249
250 // Step 8.9: Let naturalArea be imageRequest's natural width * imageRequest's natural height.
251 if let (Some(natural_width), Some(natural_height)) =
252 (record.natural_width, record.natural_height)
253 {
254 let natural_area = natural_width.to_f32_px() * natural_height.to_f32_px();
255
256 // Step 8.10: If naturalArea is 0, then return null.
257 if natural_area == 0.0 {
258 return None;
259 }
260 // Step 8.11: Let boundingClientArea be clientContentRect's width *
261 // clientContentRect's height.
262 let bounding_client_area =
263 client_content_rect.width() * client_content_rect.height();
264
265 // Step 8.12: Let scaleFactor be boundingClientArea / naturalArea.
266 let scale_factor = bounding_client_area / natural_area;
267
268 // Step 8.13: If scaleFactor is greater than 1, then divide size by scaleFactor.
269 if scale_factor > 1.0 {
270 size /= scale_factor;
271 }
272 }
273 }
274
275 // Step 9: Return an effective visual size result with size set to size,
276 // width set to width, and height set to height.
277 Some(size)
278 }
279
280 /// <https://www.w3.org/TR/largest-contentful-paint/#compute-a-new-largest-contentful-paint-candidate>
281 #[servo_tracing::instrument(
282 name = "Compute New LCP Candidate",
283 skip_all,
284 fields(
285 image_count = painted_images.len(),
286 text_count = painted_text_nodes.len(),
287 )
288 )]
289 fn compute_new_lcp_candidate(
290 &mut self,
291 painted_images: Vec<PendingImageRecord>,
292 painted_text_nodes: HashMap<OpaqueNode, TextRecord>,
293 ) -> Option<LCPCandidate> {
294 // Step 1. Let currentSize be currentCandidate’s size if
295 // currentCandidate is not null or 0 otherwise.
296 // Step 2. Let largestSize be currentSize.
297 let mut largest_size = self
298 .lcp_candidate
299 .as_ref()
300 .map_or(0.0, |candidate| candidate.area as f32);
301
302 // Step 3. Let newCandidate be null.
303 let mut new_candidate = None;
304
305 // Step 4. For each record of paintedImages:
306 for record in painted_images {
307 // Step 4.1. Let imageElement be record’s element.
308
309 // Step 4.2. If imageElement is not exposed for paint timing, given
310 // document, continue.
311 // Note: Satisfied, as the display-list builder only visits the
312 // connected DOM tree of the fully-active document being laid out.
313
314 // Step 4.3. Let intersectionRect be the value returned by the
315 // intersection rect algorithm using imageElement as the target
316 // and viewport as the root.
317 let intersection_rect =
318 transform_f32_rectangle(record.clip_rect.to_rect(), record.transform)
319 .unwrap_or_default()
320 .intersection(&self.viewport_rect.to_rect())
321 .map(|rect| rect.to_box2d())
322 .unwrap_or_default();
323
324 // Step 4.4. Let result be the effective visual size of imageElement
325 // given intersectionRect and record's request.
326 let result =
327 self.effective_visual_size(intersection_rect, LCPCandidateType::Image(&record));
328
329 // Step 4.5. If result is null, continue.
330 let Some(result) = result else {
331 continue;
332 };
333 // Step 4.6. If result's size is less than or equal to
334 // largestSize, continue.
335 if result <= largest_size {
336 continue;
337 }
338
339 // Step 4.7. Set largestSize to result’s size.
340 largest_size = result;
341
342 // Step 4.8. Set newCandidate to be a new largest contentful paint candidate ...
343 let uuid = self.lcp_next_uuid;
344 self.lcp_next_uuid += 1;
345 new_candidate = Some(LCPCandidate::new(
346 LCPCandidateID(uuid),
347 result as usize,
348 record.url,
349 record.tag.map(|tag| tag.node),
350 ));
351 }
352
353 // Step 5. For each textNode of paintedTextNodes,
354 for (_, record) in painted_text_nodes {
355 // Step 5.1. If textNode is not exposed for paint timing, given
356 // document, continue.
357 // Note: Satisfied, as the display-list builder only visits the
358 // connected DOM tree of the fully-active document being laid out.
359
360 // Step 5.2. If textNode has alpha channel value <=0 or opacity
361 // value <=0:
362 if record.style.get_color().alpha <= 0.0 || record.style.slow_clone_opacity() <= 0.0 {
363 // Step 5.2.1. If textNode's text-shadow value is none,
364 // textNode's stroke-color value is transparent and textNode's
365 // stroke-image value is none, continue.
366 // TODO: Update when we implement the `stroke-color`/`stroke-image`
367 // properties, as of now they are default (`transparent`/`none`)
368 if record.style.get_inherited_text().text_shadow.0.is_empty() {
369 continue;
370 }
371 }
372 // Step 5.3. Let intersectionRect be the union of the border boxes of
373 // all Text nodes in textNode’s set of owned text nodes,
374 // intersected with the visual viewport.
375 let intersection_rect = record
376 .border_boxes
377 .into_iter()
378 .reduce(|a, b| a.union(&b))
379 .unwrap_or_default()
380 .intersection(&self.viewport_rect)
381 .unwrap_or_default();
382 // Step 5.4. Let result be the effective visual size of textNode
383 // given intersectionRect and null.
384 let result = self.effective_visual_size(intersection_rect, LCPCandidateType::Text);
385
386 // Step 5.5. If result is null, continue.
387 let Some(result) = result else {
388 continue;
389 };
390 // Step 5.6. If result's size is less than or equal to
391 // largestSize, continue.
392 if result <= largest_size {
393 continue;
394 }
395
396 // Step 5.7. Set largestSize to result’s size.
397 largest_size = result;
398
399 // Step 5.8. Set newCandidate to be a new largest contentful paint candidate ...
400 let uuid = self.lcp_next_uuid;
401 self.lcp_next_uuid += 1;
402 new_candidate = Some(LCPCandidate::new(
403 LCPCandidateID(uuid),
404 result as usize,
405 None,
406 Some(record.tag.node),
407 ));
408 }
409
410 // TODO Step 6. If newCandidate is not null and currentSize is greater than 0:
411 // TODO Step 6.1. If newCandidate’s width minus currentCandidate’s
412 // width is less than or equal to 3, and newCandidate’s height minus
413 // currentCandidate’s height is less than or equal to 3, return null.
414
415 // Step 7. Return newCandidate.
416 new_candidate
417 }
418
419 /// <https://www.w3.org/TR/largest-contentful-paint/#sec-report-largest-contentful-paint>
420 fn report_largest_contentful_paint(
421 &mut self,
422 halt_lcp: bool,
423 painted_images: Vec<PendingImageRecord>,
424 painted_text_nodes: HashMap<OpaqueNode, TextRecord>,
425 ) {
426 // Step 1. Let window be document’s relevant global object.
427 // Step 2. If either of window’s has dispatched scroll event or has
428 // dispatched input event is true, return.
429 if halt_lcp {
430 return;
431 }
432
433 // Step 3. Let newCandidate be the result of computing a new largest
434 // contentful paint candidate given document, paintedImages,
435 // paintedTextNodes, and document’s current largest contentful paint
436 // candidate.
437 self.lcp_candidate = self.compute_new_lcp_candidate(painted_images, painted_text_nodes);
438
439 // Step 4. If newCandidate is null, return.
440 // Step 5. Set document’s current largest contentful paint candidate to
441 // newCandidate.
442 // Note: Step 4-5 are fulfilled by the assignment above, as the new
443 // candidate wether `None or Some` is computed and assigned to the
444 // current candidate.
445
446 // Step 6. Let entry be the result of creating a LargestContentfulPaint
447 // entry with newCandidate, paintTimingInfo, and document.
448 // Step 7. Queue the PerformanceEntry entry.
449 // Note: Step 6-7 are handled in script.
450 }
451
452 /// <https://www.w3.org/TR/paint-timing/#first-paint>
453 fn should_report_first_paint(&self) -> bool {
454 // Step 1. If document's set of previously reported paints contains
455 // "first-paint", then return false.
456 if self
457 .previously_reported_paints
458 .contains(PaintTimingReport::FirstPaint)
459 {
460 return false;
461 }
462 // Step 2. If document contains at least one element that is
463 // paintable, then return true.
464 // Step 3. Otherwise, return false.
465 self.is_document_paintable
466 }
467
468 /// <https://www.w3.org/TR/paint-timing/#first-contentful-paint>
469 fn should_report_first_contentful_paint(&self) -> bool {
470 // Step 1. If document's set of previously reported paints contains
471 // "first-contentful-paint", then return false.
472 if self
473 .previously_reported_paints
474 .contains(PaintTimingReport::FirstContentfulPaint)
475 {
476 return false;
477 }
478 // Step 2. If document contains at least one element that is both
479 // paintable and contentful, then return true.
480 // Step 3. Otherwise, return false.
481 self.is_document_paintable && self.is_document_contentful
482 }
483
484 /// <https://www.w3.org/TR/paint-timing/#mark-paint-timing>
485 ///
486 /// Note: Step 10 is not in the current version of the specifications.
487 /// Refer <https://github.com/w3c/paint-timing/issues/122> for details on
488 /// the issue and for the modified steps yet to be merged.
489 #[servo_tracing::instrument(name = "Mark Paint Timing", skip_all, fields(halt_lcp = halt_lcp))]
490 pub(crate) fn mark_paint_timing(
491 &mut self,
492 paint_timing_eligible: bool,
493 halt_lcp: bool,
494 ) -> PaintTimingReport {
495 // Step 1. If the document's browsing context is not paint-timing
496 // eligible, return.
497 if !paint_timing_eligible {
498 return PaintTimingReport::default();
499 }
500
501 // Step 2. Let paintTimingInfo be a new paint timing info, whose
502 // rendering update end time is the current high resolution time given
503 // document's relevant global object.
504 // Note: This is satisfied in the script thread.
505
506 // Step 3. Let paintedImages be a new ordered set.
507 // Step 4. Let paintedTextNodes be a new ordered set.
508
509 // Step 5. For each record in doc's images pending rendering list:
510 // Step 5.1. If record's request is available and ready to be painted,
511 // then run the following steps:
512 // Note: Only available images are accumulated, hence it is fulfilled.
513 // Step 5.1.1. Append record to paintedImages.
514 // Step 5.1.2. Remove record from doc's images pending rendering list.
515 let painted_images: Vec<_> = std::mem::take(&mut self.images_pending_rendering)
516 .into_iter()
517 .filter(|record| {
518 record
519 .tag
520 .is_none_or(|tag| self.reported_image_nodes.insert(tag.node))
521 })
522 .collect();
523
524 // Step 6. For each Element element in doc's descendants:
525 // Step 6.1. If element is contained in doc's set of elements with
526 // rendered text, continue.
527 // Step 6.2. If element's set of owned text nodes is empty, continue.
528 // Step 6.3. Append element to doc's set of elements with rendered text.
529 // Step 6.4. Append element to paintedTextNodes.
530 let painted_text_nodes: HashMap<_, _> =
531 std::mem::take(&mut self.elements_with_pending_rendered_text)
532 .into_iter()
533 .filter(|(node, _record)| self.elements_with_rendered_text.insert(*node))
534 .collect();
535
536 // Step 7. Let reportedPaints be the document’s set of previously
537 // reported paints. (Directly accessing)
538
539 // TODO Step 8. Let frameTimingInfo be document’s current frame timing info.
540 // TODO Step 9. Set document’s current frame timing info to null.
541
542 // Step 10. Let flushPaintTimings be the following steps:
543
544 // Note: A new PaintTimingReport to accumulate the paints.
545 let mut paint_timing_report = PaintTimingReport::default();
546
547 // Step 10.1. If document should report first paint, then:
548 // Note: This step is not in the current version of the specifications,
549 // are waiting to be merged at w3c/paint-timing#123.
550 if self.should_report_first_paint() {
551 // Step 10.1.1. Report paint timing given document, "first-paint",
552 // and paintTimingInfo.
553 paint_timing_report.insert(PaintTimingReport::FirstPaint);
554 }
555
556 // Step 10.2. If document should report first contentful paint, then:
557 if self.should_report_first_contentful_paint() {
558 // Step 10.2.1. Report paint timing given document,
559 // "first-contentful-paint", and paintTimingInfo.
560 paint_timing_report.insert(PaintTimingReport::FirstContentfulPaint);
561 }
562
563 // Step 10.3. Report largest contentful paint given document,
564 // paintTimingInfo, paintedImages and paintedTextNodes.
565 self.report_largest_contentful_paint(halt_lcp, painted_images, painted_text_nodes);
566
567 // Note: Append the newly reported paints aka [`PaintTimingReport`] to
568 // the document's set of previously reported paints.
569 self.previously_reported_paints |= paint_timing_report;
570
571 paint_timing_report
572 }
573
574 pub(crate) fn largest_contentful_paint_candidate(&self) -> Option<LCPCandidate> {
575 self.lcp_candidate.clone()
576 }
577}