fix(layout): size inline SVG icons from available space

Keep track of SVGs without intrinsic dimensions so a viewBox-only icon
stretches along the available inline axis instead of acquiring a fixed
150px natural size. Preserve author dimensions, aspect ratios, box insets,
and intrinsic percentage contributions; resolve absolute sizing probes
against their containing block and positioning insets.

Add Chromium-calibrated geometry and paint coverage for search icons,
padded containers, explicit and constrained sizes, absolute positioning,
and shrink-to-fit containers. Correct the older SVG paint expectation
that assumed the same 150px fallback.

Validation:
- cargo fmt --all
- cargo clippy --workspace --all-targets --all-features -- -D warnings
- cargo nextest run --no-fail-fast (18,310 passed, 13 skipped)
- Replayed the original Google document through moli serve: both the plus
  and apps icons are 24x24 instead of 150x150.
This commit is contained in:
ldm0
2026-09-21 06:36:01 +08:00
parent 796376878b
commit bb88e9c2c7
5 changed files with 234 additions and 4 deletions
+131
View File
@@ -18,6 +18,7 @@ pub(crate) struct ReplacedContext {
inherent_size: Size<f32>,
attribute_size: Size<Option<f32>>,
inherent_ratio: Option<f32>,
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<ResolvedAspectRatio>,
style: &taffy::Style<Atom>,
writing_mode: taffy::WritingMode,
sizing_mode: SizingMode,
requested_axis: RequestedAxis,
) -> Size<f32> {
@@ -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::<Atom> {
@@ -572,6 +702,7 @@ mod tests {
&image_context(),
ResolvedAspectRatio::new(2.0, box_sizing),
&style,
taffy::WritingMode::HorizontalTb,
SizingMode::InherentSize,
RequestedAxis::Both,
)
+1
View File
@@ -2059,6 +2059,7 @@ where
&context,
resolved_aspect_ratio,
&style,
writing_mode,
inputs.sizing_mode,
inputs.axis,
);
@@ -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 {
@@ -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'",
@@ -0,0 +1,29 @@
<head>
<style>
html,body{margin:0;background:white}
.button{display:flex;align-items:center;justify-content:center;width:48px;height:48px;background:#eee}
.icon{width:24px;height:24px;color:#222}
.case{position:absolute;left:100px;width:24px;height:24px}
svg{vertical-align:top}
</style>
</head>
<body>
<div class=button><span class=icon><svg id=plus viewBox="0 0 24 24"><path fill="currentColor" d="M11 4h2v7h7v2h-7v7h-2v-7H4v-2h7z"/></svg></span></div>
<a style="position:absolute;left:60px;top:0;display:block;box-sizing:border-box;width:40px;height:40px;padding:8px"><svg id=padded-parent viewBox="0 0 24 24"><circle cx=12 cy=12 r=8/></svg></a>
<div class=case style="top:60px"><svg id=wide viewBox="0 0 20 10"></svg></div>
<div class=case style="top:100px"><svg id=no-ratio></svg></div>
<div class=case style="top:260px"><svg id=width width=80 viewBox="0 0 10 10"></svg></div>
<div class=case style="top:350px"><svg id=css-height style="height:32px" viewBox="0 0 10 10"></svg></div>
<div class=case style="top:390px"><svg id=margin style="margin:0 2px;border:1px solid;padding:2px" viewBox="0 0 20 10"></svg></div>
<div class=case style="top:420px"><svg id=css-ratio style="aspect-ratio:2/1"></svg></div>
<div class=case style="top:450px"><svg id=percent width="100%" viewBox="0 0 10 10"></svg></div>
<div class=case style="top:480px"><svg id=min style="min-width:40px" viewBox="0 0 10 10"></svg></div>
<div class=case style="top:530px"><svg id=max style="max-width:20px" viewBox="0 0 10 10"></svg></div>
<div style="position:absolute;left:420px;top:0;width:64px"><svg id=absolute style="position:absolute;left:0;top:0" viewBox="0 0 10 10"></svg></div>
<div style="position:absolute;left:500px;top:0;width:64px"><svg id=absolute-left style="position:absolute;left:10px;top:0" viewBox="0 0 10 10"></svg></div>
<div style="position:absolute;left:580px;top:0;width:64px"><svg id=absolute-right style="position:absolute;right:10px;top:0" viewBox="0 0 10 10"></svg></div>
<div style="position:absolute;left:660px;top:0;width:64px"><svg id=absolute-insets style="position:absolute;left:10px;right:10px;top:0" viewBox="0 0 10 10"></svg></div>
<div style="position:absolute;left:740px;top:0;width:64px"><svg id=absolute-margin style="position:absolute;left:10px;top:0;margin:0 3px" viewBox="0 0 10 10"></svg></div>
<div style="position:absolute;left:420px;top:80px"><span style="display:inline-block"><svg id=shrink viewBox="0 0 10 10"></svg></span></div>
<div style="position:absolute;left:420px;top:100px"><span style="display:inline-block"><svg id=shrink-percent width="100%" viewBox="0 0 10 10"></svg></span></div>
</body>