From 6eddd639a82bac9631010591706ac03971ac6cd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn=20Borgna?= <121866228+aborgna-q@users.noreply.github.com> Date: Fri, 1 Mar 2024 14:17:32 +0000 Subject: [PATCH] feat!: mermaid rendering (#125) Renames the `dot` module to `render`, and adds a mermaid formatter using the same style configuration structs. BREAKING CHANGE: Moved `portgraph::dot` to `portgraph::render` --- src/lib.rs | 2 +- src/render.rs | 144 ++++++++++++++ src/{ => render}/dot.rs | 110 +---------- src/render/mermaid.rs | 415 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 566 insertions(+), 105 deletions(-) create mode 100644 src/render.rs rename src/{ => render}/dot.rs (82%) create mode 100644 src/render/mermaid.rs diff --git a/src/lib.rs b/src/lib.rs index 03610bd..ebc3331 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,10 +63,10 @@ use pyo3::prelude::*; use serde::{Deserialize, Deserializer, Serialize, Serializer}; pub mod algorithms; -pub mod dot; pub mod hierarchy; pub mod multiportgraph; pub mod portgraph; +pub mod render; pub mod secondary; pub mod unmanaged; pub mod view; diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..3178f6a --- /dev/null +++ b/src/render.rs @@ -0,0 +1,144 @@ +//! This module contains rendering logic from portgraphs into graphviz and +//! mermaid diagrams. + +mod dot; +mod mermaid; + +use std::borrow::Cow; + +pub use dot::{DotFormat, DotFormatter}; +pub use mermaid::{MermaidFormat, MermaidFormatter}; + +use self::mermaid::encode_label; + +/// Style of a rendered edge. +/// +/// Defaults to a box with no label. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum NodeStyle { + /// Ignore the node. No edges will be connected to it. + Hidden, + /// Draw a box with the label inside. + Box(String), +} + +impl NodeStyle { + /// Show a node label with the default style. + pub fn new(label: impl ToString) -> Self { + Self::Box(label.to_string()) + } +} + +impl Default for NodeStyle { + fn default() -> Self { + Self::Box(String::new()) + } +} + +/// Style of an edge in a rendered graph. Defaults to a box with no label. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum PortStyle { + /// Do not draw a label. Edges will be connected to the node. + Hidden, + /// Just the port label. Optionally prepend the port index. + Plain(String, bool), + /// Draw a box around the label. Optionally prepend the port index. + Boxed(String, bool), +} + +impl PortStyle { + /// Show a port label with the default style. + pub fn new(label: impl ToString) -> Self { + Self::Boxed(label.to_string(), true) + } + + /// Just the port label. Optionally prepend the port index. + pub fn text(label: impl ToString, show_offset: bool) -> Self { + Self::Plain(label.to_string(), show_offset) + } + + /// Draw a box around the label. Optionally prepend the port index. + pub fn boxed(label: impl ToString, show_offset: bool) -> Self { + Self::Boxed(label.to_string(), show_offset) + } +} + +impl Default for PortStyle { + fn default() -> Self { + Self::Boxed(String::new(), true) + } +} + +/// Style of an edge in a rendered graph. Defaults to [`EdgeStyle::Solid`]. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +#[non_exhaustive] +pub enum EdgeStyle { + /// Normal line + #[default] + Solid, + /// Dotted line + Dotted, + /// Dashed line + Dashed, + /// Edge style with a label + Labelled(String, Box), + /// Custom style + Custom(String), +} + +impl EdgeStyle { + /// Adds a label to the edge style. + /// + /// If the edge style already has a label, it will be replaced. + pub fn with_label(self, label: impl ToString) -> Self { + match self { + Self::Labelled(_, e) => Self::Labelled(label.to_string(), e), + _ => Self::Labelled(label.to_string(), Box::new(self)), + } + } + + /// Returns the base style of the edge, without labels. + pub fn strip_label(&self) -> &Self { + match self { + Self::Labelled(_, e) => e.strip_label(), + e => e, + } + } + + /// Get the style as a graphviz style string + pub(super) fn as_dot_str(&self) -> &str { + match self { + Self::Solid => "", + Self::Dotted => "dotted", + Self::Dashed => "dashed", + Self::Custom(s) => s, + // Ignore edge labels. + Self::Labelled(_lbl, e) => e.as_dot_str(), + } + } + + /// Get the style as a graphviz style string + pub(super) fn as_mermaid_str(&self) -> Cow<'_, str> { + match self { + Self::Solid => "-->".into(), + Self::Dotted => "-.->".into(), + // Dashed links are not supported in mermaid, we use dots instead. + Self::Dashed => "-.->".into(), + Self::Custom(s) => s.into(), + Self::Labelled(lbl, e) => { + let lbl = encode_label("", lbl); + match e.strip_label() { + Self::Solid => format!("--{}-->", lbl).into(), + Self::Dotted => format!("-.{}.->", lbl).into(), + Self::Dashed => format!("-.{}.->", lbl).into(), + Self::Custom(s) => s.into(), + Self::Labelled(_, _) => { + unreachable!("`strip_label` cannot return a `Labelled`") + } + } + } + } + } +} diff --git a/src/dot.rs b/src/render/dot.rs similarity index 82% rename from src/dot.rs rename to src/render/dot.rs index 86148ad..5a2b6ec 100644 --- a/src/dot.rs +++ b/src/render/dot.rs @@ -4,98 +4,9 @@ use std::fmt::Display; use crate::{Direction, Hierarchy, LinkView, NodeIndex, PortIndex, Weights}; -/// Style of an edge in a dot graph. Defaults to "None". -#[derive(Clone, Debug, PartialEq, Eq)] -#[non_exhaustive] -pub enum NodeStyle { - /// Ignore the node. No edges will be connected to it. - Hidden, - /// Draw a box with the label inside. - Box(String), -} - -impl NodeStyle { - /// Show a node label with the default style. - pub fn new(label: impl ToString) -> Self { - Self::Box(label.to_string()) - } -} - -impl Default for NodeStyle { - fn default() -> Self { - Self::Box(String::new()) - } -} - -/// Style of an edge in a dot graph. Defaults to `Box("")`. -#[derive(Clone, Debug, PartialEq, Eq)] -#[non_exhaustive] -pub enum PortStyle { - /// Do not draw a label. Edges will be connected to the node. - Hidden, - /// Just the port label. Optionally prepend the port index. - #[deprecated(note = "Use `PortStyle::Plain(_, true)` instead")] - Text(String), - /// Draw a box around the label. Optionally prepend the port index. - #[deprecated(note = "Use `PortStyle::Boxed(_, true)` instead")] - Box(String), - /// Just the port label. Optionally prepend the port index. - Plain(String, bool), - /// Draw a box around the label. Optionally prepend the port index. - Boxed(String, bool), -} - -impl PortStyle { - /// Show a port label with the default style. - pub fn new(label: impl ToString) -> Self { - Self::Boxed(label.to_string(), true) - } - - /// Just the port label. Optionally prepend the port index. - pub fn text(label: impl ToString, show_offset: bool) -> Self { - Self::Plain(label.to_string(), show_offset) - } - - /// Draw a box around the label. Optionally prepend the port index. - pub fn boxed(label: impl ToString, show_offset: bool) -> Self { - Self::Boxed(label.to_string(), show_offset) - } -} +use super::{EdgeStyle, NodeStyle, PortStyle}; -impl Default for PortStyle { - fn default() -> Self { - Self::Boxed(String::new(), true) - } -} - -/// Style of an edge in a dot graph. Defaults to [`EdgeStyle::Solid`]. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -#[non_exhaustive] -pub enum EdgeStyle { - /// Normal line - #[default] - Solid, - /// Dotted line - Dotted, - /// Dashed line - Dashed, - /// Custom style - Custom(String), -} - -impl EdgeStyle { - /// Get the style as a graphviz style string - pub fn as_str(&self) -> &str { - match self { - Self::Solid => "", - Self::Dotted => "dotted", - Self::Dashed => "dashed", - Self::Custom(s) => s, - } - } -} - -/// Configurable dot formatter for a `PortGraph`. +/// Configurable mermaid formatter for a `PortGraph`. pub struct DotFormatter<'g, G: LinkView> { graph: &'g G, forest: Option<&'g Hierarchy>, @@ -148,6 +59,9 @@ where } /// Encode some `Weights` in the dot format. + /// + /// This is a convenience method to set the node and port styles based on the weight values. + /// It overrides any previous node or port style set. pub fn with_weights<'w, N, P>(self, weights: &'w Weights) -> Self where 'w: 'g, @@ -266,18 +180,6 @@ where style: String::new(), label: make_label(offset, show_offset, &label), }), - #[allow(deprecated)] - PortStyle::Text(label) => Some(PortCellStrings { - id: format!("{}{}", dir, offset), - style: "border=\"0\"".to_string(), - label: make_label(offset, true, &label), - }), - #[allow(deprecated)] - PortStyle::Box(label) => Some(PortCellStrings { - id: format!("{}{}", dir, offset), - style: String::new(), - label: make_label(offset, true, &label), - }), }) .collect() } @@ -313,7 +215,7 @@ where let to_node = self.graph.port_node(to).expect("missing node"); let to_offset = self.graph.port_offset(to).expect("missing port").index(); let edge_style = self.edge_style(from, to); - let edge_label = edge_style.as_str(); + let edge_label = edge_style.as_dot_str(); format!( "{}:out{} -> {}:in{} [style=\"{edge_label}\"]\n", from_node.index(), diff --git a/src/render/mermaid.rs b/src/render/mermaid.rs new file mode 100644 index 0000000..7ec2c88 --- /dev/null +++ b/src/render/mermaid.rs @@ -0,0 +1,415 @@ +//! Functions to encode a `PortGraph` in mermaid format. + +use std::fmt::Display; + +use crate::{Hierarchy, LinkView, NodeIndex, Weights}; + +use super::{EdgeStyle, NodeStyle}; + +/// The indentation separator for the mermaid string. +/// +/// This is purely cosmetic and does not affect the mermaid rendering. +const INDENTATION_SEPARATOR: &str = " "; + +/// Configurable mermaid formatter for a `PortGraph`. +/// +/// Use the [`MermaidFormat`] trait to encode a `PortGraph` in mermaid format. +/// +/// # Example +/// +/// ```rust +/// # use portgraph::{LinkMut, PortGraph, PortMut, PortView, Hierarchy}; +/// # use portgraph::render::MermaidFormat; +/// let mut graph = PortGraph::new(); +/// let n1 = graph.add_node(3, 2); +/// let n2 = graph.add_node(0, 1); +/// let n3 = graph.add_node(1, 0); +/// graph.link_nodes(n2, 0, n3, 0).unwrap(); +/// +/// let mut hier = Hierarchy::new(); +/// hier.push_child(n2, n1).unwrap(); +/// hier.push_child(n3, n1).unwrap(); +/// +/// let mermaid = graph.mermaid_format().with_hierarchy(&hier).finish(); +/// ``` +pub struct MermaidFormatter<'g, G: LinkView> { + graph: &'g G, + forest: Option<&'g Hierarchy>, + node_style: Option NodeStyle + 'g>>, + #[allow(clippy::type_complexity)] + edge_style: Option EdgeStyle + 'g>>, +} + +impl<'g, G> MermaidFormatter<'g, G> +where + G: LinkView, +{ + /// Initialize a new `MermaidFormatter` for `graph`. + pub fn new(graph: &'g G) -> Self { + Self { + graph, + forest: None, + node_style: None, + edge_style: None, + } + } + + /// Set the `Hierarchy` to use for the graph. + pub fn with_hierarchy(mut self, forest: &'g Hierarchy) -> Self { + self.forest = Some(forest); + self + } + + /// Set the function to use to get the style of a node. + pub fn with_node_style(mut self, node_style: impl FnMut(NodeIndex) -> NodeStyle + 'g) -> Self { + self.node_style = Some(Box::new(node_style)); + self + } + + /// Set the function to use to get the style of an edge. + pub fn with_edge_style( + mut self, + edge_style: impl FnMut(G::LinkEndpoint, G::LinkEndpoint) -> EdgeStyle + 'g, + ) -> Self { + self.edge_style = Some(Box::new(edge_style)); + self + } + + /// Encode some `Weights` in the mermaid format. + /// + /// This is a convenience method to set the node and port styles based on the weight values. + /// It overrides any previous node or port style set. + pub fn with_weights<'w, N, P>(self, weights: &'w Weights) -> Self + where + 'w: 'g, + N: Display + Clone, + { + self.with_node_style(|n| NodeStyle::new(&weights.nodes[n])) + } + + /// Encode the graph in mermaid format. + pub fn finish(mut self) -> String { + let mut mermaid = MermaidBuilder::init(self.node_style.take(), self.edge_style.take()); + + // Explore the hierarchy from the root nodes, and add the nodes and edges to the mermaid definition. + for root in self.graph.nodes_iter().filter(|n| self.is_root(*n)) { + self.explore_node(&mut mermaid, root); + } + + mermaid.finish() + } + + /// Encode the nodes, starting from a set of roots. + fn explore_node(&self, mmd: &mut MermaidBuilder<'g, G>, node: NodeIndex) { + if self.is_leaf(node) { + mmd.add_leaf(node); + } else { + mmd.start_subgraph(node); + for child in self + .forest + .map_or_else(Vec::new, |f| f.children(node).collect()) + { + self.explore_node(mmd, child); + } + mmd.end_subgraph(); + } + for (src, tgt) in self.graph.output_links(node) { + let src_node = self.graph.port_node(src).unwrap(); + let tgt_node = self.graph.port_node(tgt).unwrap(); + mmd.add_link(src_node, src, tgt_node, tgt); + } + } + + /// Helper function to check if a node is a leaf in the hierarchy. + fn is_root(&self, node: NodeIndex) -> bool { + self.forest.map_or(true, |f| f.is_root(node)) + } + + /// Helper function to check if a node is a leaf in the hierarchy. + fn is_leaf(&self, node: NodeIndex) -> bool { + self.forest.map_or(true, |f| !f.has_children(node)) + } +} + +/// A trait for encoding a graph in mermaid format. +pub trait MermaidFormat: LinkView + Sized { + /// Initialize a [`MermaidFormatter`] for the graph. + /// + /// Call [`MermaidFormatter::finish`] to produce the final mermaid string. + /// + /// Note that mermaid diagrams do not support ports, so graph edges may be + /// unordered. + /// + /// # Example + /// + /// ```rust + /// # use portgraph::{LinkMut, PortGraph, PortMut, PortView, Hierarchy}; + /// # use portgraph::render::MermaidFormat; + /// let mut graph = PortGraph::new(); + /// let n1 = graph.add_node(3, 2); + /// let n2 = graph.add_node(0, 1); + /// let n3 = graph.add_node(1, 0); + /// graph.link_nodes(n2, 0, n3, 0).unwrap(); + /// + /// let mut hier = Hierarchy::new(); + /// hier.push_child(n2, n1).unwrap(); + /// hier.push_child(n3, n1).unwrap(); + /// + /// let mermaid = graph.mermaid_format().with_hierarchy(&hier).finish(); + /// ``` + /// + /// results in + /// + /// ```mermaid + /// graph LR + /// subgraph 0 [0] + /// direction LR + /// 1[1] + /// 1-->2 + /// 2[2] + /// end + /// ``` + fn mermaid_format(&self) -> MermaidFormatter<'_, Self>; + + /// Encode the graph in mermaid format. See + /// [`MermaidFormat::mermaid_format`] for more control over the output + /// style. + /// + /// Note that mermaid diagrams do not support ports, so graph edges may be + /// unordered. + fn mermaid_string(&self) -> String { + self.mermaid_format().finish() + } +} + +impl MermaidFormat for G +where + G: LinkView, +{ + fn mermaid_format(&self) -> MermaidFormatter<'_, Self> { + MermaidFormatter::new(self) + } +} + +/// Helper struct to manage building a mermaid string. +/// +/// Splitting this from the `MermaidFormatter` allows us to mutate this freely +/// while keeping references to the graph. +struct MermaidBuilder<'g, G: LinkView> { + /// The mmd definition being built. + output: String, + /// The current indentation level. + indent: usize, + /// The styling function for nodes. + node_style: Option NodeStyle + 'g>>, + /// The styling function for edges. + #[allow(clippy::type_complexity)] + edge_style: Option EdgeStyle + 'g>>, +} + +impl<'g, G: LinkView> MermaidBuilder<'g, G> { + /// Start a new flowchart definition. + #[allow(clippy::type_complexity)] + pub fn init( + node_style: Option NodeStyle + 'g>>, + edge_style: Option EdgeStyle + 'g>>, + ) -> Self { + Self { + output: "graph LR\n".to_string(), + indent: 1, + node_style, + edge_style, + } + } + + /// Push an arbitrary line of text to the output. + /// Indents the line according to the current indentation level. + pub fn push_line(&mut self, s: impl AsRef) { + let extra_capacity = self.indent * INDENTATION_SEPARATOR.len() + s.as_ref().len() + 1; + self.output.reserve(extra_capacity); + + self.output + .push_str(&INDENTATION_SEPARATOR.repeat(self.indent)); + self.output.push_str(s.as_ref()); + self.output.push('\n'); + } + + /// Push an arbitrary line of text to the output. + /// Indents the line according to the current indentation level. + fn push_strings(&mut self, strings: &[&str]) { + let extra_capacity = self.indent * INDENTATION_SEPARATOR.len() + + strings.iter().map(|s| s.len()).sum::() + + 1; + self.output.reserve(extra_capacity); + + self.output + .push_str(&INDENTATION_SEPARATOR.repeat(self.indent)); + for s in strings { + self.output.push_str(s); + } + self.output.push('\n'); + } + + /// Adds a leaf node to the mermaid definition. + pub fn add_leaf(&mut self, node: NodeIndex) { + let style = self + .node_style + .as_mut() + .map_or_else(NodeStyle::default, |f| f(node)); + let id = node.index().to_string(); + + match style { + NodeStyle::Hidden => self.push_strings(&[id.as_ref(), ":::hidden"]), + NodeStyle::Box(lbl) => { + self.push_strings(&[id.as_ref(), "[", &encode_label(&id, &lbl), "]"]) + } + } + } + + /// Start a new subgraph block. + /// Call `end_subgraph` to close the block. + /// + /// Blocks may be nested. + pub fn start_subgraph(&mut self, node: NodeIndex) { + let style = self + .node_style + .as_mut() + .map_or_else(NodeStyle::default, |f| f(node)); + let id = node.index().to_string(); + + match style { + NodeStyle::Hidden => self.push_strings(&["subgraph ", id.as_ref(), " [ ]"]), + NodeStyle::Box(lbl) => self.push_strings(&[ + "subgraph ", + id.as_ref(), + " [", + &encode_label(&id, &lbl), + "]", + ]), + } + self.indent += 1; + self.push_line("direction LR"); + } + + /// End the current indented block. + pub fn end_subgraph(&mut self) { + self.indent -= 1; + self.push_line("end"); + } + + /// Adds an edge to the mermaid definition. + pub fn add_link( + &mut self, + src_node: NodeIndex, + src: G::LinkEndpoint, + tgt_node: NodeIndex, + tgt: G::LinkEndpoint, + ) { + let style = self + .edge_style + .as_mut() + .map_or_else(EdgeStyle::default, |f| f(src, tgt)); + let src = src_node.index().to_string(); + let tgt = tgt_node.index().to_string(); + self.push_strings(&[&src, &style.as_mermaid_str(), &tgt]); + } + + /// Returns the built mermaid definition. + pub fn finish(self) -> String { + self.output + } +} + +/// Encode a label, or use the id if the label is empty. +/// +/// We escape double quotes and newlines in the label. +/// Other special characters may need escaping by the user. +/// +/// See https://mermaid.js.org/syntax/flowchart.html#special-characters-that-break-syntax. +pub fn encode_label(id: &str, label: &str) -> String { + if label.is_empty() { + return id.to_string(); + } + format!("\"{}\"", label.replace('"', "#quot;").replace('\n', "
")) +} + +#[cfg(test)] +mod tests { + use crate::{LinkMut, PortGraph, PortMut, PortView}; + + use super::*; + + #[test] + fn test_mermaid_string() { + let mut graph = PortGraph::new(); + let n1 = graph.add_node(3, 2); + let n2 = graph.add_node(1, 0); + let n3 = graph.add_node(1, 0); + graph.link_nodes(n1, 0, n2, 0).unwrap(); + graph.link_nodes(n1, 1, n3, 0).unwrap(); + + let mermaid = &graph.mermaid_string(); + let expected = r#"graph LR + 0[0] + 0-->1 + 0-->2 + 1[1] + 2[2] +"#; + assert_eq!(mermaid, expected, "\n{}\n{}\n", mermaid, expected); + } + + #[test] + fn test_hier_mermaid_string() { + let mut graph = PortGraph::new(); + let n1 = graph.add_node(3, 2); + let n2 = graph.add_node(0, 1); + let n3 = graph.add_node(1, 0); + graph.link_nodes(n2, 0, n3, 0).unwrap(); + + let mut hier = Hierarchy::new(); + + hier.push_child(n2, n1).unwrap(); + hier.push_child(n3, n1).unwrap(); + let mermaid = graph.mermaid_format().with_hierarchy(&hier).finish(); + let expected = r#"graph LR + subgraph 0 [0] + direction LR + 1[1] + 1-->2 + 2[2] + end +"#; + assert_eq!(mermaid, expected, "\n{}\n{}\n", mermaid, expected); + } + + #[test] + fn test_mermaid_string_weighted() { + let mut graph = PortGraph::new(); + let n1 = graph.add_node(0, 2); + let n2 = graph.add_node(1, 0); + let n3 = graph.add_node(1, 0); + let p10 = graph.output(n1, 0).unwrap(); + let p11 = graph.output(n1, 1).unwrap(); + let p20 = graph.input(n2, 0).unwrap(); + let p30 = graph.input(n3, 0).unwrap(); + + graph.link_ports(p10, p20).unwrap(); + graph.link_ports(p11, p30).unwrap(); + + let mut weights: Weights = Weights::new(); + weights[n1] = "node1".to_string(); + weights[n2] = "node2".to_string(); + weights[n3] = "node3".to_string(); + + let mermaid = graph.mermaid_format().with_weights(&weights).finish(); + let expected = r#"graph LR + 0["node1"] + 0-->1 + 0-->2 + 1["node2"] + 2["node3"] +"#; + assert_eq!(mermaid, expected, "\n{}\n{}\n", mermaid, expected); + } +}