//! Render diagram markup in a Pandoc AST into SVG //! //! 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) //! //! 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 into blocks with embedded SVG graphics. //! //! Note that this library does not do any parsing. The //! `pandoc_ast::filter` function does that. 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}; /// Represent the diagram filter. #[derive(Debug)] pub struct DiagramFilter { dot: PathBuf, roadmap_width: usize, java_path: PathBuf, plantuml_jar: PathBuf, } /// 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(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(String, PathBuf, 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}): {2}")] HelperFailed(String, PathBuf, String, 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 { 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"), } } } impl DiagramFilter { pub fn new() -> Self { Self::default() } pub fn dot_path

(&mut self, path: P) -> &mut Self where P: AsRef, { self.dot = path.as_ref().to_path_buf(); self } pub fn java_path

(&mut self, path: P) -> &mut Self where P: AsRef, { self.java_path = path.as_ref().to_path_buf(); self } pub fn plantuml_jar

(&mut self, path: P) -> &mut Self where P: AsRef, { self.plantuml_jar = path.as_ref().to_path_buf(); self } pub fn roadmap_width(&mut self, w: usize) -> &mut Self { self.roadmap_width = w; self } pub fn filter(&mut self, mut doc: Pandoc) -> Pandoc { self.walk_pandoc(&mut doc); doc } fn error_block(&self, error: DiagramError) -> Block { let msg = Inline::Str(format!("ERROR: {}", error.to_string())); 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); Err(DiagramError::HelperFailed( name.to_string(), argv0.to_path_buf(), status, stderr.into_owned(), )) } } 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), Ok(svg) => *block = self.svg_block(&svg), }, DiagramKind::Roadmap => match self.roadmap_to_svg(text) { Err(err) => *block = self.error_block(err), Ok(svg) => *block = self.svg_block(&svg), }, DiagramKind::Plantuml => match self.plantuml_to_svg(text) { Err(err) => *block = self.error_block(err), Ok(svg) => *block = self.svg_block(&svg), }, DiagramKind::Pikchr => match self.pikchr_to_svg(text, None) { Err(err) => *block = self.error_block(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) }