Files
servo/components/layout/replaced.rs
Shubham Gupta 77e4bfbcc4 Pass Request's URL for LargestContentfulPaint Entry (#42901)
Pass Request URL to `LargestContnetfulPaint` Entry

Testing: WPT Expectations Updated.
Fixes: #42902

---------

Signed-off-by: Shubham Gupta <shubham.gupta@chromium.org>
2026-03-02 20:35:37 +00:00

774 lines
29 KiB
Rust

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
use app_units::{Au, MAX_AU};
use base::id::{BrowsingContextId, PipelineId};
use data_url::DataUrl;
use embedder_traits::ViewportDetails;
use euclid::{Scale, Size2D};
use html5ever::local_name;
use layout_api::wrapper_traits::ThreadSafeLayoutNode;
use layout_api::{IFrameSize, LayoutImageDestination, SVGElementData};
use malloc_size_of_derive::MallocSizeOf;
use net_traits::image_cache::{Image, ImageOrMetadataAvailable, VectorImage};
use script::layout_dom::ServoThreadSafeLayoutNode;
use selectors::Element;
use servo_arc::Arc as ServoArc;
use servo_url::ServoUrl;
use style::Zero;
use style::attr::AttrValue;
use style::computed_values::object_fit::T as ObjectFit;
use style::logical_geometry::{Direction, WritingMode};
use style::properties::{ComputedValues, StyleBuilder};
use style::rule_cache::RuleCacheConditions;
use style::servo::url::ComputedUrl;
use style::stylesheets::container_rule::ContainerSizeQuery;
use style::values::CSSFloat;
use style::values::computed::image::Image as ComputedImage;
use style::values::computed::{Content, Context, ToComputedValue};
use style::values::generics::counters::{GenericContentItem, GenericContentItems};
use url::Url;
use webrender_api::ImageKey;
use crate::cell::ArcRefCell;
use crate::context::{LayoutContext, LayoutImageCacheResult};
use crate::dom::NodeExt;
use crate::fragment_tree::{
BaseFragment, BaseFragmentInfo, CollapsedBlockMargins, Fragment, IFrameFragment, ImageFragment,
};
use crate::geom::{LogicalVec2, PhysicalPoint, PhysicalRect, PhysicalSize};
use crate::layout_box_base::{CacheableLayoutResult, LayoutBoxBase};
use crate::sizing::{
ComputeInlineContentSizes, InlineContentSizesResult, LazySize, SizeConstraint,
};
use crate::style_ext::{AspectRatio, Clamp, ComputedValuesExt, LayoutStyle};
use crate::{ConstraintSpace, ContainingBlock};
#[derive(Debug, MallocSizeOf)]
pub(crate) struct ReplacedContents {
pub kind: ReplacedContentKind,
natural_size: NaturalSizes,
base_fragment_info: BaseFragmentInfo,
}
/// The natural dimensions of a replaced element, including a height, width, and
/// aspect ratio.
///
/// * Raster images always have an natural width and height, with 1 image pixel = 1px.
/// The natural ratio should be based on dividing those.
/// See <https://github.com/w3c/csswg-drafts/issues/4572> for the case where either is zero.
/// PNG specifically disallows this but I (SimonSapin) am not sure about other formats.
///
/// * Form controls have both natural width and height **but no natural ratio**.
/// See <https://github.com/w3c/csswg-drafts/issues/1044> and
/// <https://drafts.csswg.org/css-images/#natural-dimensions> “In general, […]”
///
/// * For SVG, see <https://svgwg.org/svg2-draft/coords.html#SizingSVGInCSS>
/// and again <https://github.com/w3c/csswg-drafts/issues/4572>.
///
/// * IFrames do not have natural width and height or natural ratio according
/// to <https://drafts.csswg.org/css-images/#intrinsic-dimensions>.
#[derive(Debug, MallocSizeOf)]
pub(crate) struct NaturalSizes {
pub width: Option<Au>,
pub height: Option<Au>,
pub ratio: Option<CSSFloat>,
}
impl NaturalSizes {
pub(crate) fn from_width_and_height(width: f32, height: f32) -> Self {
// https://drafts.csswg.org/css-images/#natural-aspect-ratio:
// "If an object has a degenerate natural aspect ratio (at least one part being
// zero or infinity), it is treated as having no natural aspect ratio.""
let ratio = if width.is_normal() && height.is_normal() {
Some(width / height)
} else {
None
};
Self {
width: Some(Au::from_f32_px(width)),
height: Some(Au::from_f32_px(height)),
ratio,
}
}
pub(crate) fn from_natural_size_in_dots(natural_size_in_dots: PhysicalSize<f64>) -> Self {
// FIXME: should 'image-resolution' (when implemented) be used *instead* of
// `script::dom::htmlimageelement::ImageRequest::current_pixel_density`?
// https://drafts.csswg.org/css-images-4/#the-image-resolution
let dppx = 1.0;
let width = natural_size_in_dots.width as f32 / dppx;
let height = natural_size_in_dots.height as f32 / dppx;
Self::from_width_and_height(width, height)
}
pub(crate) fn empty() -> Self {
Self {
width: None,
height: None,
ratio: None,
}
}
}
#[derive(Debug, MallocSizeOf)]
pub(crate) struct CanvasInfo {
pub source: Option<ImageKey>,
}
#[derive(Debug, MallocSizeOf)]
pub(crate) struct IFrameInfo {
pub pipeline_id: PipelineId,
pub browsing_context_id: BrowsingContextId,
}
#[derive(Debug, MallocSizeOf)]
pub(crate) struct ImageInfo {
pub image: Option<Image>,
pub showing_broken_image_icon: bool,
pub url: Option<ServoUrl>,
}
#[derive(Debug, MallocSizeOf)]
pub(crate) struct VideoInfo {
pub image_key: Option<ImageKey>,
}
#[derive(Debug, MallocSizeOf)]
pub(crate) enum ReplacedContentKind {
Image(ImageInfo),
IFrame(IFrameInfo),
Canvas(CanvasInfo),
Video(VideoInfo),
SVGElement(Option<VectorImage>),
Audio,
}
impl ReplacedContents {
pub fn for_element(
node: ServoThreadSafeLayoutNode<'_>,
context: &LayoutContext,
) -> Option<Self> {
if let Some(ref data_attribute_string) = node.as_typeless_object_with_data_attribute() {
if let Some(url) = try_to_parse_image_data_url(data_attribute_string) {
return Self::from_image_url(
node,
context,
&ComputedUrl::Valid(ServoArc::new(url)),
);
}
}
let (kind, natural_size) = {
if let Some((image_info, natural_size_in_dots)) = node.as_image() {
if let Some(content_image) = Self::from_content_property(node, context) {
return Some(content_image);
}
(
ReplacedContentKind::Image(image_info),
NaturalSizes::from_natural_size_in_dots(natural_size_in_dots),
)
} else if let Some((canvas_info, natural_size_in_dots)) = node.as_canvas() {
(
ReplacedContentKind::Canvas(canvas_info),
NaturalSizes::from_natural_size_in_dots(natural_size_in_dots),
)
} else if let Some((pipeline_id, browsing_context_id)) = node.as_iframe() {
(
ReplacedContentKind::IFrame(IFrameInfo {
pipeline_id,
browsing_context_id,
}),
NaturalSizes::empty(),
)
} else if let Some((video_info, natural_size_in_dots)) = node.as_video() {
(
ReplacedContentKind::Video(video_info),
natural_size_in_dots
.map_or_else(NaturalSizes::empty, NaturalSizes::from_natural_size_in_dots),
)
} else if let Some(svg_data) = node.as_svg() {
Self::svg_kind_size(svg_data, context, node)
} else if node
.as_html_element()
.is_some_and(|element| element.has_local_name(&local_name!("audio")))
{
let natural_size = NaturalSizes {
width: None,
// 40px is the height of the controls.
// See /components/script/resources/media-controls.css
height: Some(Au::from_px(40)),
ratio: None,
};
(ReplacedContentKind::Audio, natural_size)
} else {
return Self::from_content_property(node, context);
}
};
if let ReplacedContentKind::Image(ImageInfo {
image: Some(Image::Raster(ref image)),
..
}) = kind
{
context
.image_resolver
.handle_animated_image(node.opaque(), image.clone());
}
Some(Self {
kind,
natural_size,
base_fragment_info: node.into(),
})
}
fn svg_kind_size(
svg_data: SVGElementData,
context: &LayoutContext,
node: ServoThreadSafeLayoutNode<'_>,
) -> (ReplacedContentKind, NaturalSizes) {
let rule_cache_conditions = &mut RuleCacheConditions::default();
let parent_style = node.style(&context.style_context);
let style_builder = StyleBuilder::new(
context.style_context.stylist.device(),
Some(context.style_context.stylist),
Some(&parent_style),
None,
None,
false,
);
let to_computed_context = Context::new(
style_builder,
context.style_context.quirks_mode(),
rule_cache_conditions,
ContainerSizeQuery::none(),
);
let attr_to_computed = |attr_val: &AttrValue| {
if let AttrValue::Length(_, length) = attr_val {
length.to_computed_value(&to_computed_context)
} else {
None
}
};
let width = svg_data.width.and_then(attr_to_computed);
let height = svg_data.height.and_then(attr_to_computed);
let ratio = match (width, height) {
(Some(width), Some(height)) if !width.is_zero() && !height.is_zero() => {
Some(width.px() / height.px())
},
_ => svg_data.ratio_from_view_box(),
};
let natural_size = NaturalSizes {
width: width.map(|w| Au::from_f32_px(w.px())),
height: height.map(|h| Au::from_f32_px(h.px())),
ratio,
};
let svg_source = match svg_data.source {
None => {
// The SVGSVGElement is not yet serialized, so we add it to a list
// and hand it over to script to peform the serialization.
context
.image_resolver
.queue_svg_element_for_serialization(node);
None
},
// If `svg_source_result` is `Err()`, it means that the previous attempt
// had errored, then don't attempt to serialize again.
Some(svg_source_result) => svg_source_result.ok(),
};
let cached_image = svg_source.and_then(|svg_source| {
context
.image_resolver
.get_cached_image_for_url(
node.opaque(),
svg_source,
LayoutImageDestination::BoxTreeConstruction,
)
.ok()
});
let vector_image = cached_image.map(|image| match image {
Image::Vector(mut vector_image) => {
vector_image.svg_id = Some(svg_data.svg_id);
vector_image
},
_ => unreachable!("SVG element can't contain a raster image."),
});
(ReplacedContentKind::SVGElement(vector_image), natural_size)
}
fn from_content_property(
node: ServoThreadSafeLayoutNode<'_>,
context: &LayoutContext,
) -> Option<Self> {
// If the `content` property is a single image URL, non-replaced boxes
// and images get replaced with the given image.
if let Content::Items(GenericContentItems { items, .. }) =
node.style(&context.style_context).clone_content()
{
if let [GenericContentItem::Image(image)] = items.as_slice() {
// Invalid images are treated as zero-sized.
return Some(
Self::from_image(node, context, image)
.unwrap_or_else(|| Self::zero_sized_invalid_image(node)),
);
}
}
None
}
pub fn from_image_url(
node: ServoThreadSafeLayoutNode<'_>,
context: &LayoutContext,
image_url: &ComputedUrl,
) -> Option<Self> {
if let ComputedUrl::Valid(image_url) = image_url {
let (image, width, height) = match context.image_resolver.get_or_request_image_or_meta(
node.opaque(),
image_url.clone().into(),
LayoutImageDestination::BoxTreeConstruction,
) {
LayoutImageCacheResult::DataAvailable(img_or_meta) => match img_or_meta {
ImageOrMetadataAvailable::ImageAvailable { image, .. } => {
if let Image::Raster(image) = &image {
context
.image_resolver
.handle_animated_image(node.opaque(), image.clone());
}
let metadata = image.metadata();
(
Some(image.clone()),
metadata.width as f32,
metadata.height as f32,
)
},
ImageOrMetadataAvailable::MetadataAvailable(metadata, _id) => {
(None, metadata.width as f32, metadata.height as f32)
},
},
LayoutImageCacheResult::Pending | LayoutImageCacheResult::LoadError => return None,
};
return Some(Self {
kind: ReplacedContentKind::Image(ImageInfo {
image,
showing_broken_image_icon: false,
url: Some(image_url.clone().into()),
}),
natural_size: NaturalSizes::from_width_and_height(width, height),
base_fragment_info: node.into(),
});
}
None
}
pub fn from_image(
element: ServoThreadSafeLayoutNode<'_>,
context: &LayoutContext,
image: &ComputedImage,
) -> Option<Self> {
match image {
ComputedImage::Url(image_url) => Self::from_image_url(element, context, image_url),
_ => None, // TODO
}
}
pub(crate) fn zero_sized_invalid_image(node: ServoThreadSafeLayoutNode<'_>) -> Self {
Self {
kind: ReplacedContentKind::Image(ImageInfo {
image: None,
showing_broken_image_icon: false,
url: None,
}),
natural_size: NaturalSizes::from_width_and_height(0., 0.),
base_fragment_info: node.into(),
}
}
#[inline]
fn is_broken_image(&self) -> bool {
matches!(&self.kind, ReplacedContentKind::Image(image_info) if image_info.showing_broken_image_icon)
}
#[inline]
fn content_size(
&self,
axis: Direction,
preferred_aspect_ratio: Option<AspectRatio>,
get_size_in_opposite_axis: &dyn Fn() -> SizeConstraint,
get_fallback_size: &dyn Fn() -> Au,
) -> Au {
let Some(ratio) = preferred_aspect_ratio else {
return get_fallback_size();
};
let transfer = |size| ratio.compute_dependent_size(axis, size);
match get_size_in_opposite_axis() {
SizeConstraint::Definite(size) => transfer(size),
SizeConstraint::MinMax(min_size, max_size) => get_fallback_size()
.clamp_between_extremums(transfer(min_size), max_size.map(transfer)),
}
}
fn calculate_fragment_rect(
&self,
style: &ServoArc<ComputedValues>,
size: PhysicalSize<Au>,
) -> (PhysicalSize<Au>, PhysicalRect<Au>) {
if let ReplacedContentKind::Image(ImageInfo {
image: Some(Image::Raster(image)),
showing_broken_image_icon: true,
url: _,
}) = &self.kind
{
let size = Size2D::new(
Au::from_f32_px(image.metadata.width as f32),
Au::from_f32_px(image.metadata.height as f32),
)
.min(size);
return (PhysicalSize::zero(), size.into());
}
let natural_size = PhysicalSize::new(
self.natural_size.width.unwrap_or(size.width),
self.natural_size.height.unwrap_or(size.height),
);
let object_fit_size = self.natural_size.ratio.map_or(size, |width_over_height| {
let preserve_aspect_ratio_with_comparison =
|size: PhysicalSize<Au>, comparison: fn(&Au, &Au) -> bool| {
let candidate_width = size.height.scale_by(width_over_height);
if comparison(&candidate_width, &size.width) {
return PhysicalSize::new(candidate_width, size.height);
}
let candidate_height = size.width.scale_by(1. / width_over_height);
debug_assert!(comparison(&candidate_height, &size.height));
PhysicalSize::new(size.width, candidate_height)
};
match style.clone_object_fit() {
ObjectFit::Fill => size,
ObjectFit::Contain => preserve_aspect_ratio_with_comparison(size, PartialOrd::le),
ObjectFit::Cover => preserve_aspect_ratio_with_comparison(size, PartialOrd::ge),
ObjectFit::None => natural_size,
ObjectFit::ScaleDown => {
preserve_aspect_ratio_with_comparison(size.min(natural_size), PartialOrd::le)
},
}
});
let object_position = style.clone_object_position();
let horizontal_position = object_position
.horizontal
.to_used_value(size.width - object_fit_size.width);
let vertical_position = object_position
.vertical
.to_used_value(size.height - object_fit_size.height);
let object_position = PhysicalPoint::new(horizontal_position, vertical_position);
(
object_fit_size,
PhysicalRect::new(object_position, object_fit_size),
)
}
pub fn make_fragments(
&self,
layout_context: &LayoutContext,
style: &ServoArc<ComputedValues>,
size: PhysicalSize<Au>,
) -> Vec<Fragment> {
let (object_fit_size, rect) = self.calculate_fragment_rect(style, size);
let clip = PhysicalRect::new(PhysicalPoint::origin(), size);
let mut base = BaseFragment::new(self.base_fragment_info, style.clone().into(), rect);
match &self.kind {
ReplacedContentKind::Image(image_info) => image_info
.image
.as_ref()
.and_then(|image| match image {
Image::Raster(raster_image) => raster_image.id,
Image::Vector(vector_image) => {
let scale = layout_context.style_context.device_pixel_ratio();
let width = object_fit_size.width.scale_by(scale.0).to_px();
let height = object_fit_size.height.scale_by(scale.0).to_px();
let size = Size2D::new(width, height);
let tag = self.base_fragment_info.tag?;
layout_context
.image_resolver
.rasterize_vector_image(
vector_image.id,
size,
tag.node,
vector_image.svg_id.clone(),
)
.and_then(|i| i.id)
},
})
.map(|image_key| {
Fragment::Image(ArcRefCell::new(ImageFragment {
base,
clip,
image_key: Some(image_key),
showing_broken_image_icon: image_info.showing_broken_image_icon,
url: image_info.url.clone(),
}))
})
.into_iter()
.collect(),
ReplacedContentKind::Video(video_info) => {
vec![Fragment::Image(ArcRefCell::new(ImageFragment {
base,
clip,
image_key: video_info.image_key,
showing_broken_image_icon: false,
url: None,
}))]
},
ReplacedContentKind::IFrame(iframe) => {
let size = Size2D::new(rect.size.width.to_f32_px(), rect.size.height.to_f32_px());
let hidpi_scale_factor = layout_context.style_context.device_pixel_ratio();
layout_context.iframe_sizes.lock().insert(
iframe.browsing_context_id,
IFrameSize {
browsing_context_id: iframe.browsing_context_id,
pipeline_id: iframe.pipeline_id,
viewport_details: ViewportDetails {
size,
hidpi_scale_factor: Scale::new(hidpi_scale_factor.0),
},
},
);
vec![Fragment::IFrame(ArcRefCell::new(IFrameFragment {
base,
pipeline_id: iframe.pipeline_id,
}))]
},
ReplacedContentKind::Canvas(canvas_info) => {
if self.natural_size.width == Some(Au::zero()) ||
self.natural_size.height == Some(Au::zero())
{
return vec![];
}
let Some(image_key) = canvas_info.source else {
return vec![];
};
vec![Fragment::Image(ArcRefCell::new(ImageFragment {
base,
clip,
image_key: Some(image_key),
showing_broken_image_icon: false,
url: None,
}))]
},
ReplacedContentKind::SVGElement(vector_image) => {
let Some(vector_image) = vector_image else {
return vec![];
};
// TODO: This is incorrect if the SVG has a viewBox.
base.rect = PhysicalSize::new(
vector_image
.metadata
.width
.try_into()
.map_or(MAX_AU, Au::from_px),
vector_image
.metadata
.height
.try_into()
.map_or(MAX_AU, Au::from_px),
)
.into();
let scale = layout_context.style_context.device_pixel_ratio();
let raster_size = Size2D::new(
base.rect.size.width.scale_by(scale.0).to_px(),
base.rect.size.height.scale_by(scale.0).to_px(),
);
let tag = self.base_fragment_info.tag.unwrap();
layout_context
.image_resolver
.rasterize_vector_image(
vector_image.id,
raster_size,
tag.node,
vector_image.svg_id.clone(),
)
.and_then(|image| image.id)
.map(|image_key| {
Fragment::Image(ArcRefCell::new(ImageFragment {
base,
clip,
image_key: Some(image_key),
showing_broken_image_icon: false,
url: None,
}))
})
.into_iter()
.collect()
},
ReplacedContentKind::Audio => vec![],
}
}
pub(crate) fn preferred_aspect_ratio(
&self,
style: &ComputedValues,
padding_border_sums: &LogicalVec2<Au>,
) -> Option<AspectRatio> {
if matches!(self.kind, ReplacedContentKind::Audio) {
// This isn't specified, but other browsers don't support `aspect-ratio` on `<audio>`.
// See <https://phabricator.services.mozilla.com/D118245>
return None;
}
if self.is_broken_image() {
// This isn't specified, but when an image is broken, we should prefer to the aspect
// ratio from the style, rather than the aspect ratio from the broken image icon.
// Note that the broken image icon *does* affect the content size of the image
// though as we want the image to be as big as the icon if the size was not specified
// in the style.
style.preferred_aspect_ratio(None, padding_border_sums)
} else {
style.preferred_aspect_ratio(self.natural_size.ratio, padding_border_sums)
}
}
/// The inline size that would result from combining the natural size
/// and the default object size, but disregarding the specified size.
/// <https://drafts.csswg.org/css-images-3/#natural-dimensions>
/// <https://drafts.csswg.org/css-images-3/#default-object-size>
/// <https://drafts.csswg.org/css-images-3/#specified-size>
pub(crate) fn fallback_inline_size(&self, writing_mode: WritingMode) -> Au {
if writing_mode.is_horizontal() {
self.natural_size.width.unwrap_or_else(|| Au::from_px(300))
} else {
self.natural_size.height.unwrap_or_else(|| Au::from_px(150))
}
}
/// The block size that would result from combining the natural size
/// and the default object size, but disregarding the specified size.
/// <https://drafts.csswg.org/css-images-3/#natural-dimensions>
/// <https://drafts.csswg.org/css-images-3/#default-object-size>
/// <https://drafts.csswg.org/css-images-3/#specified-size>
pub(crate) fn fallback_block_size(&self, writing_mode: WritingMode) -> Au {
if writing_mode.is_horizontal() {
self.natural_size.height.unwrap_or_else(|| Au::from_px(150))
} else {
self.natural_size.width.unwrap_or_else(|| Au::from_px(300))
}
}
pub(crate) fn logical_natural_sizes(
&self,
writing_mode: WritingMode,
) -> LogicalVec2<Option<Au>> {
if writing_mode.is_horizontal() {
LogicalVec2 {
inline: self.natural_size.width,
block: self.natural_size.height,
}
} else {
LogicalVec2 {
inline: self.natural_size.height,
block: self.natural_size.width,
}
}
}
#[inline]
pub(crate) fn layout_style<'a>(&self, base: &'a LayoutBoxBase) -> LayoutStyle<'a> {
LayoutStyle::Default(&base.style)
}
pub(crate) fn layout(
&self,
layout_context: &LayoutContext,
containing_block_for_children: &ContainingBlock,
preferred_aspect_ratio: Option<AspectRatio>,
base: &LayoutBoxBase,
lazy_block_size: &LazySize,
) -> CacheableLayoutResult {
let writing_mode = base.style.writing_mode;
let inline_size = containing_block_for_children.size.inline;
let content_block_size = self.content_size(
Direction::Block,
preferred_aspect_ratio,
&|| SizeConstraint::Definite(inline_size),
&|| self.fallback_block_size(writing_mode),
);
let size = LogicalVec2 {
inline: inline_size,
block: lazy_block_size.resolve(|| content_block_size),
}
.to_physical_size(writing_mode);
CacheableLayoutResult {
baselines: Default::default(),
collapsible_margins_in_children: CollapsedBlockMargins::zero(),
content_block_size,
content_inline_size_for_table: None,
// The result doesn't depend on `containing_block_for_children.size.block`,
// but it depends on `lazy_block_size`, which is probably tied to that.
depends_on_block_constraints: true,
fragments: self.make_fragments(layout_context, &base.style, size),
specific_layout_info: None,
}
}
}
impl ComputeInlineContentSizes for ReplacedContents {
fn compute_inline_content_sizes(
&self,
_: &LayoutContext,
constraint_space: &ConstraintSpace,
) -> InlineContentSizesResult {
let inline_content_size = self.content_size(
Direction::Inline,
constraint_space.preferred_aspect_ratio,
&|| constraint_space.block_size,
&|| self.fallback_inline_size(constraint_space.style.writing_mode),
);
InlineContentSizesResult {
sizes: inline_content_size.into(),
depends_on_block_constraints: constraint_space.preferred_aspect_ratio.is_some(),
}
}
}
fn try_to_parse_image_data_url(string: &str) -> Option<Url> {
if !string.starts_with("data:") {
return None;
}
let data_url = DataUrl::process(string).ok()?;
let mime_type = data_url.mime_type();
if mime_type.type_ != "image" {
return None;
}
// TODO: Find a better way to test for supported image formats. Currently this type of check is
// repeated several places in Servo, but should be centralized somehow.
if !matches!(
mime_type.subtype.as_str(),
"png" | "jpeg" | "gif" | "webp" | "bmp" | "ico"
) {
return None;
}
Url::parse(string).ok()
}