//! SVG export from a DCEL subgraph. //! //! Generates a minimal SVG string containing one `` per filled face, //! plus stroked edges. Used as the secondary clipboard format for cross-app paste. use crate::dcel2::{Dcel, FaceId, HalfEdgeId}; use kurbo::CubicBez; /// Convert a DCEL to an SVG string. /// /// Each non-unbounded face with a fill color becomes a ``. /// Each edge with a stroke becomes a ``. /// Coordinates are document-space (no transform applied). pub fn dcel_to_svg(dcel: &Dcel) -> String { // Compute bounding box from vertex positions. let mut min_x = f64::MAX; let mut min_y = f64::MAX; let mut max_x = f64::MIN; let mut max_y = f64::MIN; for v in &dcel.vertices { if !v.deleted { min_x = min_x.min(v.position.x); min_y = min_y.min(v.position.y); max_x = max_x.max(v.position.x); max_y = max_y.max(v.position.y); } } if min_x == f64::MAX { return r#""#.to_string(); } // Add a small margin. let margin = 2.0; let vx = min_x - margin; let vy = min_y - margin; let vw = (max_x - min_x) + margin * 2.0; let vh = (max_y - min_y) + margin * 2.0; let mut svg = format!( r#""# ); // Emit filled faces. for (face_idx, face) in dcel.faces.iter().enumerate() { if face.deleted || face_idx == 0 { continue; } let fill_color = match &face.fill_color { Some(c) => format!("rgba({},{},{},{})", c.r, c.g, c.b, c.a as f32 / 255.0), None => continue, }; let face_id = FaceId(face_idx as u32); let path_d = face_boundary_to_svg_path(dcel, face_id); if path_d.is_empty() { continue; } svg.push_str(&format!(r#""#)); } // Emit stroked edges. for edge in &dcel.edges { if edge.deleted { continue; } let (stroke_color, stroke_width) = match (&edge.stroke_color, &edge.stroke_style) { (Some(c), Some(s)) => ( format!("rgba({},{},{},{})", c.r, c.g, c.b, c.a as f32 / 255.0), s.width, ), _ => continue, }; let path_d = cubic_to_svg_path(&edge.curve); svg.push_str(&format!( r#""# )); } svg.push_str(""); svg } /// Walk a face's outer boundary half-edges and build an SVG path string. fn face_boundary_to_svg_path(dcel: &Dcel, face_id: FaceId) -> String { let face = dcel.face(face_id); let start_he = face.outer_half_edge; if start_he.is_none() { return String::new(); } let mut path = String::new(); let mut first = true; let mut he_id = start_he; // Safety limit to prevent infinite loops on malformed DCELs. let limit = dcel.half_edges.len() + 1; let mut count = 0; loop { if count > limit { break; } count += 1; let he = dcel.half_edge(he_id); if he.deleted { break; } let edge = dcel.edge(he.edge); // Determine curve direction: forward half-edge is half_edges[0]. let curve = if edge.half_edges[0] == he_id { edge.curve } else { // Reverse the cubic bezier. let c = edge.curve; CubicBez::new(c.p3, c.p2, c.p1, c.p0) }; if first { path.push_str(&format!("M {:.3} {:.3} ", curve.p0.x, curve.p0.y)); first = false; } path.push_str(&format!( "C {:.3} {:.3} {:.3} {:.3} {:.3} {:.3} ", curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y, )); he_id = he.next; if he_id == start_he { break; } } if !path.is_empty() { path.push('Z'); } // Also handle inner boundaries (holes). for &inner_he_start in &face.inner_half_edges { if inner_he_start.is_none() { continue; } let inner = inner_boundary_to_svg_path(dcel, inner_he_start); if !inner.is_empty() { path.push(' '); path.push_str(&inner); } } path } fn inner_boundary_to_svg_path(dcel: &Dcel, start_he: HalfEdgeId) -> String { let mut path = String::new(); let mut first = true; let mut he_id = start_he; let limit = dcel.half_edges.len() + 1; let mut count = 0; loop { if count > limit { break; } count += 1; let he = dcel.half_edge(he_id); if he.deleted { break; } let edge = dcel.edge(he.edge); let curve = if edge.half_edges[0] == he_id { edge.curve } else { let c = edge.curve; CubicBez::new(c.p3, c.p2, c.p1, c.p0) }; if first { path.push_str(&format!("M {:.3} {:.3} ", curve.p0.x, curve.p0.y)); first = false; } path.push_str(&format!( "C {:.3} {:.3} {:.3} {:.3} {:.3} {:.3} ", curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y, )); he_id = he.next; if he_id == start_he { break; } } if !path.is_empty() { path.push('Z'); } path } /// Convert a single cubic bezier to an SVG path string. fn cubic_to_svg_path(curve: &CubicBez) -> String { format!( "M {:.3} {:.3} C {:.3} {:.3} {:.3} {:.3} {:.3} {:.3}", curve.p0.x, curve.p0.y, curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y, ) } // =========================================================================== // Document / VectorGraph → SVG (the current model). The functions above target // the legacy DCEL and are kept only for the clipboard stub. // =========================================================================== use crate::document::Document; use crate::gradient::{GradientExtend, GradientType, ShapeGradient}; use crate::layer::AnyLayer; use crate::shape::{Cap, FillRule, Join, ShapeColor}; use crate::vector_graph::{FillId, VectorGraph}; use kurbo::{BezPath, PathEl, Rect, Shape}; /// Serialize the document's **vector** content to a standalone SVG string, at document time `time`. /// Vector layers, groups of them, and text layers (as real glyph outlines) — raster/video/audio/ /// effect layers are skipped (a later pass can rasterize them to ``). Animation is a single /// static frame at `time`. pub fn document_to_svg(document: &Document, time: f64) -> String { let (w, h) = (document.width, document.height); let mut defs = String::new(); let mut body = String::new(); let mut grad_n = 0usize; // Opaque background rect (skip if the document background is transparent). let bg = document.background_color; if bg.a > 0 { body.push_str(&format!( r#""#, fill_attrs(&bg) )); } for layer in &document.root.children { layer_to_svg(layer, time, 1.0, &mut body, &mut defs, &mut grad_n); } format!( concat!( r#"{}{}"# ), w, h, w, h, defs, body ) } /// Append one layer's SVG. Recurses into groups (``); other non-vector layer types are skipped. fn layer_to_svg(layer: &AnyLayer, time: f64, parent_opacity: f64, body: &mut String, defs: &mut String, grad_n: &mut usize) { match layer { AnyLayer::Vector(vl) => { if !vl.layer.visible { return; // hidden layers are not rendered, so don't export them } let opacity = parent_opacity * vl.layer.opacity; if let Some(graph) = vl.tweened_graph_at(time) { let wrap = opacity < 0.999; if wrap { body.push_str(&format!(r#""#)); } vector_graph_to_svg(&graph, body, defs, grad_n); if wrap { body.push_str(""); } } // NOTE: placed clip instances (nested clips with their own transform) are not yet // exported — a refinement once loose-geometry export is verified. } AnyLayer::Group(g) => { if !g.layer.visible { return; } // Render children first; only emit the wrapper if it has exportable content // (avoids empty groups when every child is a non-vector/hidden layer). let mut inner = String::new(); for child in &g.children { layer_to_svg(child, time, 1.0, &mut inner, defs, grad_n); } if !inner.is_empty() { let opacity = parent_opacity * g.layer.opacity; body.push_str(&format!(r#""#)); body.push_str(&inner); body.push_str(""); } } AnyLayer::Text(tl) => text_layer_to_svg(tl, time, parent_opacity, body), // Raster/Video/Audio/Effect have no lossless vector representation — skipped this pass. _ => {} } } /// A skrifa outline pen that appends transformed glyph contours to an SVG path `d` string. /// /// skrifa emits outline points in y-up pixel space (origin at the glyph baseline); this maps each /// point into document space: `x = gx + px + skew·py`, `y = gy − py` (Y flips, `skew` applies any /// synthetic-italic slant), where `(gx, gy)` is the glyph's document-space pen position. struct SvgOutlinePen<'a> { gx: f64, gy: f64, skew: f64, d: &'a mut String, } impl<'a> SvgOutlinePen<'a> { fn map(&self, px: f32, py: f32) -> (f64, f64) { let (px, py) = (px as f64, py as f64); (self.gx + px + self.skew * py, self.gy - py) } } impl skrifa::outline::OutlinePen for SvgOutlinePen<'_> { fn move_to(&mut self, x: f32, y: f32) { let (x, y) = self.map(x, y); self.d.push_str(&format!("M{x:.2} {y:.2}")); } fn line_to(&mut self, x: f32, y: f32) { let (x, y) = self.map(x, y); self.d.push_str(&format!("L{x:.2} {y:.2}")); } fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) { let (cx, cy) = self.map(cx, cy); let (x, y) = self.map(x, y); self.d.push_str(&format!("Q{cx:.2} {cy:.2} {x:.2} {y:.2}")); } fn curve_to(&mut self, c0x: f32, c0y: f32, c1x: f32, c1y: f32, x: f32, y: f32) { let (c0x, c0y) = self.map(c0x, c0y); let (c1x, c1y) = self.map(c1x, c1y); let (x, y) = self.map(x, y); self.d.push_str(&format!("C{c0x:.2} {c0y:.2} {c1x:.2} {c1y:.2} {x:.2} {y:.2}")); } fn close(&mut self) { self.d.push('Z'); } } /// Append a text layer's glyphs to `body` as a single filled `` of real glyph outlines /// (lossless — no font dependency in the SVG). Lays the text out with the same parley path the /// renderer uses, then extracts each glyph's outline with skrifa. Variable-font axis positions and /// synthetic-italic skew are honored; synthetic bold is not (rare). fn text_layer_to_svg( tl: &crate::text_layer::TextLayer, time: f64, parent_opacity: f64, body: &mut String, ) { use skrifa::MetadataProvider; if !tl.layer.visible { return; } let content = tl.content_at(time); if content.text.is_empty() { return; } let (ox, oy) = (tl.box_origin.x, tl.box_origin.y); let mut d = String::new(); crate::fonts::with_layout(content, tl.box_width as f32, |layout| { for line in layout.lines() { for item in line.items() { let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue }; let run = glyph_run.run(); let font = run.font(); let font_size = run.font_size(); let skew = run .synthesis() .skew() .map(|angle| (angle as f64).to_radians().tan()) .unwrap_or(0.0); let Ok(font_ref) = skrifa::FontRef::from_index(font.data.data(), font.index) else { continue; }; let outlines = font_ref.outline_glyphs(); // Variable-font axis position for this run (empty for static fonts). let coords: Vec = run .normalized_coords() .iter() .map(|&c| skrifa::instance::NormalizedCoord::from_bits(c)) .collect(); let location = skrifa::instance::LocationRef::new(&coords); let size = skrifa::instance::Size::new(font_size); for g in glyph_run.positioned_glyphs() { let Some(glyph) = outlines.get(skrifa::GlyphId::new(g.id as u32)) else { continue; }; let mut pen = SvgOutlinePen { gx: ox + g.x as f64, gy: oy + g.y as f64, skew, d: &mut d, }; let settings = skrifa::outline::DrawSettings::unhinted(size, location); let _ = glyph.draw(settings, &mut pen); } } } }); if d.is_empty() { return; } let [r, g, b, a] = content.color; let to_u8 = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u8; let fill_opacity = (a as f64 * parent_opacity * tl.layer.opacity).clamp(0.0, 1.0); body.push_str(&format!( r#""#, to_u8(r), to_u8(g), to_u8(b), fill_opacity, d )); } /// Emit a vector graph's fills (``) and stroked edges (``) into `body`, /// accumulating any gradients into `defs`. Geometry is in document space (no per-layer transform). fn vector_graph_to_svg(graph: &VectorGraph, body: &mut String, defs: &mut String, grad_n: &mut usize) { // Fills first (drawn under strokes, matching the renderer). for (i, fill) in graph.fills.iter().enumerate() { if fill.deleted { continue; } let path = graph.fill_to_bezpath(FillId(i as u32)); let d = bezpath_to_d(&path); if d.is_empty() { continue; } let rule = match fill.fill_rule { FillRule::NonZero => "nonzero", FillRule::EvenOdd => "evenodd", }; if let Some(grad) = &fill.gradient_fill { let id = format!("grad{}", *grad_n); *grad_n += 1; defs.push_str(&gradient_to_svg(grad, &id, path.bounding_box())); body.push_str(&format!(r#""#)); } else if fill.image_fill.is_some() { // Image fills need / + asset embedding — skipped this (vector-only) pass. continue; } else if let Some(c) = &fill.color { body.push_str(&format!(r#""#, fill_attrs(c))); } } // Strokes: one per stroked edge (each edge may carry its own style). for edge in &graph.edges { if edge.deleted { continue; } if let (Some(style), Some(color)) = (&edge.stroke_style, &edge.stroke_color) { let d = cubic_to_svg_path(&edge.curve); body.push_str(&format!( r#""#, stroke_attrs(color), style.width, cap_str(style.cap), join_str(style.join), style.miter_limit )); } } } /// `` / `` definition matching the renderer's start/end semantics. fn gradient_to_svg(grad: &ShapeGradient, id: &str, bbox: Rect) -> String { use kurbo::Point; // Mirror renderer.rs: explicit world endpoints if present (radial reflects the edge through the // center so midpoint(start,end) == center), else derive from angle + bbox. let (start, end) = match (grad.start_world, grad.end_world) { (Some((sx, sy)), Some((ex, ey))) => match grad.kind { GradientType::Linear => (Point::new(sx, sy), Point::new(ex, ey)), GradientType::Radial => (Point::new(2.0 * sx - ex, 2.0 * sy - ey), Point::new(ex, ey)), }, _ => crate::renderer::gradient_bbox_endpoints(grad.angle, bbox), }; let stops: String = grad .stops .iter() .map(|s| { format!( r##""##, s.position, s.color.r, s.color.g, s.color.b, s.color.a as f32 / 255.0 ) }) .collect(); let spread = match grad.extend { GradientExtend::Pad => "pad", GradientExtend::Reflect => "reflect", GradientExtend::Repeat => "repeat", }; match grad.kind { GradientType::Linear => format!( r#"{stops}"#, start.x, start.y, end.x, end.y ), GradientType::Radial => { let (cx, cy) = ((start.x + end.x) * 0.5, (start.y + end.y) * 0.5); let r = (((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt()) * 0.5; format!( r#"{stops}"# ) } } } /// kurbo `BezPath` → SVG path-data string (`M/L/Q/C/Z`). fn bezpath_to_d(path: &BezPath) -> String { let mut d = String::new(); for el in path.elements() { match el { PathEl::MoveTo(p) => d.push_str(&format!("M{:.3} {:.3} ", p.x, p.y)), PathEl::LineTo(p) => d.push_str(&format!("L{:.3} {:.3} ", p.x, p.y)), PathEl::QuadTo(p1, p) => d.push_str(&format!("Q{:.3} {:.3} {:.3} {:.3} ", p1.x, p1.y, p.x, p.y)), PathEl::CurveTo(p1, p2, p) => d.push_str(&format!( "C{:.3} {:.3} {:.3} {:.3} {:.3} {:.3} ", p1.x, p1.y, p2.x, p2.y, p.x, p.y )), PathEl::ClosePath => d.push_str("Z "), } } d.trim_end().to_string() } // sRGB color → SVG attributes. Hex color + a separate `*-opacity` for max compatibility (Inkscape). fn fill_attrs(c: &ShapeColor) -> String { if c.a == 255 { format!(r##"fill="#{:02x}{:02x}{:02x}""##, c.r, c.g, c.b) } else { format!(r##"fill="#{:02x}{:02x}{:02x}" fill-opacity="{:.4}""##, c.r, c.g, c.b, c.a as f32 / 255.0) } } fn stroke_attrs(c: &ShapeColor) -> String { if c.a == 255 { format!(r##"stroke="#{:02x}{:02x}{:02x}""##, c.r, c.g, c.b) } else { format!(r##"stroke="#{:02x}{:02x}{:02x}" stroke-opacity="{:.4}""##, c.r, c.g, c.b, c.a as f32 / 255.0) } } fn cap_str(cap: Cap) -> &'static str { match cap { Cap::Butt => "butt", Cap::Round => "round", Cap::Square => "square", } } fn join_str(join: Join) -> &'static str { match join { Join::Miter => "miter", Join::Round => "round", Join::Bevel => "bevel", } } #[cfg(test)] mod export_tests { use super::*; use crate::shape::{ShapeColor, StrokeStyle}; use crate::vector_graph::{Direction, VectorGraph}; use kurbo::{CubicBez, Point}; fn line(a: Point, b: Point) -> CubicBez { // Degenerate cubic representing a straight segment (matches our model). CubicBez::new(a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b) } #[test] fn solid_triangle_fill_and_stroke() { let mut g = VectorGraph::new(); let p0 = Point::new(10.0, 10.0); let p1 = Point::new(90.0, 10.0); let p2 = Point::new(50.0, 80.0); let v0 = g.alloc_vertex(p0); let v1 = g.alloc_vertex(p1); let v2 = g.alloc_vertex(p2); let stroke = Some(StrokeStyle { width: 2.0, ..Default::default() }); let scol = Some(ShapeColor::rgb(0, 0, 0)); let e0 = g.alloc_edge(line(p0, p1), v0, v1, stroke.clone(), scol); let e1 = g.alloc_edge(line(p1, p2), v1, v2, stroke.clone(), scol); let e2 = g.alloc_edge(line(p2, p0), v2, v0, stroke.clone(), scol); g.alloc_fill( vec![(e0, Direction::Forward), (e1, Direction::Forward), (e2, Direction::Forward)], ShapeColor::rgb(255, 0, 0), crate::shape::FillRule::NonZero, ); let mut body = String::new(); let mut defs = String::new(); let mut n = 0; vector_graph_to_svg(&g, &mut body, &mut defs, &mut n); assert!(body.contains(r##"fill="#ff0000""##), "fill color missing: {body}"); assert!(body.contains(r#"fill-rule="nonzero""#), "fill-rule missing: {body}"); assert!(body.contains(r#"fill="none""#), "stroke path missing: {body}"); assert!(body.contains(r#"stroke-width="2.000""#), "stroke width missing: {body}"); assert!(defs.is_empty(), "no gradients expected: {defs}"); // 1 fill path + 3 stroked edges = 4 elements. assert_eq!(body.matches(" 80, "suspiciously short path: {body}"); } #[test] fn empty_text_layer_emits_nothing() { use crate::text_layer::TextLayer; let tl = TextLayer::new("t", Point::new(0.0, 0.0)); // no text set let mut body = String::new(); text_layer_to_svg(&tl, 0.0, 1.0, &mut body); assert!(body.is_empty(), "empty text should emit nothing: {body}"); } }