colorimetry_plot/
svgdoc.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2// Copyright (c) 2025, Harbers Bik LLC
3
4//! SVG document representation and rendering.
5//!
6//! This module provides the `SvgDocument` struct, which allows you to create and manipulate SVG documents
7//! and render them to SVG files. It includes methods for adding clip paths, symbols, and plots,
8//! as well as setting the document's width, height, and margin.
9
10use std::process;
11
12use svg::{
13    node::element::{ClipPath, Group, Path, Rectangle, Style, Symbol},
14    Document, Node,
15};
16
17use crate::{rendable::Rendable, view::ViewParameters};
18
19/// Default CSS styles for the SVG document.
20/// This CSS is applied to all elements in the SVG document by default.
21/// It sets the default fill, stroke, stroke-width, and font styles for text elements.
22/// You can append additional styles using the `append_scss` method of the `SvgDocument`.
23/// The styles are compiled to CSS using the `grass` crate when the SVG document is rendered.
24const DEFAULT_CSS: &str = "
25    * { 
26        fill:none; 
27        stroke:black; 
28        stroke-width:1px;
29        stroke-linecap: round;
30    }
31    text  {
32        fill:black; 
33        stroke: none; 
34        font-size: 16px; 
35        font-family: Helvetica, Arial, sans-serif; 
36    }
37";
38
39pub const NORTH: i32 = 90;
40pub const SOUTH: i32 = 270;
41pub const WEST: i32 = 180;
42pub const EAST: i32 = 0;
43pub const NORTH_WEST: i32 = 135;
44pub const NORTH_EAST: i32 = 45;
45pub const SOUTH_WEST: i32 = 225;
46pub const SOUTH_EAST: i32 = 315;
47
48/// Represents an SVG document with a specified width and height.  It contains clip paths, styles,
49/// layers, and symbols that can be used to create SVG graphics.  Although you can directly
50/// manipulate the SVG document, it is recommended to use higher level objects in this library such
51/// as `Chart` struct for creating scaled charts with x and y axis.
52///
53/// The `SvgDocument` struct is the main entry point for creating SVG documents in this library.
54///
55/// A SVG-global style sheet can be set using the `scss` field, which is a string containing SCSS
56/// styles. The styles are compiled to CSS using the `grass` crate when the SVG document is rendered.
57/// This allows for flexible styling of SVG elements using SCSS syntax, and verified the validity of the SCSS parameters
58/// before rendering. You can append additional SCSS styles using the `append_scss` method.
59///
60/// Any css file is also a valid SCSS file, so you can use any CSS file as a stylesheet.
61///
62/// The style sheet is embedded in the SVG document as a `<style>` element, which is
63/// the most convenient way to handle SVG images with styles.
64///
65/// You don't have to use a global style, you can set styles for individual elements using the
66/// `StyleAttr` parameter in all the plot element and plot functions.
67#[derive(Default)]
68pub struct SvgDocument {
69    pub(super) scss: String, // SCSS stylesheet content
70    pub(super) clip_paths: Vec<ClipPath>,
71    pub(super) symbols: Vec<Symbol>,
72    pub(super) plots: Vec<Box<dyn Rendable>>,
73    pub(super) nodes: Vec<Box<dyn Node>>, // layers and use
74    pub(super) margin: u32,
75    pub(super) width: Option<u32>,
76    pub(super) height: Option<u32>,
77}
78
79impl SvgDocument {
80    const DEFAULT_MARGIN: u32 = 50;
81    /// Creates a new `SvgDocument` with default settings only,
82    /// without any nodes, symbols, or plots.
83    pub fn new() -> Self {
84        SvgDocument {
85            clip_paths: Vec::new(),
86            scss: DEFAULT_CSS.to_string(),
87            nodes: Vec::new(), // layers, use, and svg elements
88            symbols: Vec::new(),
89            plots: Vec::new(),
90            margin: Self::DEFAULT_MARGIN,
91            width: None,
92            height: None,
93        }
94    }
95
96    /// Appends SCSS styles to the document's stylesheet.
97    /// This method allows you to add additional styles to the existing SCSS content.
98    /// The styles are compiled to CSS when the document is rendered.
99    ///
100    /// Most covenient is to use a separate SCSS file and include it using the `append_scss` method
101    /// using `include_str!` macro, which reads the file at compile time.
102    /// The SCSS styles are compiled to CSS using the `grass` crate when the SVG document is rendered,
103    /// and embedded in the resulting SVG document as a `<style>` element.
104    ///
105    /// Using a seperate SCSS file allows you to keep your styles organized and maintainable,
106    /// and also allows you to edit the styles in a lanaguate aware editor with syntax highlighting and autocompletion.
107    ///
108    /// # Example
109    /// ```ignore
110    /// // The file in the include_str! macro is just an example, and gives a compile error if the file does not exist.
111    /// use colorimetry_plot::svgdoc::SvgDocument;
112    ///
113    /// // Create a new SVG document and append SCSS styles from a file
114    /// let svg_doc = SvgDocument::new()
115    ///     .append_scss(include_str!("path/to/your/styles.scss"));
116    /// ```
117    pub fn append_scss(mut self, css: &str) -> Self {
118        self.scss.push_str(css);
119        self
120    }
121
122    /// Sets the margin for the SVG document.
123    /// The margin is applied to the width and height of the document when calculating the size of sub plots.
124    /// The default margin is 50 pixels.
125    pub fn set_margin(mut self, margin: u32) -> Self {
126        self.margin = margin;
127        self
128    }
129
130    /// Sets the width of the SVG document.
131    /// If not set, the width will be calculated based on the size of the sub plots and the margin.
132    /// If the width is larger than the maximum allowed size, an error will be printed and the program will exit.
133    /// The width is set in pixels.
134    pub fn set_width(mut self, width: u32) -> Self {
135        self.width = Some(width);
136        self
137    }
138
139    /// Sets the height of the SVG document.
140    /// If not set, the height will be calculated based on the size of the sub plots and the margin.
141    /// If the height is larger than the maximum allowed size, an error will be printed and the program will exit.
142    /// The height is set in pixels.
143    pub fn set_height(mut self, height: u32) -> Self {
144        self.height = Some(height);
145        self
146    }
147
148    /// Adds a path to the SVG document as a clip path with the specified ID.
149    pub fn add_clip_path(mut self, id: String, path: &Path) -> Self {
150        let clip = ClipPath::new().set("id", id).add(path.clone());
151        self.clip_paths.push(clip);
152        self
153    }
154
155    /// Adds a symbol to the SVG document.
156    pub fn add_symbol(mut self, symbol: impl Into<Symbol>) -> Self {
157        self.symbols.push(symbol.into());
158        self
159    }
160
161    /// Adds a node to the SVG document, typically used for headers, footers, or other SVG elements.
162    /// They will be added to the SVG document as-is, without any transformations or scaling,
163    /// and placed in front of any other content, into a layer with the id "front".
164    pub fn add_node(mut self, node: impl Into<Box<dyn Node>>) -> Self {
165        self.nodes.push(node.into());
166        self
167    }
168
169    /// Adds a sub plot to the SVG document.
170    /// The sub plot must implement the `Rendable` trait, which allows it to be rendered as an SVG element.
171    /// The sub plot will be positioned based on the document's flow, which is calculated by the `flow` method.
172    /// If the sub plot is larger than the document size, an error will be printed and the program will exit.
173    pub fn add_plot(mut self, svg_sub: Box<dyn Rendable>) -> Self {
174        self.plots.push(svg_sub);
175        self
176    }
177
178    /// Calculates the positions of the sub plots on the document.
179    /// The positions are calculated based on the document's width and height,
180    /// and the size of the sub plots. If there is only one sub plot, it will be centered in the document.
181    /// If there are multiple sub plots, the flow is not yet implemented and will return `todo!()`.
182    /// The sub plots will not be scaled, but positioned based on the document's flow.
183    /// If the sub plot is larger than the document size, an error will be printed and the program will exit.
184    /// # Returns
185    /// A vector of tuples containing the x and y positions of each sub plot in the document.
186    pub fn flow(&self) -> Vec<(u32, u32)> {
187        match self.plots.len() {
188            1 => {
189                let svg_sub = &self.plots[0];
190                let doc_width = self.width();
191                let doc_height = self.height();
192
193                let width = svg_sub.width();
194                let height = svg_sub.height();
195
196                if width > doc_width || height > doc_height {
197                    eprintln!("Error: SVG sub-element is larger than the document size.");
198                    eprintln!(
199                        "Document size: {doc_width}x{doc_height}, Sub-element size: {width}x{height}");
200                    eprintln!("Please adjust the size of the SVG sub-element or the document.");
201                    process::exit(1); // Exit with error code 1
202                }
203
204                let x = doc_width / 2 - width / 2;
205                let y = doc_height / 2 - height / 2;
206                vec![(x, y)]
207            }
208            _ => todo!(),
209        }
210    }
211
212    pub fn calculate_subplots_size_with_margin(&self) -> (u32, u32) {
213        match self.plots.len() {
214            1 => {
215                let svg_sub = &self.plots[0];
216                (
217                    svg_sub.width() + 2 * self.margin,
218                    svg_sub.height() + 2 * self.margin,
219                )
220            }
221            _ => (800, 600), // Default size for the SVG document
222        }
223    }
224
225    pub fn save(self, filename: &str) -> Result<(), Box<dyn std::error::Error>> {
226        Ok(svg::save(filename, &self.render())?)
227    }
228}
229
230impl Rendable for SvgDocument {
231    fn view_parameters(&self) -> ViewParameters {
232        let (subs_width, subs_height) = self.calculate_subplots_size_with_margin();
233        let width = self.width.unwrap_or(subs_width);
234        let height = self.height.unwrap_or(subs_height);
235        ViewParameters::new(0, 0, width, height, width, height)
236    }
237
238    fn set_view_parameters(&mut self, _view_box: ViewParameters) {
239        /* do nothing, calculated up on rendering */
240    }
241
242    fn render(&self) -> Document {
243        let vp = self.view_parameters();
244        let mut doc = Document::new()
245            .set("viewBox", vp.to_string())
246            .set("width", vp.width())
247            .set("height", vp.height())
248            .set("xmlns", "http://www.w3.org/2000/svg")
249            .set("xmlns:xlink", "http://www.w3.org/1999/xlink")
250            .set("version", "1.1")
251            .set("class", "colorimetry-plot");
252
253        //  let scss_content = format!("{}\n{}", DEFAULT_CSS, self.scss);
254        let css_content = match grass::from_string(self.scss.clone(), &grass::Options::default()) {
255            Ok(css) => css,
256            Err(e) => {
257                eprintln!("Failed to compile SCSS: {e}");
258                process::exit(1); // Exit with error code 1
259            }
260        };
261        doc = doc.add(Style::new(css_content));
262
263        //  add definitions for clip paths and symbols
264        let mut defs = svg::node::element::Definitions::new();
265
266        for clip_path in self.clip_paths.iter() {
267            defs.append(clip_path.clone());
268        }
269
270        self.symbols.iter().for_each(|symbol| {
271            defs.append(symbol.clone());
272        });
273        doc.append(defs);
274
275        let background = Group::new()
276            .set("id", "background")
277            .set("class", "background")
278            .add(
279                Rectangle::new()
280                    .set("x", 0)
281                    .set("y", 0)
282                    .set("width", vp.width())
283                    .set("height", vp.height()),
284            );
285        doc = doc.add(background);
286
287        // add plots
288        for (plot, (x, y)) in self.plots.iter().zip(self.flow()) {
289            let rendered_plot = plot.render().set("x", x).set("y", y);
290            doc = doc.add(rendered_plot);
291        }
292
293        // add all items on the svg document
294        for node in self.nodes.iter() {
295            doc = doc.add(node.clone());
296        }
297
298        doc
299    }
300}