From bfbb831e54df4feee8f52ab74125be4be2f4cbd7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 04:25:01 -0500 Subject: [PATCH] Use shared neutral SVG scene for paste --- Cargo.lock | 57 +++++++------- Cargo.toml | 6 +- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/paste_svg.rs | 115 ++++++++++------------------- 4 files changed, 74 insertions(+), 105 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 18d8ca455..0439c1e91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1245,7 +1245,7 @@ dependencies = [ [[package]] name = "craft-image-codecs" version = "0.1.0" -source = "git+https://github.com/dylanmtaylor/craft-libs?rev=f7d76eec548a77a79fd2e056e7846a8655ee3f43#f7d76eec548a77a79fd2e056e7846a8655ee3f43" +source = "git+https://github.com/dylanmtaylor/craft-libs?rev=2e670cf9377b523112f3fe4b409399772c745895#2e670cf9377b523112f3fe4b409399772c745895" dependencies = [ "craft-jpeg", "exr", @@ -1275,7 +1275,7 @@ dependencies = [ [[package]] name = "craft-jpeg" version = "0.1.0" -source = "git+https://github.com/dylanmtaylor/craft-libs?rev=f7d76eec548a77a79fd2e056e7846a8655ee3f43#f7d76eec548a77a79fd2e056e7846a8655ee3f43" +source = "git+https://github.com/dylanmtaylor/craft-libs?rev=2e670cf9377b523112f3fe4b409399772c745895#2e670cf9377b523112f3fe4b409399772c745895" dependencies = [ "thiserror 2.0.21", ] @@ -1411,7 +1411,7 @@ dependencies = [ [[package]] name = "craft-psd" version = "0.1.0" -source = "git+https://github.com/dylanmtaylor/craft-libs?rev=f7d76eec548a77a79fd2e056e7846a8655ee3f43#f7d76eec548a77a79fd2e056e7846a8655ee3f43" +source = "git+https://github.com/dylanmtaylor/craft-libs?rev=2e670cf9377b523112f3fe4b409399772c745895#2e670cf9377b523112f3fe4b409399772c745895" dependencies = [ "encoding_rs", "flate2", @@ -1433,6 +1433,21 @@ name = "craft-sheet" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf#ba24fe2435adb3c81ad6057faf36365fbba736cf" +[[package]] +name = "craft-svg" +version = "0.1.0" +source = "git+https://github.com/dylanmtaylor/craft-libs?rev=2e670cf9377b523112f3fe4b409399772c745895#2e670cf9377b523112f3fe4b409399772c745895" +dependencies = [ + "craft-image-codecs", + "craft-xml 0.1.0 (git+https://github.com/dylanmtaylor/craft-libs?rev=2e670cf9377b523112f3fe4b409399772c745895)", + "flate2", + "kurbo", + "resvg 0.48.1", + "roxmltree 0.21.2", + "thiserror 2.0.21", + "usvg 0.48.1", +] + [[package]] name = "craft-svg" version = "0.1.0" @@ -1458,21 +1473,6 @@ dependencies = [ "thiserror 2.0.21", ] -[[package]] -name = "craft-svg" -version = "0.1.0" -source = "git+https://github.com/dylanmtaylor/craft-libs?rev=f7d76eec548a77a79fd2e056e7846a8655ee3f43#f7d76eec548a77a79fd2e056e7846a8655ee3f43" -dependencies = [ - "craft-image-codecs", - "craft-xml 0.1.0 (git+https://github.com/dylanmtaylor/craft-libs?rev=f7d76eec548a77a79fd2e056e7846a8655ee3f43)", - "flate2", - "kurbo", - "resvg 0.48.1", - "roxmltree 0.21.2", - "thiserror 2.0.21", - "usvg 0.48.1", -] - [[package]] name = "craft-ui" version = "0.1.0" @@ -1566,6 +1566,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-xml" +version = "0.1.0" +source = "git+https://github.com/dylanmtaylor/craft-libs?rev=2e670cf9377b523112f3fe4b409399772c745895#2e670cf9377b523112f3fe4b409399772c745895" +dependencies = [ + "quick-xml 0.41.0", + "thiserror 2.0.21", +] + [[package]] name = "craft-xml" version = "0.1.0" @@ -1612,15 +1621,6 @@ dependencies = [ "thiserror 2.0.21", ] -[[package]] -name = "craft-xml" -version = "0.1.0" -source = "git+https://github.com/dylanmtaylor/craft-libs?rev=f7d76eec548a77a79fd2e056e7846a8655ee3f43#f7d76eec548a77a79fd2e056e7846a8655ee3f43" -dependencies = [ - "quick-xml 0.41.0", - "thiserror 2.0.21", -] - [[package]] name = "craft-zip" version = "0.1.0" @@ -1894,6 +1894,7 @@ dependencies = [ "craft-indd", "craft-prepress", "craft-print", + "craft-svg 0.1.0 (git+https://github.com/dylanmtaylor/craft-libs?rev=2e670cf9377b523112f3fe4b409399772c745895)", "craft-xml 0.1.0 (git+https://github.com/storytold/craft-libs?rev=ba24fe2435adb3c81ad6057faf36365fbba736cf)", "craft-xml 0.1.0 (git+https://github.com/dylanmtaylor/craft-libs?rev=f23b0f33f0d00b157476533a43cbbcfd901aa1f1)", "designcraft-color", @@ -1999,7 +2000,7 @@ dependencies = [ "craft-file-formats", "craft-image-codecs", "craft-psd", - "craft-svg 0.1.0 (git+https://github.com/dylanmtaylor/craft-libs?rev=f7d76eec548a77a79fd2e056e7846a8655ee3f43)", + "craft-svg 0.1.0 (git+https://github.com/dylanmtaylor/craft-libs?rev=2e670cf9377b523112f3fe4b409399772c745895)", "designcraft-color", "designcraft-fonts", "flate2", diff --git a/Cargo.toml b/Cargo.toml index a35967aca..a54c44f26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ craft-eps = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435 craft-epub = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } craft-idml = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } craft-indd = { git = "https://github.com/storytold/craft-libs", rev = "47bf5650b0d503b97bfb5e841746c4957897d11f", version = "=0.1.0" } -craft-svg = { git = "https://github.com/dylanmtaylor/craft-libs", rev = "f7d76eec548a77a79fd2e056e7846a8655ee3f43", version = "=0.1.0", features = ["tree-render", "tree-text"] } +craft-svg = { git = "https://github.com/dylanmtaylor/craft-libs", rev = "2e670cf9377b523112f3fe4b409399772c745895", version = "=0.1.0", features = ["tree-render", "tree-text"] } craft-xml = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } craft-xml-dtd = { package = "craft-xml", git = "https://github.com/dylanmtaylor/craft-libs", rev = "f23b0f33f0d00b157476533a43cbbcfd901aa1f1", version = "=0.1.0" } craft-rtf = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } @@ -26,8 +26,8 @@ craft-xlsx = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe243 craft-docx = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } craft-wordmodel = { git = "https://github.com/storytold/craft-libs", rev = "ba24fe2435adb3c81ad6057faf36365fbba736cf", version = "=0.1.0" } craft-csv = { git = "https://github.com/dylanmtaylor/craft-libs", rev = "44d7ac8d942dbd87048f82b3efc3e0d14483cc8d", version = "=0.1.0" } -craft-image-codecs = { git = "https://github.com/dylanmtaylor/craft-libs", rev = "f7d76eec548a77a79fd2e056e7846a8655ee3f43", version = "=0.1.0" } -craft-psd = { git = "https://github.com/dylanmtaylor/craft-libs", rev = "f7d76eec548a77a79fd2e056e7846a8655ee3f43", version = "=0.1.0" } +craft-image-codecs = { git = "https://github.com/dylanmtaylor/craft-libs", rev = "2e670cf9377b523112f3fe4b409399772c745895", version = "=0.1.0" } +craft-psd = { git = "https://github.com/dylanmtaylor/craft-libs", rev = "2e670cf9377b523112f3fe4b409399772c745895", version = "=0.1.0" } craft-file-formats = { git = "https://github.com/storytold/craft-libs", rev = "8abfa9d0f14bafbfc3449fc552ffeafb9ca6a551", version = "=0.1.0" } craft-opc = { git = "https://github.com/storytold/craft-libs", rev = "8abfa9d0f14bafbfc3449fc552ffeafb9ca6a551", version = "=0.1.0" } craft-pptx = { git = "https://github.com/storytold/craft-libs", rev = "f6cab1980", version = "=0.1.0" } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 6aa2e62df..d4011d895 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -35,6 +35,7 @@ thiserror = { workspace = true } log = { workspace = true } craft-xml = { workspace = true } craft-xml-dtd = { workspace = true } +craft-svg = { workspace = true } caseless = { workspace = true } unicode-segmentation = { workspace = true } diff --git a/crates/engine/src/cmd/paste_svg.rs b/crates/engine/src/cmd/paste_svg.rs index a5d738cef..c34c03506 100644 --- a/crates/engine/src/cmd/paste_svg.rs +++ b/crates/engine/src/cmd/paste_svg.rs @@ -3,9 +3,9 @@ //! clipboard as SVG markup; each path becomes a DesignCraft path with its fill and stroke, groups //! stay groups, and text comes in as outlines. +use craft_svg::tree::document::{Document as SvgDocument, LineCap, LineJoin, Node as SvgNode, Paint as SvgPaint, Segment}; use designcraft_doc::{Content, Fill, Item, ItemId, Selection, Shape, Stroke, StrokeType}; use designcraft_geom::{Affine, BezPath, PathData, Point, Rect}; -use designcraft_images::usvg; use serde_json::{Value, json}; use super::{CommandSpec, Result, Session, bad, cmd, has_doc, spread_param, str_param}; @@ -27,7 +27,7 @@ pub fn is_svg_markup(text: &str) -> bool { designcraft_images::is_svg(text.as_bytes()) } -/// Cap conversion nesting too; the shared image parser bounds XML before usvg runs. +/// Cap conversion nesting too; the shared SVG scene builder enforces this limit. const MAX_DEPTH: usize = 64; /// A converted SVG node, in points. @@ -68,20 +68,15 @@ struct Notes { too_deep: bool, } -fn paint(p: &usvg::Paint, opacity: f32, notes: &mut Notes) -> Option { - let rgb = match p { - usvg::Paint::Color(c) => [c.red, c.green, c.blue], - // Gradients and patterns come in as their first colour (InDesign keeps Illustrator - // gradients; DesignCraft's swatch gradients can't be made from SVG stops yet). - usvg::Paint::LinearGradient(g) => { +fn paint(p: &SvgPaint, notes: &mut Notes) -> Option { + let (rgb, opacity) = match p { + SvgPaint::Color { rgb, opacity } => (*rgb, *opacity), + // Gradients and patterns are retained as their first color where available. + SvgPaint::Gradient { first_rgb, opacity } => { notes.gradients += 1; - g.stops().first().map(|s| [s.color().red, s.color().green, s.color().blue])? + (*first_rgb.as_ref()?, *opacity) } - usvg::Paint::RadialGradient(g) => { - notes.gradients += 1; - g.stops().first().map(|s| [s.color().red, s.color().green, s.color().blue])? - } - usvg::Paint::Pattern(_) => { + SvgPaint::Pattern => { notes.gradients += 1; return None; } @@ -94,56 +89,43 @@ fn round(v: f64) -> f64 { (v * 10_000.0).round() / 10_000.0 } -/// `t` (SVG px space) then px → pt. -fn to_affine(t: usvg::Transform) -> Affine { - let pt = designcraft_images::PT_PER_PX; - Affine::scale(pt) * Affine::new([f64::from(t.sx), f64::from(t.ky), f64::from(t.kx), f64::from(t.sy), f64::from(t.tx), f64::from(t.ty)]) -} - -fn bezpath(data: &usvg::tiny_skia_path::Path, xf: Affine) -> BezPath { - use usvg::tiny_skia_path::PathSegment as S; - let p = |q: usvg::tiny_skia_path::Point| xf * Point::new(f64::from(q.x), f64::from(q.y)); +fn bezpath(segments: &[Segment]) -> BezPath { + let p = |q: [f64; 2]| Point::new(q[0] * designcraft_images::PT_PER_PX, q[1] * designcraft_images::PT_PER_PX); let mut bp = BezPath::new(); - for seg in data.segments() { + for seg in segments { match seg { - S::MoveTo(a) => bp.move_to(p(a)), - S::LineTo(a) => bp.line_to(p(a)), - S::QuadTo(a, b) => bp.quad_to(p(a), p(b)), - S::CubicTo(a, b, c) => bp.curve_to(p(a), p(b), p(c)), - S::Close => bp.close_path(), + Segment::MoveTo(a) => bp.move_to(p(*a)), + Segment::LineTo(a) => bp.line_to(p(*a)), + Segment::QuadTo(a, b) => bp.quad_to(p(*a), p(*b)), + Segment::CubicTo(a, b, c) => bp.curve_to(p(*a), p(*b), p(*c)), + Segment::Close => bp.close_path(), } } bp } -fn convert(g: &usvg::Group, depth: usize, notes: &mut Notes, out: &mut Vec) { - for node in g.children() { +fn convert(nodes: &[SvgNode], notes: &mut Notes, out: &mut Vec) { + for node in nodes { match node { - usvg::Node::Path(path) => { - if !path.is_visible() || path.data().is_empty() { - continue; - } - let xf = to_affine(path.abs_transform()); - let fill = path.fill().and_then(|f| paint(f.paint(), f.opacity().get(), notes)); - let stroke = path.stroke().and_then(|s| { - let c = paint(s.paint(), s.opacity().get(), notes)?; - // A stroke scales with the art: its width in points. - let scale = xf.determinant().abs().sqrt(); - let width = round(f64::from(s.width().get()) * scale); - let dashes = s.dasharray().map(|d| d.iter().map(|v| round(f64::from(*v) * scale)).collect::>()); + SvgNode::Path(path) => { + let fill = path.fill.as_ref().and_then(|p| paint(p, notes)); + let stroke = path.stroke.as_ref().and_then(|s| { + let c = paint(&s.paint, notes)?; + let width = round(s.width * designcraft_images::PT_PER_PX); + let dashes = s.dash_array.as_ref().map(|d| d.iter().map(|v| round(v * designcraft_images::PT_PER_PX)).collect::>()); let style = StrokeStyle { width, - cap: match s.linecap() { - usvg::LineCap::Butt => designcraft_doc::Cap::Butt, - usvg::LineCap::Round => designcraft_doc::Cap::Round, - usvg::LineCap::Square => designcraft_doc::Cap::Projecting, + cap: match s.cap { + LineCap::Butt => designcraft_doc::Cap::Butt, + LineCap::Round => designcraft_doc::Cap::Round, + LineCap::Square => designcraft_doc::Cap::Projecting, }, - join: match s.linejoin() { - usvg::LineJoin::Round => designcraft_doc::Join::Round, - usvg::LineJoin::Bevel => designcraft_doc::Join::Bevel, + join: match s.join { + LineJoin::Round => designcraft_doc::Join::Round, + LineJoin::Bevel => designcraft_doc::Join::Bevel, _ => designcraft_doc::Join::Miter, }, - miter: f64::from(s.miterlimit().get()), + miter: s.miter_limit, dashes, }; Some((c, style)) @@ -156,38 +138,22 @@ fn convert(g: &usvg::Group, depth: usize, notes: &mut Notes, out: &mut Vec) { notes.mixed_opacity += 1; } - out.push(Art::Path { path: bezpath(path.data(), xf), fill, stroke, opacity: 1.0 }); + out.push(Art::Path { path: bezpath(&path.segments), fill, stroke, opacity: 1.0 }); } - usvg::Node::Group(child) => { - if depth >= MAX_DEPTH { - notes.too_deep = true; - continue; - } + SvgNode::Group { children, opacity } => { let mut items = Vec::new(); - convert(child, depth + 1, notes, &mut items); + convert(children, notes, &mut items); // usvg wraps nodes in groups for transforms and opacity; only a group of several // objects stays a group. Its opacity goes to what it holds. - let opacity = child.opacity().get(); if items.len() > 1 { - out.push(Art::Group(items, opacity)); + out.push(Art::Group(items, *opacity)); } else { for mut a in items { - a.fade(opacity); + a.fade(*opacity); out.push(a); } } } - // Text arrives as outlines, as Illustrator's Create Outlines would give it. - usvg::Node::Text(text) => { - let mut items = Vec::new(); - convert(text.flattened(), depth + 1, notes, &mut items); - if items.len() > 1 { - out.push(Art::Group(items, 1.0)); - } else { - out.extend(items); - } - } - usvg::Node::Image(_) => notes.images += 1, } } } @@ -280,9 +246,10 @@ fn paste_svg(s: &mut Session, p: &Value) -> Result { return Err(bad(ID, "the clipboard doesn't hold SVG markup")); } let tree = designcraft_images::svg_tree(svg.as_bytes()).ok_or_else(|| bad(ID, "the SVG on the clipboard can't be read"))?; - let mut notes = Notes::default(); + let scene = SvgDocument::from_tree(&tree, MAX_DEPTH); + let mut notes = Notes { images: scene.images, too_deep: scene.too_deep, ..Default::default() }; let mut art = Vec::new(); - convert(tree.root(), 0, &mut notes, &mut art); + convert(&scene.nodes, &mut notes, &mut art); let b = bounds(&art).ok_or_else(|| bad(ID, "the SVG on the clipboard has no paths to paste"))?; let sr = spread_param(p, ID)?; let layer = s.doc()?.active_layer;