mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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.
This commit is contained in:
1 parent
bb07d267aa
commit
5f8c4e9ab5
3 files changed
+195
-12
No files matched your search
@@ -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[1..]) });
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +100,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 +146,37 @@ impl Selector {
|
||||
}
|
||||
comp.attrs.push((name.to_string(), value));
|
||||
}
|
||||
':' => {
|
||||
chars.next();
|
||||
if !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(a)?;
|
||||
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 +189,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 +200,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 +273,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 +331,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 +393,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 +665,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 +673,35 @@ 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));
|
||||
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());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user