fix(svg): cascade presentation attributes

This commit is contained in:
ldm0
2026-08-24 12:31:56 +08:00
parent f2a2e021e4
commit c17bb0141e
8 changed files with 223 additions and 20 deletions
@@ -17,10 +17,14 @@ html,body{margin:0;background:white}
#run{position:absolute;left:0;top:0;width:80px;height:40px;margin:0;padding:0;border:0;background:rgb(174,67,28);color:white}
.icon{position:absolute;left:30px;top:10px;display:block;width:20px;height:20px;fill:currentcolor}
#stroke{position:absolute;left:100px;top:10px;display:block;width:20px;height:20px;color:rgb(0,128,0);fill:none;stroke:currentcolor;stroke-width:4px;stroke-linecap:butt}
#presentation{position:absolute;left:140px;top:10px;display:block;color:rgb(51,51,51)}
</style>`;
document.body.innerHTML = `
<button id=run><svg id=run-icon class=icon viewBox="0 0 20 20"><path d="M2 2v16l16-8z"></path></svg></button>
<svg id=stroke viewBox="0 0 20 20"><path d="M2 10h16"></path></svg>`;
<button id=run><svg id=run-icon class=icon fill="none" viewBox="0 0 20 20"><path d="M2 2v16l16-8z"></path></svg></button>
<svg id=stroke stroke="red" viewBox="0 0 20 20"><path d="M2 10h16"></path></svg>
<svg id=presentation width="20" height="20" fill="none" viewBox="0 0 20 20">
<rect id=presentation-shape x="2" y="2" width="16" height="16" stroke="currentColor" stroke-width="2"></rect>
</svg>`;
'installed'
"#,
)?;
@@ -28,15 +32,15 @@ document.body.innerHTML = `
assert_eq!(
page_vm.vm_mut().eval(
"[getComputedStyle(document.getElementById('run-icon')).fill,getComputedStyle(document.getElementById('stroke')).stroke].join('|')",
"[getComputedStyle(document.getElementById('run-icon')).fill,getComputedStyle(document.getElementById('stroke')).stroke,getComputedStyle(document.getElementById('presentation')).fill,getComputedStyle(document.getElementById('presentation-shape')).stroke,getComputedStyle(document.getElementById('presentation-shape')).strokeWidth].join('|')",
)?,
"rgb(255, 255, 255)|rgb(0, 128, 0)",
"Stylo must resolve the external SVG paint rules before the paint bridge snapshots them",
"rgb(255, 255, 255)|rgb(0, 128, 0)|none|rgb(51, 51, 51)|2px",
"presentation attributes must enter Stylo below author CSS, retain SVG unitless lengths, and inherit currentColor normally",
);
let snapshot = page_vm
.vm_mut()
.screenshot_layout_snapshot(moli_layout::PaintViewport::new(140, 50, 1.0))?
.screenshot_layout_snapshot(moli_layout::PaintViewport::new(170, 50, 1.0))?
.expect("inline SVG computed-paint fixture must retain a layout root");
let raster = moli_paint::raster_snapshot(&snapshot)?;
let pixel = |x: u32, y: u32| {
@@ -48,6 +52,34 @@ document.body.innerHTML = `
assert_eq!(pixel(15, 20), [174, 67, 28, 255]);
assert_eq!(pixel(110, 20), [0, 128, 0, 255]);
assert_eq!(pixel(90, 20), [255, 255, 255, 255]);
assert_eq!(pixel(142, 20), [51, 51, 51, 255]);
assert_eq!(
pixel(150, 20),
[255, 255, 255, 255],
"fill=none must not be replaced by SVG's initial black fill"
);
page_vm
.vm_mut()
.eval("document.getElementById('presentation').setAttribute('fill','blue')")?;
assert_eq!(
page_vm
.vm_mut()
.eval("getComputedStyle(document.getElementById('presentation')).fill")?,
"rgb(0, 0, 255)",
"changing a presentation attribute must invalidate its computed style",
);
let mutated = page_vm
.vm_mut()
.screenshot_layout_snapshot(moli_layout::PaintViewport::new(170, 50, 1.0))?
.expect("mutated SVG presentation fixture must retain a layout root");
let mutated_raster = moli_paint::raster_snapshot(&mutated)?;
let center = ((20 * mutated_raster.width + 150) * 4) as usize;
assert_eq!(
&mutated_raster.rgba[center..center + 4],
[0, 0, 255, 255],
"the invalidated computed fill must reach the inline SVG paint bridge"
);
Ok::<_, anyhow::Error>(())
})
.await
@@ -163,12 +163,14 @@ pub(crate) enum StyleAttributeImpact {
impl StyleAttributeImpact {
pub(crate) fn for_attribute_name(name: &str) -> Self {
match name.to_ascii_lowercase().as_str() {
let name = name.to_ascii_lowercase();
match name.as_str() {
"style" | "class" | "id" => Self::ComputedStyle,
"hidden" | "width" | "height" | "cols" | "rows" | "size" | "value" | "border"
| "slot" | "align" => Self::LayoutMetric,
"href" | "rel" | "media" | "blocking" | "disabled" => Self::StylesheetLinkage,
"type" => Self::LayoutMetricAndStylesheetLinkage,
_ if moli_selector::is_svg_presentation_attribute_name(&name) => Self::LayoutMetric,
_ => Self::None,
}
}
@@ -2540,6 +2540,13 @@ fn style_attribute_impact_classifies_dom_and_stylesheet_inputs() {
assert!(!StyleAttributeImpact::for_attribute_name("width").changes_computed_style());
assert!(!StyleAttributeImpact::for_attribute_name("width").changes_stylesheet_linkage());
for attribute in ["fill", "stroke", "stroke-width", "paint-order"] {
assert!(
StyleAttributeImpact::for_attribute_name(attribute).affects_layout_metric(),
"SVG presentation attribute {attribute} must invalidate computed paint"
);
}
assert!(!StyleAttributeImpact::for_attribute_name("href").affects_layout_metric());
assert!(StyleAttributeImpact::for_attribute_name("href").changes_stylesheet_linkage());
+1 -1
View File
@@ -56,7 +56,7 @@ pub use stylo::{
StyloSourceStyleInvalidationTargetResultRecord, StyloStateInvalidationRoot,
StyloStyleInvalidationQuery, StyloStyleInvalidationSnapshot,
StyloStyleInvalidationSnapshotAttribute, StyloStyleSourceScope,
StyloStylesheetSourceScopeFallbackInput,
StyloStylesheetSourceScopeFallbackInput, is_svg_presentation_attribute_name,
stylo_attribute_change_can_skip_fallback_without_dependency,
stylo_attribute_change_can_use_retained_invalidator, stylo_element_dependency_snapshot,
stylo_fallback_roots_plan, stylo_focus_change_invalidation_roots,
+1
View File
@@ -75,6 +75,7 @@ pub use invalidation::{
stylo_state_change_can_use_retained_invalidator, stylo_stylesheet_owner_is_in_source_scope,
stylo_stylesheet_source_scope_fallback_roots,
};
pub use presentation::is_svg_presentation_attribute_name;
pub(crate) use query::html_directionality;
use query::{QueryDocument, QueryElement, QueryNode};
#[cfg(test)]
+167 -12
View File
@@ -8,30 +8,128 @@
use selectors::sink::Push;
use style::{
applicable_declarations::ApplicableDeclarationBlock,
properties::{Importance, PropertyDeclaration, PropertyDeclarationBlock},
context::QuirksMode,
properties::{
Importance, PropertyDeclaration, PropertyDeclarationBlock, PropertyId,
SourcePropertyDeclaration, parse_one_declaration_into,
},
rule_tree::{CascadeLevel, CascadeOrigin},
servo_arc::Arc,
shared_lock::SharedRwLock,
stylesheets::layer_rule::LayerOrder,
stylesheets::{CssRuleType, Origin, UrlExtraData, layer_rule::LayerOrder},
values::specified::{LengthPercentage, NoCalcLength, NoCalcPercentage},
};
use style_traits::ParsingMode;
use crate::dom::native::Element;
use crate::dom::{
NodeId,
native::{DomHost, Element},
};
const SVG_NAMESPACE: &str = "http://www.w3.org/2000/svg";
// Mirrors Blink's CSSPropertyIdForSVGAttributeName allowlist. These attributes
// participate in the author cascade as presentation hints: author rules and an
// inline style override them, while inheritance observes their parsed CSS
// value. Geometry attributes such as the root SVG width/height are handled
// separately below because they have element-specific SVG parsing rules.
const SVG_STYLE_PRESENTATION_ATTRIBUTES: &[&str] = &[
"alignment-baseline",
"baseline-shift",
"buffered-rendering",
"clip",
"clip-path",
"clip-rule",
"color",
"color-interpolation",
"color-interpolation-filters",
"color-rendering",
"cursor",
"direction",
"display",
"dominant-baseline",
"fill",
"fill-opacity",
"fill-rule",
"filter",
"flood-color",
"flood-opacity",
"font-family",
"font-size",
"font-stretch",
"font-style",
"font-variant",
"font-weight",
"image-rendering",
"letter-spacing",
"lighting-color",
"marker-end",
"marker-mid",
"marker-start",
"mask",
"mask-type",
"opacity",
"overflow",
"paint-order",
"pointer-events",
"shape-rendering",
"stop-color",
"stop-opacity",
"stroke",
"stroke-dasharray",
"stroke-dashoffset",
"stroke-linecap",
"stroke-linejoin",
"stroke-miterlimit",
"stroke-opacity",
"stroke-width",
"text-anchor",
"text-decoration",
"text-rendering",
"transform-origin",
"unicode-bidi",
"vector-effect",
"visibility",
"word-spacing",
"writing-mode",
];
/// Whether changing an attribute can change an SVG element's computed style
/// without any selector dependency on that attribute.
pub fn is_svg_presentation_attribute_name(name: &str) -> bool {
matches!(name, "width" | "height") || SVG_STYLE_PRESENTATION_ATTRIBUTES.contains(&name)
}
pub(super) fn synthesize_svg_presentational_hints<V>(
host: &DomHost,
handle: NodeId,
element: &Element,
quirks_mode: QuirksMode,
shared_lock: &SharedRwLock,
hints: &mut V,
) where
V: Push<ApplicableDeclarationBlock>,
{
if element.namespace() != SVG_NAMESPACE || element.local_name() != "svg" {
if element.namespace() != SVG_NAMESPACE {
return;
}
let mut block = PropertyDeclarationBlock::new();
if element.local_name() == "svg" {
append_root_svg_size_declarations(element, &mut block);
}
append_svg_style_presentation_declarations(host, handle, element, quirks_mode, &mut block);
if !block.is_empty() {
hints.push(ApplicableDeclarationBlock::from_declarations(
Arc::new(shared_lock.wrap(block)),
CascadeLevel::new(CascadeOrigin::PresHints),
LayerOrder::root(),
));
}
}
fn append_root_svg_size_declarations(element: &Element, block: &mut PropertyDeclarationBlock) {
for (attribute, is_width) in [("width", true), ("height", false)] {
let Some(value) = element.attribute(attribute) else {
continue;
@@ -46,14 +144,50 @@ pub(super) fn synthesize_svg_presentational_hints<V>(
} else {
PropertyDeclaration::Height(size)
};
hints.push(ApplicableDeclarationBlock::from_declarations(
Arc::new(shared_lock.wrap(PropertyDeclarationBlock::with_one(
declaration,
Importance::Normal,
))),
CascadeLevel::new(CascadeOrigin::PresHints),
LayerOrder::root(),
));
block.push(declaration, Importance::Normal);
}
}
fn append_svg_style_presentation_declarations(
host: &DomHost,
handle: NodeId,
element: &Element,
quirks_mode: QuirksMode,
block: &mut PropertyDeclarationBlock,
) {
let Some(base_url) = host
.owner_document_handle(handle)
.and_then(|document| host.document_base_url_for_handle(document))
else {
return;
};
let url_data = UrlExtraData::from(base_url);
for attribute in element.attributes() {
if !attribute.namespace().is_empty()
|| !SVG_STYLE_PRESENTATION_ATTRIBUTES.contains(&attribute.local_name())
{
continue;
}
let Ok(property) = PropertyId::parse_enabled_for_all_content(attribute.local_name()) else {
continue;
};
let mut declarations = SourcePropertyDeclaration::default();
if parse_one_declaration_into(
&mut declarations,
property,
attribute.value(),
Origin::Author,
&url_data,
None,
ParsingMode::ALLOW_UNITLESS_LENGTH | ParsingMode::ALLOW_ALL_NUMERIC_VALUES,
quirks_mode,
CssRuleType::Style,
)
.is_ok()
{
block.extend(declarations.drain(), Importance::Normal);
}
}
}
@@ -104,4 +238,25 @@ mod tests {
assert!(parse_svg_size_attribute("auto").is_none());
assert!(parse_svg_size_attribute("-1em").is_none());
}
#[test]
fn svg_paint_attributes_are_classified_as_presentational() {
for name in [
"fill",
"fill-opacity",
"stroke",
"stroke-width",
"paint-order",
"shape-rendering",
"width",
"height",
] {
assert!(
is_svg_presentation_attribute_name(name),
"{name} must invalidate presentation hints when mutated"
);
}
assert!(!is_svg_presentation_attribute_name("viewBox"));
assert!(!is_svg_presentation_attribute_name("d"));
}
}
+3
View File
@@ -402,7 +402,10 @@ impl<'a> TElement for QueryElement<'a> {
V: selectors::sink::Push<style::applicable_declarations::ApplicableDeclarationBlock>,
{
super::super::presentation::synthesize_svg_presentational_hints(
self.host,
self.handle,
self.element(),
self.read_quirks_mode(),
self.shared_lock,
hints,
);
@@ -2009,7 +2009,10 @@ impl<'a> TElement for StyleElement<'a> {
V: selectors::sink::Push<style::applicable_declarations::ApplicableDeclarationBlock>,
{
super::presentation::synthesize_svg_presentational_hints(
self.host(),
self.handle(),
self.element(),
self.as_query().read_quirks_mode(),
&self.style_state().shared_lock,
hints,
);