Merge #2159: svg: share CSS declaration blocks and cap <style> work (#2140) (@Barzabel)

This commit is contained in:
aiongg
2026-10-23 23:21:15 +00:00
2 changed files with 232 additions and 24 deletions
+137 -24
View File
@@ -392,7 +392,17 @@ enum PaintRef {
Current,
}
/// A CSS rule: selector (type, class, id; `*`) with its specificity and declarations.
/// The most selectors (each entry of a comma list counts) the `<style>` sheets of a document may
/// hold (#2140).
const MAX_CSS_SELECTORS: usize = 20_000;
/// The most declarations the `<style>` sheets of a document may hold, each block counted once.
const MAX_CSS_DECLARATIONS: usize = 50_000;
/// The most matching work (candidate rules checked plus declarations applied) the elements of a
/// document may cost, `<use>` copies included: a bound on elements x rules x declarations.
const MAX_CSS_WORK: usize = 50_000_000;
/// A CSS rule: selector (type, class, id; `*`) with its specificity and declaration block.
#[derive(Clone, Debug)]
struct Rule {
tag: Option<String>,
@@ -400,7 +410,39 @@ struct Rule {
id: Option<String>,
specificity: u32,
order: usize,
decls: Vec<(String, String)>,
/// Index into [`StyleSheet::blocks`]: every selector of a comma list shares one block.
block: usize,
}
/// The rules of a document's `<style>` sheets, indexed by the part of the selector an element
/// must have (id, else first class, else type) so an element only checks rules that can match.
#[derive(Default, Debug)]
struct StyleSheet {
rules: Vec<Rule>,
blocks: Vec<Vec<(String, String)>>,
/// Declarations over all blocks.
decls: usize,
by_id: HashMap<String, Vec<usize>>,
by_class: HashMap<String, Vec<usize>>,
by_tag: HashMap<String, Vec<usize>>,
universal: Vec<usize>,
}
impl StyleSheet {
fn index(&mut self) {
for (i, r) in self.rules.iter().enumerate() {
let bucket = if let Some(id) = &r.id {
self.by_id.entry(id.clone()).or_default()
} else if let Some(c) = r.classes.first() {
self.by_class.entry(c.clone()).or_default()
} else if let Some(t) = &r.tag {
self.by_tag.entry(t.clone()).or_default()
} else {
&mut self.universal
};
bucket.push(i);
}
}
}
fn parse_decls(s: &str) -> Vec<(String, String)> {
@@ -428,7 +470,9 @@ fn strip_comments(s: &str) -> String {
out
}
fn parse_css(css: &str, rules: &mut Vec<Rule>) {
/// Add the rules of one `<style>` sheet. Errs once the sheets exceed [`MAX_CSS_SELECTORS`] or
/// [`MAX_CSS_DECLARATIONS`] (#2140).
fn parse_css(css: &str, sheet: &mut StyleSheet) -> Result<(), Error> {
let css = strip_comments(css);
let mut rest = css.as_str();
while let Some(open) = rest.find('{') {
@@ -439,13 +483,28 @@ fn parse_css(css: &str, rules: &mut Vec<Rule>) {
if sel.starts_with('@') {
continue;
}
let decls = parse_decls(body);
let mut decls = parse_decls(body);
sheet.decls = sheet.decls.saturating_add(decls.len());
if sheet.decls > MAX_CSS_DECLARATIONS {
return Err(Error::Xml(format!("its <style> sheets hold more than {MAX_CSS_DECLARATIONS} declarations")));
}
// Stored with the rule's first accepted selector: a rule of unsupported selectors only
// (`a b{}`, repeated) adds no block.
let mut block = None;
for one in sel.split(',') {
let one = one.trim();
// Only simple selectors (no combinators / pseudo-classes / attributes).
if one.is_empty() || one.contains([' ', '>', '+', '~', ':', '[']) {
continue;
}
if sheet.rules.len() >= MAX_CSS_SELECTORS {
return Err(Error::Xml(format!("its <style> sheets hold more than {MAX_CSS_SELECTORS} selectors")));
}
let block = *block.get_or_insert_with(|| {
let i = sheet.blocks.len();
sheet.blocks.push(std::mem::take(&mut decls));
i
});
let mut tag = None;
let mut classes = vec![];
let mut id = None;
@@ -474,11 +533,14 @@ fn parse_css(css: &str, rules: &mut Vec<Rule>) {
}
}
flush(kind, &mut cur, &mut tag, &mut classes, &mut id);
let specificity = id.is_some() as u32 * 100 + classes.len() as u32 * 10 + tag.is_some() as u32;
let order = rules.len();
rules.push(Rule { tag, classes, id, specificity, order, decls: decls.clone() });
let specificity = (id.is_some() as u32 * 100)
.saturating_add(u32::try_from(classes.len()).unwrap_or(u32::MAX).saturating_mul(10))
.saturating_add(tag.is_some() as u32);
let order = sheet.rules.len();
sheet.rules.push(Rule { tag, classes, id, specificity, order, block });
}
}
Ok(())
}
const PROPS: &[&str] = &[
@@ -505,7 +567,9 @@ const PROPS: &[&str] = &[
struct Parser<'a, 'i> {
ids: HashMap<String, roxmltree::Node<'a, 'i>>,
rules: Vec<Rule>,
css: StyleSheet,
/// CSS matching work done so far; past [`MAX_CSS_WORK`] rules stop applying and the import errs.
css_work: std::cell::Cell<usize>,
vw: f64,
vh: f64,
skipped: Vec<String>,
@@ -531,12 +595,50 @@ impl<'a, 'i> Parser<'a, 'i> {
out.insert(a.name().to_string(), a.value().to_string());
}
}
self.apply_rules(n, &mut out);
if let Some(st) = n.attribute("style") {
for (k, v) in parse_decls(st) {
out.insert(k, v);
}
}
out
}
/// Charge `n` units of CSS work; false once the budget is spent.
fn charge_css(&self, n: usize) -> bool {
let w = self.css_work.get().saturating_add(n);
self.css_work.set(w);
w <= MAX_CSS_WORK
}
/// Apply the matching `<style>` rules to `out`, by specificity then source order.
fn apply_rules(&self, n: roxmltree::Node, out: &mut HashMap<String, String>) {
let css = &self.css;
if css.rules.is_empty() {
return;
}
// Splitting and sorting the class list is work too: charge its length first.
let class = n.attribute("class").unwrap_or_default();
if !self.charge_css(class.len()) {
return;
}
let tag = n.tag_name().name();
let classes: Vec<&str> = n.attribute("class").map(|c| c.split_whitespace().collect()).unwrap_or_default();
let mut classes: Vec<&str> = class.split_whitespace().collect();
classes.sort_unstable();
classes.dedup();
let id = n.attribute("id");
let mut matching: Vec<&Rule> = self
.rules
// Each rule sits in exactly one bucket and the classes are distinct: no rule twice.
let mut buckets: Vec<&Vec<usize>> = vec![&css.universal];
buckets.extend(id.and_then(|i| css.by_id.get(i)));
buckets.extend(classes.iter().filter_map(|c| css.by_class.get(*c)));
buckets.extend(css.by_tag.get(tag));
if !self.charge_css(buckets.iter().map(|b| b.len()).fold(0, usize::saturating_add)) {
return;
}
let mut matching: Vec<&Rule> = buckets
.iter()
.flat_map(|b| b.iter())
.filter_map(|&i| css.rules.get(i))
.filter(|r| {
r.tag.as_deref().is_none_or(|t| t == tag)
&& r.id.as_deref().is_none_or(|i| Some(i) == id)
@@ -544,17 +646,13 @@ impl<'a, 'i> Parser<'a, 'i> {
})
.collect();
matching.sort_by_key(|r| (r.specificity, r.order));
for r in matching {
for (k, v) in &r.decls {
out.insert(k.clone(), v.clone());
}
let blocks: Vec<&Vec<(String, String)>> = matching.iter().filter_map(|r| css.blocks.get(r.block)).collect();
if !self.charge_css(blocks.iter().map(|b| b.len()).fold(0, usize::saturating_add)) {
return;
}
if let Some(st) = n.attribute("style") {
for (k, v) in parse_decls(st) {
out.insert(k, v);
}
for (k, v) in blocks.into_iter().flatten() {
out.insert(k.clone(), v.clone());
}
out
}
fn length(&self, s: &str, axis: char, font: f64) -> Option<f64> {
@@ -1147,20 +1245,31 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
return Err(Error::NotSvg);
}
let mut ids = HashMap::new();
let mut rules = vec![];
let mut css = StyleSheet::default();
for n in root.descendants().filter(|n| n.is_element()) {
if let Some(id) = n.attribute("id") {
ids.entry(id.to_string()).or_insert(n);
}
if n.tag_name().name() == "style" {
let css: String = n.children().filter_map(|c| c.text()).collect();
parse_css(&css, &mut rules);
let text: String = n.children().filter_map(|c| c.text()).collect();
parse_css(&text, &mut css)?;
}
}
css.index();
let vb = viewbox(root);
// Room for generous reuse, and always for every element of the file itself.
let budget = MAX_EXPANDED_ELEMENTS.max(root.descendants().filter(|n| n.is_element()).count().saturating_mul(2));
let mut p = Parser { ids, rules, vw: vb.map_or(300.0, |v| v.1), vh: vb.map_or(150.0, |v| v.2), skipped: vec![], work: 0, budget, using: vec![] };
let mut p = Parser {
ids,
css,
css_work: std::cell::Cell::new(0),
vw: vb.map_or(300.0, |v| v.1),
vh: vb.map_or(150.0, |v| v.2),
skipped: vec![],
work: 0,
budget,
using: vec![],
};
let st = Style::default();
let pct_or = |name: &str, axis: char, def: f64| -> f64 {
match root.attribute(name) {
@@ -1200,6 +1309,10 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
// Never a silently partial drawing.
return Err(Error::Xml(format!("its <use> references expand to more than {} elements", p.budget)));
}
if p.css_work.get() > MAX_CSS_WORK {
// Never a silently unstyled drawing.
return Err(Error::Xml(format!("matching its <style> rules takes more than {MAX_CSS_WORK} steps")));
}
let transform = viewbox_transform(root, dw, dh);
Ok(Doc {
width: dw,
+95
View File
@@ -296,3 +296,98 @@ fn a_symbol_used_a_thousand_times_still_imports() {
let doc = parse(svg.as_bytes()).unwrap();
assert_eq!(depth_and_shapes(&doc.root).1, 3000);
}
/// An SVG with one `<style>` sheet and one rectangle per given class list.
fn styled(css: &str, rects: &[&str]) -> Vec<u8> {
let rects: String = rects.iter().map(|attrs| format!(r#"<rect width="1" height="1" {attrs}/>"#)).collect();
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><style>{css}</style>{rects}</svg>"#).into_bytes()
}
#[test]
fn comma_selector_lists_share_one_declaration_block() {
// Before #2140 every selector of a comma list got its own copy of the declaration block, so
// `a,a,a…{…}` stored selectors × declarations strings; one selector past the cap is refused.
let sels = vec!["rect"; MAX_CSS_SELECTORS + 1].join(",");
let decls: String = (0..10).map(|i| format!("p{i}:v;")).collect();
let svg = styled(&format!("{sels}{{fill:red;{decls}}}"), &[""]);
let e = parse(&svg).unwrap_err();
assert!(e.to_string().contains("selectors"), "{e}");
}
#[test]
fn a_style_sheet_with_too_many_declarations_errors() {
let decls: String = (0..=MAX_CSS_DECLARATIONS).map(|i| format!("p{i}:v;")).collect();
let svg = styled(&format!("rect{{{decls}}}"), &[""]);
let e = parse(&svg).unwrap_err();
assert!(e.to_string().contains("declarations"), "{e}");
}
#[test]
fn selector_lists_keep_the_css_cascade() {
let green = Some(Paint::Color([0.0, 1.0, 0.0]));
let blue = Some(Paint::Color([0.0, 0.0, 1.0]));
let red = Some(Paint::Color([1.0, 0.0, 0.0]));
let css = "rect, .b, #c { fill: #00ff00 } .b, .d { fill: #0000ff; stroke: #000000; stroke-width: 3 } rect.e, #c { fill: #ff0000 }";
let svg = styled(css, &["", r#"class="b""#, r#"id="c" class="b""#, r#"class="b" style="fill:#00ff00""#, r##"class="e" fill="#0000ff""##, r#"class="d""#]);
let doc = parse(&svg).unwrap();
let shapes = doc.flatten();
let fills: Vec<_> = shapes.iter().map(|s| s.2.fill.clone()).collect();
// Type selector; class beats type; id beats class; inline style beats every rule; a later,
// more specific rule beats the presentation attribute.
assert_eq!(fills, vec![green.clone(), blue.clone(), red.clone(), green, red, blue]);
assert!(shapes[0].2.stroke.is_none());
// The second list's block reaches both of its selectors.
for i in [1, 5] {
assert_eq!(shapes[i].2.stroke.as_ref().map(|s| s.width), Some(3.0), "shape {i}");
}
}
#[test]
fn a_comma_list_stores_its_declarations_once() {
let sels = vec!["rect"; 1000].join(",");
let decls: String = (0..50).map(|i| format!("p{i}:v;")).collect();
let mut sheet = StyleSheet::default();
parse_css(&format!("{sels}{{{decls}}}"), &mut sheet).unwrap();
assert_eq!(sheet.rules.len(), 1000);
assert_eq!(sheet.blocks.len(), 1);
assert_eq!(sheet.decls, 50);
}
#[test]
fn rules_of_unsupported_selectors_store_no_blocks() {
let mut sheet = StyleSheet::default();
parse_css(&"a b{}".repeat(100_000), &mut sheet).unwrap();
parse_css("a b{fill:red} a>b, rect{fill:blue} c:hover, d{fill:red}", &mut sheet).unwrap();
assert_eq!(sheet.rules.len(), 2);
assert_eq!(sheet.blocks.len(), 2);
assert!(sheet.rules.iter().all(|r| sheet.blocks.get(r.block).is_some_and(|b| b.len() == 1)));
}
#[test]
fn huge_class_lists_count_against_the_matching_budget() {
// One rule, but a 1 MB class attribute re-split on each of 100 `<use>` copies (#2140).
let class = "a ".repeat(500_000);
let uses: String = (0..100).map(|i| format!(r##"<use href="#r" x="{i}"/>"##)).collect();
let svg = |uses: &str| {
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><style>.z{{fill:red}}</style><defs><rect id="r" width="1" height="1" class="{class}"/></defs>{uses}</svg>"##
)
};
let e = parse(svg(&uses).as_bytes()).unwrap_err();
assert!(e.to_string().contains("<style> rules"), "{e}");
let few: String = uses.split_inclusive("/>").take(10).collect();
assert!(parse(svg(&few).as_bytes()).is_ok());
}
#[test]
fn matching_work_past_the_budget_errors_without_doing_it() {
// Within both sheet caps, yet one rectangle would apply 10 000 x 5 001 declarations.
let sels = vec!["rect"; 10_000].join(",");
let decls: String = (0..5_001).map(|i| format!("p{i}:v;")).collect();
let svg = styled(&format!("{sels}{{{decls}}}"), &[""]);
let e = parse(&svg).unwrap_err();
assert!(e.to_string().contains("<style> rules"), "{e}");
// The same sheet styles an element it does not match at no cost.
let ok = styled(&format!("{}{{{decls}}}", vec!["circle"; 10_000].join(",")), &[""]);
assert!(parse(&ok).is_ok());
}