Merge branch 'feat/diagrams-a' into develop
This commit is contained in:
Generated
+3
@@ -162,6 +162,9 @@ version = "0.1.0"
|
||||
[[package]]
|
||||
name = "notescraft-diagrams"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-editor"
|
||||
|
||||
@@ -7,6 +7,9 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Error type for the diagrams crate: one enum, one alias, no `anyhow`.
|
||||
|
||||
use thiserror::Error as ThisError;
|
||||
|
||||
/// Everything that can go wrong turning Mermaid source into a diagram or a layout.
|
||||
///
|
||||
/// Recoverable syntax problems never reach this type: they become
|
||||
/// [`Diagnostic`](crate::Diagnostic) values and parsing continues.
|
||||
#[derive(Debug, ThisError)]
|
||||
pub enum Error {
|
||||
/// The source is larger than the cap.
|
||||
#[error("diagram source is {len} bytes, the cap is {max} bytes")]
|
||||
TooLarge {
|
||||
/// Actual byte length of the input.
|
||||
len: usize,
|
||||
/// The cap that was exceeded.
|
||||
max: usize,
|
||||
},
|
||||
/// No Mermaid diagram header could be found on the first meaningful line.
|
||||
#[error("no Mermaid diagram header found (expected flowchart, graph or a known diagram kind)")]
|
||||
NoHeader,
|
||||
/// A structural cap (nodes, edges, subgraph depth) was exceeded.
|
||||
#[error("too many {what}: the cap is {max}")]
|
||||
LimitExceeded {
|
||||
/// What hit the cap (`"nodes"`, `"edges"`, ...).
|
||||
what: &'static str,
|
||||
/// The cap that was exceeded.
|
||||
max: usize,
|
||||
},
|
||||
/// Layout was asked for a diagram kind that has no layout yet.
|
||||
#[error("no layout is implemented for diagram kind `{kind}`")]
|
||||
NotLayoutable {
|
||||
/// Mermaid diagram kind that was recognised but cannot be laid out yet.
|
||||
kind: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Result alias used throughout the crate.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Flowchart statements: nodes, edges, subgraphs and bookkeeping.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::geom::EdgeStyle;
|
||||
use crate::lex::{Item, lex};
|
||||
use crate::model::{Diagnostic, Direction, Edge, Flowchart, Group, Node, NodeShape};
|
||||
use crate::parse::{MAX_EDGES, MAX_NODES, MAX_SUBGRAPH_DEPTH};
|
||||
|
||||
/// Accumulates flowchart statements into a [`Flowchart`].
|
||||
pub(crate) struct Builder {
|
||||
direction: Direction,
|
||||
nodes: Vec<Node>,
|
||||
index: HashMap<String, usize>,
|
||||
edges: Vec<Edge>,
|
||||
groups: Vec<Group>,
|
||||
stack: Vec<usize>,
|
||||
diagnostics: Vec<Diagnostic>,
|
||||
source: String,
|
||||
last_line: usize,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
/// A builder holding `source`, returned unchanged in the finished flowchart.
|
||||
pub fn new(source: String) -> Self {
|
||||
Self {
|
||||
direction: Direction::default(),
|
||||
nodes: Vec::new(),
|
||||
index: HashMap::new(),
|
||||
edges: Vec::new(),
|
||||
groups: Vec::new(),
|
||||
stack: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
source,
|
||||
last_line: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the direction declared in the header.
|
||||
pub fn set_direction(&mut self, direction: Direction) {
|
||||
self.direction = direction;
|
||||
}
|
||||
|
||||
/// Records a non-fatal problem.
|
||||
pub fn diagnostic(&mut self, line: usize, message: impl Into<String>) {
|
||||
self.diagnostics.push(Diagnostic::new(line, message));
|
||||
}
|
||||
|
||||
/// Consumes one source line.
|
||||
pub fn line(&mut self, line_no: usize, text: &str) -> Result<()> {
|
||||
self.last_line = line_no;
|
||||
let trimmed = text.trim().trim_end_matches(';').trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with("%%") {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("subgraph")
|
||||
&& (rest.is_empty() || rest.starts_with(char::is_whitespace))
|
||||
{
|
||||
return self.subgraph(rest);
|
||||
}
|
||||
if trimmed == "end" {
|
||||
self.end(line_no);
|
||||
return Ok(());
|
||||
}
|
||||
if trimmed.starts_with("direction ") {
|
||||
self.diagnostic(line_no, "per-subgraph direction is ignored; the flowchart direction is used");
|
||||
return Ok(());
|
||||
}
|
||||
let items = match lex(trimmed) {
|
||||
Ok(items) => items,
|
||||
Err(message) => {
|
||||
self.diagnostic(line_no, message);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
self.items(line_no, items)
|
||||
}
|
||||
|
||||
/// Reports subgraphs still open when the source ends.
|
||||
pub fn close_open_subgraphs(&mut self) {
|
||||
let open = self.stack.len();
|
||||
if open > 0 {
|
||||
let last = self.last_line;
|
||||
self.diagnostic(last, format!("{open} subgraph(s) were never closed with `end`"));
|
||||
self.stack.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumes the finished flowchart.
|
||||
pub fn finish(self) -> Flowchart {
|
||||
Flowchart { direction: self.direction, nodes: self.nodes, edges: self.edges, groups: self.groups, diagnostics: self.diagnostics, source: self.source }
|
||||
}
|
||||
|
||||
fn items(&mut self, line_no: usize, items: Vec<Item>) -> Result<()> {
|
||||
let mut chain: Vec<usize> = Vec::new();
|
||||
let mut prev: Option<(Vec<usize>, EdgeStyle, Option<String>)> = None;
|
||||
for item in items {
|
||||
match item {
|
||||
Item::Node { id, label, shape } => chain.push(self.node(&id, &label, shape)?),
|
||||
Item::And => {
|
||||
if chain.is_empty() {
|
||||
self.diagnostic(line_no, "`&` without a node on its left");
|
||||
}
|
||||
}
|
||||
Item::Edge { style, label } => {
|
||||
if let Some((sources, pstyle, plabel)) = prev.take() {
|
||||
if chain.is_empty() {
|
||||
self.diagnostic(line_no, "edge without a target node");
|
||||
} else {
|
||||
self.connect(&sources, &chain, pstyle, plabel.as_deref())?;
|
||||
}
|
||||
} else if chain.is_empty() {
|
||||
self.diagnostic(line_no, "edge without a source node");
|
||||
}
|
||||
prev = Some((std::mem::take(&mut chain), style, label));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((sources, style, label)) = prev {
|
||||
if chain.is_empty() {
|
||||
self.diagnostic(line_no, "edge without a target node");
|
||||
} else {
|
||||
self.connect(&sources, &chain, style, label.as_deref())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn connect(&mut self, sources: &[usize], targets: &[usize], style: EdgeStyle, label: Option<&str>) -> Result<()> {
|
||||
for from in sources.iter().copied() {
|
||||
for to in targets.iter().copied() {
|
||||
if self.edges.len() >= MAX_EDGES {
|
||||
return Err(Error::LimitExceeded { what: "edges", max: MAX_EDGES });
|
||||
}
|
||||
self.edges.push(Edge { from, to, label: label.map(str::to_owned), style });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn node(&mut self, id: &str, label: &str, shape: NodeShape) -> Result<usize> {
|
||||
if let Some(&idx) = self.index.get(id) {
|
||||
if !label.is_empty()
|
||||
&& label != id
|
||||
&& let Some(node) = self.nodes.get_mut(idx)
|
||||
{
|
||||
label.clone_into(&mut node.label);
|
||||
node.shape = shape;
|
||||
}
|
||||
return Ok(idx);
|
||||
}
|
||||
if self.nodes.len() >= MAX_NODES {
|
||||
return Err(Error::LimitExceeded { what: "nodes", max: MAX_NODES });
|
||||
}
|
||||
let group = self.stack.last().and_then(|i| self.groups.get(*i)).map(|g| g.id.clone());
|
||||
let idx = self.nodes.len();
|
||||
let label = if label.is_empty() { id.to_owned() } else { label.to_owned() };
|
||||
self.nodes.push(Node { id: id.to_owned(), label, shape, group });
|
||||
self.index.insert(id.to_owned(), idx);
|
||||
Ok(idx)
|
||||
}
|
||||
|
||||
fn subgraph(&mut self, rest: &str) -> Result<()> {
|
||||
if self.stack.len() >= MAX_SUBGRAPH_DEPTH {
|
||||
return Err(Error::LimitExceeded { what: "subgraph nesting levels", max: MAX_SUBGRAPH_DEPTH });
|
||||
}
|
||||
let text = rest.trim().trim_matches('"');
|
||||
let (written, title) = match text.find('[') {
|
||||
Some(open) => {
|
||||
let close = text.rfind(']').unwrap_or(text.len());
|
||||
(text.get(..open).unwrap_or_default().trim().to_owned(), text.get(open.saturating_add(1)..close).unwrap_or_default().trim().to_owned())
|
||||
}
|
||||
// `subgraph name` is both id and title; a multi-word title gets a generated id.
|
||||
None if !text.is_empty() && !text.contains(char::is_whitespace) => (text.to_owned(), text.to_owned()),
|
||||
None => (String::new(), text.to_owned()),
|
||||
};
|
||||
let id = if written.is_empty() { self.unique_id() } else { written };
|
||||
let parent = self.stack.last().copied();
|
||||
let depth = self.stack.len();
|
||||
self.groups.push(Group { id, title, depth, parent });
|
||||
let last = self.groups.len().saturating_sub(1);
|
||||
self.stack.push(last);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn end(&mut self, line_no: usize) {
|
||||
if self.stack.pop().is_none() {
|
||||
self.diagnostic(line_no, "`end` without a matching `subgraph`");
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_id(&self) -> String {
|
||||
let mut id = format!("sub{}", self.groups.len());
|
||||
while self.groups.iter().any(|g| g.id == id) {
|
||||
id.push('_');
|
||||
}
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Builder;
|
||||
use crate::model::{Diagram, Direction, NodeShape};
|
||||
use crate::parse::parse;
|
||||
|
||||
fn build(src: &str) -> Builder {
|
||||
Builder::new(src.to_owned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nodes_and_edges() {
|
||||
let mut b = build("");
|
||||
b.line(1, "A[Start] --> B(End) --> C{Done}").unwrap_or(());
|
||||
let f = b.finish();
|
||||
assert_eq!(f.nodes.len(), 3);
|
||||
assert_eq!(f.edges.len(), 2);
|
||||
assert_eq!(f.nodes.get(1).map(|n| n.shape), Some(NodeShape::Round));
|
||||
assert_eq!(f.nodes.get(2).map(|n| n.shape), Some(NodeShape::Rhombus));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ampersand_expands_to_a_product() {
|
||||
let mut b = build("");
|
||||
b.line(1, "A & B --> C").unwrap_or(());
|
||||
let f = b.finish();
|
||||
assert_eq!(f.edges.len(), 2);
|
||||
assert_eq!(f.edges.first().map(|e| (e.from, e.to)), Some((0, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subgraphs_track_membership_and_nesting() {
|
||||
let mut b = build("");
|
||||
b.line(1, "subgraph outer [Outer]").unwrap_or(());
|
||||
b.line(2, "subgraph inner").unwrap_or(());
|
||||
b.line(3, "A").unwrap_or(());
|
||||
b.line(4, "end").unwrap_or(());
|
||||
b.line(5, "B").unwrap_or(());
|
||||
b.line(6, "end").unwrap_or(());
|
||||
let f = b.finish();
|
||||
assert_eq!(f.groups.len(), 2);
|
||||
assert_eq!(f.groups.first().map(|g| g.id.as_str()), Some("outer"));
|
||||
assert_eq!(f.groups.first().map(|g| g.depth), Some(0));
|
||||
assert_eq!(f.groups.get(1).map(|g| g.parent), Some(Some(0)));
|
||||
assert_eq!(f.groups.get(1).map(|g| g.id.as_str()), Some("inner"));
|
||||
assert_eq!(f.nodes.first().and_then(|n| n.group.as_deref()), Some("inner"));
|
||||
assert_eq!(f.nodes.get(1).and_then(|n| n.group.as_deref()), Some("outer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dangling_end_and_unclosed_subgraph_are_diagnostics() {
|
||||
let mut b = build("");
|
||||
b.line(1, "end").unwrap_or(());
|
||||
b.line(2, "subgraph x").unwrap_or(());
|
||||
b.close_open_subgraphs();
|
||||
let f = b.finish();
|
||||
assert_eq!(f.diagnostics.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direction_line_inside_subgraph_is_a_diagnostic() {
|
||||
let mut b = build("");
|
||||
b.line(1, "direction LR").unwrap_or(());
|
||||
let f = b.finish();
|
||||
assert_eq!(f.nodes, []);
|
||||
assert_eq!(f.diagnostics.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_flowchart_parses() {
|
||||
let d = parse("flowchart LR\n A --> B\n B -->|yes| C\n").unwrap_or_else(|_| panic!("parse failed"));
|
||||
let Diagram::Flowchart(f) = d else { panic!("expected flowchart") };
|
||||
assert_eq!(f.direction, Direction::LeftRight);
|
||||
assert_eq!(f.nodes.len(), 3);
|
||||
assert_eq!(f.edges.get(1).and_then(|e| e.label.as_deref()), Some("yes"));
|
||||
assert_eq!(f.diagnostics, []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Geometry primitives shared by the layout and the SVG renderer.
|
||||
|
||||
/// A point in diagram space (origin at the top-left of the drawing).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Point {
|
||||
/// Horizontal coordinate.
|
||||
pub x: f64,
|
||||
/// Vertical coordinate.
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
impl Point {
|
||||
/// Builds a point.
|
||||
#[must_use]
|
||||
pub const fn new(x: f64, y: f64) -> Self {
|
||||
Self { x, y }
|
||||
}
|
||||
}
|
||||
|
||||
/// An axis-aligned rectangle.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Frame {
|
||||
/// Left edge.
|
||||
pub x: f64,
|
||||
/// Top edge.
|
||||
pub y: f64,
|
||||
/// Width of the rectangle.
|
||||
pub width: f64,
|
||||
/// Height of the rectangle.
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// Builds a frame.
|
||||
#[must_use]
|
||||
pub const fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
|
||||
Self { x, y, width, height }
|
||||
}
|
||||
|
||||
/// Horizontal centre.
|
||||
#[must_use]
|
||||
pub fn center_x(self) -> f64 {
|
||||
self.x + self.width / 2.0
|
||||
}
|
||||
|
||||
/// Vertical centre.
|
||||
#[must_use]
|
||||
pub fn center_y(self) -> f64 {
|
||||
self.y + self.height / 2.0
|
||||
}
|
||||
|
||||
/// Right edge.
|
||||
#[must_use]
|
||||
pub fn right(self) -> f64 {
|
||||
self.x + self.width
|
||||
}
|
||||
|
||||
/// Bottom edge.
|
||||
#[must_use]
|
||||
pub fn bottom(self) -> f64 {
|
||||
self.y + self.height
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal placement of a [`Text`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Align {
|
||||
/// Centred on `x`.
|
||||
Middle,
|
||||
/// Left edge at `x`.
|
||||
Start,
|
||||
}
|
||||
|
||||
/// A run of text drawn at a point.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Text {
|
||||
/// Anchor point.
|
||||
pub at: Point,
|
||||
/// Unstyled text content; escaping happens in the renderer.
|
||||
pub content: String,
|
||||
/// Horizontal placement relative to [`Text::at`].
|
||||
pub align: Align,
|
||||
}
|
||||
|
||||
/// Stroke style of an [`Shape::Edge`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EdgeStyle {
|
||||
/// Plain line with an arrow head.
|
||||
Solid,
|
||||
/// Dashed line with an arrow head.
|
||||
Dotted,
|
||||
/// Thick line with an arrow head.
|
||||
Thick,
|
||||
}
|
||||
|
||||
/// One drawn primitive. Rendering is a pure function of these values.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Shape {
|
||||
/// Rounded or square box (rectangle, rounded rectangle, cylinder).
|
||||
Box {
|
||||
/// Outer rectangle of the box.
|
||||
frame: Frame,
|
||||
/// Corner radius; `0.0` for a square rectangle.
|
||||
radius: f64,
|
||||
/// Label drawn inside the box.
|
||||
text: Text,
|
||||
},
|
||||
/// Rhombus / decision node.
|
||||
Diamond {
|
||||
/// Bounding rectangle of the rhombus.
|
||||
frame: Frame,
|
||||
/// Label drawn inside the rhombus.
|
||||
text: Text,
|
||||
},
|
||||
/// Ellipse node.
|
||||
Circle {
|
||||
/// Bounding rectangle of the ellipse.
|
||||
frame: Frame,
|
||||
/// Label drawn inside the ellipse.
|
||||
text: Text,
|
||||
},
|
||||
/// A polyline edge with an optional label.
|
||||
Edge {
|
||||
/// Polyline vertices, in order.
|
||||
points: Vec<Point>,
|
||||
/// Label drawn near the middle of the polyline.
|
||||
label: Option<Text>,
|
||||
/// Stroke style.
|
||||
style: EdgeStyle,
|
||||
},
|
||||
/// Free-standing text (currently only used as an edge label).
|
||||
Text {
|
||||
/// Anchor point.
|
||||
text: Text,
|
||||
},
|
||||
/// Bounding box of a subgraph, drawn behind its members.
|
||||
Group {
|
||||
/// Rectangle enclosing every member node.
|
||||
frame: Frame,
|
||||
/// Title drawn at the top-left inside the rectangle.
|
||||
title: Text,
|
||||
},
|
||||
}
|
||||
|
||||
/// A laid-out diagram: drawable primitives plus the canvas size.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Layout {
|
||||
/// Shapes in draw order (groups and edges first, node shapes last).
|
||||
pub shapes: Vec<Shape>,
|
||||
/// Canvas width in user units.
|
||||
pub width: f64,
|
||||
/// Canvas height in user units.
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Frame, Point};
|
||||
|
||||
#[test]
|
||||
fn frame_accessors() {
|
||||
let f = Frame::new(10.0, 20.0, 30.0, 40.0);
|
||||
assert!((f.center_x() - 25.0).abs() < f64::EPSILON);
|
||||
assert!((f.center_y() - 40.0).abs() < f64::EPSILON);
|
||||
assert!((f.right() - 40.0).abs() < f64::EPSILON);
|
||||
assert!((f.bottom() - 60.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_is_a_pair() {
|
||||
assert_eq!(Point::new(1.0, 2.0), Point { x: 1.0, y: 2.0 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Flowchart placement: turns ranks and layer order into drawable [`Shape`]s.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::geom::{Align, Frame, Layout, Point, Shape, Text};
|
||||
use crate::model::{Diagram, Direction, Flowchart, NodeShape};
|
||||
use crate::rank;
|
||||
|
||||
const MARGIN: f64 = 24.0;
|
||||
const NODE_HEIGHT: f64 = 40.0;
|
||||
const MIN_NODE_WIDTH: f64 = 64.0;
|
||||
const CHAR_WIDTH: f64 = 7.5;
|
||||
const NODE_PADDING: f64 = 32.0;
|
||||
const NODE_GAP: f64 = 40.0;
|
||||
const RANK_GAP: f64 = 64.0;
|
||||
const GROUP_PAD: f64 = 16.0;
|
||||
const GROUP_TITLE: f64 = 24.0;
|
||||
const RADIUS_ROUND: f64 = 8.0;
|
||||
const RADIUS_DB: f64 = 4.0;
|
||||
const LOOP_SIZE: f64 = 24.0;
|
||||
|
||||
/// Lays a diagram out. Only flowcharts have a layout today.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::NotLayoutable`] for diagram kinds that are parsed but not laid out yet.
|
||||
pub fn layout(diagram: &Diagram) -> Result<Layout> {
|
||||
match diagram {
|
||||
Diagram::Flowchart(f) => Ok(flowchart(f)),
|
||||
Diagram::Unsupported { kind, .. } => Err(Error::NotLayoutable { kind: kind.clone() }),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_size(label: &str, shape: NodeShape) -> (f64, f64) {
|
||||
let chars = label.chars().count().min(200);
|
||||
let w = (f64::from(u32::try_from(chars).unwrap_or(200)) * CHAR_WIDTH + NODE_PADDING).max(MIN_NODE_WIDTH);
|
||||
match shape {
|
||||
NodeShape::Rhombus => (w * 1.3, NODE_HEIGHT * 1.4),
|
||||
NodeShape::Circle => (w, NODE_HEIGHT * 1.2),
|
||||
_ => (w, NODE_HEIGHT),
|
||||
}
|
||||
}
|
||||
|
||||
fn flowchart(chart: &Flowchart) -> Layout {
|
||||
let f = chart;
|
||||
let count = f.nodes.len();
|
||||
let horizontal = matches!(f.direction, Direction::LeftRight | Direction::RightLeft);
|
||||
let reversed = matches!(f.direction, Direction::BottomTop | Direction::RightLeft);
|
||||
let edges: Vec<(usize, usize)> = f.edges.iter().map(|e| (e.from, e.to)).collect();
|
||||
let dag = rank::acyclic(count, &edges);
|
||||
let ranks = rank::ranks(count, &dag);
|
||||
let layers = rank::layers(count, &ranks, &dag);
|
||||
|
||||
let sizes: Vec<(f64, f64)> = f.nodes.iter().map(|node| node_size(&node.label, node.shape)).collect();
|
||||
let size_of = |i: usize| sizes.get(i).copied().unwrap_or((MIN_NODE_WIDTH, NODE_HEIGHT));
|
||||
// Abstract axes: `along` runs inside a layer, `across` runs from rank to rank.
|
||||
let along = |i: usize| if horizontal { size_of(i).1 } else { size_of(i).0 };
|
||||
let across = |i: usize| if horizontal { size_of(i).0 } else { size_of(i).1 };
|
||||
|
||||
let layer_extent: Vec<f64> = layers
|
||||
.iter()
|
||||
.map(|l| l.iter().map(|&i| along(i)).sum::<f64>() + NODE_GAP * f64::from(u32::try_from(l.len().saturating_sub(1)).unwrap_or(u32::MAX)))
|
||||
.collect();
|
||||
let widest = layer_extent.iter().copied().fold(0.0, f64::max);
|
||||
|
||||
let mut centers = vec![Point::new(0.0, 0.0); count];
|
||||
let mut offset = 0.0;
|
||||
for (layer, extent) in layers.iter().zip(&layer_extent) {
|
||||
let thickness = layer.iter().map(|&i| across(i)).fold(0.0, f64::max);
|
||||
let mut cursor = (widest - extent) / 2.0;
|
||||
for &i in layer {
|
||||
let a = cursor + along(i) / 2.0;
|
||||
let b = offset + thickness / 2.0;
|
||||
cursor += along(i) + NODE_GAP;
|
||||
if let Some(slot) = centers.get_mut(i) {
|
||||
*slot = if horizontal { Point::new(b, a) } else { Point::new(a, b) };
|
||||
}
|
||||
}
|
||||
offset += thickness + RANK_GAP;
|
||||
}
|
||||
let total = (offset - RANK_GAP).max(0.0);
|
||||
for c in &mut centers {
|
||||
if reversed {
|
||||
if horizontal {
|
||||
c.x = total - c.x;
|
||||
} else {
|
||||
c.y = total - c.y;
|
||||
}
|
||||
}
|
||||
c.x += MARGIN;
|
||||
c.y += MARGIN;
|
||||
}
|
||||
let frame_of = |i: usize| {
|
||||
let (w, h) = size_of(i);
|
||||
let c = centers.get(i).copied().unwrap_or(Point::new(0.0, 0.0));
|
||||
Frame::new(c.x - w / 2.0, c.y - h / 2.0, w, h)
|
||||
};
|
||||
|
||||
let mut shapes = group_shapes(f, &frame_of);
|
||||
for e in &f.edges {
|
||||
shapes.push(edge_shape(e.from, e.to, e.label.as_deref(), e.style, &frame_of));
|
||||
}
|
||||
for (i, node) in f.nodes.iter().enumerate() {
|
||||
let frame = frame_of(i);
|
||||
let text = Text { at: Point::new(frame.center_x(), frame.center_y()), content: node.label.clone(), align: Align::Middle };
|
||||
shapes.push(match node.shape {
|
||||
NodeShape::Rect => Shape::Box { frame, radius: 0.0, text },
|
||||
NodeShape::Round => Shape::Box { frame, radius: RADIUS_ROUND, text },
|
||||
NodeShape::Database => Shape::Box { frame, radius: RADIUS_DB, text },
|
||||
NodeShape::Rhombus => Shape::Diamond { frame, text },
|
||||
NodeShape::Circle => Shape::Circle { frame, text },
|
||||
});
|
||||
}
|
||||
let (mut width, mut height) = (2.0 * MARGIN, 2.0 * MARGIN);
|
||||
for s in &shapes {
|
||||
let f = match s {
|
||||
Shape::Box { frame, .. } | Shape::Diamond { frame, .. } | Shape::Circle { frame, .. } | Shape::Group { frame, .. } => *frame,
|
||||
_ => continue,
|
||||
};
|
||||
width = width.max(f.right() + MARGIN);
|
||||
height = height.max(f.bottom() + MARGIN);
|
||||
}
|
||||
Layout { shapes, width, height }
|
||||
}
|
||||
|
||||
fn group_shapes(f: &Flowchart, frame_of: &dyn Fn(usize) -> Frame) -> Vec<Shape> {
|
||||
let mut order: Vec<usize> = (0..f.groups.len()).collect();
|
||||
order.sort_by_key(|&g| f.groups.get(g).map_or(0, |group| group.depth));
|
||||
let mut out = Vec::new();
|
||||
for g in order {
|
||||
let Some(group) = f.groups.get(g) else { continue };
|
||||
let mut bounds: Option<(f64, f64, f64, f64)> = None;
|
||||
for (i, node) in f.nodes.iter().enumerate() {
|
||||
if !in_group(f, node.group.as_deref(), &group.id) {
|
||||
continue;
|
||||
}
|
||||
let fr = frame_of(i);
|
||||
bounds = Some(match bounds {
|
||||
None => (fr.x, fr.y, fr.right(), fr.bottom()),
|
||||
Some((x0, y0, x1, y1)) => (x0.min(fr.x), y0.min(fr.y), x1.max(fr.right()), y1.max(fr.bottom())),
|
||||
});
|
||||
}
|
||||
let Some((x0, y0, x1, y1)) = bounds else { continue };
|
||||
let pad = GROUP_PAD * (1.0 + 0.5 * f64::from(u32::try_from(f.groups.len().saturating_sub(g).min(8)).unwrap_or(0)) / 8.0);
|
||||
let frame = Frame::new(x0 - pad, y0 - pad - GROUP_TITLE, x1 - x0 + 2.0 * pad, y1 - y0 + 2.0 * pad + GROUP_TITLE);
|
||||
let title = Text { at: Point::new(frame.x + 8.0, frame.y + GROUP_TITLE / 2.0), content: group.title.clone(), align: Align::Start };
|
||||
out.push(Shape::Group { frame, title });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True when `start` is `target` or one of its descendants (walks the parent chain, depth-capped).
|
||||
fn in_group(f: &Flowchart, start: Option<&str>, target: &str) -> bool {
|
||||
let mut current = start;
|
||||
for _ in 0..=crate::parse::MAX_SUBGRAPH_DEPTH {
|
||||
let Some(id) = current else { return false };
|
||||
if id == target {
|
||||
return true;
|
||||
}
|
||||
current = f.groups.iter().find(|g| g.id == id).and_then(|g| g.parent).and_then(|p| f.groups.get(p)).map(|g| g.id.as_str());
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn edge_shape(from: usize, to: usize, label: Option<&str>, style: crate::geom::EdgeStyle, frame_of: &dyn Fn(usize) -> Frame) -> Shape {
|
||||
let (a, b) = (frame_of(from), frame_of(to));
|
||||
let points = if from == to {
|
||||
let (x, y) = (a.right(), a.y);
|
||||
vec![
|
||||
Point::new(x, a.center_y() - 8.0),
|
||||
Point::new(x + LOOP_SIZE, a.center_y() - 8.0),
|
||||
Point::new(x + LOOP_SIZE, y - LOOP_SIZE / 2.0),
|
||||
Point::new(a.center_x() + 8.0, y - LOOP_SIZE / 2.0),
|
||||
Point::new(a.center_x() + 8.0, y),
|
||||
]
|
||||
} else {
|
||||
vec![clip(a, b), clip(b, a)]
|
||||
};
|
||||
let label = label.map(|l| {
|
||||
let (first, last) = (points.first().copied().unwrap_or(Point::new(0.0, 0.0)), points.last().copied().unwrap_or(Point::new(0.0, 0.0)));
|
||||
Text { at: Point::new(f64::midpoint(first.x, last.x), f64::midpoint(first.y, last.y) - 6.0), content: l.to_owned(), align: Align::Middle }
|
||||
});
|
||||
Shape::Edge { points, label, style }
|
||||
}
|
||||
|
||||
/// Where the segment from the centre of `a` towards the centre of `b` leaves `a`.
|
||||
fn clip(a: Frame, b: Frame) -> Point {
|
||||
let (dx, dy) = (b.center_x() - a.center_x(), b.center_y() - a.center_y());
|
||||
let (hw, hh) = (a.width / 2.0, a.height / 2.0);
|
||||
let tx = if dx.abs() > f64::EPSILON { hw / dx.abs() } else { f64::INFINITY };
|
||||
let ty = if dy.abs() > f64::EPSILON { hh / dy.abs() } else { f64::INFINITY };
|
||||
let t = tx.min(ty);
|
||||
if t.is_finite() { Point::new(a.center_x() + dx * t, a.center_y() + dy * t) } else { Point::new(a.center_x(), a.center_y()) }
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Line lexer for the flowchart subset: nodes, edges and `&` separators.
|
||||
//!
|
||||
//! Pure byte scanning: every Mermaid delimiter is ASCII, so scanning for ASCII
|
||||
//! delimiters is safe on UTF-8 input.
|
||||
|
||||
use crate::geom::EdgeStyle;
|
||||
use crate::model::NodeShape;
|
||||
|
||||
/// One lexical element of a flowchart line.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) enum Item {
|
||||
/// A node, either newly declared or a reference to an existing one.
|
||||
Node {
|
||||
/// Identifier as written.
|
||||
id: String,
|
||||
/// Label text (may equal the id).
|
||||
label: String,
|
||||
/// Outline shape.
|
||||
shape: NodeShape,
|
||||
},
|
||||
/// An edge operator, possibly carrying a label.
|
||||
Edge {
|
||||
/// Stroke style implied by the operator.
|
||||
style: EdgeStyle,
|
||||
/// Label written as `|text|` or `-- text -->`.
|
||||
label: Option<String>,
|
||||
},
|
||||
/// The `&` separator between alternatives in `A & B --> C`.
|
||||
And,
|
||||
}
|
||||
|
||||
/// Lexes one flowchart line. `Err` carries the diagnostic message.
|
||||
pub(crate) fn lex(line: &str) -> std::result::Result<Vec<Item>, String> {
|
||||
let mut c = Cursor { bytes: line.as_bytes(), pos: 0 };
|
||||
let mut items = Vec::new();
|
||||
loop {
|
||||
c.skip_ws();
|
||||
let Some(b) = c.peek() else { return Ok(items) };
|
||||
if b == b'&' {
|
||||
c.advance();
|
||||
items.push(Item::And);
|
||||
} else if let Some(edge) = c.edge() {
|
||||
items.push(Item::Edge { style: edge.style, label: edge.label });
|
||||
} else if matches!(b, b'-' | b'=') {
|
||||
return Err("edge operator is missing its terminator".to_owned());
|
||||
} else {
|
||||
items.push(c.node()?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes between two positions, decoded lossily (never fails).
|
||||
fn text(bytes: &[u8], from: usize, to: usize) -> String {
|
||||
bytes.get(from..to).map_or_else(String::new, |b| String::from_utf8_lossy(b).into_owned())
|
||||
}
|
||||
|
||||
struct Cursor<'a> {
|
||||
bytes: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
struct EdgeToken {
|
||||
style: EdgeStyle,
|
||||
label: Option<String>,
|
||||
}
|
||||
|
||||
impl Cursor<'_> {
|
||||
fn peek(&self) -> Option<u8> {
|
||||
self.bytes.get(self.pos).copied()
|
||||
}
|
||||
|
||||
fn advance(&mut self) {
|
||||
self.pos = self.pos.saturating_add(1);
|
||||
}
|
||||
|
||||
fn matches(&self, pat: &str) -> bool {
|
||||
self.bytes.get(self.pos..).is_some_and(|b| b.starts_with(pat.as_bytes()))
|
||||
}
|
||||
|
||||
fn skip_ws(&mut self) {
|
||||
while matches!(self.peek(), Some(b' ' | b'\t')) {
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads an edge operator at the cursor, if one is there.
|
||||
fn edge(&mut self) -> Option<EdgeToken> {
|
||||
let plain: [(&str, EdgeStyle); 4] = [("-.->", EdgeStyle::Dotted), ("==>", EdgeStyle::Thick), ("===", EdgeStyle::Thick), ("---", EdgeStyle::Solid)];
|
||||
for (pat, style) in plain {
|
||||
if self.matches(pat) {
|
||||
self.pos = self.pos.checked_add(pat.len())?;
|
||||
return Some(self.finish_edge(EdgeToken { style, label: None }));
|
||||
}
|
||||
}
|
||||
if self.matches("-->") {
|
||||
self.advance();
|
||||
self.advance();
|
||||
self.advance();
|
||||
return Some(self.finish_edge(EdgeToken { style: EdgeStyle::Solid, label: None }));
|
||||
}
|
||||
if self.matches("--") {
|
||||
return self.labelled_edge();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Reads `-- text -->`: scans forward for the terminating operator.
|
||||
fn labelled_edge(&mut self) -> Option<EdgeToken> {
|
||||
let start = self.pos.checked_add(2)?;
|
||||
let terminators: [(&str, EdgeStyle); 5] =
|
||||
[("-->", EdgeStyle::Solid), ("---", EdgeStyle::Solid), ("==>", EdgeStyle::Thick), ("===", EdgeStyle::Thick), ("-.->", EdgeStyle::Dotted)];
|
||||
let mut best: Option<(usize, usize, EdgeStyle)> = None;
|
||||
for (pat, style) in terminators {
|
||||
if let Some(found) = find(&self.bytes[start..], pat) {
|
||||
let at = start.checked_add(found)?;
|
||||
if best.is_none_or(|(b, _, _)| at < b) {
|
||||
best = Some((at, pat.len(), style));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (at, len, style) = best?;
|
||||
let label = text(self.bytes, start, at).trim().to_owned();
|
||||
self.pos = at.checked_add(len)?;
|
||||
Some(self.finish_edge(EdgeToken { style, label: non_empty(label) }))
|
||||
}
|
||||
|
||||
/// Consumes an optional `|label|` following an edge operator.
|
||||
fn finish_edge(&mut self, mut edge: EdgeToken) -> EdgeToken {
|
||||
self.skip_ws();
|
||||
if self.peek() == Some(b'|') {
|
||||
self.advance();
|
||||
let start = self.pos;
|
||||
while let Some(b) = self.peek() {
|
||||
self.advance();
|
||||
if b == b'|' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let label = text(self.bytes, start, self.pos.saturating_sub(1)).trim().to_owned();
|
||||
edge.label = non_empty(label);
|
||||
}
|
||||
edge
|
||||
}
|
||||
|
||||
/// Reads an identifier, then an optional shape block.
|
||||
fn node(&mut self) -> std::result::Result<Item, String> {
|
||||
let id = self.ident();
|
||||
if id.is_empty() {
|
||||
return Err(format!("unexpected character `{}`", self.peek().map_or(' ', char::from)));
|
||||
}
|
||||
let (label, shape) = self.block()?;
|
||||
Ok(Item::Node { label, id, shape })
|
||||
}
|
||||
|
||||
fn ident(&mut self) -> String {
|
||||
let start = self.pos;
|
||||
while let Some(b) = self.peek() {
|
||||
let dot_ok = b == b'.' && !matches!(self.bytes.get(self.pos.saturating_add(1)).copied(), Some(b'-' | b'='));
|
||||
if b.is_ascii_alphanumeric() || matches!(b, b'_' | b':' | b'/' | b'+') || dot_ok {
|
||||
self.advance();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
text(self.bytes, start, self.pos)
|
||||
}
|
||||
|
||||
/// Reads `[]`, `()`, `{}`, `(())` or `([])` after an identifier.
|
||||
fn block(&mut self) -> std::result::Result<(String, NodeShape), String> {
|
||||
self.skip_ws();
|
||||
let Some(outer) = self.peek().filter(|b| matches!(b, b'[' | b'(' | b'{')) else {
|
||||
return Ok((String::new(), NodeShape::Rect));
|
||||
};
|
||||
let mut opens = vec![self.pos];
|
||||
self.advance();
|
||||
let mut depth = 1usize;
|
||||
while let Some(b) = self.peek() {
|
||||
self.advance();
|
||||
match b {
|
||||
b'[' | b'(' | b'{' => {
|
||||
depth = depth.saturating_add(1);
|
||||
opens.push(self.pos.saturating_sub(1));
|
||||
}
|
||||
b']' | b')' | b'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
let close = self.pos.saturating_sub(1);
|
||||
let inner = opens.get(1).and_then(|i| self.bytes.get(*i).copied());
|
||||
let start = opens.last().and_then(|p| p.checked_add(1)).ok_or_else(|| "empty node label".to_owned())?;
|
||||
return Ok((unquote(&text(self.bytes, start, close)), node_shape(outer, inner)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err("unterminated node shape block".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty(label: String) -> Option<String> {
|
||||
(!label.is_empty()).then_some(label)
|
||||
}
|
||||
|
||||
fn unquote(label: &str) -> String {
|
||||
let trimmed = label.trim();
|
||||
trimmed.strip_prefix('"').and_then(|s| s.strip_suffix('"')).map_or_else(|| trimmed.to_owned(), str::to_owned)
|
||||
}
|
||||
|
||||
fn node_shape(outer: u8, inner: Option<u8>) -> NodeShape {
|
||||
match (outer, inner) {
|
||||
(b'(', Some(b'(')) => NodeShape::Circle,
|
||||
(b'(', Some(b'[')) => NodeShape::Database,
|
||||
(b'(', None) => NodeShape::Round,
|
||||
(b'[', None | Some(b'[')) => NodeShape::Rect,
|
||||
_ => NodeShape::Rhombus,
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte offset of `pat` inside `haystack`, if present.
|
||||
fn find(haystack: &[u8], pat: &str) -> Option<usize> {
|
||||
let pat = pat.as_bytes();
|
||||
if pat.is_empty() || haystack.len() < pat.len() {
|
||||
return None;
|
||||
}
|
||||
haystack.windows(pat.len()).position(|w| w == pat)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Item, NodeShape, lex};
|
||||
|
||||
#[test]
|
||||
fn lexes_nodes_and_edges() {
|
||||
let items = lex("A[Start] --> B").unwrap_or_default();
|
||||
assert_eq!(items.len(), 3);
|
||||
assert!(matches!(items.first(), Some(Item::Node { id, shape: NodeShape::Rect, .. }) if id == "A"));
|
||||
assert!(matches!(items.get(1), Some(Item::Edge { style: crate::geom::EdgeStyle::Solid, label: None })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexes_all_shapes() {
|
||||
for (src, shape) in [
|
||||
("A[x]", NodeShape::Rect),
|
||||
("A(x)", NodeShape::Round),
|
||||
("A{x}", NodeShape::Rhombus),
|
||||
("A((x))", NodeShape::Circle),
|
||||
("A([x])", NodeShape::Database),
|
||||
] {
|
||||
let items = lex(src).unwrap_or_default();
|
||||
assert!(matches!(items.first(), Some(Item::Node { shape: s, .. }) if *s == shape), "{src} -> {items:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexes_both_label_syntaxes() {
|
||||
let pipe = lex("A -->|go| B").unwrap_or_default();
|
||||
assert!(matches!(pipe.get(1), Some(Item::Edge { label: Some(l), .. }) if l == "go"));
|
||||
let text = lex("A -- go --> B").unwrap_or_default();
|
||||
assert!(matches!(text.get(1), Some(Item::Edge { label: Some(l), .. }) if l == "go"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexes_ampersand_chains() {
|
||||
let items = lex("A & B --> C").unwrap_or_default();
|
||||
assert_eq!(items.len(), 5);
|
||||
assert!(matches!(items.get(1), Some(Item::And)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_bad_lines() {
|
||||
assert!(lex("A[x").is_err());
|
||||
}
|
||||
}
|
||||
+19
-11
@@ -1,16 +1,24 @@
|
||||
//! Diagrams: Mermaid-subset parser, layout and SVG/egui shapes.
|
||||
//! Diagrams: Mermaid-subset parser, layout and SVG output.
|
||||
//!
|
||||
//! Supports flowchart/graph, sequence, class/state UML, ER, gantt, pie.
|
||||
//! Unsupported syntax yields a diagnostic; the source is never dropped.
|
||||
//! This part supports flowcharts (`flowchart`/`graph`); other Mermaid kinds are recognised and
|
||||
//! returned as [`Diagram::Unsupported`]. Unsupported or malformed syntax yields a
|
||||
//! [`Diagnostic`] and parsing continues; the source is never dropped ([`Diagram::source`]).
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// TODO(phase 2): Mermaid-subset parser, layout and SVG/egui shapes (flowchart, sequence, class/state UML, ER, gantt, pie).
|
||||
mod error;
|
||||
mod flowchart;
|
||||
mod geom;
|
||||
mod layout;
|
||||
mod lex;
|
||||
mod model;
|
||||
mod parse;
|
||||
mod rank;
|
||||
mod svg;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn package_name_is_correct() {
|
||||
assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-diagrams");
|
||||
}
|
||||
}
|
||||
pub use error::{Error, Result};
|
||||
pub use geom::{Align, EdgeStyle, Frame, Layout, Point, Shape, Text};
|
||||
pub use layout::layout;
|
||||
pub use model::{Diagnostic, Diagram, Direction, Edge, Flowchart, Group, Node, NodeShape};
|
||||
pub use parse::{MAX_EDGES, MAX_INPUT_BYTES, MAX_NODES, MAX_SUBGRAPH_DEPTH, parse};
|
||||
pub use svg::{Palette, escape, to_svg, to_svg_with};
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Parsed-diagram model: diagnostics, diagrams and flowchart elements.
|
||||
|
||||
use crate::geom::EdgeStyle;
|
||||
|
||||
/// A non-fatal problem found while parsing. Parsing always continues after one.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Diagnostic {
|
||||
/// One-based line number in the input.
|
||||
pub line: usize,
|
||||
/// Human-readable description of the problem.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl Diagnostic {
|
||||
/// Builds a diagnostic.
|
||||
#[must_use]
|
||||
pub fn new(line: usize, message: impl Into<String>) -> Self {
|
||||
Self { line, message: message.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed diagram. Unimplemented kinds are kept as source plus a kind name.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Diagram {
|
||||
/// A supported flowchart.
|
||||
Flowchart(Flowchart),
|
||||
/// A recognised Mermaid kind that this build cannot render yet.
|
||||
Unsupported {
|
||||
/// Mermaid diagram kind as written in the header (for example `gantt`).
|
||||
kind: String,
|
||||
/// The original input, unchanged.
|
||||
source: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Diagram {
|
||||
/// Diagnostics collected while parsing. Always empty for [`Diagram::Unsupported`].
|
||||
#[must_use]
|
||||
pub fn diagnostics(&self) -> &[Diagnostic] {
|
||||
match self {
|
||||
Self::Flowchart(f) => &f.diagnostics,
|
||||
Self::Unsupported { .. } => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The input, unchanged, for every diagram kind.
|
||||
#[must_use]
|
||||
pub fn source(&self) -> &str {
|
||||
match self {
|
||||
Self::Flowchart(f) => f.source(),
|
||||
Self::Unsupported { source, .. } => source,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Mermaid diagram kind, used for diagnostics and MCP tool results.
|
||||
#[must_use]
|
||||
pub fn kind(&self) -> &str {
|
||||
match self {
|
||||
Self::Flowchart(_) => "flowchart",
|
||||
Self::Unsupported { kind, .. } => kind,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flow direction declared by the header.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Direction {
|
||||
/// `TD` / `TB`: top to bottom.
|
||||
#[default]
|
||||
TopDown,
|
||||
/// `BT`: bottom to top.
|
||||
BottomTop,
|
||||
/// `LR`: left to right.
|
||||
LeftRight,
|
||||
/// `RL`: right to left.
|
||||
RightLeft,
|
||||
}
|
||||
|
||||
/// Shape drawn for a node, as written between brackets in the source.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum NodeShape {
|
||||
/// `id[Label]`: square rectangle.
|
||||
#[default]
|
||||
Rect,
|
||||
/// `id(Label)`: rounded rectangle.
|
||||
Round,
|
||||
/// `id{Label}`: rhombus.
|
||||
Rhombus,
|
||||
/// `id((Label))`: ellipse.
|
||||
Circle,
|
||||
/// `id([Label])`: cylinder drawn as a box with double side strokes.
|
||||
Database,
|
||||
}
|
||||
|
||||
/// A node of a flowchart, addressed by index in [`Flowchart::nodes`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Node {
|
||||
/// Identifier as written in the source.
|
||||
pub id: String,
|
||||
/// Display text (defaults to the id when no label was written).
|
||||
pub label: String,
|
||||
/// Node outline.
|
||||
pub shape: NodeShape,
|
||||
/// Id of the innermost subgraph containing this node, if any.
|
||||
pub group: Option<String>,
|
||||
}
|
||||
|
||||
/// An edge between two node indices.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Edge {
|
||||
/// Index of the source node in [`Flowchart::nodes`].
|
||||
pub from: usize,
|
||||
/// Index of the target node in [`Flowchart::nodes`].
|
||||
pub to: usize,
|
||||
/// Optional edge label.
|
||||
pub label: Option<String>,
|
||||
/// Stroke style implied by the edge operator.
|
||||
pub style: EdgeStyle,
|
||||
}
|
||||
|
||||
/// A `subgraph` block. Members are listed through [`Node::group`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Group {
|
||||
/// Stable id (`sub0`, `sub1`, ... for title-only subgraphs, else the written id).
|
||||
pub id: String,
|
||||
/// Title drawn at the top of the box.
|
||||
pub title: String,
|
||||
/// Nesting depth, `0` for a top-level subgraph.
|
||||
pub depth: usize,
|
||||
/// Index of the enclosing subgraph in [`Flowchart::groups`], if any.
|
||||
pub parent: Option<usize>,
|
||||
}
|
||||
|
||||
/// A parsed flowchart.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Flowchart {
|
||||
/// Declared direction (`TopDown` when the header omits it).
|
||||
pub direction: Direction,
|
||||
/// Nodes in first-appearance order.
|
||||
pub nodes: Vec<Node>,
|
||||
/// Edges in source order.
|
||||
pub edges: Vec<Edge>,
|
||||
/// Subgraphs in opening order, outermost first.
|
||||
pub groups: Vec<Group>,
|
||||
/// Non-fatal parse problems.
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
pub(crate) source: String,
|
||||
}
|
||||
|
||||
impl Flowchart {
|
||||
/// The input, unchanged.
|
||||
#[must_use]
|
||||
pub fn source(&self) -> &str {
|
||||
&self.source
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Entry point: header dispatch, hard caps and line iteration.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::flowchart::Builder;
|
||||
use crate::model::{Diagram, Direction};
|
||||
|
||||
/// Largest input [`parse`] accepts (1 MiB).
|
||||
pub const MAX_INPUT_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// Largest node count a flowchart may hold.
|
||||
pub const MAX_NODES: usize = 5_000;
|
||||
|
||||
/// Largest edge count a flowchart may hold.
|
||||
pub const MAX_EDGES: usize = 20_000;
|
||||
|
||||
/// Largest `subgraph` nesting depth (8 levels).
|
||||
pub const MAX_SUBGRAPH_DEPTH: usize = 8;
|
||||
|
||||
/// Mermaid kinds recognised by v1 that later parts of this crate will implement.
|
||||
const RECOGNISED_OTHER_HEADERS: [&str; 7] = ["sequencediagram", "classdiagram", "statediagram-v2", "statediagram", "erdiagram", "gantt", "pie"];
|
||||
|
||||
/// Parses Mermaid source into a [`Diagram`].
|
||||
///
|
||||
/// A flowchart is fully parsed. Other recognised Mermaid kinds come back as
|
||||
/// [`Diagram::Unsupported`] with the source intact, never as an error. Text that
|
||||
/// cannot be understood becomes a diagnostic on the flowchart it belongs to.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::TooLarge`] past [`MAX_INPUT_BYTES`], [`Error::NoHeader`] without a known header, and
|
||||
/// [`Error::LimitExceeded`] when a structural cap is hit.
|
||||
pub fn parse(src: &str) -> Result<Diagram> {
|
||||
if src.len() > MAX_INPUT_BYTES {
|
||||
return Err(Error::TooLarge { len: src.len(), max: MAX_INPUT_BYTES });
|
||||
}
|
||||
let Some((header_no, header)) = find_header(src) else { return Err(Error::NoHeader) };
|
||||
let (kind, rest) = split_header(&header);
|
||||
let lower = kind.to_ascii_lowercase();
|
||||
if lower != "flowchart" && lower != "graph" {
|
||||
return if RECOGNISED_OTHER_HEADERS.contains(&lower.as_str()) {
|
||||
Ok(Diagram::Unsupported { kind, source: src.to_owned() })
|
||||
} else {
|
||||
Err(Error::NoHeader)
|
||||
};
|
||||
}
|
||||
|
||||
let mut builder = Builder::new(src.to_owned());
|
||||
let rest = rest.trim().trim_end_matches(';').trim();
|
||||
match direction(rest) {
|
||||
Some(dir) => builder.set_direction(dir),
|
||||
None if rest.is_empty() => {}
|
||||
None => builder.diagnostic(header_no + 1, format!("unknown flowchart direction `{rest}`")),
|
||||
}
|
||||
for (i, line) in src.lines().enumerate().skip(header_no.saturating_add(1)) {
|
||||
builder.line(i.saturating_add(1), line)?;
|
||||
}
|
||||
builder.close_open_subgraphs();
|
||||
Ok(Diagram::Flowchart(builder.finish()))
|
||||
}
|
||||
|
||||
/// Parses `TD`, `TB`, `BT`, `LR` or `RL` (case-insensitive).
|
||||
fn direction(token: &str) -> Option<Direction> {
|
||||
match token.to_ascii_uppercase().as_str() {
|
||||
"TD" | "TB" => Some(Direction::TopDown),
|
||||
"BT" => Some(Direction::BottomTop),
|
||||
"LR" => Some(Direction::LeftRight),
|
||||
"RL" => Some(Direction::RightLeft),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// First non-blank, non-comment line as `(zero-based index, trimmed text)`.
|
||||
fn find_header(src: &str) -> Option<(usize, String)> {
|
||||
src.lines().enumerate().find_map(|(i, line)| {
|
||||
let trimmed = line.trim();
|
||||
(!trimmed.is_empty() && !trimmed.starts_with("%%")).then(|| (i, trimmed.to_owned()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Splits `flowchart TD` into its kind and the remainder.
|
||||
fn split_header(header: &str) -> (String, &str) {
|
||||
let trimmed = header.trim_start_matches('%');
|
||||
match trimmed.split_once(char::is_whitespace) {
|
||||
Some((kind, rest)) => (kind.trim().to_owned(), rest),
|
||||
None => (trimmed.to_owned(), ""),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{direction, find_header, parse, split_header};
|
||||
use crate::error::Error;
|
||||
use crate::model::{Diagram, Direction};
|
||||
|
||||
#[test]
|
||||
fn empty_and_garbage_have_no_header() {
|
||||
assert!(matches!(parse(""), Err(Error::NoHeader)));
|
||||
assert!(matches!(parse("banana\n A --> B"), Err(Error::NoHeader)));
|
||||
assert!(find_header("%% just a comment").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognised_but_unimplemented_kinds_are_unsupported() {
|
||||
let d = parse("sequenceDiagram\n A->>B: hi").unwrap_or_else(|_| panic!("expected Unsupported"));
|
||||
assert!(matches!(d, Diagram::Unsupported { ref kind, ref source } if kind == "sequenceDiagram" && source.contains("A->>B")));
|
||||
assert_eq!(d.source(), "sequenceDiagram\n A->>B: hi");
|
||||
assert_eq!(d.diagnostics(), []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_large_input_is_rejected() {
|
||||
let big = "a".repeat(super::MAX_INPUT_BYTES + 1);
|
||||
assert!(matches!(parse(&big), Err(Error::TooLarge { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_helpers() {
|
||||
assert_eq!(direction("lr"), Some(Direction::LeftRight));
|
||||
assert_eq!(direction("nope"), None);
|
||||
assert_eq!(split_header("flowchart TD"), ("flowchart".to_owned(), "TD"));
|
||||
assert_eq!(split_header("gantt").0, "gantt");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Layer assignment and in-layer ordering for flowcharts (Sugiyama-lite).
|
||||
//!
|
||||
//! Everything here is iterative and deterministic: no recursion on input depth, no hash
|
||||
//! iteration order, and no indexing with computed values.
|
||||
|
||||
/// Number of barycenter sweeps over the layers.
|
||||
const SWEEPS: usize = 4;
|
||||
|
||||
fn at(values: &[usize], i: usize) -> usize {
|
||||
values.get(i).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn set(values: &mut [usize], i: usize, v: usize) {
|
||||
if let Some(slot) = values.get_mut(i) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
|
||||
/// Edges oriented so that the graph is acyclic: back edges found by an iterative DFS are reversed.
|
||||
/// Self loops are dropped. Order of the result follows the input order.
|
||||
pub(crate) fn acyclic(n: usize, edges: &[(usize, usize)]) -> Vec<(usize, usize)> {
|
||||
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
|
||||
for (i, &(from, _)) in edges.iter().enumerate() {
|
||||
if let Some(list) = adj.get_mut(from) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
// 0 = unvisited, 1 = on the DFS stack, 2 = done.
|
||||
let mut state = vec![0u8; n];
|
||||
let mut reversed = vec![false; edges.len()];
|
||||
for root in 0..n {
|
||||
if state.get(root).copied() != Some(0) {
|
||||
continue;
|
||||
}
|
||||
let mut stack: Vec<(usize, usize)> = vec![(root, 0)];
|
||||
if let Some(s) = state.get_mut(root) {
|
||||
*s = 1;
|
||||
}
|
||||
while let Some(&(node, next)) = stack.last() {
|
||||
let edge_ids = adj.get(node).map_or(&[][..], Vec::as_slice);
|
||||
let Some(&edge_id) = edge_ids.get(next) else {
|
||||
if let Some(s) = state.get_mut(node) {
|
||||
*s = 2;
|
||||
}
|
||||
stack.pop();
|
||||
continue;
|
||||
};
|
||||
if let Some(top) = stack.last_mut() {
|
||||
top.1 += 1;
|
||||
}
|
||||
let Some(&(_, to)) = edges.get(edge_id) else { continue };
|
||||
match state.get(to).copied() {
|
||||
Some(0) => {
|
||||
if let Some(s) = state.get_mut(to) {
|
||||
*s = 1;
|
||||
}
|
||||
stack.push((to, 0));
|
||||
}
|
||||
Some(1) => {
|
||||
if let Some(r) = reversed.get_mut(edge_id) {
|
||||
*r = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
edges.iter().zip(&reversed).filter(|((from, to), _)| from != to).map(|(&(from, to), &rev)| if rev { (to, from) } else { (from, to) }).collect()
|
||||
}
|
||||
|
||||
/// Longest-path ranks of an acyclic edge list (Kahn order, so no recursion).
|
||||
pub(crate) fn ranks(n: usize, dag: &[(usize, usize)]) -> Vec<usize> {
|
||||
let mut indegree = vec![0usize; n];
|
||||
let mut out: Vec<Vec<usize>> = vec![Vec::new(); n];
|
||||
for &(from, to) in dag {
|
||||
if let Some(d) = indegree.get_mut(to) {
|
||||
*d += 1;
|
||||
}
|
||||
if let Some(list) = out.get_mut(from) {
|
||||
list.push(to);
|
||||
}
|
||||
}
|
||||
let mut rank = vec![0usize; n];
|
||||
let mut queue: Vec<usize> = (0..n).filter(|&i| at(&indegree, i) == 0).collect();
|
||||
let mut head = 0;
|
||||
while let Some(&node) = queue.get(head) {
|
||||
head += 1;
|
||||
let r = at(&rank, node);
|
||||
for &to in out.get(node).map_or(&[][..], Vec::as_slice) {
|
||||
if at(&rank, to) < r + 1 {
|
||||
set(&mut rank, to, r + 1);
|
||||
}
|
||||
if let Some(d) = indegree.get_mut(to) {
|
||||
*d = d.saturating_sub(1);
|
||||
if *d == 0 {
|
||||
queue.push(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rank
|
||||
}
|
||||
|
||||
/// Nodes grouped per rank and ordered by repeated barycenter sweeps.
|
||||
pub(crate) fn layers(n: usize, rank: &[usize], dag: &[(usize, usize)]) -> Vec<Vec<usize>> {
|
||||
let depth = rank.iter().copied().max().map_or(0, |m| m + 1);
|
||||
let mut layers: Vec<Vec<usize>> = vec![Vec::new(); depth];
|
||||
for node in 0..n {
|
||||
if let Some(layer) = layers.get_mut(at(rank, node)) {
|
||||
layer.push(node);
|
||||
}
|
||||
}
|
||||
let mut preds: Vec<Vec<usize>> = vec![Vec::new(); n];
|
||||
let mut succs: Vec<Vec<usize>> = vec![Vec::new(); n];
|
||||
for &(from, to) in dag {
|
||||
if let Some(p) = preds.get_mut(to) {
|
||||
p.push(from);
|
||||
}
|
||||
if let Some(s) = succs.get_mut(from) {
|
||||
s.push(to);
|
||||
}
|
||||
}
|
||||
let mut pos = vec![0usize; n];
|
||||
refresh(&layers, &mut pos);
|
||||
for sweep in 0..SWEEPS {
|
||||
let downward = sweep % 2 == 0;
|
||||
let order: Vec<usize> = if downward { (0..depth).collect() } else { (0..depth).rev().collect() };
|
||||
for r in order {
|
||||
let neighbours = if downward { &preds } else { &succs };
|
||||
let Some(layer) = layers.get_mut(r) else { continue };
|
||||
let mut keyed: Vec<(usize, usize, usize)> = layer
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &node)| {
|
||||
let adj = neighbours.get(node).map_or(&[][..], Vec::as_slice);
|
||||
let sum: usize = adj.iter().map(|&m| at(&pos, m) * 2 + 1).sum();
|
||||
// Scaled barycenter; nodes without neighbours keep their slot.
|
||||
let key = if adj.is_empty() { i * 2 + 1 } else { sum / adj.len() };
|
||||
(key, node, i)
|
||||
})
|
||||
.collect();
|
||||
keyed.sort_unstable();
|
||||
*layer = keyed.into_iter().map(|(_, node, _)| node).collect();
|
||||
refresh_layer(layer, &mut pos);
|
||||
}
|
||||
}
|
||||
layers
|
||||
}
|
||||
|
||||
fn refresh(layers: &[Vec<usize>], pos: &mut [usize]) {
|
||||
for layer in layers {
|
||||
refresh_layer(layer, pos);
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_layer(layer: &[usize], pos: &mut [usize]) {
|
||||
for (i, &node) in layer.iter().enumerate() {
|
||||
set(pos, node, i);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cycle_is_broken_and_ranked() {
|
||||
let dag = acyclic(3, &[(0, 1), (1, 2), (2, 0)]);
|
||||
assert_eq!(dag.len(), 3);
|
||||
let r = ranks(3, &dag);
|
||||
assert_eq!(r.iter().copied().max(), Some(2), "a 3-cycle becomes a chain: {r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_loops_are_dropped() {
|
||||
assert_eq!(acyclic(1, &[(0, 0)]), Vec::<(usize, usize)>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diamond_has_three_ranks() {
|
||||
let dag = [(0, 1), (0, 2), (1, 3), (2, 3)];
|
||||
assert_eq!(ranks(4, &dag), vec![0, 1, 1, 2]);
|
||||
let layers = layers(4, &ranks(4, &dag), &dag);
|
||||
assert_eq!(layers.len(), 3);
|
||||
assert_eq!(layers.get(1).map(Vec::len), Some(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! SVG output for a [`Layout`]: plain escaped markup, no scripts, no external references.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::geom::{Align, EdgeStyle, Frame, Layout, Point, Shape, Text};
|
||||
|
||||
/// Colours used by [`to_svg`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Palette {
|
||||
/// Page background.
|
||||
pub background: String,
|
||||
/// Node fill.
|
||||
pub node_fill: String,
|
||||
/// Node outline.
|
||||
pub node_stroke: String,
|
||||
/// Text colour.
|
||||
pub text: String,
|
||||
/// Edge stroke and arrow head.
|
||||
pub edge: String,
|
||||
/// Subgraph fill.
|
||||
pub group_fill: String,
|
||||
/// Subgraph outline.
|
||||
pub group_stroke: String,
|
||||
}
|
||||
|
||||
impl Default for Palette {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
background: "#ffffff".into(),
|
||||
node_fill: "#eef1ff".into(),
|
||||
node_stroke: "#5b47d6".into(),
|
||||
text: "#1b1b21".into(),
|
||||
edge: "#5f5f6e".into(),
|
||||
group_fill: "#f7f7f9".into(),
|
||||
group_stroke: "#bcc0cc".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Escapes text for use in SVG text nodes and attribute values.
|
||||
#[must_use]
|
||||
pub fn escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
c if c.is_control() && c != '\n' => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Renders a layout as a standalone SVG document with the default palette.
|
||||
#[must_use]
|
||||
pub fn to_svg(layout: &Layout) -> String {
|
||||
to_svg_with(layout, &Palette::default())
|
||||
}
|
||||
|
||||
/// Renders a layout as a standalone SVG document.
|
||||
#[must_use]
|
||||
pub fn to_svg_with(layout: &Layout, p: &Palette) -> String {
|
||||
let mut s = String::new();
|
||||
let (w, h) = (layout.width.ceil(), layout.height.ceil());
|
||||
let _ = write!(
|
||||
s,
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\" font-family=\"sans-serif\" font-size=\"14\">\
|
||||
<defs><marker id=\"arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"8\" markerHeight=\"8\" orient=\"auto-start-reverse\"><path d=\"M0 0L10 5L0 10z\" fill=\"{edge}\"/></marker></defs>\
|
||||
<rect width=\"{w}\" height=\"{h}\" fill=\"{bg}\"/>",
|
||||
edge = escape(&p.edge),
|
||||
bg = escape(&p.background),
|
||||
);
|
||||
for shape in &layout.shapes {
|
||||
shape_svg(&mut s, shape, p);
|
||||
}
|
||||
s.push_str("</svg>");
|
||||
s
|
||||
}
|
||||
|
||||
fn shape_svg(s: &mut String, shape: &Shape, p: &Palette) {
|
||||
let (fill, stroke) = (escape(&p.node_fill), escape(&p.node_stroke));
|
||||
match shape {
|
||||
Shape::Box { frame, radius, text } => {
|
||||
let _ = write!(
|
||||
s,
|
||||
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" rx=\"{radius}\" fill=\"{fill}\" stroke=\"{stroke}\"/>",
|
||||
frame.x, frame.y, frame.width, frame.height
|
||||
);
|
||||
text_svg(s, text, p);
|
||||
}
|
||||
Shape::Diamond { frame, text } => {
|
||||
let f: &Frame = frame;
|
||||
let _ = write!(
|
||||
s,
|
||||
"<polygon points=\"{},{} {},{} {},{} {},{}\" fill=\"{fill}\" stroke=\"{stroke}\"/>",
|
||||
f.center_x(),
|
||||
f.y,
|
||||
f.right(),
|
||||
f.center_y(),
|
||||
f.center_x(),
|
||||
f.bottom(),
|
||||
f.x,
|
||||
f.center_y()
|
||||
);
|
||||
text_svg(s, text, p);
|
||||
}
|
||||
Shape::Circle { frame, text } => {
|
||||
let _ = write!(
|
||||
s,
|
||||
"<ellipse cx=\"{}\" cy=\"{}\" rx=\"{}\" ry=\"{}\" fill=\"{fill}\" stroke=\"{stroke}\"/>",
|
||||
frame.center_x(),
|
||||
frame.center_y(),
|
||||
frame.width / 2.0,
|
||||
frame.height / 2.0
|
||||
);
|
||||
text_svg(s, text, p);
|
||||
}
|
||||
Shape::Edge { points, label, style } => {
|
||||
let pts: Vec<String> = points.iter().map(|Point { x, y }| format!("{x},{y}")).collect();
|
||||
let (width, dash) = match style {
|
||||
EdgeStyle::Solid => (1.5, ""),
|
||||
EdgeStyle::Dotted => (1.5, " stroke-dasharray=\"4 4\""),
|
||||
EdgeStyle::Thick => (3.0, ""),
|
||||
};
|
||||
let _ = write!(
|
||||
s,
|
||||
"<polyline points=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{width}\"{dash} marker-end=\"url(#arrow)\"/>",
|
||||
pts.join(" "),
|
||||
escape(&p.edge)
|
||||
);
|
||||
if let Some(label) = label {
|
||||
text_svg(s, label, p);
|
||||
}
|
||||
}
|
||||
Shape::Text { text } => text_svg(s, text, p),
|
||||
Shape::Group { frame, title } => {
|
||||
let _ = write!(
|
||||
s,
|
||||
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" rx=\"8\" fill=\"{}\" stroke=\"{}\"/>",
|
||||
frame.x,
|
||||
frame.y,
|
||||
frame.width,
|
||||
frame.height,
|
||||
escape(&p.group_fill),
|
||||
escape(&p.group_stroke)
|
||||
);
|
||||
text_svg(s, title, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn text_svg(s: &mut String, t: &Text, p: &Palette) {
|
||||
let anchor = match t.align {
|
||||
Align::Middle => "middle",
|
||||
Align::Start => "start",
|
||||
};
|
||||
let _ = write!(
|
||||
s,
|
||||
"<text x=\"{}\" y=\"{}\" text-anchor=\"{anchor}\" dominant-baseline=\"central\" fill=\"{}\">{}</text>",
|
||||
t.at.x,
|
||||
t.at.y,
|
||||
escape(&p.text),
|
||||
escape(&t.content)
|
||||
);
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
%% chains, `&`, both label syntaxes and nested subgraphs
|
||||
graph LR
|
||||
A --> B --> C
|
||||
D & E --> F
|
||||
G -->|pipe label| H
|
||||
I -- inline label --> J
|
||||
K -.-> L
|
||||
M ==> N
|
||||
O --- P
|
||||
subgraph outer [Outer box]
|
||||
direction TB
|
||||
subgraph inner
|
||||
Q --> R
|
||||
end
|
||||
S --> T
|
||||
end
|
||||
C --> Q
|
||||
T --> Z
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
flowchart TD
|
||||
A --> B
|
||||
B --> C
|
||||
C --> A
|
||||
A --> D
|
||||
D --> B
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
flowchart TD
|
||||
A["<script>alert('x')</script>"] --> B["a & b < c > d \"quoted\""]
|
||||
B --> C[plain]
|
||||
@@ -0,0 +1,9 @@
|
||||
flowchart TD
|
||||
A[ok] --> ][
|
||||
~~~ not an edge ~~~
|
||||
B --> C
|
||||
subgraph
|
||||
C --> D
|
||||
??
|
||||
D --> E( )
|
||||
E --> F
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
sequenceDiagram
|
||||
Alice->>Bob: Hello Bob, how are you?
|
||||
Bob-->>Alice: Fine!
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
%% every node shape of the subset
|
||||
flowchart TD
|
||||
A[Start] --> B(Round)
|
||||
B --> C{Diamond}
|
||||
C --> D((Circle))
|
||||
D --> E([Stadium])
|
||||
E --> F[End]
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Parse, layout and SVG behaviour of flowcharts, driven by the fixtures in `tests/fixtures`.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use notescraft_diagrams::{Diagram, Error, NodeShape, Shape, escape, layout, parse, to_svg};
|
||||
|
||||
fn fixture(name: &str) -> String {
|
||||
std::fs::read_to_string(format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn flow(src: &str) -> notescraft_diagrams::Flowchart {
|
||||
match parse(src) {
|
||||
Ok(Diagram::Flowchart(f)) => f,
|
||||
other => panic!("expected a flowchart, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_are_recognised() {
|
||||
let f = flow(&fixture("shapes.mmd"));
|
||||
let shapes: Vec<NodeShape> = f.nodes.iter().map(|n| n.shape).collect();
|
||||
for want in [NodeShape::Rect, NodeShape::Round, NodeShape::Rhombus, NodeShape::Circle, NodeShape::Database] {
|
||||
assert!(shapes.contains(&want), "missing {want:?} in {shapes:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chains_ampersands_labels_and_subgraphs() {
|
||||
let f = flow(&fixture("chains.mmd"));
|
||||
assert!(f.edges.len() >= 12, "edges: {}", f.edges.len());
|
||||
assert!(f.edges.iter().any(|e| e.label.as_deref() == Some("pipe label")));
|
||||
assert!(f.edges.iter().any(|e| e.label.as_deref() == Some("inline label")));
|
||||
assert_eq!(f.groups.len(), 2);
|
||||
assert_eq!(f.groups.iter().map(|g| g.depth).max(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_lines_become_diagnostics() {
|
||||
let d = parse(&fixture("malformed.mmd")).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_ne!(d.diagnostics(), []);
|
||||
assert!(!flow(&fixture("malformed.mmd")).edges.is_empty(), "good lines after bad ones still parse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_and_source() {
|
||||
assert!(matches!(parse("no header here"), Err(Error::NoHeader)));
|
||||
assert!(matches!(parse(""), Err(Error::NoHeader)));
|
||||
let seq = fixture("sequence.mmd");
|
||||
let d = parse(&seq).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(matches!(d, Diagram::Unsupported { .. }));
|
||||
assert_eq!(d.source(), seq);
|
||||
assert!(matches!(layout(&d), Err(Error::NotLayoutable { .. })));
|
||||
let src = fixture("chains.mmd");
|
||||
assert_eq!(parse(&src).unwrap_or_else(|e| panic!("{e}")).source(), src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_input_is_rejected() {
|
||||
let big = format!("flowchart TD\n{}", "A --> B\n".repeat(200_000));
|
||||
assert!(matches!(parse(&big), Err(Error::TooLarge { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyclic_graph_lays_out() {
|
||||
let d = parse(&fixture("cyclic.mmd")).unwrap_or_else(|e| panic!("{e}"));
|
||||
let l = layout(&d).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(l.width > 0.0 && l.height > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_is_deterministic_and_ordered_back_to_front() {
|
||||
let d = parse(&fixture("chains.mmd")).unwrap_or_else(|e| panic!("{e}"));
|
||||
let (a, b) = (layout(&d).unwrap_or_else(|e| panic!("{e}")), layout(&d).unwrap_or_else(|e| panic!("{e}")));
|
||||
assert_eq!(a, b);
|
||||
let first_node = a.shapes.iter().position(|s| matches!(s, Shape::Box { .. } | Shape::Diamond { .. } | Shape::Circle { .. }));
|
||||
let last_edge = a.shapes.iter().rposition(|s| matches!(s, Shape::Edge { .. }));
|
||||
assert!(last_edge < first_node, "edges are drawn before nodes");
|
||||
assert!(a.shapes.iter().any(|s| matches!(s, Shape::Group { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directions_change_the_orientation() {
|
||||
let td = layout(&parse("flowchart TD\nA --> B --> C").unwrap_or_else(|e| panic!("{e}"))).unwrap_or_else(|e| panic!("{e}"));
|
||||
let lr = layout(&parse("flowchart LR\nA --> B --> C").unwrap_or_else(|e| panic!("{e}"))).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(td.height > td.width, "TD grows downwards: {}x{}", td.width, td.height);
|
||||
assert!(lr.width > lr.height, "LR grows rightwards: {}x{}", lr.width, lr.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_is_escaped() {
|
||||
let d = parse(&fixture("escaping.mmd")).unwrap_or_else(|e| panic!("{e}"));
|
||||
let svg = to_svg(&layout(&d).unwrap_or_else(|e| panic!("{e}")));
|
||||
assert!(!svg.contains("<script"), "{svg}");
|
||||
assert!(svg.starts_with("<svg") && svg.ends_with("</svg>"));
|
||||
assert_eq!(escape("<a href=\"x\">&'"), "<a href="x">&'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_loop_lays_out() {
|
||||
let l = layout(&parse("flowchart TD\nA --> A").unwrap_or_else(|e| panic!("{e}"))).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(l.shapes.iter().any(|s| matches!(s, Shape::Edge { points, .. } if points.len() > 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "perf: run with --release -- --ignored"]
|
||||
fn thousand_node_chain_lays_out_under_a_second() {
|
||||
let mut src = String::from("flowchart TD\n");
|
||||
for i in 0..1000 {
|
||||
let _ = writeln!(src, "N{i} --> N{}", i + 1);
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
let l = layout(&parse(&src).unwrap_or_else(|e| panic!("{e}"))).unwrap_or_else(|e| panic!("{e}"));
|
||||
let took = start.elapsed();
|
||||
eprintln!("1000-node chain: {took:?}, {} shapes", l.shapes.len());
|
||||
assert!(took.as_secs_f64() < 1.0, "{took:?}");
|
||||
}
|
||||
Reference in New Issue
Block a user