SVG: bound tessellation, kept geometry, attributes, entity expansion and re-rendering (#2986)

* SVG: bound tessellation, kept geometry, attributes, entity expansion and re-rendering

Circles and rounded rects flattened at a fixed 0.01 tolerance, so a huge
radius made unbounded curves; the tolerance now grows with the radius as
arcs already did. Every path a <use> copies and every gradient reference's
stops were parsed and kept without a document-wide limit; both now share a
budget of 8 million items. An element with thousands of attributes made
the XML parser's duplicate check quadratic, and repeated entity references
could expand attribute text to gigabytes below the node limit; a preflight
refuses more than 1,024 attributes per element and entity references that
expand past 64 MiB. Nested knockout and non-isolated groups re-rendered
their children several times per level; node draws are now held to 64 per
node of the tree.

* address review: entity_bytes takes the largest declaration of each name

* address review: drop tessellation-tolerance, attribute-count and entity-size refusals; keep retained and draw budgets, warn when the draw budget is hit
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 14:39:06 -05:00
1 parent 70a05be71a
commit 6ff564e750
5 files changed
+97 -1

No files matched your search

Generated
+1
View File
@@ -1939,6 +1939,7 @@ dependencies = [
"effectcraft-path",
"effectcraft-raster",
"kurbo",
"log",
"rayon",
"roxmltree",
]
+1
View File
@@ -16,3 +16,4 @@ effectcraft-path = { workspace = true }
kurbo = { workspace = true }
roxmltree = "0.21"
rayon = { workspace = true }
log = { workspace = true }
+27
View File
@@ -603,8 +603,15 @@ struct Parser<'a, 'i> {
/// The `<use>` targets being expanded, outermost first: a reference back into one is a
/// cycle and is skipped.
using: Vec<roxmltree::NodeId>,
/// Path elements and gradient stops kept so far, `<use>` copies and repeated gradient
/// references included; past [`MAX_RETAINED`] the import errs.
retained: std::cell::Cell<usize>,
}
/// The most path elements and gradient stops an import keeps: a complex path reused many times,
/// or a gradient with many stops referenced by many shapes, is copied for each use.
const MAX_RETAINED: usize = 8_000_000;
fn is_svg_ns(n: &roxmltree::Node) -> bool {
n.is_element()
}
@@ -628,6 +635,13 @@ impl<'a, 'i> Parser<'a, 'i> {
out
}
/// Charge `n` kept path elements or gradient stops; false once [`MAX_RETAINED`] is passed.
fn charge_retained(&self, n: usize) -> bool {
let r = self.retained.get().saturating_add(n);
self.retained.set(r);
r <= MAX_RETAINED
}
/// 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);
@@ -875,6 +889,10 @@ impl<'a, 'i> Parser<'a, 'i> {
// Stops: from the first element in the chain that has any.
let mut stops = vec![];
if let Some(src) = chain.iter().find(|e| e.children().any(|c| c.is_element() && c.tag_name().name() == "stop")) {
// Charged before the stops are read: every reference reads and keeps them again.
if !self.charge_retained(src.children().filter(|c| c.is_element()).count()) {
return None;
}
let mut last = 0.0f64;
for s in src.children().filter(|c| c.is_element() && c.tag_name().name() == "stop") {
let d = self.declared(s);
@@ -1026,6 +1044,11 @@ impl<'a, 'i> Parser<'a, 'i> {
return;
}
let Some(geom) = self.geometry(n, &st) else { return };
if let Geom::Path(p) = &geom
&& !self.charge_retained(p.elements().len())
{
return;
}
let fill = if matches!(tag, "line") { None } else { st.fill.as_ref().and_then(|p| self.resolve_paint(p, &st)) };
let (fill, fill_alpha) = match fill {
Some((p, a)) => (Some(p), a),
@@ -1304,6 +1327,7 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
work: 0,
budget,
using: vec![],
retained: std::cell::Cell::new(0),
};
let st = Style::default();
let pct_or = |name: &str, axis: char, def: f64| -> f64 {
@@ -1344,6 +1368,9 @@ 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.retained.get() > MAX_RETAINED {
return Err(Error::Xml(format!("its paths and gradients, with their copies, hold more than {MAX_RETAINED} elements")));
}
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")));
+31 -1
View File
@@ -20,7 +20,10 @@ pub fn rasterize(doc: &Doc, w: u32, h: u32, scale: f64) -> Image {
/// Rasterise `doc` with `m` mapping document pixels to image pixels.
pub fn rasterize_with(doc: &Doc, w: u32, h: u32, m: Affine) -> Image {
let mut budget = SurfaceBudget { live_bytes: 0, limit_bytes: MAX_LIVE_SURFACE_BYTES };
// Nested knockout and non-isolated groups draw their children more than once; the number of
// node draws is bounded by a multiple of the tree's size, past which drawing stops.
let draws_left = node_count(&doc.root).saturating_mul(MAX_DRAWS_PER_NODE).max(MIN_DRAWS);
let mut budget = SurfaceBudget { live_bytes: 0, limit_bytes: MAX_LIVE_SURFACE_BYTES, draws_left };
let Some(mut img) = budget.image(w, h, None) else { return Image::default() };
draw_group(&doc.root, m, 1.0, &mut img, &mut budget);
img
@@ -29,9 +32,31 @@ pub fn rasterize_with(doc: &Doc, w: u32, h: u32, m: Affine) -> Image {
/// Bound the simultaneously live offscreen image surfaces created by nested groups.
const MAX_LIVE_SURFACE_BYTES: usize = 512 * 1024 * 1024;
/// How many times each node of a document may be drawn, on average, in one rasterisation.
const MAX_DRAWS_PER_NODE: usize = 64;
const MIN_DRAWS: usize = 100_000;
/// The nodes in `g`'s tree, `g` included.
fn node_count(g: &Group) -> usize {
let mut n = 0usize;
let mut stack = vec![g];
while let Some(g) = stack.pop() {
n = n.saturating_add(1);
for c in &g.children {
match c {
Node::Group(cg) => stack.push(cg),
_ => n = n.saturating_add(1),
}
}
}
n
}
struct SurfaceBudget {
live_bytes: usize,
limit_bytes: usize,
/// Node draws left (see [`MAX_DRAWS_PER_NODE`]).
draws_left: usize,
}
impl SurfaceBudget {
@@ -394,6 +419,11 @@ fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] {
}
fn draw_node(n: &Node, m: Affine, opacity: f64, dst: &mut Image, budget: &mut SurfaceBudget) {
let Some(left) = budget.draws_left.checked_sub(1) else { return };
budget.draws_left = left;
if left == 0 {
log::warn!("svg: draw budget reached, the rest of the drawing is skipped");
}
match n {
Node::Group(g) => draw_group(g, m, opacity, dst, budget),
Node::Shape(s) => {
+37
View File
@@ -446,3 +446,40 @@ fn at_rule_blocks_are_skipped_by_brace_depth() {
assert_eq!(sheet.rules.len(), 1);
assert_eq!(sheet.rules[0].classes, vec!["e".to_string()]);
}
#[test]
fn gradient_stops_copied_past_the_budget_are_refused() {
// 4,000 stops referenced by 2,100 shapes would keep 8.4 million stops.
let stops: String = (0..4000).map(|i| format!("<stop offset=\"{}\" stop-color=\"red\"/>", i as f64 / 4000.0)).collect();
let rects = "<rect width=\"1\" height=\"1\" fill=\"url(#g)\"/>".repeat(2100);
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\"><defs><linearGradient id=\"g\">{stops}</linearGradient></defs>{rects}</svg>");
assert!(matches!(parse(svg.as_bytes()), Err(Error::Xml(e)) if e.contains("gradients")));
}
#[test]
fn nested_knockout_groups_stop_at_the_draw_budget() {
// Each knockout level draws its children twice and each non-isolated level twice more:
// 30 nested levels would be 4^30 draws. The draw budget ends it.
let mut g = Group::new("leaf");
g.children.push(Node::Shape(Shape {
name: "s".into(),
geom: Geom::Rect { x: 0.0, y: 0.0, w: 4.0, h: 4.0, rx: 0.0, ry: 0.0 },
transform: Affine::IDENTITY,
fill: Some(Paint::Color([1.0, 0.0, 0.0])),
fill_opacity: 1.0,
fill_rule: FillRule::NonZero,
stroke: None,
opacity: 1.0,
}));
for _ in 0..30 {
let mut p = Group::new("k");
p.knockout = true;
p.isolated = false;
p.children.push(Node::Group(g));
g = p;
}
let doc = Doc { width: 4.0, height: 4.0, root: g, skipped: vec![] };
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || tx.send(rasterize(&doc, 4, 4, 1.0).width).ok());
assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(20)).expect("rendering finishes"), 4);
}