subplot/
diagrams.rs

1use crate::SubplotError;
2
3use std::env;
4use std::ffi::OsString;
5use std::io::prelude::*;
6use std::path::PathBuf;
7use std::process::{Command, Stdio};
8use std::sync::Mutex;
9
10#[cfg(feature = "unstable-cli")]
11use clap::Parser;
12use lazy_static::lazy_static;
13// Resources used to configure paths for dot, plantuml.jar, and friends
14
15#[allow(missing_docs)]
16#[derive(Debug)]
17#[cfg_attr(feature = "unstable-cli", derive(Parser))]
18pub struct MarkupOpts {
19    #[cfg_attr(
20        feature = "unstable-cli",
21        clap(
22            long = "dot",
23            help = "Path to the `dot` binary.",
24            name = "DOTPATH",
25            env = "SUBPLOT_DOT_PATH"
26        )
27    )]
28    dot_path: Option<PathBuf>,
29    #[cfg_attr(
30        feature = "unstable-cli",
31        clap(
32            long = "plantuml-jar",
33            help = "Path to the `plantuml.jar` file.",
34            name = "PLANTUMLJARPATH",
35            env = "SUBPLOT_PLANTUML_JAR_PATH"
36        )
37    )]
38    plantuml_jar_path: Option<PathBuf>,
39    #[cfg_attr(
40        feature = "unstable-cli",
41        clap(
42            long = "java",
43            help = "Path to Java executable (note, effectively overrides JAVA_HOME if set to an absolute path)",
44            name = "JAVA_PATH",
45            env = "SUBPLOT_JAVA_PATH"
46        )
47    )]
48    java_path: Option<PathBuf>,
49}
50
51impl MarkupOpts {
52    /// Handle CLI arguments and environment variables for markup binaries
53    pub fn handle(&self) {
54        if let Some(dotpath) = &self.dot_path {
55            DOT_PATH.lock().unwrap().clone_from(dotpath);
56        }
57        if let Some(plantuml_path) = &self.plantuml_jar_path {
58            PLANTUML_JAR_PATH.lock().unwrap().clone_from(plantuml_path);
59        }
60        if let Some(java_path) = &self.java_path {
61            JAVA_PATH.lock().unwrap().clone_from(java_path);
62        }
63    }
64}
65
66lazy_static! {
67    static ref DOT_PATH: Mutex<PathBuf> = Mutex::new(env!("BUILTIN_DOT_PATH").into());
68    static ref PLANTUML_JAR_PATH: Mutex<PathBuf> =
69        Mutex::new(env!("BUILTIN_PLANTUML_JAR_PATH").into());
70    static ref JAVA_PATH: Mutex<PathBuf> = Mutex::new(env!("BUILTIN_JAVA_PATH").into());
71}
72
73/// An SVG image.
74///
75/// SVG images are vector images, but we only need to treat them as
76/// opaque blobs of bytes, so we don't try to represent them in any
77/// other way.
78pub struct Svg {
79    data: Vec<u8>,
80}
81
82impl Svg {
83    fn new(data: Vec<u8>) -> Self {
84        Self { data }
85    }
86
87    /// Return slice of the bytes of the image.
88    pub fn data(&self) -> &[u8] {
89        &self.data
90    }
91
92    /// Number of bytes in the binary representation of the image.
93    #[allow(clippy::len_without_is_empty)] // is-empty doesn't make sense
94    pub fn len(&self) -> usize {
95        self.data.len()
96    }
97}
98
99/// A code block with markup for a diagram.
100///
101/// The code block will be converted to an SVG image using an external
102/// filter such as Graphviz dot or plantuml. SVG is the chosen image
103/// format as it's suitable for all kinds of output formats from
104/// typesetting.
105///
106/// This trait defines the interface for different kinds of markup
107/// conversions. There's only one function that needs to be defined
108/// for the trait.
109pub trait DiagramMarkup {
110    /// Convert the markup into an SVG.
111    fn as_svg(&self) -> Result<Svg, SubplotError>;
112}
113
114/// A code block with pikchr markup.
115///
116/// ~~~~
117/// use subplot::{DiagramMarkup, PikchrMarkup};
118/// let markup = r#"line; box "Hello," "World!"; arrow"#;
119/// let svg = PikchrMarkup::new(markup, None).as_svg().unwrap();
120/// assert!(svg.len() > 0);
121/// ~~~~
122pub struct PikchrMarkup {
123    markup: String,
124    class: Option<String>,
125}
126
127impl PikchrMarkup {
128    /// Create a new Pikchr Markup holder
129    pub fn new(markup: &str, class: Option<&str>) -> PikchrMarkup {
130        PikchrMarkup {
131            markup: markup.to_owned(),
132            class: class.map(str::to_owned),
133        }
134    }
135}
136
137impl DiagramMarkup for PikchrMarkup {
138    fn as_svg(&self) -> Result<Svg, SubplotError> {
139        let mut flags = pikchr::PikchrFlags::default();
140        flags.generate_plain_errors();
141        let image = pikchr::Pikchr::render(&self.markup, self.class.as_deref(), flags)
142            .map_err(SubplotError::PikchrRenderError)?;
143        Ok(Svg::new(image.as_bytes().to_vec()))
144    }
145}
146
147/// A code block with Dot markup.
148///
149/// ~~~~
150/// use subplot::{DiagramMarkup, DotMarkup};
151/// let markup = r#"digraph "foo" { a -> b }"#;
152/// let svg = DotMarkup::new(&markup).as_svg().unwrap();
153/// assert!(svg.len() > 0);
154/// ~~~~
155pub struct DotMarkup {
156    markup: String,
157}
158
159impl DotMarkup {
160    /// Create a new DotMarkup.
161    pub fn new(markup: &str) -> DotMarkup {
162        DotMarkup {
163            markup: markup.to_owned(),
164        }
165    }
166}
167
168impl DiagramMarkup for DotMarkup {
169    fn as_svg(&self) -> Result<Svg, SubplotError> {
170        let path = DOT_PATH.lock().unwrap().clone();
171        let mut child = Command::new(&path)
172            .arg("-Tsvg")
173            .stdin(Stdio::piped())
174            .stdout(Stdio::piped())
175            .stderr(Stdio::piped())
176            .spawn()
177            .map_err(|err| SubplotError::Spawn(path.clone(), err))?;
178        if let Some(stdin) = child.stdin.as_mut() {
179            stdin
180                .write_all(self.markup.as_bytes())
181                .map_err(SubplotError::WriteToChild)?;
182            let output = child
183                .wait_with_output()
184                .map_err(SubplotError::WaitForChild)?;
185            if output.status.success() {
186                Ok(Svg::new(output.stdout))
187            } else {
188                Err(SubplotError::child_failed("dot", &output))
189            }
190        } else {
191            Err(SubplotError::ChildNoStdin)
192        }
193    }
194}
195
196/// A code block with PlantUML markup.
197///
198/// ~~~~
199/// use subplot::{DiagramMarkup, PlantumlMarkup};
200/// let markup = "@startuml\nAlice -> Bob\n@enduml";
201/// let svg = PlantumlMarkup::new(&markup).as_svg().unwrap();
202/// assert!(svg.len() > 0);
203/// ~~~~
204pub struct PlantumlMarkup {
205    markup: String,
206}
207
208impl PlantumlMarkup {
209    /// Create a new PlantumlMarkup.
210    pub fn new(markup: &str) -> PlantumlMarkup {
211        PlantumlMarkup {
212            markup: markup.to_owned(),
213        }
214    }
215
216    // If JAVA_HOME is set, and PATH is set, then:
217    // Check if JAVA_HOME/bin is in PATH, if not, prepend it and return a new
218    // PATH
219    fn build_java_path() -> Option<OsString> {
220        let java_home = env::var_os("JAVA_HOME")?;
221        let cur_path = env::var_os("PATH")?;
222        let cur_path: Vec<_> = env::split_paths(&cur_path).collect();
223        let java_home = PathBuf::from(java_home);
224        let java_bin = java_home.join("bin");
225        if cur_path.iter().any(|v| v.as_os_str() == java_bin) {
226            // No need to add JAVA_HOME/bin it's already on-path
227            return None;
228        }
229        env::join_paths(Some(java_bin).iter().chain(cur_path.iter())).ok()
230    }
231}
232
233impl DiagramMarkup for PlantumlMarkup {
234    fn as_svg(&self) -> Result<Svg, SubplotError> {
235        let path = JAVA_PATH.lock().unwrap().clone();
236        let mut cmd = Command::new(&path);
237        cmd.arg("-Djava.awt.headless=true")
238            .arg("-jar")
239            .arg(PLANTUML_JAR_PATH.lock().unwrap().clone())
240            .arg("--")
241            .arg("-pipe")
242            .arg("-tsvg")
243            .arg("-v")
244            .arg("-graphvizdot")
245            .arg(DOT_PATH.lock().unwrap().clone())
246            .stdin(Stdio::piped())
247            .stdout(Stdio::piped())
248            .stderr(Stdio::piped());
249        if let Some(path) = Self::build_java_path() {
250            cmd.env("PATH", path);
251        }
252        let mut child = cmd
253            .spawn()
254            .map_err(|err| SubplotError::Spawn(path.clone(), err))?;
255        if let Some(stdin) = child.stdin.as_mut() {
256            stdin
257                .write_all(self.markup.as_bytes())
258                .map_err(SubplotError::WriteToChild)?;
259            let output = child
260                .wait_with_output()
261                .map_err(SubplotError::WaitForChild)?;
262            if output.status.success() {
263                Ok(Svg::new(output.stdout))
264            } else {
265                Err(SubplotError::child_failed("plantuml", &output))
266            }
267        } else {
268            Err(SubplotError::ChildNoStdin)
269        }
270    }
271}