Parser, layout engine, and SVG renderer for Graphviz DOT graphs
Dependencies
DOT string ──→ parse ──→ AST ──→ layout ──→ SVG/HTML{
"deps": {
"dowdiness/graphviz": "0.1.0"
}
}moon updatefn main {
let dot = "digraph { a -> b -> c }"
match @parser.parse_dot(dot) {
Ok(graph) => {
let layout = @layout.compute_layout(graph)
println(@svg.render_svg(layout))
}
Err(e) => println("Error at position \{e.position}: \{e.message}")
}
}| Package | Import path | Purpose |
|---|---|---|
| parser | dowdiness/graphviz/lib/parser | Parse DOT → AST, format AST → DOT |
| layout | dowdiness/graphviz/lib/layout | Compute node positions and edge routes |
| svg | dowdiness/graphviz/lib/svg | Render layout to SVG or HTML |
let result : Result[Graph, ParseError] = @parser.parse_dot(
#|digraph G {
#| rankdir=LR;
#| a -> b [label="edge"];
#| b -> c;
#|}
)
match result {
Ok(graph) => {
// graph.directed == true
// graph.id == Some("G")
// graph.statements contains NodeStmt, EdgeStmt, etc.
}
Err(e) => println("Parse error at \{e.position}: \{e.message}")
}let dot_string : String = @parser.format_graph(graph)let attrs : Array[Attribute] = @parser.parse_attributes("color=red, style=bold")
// [{ key: "color", value: "red" }, { key: "style", value: "bold" }]// Top-level graph
struct Graph {
strict : Bool // "strict" keyword present
directed : Bool // digraph (true) vs graph (false)
id : String? // optional graph name
statements : Array[Statement]
}
// Statements
enum Statement {
NodeStmt(NodeId, AttributeList?) // a [color=red]
EdgeStmt(NodeId, Array[(EdgeOp, NodeId)], AttributeList?) // a -> b -> c [label="x"]
AttrStmt(AttrTarget, AttributeList) // node [shape=box]
Assignment(String, String) // rankdir=LR
Subgraph(Subgraph) // subgraph cluster_0 { ... }
}
// Edge operators
enum EdgeOp { Directed /* -> */ ; Undirected /* -- */ }
// Attribute statement targets
enum AttrTarget { GraphAttr; NodeAttr; EdgeAttr }
// Node identifier with optional port
struct NodeId { id : String; port : Port? }
struct Port { id : String?; compass : CompassPoint? }
enum CompassPoint { N; NE; E; SE; S; SW; W; NW; C; Underscore }
// Attributes
struct AttributeList { attributes : Array[Attribute] }
struct Attribute { key : String; value : String }
// Subgraphs
struct Subgraph { id : String?; statements : Array[Statement] }pub(open) trait ToDot {
to_graph(Self) -> Graph
}
pub(open) trait FromDot {
from_graph(Graph) -> Self?
}struct MyGraph {
edges : Array[(String, String)]
}
pub impl @parser.ToDot for MyGraph with to_graph(self) {
@parser.Graph::{
strict: false,
directed: true,
id: None,
statements: self.edges.map(fn(edge) {
@parser.EdgeStmt(
@parser.NodeId::{ id: edge.0, port: None },
[(@parser.Directed, @parser.NodeId::{ id: edge.1, port: None })],
None,
)
}),
}
}
// Then use the convenience functions:
let dot_string : String = @parser.to_dot_string(my_graph)
let parsed : MyGraph? = @parser.from_dot_string(dot_string)let graph = @parser.parse_dot("digraph { a -> b -> c }").unwrap()
// Default settings
let layout : GraphLayout = @layout.compute_layout(graph)
// Custom settings
let layout = @layout.compute_layout_with_config(
graph,
@layout.LayoutConfig::compact(),
)// Default — comfortable spacing for readability
LayoutConfig::default()
// node_width: 72.0, node_height: 36.0
// layer_spacing: 80.0, node_spacing: 50.0, edge_spacing: 10.0
// Compact — tighter spacing for dense graphs
LayoutConfig::compact()
// node_width: 50.0, node_height: 28.0
// layer_spacing: 50.0, node_spacing: 35.0, edge_spacing: 10.0let config = @layout.LayoutConfig::{
node_width: 120.0,
node_height: 50.0,
layer_spacing: 150.0,
node_spacing: 80.0,
edge_spacing: 25.0,
}struct GraphLayout {
nodes : Map[String, LayoutNode] // keyed by node ID
edges : Array[LayoutEdge]
bounds : Bounds // bounding box of the entire graph
layers : Array[Array[String]] // node IDs grouped by layer
directed : Bool
}
struct LayoutNode {
id : String
label : String
position : Point // top-left corner (x, y)
size : Size
layer : Int
order : Int // order within layer
color : String? // from DOT attributes
fontcolor : String?
fillcolor : String?
}
struct LayoutEdge {
from : String
to : String
waypoints : Array[Point] // polyline points
reversed : Bool // true if edge was reversed to break cycles
}let svg : String = @svg.render_svg(layout)
let html : String = @svg.render_html(layout, "My Graph")let svg = @svg.render_svg_with_config(layout, @svg.SvgConfig::dark_theme())// Default — light background, dark strokes
SvgConfig::default()
// padding: 20.0
// node_fill: "#fff", node_stroke: "#333"
// edge_stroke: "#666", text_color: "#000"
// font_size: 14.0, font_family: "Arial, sans-serif"
// Dark theme — dark background, light strokes
SvgConfig::dark_theme()
// node_fill: "#252526", node_stroke: "#3c3c3c"
// edge_stroke: "#858585", text_color: "#d4d4d4"let config = @svg.SvgConfig::{
padding: 30.0,
node_stroke: "#0066cc",
node_fill: "#e6f0ff",
node_stroke_width: 2.0,
edge_stroke: "#0066cc",
edge_stroke_width: 1.5,
text_color: "#003366",
font_size: 12.0,
font_family: "monospace",
}// Save SVG
match @svg.save_svg(layout, "output.svg") {
Ok(_) => println("Saved!")
Err(e) => println("Error: \{e}")
}
// Save HTML with title
match @svg.save_html(layout, "output.html", "My Graph") {
Ok(_) => println("Saved!")
Err(e) => println("Error: \{e}")
}
// With custom config
@svg.save_svg_with_config(layout, "output.svg", config) |> ignore
@svg.save_html_with_config(layout, "output.html", "Title", config) |> ignorefn main {
let dot =
#|digraph {
#| node [shape=box];
#| Start -> Parse -> Layout -> Render;
#| Parse -> Error [style=dashed];
#|}
match @parser.parse_dot(dot) {
Ok(graph) => {
// Customize layout
let config = @layout.LayoutConfig::compact()
let layout = @layout.compute_layout_with_config(graph, config)
// Render with dark theme
let svg = @svg.render_svg_with_config(layout, @svg.SvgConfig::dark_theme())
println(svg)
// Or save as HTML
@svg.save_html(layout, "pipeline.html", "Pipeline") |> ignore
}
Err(e) => println("Parse error: \{e.message}")
}
}Parser, layout engine, and SVG renderer for Graphviz DOT graphs
Dependencies