mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-12 05:12:21 +00:00
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:
1 parent
70a05be71a
commit
6ff564e750
5 files changed
+97
-1
No files matched your search
Generated
+1
@@ -1939,6 +1939,7 @@ dependencies = [
|
||||
"effectcraft-path",
|
||||
"effectcraft-raster",
|
||||
"kurbo",
|
||||
"log",
|
||||
"rayon",
|
||||
"roxmltree",
|
||||
]
|
||||
|
||||
@@ -16,3 +16,4 @@ effectcraft-path = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
roxmltree = "0.21"
|
||||
rayon = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -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")));
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user