//! Render diagram markup in a Pandoc AST into SVG //! //! Provide a [Pandoc filter](https://pandoc.org/filters.html) filter //! to convert inline diagram markup into images. //! //! Process a Pandoc abstract syntax tree and convert fenced code //! blocks with diagram markup into embedded SVG images. Supported //! diagram markup languages: //! //! - [pikchr](https://pikchr.org/home/doc/trunk/homepage.md) //! - [Graphviz dot](https://graphviz.org/doc/info/lang.html) //! - [PlantUML](https://plantuml.com/) //! - [roadmap](https://gitlab.com/larswirzenius/roadmap) //! - [raw SVG](https://en.wikipedia.org/wiki/Scalable_Vector_Graphics) //! //! Example: //! //! ~~~~~~markdown //! This is a sample Markdown file. //! //! ```dot //! digraph "broken" { //! foo -> bar; //! } //! ``` //! ~~~~~~ //! //! The Pandoc AST has represents input as `Block` and other types of //! nodes. This crate turns `Block::CodeBlock` nodes that carry a //! class attribute that specifies one of the supported diagram markup //! languages (`dot`, `plantuml`, `pikchr`, `roadmap`, `svg`) into //! blocks with embedded SVG graphics. //! //! Note that this library does not do any parsing of an input //! document. Use the [`pandoc_ast`](https://crates.io/crates/pandoc_ast) //! or [`pandoc`](https://crates.io/crates/pandoc) crates for that. //! //! # Example //! //! ``` //! # use pandoc_filter_diagram::DiagramFilter; //! # let mut json = r#"{"blocks":[{"t":"CodeBlock","c":[["",["dot"],[]],"digraph \"broken\" {\n foo -> bar;\n}"]}],"pandoc-api-version":[1,20],"meta":{}}"#; //! let mut df = DiagramFilter::new(); //! let json = pandoc_ast::filter(json.to_string(), |doc| df.filter(doc)); //! if !df.errors().is_empty() { //! for e in df.errors().iter() { //! eprintln!("ERROR: {}", e); //! } //! std::process::exit(1); //! } //! ``` #[cfg(feature = "pandoc_ast_07")] pub extern crate pandoc_ast_07 as pandoc_ast; #[cfg(feature = "pandoc_ast_08")] pub extern crate pandoc_ast_08 as pandoc_ast; use pandoc_ast::{Block, Inline, MutVisitor, Pandoc}; use std::env; use std::ffi::OsString; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; /// Convert inline diagram markup as images for Pandoc. /// /// This acts a filter on the Pandoc Abstract Syntax Tree (AST), to /// modify it so that any inline markup for diagrams are rendered as /// SVG images. The library is meant to be used with the /// `pandoc_ast::filter` function. /// /// Filtering may fail. Because of the API constrain imposed by /// `pandoc_ast::filter`, this library doesn't return a `Result`. /// Instead, it collects any errors and lets the caller query for them /// after the filtering is done (see the /// [`errors`](DiagramFilter::errors) method). All errors are always /// rendered as text in the document as well, but that requires a /// human to read the document to spot any errors. #[derive(Debug)] pub struct DiagramFilter { dot: PathBuf, roadmap_width: usize, java_path: PathBuf, plantuml_jar: PathBuf, errors: Vec, } /// Possible errors for diagram filtering. #[derive(Debug, thiserror::Error)] pub enum DiagramError { /// When rendering a pikchr, something went wrong. #[error("failure rendering pikchr diagram: {0}")] PikchrRenderError( /// The error message from pikchr. String, ), /// When rendering a roadmap, something went wrong. #[error("failure rendering roadmap diagram: {0}")] Roadmap(#[from] roadmap::RoadmapError), /// Failed to invoke a program. /// /// This Pandoc filter uses some helper programs to do some of its /// work. It failed to invoke such a helper program. #[error("failed to invoke helper program {0} (as {1}): {2}")] InvokeFailed( /// Name of the program. String, /// Path with which the program was invoked. PathBuf, /// Standard error output of program. String, ), /// A helper program failed /// /// The filter uses some helper programs to implement some of its /// functionality, for example the GraphViz dot program. This /// error means that the helper program failed (exit code was not /// zero). /// /// This probably implies there's something wrong in the filter. /// Please report this error. #[error("helper program failed: {0} (as {1}), exit status {2}:\n{3}")] HelperFailed( /// Name of the program. String, /// Path with which the program was invoked. PathBuf, /// How did the program end? Status code or signal? String, /// Standard error output of program. String, ), /// I/O error /// /// The filter did some I/O, and it failed. This is a generic /// wrapper for any kind of I/O error. #[error(transparent)] IoError(#[from] std::io::Error), } type Svg = Vec; impl Default for DiagramFilter { /// Create a filter with default settings. fn default() -> Self { Self { dot: PathBuf::from("dot"), roadmap_width: 50, java_path: PathBuf::from("java"), plantuml_jar: PathBuf::from("/usr/share/plantuml/plantuml.jar"), errors: vec![], } } } impl DiagramFilter { /// Create a new filter. pub fn new() -> Self { Self::default() } /// Process a parsed document to convert inline diagram markup /// into SVG. This method is suitable to be passed to /// `pandoc_ast::filter` as the filter function argument. pub fn filter(&mut self, mut doc: Pandoc) -> Pandoc { self.walk_pandoc(&mut doc); doc } /// Return any errors that occurred during the filtering process. /// The caller can decide how to report them to the user in a /// suitable way. pub fn errors(&self) -> &[DiagramError] { &self.errors } /// Set the name by which to invoke Graphviz `dot` program. The /// default is "`dot`". pub fn dot_path

