Skip to main content

graphviz_pure/
lib.rs

1//! High-level Graphviz orchestration engine (`graphviz-pure`).
2
3pub use graphviz_pure_core::{Graph, GraphvizError, Result};
4pub use graphviz_pure_layout::{
5    LayoutEngine, LayoutOptions, layout_graph, layout_graph_with_options,
6};
7pub use graphviz_pure_parser::{GlobalAttrs, parse_dot, parse_dot_all};
8pub use graphviz_pure_render::{OutputFormat, render_graph, render_graph_named};
9
10/// End-to-end DOT rendering pipeline for any supported engine and output format. Renders the
11/// first graph in `dot_source`.
12pub fn render_dot(dot_source: &str, engine: &str, format: &str) -> Result<String> {
13    let output_format = OutputFormat::parse(format)?;
14    // Each call is one C process: per-process counters (gradient and anchor ids) restart.
15    graphviz_pure_render::reset_render_state();
16    let graph = parse_dot(dot_source)?;
17    render_parsed(
18        graph,
19        engine,
20        output_format,
21        format,
22        LayoutOptions::default(),
23    )
24}
25
26/// Renders every graph in `dot_source`, applying `globals` (`-G`/`-N`/`-E`) before each graph body.
27pub fn render_dot_all(
28    dot_source: &str,
29    engine: &str,
30    format: &str,
31    globals: &GlobalAttrs,
32) -> Result<Vec<String>> {
33    render_dot_all_with_options(
34        dot_source,
35        engine,
36        format,
37        globals,
38        LayoutOptions::default(),
39    )
40}
41
42/// [`render_dot_all`] with engine options such as `-n` (keep input positions).
43pub fn render_dot_all_with_options(
44    dot_source: &str,
45    engine: &str,
46    format: &str,
47    globals: &GlobalAttrs,
48    options: LayoutOptions,
49) -> Result<Vec<String>> {
50    let output_format = OutputFormat::parse(format)?;
51    graphviz_pure_render::reset_render_state();
52    parse_dot_all(dot_source, globals)?
53        .into_iter()
54        .map(|graph| render_parsed(graph, engine, output_format, format, options))
55        .collect()
56}
57
58/// [`render_dot_all_with_options`] that renders the graphs read before a syntax error
59/// and returns that error alongside, as `dot` does.
60pub fn render_dot_all_partial(
61    dot_source: &str,
62    engine: &str,
63    format: &str,
64    globals: &GlobalAttrs,
65    options: LayoutOptions,
66) -> (Vec<String>, Option<graphviz_pure_core::GraphvizError>) {
67    let output_format = match OutputFormat::parse(format) {
68        Ok(f) => f,
69        Err(e) => return (Vec::new(), Some(e)),
70    };
71    graphviz_pure_render::reset_render_state();
72    let (graphs, parse_err) = graphviz_pure_parser::parse_dot_all_partial(dot_source, globals);
73    let mut out = Vec::with_capacity(graphs.len());
74    for graph in graphs {
75        match render_parsed(graph, engine, output_format, format, options) {
76            Ok(s) => out.push(s),
77            Err(e) => return (out, Some(e)),
78        }
79    }
80    (out, parse_err)
81}
82
83/// Lays out and renders every graph in `dot_source` as SVG, for raster output: each SVG comes
84/// with whether its graph sets `dpi` (or `resolution`), since Graphviz then scales the drawing
85/// to that resolution instead of drawing raster formats at 96 dpi. Like
86/// [`render_dot_all_partial`], the graphs read before a syntax error are returned with it.
87pub fn render_svg_all_partial(
88    dot_source: &str,
89    engine: &str,
90    globals: &GlobalAttrs,
91    options: LayoutOptions,
92) -> (
93    Vec<(String, bool)>,
94    Option<graphviz_pure_core::GraphvizError>,
95) {
96    graphviz_pure_render::reset_render_state();
97    let (graphs, parse_err) = graphviz_pure_parser::parse_dot_all_partial(dot_source, globals);
98    let mut out = Vec::with_capacity(graphs.len());
99    for graph in graphs {
100        let explicit_dpi = ["dpi", "resolution"].iter().any(|k| {
101            graph
102                .graph_attrs
103                .get(*k)
104                .and_then(|v| v.trim().parse::<f64>().ok())
105                .is_some_and(|d| d > 0.0)
106        });
107        match render_parsed(graph, engine, OutputFormat::Svg, "svg", options) {
108            Ok(s) => out.push((s, explicit_dpi)),
109            Err(e) => return (out, Some(e)),
110        }
111    }
112    (out, parse_err)
113}
114
115fn render_parsed(
116    mut graph: Graph,
117    engine: &str,
118    output_format: OutputFormat,
119    format_name: &str,
120    options: LayoutOptions,
121) -> Result<String> {
122    // As in C (`gvLayoutJobs`), a graph's `layout` attribute overrides the requested engine.
123    let layout_engine = match graph.graph_attrs.get("layout") {
124        Some(name) if !name.trim().is_empty() => LayoutEngine::parse(name.trim())?,
125        _ => LayoutEngine::parse(engine)?,
126    };
127    let lname = format_name.to_ascii_lowercase();
128    if output_format != OutputFormat::Canon && lname != "dot_json" && lname != "xdot_json" {
129        layout_graph_with_options(&mut graph, layout_engine, options)?;
130    }
131    render_graph_named(&graph, output_format, format_name)
132}
133
134#[cfg(test)]
135mod tests {
136
137    #[test]
138    fn repeated_renders_are_identical() {
139        // Gradient and HTML anchor ids are per-process statics in C; a library call must
140        // not see the counts left by an earlier call.
141        let src = r#"digraph { a [style=filled, fillcolor="red:blue"];
142            b [label=<<TABLE><TR><TD HREF="x">c</TD></TR></TABLE>>]; a -> b }"#;
143        let first = render_dot(src, "dot", "svg").unwrap();
144        assert_eq!(first, render_dot(src, "dot", "svg").unwrap());
145        assert!(first.contains("_l_0"));
146    }
147
148    use super::*;
149    use std::fs;
150    use std::path::PathBuf;
151
152    #[test]
153    fn smoke_render_svg() {
154        let svg = render_dot("digraph G { a -> b; }", "dot", "svg").unwrap();
155        assert!(svg.contains("<svg"));
156    }
157
158    #[test]
159    fn render_dot_all_renders_every_graph_with_globals() {
160        let mut globals = GlobalAttrs::default();
161        globals.node.insert("shape".into(), "box".into());
162        let outs = render_dot_all(
163            "digraph G { a -> b }\ndigraph H { c -> d }",
164            "dot",
165            "svg",
166            &globals,
167        )
168        .unwrap();
169        assert_eq!(outs.len(), 2);
170        assert!(outs[0].contains("<title>a</title>") && !outs[0].contains("<ellipse"));
171        assert!(outs[1].contains("<title>c</title>"));
172    }
173
174    #[test]
175    fn test_multigraph_and_duplicate_cluster_scopes() {
176        // 1. Multi-graph DOT stream (`multi.gv`)
177        let multi = "digraph G { a -> b; }\ndigraph H { c -> d; }\n";
178        let svg = render_dot(multi, "dot", "svg").unwrap();
179        assert!(svg.contains("<svg"));
180        assert!(svg.contains("<title>a</title>"));
181
182        // 2. Same cluster name in different parent scopes (`b7.gv`)
183        let b7 = r#"
184            digraph G {
185                subgraph cluster_0 {
186                    subgraph cluster_1 {}
187                    a -> c;
188                }
189                subgraph cluster_1 {
190                    y -> z;
191                    y -> q;
192                }
193                y -> b;
194            }
195        "#;
196        let g = parse_dot(b7).unwrap();
197        assert_eq!(g.subgraphs.len(), 3);
198        let j0 = render_dot(b7, "dot", "json0").unwrap();
199        assert!(j0.contains("\"_subgraph_cnt\": 3"));
200    }
201
202    #[test]
203    fn test_wild_corpus_renders_without_error() {
204        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
205        let wild_dir = manifest_dir.join("../../tests/wild");
206        let mut entries: Vec<PathBuf> = fs::read_dir(&wild_dir)
207            .expect("tests/wild directory must exist")
208            .filter_map(|e| e.ok().map(|e| e.path()))
209            .filter(|p| p.extension().is_some_and(|ext| ext == "gv"))
210            .collect();
211        entries.sort();
212        assert!(
213            entries.len() >= 30,
214            "expected at least 30 wild corpus fixtures"
215        );
216
217        for path in entries {
218            let src = graphviz_pure_core::charset::decode(fs::read(&path).unwrap());
219            let svg = render_dot(&src, "dot", "svg")
220                .unwrap_or_else(|e| panic!("Failed rendering SVG for {}: {e}", path.display()));
221            assert!(svg.contains("<svg"), "Missing <svg in {}", path.display());
222            assert!(
223                !svg.contains("NaN") && !svg.contains("Infinity"),
224                "Non-finite coordinate in {}",
225                path.display()
226            );
227
228            let j0 = render_dot(&src, "dot", "json0")
229                .unwrap_or_else(|e| panic!("Failed rendering json0 for {}: {e}", path.display()));
230            assert!(j0.contains("\"bb\":"), "Missing bb in {}", path.display());
231        }
232    }
233}