diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 172b4e1..e96fa02 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -10693,11 +10693,18 @@ fn apply_container_size_containment( } } -#[derive(Clone, Copy)] +#[derive(Clone)] struct DeferredCyclicInlineSize { node: NodeId, flex_item: NodeId, slot: usize, + source: DeferredCyclicInlineSource, +} + +#[derive(Clone)] +enum DeferredCyclicInlineSource { + Expression(String), + Percent(f32), } /// During a flex item's intrinsic-size calculation, percentages in descendant @@ -10717,22 +10724,36 @@ fn defer_cyclic_flex_inline_sizes( vw: f32, vh: f32, ) -> Vec { - let candidates: Vec<(NodeId, usize, String)> = styles + let candidates: Vec<(NodeId, usize, DeferredCyclicInlineSource)> = styles .iter() .filter(|(_, style)| !style.ignores_used_box_sizes()) .flat_map(|(&id, style)| { [0usize, 2, 4].into_iter().filter_map(move |slot| { - style.size_expressions[slot] + let functional = style.size_expressions[slot] .as_ref() .filter(|expression| expression.contains('%')) .cloned() - .map(|expression| (id, slot, expression)) + .map(DeferredCyclicInlineSource::Expression); + let dimension = match slot { + 0 => style.width, + 2 => style.min_width, + 4 => style.max_width, + _ => unreachable!(), + }; + functional + .or_else(|| match dimension { + crate::Dimension::Percent(percent) => { + Some(DeferredCyclicInlineSource::Percent(percent)) + } + _ => None, + }) + .map(|source| (id, slot, source)) }) }) .collect(); let mut deferred = Vec::new(); - for (id, slot, expression) in candidates { + for (id, slot, source) in candidates { // Start at the expression's containing-box chain, not the sized // node itself. A percentage-sized direct child of a nested flex // container makes that container's contribution cyclic when the @@ -10781,8 +10802,13 @@ fn defer_cyclic_flex_inline_sizes( .get(&id) .and_then(|style| style.font_size) .unwrap_or(root_fs); - let Some(intrinsic) = - crate::style::resolve_contextual_length(&expression, em, root_fs, vw, vh, 0.0) + let intrinsic = match &source { + DeferredCyclicInlineSource::Expression(expression) => { + crate::style::resolve_contextual_length(expression, em, root_fs, vw, vh, 0.0) + } + DeferredCyclicInlineSource::Percent(_) => Some(0.0), + }; + let Some(intrinsic) = intrinsic else { continue; }; @@ -10802,6 +10828,7 @@ fn defer_cyclic_flex_inline_sizes( node: id, flex_item, slot, + source, }); } @@ -10852,12 +10879,6 @@ fn resolve_deferred_flex_inline_sizes( } for entry in deferred { - let Some(expression) = styles - .get(&entry.node) - .and_then(|style| style.size_expressions[entry.slot].clone()) - else { - continue; - }; let mut containing = tree.get_node(entry.node).and_then(|node| node.parent); let basis = loop { let Some(parent) = containing else { @@ -10887,8 +10908,20 @@ fn resolve_deferred_flex_inline_sizes( .get(&entry.node) .and_then(|style| style.font_size) .unwrap_or(root_fs); - let Some(value) = - crate::style::resolve_contextual_length(&expression, em, root_fs, vw, vh, basis) + let value = match &entry.source { + DeferredCyclicInlineSource::Expression(expression) => { + crate::style::resolve_contextual_length( + expression, + em, + root_fs, + vw, + vh, + basis, + ) + } + DeferredCyclicInlineSource::Percent(percent) => Some(basis * percent), + }; + let Some(value) = value else { continue; }; @@ -11080,14 +11113,18 @@ fn build( .aspect_ratio .filter(|ratio| ratio.is_finite() && *ratio > 0.0) { - // A percentage inline size inside an auto grid track is cyclic + // A 100% inline size inside an auto grid track is cyclic // during intrinsic sizing: it behaves as auto for the contribution // pass, then fills the final track. Taffy 0.12 keeps the intrinsic // fallback width instead of revisiting that percentage. Express // the final fill as grid self-stretch so the known content width - // reaches the replaced-item measure callback. - if matches!(style.width, crate::Dimension::Percent(_)) + // reaches the replaced-item measure callback. This substitution is + // only equivalent for an in-flow grid item at exactly 100%; an + // abs-pos SVG or a smaller percentage must keep its containing- + // block-relative authored size. + if matches!(style.width, crate::Dimension::Percent(percent) if percent == 1.0) && matches!(style.height, crate::Dimension::Auto) + && is_in_flow_grid_item(tree, id, style, styles) { taffy_style.size.width = taffy::Dimension::auto(); taffy_style.min_size.width = taffy::Dimension::length(0.0); diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index 3b2b386..17668f8 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -1661,6 +1661,31 @@ fn ratio_only_inline_svg_sizes_inside_an_auto_grid_row() { ); } +/// A percentage-sized absolutely positioned SVG resolves against its +/// containing block like any other replaced element. The special intrinsic +/// sizing used for a ratio-only SVG in an auto grid track must not turn this +/// authored percentage into grid-style self-stretch (or the 300px default +/// object size wins instead). +#[test] +fn absolute_inline_svg_keeps_percentage_width_and_view_box_ratio() { + let tree = parse_html( + r#" +
+ +
+ "#, + ); + let layout = layout_dom(&tree, (1000.0, 600.0)); + let art = layout.rects[&tree.get_element_by_id("art").unwrap()]; + assert!((art.x - 640.0).abs() < 0.01, "percentage width must anchor from the right: {art:?}"); + assert!((art.width - 160.0).abs() < 0.01, "percentage width: {art:?}"); + assert!((art.height - 160.0).abs() < 0.01, "viewBox ratio: {art:?}"); +} + /// Chromium 150 includes collapsed descendant margins in a grid item's /// intrinsic block-size. The nested 64px margin reaches through `section`, /// making the first item 200px tall; the second row therefore begins after @@ -3385,6 +3410,65 @@ fn cyclic_descendant_percentages_do_not_inflate_a_flex_items_intrinsic_minimum() } } +/// Chromium resolves both spellings to the same 163px content width: the +/// percentage is cyclic while the link's flex-item width is being measured, +/// then resolves against that link's final width. Carbon Ads uses the bare +/// spelling; treating its 500px image as a permanent intrinsic floor widened +/// Bootstrap's 195px sidebar card to 532px. +#[test] +fn bare_and_functional_cyclic_image_percentages_use_the_final_flex_width() { + let tree = parse_html( + r#" + +
+ + + +
+
+ + + +
+ "#, + ); + let bare_image = tree.get_element_by_id("bare-image").unwrap(); + let functional_image = tree.get_element_by_id("functional-image").unwrap(); + let intrinsic = HashMap::from([ + (bare_image, (500.0, 500.0)), + (functional_image, (500.0, 500.0)), + ]); + let layout = layout_dom_with_images(&tree, (1280.0, 720.0), &intrinsic); + let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()]; + + for (prefix, wrap_id, link_id, image_id) in [ + ("bare", "bare-wrap", "bare-link", "bare-image"), + ( + "functional", + "functional-wrap", + "functional-link", + "functional-image", + ), + ] { + let wrap = rect(wrap_id); + let link = rect(link_id); + let image = rect(image_id); + assert_eq!(wrap.width, 195.0, "{prefix} percentage wrapper: {wrap:?}"); + assert_eq!(link.width, 163.0, "{prefix} percentage link: {link:?}"); + assert_eq!(image.width, 163.0, "{prefix} percentage image: {image:?}"); + assert_eq!(image.height, 163.0, "{prefix} image ratio: {image:?}"); + } +} + /// The cyclic percentage affects only the min-content contribution. With /// positive free space, the same boxes retain their natural max-content bases. #[test]