From 539f54307fe712b5efc0d06d0e2cb68cde36367b Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 10 Sep 2026 01:00:46 +0800 Subject: [PATCH] fix(layout): honor automatic content minimum for inline aspect ratios --- moli-layout/src/intrinsic.rs | 118 ++++++++++++++++++-- moli-layout/tests/phase4_layout_contract.rs | 80 +++++++++++++ 2 files changed, 191 insertions(+), 7 deletions(-) diff --git a/moli-layout/src/intrinsic.rs b/moli-layout/src/intrinsic.rs index 7702ce8f7b..f332470fb3 100644 --- a/moli-layout/src/intrinsic.rs +++ b/moli-layout/src/intrinsic.rs @@ -8,11 +8,12 @@ use std::{fmt::Debug, hash::Hash}; use taffy::{ - AvailableSpace, LayoutInput, LayoutOutput, LayoutPartialTree, RequestedAxis, RunMode, Size, - SizingPurpose, + AvailableSpace, BoxSizing, Dimension, LayoutInput, LayoutOutput, LayoutPartialTree, MaybeMath, + MaybeResolve, RequestedAxis, ResolveOrZero, RunMode, Size, SizingMode, SizingPurpose, + WritingMode, }; -use crate::{LayoutBoxId, LayoutDisplay, LayoutWorld}; +use crate::{LayoutBoxId, LayoutDisplay, LayoutWorld, style::resolve_stylo_calc_value}; impl LayoutWorld where @@ -86,10 +87,10 @@ where | LayoutDisplay::InlineTable ); let AvailableSpace::Definite(available_width) = inputs.available_space.width else { - return self.compute_child_layout(child.to_taffy(), inputs); + return self.layout_atomic_inline_with_content_minimum(child, inputs); }; if !uses_fit_content { - return self.compute_child_layout(child.to_taffy(), inputs); + return self.layout_atomic_inline_with_content_minimum(child, inputs); } let intrinsic_inputs = LayoutInput { @@ -111,8 +112,8 @@ where height: inputs.definite_dimensions.height, }; - self.compute_child_layout( - child.to_taffy(), + self.layout_atomic_inline_with_content_minimum( + child, LayoutInput { known_dimensions, definite_dimensions, @@ -120,4 +121,107 @@ where }, ) } + + fn layout_atomic_inline_with_content_minimum( + &mut self, + child: LayoutBoxId, + inputs: LayoutInput, + ) -> LayoutOutput { + let Some(minimum) = self.atomic_ratio_content_minimum(child, inputs) else { + return self.compute_child_layout(child.to_taffy(), inputs); + }; + // This is a pass-local used minimum, not a computed-style mutation. + // Taffy's leaf/block algorithms otherwise resolve the ratio into a + // definite height before they have measured the box's content. + let previous_minimum = std::mem::replace( + &mut self.boxes[child.index()].style.taffy.min_size.height, + minimum, + ); + let output = self.compute_child_layout(child.to_taffy(), inputs); + self.boxes[child.index()].style.taffy.min_size.height = previous_minimum; + output + } + + /// CSS Sizing 4 ยง4.3: a non-replaced, non-scrolling atomic flow box with + /// an automatic ratio-dependent height cannot shrink below its content. + fn atomic_ratio_content_minimum( + &mut self, + child: LayoutBoxId, + inputs: LayoutInput, + ) -> Option { + let layout_box = &self.boxes[child.index()]; + let style = &layout_box.style.taffy; + if layout_box.is_replaced() + || layout_box.style.display() != LayoutDisplay::InlineBlock + || layout_box.style.writing_mode() != WritingMode::HorizontalTb + || layout_box.resolved_aspect_ratio().is_none() + || !style.size.height.is_auto() + || !style.min_size.height.is_auto() + || style.overflow.y.is_scroll_container() + || inputs.known_dimensions.height.is_some() + || inputs.sizing_mode != SizingMode::InherentSize + || inputs.axis == RequestedAxis::Horizontal + { + return None; + } + let padding_border = (style + .padding + .resolve_or_zero(inputs.parent_size.width, resolve_stylo_calc_value) + + style + .border + .resolve_or_zero(inputs.parent_size.width, resolve_stylo_calc_value)) + .sum_axes(); + let adjustment = if style.box_sizing == BoxSizing::ContentBox { + padding_border + } else { + Size::ZERO + }; + let width = inputs.known_dimensions.width.or_else(|| { + style + .size + .width + .maybe_resolve(inputs.parent_size.width, resolve_stylo_calc_value) + .maybe_add(adjustment.width) + .maybe_clamp( + style + .min_size + .width + .maybe_resolve(inputs.parent_size.width, resolve_stylo_calc_value) + .maybe_add(adjustment.width), + style + .max_size + .width + .maybe_resolve(inputs.parent_size.width, resolve_stylo_calc_value) + .maybe_add(adjustment.width), + ) + })?; + let maximum = style + .max_size + .height + .maybe_resolve(inputs.parent_size.height, resolve_stylo_calc_value) + .maybe_add(adjustment.height); + let measured = self.compute_child_layout( + child.to_taffy(), + LayoutInput { + run_mode: RunMode::ComputeSize, + sizing_mode: SizingMode::ContentSize, + known_dimensions: Size { + width: Some(width), + height: None, + }, + definite_dimensions: Size { + width: Some(width), + height: None, + }, + available_space: Size { + width: AvailableSpace::Definite(width), + height: AvailableSpace::MaxContent, + }, + ..inputs + }, + ); + Some(Dimension::length( + (measured.size.height.maybe_min(maximum) - adjustment.height).max(0.0), + )) + } } diff --git a/moli-layout/tests/phase4_layout_contract.rs b/moli-layout/tests/phase4_layout_contract.rs index ad38e38ca0..81168de2a0 100644 --- a/moli-layout/tests/phase4_layout_contract.rs +++ b/moli-layout/tests/phase4_layout_contract.rs @@ -243,6 +243,86 @@ fn assert_close(actual: f32, expected: f32) { assert!((actual - expected).abs() < 0.05, "{actual} != {expected}"); } +#[test] +fn inline_block_ratio_height_respects_automatic_content_minimum_and_opt_outs() { + let source = Source(vec![ + Node::element("root", "div", LayoutElementCategory::Generic, None, vec![1]), + Node::element( + "ratio", + "div", + LayoutElementCategory::Generic, + None, + vec![2], + ), + Node::element( + "content", + "div", + LayoutElementCategory::Generic, + None, + vec![], + ), + ]); + for (minimum, maximum, overflow, explicit_height, expected) in [ + ( + Dimension::auto(), + Dimension::auto(), + Overflow::Visible, + Dimension::auto(), + 20.0, + ), + ( + Dimension::length(0.0), + Dimension::auto(), + Overflow::Visible, + Dimension::auto(), + 10.0, + ), + ( + Dimension::auto(), + Dimension::auto(), + Overflow::Hidden, + Dimension::auto(), + 10.0, + ), + ( + Dimension::auto(), + Dimension::length(18.0), + Overflow::Visible, + Dimension::auto(), + 18.0, + ), + ( + Dimension::auto(), + Dimension::auto(), + Overflow::Visible, + Dimension::length(10.0), + 10.0, + ), + ] { + let mut styles = Styles::default(); + styles.primary.insert( + 0, + sized(LayoutDisplay::Block, 200.0, 100.0, PaintColor::TRANSPARENT), + ); + styles.primary.insert( + 1, + style(LayoutDisplay::InlineBlock, GREEN).tap_taffy(|style| { + style.size.width = Dimension::length(40.0); + style.size.height = explicit_height; + style.aspect_ratio = Some(4.0); + style.min_size.height = minimum; + style.max_size.height = maximum; + style.overflow.y = overflow; + }), + ); + styles + .primary + .insert(2, sized(LayoutDisplay::Block, 10.0, 20.0, BLUE)); + let snapshot = render(&source, &mut styles, 200, 100); + assert_close(rect(&snapshot, GREEN).height, expected); + } +} + #[test] fn table_caption_tracks_rows_cells_and_common_spans_share_one_wrapper_geometry() { use LayoutElementCategory::Table;