(&mut self, path: P) -> &mut Self where P: AsRef, { self.dot = path.as_ref().to_path_buf(); self } /// Set the name by which to invoke the Java runtime, for /// PlantUML. The default is "`java`". pub fn java_path

(&mut self, path: P) -> &mut Self where P: AsRef, { self.java_path = path.as_ref().to_path_buf(); self } /// Set the location of the PlantUML jar (Java bytecode archive). /// The default is "`/usr/share/plantuml/plantuml.jar`". pub fn plantuml_jar

(&mut self, path: P) -> &mut Self where P: AsRef, { self.plantuml_jar = path.as_ref().to_path_buf(); self } /// Set the maximum width, in characters, of the roadmap text /// nodes. The default is 50. pub fn roadmap_width(&mut self, w: usize) -> &mut Self { self.roadmap_width = w; self } fn error_block(&self, error: &DiagramError) -> Block { let msg = Inline::Str(format!("ERROR: {}", error)); let msg = vec![Inline::Strong(vec![msg])]; Block::Para(msg) } fn svg_block(&self, svg: &[u8]) -> Block { let url = self.svg_as_data_url(svg); let attr = ("".to_string(), vec![], vec![]); let img = Inline::Image(attr, vec![], (url, "".to_string())); Block::Para(vec![img]) } fn svg_as_data_url(&self, svg: &[u8]) -> String { let svg = base64::encode(svg); format!("data:image/svg+xml;base64,{}", svg) } fn pikchr_to_svg(&self, markup: &str, _class: Option<&str>) -> Result { let mut flags = pikchr::PikchrFlags::default(); flags.generate_plain_errors(); let image = pikchr::Pikchr::render(markup, None, flags).map_err(DiagramError::PikchrRenderError)?; Ok(image.as_bytes().to_vec()) } fn dot_to_svg(&self, markup: &str) -> Result { filter_via(markup, "dot", &self.dot, &["-Tsvg"], None) } fn roadmap_to_svg(&self, markup: &str) -> Result { match roadmap::from_yaml(markup) { Ok(ref mut roadmap) => { roadmap.set_missing_statuses(); match roadmap.format_as_dot(self.roadmap_width) { Ok(dot) => self.dot_to_svg(&dot), Err(e) => Err(DiagramError::Roadmap(e)), } } Err(err) => Err(DiagramError::Roadmap(err)), } } fn plantuml_to_svg(&self, markup: &str) -> Result { let args = &[ "-Djava.awt.headless=true", "-jar", self.plantuml_jar.to_str().unwrap(), "--", "-pipe", "-tsvg", "-v", "-graphvizdot", self.dot.to_str().unwrap(), ]; filter_via(markup, "java", &self.java_path, args, build_java_path()) } } // If JAVA_HOME is set, and PATH is set, then: // Check if JAVA_HOME/bin is in PATH, if not, prepend it and return a new // PATH fn build_java_path() -> Option { let java_home = env::var_os("JAVA_HOME")?; let cur_path = env::var_os("PATH")?; let cur_path: Vec<_> = env::split_paths(&cur_path).collect(); let java_home = PathBuf::from(java_home); let java_bin = java_home.join("bin"); if cur_path.iter().any(|v| v.as_os_str() == java_bin) { // No need to add JAVA_HOME/bin it's already on-path return None; } env::join_paths(Some(java_bin).iter().chain(cur_path.iter())).ok() } fn filter_via( markup: &str, name: &str, argv0: &Path, args: &[&str], cmdpath: Option, ) -> Result { // Start program without waiting for it to finish. let mut cmd = Command::new(argv0); cmd.args(args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); if let Some(p) = cmdpath { cmd.env("PATH", p); } let mut child = cmd.spawn()?; if let Some(stdin) = child.stdin.as_mut() { stdin.write_all(markup.as_bytes())?; let output = child.wait_with_output()?; if output.status.success() { Ok(output.stdout) } else { let status = if let Some(code) = output.status.code() { format!("{}", code) } else { String::from("terminated by signal") }; let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); Err(DiagramError::HelperFailed( name.to_string(), argv0.to_path_buf(), status, stderr, )) } } else { // Something went wrong when we invoked the program. Read stderr. let mut error_message = vec![]; if let Some(mut stderr) = child.stderr { stderr.read_to_end(&mut error_message)?; } Err(DiagramError::InvokeFailed( name.to_string(), argv0.to_path_buf(), String::from_utf8_lossy(&error_message).into_owned(), )) } } impl MutVisitor for DiagramFilter { fn visit_block(&mut self, block: &mut Block) { match block { Block::CodeBlock((_id, classes, _kv), text) => match DiagramKind::from(classes) { DiagramKind::GraphvizDot => match self.dot_to_svg(text) { Err(err) => { *block = self.error_block(&err); self.errors.push(err); } Ok(svg) => *block = self.svg_block(&svg), }, DiagramKind::Roadmap => match self.roadmap_to_svg(text) { Err(err) => { *block = self.error_block(&err); self.errors.push(err); } Ok(svg) => *block = self.svg_block(&svg), }, DiagramKind::Plantuml => match self.plantuml_to_svg(text) { Err(err) => { *block = self.error_block(&err); self.errors.push(err); } Ok(svg) => *block = self.svg_block(&svg), }, DiagramKind::Pikchr => match self.pikchr_to_svg(text, None) { Err(err) => { *block = self.error_block(&err); self.errors.push(err); } Ok(svg) => *block = self.svg_block(&svg), }, DiagramKind::Svg => *block = self.svg_block(text.as_bytes()), DiagramKind::Other => (), }, _ => { self.walk_block(block); } } } } enum DiagramKind { GraphvizDot, Pikchr, Plantuml, Roadmap, Svg, Other, } impl DiagramKind { fn from(classes: &[String]) -> Self { if has_class(classes, "pikchr") { Self::Pikchr } else if has_class(classes, "dot") { Self::GraphvizDot } else if has_class(classes, "roadmap") { Self::Roadmap } else if has_class(classes, "plantuml") { Self::Plantuml } else if has_class(classes, "svg") { Self::Svg } else { Self::Other } } } fn has_class(classes: &[String], wanted: &str) -> bool { classes.iter().any(|s| s == wanted) }