diff --git a/Cargo.lock b/Cargo.lock index fcb4c1124..32cffe9fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6471,6 +6471,7 @@ dependencies = [ "vectorcraft-doc", "vectorcraft-effects", "vectorcraft-geom", + "vectorcraft-pathops", "vectorcraft-testkit", "vectorcraft-text", ] diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 44b8c696e..7c506d2bb 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -17,6 +17,7 @@ vectorcraft-doc = { workspace = true } vectorcraft-effects = { workspace = true } vectorcraft-brush = { workspace = true } vectorcraft-text = { workspace = true } +vectorcraft-pathops = { workspace = true } kurbo = { workspace = true } usvg = { workspace = true } thiserror = { workspace = true } diff --git a/crates/svg/src/import/convert.rs b/crates/svg/src/import/convert.rs index 92f5234ee..d8cf3bcd5 100644 --- a/crates/svg/src/import/convert.rs +++ b/crates/svg/src/import/convert.rs @@ -186,22 +186,85 @@ pub(super) fn drawn_ellipse(p: &usvg::tiny_skia_path::Path) -> Option Some(PathData::single(SubPath::new(anchors, true))) } +const MAX_CLIP_DEPTH: usize = 16; + +/// Most clip paths read for one clip. They are shared, so children that each name the next one +/// multiply the work at every level: without this, a few lines of SVG would never finish. +const MAX_CLIP_READS: usize = 1024; + /// The shapes of clip path `cp` on an element whose user space `ts` maps to the document. pub(super) fn clip_shapes(cp: &usvg::ClipPath, ts: Affine) -> Vec<(BezPath, FillRule)> { - fn collect(g: &usvg::Group, m: Affine, out: &mut Vec<(BezPath, FillRule)>) { + let mut reads = MAX_CLIP_READS; + clip_shapes_depth(cp, ts, 0, &mut reads) +} + +/// Recursively collect shapes for `cp` in document space up to `MAX_CLIP_DEPTH`, and while `reads` +/// are left. +fn clip_shapes_depth(cp: &usvg::ClipPath, ts: Affine, depth: usize, reads: &mut usize) -> Vec<(BezPath, FillRule)> { + if depth > MAX_CLIP_DEPTH || *reads == 0 { + return Vec::new(); + } + *reads -= 1; + fn collect(g: &usvg::Group, m: Affine, depth: usize, reads: &mut usize, out: &mut Vec<(BezPath, FillRule)>) { for c in g.children() { match c { - usvg::Node::Group(g) => collect(g, m * aff(g.transform()), out), + usvg::Node::Group(g) => { + let m_child = m * aff(g.transform()); + if let Some(child_cp) = g.clip_path() { + let mut child_shapes = Vec::new(); + collect(g, m_child, depth + 1, reads, &mut child_shapes); + if !child_shapes.is_empty() { + let clip_paths = clip_shapes_depth(child_cp, m_child, depth + 1, reads); + intersect_shapes(&child_shapes, &clip_paths, out); + } + } else { + collect(g, m_child, depth, reads, out); + } + } usvg::Node::Path(p) => out.push((bezpath(p.data(), m), p.fill().map(|f| rule(f.rule())).unwrap_or_default())), _ => {} } } } let mut shapes = Vec::new(); - collect(cp.root(), ts * aff(cp.transform()), &mut shapes); + collect(cp.root(), ts * aff(cp.transform()), depth, reads, &mut shapes); + if !shapes.is_empty() + && depth > 0 + && depth < MAX_CLIP_DEPTH + && *reads > 0 + && let Some(parent_cp) = cp.clip_path() + { + let parent_shapes = clip_shapes_depth(parent_cp, ts, depth + 1, reads); + let mut intersected = Vec::new(); + intersect_shapes(&shapes, &parent_shapes, &mut intersected); + shapes = intersected; + } shapes } +/// Intersect `shapes` with `clips`: SVG 1.1 §14.3.5 specifies that a clip path clips to the union +/// of its children, each child first clipped by its own clip-path if present. +fn intersect_shapes(shapes: &[(BezPath, FillRule)], clips: &[(BezPath, FillRule)], out: &mut Vec<(BezPath, FillRule)>) { + if shapes.is_empty() || clips.is_empty() { + return; + } + let clip_data = match clips { + [(bp, rule)] => (PathData::from_bezpath(bp), *rule), + _ => { + let paths: Vec<(PathData, FillRule)> = clips.iter().map(|(bp, r)| (PathData::from_bezpath(bp), *r)).collect(); + let refs: Vec<(&PathData, FillRule)> = paths.iter().map(|(p, r)| (p, *r)).collect(); + (vectorcraft_pathops::unite_all(&refs), FillRule::NonZero) + } + }; + for (bp, r) in shapes { + let child_data = PathData::from_bezpath(bp); + let inter = vectorcraft_pathops::boolean(&child_data, *r, &clip_data.0, clip_data.1, vectorcraft_pathops::BoolOp::Intersect); + if !inter.subpaths.is_empty() { + out.push((inter.to_bezpath(), FillRule::NonZero)); + } + } +} + /// Does clip path `cp` (on an element whose user space `ts` maps to the document) leave `art`, /// placed by `xf`, whole? (Only its bounds are checked: the box must be inside a clip shape.) pub(super) fn uncut(cp: &usvg::ClipPath, ts: Affine, art: &Node, xf: Affine) -> bool { @@ -217,6 +280,7 @@ pub(super) fn uncut(cp: &usvg::ClipPath, ts: Affine, art: &Node, xf: Affine) -> corners.iter().all(|c| shapes.iter().any(|(s, _)| kurbo::Shape::contains(s, xf * *c))) } +/// Convert a usvg fill rule to the document model fill rule. pub(super) fn rule(r: usvg::FillRule) -> FillRule { match r { usvg::FillRule::NonZero => FillRule::NonZero, diff --git a/crates/svg/tests/it/import_details.rs b/crates/svg/tests/it/import_details.rs index 967963a6f..13518a6fd 100644 --- a/crates/svg/tests/it/import_details.rs +++ b/crates/svg/tests/it/import_details.rs @@ -252,6 +252,147 @@ fn clip_path_children_unite_under_their_own_rules() { assert!(matches!(children[1].kind, NodeKind::Compound { rule: vectorcraft_geom::FillRule::EvenOdd, .. })); } +/// SVG 1.1 §14.3.5: a child of a clipPath element with its own clip-path is clipped by it before +/// contributing to the clip (https://www.w3.org/TR/SVG11/masking.html#EstablishingANewClippingPath). +#[test] +fn clip_path_child_clip_path_clips_child() { + let page = r##" + + + + + + + + "##; + let (d, w) = import_with_report(page).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let mine = render_artboard(&d); + assert_similar(&mine, &resvg_render(page, 200, 200), 24.0, 0.003); + assert_eq!(mine.over_white(25, 25), [255, 0, 0], "the left strip is clipped in"); + assert_eq!(mine.over_white(100, 100), [255, 255, 255], "the right side is cut by inner clip"); + // Written back out, the clip stays identical. + let out = vectorcraft_svg::export(&d, &Default::default()); + assert_similar(&render_artboard(&vectorcraft_svg::import(&out).unwrap()), &mine, 24.0, 0.003); +} + +#[test] +fn clip_path_child_with_chained_clip_path_intersects() { + let page = r##" + + + + + + + + + "##; + let (d, w) = import_with_report(page).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let mine = render_artboard(&d); + assert_similar(&mine, &resvg_render(page, 200, 200), 24.0, 0.003); + assert_eq!(mine.over_white(25, 25), [255, 0, 0], "top left quadrant is inside both clips"); + assert_eq!(mine.over_white(25, 150), [255, 255, 255], "bottom left is cut by base clip"); + assert_eq!(mine.over_white(100, 25), [255, 255, 255], "top right is cut by inner clip"); +} + +/// Clip paths are shared, so children that each name the next one multiply at every level: ten +/// children on seven levels would take 10^7 reads unbounded. The import must still finish. +#[test] +fn clip_paths_whose_children_fan_out_still_import() { + let mut defs = String::from(r#""#); + for n in 1..8 { + let kids = format!(r#""#, n - 1).repeat(10); + defs += &format!(r#"{kids}"#); + } + let svg = format!( + r#"{defs}"# + ); + assert!(import_with_report(&svg).is_ok()); +} + +#[test] +fn clip_path_deep_chain_preserves_art() { + let mut defs = String::from(r#""#); + for n in 1..=20 { + defs += &format!(r#""#, n - 1); + } + let svg = format!( + r##"{defs}"## + ); + let (d, w) = import_with_report(&svg).unwrap(); + assert!(!w.is_empty(), "expected warning about deep clip nesting"); + let mine = render_artboard(&d); + assert_eq!(mine.over_white(50, 50), [255, 0, 0], "red rect should be preserved despite depth limit"); +} + +#[test] +fn clip_path_child_deep_chain_preserves_art() { + let mut defs = String::from(r#""#); + for n in 1..=20 { + defs += &format!(r#""#, n - 1); + } + defs += r#""#; + let svg = format!( + r##"{defs}"## + ); + let (d, _w) = import_with_report(&svg).unwrap(); + let mine = render_artboard(&d); + assert_eq!(mine.over_white(50, 50), [255, 0, 0], "red rect should be preserved despite depth limit in child clip"); +} + +#[test] +fn clip_path_preserves_union_when_read_budget_exhausted() { + let mut defs = String::from(r#""#); + let mut kids = String::new(); + for i in 0..1100 { + let x = (i % 20) * 10; + let y = (i / 20) * 10; + kids += &format!(r##""##); + } + defs += &format!(r#"{kids}"#); + let svg = format!( + r##"{defs}"## + ); + let (d, _w) = import_with_report(&svg).unwrap(); + let mine = render_artboard(&d); + // Early tiles processed within the 1024 read budget must be preserved in the union. + assert_eq!(mine.over_white(5, 5), [255, 0, 0], "early tile inside read budget is preserved"); +} + +#[test] +fn clip_path_empty_clips_everything() { + let page = r##" + + + + + "##; + let (d, _w) = import_with_report(page).unwrap(); + let mine = render_artboard(&d); + assert_eq!(mine.over_white(50, 50), [255, 255, 255], "genuinely empty clip path clips away all art"); +} + +#[test] +fn clip_path_child_with_empty_linked_clip_clips_everything() { + let page = r##" + + + + + + + + + + + "##; + let (d, _w) = import_with_report(page).unwrap(); + let mine = render_artboard(&d); + assert_eq!(mine.over_white(50, 50), [255, 255, 255], "empty linked clip path clips away all art"); +} + #[test] fn undisplayed_objects_import_hidden() { let body = r##"