From fd87ddd0b914f1a1f2fd6b607a96393fd25dae97 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:03:00 +0000 Subject: [PATCH] Transfer final flex sizes through aspect ratios --- crates/obscura-render/tests/layout_test.rs | 117 ++++++++++++++++++ render-repros/checks.json | 6 + render-repros/flex-post-ratio-cross-size.html | 53 ++++++++ vendor/taffy/src/compute/flexbox.rs | 107 +++++++++++++--- 4 files changed, 265 insertions(+), 18 deletions(-) create mode 100644 render-repros/flex-post-ratio-cross-size.html diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index d5a0a54..3b2b386 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -309,6 +309,123 @@ fn percentage_padding_uses_final_flex_containing_block_width() { } } +#[test] +fn final_flex_main_size_drives_auto_cross_size_through_aspect_ratio() { + let tree = parse_html(include_str!("../../../render-repros/flex-post-ratio-cross-size.html")); + let layout = layout_dom(&tree, (1080.0, 1000.0)); + let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()]; + + let row = rect("row"); + let media = rect("media"); + let visual = rect("visual"); + let copy = rect("copy"); + let after = rect("after"); + + assert!((media.width - 520.0).abs() < 0.01, "flexed media width: {media:?}"); + assert!( + (media.height - 520.0 / 1.72).abs() < 0.51, + "auto cross-size must use the final 520px main size: {media:?}" + ); + assert!( + (visual.width - 520.0).abs() < 0.01 && (visual.height - 520.0 / 1.75571).abs() < 0.51, + "absolute visual must use the ratio-sized containing block: {visual:?}" + ); + assert!( + (copy.x - 560.0).abs() < 0.01 && (copy.width - 520.0).abs() < 0.01, + "the sibling must receive the other flexible share: {copy:?}" + ); + assert!( + (row.height - media.height).abs() < 0.01 && (after.y - (row.height + 150.0)).abs() < 0.01, + "the ratio-sized item must contribute to its line and following flow: row={row:?} after={after:?}" + ); +} + +#[test] +fn post_flex_aspect_transfer_respects_content_and_border_boxes() { + let tree = parse_html( + r#" + +
+
+
+
+ "#, + ); + let layout = layout_dom(&tree, (500.0, 300.0)); + let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()]; + let content = rect("content"); + let border = rect("border"); + let row = rect("row"); + let minimum = rect("minimum"); + let maximum = rect("maximum"); + let explicit = rect("explicit"); + + assert!( + (content.width - 200.0).abs() < 0.01 && (content.height - 115.0).abs() < 0.01, + "content-box ratio must exclude then restore 30px of padding and border: {content:?}" + ); + assert!( + (border.width - 200.0).abs() < 0.01 && (border.height - 100.0).abs() < 0.01, + "authored border-box ratio must apply to the outer box: {border:?}" + ); + assert!((row.height - 115.0).abs() < 0.01, "the tallest transferred cross-size sets the line: {row:?}"); + assert!( + (minimum.width - 200.0).abs() < 0.01 && (minimum.height - 130.0).abs() < 0.01, + "the transferred cross-size must obey min-height: {minimum:?}" + ); + assert!( + (maximum.width - 200.0).abs() < 0.01 && (maximum.height - 70.0).abs() < 0.01, + "the transferred cross-size must obey max-height: {maximum:?}" + ); + assert!( + (explicit.width - 200.0).abs() < 0.01 && (explicit.height - 80.0).abs() < 0.01, + "an explicit cross-size must not be replaced by the preferred ratio: {explicit:?}" + ); +} + +#[cfg(feature = "paint")] +#[test] +fn scrolled_ratio_sized_flex_clip_keeps_absolute_visual_visible() { + let tree = parse_html( + r#" + +
+ "#, + ); + let pixmap = obscura_render::paint_dom_scrolled(&tree, (1080.0, 400.0), None, (0.0, 8300.0)) + .expect("scrolled ratio fixture paints"); + let visual = pixmap.pixel(260, 100).expect("visual center pixel"); + let clip_tail = pixmap.pixel(260, 299).expect("clip tail pixel"); + + assert!( + visual.red() == 47 && visual.green() == 158 && visual.blue() == 68, + "the absolute visual must survive its overflow clip: {visual:?}" + ); + assert!( + clip_tail.red() == 112 && clip_tail.green() == 72 && clip_tail.blue() == 232, + "the ratio-sized clip owner must extend below the visual: {clip_tail:?}" + ); +} + #[test] fn mixed_percentage_padding_shorthand_preserves_fixed_sides_and_content_origin() { let tree = parse_html( diff --git a/render-repros/checks.json b/render-repros/checks.json index 9da45b6..dbbd113 100644 --- a/render-repros/checks.json +++ b/render-repros/checks.json @@ -83,6 +83,12 @@ {"name": "invalid shorthand does not partially mutate", "color": "#d6336c", "x": 200, "y": 160, "width": 100, "height": 40}, {"name": "CSS supports accepts only the shorthand grammar", "color": "#18723a", "x": 400, "y": 120, "width": 180, "height": 40} ], + "flex-post-ratio-cross-size": [ + {"name": "absolute visual survives the ratio-sized overflow clip", "color": "#2f9e44", "x": 0, "y": 0, "width": 520, "height": 296}, + {"name": "final flex width sizes the media cross axis", "color": "#7048e8", "x": 0, "y": 296, "width": 520, "height": 6}, + {"name": "sibling receives the other flexible share", "color": "#1971c2", "x": 560, "y": 0, "width": 340, "height": 150}, + {"name": "following content starts after ratio height and column gap", "color": "#e8590c", "x": 0, "y": 452, "width": 900, "height": 20} + ], "flex-whitespace-items": [ {"name": "direct flex sidebar starts at container edge", "color": "#1971c2", "x": 0, "y": 0, "width": 225, "height": 180}, {"name": "direct flex body keeps its intended inset", "color": "#2b8a3e", "x": 225, "y": 0, "width": 675, "height": 200}, diff --git a/render-repros/flex-post-ratio-cross-size.html b/render-repros/flex-post-ratio-cross-size.html new file mode 100644 index 0000000..0520faf --- /dev/null +++ b/render-repros/flex-post-ratio-cross-size.html @@ -0,0 +1,53 @@ + + + +
+
+
+
+
+
+
diff --git a/vendor/taffy/src/compute/flexbox.rs b/vendor/taffy/src/compute/flexbox.rs index c526323..f72c980 100644 --- a/vendor/taffy/src/compute/flexbox.rs +++ b/vendor/taffy/src/compute/flexbox.rs @@ -24,6 +24,31 @@ fn resolve_flex_normal(alignment: AlignSelf) -> AlignSelf { if alignment.keyword == AlignItemsKeyword::Normal { AlignSelf::STRETCH } else { alignment } } +/// Transfer a definite border-box size through a preferred aspect ratio while +/// applying the ratio to the box selected by CSS `box-sizing` and intrinsic +/// ratio provenance. +#[inline] +fn maybe_apply_preferred_aspect_ratio( + size: Size>, + aspect_ratio: Option, + adjustment: Size, +) -> Size> { + match aspect_ratio { + Some(ratio) if ratio.is_finite() && ratio > 0.0 => match (size.width, size.height) { + (Some(width), None) => Size { + width: Some(width), + height: Some((width - adjustment.width).max(0.0) / ratio + adjustment.height), + }, + (None, Some(height)) => Size { + width: Some((height - adjustment.height).max(0.0) * ratio + adjustment.width), + height: Some(height), + }, + _ => size, + }, + _ => size, + } +} + /// The intermediate results of a flexbox calculation for a single item struct FlexItem { /// The identifier for the associated node @@ -38,6 +63,13 @@ struct FlexItem { min_size: Size>, /// The maximum allowable size of this item max_size: Size>, + /// The item's preferred aspect ratio. + aspect_ratio: Option, + /// Insets excluded while transferring sizes through the preferred aspect ratio. + aspect_ratio_adjustment: Size, + /// Whether the authored cross-size is auto. The resolved `size` above may + /// contain a provisional transfer from the pre-flex main size. + cross_size_is_auto: bool, /// The cross-alignment of this item align_self: AlignSelf, @@ -533,6 +565,7 @@ fn generate_anonymous_flex_items( .filter(|(_, _, style)| style.box_generation_mode() != BoxGenerationMode::None) .map(|(index, child, child_style)| { let aspect_ratio = child_style.aspect_ratio(); + let raw_size = child_style.size(); let padding = child_style .padding() .resolve_or_zero(constants.node_inner_size.width, |val, basis| tree.calc(val, basis)); @@ -542,24 +575,40 @@ fn generate_anonymous_flex_items( let pb_sum = (padding + border).sum_axes(); let box_sizing_adjustment = if child_style.box_sizing() == BoxSizing::ContentBox { pb_sum } else { Size::ZERO }; + let aspect_ratio_adjustment = + if child_style.aspect_ratio_uses_content_box() { pb_sum } else { box_sizing_adjustment }; + let size = maybe_apply_preferred_aspect_ratio( + raw_size + .maybe_resolve(constants.node_inner_size, |val, basis| tree.calc(val, basis)) + .maybe_add(box_sizing_adjustment), + aspect_ratio, + aspect_ratio_adjustment, + ); + let min_size = maybe_apply_preferred_aspect_ratio( + child_style + .min_size() + .maybe_resolve(constants.node_inner_size, |val, basis| tree.calc(val, basis)) + .maybe_add(box_sizing_adjustment), + aspect_ratio, + aspect_ratio_adjustment, + ); + let max_size = maybe_apply_preferred_aspect_ratio( + child_style + .max_size() + .maybe_resolve(constants.node_inner_size, |val, basis| tree.calc(val, basis)) + .maybe_add(box_sizing_adjustment), + aspect_ratio, + aspect_ratio_adjustment, + ); FlexItem { node: child, order: index as u32, - size: child_style - .size() - .maybe_resolve(constants.node_inner_size, |val, basis| tree.calc(val, basis)) - .maybe_apply_aspect_ratio(aspect_ratio) - .maybe_add(box_sizing_adjustment), - min_size: child_style - .min_size() - .maybe_resolve(constants.node_inner_size, |val, basis| tree.calc(val, basis)) - .maybe_apply_aspect_ratio(aspect_ratio) - .maybe_add(box_sizing_adjustment), - max_size: child_style - .max_size() - .maybe_resolve(constants.node_inner_size, |val, basis| tree.calc(val, basis)) - .maybe_apply_aspect_ratio(aspect_ratio) - .maybe_add(box_sizing_adjustment), + size, + min_size, + max_size, + aspect_ratio, + aspect_ratio_adjustment, + cross_size_is_auto: raw_size.cross(constants.dir).is_auto(), inset: child_style .inset() @@ -1399,9 +1448,31 @@ fn determine_hypothetical_cross_size( let child_known_main = constants.container_size.main(constants.dir).into(); - let child_cross = child - .size - .cross(constants.dir) + // A preferred aspect ratio can provisionally fill an auto cross-size + // from the authored main size while flex items are generated. That + // value is stale after flexible-length resolution changes the main + // size. Transfer the final target main size through the ratio instead, + // matching Flexbox 9.4's requirement to lay the item out with its used + // main size. Keep intrinsic ratios in the content box and authored + // ratios in the box selected by `box-sizing`. + let ratio_cross = if child.cross_size_is_auto { + child.aspect_ratio.and_then(|ratio| { + if ratio.is_finite() && ratio > 0.0 { + let ratio_main = (child.target_size.main(constants.dir) + - child.aspect_ratio_adjustment.main(constants.dir)) + .max(0.0); + let ratio_cross = if constants.is_row { ratio_main / ratio } else { ratio_main * ratio }; + Some(ratio_cross + child.aspect_ratio_adjustment.cross(constants.dir)) + } else { + None + } + }) + } else { + None + }; + + let child_cross = ratio_cross + .or(child.size.cross(constants.dir)) .maybe_clamp(child.min_size.cross(constants.dir), child.max_size.cross(constants.dir)) .maybe_max(padding_border_sum);