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}