mirror of
https://github.com/lexmount/moli.git
synced 2026-10-05 00:00:55 +00:00
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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user