Correct ratio-only replaced element sizing

This commit is contained in:
SGavrl committed 2026-08-04 09:54:56 +00:00
1 parent 99ff0601bf
commit 6d0f884ee0
7 files changed
+316 -13

No files matched your search

+175
View File
@@ -5317,6 +5317,33 @@ fn layout_dom_once(
// subtree (nextjs.org's nav stretched to a full 688px, burying the
// hero), so it is gone.
// Capture the definite containing width for ratio-only auto/auto
// replaced boxes before installing their metadata. The inline layout
// stand-in asks atomic items for max-content before its own percentage
// width becomes definite; Gecko instead resolves this case directly
// from the containing block's content width.
let ratio_only_available_widths: HashMap<NodeId, f32> = intrinsic
.iter()
.filter_map(|(&nid, metadata)| {
let style = styles.get(&nid)?;
(metadata.width.is_none()
&& metadata.height.is_none()
&& metadata.ratio.is_some()
&& style.width == crate::Dimension::Auto
&& style.height == crate::Dimension::Auto)
.then(|| {
reliable_ratio_only_available_width(
tree,
nid,
&styles,
initial_cb_width,
)
.map(|width| (nid, width))
})
.flatten()
})
.collect();
// Apply fetched intrinsic image sizes. A replaced element with no
// explicit dimensions must size from its intrinsic pixels (else it is
// 0x0 and never paints); with one dimension given, the aspect ratio
@@ -5329,6 +5356,7 @@ fn layout_dom_once(
if let Some(s) = styles.get_mut(&nid) {
s.intrinsic_size = metadata.natural_size();
s.replaced_intrinsic = Some(metadata);
s.ratio_only_available_width = ratio_only_available_widths.get(&nid).copied();
if (s.aspect_ratio.is_none() || s.aspect_ratio_is_mapped)
&& metadata.ratio.is_some()
{
@@ -8620,6 +8648,7 @@ fn is_flattenable_inline(
};
style.display == crate::Display::Inline
&& !style.is_inline_block
&& !style.is_replaced_box
&& style.before_pseudo.is_none()
&& style.after_pseudo.is_none()
&& style.background_color.is_none()
@@ -9219,6 +9248,152 @@ fn reliable_normal_flow_content_width(
Some(content_width.max(0.0))
}
/// Resolve an authored definite width even when the box's placement is owned
/// by float or positioned layout. A pixel width is independent of placement;
/// a percentage is definite only when the containing block can itself be
/// resolved without layout. Auto widths and functional size expressions stay
/// on the ordinary Taffy path.
fn reliable_declared_content_width(
tree: &DomTree,
id: NodeId,
styles: &HashMap<NodeId, crate::LayoutStyle>,
initial_cb_width: f32,
depth: usize,
) -> Option<f32> {
if depth > 4096 {
return None;
}
let style = styles.get(&id)?;
if style.width_fit_content
|| style.size_expressions[0].is_some()
|| style.size_expressions[2].is_some()
|| style.size_expressions[4].is_some()
{
return None;
}
let needs_containing_width = matches!(
style.width,
crate::Dimension::Percent(_)
) || matches!(style.min_width, crate::Dimension::Percent(_))
|| matches!(style.max_width, crate::Dimension::Percent(_));
let containing_width = needs_containing_width.then(|| {
let mut parent = tree.get_node(id).and_then(|node| node.parent);
while parent.is_some_and(|parent_id| {
styles
.get(&parent_id)
.is_some_and(|parent_style| parent_style.display_contents)
}) {
parent = parent
.and_then(|parent_id| tree.get_node(parent_id).and_then(|node| node.parent));
}
if let Some(parent_id) = parent {
reliable_normal_flow_content_width(
tree,
parent_id,
styles,
initial_cb_width,
depth + 1,
)
.or_else(|| {
reliable_declared_content_width(
tree,
parent_id,
styles,
initial_cb_width,
depth + 1,
)
})
} else {
Some(initial_cb_width)
}
});
let containing_width = match containing_width {
Some(Some(width)) => Some(width),
Some(None) => return None,
None => None,
};
let horizontal_edges =
style.padding.left + style.padding.right + style.border.left + style.border.right;
let declared_content = |dimension: crate::Dimension| match dimension {
crate::Dimension::Px(value) => Some(if style.box_sizing == crate::BoxSizing::ContentBox {
value
} else {
(value - horizontal_edges).max(0.0)
}),
crate::Dimension::Percent(percent) => {
let value = containing_width? * percent;
Some(if style.box_sizing == crate::BoxSizing::ContentBox {
value
} else {
(value - horizontal_edges).max(0.0)
})
}
_ => None,
};
let mut content_width = declared_content(style.width)?;
if let Some(minimum) = declared_content(style.min_width) {
content_width = content_width.max(minimum);
}
if let Some(maximum) = declared_content(style.max_width) {
content_width = content_width.min(maximum);
}
Some(content_width.max(0.0))
}
/// Resolve the containing-block content width used by CSS's ratio-only
/// auto/auto replaced sizing branch. Decoration-free inline ancestors do not
/// establish containing blocks, so skip them (and display:contents) before
/// using the existing conservative ordinary-flow width resolver. A definite
/// authored width is also safe for floated or positioned containing boxes;
/// their auto-sized allocations still return `None` and remain layout-owned.
fn reliable_ratio_only_available_width(
tree: &DomTree,
id: NodeId,
styles: &HashMap<NodeId, crate::LayoutStyle>,
initial_cb_width: f32,
) -> Option<f32> {
let image = styles.get(&id)?;
let mut parent = tree.get_node(id).and_then(|node| node.parent);
while let Some(parent_id) = parent {
let Some(parent_style) = styles.get(&parent_id) else {
parent = tree
.get_node(parent_id)
.and_then(|node| node.parent);
continue;
};
if parent_style.display_contents
|| (parent_style.display == crate::Display::Inline
&& !parent_style.is_inline_block)
{
parent = tree
.get_node(parent_id)
.and_then(|node| node.parent);
continue;
}
break;
}
let containing_width = if let Some(parent_id) = parent {
reliable_normal_flow_content_width(tree, parent_id, styles, initial_cb_width, 0)
.or_else(|| {
reliable_declared_content_width(tree, parent_id, styles, initial_cb_width, 0)
})?
} else {
initial_cb_width
};
let horizontal_edges =
image.padding.left + image.padding.right + image.border.left + image.border.right;
Some(
(containing_width
- image.margin.left
- image.margin.right
- horizontal_edges)
.max(0.0),
)
}
fn reliable_table_available_width(
tree: &DomTree,
id: NodeId,
+10 -3
View File
@@ -745,6 +745,10 @@ struct ReplacedItem {
/// CSS replaced sizing stretch-fits this case to a definite available
/// inline size instead of using the 300x150 default-object contribution.
ratio_only: bool,
/// Definite normal-flow containing width captured before Taffy's intrinsic
/// flex-item contribution pass. Only populated for ratio-only auto/auto
/// replaced boxes; decoded raster dimensions never use it.
ratio_only_available_width: Option<f32>,
/// CSS Sizing's cyclic-percentage rule makes a proper replaced element's
/// inline min-content contribution zero when its preferred or maximum
/// inline size contains a percentage. The natural size still participates
@@ -801,6 +805,7 @@ impl ReplacedItem {
ratio_only: intrinsic.width.is_none()
&& intrinsic.height.is_none()
&& explicit_ratio.is_some(),
ratio_only_available_width: style.ratio_only_available_width,
zero_inline_min_content: matches!(style.width, Dimension::Percent(_))
|| matches!(style.max_width, Dimension::Percent(_))
|| expression_has_percentage(0)
@@ -1541,9 +1546,11 @@ impl TextEngine {
&& replaced.preferred_width.is_none()
&& replaced.preferred_height.is_none()
&& known.width.is_none())
.then(|| match available.width {
taffy::AvailableSpace::Definite(width) => Some(width.max(0.0)),
_ => None,
.then(|| {
match available.width {
taffy::AvailableSpace::Definite(width) => Some(width.max(0.0)),
_ => replaced.ratio_only_available_width,
}
})
.flatten();
let mut size = replaced.size(taffy::Size {
+6
View File
@@ -861,6 +861,12 @@ pub struct LayoutStyle {
/// SVG can expose only one dimension or a `viewBox` ratio, distinctions
/// that the stable public `intrinsic_size` tuple cannot represent.
pub(crate) replaced_intrinsic: Option<ReplacedIntrinsic>,
/// Definite content-box width available to an auto/auto ratio-only
/// replaced element in ordinary block flow. Taffy's flex-row stand-in for
/// an inline formatting context asks atomic children for max-content first,
/// so its measure callback otherwise never sees the definite line width
/// that CSS replaced sizing uses for this special case.
pub(crate) ratio_only_available_width: Option<f32>,
/// The current ratio came from HTML width/height presentation hints
/// (`<img>` or its selected `<picture><source>`), rather than authored
/// CSS. A decoded image's natural ratio replaces this provisional ratio.
+78
View File
@@ -1146,6 +1146,7 @@ impl PreparedRender {
(crate::Display::Block, true) => "inline-block",
(crate::Display::Flex, false) => "flex",
(crate::Display::Grid, false) => "grid",
(crate::Display::Inline, true) => "inline-block",
(crate::Display::Inline, false) => "inline",
_ => "block",
}
@@ -9701,6 +9702,83 @@ mod tests {
assert_eq!((explicit.width, explicit.height), (120.0, 80.0));
}
#[test]
fn ratio_only_inline_images_stretch_fit_the_definite_line_width() {
let source = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20576%20576'%3E%3Crect%20width='576'%20height='576'%20fill='%23ff80ab'/%3E%3C/svg%3E";
let raster = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAYAAAC56t6BAAAAFklEQVR4nGP8z8Dwn4GBgYGJAQrgDAAxOwIE7x6DkQAAAABJRU5ErkJggg==";
let tree = parse_html(&format!(
r#"<html><head><style>
html,body {{ margin:0 }}
.case {{ width:370px; line-height:0 }}
#authored-inline {{ display:inline }}
#authored-block {{ display:block }}
#authored-inline-block {{ display:inline-block }}
#positioned {{ position:absolute; left:0; top:1900px; width:340px;
min-width:360px; max-width:380px; box-sizing:border-box;
padding:10px; border:10px solid transparent }}
#float-row {{ width:500px }}
#float-column {{ float:left; width:40%; box-sizing:border-box;
padding-left:10px; padding-right:10px }}
</style></head><body>
<div class="case"><img id="direct" src="{source}"></div>
<div class="case"><a><img id="anchored" src="{source}"></a></div>
<div class="case"><img id="authored-inline" src="{source}"></div>
<div class="case"><img id="authored-block" src="{source}"></div>
<div class="case"><img id="authored-inline-block" src="{source}"></div>
<div class="case"><img id="intrinsic-raster" src="{raster}"></div>
<div id="positioned"><img id="positioned-image" src="{source}"></div>
<div id="float-row"><div id="float-column"><img id="float-image" src="{source}"></div></div>
</body></html>"#
));
let mut resources = RenderResourceCache::default();
let prepared =
prepare_dom(&tree, (500.0, 2400.0), None, &mut resources).expect("image matrix");
let node = |selector| tree.query_selector(selector).unwrap().unwrap();
let rect = |selector| prepared.layout.rects[&node(selector)];
for selector in [
"#direct",
"#anchored",
"#authored-inline",
"#authored-block",
"#authored-inline-block",
] {
let image = rect(selector);
assert_eq!(
(image.width, image.height),
(370.0, 370.0),
"ratio-only auto/auto sizing for {selector}: {image:?}"
);
}
assert_eq!(
(rect("#intrinsic-raster").width, rect("#intrinsic-raster").height),
(2.0, 3.0),
"a decoded image with real intrinsic axes must not stretch-fit"
);
assert_eq!(
(rect("#positioned-image").width, rect("#positioned-image").height),
(320.0, 320.0),
"a definite positioned border-box honors edges and min/max widths"
);
assert_eq!(
(rect("#float-image").width, rect("#float-image").height),
(180.0, 180.0),
"a floated percentage column resolves against its reliable containing width"
);
let display = |selector| {
prepared
.computed_style(node(selector))
.unwrap()["display"]
.clone()
};
assert_eq!(display("#direct"), "inline");
assert_eq!(display("#anchored"), "inline");
assert_eq!(display("#authored-inline"), "inline");
assert_eq!(display("#authored-block"), "block");
assert_eq!(display("#authored-inline-block"), "inline-block");
}
#[test]
fn native_placeholders_honor_default_author_color_opacity_and_value_state() {
let tree = parse_html(
+9 -10
View File
@@ -287,15 +287,14 @@ pub fn ua_style(tag: &str) -> LayoutStyle {
style.font_weight = Some("bold".to_string());
}
} else if tag == "img" {
// Images are inline-level replaced elements by default. Model the
// atomic inner box with the same inline-block marker used by native
// controls, so an icon between text fragments participates in their
// line instead of splitting the parent into block runs. Chromium's UA
// sheet does not impose a responsive max-width: author/reset CSS must
// opt into that. A synthetic `max-width:100%` here distorts deliberately
// oversized art whose height and intrinsic ratio establish its width.
// Images are inline-level replaced elements by default. Replacement
// already makes the box atomic; `is_inline_block` is reserved for an
// authored inline-block outer display and native controls. Chromium's
// UA sheet does not impose a responsive max-width: author/reset CSS
// must opt into that. A synthetic `max-width:100%` here distorts
// deliberately oversized art whose height and intrinsic ratio
// establish its width.
style.display = Display::Inline;
style.is_inline_block = true;
}
style
}
@@ -8934,7 +8933,7 @@ mod tests {
let native_image = compute_style("img", None);
assert_eq!(native_image.display, Display::Inline);
assert!(native_image.is_inline_block);
assert!(!native_image.is_inline_block);
let block_image = compute_style("img", Some("display:block"));
assert_eq!(block_image.display, Display::Block);
@@ -8990,7 +8989,7 @@ mod tests {
fn image_ua_style_does_not_invent_a_responsive_size_cap() {
let image = ua_style("img");
assert_eq!(image.display, crate::Display::Inline);
assert!(image.is_inline_block);
assert!(!image.is_inline_block);
assert_eq!(image.max_width, crate::Dimension::Auto);
let authored = compute_style("img", Some("max-width:100%"));