Files
vectorcraft/crates/svg/src/import/css.rs
T
Gert Pellin 2c01a42b35 SVG import: CSS transforms in CSS syntax move objects; the font shorthand styles type
usvg takes a `transform` CSS property as a presentation attribute that
overrides the element's own, but reads its value as SVG: CSS units
(`translate(100px, 0)`, `rotate(45deg)`) don't parse, and the object
was left with no transform at all. Before usvg runs, transform
declarations in `style` attributes and `<style>` rules are now written
in SVG syntax (lengths in px, angles in deg/rad/grad/turn, translateX/Y,
scaleX/Y and skew as SVG's functions); values that can't be are left.

The text pass's own cascade now expands the `font` shorthand into its
longhands, as usvg does for the rest of the drawing, so text styled
with `font: italic bold 30px …` keeps its size, weight and style.

Refs #1557
2026-10-13 08:53:06 +07:00

390 lines
15 KiB
Rust

//! 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
//! skipped. Precedence: `!important`, then the `style` attribute, then specificity, then source
//! order; a presentation attribute applies only when no rule sets the property.
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use usvg::roxmltree;
pub(super) type XNode<'a, 'i> = roxmltree::Node<'a, 'i>;
/// `name: value` declarations of a block or `style` attribute, with their `!important` flag. The
/// `font` shorthand becomes its longhands, as usvg reads it for the rest of the drawing (#1557).
fn parse_decls(s: &str) -> Vec<(String, String, bool)> {
s.split(';')
.filter_map(|d| {
let (k, v) = d.split_once(':')?;
let v = v.trim();
let (v, important) = match v.strip_suffix("important").map(str::trim_end).and_then(|v| v.strip_suffix('!')) {
Some(v) => (v.trim_end(), true),
None => (v, false),
};
Some((k.trim().to_ascii_lowercase(), v.to_string(), important))
})
.flat_map(|(k, v, important)| match k.as_str() {
"font" => font_longhands(&v).into_iter().map(|(k, v)| (k.to_string(), v, important)).collect(),
_ => vec![(k, v, important)],
})
.collect()
}
/// The longhands of a `font` shorthand: those it leaves out back to their initial values. None
/// when it doesn't parse (it is then ignored, as in CSS).
fn font_longhands(v: &str) -> Vec<(&'static str, String)> {
let Ok(f) = svgtypes::FontShorthand::from_str(v) else { return Vec::new() };
let or = |o: Option<&str>| o.unwrap_or("normal").to_string();
vec![
("font-style", or(f.font_style)),
("font-variant", or(f.font_variant)),
("font-weight", or(f.font_weight)),
("font-stretch", or(f.font_stretch)),
("font-size", f.font_size.to_string()),
("font-family", f.font_family.to_string()),
]
}
/// One compound selector (`text.a.b#c[x=y]`).
#[derive(Debug, Default)]
struct Compound {
tag: Option<String>,
id: Option<String>,
classes: Vec<String>,
attrs: Vec<(String, Option<String>)>,
}
impl Compound {
fn matches(&self, n: XNode) -> bool {
self.tag.as_deref().is_none_or(|t| n.tag_name().name() == t)
&& 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)))
}
}
/// A complex selector, rightmost compound first; each compound's flag says whether it must be the
/// child (true) or a descendant (false) of the next one.
#[derive(Debug)]
struct Selector {
parts: Vec<(Compound, bool)>,
/// (ids, classes and attributes, types).
specificity: (u32, u32, u32),
}
fn is_ident(c: char) -> bool {
c.is_alphanumeric() || c == '-' || c == '_'
}
impl Selector {
/// `None` for selectors this cascade doesn't support (they never match).
fn parse(s: &str) -> 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)) {
out.push(c);
chars.next();
}
(!out.is_empty()).then_some(out)
}
// Compounds left to right; `links[i]` joins compounds i and i + 1 (true = child).
let mut comps: Vec<Compound> = vec![];
let mut links: Vec<bool> = vec![];
let mut cur: Option<Compound> = None;
let mut link: Option<bool> = None;
let mut chars = s.trim().chars().peekable();
while let Some(&c) = chars.peek() {
if c.is_whitespace() || c == '>' {
chars.next();
comps.extend(cur.take());
if comps.is_empty() {
return None;
}
link = Some(c == '>' || link == Some(true));
continue;
}
let comp = match &mut cur {
Some(comp) => comp,
None => {
if !comps.is_empty() {
links.push(link.take()?);
}
cur.insert(Compound::default())
}
};
match c {
'*' => {
chars.next();
}
'#' => {
chars.next();
comp.id = Some(ident(&mut chars)?);
}
'.' => {
chars.next();
comp.classes.push(ident(&mut chars)?);
}
'[' => {
chars.next();
let body: String = chars.by_ref().take_while(|c| *c != ']').collect();
let (name, value) = match body.split_once('=') {
Some((n, v)) => (n.trim(), Some(v.trim().trim_matches(|c| c == '"' || c == '\'').to_string())),
None => (body.trim(), None),
};
if name.is_empty() || !name.chars().all(is_ident) {
return None;
}
comp.attrs.push((name.to_string(), value));
}
c if is_ident(c) => comp.tag = Some(ident(&mut chars)?),
_ => return None,
}
}
// A trailing `>` has nothing to apply to.
if cur.is_none() && link == Some(true) {
return None;
}
comps.extend(cur);
if comps.is_empty() {
return None;
}
let mut specificity = (0, 0, 0);
for c in &comps {
specificity.0 += c.id.is_some() as u32;
specificity.1 += (c.classes.len() + c.attrs.len()) as u32;
specificity.2 += c.tag.is_some() as u32;
}
// Rightmost first, each with its link to the compound on its left.
let parts = comps.into_iter().enumerate().rev().map(|(i, c)| (c, i.checked_sub(1).and_then(|k| links.get(k)).is_some_and(|l| *l))).collect();
Some(Self { parts, specificity })
}
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 };
if !c.matches(n) {
return false;
}
if rest.is_empty() {
return true;
}
if *child {
n.parent_element().is_some_and(|p| go(rest, p))
} else {
n.ancestors().skip(1).filter(|a| a.is_element()).any(|a| go(rest, a))
}
}
go(&self.parts, n)
}
}
struct Rule {
selector: Selector,
decls: Rc<Vec<(String, String, bool)>>,
}
/// Rules from every `<style>` element, in source order.
fn parse_sheet(css: &str, out: &mut Vec<Rule>) {
// Drop comments.
let mut text = String::with_capacity(css.len());
let mut rest = css;
while let Some(i) = rest.find("/*") {
text.push_str(&rest[..i]);
rest = rest[i + 2..].find("*/").map_or("", |j| &rest[i + 2 + j + 2..]);
}
text.push_str(rest);
let mut s = text.as_str();
loop {
s = s.trim_start();
if s.is_empty() {
break;
}
let Some(open) = s.find('{') else { break };
let head = s[..open].trim();
if head.starts_with('@') {
// Statement at-rules end at `;` before any block.
if let Some(semi) = s.find(';').filter(|&semi| semi < open) {
s = &s[semi + 1..];
continue;
}
// Skip the (possibly nested) block.
let mut depth = 0usize;
let mut end = s.len();
for (i, c) in s[open..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = open + i + 1;
break;
}
}
_ => {}
}
}
s = &s[end..];
continue;
}
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(',') {
if let Some(selector) = Selector::parse(sel) {
out.push(Rule { selector, decls: decls.clone() });
}
}
s = s.get(close + 1..).unwrap_or("");
}
}
/// The cascade over one parsed document.
pub(super) struct Styles {
rules: Vec<Rule>,
/// Declared properties of each element queried so far.
cache: RefCell<HashMap<roxmltree::NodeId, Rc<HashMap<String, String>>>>,
}
impl Styles {
pub(super) fn new(doc: &roxmltree::Document) -> Self {
let mut rules = vec![];
for st in doc.descendants().filter(|n| n.is_element() && n.tag_name().name() == "style") {
if st.attribute("type").is_some_and(|t| !t.trim().is_empty() && t.trim() != "text/css") {
continue;
}
let css: String = st.children().filter_map(|c| c.text()).collect();
parse_sheet(&css, &mut rules);
}
Self { rules, cache: RefCell::default() }
}
/// Properties set on `n` by rules and its `style` attribute, after the cascade.
fn declared(&self, n: XNode) -> Rc<HashMap<String, String>> {
if let Some(m) = self.cache.borrow().get(&n.id()) {
return m.clone();
}
// (important, inline, specificity, order) → highest wins.
type Key = (bool, bool, (u32, u32, u32), usize);
let mut best: HashMap<String, (Key, String)> = HashMap::new();
let mut put = |name: &str, value: &str, key: Key| {
if best.get(name).is_none_or(|(k, _)| key >= *k) {
best.insert(name.to_string(), (key, value.to_string()));
}
};
let mut order = 0;
for r in self.rules.iter().filter(|r| r.selector.matches(n)) {
for (k, v, imp) in r.decls.iter() {
order += 1;
put(k, v, (*imp, false, r.selector.specificity, order));
}
}
if let Some(st) = n.attribute("style") {
for (k, v, imp) in parse_decls(st) {
order += 1;
put(&k, &v, (imp, true, (0, 0, 0), order));
}
}
let m: Rc<HashMap<String, String>> = Rc::new(best.into_iter().map(|(k, (_, v))| (k, v)).collect());
self.cache.borrow_mut().insert(n.id(), m.clone());
m
}
/// 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()))
}
/// An inherited property: the nearest specified value other than `inherit`.
pub(super) fn prop(&self, n: XNode, name: &str) -> Option<String> {
n.ancestors().filter(|a| a.is_element()).find_map(|a| self.own(a, name).filter(|v| v != "inherit"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn with_doc(svg: &str, f: impl FnOnce(&roxmltree::Document, &Styles)) {
let doc = roxmltree::Document::parse(svg).unwrap();
let st = Styles::new(&doc);
f(&doc, &st);
}
fn by_id<'a, 'i>(doc: &'a roxmltree::Document<'i>, id: &str) -> XNode<'a, 'i> {
doc.descendants().find(|n| n.attribute("id") == Some(id)).unwrap()
}
#[test]
fn selectors_and_precedence() {
with_doc(
r#"<svg xmlns="http://www.w3.org/2000/svg"><style>
/* comment { fill: red } */
@media print { text { fill: red } }
@import url(x.css);
text { fill: #111; font-size: 9px }
g text { fill: #222 }
g > text.a { fill: #333 }
.a.b { stroke: blue }
#t2 { fill: #444 }
[data-k=v] { font-weight: bold }
text:first-child, text ~ text { fill: red }
.imp { fill: #555 !important }
</style>
<text id="t0">x</text>
<g><text id="t1" class="a b" fill="green">x</text>
<g><text id="t2" class="a" style="fill:#666">x</text></g>
<text id="t3" class="a imp" style="fill:#777" data-k="v">x</text></g>
</svg>"#,
|doc, st| {
assert_eq!(st.own(by_id(doc, "t0"), "fill").as_deref(), Some("#111"));
assert_eq!(st.own(by_id(doc, "t1"), "fill").as_deref(), Some("#333"), "child + class beats descendant");
assert_eq!(st.own(by_id(doc, "t1"), "stroke").as_deref(), Some("blue"), "multiple classes");
assert_eq!(st.own(by_id(doc, "t2"), "fill").as_deref(), Some("#666"), "style attribute beats #id");
assert_eq!(st.own(by_id(doc, "t3"), "fill").as_deref(), Some("#555"), "!important beats style");
assert_eq!(st.own(by_id(doc, "t3"), "font-weight").as_deref(), Some("bold"));
assert_eq!(st.own(by_id(doc, "t2"), "stroke"), None, "`.a.b` needs both classes");
assert_eq!(st.prop(by_id(doc, "t2"), "font-size").as_deref(), Some("9px"));
},
);
}
#[test]
fn presentation_attributes_lose_to_rules() {
with_doc(
r#"<svg xmlns="http://www.w3.org/2000/svg"><style>#a { fill: blue }</style><text id="a" fill="red" stroke="red">x</text></svg>"#,
|doc, st| {
assert_eq!(st.own(by_id(doc, "a"), "fill").as_deref(), Some("blue"));
assert_eq!(st.own(by_id(doc, "a"), "stroke").as_deref(), Some("red"));
},
);
}
#[test]
fn the_font_shorthand_sets_its_longhands() {
let d = parse_decls("fill: red; font: italic bold 30px 'DejaVu Sans' ; font-weight: 300 !important; font: nonsense");
let get = |k: &str| d.iter().filter(|(n, ..)| n == k).map(|(_, v, i)| (v.as_str(), *i)).collect::<Vec<_>>();
assert_eq!(get("font-style"), [("italic", false)]);
assert_eq!(get("font-weight"), [("bold", false), ("300", true)]);
assert_eq!(get("font-size"), [("30px", false)]);
assert_eq!(get("font-family"), [("'DejaVu Sans'", false)]);
assert_eq!(get("font-variant"), [("normal", false)]);
assert_eq!(get("fill"), [("red", false)]);
assert!(get("font").is_empty(), "the shorthand itself is not kept");
}
#[test]
fn unsupported_selectors_are_dropped() {
for s in ["a + b", "a ~ b", "a:hover", "svg|text", "> a", "a >", ""] {
assert!(Selector::parse(s).is_none(), "{s}");
}
let s = Selector::parse("g > text.x#y").unwrap();
assert_eq!(s.specificity, (1, 1, 2));
assert_eq!(s.parts.len(), 2);
assert!(s.parts[0].1, "text must be a child of g");
}
}