Skip to main content

dendryform_svg/
lib.rs

1//! # dendryform-svg
2//!
3//! Static SVG renderer for dendryform diagrams.
4//!
5//! Consumes a [`LayoutPlan`](dendryform_layout::LayoutPlan) and
6//! [`Theme`](dendryform_core::Theme) to produce a self-contained SVG string
7//! with absolute pixel coordinates, embedded font imports, and the dark
8//! Taproot theme.
9//!
10//! ## Quick Start
11//!
12//! ```no_run
13//! use dendryform_core::Theme;
14//! use dendryform_svg::render_svg;
15//! use dendryform_layout::compute_layout;
16//!
17//! let diagram = dendryform_parse::parse_yaml_file("examples/taproot/architecture.yaml").unwrap();
18//! let plan = compute_layout(&diagram).unwrap();
19//! let svg = render_svg(&plan, &Theme::dark(), 1100.0).unwrap();
20//! std::fs::write("output.svg", svg).unwrap();
21//! ```
22
23mod defs;
24mod error;
25mod escape;
26mod metrics;
27mod render;
28mod resolve;
29
30pub use error::SvgError;
31pub use render::render_svg;
32
33/// Returns the version of the dendryform-svg crate.
34pub fn version() -> &'static str {
35    env!("CARGO_PKG_VERSION")
36}
37
38#[cfg(test)]
39mod tests {
40    use dendryform_core::{
41        Color, Connector, ConnectorStyle, Container, ContainerBorder, Diagram, DiagramHeader,
42        FlowLabels, Layer, LegendEntry, Node, NodeId, NodeKind, RawDiagram, Theme, Tier, Title,
43    };
44    use dendryform_layout::compute_layout;
45
46    use super::*;
47
48    fn test_node(id: &str, color: Color) -> Node {
49        Node::builder()
50            .id(NodeId::new(id).unwrap())
51            .kind(NodeKind::System)
52            .color(color)
53            .icon("\u{25c7}")
54            .title(id)
55            .description("test node")
56            .build()
57            .unwrap()
58    }
59
60    fn make_diagram(layers: Vec<Layer>, legend: Vec<LegendEntry>) -> Diagram {
61        let raw = RawDiagram {
62            diagram: DiagramHeader::new(Title::new("test", "accent"), "subtitle", "dark"),
63            layers,
64            legend,
65            edges: vec![],
66        };
67        Diagram::try_from(raw).unwrap()
68    }
69
70    #[test]
71    fn test_version_is_set() {
72        assert_eq!(version(), "0.1.0");
73    }
74
75    #[test]
76    fn test_render_minimal() {
77        let diagram = make_diagram(
78            vec![Layer::Tier(Tier::new(
79                NodeId::new("main").unwrap(),
80                vec![test_node("app", Color::Blue)],
81            ))],
82            vec![],
83        );
84        let plan = compute_layout(&diagram).unwrap();
85        let svg = render_svg(&plan, &Theme::dark(), 1100.0).unwrap();
86
87        assert!(svg.contains("<svg"));
88        assert!(svg.contains("</svg>"));
89        assert!(svg.contains("accent"));
90        assert!(svg.contains("test"));
91    }
92
93    #[test]
94    fn test_render_contains_defs() {
95        let diagram = make_diagram(
96            vec![Layer::Tier(Tier::new(
97                NodeId::new("main").unwrap(),
98                vec![test_node("app", Color::Blue)],
99            ))],
100            vec![],
101        );
102        let plan = compute_layout(&diagram).unwrap();
103        let svg = render_svg(&plan, &Theme::dark(), 1100.0).unwrap();
104
105        assert!(svg.contains("<defs>"));
106        assert!(svg.contains("arrowhead"));
107        assert!(svg.contains("connector-grad"));
108        assert!(svg.contains("@import url"));
109    }
110
111    #[test]
112    fn test_render_contains_node() {
113        let diagram = make_diagram(
114            vec![Layer::Tier(Tier::new(
115                NodeId::new("main").unwrap(),
116                vec![test_node("myapp", Color::Green)],
117            ))],
118            vec![],
119        );
120        let plan = compute_layout(&diagram).unwrap();
121        let svg = render_svg(&plan, &Theme::dark(), 1100.0).unwrap();
122
123        assert!(svg.contains("myapp"));
124        // Accent color for green
125        assert!(svg.contains("#3ddc84"));
126    }
127
128    #[test]
129    fn test_render_contains_connector() {
130        let diagram = make_diagram(
131            vec![
132                Layer::Tier(Tier::new(
133                    NodeId::new("top").unwrap(),
134                    vec![test_node("a", Color::Blue)],
135                )),
136                Layer::Connector(Connector::with_label(ConnectorStyle::Line, "HTTPS")),
137                Layer::Tier(Tier::new(
138                    NodeId::new("bottom").unwrap(),
139                    vec![test_node("b", Color::Green)],
140                )),
141            ],
142            vec![],
143        );
144        let plan = compute_layout(&diagram).unwrap();
145        let svg = render_svg(&plan, &Theme::dark(), 1100.0).unwrap();
146
147        assert!(svg.contains("HTTPS"));
148        assert!(svg.contains("marker-end"));
149    }
150
151    #[test]
152    fn test_render_contains_container() {
153        let container = Container::new(
154            "server",
155            ContainerBorder::Solid,
156            Color::Green,
157            vec![Layer::Tier(Tier::new(
158                NodeId::new("inner").unwrap(),
159                vec![test_node("api", Color::Green)],
160            ))],
161        );
162        let diagram = make_diagram(
163            vec![Layer::Tier(Tier::with_container(
164                NodeId::new("server").unwrap(),
165                container,
166            ))],
167            vec![],
168        );
169        let plan = compute_layout(&diagram).unwrap();
170        let svg = render_svg(&plan, &Theme::dark(), 1100.0).unwrap();
171
172        assert!(svg.contains("SERVER"));
173        assert!(svg.contains("api"));
174    }
175
176    #[test]
177    fn test_render_contains_legend() {
178        let diagram = make_diagram(
179            vec![Layer::Tier(Tier::new(
180                NodeId::new("main").unwrap(),
181                vec![test_node("a", Color::Blue)],
182            ))],
183            vec![
184                LegendEntry::new(Color::Blue, "Clients"),
185                LegendEntry::new(Color::Green, "Servers"),
186            ],
187        );
188        let plan = compute_layout(&diagram).unwrap();
189        let svg = render_svg(&plan, &Theme::dark(), 1100.0).unwrap();
190
191        assert!(svg.contains("Clients"));
192        assert!(svg.contains("Servers"));
193        // Check for swatch rects (legend_swatch_size = 10)
194        assert!(svg.contains("width=\"10\""));
195    }
196
197    #[test]
198    fn test_render_contains_flow_labels() {
199        let diagram = make_diagram(
200            vec![
201                Layer::Tier(Tier::new(
202                    NodeId::new("top").unwrap(),
203                    vec![test_node("a", Color::Blue)],
204                )),
205                Layer::FlowLabels(FlowLabels::new(vec![
206                    "SQL queries".to_owned(),
207                    "cache reads".to_owned(),
208                ])),
209                Layer::Tier(Tier::new(
210                    NodeId::new("bottom").unwrap(),
211                    vec![test_node("b", Color::Red)],
212                )),
213            ],
214            vec![],
215        );
216        let plan = compute_layout(&diagram).unwrap();
217        let svg = render_svg(&plan, &Theme::dark(), 1100.0).unwrap();
218
219        assert!(svg.contains("SQL queries"));
220        assert!(svg.contains("\u{2193}")); // down arrow
221    }
222
223    #[test]
224    fn test_render_internal_connector() {
225        let container = Container::new(
226            "server",
227            ContainerBorder::Solid,
228            Color::Green,
229            vec![
230                Layer::Tier(Tier::new(
231                    NodeId::new("inner1").unwrap(),
232                    vec![test_node("a", Color::Green)],
233                )),
234                Layer::Connector(Connector::new(ConnectorStyle::Dots)),
235                Layer::Tier(Tier::new(
236                    NodeId::new("inner2").unwrap(),
237                    vec![test_node("b", Color::Green)],
238                )),
239            ],
240        );
241        let diagram = make_diagram(
242            vec![Layer::Tier(Tier::with_container(
243                NodeId::new("server").unwrap(),
244                container,
245            ))],
246            vec![],
247        );
248        let plan = compute_layout(&diagram).unwrap();
249        let svg = render_svg(&plan, &Theme::dark(), 1100.0).unwrap();
250
251        // Should have dot circles
252        assert!(svg.contains("<circle"));
253    }
254
255    #[test]
256    fn test_xml_escaping() {
257        let node = Node::builder()
258            .id(NodeId::new("test").unwrap())
259            .kind(NodeKind::System)
260            .color(Color::Blue)
261            .icon("<>")
262            .title("A & B")
263            .description("x < y > z")
264            .build()
265            .unwrap();
266        let diagram = make_diagram(
267            vec![Layer::Tier(Tier::new(
268                NodeId::new("main").unwrap(),
269                vec![node],
270            ))],
271            vec![],
272        );
273        let plan = compute_layout(&diagram).unwrap();
274        let svg = render_svg(&plan, &Theme::dark(), 1100.0).unwrap();
275
276        assert!(svg.contains("&amp;"));
277        assert!(svg.contains("&lt;"));
278        assert!(svg.contains("&gt;"));
279        assert!(!svg.contains("A & B"));
280    }
281
282    #[test]
283    fn test_render_background_rect() {
284        let diagram = make_diagram(
285            vec![Layer::Tier(Tier::new(
286                NodeId::new("main").unwrap(),
287                vec![test_node("a", Color::Blue)],
288            ))],
289            vec![],
290        );
291        let plan = compute_layout(&diagram).unwrap();
292        let svg = render_svg(&plan, &Theme::dark(), 1100.0).unwrap();
293
294        assert!(svg.contains("#0a0e14")); // dark theme page background
295        assert!(svg.contains("viewBox=\"0 0 1100"));
296    }
297
298    #[test]
299    fn test_render_taproot_svg() {
300        let yaml = include_str!("../../../examples/taproot/architecture.yaml");
301        let diagram = dendryform_parse::parse_yaml(yaml).unwrap();
302        let plan = compute_layout(&diagram).unwrap();
303        let svg = render_svg(&plan, &Theme::dark(), 1100.0).unwrap();
304
305        assert!(svg.contains("<svg"));
306        assert!(svg.contains("taproot"));
307        assert!(svg.contains("system architecture"));
308        assert!(svg.contains("Claude Desktop"));
309        assert!(svg.contains("Streamable HTTP + SSE"));
310        assert!(svg.contains("BigQuery"));
311        assert!(svg.contains("SQL queries"));
312        assert!(svg.contains("Client / Auth"));
313    }
314
315    #[test]
316    fn test_render_ai_kasu_svg() {
317        let yaml = include_str!("../../../examples/ai-kasu/architecture.yaml");
318        let diagram = dendryform_parse::parse_yaml(yaml).unwrap();
319        let plan = compute_layout(&diagram).unwrap();
320        let svg = render_svg(&plan, &Theme::dark(), 1100.0).unwrap();
321
322        assert!(svg.contains("<svg"));
323        assert!(svg.contains("ai-kasu"));
324        assert!(svg.contains("MCP server architecture"));
325        assert!(svg.contains("Claude Code"));
326        assert!(svg.contains("KnowledgeEngine"));
327        assert!(svg.contains("SERVER"));
328    }
329
330    #[test]
331    fn test_render_oxur_lisp_svg() {
332        let yaml = include_str!("../../../examples/oxur-lisp/architecture.yaml");
333        let diagram = dendryform_parse::parse_yaml(yaml).unwrap();
334        let plan = compute_layout(&diagram).unwrap();
335        let svg = render_svg(&plan, &Theme::dark(), 1100.0).unwrap();
336
337        assert!(svg.contains("<svg"));
338        assert!(svg.contains("oxur"));
339        assert!(svg.contains("language architecture"));
340        assert!(svg.contains("OXUR COMPILATION PIPELINE"));
341        assert!(svg.contains("Frontend (oxur-lang)"));
342    }
343}