Merge pull request #1799 from switch87/fix/1557-css-not

SVG import: style sheet rules with :not() selectors apply (#1557)
This commit is contained in:
BFlat authored and GitHub committed 2026-10-14 05:42:02 +07:00
commit 1a7ad48633
3 files changed
+209 -12

No files matched your search

+40 -6
View File
@@ -476,6 +476,9 @@ pub(super) struct Edits {
list: Vec<(std::ops::Range<usize>, String)>,
/// The ids made up so far, by element.
ids: HashMap<roxmltree::NodeId, String>,
/// Declarations added to elements' `style`s, by element: (where they go, whether the element
/// has a `style` attribute, the declarations, XML-escaped).
styles: HashMap<roxmltree::NodeId, (usize, bool, String)>,
}
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.strip_prefix(';').unwrap_or(&decls)) });
}
if self.list.is_empty() {
return svg.into();
}
@@ -534,12 +552,15 @@ impl Edits {
/// to come back hidden; one something links to (a `<use>` 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 `<symbol>` with a `viewBox` become numbers ([`symbol_units`]).
/// * percentage lengths in a `<symbol>` 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 `<svg:image>`.)
let css_transform = has_css_transform(svg);
let not = svg.contains(":not(");
if !css_transform
&& !not
&& !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "groupmode"].iter().any(|t| svg.contains(t))
{
return (svg.into(), found);
@@ -570,8 +591,12 @@ fn prepass<'s>(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");
}
}
+125 -6
View File
@@ -1,9 +1,9 @@
//! The style cascade for the `<text>` pre-pass: `<style>` rules, the `style` attribute and
//! presentation attributes.
//!
//! Selectors: type, `*`, `#id`, any number of `.class`es and `[attr]` / `[attr=value]`, combined by
//! descendant (space) and child (`>`) combinators. Rules with other selectors (pseudo-classes,
//! sibling combinators, namespaces) never match, and at-rule blocks (`@media`, `@font-face`…) are
//! Selectors: type, `*`, `#id`, any number of `.class`es, `[attr]` / `[attr=value]` and `:not()` of a
//! list of those, combined by descendant (space) and child (`>`) combinators. Rules with other
//! selectors (other pseudo-classes, sibling combinators, namespaces) never match, and at-rule blocks (`@media`, `@font-face`…) are
//! skipped. Precedence: `!important`, then the `style` attribute, then specificity, then source
//! order; a presentation attribute applies only when no rule sets the property.
@@ -57,6 +57,8 @@ struct Compound {
id: Option<String>,
classes: Vec<String>,
attrs: Vec<(String, Option<String>)>,
/// The compounds of `:not()`s: the element must match none of them.
nots: Vec<Compound>,
}
impl Compound {
@@ -65,6 +67,7 @@ impl Compound {
&& self.id.as_deref().is_none_or(|id| n.attribute("id") == Some(id))
&& (self.classes.is_empty() || n.attribute("class").is_some_and(|c| self.classes.iter().all(|k| c.split_whitespace().any(|x| x == k))))
&& self.attrs.iter().all(|(a, v)| n.attribute(a.as_str()).is_some_and(|x| v.as_deref().is_none_or(|v| x == v)))
&& !self.nots.iter().any(|c| c.matches(n))
}
}
@@ -81,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> {
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<Self> {
fn ident(chars: &mut std::iter::Peekable<std::str::Chars>) -> Option<String> {
let mut out = String::new();
while let Some(&c) = chars.peek().filter(|c| is_ident(**c)) {
@@ -97,6 +109,8 @@ impl Selector {
let mut links: Vec<bool> = vec![];
let mut cur: Option<Compound> = None;
let mut link: Option<bool> = None;
// What the `:not()`s add: the specificity of each one's most specific argument.
let mut not_specificity = (0, 0, 0);
let mut chars = s.trim().chars().peekable();
while let Some(&c) = chars.peek() {
if c.is_whitespace() || c == '>' {
@@ -141,6 +155,37 @@ impl Selector {
}
comp.attrs.push((name.to_string(), value));
}
':' => {
chars.next();
if nesting >= MAX_NOT_NESTING || !ident(&mut chars)?.eq_ignore_ascii_case("not") || chars.next() != Some('(') {
return None;
}
let mut depth = 1;
let mut arg = String::new();
for c in chars.by_ref() {
match c {
'(' => depth += 1,
')' => depth -= 1,
_ => {}
}
if depth == 0 {
break;
}
arg.push(c);
}
if depth != 0 {
return None;
}
// 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_nested(a, nesting + 1)?;
let [(c, _)]: [(Compound, bool); 1] = s.parts.try_into().ok()?;
most = most.max(s.specificity);
comp.nots.push(c);
}
not_specificity = (not_specificity.0 + most.0, not_specificity.1 + most.1, not_specificity.2 + most.2);
}
c if is_ident(c) => comp.tag = Some(ident(&mut chars)?),
_ => return None,
}
@@ -153,7 +198,7 @@ impl Selector {
if comps.is_empty() {
return None;
}
let mut specificity = (0, 0, 0);
let mut specificity = not_specificity;
for c in &comps {
specificity.0 += c.id.is_some() as u32;
specificity.1 += (c.classes.len() + c.attrs.len()) as u32;
@@ -164,6 +209,10 @@ impl Selector {
Some(Self { parts, specificity })
}
fn has_not(&self) -> bool {
self.parts.iter().any(|(c, _)| !c.nots.is_empty())
}
fn matches(&self, n: XNode) -> bool {
fn go(parts: &[(Compound, bool)], n: XNode) -> bool {
let Some(((c, child), rest)) = parts.split_first() else { return true };
@@ -233,6 +282,19 @@ impl Index {
}
}
/// `list` split at the commas outside parentheses (those in a `:not()` stay).
fn top_level_commas(list: &str) -> impl Iterator<Item = &str> {
let mut depth = 0usize;
list.split(move |c| {
match c {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
_ => {}
}
c == ',' && depth == 0
})
}
/// Rules from every `<style>` element, in source order.
fn parse_sheet(css: &str, out: &mut Vec<Rule>) {
// Drop comments.
@@ -278,7 +340,7 @@ fn parse_sheet(css: &str, out: &mut Vec<Rule>) {
}
let close = s[open..].find('}').map_or(s.len(), |i| open + i);
let decls = Rc::new(parse_decls(&s[open + 1..close]));
for sel in head.split(',') {
for sel in top_level_commas(head) {
if let Some(selector) = Selector::parse(sel) {
out.push(Rule { selector, decls: decls.clone() });
}
@@ -340,6 +402,27 @@ impl Styles {
m
}
/// Are there rules with a `:not()` (which usvg's style sheets drop)?
pub(super) fn has_not_rules(&self) -> bool {
self.rules.iter().any(|r| r.selector.has_not())
}
/// The properties rules with a `:not()` set on `n`, each with its value after the cascade.
pub(super) fn set_by_not_rules(&self, n: XNode) -> Vec<(String, String)> {
let declared = self.declared(n);
let mut out: Vec<(String, String)> = Vec::new();
for r in self.rules.iter().filter(|r| r.selector.has_not() && r.selector.matches(n)) {
for (k, ..) in r.decls.iter() {
if !out.iter().any(|(o, _)| o == k)
&& let Some(v) = declared.get(k)
{
out.push((k.clone(), v.clone()));
}
}
}
out
}
/// A property specified on this element (rules and `style` beat the presentation attribute).
pub(super) fn own(&self, n: XNode, name: &str) -> Option<String> {
self.declared(n).get(name).cloned().or_else(|| n.attribute(name).map(|v| v.trim().to_string()))
@@ -591,7 +674,7 @@ mod tests {
#[test]
fn unsupported_selectors_are_dropped() {
for s in ["a + b", "a ~ b", "a:hover", "svg|text", "> a", "a >", ""] {
for s in ["a + b", "a ~ b", "a:hover", "svg|text", "> a", "a >", "", "a:not(b c)", "a:not(.x", "a:not(b > c)", "a:not(:hover)"] {
assert!(Selector::parse(s).is_none(), "{s}");
}
let s = Selector::parse("g > text.x#y").unwrap();
@@ -599,4 +682,40 @@ mod tests {
assert_eq!(s.parts.len(), 2);
assert!(s.parts[0].1, "text must be a child of g");
}
/// #1557: `:not()` of a list of compound selectors; it counts as its most specific argument.
#[test]
fn not_selectors() {
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#"<svg xmlns="http://www.w3.org/2000/svg"><style>
rect:not(.keep) { fill: green }
g > :not(rect, #t1) { stroke: blue }
:not(:not(.both)) { opacity: 0.5 }
.keep { fill: red }
</style>
<rect id="r0" fill="red"/><rect id="r1" class="keep"/>
<g><text id="t0">x</text><text id="t1">x</text><rect id="r2" class="both"/></g>
</svg>"#,
|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());
},
);
}
}
+44
View File
@@ -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##"<style>
rect:not(.keep) { fill: #00ff00 }
#b { fill: #0000ff }
g:not(.x) { fill: #00ffff }
.s:not(#z) { fill: #ff00ff }
.h:not(.shown) { display: none }
.t:not(.u) { transform: translate(100px, 0) }
.i:not(.u) { fill: #00ff00 !important }
</style>
<rect id="a" x="0" y="0" width="20" height="20" fill="#ff0000"/>
<rect id="b" x="20" y="0" width="20" height="20" fill="#ff0000"/>
<rect class="keep" x="40" y="0" width="20" height="20" fill="#ff0000"/>
<g><circle cx="70" cy="10" r="10"/></g>
<rect class="s" x="80" y="0" width="20" height="20" style="fill:#ff0000"/>
<rect class="i" x="100" y="0" width="20" height="20" style="fill:#ff0000"/>
<rect class="h" x="0" y="40" width="20" height="20" fill="#000000"/>
<rect class="t" x="0" y="80" width="20" height="20" fill="#000000"/>"##),
&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 `<symbol>` with a `viewBox` are of the `viewBox`, so the symbol draws as
/// the same one written with numbers.
#[test]