Skip to main content

svgdx/
lib.rs

1//! ## svgdx - create SVG diagrams easily
2//!
3//! `svgdx` is normally run as a command line tool, taking an input file and processing
4//! it into an SVG output file.
5//!
6//! ## Library use
7//!
8//! Support as a library is primarily to allow other front-ends to convert svgdx
9//! documents to SVG without having to call `svgdx` as a command-line subprocess.
10//!
11//! A `TransformConfig` object should be created as appropriate to configure the
12//! transform process, and the appropriate `transform_*` function called passing
13//! this and appropriate input / output parameters as required.
14//!
15//! Errors in processing are handled via `svgdx::Result`; currently these are mainly
16//! useful in providing basic error messages suitable for end-users.
17//!
18//! ## Example
19//!
20//! ```
21//! let cfg = svgdx::TransformConfig::default();
22//!
23//! let input = r#"<rect wh="50" text="Hello!"/>"#;
24//! let output = svgdx::transform_str(input, &cfg).unwrap();
25//!
26//! println!("{output}");
27//! ```
28
29#[cfg(target_arch = "wasm32")]
30use wasm_bindgen::prelude::*;
31
32#[cfg(feature = "cli")]
33use std::fs::{self, File};
34#[cfg(feature = "cli")]
35use std::io::{BufReader, IsTerminal, Read};
36
37use std::io::{BufRead, Cursor, Write};
38
39#[cfg(feature = "cli")]
40use tempfile::NamedTempFile;
41
42#[cfg(feature = "cli")]
43pub mod cli;
44mod constants;
45mod context;
46mod document;
47mod elements;
48mod errors;
49mod expr;
50mod geometry;
51#[cfg(feature = "server")]
52pub mod server;
53mod style;
54mod transform;
55mod types;
56
57pub use errors::{Error, Result};
58pub use style::{AutoStyleMode, ThemeType};
59use transform::Transformer;
60
61// Allow users of this as a library to easily retrieve the version of svgdx being used
62pub const VERSION: &str = env!("CARGO_PKG_VERSION");
63
64/// Settings to configure a single transformation.
65///
66/// Note the settings here are specific to a single transformation; alternate front-ends
67/// may use this directly rather than `Config` which wraps this struct when `svgdx` is
68/// run as a command-line program.
69#[derive(Clone, Debug)]
70pub struct TransformConfig {
71    /// Add debug info (e.g. input source) to output
72    pub debug: bool,
73    /// Overall output image scale (in mm as scale of user units)
74    pub scale: f32,
75    /// Border width (user-units, default 5)
76    pub border: u16,
77    /// Add style & defs entries based on class usage
78    pub auto_style_mode: AutoStyleMode,
79    /// Background colour (default "default" - use theme default or none)
80    pub background: String, // TODO: sanitize this with a `Colour: FromStr + Display` type
81    /// Random seed
82    pub seed: u64,
83    /// Maximum loop iterations
84    pub loop_limit: u32,
85    /// Max length of variable
86    pub var_limit: u32,
87    /// Maximum depth of recursion
88    pub depth_limit: u32,
89    /// Maximum path repeat expansion (`r` command)
90    pub path_repeat_limit: u32,
91    /// Add source metadata to output
92    pub add_metadata: bool,
93    /// Default font-size (in user-units)
94    pub font_size: f32,
95    /// Default font-family
96    pub font_family: String,
97    /// Theme to use (default "default")
98    pub theme: ThemeType,
99    /// Make styles local to this document
100    pub use_local_styles: bool,
101    /// Optional style to apply to SVG root element
102    pub svg_style: Option<String>,
103    /// Error handling mode
104    pub error_mode: ErrorMode,
105}
106
107impl Default for TransformConfig {
108    fn default() -> Self {
109        Self {
110            debug: false,
111            scale: 1.0,
112            border: 5,
113            auto_style_mode: AutoStyleMode::default(),
114            background: "default".to_owned(),
115            seed: 0,
116            loop_limit: 1000,
117            var_limit: 1024,
118            depth_limit: 100,
119            path_repeat_limit: 10000,
120            add_metadata: false,
121            font_size: 3.0,
122            font_family: "sans-serif".to_owned(),
123            theme: ThemeType::default(),
124            use_local_styles: false,
125            svg_style: None,
126            error_mode: ErrorMode::default(),
127        }
128    }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
132#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
133pub enum ErrorMode {
134    /// Un-resolved errors prevent processing
135    #[default]
136    Strict,
137    /// Continue with error message in XML comment
138    Warn,
139    /// Continue silently ignoring errors
140    Ignore,
141}
142
143impl std::str::FromStr for ErrorMode {
144    type Err = Error;
145
146    fn from_str(s: &str) -> Result<Self> {
147        match s {
148            "strict" => Ok(ErrorMode::Strict),
149            "warn" => Ok(ErrorMode::Warn),
150            "ignore" => Ok(ErrorMode::Ignore),
151            _ => Err(Error::InvalidValue(
152                "error-mode must be 'strict', 'warn', or 'ignore'".to_string(),
153                s.to_string(),
154            )),
155        }
156    }
157}
158
159/// Reads from the `reader` stream, processes document, and writes to `writer`.
160///
161/// Note the entire stream may be read before any converted data is written to `writer`.
162///
163/// The transform can be modified by providing a suitable `TransformConfig` value.
164pub fn transform_stream(
165    reader: &mut dyn BufRead,
166    writer: &mut dyn Write,
167    config: &TransformConfig,
168) -> Result<()> {
169    let mut t = Transformer::from_config(config);
170    t.transform(reader, writer)
171}
172
173/// Read file from `input` ('-' for stdin), process the result,
174/// and write to file given by `output` ('-' for stdout).
175///
176/// The transform can be modified by providing a suitable `TransformConfig` value.
177#[cfg(feature = "cli")]
178pub fn transform_file(input: &str, output: &str, cfg: &TransformConfig) -> Result<()> {
179    let mut in_reader = if input == "-" {
180        let mut stdin = std::io::stdin().lock();
181        if stdin.is_terminal() {
182            // This is unpleasant; at least on Mac, a single Ctrl-D is not otherwise
183            // enough to signal end-of-input, even when given at the start of a line.
184            // Work around this by reading entire input, then wrapping in a Cursor to
185            // provide a buffered reader.
186            // It would be nice to improve this.
187            let mut buf = Vec::new();
188            stdin
189                .read_to_end(&mut buf)
190                .expect("stdin should be readable to EOF");
191            Box::new(BufReader::new(Cursor::new(buf))) as Box<dyn BufRead>
192        } else {
193            Box::new(stdin) as Box<dyn BufRead>
194        }
195    } else {
196        Box::new(BufReader::new(File::open(input).map_err(Error::Io)?)) as Box<dyn BufRead>
197    };
198
199    if output == "-" {
200        transform_stream(&mut in_reader, &mut std::io::stdout(), cfg)?;
201    } else {
202        let mut out_temp = NamedTempFile::new().map_err(Error::Io)?;
203        transform_stream(&mut in_reader, &mut out_temp, cfg)?;
204        // Copy content rather than rename (by .persist()) since this
205        // could cross filesystems; some apps (e.g. eog) also fail to
206        // react to 'moved-over' files.
207        fs::copy(out_temp.path(), output).map_err(Error::Io)?;
208    }
209
210    Ok(())
211}
212
213/// Transform `input` provided as a string, returning the result as a string.
214///
215/// The transform can be modified by providing a suitable `TransformConfig` value.
216#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
217pub fn transform_string(input: String, add_metadata: bool) -> core::result::Result<String, String> {
218    let cfg = TransformConfig {
219        add_metadata,
220        ..Default::default()
221    };
222    transform_str(input, &cfg).map_err(|e| e.to_string())
223}
224
225pub fn transform_str<T: Into<String>>(input: T, cfg: &TransformConfig) -> Result<String> {
226    let input = input.into();
227
228    let mut input = Cursor::new(input);
229    let mut output: Vec<u8> = vec![];
230
231    transform_stream(&mut input, &mut output, cfg)?;
232
233    Ok(String::from_utf8(output).expect("Non-UTF8 output generated"))
234}
235
236/// Transform the provided `input` string using default config, returning the result string.
237///
238/// Uses default `TransformConfig` settings.
239pub fn transform_str_default<T: Into<String>>(input: T) -> Result<String> {
240    transform_str(input, &TransformConfig::default())
241}
242
243// JSON API for editor/WASM use
244#[cfg(feature = "json")]
245pub mod json_api {
246    use super::{TransformConfig, transform_str};
247    use serde_derive::{Deserialize, Serialize};
248
249    pub const JSON_API_VERSION: u32 = 1;
250
251    #[derive(Debug, Deserialize)]
252    pub struct TransformRequest {
253        pub version: u32,
254        pub input: String,
255        #[serde(default)]
256        pub config: RequestConfig,
257    }
258
259    #[derive(Debug, Default, Deserialize)]
260    pub struct RequestConfig {
261        #[serde(default)]
262        pub add_metadata: bool,
263    }
264
265    impl From<RequestConfig> for TransformConfig {
266        fn from(config: RequestConfig) -> Self {
267            TransformConfig {
268                add_metadata: config.add_metadata,
269                ..Default::default()
270            }
271        }
272    }
273
274    #[derive(Debug, Serialize)]
275    pub struct TransformResponse {
276        pub version: u32,
277        #[serde(skip_serializing_if = "Option::is_none")]
278        pub svg: Option<String>,
279        #[serde(skip_serializing_if = "Option::is_none")]
280        pub error: Option<String>,
281        #[serde(skip_serializing_if = "Vec::is_empty")]
282        pub warnings: Vec<String>,
283    }
284
285    impl TransformResponse {
286        pub fn success(svg: String) -> Self {
287            Self {
288                version: JSON_API_VERSION,
289                svg: Some(svg),
290                error: None,
291                warnings: vec![],
292            }
293        }
294
295        pub fn error(message: String) -> Self {
296            Self {
297                version: JSON_API_VERSION,
298                svg: None,
299                error: Some(message),
300                warnings: vec![],
301            }
302        }
303    }
304
305    /// Transform input using JSON request/response format.
306    ///
307    /// Takes a JSON string containing `TransformRequest`, returns JSON string
308    /// containing `TransformResponse`.
309    pub fn transform_json_impl(input: &str) -> String {
310        let result: TransformResponse = match serde_json::from_str::<TransformRequest>(input) {
311            Ok(request) => {
312                if request.version != JSON_API_VERSION {
313                    TransformResponse::error(format!(
314                        "Unsupported API version: {} (expected {})",
315                        request.version, JSON_API_VERSION
316                    ))
317                } else {
318                    let config: TransformConfig = request.config.into();
319                    match transform_str(request.input, &config) {
320                        Ok(svg) => TransformResponse::success(svg),
321                        Err(e) => TransformResponse::error(e.to_string()),
322                    }
323                }
324            }
325            Err(e) => TransformResponse::error(format!("Invalid JSON request: {e}")),
326        };
327        serde_json::to_string(&result).expect("Failed to serialize response")
328    }
329}
330
331/// Transform input using JSON request/response format (WASM entry point).
332///
333/// Takes a JSON string containing a request object, returns JSON string response.
334/// Request format: `{"version": 1, "input": "...", "config": {"add_metadata": bool}}`
335/// Success response: `{"version": 1, "svg": "...", "warnings": []}`
336/// Error response: `{"version": 1, "error": "..."}`
337#[cfg(all(feature = "json", target_arch = "wasm32"))]
338#[wasm_bindgen]
339pub fn transform_json(input: String) -> String {
340    json_api::transform_json_impl(&input)
341}
342
343#[cfg(all(test, feature = "json"))]
344mod json_tests {
345    use super::json_api::*;
346
347    #[test]
348    fn test_json_transform_success() {
349        let request = r#"{"version": 1, "input": "<svg><rect wh=\"10\"/></svg>", "config": {}}"#;
350        let response = transform_json_impl(request);
351        let parsed: serde_json::Value = serde_json::from_str(&response).unwrap();
352
353        assert_eq!(parsed["version"], 1);
354        assert!(parsed["svg"].as_str().unwrap().contains("<svg"));
355        assert!(parsed["error"].is_null());
356    }
357
358    #[test]
359    fn test_json_transform_error() {
360        let request = r#"{"version": 1, "input": "<svg><invalid", "config": {}}"#;
361        let response = transform_json_impl(request);
362        let parsed: serde_json::Value = serde_json::from_str(&response).unwrap();
363
364        assert_eq!(parsed["version"], 1);
365        assert!(parsed["svg"].is_null());
366        assert!(parsed["error"].as_str().is_some());
367    }
368
369    #[test]
370    fn test_json_invalid_version() {
371        let request = r#"{"version": 999, "input": "<svg/>", "config": {}}"#;
372        let response = transform_json_impl(request);
373        let parsed: serde_json::Value = serde_json::from_str(&response).unwrap();
374
375        assert_eq!(parsed["version"], 1);
376        assert!(
377            parsed["error"]
378                .as_str()
379                .unwrap()
380                .contains("Unsupported API version")
381        );
382    }
383
384    #[test]
385    fn test_json_invalid_request() {
386        let request = r#"not valid json"#;
387        let response = transform_json_impl(request);
388        let parsed: serde_json::Value = serde_json::from_str(&response).unwrap();
389
390        assert_eq!(parsed["version"], 1);
391        assert!(
392            parsed["error"]
393                .as_str()
394                .unwrap()
395                .contains("Invalid JSON request")
396        );
397    }
398}