diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 67ae969..5f6793d 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -264,6 +264,8 @@ pub struct LayoutBox { pub css_max_width: LengthOrAuto, pub css_min_height: LengthOrAuto, pub css_max_height: LengthOrAuto, + /// CSS `aspect-ratio`, stored as width / height. `None` means `auto`. + pub aspect_ratio: Option, /// CSS margin values (may contain percentages for layout resolution). pub css_margin: [LengthOrAuto; 4], /// CSS padding values (may contain percentages for layout resolution). @@ -408,6 +410,7 @@ impl LayoutBox { css_max_width: style.max_width, css_min_height: style.min_height, css_max_height: style.max_height, + aspect_ratio: style.aspect_ratio, css_margin: [ style.margin_top, style.margin_right, @@ -776,14 +779,37 @@ fn intrinsic_ratio(width: f32, height: f32) -> Option { } } +fn preferred_aspect_ratio(b: &LayoutBox) -> Option { + b.aspect_ratio.filter(|ratio| *ratio > 0.0) +} + +fn aspect_ratio_content_height(b: &LayoutBox) -> Option { + let ratio = preferred_aspect_ratio(b)?; + Some(b.rect.width / ratio) +} + +fn apply_preferred_aspect_ratio_auto_height(b: &mut LayoutBox) { + if !matches!(b.css_height, LengthOrAuto::Auto) { + return; + } + if let Some(height) = aspect_ratio_content_height(b) { + b.rect.height = b.rect.height.max(height); + } +} + fn replaced_intrinsic_size_for_width( b: &LayoutBox, fallback_auto_width: f32, ) -> Option<(f32, f32, Option)> { if let Some((width, height)) = b.replaced_size { - Some((width, height, intrinsic_ratio(width, height))) + Some(( + width, + height, + preferred_aspect_ratio(b).or_else(|| intrinsic_ratio(width, height)), + )) } else { - b.replaced_ratio.map(|ratio| { + let ratio = preferred_aspect_ratio(b).or(b.replaced_ratio)?; + Some({ let width = fallback_auto_width.max(0.0); let height = if ratio > 0.0 { width / ratio } else { 0.0 }; (width, height, (ratio > 0.0).then_some(ratio)) @@ -823,7 +849,11 @@ fn resolve_replaced_content_size( let mut height = match (specified_height, specified_width, ratio) { (Some(height), _, _) => height, (None, Some(_), Some(ratio)) if ratio > 0.0 => width / ratio, - (None, _, Some(ratio)) if b.replaced_ratio.is_some() && ratio > 0.0 => width / ratio, + (None, _, Some(ratio)) + if (b.replaced_ratio.is_some() || b.aspect_ratio.is_some()) && ratio > 0.0 => + { + width / ratio + } (None, _, _) => intrinsic_height, }; @@ -850,7 +880,8 @@ fn intrinsic_replaced_content_width(b: &LayoutBox) -> Option { width } else if let Some(height) = resolve_intrinsic_size_constraint(b.css_height, b.box_sizing, vert) { - intrinsic_ratio(intrinsic_width, intrinsic_height) + preferred_aspect_ratio(b) + .or_else(|| intrinsic_ratio(intrinsic_width, intrinsic_height)) .map(|ratio| height * ratio) .unwrap_or(intrinsic_width) } else { @@ -2496,6 +2527,7 @@ fn compute_layout( } // Save the natural content height before CSS height override. + apply_preferred_aspect_ratio_auto_height(b); b.content_height = b.rect.height; // Apply explicit CSS height (adjusted for box-sizing), overriding auto height. @@ -3013,6 +3045,7 @@ fn layout_absolute_child( } // Save natural content height. + apply_preferred_aspect_ratio_auto_height(child); child.content_height = child.rect.height; // --- Resolve content height --- @@ -3164,6 +3197,7 @@ fn is_empty_block(b: &LayoutBox) -> bool { // width/height) has no children but still produces a ratio-derived // height, so it is not an empty, self-collapsing block. && b.replaced_ratio.is_none() + && b.aspect_ratio.is_none() && matches!(b.css_height, LengthOrAuto::Auto) } @@ -3579,6 +3613,7 @@ fn layout_float_child( } // Resolve explicit CSS height. + apply_preferred_aspect_ratio_auto_height(child); child.content_height = child.rect.height; match child.css_height { LengthOrAuto::Length(h) => { @@ -7997,6 +8032,83 @@ mod tests { assert!((img_box.rect.height - 30.0).abs() < 0.5); } + #[test] + fn block_aspect_ratio_derives_auto_height_from_width() { + let html_str = r#" + +
"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + + let body = &tree.root.children[0]; + let frame = &body.children[0]; + + assert!((frame.rect.width - 160.0).abs() < 0.5); + assert!( + (frame.rect.height - 90.0).abs() < 0.5, + "auto height should follow aspect-ratio, got {}", + frame.rect.height + ); + } + + #[test] + fn aspect_ratio_with_padding_derives_auto_height_from_content_width() { + let html_str = r#" + +
"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + + let body = &tree.root.children[0]; + let frame = &body.children[0]; + + assert!((frame.rect.width - 160.0).abs() < 0.5); + assert!( + (frame.rect.height - 160.0 / 1.5).abs() < 0.5, + "content height should follow the content box ratio, got {}", + frame.rect.height + ); + } + + #[test] + fn replaced_declared_aspect_ratio_overrides_intrinsic_ratio() { + let mut doc = Document::new(); + let root = doc.root(); + let html = doc.create_element("html"); + let body = doc.create_element("body"); + let img = doc.create_element("img"); + doc.set_attribute(img, "style", "height:30px; width:auto; aspect-ratio:1/1"); + doc.append_child(root, html); + doc.append_child(html, body); + doc.append_child(body, img); + + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); + let mut sizes = HashMap::new(); + sizes.insert(img, ReplacedContent::Size(274.0, 100.0)); + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &sizes); + + let img_box = find_box(&tree.root, img).expect("image box present"); + assert!( + (img_box.rect.width - 30.0).abs() < 0.5, + "auto width should use declared aspect-ratio, got {}", + img_box.rect.width + ); + assert!((img_box.rect.height - 30.0).abs() < 0.5); + } + #[test] fn svg_percentage_height_does_not_use_viewport_height() { // Bing's homepage search icons are viewBox-only SVGs styled with diff --git a/crates/style/src/computed.rs b/crates/style/src/computed.rs index 2f01387..43eb040 100644 --- a/crates/style/src/computed.rs +++ b/crates/style/src/computed.rs @@ -868,6 +868,8 @@ pub struct ComputedStyle { pub max_width: LengthOrAuto, pub min_height: LengthOrAuto, pub max_height: LengthOrAuto, + /// CSS `aspect-ratio`, stored as width / height. `None` is `auto`. + pub aspect_ratio: Option, // Box model: sizing pub box_sizing: BoxSizing, @@ -1035,6 +1037,7 @@ impl Default for ComputedStyle { max_width: LengthOrAuto::Auto, min_height: LengthOrAuto::Auto, max_height: LengthOrAuto::Auto, + aspect_ratio: None, box_sizing: BoxSizing::ContentBox, @@ -2115,6 +2118,44 @@ fn shadow_length_px( } } +fn positive_css_number(value: &CssValue) -> Option { + match value { + CssValue::Number(n) if n.is_finite() && *n > 0.0 => Some(*n as f32), + _ => None, + } +} + +fn parse_aspect_ratio_components(values: &[&CssValue]) -> Option { + match values { + [single] => positive_css_number(single), + [width, CssValue::Keyword(slash), height] if slash == "/" => { + let width = positive_css_number(width)?; + let height = positive_css_number(height)?; + Some(width / height) + } + _ => None, + } +} + +fn parse_aspect_ratio(value: &CssValue) -> Option> { + match value { + CssValue::Auto | CssValue::None => Some(None), + CssValue::Number(_) => positive_css_number(value).map(Some), + CssValue::List(items) => { + let non_auto: Vec<&CssValue> = items + .iter() + .filter(|item| !matches!(item, CssValue::Auto)) + .collect(); + if non_auto.is_empty() { + Some(None) + } else { + parse_aspect_ratio_components(&non_auto).map(Some) + } + } + _ => None, + } +} + fn parse_transform(value: &CssValue) -> Option { match value { CssValue::None => Some(Transform::None), @@ -2431,6 +2472,11 @@ fn apply_property( style.max_height = resolve_layout_length_or_auto(value, current_fs, root_font_size, viewport); } + "aspect-ratio" => { + if let Some(ratio) = parse_aspect_ratio(value) { + style.aspect_ratio = ratio; + } + } // Box sizing "box-sizing" => { @@ -3747,6 +3793,7 @@ fn inherit_property(style: &mut ComputedStyle, property: &str, parent: &Computed "max-width" => style.max_width = parent.max_width, "min-height" => style.min_height = parent.min_height, "max-height" => style.max_height = parent.max_height, + "aspect-ratio" => style.aspect_ratio = parent.aspect_ratio, "box-sizing" => style.box_sizing = parent.box_sizing, "background-color" => style.background_color = parent.background_color, "background-image" => { @@ -3839,6 +3886,7 @@ fn reset_property_to_initial(style: &mut ComputedStyle, property: &str) { "max-width" => style.max_width = initial.max_width, "min-height" => style.min_height = initial.min_height, "max-height" => style.max_height = initial.max_height, + "aspect-ratio" => style.aspect_ratio = initial.aspect_ratio, "box-sizing" => style.box_sizing = initial.box_sizing, "color" => style.color = initial.color, "font-size" => { @@ -6154,6 +6202,42 @@ mod tests { assert_eq!(child.style.box_sizing, BoxSizing::ContentBox); } + #[test] + fn aspect_ratio_parses_ratio_syntax() { + let html_str = r#" + +
"#; + let doc = we_html::parse_html(html_str); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); + let body = &styled.children[0]; + let wide = &body.children[0]; + let square = &body.children[1]; + + assert!((wide.style.aspect_ratio.unwrap() - 16.0 / 9.0).abs() < 0.001); + assert!((square.style.aspect_ratio.unwrap() - 1.0).abs() < 0.001); + } + + #[test] + fn aspect_ratio_auto_resets_to_initial() { + let html_str = r#" + +
"#; + let doc = we_html::parse_html(html_str); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); + let body = &styled.children[0]; + let div = &body.children[0]; + + assert_eq!(div.style.aspect_ratio, None); + } + #[test] fn z_index_parsing() { let html_str = r#"