Skip to main content

plotive_svg/
lib.rs

1use std::path::Path;
2use std::{fmt, io};
3
4use plotive::geom::{self, Transform};
5use plotive::render::{self, Surface};
6use plotive::{Prepare, Rgba8, Style, des, drawing};
7use svg::Node;
8use svg::node::element;
9
10#[derive(Debug)]
11pub enum Error {
12    Io(io::Error),
13    Drawing(drawing::Error),
14}
15
16impl From<io::Error> for Error {
17    fn from(err: io::Error) -> Self {
18        Error::Io(err)
19    }
20}
21
22impl From<drawing::Error> for Error {
23    fn from(err: drawing::Error) -> Self {
24        Error::Drawing(err)
25    }
26}
27
28impl fmt::Display for Error {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            Error::Io(err) => write!(f, "IO error: {}", err),
32            Error::Drawing(err) => write!(f, "Drawing error: {}", err),
33        }
34    }
35}
36
37impl std::error::Error for Error {}
38
39/// Parameters needed for saving a figure as SVG
40#[derive(Debug, Clone)]
41pub struct Params<'a> {
42    pub style: Style,
43    pub scale: f32,
44    /// Optional font database to use for text rendering
45    /// This parameter is ignored when saving a prepared figure,
46    /// as the fonts have already been resolved.
47    /// In such case, this parameter can be left to `None` (which is the default).
48    pub fontdb: Option<&'a plotive::fontdb::Database>,
49    /// Optional prefix for generated IDs (e.g., for clip paths, gradients).
50    /// Use this when embedding multiple SVGs in the same document to avoid ID conflicts.
51    pub id_prefix: Option<String>,
52}
53
54impl Default for Params<'_> {
55    fn default() -> Self {
56        Self {
57            style: Style::default(),
58            scale: 1.0,
59            fontdb: None,
60            id_prefix: None,
61        }
62    }
63}
64
65/// Trait for saving a figure as SVG file
66pub trait SaveSvg {
67    /// Save the figure as a SVG file at the given path.
68    ///
69    /// The data source parameter is ignored when saving a prepared figure,
70    /// as the data has already been resolved.
71    /// Therefore, this parameter can be left to `&()` when saving a prepared figure.
72    ///
73    /// # Example
74    ///
75    /// ```rust
76    /// use plotive::des;
77    /// use plotive::Prepare;
78    /// use plotive_svg::{SaveSvg, Params};
79    ///
80    /// // Create your figure design (this one has inline data for simplicity)
81    /// let fig = des::series::Line::new(
82    ///     des::data_inline(vec![0.0, 1.0, 2.0]),
83    ///     des::data_inline(vec![0.0, 1.0, 0.0]),
84    /// ).into_plot()
85    /// .into_figure();
86    ///
87    /// // data source is not needed for inline data
88    /// fig.save_svg("figure.svg", &(), Default::default()).unwrap();
89    /// # std::fs::remove_file("figure.svg").unwrap();
90    /// ```
91    fn save_svg<P, D>(&self, path: P, data_src: &D, params: Params) -> Result<(), Error>
92    where
93        P: AsRef<Path>,
94        D: plotive::data::Source + ?Sized;
95}
96
97impl SaveSvg for des::Figure {
98    fn save_svg<P, D>(&self, path: P, data_src: &D, params: Params) -> Result<(), Error>
99    where
100        P: AsRef<Path>,
101        D: plotive::data::Source + ?Sized,
102    {
103        let prepared = self.prepare(data_src, params.fontdb)?;
104        prepared.save_svg(path, data_src, params)
105    }
106}
107
108impl SaveSvg for drawing::PreparedFigure {
109    fn save_svg<P, D>(&self, path: P, _data_src: &D, params: Params) -> Result<(), Error>
110    where
111        P: AsRef<Path>,
112        D: plotive::data::Source + ?Sized,
113    {
114        let size = self.size();
115        let witdth = (size.width() * params.scale) as u32;
116        let height = (size.height() * params.scale) as u32;
117
118        let mut surface = SvgSurface::new(witdth, height);
119        if let Some(id_prefix) = params.id_prefix.as_ref() {
120            surface = surface.with_id_prefix(id_prefix);
121        }
122
123        self.draw(&mut surface, &params.style);
124        surface.save_svg(path)?;
125        Ok(())
126    }
127}
128
129pub struct SvgSurface {
130    doc: svg::Document,
131    defs: Option<element::Definitions>,
132    doc_children: Vec<Box<dyn Node>>,
133    id_num: u32,
134    id_prefix: Option<String>,
135    group_stack: Vec<element::Group>,
136}
137
138impl SvgSurface {
139    pub fn new(width: u32, height: u32) -> Self {
140        let doc = svg::Document::new()
141            .set("width", width)
142            .set("height", height);
143        SvgSurface {
144            doc,
145            defs: None,
146            doc_children: Vec::new(),
147            id_prefix: None,
148            id_num: 0,
149            group_stack: vec![],
150        }
151    }
152
153    /// Set a prefix for generated IDs (e.g., for clip paths).
154    /// Use this when embedding multiple SVGs in the same document to avoid ID conflicts.
155    pub fn with_id_prefix<S: Into<String>>(mut self, prefix: S) -> Self {
156        self.id_prefix = Some(prefix.into());
157        self
158    }
159
160    pub fn save_svg<P: AsRef<std::path::Path>>(&self, path: P) -> io::Result<()> {
161        if !self.group_stack.is_empty() {
162            panic!("Unbalanced clip stack");
163        }
164        svg::save(path, &self.doc)
165    }
166
167    pub fn write<W>(&self, dest: &mut W) -> io::Result<()>
168    where
169        W: io::Write,
170    {
171        if !self.group_stack.is_empty() {
172            panic!("Unbalanced clip stack");
173        }
174        svg::write(dest, &self.doc)
175    }
176}
177
178impl Surface for SvgSurface {
179    fn caps(&self) -> render::SurfaceCaps {
180        render::SurfaceCaps {
181            max_gradient_stops: usize::MAX,
182        }
183    }
184
185    /// Prepare the surface for drawing, with the given width and height in plot units
186    fn prepare(&mut self, size: geom::Size, fill: Option<render::Paint>) {
187        self.doc
188            .assign("viewBox", (0, 0, size.width(), size.height()));
189        if let Some(fill) = fill {
190            let mut node = element::Rectangle::new()
191                .set("width", "100%")
192                .set("height", "100%");
193            match fill {
194                render::Paint::Solid(color) => node.assign("fill", color.html()),
195                render::Paint::LinearGradient {
196                    start_pos,
197                    end_pos,
198                    stops,
199                } => {
200                    let grad_id = self.add_linear_gradient(start_pos, end_pos, stops);
201                    node.assign("fill", format!("url(#{})", grad_id));
202                }
203            }
204            self.append_node(node);
205        }
206    }
207
208    /// Draw a rectangle
209    fn draw_rect(&mut self, rect: &render::Rect) {
210        let mut node = rectangle_node(&rect.rect);
211        self.assign_fill(&mut node, rect.fill.as_ref());
212        self.assign_stroke(&mut node, rect.stroke.as_ref());
213        self.assign_transform(&mut node, rect.transform);
214        self.append_node(node);
215    }
216
217    fn draw_path(&mut self, path: &render::Path) {
218        let mut node = element::Path::new();
219        self.assign_fill(&mut node, path.fill.as_ref());
220        self.assign_stroke(&mut node, path.stroke.as_ref());
221        self.assign_transform(&mut node, path.transform);
222        node.assign("d", path_data(path.path));
223        self.append_node(node);
224    }
225
226    fn push_clip(&mut self, clip: &render::Clip) {
227        let clip_id = self.bump_id();
228        let clip_id_url = format!("url(#{})", clip_id);
229        let mut rect_node = rectangle_node(&clip.rect);
230        self.assign_transform(&mut rect_node, clip.transform);
231        let node = element::ClipPath::new()
232            .set("id", clip_id.clone())
233            .add(rect_node);
234        let defs = self.defs.get_or_insert_with(element::Definitions::new);
235        defs.append(node);
236        self.group_stack
237            .push(element::Group::new().set("clip-path", clip_id_url));
238    }
239
240    fn pop_clip(&mut self) {
241        let g = self.group_stack.pop();
242        if g.is_none() {
243            panic!("Unbalanced clip stack");
244        }
245        self.append_node(g.unwrap());
246    }
247
248    fn finalize(&mut self) {
249        if !self.group_stack.is_empty() {
250            panic!("Unbalanced clip stack");
251        }
252
253        if let Some(defs) = self.defs.take() {
254            self.doc.append(defs);
255        }
256        for child in self.doc_children.drain(..) {
257            self.doc.append(child);
258        }
259    }
260}
261
262impl SvgSurface {
263    fn append_node<T>(&mut self, node: T)
264    where
265        T: Node,
266    {
267        if self.group_stack.is_empty() {
268            self.doc_children.push(Box::new(node));
269        } else {
270            self.group_stack.last_mut().unwrap().append(node);
271        }
272    }
273
274    fn bump_id(&mut self) -> String {
275        self.id_num += 1;
276        let prefix = self.id_prefix.as_deref().unwrap_or("plotive");
277        format!("{}{}", prefix, self.id_num)
278    }
279
280    fn add_linear_gradient(
281        &mut self,
282        start_pos: geom::Point,
283        end_pos: geom::Point,
284        stops: &[(f32, Rgba8)],
285    ) -> String {
286        let id = self.bump_id();
287        let mut grad = element::LinearGradient::new()
288            .set("id", id.clone())
289            .set("gradientUnits", "userSpaceOnUse")
290            .set("x1", start_pos.x)
291            .set("y1", start_pos.y)
292            .set("x2", end_pos.x)
293            .set("y2", end_pos.y);
294        for (offset, color) in stops {
295            grad.append(
296                element::Stop::new()
297                    .set("offset", format!("{}%", offset * 100.0))
298                    .set("stop-color", color.html()),
299            );
300        }
301        let defs = self.defs.get_or_insert_with(element::Definitions::new);
302        defs.append(grad);
303        id
304    }
305
306    fn assign_transform<N>(&self, node: &mut N, transform: Option<&geom::Transform>)
307    where
308        N: Node,
309    {
310        if let Some(Transform {
311            sx,
312            kx,
313            ky,
314            sy,
315            tx,
316            ty,
317        }) = transform
318        {
319            node.assign(
320                "transform",
321                format!("matrix({sx} {ky} {kx} {sy} {tx} {ty})"),
322            );
323        }
324    }
325
326    fn assign_fill<N>(&mut self, node: &mut N, fill: Option<&render::Paint>)
327    where
328        N: Node,
329    {
330        match fill {
331            Some(render::Paint::Solid(color)) => {
332                let (rgb, opacity) = color.split_rgb_opacity();
333                node.assign("fill", rgb.html());
334                if let Some(opacity) = opacity {
335                    node.assign("fill-opacity", opacity);
336                }
337            }
338            Some(render::Paint::LinearGradient {
339                start_pos,
340                end_pos,
341                stops,
342            }) => {
343                let grad_id = self.add_linear_gradient(*start_pos, *end_pos, stops);
344                node.assign("fill", format!("url(#{})", grad_id));
345            }
346            None => {
347                node.assign("fill", "none");
348            }
349        }
350    }
351    fn assign_stroke<N>(&self, node: &mut N, stroke: Option<&render::Stroke>)
352    where
353        N: Node,
354    {
355        if let Some(stroke) = stroke {
356            let (rgb, opacity) = stroke.color.split_rgb_opacity();
357            node.assign("stroke", rgb.html());
358            if let Some(opacity) = opacity {
359                node.assign("stroke-opacity", opacity);
360            }
361            let w = stroke.width;
362            node.assign("stroke-width", w);
363            match stroke.pattern {
364                render::LinePattern::Solid => (),
365                render::LinePattern::Dash(dash) => {
366                    let array: Vec<f32> = dash.iter().map(|d| d * w).collect();
367                    node.assign("stroke-dasharray", array)
368                }
369            }
370            match stroke.join {
371                render::LineJoin::Miter => node.assign("stroke-linejoin", "miter"),
372                render::LineJoin::Round => node.assign("stroke-linejoin", "round"),
373                render::LineJoin::Bevel => node.assign("stroke-linejoin", "bevel"),
374            }
375            match stroke.cap {
376                render::LineCap::Butt => node.assign("stroke-linecap", "butt"),
377                render::LineCap::Round => node.assign("stroke-linecap", "round"),
378                render::LineCap::Square => node.assign("stroke-linecap", "square"),
379            }
380        } else {
381            node.assign("stroke", "none");
382        }
383    }
384}
385
386fn path_data(path: &geom::Path) -> element::path::Data {
387    let mut data = element::path::Data::new();
388    for segment in path.segments() {
389        match segment {
390            geom::PathSegment::MoveTo(p) => {
391                data = data.move_to((p.x, p.y));
392            }
393            geom::PathSegment::LineTo(p) => {
394                data = data.line_to((p.x, p.y));
395            }
396            geom::PathSegment::QuadTo(p1, p2) => {
397                data = data.quadratic_curve_to((p1.x, p1.y, p2.x, p2.y));
398            }
399            geom::PathSegment::CubicTo(p1, p2, p3) => {
400                data = data.cubic_curve_to((p1.x, p1.y, p2.x, p2.y, p3.x, p3.y));
401            }
402            geom::PathSegment::Close => {
403                data = data.close();
404            }
405        }
406    }
407    data
408}
409
410fn rectangle_node(rect: &geom::Rect) -> element::Rectangle {
411    element::Rectangle::new()
412        .set("x", rect.x())
413        .set("y", rect.y())
414        .set("width", rect.width())
415        .set("height", rect.height())
416}