From 5f8c4e9ab53f412f4d4b8864f653d04b00a9f6b5 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 19:52:01 +0200 Subject: [PATCH 1/3] SVG import: style sheet rules with :not() selectors apply (#1557) usvg's style sheets (simplecss) drop rules whose selector has a :not(), so rect:not(.keep) { fill: green } left the rect as it was. The text pass's own cascade now reads :not() of a list of compound selectors (counted as its most specific argument), and the import pre-pass writes what such rules set on an element into its style attribute, with the value that wins the cascade there, so usvg reads what CSS would: against other rules, the style attribute and !important, inherited from groups, display: none and CSS transforms included. Selector lists are split at top-level commas only. --- crates/svg/src/import.rs | 46 ++++++++++-- crates/svg/src/import/css.rs | 117 +++++++++++++++++++++++++++-- crates/svg/tests/import_details.rs | 44 +++++++++++ 3 files changed, 195 insertions(+), 12 deletions(-) diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 289693e8..231963e3 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -476,6 +476,9 @@ pub(super) struct Edits { list: Vec<(std::ops::Range, String)>, /// The ids made up so far, by element. ids: HashMap, + /// Declarations added to elements' `style`s, by element: (where they go, whether the element + /// has a `style` attribute, the declarations, XML-escaped). + styles: HashMap, } impl Edits { @@ -505,9 +508,24 @@ impl Edits { } } + /// Add the declaration `decl` (XML-escaped already) to the `style` of element `n` (in `svg`), + /// after the ones already there (so it wins over them and over style sheet rules). + fn style(&mut self, svg: &str, n: XNode, decl: &str) { + let (_, _, decls) = + self.styles.entry(n.id()).or_insert_with(|| match n.attributes().find(|a| a.name() == "style" && a.namespace().is_none()) { + Some(a) => (a.range_value().end, true, String::new()), + None => (tag_name_end(svg, n), false, String::new()), + }); + decls.push(';'); + decls.push_str(decl); + } + /// `svg` with the edits made; overlapping ones and ranges off character boundaries are /// skipped. fn apply(mut self, svg: &str) -> Cow<'_, str> { + for (at, has_style, decls) in std::mem::take(&mut self.styles).into_values() { + self.insert(at, if has_style { decls } else { format!(" style=\"{}\"", &decls[1..]) }); + } if self.list.is_empty() { return svg.into(); } @@ -534,12 +552,15 @@ impl Edits { /// to come back hidden; one something links to (a `` template) stays as it is; /// * shapes with a non-scaling stroke get an id when they have none, recorded ([`vector_effect`]); /// * linked files are read ([`files`]); -/// * percentage lengths in a `` with a `viewBox` become numbers ([`symbol_units`]). +/// * percentage lengths in a `` with a `viewBox` become numbers ([`symbol_units`]); +/// * what style sheet rules with a `:not()` set goes in `style` attributes ([`not_rules`]). fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) { let mut found = Found::default(); // (`:` for prefixed elements such as ``.) let css_transform = has_css_transform(svg); + let not = svg.contains(":not("); if !css_transform + && !not && !["(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) { } } let (display, non_scaling) = (svg.contains("display"), svg.contains(vector_effect::NON_SCALING)); - if display || non_scaling { + if display || non_scaling || not { let css = css::Styles::new(&xml); + // First: an undisplayed object shown for usvg is shown after these. + if not && css.has_not_rules() { + not_rules(svg, &xml, &css, &mut edits); + } if display { hidden_objects(svg, &xml, &css, &mut edits, &mut found.hidden); } @@ -594,6 +619,18 @@ fn has_css_transform(svg: &str) -> bool { svg.match_indices("transform").any(|(i, t)| svg.get(i + t.len()..).is_some_and(|s| s.trim_start().starts_with(':'))) } +/// usvg's style sheets drop rules with a `:not()` selector (#1557). The properties such rules set +/// on an element go in its `style` attribute, with the value that wins the cascade there (which +/// can be another rule's or the attribute's own), so usvg reads what CSS would. +fn not_rules(svg: &str, xml: &roxmltree::Document, css: &css::Styles, edits: &mut Edits) { + for n in xml.descendants().filter(XNode::is_element) { + for (name, value) in css.set_by_not_rules(n) { + let value = if name == "transform" { css_transform::to_svg(&value).unwrap_or(value) } else { value }; + edits.style(svg, n, &crate::xml_escape(&format!("{name}:{value}"))); + } + } +} + /// The elements that are objects (what [`hidden_objects`] shows). const OBJECTS: &[&str] = &["g", "a", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "image", "text", "use"]; @@ -615,10 +652,7 @@ fn hidden_objects(svg: &str, xml: &roxmltree::Document, css: &css::Styles, edits } ids.insert(edits.id(svg, n)); // A `style` declaration wins over the attribute and style sheet rules. - match n.attributes().find(|a| a.name() == "style" && a.namespace().is_none()) { - Some(a) => edits.insert(a.range_value().end, ";display:inline".to_string()), - None => edits.insert(tag_name_end(svg, n), " style=\"display:inline\"".to_string()), - } + edits.style(svg, n, "display:inline"); } } diff --git a/crates/svg/src/import/css.rs b/crates/svg/src/import/css.rs index 172e93b1..5c87742e 100644 --- a/crates/svg/src/import/css.rs +++ b/crates/svg/src/import/css.rs @@ -1,9 +1,9 @@ //! The style cascade for the `` pre-pass: ` + + xx + "#, + |doc, st| { + assert_eq!(st.own(by_id(doc, "r0"), "fill").as_deref(), Some("green")); + assert_eq!(st.own(by_id(doc, "r1"), "fill").as_deref(), Some("red")); + assert_eq!(st.own(by_id(doc, "t0"), "stroke").as_deref(), Some("blue")); + assert_eq!(st.own(by_id(doc, "t1"), "stroke"), None); + assert_eq!(st.own(by_id(doc, "r2"), "stroke"), None); + assert_eq!(st.own(by_id(doc, "r2"), "opacity").as_deref(), Some("0.5")); + assert_eq!(st.own(by_id(doc, "r0"), "opacity"), None); + assert!(st.has_not_rules()); + assert_eq!(st.set_by_not_rules(by_id(doc, "r0")), [("fill".to_string(), "green".to_string())]); + assert!(st.set_by_not_rules(by_id(doc, "r1")).is_empty()); + }, + ); + } } diff --git a/crates/svg/tests/import_details.rs b/crates/svg/tests/import_details.rs index 9929aff9..abd21972 100644 --- a/crates/svg/tests/import_details.rs +++ b/crates/svg/tests/import_details.rs @@ -203,6 +203,50 @@ fn css_transforms_move_objects() { assert!(near(b[2], -20.0, 0.0, 0.0, 50.0), "{:?}", b[2]); } +/// #1557: style sheet rules with a `:not()` selector apply, with CSS precedence: against other +/// rules, the `style` attribute, `!important`, and for a shape, a group (inherited) and a hidden +/// object. +#[test] +fn not_selectors_apply() { + let d = import_with( + &svg(r##" + + + + + + + + "##), + &ImportOptions::default(), + ) + .unwrap() + .0; + let im = render_artboard(&d); + let rgb = |x: u32, y: u32| { + let p = im.pixel(x, y); + [p[0], p[1], p[2]] + }; + assert_eq!(rgb(10, 10), [0, 255, 0], "rect:not(.keep) beats the fill attribute"); + assert_eq!(rgb(30, 10), [0, 0, 255], "#b is more specific"); + assert_eq!(rgb(50, 10), [255, 0, 0], ".keep is left alone"); + assert_eq!(rgb(70, 10), [0, 255, 255], "inherited from the group"); + assert_eq!(rgb(90, 10), [255, 0, 0], "the style attribute wins"); + assert_eq!(rgb(110, 10), [0, 255, 0], "!important beats the style attribute"); + assert_eq!(rgb(10, 90), [255, 255, 255], "moved by the CSS transform"); + assert_eq!(rgb(110, 90), [0, 255, 0]); + let hidden: Vec<&Node> = art(&d).into_iter().filter(|n| !n.visible).collect(); + assert_eq!(hidden.len(), 1, "display: none from a :not() rule comes in as a hidden object"); +} + /// #1561: percentages in a `` with a `viewBox` are of the `viewBox`, so the symbol draws as /// the same one written with numbers. #[test] From 6f9595542d359f1dbf57de1579b09ac3236f88c9 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 21:59:00 +0200 Subject: [PATCH 2/3] SVG import: no slice indexing when writing a new style attribute --- crates/svg/src/import.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 231963e3..323621e1 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -524,7 +524,7 @@ impl Edits { /// skipped. fn apply(mut self, svg: &str) -> Cow<'_, str> { for (at, has_style, decls) in std::mem::take(&mut self.styles).into_values() { - self.insert(at, if has_style { decls } else { format!(" style=\"{}\"", &decls[1..]) }); + self.insert(at, if has_style { decls } else { format!(" style=\"{}\"", decls.strip_prefix(';').unwrap_or(&decls)) }); } if self.list.is_empty() { return svg.into(); From 301a99d91ceac347e6449eea14c7251eb18b3582 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 04:49:31 +0700 Subject: [PATCH 3/3] SVG import: nested :not() selectors are bounded --- crates/svg/src/import/css.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/svg/src/import/css.rs b/crates/svg/src/import/css.rs index 5c87742e..8f508a7f 100644 --- a/crates/svg/src/import/css.rs +++ b/crates/svg/src/import/css.rs @@ -84,9 +84,18 @@ fn is_ident(c: char) -> bool { c.is_alphanumeric() || c == '-' || c == '_' } +/// How deep `:not()`s may nest (real style sheets nest one or two). +const MAX_NOT_NESTING: usize = 8; + impl Selector { /// `None` for selectors this cascade doesn't support (they never match). fn parse(s: &str) -> Option { + Self::parse_nested(s, 0) + } + + /// [`Self::parse`] for a selector `nesting` `:not()`s deep: past [`MAX_NOT_NESTING`] it is not + /// supported, so a style sheet of `:not(:not(:not(…` can't exhaust the stack. + fn parse_nested(s: &str, nesting: usize) -> Option { fn ident(chars: &mut std::iter::Peekable) -> Option { let mut out = String::new(); while let Some(&c) = chars.peek().filter(|c| is_ident(**c)) { @@ -148,7 +157,7 @@ impl Selector { } ':' => { chars.next(); - if !ident(&mut chars)?.eq_ignore_ascii_case("not") || chars.next() != Some('(') { + if nesting >= MAX_NOT_NESTING || !ident(&mut chars)?.eq_ignore_ascii_case("not") || chars.next() != Some('(') { return None; } let mut depth = 1; @@ -170,7 +179,7 @@ impl Selector { // A list of compound selectors (each may hold a `:not()` of its own). let mut most = (0, 0, 0); for a in top_level_commas(&arg) { - let s = Self::parse(a)?; + let s = Self::parse_nested(a, nesting + 1)?; let [(c, _)]: [(Compound, bool); 1] = s.parts.try_into().ok()?; most = most.max(s.specificity); comp.nots.push(c); @@ -680,6 +689,11 @@ mod tests { assert_eq!(Selector::parse("rect:not(.keep)").unwrap().specificity, (0, 1, 1)); assert_eq!(Selector::parse("g :not(#a, .b.c, text)").unwrap().specificity, (1, 0, 1)); assert_eq!(Selector::parse(":not(rect):not([x])").unwrap().specificity, (0, 1, 1)); + // Nesting is bounded: a hostile sheet can't recurse without end. + let nested = |n: usize| format!("{}.x{}", ":not(".repeat(n), ")".repeat(n)); + assert!(Selector::parse(&nested(MAX_NOT_NESTING)).is_some()); + assert!(Selector::parse(&nested(MAX_NOT_NESTING + 1)).is_none()); + assert!(Selector::parse(&nested(200_000)).is_none()); with_doc( r#"