Merge branch 'feat/diagrams-a' into develop

This commit is contained in:
Marcos Paulo
2026-10-13 19:15:38 -03:00
19 changed files with 1777 additions and 11 deletions
Generated
+3
View File
@@ -162,6 +162,9 @@ version = "0.1.0"
[[package]]
name = "notescraft-diagrams"
version = "0.1.0"
dependencies = [
"thiserror",
]
[[package]]
name = "notescraft-editor"
+3
View File
@@ -7,6 +7,9 @@ license.workspace = true
rust-version.workspace = true
publish = false
[dependencies]
thiserror = { workspace = true }
[lib]
path = "src/lib.rs"
+39
View File
@@ -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>;
+279
View File
@@ -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, []);
}
}
+173
View File
@@ -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 });
}
}
+192
View File
@@ -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()) }
}
+273
View File
@@ -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
View File
@@ -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};
+156
View File
@@ -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
}
}
+122
View File
@@ -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");
}
}
+187
View File
@@ -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));
}
}
+169
View File
@@ -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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
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
View File
@@ -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
View File
@@ -0,0 +1,6 @@
flowchart TD
A --> B
B --> C
C --> A
A --> D
D --> B
+3
View File
@@ -0,0 +1,3 @@
flowchart TD
A["<script>alert('x')</script>"] --> B["a & b < c > d \"quoted\""]
B --> C[plain]
+9
View File
@@ -0,0 +1,9 @@
flowchart TD
A[ok] --> ][
~~~ not an edge ~~~
B --> C
subgraph
C --> D
??
D --> E( )
E --> F
+3
View File
@@ -0,0 +1,3 @@
sequenceDiagram
Alice->>Bob: Hello Bob, how are you?
Bob-->>Alice: Fine!
+7
View File
@@ -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]
+116
View File
@@ -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\">&'"), "&lt;a href=&quot;x&quot;&gt;&amp;&#39;");
}
#[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:?}");
}