diff --git a/moli-layout/src/replaced.rs b/moli-layout/src/replaced.rs index 6ae35ddb9e..8c4cd9c233 100644 --- a/moli-layout/src/replaced.rs +++ b/moli-layout/src/replaced.rs @@ -18,6 +18,7 @@ pub(crate) struct ReplacedContext { inherent_size: Size, attribute_size: Size>, inherent_ratio: Option, + svg_without_intrinsic_size: bool, } impl ReplacedContext { @@ -103,6 +104,11 @@ impl ReplacedContext { inherent_size, attribute_size, inherent_ratio, + // Keep this distinction after computing the fallback object size. + // A viewBox supplies an aspect ratio, not natural dimensions. + svg_without_intrinsic_size: kind == LayoutReplacedKind::Svg + && metrics.intrinsic_width.is_none() + && metrics.intrinsic_height.is_none(), } } @@ -111,6 +117,7 @@ impl ReplacedContext { inherent_size: size, attribute_size: Size::NONE, inherent_ratio: None, + svg_without_intrinsic_size: false, } } @@ -232,6 +239,7 @@ pub(crate) fn measure_replaced( context: &ReplacedContext, resolved_aspect_ratio: Option, style: &taffy::Style, + writing_mode: taffy::WritingMode, sizing_mode: SizingMode, requested_axis: RequestedAxis, ) -> Size { @@ -359,6 +367,71 @@ pub(crate) fn measure_replaced( padding_border_sum, ) .unwrap_or(context.inherent_size) + } else if context.svg_without_intrinsic_size + && let Some(aspect_ratio) = resolved_aspect_ratio + { + // Blink's ComputeReplacedSizeInternal stretches the inline axis when + // only an aspect ratio is available. Falling back to a 300x150 object + // here makes a viewBox-only icon 150x150 even inside a 24px container. + // This is an auto-size fallback, not an available-space clamp: natural + // dimensions and definite author sizes above may still overflow. + let inline_axis = writing_mode.inline_axis(); + let margin = style + .margin + .resolve_or_zero(parent_size.width, resolve_stylo_calc_value) + .sum_axes() + .get_abs(inline_axis); + let available_inline_size = + available_space + .get_abs(inline_axis) + .into_option() + .or_else(|| { + // Absolute layout asks for min/max-content sizes before its final + // fit-content pass. A ratio-only SVG still stretches against its + // definite containing block in these probes (unlike an in-flow + // SVG inside a shrink-to-fit container, which contributes zero). + if style.position != taffy::Position::Absolute { + return None; + } + let insets = style + .inset + .resolve_or_zero(parent_size, resolve_stylo_calc_value); + parent_size + .get_abs(inline_axis) + .map(|size| size - insets.sum_axes().get_abs(inline_axis)) + }); + let inline_size = match available_inline_size { + Some(available) => { + (available - margin - padding_border_sum.get_abs(inline_axis)).max(0.0) + } + // An unresolved percentage uses the default dimension for an + // intrinsic contribution; an auto inline size contributes zero. + None if style + .size + .get_abs(inline_axis) + .may_have_percentage_dependence() => + { + if writing_mode.is_horizontal() { + 300.0 + } else { + 150.0 + } + } + None => 0.0, + }; + let size = if writing_mode.is_horizontal() { + Size { + width: Some(inline_size), + height: None, + } + } else { + Size { + width: None, + height: Some(inline_size), + } + }; + apply_aspect_ratio_to_content_size(size, Some(aspect_ratio), padding_border_sum) + .unwrap_or(context.inherent_size) } else { context.inherent_size }; @@ -539,11 +612,68 @@ mod tests { .or(Some(1.0)) .and_then(|ratio| ResolvedAspectRatio::new(ratio, style.box_sizing)), style, + taffy::WritingMode::HorizontalTb, SizingMode::InherentSize, RequestedAxis::Both, ) } + #[test] + fn viewbox_only_svg_stretches_the_logical_inline_axis() { + let context = ReplacedContext::for_element( + LayoutReplacedKind::Svg, + Some(ReplacedMetrics { + intrinsic_ratio: Some(2.0), + ..ReplacedMetrics::default() + }), + ); + for (writing_mode, expected) in [ + ( + taffy::WritingMode::HorizontalTb, + Size { + width: 24.0, + height: 12.0, + }, + ), + ( + taffy::WritingMode::VerticalRl, + Size { + width: 96.0, + height: 48.0, + }, + ), + ( + taffy::WritingMode::VerticalLr, + Size { + width: 96.0, + height: 48.0, + }, + ), + ] { + assert_eq!( + measure_replaced( + Size::NONE, + Size { + width: Some(24.0), + height: Some(48.0) + }, + Size { + width: AvailableSpace::Definite(24.0), + height: AvailableSpace::Definite(48.0) + }, + &context, + ResolvedAspectRatio::new(2.0, BoxSizing::ContentBox), + &taffy::Style::default(), + writing_mode, + SizingMode::InherentSize, + RequestedAxis::Both, + ), + expected, + "the SVG must derive its auto block size from its available inline size", + ); + } + } + #[test] fn preferred_ratio_uses_its_selected_box_sizing_basis() { let style = taffy::Style:: { @@ -572,6 +702,7 @@ mod tests { &image_context(), ResolvedAspectRatio::new(2.0, box_sizing), &style, + taffy::WritingMode::HorizontalTb, SizingMode::InherentSize, RequestedAxis::Both, ) diff --git a/moli-layout/src/taffy_tree.rs b/moli-layout/src/taffy_tree.rs index cf15f9eea8..dfa1ee83a5 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -2059,6 +2059,7 @@ where &context, resolved_aspect_ratio, &style, + writing_mode, inputs.sizing_mode, inputs.axis, ); diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/inline_svg_paint.rs b/moli-renderer-v8/src/runtime/page_vm/tests/inline_svg_paint.rs index 48887e903b..eba68250b0 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/inline_svg_paint.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/inline_svg_paint.rs @@ -1,5 +1,75 @@ use super::*; +#[tokio::test(flavor = "current_thread")] +async fn screenshot_sizes_viewbox_only_svg_from_available_inline_space() { + run_page_vm_async_test(async move { + let loader = + crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); + let mut page_vm = test_page_vm_with_loader_and_document_url( + &loader, + Vec::new(), + Url::parse("https://example.com/inline-svg-auto-size.html")?, + ); + let fixture = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/inline-svg-auto-size.html" + )); + page_vm.vm_mut().eval(&format!( + "document.documentElement.innerHTML = {}; 'installed'", + serde_json::to_string(fixture)?, + ))?; + page_vm.vm_mut().sync_live_document_style_sources(); + let snapshot = page_vm + .vm_mut() + .screenshot_layout_snapshot(moli_layout::PaintViewport::new(800, 600, 1.0))? + .expect("inline SVG sizing fixture must retain a layout root"); + let geometry = page_vm.vm_mut().eval( + "JSON.stringify(Object.fromEntries([...document.querySelectorAll('svg')].map(element => { const rect = element.getBoundingClientRect(); return [element.id, [rect.width, rect.height]]; })))", + )?; + let geometry: serde_json::Value = serde_json::from_str(&geometry)?; + // Measured in Chromium 145. A viewBox supplies a ratio, not a fixed + // 150px natural size; definite author sizes may still overflow. + for (id, expected) in [ + ("plus", [24, 24]), + ("padded-parent", [24, 24]), + ("wide", [24, 12]), + ("no-ratio", [300, 150]), + ("width", [80, 80]), + ("css-height", [32, 32]), + ("margin", [20, 13]), + ("css-ratio", [24, 12]), + ("percent", [24, 24]), + ("min", [40, 40]), + ("max", [20, 20]), + ("absolute", [64, 64]), + ("absolute-left", [54, 54]), + ("absolute-right", [54, 54]), + ("absolute-insets", [44, 44]), + ("absolute-margin", [48, 48]), + ("shrink", [0, 0]), + ("shrink-percent", [300, 300]), + ] { + assert_eq!( + geometry[id], + serde_json::json!(expected), + "Chromium-calibrated SVG geometry mismatch for {id}: {geometry}", + ); + } + let raster = moli_paint::raster_snapshot(&snapshot)?; + let pixel = |x: u32, y: u32| { + let index = ((y * raster.width + x) * 4) as usize; + <[u8; 4]>::try_from(&raster.rgba[index..index + 4]).expect("RGBA pixel") + }; + assert_eq!(pixel(24, 24), [34, 34, 34, 255]); + assert_eq!(pixel(14, 24), [238, 238, 238, 255]); + assert_eq!(pixel(30, 30), [238, 238, 238, 255]); + assert_eq!(pixel(24, 55), [255, 255, 255, 255]); + Ok::<_, anyhow::Error>(()) + }) + .await + .expect("inline SVG automatic sizing fixture should run"); +} + #[tokio::test(flavor = "current_thread")] async fn screenshot_projects_external_svg_root_paint_like_chromium() { run_page_vm_async_test(async move { diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs index 2b94d765a3..b26979a9de 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs @@ -3623,10 +3623,9 @@ document.body.innerHTML = ` assert_eq!(first_pixel(65, 5), [100, 106, 115, 255]); assert_eq!(first_pixel(80, 5), [255, 255, 255, 255]); assert_eq!(first_pixel(140, 40), [0, 128, 0, 255]); - // A viewBox-only square uses its 1:1 ratio inside the CSS 300x150 - // default object size, yielding a 150x150 replaced box rather than - // losing the ratio and stretching to 300x150. - assert_eq!(first_pixel(170, 40), [255, 255, 255, 255]); + // The viewBox-only square stretches to the containing block's width + // and transfers that width through its 1:1 ratio, like Chromium. + assert_eq!(first_pixel(170, 40), [0, 128, 0, 255]); page_vm.vm_mut().eval( "document.getElementById('icon').classList.add('blue');document.getElementById('shape').setAttribute('x','2');document.getElementById('feishu-time').setAttribute('width','2em');'mutated'", diff --git a/moli-renderer-v8/tests/fixtures/inline-svg-auto-size.html b/moli-renderer-v8/tests/fixtures/inline-svg-auto-size.html new file mode 100644 index 0000000000..af4f02fbfc --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/inline-svg-auto-size.html @@ -0,0 +1,29 @@ + + + + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+