M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FlowchartGraph {
pub direction: GraphDirection,
pub statements: Vec<Statement>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GraphDirection {
TopToBottom,
BottomToTop,
LeftToRight,
RightToLeft,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Statement {
Node(Node),
Edge(Edge),
Subgraph(Subgraph),
Style(StyleStatement),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Node {
pub id: String,
pub label: Option<String>,
pub shape: NodeShape,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeShape {
Rectangle,
RoundedRectangle,
Stadium,
Diamond,
Hexagon,
Asymmetric,
Subroutine,
Cylinder,
Circle,
StartState,
EndState,
ForkJoin,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Edge {
pub from: String,
pub to: String,
pub label: Option<String>,
pub style: EdgeStyle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdgeStyle {
Arrow,
Line,
DottedArrow,
DottedLine,
ThickArrow,
ThickLine,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Subgraph {
pub id: String,
pub title: Option<String>,
pub statements: Vec<Statement>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StyleStatement {
pub node_id: String,
pub properties: Vec<(String, String)>,
}
+547
View File
@@ -0,0 +1,547 @@
use std::collections::HashMap;
use crate::error::MermaidError;
use crate::text_wrap::line_width;
use crate::theme::MermaidTheme;
/// Mermaid 11.12.2 block default padding (from `getConfig2()?.block?.padding ?? 8`).
/// This is the layout padding between sibling blocks AND the node shape padding
/// (added to bbox.width / bbox.height in rect2()).
const BLOCK_PADDING: f64 = 8.0;
/// Approximate per-character width for 16px Trebuchet MS rendered in Chromium.
/// Derived from reference SVG foreignObject measurements: "A" → 10.953, "B" → 10.984.
/// This replaces DEFAULT_CHAR_WIDTH (8.0) which is too narrow for block-beta nodes.
const BLOCK_CHAR_WIDTH: f64 = 10.97;
/// Approximate text height for a single line of 16px Trebuchet MS in Chromium foreignObject.
/// Reference SVG shows foreignObject height = 19 for single-line labels.
const BLOCK_TEXT_HEIGHT: f64 = 19.0;
/// ViewBox margin around content bounds (from `bounds2.x - 5, bounds2.y - 5, …+10, …+10`).
const VB_MARGIN: f64 = 5.0;
/// Arrow point marker offset (from mermaid's `markerOffsets.arrow_point = 4`).
/// Applied via `getLineFunctionsWithOffset` to shift the last edge point backward
/// so the arrowhead marker tip lands near the target node's edge.
const ARROW_POINT_OFFSET: f64 = 4.0;
pub fn render_block_diagram_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let diagram = parse_block_beta(mermaid_source)?;
let ordered_nodes = &diagram.node_order;
// --- Phase 1: Calculate node sizes (like mermaid's calculateBlockSize) ---
// Mermaid inserts the node into the DOM, calls getBBox(), then stores
// { width: bbox.width, height: bbox.height }. The rect2() shape adds
// `node.padding` (= block.padding = 8) to both dimensions.
// We approximate the label bbox using BLOCK_CHAR_WIDTH and BLOCK_TEXT_HEIGHT.
let mut node_sizes: HashMap<String, (f64, f64)> = HashMap::new();
for id in ordered_nodes {
let label = diagram.nodes.get(id).map(String::as_str).unwrap_or(id);
let text_w = line_width(label, BLOCK_CHAR_WIDTH);
let w = text_w + BLOCK_PADDING;
let h = BLOCK_TEXT_HEIGHT + BLOCK_PADDING;
node_sizes.insert(id.clone(), (w, h));
}
// --- Phase 2: setBlockSizes (normalize children to max width/height) ---
let max_w = node_sizes.values().map(|(w, _)| *w).fold(0.0_f64, f64::max);
let max_h = node_sizes.values().map(|(_, h)| *h).fold(0.0_f64, f64::max);
// All children get the same dimensions (mermaid normalizes to maxChildSize).
for size in node_sizes.values_mut() {
*size = (max_w, max_h);
}
// --- Phase 3: layoutBlocks (position each child) ---
// Mermaid logic: columns determine how many nodes per row.
// startingPosX = -padding (because root.size.x is 0, which is falsy in JS)
// child.x = startingPosX + padding + halfWidth; startingPosX = child.x + halfWidth
// child.y = parent.y - parent.height/2 + py*(height+padding) + height/2 + padding
//
// First compute the root block size so we can derive child y positions.
let columns = diagram.columns;
let num_items = ordered_nodes.len() as i32;
let x_size = if columns > 0 && columns < num_items {
columns
} else {
num_items
};
let y_size = if x_size > 0 {
(num_items as f64 / x_size as f64).ceil() as i32
} else {
1
};
let _root_w = x_size as f64 * (max_w + BLOCK_PADDING) + BLOCK_PADDING;
let root_h = y_size as f64 * (max_h + BLOCK_PADDING) + BLOCK_PADDING;
let mut node_layout: HashMap<String, (f64, f64)> = HashMap::new();
let half_w = max_w / 2.0;
let mut starting_pos_x = -BLOCK_PADDING;
let mut current_row: i32 = 0;
for (col_pos, id) in ordered_nodes.iter().enumerate() {
let (_, py) = calculate_block_position(columns, col_pos as i32);
if py != current_row {
current_row = py;
starting_pos_x = -BLOCK_PADDING;
}
let cx = starting_pos_x + BLOCK_PADDING + half_w;
// Mermaid: child.size.y = parent.y - parent.height/2 + py*(height+padding) + height/2 + padding
// parent.y = 0, parent.height = root_h
let cy = -root_h / 2.0 + py as f64 * (max_h + BLOCK_PADDING) + max_h / 2.0 + BLOCK_PADDING;
node_layout.insert(id.clone(), (cx, cy));
starting_pos_x = cx + half_w;
}
// --- Phase 4: findBounds ---
let (mut min_x, mut min_y, mut max_x, mut max_y) = (
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
);
for id in ordered_nodes {
let (cx, cy) = node_layout[id];
let (w, h) = node_sizes[id];
min_x = min_x.min(cx - w / 2.0);
min_y = min_y.min(cy - h / 2.0);
max_x = max_x.max(cx + w / 2.0);
max_y = max_y.max(cy + h / 2.0);
}
let bounds_w = max_x - min_x;
let bounds_h = max_y - min_y;
let vb_x = min_x - VB_MARGIN;
let vb_y = min_y - VB_MARGIN;
let vb_w = bounds_w + VB_MARGIN * 2.0;
let vb_h = bounds_h + VB_MARGIN * 2.0;
let background_color = if theme.background == "#ffffff" {
"white"
} else {
theme.background.as_str()
};
let text_color = if theme.text_color == "#333333" {
"#333"
} else {
theme.text_color.as_str()
};
let mut svg = String::new();
svg.push_str(&format!(
"<svg aria-roledescription=\"block\" role=\"graphics-document document\" viewBox=\"{vb_x} {vb_y} {vb_w} {vb_h}\" style=\"max-width: {vb_w}px; background-color: {background_color};\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" id=\"my-svg\">"
));
svg.push_str(&format!(
"<style>{}</style>",
block_css(
text_color,
&theme.edge_color,
&theme.node_fill,
&theme.node_stroke
)
));
svg.push_str("<g/>");
svg.push_str(
"<marker orient=\"auto\" markerHeight=\"12\" markerWidth=\"12\" markerUnits=\"userSpaceOnUse\" refY=\"5\" refX=\"6\" viewBox=\"0 0 10 10\" class=\"marker block\" id=\"my-svg_block-pointEnd\"><path style=\"stroke-width: 1; stroke-dasharray: 1, 0;\" class=\"arrowMarkerPath\" d=\"M 0 0 L 10 5 L 0 10 z\"/></marker>"
);
svg.push_str(
"<marker orient=\"auto\" markerHeight=\"12\" markerWidth=\"12\" markerUnits=\"userSpaceOnUse\" refY=\"5\" refX=\"4.5\" viewBox=\"0 0 10 10\" class=\"marker block\" id=\"my-svg_block-pointStart\"><path style=\"stroke-width: 1; stroke-dasharray: 1, 0;\" class=\"arrowMarkerPath\" d=\"M 0 5 L 10 10 L 10 0 z\"/></marker>"
);
svg.push_str(
"<marker orient=\"auto\" markerHeight=\"11\" markerWidth=\"11\" markerUnits=\"userSpaceOnUse\" refY=\"5\" refX=\"11\" viewBox=\"0 0 10 10\" class=\"marker block\" id=\"my-svg_block-circleEnd\"><circle style=\"stroke-width: 1; stroke-dasharray: 1, 0;\" class=\"arrowMarkerPath\" r=\"5\" cy=\"5\" cx=\"5\"/></marker>"
);
svg.push_str(
"<marker orient=\"auto\" markerHeight=\"11\" markerWidth=\"11\" markerUnits=\"userSpaceOnUse\" refY=\"5\" refX=\"-1\" viewBox=\"0 0 10 10\" class=\"marker block\" id=\"my-svg_block-circleStart\"><circle style=\"stroke-width: 1; stroke-dasharray: 1, 0;\" class=\"arrowMarkerPath\" r=\"5\" cy=\"5\" cx=\"5\"/></marker>"
);
svg.push_str(
"<marker orient=\"auto\" markerHeight=\"11\" markerWidth=\"11\" markerUnits=\"userSpaceOnUse\" refY=\"5.2\" refX=\"12\" viewBox=\"0 0 11 11\" class=\"marker cross block\" id=\"my-svg_block-crossEnd\"><path style=\"stroke-width: 2; stroke-dasharray: 1, 0;\" class=\"arrowMarkerPath\" d=\"M 1,1 l 9,9 M 10,1 l -9,9\"/></marker>"
);
svg.push_str(
"<marker orient=\"auto\" markerHeight=\"11\" markerWidth=\"11\" markerUnits=\"userSpaceOnUse\" refY=\"5.2\" refX=\"-1\" viewBox=\"0 0 11 11\" class=\"marker cross block\" id=\"my-svg_block-crossStart\"><path style=\"stroke-width: 2; stroke-dasharray: 1, 0;\" class=\"arrowMarkerPath\" d=\"M 1,1 l 9,9 M 10,1 l -9,9\"/></marker>"
);
svg.push_str("<g class=\"block\">");
// --- Render nodes ---
for id in ordered_nodes {
let Some((cx, cy)) = node_layout.get(id).copied() else {
continue;
};
let (w, h) = node_sizes.get(id).copied().unwrap_or((0.0, 0.0));
let label = diagram.nodes.get(id).map(String::as_str).unwrap_or(id);
svg.push_str(&format!(
"<g class=\"node default default flowchart-label\" id=\"{id}\" transform=\"translate({cx}, {cy})\">",
id = escape_xml(id)
));
svg.push_str(&format!(
"<rect class=\"basic label-container\" style=\"\" rx=\"0\" ry=\"0\" x=\"{x2}\" y=\"{y2}\" width=\"{w}\" height=\"{h}\"/>",
x2 = -w / 2.0,
y2 = -h / 2.0
));
svg.push_str(&format!(
"<g class=\"label\" style=\"\"><text text-anchor=\"middle\" dominant-baseline=\"central\" class=\"nodeLabel\" dy=\"0\">{}</text></g>",
escape_xml(label),
));
svg.push_str("</g>");
}
// --- Render edges ---
// Mermaid 11.12.2: 3 points [start_center, midpoint, end_center],
// clipped via node intersection, then curveBasis path.
for (idx, (from, to)) in diagram.edges.iter().enumerate() {
let Some((fx, fy)) = node_layout.get(from).copied() else {
continue;
};
let Some((tx, ty)) = node_layout.get(to).copied() else {
continue;
};
let (fw, fh) = node_sizes.get(from).copied().unwrap_or((0.0, 0.0));
let (tw, th) = node_sizes.get(to).copied().unwrap_or((0.0, 0.0));
let mid_x = fx + (tx - fx) / 2.0;
let mid_y = fy + (ty - fy) / 2.0;
let start = rect_intersect(fx, fy, fw, fh, mid_x, mid_y);
let end = rect_intersect(tx, ty, tw, th, mid_x, mid_y);
// Apply arrow_point marker offset to end point (mermaid's getLineFunctionsWithOffset).
// This shifts the curve endpoint backward by ARROW_POINT_OFFSET in the edge direction
// so the arrowhead marker tip lands at the correct position.
let edge_dx = end.0 - start.0;
let edge_dy = end.1 - start.1;
let edge_len = (edge_dx * edge_dx + edge_dy * edge_dy).sqrt();
let offset_end = if edge_len > 1e-9 {
(
end.0 - ARROW_POINT_OFFSET * edge_dx / edge_len,
end.1 - ARROW_POINT_OFFSET * edge_dy / edge_len,
)
} else {
end
};
let points = vec![start, (mid_x, mid_y), offset_end];
let d = curve_basis_path(&points);
let edge_no = idx + 1;
let ls = format!("{}1", from.to_lowercase());
let le = format!("{}1", to.to_lowercase());
svg.push_str(&format!(
"<path marker-end=\"url(#my-svg_block-pointEnd)\" class=\"edge-thickness-normal edge-pattern-solid flowchart-link LS-{ls} LE-{le}\" id=\"{edge_no}-{from}-{to}\" d=\"{d}\"/>",
from = escape_xml(from),
to = escape_xml(to)
));
}
svg.push_str("</g></svg>");
Ok(svg)
}
/// Compute the (px, py) grid position for a given column position.
/// Mirrors mermaid's `calculateBlockPosition(columns, position)`.
fn calculate_block_position(columns: i32, position: i32) -> (i32, i32) {
if columns < 0 {
return (position, 0);
}
if columns == 1 {
return (0, position);
}
let px = position % columns;
let py = position / columns;
(px, py)
}
/// Compute the intersection of a ray from inside (cx, cy) toward outside (ox, oy)
/// with the boundary of a rect centered at (cx, cy) with given width and height.
fn rect_intersect(cx: f64, cy: f64, w: f64, h: f64, ox: f64, oy: f64) -> (f64, f64) {
let hw = w / 2.0;
let hh = h / 2.0;
let dx = ox - cx;
let dy = oy - cy;
if dx.abs() < 1e-9 && dy.abs() < 1e-9 {
return (cx + hw, cy);
}
if dx.abs() > 1e-9 {
let t_x = if dx > 0.0 { hw / dx } else { -hw / dx };
let y_at_edge = cy + dy * t_x;
if (y_at_edge - cy).abs() <= hh + 1e-9 {
if dx > 0.0 {
return (cx + hw, y_at_edge);
} else {
return (cx - hw, y_at_edge);
}
}
}
if dy.abs() > 1e-9 {
let t_y = if dy > 0.0 { hh / dy } else { -hh / dy };
let x_at_edge = cx + dx * t_y;
if dy > 0.0 {
return (x_at_edge, cy + hh);
} else {
return (x_at_edge, cy - hh);
}
}
(cx + hw, cy)
}
/// Generate an SVG path string using D3's curveBasis (uniform cubic B-spline).
fn curve_basis_path(points: &[(f64, f64)]) -> String {
if points.is_empty() {
return String::new();
}
if points.len() == 1 {
return format!("M{},{}", fmt_num(points[0].0), fmt_num(points[0].1));
}
if points.len() == 2 {
let (x0, y0) = points[0];
let (x1, y1) = points[1];
return format!(
"M{},{}L{},{}",
fmt_num(x0),
fmt_num(y0),
fmt_num(x1),
fmt_num(y1)
);
}
let mut path = String::new();
let n = points.len();
let (x0, y0) = points[0];
path.push_str(&format!("M{},{}", fmt_num(x0), fmt_num(y0)));
let (x1, y1) = points[1];
let lx = (2.0 * x0 + x1) / 3.0;
let ly = (2.0 * y0 + y1) / 3.0;
path.push_str(&format!("L{},{}", fmt_num(lx), fmt_num(ly)));
for i in 1..n - 1 {
let (px, py) = points[i - 1];
let (cx, cy) = points[i];
let (nx, ny) = points[i + 1];
let cp1x = (2.0 * cx + px) / 3.0;
let cp1y = (2.0 * cy + py) / 3.0;
let cp2x = (2.0 * cx + nx) / 3.0;
let cp2y = (2.0 * cy + ny) / 3.0;
if i == n - 2 {
let end_x = (2.0 * cx + nx) / 3.0;
let end_y = (2.0 * cy + ny) / 3.0;
path.push_str(&format!(
"C{},{},{},{},{},{}",
fmt_num(cp1x),
fmt_num(cp1y),
fmt_num(cp2x),
fmt_num(cp2y),
fmt_num(end_x),
fmt_num(end_y)
));
} else {
let epx = (cx + nx) / 2.0;
let epy = (cy + ny) / 2.0;
path.push_str(&format!(
"C{},{},{},{},{},{}",
fmt_num(cp1x),
fmt_num(cp1y),
fmt_num(cp2x),
fmt_num(cp2y),
fmt_num(epx),
fmt_num(epy)
));
}
}
let (xn, yn) = points[n - 1];
path.push_str(&format!("L{},{}", fmt_num(xn), fmt_num(yn)));
path
}
fn fmt_num(v: f64) -> String {
let rounded = (v * 1000.0).round() / 1000.0;
if (rounded - rounded.round()).abs() < 1e-9 {
format!("{:.0}", rounded)
} else {
let s = format!("{rounded:.3}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
}
#[derive(Debug, Clone)]
struct BlockDiagram {
nodes: HashMap<String, String>,
/// Insertion-ordered list of node IDs (preserves declaration order).
node_order: Vec<String>,
edges: Vec<(String, String)>,
/// Number of columns (from `columns N`). -1 means auto (single row).
columns: i32,
}
fn parse_block_beta(input: &str) -> Result<BlockDiagram, MermaidError> {
let mut found_header = false;
let mut nodes: HashMap<String, String> = HashMap::new();
let mut node_order: Vec<String> = Vec::new();
let mut edges: Vec<(String, String)> = Vec::new();
let mut columns: i32 = -1;
let insert_node = |id: String,
label: String,
nodes: &mut HashMap<String, String>,
order: &mut Vec<String>| {
if let std::collections::hash_map::Entry::Vacant(e) = nodes.entry(id.clone()) {
order.push(id.clone());
e.insert(label);
} else if label != id {
// Only update the label if the new label is explicit (not just the bare ID).
nodes.insert(id, label);
}
};
for (idx, raw) in input.lines().enumerate() {
let line_no = idx + 1;
let line = raw.trim();
if line.is_empty() || line.starts_with("%%") {
continue;
}
if !found_header {
if line.split_whitespace().next() != Some("block-beta") {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected 'block-beta' declaration".to_string(),
});
}
found_header = true;
continue;
}
// columns directive
if let Some(rest) = line.strip_prefix("columns") {
let rest = rest.trim();
if rest == "auto" {
columns = -1;
} else if let Ok(n) = rest.parse::<i32>() {
columns = n;
}
continue;
}
// Skip block:/end group markers (we don't support nested groups yet
// but should not error on them)
if line == "end" || line.starts_with("block:") || line.starts_with("block ") {
continue;
}
// Skip style/classDef/class/linkStyle/space directives
if line.starts_with("style ")
|| line.starts_with("classDef ")
|| line.starts_with("class ")
|| line.starts_with("linkStyle ")
{
continue;
}
// Space directive: `space` or `space:N`
if line == "space" || line.starts_with("space:") {
// Space nodes are invisible placeholders; skip for now
continue;
}
// Edge line: contains `-->`
if let Some((lhs, rhs)) = line.split_once("-->") {
let (from_id, from_label) = parse_block_node(lhs.trim(), line_no)?;
let (to_id, to_label) = parse_block_node(rhs.trim(), line_no)?;
insert_node(from_id.clone(), from_label, &mut nodes, &mut node_order);
insert_node(to_id.clone(), to_label, &mut nodes, &mut node_order);
edges.push((from_id, to_id));
continue;
}
// Standalone node declaration
if let Ok((id, label)) = parse_block_node(line, line_no) {
insert_node(id, label, &mut nodes, &mut node_order);
continue;
}
}
if !found_header {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'block-beta' declaration".to_string(),
});
}
Ok(BlockDiagram {
nodes,
node_order,
edges,
columns,
})
}
fn parse_block_node(s: &str, line: usize) -> Result<(String, String), MermaidError> {
let s = s.trim();
if s.is_empty() {
return Err(MermaidError::ParseError {
line,
message: "Empty node".to_string(),
});
}
if let Some(bracket_start) = s.find('[') {
let id = s[..bracket_start].trim().to_string();
let inner = s[bracket_start + 1..].trim();
let inner = inner.strip_suffix(']').unwrap_or(inner).trim();
let label = strip_quotes(inner);
if id.is_empty() {
return Err(MermaidError::ParseError {
line,
message: format!("Missing node id in '{s}'"),
});
}
return Ok((id, label));
}
Ok((s.to_string(), s.to_string()))
}
fn strip_quotes(s: &str) -> String {
let s = s.trim();
if let Some(inner) = s.strip_prefix('"').and_then(|t| t.strip_suffix('"')) {
return inner.to_string();
}
if let Some(inner) = s.strip_prefix('\'').and_then(|t| t.strip_suffix('\'')) {
return inner.to_string();
}
s.to_string()
}
fn block_css(text_color: &str, edge_color: &str, node_fill: &str, node_stroke: &str) -> String {
format!(
"#my-svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:{text_color};}}@keyframes edge-animation-frame{{from{{stroke-dashoffset:0;}}}}@keyframes dash{{to{{stroke-dashoffset:0;}}}}#my-svg .edge-animation-slow{{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}}#my-svg .edge-animation-fast{{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}}#my-svg .error-icon{{fill:#552222;}}#my-svg .error-text{{fill:#552222;stroke:#552222;}}#my-svg .edge-thickness-normal{{stroke-width:1px;}}#my-svg .edge-thickness-thick{{stroke-width:3.5px;}}#my-svg .edge-pattern-solid{{stroke-dasharray:0;}}#my-svg .edge-thickness-invisible{{stroke-width:0;fill:none;}}#my-svg .edge-pattern-dashed{{stroke-dasharray:3;}}#my-svg .edge-pattern-dotted{{stroke-dasharray:2;}}#my-svg .marker{{fill:{edge_color};stroke:{edge_color};}}#my-svg .marker.cross{{stroke:{edge_color};}}#my-svg svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;}}#my-svg p{{margin:0;}}#my-svg .label{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;color:{text_color};}}#my-svg .cluster-label text{{fill:{text_color};}}#my-svg .cluster-label span,#my-svg p{{color:{text_color};}}#my-svg .label text,#my-svg span,#my-svg p{{fill:{text_color};color:{text_color};}}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{{fill:{node_fill};stroke:{node_stroke};stroke-width:1px;}}#my-svg .flowchart-label text{{text-anchor:middle;}}#my-svg .node .label{{text-align:center;}}#my-svg .node.clickable{{cursor:pointer;}}#my-svg .arrowheadPath{{fill:{edge_color};}}#my-svg .edgePath .path{{stroke:{edge_color};stroke-width:2.0px;}}#my-svg .flowchart-link{{stroke:{edge_color};fill:none;}}#my-svg .edgeLabel{{background-color:rgba(232,232,232, 0.8);text-align:center;}}#my-svg .edgeLabel rect{{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}}#my-svg .labelBkg{{background-color:rgba(232, 232, 232, 0.5);}}#my-svg .node .cluster{{fill:rgba(255, 255, 222, 0.5);stroke:rgba(170, 170, 51, 0.2);box-shadow:rgba(50, 50, 93, 0.25) 0px 13px 27px -5px,rgba(0, 0, 0, 0.3) 0px 8px 16px -8px;stroke-width:1px;}}#my-svg .cluster text{{fill:{text_color};}}#my-svg .cluster span,#my-svg p{{color:{text_color};}}#my-svg div.mermaidTooltip{{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}}#my-svg .flowchartTitleText{{text-anchor:middle;font-size:18px;fill:{text_color};}}#my-svg :root{{--mermaid-font-family:\"trebuchet ms\",verdana,arial,sans-serif;}}",
)
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+246
View File
@@ -0,0 +1,246 @@
use std::borrow::Cow;
use serde_yaml::Value;
use crate::theme::{MermaidTheme, MermaidThemePreset, MermaidThemeVariables};
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ParsedMermaidSource<'a> {
pub body: Cow<'a, str>,
pub frontmatter: Option<MermaidFrontmatter>,
pub config: RenderConfig,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MermaidFrontmatter {
pub title: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RenderConfig {
pub theme: Option<MermaidThemePreset>,
pub theme_variables: MermaidThemeVariables,
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
pub layout: Option<String>,
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
pub look: Option<String>,
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
pub security_level: Option<String>,
pub font_family: Option<String>,
pub font_size: Option<String>,
pub flowchart: FlowchartConfig,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FlowchartConfig {
pub curve: Option<String>,
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
pub html_labels: Option<bool>,
pub node_spacing: Option<u32>,
pub rank_spacing: Option<u32>,
pub padding: Option<u32>,
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
pub diagram_padding: Option<u32>,
pub wrapping_width: Option<u32>,
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
pub use_max_width: Option<bool>,
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
pub default_renderer: Option<String>,
}
impl RenderConfig {
pub fn to_mermaid_theme(&self) -> Option<MermaidTheme> {
if self.theme.is_none() && self.theme_variables.is_empty() {
return None;
}
let mut theme = self.theme.unwrap_or(MermaidThemePreset::Default).to_theme();
self.theme_variables.apply_to(&mut theme);
Some(theme)
}
pub fn font_size_px(&self) -> Option<f64> {
self.font_size.as_deref().and_then(parse_font_size)
}
}
pub fn parse_mermaid_frontmatter(source: &str) -> ParsedMermaidSource<'_> {
let Some((yaml_start, yaml_end, body_start)) = frontmatter_bounds(source) else {
return ParsedMermaidSource {
body: Cow::Borrowed(source),
frontmatter: None,
config: RenderConfig::default(),
};
};
let body = Cow::Owned(source[body_start..].to_string());
let yaml = &source[yaml_start..yaml_end];
let Some(value) = parse_yaml_value(yaml) else {
return ParsedMermaidSource {
body,
frontmatter: Some(MermaidFrontmatter::default()),
config: RenderConfig::default(),
};
};
let frontmatter = parse_frontmatter_metadata(&value);
let config = parse_render_config(&value);
ParsedMermaidSource {
body,
frontmatter: Some(frontmatter),
config,
}
}
fn parse_yaml_value(yaml: &str) -> Option<Value> {
if yaml.trim().is_empty() {
return Some(Value::Null);
}
serde_yaml::from_str::<Value>(yaml).ok()
}
fn parse_frontmatter_metadata(value: &Value) -> MermaidFrontmatter {
MermaidFrontmatter {
title: mapping_value(value, "title").and_then(value_to_string),
}
}
fn parse_render_config(value: &Value) -> RenderConfig {
let Some(config) = mapping_value(value, "config") else {
return RenderConfig::default();
};
RenderConfig {
theme: mapping_value(config, "theme")
.and_then(value_to_string)
.and_then(|theme| MermaidThemePreset::parse(&theme)),
theme_variables: parse_theme_variables(mapping_value(config, "themeVariables")),
layout: mapping_value(config, "layout").and_then(value_to_string),
look: mapping_value(config, "look").and_then(value_to_string),
security_level: mapping_value(config, "securityLevel").and_then(value_to_string),
font_family: mapping_value(config, "fontFamily").and_then(value_to_string),
font_size: mapping_value(config, "fontSize").and_then(value_to_string),
flowchart: parse_flowchart_config(mapping_value(config, "flowchart")),
}
}
fn parse_theme_variables(value: Option<&Value>) -> MermaidThemeVariables {
let mut variables = MermaidThemeVariables::default();
let Some(Value::Mapping(mapping)) = value else {
return variables;
};
for (key, value) in mapping {
let Some(key) = key.as_str() else {
continue;
};
let Some(value) = value_to_string(value) else {
continue;
};
variables.apply_mermaid_alias(key, value);
}
variables
}
fn parse_flowchart_config(value: Option<&Value>) -> FlowchartConfig {
let Some(flowchart) = value else {
return FlowchartConfig::default();
};
FlowchartConfig {
curve: mapping_value(flowchart, "curve").and_then(value_to_string),
html_labels: mapping_value(flowchart, "htmlLabels").and_then(value_to_bool),
node_spacing: mapping_value(flowchart, "nodeSpacing").and_then(value_to_u32),
rank_spacing: mapping_value(flowchart, "rankSpacing").and_then(value_to_u32),
padding: mapping_value(flowchart, "padding").and_then(value_to_u32),
diagram_padding: mapping_value(flowchart, "diagramPadding").and_then(value_to_u32),
wrapping_width: mapping_value(flowchart, "wrappingWidth").and_then(value_to_u32),
use_max_width: mapping_value(flowchart, "useMaxWidth").and_then(value_to_bool),
default_renderer: mapping_value(flowchart, "defaultRenderer").and_then(value_to_string),
}
}
fn mapping_value<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
let Value::Mapping(mapping) = value else {
return None;
};
mapping.get(&Value::String(key.to_string()))
}
fn value_to_string(value: &Value) -> Option<String> {
match value {
Value::String(value) => Some(value.clone()),
Value::Number(value) => Some(value.to_string()),
Value::Bool(value) => Some(value.to_string()),
_ => None,
}
}
fn value_to_bool(value: &Value) -> Option<bool> {
match value {
Value::Bool(value) => Some(*value),
Value::String(value) => match value.as_str() {
"true" => Some(true),
"false" => Some(false),
_ => None,
},
_ => None,
}
}
fn value_to_u32(value: &Value) -> Option<u32> {
match value {
Value::Number(value) => value.as_u64().and_then(|value| u32::try_from(value).ok()),
Value::String(value) => value.parse().ok(),
_ => None,
}
}
fn parse_font_size(value: &str) -> Option<f64> {
let trimmed = value.trim();
let numeric = trimmed.strip_suffix("px").unwrap_or(trimmed).trim();
numeric
.parse::<f64>()
.ok()
.filter(|size| size.is_finite() && *size > 0.0)
}
fn frontmatter_bounds(source: &str) -> Option<(usize, usize, usize)> {
let mut cursor = 0;
while cursor < source.len() {
let end = next_line_end(source, cursor);
let line = source[cursor..end].trim();
if line.is_empty() {
cursor = end;
continue;
}
if line != "---" {
return None;
}
let yaml_start = end;
let mut scan = end;
while scan < source.len() {
let scan_end = next_line_end(source, scan);
if source[scan..scan_end].trim() == "---" {
return Some((yaml_start, scan, scan_end));
}
scan = scan_end;
}
return None;
}
None
}
fn next_line_end(source: &str, start: usize) -> usize {
source[start..]
.find('\n')
.map(|position| start + position + 1)
.unwrap_or(source.len())
}
+936
View File
@@ -0,0 +1,936 @@
use std::collections::{BTreeMap, BTreeSet};
use crate::error::MermaidError;
use crate::text_wrap::{line_width, DEFAULT_CHAR_WIDTH};
use crate::theme::MermaidTheme;
use dagre_rust::layout::layout as dagre_layout;
use dagre_rust::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::Graph;
// --- Constants matching mermaid.js ER renderer defaults ---
const PADDING: f64 = 10.0;
const TEXT_PADDING: f64 = 6.0;
const NODE_SEP: f64 = 140.0;
const RANK_SEP: f64 = 80.0;
const GRAPH_MARGIN: f64 = 8.0;
const LINE_HEIGHT: f64 = 36.75;
const MIN_ENTITY_WIDTH: f64 = 100.0;
const FONT_SIZE: f64 = 16.0;
const COLUMN_TEXT_PADDING: f64 = 8.0;
// --- ER-specific AST ---
#[derive(Debug, Clone, PartialEq)]
enum Cardinality {
ZeroOrOne,
ZeroOrMore,
OneOrMore,
OnlyOne,
}
impl Cardinality {
fn marker_name(&self) -> &'static str {
match self {
Cardinality::ZeroOrOne => "zeroOrOne",
Cardinality::ZeroOrMore => "zeroOrMore",
Cardinality::OneOrMore => "oneOrMore",
Cardinality::OnlyOne => "onlyOne",
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum Identification {
Identifying,
NonIdentifying,
}
#[derive(Debug, Clone)]
struct RelSpec {
card_a: Cardinality,
card_b: Cardinality,
rel_type: Identification,
}
#[derive(Debug, Clone)]
struct Attribute {
attr_type: String,
name: String,
}
#[derive(Debug, Clone)]
struct Entity {
id: String,
attributes: Vec<Attribute>,
}
#[derive(Debug, Clone)]
struct Relationship {
entity_a: String,
entity_b: String,
role: String,
rel_spec: RelSpec,
}
#[derive(Debug, Clone)]
struct ErDiagram {
entities: BTreeMap<String, Entity>,
relationships: Vec<Relationship>,
}
// --- Layout types ---
#[derive(Debug, Clone)]
struct EntityLayout {
id: String,
x: f64,
y: f64,
width: f64,
height: f64,
header_height: f64,
max_type_width: f64,
attributes: Vec<Attribute>,
row_heights: Vec<f64>,
}
#[derive(Debug, Clone)]
struct EdgeLayout {
#[allow(dead_code)]
from: String,
#[allow(dead_code)]
to: String,
role: String,
rel_spec: RelSpec,
points: Vec<(f64, f64)>,
label_pos: Option<(f64, f64)>,
label_width: f64,
label_height: f64,
}
#[derive(Debug, Clone)]
struct DiagramLayout {
entities: BTreeMap<String, EntityLayout>,
edges: Vec<EdgeLayout>,
width: f64,
height: f64,
}
// --- Public entry point ---
pub fn render_er_diagram_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let diagram = parse_er_diagram(mermaid_source)?;
let layout = compute_layout(&diagram);
Ok(render_svg(&layout, theme))
}
// --- Parser ---
fn parse_er_diagram(input: &str) -> Result<ErDiagram, MermaidError> {
let lines: Vec<&str> = input.lines().collect();
let mut i = 0_usize;
// Find header
while i < lines.len() {
let line = lines[i].trim();
if line.is_empty() || line.starts_with("%%") {
i += 1;
continue;
}
if line.split_whitespace().next() == Some("erDiagram") {
i += 1;
break;
}
return Err(MermaidError::ParseError {
line: i + 1,
message: "Expected 'erDiagram' declaration".to_string(),
});
}
let mut entities: BTreeMap<String, Entity> = BTreeMap::new();
let mut referenced_entities: BTreeSet<String> = BTreeSet::new();
let mut relationships: Vec<Relationship> = Vec::new();
while i < lines.len() {
let raw = lines[i];
let line = raw.trim();
let line_no = i + 1;
i += 1;
if line.is_empty() || line.starts_with("%%") {
continue;
}
// Entity block: `ENTITY_NAME {`
if let Some(name_raw) = line.strip_suffix('{') {
let name = name_raw.trim();
if name.is_empty() {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected entity name before '{'".to_string(),
});
}
let mut attrs: Vec<Attribute> = Vec::new();
while i < lines.len() {
let attr_raw = lines[i];
let attr_line = attr_raw.trim();
i += 1;
if attr_line.is_empty() || attr_line.starts_with("%%") {
continue;
}
if attr_line == "}" {
break;
}
// Parse "type name" pairs
let parts: Vec<&str> = attr_line.splitn(2, char::is_whitespace).collect();
if parts.len() >= 2 {
attrs.push(Attribute {
attr_type: parts[0].to_string(),
name: parts[1].trim().to_string(),
});
} else if !parts.is_empty() {
attrs.push(Attribute {
attr_type: parts[0].to_string(),
name: String::new(),
});
}
}
entities.insert(
name.to_string(),
Entity {
id: name.to_string(),
attributes: attrs,
},
);
continue;
}
if line == "}" {
continue;
}
// Relationship: `ENTITY_A ||--o{ ENTITY_B : label`
if line.contains("--") || line.contains("..") {
if let Some(rel) = parse_relationship(line, line_no)? {
referenced_entities.insert(rel.entity_a.clone());
referenced_entities.insert(rel.entity_b.clone());
relationships.push(rel);
continue;
}
}
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Unrecognized erDiagram line: {line}"),
});
}
// Ensure all referenced entities exist
for id in referenced_entities {
entities.entry(id.clone()).or_insert_with(|| Entity {
id,
attributes: Vec::new(),
});
}
Ok(ErDiagram {
entities,
relationships,
})
}
fn parse_relationship(line: &str, line_no: usize) -> Result<Option<Relationship>, MermaidError> {
// Split on `:` to get role label
let (lhs, role) = match line.split_once(':') {
Some((a, b)) => {
let label = b.trim();
(
a.trim(),
if label.is_empty() {
String::new()
} else {
label.to_string()
},
)
}
None => (line.trim(), String::new()),
};
let parts: Vec<&str> = lhs.split_whitespace().collect();
if parts.len() < 3 {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid erDiagram relationship: {line}"),
});
}
let entity_a = parts[0].to_string();
let rel_str = parts[1];
let entity_b = parts[2].to_string();
let rel_spec = parse_rel_spec(rel_str, line_no)?;
Ok(Some(Relationship {
entity_a,
entity_b,
role,
rel_spec,
}))
}
fn parse_rel_spec(s: &str, line_no: usize) -> Result<RelSpec, MermaidError> {
// Format: cardA--cardB or cardA..cardB
// Cards: || (only one), |o or o| (zero or one), }| or |{ (one or more),
// }o or o{ (zero or more)
// `--` = IDENTIFYING, `..` = NON_IDENTIFYING
let (left_part, rel_type, right_part) = if let Some(idx) = s.find("--") {
(&s[..idx], Identification::Identifying, &s[idx + 2..])
} else if let Some(idx) = s.find("..") {
(&s[..idx], Identification::NonIdentifying, &s[idx + 2..])
} else {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid relationship spec: {s}"),
});
};
let card_a = parse_cardinality(left_part, line_no)?;
let card_b = parse_cardinality(right_part, line_no)?;
Ok(RelSpec {
card_a,
card_b,
rel_type,
})
}
fn parse_cardinality(s: &str, line_no: usize) -> Result<Cardinality, MermaidError> {
match s {
"||" => Ok(Cardinality::OnlyOne),
"|o" | "o|" => Ok(Cardinality::ZeroOrOne),
"|{" | "}|" => Ok(Cardinality::OneOrMore),
"o{" | "}o" => Ok(Cardinality::ZeroOrMore),
_ => Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid cardinality: {s}"),
}),
}
}
// --- Layout computation using dagre ---
fn compute_entity_metrics(entity: &Entity) -> (f64, f64, f64, f64, Vec<f64>) {
// Returns: (total_width, total_height, header_height, max_type_width, row_heights)
let header_text = &entity.id;
let header_width = line_width(header_text, DEFAULT_CHAR_WIDTH);
let header_height = LINE_HEIGHT + TEXT_PADDING;
if entity.attributes.is_empty() {
let width = (header_width + PADDING * 2.0).max(MIN_ENTITY_WIDTH);
let height = header_height + PADDING;
return (width, height, header_height, 0.0, Vec::new());
}
let mut max_type_width: f64 = 0.0;
let mut max_name_width: f64 = 0.0;
let mut row_heights = Vec::new();
for attr in &entity.attributes {
let type_w = line_width(&attr.attr_type, DEFAULT_CHAR_WIDTH);
let name_w = line_width(&attr.name, DEFAULT_CHAR_WIDTH);
max_type_width = max_type_width.max(type_w + COLUMN_TEXT_PADDING * 2.0);
max_name_width = max_name_width.max(name_w + COLUMN_TEXT_PADDING * 2.0);
row_heights.push(LINE_HEIGHT + TEXT_PADDING);
}
let attr_total_width = max_type_width + max_name_width;
let content_width = attr_total_width.max(header_width + PADDING * 2.0);
let total_width = content_width.max(MIN_ENTITY_WIDTH);
let total_attr_height: f64 = row_heights.iter().sum();
let total_height = header_height + total_attr_height;
(
total_width,
total_height,
header_height,
max_type_width,
row_heights,
)
}
fn compute_layout(diagram: &ErDiagram) -> DiagramLayout {
let mut entity_metrics: BTreeMap<String, (f64, f64, f64, f64, Vec<f64>)> = BTreeMap::new();
for (id, entity) in &diagram.entities {
entity_metrics.insert(id.clone(), compute_entity_metrics(entity));
}
type DagreGraph = Graph<GraphConfig, GraphNode, GraphEdge>;
let mut g: DagreGraph = Graph::new(Some(graphlib_rust::GraphOption {
directed: Some(true),
multigraph: Some(true),
compound: Some(false),
}));
g.set_graph(GraphConfig {
rankdir: Some("tb".to_string()),
nodesep: Some(NODE_SEP as f32),
ranksep: Some(RANK_SEP as f32),
edgesep: Some(20.0),
marginx: Some(GRAPH_MARGIN as f32),
marginy: Some(GRAPH_MARGIN as f32),
..Default::default()
});
for (id, (w, h, _, _, _)) in &entity_metrics {
g.set_node(
id.clone(),
Some(GraphNode {
width: *w as f32,
height: *h as f32,
..Default::default()
}),
);
}
let mut edge_keys: Vec<(String, String)> = Vec::new();
for rel in &diagram.relationships {
let label_text = &rel.role;
let label_width = if label_text.is_empty() {
0.0
} else {
line_width(label_text, DEFAULT_CHAR_WIDTH)
};
let label_height = if label_text.is_empty() {
0.0
} else {
LINE_HEIGHT
};
let edge_label = GraphEdge {
labelpos: Some("c".to_string()),
width: Some(label_width as f32),
height: Some(label_height as f32),
..Default::default()
};
let _ = g.set_edge(&rel.entity_a, &rel.entity_b, Some(edge_label), None);
edge_keys.push((rel.entity_a.clone(), rel.entity_b.clone()));
}
dagre_layout(&mut g);
let mut positions: BTreeMap<String, (f64, f64)> = BTreeMap::new();
for node_id in g.nodes() {
if let Some(node) = g.node(&node_id) {
positions.insert(node_id, (node.x as f64, node.y as f64));
}
}
let mut edges: Vec<EdgeLayout> = Vec::new();
for (idx, (from, to)) in edge_keys.iter().enumerate() {
let Some(edge) = g.edge(from, to, None) else {
continue;
};
let points: Vec<(f64, f64)> = edge
.points
.as_ref()
.map(|pts| pts.iter().map(|p| (p.x as f64, p.y as f64)).collect())
.unwrap_or_default();
let label_pos = if edge.width.unwrap_or(0.0) > 0.0 || edge.height.unwrap_or(0.0) > 0.0 {
Some((edge.x as f64, edge.y as f64))
} else {
None
};
let rel = &diagram.relationships[idx];
let label_width = edge.width.unwrap_or(0.0) as f64;
let label_height = edge.height.unwrap_or(0.0) as f64;
edges.push(EdgeLayout {
from: from.clone(),
to: to.clone(),
role: rel.role.clone(),
rel_spec: rel.rel_spec.clone(),
points,
label_pos,
label_width,
label_height,
});
}
let mut entity_layouts: BTreeMap<String, EntityLayout> = BTreeMap::new();
for (id, (x, y)) in &positions {
let Some((w, h, header_h, max_type_w, row_heights)) = entity_metrics.get(id).cloned()
else {
continue;
};
let entity = diagram.entities.get(id).cloned().unwrap_or(Entity {
id: id.clone(),
attributes: Vec::new(),
});
entity_layouts.insert(
id.clone(),
EntityLayout {
id: id.clone(),
x: *x,
y: *y,
width: w,
height: h,
header_height: header_h,
max_type_width: max_type_w,
attributes: entity.attributes,
row_heights,
},
);
}
// Compute bounds and normalize
let (min_x, min_y, max_x, max_y) = compute_bounds(&entity_layouts, &edges);
let dx = GRAPH_MARGIN - min_x;
let dy = GRAPH_MARGIN - min_y;
for entity in entity_layouts.values_mut() {
entity.x += dx;
entity.y += dy;
}
for edge in &mut edges {
for p in &mut edge.points {
p.0 += dx;
p.1 += dy;
}
if let Some((x, y)) = edge.label_pos {
edge.label_pos = Some((x + dx, y + dy));
}
}
let width = (max_x - min_x) + GRAPH_MARGIN * 2.0;
let height = (max_y - min_y) + GRAPH_MARGIN * 2.0;
DiagramLayout {
entities: entity_layouts,
edges,
width,
height,
}
}
fn compute_bounds(
entities: &BTreeMap<String, EntityLayout>,
edges: &[EdgeLayout],
) -> (f64, f64, f64, f64) {
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
for entity in entities.values() {
let left = entity.x - entity.width / 2.0;
let right = entity.x + entity.width / 2.0;
let top = entity.y - entity.height / 2.0;
let bottom = entity.y + entity.height / 2.0;
min_x = min_x.min(left);
min_y = min_y.min(top);
max_x = max_x.max(right);
max_y = max_y.max(bottom);
}
for edge in edges {
for (x, y) in &edge.points {
min_x = min_x.min(*x);
min_y = min_y.min(*y);
max_x = max_x.max(*x);
max_y = max_y.max(*y);
}
if let Some((x, y)) = edge.label_pos {
let left = x - edge.label_width / 2.0;
let right = x + edge.label_width / 2.0;
let top = y - edge.label_height / 2.0;
let bottom = y + edge.label_height / 2.0;
min_x = min_x.min(left);
min_y = min_y.min(top);
max_x = max_x.max(right);
max_y = max_y.max(bottom);
}
}
if !min_x.is_finite() {
min_x = 0.0;
max_x = 0.0;
}
if !min_y.is_finite() {
min_y = 0.0;
max_y = 0.0;
}
(min_x, min_y, max_x, max_y)
}
// --- SVG Rendering ---
fn render_svg(layout: &DiagramLayout, theme: &MermaidTheme) -> String {
let mut svg = String::new();
svg.push_str(&format!(
"<svg aria-roledescription=\"er\" role=\"graphics-document document\" \
viewBox=\"0 0 {w} {h}\" style=\"max-width: {w}px; background-color: {bg};\" \
class=\"erDiagram\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" id=\"my-svg\">",
bg = theme.background,
w = layout.width,
h = layout.height
));
// CSS styles matching mermaid.js ER theme
svg.push_str(&format!(
"<style>\
#my-svg {{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:{font_size}px;fill:{text};}}\
#my-svg .entityBox {{fill:{node_fill};stroke:{node_stroke};}}\
#my-svg .relationshipLine {{stroke:{edge};stroke-width:1;fill:none;}}\
#my-svg .marker {{fill:none !important;stroke:{edge} !important;stroke-width:1;}}\
#my-svg .edgeLabel .label {{fill:{node_stroke};font-size:14px;}}
#my-svg .label {{font-family:\"trebuchet ms\",verdana,arial,sans-serif;color:{text};}}\
#my-svg .label text, #my-svg span {{fill:{text};color:{text};}}\
#my-svg .node rect, #my-svg .node circle, #my-svg .node ellipse, #my-svg .node polygon {{fill:{node_fill};stroke:{node_stroke};stroke-width:1px;}}\
#my-svg .divider {{stroke:{node_stroke};stroke-width:1;}}\
</style>",
font_size = FONT_SIZE,
text = theme.text_color,
node_fill = theme.node_fill,
node_stroke = theme.node_stroke,
edge = theme.edge_color,
));
// ER-specific SVG marker definitions
svg.push_str("<defs>");
render_er_markers(&mut svg, theme);
svg.push_str("</defs>");
svg.push_str("<g>");
// Edges (paths)
svg.push_str("<g class=\"edgePaths\">");
for edge in &layout.edges {
render_edge_path(&mut svg, edge);
}
svg.push_str("</g>");
// Edge labels
svg.push_str("<g class=\"edgeLabels\">");
for edge in &layout.edges {
render_edge_label(&mut svg, edge, theme);
}
svg.push_str("</g>");
// Entity nodes
svg.push_str("<g class=\"nodes\">");
for entity in layout.entities.values() {
render_entity_node(&mut svg, entity, theme);
}
svg.push_str("</g>");
svg.push_str("</g></svg>");
svg
}
fn render_er_markers(svg: &mut String, theme: &MermaidTheme) {
let edge_color = &theme.edge_color;
// onlyOne markers: two perpendicular bars
svg.push_str(&format!(
"<marker id=\"my-svg_er-onlyOneStart\" class=\"marker onlyOne\" \
refX=\"0\" refY=\"9\" markerWidth=\"18\" markerHeight=\"18\" orient=\"auto\">\
<path d=\"M9,0 L9,18 M15,0 L15,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
</marker>"
));
svg.push_str(&format!(
"<marker id=\"my-svg_er-onlyOneEnd\" class=\"marker onlyOne\" \
refX=\"18\" refY=\"9\" markerWidth=\"18\" markerHeight=\"18\" orient=\"auto\">\
<path d=\"M3,0 L3,18 M9,0 L9,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
</marker>"
));
// zeroOrOne markers: circle + perpendicular bar
svg.push_str(&format!(
"<marker id=\"my-svg_er-zeroOrOneStart\" class=\"marker zeroOrOne\" \
refX=\"0\" refY=\"9\" markerWidth=\"30\" markerHeight=\"18\" orient=\"auto\">\
<circle fill=\"white\" cx=\"21\" cy=\"9\" r=\"6\" stroke=\"{edge_color}\"/>\
<path d=\"M9,0 L9,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
</marker>"
));
svg.push_str(&format!(
"<marker id=\"my-svg_er-zeroOrOneEnd\" class=\"marker zeroOrOne\" \
refX=\"30\" refY=\"9\" markerWidth=\"30\" markerHeight=\"18\" orient=\"auto\">\
<circle fill=\"white\" cx=\"9\" cy=\"9\" r=\"6\" stroke=\"{edge_color}\"/>\
<path d=\"M21,0 L21,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
</marker>"
));
// oneOrMore markers: crow's foot + perpendicular bar
svg.push_str(&format!(
"<marker id=\"my-svg_er-oneOrMoreStart\" class=\"marker oneOrMore\" \
refX=\"18\" refY=\"18\" markerWidth=\"45\" markerHeight=\"36\" orient=\"auto\">\
<path d=\"M0,18 Q 18,0 36,18 Q 18,36 0,18 M42,9 L42,27\" stroke=\"{edge_color}\" fill=\"none\"/>\
</marker>"
));
svg.push_str(&format!(
"<marker id=\"my-svg_er-oneOrMoreEnd\" class=\"marker oneOrMore\" \
refX=\"27\" refY=\"18\" markerWidth=\"45\" markerHeight=\"36\" orient=\"auto\">\
<path d=\"M3,9 L3,27 M9,18 Q27,0 45,18 Q27,36 9,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
</marker>"
));
// zeroOrMore markers: circle + crow's foot
svg.push_str(&format!(
"<marker id=\"my-svg_er-zeroOrMoreStart\" class=\"marker zeroOrMore\" \
refX=\"18\" refY=\"18\" markerWidth=\"57\" markerHeight=\"36\" orient=\"auto\">\
<circle fill=\"white\" cx=\"48\" cy=\"18\" r=\"6\" stroke=\"{edge_color}\"/>\
<path d=\"M0,18 Q18,0 36,18 Q18,36 0,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
</marker>"
));
svg.push_str(&format!(
"<marker id=\"my-svg_er-zeroOrMoreEnd\" class=\"marker zeroOrMore\" \
refX=\"39\" refY=\"18\" markerWidth=\"57\" markerHeight=\"36\" orient=\"auto\">\
<circle fill=\"white\" cx=\"9\" cy=\"18\" r=\"6\" stroke=\"{edge_color}\"/>\
<path d=\"M21,18 Q39,0 57,18 Q39,36 21,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
</marker>"
));
}
fn render_edge_path(svg: &mut String, edge: &EdgeLayout) {
let d = points_to_path_d(&edge.points);
let dash = if edge.rel_spec.rel_type == Identification::NonIdentifying {
" stroke-dasharray=\"8,8\""
} else {
""
};
// In mermaid.js ER: arrowTypeStart = cardA, arrowTypeEnd = cardB
// card_a is the cardinality at entity_a's side, card_b is at entity_b's side.
// marker-start decorates the start of the path (entity_a), marker-end the end (entity_b).
let marker_start_name = edge.rel_spec.card_a.marker_name();
let marker_end_name = edge.rel_spec.card_b.marker_name();
svg.push_str(&format!(
"<path class=\"edge-thickness-normal relationshipLine\" \
d=\"{d}\" \
marker-start=\"url(#my-svg_er-{marker_start_name}Start)\" \
marker-end=\"url(#my-svg_er-{marker_end_name}End)\" \
style=\"fill:none;\"{dash}/>",
));
}
fn render_edge_label(svg: &mut String, edge: &EdgeLayout, theme: &MermaidTheme) {
if edge.role.is_empty() {
return;
}
let Some((x, y)) = edge.label_pos else {
return;
};
svg.push_str(&format!(
"<g transform=\"translate({x}, {y})\" class=\"edgeLabel\">"
));
svg.push_str(&format!(
"<text x=\"0\" y=\"0\" text-anchor=\"middle\" dominant-baseline=\"middle\" \
fill=\"{color}\" style=\"font-size:14px\">{label}</text>",
color = theme.text_color,
label = escape_xml(&edge.role)
));
svg.push_str("</g>");
}
fn render_entity_node(svg: &mut String, entity: &EntityLayout, theme: &MermaidTheme) {
let x_offset = -entity.width / 2.0;
let y_offset = -entity.height / 2.0;
svg.push_str(&format!(
"<g transform=\"translate({x},{y})\" id=\"entity-{id}\" class=\"node default\">",
x = entity.x,
y = entity.y,
id = escape_xml(&entity.id)
));
// Outer rectangle (entity box)
svg.push_str(&format!(
"<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" class=\"entityBox\" rx=\"0\" ry=\"0\"/>",
x = x_offset,
y = y_offset,
w = entity.width,
h = entity.height
));
// Header text (entity name)
let header_text_y = y_offset + entity.header_height / 2.0;
svg.push_str(&format!(
"<text x=\"0\" y=\"{y}\" text-anchor=\"middle\" dominant-baseline=\"middle\" \
fill=\"{color}\" class=\"er entityLabel\">{label}</text>",
y = header_text_y,
color = theme.text_color,
label = escape_xml(&entity.id)
));
if !entity.attributes.is_empty() {
// Horizontal divider between header and attributes
let divider_y = y_offset + entity.header_height;
svg.push_str(&format!(
"<line x1=\"{x1}\" y1=\"{y}\" x2=\"{x2}\" y2=\"{y}\" class=\"divider\"/>",
x1 = x_offset,
x2 = x_offset + entity.width,
y = divider_y
));
let col_divider_x = x_offset + entity.max_type_width;
let attr_start_y = y_offset + entity.header_height;
let attr_end_y = y_offset + entity.height;
// Attribute rows
let mut current_y = attr_start_y;
// Compute alternating row fill colors matching mermaid.js erBox behaviour.
// Light themes keep the upstream values: rowOdd = lighten(primary, 75)
// ≈ #ffffff, rowEven = slightly lighter than node_fill. Dark themes
// derive both stripes from the background instead (as theme-dark does),
// so attribute text keeps its contrast instead of white-on-white.
let (row_odd_fill, row_even_fill) = if is_dark_hex(&theme.background) {
(
lighten_hex(&theme.background, 0.08),
lighten_hex(&theme.background, 0.16),
)
} else {
(String::from("#ffffff"), lighten_hex(&theme.node_fill, 0.25))
};
for (i, attr) in entity.attributes.iter().enumerate() {
let row_h = entity
.row_heights
.get(i)
.copied()
.unwrap_or(LINE_HEIGHT + TEXT_PADDING);
let text_y = current_y + row_h / 2.0;
// Alternating row background rect (zebra striping)
// Mermaid uses contentRowIndex = i + 1; isEven when contentRowIndex % 2 == 0 && i > 0
let is_even = (i + 1) % 2 == 0 && i > 0;
let row_fill = if is_even {
row_even_fill.as_str()
} else {
row_odd_fill.as_str()
};
svg.push_str(&format!(
"<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" \
style=\"fill:{fill};stroke:{stroke}\" class=\"er attributeBox{parity}\"/>",
x = x_offset,
y = current_y,
w = entity.width,
h = row_h,
fill = row_fill,
stroke = theme.node_stroke,
parity = if is_even { "Even" } else { "Odd" },
));
// Horizontal line between attribute rows (faint separator)
if i > 0 {
svg.push_str(&format!(
"<line x1=\"{x1}\" y1=\"{y}\" x2=\"{x2}\" y2=\"{y}\" class=\"divider\" style=\"stroke-opacity:0.3\"/>",
x1 = x_offset,
x2 = x_offset + entity.width,
y = current_y
));
}
// Type text (left column)
let type_text_x = x_offset + COLUMN_TEXT_PADDING;
svg.push_str(&format!(
"<text x=\"{x}\" y=\"{y}\" text-anchor=\"start\" dominant-baseline=\"middle\" \
fill=\"{color}\" class=\"er entityLabel\">{text}</text>",
x = type_text_x,
y = text_y,
color = theme.text_color,
text = escape_xml(&attr.attr_type)
));
// Name text (right column)
let name_text_x = col_divider_x + COLUMN_TEXT_PADDING;
svg.push_str(&format!(
"<text x=\"{x}\" y=\"{y}\" text-anchor=\"start\" dominant-baseline=\"middle\" \
fill=\"{color}\" class=\"er entityLabel\">{text}</text>",
x = name_text_x,
y = text_y,
color = theme.text_color,
text = escape_xml(&attr.name)
));
current_y += row_h;
}
// Vertical divider between type and name columns.
// Drawn after the row background rects so it is not covered by their fill.
svg.push_str(&format!(
"<line x1=\"{x}\" y1=\"{y1}\" x2=\"{x}\" y2=\"{y2}\" class=\"divider\"/>",
x = col_divider_x,
y1 = attr_start_y,
y2 = attr_end_y
));
}
svg.push_str("</g>");
}
fn points_to_path_d(points: &[(f64, f64)]) -> String {
if points.is_empty() {
return String::new();
}
let mut d = String::new();
let (x0, y0) = points[0];
d.push_str(&format!("M{x0},{y0}"));
for (x, y) in &points[1..] {
d.push_str(&format!("L{x},{y}"));
}
d
}
/// Lighten a hex color by blending it toward white.
/// `amount` is 0.0 (no change) to 1.0 (white).
fn is_dark_hex(hex: &str) -> bool {
let hex = hex.trim_start_matches('#');
if hex.len() < 6 {
return false;
}
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(255) as f64;
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(255) as f64;
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(255) as f64;
0.2126 * r + 0.7152 * g + 0.0722 * b < 128.0
}
fn lighten_hex(hex: &str, amount: f64) -> String {
let hex = hex.trim_start_matches('#');
if hex.len() < 6 {
return format!("#{hex}");
}
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
let lr = r as f64 + (255.0 - r as f64) * amount;
let lg = g as f64 + (255.0 - g as f64) * amount;
let lb = b as f64 + (255.0 - b as f64) * amount;
format!("#{:02X}{:02X}{:02X}", lr as u8, lg as u8, lb as u8)
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+22
View File
@@ -0,0 +1,22 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum MermaidError {
#[error("Parse error at line {line}: {message}")]
ParseError { line: usize, message: String },
#[error("Invalid graph direction: {0}")]
InvalidDirection(String),
#[error("Invalid node shape: {0}")]
InvalidNodeShape(String),
#[error("DOT generation error: {0}")]
DotGenerationError(String),
#[error("SVG rendering error: {0}")]
RenderError(String),
#[error("Unsupported diagram type: {0}")]
UnsupportedDiagramType(String),
}
+437
View File
@@ -0,0 +1,437 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
use std::collections::BTreeMap;
/// Mermaid 11.12.2 gantt config defaults (from config.schema.yaml)
const BAR_HEIGHT: f64 = 20.0;
const BAR_GAP: f64 = 4.0;
const TOP_PADDING: f64 = 50.0;
const LEFT_PADDING: f64 = 75.0;
const RIGHT_PADDING: f64 = 75.0;
const GRID_LINE_START_PADDING: f64 = 35.0;
const FONT_SIZE: f64 = 11.0;
const SECTION_FONT_SIZE: f64 = 11.0;
const TITLE_TOP_MARGIN: f64 = 25.0;
const BOTTOM_AXIS_HEIGHT: f64 = 50.0;
const RX: f64 = 3.0;
const RY: f64 = 3.0;
/// Default theme colors from Mermaid 11.12.2 theme-default.js
const SECTION_BKG_COLOR: &str = "rgba(102,102,255,0.49)";
const ALT_SECTION_BKG_COLOR: &str = "white";
const TASK_BKG_COLOR: &str = "#8a90dd";
const TASK_BORDER_COLOR: &str = "#534fbc";
const TASK_TEXT_COLOR: &str = "white";
const TASK_TEXT_DARK_COLOR: &str = "black";
const GRID_COLOR: &str = "#333";
const TITLE_COLOR: &str = "#333";
const FONT_FAMILY: &str = "'trebuchet ms', verdana, arial, sans-serif";
pub fn render_gantt_diagram_to_svg(
mermaid_source: &str,
_theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let chart = parse_gantt_diagram(mermaid_source)?;
// Collect unique categories (section types) in order
let mut categories: Vec<String> = Vec::new();
for task in &chart.tasks {
let cat = task.section.clone().unwrap_or_default();
if !categories.contains(&cat) {
categories.push(cat);
}
}
// Category heights (count of tasks per category)
let mut category_heights: BTreeMap<String, usize> = BTreeMap::new();
for task in &chart.tasks {
let cat = task.section.clone().unwrap_or_default();
*category_heights.entry(cat).or_insert(0) += 1;
}
let num_tasks = chart.tasks.len();
let gap = BAR_HEIGHT + BAR_GAP;
let h = 2.0 * TOP_PADDING + num_tasks as f64 * gap;
// Compute time domain
let mut min_day = i32::MAX;
let mut max_day = i32::MIN;
for task in &chart.tasks {
min_day = min_day.min(task.start_day);
max_day = max_day.max(task.start_day + task.duration_days);
}
let w = 784.0_f64;
let plot_width = w - LEFT_PADDING - RIGHT_PADDING;
// Time scale: maps day offset to pixel x
let span_days = (max_day - min_day).max(1) as f64;
let px_per_day = plot_width / span_days;
let mut svg = String::new();
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {w} {h}\">"
));
// Embedded <style> matching Mermaid 11.12.2 gantt styles
svg.push_str("<style>");
svg.push_str(&format!(
".section {{ stroke: none; opacity: 0.2; }}\
.section0 {{ fill: {SECTION_BKG_COLOR}; }}\
.section1 {{ fill: {ALT_SECTION_BKG_COLOR}; opacity: 0.2; }}\
.grid .tick line {{ stroke: {GRID_COLOR}; opacity: 0.8; shape-rendering: crispEdges; }}\
.grid .tick text {{ font-family: {FONT_FAMILY}; fill: #000; font-size: 10px; }}\
.grid path {{ stroke-width: 0; }}\
.task {{ stroke-width: 2; }}\
.task0 {{ fill: {TASK_BKG_COLOR}; stroke: {TASK_BORDER_COLOR}; }}\
.taskText {{ text-anchor: middle; font-family: {FONT_FAMILY}; }}\
.taskText0 {{ fill: {TASK_TEXT_COLOR}; }}\
.taskTextOutsideRight {{ fill: {TASK_TEXT_DARK_COLOR}; text-anchor: start; font-family: {FONT_FAMILY}; }}\
.taskTextOutsideLeft {{ fill: {TASK_TEXT_DARK_COLOR}; text-anchor: end; }}\
.titleText {{ text-anchor: middle; font-size: 18px; font-family: {FONT_FAMILY}; fill: {TITLE_COLOR}; }}\
.sectionTitle {{ text-anchor: start; font-family: {FONT_FAMILY}; font-size: {SECTION_FONT_SIZE}px; }}\
.sectionTitle0, .sectionTitle1 {{ fill: {TITLE_COLOR}; }}"
));
svg.push_str("</style>");
// 1. Section background bands
{
let mut task_idx = 0;
for (cat_order, cat) in categories.iter().enumerate() {
let count = category_heights.get(cat).copied().unwrap_or(0);
if count == 0 {
continue;
}
let y = task_idx as f64 * gap + TOP_PADDING - 2.0;
let rect_h = count as f64 * gap;
let section_class = format!("section section{}", cat_order % 2);
svg.push_str(&format!(
"<rect x=\"0\" y=\"{y:.1}\" width=\"{w_rect:.1}\" height=\"{rect_h:.1}\" class=\"{section_class}\"/>",
w_rect = w - RIGHT_PADDING / 2.0
));
task_idx += count;
}
}
// 2. Grid lines and bottom axis
{
let axis_y = h - BOTTOM_AXIS_HEIGHT;
svg.push_str(&format!(
"<g class=\"grid\" transform=\"translate({LEFT_PADDING}, {axis_y})\">"
));
let total_days = (max_day - min_day) as usize;
for d in 0..=total_days {
let x = d as f64 * px_per_day;
// Matches D3.js: tickSize(-h + topPadding + gridLineStartPadding)
let tick_top = -h + TOP_PADDING + GRID_LINE_START_PADDING;
svg.push_str(&format!(
"<g class=\"tick\" transform=\"translate({x:.2}, 0)\">\
<line y2=\"{tick_top:.1}\"/>\
<text dy=\"1em\" text-anchor=\"middle\">{label}</text>\
</g>",
label = day_to_ymd_str(min_day + d as i32)
));
}
svg.push_str("</g>");
}
// 3. Task bars
for (i, task) in chart.tasks.iter().enumerate() {
let x = (task.start_day - min_day) as f64 * px_per_day + LEFT_PADDING;
let bar_w = task.duration_days as f64 * px_per_day;
let y = i as f64 * gap + TOP_PADDING;
let sec_num = task
.section
.as_ref()
.and_then(|s| categories.iter().position(|c| c == s))
.unwrap_or(0)
% 4;
svg.push_str(&format!(
"<rect rx=\"{RX}\" ry=\"{RY}\" x=\"{x:.2}\" y=\"{y:.2}\" width=\"{bar_w:.2}\" height=\"{BAR_HEIGHT}\" \
class=\"task task{sec_num}\"/>"
));
}
// 4. Task text (inside bars, or outside if text doesn't fit)
for (i, task) in chart.tasks.iter().enumerate() {
let start_x = (task.start_day - min_day) as f64 * px_per_day;
let end_x = start_x + task.duration_days as f64 * px_per_day;
let bar_w = end_x - start_x;
// Estimate text width (Mermaid uses getBBox, we approximate)
let text_width = task.name.len() as f64 * FONT_SIZE * 0.6;
let (tx, text_class) = if text_width > bar_w {
if end_x + text_width + 1.5 * LEFT_PADDING > w - LEFT_PADDING {
(start_x + LEFT_PADDING - 5.0, "taskTextOutsideLeft")
} else {
(end_x + LEFT_PADDING + 5.0, "taskTextOutsideRight")
}
} else {
(bar_w / 2.0 + start_x + LEFT_PADDING, "taskText taskText0")
};
let ty = i as f64 * gap + BAR_HEIGHT / 2.0 + (FONT_SIZE / 2.0 - 2.0) + TOP_PADDING;
svg.push_str(&format!(
"<text x=\"{tx:.2}\" y=\"{ty:.2}\" font-size=\"{FONT_SIZE}\" class=\"{text_class}\">{}</text>",
escape_xml(&task.name)
));
}
// 5. Section labels (vertLabels)
{
let ordered_cats: Vec<(String, usize)> = categories
.iter()
.map(|c| (c.clone(), category_heights.get(c).copied().unwrap_or(0)))
.collect();
let mut prev_total = 0_usize;
for (i, (cat_name, count)) in ordered_cats.iter().enumerate() {
if cat_name.is_empty() {
prev_total += count;
continue;
}
let y = if i > 0 {
(*count as f64 * gap) / 2.0 + prev_total as f64 * gap + TOP_PADDING
} else {
(*count as f64 * gap) / 2.0 + TOP_PADDING
};
let sec_num = i % 4;
svg.push_str(&format!(
"<text x=\"10\" y=\"{y:.2}\" font-size=\"{SECTION_FONT_SIZE}\" class=\"sectionTitle sectionTitle{sec_num}\">{}</text>",
escape_xml(cat_name)
));
prev_total += count;
}
}
// 6. Title
if let Some(title) = &chart.title {
svg.push_str(&format!(
"<text x=\"{x:.1}\" y=\"{TITLE_TOP_MARGIN}\" class=\"titleText\">{}</text>",
escape_xml(title),
x = w / 2.0
));
}
svg.push_str("</svg>");
Ok(svg)
}
#[derive(Debug, Clone)]
struct GanttChart {
title: Option<String>,
tasks: Vec<GanttTask>,
}
#[derive(Debug, Clone)]
struct GanttTask {
section: Option<String>,
name: String,
start_day: i32,
duration_days: i32,
}
fn parse_gantt_diagram(input: &str) -> Result<GanttChart, MermaidError> {
let lines: Vec<&str> = input.lines().collect();
let mut i = 0_usize;
while i < lines.len() {
let line = lines[i].trim();
if line.is_empty() || line.starts_with("%%") {
i += 1;
continue;
}
if line.split_whitespace().next() == Some("gantt") {
i += 1;
break;
}
return Err(MermaidError::ParseError {
line: i + 1,
message: "Expected 'gantt' declaration".to_string(),
});
}
let mut title: Option<String> = None;
let mut current_section: Option<String> = None;
let mut tasks: Vec<GanttTask> = Vec::new();
let mut tasks_by_id: BTreeMap<String, (i32, i32)> = BTreeMap::new();
while i < lines.len() {
let raw = lines[i];
let line = raw.trim();
let line_no = i + 1;
i += 1;
if line.is_empty() || line.starts_with("%%") {
continue;
}
if let Some(rest) = line.strip_prefix("title ") {
let t = rest.trim();
if !t.is_empty() {
title = Some(t.to_string());
}
continue;
}
if line.starts_with("dateFormat ") {
continue;
}
if let Some(rest) = line.strip_prefix("section ") {
let name = rest.trim();
current_section = if name.is_empty() {
None
} else {
Some(name.to_string())
};
continue;
}
let Some((name_raw, spec_raw)) = line.split_once(':') else {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid gantt task line: {line}"),
});
};
let name = name_raw.trim();
let spec_parts: Vec<&str> = spec_raw
.split(',')
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.collect();
if spec_parts.len() < 3 {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid gantt task spec: {spec_raw}"),
});
}
let id = spec_parts[0].to_string();
let start_spec = spec_parts[1];
let duration_spec = spec_parts[2];
let duration_days =
parse_duration_days(duration_spec).map_err(|message| MermaidError::ParseError {
line: line_no,
message,
})?;
let start_day = if let Some(after) = start_spec.strip_prefix("after ") {
let ref_id = after.trim();
let Some((ref_start, ref_dur)) = tasks_by_id.get(ref_id).copied() else {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Unknown gantt dependency id: {ref_id}"),
});
};
ref_start + ref_dur
} else {
parse_ymd_to_day(start_spec).map_err(|message| MermaidError::ParseError {
line: line_no,
message,
})?
};
let task = GanttTask {
section: current_section.clone(),
name: name.to_string(),
start_day,
duration_days,
};
tasks_by_id.insert(id, (start_day, duration_days));
tasks.push(task);
}
if tasks.is_empty() {
return Err(MermaidError::ParseError {
line: 1,
message: "Gantt diagram requires at least one task".to_string(),
});
}
Ok(GanttChart { title, tasks })
}
fn parse_duration_days(spec: &str) -> Result<i32, String> {
let spec = spec.trim();
if spec.is_empty() {
return Err("Empty duration".to_string());
}
let (num_str, unit) = spec.split_at(spec.len().saturating_sub(1));
let n: i32 = num_str
.trim()
.parse()
.map_err(|_| format!("Invalid duration: {spec}"))?;
match unit {
"d" | "D" => Ok(n),
"w" | "W" => Ok(n * 7),
_ => Err(format!("Unsupported duration unit: {spec}")),
}
}
fn parse_ymd_to_day(s: &str) -> Result<i32, String> {
let parts: Vec<&str> = s.trim().split('-').collect();
if parts.len() != 3 {
return Err(format!("Invalid date: {s}"));
}
let y: i32 = parts[0].parse().map_err(|_| format!("Invalid year: {s}"))?;
let m: i32 = parts[1]
.parse()
.map_err(|_| format!("Invalid month: {s}"))?;
let d: i32 = parts[2].parse().map_err(|_| format!("Invalid day: {s}"))?;
Ok(days_from_civil(y, m, d))
}
/// Convert a day number back to (year, month, day).
fn day_to_ymd(day_number: i32) -> (i32, i32, i32) {
let z = day_number + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
fn day_to_ymd_str(day_number: i32) -> String {
let (y, m, d) = day_to_ymd(day_number);
format!("{y:04}-{m:02}-{d:02}")
}
fn days_from_civil(y: i32, m: i32, d: i32) -> i32 {
let y = y - if m <= 2 { 1 } else { 0 };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+576
View File
@@ -0,0 +1,576 @@
use std::collections::HashMap;
use crate::error::MermaidError;
use crate::text_wrap::line_width;
use crate::theme::MermaidTheme;
// Matches Mermaid 11.12.2 gitGraphRenderer.ts constants.
const LAYOUT_OFFSET: f64 = 10.0;
const COMMIT_STEP: f64 = 40.0;
const PX: f64 = 4.0;
const PY: f64 = 2.0;
// Gitgraph-specific char width for "trebuchet ms" at 16px.
// Browser getBBox measures ~10.0 px/char for "main" and ~9.1 for "develop";
// 9.5 is a good average that closes the gap vs the global DEFAULT_CHAR_WIDTH=8.0.
const GITGRAPH_CHAR_WIDTH: f64 = 9.5;
// Branch spacing: 50 + 40 (rotateCommitLabel) = 90.
const BRANCH_Y_GAP: f64 = 90.0;
const COMMIT_RADIUS: f64 = 10.0;
const MERGE_OUTER_RADIUS: f64 = 9.0;
const MERGE_INNER_RADIUS: f64 = 6.0;
const ARROW_STROKE_WIDTH: f64 = 8.0;
const TURN_RADIUS: f64 = 20.0;
const THEME_COLOR_LIMIT: usize = 8;
// Branch label: rect width = bbox.width + 18, x = -(bbox.width + 34).
const BRANCH_LABEL_BG_PADDING: f64 = 18.0;
const BRANCH_LABEL_BG_X_TRANSLATE: f64 = -19.0;
const BRANCH_LABEL_BG_Y: f64 = -1.5;
const BRANCH_LABEL_BG_HEIGHT: f64 = 23.0;
const VIEWBOX_MARGIN: f64 = 8.0;
// Commit label constants from Mermaid 11.12.2.
const COMMIT_LABEL_FONT_SIZE: f64 = 10.0;
const COMMIT_LABEL_RECT_HEIGHT: f64 = 15.0;
const COMMIT_LABEL_RECT_Y_OFFSET: f64 = 13.5;
const COMMIT_LABEL_TEXT_Y_OFFSET: f64 = 25.0;
pub fn render_gitgraph_diagram_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let graph = parse_gitgraph(mermaid_source)?;
let mut branch_order: Vec<String> = graph.branch_order.clone();
if branch_order.is_empty() {
branch_order.push(graph.main_branch.clone());
}
// Mermaid 11.12.2 setBranchPosition: pos += 50 + (rotateCommitLabel ? 40 : 0).
let mut y_for_branch: HashMap<&str, f64> = HashMap::new();
for (idx, b) in branch_order.iter().enumerate() {
y_for_branch.insert(b.as_str(), idx as f64 * BRANCH_Y_GAP);
}
// Mermaid 11.12.2 drawCommits: pos starts at 0, increments by COMMIT_STEP + LAYOUT_OFFSET.
// posWithOffset = pos + LAYOUT_OFFSET. So commit x values: 10, 60, 110, 160, ...
// After last commit, pos increments once more, giving maxPos.
let num_commits = graph.commits.len();
let max_pos = if num_commits == 0 {
0.0
} else {
num_commits as f64 * (COMMIT_STEP + LAYOUT_OFFSET)
};
// Compute branch label bbox widths (approximation of browser getBBox).
let bbox_height = 19.0; // Typical text bbox height at 16px.
let branch_bbox_widths: Vec<f64> = branch_order
.iter()
.map(|b| line_width(b, GITGRAPH_CHAR_WIDTH))
.collect();
// Compute viewBox bounds from branch labels.
let mut min_x: f64 = 0.0;
let mut min_y: f64 = 0.0;
for (idx, branch) in branch_order.iter().enumerate() {
let y = y_for_branch.get(branch.as_str()).copied().unwrap_or(0.0);
let text_w = branch_bbox_widths[idx];
// Mermaid 11.12.2 drawBranches: bkg rect x = -(bbox.width + 4 + 30),
// transform = translate(-19, pos - bbox.height/2).
let bg_x = -(text_w + PX + 30.0);
let bg_translate_y = y - bbox_height / 2.0;
let label_left = BRANCH_LABEL_BG_X_TRANSLATE + bg_x;
let label_top = bg_translate_y + BRANCH_LABEL_BG_Y;
min_x = min_x.min(label_left);
min_y = min_y.min(label_top);
}
let max_x = max_pos;
let y_end = (branch_order.len().saturating_sub(1) as f64) * BRANCH_Y_GAP;
let mut max_y = y_end + COMMIT_RADIUS;
// Account for commit labels below commits.
for commit in &graph.commits {
if commit.kind != CommitKind::Normal {
continue;
}
let Some(y) = y_for_branch.get(commit.branch.as_str()).copied() else {
continue;
};
let label_text = commit_label_text(commit.seq);
let text_w = line_width(&label_text, GITGRAPH_CHAR_WIDTH) * (COMMIT_LABEL_FONT_SIZE / 16.0);
let r_y = 10.0 + text_w / 25.0 * 8.5;
let label_bottom = y + r_y + COMMIT_LABEL_RECT_Y_OFFSET + COMMIT_LABEL_RECT_HEIGHT;
max_y = max_y.max(label_bottom);
}
let vb_x = min_x - VIEWBOX_MARGIN;
let vb_y = min_y - VIEWBOX_MARGIN;
let vb_w = (max_x - min_x) + VIEWBOX_MARGIN * 2.0;
let vb_h = (max_y - min_y) + VIEWBOX_MARGIN * 2.0;
let mut svg = String::new();
svg.push_str(&format!(
"<svg id=\"my-svg\" width=\"100%\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" style=\"max-width: {vb_w}px; background-color: {};\" viewBox=\"{vb_x} {vb_y} {vb_w} {vb_h}\" role=\"graphics-document document\" aria-roledescription=\"gitGraph\">",
theme.background
));
// Emit style block matching Mermaid 11.12.2 CSS.
emit_style_block(&mut svg, theme);
svg.push_str("<g/>");
svg.push_str("<g class=\"commit-bullets\"/>");
svg.push_str("<g class=\"commit-labels\"/>");
// --- Branches + labels ---
svg.push_str("<g>");
for (idx, branch) in branch_order.iter().enumerate() {
let y = y_for_branch.get(branch.as_str()).copied().unwrap_or(0.0);
svg.push_str(&format!(
"<line x1=\"0\" y1=\"{y}\" x2=\"{max_pos}\" y2=\"{y}\" class=\"branch branch{idx}\"/>",
));
let text_w = branch_bbox_widths[idx];
// Mermaid 11.12.2: rect x = -(bbox.width + 4 + 30), width = bbox.width + 18,
// y = -bbox.height/2 + 8, height = bbox.height + 4,
// transform = translate(-19, pos - bbox.height/2).
let bg_w = text_w + BRANCH_LABEL_BG_PADDING;
let bg_x = -(text_w + PX + 30.0);
let bg_translate_y = y - bbox_height / 2.0;
svg.push_str(&format!(
"<rect class=\"branchLabelBkg label{idx}\" rx=\"4\" ry=\"4\" x=\"{bg_x}\" y=\"{BRANCH_LABEL_BG_Y}\" width=\"{bg_w}\" height=\"{BRANCH_LABEL_BG_HEIGHT}\" transform=\"translate({BRANCH_LABEL_BG_X_TRANSLATE}, {bg_translate_y})\"/>",
));
// Mermaid 11.12.2: label translate(-(bbox.width + 14 + 30), pos - bbox.height/2 - 1).
let label_x = -(text_w + 14.0 + 30.0);
let label_y = y - bbox_height / 2.0 - 1.0;
svg.push_str("<g class=\"branchLabel\">");
svg.push_str(&format!(
"<g class=\"label branch-label{idx}\" transform=\"translate({label_x}, {label_y})\"><text><tspan xml:space=\"preserve\" dy=\"1em\" x=\"0\" class=\"row\">{}</tspan></text></g>",
escape_xml(branch)
));
svg.push_str("</g>");
}
svg.push_str("</g>");
// --- Arrows ---
svg.push_str("<g class=\"commit-arrows\">");
for commit in &graph.commits {
let x = commit_x(commit.seq);
let y = y_for_branch
.get(commit.branch.as_str())
.copied()
.unwrap_or(0.0);
let commit_branch_idx = branch_order
.iter()
.position(|b| b == &commit.branch)
.unwrap_or(0);
for parent in &commit.parents {
let Some(parent_commit) = graph
.commit_by_id
.get(parent)
.and_then(|idx| graph.commits.get(*idx))
else {
continue;
};
let px = commit_x(parent_commit.seq);
let py = y_for_branch
.get(parent_commit.branch.as_str())
.copied()
.unwrap_or(0.0);
let arrow_idx = if commit.kind == CommitKind::Merge {
branch_order
.iter()
.position(|b| b == &parent_commit.branch)
.unwrap_or(0)
} else {
commit_branch_idx
};
let class = format!("arrow arrow{arrow_idx}");
if (py - y).abs() < 0.1 {
svg.push_str(&format!(
"<path d=\"M {px} {py} L {x} {y}\" class=\"{class}\"/>",
));
} else if y > py {
// Branch down: from parent → vertical → arc → horizontal to commit.
let bend_y = y - TURN_RADIUS;
let arc_end_x = px + TURN_RADIUS;
svg.push_str(&format!(
"<path d=\"M {px} {py} L {px} {bend_y} A {TURN_RADIUS} {TURN_RADIUS}, 0, 0, 0, {arc_end_x} {y} L {x} {y}\" class=\"{class}\"/>",
));
} else {
// Merge up: from parent → horizontal → arc → vertical to commit.
let bend_x = x - TURN_RADIUS;
let arc_end_y = py - TURN_RADIUS;
svg.push_str(&format!(
"<path d=\"M {px} {py} L {bend_x} {py} A {TURN_RADIUS} {TURN_RADIUS}, 0, 0, 0, {x} {arc_end_y} L {x} {y}\" class=\"{class}\"/>",
));
}
}
}
svg.push_str("</g>");
// --- Commit bullets ---
svg.push_str("<g class=\"commit-bullets\">");
for commit in &graph.commits {
let x = commit_x(commit.seq);
let y = y_for_branch
.get(commit.branch.as_str())
.copied()
.unwrap_or(0.0);
let branch_idx = branch_order
.iter()
.position(|b| b == &commit.branch)
.unwrap_or(0);
let id_class = commit_id_class(commit.seq);
match commit.kind {
CommitKind::Merge => {
svg.push_str(&format!(
"<circle cx=\"{x}\" cy=\"{y}\" r=\"{MERGE_OUTER_RADIUS}\" class=\"commit {id_class} commit{branch_idx}\"/>",
));
svg.push_str(&format!(
"<circle cx=\"{x}\" cy=\"{y}\" r=\"{MERGE_INNER_RADIUS}\" class=\"commit commit-merge {id_class} commit{branch_idx}\"/>",
));
}
CommitKind::Normal => {
svg.push_str(&format!(
"<circle cx=\"{x}\" cy=\"{y}\" r=\"{COMMIT_RADIUS}\" class=\"commit {id_class} commit{branch_idx}\"/>",
));
}
}
}
svg.push_str("</g>");
// --- Commit labels ---
svg.push_str("<g class=\"commit-labels\">");
for commit in &graph.commits {
if commit.kind != CommitKind::Normal {
continue;
}
let x = commit_x(commit.seq);
let pos = commit.seq as f64 * (COMMIT_STEP + LAYOUT_OFFSET);
let Some(y) = y_for_branch.get(commit.branch.as_str()).copied() else {
continue;
};
let label = commit_label_text(commit.seq);
// Approximate bbox of commit label text at font-size 10px.
let text_w = line_width(&label, GITGRAPH_CHAR_WIDTH) * (COMMIT_LABEL_FONT_SIZE / 16.0);
let pos_with_offset = x;
// Mermaid 11.12.2: rect x = posWithOffset - bbox.width/2 - PY,
// rect width = bbox.width + 2*PY, rect height = bbox.height + 2*PY.
let rect_w = text_w + 2.0 * PY;
let rect_x = pos_with_offset - text_w / 2.0 - PY;
let rect_y = y + COMMIT_LABEL_RECT_Y_OFFSET;
let text_x = pos_with_offset - text_w / 2.0;
let text_y = y + COMMIT_LABEL_TEXT_Y_OFFSET;
// Mermaid 11.12.2: r_x = -7.5 - (bbox.width + 10) / 25 * 9.5,
// r_y = 10 + bbox.width / 25 * 8.5,
// wrapper transform = translate(r_x, r_y) rotate(-45, pos, y).
let r_x = -7.5 - (text_w + 10.0) / 25.0 * 9.5;
let r_y = 10.0 + text_w / 25.0 * 8.5;
svg.push_str(&format!(
"<g transform=\"translate({r_x}, {r_y}) rotate(-45, {pos}, {y})\">",
));
svg.push_str(&format!(
"<rect class=\"commit-label-bkg\" x=\"{rect_x}\" y=\"{rect_y}\" width=\"{rect_w}\" height=\"{COMMIT_LABEL_RECT_HEIGHT}\"/>",
));
svg.push_str(&format!(
"<text x=\"{text_x}\" y=\"{text_y}\" class=\"commit-label\">{}</text>",
escape_xml(&label)
));
svg.push_str("</g>");
}
svg.push_str("</g>");
svg.push_str("</svg>");
Ok(svg)
}
/// Compute the x position of a commit (posWithOffset in Mermaid 11.12.2).
fn commit_x(seq: usize) -> f64 {
seq as f64 * (COMMIT_STEP + LAYOUT_OFFSET) + LAYOUT_OFFSET
}
/// Generate the commit label text (deterministic hash from seq).
fn commit_label_text(seq: usize) -> String {
let hash = ((seq as u32).wrapping_mul(0x9E37_79B9) ^ 0x079A_D076) & 0x0FFF_FFFF;
format!("{seq}-{hash:07x}")
}
/// Generate the commit CSS id class.
fn commit_id_class(seq: usize) -> String {
commit_label_text(seq)
}
/// Emit the CSS style block matching Mermaid 11.12.2.
fn emit_style_block(svg: &mut String, theme: &MermaidTheme) {
svg.push_str(&format!(
"<style>#my-svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:{};}}",
theme.text_color
));
// Mermaid 11.12.2 always emits commit-id, commit-msg, branch-label base styles.
svg.push_str(
"#my-svg .commit-id,#my-svg .commit-msg,#my-svg .branch-label{fill:lightgrey;color:lightgrey;font-family:'trebuchet ms',verdana,arial,sans-serif;font-family:var(--mermaid-font-family);}"
);
// Always emit all 8 branch color sets (THEME_COLOR_LIMIT) to match the reference.
for i in 0..THEME_COLOR_LIMIT {
let color = branch_color(i);
let label_color = branch_label_color(i);
svg.push_str(&format!("#my-svg .branch-label{i}{{fill:{label_color};}}"));
svg.push_str(&format!(
"#my-svg .commit{i}{{stroke:{color};fill:{color};}}"
));
svg.push_str(&format!(
"#my-svg .commit-highlight{i}{{stroke:{color};fill:{color};}}"
));
svg.push_str(&format!("#my-svg .label{i}{{fill:{color};}}"));
svg.push_str(&format!("#my-svg .arrow{i}{{stroke:{color};}}"));
}
svg.push_str(&format!(
"#my-svg .branch{{stroke-width:1;stroke:{};stroke-dasharray:2;}}",
theme.edge_color
));
svg.push_str("#my-svg .commit-label{font-size:10px;fill:#000021;}");
svg.push_str("#my-svg .commit-label-bkg{font-size:10px;fill:#ffffde;opacity:0.5;}");
svg.push_str(&format!(
"#my-svg .tag-label{{font-size:10px;fill:{tag_label_color};}}",
tag_label_color = "#131300"
));
svg.push_str(&format!(
"#my-svg .tag-label-bkg{{fill:{};stroke:hsl(240, 60%, 86.2745098039%);}}",
theme.node_fill
));
svg.push_str(&format!("#my-svg .tag-hole{{fill:{};}}", theme.text_color));
svg.push_str(&format!(
"#my-svg .commit-merge{{stroke:{};fill:{};}}",
theme.node_fill, theme.node_fill
));
svg.push_str(&format!(
"#my-svg .commit-reverse{{stroke:{};fill:{};stroke-width:3;}}",
theme.node_fill, theme.node_fill
));
svg.push_str(&format!(
"#my-svg .commit-highlight-inner{{stroke:{};fill:{};}}",
theme.node_fill, theme.node_fill
));
svg.push_str(&format!(
"#my-svg .arrow{{stroke-width:{ARROW_STROKE_WIDTH};stroke-linecap:round;fill:none;}}"
));
svg.push_str(&format!(
"#my-svg .gitTitleText{{text-anchor:middle;font-size:18px;fill:{};}}",
theme.text_color
));
svg.push_str("</style>");
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CommitKind {
Normal,
Merge,
}
#[derive(Debug, Clone)]
struct Commit {
seq: usize,
branch: String,
parents: Vec<String>,
kind: CommitKind,
}
#[derive(Debug, Clone)]
struct GitGraph {
main_branch: String,
branch_order: Vec<String>,
commits: Vec<Commit>,
commit_by_id: HashMap<String, usize>,
}
fn parse_gitgraph(input: &str) -> Result<GitGraph, MermaidError> {
let mut found_header = false;
let main_branch = "main".to_string();
let mut branch_order: Vec<String> = vec![main_branch.clone()];
let mut branches: HashMap<String, Option<String>> = HashMap::new();
branches.insert(main_branch.clone(), None);
let mut current_branch = main_branch.clone();
let mut head: Option<String> = None;
let mut commits: Vec<Commit> = Vec::new();
let mut commit_by_id: HashMap<String, usize> = HashMap::new();
for (idx, raw) in input.lines().enumerate() {
let line_no = idx + 1;
let line = raw.trim();
if line.is_empty() || line.starts_with("%%") {
continue;
}
if !found_header {
if line.split_whitespace().next() != Some("gitGraph") {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected 'gitGraph' declaration".to_string(),
});
}
found_header = true;
continue;
}
let mut parts = line.split_whitespace();
let Some(cmd) = parts.next() else {
continue;
};
match cmd {
"commit" => {
let seq = commits.len();
let id = format!("{seq}");
let parents = head.clone().into_iter().collect();
let commit = Commit {
seq,
branch: current_branch.clone(),
parents,
kind: CommitKind::Normal,
};
commit_by_id.insert(id.clone(), seq);
commits.push(commit);
head = Some(id.clone());
branches.insert(current_branch.clone(), Some(id));
}
"branch" => {
let Some(name) = parts.next() else {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected branch name".to_string(),
});
};
let name = name.to_string();
if !branches.contains_key(&name) {
branches.insert(name.clone(), head.clone());
branch_order.push(name.clone());
}
}
"checkout" => {
let Some(name) = parts.next() else {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected branch name".to_string(),
});
};
let name = name.to_string();
current_branch = name.clone();
head = branches.get(&name).cloned().flatten();
if !branches.contains_key(&name) {
branches.insert(name.clone(), head.clone());
branch_order.push(name);
}
}
"merge" => {
let Some(other) = parts.next() else {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected branch name".to_string(),
});
};
let other = other.to_string();
let other_head = branches.get(&other).cloned().flatten();
let mut parents: Vec<String> = Vec::new();
if let Some(h) = head.clone() {
parents.push(h);
}
if let Some(oh) = other_head {
parents.push(oh);
}
let seq = commits.len();
let id = format!("{seq}");
let commit = Commit {
seq,
branch: current_branch.clone(),
parents,
kind: CommitKind::Merge,
};
commit_by_id.insert(id.clone(), seq);
commits.push(commit);
head = Some(id.clone());
branches.insert(current_branch.clone(), Some(id));
}
_ => {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Unrecognized gitGraph command: {cmd}"),
});
}
}
}
if !found_header {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'gitGraph' declaration".to_string(),
});
}
Ok(GitGraph {
main_branch,
branch_order,
commits,
commit_by_id,
})
}
fn branch_color(order: usize) -> &'static str {
match order {
0 => "hsl(240, 100%, 46.2745098039%)",
1 => "hsl(60, 100%, 43.5294117647%)",
2 => "hsl(80, 100%, 46.2745098039%)",
3 => "hsl(210, 100%, 46.2745098039%)",
4 => "hsl(180, 100%, 46.2745098039%)",
5 => "hsl(150, 100%, 46.2745098039%)",
6 => "hsl(300, 100%, 46.2745098039%)",
7 => "hsl(0, 100%, 46.2745098039%)",
_ => "hsl(180, 100%, 46.2745098039%)",
}
}
fn branch_label_color(order: usize) -> &'static str {
// Mermaid 11.12.2 default theme: branch-label0 = #ffffff, rest = black.
match order {
0 => "#ffffff",
3 => "#ffffff",
_ => "black",
}
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+56
View File
@@ -0,0 +1,56 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
const INFO_WIDTH: f64 = 400.0;
const INFO_HEIGHT: f64 = 150.0;
const PINNED_MERMAID_VERSION: &str = "11.12.2";
pub fn render_info_diagram_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
if first_diagram_type_token(mermaid_source) != Some("info") {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'info' declaration".to_string(),
});
}
let background_color = if theme.background == "#ffffff" {
"white"
} else {
theme.background.as_str()
};
let text_color = if theme.text_color == "#333333" {
"#333"
} else {
theme.text_color.as_str()
};
let mut svg = String::new();
svg.push_str(&format!(
"<svg aria-roledescription=\"info\" role=\"graphics-document document\" viewBox=\"0 0 {INFO_WIDTH} {INFO_HEIGHT}\" style=\"max-width: {INFO_WIDTH}px; background-color: {background_color};\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" id=\"my-svg\">"
));
svg.push_str(&format!(
"<style>#my-svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:{text_color};}}@keyframes edge-animation-frame{{from{{stroke-dashoffset:0;}}}}@keyframes dash{{to{{stroke-dashoffset:0;}}}}#my-svg .edge-animation-slow{{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}}#my-svg .edge-animation-fast{{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}}#my-svg .error-icon{{fill:#552222;}}#my-svg .error-text{{fill:#552222;stroke:#552222;}}#my-svg .edge-thickness-normal{{stroke-width:1px;}}#my-svg .edge-thickness-thick{{stroke-width:3.5px;}}#my-svg .edge-pattern-solid{{stroke-dasharray:0;}}#my-svg .edge-thickness-invisible{{stroke-width:0;fill:none;}}#my-svg .edge-pattern-dashed{{stroke-dasharray:3;}}#my-svg .edge-pattern-dotted{{stroke-dasharray:2;}}#my-svg .marker{{fill:{edge};stroke:{edge};}}#my-svg .marker.cross{{stroke:{edge};}}#my-svg svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;}}#my-svg p{{margin:0;}}#my-svg :root{{--mermaid-font-family:\"trebuchet ms\",verdana,arial,sans-serif;}}</style>",
edge = theme.edge_color
));
svg.push_str("<g/>");
svg.push_str(&format!(
"<g><text style=\"text-anchor: middle;\" font-size=\"32\" class=\"version\" y=\"40\" x=\"100\">v{PINNED_MERMAID_VERSION}</text></g>"
));
svg.push_str("</svg>");
Ok(svg)
}
fn first_diagram_type_token(input: &str) -> Option<&str> {
input
.lines()
.map(|l| l.trim())
.find(|l| !l.is_empty() && !l.starts_with("%%"))
.and_then(|l| l.split_whitespace().next())
}
+563
View File
@@ -0,0 +1,563 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
// --- Mermaid 11.12.2 journey config defaults ---
const DIAGRAM_MARGIN_X: f64 = 50.0;
const DIAGRAM_MARGIN_Y: f64 = 10.0;
const LEFT_MARGIN: f64 = 150.0;
const TASK_WIDTH: f64 = 150.0;
const TASK_HEIGHT: f64 = 50.0;
const TASK_MARGIN: f64 = 50.0;
const SECTION_Y: f64 = 50.0;
const FACE_RADIUS: f64 = 15.0;
const ACTOR_CIRCLE_R: f64 = 7.0;
const MAX_FACE_Y: f64 = 300.0;
const FACE_Y_PER_SCORE: f64 = 30.0;
const TASK_LINE_BOTTOM: f64 = MAX_FACE_Y + 5.0 * FACE_Y_PER_SCORE; // 450
const ARROW_Y_MULTIPLIER: f64 = 4.0; // conf.height * 4 = 200
const FONT_FAMILY: &str = "'trebuchet ms', verdana, arial, sans-serif";
const TASK_FONT_SIZE: f64 = 14.0;
const TASK_FONT_FAMILY: &str = "'Open Sans', sans-serif";
const TITLE_FONT_SIZE: &str = "4ex";
// CSS section/task fill colors from Mermaid default theme
const SECTION_FILLS: &[&str] = &[
"#ECECFF",
"#ffffde",
"hsl(304, 100%, 96.2745098039%)",
"hsl(124, 100%, 93.5294117647%)",
"hsl(176, 100%, 96.2745098039%)",
"hsl(-4, 100%, 93.5294117647%)",
"hsl(8, 100%, 96.2745098039%)",
"hsl(188, 100%, 93.5294117647%)",
];
// SVG fill attributes for section rects (from sectionFills config)
const SECTION_SVG_FILLS: &[&str] = &[
"#191970", "#8B008B", "#4B0082", "#2F4F4F", "#800000", "#8B4513", "#00008B",
];
const ACTOR_COLOURS: &[&str] = &[
"#8FBC8F", "#7CFC00", "#00FFFF", "#20B2AA", "#B0E0E6", "#FFFFE0",
];
pub fn render_journey_diagram_to_svg(
mermaid_source: &str,
_theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let journey = parse_journey_diagram(mermaid_source)?;
// Collect unique actors in order of first appearance
let mut actors: Vec<String> = Vec::new();
for row in &journey.rows {
if let JourneyRow::Task(task) = row {
for actor in &task.actors {
if !actors.contains(actor) {
actors.push(actor.clone());
}
}
}
}
// In mermaid.js: leftMargin = conf.leftMargin + maxWidth
// maxWidth is from actor legend text measurement; for simple cases it's 0
let left_margin = LEFT_MARGIN;
// Flatten tasks with their section assignments
let mut flat_tasks: Vec<FlatTask> = Vec::new();
let mut current_section: Option<String> = None;
for row in &journey.rows {
match row {
JourneyRow::Section(name) => {
current_section = Some(name.clone());
}
JourneyRow::Task(task) => {
flat_tasks.push(FlatTask {
name: task.name.clone(),
score: task.score,
actors: task.actors.clone(),
section: current_section.clone().unwrap_or_default(),
});
}
}
}
let num_tasks = flat_tasks.len();
if num_tasks == 0 {
return Err(MermaidError::ParseError {
line: 1,
message: "Journey requires at least one task".to_string(),
});
}
// Compute section info
let mut sections: Vec<SectionInfo> = Vec::new();
{
let mut last_section = String::new();
let mut section_idx: usize = 0;
for (i, task) in flat_tasks.iter().enumerate() {
if task.section != last_section {
// Count tasks in this section
let count = flat_tasks[i..]
.iter()
.take_while(|t| t.section == task.section)
.count();
let section_num = section_idx % SECTION_SVG_FILLS.len();
sections.push(SectionInfo {
name: task.section.clone(),
first_task_idx: i,
task_count: count,
section_num,
});
last_section = task.section.clone();
section_idx += 1;
}
}
}
// Compute task positions: task.x = i * taskMargin + i * width + leftMargin
let task_positions: Vec<f64> = (0..num_tasks)
.map(|i| i as f64 * TASK_MARGIN + i as f64 * TASK_WIDTH + left_margin)
.collect();
// Section vertical height for task y position
let section_v_height = TASK_HEIGHT * 2.0 + DIAGRAM_MARGIN_Y; // 110
let task_y = section_v_height; // 110 (0 + sectionVHeight)
// Arrow y: conf.height * 4 = 50 * 4 = 200
let arrow_y = TASK_HEIGHT * ARROW_Y_MULTIPLIER;
// Compute overall dimensions using mermaid.js bounds logic:
// bounds.insert(task.x, task.y, task.x + task.width + taskMargin, 300+5*30)
// where task.width = diagramMarginX (50), NOT the visual TASK_WIDTH (150)
let last_task_x = task_positions.last().copied().unwrap_or(left_margin);
let bounds_stopx = last_task_x + DIAGRAM_MARGIN_X + TASK_MARGIN; // 50 + 50 = 100
let width = left_margin + bounds_stopx + 2.0 * DIAGRAM_MARGIN_X;
// height = stopy - starty + 2 * diagramMarginY; starty=0, stopy=450
let height = TASK_LINE_BOTTOM + 2.0 * DIAGRAM_MARGIN_Y;
let has_title = journey.title.is_some();
let extra_vert_for_title = if has_title { 70.0 } else { 0.0 };
let viewbox_height = height + extra_vert_for_title;
let svg_height = height + extra_vert_for_title + 25.0;
// Arrow endpoint: width - leftMargin - 4
let arrow_x2 = width - left_margin - 4.0;
let mut svg = String::new();
// SVG header
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" \
xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
style=\"max-width: {width:.0}px;\" \
width=\"100%\" \
viewBox=\"0 -25 {width:.0} {vh:.0}\" \
preserveAspectRatio=\"xMinYMin meet\" \
height=\"{sh:.0}\" \
role=\"graphics-document document\" \
aria-roledescription=\"journey\">",
vh = viewbox_height,
sh = svg_height,
));
// CSS styles matching mermaid.js default theme
svg.push_str("<style>");
svg.push_str(&format!(
"svg {{font-family:{FONT_FAMILY};font-size:16px;fill:#333;}}"
));
svg.push_str(".mouth{stroke:#666;}");
svg.push_str("line{stroke:#333;}");
svg.push_str(&format!(".legend{{fill:#333;font-family:{FONT_FAMILY};}}"));
svg.push_str(".label text{fill:#333;}");
svg.push_str(".face{fill:#FFF8DC;stroke:#999;}");
// Section/task type fills
for (i, fill) in SECTION_FILLS.iter().enumerate() {
svg.push_str(&format!(".task-type-{i},.section-type-{i}{{fill:{fill};}}"));
}
// Actor colors
for (i, color) in ACTOR_COLOURS.iter().enumerate() {
svg.push_str(&format!(".actor-{i}{{fill:{color};}}"));
}
svg.push_str("</style>");
// Arrowhead marker definition
svg.push_str("<defs><marker id=\"arrowhead\" refX=\"5\" refY=\"2\" markerWidth=\"6\" markerHeight=\"4\" orient=\"auto\"><path d=\"M 0,0 V 4 L6,2 Z\"/></marker></defs>");
// Actor legend
let mut actor_y = 60.0_f64;
for (pos, actor) in actors.iter().enumerate() {
let color = ACTOR_COLOURS[pos % ACTOR_COLOURS.len()];
svg.push_str(&format!(
"<circle cx=\"20\" cy=\"{actor_y:.0}\" class=\"actor-{pos}\" fill=\"{color}\" stroke=\"#000\" r=\"{ACTOR_CIRCLE_R}\"/>"
));
svg.push_str(&format!(
"<text x=\"40\" y=\"{ty:.0}\" class=\"legend\"><tspan x=\"50\">{}</tspan></text>",
escape_xml(actor),
ty = actor_y + 7.0,
));
actor_y += 20.0;
}
// Draw sections
for section in &sections {
let section_x = task_positions[section.first_task_idx];
let section_width = TASK_WIDTH * section.task_count as f64
+ DIAGRAM_MARGIN_X * (section.task_count as f64 - 1.0);
let fill = SECTION_SVG_FILLS[section.section_num % SECTION_SVG_FILLS.len()];
let num = section.section_num;
svg.push_str("<g>");
svg.push_str(&format!(
"<rect x=\"{section_x:.0}\" y=\"{SECTION_Y:.0}\" fill=\"{fill}\" stroke=\"#666\" \
width=\"{section_width:.0}\" height=\"{TASK_HEIGHT:.0}\" rx=\"3\" ry=\"3\" \
class=\"journey-section section-type-{num}\"/>"
));
// Section label using foreignObject with tspan fallback
let center_x = section_x + section_width / 2.0;
let center_y = SECTION_Y + TASK_HEIGHT / 2.0;
svg.push_str(&format!(
"<switch>\
<foreignObject x=\"{section_x:.0}\" y=\"{SECTION_Y:.0}\" width=\"{section_width:.0}\" height=\"{TASK_HEIGHT:.0}\" \
requiredExtensions=\"http://www.w3.org/1999/xhtml\">\
<div class=\"journey-section section-type-{num}\" xmlns=\"http://www.w3.org/1999/xhtml\" \
style=\"display: table; height: 100%; width: 100%;\">\
<div class=\"label\" style=\"display: table-cell; text-align: center; vertical-align: middle;\">\
{}</div></div></foreignObject>\
<text x=\"{center_x:.0}\" y=\"{center_y:.0}\" dominant-baseline=\"central\" \
alignment-baseline=\"central\" class=\"journey-section\" \
style=\"text-anchor: middle; font-size: {TASK_FONT_SIZE}px; font-family: {TASK_FONT_FAMILY};\">\
<tspan x=\"{center_x:.0}\" dy=\"0\">{}</tspan></text></switch>",
escape_xml(&section.name),
escape_xml(&section.name),
));
svg.push_str("</g>");
}
// Draw tasks
let mut current_section_num = 0_usize;
let mut current_section_name = String::new();
let mut section_idx = 0_usize;
for (i, task) in flat_tasks.iter().enumerate() {
let task_counter = i;
// Track section
if task.section != current_section_name {
if section_idx < sections.len() {
current_section_num = sections[section_idx].section_num;
section_idx += 1;
}
current_section_name = task.section.clone();
}
let tx = task_positions[i];
let center = tx + TASK_WIDTH / 2.0;
let fill = SECTION_SVG_FILLS[current_section_num % SECTION_SVG_FILLS.len()];
let num = current_section_num;
svg.push_str("<g>");
// Dashed task line
svg.push_str(&format!(
"<line id=\"task{task_counter}\" x1=\"{center:.0}\" y1=\"{task_y:.0}\" \
x2=\"{center:.0}\" y2=\"{TASK_LINE_BOTTOM:.0}\" class=\"task-line\" \
stroke-width=\"1px\" stroke-dasharray=\"4 2\" stroke=\"#666\"/>"
));
// Face icon
let face_cy = MAX_FACE_Y + (5.0 - task.score as f64) * FACE_Y_PER_SCORE;
draw_face(&mut svg, center, face_cy, task.score);
// Task rectangle
svg.push_str(&format!(
"<rect x=\"{tx:.0}\" y=\"{task_y:.0}\" fill=\"{fill}\" stroke=\"#666\" \
width=\"{TASK_WIDTH:.0}\" height=\"{TASK_HEIGHT:.0}\" rx=\"3\" ry=\"3\" \
class=\"task task-type-{num}\"/>"
));
// Actor circles on the task
let mut x_pos = tx + 14.0;
for actor_name in &task.actors {
if let Some(pos) = actors.iter().position(|a| a == actor_name) {
let color = ACTOR_COLOURS[pos % ACTOR_COLOURS.len()];
svg.push_str(&format!(
"<circle cx=\"{x_pos:.0}\" cy=\"{task_y:.0}\" class=\"actor-{pos}\" \
fill=\"{color}\" stroke=\"#000\" r=\"{ACTOR_CIRCLE_R}\"><title>{}</title></circle>",
escape_xml(actor_name)
));
x_pos += 10.0;
}
}
// Task label using foreignObject with tspan fallback
let task_center_x = tx + TASK_WIDTH / 2.0;
let task_center_y = task_y + TASK_HEIGHT / 2.0;
svg.push_str(&format!(
"<switch>\
<foreignObject x=\"{tx:.0}\" y=\"{task_y:.0}\" width=\"{TASK_WIDTH:.0}\" height=\"{TASK_HEIGHT:.0}\" \
requiredExtensions=\"http://www.w3.org/1999/xhtml\">\
<div class=\"task\" xmlns=\"http://www.w3.org/1999/xhtml\" \
style=\"display: table; height: 100%; width: 100%;\">\
<div class=\"label\" style=\"display: table-cell; text-align: center; vertical-align: middle;\">\
{}</div></div></foreignObject>\
<text x=\"{task_center_x:.0}\" y=\"{task_center_y:.0}\" dominant-baseline=\"central\" \
alignment-baseline=\"central\" class=\"task\" \
style=\"text-anchor: middle; font-size: {TASK_FONT_SIZE}px; font-family: {TASK_FONT_FAMILY};\">\
<tspan x=\"{task_center_x:.0}\" dy=\"0\">{}</tspan></text></switch>",
escape_xml(&task.name),
escape_xml(&task.name),
));
svg.push_str("</g>");
}
// Title
if let Some(title) = &journey.title {
svg.push_str(&format!(
"<text x=\"{left_margin:.0}\" font-size=\"{TITLE_FONT_SIZE}\" \
font-weight=\"bold\" y=\"25\" fill=\"#333\" \
font-family=\"{FONT_FAMILY}\">{}</text>",
escape_xml(title),
));
}
// Horizontal arrow
svg.push_str(&format!(
"<line x1=\"{left_margin:.0}\" y1=\"{arrow_y:.0}\" x2=\"{arrow_x2:.0}\" y2=\"{arrow_y:.0}\" \
stroke-width=\"4\" stroke=\"black\" marker-end=\"url(#arrowhead)\"/>"
));
svg.push_str("</svg>");
Ok(svg)
}
fn draw_face(svg: &mut String, cx: f64, cy: f64, score: i32) {
// Face circle
svg.push_str(&format!(
"<circle cx=\"{cx:.0}\" cy=\"{cy:.0}\" class=\"face\" r=\"{FACE_RADIUS}\" \
stroke-width=\"2\" overflow=\"visible\"/>"
));
svg.push_str("<g>");
// Eyes
let eye_y = cy - FACE_RADIUS / 3.0;
let left_eye_x = cx - FACE_RADIUS / 3.0;
let right_eye_x = cx + FACE_RADIUS / 3.0;
svg.push_str(&format!(
"<circle cx=\"{left_eye_x:.0}\" cy=\"{eye_y:.0}\" r=\"1.5\" stroke-width=\"2\" fill=\"#666\" stroke=\"#666\"/>"
));
svg.push_str(&format!(
"<circle cx=\"{right_eye_x:.0}\" cy=\"{eye_y:.0}\" r=\"1.5\" stroke-width=\"2\" fill=\"#666\" stroke=\"#666\"/>"
));
// Mouth based on score
if score > 3 {
// Happy: smile arc
let inner_r = FACE_RADIUS / 2.0;
let outer_r = FACE_RADIUS / 2.2;
let arc_path = generate_smile_arc(inner_r, outer_r);
svg.push_str(&format!(
"<path class=\"mouth\" d=\"{arc_path}\" transform=\"translate({cx:.0},{ty:.0})\"/>",
ty = cy + 2.0,
));
} else if score < 3 {
// Sad: frown arc
let inner_r = FACE_RADIUS / 2.0;
let outer_r = FACE_RADIUS / 2.2;
let arc_path = generate_sad_arc(inner_r, outer_r);
svg.push_str(&format!(
"<path class=\"mouth\" d=\"{arc_path}\" transform=\"translate({cx:.0},{ty:.0})\"/>",
ty = cy + 7.0,
));
} else {
// Neutral: straight line
svg.push_str(&format!(
"<line class=\"mouth\" stroke=\"#666\" x1=\"{x1:.0}\" y1=\"{y1:.0}\" \
x2=\"{x2:.0}\" y2=\"{y1:.0}\" stroke-width=\"1px\"/>",
x1 = cx - 5.0,
y1 = cy + 7.0,
x2 = cx + 5.0,
));
}
svg.push_str("</g>");
}
/// Generate the smile arc path matching d3.arc with
/// startAngle=PI/2, endAngle=3*PI/2, innerRadius=r/2, outerRadius=r/2.2
fn generate_smile_arc(inner_r: f64, outer_r: f64) -> String {
// The exact path from the reference SVG is:
// M7.5,0A7.5,7.5,0,1,1,-7.5,0L-6.818,0A6.818,6.818,0,1,0,6.818,0Z
format!(
"M{or},0A{or},{or},0,1,1,-{or},0L-{ir},0A{ir},{ir},0,1,0,{ir},0Z",
or = format_num(outer_r),
ir = format_num(inner_r),
)
}
/// Generate the sad arc path matching d3.arc with
/// startAngle=3*PI/2, endAngle=5*PI/2, innerRadius=r/2, outerRadius=r/2.2
fn generate_sad_arc(inner_r: f64, outer_r: f64) -> String {
format!(
"M-{or},0A{or},{or},0,1,1,{or},0L{ir},0A{ir},{ir},0,1,0,-{ir},0Z",
or = format_num(outer_r),
ir = format_num(inner_r),
)
}
fn format_num(n: f64) -> String {
let s = format!("{n:.3}");
if s.contains('.') {
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
trimmed.to_string()
} else {
s
}
}
#[derive(Debug, Clone)]
struct JourneyDiagram {
title: Option<String>,
rows: Vec<JourneyRow>,
}
#[derive(Debug, Clone)]
enum JourneyRow {
Section(String),
Task(JourneyTask),
}
#[derive(Debug, Clone)]
struct JourneyTask {
name: String,
score: i32,
actors: Vec<String>,
}
#[derive(Debug, Clone)]
struct FlatTask {
name: String,
score: i32,
actors: Vec<String>,
section: String,
}
#[derive(Debug)]
struct SectionInfo {
name: String,
first_task_idx: usize,
task_count: usize,
section_num: usize,
}
fn parse_journey_diagram(input: &str) -> Result<JourneyDiagram, MermaidError> {
let lines: Vec<&str> = input.lines().collect();
let mut i = 0_usize;
while i < lines.len() {
let line = lines[i].trim();
if line.is_empty() || line.starts_with("%%") {
i += 1;
continue;
}
if line.split_whitespace().next() == Some("journey") {
i += 1;
break;
}
return Err(MermaidError::ParseError {
line: i + 1,
message: "Expected 'journey' declaration".to_string(),
});
}
let mut title: Option<String> = None;
let mut rows: Vec<JourneyRow> = Vec::new();
while i < lines.len() {
let raw = lines[i];
let line = raw.trim();
let line_no = i + 1;
i += 1;
if line.is_empty() || line.starts_with("%%") {
continue;
}
if let Some(rest) = line.strip_prefix("title ") {
let t = rest.trim();
if !t.is_empty() {
title = Some(t.to_string());
}
continue;
}
if let Some(rest) = line.strip_prefix("section ") {
let name = rest.trim();
if !name.is_empty() {
rows.push(JourneyRow::Section(name.to_string()));
}
continue;
}
let parts: Vec<&str> = line
.split(':')
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.collect();
if parts.len() < 2 {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid journey task line: {line}"),
});
}
let name = parts[0].to_string();
let score: i32 = parts[1].parse().map_err(|_| MermaidError::ParseError {
line: line_no,
message: format!("Invalid journey score: {line}"),
})?;
let actors: Vec<String> = if parts.len() >= 3 {
// Actors field may contain comma-separated names (e.g., "Alice, Bob")
// Join remaining parts (in case actor names contain colons) then split by comma
parts[2..]
.join(": ")
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
} else {
Vec::new()
};
rows.push(JourneyRow::Task(JourneyTask {
name,
score,
actors,
}));
}
if rows.is_empty() {
return Err(MermaidError::ParseError {
line: 1,
message: "Journey requires at least one section/task".to_string(),
});
}
Ok(JourneyDiagram { title, rows })
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+506
View File
@@ -0,0 +1,506 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
use serde_yaml::{Mapping, Value};
const COLUMN_WIDTH: f64 = 200.0;
const COLUMN_GAP: f64 = 5.0;
const HEADER_HEIGHT: f64 = 25.0;
const BOTTOM_PADDING: f64 = 10.0;
const TASK_WIDTH: f64 = 185.0;
const TASK_HEIGHT: f64 = 44.0;
const TASK_HEIGHT_WITH_ASSIGNED: f64 = 56.0;
const TASK_GAP: f64 = 5.0;
const TASK_TEXT_LINE_HEIGHT: f64 = 24.0;
const TASK_INNER_PADDING: f64 = 10.0;
const DIAGRAM_PADDING: f64 = 10.0;
/// Per the reference SVG, the priority line is inset 2px from the card rect's left edge,
/// and 2px from the top/bottom of the rect.
const PRIORITY_LINE_INSET_X: f64 = 2.0;
const PRIORITY_LINE_INSET_Y: f64 = 2.0;
const PRIORITY_LINE_STROKE_WIDTH: f64 = 4.0;
pub fn render_kanban_diagram_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let board = parse_kanban(mermaid_source)?;
let columns_count = board.columns.len().max(1) as f64;
let total_width =
DIAGRAM_PADDING * 2.0 + columns_count * COLUMN_WIDTH + (columns_count - 1.0) * COLUMN_GAP;
let mut max_col_height: f64 = 0.0;
for col in &board.columns {
let tasks_h = tasks_stack_height(&col.tasks);
let h = HEADER_HEIGHT + tasks_h + BOTTOM_PADDING;
max_col_height = max_col_height.max(h);
}
let total_height = DIAGRAM_PADDING * 2.0 + max_col_height;
let mut svg = String::new();
svg.push_str(&format!(
"<svg id=\"my-svg\" width=\"100%\" xmlns=\"http://www.w3.org/2000/svg\" \
xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
style=\"max-width: {total_width}px; background-color: white;\" \
viewBox=\"0 0 {total_width} {total_height}\" \
role=\"graphics-document document\" aria-roledescription=\"kanban\">"
));
// Emit CSS matching Mermaid 11.12.2 reference
svg.push_str("<style>");
svg.push_str(&format!(
"#my-svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:{};}}",
theme.text_color
));
svg.push_str("#my-svg p{margin:0;}");
// Section-specific CSS (sections 0-10)
for i in 0..=10i32 {
let (fill_hue, fill_sat, fill_light) = section_hsl(i);
let text_fill = section_text_color(i);
svg.push_str(&format!(
"#my-svg .section-{i} rect,#my-svg .section-{i} path,\
#my-svg .section-{i} circle,#my-svg .section-{i} polygon,\
#my-svg .section-{i} path\
{{fill:hsl({fill_hue}, {fill_sat}%, {fill_light}%);\
stroke:hsl({fill_hue}, {fill_sat}%, {fill_light}%);}}",
));
svg.push_str(&format!("#my-svg .section-{i} text{{fill:{text_fill};}}",));
}
// Node styling (matches reference exactly)
svg.push_str(&format!(
"#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,\
#my-svg .node polygon,#my-svg .node path\
{{fill:white;stroke:{};stroke-width:1px;}}",
theme.node_stroke
));
svg.push_str(&format!(
"#my-svg .kanban-ticket-link{{fill:white;stroke:{};text-decoration:underline;}}",
theme.node_stroke
));
// Cluster-label and label styling — makes header text #333 via CSS
svg.push_str(&format!(
"#my-svg .cluster-label,#my-svg .label{{color:{};fill:{};}}",
theme.text_color, theme.text_color
));
svg.push_str(&format!(
"#my-svg .cluster-label text{{fill:{};font-size:16px;}}",
theme.text_color
));
svg.push_str(&format!(
"#my-svg .label text{{fill:{};font-size:16px;}}",
theme.text_color
));
// Kanban-label class
svg.push_str(
"#my-svg .kanban-label{dy:1em;alignment-baseline:middle;\
text-anchor:middle;dominant-baseline:middle;text-align:center;}",
);
svg.push_str("</style>");
// Background rect
svg.push_str(&format!(
"<rect x=\"0\" y=\"0\" width=\"{total_width}\" height=\"{total_height}\" fill=\"white\"/>"
));
// Empty g (matches reference structure)
svg.push_str("<g/>");
// === Sections (columns) ===
svg.push_str("<g class=\"sections\">");
for (col_idx, col) in board.columns.iter().enumerate() {
let section_idx = col_idx + 1;
let col_x = DIAGRAM_PADDING + col_idx as f64 * (COLUMN_WIDTH + COLUMN_GAP);
let col_y = DIAGRAM_PADDING;
let tasks_h = tasks_stack_height(&col.tasks);
let col_h = HEADER_HEIGHT + tasks_h + BOTTOM_PADDING;
svg.push_str(&format!(
"<g class=\"cluster undefined section-{section_idx}\" id=\"{}\" data-look=\"classic\">",
escape_xml(&col.title)
));
// Section rect — no inline fill/stroke; CSS handles it via .section-N
svg.push_str(&format!(
"<rect style=\"\" rx=\"5\" ry=\"5\" x=\"{col_x}\" y=\"{col_y}\" \
width=\"{COLUMN_WIDTH}\" height=\"{col_h}\"/>"
));
// Cluster label using SVG <text> for universal renderer compatibility.
// Centered horizontally within the column, vertically within the header.
let label_x = col_x;
let label_y = col_y;
let text_x = COLUMN_WIDTH / 2.0;
let text_y = HEADER_HEIGHT / 2.0;
svg.push_str(&format!(
"<g class=\"cluster-label\" transform=\"translate({label_x}, {label_y})\">"
));
svg.push_str(&format!(
"<text x=\"{text_x}\" y=\"{text_y}\" \
text-anchor=\"middle\" dominant-baseline=\"central\" \
font-family=\"'trebuchet ms', verdana, arial, sans-serif\">{}</text>",
escape_xml(&col.title)
));
svg.push_str("</g>");
svg.push_str("</g>");
}
svg.push_str("</g>");
// === Items (task cards) ===
svg.push_str("<g class=\"items\">");
for (col_idx, col) in board.columns.iter().enumerate() {
let col_x = DIAGRAM_PADDING + col_idx as f64 * (COLUMN_WIDTH + COLUMN_GAP);
let col_y = DIAGRAM_PADDING;
let col_center_x = col_x + COLUMN_WIDTH / 2.0;
if col.tasks.is_empty() {
continue;
}
// First task center is HEADER_HEIGHT below column top, plus half the task height
let mut task_center_y = col_y + HEADER_HEIGHT + task_height(&col.tasks[0]) / 2.0;
for (task_idx, task) in col.tasks.iter().enumerate() {
if task_idx > 0 {
let prev_h = task_height(&col.tasks[task_idx - 1]);
let curr_h = task_height(task);
task_center_y += prev_h / 2.0 + TASK_GAP + curr_h / 2.0;
}
let t_h = task_height(task);
let half_w = TASK_WIDTH / 2.0;
let half_h = t_h / 2.0;
svg.push_str(&format!(
"<g class=\"node undefined\" id=\"{}\" \
transform=\"translate({col_center_x}, {task_center_y})\">",
escape_xml(&task.label)
));
// Card rect (centered at origin of the transform)
svg.push_str(&format!(
"<rect class=\"basic label-container\" style=\"\" rx=\"5\" ry=\"5\" \
x=\"{x}\" y=\"{y}\" width=\"{TASK_WIDTH}\" height=\"{t_h}\"/>",
x = -half_w,
y = -half_h,
));
// Task label — positioned as foreignObject
let label_tx = -half_w + TASK_INNER_PADDING;
let label_ty = if task.assigned.is_some() {
-half_h + 4.0 // 4px from top when assigned
} else {
-(TASK_TEXT_LINE_HEIGHT / 2.0)
};
emit_label_text(&mut svg, label_tx, label_ty, &task.label);
// Assigned/empty placeholders
match &task.assigned {
Some(assigned) => {
// Empty middle-left placeholder
emit_empty_label(&mut svg, label_tx, 0.0);
// Assigned name — bottom-right
let assigned_w = estimate_text_width(assigned);
let assigned_tx = half_w - TASK_INNER_PADDING - assigned_w;
emit_label_text(&mut svg, assigned_tx, 0.0, assigned);
}
None => {
// Two empty label placeholders (matches reference for non-assigned)
let ph_y = TASK_TEXT_LINE_HEIGHT / 2.0;
emit_empty_label(&mut svg, label_tx, ph_y);
let right_tx = half_w - TASK_INNER_PADDING;
emit_empty_label(&mut svg, right_tx, ph_y);
}
}
// Priority indicator line
if let Some(priority_color) = task.priority.as_deref().and_then(color_from_priority) {
let line_x = -half_w + PRIORITY_LINE_INSET_X;
let line_y1 = -half_h + PRIORITY_LINE_INSET_Y;
let line_y2 = half_h - PRIORITY_LINE_INSET_Y;
svg.push_str(&format!(
"<line x1=\"{line_x}\" y1=\"{line_y1}\" x2=\"{line_x}\" y2=\"{line_y2}\" \
stroke-width=\"{PRIORITY_LINE_STROKE_WIDTH}\" stroke=\"{priority_color}\"/>"
));
}
svg.push_str("</g>");
}
}
svg.push_str("</g>");
svg.push_str("</svg>");
Ok(svg)
}
/// Emit a native SVG `<text>` label inside `<g class="label">`.
/// Text is left-aligned (text-anchor="start") and vertically centred within
/// one line height so it sits in the same place the old foreignObject did.
fn emit_label_text(svg: &mut String, tx: f64, ty: f64, text: &str) {
let text_y = TASK_TEXT_LINE_HEIGHT / 2.0;
svg.push_str(&format!(
"<g class=\"label\" transform=\"translate({tx}, {ty})\">\
<text x=\"0\" y=\"{text_y}\" \
text-anchor=\"start\" dominant-baseline=\"central\" \
font-family=\"'trebuchet ms', verdana, arial, sans-serif\">\
{}</text></g>",
escape_xml(text),
));
}
/// Emit an empty placeholder `<g class="label">` (no visible content).
fn emit_empty_label(svg: &mut String, tx: f64, ty: f64) {
svg.push_str(&format!(
"<g class=\"label\" transform=\"translate({tx}, {ty})\"/>",
));
}
#[derive(Debug, Clone)]
struct KanbanBoard {
columns: Vec<KanbanColumn>,
}
#[derive(Debug, Clone)]
struct KanbanColumn {
title: String,
tasks: Vec<KanbanTask>,
}
#[derive(Debug, Clone)]
struct KanbanTask {
label: String,
assigned: Option<String>,
priority: Option<String>,
ticket: Option<String>,
}
fn parse_kanban(input: &str) -> Result<KanbanBoard, MermaidError> {
let lines = input.lines().enumerate();
let mut found_header = false;
let mut columns: Vec<KanbanColumn> = Vec::new();
let mut current_idx: Option<usize> = None;
for (idx, raw) in lines {
let line_no = idx + 1;
let line = raw.trim_end_matches(['\r', '\n']).to_string();
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("%%") {
continue;
}
if !found_header {
if trimmed.split_whitespace().next() != Some("kanban") {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected 'kanban' declaration".to_string(),
});
}
found_header = true;
continue;
}
let is_indented = raw.chars().next().is_some_and(|c| c.is_whitespace());
if !is_indented {
columns.push(KanbanColumn {
title: trimmed.to_string(),
tasks: Vec::new(),
});
current_idx = Some(columns.len() - 1);
continue;
}
let Some(cur) = current_idx else {
return Err(MermaidError::ParseError {
line: line_no,
message: "Task found before any kanban column".to_string(),
});
};
let task = parse_kanban_task(trimmed, line_no)?;
columns[cur].tasks.push(task);
}
if !found_header {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'kanban' declaration".to_string(),
});
}
Ok(KanbanBoard { columns })
}
fn task_height(task: &KanbanTask) -> f64 {
if task.assigned.is_some() {
TASK_HEIGHT_WITH_ASSIGNED
} else {
TASK_HEIGHT
}
}
fn tasks_stack_height(tasks: &[KanbanTask]) -> f64 {
let mut total = 0.0;
for (i, task) in tasks.iter().enumerate() {
if i > 0 {
total += TASK_GAP;
}
total += task_height(task);
}
total
}
fn parse_kanban_task(line: &str, line_no: usize) -> Result<KanbanTask, MermaidError> {
let (label, shape_data) = split_label_and_shape_data(line);
let mut task = KanbanTask {
label,
assigned: None,
priority: None,
ticket: None,
};
let Some(shape_data) = shape_data else {
return Ok(task);
};
apply_shape_data(&mut task, &shape_data, line_no)?;
Ok(task)
}
fn split_label_and_shape_data(line: &str) -> (String, Option<String>) {
let Some(start) = line.find("@{") else {
return (line.to_string(), None);
};
if !line.ends_with('}') {
return (line.to_string(), None);
}
let (label, rest) = line.split_at(start);
let shape_data = rest.strip_prefix("@{").unwrap_or(rest);
let shape_data = shape_data.strip_suffix('}').unwrap_or(shape_data);
(
label.trim_end().to_string(),
Some(shape_data.trim().to_string()),
)
}
fn apply_shape_data(
task: &mut KanbanTask,
shape_data: &str,
line_no: usize,
) -> Result<(), MermaidError> {
let yaml_data = if shape_data.contains('\n') {
format!("{shape_data}\n")
} else {
format!("{{\n{shape_data}\n}}")
};
let doc: Value = serde_yaml::from_str(&yaml_data).map_err(|e| MermaidError::ParseError {
line: line_no,
message: format!("Invalid kanban metadata: {e}"),
})?;
let Value::Mapping(map) = doc else {
return Err(MermaidError::ParseError {
line: line_no,
message: "Invalid kanban metadata: expected a YAML mapping".to_string(),
});
};
if let Some(label) = yaml_get_string(&map, "label") {
task.label = label;
}
if let Some(assigned) = yaml_get_string(&map, "assigned") {
task.assigned = Some(assigned);
}
if let Some(priority) = yaml_get_string(&map, "priority") {
task.priority = Some(priority);
}
if let Some(ticket) = yaml_get_string(&map, "ticket") {
task.ticket = Some(ticket);
}
Ok(())
}
fn yaml_get_string(map: &Mapping, key: &str) -> Option<String> {
let value = map.get(Value::String(key.to_string()))?;
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
fn color_from_priority(priority: &str) -> Option<&'static str> {
match priority {
"Very High" => Some("red"),
"High" => Some("orange"),
"Medium" => None,
"Low" => Some("blue"),
"Very Low" => Some("lightblue"),
_ => None,
}
}
/// Returns (hue, saturation, lightness) for a given section index.
/// Matches the Mermaid 11.12.2 CSS section color scheme.
fn section_hsl(idx: i32) -> (i32, f64, f64) {
let hues: [i32; 12] = [60, 80, 270, 300, 330, 0, 30, 90, 150, 180, 210, 240];
let wrapped = idx.rem_euclid(12);
let hue = hues[wrapped as usize];
if idx == 0 {
(hue, 100.0, 83.5294117647)
} else {
(hue, 100.0, 86.2745098039)
}
}
/// Returns the text fill color for a given section index.
fn section_text_color(idx: i32) -> &'static str {
// From the reference CSS: section-2 and section--1 use #ffffff; most others use black.
// Note: .cluster-label CSS overrides this for header text to #333.
match idx {
-1 | 2 => "#ffffff",
_ => "black",
}
}
/// Estimate text width in SVG units using a simple char-width heuristic.
/// Uses a slightly wider estimate (9px/char) to prevent clipping in foreignObject,
/// since the inner div's max-width CSS handles the real constraint.
fn estimate_text_width(text: &str) -> f64 {
let char_width = 9.5;
text.len() as f64 * char_width
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,373 @@
use std::collections::{HashMap, HashSet};
use dagre_rust::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::{Edge, Graph};
pub type DagreGraph = Graph<GraphConfig, GraphNode, GraphEdge>;
pub struct ExtractedCluster {
pub graph: DagreGraph,
pub children: HashMap<String, ExtractedCluster>,
}
#[derive(Debug, Clone)]
struct ClusterDbEntry {
anchor_id: String,
external_connections: bool,
}
#[derive(Debug, Default)]
struct AdjustState {
cluster_db: HashMap<String, ClusterDbEntry>,
descendants: HashMap<String, HashSet<String>>,
}
pub fn adjust_clusters_and_edges(graph: &mut DagreGraph) -> HashMap<String, ExtractedCluster> {
let mut state = AdjustState::default();
let mut parents: HashMap<String, String> = HashMap::new();
let nodes = graph.nodes();
for id in &nodes {
if graph.children(id).is_empty() {
continue;
}
let descendants = extract_descendants(id, graph, &mut parents);
state.descendants.insert(id.clone(), descendants);
let anchor_id = find_non_cluster_child(id, graph, id).unwrap_or_else(|| id.clone());
state.cluster_db.insert(
id.clone(),
ClusterDbEntry {
anchor_id,
external_connections: false,
},
);
}
let edges = graph.edges();
for id in &nodes {
if graph.children(id).is_empty() {
continue;
}
for edge in &edges {
let d1 = is_descendant(&edge.v, id, &state);
let d2 = is_descendant(&edge.w, id, &state);
if d1 != d2 {
if let Some(entry) = state.cluster_db.get_mut(id) {
entry.external_connections = true;
}
break;
}
}
}
let cluster_ids: Vec<String> = state.cluster_db.keys().cloned().collect();
for id in cluster_ids {
let Some(non_cluster_child) = state
.cluster_db
.get(&id)
.map(|entry| entry.anchor_id.clone())
else {
continue;
};
let Some(parent) = graph.parent(&non_cluster_child) else {
continue;
};
if parent == &id {
continue;
}
let Some(parent_entry) = state.cluster_db.get(parent) else {
continue;
};
if !parent_entry.external_connections {
if let Some(entry) = state.cluster_db.get_mut(&id) {
entry.anchor_id = parent.clone();
}
}
}
let edge_objs = graph.edges();
for edge_obj in edge_objs {
if !state.cluster_db.contains_key(&edge_obj.v)
&& !state.cluster_db.contains_key(&edge_obj.w)
{
continue;
}
let Some(edge_label) = graph.edge_with_obj(&edge_obj).cloned() else {
continue;
};
let v = get_anchor_id(&edge_obj.v, &state);
let w = get_anchor_id(&edge_obj.w, &state);
graph.remove_edge_with_obj(&edge_obj);
if v != edge_obj.v {
if let Some(parent) = graph.parent(&v) {
if let Some(entry) = state.cluster_db.get_mut(parent) {
entry.external_connections = true;
}
}
}
if w != edge_obj.w {
if let Some(parent) = graph.parent(&w) {
if let Some(entry) = state.cluster_db.get_mut(parent) {
entry.external_connections = true;
}
}
}
let _ = graph.set_edge(&v, &w, Some(edge_label), edge_obj.name.clone());
}
extractor(graph, &state, 0)
}
fn extract_descendants(
id: &String,
graph: &DagreGraph,
parents: &mut HashMap<String, String>,
) -> HashSet<String> {
let children = graph.children(id);
let mut res: HashSet<String> = children.iter().cloned().collect();
for child in children {
parents.insert(child.clone(), id.clone());
res.extend(extract_descendants(&child, graph, parents));
}
res
}
fn is_descendant(id: &String, ancestor_id: &String, state: &AdjustState) -> bool {
state
.descendants
.get(ancestor_id)
.is_some_and(|desc| desc.contains(id))
}
fn edge_in_cluster(edge: &Edge, cluster_id: &String, state: &AdjustState) -> bool {
if &edge.v == cluster_id || &edge.w == cluster_id {
return false;
}
let Some(cluster_descendants) = state.descendants.get(cluster_id) else {
return false;
};
cluster_descendants.contains(&edge.v) || cluster_descendants.contains(&edge.w)
}
fn find_common_edges(graph: &DagreGraph, id1: &String, id2: &String) -> Vec<(String, String)> {
let edges = graph.edges();
let edges1: Vec<&Edge> = edges
.iter()
.filter(|edge| &edge.v == id1 || &edge.w == id1)
.collect();
let edges2: Vec<&Edge> = edges
.iter()
.filter(|edge| &edge.v == id2 || &edge.w == id2)
.collect();
let edges1_prim: Vec<(String, String)> = edges1
.into_iter()
.map(|edge| {
let v = if &edge.v == id1 {
id2.clone()
} else {
edge.v.clone()
};
let w = if &edge.w == id1 {
id1.clone()
} else {
edge.w.clone()
};
(v, w)
})
.collect();
let edges2_prim: Vec<(String, String)> = edges2
.into_iter()
.map(|edge| (edge.v.clone(), edge.w.clone()))
.collect();
edges1_prim
.into_iter()
.filter(|(v, w)| edges2_prim.iter().any(|(v2, w2)| v == v2 && w == w2))
.collect()
}
fn find_non_cluster_child(id: &String, graph: &DagreGraph, cluster_id: &String) -> Option<String> {
let children = graph.children(id);
if children.is_empty() {
return Some(id.clone());
}
let mut reserve: Option<String> = None;
for child in children {
let Some(candidate) = find_non_cluster_child(&child, graph, cluster_id) else {
continue;
};
let candidate_id = candidate.clone();
let common_edges = find_common_edges(graph, cluster_id, &candidate_id);
if !common_edges.is_empty() {
reserve = Some(candidate);
} else {
return Some(candidate);
}
}
reserve
}
fn get_anchor_id(id: &String, state: &AdjustState) -> String {
let Some(entry) = state.cluster_db.get(id) else {
return id.clone();
};
if !entry.external_connections {
return id.clone();
}
entry.anchor_id.clone()
}
fn new_cluster_graph(rankdir: &str) -> DagreGraph {
let dir = if rankdir == "tb" { "lr" } else { "tb" };
let mut g: DagreGraph = Graph::new(Some(graphlib_rust::GraphOption {
directed: Some(true),
multigraph: Some(true),
compound: Some(true),
}));
g.set_graph(GraphConfig {
rankdir: Some(dir.to_string()),
nodesep: Some(50.0),
ranksep: Some(50.0),
marginx: Some(8.0),
marginy: Some(8.0),
..Default::default()
});
g
}
fn copy(
cluster_id: &String,
graph: &mut DagreGraph,
new_graph: &mut DagreGraph,
root_id: &String,
state: &AdjustState,
) {
let mut nodes = graph.children(cluster_id);
if cluster_id != root_id {
nodes.push(cluster_id.clone());
}
for node in nodes {
if !graph.children(&node).is_empty() {
copy(&node, graph, new_graph, root_id, state);
} else {
let Some(data) = graph.node(&node).cloned() else {
continue;
};
new_graph.set_node(node.clone(), Some(data));
if let Some(parent) = graph.parent(&node) {
if root_id != parent {
let _ = new_graph.set_parent(&node, Some(parent.clone()));
}
}
if cluster_id != root_id && node != *cluster_id {
let _ = new_graph.set_parent(&node, Some(cluster_id.clone()));
}
let edge_objs: Vec<Edge> = graph
.edges()
.into_iter()
.filter(|e| e.v == node || e.w == node)
.collect();
for edge_obj in edge_objs {
let Some(edge_label) = graph.edge_with_obj(&edge_obj).cloned() else {
continue;
};
if edge_in_cluster(&edge_obj, root_id, state) {
let _ = new_graph.set_edge(
&edge_obj.v,
&edge_obj.w,
Some(edge_label),
edge_obj.name.clone(),
);
}
}
}
graph.remove_node(&node);
}
}
fn extractor(
graph: &mut DagreGraph,
state: &AdjustState,
depth: usize,
) -> HashMap<String, ExtractedCluster> {
if depth > 10 {
return HashMap::new();
}
let nodes = graph.nodes();
if !nodes.iter().any(|node| !graph.children(node).is_empty()) {
return HashMap::new();
}
let mut extracted: HashMap<String, ExtractedCluster> = HashMap::new();
let rankdir = graph
.graph()
.rankdir
.clone()
.unwrap_or_else(|| "tb".to_string());
for node in nodes {
if graph.node(&node).is_none() {
continue;
}
if graph.children(&node).is_empty() {
continue;
}
let Some(entry) = state.cluster_db.get(&node) else {
continue;
};
if entry.external_connections {
continue;
}
let mut cluster_graph = new_cluster_graph(&rankdir);
copy(&node, graph, &mut cluster_graph, &node, state);
let children = extractor(&mut cluster_graph, state, depth + 1);
extracted.insert(
node.clone(),
ExtractedCluster {
graph: cluster_graph,
children,
},
);
}
extracted
}
@@ -0,0 +1,569 @@
use std::collections::HashMap;
use crate::ast::{EdgeStyle, FlowchartGraph, GraphDirection, NodeShape};
use crate::config::RenderConfig;
use crate::layout::{LayoutEdge, LayoutNode, LayoutResult, LayoutSubgraph};
use crate::text_wrap::{
measure_wrapped_lines_with_font_size, scale_char_width, wrap_text_lines, DEFAULT_CHAR_WIDTH,
DEFAULT_FONT_SIZE, DEFAULT_WRAP_WIDTH,
};
use dagre_rust::layout::layout as dagre_layout;
use dagre_rust::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::Graph;
use super::cluster_adjust::{adjust_clusters_and_edges, ExtractedCluster};
use super::{flow_data, flow_db};
const FLOWCHART_PADDING: f64 = 15.0;
const EDGE_LABEL_PADDING: f64 = 2.0;
const SUBGRAPH_PADDING: f64 = 8.0;
#[derive(Debug, Clone)]
struct NodeMeta {
label: String,
shape: NodeShape,
width: f64,
height: f64,
fill_color: Option<String>,
stroke_color: Option<String>,
is_group: bool,
title: Option<String>,
}
#[derive(Debug, Clone)]
struct EdgeMeta {
label: Option<String>,
style: EdgeStyle,
}
#[derive(Debug, Default)]
struct LocalLayout {
nodes: HashMap<String, LayoutNode>,
edges: Vec<LayoutEdge>,
subgraphs: Vec<LayoutSubgraph>,
width: f64,
height: f64,
}
#[derive(Debug, Clone, Copy)]
struct PortLayoutOptions {
node_spacing: f64,
rank_spacing: f64,
padding: f64,
wrapping_width: f64,
font_size: f64,
}
impl Default for PortLayoutOptions {
fn default() -> Self {
Self {
node_spacing: 50.0,
rank_spacing: 50.0,
padding: FLOWCHART_PADDING,
wrapping_width: DEFAULT_WRAP_WIDTH,
font_size: DEFAULT_FONT_SIZE,
}
}
}
impl PortLayoutOptions {
fn from_render_config(config: &RenderConfig) -> Self {
let default = Self::default();
Self {
node_spacing: config
.flowchart
.node_spacing
.map(f64::from)
.unwrap_or(default.node_spacing),
rank_spacing: config
.flowchart
.rank_spacing
.map(f64::from)
.unwrap_or(default.rank_spacing),
padding: config
.flowchart
.padding
.map(f64::from)
.unwrap_or(default.padding),
wrapping_width: config
.flowchart
.wrapping_width
.map(f64::from)
.unwrap_or(default.wrapping_width),
font_size: config.font_size_px().unwrap_or(default.font_size),
}
}
}
pub fn compute_layout_ported(flowchart: &FlowchartGraph) -> LayoutResult {
compute_layout_ported_with_config(flowchart, &RenderConfig::default())
}
pub fn compute_layout_ported_with_config(
flowchart: &FlowchartGraph,
config: &RenderConfig,
) -> LayoutResult {
let options = PortLayoutOptions::from_render_config(config);
let db = flow_db::from_flowchart_graph(flowchart);
let data = flow_data::get_data(&db);
let rankdir = match db.direction {
GraphDirection::TopToBottom => "tb",
GraphDirection::BottomToTop => "bt",
GraphDirection::LeftToRight => "lr",
GraphDirection::RightToLeft => "rl",
};
let mut node_meta: HashMap<String, NodeMeta> = HashMap::new();
for node in &data.nodes {
let (fill_color, stroke_color) =
node.styles.iter().fold((None, None), |mut acc, (k, v)| {
if k == "fill" {
acc.0 = Some(v.clone());
}
if k == "stroke" {
acc.1 = Some(v.clone());
}
acc
});
let (width, height) = if node.is_group {
(0.0, 0.0)
} else {
measure_node(&node.label, node.shape, &options)
};
node_meta.insert(
node.id.clone(),
NodeMeta {
label: node.label.clone(),
shape: node.shape,
width,
height,
fill_color,
stroke_color,
is_group: node.is_group,
title: if node.is_group {
Some(node.label.clone())
} else {
None
},
},
);
}
let mut edge_meta: HashMap<(String, String), EdgeMeta> = HashMap::new();
let mut g: Graph<GraphConfig, GraphNode, GraphEdge> =
Graph::new(Some(graphlib_rust::GraphOption {
directed: Some(true),
multigraph: Some(true),
compound: Some(true),
}));
g.set_graph(GraphConfig {
rankdir: Some(rankdir.to_string()),
nodesep: Some(options.node_spacing as f32),
ranksep: Some(options.rank_spacing as f32),
marginx: Some(8.0),
marginy: Some(8.0),
..Default::default()
});
for node in &data.nodes {
if node.is_group {
g.set_node(
node.id.clone(),
Some(GraphNode {
width: 0.0,
height: 0.0,
padding: Some(SUBGRAPH_PADDING as f32),
..Default::default()
}),
);
} else {
let Some(meta) = node_meta.get(&node.id) else {
continue;
};
g.set_node(
node.id.clone(),
Some(GraphNode {
width: meta.width as f32,
height: meta.height as f32,
..Default::default()
}),
);
}
if let Some(parent_id) = &node.parent_id {
let _ = g.set_parent(&node.id, Some(parent_id.clone()));
}
}
for edge in &data.edges {
let mut edge_label = GraphEdge {
labelpos: Some("c".to_string()),
..Default::default()
};
if let Some(label) = &edge.label {
if let Some((width, height)) = edge_label_dimensions(label, &options) {
edge_label.width = Some(width as f32);
edge_label.height = Some(height as f32);
}
}
edge_meta.insert(
(edge.start.clone(), edge.end.clone()),
EdgeMeta {
label: edge.label.clone(),
style: edge.style,
},
);
let _ = g.set_edge(&edge.start, &edge.end, Some(edge_label), None);
}
let mut extracted = adjust_clusters_and_edges(&mut g);
apply_options_to_extracted(&mut extracted, &options);
let layout = layout_recursive(&mut g, &mut extracted, &node_meta, &edge_meta);
LayoutResult {
nodes: layout.nodes,
edges: layout.edges,
subgraphs: layout.subgraphs,
width: layout.width,
height: layout.height,
}
}
fn apply_options_to_extracted(
extracted: &mut HashMap<String, ExtractedCluster>,
options: &PortLayoutOptions,
) {
for cluster in extracted.values_mut() {
let graph_config = cluster.graph.graph_mut();
graph_config.nodesep = Some(options.node_spacing as f32);
graph_config.ranksep = Some(options.rank_spacing as f32);
apply_options_to_extracted(&mut cluster.children, options);
}
}
fn layout_recursive(
graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
extracted: &mut HashMap<String, ExtractedCluster>,
node_meta: &HashMap<String, NodeMeta>,
edge_meta: &HashMap<(String, String), EdgeMeta>,
) -> LocalLayout {
let mut child_layouts: HashMap<String, LocalLayout> = HashMap::new();
for (cluster_id, cluster) in extracted.iter_mut() {
let layout = layout_recursive(
&mut cluster.graph,
&mut cluster.children,
node_meta,
edge_meta,
);
if let Some(node) = graph.node_mut(cluster_id) {
node.width = layout.width as f32;
node.height = layout.height as f32;
}
child_layouts.insert(cluster_id.clone(), layout);
}
dagre_layout(graph);
let mut layout = extract_local_layout(graph, node_meta, edge_meta);
for (cluster_id, mut child_layout) in child_layouts {
let Some(cluster_node) = graph.node(&cluster_id) else {
continue;
};
let dx = cluster_node.x as f64 - cluster_node.width as f64 / 2.0;
let dy = cluster_node.y as f64 - cluster_node.height as f64 / 2.0;
shift_layout(&mut child_layout, dx, dy);
for (id, node) in child_layout.nodes {
layout.nodes.insert(id, node);
}
layout.edges.extend(child_layout.edges);
layout.subgraphs.extend(child_layout.subgraphs);
}
layout
}
fn extract_local_layout(
graph: &Graph<GraphConfig, GraphNode, GraphEdge>,
node_meta: &HashMap<String, NodeMeta>,
edge_meta: &HashMap<(String, String), EdgeMeta>,
) -> LocalLayout {
let mut nodes: HashMap<String, LayoutNode> = HashMap::new();
let mut subgraphs: Vec<LayoutSubgraph> = Vec::new();
for node_id in graph.nodes() {
let Some(meta) = node_meta.get(&node_id) else {
continue;
};
let Some(node) = graph.node(&node_id) else {
continue;
};
if meta.is_group {
let width = node.width as f64;
let height = node.height as f64;
if width <= 0.0 || height <= 0.0 {
continue;
}
subgraphs.push(LayoutSubgraph {
id: node_id.clone(),
title: meta.title.clone(),
x: node.x as f64 - width / 2.0,
y: node.y as f64 - height / 2.0,
width,
height,
});
} else {
nodes.insert(
node_id.clone(),
LayoutNode {
id: node_id.clone(),
x: node.x as f64,
y: node.y as f64,
width: meta.width,
height: meta.height,
shape: meta.shape,
label: meta.label.clone(),
fill_color: meta.fill_color.clone(),
stroke_color: meta.stroke_color.clone(),
},
);
}
}
let mut edges: Vec<LayoutEdge> = Vec::new();
for edge_obj in graph.edges() {
let Some(edge_label) = graph.edge_with_obj(&edge_obj) else {
continue;
};
let Some(meta) = edge_meta.get(&(edge_obj.v.clone(), edge_obj.w.clone())) else {
continue;
};
let points: Vec<(f64, f64)> = edge_label
.points
.as_ref()
.map(|pts| pts.iter().map(|p| (p.x as f64, p.y as f64)).collect())
.unwrap_or_default();
let label_pos = meta.label.as_ref().and_then(|label| {
if label.trim().is_empty() {
None
} else if edge_label.width.unwrap_or(0.0) > 0.0
|| edge_label.height.unwrap_or(0.0) > 0.0
{
Some((edge_label.x as f64, edge_label.y as f64))
} else {
None
}
});
edges.push(LayoutEdge {
from: edge_obj.v,
to: edge_obj.w,
label: meta.label.clone(),
style: meta.style,
points,
label_pos,
});
}
let graph_width = graph.graph().width as f64;
let graph_height = graph.graph().height as f64;
LocalLayout {
nodes,
edges,
subgraphs,
width: graph_width,
height: graph_height,
}
}
fn shift_layout(layout: &mut LocalLayout, dx: f64, dy: f64) {
for node in layout.nodes.values_mut() {
node.x += dx;
node.y += dy;
}
for edge in &mut layout.edges {
for point in &mut edge.points {
point.0 += dx;
point.1 += dy;
}
if let Some((x, y)) = edge.label_pos.as_mut() {
*x += dx;
*y += dy;
}
}
for sg in &mut layout.subgraphs {
sg.x += dx;
sg.y += dy;
}
}
fn measure_node(label: &str, shape: NodeShape, options: &PortLayoutOptions) -> (f64, f64) {
let char_width = scale_char_width(DEFAULT_CHAR_WIDTH, options.font_size);
let lines = wrap_text_lines(label, options.wrapping_width, char_width);
let (text_width, text_height) =
measure_wrapped_lines_with_font_size(&lines, char_width, options.font_size);
let padding = options.padding;
match shape {
NodeShape::Rectangle => (text_width + padding * 4.0, text_height + padding * 2.0),
NodeShape::RoundedRectangle => (text_width + padding * 2.0, text_height + padding * 2.0),
NodeShape::Subroutine => {
let w = text_width + padding;
let h = text_height + padding;
(w + 16.0, h)
}
NodeShape::Asymmetric => {
let w = text_width + padding;
let h = text_height + padding;
(w + h / 4.0, h)
}
NodeShape::Hexagon => {
let h = text_height + padding;
let w = text_width + padding * 2.5;
(w * 7.0 / 6.0, h)
}
NodeShape::Diamond => {
let w = text_width + padding;
let h = text_height + padding;
let s = w + h;
(s, s)
}
NodeShape::Circle => {
let diameter = text_width + padding;
(diameter, diameter)
}
NodeShape::StartState => (14.0, 14.0),
NodeShape::EndState => (20.0, 20.0),
NodeShape::ForkJoin => (70.0, 10.0),
NodeShape::Stadium => {
let h = text_height + padding;
let w = text_width + h / 4.0 + padding;
(w, h)
}
NodeShape::Cylinder => {
let w = text_width + padding;
let rx = w / 2.0;
let ry = rx / (2.5 + w / 50.0);
let h = text_height + ry + padding;
(w, h + 2.0 * ry)
}
}
}
fn edge_label_dimensions(label: &str, options: &PortLayoutOptions) -> Option<(f64, f64)> {
if label.trim().is_empty() {
return None;
}
let char_width = scale_char_width(DEFAULT_CHAR_WIDTH, options.font_size);
let lines = wrap_text_lines(label, options.wrapping_width, char_width);
if lines.is_empty() {
return None;
}
let (text_width, text_height) =
measure_wrapped_lines_with_font_size(&lines, char_width, options.font_size);
let width = text_width + EDGE_LABEL_PADDING * 2.0;
let height = text_height + EDGE_LABEL_PADDING * 2.0;
Some((width, height))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::{Edge, FlowchartGraph, GraphDirection, Statement};
use crate::config::FlowchartConfig;
#[test]
fn ported_layout_uses_spacing_config() {
let graph = FlowchartGraph {
direction: GraphDirection::TopToBottom,
statements: vec![Statement::Edge(Edge {
from: "A".to_string(),
to: "B".to_string(),
label: None,
style: EdgeStyle::Arrow,
})],
};
let default_layout = compute_layout_ported(&graph);
let config = RenderConfig {
flowchart: FlowchartConfig {
rank_spacing: Some(140),
..Default::default()
},
..Default::default()
};
let configured_layout = compute_layout_ported_with_config(&graph, &config);
let default_delta = default_layout.nodes["B"].y - default_layout.nodes["A"].y;
let configured_delta = configured_layout.nodes["B"].y - configured_layout.nodes["A"].y;
assert!(configured_delta > default_delta);
}
#[test]
fn ported_layout_uses_padding_and_wrapping_config() {
let default = PortLayoutOptions::default();
let config = RenderConfig {
flowchart: FlowchartConfig {
padding: Some(4),
wrapping_width: Some(70),
..Default::default()
},
..Default::default()
};
let configured = PortLayoutOptions::from_render_config(&config);
let default_size = measure_node(
"Long label that wraps across several rendered lines",
NodeShape::Rectangle,
&default,
);
let configured_size = measure_node(
"Long label that wraps across several rendered lines",
NodeShape::Rectangle,
&configured,
);
assert!(configured_size.1 > default_size.1);
assert!(configured_size.0 < default_size.0);
}
#[test]
fn ported_layout_uses_font_size_config() {
let default = PortLayoutOptions::default();
let config = RenderConfig {
font_size: Some("32px".to_string()),
..Default::default()
};
let configured = PortLayoutOptions::from_render_config(&config);
let default_size = measure_node("Font", NodeShape::Rectangle, &default);
let configured_size = measure_node("Font", NodeShape::Rectangle, &configured);
let (default_label_width, default_label_height) =
edge_label_dimensions("Edge", &default).expect("label should have dimensions");
let (configured_label_width, configured_label_height) =
edge_label_dimensions("Edge", &configured).expect("label should have dimensions");
assert!(configured_size.0 > default_size.0);
assert!(configured_size.1 > default_size.1);
assert!(configured_label_width > default_label_width);
assert!(configured_label_height > default_label_height);
}
}
@@ -0,0 +1,75 @@
use crate::ast::{EdgeStyle, NodeShape};
use super::flow_db::{FlowDb, FlowEdge, FlowVertex};
#[derive(Debug, Clone)]
pub struct FlowData {
pub nodes: Vec<FlowDataNode>,
pub edges: Vec<FlowDataEdge>,
}
#[derive(Debug, Clone)]
pub struct FlowDataNode {
pub id: String,
pub label: String,
pub shape: NodeShape,
pub parent_id: Option<String>,
pub styles: Vec<(String, String)>,
pub is_group: bool,
}
#[derive(Debug, Clone)]
pub struct FlowDataEdge {
pub start: String,
pub end: String,
pub label: Option<String>,
pub style: EdgeStyle,
}
pub fn get_data(db: &FlowDb) -> FlowData {
let mut nodes: Vec<FlowDataNode> = Vec::new();
for sg in db.subgraphs.iter().rev() {
nodes.push(FlowDataNode {
id: sg.id.clone(),
label: sg.title.clone().unwrap_or_else(|| sg.id.clone()),
shape: NodeShape::Rectangle,
parent_id: sg.parent_id.clone(),
styles: Vec::new(),
is_group: true,
});
}
for id in &db.vertex_order {
if let Some(v) = db.vertices.get(id) {
nodes.push(make_node_data(db, v));
}
}
let edges: Vec<FlowDataEdge> = db.edges.iter().map(make_edge_data).collect();
FlowData { nodes, edges }
}
fn make_node_data(db: &FlowDb, v: &FlowVertex) -> FlowDataNode {
let parent_id = db.node_to_subgraph.get(&v.id).cloned();
let styles = db.node_styles.get(&v.id).cloned().unwrap_or_default();
FlowDataNode {
id: v.id.clone(),
label: v.label.clone(),
shape: v.shape,
parent_id,
styles,
is_group: false,
}
}
fn make_edge_data(e: &FlowEdge) -> FlowDataEdge {
FlowDataEdge {
start: e.start.clone(),
end: e.end.clone(),
label: e.label.clone(),
style: e.style,
}
}
+131
View File
@@ -0,0 +1,131 @@
use std::collections::HashMap;
use crate::ast::{EdgeStyle, FlowchartGraph, GraphDirection, NodeShape, Statement};
#[derive(Debug, Clone)]
pub struct FlowDb {
pub direction: GraphDirection,
pub vertices: HashMap<String, FlowVertex>,
pub vertex_order: Vec<String>,
pub edges: Vec<FlowEdge>,
pub subgraphs: Vec<FlowSubgraph>,
pub node_to_subgraph: HashMap<String, String>,
pub node_styles: HashMap<String, Vec<(String, String)>>,
}
#[derive(Debug, Clone)]
pub struct FlowVertex {
pub id: String,
pub label: String,
pub shape: NodeShape,
}
#[derive(Debug, Clone)]
pub struct FlowEdge {
pub start: String,
pub end: String,
pub label: Option<String>,
pub style: EdgeStyle,
}
#[derive(Debug, Clone)]
pub struct FlowSubgraph {
pub id: String,
pub title: Option<String>,
pub parent_id: Option<String>,
}
pub fn from_flowchart_graph(graph: &FlowchartGraph) -> FlowDb {
let mut db = FlowDb {
direction: graph.direction,
vertices: HashMap::new(),
vertex_order: Vec::new(),
edges: Vec::new(),
subgraphs: Vec::new(),
node_to_subgraph: HashMap::new(),
node_styles: HashMap::new(),
};
collect_statements(&mut db, &graph.statements, None);
db
}
fn collect_statements(db: &mut FlowDb, statements: &[Statement], current_subgraph: Option<&str>) {
for stmt in statements {
match stmt {
Statement::Node(node) => {
ensure_vertex(db, &node.id, node.label.as_deref(), node.shape);
maybe_assign_to_subgraph(db, &node.id, current_subgraph);
}
Statement::Edge(edge) => {
ensure_vertex(db, &edge.from, None, NodeShape::Rectangle);
ensure_vertex(db, &edge.to, None, NodeShape::Rectangle);
maybe_assign_to_subgraph(db, &edge.from, current_subgraph);
maybe_assign_to_subgraph(db, &edge.to, current_subgraph);
db.edges.push(FlowEdge {
start: edge.from.clone(),
end: edge.to.clone(),
label: edge.label.clone(),
style: edge.style,
});
}
Statement::Subgraph(subgraph) => {
collect_statements(db, &subgraph.statements, Some(&subgraph.id));
db.subgraphs.push(FlowSubgraph {
id: subgraph.id.clone(),
title: subgraph.title.clone().or_else(|| Some(subgraph.id.clone())),
parent_id: current_subgraph.map(|s| s.to_string()),
});
}
Statement::Style(style) => {
ensure_vertex(db, &style.node_id, None, NodeShape::Rectangle);
maybe_assign_to_subgraph(db, &style.node_id, current_subgraph);
db.node_styles
.entry(style.node_id.clone())
.or_default()
.extend(style.properties.iter().cloned());
}
}
}
}
fn ensure_vertex(db: &mut FlowDb, id: &str, label: Option<&str>, shape: NodeShape) {
if is_subgraph_id(db, id) {
return;
}
let id = id.to_string();
match db.vertices.get_mut(&id) {
Some(v) => {
if let Some(label) = label {
v.label = label.to_string();
v.shape = shape;
}
}
None => {
let label = label.unwrap_or(id.as_str()).to_string();
db.vertex_order.push(id.clone());
db.vertices
.insert(id.clone(), FlowVertex { id, label, shape });
}
}
}
fn maybe_assign_to_subgraph(db: &mut FlowDb, node_id: &str, current_subgraph: Option<&str>) {
if is_subgraph_id(db, node_id) {
return;
}
let Some(subgraph_id) = current_subgraph else {
return;
};
db.node_to_subgraph
.entry(node_id.to_string())
.or_insert_with(|| subgraph_id.to_string());
}
fn is_subgraph_id(db: &FlowDb, id: &str) -> bool {
db.subgraphs.iter().any(|subgraph| subgraph.id == id)
}
@@ -0,0 +1,6 @@
use crate::ast::FlowchartGraph;
use crate::error::MermaidError;
pub fn parse_flowchart(mermaid_source: &str) -> Result<FlowchartGraph, MermaidError> {
crate::parser::parse_mermaid(mermaid_source)
}
+40
View File
@@ -0,0 +1,40 @@
mod cluster_adjust;
mod dagre_layout_port;
mod flow_data;
mod flow_db;
mod flow_parser;
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
use crate::RenderConfig;
pub fn render_mermaid_to_svg_ported(
mermaid_source: &str,
theme: &MermaidTheme,
config: &RenderConfig,
) -> Result<String, MermaidError> {
let graph = flow_parser::parse_flowchart(mermaid_source)?;
let layout_result = dagre_layout_port::compute_layout_ported_with_config(&graph, config);
Ok(crate::svg_renderer::render_with_config(
&layout_result,
theme,
config,
))
}
// HERMETIC VENDORING PATCH: the experimental dagre flowchart "port" is disabled
// unconditionally. Upstream gated it on the `MERMAID_TO_SVG_USE_PORT` env var;
// reading the environment makes rendering non-deterministic over untrusted
// input, and the port mis-routes back-edges on cyclic flowcharts (detached
// arrowheads) — the exact defect this engine was adopted to fix. The default
// `layout::compute_layout` path routes cycles correctly.
pub fn is_enabled() -> bool {
false
}
#[allow(dead_code)]
pub(crate) fn compute_layout_ported(
flowchart: &crate::ast::FlowchartGraph,
) -> crate::layout::LayoutResult {
dagre_layout_port::compute_layout_ported(flowchart)
}
+670
View File
@@ -0,0 +1,670 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
/// Node type in the mindmap, following Mermaid 11.12.2 nodeType enum.
#[derive(Debug, Clone, Copy, PartialEq)]
enum MindmapNodeType {
Default, // no-border — "rounded" shape
Rect, // [text]
RoundedRect, // (text)
Circle, // ((text))
#[allow(dead_code)]
Cloud, // )text(
Bang, // ))text((
Hexagon, // {{text}}
}
/// A node in the mindmap tree.
#[derive(Debug, Clone)]
struct MindmapNode {
id: String,
label: String,
node_type: MindmapNodeType,
children: Vec<MindmapNode>,
section: Option<usize>,
}
/// Colors for a section (branch) of the mindmap.
struct SectionColors {
fill: &'static str,
text: &'static str,
edge: &'static str,
}
/// Root fill: hsl(240, 100%, 46.27%) from reference SVG.
const ROOT_FILL: &str = "hsl(240, 100%, 46.27%)";
const ROOT_TEXT: &str = "#ffffff";
/// Mermaid 11.12.2 default theme section colors, extracted from reference SVG.
/// Each section-N fill is hsl(H, 100%, ~73-76%).
const SECTION_COLORS: &[SectionColors] = &[
// Section 0: hsl(60, 100%, 73.53%) — yellow
SectionColors {
fill: "hsl(60, 100%, 73.53%)",
text: "black",
edge: "hsl(60, 100%, 73.53%)",
},
// Section 1: hsl(80, 100%, 76.27%) — yellow-green
SectionColors {
fill: "hsl(80, 100%, 76.27%)",
text: "black",
edge: "hsl(80, 100%, 76.27%)",
},
// Section 2: hsl(270, 100%, 76.27%) — purple
SectionColors {
fill: "hsl(270, 100%, 76.27%)",
text: "#ffffff",
edge: "hsl(270, 100%, 76.27%)",
},
// Section 3: hsl(300, 100%, 76.27%) — magenta
SectionColors {
fill: "hsl(300, 100%, 76.27%)",
text: "black",
edge: "hsl(300, 100%, 76.27%)",
},
// Section 4: hsl(330, 100%, 76.27%) — pink
SectionColors {
fill: "hsl(330, 100%, 76.27%)",
text: "black",
edge: "hsl(330, 100%, 76.27%)",
},
// Section 5: hsl(0, 100%, 76.27%) — red
SectionColors {
fill: "hsl(0, 100%, 76.27%)",
text: "black",
edge: "hsl(0, 100%, 76.27%)",
},
// Section 6: hsl(30, 100%, 76.27%) — orange
SectionColors {
fill: "hsl(30, 100%, 76.27%)",
text: "black",
edge: "hsl(30, 100%, 76.27%)",
},
// Section 7: hsl(90, 100%, 76.27%) — lime
SectionColors {
fill: "hsl(90, 100%, 76.27%)",
text: "black",
edge: "hsl(90, 100%, 76.27%)",
},
];
fn section_color(section: usize) -> &'static SectionColors {
&SECTION_COLORS[section % SECTION_COLORS.len()]
}
/// Layout result for a placed node.
#[derive(Debug, Clone)]
struct PlacedNode {
#[allow(dead_code)]
id: String,
label: String,
node_type: MindmapNodeType,
x: f64,
y: f64,
width: f64,
height: f64,
section: Option<usize>,
is_root: bool,
}
/// Layout result for an edge.
#[derive(Debug, Clone)]
struct PlacedEdge {
from_x: f64,
from_y: f64,
to_x: f64,
to_y: f64,
section: Option<usize>,
depth: usize,
}
// --- Parsing ---
fn parse_mindmap_tree(input: &str) -> Result<MindmapNode, MermaidError> {
let lines: Vec<&str> = input.lines().collect();
let mut i = 0_usize;
// Find "mindmap" declaration
while i < lines.len() {
let line = lines[i].trim();
if line.is_empty() || line.starts_with("%%") {
i += 1;
continue;
}
if line.split_whitespace().next() == Some("mindmap") {
i += 1;
break;
}
return Err(MermaidError::ParseError {
line: i + 1,
message: "Expected 'mindmap' declaration".to_string(),
});
}
let mut stack: Vec<(usize, MindmapNode)> = Vec::new();
let mut next_id = 0_usize;
while i < lines.len() {
let raw = lines[i];
i += 1;
if raw.trim().is_empty() || raw.trim_start().starts_with("%%") {
continue;
}
let indent = raw.chars().take_while(|c| *c == ' ' || *c == '\t').count();
let text = raw.trim();
// Skip decoration lines like ::icon(...)
if text.starts_with("::") {
continue;
}
let (label, node_type) = extract_label_and_type(text);
let node_id = format!("n{next_id}");
next_id += 1;
let is_root = stack.is_empty();
// Pop stack entries with indent >= current
while let Some((d, _)) = stack.last() {
if *d >= indent {
let (_, child) = stack.pop().unwrap();
if let Some((_, parent)) = stack.last_mut() {
parent.children.push(child);
} else {
// This was the root — re-push it
stack.push((indent, child));
break;
}
} else {
break;
}
}
let node = MindmapNode {
id: node_id,
label,
node_type: if is_root && node_type == MindmapNodeType::Default {
MindmapNodeType::Circle
} else {
node_type
},
children: Vec::new(),
section: None,
};
stack.push((indent, node));
}
// Collapse stack to get root
while stack.len() > 1 {
let (_, child) = stack.pop().unwrap();
if let Some((_, parent)) = stack.last_mut() {
parent.children.push(child);
}
}
stack.pop().map(|(_, n)| n).ok_or(MermaidError::ParseError {
line: 0,
message: "No nodes found in mindmap".to_string(),
})
}
fn extract_label_and_type(text: &str) -> (String, MindmapNodeType) {
let t = text.trim();
// (( ... )) → Circle
if let Some(start) = t.find("((") {
if t.ends_with("))") && start + 2 < t.len().saturating_sub(2) {
let label = t[start + 2..t.len() - 2].trim().to_string();
return (label, MindmapNodeType::Circle);
}
}
// {{ ... }} → Hexagon
if let Some(start) = t.find("{{") {
if t.ends_with("}}") && start + 2 < t.len().saturating_sub(2) {
let label = t[start + 2..t.len() - 2].trim().to_string();
return (label, MindmapNodeType::Hexagon);
}
}
// )) ... (( → Bang
if let Some(start) = t.find("))") {
if t.ends_with("((") && start + 2 < t.len().saturating_sub(2) {
let label = t[start + 2..t.len() - 2].trim().to_string();
return (label, MindmapNodeType::Bang);
}
}
// [ ... ] → Rect
if let Some(start) = t.find('[') {
if t.ends_with(']') && start + 1 < t.len().saturating_sub(1) {
let label = t[start + 1..t.len() - 1].trim().to_string();
return (label, MindmapNodeType::Rect);
}
}
// ( ... ) → RoundedRect
if let Some(start) = t.find('(') {
if t.ends_with(')') && start + 1 < t.len().saturating_sub(1) {
let label = t[start + 1..t.len() - 1].trim().to_string();
return (label, MindmapNodeType::RoundedRect);
}
}
// Default (no delimiter)
(t.to_string(), MindmapNodeType::Default)
}
// --- Section assignment ---
fn assign_sections(node: &mut MindmapNode, section: Option<usize>) {
node.section = section;
for (i, child) in node.children.iter_mut().enumerate() {
let child_section = if section.is_none() {
// Direct children of root get their own section number
Some(i)
} else {
section
};
assign_sections(child, child_section);
}
}
// --- Node sizing ---
const FONT_SIZE: f64 = 16.0;
const NODE_PADDING: f64 = 15.0;
const ROOT_PADDING: f64 = 20.0;
/// Estimate text width using display-width units × average character width.
/// We use this instead of `text_wrap::line_width` because in sandbox/CI
/// environments the font measurer may return 0 (no real fonts loaded).
fn estimate_text_width(text: &str) -> f64 {
// Average character width for 16px Trebuchet MS is roughly 8.5px
let avg_char_width = 8.5;
crate::text_wrap::display_width_units(text) * avg_char_width
}
fn measure_node(node: &MindmapNode) -> (f64, f64) {
let text_width = estimate_text_width(&node.label);
let text_height = FONT_SIZE;
match node.node_type {
MindmapNodeType::Circle => {
let diameter = (text_width.max(text_height) + ROOT_PADDING * 2.0).max(60.0);
(diameter, diameter)
}
MindmapNodeType::Rect | MindmapNodeType::RoundedRect | MindmapNodeType::Default => {
let w = text_width + NODE_PADDING * 2.0;
let h = text_height + NODE_PADDING * 2.0;
(w.max(40.0), h.max(36.0))
}
MindmapNodeType::Hexagon => {
let w = text_width + NODE_PADDING * 3.0;
let h = text_height + NODE_PADDING * 2.0;
(w.max(50.0), h.max(40.0))
}
MindmapNodeType::Cloud | MindmapNodeType::Bang => {
let w = text_width + NODE_PADDING * 2.5;
let h = text_height + NODE_PADDING * 2.5;
(w.max(50.0), h.max(40.0))
}
}
}
// --- Layout ---
/// Simple radial mindmap layout.
/// Root is placed at center. Children of root are distributed radially.
/// Deeper nodes extend outward from their parent.
fn layout_mindmap(root: &MindmapNode) -> (Vec<PlacedNode>, Vec<PlacedEdge>) {
let mut placed_nodes = Vec::new();
let mut placed_edges = Vec::new();
let (root_w, root_h) = measure_node(root);
// Place root at origin (will be shifted later)
placed_nodes.push(PlacedNode {
id: root.id.clone(),
label: root.label.clone(),
node_type: root.node_type,
x: 0.0,
y: 0.0,
width: root_w,
height: root_h,
section: root.section,
is_root: true,
});
let n_children = root.children.len();
if n_children == 0 {
return (placed_nodes, placed_edges);
}
// Calculate total subtree "weight" for each branch
let weights: Vec<f64> = root.children.iter().map(subtree_weight).collect();
let total_weight: f64 = weights.iter().sum();
// Distribute branches around the root
let start_angle: f64 = -std::f64::consts::FRAC_PI_2; // top
let mut current_angle = start_angle;
let base_radius = 120.0 + (n_children as f64) * 20.0;
for (i, child) in root.children.iter().enumerate() {
let weight_fraction = weights[i] / total_weight;
let sweep = std::f64::consts::TAU * weight_fraction;
let mid_angle = current_angle + sweep / 2.0;
layout_subtree(
child,
0.0,
0.0,
mid_angle,
base_radius,
1,
&mut placed_nodes,
&mut placed_edges,
);
current_angle += sweep;
}
// Normalize positions
let padding = 20.0;
let min_x = placed_nodes
.iter()
.map(|n| n.x - n.width / 2.0)
.fold(f64::INFINITY, f64::min);
let min_y = placed_nodes
.iter()
.map(|n| n.y - n.height / 2.0)
.fold(f64::INFINITY, f64::min);
let shift_x = -min_x + padding;
let shift_y = -min_y + padding;
for node in &mut placed_nodes {
node.x += shift_x;
node.y += shift_y;
}
for edge in &mut placed_edges {
edge.from_x += shift_x;
edge.from_y += shift_y;
edge.to_x += shift_x;
edge.to_y += shift_y;
}
(placed_nodes, placed_edges)
}
fn subtree_weight(node: &MindmapNode) -> f64 {
if node.children.is_empty() {
return 1.0;
}
let child_weight: f64 = node.children.iter().map(subtree_weight).sum();
child_weight.max(1.0)
}
#[allow(clippy::too_many_arguments)]
fn layout_subtree(
node: &MindmapNode,
parent_x: f64,
parent_y: f64,
angle: f64,
radius: f64,
depth: usize,
placed_nodes: &mut Vec<PlacedNode>,
placed_edges: &mut Vec<PlacedEdge>,
) {
let (node_w, node_h) = measure_node(node);
let x = parent_x + angle.cos() * radius;
let y = parent_y + angle.sin() * radius;
placed_nodes.push(PlacedNode {
id: node.id.clone(),
label: node.label.clone(),
node_type: node.node_type,
x,
y,
width: node_w,
height: node_h,
section: node.section,
is_root: false,
});
placed_edges.push(PlacedEdge {
from_x: parent_x,
from_y: parent_y,
to_x: x,
to_y: y,
section: node.section,
depth,
});
let n_children = node.children.len();
if n_children == 0 {
return;
}
let weights: Vec<f64> = node.children.iter().map(subtree_weight).collect();
let total_weight: f64 = weights.iter().sum();
// Fan out children around the parent→child direction
let fan_spread = std::f64::consts::FRAC_PI_2.min(0.8 * (n_children as f64).sqrt());
let child_radius = 100.0 + (depth as f64) * 10.0;
let mut current_angle = angle - fan_spread / 2.0;
for (i, child) in node.children.iter().enumerate() {
let weight_fraction = weights[i] / total_weight;
let sweep = fan_spread * weight_fraction;
let mid_angle = current_angle + sweep / 2.0;
layout_subtree(
child,
x,
y,
mid_angle,
child_radius,
depth + 1,
placed_nodes,
placed_edges,
);
current_angle += sweep;
}
}
// --- SVG Rendering ---
pub fn render_mindmap_to_svg(input: &str, _theme: &MermaidTheme) -> Result<String, MermaidError> {
let mut root = parse_mindmap_tree(input)?;
assign_sections(&mut root, None);
let (placed_nodes, placed_edges) = layout_mindmap(&root);
let max_x = placed_nodes
.iter()
.map(|n| n.x + n.width / 2.0)
.fold(0.0_f64, f64::max);
let max_y = placed_nodes
.iter()
.map(|n| n.y + n.height / 2.0)
.fold(0.0_f64, f64::max);
let padding = 20.0;
let svg_width = max_x + padding;
let svg_height = max_y + padding;
let mut svg = String::new();
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" \
width=\"{}\" height=\"{}\" \
viewBox=\"0 0 {} {}\" \
aria-roledescription=\"mindmap\" \
role=\"graphics-document document\" \
style=\"max-width: 100%;\">",
svg_width, svg_height, svg_width, svg_height,
));
// Render edges first (behind nodes)
for edge in &placed_edges {
render_edge(&mut svg, edge);
}
// Render nodes
for node in &placed_nodes {
render_node(&mut svg, node);
}
svg.push_str("</svg>");
Ok(svg)
}
fn render_edge(svg: &mut String, edge: &PlacedEdge) {
let color = match edge.section {
Some(s) => section_color(s).edge,
None => "#333333",
};
// Stroke width based on depth: 17 - 3*depth, minimum 2
let stroke_width = (17.0 - 3.0 * edge.depth as f64).max(2.0);
// Curved edge using quadratic bezier
let mx = (edge.from_x + edge.to_x) / 2.0;
let my = (edge.from_y + edge.to_y) / 2.0;
svg.push_str(&format!(
"<path d=\"M {:.1},{:.1} Q {:.1},{:.1} {:.1},{:.1}\" \
fill=\"none\" stroke=\"{}\" stroke-width=\"{:.1}\" \
stroke-linecap=\"round\" />",
edge.from_x, edge.from_y, mx, my, edge.to_x, edge.to_y, color, stroke_width,
));
}
fn render_node(svg: &mut String, node: &PlacedNode) {
let (fill, text_color) = if node.is_root {
(ROOT_FILL.to_string(), ROOT_TEXT.to_string())
} else {
match node.section {
Some(s) => {
let sc = section_color(s);
(sc.fill.to_string(), sc.text.to_string())
}
None => ("#ECECFF".to_string(), "#333333".to_string()),
}
};
let cx = node.x;
let cy = node.y;
match node.node_type {
MindmapNodeType::Circle => {
let r = node.width / 2.0;
svg.push_str(&format!(
"<circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"{:.1}\" \
fill=\"{}\" stroke=\"none\" />",
cx, cy, r, fill,
));
}
MindmapNodeType::Rect => {
let x = cx - node.width / 2.0;
let y = cy - node.height / 2.0;
svg.push_str(&format!(
"<rect x=\"{:.1}\" y=\"{:.1}\" width=\"{:.1}\" height=\"{:.1}\" \
rx=\"0\" ry=\"0\" fill=\"{}\" stroke=\"none\" />",
x, y, node.width, node.height, fill,
));
}
MindmapNodeType::RoundedRect | MindmapNodeType::Default => {
let x = cx - node.width / 2.0;
let y = cy - node.height / 2.0;
// Corner radius = 5 matching Mermaid reference SVG path data
svg.push_str(&format!(
"<rect x=\"{:.1}\" y=\"{:.1}\" width=\"{:.1}\" height=\"{:.1}\" \
rx=\"5\" ry=\"5\" fill=\"{}\" stroke=\"none\" />",
x, y, node.width, node.height, fill,
));
}
MindmapNodeType::Hexagon => {
let x = cx - node.width / 2.0;
let y = cy - node.height / 2.0;
let inset = node.height / 4.0;
let points = format!(
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
x + inset,
y,
x + node.width - inset,
y,
x + node.width,
cy,
x + node.width - inset,
y + node.height,
x + inset,
y + node.height,
x,
cy,
);
svg.push_str(&format!(
"<polygon points=\"{}\" fill=\"{}\" stroke=\"none\" />",
points, fill,
));
}
MindmapNodeType::Cloud | MindmapNodeType::Bang => {
let rx = node.width / 2.0;
let ry = node.height / 2.0;
svg.push_str(&format!(
"<ellipse cx=\"{:.1}\" cy=\"{:.1}\" rx=\"{:.1}\" ry=\"{:.1}\" \
fill=\"{}\" stroke=\"none\" />",
cx, cy, rx, ry, fill,
));
}
}
// Render underline decoration (non-root nodes get a colored line below)
if !node.is_root {
match node.node_type {
MindmapNodeType::Circle => {}
_ => {
let x1 = cx - node.width / 2.0;
let x2 = cx + node.width / 2.0;
let line_y = cy + node.height / 2.0 + 5.0;
// Underline uses the complementary/inverted hue color from CSS
// (section-N line stroke in reference is the hue+180 version)
svg.push_str(&format!(
"<line x1=\"{:.1}\" y1=\"{:.1}\" x2=\"{:.1}\" y2=\"{:.1}\" \
stroke=\"{}\" stroke-width=\"3\" />",
x1, line_y, x2, line_y, fill,
));
}
}
}
// Render text
svg.push_str(&format!(
"<text x=\"{:.1}\" y=\"{:.1}\" \
text-anchor=\"middle\" dominant-baseline=\"central\" \
font-family=\"'trebuchet ms', verdana, arial, sans-serif\" \
font-size=\"{}\" fill=\"{}\">{}</text>",
cx,
cy,
FONT_SIZE,
text_color,
html_escape(&node.label),
));
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
+331
View File
@@ -0,0 +1,331 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
const DEFAULT_ROW_HEIGHT: f64 = 32.0;
const DEFAULT_BIT_WIDTH: f64 = 32.0;
const DEFAULT_BITS_PER_ROW: u32 = 32;
const DEFAULT_SHOW_BITS: bool = true;
const DEFAULT_PADDING_X: f64 = 5.0;
const DEFAULT_PADDING_Y: f64 = 5.0;
pub fn render_packet_diagram_to_svg(
mermaid_source: &str,
_theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let diagram = parse_packet_diagram(mermaid_source)?;
let padding_y = DEFAULT_PADDING_Y + if DEFAULT_SHOW_BITS { 10.0 } else { 0.0 };
let total_row_height = DEFAULT_ROW_HEIGHT + padding_y;
let svg_width = DEFAULT_BIT_WIDTH * (DEFAULT_BITS_PER_ROW as f64) + 2.0;
let svg_height = total_row_height * ((diagram.rows.len() + 1) as f64)
- if diagram.title.is_some() {
0.0
} else {
DEFAULT_ROW_HEIGHT
};
let mut svg = String::new();
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {svg_width} {svg_height}\">"
));
svg.push_str(
"<style>\
.packetByte{font-size:10px;}\
.packetByte.start{fill:black;}\
.packetByte.end{fill:black;}\
.packetLabel{fill:black;font-size:12px;}\
.packetTitle{fill:black;font-size:14px;}\
.packetBlock{stroke:black;stroke-width:1;fill:#efefef;}\
</style>",
);
svg.push_str("<g>");
for (row_idx, row) in diagram.rows.iter().enumerate() {
let word_y = row_idx as f64 * total_row_height + padding_y;
for block in row {
let block_x = 1.0 + (block.start % DEFAULT_BITS_PER_ROW) as f64 * DEFAULT_BIT_WIDTH;
let width =
(block.end - block.start + 1) as f64 * DEFAULT_BIT_WIDTH - DEFAULT_PADDING_X;
svg.push_str(&format!(
"<rect class=\"packetBlock\" x=\"{block_x}\" y=\"{word_y}\" width=\"{width}\" height=\"{DEFAULT_ROW_HEIGHT}\"/>"
));
let label_x = block_x + width / 2.0;
let label_y = word_y + DEFAULT_ROW_HEIGHT / 2.0;
svg.push_str(&format!(
"<text class=\"packetLabel\" x=\"{label_x}\" y=\"{label_y}\" text-anchor=\"middle\" dominant-baseline=\"middle\">{}</text>",
escape_xml(&block.label)
));
if DEFAULT_SHOW_BITS {
let bit_y = word_y - 2.0;
if block.start == block.end {
svg.push_str(&format!(
"<text class=\"packetByte start\" x=\"{label_x}\" y=\"{bit_y}\" text-anchor=\"middle\" dominant-baseline=\"auto\">{}</text>",
block.start
));
} else {
let end_x = block_x + width;
svg.push_str(&format!(
"<text class=\"packetByte start\" x=\"{block_x}\" y=\"{bit_y}\" text-anchor=\"start\" dominant-baseline=\"auto\">{}</text>",
block.start
));
svg.push_str(&format!(
"<text class=\"packetByte end\" x=\"{end_x}\" y=\"{bit_y}\" text-anchor=\"end\" dominant-baseline=\"auto\">{}</text>",
block.end
));
}
}
}
}
svg.push_str("</g>");
let title_x = svg_width / 2.0;
let title_y = svg_height - total_row_height / 2.0;
svg.push_str(&format!(
"<text class=\"packetTitle\" x=\"{title_x}\" y=\"{title_y}\" text-anchor=\"middle\" dominant-baseline=\"middle\">{}</text>",
diagram
.title
.as_deref()
.map(escape_xml)
.unwrap_or_default()
));
svg.push_str("</svg>");
Ok(svg)
}
#[derive(Debug, Clone)]
struct PacketDiagram {
title: Option<String>,
rows: Vec<Vec<PacketBlock>>,
}
#[derive(Debug, Clone)]
struct PacketBlock {
start: u32,
end: u32,
label: String,
}
fn parse_packet_diagram(input: &str) -> Result<PacketDiagram, MermaidError> {
let lines = input.lines().enumerate();
let mut found_header = false;
let mut title: Option<String> = None;
let mut blocks: Vec<PacketBlock> = Vec::new();
for (idx, raw) in lines {
let line_no = idx + 1;
let line = raw.trim();
if line.is_empty() || line.starts_with("%%") {
continue;
}
if !found_header {
if line.split_whitespace().next() != Some("packet-beta") {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected 'packet-beta' declaration".to_string(),
});
}
found_header = true;
continue;
}
if let Some(rest) = line.strip_prefix("title ") {
let t = rest.trim();
if !t.is_empty() {
title = Some(t.to_string());
}
continue;
}
let Some((range_raw, label_raw)) = line.split_once(':') else {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid packet block: {line}"),
});
};
let (start, end) = parse_range(range_raw.trim(), line_no)?;
let label = parse_label(label_raw.trim(), line_no)?;
blocks.push(PacketBlock { start, end, label });
}
if !found_header {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'packet-beta' declaration".to_string(),
});
}
if blocks.is_empty() {
return Err(MermaidError::ParseError {
line: 1,
message: "Packet diagram requires at least one block".to_string(),
});
}
ensure_contiguous(&blocks)?;
let rows = split_into_rows(blocks, DEFAULT_BITS_PER_ROW);
Ok(PacketDiagram { title, rows })
}
fn parse_range(s: &str, line: usize) -> Result<(u32, u32), MermaidError> {
let s = s.trim();
if let Some((start_str, end_str)) = s.split_once('-') {
let start: u32 = start_str
.trim()
.parse()
.map_err(|_| MermaidError::ParseError {
line,
message: format!("Invalid packet start: {start_str}"),
})?;
let end: u32 = end_str
.trim()
.parse()
.map_err(|_| MermaidError::ParseError {
line,
message: format!("Invalid packet end: {end_str}"),
})?;
if end < start {
return Err(MermaidError::ParseError {
line,
message: format!("Packet block {start}-{end} is invalid (end < start)"),
});
}
return Ok((start, end));
}
let start: u32 = s.parse().map_err(|_| MermaidError::ParseError {
line,
message: format!("Invalid packet bit index: {s}"),
})?;
Ok((start, start))
}
fn parse_label(s: &str, line: usize) -> Result<String, MermaidError> {
let s = s.trim();
if let Some(stripped) = s.strip_prefix('"').and_then(|t| t.strip_suffix('"')) {
return Ok(stripped.to_string());
}
if let Some(stripped) = s.strip_prefix('\'').and_then(|t| t.strip_suffix('\'')) {
return Ok(stripped.to_string());
}
if s.is_empty() {
return Err(MermaidError::ParseError {
line,
message: "Packet block label cannot be empty".to_string(),
});
}
Ok(s.to_string())
}
fn ensure_contiguous(blocks: &[PacketBlock]) -> Result<(), MermaidError> {
let mut last: Option<u32> = None;
for block in blocks {
if let Some(last_bit) = last {
if block.start != last_bit + 1 {
return Err(MermaidError::ParseError {
line: 1,
message: format!(
"Packet block {}-{} is not contiguous. It should start from {}.",
block.start,
block.end,
last_bit + 1
),
});
}
}
last = Some(block.end);
}
Ok(())
}
fn split_into_rows(blocks: Vec<PacketBlock>, bits_per_row: u32) -> Vec<Vec<PacketBlock>> {
let mut rows: Vec<Vec<PacketBlock>> = Vec::new();
let mut word: Vec<PacketBlock> = Vec::new();
let mut row = 1_u32;
for block in blocks {
let mut cur = block;
loop {
let (fitting, remainder) = split_block_at_row_boundary(&cur, row, bits_per_row);
word.push(fitting);
if word
.last()
.is_some_and(|b| b.end.saturating_add(1) == row.saturating_mul(bits_per_row))
{
rows.push(std::mem::take(&mut word));
row = row.saturating_add(1);
}
let Some(next) = remainder else {
break;
};
cur = next;
}
}
if !word.is_empty() {
rows.push(word);
}
rows
}
fn split_block_at_row_boundary(
block: &PacketBlock,
row: u32,
bits_per_row: u32,
) -> (PacketBlock, Option<PacketBlock>) {
let row_end_exclusive = row.saturating_mul(bits_per_row);
if block.end.saturating_add(1) <= row_end_exclusive {
return (block.clone(), None);
}
let first = PacketBlock {
start: block.start,
end: row_end_exclusive.saturating_sub(1),
label: block.label.clone(),
};
let second = PacketBlock {
start: row_end_exclusive,
end: block.end,
label: block.label.clone(),
};
(first, Some(second))
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+659
View File
@@ -0,0 +1,659 @@
use crate::ast::{
Edge, EdgeStyle, FlowchartGraph, GraphDirection, Node, NodeShape, Statement, StyleStatement,
Subgraph,
};
use crate::error::MermaidError;
pub fn parse_mermaid(input: &str) -> Result<FlowchartGraph, MermaidError> {
if let Some(first_line) = first_non_empty_non_comment_line(input) {
let first_token = first_line.split_whitespace().next().unwrap_or("");
if first_token != "graph"
&& first_token != "flowchart"
&& is_known_mermaid_type(first_token)
{
return Err(MermaidError::UnsupportedDiagramType(
first_token.to_string(),
));
}
}
let mut parser = Parser::new(input);
parser.parse()
}
fn normalize_label(label: &str) -> String {
let label = strip_wrapping_quotes(label.trim());
decode_html_entities(label)
.replace("\\n", "\n")
.replace("<br/>", "\n")
.replace("<br />", "\n")
.replace("<br>", "\n")
.replace("<BR/>", "\n")
.replace("<BR />", "\n")
.replace("<BR>", "\n")
}
fn decode_html_entities(label: &str) -> String {
label
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&amp;", "&")
}
fn strip_wrapping_quotes(label: &str) -> &str {
let bytes = label.as_bytes();
if bytes.len() >= 2
&& ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
|| (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
{
&label[1..label.len() - 1]
} else {
label
}
}
fn first_non_empty_non_comment_line(input: &str) -> Option<&str> {
input
.lines()
.map(|l| l.trim())
.find(|l| !l.is_empty() && !l.starts_with("%%"))
}
fn is_known_mermaid_type(token: &str) -> bool {
matches!(
token,
"sequenceDiagram"
| "classDiagram"
| "classDiagram-v2"
| "stateDiagram"
| "stateDiagram-v2"
| "erDiagram"
| "journey"
| "gantt"
| "pie"
| "mindmap"
| "timeline"
| "info"
| "kanban"
| "gitGraph"
| "requirementDiagram"
| "C4Context"
| "C4Container"
| "C4Component"
| "C4Dynamic"
| "C4Deployment"
| "sankey-beta"
| "packet-beta"
| "xychart-beta"
| "radar-beta"
| "block-beta"
| "flowchart-elk"
| "quadrantChart"
)
}
struct Parser<'a> {
lines: Vec<&'a str>,
current_line: usize,
next_subgraph_index: usize,
}
impl<'a> Parser<'a> {
fn new(input: &'a str) -> Self {
let lines: Vec<&str> = input.lines().collect();
Self {
lines,
current_line: 0,
next_subgraph_index: 0,
}
}
fn parse(&mut self) -> Result<FlowchartGraph, MermaidError> {
let direction = self.parse_graph_declaration()?;
let statements = self.parse_statements()?;
Ok(FlowchartGraph {
direction,
statements,
})
}
fn current_line_content(&self) -> Option<&'a str> {
self.lines.get(self.current_line).map(|s| s.trim())
}
fn advance(&mut self) {
self.current_line += 1;
}
fn skip_empty_lines(&mut self) {
while let Some(line) = self.current_line_content() {
if line.is_empty() || line.starts_with("%%") {
self.advance();
} else {
break;
}
}
}
fn parse_graph_declaration(&mut self) -> Result<GraphDirection, MermaidError> {
self.skip_empty_lines();
let line = self
.current_line_content()
.ok_or_else(|| MermaidError::ParseError {
line: self.current_line + 1,
message: "Expected graph declaration".to_string(),
})?;
let direction = if line.starts_with("graph ") || line.starts_with("flowchart ") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 2 {
return Err(MermaidError::ParseError {
line: self.current_line + 1,
message: "Expected direction after 'graph' or 'flowchart'".to_string(),
});
}
self.parse_direction(parts[1])?
} else {
return Err(MermaidError::ParseError {
line: self.current_line + 1,
message: "Expected 'graph' or 'flowchart' declaration".to_string(),
});
};
self.advance();
Ok(direction)
}
fn parse_direction(&self, dir: &str) -> Result<GraphDirection, MermaidError> {
match dir.to_uppercase().as_str() {
"TD" | "TB" => Ok(GraphDirection::TopToBottom),
"BT" => Ok(GraphDirection::BottomToTop),
"LR" => Ok(GraphDirection::LeftToRight),
"RL" => Ok(GraphDirection::RightToLeft),
_ => Err(MermaidError::InvalidDirection(dir.to_string())),
}
}
fn parse_statements(&mut self) -> Result<Vec<Statement>, MermaidError> {
let mut statements = Vec::new();
while self.current_line_content().is_some() {
self.skip_empty_lines();
let Some(line) = self.current_line_content() else {
break;
};
if line.is_empty() {
self.advance();
continue;
}
if line == "end" {
break;
}
if line.starts_with("subgraph ") {
statements.push(Statement::Subgraph(self.parse_subgraph()?));
} else if line.starts_with("style ") {
statements.push(Statement::Style(self.parse_style()?));
} else if self.line_contains_edge(line) {
let edge_statements = self.parse_edge_chain(line)?;
statements.extend(edge_statements);
self.advance();
} else {
if let Some(node) = self.try_parse_node(line) {
statements.push(Statement::Node(node));
}
self.advance();
}
}
Ok(statements)
}
fn line_contains_edge(&self, line: &str) -> bool {
self.find_edge_start(line).is_some()
}
fn parse_edge_chain(&mut self, line: &str) -> Result<Vec<Statement>, MermaidError> {
let mut statements = Vec::new();
let mut remaining = line.trim();
let mut collected_nodes: Vec<(String, Option<Node>)> = Vec::new();
let first_node_end = self.find_edge_start(remaining).unwrap_or(remaining.len());
let first_node_str = remaining[..first_node_end].trim();
if let Some(node) = self.try_parse_node(first_node_str) {
collected_nodes.push((node.id.clone(), Some(node)));
} else {
let id = self.extract_node_id(first_node_str);
collected_nodes.push((id, None));
}
remaining = &remaining[first_node_end..];
while !remaining.is_empty() {
let (edge_style, label, edge_len) = self.parse_edge_syntax(remaining)?;
remaining = remaining[edge_len..].trim_start();
let next_node_end = self.find_edge_start(remaining).unwrap_or(remaining.len());
let next_node_str = remaining[..next_node_end].trim();
if next_node_str.is_empty() {
break;
}
let (next_id, next_node) = if let Some(node) = self.try_parse_node(next_node_str) {
(node.id.clone(), Some(node))
} else {
let id = self.extract_node_id(next_node_str);
(id, None)
};
if let Some((from_id, _)) = collected_nodes.last() {
statements.push(Statement::Edge(Edge {
from: from_id.clone(),
to: next_id.clone(),
label,
style: edge_style,
}));
}
collected_nodes.push((next_id, next_node));
remaining = &remaining[next_node_end..];
}
let mut node_statements: Vec<Statement> = collected_nodes
.into_iter()
.filter_map(|(_, node_opt)| node_opt.map(Statement::Node))
.collect();
node_statements.append(&mut statements);
statements = node_statements;
Ok(statements)
}
/// Byte index where the first edge token starts, ignoring tokens inside
/// bracket/quote-delimited node labels (`[..]`, `(..)`, `{..}`, `".."`).
fn find_edge_start(&self, s: &str) -> Option<usize> {
const PATTERNS: [&str; 9] = ["-.->", "-.-", "-->", "---", "==>", "===", "--", "==", "-."];
let bytes = s.as_bytes();
let mut depth: usize = 0;
let mut in_quote = false;
for i in 0..bytes.len() {
let b = bytes[i];
if in_quote {
if b == b'"' {
in_quote = false;
}
continue;
}
match b {
b'"' => in_quote = true,
b'[' | b'(' | b'{' => depth += 1,
b']' | b')' | b'}' => depth = depth.saturating_sub(1),
_ if depth == 0 => {
if PATTERNS
.iter()
.any(|p| bytes[i..].starts_with(p.as_bytes()))
{
return Some(i);
}
}
_ => {}
}
}
None
}
fn parse_edge_syntax(
&self,
s: &str,
) -> Result<(EdgeStyle, Option<String>, usize), MermaidError> {
let s = s.trim_start();
let edge_patterns: &[(&str, EdgeStyle, &str)] = &[
("-->|", EdgeStyle::Arrow, "|"),
("---|", EdgeStyle::Line, "|"),
("-.->|", EdgeStyle::DottedArrow, "|"),
("-.-|", EdgeStyle::DottedLine, "|"),
("==>|", EdgeStyle::ThickArrow, "|"),
("===|", EdgeStyle::ThickLine, "|"),
("-->", EdgeStyle::Arrow, ""),
("---", EdgeStyle::Line, ""),
("-.->", EdgeStyle::DottedArrow, ""),
("-.-", EdgeStyle::DottedLine, ""),
("==>", EdgeStyle::ThickArrow, ""),
("===", EdgeStyle::ThickLine, ""),
];
for (pattern, style, label_end) in edge_patterns {
if let Some(after_pattern) = s.strip_prefix(pattern) {
if !label_end.is_empty() {
if let Some(end_idx) = after_pattern.find(label_end) {
let label = normalize_label(&after_pattern[..end_idx]);
let total_len = pattern.len() + end_idx + label_end.len();
return Ok((*style, Some(label), total_len));
}
} else {
return Ok((*style, None, pattern.len()));
}
}
}
// Open-label forms: `-- text -->`, `-- text ---`, `== text ==>`,
// `== text ===`, `-. text .->`, `-. text .-`.
let open_patterns: &[(&str, &[(&str, EdgeStyle)])] = &[
("--", &[("-->", EdgeStyle::Arrow), ("---", EdgeStyle::Line)]),
(
"==",
&[
("==>", EdgeStyle::ThickArrow),
("===", EdgeStyle::ThickLine),
],
),
(
"-.",
&[
(".->", EdgeStyle::DottedArrow),
(".-", EdgeStyle::DottedLine),
],
),
];
for (opener, closers) in open_patterns {
let Some(after) = s.strip_prefix(opener) else {
continue;
};
let mut best: Option<(usize, &str, EdgeStyle)> = None;
for (closer, style) in *closers {
if let Some(idx) = after.find(closer) {
let better = match best {
Some((best_idx, best_closer, _)) => {
idx < best_idx || (idx == best_idx && closer.len() > best_closer.len())
}
None => true,
};
if better {
best = Some((idx, closer, *style));
}
}
}
if let Some((idx, closer, style)) = best {
let label = normalize_label(&after[..idx]);
let total_len = opener.len() + idx + closer.len();
return Ok((style, Some(label), total_len));
}
}
Err(MermaidError::ParseError {
line: self.current_line + 1,
message: format!("Invalid edge syntax: {}", s),
})
}
fn extract_node_id(&self, s: &str) -> String {
let s = s.trim();
for (open, _close) in [('[', ']'), ('(', ')'), ('{', '}'), ('<', '>')] {
if let Some(idx) = s.find(open) {
return s[..idx].trim().to_string();
}
}
s.to_string()
}
fn try_parse_node(&self, s: &str) -> Option<Node> {
let s = s.trim();
if s.is_empty() {
return None;
}
if let Some(paren_paren_start) = s.find("((") {
if s.ends_with("))") {
let id = s[..paren_paren_start].trim().to_string();
let label = normalize_label(&s[paren_paren_start + 2..s.len() - 2]);
let id = if id.is_empty() {
label.chars().filter(|c| c.is_alphanumeric()).collect()
} else {
id
};
return Some(Node {
id,
label: Some(label),
shape: NodeShape::Circle,
});
}
}
if let Some(bracket_paren_start) = s.find("([") {
if s.ends_with("])") {
let id = s[..bracket_paren_start].trim().to_string();
let label = normalize_label(&s[bracket_paren_start + 2..s.len() - 2]);
let id = if id.is_empty() {
label.chars().filter(|c| c.is_alphanumeric()).collect()
} else {
id
};
return Some(Node {
id,
label: Some(label),
shape: NodeShape::Stadium,
});
}
}
if let Some(paren_bracket_start) = s.find("[(") {
if s.ends_with(")]") {
let id = s[..paren_bracket_start].trim().to_string();
let label = normalize_label(&s[paren_bracket_start + 2..s.len() - 2]);
let id = if id.is_empty() {
label.chars().filter(|c| c.is_alphanumeric()).collect()
} else {
id
};
return Some(Node {
id,
label: Some(label),
shape: NodeShape::Cylinder,
});
}
}
if let Some(bracket_bracket_start) = s.find("[[") {
if s.ends_with("]]") {
let id = s[..bracket_bracket_start].trim().to_string();
let label = normalize_label(&s[bracket_bracket_start + 2..s.len() - 2]);
let id = if id.is_empty() {
label.chars().filter(|c| c.is_alphanumeric()).collect()
} else {
id
};
return Some(Node {
id,
label: Some(label),
shape: NodeShape::Subroutine,
});
}
}
if let Some(brace_brace_start) = s.find("{{") {
if s.ends_with("}}") {
let id = s[..brace_brace_start].trim().to_string();
let label = normalize_label(&s[brace_brace_start + 2..s.len() - 2]);
let id = if id.is_empty() {
label.chars().filter(|c| c.is_alphanumeric()).collect()
} else {
id
};
return Some(Node {
id,
label: Some(label),
shape: NodeShape::Hexagon,
});
}
}
if let Some(bracket_start) = s.find('[') {
if s.ends_with(']') {
let id = s[..bracket_start].trim().to_string();
let label = normalize_label(&s[bracket_start + 1..s.len() - 1]);
let id = if id.is_empty() {
label.chars().filter(|c| c.is_alphanumeric()).collect()
} else {
id
};
return Some(Node {
id,
label: Some(label),
shape: NodeShape::Rectangle,
});
}
}
if let Some(paren_start) = s.find('(') {
if s.ends_with(')') && !s.ends_with("))") {
let id = s[..paren_start].trim().to_string();
let label = normalize_label(&s[paren_start + 1..s.len() - 1]);
let id = if id.is_empty() {
label.chars().filter(|c| c.is_alphanumeric()).collect()
} else {
id
};
return Some(Node {
id,
label: Some(label),
shape: NodeShape::RoundedRectangle,
});
}
}
if let Some(brace_start) = s.find('{') {
if s.ends_with('}') && !s.ends_with("}}") {
let id = s[..brace_start].trim().to_string();
let label = normalize_label(&s[brace_start + 1..s.len() - 1]);
let id = if id.is_empty() {
label.chars().filter(|c| c.is_alphanumeric()).collect()
} else {
id
};
return Some(Node {
id,
label: Some(label),
shape: NodeShape::Diamond,
});
}
}
if s.contains('>') && s.ends_with(']') {
if let Some(gt_idx) = s.find('>') {
let id = s[..gt_idx].trim().to_string();
let label = normalize_label(&s[gt_idx + 1..s.len() - 1]);
return Some(Node {
id,
label: Some(label),
shape: NodeShape::Asymmetric,
});
}
}
if s.chars().all(|c| c.is_alphanumeric() || c == '_') {
return Some(Node {
id: s.to_string(),
label: None,
shape: NodeShape::Rectangle,
});
}
None
}
fn parse_subgraph(&mut self) -> Result<Subgraph, MermaidError> {
let line = self
.current_line_content()
.ok_or_else(|| MermaidError::ParseError {
line: self.current_line + 1,
message: "Expected subgraph".to_string(),
})?;
let after_keyword = line.strip_prefix("subgraph ").unwrap_or("").trim();
let (id, title) = if let Some(bracket_start) = after_keyword.find('[') {
if after_keyword.ends_with(']') {
let id = after_keyword[..bracket_start].trim().to_string();
let title =
normalize_label(&after_keyword[bracket_start + 1..after_keyword.len() - 1]);
(id, Some(title))
} else {
(after_keyword.to_string(), None)
}
} else if after_keyword.split_whitespace().count() > 1 {
let id = format!("subGraph{}", self.next_subgraph_index);
self.next_subgraph_index += 1;
(id, Some(normalize_label(after_keyword)))
} else {
let id = after_keyword.to_string();
(id, None)
};
self.advance();
let statements = self.parse_statements()?;
if self.current_line_content() == Some("end") {
self.advance();
}
Ok(Subgraph {
id,
title,
statements,
})
}
fn parse_style(&mut self) -> Result<StyleStatement, MermaidError> {
let line = self
.current_line_content()
.ok_or_else(|| MermaidError::ParseError {
line: self.current_line + 1,
message: "Expected style statement".to_string(),
})?;
let after_keyword = line.strip_prefix("style ").unwrap_or("").trim();
let parts: Vec<&str> = after_keyword.splitn(2, ' ').collect();
if parts.is_empty() {
return Err(MermaidError::ParseError {
line: self.current_line + 1,
message: "Expected node id after 'style'".to_string(),
});
}
let node_id = parts[0].to_string();
let properties = if parts.len() > 1 {
parts[1]
.split(',')
.filter_map(|prop| {
let kv: Vec<&str> = prop.splitn(2, ':').collect();
if kv.len() == 2 {
Some((kv[0].trim().to_string(), kv[1].trim().to_string()))
} else {
None
}
})
.collect()
} else {
Vec::new()
};
self.advance();
Ok(StyleStatement {
node_id,
properties,
})
}
}
+309
View File
@@ -0,0 +1,309 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
use std::f64::consts::PI;
/// Mermaid 11.12.2 default pie chart colors (from the default theme).
/// pie1 = primaryColor (#ECECFF), pie2 = secondaryColor (#ffffde),
/// pie3pie12 computed via adjust/darken on primary/secondary/tertiary.
const MERMAID_PIE_COLORS: [&str; 12] = [
"#ECECFF", // pie1 primaryColor
"#ffffde", // pie2 secondaryColor
"hsl(80, 100%, 56.2745098039%)", // pie3 adjust(tertiaryColor, l:-40)
"hsl(240, 60%, 86.2745098039%)", // pie4 adjust(primaryColor, l:-10)
"hsl(120, 100%, 66.2745098039%)", // pie5 adjust(secondaryColor, l:-30)
"hsl(80, 100%, 76.2745098039%)", // pie6 adjust(tertiaryColor, l:-20)
"hsl(300, 60%, 76.2745098039%)", // pie7 adjust(primaryColor, h:60, l:-20)
"hsl(180, 60%, 56.2745098039%)", // pie8 adjust(primaryColor, h:-60, l:-40)
"hsl(0, 60%, 56.2745098039%)", // pie9 adjust(primaryColor, h:120, l:-40)
"hsl(300, 60%, 56.2745098039%)", // pie10 adjust(primaryColor, h:60, l:-40)
"hsl(150, 60%, 56.2745098039%)", // pie11 adjust(primaryColor, h:-90, l:-40)
"hsl(0, 60%, 66.2745098039%)", // pie12 adjust(primaryColor, h:120, l:-30)
];
// Mermaid 11.12.2 pie chart constants (from pieRenderer.ts and default config).
const PIE_HEIGHT: f64 = 450.0;
const PIE_WIDTH: f64 = 450.0;
const MARGIN: f64 = 40.0;
const RADIUS: f64 = (PIE_WIDTH / 2.0) - MARGIN; // 185
const OUTER_STROKE_WIDTH: f64 = 2.0;
const OUTER_RADIUS: f64 = RADIUS + OUTER_STROKE_WIDTH / 2.0; // 186
const TEXT_POSITION: f64 = 0.75;
const LEGEND_RECT_SIZE: f64 = 18.0;
const LEGEND_SPACING: f64 = 4.0;
const FONT_FAMILY: &str = "trebuchet ms,verdana,arial,sans-serif";
pub fn render_pie_diagram_to_svg(
mermaid_source: &str,
_theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let chart = parse_pie_diagram(mermaid_source)?;
let total: f64 = chart.slices.iter().map(|s| s.value).sum();
if total <= 0.0 {
return Err(MermaidError::ParseError {
line: 1,
message: "Pie diagram total must be > 0".to_string(),
});
}
// Filter slices ≥1% and sort descending by value (matches d3.pie() default).
let mut slices: Vec<&PieSlice> = chart
.slices
.iter()
.filter(|s| s.value / total * 100.0 >= 1.0)
.collect();
slices.sort_by(|a, b| b.value.partial_cmp(&a.value).unwrap());
// All slices for the legend (unfiltered, original order).
let all_slices: Vec<&PieSlice> = chart.slices.iter().collect();
// Center of the pie in the translated group coordinate system is (0, 0).
let cx = PIE_WIDTH / 2.0;
let cy = PIE_HEIGHT / 2.0;
// Estimate legend text width (rough: 10px per char at 17px font).
let longest_label_len = all_slices
.iter()
.map(|s| {
if chart.show_data {
format!("{} [{}]", s.label, s.value).len()
} else {
s.label.len()
}
})
.max()
.unwrap_or(0);
let legend_text_width = longest_label_len as f64 * 10.0;
let total_width = PIE_WIDTH + MARGIN + LEGEND_RECT_SIZE + LEGEND_SPACING + legend_text_width;
let mut svg = String::new();
// Mermaid uses a CSS style block for pie chart classes.
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {total_width:.4} {PIE_HEIGHT}\" \
style=\"max-width: {total_width:.3}px; background-color: white;\" \
role=\"graphics-document document\" aria-roledescription=\"pie\">"
));
// Inline CSS matching Mermaid 11.12.2 pieStyles.
svg.push_str(&format!(
"<style>\
.pieCircle{{stroke:black;stroke-width:2px;opacity:0.7;}}\
.pieOuterCircle{{stroke:black;stroke-width:2px;fill:none;}}\
.pieTitleText{{text-anchor:middle;font-size:25px;fill:black;font-family:{FONT_FAMILY};}}\
.slice{{font-family:{FONT_FAMILY};fill:#333;font-size:17px;}}\
.legend text{{fill:black;font-family:{FONT_FAMILY};font-size:17px;}}\
</style>"
));
// Group translated to pie center (matches mermaid: translate(pieWidth/2, height/2)).
svg.push_str(&format!("<g transform=\"translate({cx},{cy})\">"));
// Outer circle.
svg.push_str(&format!(
"<circle cx=\"0\" cy=\"0\" r=\"{OUTER_RADIUS}\" class=\"pieOuterCircle\"/>"
));
// Draw pie slices.
// d3.pie() default: startAngle=0 (12 o'clock), endAngle=2π, clockwise.
// In SVG with translate to center, angle 0 points up (-y).
let mut angle = -PI / 2.0;
for (idx, slice) in slices.iter().enumerate() {
let pct = (slice.value / total * 100.0).round() as i64;
if pct == 0 {
continue;
}
let frac = slice.value / total;
let sweep = frac * 2.0 * PI;
let next = angle + sweep;
let (x0, y0) = polar(0.0, 0.0, RADIUS, angle);
let (x1, y1) = polar(0.0, 0.0, RADIUS, next);
let large_arc = if sweep > PI { 1 } else { 0 };
let fill = MERMAID_PIE_COLORS[idx % MERMAID_PIE_COLORS.len()];
// Slice path.
svg.push_str(&format!(
"<path d=\"M0,0L{x0:.3},{y0:.3}A{RADIUS},{RADIUS},0,{large_arc},1,{x1:.3},{y1:.3}Z\" \
fill=\"{fill}\" class=\"pieCircle\"/>"
));
// Percentage label inside the slice at textPosition (0.75) of radius.
let label_r = RADIUS * TEXT_POSITION;
let mid = angle + sweep / 2.0;
let (lx, ly) = polar(0.0, 0.0, label_r, mid);
svg.push_str(&format!(
"<text transform=\"translate({lx:.3},{ly:.3})\" class=\"slice\" \
style=\"text-anchor: middle;\">{pct}%</text>"
));
angle = next;
}
// Title (positioned above the pie).
if let Some(title) = &chart.title {
let title_y = -((PIE_HEIGHT - 50.0) / 2.0);
svg.push_str(&format!(
"<text x=\"0\" y=\"{title_y:.0}\" class=\"pieTitleText\">{}</text>",
escape_xml(title)
));
}
// Legend (to the right of the pie).
let legend_h = LEGEND_RECT_SIZE + LEGEND_SPACING;
let legend_offset = legend_h * all_slices.len() as f64 / 2.0;
let legend_x = 12.0 * LEGEND_RECT_SIZE; // 216
// Build a color map that assigns colors to labels in the same order as the
// sorted/filtered slices (matching d3.scaleOrdinal behavior).
let mut color_map: Vec<(&str, &str)> = Vec::new();
for (idx, slice) in slices.iter().enumerate() {
color_map.push((
&slice.label,
MERMAID_PIE_COLORS[idx % MERMAID_PIE_COLORS.len()],
));
}
for (legend_idx, slice) in all_slices.iter().enumerate() {
let vert = legend_idx as f64 * legend_h - legend_offset;
let color = color_map
.iter()
.find(|(label, _)| *label == slice.label)
.map(|(_, c)| *c)
.unwrap_or(MERMAID_PIE_COLORS[legend_idx % MERMAID_PIE_COLORS.len()]);
svg.push_str(&format!(
"<g class=\"legend\" transform=\"translate({legend_x},{vert})\">"
));
svg.push_str(&format!(
"<rect width=\"{LEGEND_RECT_SIZE}\" height=\"{LEGEND_RECT_SIZE}\" \
style=\"fill: {color}; stroke: {color};\"/>"
));
let label_text = if chart.show_data {
format!("{} [{}]", slice.label, slice.value)
} else {
slice.label.clone()
};
let text_x = LEGEND_RECT_SIZE + LEGEND_SPACING;
let text_y = LEGEND_RECT_SIZE - LEGEND_SPACING;
svg.push_str(&format!(
"<text x=\"{text_x}\" y=\"{text_y}\">{}</text>",
escape_xml(&label_text)
));
svg.push_str("</g>");
}
svg.push_str("</g>"); // close main group
svg.push_str("</svg>");
Ok(svg)
}
#[derive(Debug, Clone)]
struct PieChart {
title: Option<String>,
show_data: bool,
slices: Vec<PieSlice>,
}
#[derive(Debug, Clone)]
struct PieSlice {
label: String,
value: f64,
}
fn parse_pie_diagram(input: &str) -> Result<PieChart, MermaidError> {
let lines: Vec<&str> = input.lines().collect();
let mut i = 0_usize;
let mut show_data = false;
while i < lines.len() {
let line = lines[i].trim();
if line.is_empty() || line.starts_with("%%") {
i += 1;
continue;
}
let mut tokens = line.split_whitespace();
let first = tokens.next().unwrap_or("");
if first == "pie" {
show_data = tokens.any(|t| t == "showData");
i += 1;
break;
}
return Err(MermaidError::ParseError {
line: i + 1,
message: "Expected 'pie' declaration".to_string(),
});
}
let mut title: Option<String> = None;
let mut slices: Vec<PieSlice> = Vec::new();
while i < lines.len() {
let raw = lines[i];
let line = raw.trim();
let line_no = i + 1;
i += 1;
if line.is_empty() || line.starts_with("%%") {
continue;
}
if let Some(rest) = line.strip_prefix("title ") {
let t = rest.trim();
if !t.is_empty() {
title = Some(t.to_string());
}
continue;
}
let Some((label_raw, value_raw)) = line.split_once(':') else {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid pie slice: {line}"),
});
};
let mut label = label_raw.trim().to_string();
if let Some(stripped) = label.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
label = stripped.to_string();
}
let value_str = value_raw.trim();
let value: f64 = value_str.parse().map_err(|_| MermaidError::ParseError {
line: line_no,
message: format!("Invalid pie value: {value_str}"),
})?;
slices.push(PieSlice { label, value });
}
if slices.is_empty() {
return Err(MermaidError::ParseError {
line: 1,
message: "Pie diagram requires at least one slice".to_string(),
});
}
Ok(PieChart {
title,
show_data,
slices,
})
}
fn polar(cx: f64, cy: f64, r: f64, angle: f64) -> (f64, f64) {
(cx + r * angle.cos(), cy + r * angle.sin())
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+540
View File
@@ -0,0 +1,540 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
use std::collections::BTreeMap;
/// Quadrant chart theme colors, derived from the mermaid.js default theme.
/// Mermaid uses `primaryColor = "#ECECFF"` and derives fills by adjusting RGB channels.
struct QuadrantTheme<'a> {
quadrant1_fill: &'a str,
quadrant2_fill: &'a str,
quadrant3_fill: &'a str,
quadrant4_fill: &'a str,
border_stroke: &'a str,
title_fill: &'a str,
axis_text_fill: &'a str,
point_fill: &'a str,
point_text_fill: &'a str,
quadrant_text_fill: &'a str,
}
fn quadrant_theme_for(theme: &MermaidTheme) -> QuadrantTheme<'static> {
let is_dark = theme.background.starts_with("#1") || theme.background.starts_with("#0");
if is_dark {
QuadrantTheme {
quadrant1_fill: "#1f2020",
quadrant2_fill: "#242525",
quadrant3_fill: "#292a2a",
quadrant4_fill: "#2e2f2f",
border_stroke: "#e0dfdf",
title_fill: "#ccc",
axis_text_fill: "#ccc",
point_fill: "#ccc",
point_text_fill: "#ccc",
quadrant_text_fill: "#ccc",
}
} else {
// Default (light) theme: primaryColor = "#ECECFF"
// quadrant fills = primaryColor + adjust({r: N, g: N, b: N}) for N in 0,5,10,15
// border = mkBorder("#ECECFF", false) = adjust("#ECECFF", {s:-40, l:-10}) = #C7C7F1
// point fill = darken("#ECECFF") ≈ #333333 (text color in practice)
QuadrantTheme {
quadrant1_fill: "#ECECFF",
quadrant2_fill: "#F1F1FF",
quadrant3_fill: "#F6F6FF",
quadrant4_fill: "#FBFBFF",
border_stroke: "#C7C7F1",
title_fill: "#333333",
axis_text_fill: "#333333",
point_fill: "#333333",
point_text_fill: "#333333",
quadrant_text_fill: "#333333",
}
}
}
/// Mermaid.js default config values for quadrant charts.
const CHART_WIDTH: f64 = 500.0;
const CHART_HEIGHT: f64 = 500.0;
const TITLE_FONT_SIZE: f64 = 20.0;
const TITLE_PADDING: f64 = 10.0;
const QUADRANT_PADDING: f64 = 5.0;
const X_AXIS_LABEL_PADDING: f64 = 5.0;
const Y_AXIS_LABEL_PADDING: f64 = 5.0;
const X_AXIS_LABEL_FONT_SIZE: f64 = 16.0;
const Y_AXIS_LABEL_FONT_SIZE: f64 = 16.0;
const QUADRANT_LABEL_FONT_SIZE: f64 = 16.0;
const QUADRANT_TEXT_TOP_PADDING: f64 = 5.0;
const POINT_TEXT_PADDING: f64 = 5.0;
const POINT_LABEL_FONT_SIZE: f64 = 12.0;
const POINT_RADIUS: f64 = 5.0;
const INTERNAL_BORDER_STROKE_WIDTH: f64 = 1.0;
const EXTERNAL_BORDER_STROKE_WIDTH: f64 = 2.0;
const FONT_FAMILY: &str = "trebuchet ms,verdana,arial,sans-serif";
pub fn render_quadrant_chart_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let chart = parse_quadrant_chart(mermaid_source)?;
let qt = quadrant_theme_for(theme);
let has_points = !chart.points.is_empty();
let show_title = chart.title.is_some();
let show_x_axis = chart.x_axis.is_some();
let show_y_axis = chart.y_axis.is_some();
// x-axis goes to bottom when points exist, top otherwise
let x_axis_bottom = has_points;
// Space calculations (matches mermaid.js QuadrantBuilder.calculateSpace)
let x_axis_space = if show_x_axis {
X_AXIS_LABEL_PADDING * 2.0 + X_AXIS_LABEL_FONT_SIZE
} else {
0.0
};
let y_axis_space_left = if show_y_axis {
Y_AXIS_LABEL_PADDING * 2.0 + Y_AXIS_LABEL_FONT_SIZE
} else {
0.0
};
let title_space_top = if show_title {
TITLE_FONT_SIZE + TITLE_PADDING * 2.0
} else {
0.0
};
let x_axis_top = if !x_axis_bottom { x_axis_space } else { 0.0 };
let x_axis_bot = if x_axis_bottom { x_axis_space } else { 0.0 };
let quadrant_left = QUADRANT_PADDING + y_axis_space_left;
let quadrant_top = QUADRANT_PADDING + x_axis_top + title_space_top;
let quadrant_width = CHART_WIDTH - QUADRANT_PADDING * 2.0 - y_axis_space_left;
let quadrant_height =
CHART_HEIGHT - QUADRANT_PADDING * 2.0 - x_axis_top - x_axis_bot - title_space_top;
let half_w = quadrant_width / 2.0;
let half_h = quadrant_height / 2.0;
let half_ext = EXTERNAL_BORDER_STROKE_WIDTH / 2.0;
let mut svg = String::new();
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {CHART_WIDTH} {CHART_HEIGHT}\">"
));
svg.push_str(&format!(
"<rect x=\"0\" y=\"0\" width=\"{CHART_WIDTH}\" height=\"{CHART_HEIGHT}\" fill=\"{}\"/>",
theme.background
));
// --- Quadrant fill rects (draw FIRST, behind everything else) ---
let q1_text = chart.quadrants.get(&1).cloned().unwrap_or_default();
let q2_text = chart.quadrants.get(&2).cloned().unwrap_or_default();
let q3_text = chart.quadrants.get(&3).cloned().unwrap_or_default();
let q4_text = chart.quadrants.get(&4).cloned().unwrap_or_default();
// Mermaid quadrant layout:
// Q1 = top-right, Q2 = top-left, Q3 = bottom-left, Q4 = bottom-right
let quadrant_rects: [(f64, f64, &str); 4] = [
(quadrant_left + half_w, quadrant_top, qt.quadrant1_fill), // Q1 top-right
(quadrant_left, quadrant_top, qt.quadrant2_fill), // Q2 top-left
(quadrant_left, quadrant_top + half_h, qt.quadrant3_fill), // Q3 bottom-left
(
quadrant_left + half_w,
quadrant_top + half_h,
qt.quadrant4_fill,
), // Q4 bottom-right
];
for (rx, ry, fill) in &quadrant_rects {
svg.push_str(&format!(
"<rect x=\"{rx:.1}\" y=\"{ry:.1}\" width=\"{half_w:.1}\" height=\"{half_h:.1}\" fill=\"{fill}\"/>"
));
}
// --- Quadrant labels ---
let quadrant_labels: [(&str, f64, f64); 4] = [
(
&q1_text,
quadrant_left + half_w + half_w / 2.0,
quadrant_top,
),
(&q2_text, quadrant_left + half_w / 2.0, quadrant_top),
(
&q3_text,
quadrant_left + half_w / 2.0,
quadrant_top + half_h,
),
(
&q4_text,
quadrant_left + half_w + half_w / 2.0,
quadrant_top + half_h,
),
];
for (text, tx, ty_base) in &quadrant_labels {
if text.is_empty() {
continue;
}
// When points exist, labels go to top of quadrant; otherwise center
let ty = if has_points {
ty_base + QUADRANT_TEXT_TOP_PADDING
} else {
ty_base + half_h / 2.0
};
let dominant_baseline = if has_points { "hanging" } else { "middle" };
svg.push_str(&format!(
"<text x=\"{tx:.1}\" y=\"{ty:.1}\" text-anchor=\"middle\" dominant-baseline=\"{dominant_baseline}\" \
font-family=\"{FONT_FAMILY}\" font-size=\"{QUADRANT_LABEL_FONT_SIZE}\" \
fill=\"{fill}\">{}</text>",
escape_xml(text),
fill = qt.quadrant_text_fill
));
}
// --- Border lines (external + internal, all solid) ---
// External border: 4 lines forming the outer rectangle
let ext_lines: [(f64, f64, f64, f64); 4] = [
// top
(
quadrant_left - half_ext,
quadrant_top,
quadrant_left + quadrant_width + half_ext,
quadrant_top,
),
// right
(
quadrant_left + quadrant_width,
quadrant_top + half_ext,
quadrant_left + quadrant_width,
quadrant_top + quadrant_height - half_ext,
),
// bottom
(
quadrant_left - half_ext,
quadrant_top + quadrant_height,
quadrant_left + quadrant_width + half_ext,
quadrant_top + quadrant_height,
),
// left
(
quadrant_left,
quadrant_top + half_ext,
quadrant_left,
quadrant_top + quadrant_height - half_ext,
),
];
for (x1, y1, x2, y2) in &ext_lines {
svg.push_str(&format!(
"<line x1=\"{x1:.1}\" y1=\"{y1:.1}\" x2=\"{x2:.1}\" y2=\"{y2:.1}\" \
stroke=\"{stroke}\" stroke-width=\"{EXTERNAL_BORDER_STROKE_WIDTH}\"/>",
stroke = qt.border_stroke
));
}
// Internal dividers (solid lines, no dash)
// Vertical
svg.push_str(&format!(
"<line x1=\"{x:.1}\" y1=\"{y1:.1}\" x2=\"{x:.1}\" y2=\"{y2:.1}\" \
stroke=\"{stroke}\" stroke-width=\"{INTERNAL_BORDER_STROKE_WIDTH}\"/>",
x = quadrant_left + half_w,
y1 = quadrant_top + half_ext,
y2 = quadrant_top + quadrant_height - half_ext,
stroke = qt.border_stroke
));
// Horizontal
svg.push_str(&format!(
"<line x1=\"{x1:.1}\" y1=\"{y:.1}\" x2=\"{x2:.1}\" y2=\"{y:.1}\" \
stroke=\"{stroke}\" stroke-width=\"{INTERNAL_BORDER_STROKE_WIDTH}\"/>",
x1 = quadrant_left + half_ext,
y = quadrant_top + half_h,
x2 = quadrant_left + quadrant_width - half_ext,
stroke = qt.border_stroke
));
// --- Axis labels ---
let draw_x_labels_in_middle = chart
.x_axis
.as_ref()
.is_some_and(|(_, high)| !high.is_empty());
let draw_y_labels_in_middle = chart
.y_axis
.as_ref()
.is_some_and(|(_, high)| !high.is_empty());
if let Some((low, high)) = &chart.x_axis {
let x_axis_y = if x_axis_bottom {
X_AXIS_LABEL_PADDING + quadrant_top + quadrant_height + QUADRANT_PADDING
} else {
X_AXIS_LABEL_PADDING + title_space_top
};
let low_x = quadrant_left
+ if draw_x_labels_in_middle {
half_w / 2.0
} else {
0.0
};
let text_anchor_low = if draw_x_labels_in_middle {
"middle"
} else {
"start"
};
svg.push_str(&format!(
"<text x=\"{low_x:.1}\" y=\"{x_axis_y:.1}\" text-anchor=\"{text_anchor_low}\" dominant-baseline=\"hanging\" \
font-family=\"{FONT_FAMILY}\" font-size=\"{X_AXIS_LABEL_FONT_SIZE}\" \
fill=\"{fill}\">{}</text>",
escape_xml(low),
fill = qt.axis_text_fill
));
if !high.is_empty() {
let high_x = quadrant_left
+ half_w
+ if draw_x_labels_in_middle {
half_w / 2.0
} else {
0.0
};
svg.push_str(&format!(
"<text x=\"{high_x:.1}\" y=\"{x_axis_y:.1}\" text-anchor=\"middle\" dominant-baseline=\"hanging\" \
font-family=\"{FONT_FAMILY}\" font-size=\"{X_AXIS_LABEL_FONT_SIZE}\" \
fill=\"{fill}\">{}</text>",
escape_xml(high),
fill = qt.axis_text_fill
));
}
}
if let Some((low, high)) = &chart.y_axis {
let y_axis_x = Y_AXIS_LABEL_PADDING;
// Bottom label (low value) — rotated -90° at (y_axis_x, low_y)
let low_y = quadrant_top + quadrant_height
- if draw_y_labels_in_middle {
half_h / 2.0
} else {
0.0
};
svg.push_str(&format!(
"<text x=\"0\" y=\"0\" text-anchor=\"middle\" dominant-baseline=\"hanging\" \
font-family=\"{FONT_FAMILY}\" font-size=\"{Y_AXIS_LABEL_FONT_SIZE}\" \
fill=\"{fill}\" transform=\"translate({y_axis_x:.1}, {low_y:.1}) rotate(-90)\">{}</text>",
escape_xml(low),
fill = qt.axis_text_fill
));
// Top label (high value) — rotated -90° at (y_axis_x, high_y)
if !high.is_empty() {
let high_y = quadrant_top + half_h
- if draw_y_labels_in_middle {
half_h / 2.0
} else {
0.0
};
svg.push_str(&format!(
"<text x=\"0\" y=\"0\" text-anchor=\"middle\" dominant-baseline=\"hanging\" \
font-family=\"{FONT_FAMILY}\" font-size=\"{Y_AXIS_LABEL_FONT_SIZE}\" \
fill=\"{fill}\" transform=\"translate({y_axis_x:.1}, {high_y:.1}) rotate(-90)\">{}</text>",
escape_xml(high),
fill = qt.axis_text_fill
));
}
}
// --- Title ---
if let Some(title) = &chart.title {
svg.push_str(&format!(
"<text x=\"{x:.1}\" y=\"{y:.1}\" text-anchor=\"middle\" dominant-baseline=\"hanging\" \
font-family=\"{FONT_FAMILY}\" font-size=\"{TITLE_FONT_SIZE}\" \
fill=\"{fill}\">{}</text>",
escape_xml(title),
x = CHART_WIDTH / 2.0,
y = TITLE_PADDING,
fill = qt.title_fill
));
}
// --- Data points ---
for point in &chart.points {
let px = quadrant_left + point.x.clamp(0.0, 1.0) * quadrant_width;
let py = quadrant_top + (1.0 - point.y.clamp(0.0, 1.0)) * quadrant_height;
svg.push_str(&format!(
"<circle cx=\"{px:.1}\" cy=\"{py:.1}\" r=\"{POINT_RADIUS}\" \
fill=\"{fill}\" stroke=\"{fill}\" stroke-width=\"0\"/>",
fill = qt.point_fill
));
svg.push_str(&format!(
"<text x=\"0\" y=\"0\" text-anchor=\"middle\" dominant-baseline=\"hanging\" \
font-family=\"{FONT_FAMILY}\" font-size=\"{POINT_LABEL_FONT_SIZE}\" \
fill=\"{fill}\" transform=\"translate({px:.1}, {ty:.1})\">{}</text>",
escape_xml(&point.label),
fill = qt.point_text_fill,
ty = py + POINT_TEXT_PADDING
));
}
svg.push_str("</svg>");
Ok(svg)
}
#[derive(Debug, Clone)]
struct QuadrantChart {
title: Option<String>,
x_axis: Option<(String, String)>,
y_axis: Option<(String, String)>,
quadrants: BTreeMap<i32, String>,
points: Vec<QuadrantPoint>,
}
#[derive(Debug, Clone)]
struct QuadrantPoint {
label: String,
x: f64,
y: f64,
}
fn parse_quadrant_chart(input: &str) -> Result<QuadrantChart, MermaidError> {
let lines: Vec<&str> = input.lines().collect();
let mut i = 0_usize;
while i < lines.len() {
let line = lines[i].trim();
if line.is_empty() || line.starts_with("%%") {
i += 1;
continue;
}
if line.split_whitespace().next() == Some("quadrantChart") {
i += 1;
break;
}
return Err(MermaidError::ParseError {
line: i + 1,
message: "Expected 'quadrantChart' declaration".to_string(),
});
}
let mut title: Option<String> = None;
let mut x_axis: Option<(String, String)> = None;
let mut y_axis: Option<(String, String)> = None;
let mut quadrants: BTreeMap<i32, String> = BTreeMap::new();
let mut points: Vec<QuadrantPoint> = Vec::new();
while i < lines.len() {
let raw = lines[i];
let line = raw.trim();
let line_no = i + 1;
i += 1;
if line.is_empty() || line.starts_with("%%") {
continue;
}
if let Some(rest) = line.strip_prefix("title ") {
let t = rest.trim();
if !t.is_empty() {
title = Some(t.to_string());
}
continue;
}
if let Some(rest) = line.strip_prefix("x-axis ") {
x_axis = Some(parse_axis(rest.trim(), line_no)?);
continue;
}
if let Some(rest) = line.strip_prefix("y-axis ") {
y_axis = Some(parse_axis(rest.trim(), line_no)?);
continue;
}
if let Some(rest) = line.strip_prefix("quadrant-") {
let Some((n_str, label)) = rest.split_once(' ') else {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid quadrant label: {line}"),
});
};
let n: i32 = n_str.trim().parse().map_err(|_| MermaidError::ParseError {
line: line_no,
message: format!("Invalid quadrant label: {line}"),
})?;
quadrants.insert(n, label.trim().to_string());
continue;
}
let Some((label_raw, coords_raw)) = line.split_once(':') else {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid quadrant point: {line}"),
});
};
let mut label = label_raw.trim().to_string();
if let Some(stripped) = label.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
label = stripped.to_string();
}
let coords = coords_raw.trim();
let coords = coords
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.ok_or_else(|| MermaidError::ParseError {
line: line_no,
message: format!("Invalid quadrant point: {line}"),
})?;
let parts: Vec<&str> = coords.split(',').map(|p| p.trim()).collect();
if parts.len() != 2 {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid quadrant point: {line}"),
});
}
let x: f64 = parts[0].parse().map_err(|_| MermaidError::ParseError {
line: line_no,
message: format!("Invalid quadrant point: {line}"),
})?;
let y: f64 = parts[1].parse().map_err(|_| MermaidError::ParseError {
line: line_no,
message: format!("Invalid quadrant point: {line}"),
})?;
points.push(QuadrantPoint { label, x, y });
}
if points.is_empty() {
return Err(MermaidError::ParseError {
line: 1,
message: "Quadrant chart requires at least one point".to_string(),
});
}
Ok(QuadrantChart {
title,
x_axis,
y_axis,
quadrants,
points,
})
}
fn parse_axis(s: &str, line_no: usize) -> Result<(String, String), MermaidError> {
let Some((a, b)) = s.split_once("-->") else {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid axis: {s}"),
});
};
Ok((a.trim().to_string(), b.trim().to_string()))
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+281
View File
@@ -0,0 +1,281 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
use std::f64::consts::PI;
const WIDTH: f64 = 600.0;
const HEIGHT: f64 = 600.0;
const MARGIN: f64 = 50.0;
const AXIS_SCALE_FACTOR: f64 = 1.0;
const AXIS_LABEL_FACTOR: f64 = 1.05;
const CURVE_TENSION: f64 = 0.17;
const DEFAULT_TICKS: usize = 5;
const DEFAULT_MIN: f64 = 0.0;
const AXIS_COLOR: &str = "#333333";
const GRATICULE_COLOR: &str = "#DEDEDE";
const GRATICULE_OPACITY: f64 = 0.3;
const CURVE_COLOR_0: &str = "hsl(240, 100%, 76.2745098039%)";
pub fn render_radar_diagram_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let diagram = parse_radar(mermaid_source)?;
if diagram.axes.is_empty() {
return Err(MermaidError::ParseError {
line: 1,
message: "radar diagram requires at least one axis".to_string(),
});
}
let max_value = diagram
.curves
.iter()
.flat_map(|c| c.values.iter().copied())
.fold(0.0, f64::max)
.max(1.0);
let total_width = WIDTH + 2.0 * MARGIN;
let total_height = HEIGHT + 2.0 * MARGIN;
let center_x = MARGIN + WIDTH / 2.0;
let center_y = MARGIN + HEIGHT / 2.0;
let radius = WIDTH.min(HEIGHT) / 2.0;
let mut svg = String::new();
svg.push_str(&format!(
"<svg aria-roledescription=\"radar\" role=\"graphics-document document\" height=\"{total_height}\" viewBox=\"0 0 {total_width} {total_height}\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"{total_width}\" id=\"my-svg\" style=\"background-color: {};\">",
theme.background
));
svg.push_str("<style>");
svg.push_str(&format!(
"#my-svg .radarAxisLine{{stroke:{AXIS_COLOR};stroke-width:2;}}"
));
svg.push_str(&format!(
"#my-svg .radarAxisLabel{{dominant-baseline:middle;text-anchor:middle;font-size:12px;color:{AXIS_COLOR};}}"
));
svg.push_str(&format!(
"#my-svg .radarGraticule{{fill:{GRATICULE_COLOR};fill-opacity:{GRATICULE_OPACITY};stroke:{GRATICULE_COLOR};stroke-width:1;}}"
));
svg.push_str(
"#my-svg .radarLegendText{text-anchor:start;font-size:12px;dominant-baseline:hanging;}",
);
svg.push_str(&format!(
"#my-svg .radarCurve-0{{color:{CURVE_COLOR_0};fill:{CURVE_COLOR_0};fill-opacity:0.5;stroke:{CURVE_COLOR_0};stroke-width:2;}}"
));
svg.push_str(&format!(
"#my-svg .radarLegendBox-0{{fill:{CURVE_COLOR_0};fill-opacity:0.5;stroke:{CURVE_COLOR_0};}}"
));
svg.push_str("</style>");
svg.push_str("<g/>");
svg.push_str(&format!(
"<g transform=\"translate({center_x}, {center_y})\">"
));
for i in 0..DEFAULT_TICKS {
let r = radius * (i as f64 + 1.0) / (DEFAULT_TICKS as f64);
svg.push_str(&format!("<circle class=\"radarGraticule\" r=\"{r}\"/>"));
}
let n_axes = diagram.axes.len();
for (i, axis_label) in diagram.axes.iter().enumerate() {
let angle = 2.0 * (i as f64) * PI / (n_axes as f64) - PI / 2.0;
let x2 = radius * AXIS_SCALE_FACTOR * angle.cos();
let y2 = radius * AXIS_SCALE_FACTOR * angle.sin();
svg.push_str(&format!(
"<line class=\"radarAxisLine\" y2=\"{y2}\" x2=\"{x2}\" y1=\"0\" x1=\"0\"/>"
));
let lx = radius * AXIS_LABEL_FACTOR * angle.cos();
let ly = radius * AXIS_LABEL_FACTOR * angle.sin();
svg.push_str(&format!(
"<text class=\"radarAxisLabel\" y=\"{ly}\" x=\"{lx}\">{}</text>",
escape_xml(axis_label)
));
}
for (curve_idx, curve) in diagram.curves.iter().enumerate() {
if curve.values.len() != n_axes {
continue;
}
let mut points = Vec::with_capacity(n_axes);
for (i, v) in curve.values.iter().copied().enumerate() {
let angle = 2.0 * (i as f64) * PI / (n_axes as f64) - PI / 2.0;
let r = radius * ((v.max(DEFAULT_MIN)).min(max_value) - DEFAULT_MIN)
/ (max_value - DEFAULT_MIN);
points.push((r * angle.cos(), r * angle.sin()));
}
let d = closed_round_curve(&points, CURVE_TENSION);
svg.push_str(&format!(
"<path class=\"radarCurve-{curve_idx}\" d=\"{d}\"/>"
));
let legend_x = (WIDTH / 2.0 + MARGIN) * 3.0 / 4.0;
let legend_y = -(HEIGHT / 2.0 + MARGIN) * 3.0 / 4.0;
let item_y = legend_y + curve_idx as f64 * 20.0;
svg.push_str(&format!(
"<g transform=\"translate({legend_x}, {item_y})\">"
));
svg.push_str(&format!(
"<rect class=\"radarLegendBox-{curve_idx}\" height=\"12\" width=\"12\"/>"
));
svg.push_str(&format!(
"<text class=\"radarLegendText\" y=\"0\" x=\"16\">{}</text>",
escape_xml(&curve.name)
));
svg.push_str("</g>");
}
svg.push_str("<text y=\"-350\" x=\"0\" class=\"radarTitle\"/>");
svg.push_str("</g></svg>");
Ok(svg)
}
#[derive(Debug, Clone)]
struct RadarDiagram {
axes: Vec<String>,
curves: Vec<RadarCurve>,
}
#[derive(Debug, Clone)]
struct RadarCurve {
name: String,
values: Vec<f64>,
}
fn parse_radar(input: &str) -> Result<RadarDiagram, MermaidError> {
let mut found_header = false;
let mut axes: Vec<String> = Vec::new();
let mut curves: Vec<RadarCurve> = Vec::new();
for (idx, raw) in input.lines().enumerate() {
let line_no = idx + 1;
let line = raw.trim();
if line.is_empty() || line.starts_with("%%") {
continue;
}
if !found_header {
if line.split_whitespace().next() != Some("radar-beta") {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected 'radar-beta' declaration".to_string(),
});
}
found_header = true;
continue;
}
if let Some(rest) = line.strip_prefix("axis ") {
axes = rest
.split(',')
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.map(|p| p.to_string())
.collect();
continue;
}
if let Some(rest) = line.strip_prefix("curve ") {
let (name, values) = parse_curve(rest.trim(), line_no)?;
curves.push(RadarCurve { name, values });
continue;
}
}
if !found_header {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'radar-beta' declaration".to_string(),
});
}
Ok(RadarDiagram { axes, curves })
}
fn parse_curve(s: &str, line: usize) -> Result<(String, Vec<f64>), MermaidError> {
let Some((name, rest)) = s.split_once('{') else {
return Err(MermaidError::ParseError {
line,
message: format!("Invalid curve: {s}"),
});
};
let name = name.trim();
let inner = rest
.strip_suffix('}')
.ok_or_else(|| MermaidError::ParseError {
line,
message: format!("Invalid curve: {s}"),
})?
.trim();
let mut values: Vec<f64> = Vec::new();
for part in inner.split(',') {
let p = part.trim();
if p.is_empty() {
continue;
}
let v: f64 = p.parse().map_err(|_| MermaidError::ParseError {
line,
message: format!("Invalid curve value: {p}"),
})?;
values.push(v);
}
Ok((name.to_string(), values))
}
fn closed_round_curve(points: &[(f64, f64)], tension: f64) -> String {
if points.is_empty() {
return String::new();
}
let n = points.len();
let mut d = String::new();
d.push_str(&format!("M{},{}", points[0].0, points[0].1));
for i in 0..n {
let p0 = points[(i + n - 1) % n];
let p1 = points[i];
let p2 = points[(i + 1) % n];
let p3 = points[(i + 2) % n];
let cp1 = (
p1.0 + (p2.0 - p0.0) * tension,
p1.1 + (p2.1 - p0.1) * tension,
);
let cp2 = (
p2.0 - (p3.0 - p1.0) * tension,
p2.1 - (p3.1 - p1.1) * tension,
);
d.push_str(&format!(
" C{},{} {},{} {},{}",
cp1.0, cp1.1, cp2.0, cp2.1, p2.0, p2.1
));
}
d.push_str(" Z");
d
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
+874
View File
@@ -0,0 +1,874 @@
use std::collections::HashMap;
use crate::error::MermaidError;
use crate::text_wrap::{line_width, DEFAULT_CHAR_WIDTH};
use crate::theme::MermaidTheme;
use dagre_rust::layout::layout as dagre_layout;
use dagre_rust::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::Graph;
const BOX_PADDING: f64 = 20.0;
const BOX_GAP: f64 = 20.0;
const LINE_HEIGHT: f64 = 24.0;
const NODE_SEP: f64 = 50.0;
const RANK_SEP: f64 = 50.0;
const GRAPH_MARGIN: f64 = 8.0;
pub fn render_requirement_diagram_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let diagram = parse_requirement_diagram(mermaid_source)?;
let layout = compute_layout(&diagram);
Ok(render_svg(&layout, theme))
}
#[derive(Debug, Clone, Copy)]
enum Direction {
Tb,
Bt,
Lr,
Rl,
}
impl Direction {
fn as_rankdir(self) -> &'static str {
match self {
Direction::Tb => "tb",
Direction::Bt => "bt",
Direction::Lr => "lr",
Direction::Rl => "rl",
}
}
}
#[derive(Debug, Clone)]
struct RequirementDiagram {
direction: Direction,
nodes: HashMap<String, ReqNode>,
relations: Vec<Relation>,
}
#[derive(Debug, Clone)]
enum ReqNode {
Requirement(RequirementNode),
Element(ElementNode),
}
#[derive(Debug, Clone)]
struct RequirementNode {
name: String,
requirement_id: String,
text: String,
risk: String,
verify_method: String,
req_type: String,
}
#[derive(Debug, Clone)]
struct ElementNode {
name: String,
element_type: String,
doc_ref: String,
}
#[derive(Debug, Clone)]
struct Relation {
src: String,
dst: String,
rel_type: String,
}
#[derive(Debug, Clone)]
struct NodeLayout {
id: String,
x: f64,
y: f64,
width: f64,
height: f64,
labels: Vec<LabelLayout>,
divider_y: Option<f64>,
}
#[derive(Debug, Clone)]
struct EdgeLayout {
id: String,
rel_type: String,
label: String,
points: Vec<(f64, f64)>,
label_pos: Option<(f64, f64)>,
label_width: f64,
label_height: f64,
}
#[derive(Debug, Clone)]
struct DiagramLayout {
nodes: HashMap<String, NodeLayout>,
edges: Vec<EdgeLayout>,
width: f64,
height: f64,
}
#[derive(Debug, Clone)]
struct LabelLayout {
text: String,
x: f64,
y: f64,
anchor: &'static str,
bold: bool,
}
fn parse_requirement_diagram(input: &str) -> Result<RequirementDiagram, MermaidError> {
let mut found_header = false;
let mut direction = Direction::Tb;
let mut nodes: HashMap<String, ReqNode> = HashMap::new();
let mut relations: Vec<Relation> = Vec::new();
let lines: Vec<&str> = input.lines().collect();
let mut i = 0_usize;
while i < lines.len() {
let raw = lines[i];
let line_no = i + 1;
let line = raw.trim();
i += 1;
if line.is_empty() || line.starts_with("%%") {
continue;
}
if !found_header {
if first_diagram_type_token(line) != Some("requirementDiagram") {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected 'requirementDiagram' declaration".to_string(),
});
}
found_header = true;
continue;
}
if let Some(rest) = line.strip_prefix("direction ") {
direction = parse_direction(rest.trim(), line_no)?;
continue;
}
if let Some((node, new_i)) = try_parse_requirement_or_element(&lines, i - 1)? {
let id = match &node {
ReqNode::Requirement(r) => r.name.clone(),
ReqNode::Element(e) => e.name.clone(),
};
nodes.insert(id, node);
i = new_i;
continue;
}
if let Some(rel) = try_parse_relation(line, line_no)? {
relations.push(rel);
continue;
}
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Unrecognized requirementDiagram line: {line}"),
});
}
if !found_header {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'requirementDiagram' declaration".to_string(),
});
}
Ok(RequirementDiagram {
direction,
nodes,
relations,
})
}
fn first_diagram_type_token(line: &str) -> Option<&str> {
line.split_whitespace().next()
}
fn parse_direction(s: &str, line: usize) -> Result<Direction, MermaidError> {
match s.to_uppercase().as_str() {
"TB" | "TD" => Ok(Direction::Tb),
"BT" => Ok(Direction::Bt),
"LR" => Ok(Direction::Lr),
"RL" => Ok(Direction::Rl),
_ => Err(MermaidError::ParseError {
line,
message: format!("Invalid direction: {s}"),
}),
}
}
fn try_parse_requirement_or_element(
lines: &[&str],
start_idx: usize,
) -> Result<Option<(ReqNode, usize)>, MermaidError> {
let line = lines[start_idx].trim();
if line.is_empty() || line.starts_with("%%") {
return Ok(None);
}
let (kind, name, has_open_brace) = if let Some((kw, rest)) = split_once_ws(line) {
let kind = kw;
let (name_raw, tail) = split_once_ws(rest).unwrap_or((rest, ""));
let name = name_raw.trim();
let has_open_brace = tail.contains('{') || name.ends_with('{');
(kind, name.trim_end_matches('{').trim(), has_open_brace)
} else {
return Ok(None);
};
let kind_lower = kind.to_lowercase();
if kind_lower != "element"
&& kind_lower != "requirement"
&& kind_lower != "functionalrequirement"
&& kind_lower != "interfacerequirement"
&& kind_lower != "performancerequirement"
&& kind_lower != "physicalrequirement"
&& kind_lower != "designconstraint"
{
return Ok(None);
}
if name.is_empty() {
return Err(MermaidError::ParseError {
line: start_idx + 1,
message: format!("Expected name after '{kind}'"),
});
}
let mut i = start_idx + 1;
if !has_open_brace {
while i < lines.len() {
let l = lines[i].trim();
if l.is_empty() || l.starts_with("%%") {
i += 1;
continue;
}
if l.starts_with('{') {
i += 1;
break;
}
return Ok(None);
}
}
let mut props: HashMap<String, String> = HashMap::new();
while i < lines.len() {
let raw = lines[i];
let line_no = i + 1;
let l = raw.trim();
i += 1;
if l.is_empty() || l.starts_with("%%") {
continue;
}
if l.starts_with('}') {
break;
}
let Some((k, v)) = l.split_once(':') else {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid property line: {l}"),
});
};
let key = k.trim().to_string();
let mut value = v.trim().trim_end_matches(',').trim().to_string();
value = strip_quotes(&value);
props.insert(key, value);
}
if kind_lower == "element" {
let node = ReqNode::Element(ElementNode {
name: name.to_string(),
element_type: props.get("type").cloned().unwrap_or_else(String::new),
doc_ref: props
.get("docref")
.or_else(|| props.get("docRef"))
.cloned()
.unwrap_or_else(String::new),
});
return Ok(Some((node, i)));
}
let req_type = match kind_lower.as_str() {
"functionalrequirement" => "Functional Requirement",
"interfacerequirement" => "Interface Requirement",
"performancerequirement" => "Performance Requirement",
"physicalrequirement" => "Physical Requirement",
"designconstraint" => "Design Constraint",
_ => "Requirement",
}
.to_string();
let risk = props.get("risk").cloned().unwrap_or_else(String::new);
let verify_method = props
.get("verifyMethod")
.or_else(|| props.get("verifymethod"))
.cloned()
.unwrap_or_else(String::new);
let node = ReqNode::Requirement(RequirementNode {
name: name.to_string(),
requirement_id: props.get("id").cloned().unwrap_or_else(String::new),
text: props.get("text").cloned().unwrap_or_else(String::new),
risk: normalize_risk(&risk),
verify_method: normalize_verify_method(&verify_method),
req_type,
});
Ok(Some((node, i)))
}
fn normalize_risk(s: &str) -> String {
match s.trim().to_lowercase().as_str() {
"low" => "Low".to_string(),
"medium" => "Medium".to_string(),
"high" => "High".to_string(),
_ => s.trim().to_string(),
}
}
fn normalize_verify_method(s: &str) -> String {
match s.trim().to_lowercase().as_str() {
"analysis" => "Analysis".to_string(),
"demonstration" => "Demonstration".to_string(),
"inspection" => "Inspection".to_string(),
"test" => "Test".to_string(),
_ => s.trim().to_string(),
}
}
fn strip_quotes(s: &str) -> String {
let s = s.trim();
if let Some(inner) = s.strip_prefix('"').and_then(|t| t.strip_suffix('"')) {
return inner.to_string();
}
if let Some(inner) = s.strip_prefix('\'').and_then(|t| t.strip_suffix('\'')) {
return inner.to_string();
}
s.to_string()
}
fn split_once_ws(s: &str) -> Option<(&str, &str)> {
let mut it = s.splitn(2, char::is_whitespace);
let a = it.next()?;
let b = it.next().unwrap_or("");
Some((a, b.trim()))
}
fn try_parse_relation(line: &str, line_no: usize) -> Result<Option<Relation>, MermaidError> {
let line = line.trim();
if line.is_empty() || line.starts_with("%%") {
return Ok(None);
}
if let Some((lhs, rhs)) = line.split_once("->") {
let dst = rhs.trim();
let tokens: Vec<&str> = lhs.split_whitespace().filter(|t| *t != "-").collect();
if tokens.len() < 2 || dst.is_empty() {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid relationship: {line}"),
});
}
let src = tokens[0].trim();
let rel = tokens[1].trim();
if src.is_empty() || rel.is_empty() {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid relationship: {line}"),
});
}
return Ok(Some(Relation {
src: src.to_string(),
dst: dst.to_string(),
rel_type: rel.to_string(),
}));
}
if let Some((lhs, rhs)) = line.split_once("<-") {
let dst = lhs.trim();
let tokens: Vec<&str> = rhs.split_whitespace().filter(|t| *t != "-").collect();
if tokens.len() < 2 || dst.is_empty() {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid relationship: {line}"),
});
}
let rel = tokens[0].trim();
let src = tokens[1].trim();
if src.is_empty() || rel.is_empty() {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid relationship: {line}"),
});
}
return Ok(Some(Relation {
src: src.to_string(),
dst: dst.to_string(),
rel_type: rel.to_string(),
}));
}
Ok(None)
}
fn compute_layout(diagram: &RequirementDiagram) -> DiagramLayout {
let mut node_metrics: HashMap<String, (f64, f64, Vec<LabelLayout>, Option<f64>)> =
HashMap::new();
for (id, node) in &diagram.nodes {
let metrics = compute_requirement_box_layout(node);
node_metrics.insert(id.clone(), metrics);
}
type DagreGraph = Graph<GraphConfig, GraphNode, GraphEdge>;
let mut g: DagreGraph = Graph::new(Some(graphlib_rust::GraphOption {
directed: Some(true),
multigraph: Some(true),
compound: Some(false),
}));
g.set_graph(GraphConfig {
rankdir: Some(diagram.direction.as_rankdir().to_string()),
nodesep: Some(NODE_SEP as f32),
ranksep: Some(RANK_SEP as f32),
edgesep: Some(20.0),
marginx: Some(GRAPH_MARGIN as f32),
marginy: Some(GRAPH_MARGIN as f32),
..Default::default()
});
for (id, (w, h, _, _)) in &node_metrics {
g.set_node(
id.clone(),
Some(GraphNode {
width: *w as f32,
height: *h as f32,
..Default::default()
}),
);
}
let mut edge_keys: Vec<(String, String)> = Vec::new();
for rel in &diagram.relations {
let edge_label_text = format!("<<{}>>", rel.rel_type);
let label_width = line_width(&edge_label_text, DEFAULT_CHAR_WIDTH);
let label_height = LINE_HEIGHT;
let edge_label = GraphEdge {
labelpos: Some("c".to_string()),
width: Some(label_width as f32),
height: Some(label_height as f32),
..Default::default()
};
let _ = g.set_edge(&rel.src, &rel.dst, Some(edge_label), None);
edge_keys.push((rel.src.clone(), rel.dst.clone()));
}
dagre_layout(&mut g);
let mut positions: HashMap<String, (f64, f64)> = HashMap::new();
for node_id in g.nodes() {
if let Some(node) = g.node(&node_id) {
positions.insert(node_id, (node.x as f64, node.y as f64));
}
}
let mut edges: Vec<EdgeLayout> = Vec::new();
for (idx, (from, to)) in edge_keys.iter().enumerate() {
let Some(edge) = g.edge(from, to, None) else {
continue;
};
let points: Vec<(f64, f64)> = edge
.points
.as_ref()
.map(|pts| pts.iter().map(|p| (p.x as f64, p.y as f64)).collect())
.unwrap_or_default();
let label_pos = if edge.width.unwrap_or(0.0) > 0.0 || edge.height.unwrap_or(0.0) > 0.0 {
Some((edge.x as f64, edge.y as f64))
} else {
None
};
let rel_type = diagram
.relations
.get(idx)
.map(|r| r.rel_type.clone())
.unwrap_or_default();
let label = format!("<<{}>>", rel_type);
let label_width = edge.width.unwrap_or(0.0) as f64;
let label_height = edge.height.unwrap_or(0.0) as f64;
edges.push(EdgeLayout {
id: format!("{from}-{to}-{idx}"),
rel_type,
label,
points,
label_pos,
label_width,
label_height,
});
}
let mut layout_nodes: HashMap<String, NodeLayout> = HashMap::new();
for (id, (x, y)) in &positions {
let Some((w, h, labels, divider_y)) = node_metrics.get(id).cloned() else {
continue;
};
layout_nodes.insert(
id.clone(),
NodeLayout {
id: id.clone(),
x: *x,
y: *y,
width: w,
height: h,
labels,
divider_y,
},
);
}
let (min_x, min_y, max_x, max_y) = compute_bounds(&layout_nodes, &edges);
let dx = GRAPH_MARGIN - min_x;
let dy = GRAPH_MARGIN - min_y;
for node in layout_nodes.values_mut() {
node.x += dx;
node.y += dy;
}
for edge in &mut edges {
for p in &mut edge.points {
p.0 += dx;
p.1 += dy;
}
if let Some((x, y)) = edge.label_pos {
edge.label_pos = Some((x + dx, y + dy));
}
}
let width = (max_x - min_x) + GRAPH_MARGIN * 2.0;
let height = (max_y - min_y) + GRAPH_MARGIN * 2.0;
DiagramLayout {
nodes: layout_nodes,
edges,
width,
height,
}
}
fn compute_bounds(
nodes: &HashMap<String, NodeLayout>,
edges: &[EdgeLayout],
) -> (f64, f64, f64, f64) {
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
for node in nodes.values() {
let left = node.x - node.width / 2.0;
let right = node.x + node.width / 2.0;
let top = node.y - node.height / 2.0;
let bottom = node.y + node.height / 2.0;
min_x = min_x.min(left);
min_y = min_y.min(top);
max_x = max_x.max(right);
max_y = max_y.max(bottom);
}
for edge in edges {
for (x, y) in &edge.points {
min_x = min_x.min(*x);
min_y = min_y.min(*y);
max_x = max_x.max(*x);
max_y = max_y.max(*y);
}
if let Some((x, y)) = edge.label_pos {
let left = x - edge.label_width / 2.0;
let right = x + edge.label_width / 2.0;
let top = y - edge.label_height / 2.0;
let bottom = y + edge.label_height / 2.0;
min_x = min_x.min(left);
min_y = min_y.min(top);
max_x = max_x.max(right);
max_y = max_y.max(bottom);
}
}
if !min_x.is_finite() {
min_x = 0.0;
max_x = 0.0;
}
if !min_y.is_finite() {
min_y = 0.0;
max_y = 0.0;
}
(min_x, min_y, max_x, max_y)
}
fn compute_requirement_box_layout(node: &ReqNode) -> (f64, f64, Vec<LabelLayout>, Option<f64>) {
let (type_line, name_line, body_lines) = match node {
ReqNode::Requirement(r) => {
let mut body = Vec::new();
if !r.requirement_id.is_empty() {
body.push(format!("ID: {}", r.requirement_id));
}
if !r.text.is_empty() {
body.push(format!("Text: {}", r.text));
}
if !r.risk.is_empty() {
body.push(format!("Risk: {}", r.risk));
}
if !r.verify_method.is_empty() {
body.push(format!("Verification: {}", r.verify_method));
}
(format!("<<{}>>", r.req_type), r.name.clone(), body)
}
ReqNode::Element(e) => {
let mut body = Vec::new();
if !e.element_type.is_empty() {
body.push(format!("Type: {}", e.element_type));
}
if !e.doc_ref.is_empty() {
body.push(format!("Doc Ref: {}", e.doc_ref));
}
("<<Element>>".to_string(), e.name.clone(), body)
}
};
let type_width = line_width(&type_line, DEFAULT_CHAR_WIDTH);
let name_width = line_width(&name_line, DEFAULT_CHAR_WIDTH);
let mut max_width = type_width.max(name_width);
for line in &body_lines {
max_width = max_width.max(line_width(line, DEFAULT_CHAR_WIDTH));
}
let content_height =
LINE_HEIGHT + LINE_HEIGHT + BOX_GAP + body_lines.len() as f64 * LINE_HEIGHT;
let total_width = max_width + BOX_PADDING;
let total_height = content_height + BOX_PADDING;
let mut labels: Vec<LabelLayout> = Vec::new();
labels.push(LabelLayout {
text: type_line,
x: 0.0,
y: 0.0 - content_height / 2.0 + BOX_PADDING / 2.0,
anchor: "middle",
bold: false,
});
labels.push(LabelLayout {
text: name_line,
x: 0.0,
y: LINE_HEIGHT - content_height / 2.0 + BOX_PADDING / 2.0,
anchor: "middle",
bold: true,
});
let left_x = -total_width / 2.0 + BOX_PADDING / 2.0;
let mut y_offset = LINE_HEIGHT + LINE_HEIGHT + BOX_GAP;
for line in body_lines {
labels.push(LabelLayout {
text: line,
x: left_x,
y: y_offset - content_height / 2.0 + BOX_PADDING / 2.0,
anchor: "start",
bold: false,
});
y_offset += LINE_HEIGHT;
}
let divider_y = if y_offset > LINE_HEIGHT + LINE_HEIGHT + BOX_GAP {
Some(-total_height / 2.0 + (LINE_HEIGHT + LINE_HEIGHT + BOX_GAP))
} else {
None
};
(total_width, total_height, labels, divider_y)
}
fn render_svg(layout: &DiagramLayout, theme: &MermaidTheme) -> String {
let mut svg = String::new();
svg.push_str(&format!(
"<svg aria-roledescription=\"requirement\" role=\"graphics-document document\" viewBox=\"0 0 {w} {h}\" style=\"max-width: {w}px; background-color: {};\" class=\"requirementDiagram\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" id=\"my-svg\">",
theme.background,
w = layout.width,
h = layout.height
));
svg.push_str(&format!(
"<style>#my-svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:{};}}#my-svg .relationshipLine{{stroke:{};stroke-width:1;}}#my-svg .node rect{{fill:{};stroke:{};stroke-width:1.3;}}#my-svg .label{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;color:{};}}#my-svg .label text,#my-svg span{{fill:{};color:{};}}#my-svg .labelBkg{{background-color:rgba(232,232,232, 0.8);}}</style>",
theme.text_color,
theme.edge_color,
theme.node_fill,
theme.node_stroke,
theme.text_color,
theme.text_color,
theme.text_color
));
svg.push_str("<g>");
svg.push_str(&format!(
"<defs><marker orient=\"auto\" markerHeight=\"20\" markerWidth=\"20\" refY=\"10\" refX=\"0\" id=\"my-svg_requirement-requirement_containsStart\"><g fill=\"none\" stroke=\"{}\" stroke-width=\"1\"><circle r=\"9\" cy=\"10\" cx=\"10\"/><line y2=\"10\" y1=\"10\" x2=\"19\" x1=\"1\"/><line x2=\"10\" x1=\"10\" y2=\"19\" y1=\"1\"/></g></marker></defs>",
theme.edge_color
));
svg.push_str(&format!(
"<defs><marker orient=\"auto\" markerHeight=\"20\" markerWidth=\"20\" refY=\"10\" refX=\"20\" id=\"my-svg_requirement-requirement_arrowEnd\"><path d=\"M0,0 L20,10 M20,10 L0,20\" fill=\"none\" stroke=\"{}\" stroke-width=\"1\"/></marker></defs>",
theme.edge_color
));
svg.push_str("<g class=\"root\">");
svg.push_str("<g class=\"clusters\"/>");
svg.push_str("<g class=\"edgePaths\">");
for edge in &layout.edges {
let d = points_to_path_d(&edge.points);
let is_contains = edge.rel_type == "contains";
let dash = if is_contains {
""
} else {
"stroke-dasharray: 10,7;"
};
let marker_end = if is_contains {
""
} else {
" marker-end=\"url(#my-svg_requirement-requirement_arrowEnd)\""
};
svg.push_str(&format!(
"<path{marker_end} style=\"fill:none;{dash}\" class=\"edge-thickness-normal edge-pattern-dashed relationshipLine\" id=\"{}\" d=\"{}\"/>",
escape_xml(&edge.id),
d
));
}
svg.push_str("</g>");
svg.push_str("<g class=\"edgeLabels\">");
for edge in &layout.edges {
let Some((x, y)) = edge.label_pos else {
continue;
};
let x2 = -edge.label_width / 2.0;
let y2 = -edge.label_height / 2.0;
svg.push_str(&format!(
"<g transform=\"translate({x}, {y})\" class=\"edgeLabel\">",
x = x,
y = y,
));
svg.push_str(&format!(
"<rect x=\"{x2}\" y=\"{y2}\" width=\"{w}\" height=\"{h}\" fill=\"#E8E8E8\" fill-opacity=\"0.8\" stroke=\"none\"/>",
x2 = x2,
y2 = y2,
w = edge.label_width,
h = edge.label_height,
));
svg.push_str(&format!(
"<text x=\"0\" y=\"0\" text-anchor=\"middle\" dominant-baseline=\"middle\" fill=\"{}\">{}</text>",
theme.text_color,
escape_xml(&edge.label)
));
svg.push_str("</g>");
}
svg.push_str("</g>");
svg.push_str("<g class=\"nodes\">");
let mut node_ids: Vec<&String> = layout.nodes.keys().collect();
node_ids.sort();
for node_id in node_ids {
let Some(node_layout) = layout.nodes.get(node_id) else {
continue;
};
let x2 = -node_layout.width / 2.0;
let y2 = -node_layout.height / 2.0;
svg.push_str(&format!(
"<g transform=\"translate({x},{y})\" id=\"{id}\" class=\"node default\">",
x = node_layout.x,
y = node_layout.y,
id = escape_xml(&node_layout.id)
));
svg.push_str(&format!(
"<rect x=\"{x2}\" y=\"{y2}\" width=\"{w}\" height=\"{h}\"/>",
x2 = x2,
y2 = y2,
w = node_layout.width,
h = node_layout.height
));
if let Some(divider_y) = node_layout.divider_y {
svg.push_str(&format!(
"<line x1=\"{x1}\" y1=\"{y}\" x2=\"{x2}\" y2=\"{y}\" stroke=\"{}\" stroke-width=\"1.3\"/>",
theme.node_stroke,
x1 = x2,
x2 = x2 + node_layout.width,
y = divider_y
));
}
for label in &node_layout.labels {
let font_weight = if label.bold {
" font-weight=\"bold\""
} else {
""
};
svg.push_str(&format!(
"<text x=\"{x}\" y=\"{y}\" text-anchor=\"{anchor}\" dominant-baseline=\"middle\" fill=\"{}\"{font_weight}>{}</text>",
theme.text_color,
escape_xml(&label.text),
x = label.x,
y = label.y,
anchor = label.anchor,
));
}
svg.push_str("</g>");
}
svg.push_str("</g>");
svg.push_str("</g></g></svg>");
svg
}
fn points_to_path_d(points: &[(f64, f64)]) -> String {
if points.is_empty() {
return String::new();
}
let mut d = String::new();
let (x0, y0) = points[0];
d.push_str(&format!("M{x0},{y0}"));
for (x, y) in &points[1..] {
d.push_str(&format!("L{x},{y}"));
}
d
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+436
View File
@@ -0,0 +1,436 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
use std::collections::{BTreeMap, BTreeSet, HashMap};
const WIDTH: f64 = 600.0;
const HEIGHT: f64 = 400.0;
const NODE_WIDTH: f64 = 10.0;
const NODE_PADDING: f64 = 25.0;
const LABEL_OFFSET: f64 = 6.0;
const NODE_COLORS: [&str; 10] = [
"#4e79a7", "#f28e2c", "#e15759", "#76b7b2", "#59a14f", "#edc948", "#b07aa1", "#9c755f",
"#bab0ab", "#ff9da7",
];
pub fn render_sankey_diagram_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let diagram = parse_sankey(mermaid_source)?;
let layout = compute_layout(&diagram);
let mut svg = String::new();
svg.push_str(&format!(
"<svg aria-roledescription=\"sankey\" role=\"graphics-document document\" viewBox=\"0 0 {WIDTH} {HEIGHT}\" style=\"max-width: {WIDTH}px; background-color: {};\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"100%\" id=\"my-svg\">",
theme.background
));
svg.push_str("<g/>");
svg.push_str(&format!(
"<rect x=\"0\" y=\"0\" width=\"{WIDTH}\" height=\"{HEIGHT}\" fill=\"{}\"/>",
theme.background
));
svg.push_str("<defs>");
for (idx, link) in layout.links.iter().enumerate() {
let grad_id = format!("linearGradient-{idx}");
svg.push_str(&format!(
"<linearGradient id=\"{grad_id}\" gradientUnits=\"userSpaceOnUse\" x1=\"{x1}\" x2=\"{x2}\">",
x1 = link.x0,
x2 = link.x1,
));
svg.push_str(&format!(
"<stop offset=\"0%\" stop-color=\"{}\"/>",
escape_xml(&link.source_color)
));
svg.push_str(&format!(
"<stop offset=\"100%\" stop-color=\"{}\"/>",
escape_xml(&link.target_color)
));
svg.push_str("</linearGradient>");
}
svg.push_str("</defs>");
svg.push_str("<g class=\"nodes\">");
for node in &layout.nodes {
svg.push_str(&format!(
"<g class=\"node\" id=\"{}\" transform=\"translate({},{})\" x=\"{}\" y=\"{}\">",
escape_xml(&node.dom_id),
node.x,
node.y,
node.x,
node.y
));
svg.push_str(&format!(
"<rect height=\"{}\" width=\"{}\" fill=\"{}\"/>",
node.height,
NODE_WIDTH,
escape_xml(&node.color)
));
svg.push_str("</g>");
}
svg.push_str("</g>");
svg.push_str("<g class=\"node-labels\" font-size=\"14\">");
for node in &layout.nodes {
let center_y = node.y + node.height / 2.0;
if node.depth == layout.max_depth {
let x = node.x - LABEL_OFFSET;
svg.push_str(&format!(
"<text x=\"{x}\" y=\"{center_y}\" dy=\"0em\" text-anchor=\"end\">{}</text>",
escape_xml(&node.display_label)
));
} else {
let x = node.x + NODE_WIDTH + LABEL_OFFSET;
svg.push_str(&format!(
"<text x=\"{x}\" y=\"{center_y}\" dy=\"0em\" text-anchor=\"start\">{}</text>",
escape_xml(&node.display_label)
));
}
}
svg.push_str("</g>");
svg.push_str("<g class=\"links\" fill=\"none\" stroke-opacity=\"0.5\">");
for (idx, link) in layout.links.iter().enumerate() {
let grad_id = format!("linearGradient-{idx}");
let mx = (link.x0 + link.x1) / 2.0;
let d = format!(
"M{sx},{sy}C{mx},{sy},{mx},{ty},{tx},{ty}",
sx = link.x0,
sy = link.y0,
mx = mx,
tx = link.x1,
ty = link.y1,
);
svg.push_str("<g class=\"link\" style=\"mix-blend-mode: multiply;\">");
svg.push_str(&format!(
"<path d=\"{d}\" stroke=\"url(#{grad_id})\" stroke-width=\"{}\"/>",
link.thickness
));
svg.push_str("</g>");
}
svg.push_str("</g>");
svg.push_str("</svg>");
Ok(svg)
}
#[derive(Debug, Clone)]
struct SankeyDiagram {
links: Vec<SankeyLink>,
node_order: Vec<String>,
}
#[derive(Debug, Clone)]
struct SankeyLink {
source: String,
target: String,
value: f64,
}
#[derive(Debug, Clone)]
struct SankeyNodeLayout {
name: String,
display_label: String,
dom_id: String,
depth: usize,
x: f64,
y: f64,
height: f64,
color: String,
}
#[derive(Debug, Clone)]
struct SankeyLinkLayout {
x0: f64,
y0: f64,
x1: f64,
y1: f64,
thickness: f64,
source_color: String,
target_color: String,
}
#[derive(Debug, Clone)]
struct SankeyLayout {
nodes: Vec<SankeyNodeLayout>,
links: Vec<SankeyLinkLayout>,
max_depth: usize,
}
fn parse_sankey(input: &str) -> Result<SankeyDiagram, MermaidError> {
let mut found_header = false;
let mut links: Vec<SankeyLink> = Vec::new();
let mut node_seen: BTreeSet<String> = BTreeSet::new();
let mut node_order: Vec<String> = Vec::new();
for (idx, raw) in input.lines().enumerate() {
let line_no = idx + 1;
let line = raw.trim();
if line.is_empty() || line.starts_with("%%") {
continue;
}
if !found_header {
if line.split_whitespace().next() != Some("sankey-beta") {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected 'sankey-beta' declaration".to_string(),
});
}
found_header = true;
continue;
}
let parts: Vec<&str> = line.split(',').map(|p| p.trim()).collect();
if parts.len() != 3 {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid sankey link: {line}"),
});
}
let source = parts[0].to_string();
let target = parts[1].to_string();
let value: f64 = parts[2].parse().map_err(|_| MermaidError::ParseError {
line: line_no,
message: format!("Invalid sankey value: {}", parts[2]),
})?;
if node_seen.insert(source.clone()) {
node_order.push(source.clone());
}
if node_seen.insert(target.clone()) {
node_order.push(target.clone());
}
links.push(SankeyLink {
source,
target,
value,
});
}
if !found_header {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'sankey-beta' declaration".to_string(),
});
}
if links.is_empty() {
return Err(MermaidError::ParseError {
line: 1,
message: "sankey diagram requires at least one link".to_string(),
});
}
Ok(SankeyDiagram { links, node_order })
}
fn compute_layout(diagram: &SankeyDiagram) -> SankeyLayout {
let mut in_sum: HashMap<&str, f64> = HashMap::new();
let mut out_sum: HashMap<&str, f64> = HashMap::new();
for link in &diagram.links {
*out_sum.entry(link.source.as_str()).or_insert(0.0) += link.value;
*in_sum.entry(link.target.as_str()).or_insert(0.0) += link.value;
}
let mut value_by_node: HashMap<&str, f64> = HashMap::new();
for node in &diagram.node_order {
let v_in = *in_sum.get(node.as_str()).unwrap_or(&0.0);
let v_out = *out_sum.get(node.as_str()).unwrap_or(&0.0);
value_by_node.insert(node.as_str(), v_in.max(v_out));
}
let mut preds: HashMap<&str, Vec<&str>> = HashMap::new();
for link in &diagram.links {
preds
.entry(link.target.as_str())
.or_default()
.push(link.source.as_str());
preds.entry(link.source.as_str()).or_default();
}
let mut depth: HashMap<&str, usize> = HashMap::new();
for node in &diagram.node_order {
depth.insert(node.as_str(), 0);
}
let mut changed = true;
for _ in 0..diagram.node_order.len().saturating_mul(2) {
if !changed {
break;
}
changed = false;
for node in &diagram.node_order {
let node = node.as_str();
let p = preds.get(node).map(|v| v.as_slice()).unwrap_or(&[]);
let mut d = 0_usize;
for &pred in p {
d = d.max(depth.get(pred).copied().unwrap_or(0).saturating_add(1));
}
if depth.get(node).copied().unwrap_or(0) != d {
depth.insert(node, d);
changed = true;
}
}
}
let max_depth = depth.values().copied().max().unwrap_or(0);
let layers = max_depth.max(1) + 1;
let mut nodes_by_depth: BTreeMap<usize, Vec<&str>> = BTreeMap::new();
for node in &diagram.node_order {
let d = depth.get(node.as_str()).copied().unwrap_or(0);
nodes_by_depth.entry(d).or_default().push(node.as_str());
}
let mut ky = f64::INFINITY;
for nodes in nodes_by_depth.values() {
let sum: f64 = nodes
.iter()
.map(|n| value_by_node.get(n).copied().unwrap_or(0.0))
.sum();
if sum <= 0.0 {
continue;
}
let n = nodes.len() as f64;
let available = HEIGHT - (n - 1.0).max(0.0) * NODE_PADDING;
ky = ky.min(available / sum);
}
if !ky.is_finite() {
ky = 1.0;
}
let mut node_layout: HashMap<&str, SankeyNodeLayout> = HashMap::new();
for (d, nodes) in &nodes_by_depth {
let sum: f64 = nodes
.iter()
.map(|n| value_by_node.get(n).copied().unwrap_or(0.0))
.sum();
let used = sum * ky + (nodes.len().saturating_sub(1) as f64) * NODE_PADDING;
let mut y = (HEIGHT - used) / 2.0;
let x = if layers <= 1 {
0.0
} else {
(WIDTH - NODE_WIDTH) * (*d as f64) / ((layers - 1) as f64)
};
for &name in nodes {
let v = value_by_node.get(name).copied().unwrap_or(0.0);
let h = v * ky;
let global_idx = diagram
.node_order
.iter()
.position(|n| n == name)
.unwrap_or(0);
let dom_id = format!("node-{}", global_idx + 1);
let color = NODE_COLORS
.get(global_idx)
.copied()
.unwrap_or(NODE_COLORS[0])
.to_string();
node_layout.insert(
name,
SankeyNodeLayout {
name: name.to_string(),
display_label: format_sankey_node_label(name, v),
dom_id,
depth: *d,
x,
y,
height: h,
color,
},
);
y += h + NODE_PADDING;
}
}
let mut out_offset: HashMap<&str, f64> = HashMap::new();
let mut in_offset: HashMap<&str, f64> = HashMap::new();
for node in &diagram.node_order {
out_offset.insert(node.as_str(), 0.0);
in_offset.insert(node.as_str(), 0.0);
}
let mut link_layouts = Vec::new();
for link in &diagram.links {
let Some(source_node) = node_layout.get(link.source.as_str()) else {
continue;
};
let Some(target_node) = node_layout.get(link.target.as_str()) else {
continue;
};
let thickness = link.value * ky;
let so = *out_offset.get(link.source.as_str()).unwrap_or(&0.0);
let ti = *in_offset.get(link.target.as_str()).unwrap_or(&0.0);
let y0 = source_node.y + so + thickness / 2.0;
let y1 = target_node.y + ti + thickness / 2.0;
out_offset.insert(link.source.as_str(), so + thickness);
in_offset.insert(link.target.as_str(), ti + thickness);
let x0 = source_node.x + NODE_WIDTH;
let x1 = target_node.x;
link_layouts.push(SankeyLinkLayout {
x0,
y0,
x1,
y1,
thickness,
source_color: source_node.color.clone(),
target_color: target_node.color.clone(),
});
}
let mut nodes_vec: Vec<SankeyNodeLayout> = diagram
.node_order
.iter()
.filter_map(|n| node_layout.get(n.as_str()).cloned())
.collect();
nodes_vec.sort_by(|a, b| {
a.depth
.cmp(&b.depth)
.then_with(|| a.y.total_cmp(&b.y))
.then_with(|| a.name.cmp(&b.name))
});
SankeyLayout {
nodes: nodes_vec,
links: link_layouts,
max_depth,
}
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
fn format_sankey_node_label(name: &str, value: f64) -> String {
if value.fract().abs() < f64::EPSILON {
format!("{name} {}", value as i64)
} else {
format!("{name} {value}")
}
}
File diff suppressed because it is too large Load Diff
+172
View File
@@ -0,0 +1,172 @@
use crate::ast::{Edge, EdgeStyle, FlowchartGraph, GraphDirection, Node, NodeShape, Statement};
use crate::error::MermaidError;
use std::collections::BTreeMap;
pub fn parse_state_diagram(input: &str) -> Result<FlowchartGraph, MermaidError> {
let lines: Vec<&str> = input.lines().collect();
let mut i = 0_usize;
let mut header: Option<&str> = None;
while i < lines.len() {
let line = lines[i].trim();
if line.is_empty() || line.starts_with("%%") {
i += 1;
continue;
}
let token = line.split_whitespace().next().unwrap_or("");
if token == "stateDiagram" || token == "stateDiagram-v2" {
header = Some(token);
i += 1;
break;
}
return Err(MermaidError::ParseError {
line: i + 1,
message: "Expected 'stateDiagram' or 'stateDiagram-v2' declaration".to_string(),
});
}
if header.is_none() {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'stateDiagram' or 'stateDiagram-v2' declaration".to_string(),
});
}
let mut nodes: BTreeMap<String, NodeShape> = BTreeMap::new();
let mut node_order: Vec<String> = Vec::new();
let mut edges: Vec<(String, String, Option<String>)> = Vec::new();
while i < lines.len() {
let raw = lines[i];
let line = raw.trim();
let line_no = i + 1;
i += 1;
if line.is_empty() || line.starts_with("%%") {
continue;
}
if let Some(rest) = line.strip_prefix("state ") {
let rest = rest.trim();
if rest.is_empty() {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected state name after 'state'".to_string(),
});
}
let name = rest.split_whitespace().next().unwrap_or("").to_string();
let shape = if rest.contains("<<choice>>") {
NodeShape::Diamond
} else if rest.contains("<<fork>>") || rest.contains("<<join>>") {
NodeShape::ForkJoin
} else {
NodeShape::RoundedRectangle
};
if !nodes.contains_key(&name) {
node_order.push(name.clone());
}
nodes.insert(name, shape);
continue;
}
if let Some((from_raw, rhs)) = line.split_once("-->") {
let from_raw = from_raw.trim();
let rhs = rhs.trim();
let (to_raw, label) = match rhs.split_once(':') {
Some((a, b)) => {
let label = b.trim();
(
a.trim(),
if label.is_empty() {
None
} else {
Some(label.to_string())
},
)
}
None => (rhs, None),
};
let from = normalize_state_id(from_raw, true);
let to = normalize_state_id(to_raw, false);
ensure_state_node(&mut nodes, &mut node_order, &from);
ensure_state_node(&mut nodes, &mut node_order, &to);
edges.push((from, to, label));
continue;
}
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Unrecognized stateDiagram line: {line}"),
});
}
let mut statements: Vec<Statement> = Vec::new();
for id in node_order {
let Some(shape) = nodes.get(&id) else {
continue;
};
let label = match shape {
NodeShape::StartState | NodeShape::EndState | NodeShape::ForkJoin => None,
_ => Some(id.clone()),
};
statements.push(Statement::Node(Node {
id: id.clone(),
label,
shape: *shape,
}));
}
for (from, to, label) in edges {
statements.push(Statement::Edge(Edge {
from,
to,
label,
style: EdgeStyle::Arrow,
}));
}
Ok(FlowchartGraph {
direction: GraphDirection::TopToBottom,
statements,
})
}
fn normalize_state_id(raw: &str, is_from: bool) -> String {
let raw = raw.trim();
if raw == "[*]" {
if is_from {
"__start".to_string()
} else {
"__end".to_string()
}
} else {
raw.to_string()
}
}
fn ensure_state_node(
nodes: &mut BTreeMap<String, NodeShape>,
node_order: &mut Vec<String>,
id: &str,
) {
if nodes.contains_key(id) {
return;
}
node_order.push(id.to_string());
let shape = match id {
"__start" => NodeShape::StartState,
"__end" => NodeShape::EndState,
_ => NodeShape::RoundedRectangle,
};
nodes.insert(id.to_string(), shape);
}
+990
View File
@@ -0,0 +1,990 @@
use crate::ast::{EdgeStyle, NodeShape};
use crate::config::RenderConfig;
use crate::layout::{LayoutEdge, LayoutNode, LayoutResult, LayoutSubgraph};
use crate::text_wrap::{
line_width_words, measure_wrapped_lines_with_font_size, scale_char_width, wrap_text_lines,
wrapped_text_height_with_font_size, DEFAULT_CHAR_WIDTH, DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT,
DEFAULT_WRAP_WIDTH,
};
use crate::theme::MermaidTheme;
/// The arrowhead marker has refX="5" with viewBox 0..10 and markerWidth 8.
/// The tip at viewBox x=10 extends (105)/10 × 8 = 4 px past the reference point.
/// We shorten each arrowed edge by this amount so the tip lands exactly on the
/// target node border — matching how mermaid.js renders edges.
const EDGE_ARROWHEAD_OFFSET: f64 = 4.0;
const EDGE_ARROWHEAD_OFFSET_THICK: f64 = 5.5; // markerWidth 11 × (105)/10
const EDGE_LABEL_CHAR_WIDTH: f64 = DEFAULT_CHAR_WIDTH;
const EDGE_LABEL_PADDING_H: f64 = 2.0;
const EDGE_LABEL_PADDING_V: f64 = 2.0;
const EDGE_LABEL_BG_OPACITY: f64 = 0.8;
const SUBGRAPH_TITLE_TOP_MARGIN: f64 = 0.0;
const STATE_CHAR_WIDTH: f64 = 6.7;
const DEFAULT_FONT_FAMILY: &str = "Trebuchet MS, verdana, arial, sans-serif";
pub fn render(layout: &LayoutResult, theme: &MermaidTheme) -> String {
render_with_config(layout, theme, &RenderConfig::default())
}
pub fn render_with_config(
layout: &LayoutResult,
theme: &MermaidTheme,
config: &RenderConfig,
) -> String {
let is_state_diagram = layout.nodes.values().any(|node| {
matches!(
node.shape,
NodeShape::StartState | NodeShape::EndState | NodeShape::ForkJoin
)
});
let mut svg = SvgRenderer::new(
layout.width,
layout.height,
theme,
is_state_diagram,
SvgRenderOptions::from_render_config(config),
);
svg.render(layout)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EdgeCurve {
Basis,
Linear,
}
#[derive(Debug, Clone)]
struct SvgRenderOptions {
font_family: String,
font_size: f64,
wrapping_width: f64,
edge_curve: EdgeCurve,
}
impl Default for SvgRenderOptions {
fn default() -> Self {
Self {
font_family: DEFAULT_FONT_FAMILY.to_string(),
font_size: DEFAULT_FONT_SIZE,
wrapping_width: DEFAULT_WRAP_WIDTH,
edge_curve: EdgeCurve::Basis,
}
}
}
impl SvgRenderOptions {
fn from_render_config(config: &RenderConfig) -> Self {
let default = Self::default();
Self {
font_family: config.font_family.clone().unwrap_or(default.font_family),
font_size: config.font_size_px().unwrap_or(default.font_size),
wrapping_width: config
.flowchart
.wrapping_width
.map(f64::from)
.unwrap_or(default.wrapping_width),
edge_curve: config
.flowchart
.curve
.as_deref()
.map(EdgeCurve::from_mermaid_name)
.unwrap_or(default.edge_curve),
}
}
}
impl EdgeCurve {
fn from_mermaid_name(name: &str) -> Self {
if name.eq_ignore_ascii_case("linear") {
Self::Linear
} else {
Self::Basis
}
}
}
struct SvgRenderer<'a> {
width: f64,
height: f64,
theme: &'a MermaidTheme,
is_state_diagram: bool,
options: SvgRenderOptions,
output: String,
}
impl<'a> SvgRenderer<'a> {
fn new(
width: f64,
height: f64,
theme: &'a MermaidTheme,
is_state_diagram: bool,
options: SvgRenderOptions,
) -> Self {
Self {
width,
height,
theme,
is_state_diagram,
options,
output: String::new(),
}
}
fn render(&mut self, layout: &LayoutResult) -> String {
self.write_header();
self.write_defs();
for subgraph in &layout.subgraphs {
self.render_subgraph_background(subgraph);
}
for edge in &layout.edges {
self.render_edge_line(edge);
}
let mut nodes: Vec<&LayoutNode> = layout.nodes.values().collect();
nodes.sort_by(|a, b| a.id.cmp(&b.id));
for node in nodes {
self.render_node(node);
}
for subgraph in &layout.subgraphs {
self.render_subgraph_title(subgraph);
}
self.render_edge_labels(&layout.edges);
self.write_footer();
std::mem::take(&mut self.output)
}
/// Matches mermaid's SVG behavior: sizing via setupGraphViewbox.js and background via SVG style
/// (mermaid-cli src/index.js sets svg.style.backgroundColor), with a background rect for rasterizers.
fn write_header(&mut self) {
self.output.push_str(&format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<svg width="{:.0}" height="{:.0}" viewBox="0 0 {:.0} {:.0}" xmlns="http://www.w3.org/2000/svg" style="background-color: {};">
<rect x="0" y="0" width="{:.0}" height="{:.0}" fill="{}" stroke="none"/>
"#,
self.width,
self.height,
self.width,
self.height,
self.theme.background,
self.width,
self.height,
self.theme.background
));
}
fn write_defs(&mut self) {
self.output.push_str(&format!(
r#"<defs>
<marker id="arrowhead" markerWidth="8" markerHeight="8" refX="5" refY="5" orient="auto" markerUnits="userSpaceOnUse" viewBox="0 0 10 10">
<path d="M 0 0 L 10 5 L 0 10 z" fill="{}" stroke="{}" stroke-width="1"/>
</marker>
<marker id="arrowhead-thick" markerWidth="11" markerHeight="11" refX="5" refY="5" orient="auto" markerUnits="userSpaceOnUse" viewBox="0 0 10 10">
<path d="M 0 0 L 10 5 L 0 10 z" fill="{0}" stroke="{0}" stroke-width="1"/>
</marker>
</defs>
"#,
self.theme.edge_color, self.theme.edge_color
));
}
fn write_footer(&mut self) {
self.output.push_str("</svg>\n");
}
fn render_subgraph_background(&mut self, subgraph: &LayoutSubgraph) {
self.output.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" fill="{}" stroke="{}" stroke-width="1"/>\n"#,
subgraph.x, subgraph.y, subgraph.width, subgraph.height,
self.theme.subgraph_fill, self.theme.subgraph_stroke
));
}
fn render_subgraph_title(&mut self, subgraph: &LayoutSubgraph) {
if let Some(title) = &subgraph.title {
let char_width = scale_char_width(DEFAULT_CHAR_WIDTH, self.options.font_size);
let lines = wrap_text_lines(title, self.options.wrapping_width, char_width);
if lines.is_empty() {
return;
}
let (_, text_height) =
measure_wrapped_lines_with_font_size(&lines, char_width, self.options.font_size);
let title_x = subgraph.x + subgraph.width / 2.0;
let title_y = subgraph.y + SUBGRAPH_TITLE_TOP_MARGIN + text_height / 2.0;
self.render_text_lines(
title_x,
title_y,
&lines,
self.options.font_size,
DEFAULT_LINE_HEIGHT,
&self.theme.text_color,
);
}
}
fn render_node(&mut self, node: &LayoutNode) {
match node.shape {
NodeShape::Rectangle => self.render_rectangle(node, 0.0),
NodeShape::RoundedRectangle => self.render_rectangle(node, 5.0),
NodeShape::Stadium => self.render_rectangle(node, node.height / 2.0),
NodeShape::Diamond => self.render_diamond(node),
NodeShape::Circle => self.render_circle(node),
NodeShape::StartState => self.render_start_state(node),
NodeShape::EndState => self.render_end_state(node),
NodeShape::ForkJoin => self.render_fork_join(node),
NodeShape::Hexagon => self.render_hexagon(node),
NodeShape::Cylinder => self.render_cylinder(node),
NodeShape::Subroutine => self.render_subroutine(node),
NodeShape::Asymmetric => self.render_asymmetric(node),
}
}
fn render_rectangle(&mut self, node: &LayoutNode, rx: f64) {
let x = node.x - node.width / 2.0;
let y = node.y - node.height / 2.0;
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
let stroke = node
.stroke_color
.as_ref()
.unwrap_or(&self.theme.node_stroke);
self.output.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" rx="{:.1}" fill="{}" stroke="{}" stroke-width="1"/>
"#,
x, y, node.width, node.height, rx, fill, stroke
));
self.render_text(node.x, node.y, &node.label);
}
fn render_start_state(&mut self, node: &LayoutNode) {
let r = node.width.min(node.height) / 2.0;
self.output.push_str(&format!(
r#"<circle cx="{:.1}" cy="{:.1}" r="{:.1}" fill="{}" stroke="{}" stroke-width="1.5"/>
"#,
node.x, node.y, r, self.theme.edge_color, self.theme.edge_color
));
}
fn render_end_state(&mut self, node: &LayoutNode) {
let outer_r = node.width.min(node.height) / 2.0;
let inner_r = (outer_r - 4.0).max(outer_r * 0.55).min(outer_r - 2.0);
self.output.push_str(&format!(
r#"<circle cx="{:.1}" cy="{:.1}" r="{:.1}" fill="{}" stroke="{}" stroke-width="1"/>
"#,
node.x, node.y, outer_r, self.theme.node_stroke, self.theme.background
));
self.output.push_str(&format!(
r#"<circle cx="{:.1}" cy="{:.1}" r="{:.1}" fill="{}" stroke="none"/>
"#,
node.x, node.y, inner_r, self.theme.background
));
}
fn render_fork_join(&mut self, node: &LayoutNode) {
let x = node.x - node.width / 2.0;
let y = node.y - node.height / 2.0;
self.output.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" rx="1" fill="{}" stroke="{}" stroke-width="1"/>
"#,
x, y, node.width, node.height, self.theme.edge_color, self.theme.edge_color
));
}
fn render_diamond(&mut self, node: &LayoutNode) {
let hw = node.width / 2.0;
let hh = node.height / 2.0;
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
let stroke = node
.stroke_color
.as_ref()
.unwrap_or(&self.theme.node_stroke);
let points = format!(
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
node.x,
node.y - hh,
node.x + hw,
node.y,
node.x,
node.y + hh,
node.x - hw,
node.y
);
self.output.push_str(&format!(
r#"<polygon points="{}" fill="{}" stroke="{}" stroke-width="1"/>
"#,
points, fill, stroke
));
self.render_text(node.x, node.y, &node.label);
}
fn render_circle(&mut self, node: &LayoutNode) {
let r = node.width.min(node.height) / 2.0;
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
let stroke = node
.stroke_color
.as_ref()
.unwrap_or(&self.theme.node_stroke);
self.output.push_str(&format!(
r#"<circle cx="{:.1}" cy="{:.1}" r="{:.1}" fill="{}" stroke="{}" stroke-width="1"/>
"#,
node.x, node.y, r, fill, stroke
));
self.render_text(node.x, node.y, &node.label);
}
fn render_hexagon(&mut self, node: &LayoutNode) {
let hw = node.width / 2.0;
let hh = node.height / 2.0;
let inset = node.height / 3.0;
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
let stroke = node
.stroke_color
.as_ref()
.unwrap_or(&self.theme.node_stroke);
let points = format!(
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
node.x - hw + inset,
node.y - hh,
node.x + hw - inset,
node.y - hh,
node.x + hw,
node.y,
node.x + hw - inset,
node.y + hh,
node.x - hw + inset,
node.y + hh,
node.x - hw,
node.y
);
self.output.push_str(&format!(
r#"<polygon points="{}" fill="{}" stroke="{}" stroke-width="1"/>
"#,
points, fill, stroke
));
self.render_text(node.x, node.y, &node.label);
}
fn render_cylinder(&mut self, node: &LayoutNode) {
let hw = node.width / 2.0;
let hh = node.height / 2.0;
let ellipse_ry = (hw / 4.0).min(hh / 2.0);
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
let stroke = node
.stroke_color
.as_ref()
.unwrap_or(&self.theme.node_stroke);
let x = node.x - hw;
let y = node.y - hh;
let body_top = y + ellipse_ry;
let body_bottom = node.y + hh - ellipse_ry;
self.output.push_str(&format!(
r#"<path d="M {:.1} {:.1} L {:.1} {:.1} A {:.1} {:.1} 0 0 0 {:.1} {:.1} L {:.1} {:.1} A {:.1} {:.1} 0 0 0 {:.1} {:.1} Z" fill="{}" stroke="{}" stroke-width="1"/>
"#,
x,
body_top,
x,
body_bottom,
hw,
ellipse_ry,
node.x + hw,
body_bottom,
node.x + hw,
body_top,
hw,
ellipse_ry,
x,
body_top,
fill,
stroke
));
self.output.push_str(&format!(
r#"<ellipse cx="{:.1}" cy="{:.1}" rx="{:.1}" ry="{:.1}" fill="{}" stroke="{}" stroke-width="1"/>
"#,
node.x, body_top, hw, ellipse_ry, fill, stroke
));
// Center text in the cylinder body (below the top ellipse cap)
let body_center_y = (body_top + body_bottom) / 2.0;
self.render_text(node.x, body_center_y, &node.label);
}
fn render_subroutine(&mut self, node: &LayoutNode) {
let x = node.x - node.width / 2.0;
let y = node.y - node.height / 2.0;
let bar_inset = 8.0;
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
let stroke = node
.stroke_color
.as_ref()
.unwrap_or(&self.theme.node_stroke);
self.output.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" fill="{}" stroke="{}" stroke-width="1"/>
"#,
x, y, node.width, node.height, fill, stroke
));
self.output.push_str(&format!(
r#"<line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1"/>
"#,
x + bar_inset,
y,
x + bar_inset,
y + node.height,
stroke
));
self.output.push_str(&format!(
r#"<line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1"/>
"#,
x + node.width - bar_inset,
y,
x + node.width - bar_inset,
y + node.height,
stroke
));
self.render_text(node.x, node.y, &node.label);
}
fn render_asymmetric(&mut self, node: &LayoutNode) {
let hw = node.width / 2.0;
let hh = node.height / 2.0;
let point_offset = hh;
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
let stroke = node
.stroke_color
.as_ref()
.unwrap_or(&self.theme.node_stroke);
// Mermaid's `>text]` flag shape: indent (V-notch) on the LEFT, flat RIGHT.
let points = format!(
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
node.x - hw + point_offset,
node.y - hh,
node.x + hw,
node.y - hh,
node.x + hw,
node.y + hh,
node.x - hw + point_offset,
node.y + hh,
node.x - hw,
node.y,
);
self.output.push_str(&format!(
r#"<polygon points="{}" fill="{}" stroke="{}" stroke-width="1"/>
"#,
points, fill, stroke
));
self.render_text(node.x + point_offset / 4.0, node.y, &node.label);
}
fn render_text(&mut self, x: f64, y: f64, text: &str) {
let char_width = if self.is_state_diagram {
scale_char_width(STATE_CHAR_WIDTH, self.options.font_size)
} else {
scale_char_width(DEFAULT_CHAR_WIDTH, self.options.font_size)
};
let lines = wrap_text_lines(text, self.options.wrapping_width, char_width);
if lines.is_empty() {
return;
}
self.render_text_lines(
x,
y,
&lines,
self.options.font_size,
DEFAULT_LINE_HEIGHT,
&self.theme.text_color,
);
}
fn render_text_lines(
&mut self,
x: f64,
y: f64,
lines: &[Vec<String>],
font_size: f64,
line_height: f64,
color: &str,
) {
let line_height_px = font_size * line_height;
// With dominant-baseline="central", the y attribute positions the vertical
// center of the text glyph. We distribute n lines evenly around the center y.
let start_y = y - (lines.len() as f64 - 1.0) * line_height_px / 2.0;
let font_family = Self::escape_xml(&self.options.font_family);
self.output.push_str(&format!(
r#"<text text-anchor="middle" dominant-baseline="central" font-family="{}" font-size="{:.0}" fill="{}">
"#,
font_family, font_size, color
));
for (i, line) in lines.iter().enumerate() {
let line_y = start_y + (i as f64 * line_height_px);
let line_text = line.join(" ");
self.output.push_str(&format!(
r#"<tspan x="{:.1}" y="{:.1}">{}</tspan>"#,
x,
line_y,
Self::escape_xml(&line_text)
));
self.output.push('\n');
}
self.output.push_str("</text>\n");
}
/// Matches Mermaid flowchart edge thickness/pattern defaults
/// (see packages/mermaid/src/diagrams/flowchart/styles.ts and rendering-elements/edges.js).
fn render_edge_line(&mut self, edge: &LayoutEdge) {
if edge.points.len() < 2 {
return;
}
let has_arrow = matches!(
edge.style,
EdgeStyle::Arrow | EdgeStyle::DottedArrow | EdgeStyle::ThickArrow
);
let is_dotted = matches!(edge.style, EdgeStyle::DottedArrow | EdgeStyle::DottedLine);
let is_thick = matches!(edge.style, EdgeStyle::ThickArrow | EdgeStyle::ThickLine);
let marker = match (has_arrow, is_thick) {
(true, true) => r#" marker-end="url(#arrowhead-thick)""#,
(true, false) => r#" marker-end="url(#arrowhead)""#,
_ => "",
};
let stroke_width = if is_thick { 3.5 } else { 1.0 };
let dash_array = if is_dotted {
// Match mermaid's dotted style: round-capped short dashes for dot look
r#" stroke-dasharray="3 3""#
} else {
""
};
let mut points = edge.points.clone();
if has_arrow {
let offset = if is_thick {
EDGE_ARROWHEAD_OFFSET_THICK
} else {
EDGE_ARROWHEAD_OFFSET
};
Self::shorten_end_for_marker(&mut points, offset);
}
let d = self.edge_path_d(&points);
self.output.push_str(&format!(
r#"<path d="{}" fill="none" stroke="{}" stroke-width="{:.1}" stroke-linecap="round" stroke-linejoin="round"{}{}/>
"#,
d, self.theme.edge_color, stroke_width, dash_array, marker
));
}
fn shorten_end_for_marker(points: &mut [(f64, f64)], offset: f64) {
if points.len() < 2 || offset <= 0.0 {
return;
}
let last_idx = points.len() - 1;
let prev = points[last_idx - 1];
let last = points[last_idx];
let dx = last.0 - prev.0;
let dy = last.1 - prev.1;
let len = (dx * dx + dy * dy).sqrt();
if len <= offset {
return;
}
let ux = dx / len;
let uy = dy / len;
points[last_idx] = (last.0 - ux * offset, last.1 - uy * offset);
}
fn edge_path_d(&self, points: &[(f64, f64)]) -> String {
match self.options.edge_curve {
EdgeCurve::Basis => {
let points = Self::fix_corners(points);
Self::basis_spline_path_d(&points)
}
EdgeCurve::Linear => Self::linear_path_d(points),
}
}
fn linear_path_d(points: &[(f64, f64)]) -> String {
let Some((first_x, first_y)) = points.first().copied() else {
return String::new();
};
let mut d = format!("M{first_x:.1},{first_y:.1}");
for (x, y) in points.iter().skip(1) {
d.push_str(&format!("L{x:.1},{y:.1}"));
}
d
}
fn basis_spline_path_d(points: &[(f64, f64)]) -> String {
if points.is_empty() {
return String::new();
}
let mut d = String::new();
let mut x0 = f64::NAN;
let mut y0 = f64::NAN;
let mut x1 = f64::NAN;
let mut y1 = f64::NAN;
let mut point_state = 0;
for &(x, y) in points {
match point_state {
0 => {
point_state = 1;
d.push_str(&format!("M{x:.1},{y:.1}"));
}
1 => {
point_state = 2;
}
2 => {
point_state = 3;
d.push_str(&format!(
"L{:.1},{:.1}",
(5.0 * x0 + x1) / 6.0,
(5.0 * y0 + y1) / 6.0
));
d.push_str(&Self::basis_point(x0, y0, x1, y1, x, y));
}
_ => {
d.push_str(&Self::basis_point(x0, y0, x1, y1, x, y));
}
}
x0 = x1;
x1 = x;
y0 = y1;
y1 = y;
}
match point_state {
3 => {
d.push_str(&Self::basis_point(x0, y0, x1, y1, x1, y1));
d.push_str(&format!("L{x1:.1},{y1:.1}"));
}
2 => {
d.push_str(&format!("L{x1:.1},{y1:.1}"));
}
_ => {}
}
d
}
fn basis_point(x0: f64, y0: f64, x1: f64, y1: f64, x: f64, y: f64) -> String {
format!(
"C{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
(2.0 * x0 + x1) / 3.0,
(2.0 * y0 + y1) / 3.0,
(x0 + 2.0 * x1) / 3.0,
(y0 + 2.0 * y1) / 3.0,
(x0 + 4.0 * x1 + x) / 6.0,
(y0 + 4.0 * y1 + y) / 6.0,
)
}
fn fix_corners(points: &[(f64, f64)]) -> Vec<(f64, f64)> {
let corner_positions = Self::corner_positions(points);
let mut new_points = Vec::new();
for (idx, point) in points.iter().enumerate() {
if corner_positions.contains(&idx) {
let prev_point = points[idx - 1];
let next_point = points[idx + 1];
let corner_point = *point;
let new_prev = Self::find_adjacent_point(prev_point, corner_point, 5.0);
let new_next = Self::find_adjacent_point(next_point, corner_point, 5.0);
let x_diff = new_next.0 - new_prev.0;
let y_diff = new_next.1 - new_prev.1;
let mut new_corner = corner_point;
let a = (2.0_f64).sqrt() * 2.0;
if (next_point.0 - prev_point.0).abs() > 10.0
&& (next_point.1 - prev_point.1).abs() >= 10.0
{
if (corner_point.0 - new_prev.0).abs() < f64::EPSILON {
new_corner = (
if x_diff < 0.0 {
new_prev.0 - 5.0 + a
} else {
new_prev.0 + 5.0 - a
},
if y_diff < 0.0 {
new_prev.1 - a
} else {
new_prev.1 + a
},
);
} else {
new_corner = (
if x_diff < 0.0 {
new_prev.0 - a
} else {
new_prev.0 + a
},
if y_diff < 0.0 {
new_prev.1 - 5.0 + a
} else {
new_prev.1 + 5.0 - a
},
);
}
}
new_points.push(new_prev);
new_points.push(new_corner);
new_points.push(new_next);
} else {
new_points.push(*point);
}
}
new_points
}
fn corner_positions(points: &[(f64, f64)]) -> Vec<usize> {
let mut positions = Vec::new();
if points.len() < 3 {
return positions;
}
for i in 1..points.len() - 1 {
let prev = points[i - 1];
let curr = points[i];
let next = points[i + 1];
if ((prev.0 - curr.0).abs() < f64::EPSILON
&& (curr.1 - next.1).abs() < f64::EPSILON
&& (curr.0 - next.0).abs() > 5.0
&& (curr.1 - prev.1).abs() > 5.0)
|| ((prev.1 - curr.1).abs() < f64::EPSILON
&& (curr.0 - next.0).abs() < f64::EPSILON
&& (curr.0 - prev.0).abs() > 5.0
&& (curr.1 - next.1).abs() > 5.0)
{
positions.push(i);
}
}
positions
}
fn find_adjacent_point(a: (f64, f64), b: (f64, f64), distance: f64) -> (f64, f64) {
let x_diff = b.0 - a.0;
let y_diff = b.1 - a.1;
let length = (x_diff * x_diff + y_diff * y_diff).sqrt();
if length == 0.0 {
return a;
}
let ratio = distance / length;
(b.0 - ratio * x_diff, b.1 - ratio * y_diff)
}
fn render_edge_labels(&mut self, edges: &[LayoutEdge]) {
struct LabelInfo {
x: f64,
y: f64,
width: f64,
height: f64,
lines: Vec<Vec<String>>,
}
let mut labels: Vec<LabelInfo> = Vec::new();
let char_width = if self.is_state_diagram {
scale_char_width(STATE_CHAR_WIDTH, self.options.font_size)
} else {
scale_char_width(EDGE_LABEL_CHAR_WIDTH, self.options.font_size)
};
for edge in edges {
let Some(label) = &edge.label else {
continue;
};
if label.trim().is_empty() || (edge.label_pos.is_none() && edge.points.len() < 2) {
continue;
}
let (label_x, label_y) = if let Some((x, y)) = edge.label_pos {
if x > 0.0 && y > 0.0 {
(x, y)
} else {
let label_points = Self::fix_corners(&edge.points);
Self::label_position(&label_points)
}
} else {
let label_points = Self::fix_corners(&edge.points);
Self::label_position(&label_points)
};
let lines = wrap_text_lines(label, self.options.wrapping_width, char_width);
if lines.is_empty() {
continue;
}
let max_line_width = lines
.iter()
.map(|line| line_width_words(line, char_width))
.fold(0.0, f64::max);
let total_height =
wrapped_text_height_with_font_size(lines.len(), self.options.font_size);
let rect_width = max_line_width + EDGE_LABEL_PADDING_H * 2.0;
let rect_height = total_height + EDGE_LABEL_PADDING_V * 2.0;
labels.push(LabelInfo {
x: label_x,
y: label_y,
width: rect_width,
height: rect_height,
lines,
});
}
const MIN_SEPARATION: f64 = 8.0;
const MAX_ITERATIONS: usize = 10;
for _ in 0..MAX_ITERATIONS {
let mut any_collision = false;
for i in 0..labels.len() {
for j in (i + 1)..labels.len() {
let a_left = labels[i].x - labels[i].width / 2.0 - MIN_SEPARATION;
let a_right = labels[i].x + labels[i].width / 2.0 + MIN_SEPARATION;
let a_top = labels[i].y - labels[i].height / 2.0 - MIN_SEPARATION;
let a_bottom = labels[i].y + labels[i].height / 2.0 + MIN_SEPARATION;
let b_left = labels[j].x - labels[j].width / 2.0 - MIN_SEPARATION;
let b_right = labels[j].x + labels[j].width / 2.0 + MIN_SEPARATION;
let b_top = labels[j].y - labels[j].height / 2.0 - MIN_SEPARATION;
let b_bottom = labels[j].y + labels[j].height / 2.0 + MIN_SEPARATION;
let overlap_x = a_right > b_left && b_right > a_left;
let overlap_y = a_bottom > b_top && b_bottom > a_top;
if overlap_x && overlap_y {
any_collision = true;
let dx = labels[j].x - labels[i].x;
let dy = labels[j].y - labels[i].y;
let overlap_amount_x = (a_right - b_left).min(b_right - a_left);
let overlap_amount_y = (a_bottom - b_top).min(b_bottom - a_top);
if overlap_amount_x < overlap_amount_y {
let shift = overlap_amount_x / 2.0;
if dx >= 0.0 {
labels[i].x -= shift;
labels[j].x += shift;
} else {
labels[i].x += shift;
labels[j].x -= shift;
}
} else {
let shift = overlap_amount_y / 2.0;
if dy >= 0.0 {
labels[i].y -= shift;
labels[j].y += shift;
} else {
labels[i].y += shift;
labels[j].y -= shift;
}
}
}
}
}
if !any_collision {
break;
}
}
for info in &labels {
let rect_x = info.x - info.width / 2.0;
let rect_y = info.y - info.height / 2.0;
self.output.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" fill="rgba(232,232,232,{})" rx="2"/>
"#,
rect_x, rect_y, info.width, info.height, EDGE_LABEL_BG_OPACITY
));
self.render_text_lines(
info.x,
info.y,
&info.lines,
self.options.font_size,
DEFAULT_LINE_HEIGHT,
&self.theme.text_color,
);
}
}
fn label_position(points: &[(f64, f64)]) -> (f64, f64) {
if points.len() < 2 {
return points.first().copied().unwrap_or((0.0, 0.0));
}
let mut segment_lengths = Vec::with_capacity(points.len() - 1);
let mut total_length = 0.0;
for i in 0..points.len() - 1 {
let dx = points[i + 1].0 - points[i].0;
let dy = points[i + 1].1 - points[i].1;
let len = (dx * dx + dy * dy).sqrt();
segment_lengths.push(len);
total_length += len;
}
if total_length < 0.001 {
return points[0];
}
let target_distance = total_length * 0.5;
let mut accumulated = 0.0;
for (i, &seg_len) in segment_lengths.iter().enumerate() {
if accumulated + seg_len >= target_distance {
let remaining = target_distance - accumulated;
let t = if seg_len > 0.001 {
remaining / seg_len
} else {
0.0
};
let x = points[i].0 + t * (points[i + 1].0 - points[i].0);
let y = points[i].1 + t * (points[i + 1].1 - points[i].1);
return (x, y);
}
accumulated += seg_len;
}
let last = points.len() - 1;
(
(points[0].0 + points[last].0) / 2.0,
(points[0].1 + points[last].1) / 2.0,
)
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
}
+346
View File
@@ -0,0 +1,346 @@
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
pub const DEFAULT_FONT_SIZE: f64 = 16.0;
pub const DEFAULT_LINE_HEIGHT: f64 = 1.1;
pub const DEFAULT_WRAP_WIDTH: f64 = 200.0;
pub const DEFAULT_CHAR_WIDTH: f64 = 8.0;
pub const DEFAULT_TEXT_HEIGHT: f64 = 24.0;
/// A single unbreakable token is kept whole (its box widens to fit it, matching
/// mermaid's default `htmlLabels`) unless it is wider than this many wrap-widths.
/// ~5x keeps the worst-case whole-token box near one target-width frame, so the
/// downstream rasterizer's scale-to-`target_width_px` stays ~1x and text stays
/// legible; memory is bounded separately by the consuming crate's raster caps.
const SINGLE_TOKEN_WIDTH_CAP_FACTOR: f64 = 5.0;
/// Identifier-boundary characters preferred as break points when an over-cap
/// token must be split.
const TOKEN_BREAK_CHARS: [char; 4] = ['_', '-', '.', '/'];
/// Display width of `text` in narrow-character units (East Asian wide
/// characters count as two).
pub fn display_width_units(text: &str) -> f64 {
UnicodeWidthStr::width(text) as f64
}
/// Mirrors mermaid.js splitText.ts splitLineToFitWidth behavior for non-markdown labels.
/// Source: packages/mermaid/src/rendering-util/splitText.ts.
pub fn wrap_text_lines(text: &str, max_width: f64, char_width: f64) -> Vec<Vec<String>> {
if text.is_empty() {
return Vec::new();
}
let max_width = if max_width.is_finite() {
max_width
} else {
f64::INFINITY
};
let mut lines = Vec::new();
for raw_line in text.split('\n') {
let trimmed = raw_line.trim();
if trimmed.is_empty() {
lines.push(vec![String::new()]);
continue;
}
let words = split_line_to_words(trimmed);
let wrapped = split_line_to_fit_width(words, max_width, char_width);
lines.extend(wrapped);
}
lines
}
/// Matches mermaid.js createText.ts line-width checks using display-width
/// estimation.
pub fn line_width(line: &str, char_width: f64) -> f64 {
if line.is_empty() {
return 0.0;
}
display_width_units(line) * char_width
}
pub fn measure_wrapped_lines_with_font_size(
lines: &[Vec<String>],
char_width: f64,
font_size: f64,
) -> (f64, f64) {
let max_width = lines
.iter()
.map(|line| line_width_words(line, char_width))
.fold(0.0, f64::max);
(
max_width,
wrapped_text_height_with_font_size(lines.len(), font_size),
)
}
pub fn wrapped_text_height_with_font_size(line_count: usize, font_size: f64) -> f64 {
if line_count == 0 {
return 0.0;
}
let font_size = normalized_font_size(font_size);
let text_height = DEFAULT_TEXT_HEIGHT * font_size / DEFAULT_FONT_SIZE;
let line_spacing = font_size * DEFAULT_LINE_HEIGHT;
text_height + (line_count.saturating_sub(1)) as f64 * line_spacing
}
pub fn scale_char_width(char_width: f64, font_size: f64) -> f64 {
char_width * normalized_font_size(font_size) / DEFAULT_FONT_SIZE
}
fn normalized_font_size(font_size: f64) -> f64 {
if font_size.is_finite() && font_size > 0.0 {
font_size
} else {
DEFAULT_FONT_SIZE
}
}
fn split_line_to_words(text: &str) -> Vec<String> {
let mut words = Vec::new();
for word in text.split_whitespace() {
words.push(word.to_string());
}
if words.is_empty() {
words.push(String::new());
}
words
}
fn split_line_to_fit_width(
words: Vec<String>,
max_width: f64,
char_width: f64,
) -> Vec<Vec<String>> {
let mut remaining = std::collections::VecDeque::from(words);
let mut lines: Vec<Vec<String>> = Vec::new();
let mut current: Vec<String> = Vec::new();
loop {
if remaining.is_empty() {
if !current.is_empty() {
lines.push(current);
}
break;
}
let next_word = remaining.pop_front().unwrap_or_default();
let mut line_with_next = current.clone();
line_with_next.push(next_word.clone());
if check_fit(&line_with_next, max_width, char_width) {
current = line_with_next;
continue;
}
if !current.is_empty() {
lines.push(current);
current = Vec::new();
remaining.push_front(next_word);
continue;
}
if !next_word.is_empty() {
// Keep an unbreakable token whole so its box can widen (see const doc).
let cap = max_width * SINGLE_TOKEN_WIDTH_CAP_FACTOR;
if line_width(&next_word, char_width) <= cap {
lines.push(vec![next_word]);
} else {
let (first, rest) = split_token_at_cap(&next_word, cap, char_width);
lines.push(vec![first]);
if !rest.is_empty() {
remaining.push_front(rest);
}
}
}
}
lines
}
fn check_fit(words: &[String], max_width: f64, char_width: f64) -> bool {
line_width_words(words, char_width) <= max_width
}
fn split_word_to_fit_width(word: &str, max_width: f64, char_width: f64) -> (String, String) {
let graphemes: Vec<&str> = word.graphemes(true).collect();
if graphemes.is_empty() {
return (String::new(), String::new());
}
let mut used = Vec::new();
let mut remaining_start = graphemes.len();
for (idx, grapheme) in graphemes.iter().enumerate() {
let mut candidate = used.clone();
candidate.push(*grapheme);
let candidate_str = candidate.concat();
if line_width(&candidate_str, char_width) <= max_width || used.is_empty() {
used = candidate;
continue;
}
remaining_start = idx;
break;
}
if used.is_empty() {
used.push(graphemes[0]);
remaining_start = 1;
}
let remaining = if remaining_start < graphemes.len() {
graphemes[remaining_start..].concat()
} else {
String::new()
};
(used.concat(), remaining)
}
/// Splits an over-cap token: prefers the last identifier boundary (`_`, `-`,
/// `.`, `/`) within the cap-fitting prefix, otherwise falls back to the grapheme
/// break used elsewhere. Break points are identifier-char granular, so long
/// URLs/paths break at a separator instead of mid-segment.
fn split_token_at_cap(word: &str, cap: f64, char_width: f64) -> (String, String) {
// Grapheme prefix that fits the cap; also guarantees forward progress, so it
// is always a strict prefix here (the whole word is wider than the cap).
let (graphemic_first, graphemic_rest) = split_word_to_fit_width(word, cap, char_width);
// Break chars are single-byte ASCII, so the rfind byte index + 1 is a valid
// char boundary that keeps the separator on the first line.
if let Some(boundary) = graphemic_first.rfind(|c| TOKEN_BREAK_CHARS.contains(&c)) {
let pos = boundary + 1;
return (word[..pos].to_string(), word[pos..].to_string());
}
(graphemic_first, graphemic_rest)
}
pub fn line_width_words(words: &[String], char_width: f64) -> f64 {
let joined = join_words(words);
line_width(&joined, char_width)
}
fn join_words(words: &[String]) -> String {
let mut out = String::new();
for (idx, word) in words.iter().enumerate() {
if idx > 0 {
out.push(' ');
}
out.push_str(word);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wraps_long_single_token_whole_without_slicing() {
// A single long identifier stays whole on one line (mermaid htmlLabels
// behavior), instead of being hard-sliced mid-identifier.
let label = "mark_filter_restore_context";
let lines = wrap_text_lines(label, DEFAULT_WRAP_WIDTH, DEFAULT_CHAR_WIDTH);
assert_eq!(lines, vec![vec![label.to_string()]]);
}
#[test]
fn long_single_token_measures_wider_than_wrap_cap() {
// Keeping the token whole means the measured text width is no longer
// clamped to the wrap cap, so the node box widens to fit it.
let lines = wrap_text_lines(
"mark_filter_restore_context",
DEFAULT_WRAP_WIDTH,
DEFAULT_CHAR_WIDTH,
);
let (width, _height) =
measure_wrapped_lines_with_font_size(&lines, DEFAULT_CHAR_WIDTH, DEFAULT_FONT_SIZE);
assert!(
width > DEFAULT_WRAP_WIDTH,
"measured width {width} must exceed wrap cap {DEFAULT_WRAP_WIDTH}"
);
}
#[test]
fn long_token_with_trailing_words_keeps_token_on_first_line() {
// The long leading token stays whole on its own line; the trailing
// words wrap onto a following line instead of being merged into it.
let lines = wrap_text_lines(
"_render_sidebar_for_active column mgmt",
DEFAULT_WRAP_WIDTH,
DEFAULT_CHAR_WIDTH,
);
assert_eq!(
lines,
vec![
vec!["_render_sidebar_for_active".to_string()],
vec!["column".to_string(), "mgmt".to_string()],
]
);
}
#[test]
fn multi_word_label_still_wraps_at_spaces() {
// Regression guard: a normal multi-word label that exceeds the wrap
// width still wraps at spaces, with every word kept intact.
let phrase = "the quick brown fox jumps over the lazy dog";
let lines = wrap_text_lines(phrase, DEFAULT_WRAP_WIDTH, DEFAULT_CHAR_WIDTH);
assert!(lines.len() >= 2, "long phrase must wrap: {lines:?}");
let flat: Vec<String> = lines.iter().flatten().cloned().collect();
let words: Vec<String> = phrase.split(' ').map(str::to_string).collect();
assert_eq!(flat, words);
}
#[test]
fn pathologically_long_token_breaks_on_identifier_boundary() {
// A token wider than the cap is force-broken, but the break lands on an
// identifier boundary ('_'), not mid-segment, and loses no graphemes.
let token = "segment_".repeat(25);
let cap = SINGLE_TOKEN_WIDTH_CAP_FACTOR * DEFAULT_WRAP_WIDTH;
assert!(line_width(&token, DEFAULT_CHAR_WIDTH) > cap);
let lines = wrap_text_lines(&token, DEFAULT_WRAP_WIDTH, DEFAULT_CHAR_WIDTH);
assert!(
lines.len() >= 2,
"over-cap token must be force-broken: {lines:?}"
);
assert_eq!(lines[0].len(), 1, "each broken piece is a single word");
assert!(
lines[0][0].ends_with('_'),
"first break must land on an identifier boundary, got {:?}",
lines[0][0]
);
let rejoined: String = lines.iter().flatten().cloned().collect();
assert_eq!(rejoined, token);
}
#[test]
fn over_cap_token_without_break_char_falls_back_to_grapheme_break() {
// No identifier boundary: the grapheme-break fallback still bounds each
// line to the cap and loses no graphemes.
let token = "a".repeat(200);
let cap = SINGLE_TOKEN_WIDTH_CAP_FACTOR * DEFAULT_WRAP_WIDTH;
assert!(line_width(&token, DEFAULT_CHAR_WIDTH) > cap);
let lines = wrap_text_lines(&token, DEFAULT_WRAP_WIDTH, DEFAULT_CHAR_WIDTH);
assert!(lines.len() >= 2, "over-cap token must be broken: {lines:?}");
assert!(line_width(&lines[0].concat(), DEFAULT_CHAR_WIDTH) <= cap);
let rejoined: String = lines.iter().flatten().cloned().collect();
assert_eq!(rejoined, token);
}
#[test]
fn over_cap_cjk_token_breaks_on_boundary_and_counts_wide_chars() {
// Wide chars count as two narrow units; an over-cap CJK token with
// separators still breaks at a `_`, never panics, and rejoins losslessly.
assert_eq!(display_width_units(""), 2.0);
let token = "中文_".repeat(50);
let cap = SINGLE_TOKEN_WIDTH_CAP_FACTOR * DEFAULT_WRAP_WIDTH;
assert!(line_width(&token, DEFAULT_CHAR_WIDTH) > cap);
let lines = wrap_text_lines(&token, DEFAULT_WRAP_WIDTH, DEFAULT_CHAR_WIDTH);
assert!(lines.len() >= 2, "over-cap CJK token must break: {lines:?}");
assert!(
lines[0][0].ends_with('_'),
"CJK break must land on a boundary, got {:?}",
lines[0][0]
);
let rejoined: String = lines.iter().flatten().cloned().collect();
assert_eq!(rejoined, token);
}
}
+164
View File
@@ -0,0 +1,164 @@
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MermaidTheme {
pub background: String,
pub node_fill: String,
pub node_stroke: String,
pub text_color: String,
pub edge_color: String,
pub subgraph_fill: String,
pub subgraph_stroke: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MermaidThemePreset {
Default,
Base,
Dark,
Forest,
Neutral,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct MermaidThemeVariables {
pub background: Option<String>,
pub node_fill: Option<String>,
pub node_stroke: Option<String>,
pub text_color: Option<String>,
pub edge_color: Option<String>,
pub subgraph_fill: Option<String>,
pub subgraph_stroke: Option<String>,
}
impl Default for MermaidTheme {
fn default() -> Self {
Self::light()
}
}
impl MermaidThemePreset {
pub fn parse(value: &str) -> Option<Self> {
match value {
"default" => Some(Self::Default),
"base" => Some(Self::Base),
"dark" => Some(Self::Dark),
"forest" => Some(Self::Forest),
"neutral" => Some(Self::Neutral),
_ => None,
}
}
pub fn to_theme(self) -> MermaidTheme {
match self {
Self::Default => MermaidTheme::light(),
Self::Base => MermaidTheme::base(),
Self::Dark => MermaidTheme::dark(),
Self::Forest => MermaidTheme::forest(),
Self::Neutral => MermaidTheme::neutral(),
}
}
}
impl MermaidThemeVariables {
pub fn is_empty(&self) -> bool {
self.background.is_none()
&& self.node_fill.is_none()
&& self.node_stroke.is_none()
&& self.text_color.is_none()
&& self.edge_color.is_none()
&& self.subgraph_fill.is_none()
&& self.subgraph_stroke.is_none()
}
pub fn apply_mermaid_alias(&mut self, key: &str, value: String) -> bool {
match key {
"background" => self.background = Some(value),
"primaryColor" | "mainBkg" => self.node_fill = Some(value),
"primaryBorderColor" | "nodeBorder" => self.node_stroke = Some(value),
"primaryTextColor" | "nodeTextColor" | "textColor" => self.text_color = Some(value),
"lineColor" | "defaultLinkColor" => self.edge_color = Some(value),
"clusterBkg" => self.subgraph_fill = Some(value),
"clusterBorder" => self.subgraph_stroke = Some(value),
_ => return false,
}
true
}
pub fn apply_to(&self, theme: &mut MermaidTheme) {
if let Some(value) = &self.background {
theme.background.clone_from(value);
}
if let Some(value) = &self.node_fill {
theme.node_fill.clone_from(value);
}
if let Some(value) = &self.node_stroke {
theme.node_stroke.clone_from(value);
}
if let Some(value) = &self.text_color {
theme.text_color.clone_from(value);
}
if let Some(value) = &self.edge_color {
theme.edge_color.clone_from(value);
}
if let Some(value) = &self.subgraph_fill {
theme.subgraph_fill.clone_from(value);
}
if let Some(value) = &self.subgraph_stroke {
theme.subgraph_stroke.clone_from(value);
}
}
}
impl MermaidTheme {
pub fn light() -> Self {
Self {
background: "#ffffff".to_string(),
node_fill: "#ECECFF".to_string(),
node_stroke: "#9370DB".to_string(),
text_color: "#333333".to_string(),
edge_color: "#333333".to_string(),
subgraph_fill: "#ffffde".to_string(),
subgraph_stroke: "#aaaa33".to_string(),
}
}
pub fn dark() -> Self {
Self {
background: "#1e1e1e".to_string(),
node_fill: "#2d2d2d".to_string(),
node_stroke: "#888888".to_string(),
text_color: "#ffffff".to_string(),
edge_color: "#888888".to_string(),
subgraph_fill: "#3a3a20".to_string(),
subgraph_stroke: "#888844".to_string(),
}
}
pub fn base() -> Self {
Self::light()
}
pub fn forest() -> Self {
Self {
background: "#f4f4f4".to_string(),
node_fill: "#cde498".to_string(),
node_stroke: "#13540c".to_string(),
text_color: "#333333".to_string(),
edge_color: "#333333".to_string(),
subgraph_fill: "#cde498".to_string(),
subgraph_stroke: "#13540c".to_string(),
}
}
pub fn neutral() -> Self {
Self {
background: "#ffffff".to_string(),
node_fill: "#eeeeee".to_string(),
node_stroke: "#999999".to_string(),
text_color: "#333333".to_string(),
edge_color: "#333333".to_string(),
subgraph_fill: "#eeeeee".to_string(),
subgraph_stroke: "#999999".to_string(),
}
}
}
+513
View File
@@ -0,0 +1,513 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
// --- Mermaid 11.12.2 timeline layout constants ---
const LEFT_MARGIN: f64 = 50.0;
const INITIAL_MASTER_X: f64 = 50.0 + LEFT_MARGIN; // 100
const INITIAL_MASTER_Y: f64 = 50.0;
const NODE_BASE_WIDTH: f64 = 150.0;
const NODE_PADDING: f64 = 20.0;
const NODE_WIDTH: f64 = NODE_BASE_WIDTH + 2.0 * NODE_PADDING; // 190
const NODE_STEP: f64 = 200.0;
const FONT_SIZE: f64 = 16.0;
const EVENT_VERTICAL_GAP: f64 = 100.0;
const DASHED_LINE_EXTENSION: f64 = 100.0;
const NODE_CORNER_RADIUS: f64 = 5.0;
const MAX_SECTIONS: usize = 12;
const ARROW_STROKE_WIDTH: f64 = 4.0;
const CONNECTOR_STROKE_WIDTH: f64 = 2.0;
const NODE_LINE_STROKE_WIDTH: f64 = 3.0;
const FONT_FAMILY: &str = r#""trebuchet ms", verdana, arial, sans-serif"#;
const TASK_FONT_SIZE: f64 = 14.0;
const TASK_FONT_FAMILY: &str = "'Open Sans', sans-serif";
// Approximate character width for text measurement at 16px
const CHAR_WIDTH: f64 = 9.0;
// Mermaid 11.12.2 default theme cScale colors (after darken by 10)
const CSCALE_FILLS: &[&str] = &[
"#BABAFF", // cScale0: periwinkle (primaryColor #ECECFF)
"#FFFFAC", // cScale1: yellow (secondaryColor #ffffde)
"#E8FFB9", // cScale2: lime green (tertiaryColor)
"#D4BAFF", // cScale3
"#FFBAFF", // cScale4
"#FFBADC", // cScale5
"#BAFFBA", // cScale6
"#BAFFDC", // cScale7
"#BAFFFF", // cScale8
"#BABAFF", // cScale9
"#DCBAFF", // cScale10
"#FFBAEF", // cScale11
];
// cScaleInv = hue-shifted by 180° from cScale (for node bottom line stroke)
const CSCALE_INV: &[&str] = &[
"#FFFFAC", "#BABAFF", "#FFB9E8", "#BAFFD4", "#BAFF9A", "#BAFFDC", "#FFBA9A", "#FFBADC",
"#FFBABA", "#FFFFBA", "#BAFFBA", "#BAFFE0",
];
// cScaleLabel text colors (cScaleLabel0 and cScaleLabel3 = white, rest = black)
const CSCALE_LABEL: &[&str] = &[
"#ffffff", "#000000", "#000000", "#ffffff", "#000000", "#000000", "#000000", "#000000",
"#000000", "#000000", "#000000", "#000000",
];
pub fn render_timeline_diagram_to_svg(
mermaid_source: &str,
_theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let timeline = parse_timeline_diagram(mermaid_source)?;
let has_sections = !timeline.sections.is_empty();
let tasks = &timeline.tasks;
if tasks.is_empty() {
return Err(MermaidError::ParseError {
line: 1,
message: "Timeline requires at least one entry".to_string(),
});
}
// --- Compute layout metrics ---
let mut max_section_height = 0.0_f64;
if has_sections {
for section_name in &timeline.sections {
let h = estimate_node_height(section_name, NODE_PADDING, 0.0);
max_section_height = max_section_height.max(h + 20.0);
}
}
let mut max_task_height = 0.0_f64;
let mut max_event_line_length = 0.0_f64;
for task in tasks {
let h = estimate_node_height(&task.period, NODE_PADDING, 0.0);
max_task_height = max_task_height.max(h + 20.0);
let mut event_line_len = 0.0_f64;
for event in &task.events {
event_line_len += estimate_node_height(event, NODE_PADDING, 50.0);
}
if task.events.len() > 1 {
event_line_len += (task.events.len() - 1) as f64 * 10.0;
}
max_event_line_length = max_event_line_length.max(event_line_len);
}
// --- Build SVG body ---
let mut svg = String::with_capacity(4096);
let mut content_right = 0.0_f64;
let mut content_bottom = 0.0_f64;
// CSS styles matching mermaid.js timeline default theme
let mut css = String::new();
css.push_str(&format!(
"svg{{font-family:{FONT_FAMILY};font-size:{FONT_SIZE}px;fill:#333;}}"
));
for i in 0..MAX_SECTIONS {
let si = i as isize - 1;
let fill = CSCALE_FILLS[i % CSCALE_FILLS.len()];
let label = CSCALE_LABEL[i % CSCALE_LABEL.len()];
let inv = CSCALE_INV[i % CSCALE_INV.len()];
css.push_str(&format!(
".section-{si} rect,.section-{si} path,.section-{si} circle{{fill:{fill};}}"
));
css.push_str(&format!(".section-{si} text{{fill:{label};}}"));
css.push_str(&format!(
".section-{si} line{{stroke:{inv};stroke-width:{NODE_LINE_STROKE_WIDTH};}}"
));
}
css.push_str(".eventWrapper{filter:brightness(120%);}");
css.push_str(".lineWrapper line{stroke:black;}");
let defs = "<defs><marker id=\"arrowhead\" refX=\"5\" refY=\"2\" markerWidth=\"6\" \
markerHeight=\"4\" orient=\"auto\"><path d=\"M 0,0 V 4 L6,2 Z\"/></marker></defs>";
let mut body = String::new();
// --- Draw tasks and events ---
let mut master_x = INITIAL_MASTER_X;
let master_y = INITIAL_MASTER_Y;
let section_begin_y = INITIAL_MASTER_Y;
let mut section_number: usize = 0;
if has_sections {
for section_name in &timeline.sections {
let tasks_for_section: Vec<&TimelineTask> = tasks
.iter()
.filter(|t| t.section.as_deref() == Some(section_name.as_str()))
.collect();
let section_width = 200.0 * (tasks_for_section.len().max(1)) as f64 - 50.0;
let section_idx = section_number % MAX_SECTIONS;
let section_css_idx = section_idx as isize - 1;
body.push_str(&format!(
"<g class=\"timeline-node section-{section_css_idx}\" \
transform=\"translate({master_x},{section_begin_y})\">"
));
render_node_background(&mut body, section_width, max_section_height);
render_node_text(&mut body, section_name, section_width);
body.push_str("</g>");
let task_y = section_begin_y + max_section_height + 50.0;
if !tasks_for_section.is_empty() {
render_tasks(
&mut body,
&tasks_for_section,
section_number,
&mut master_x,
task_y,
max_task_height,
max_event_line_length,
&mut content_right,
&mut content_bottom,
false,
);
}
master_x += 200.0 * (tasks_for_section.len().max(1)) as f64;
section_number += 1;
}
} else {
let task_refs: Vec<&TimelineTask> = tasks.iter().collect();
render_tasks(
&mut body,
&task_refs,
section_number,
&mut master_x,
master_y,
max_task_height,
max_event_line_length,
&mut content_right,
&mut content_bottom,
true,
);
}
// --- Horizontal arrow ---
let depth_y = if has_sections {
max_section_height + max_task_height + 150.0
} else {
max_task_height + 100.0
};
// In mermaid.js, box.width is computed from SVG bounding box BEFORE arrow/title
let nodes_box_width = content_right;
let arrow_x1 = LEFT_MARGIN;
let arrow_x2 = nodes_box_width + 3.0 * LEFT_MARGIN;
content_right = content_right.max(arrow_x2 + 10.0);
body.push_str(&format!(
"<g class=\"lineWrapper\"><line x1=\"{arrow_x1:.1}\" y1=\"{depth_y:.1}\" \
x2=\"{arrow_x2:.1}\" y2=\"{depth_y:.1}\" \
stroke-width=\"{ARROW_STROKE_WIDTH}\" stroke=\"black\" \
marker-end=\"url(#arrowhead)\"/></g>"
));
// --- Title ---
// Position uses node bounding box width (before arrow), matching mermaid.js
let title_content = if let Some(title) = &timeline.title {
let title_x = nodes_box_width / 2.0 - LEFT_MARGIN;
// Estimate title width to expand viewBox if needed
let approx_title_width = title.len() as f64 * 24.0; // ~24px/char at 4ex
content_right = content_right.max(title_x + approx_title_width + 20.0);
format!(
"<text x=\"{title_x:.1}\" y=\"20\" font-size=\"4ex\" \
font-weight=\"bold\" fill=\"#333\">{}</text>",
escape_xml(title)
)
} else {
String::new()
};
content_bottom = content_bottom.max(depth_y + 20.0);
// --- Assemble final SVG ---
let vb_padding = 50.0;
let vb_width = content_right + vb_padding;
let vb_height = content_bottom + vb_padding;
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" \
xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
style=\"max-width: {vb_width:.0}px;\" \
width=\"100%\" \
viewBox=\"0 -25 {vb_width:.0} {vh:.0}\" \
preserveAspectRatio=\"xMinYMin meet\" \
height=\"{sh:.0}\" \
role=\"graphics-document document\" \
aria-roledescription=\"timeline\">",
vh = vb_height + 25.0,
sh = vb_height + 50.0,
));
svg.push_str(&format!("<style>{css}</style>"));
svg.push_str(defs);
svg.push_str(&title_content);
svg.push_str(&body);
svg.push_str("</svg>");
Ok(svg)
}
#[allow(clippy::too_many_arguments)]
fn render_tasks(
body: &mut String,
tasks: &[&TimelineTask],
initial_section_color: usize,
master_x: &mut f64,
master_y: f64,
max_task_height: f64,
max_event_line_length: f64,
content_right: &mut f64,
content_bottom: &mut f64,
is_multicolor: bool,
) {
let mut section_color = initial_section_color;
for task in tasks {
let section_idx = section_color % MAX_SECTIONS;
let section_css_idx = section_idx as isize - 1;
// Draw task (period) node
body.push_str(&format!(
"<g class=\"taskWrapper\"><g class=\"timeline-node section-{section_css_idx}\" \
transform=\"translate({mx},{my})\">",
mx = *master_x,
my = master_y,
));
render_node_background(body, NODE_WIDTH, max_task_height);
render_node_text(body, &task.period, NODE_WIDTH);
body.push_str("</g></g>");
*content_right = (*content_right).max(*master_x + NODE_WIDTH);
// Draw events below
if !task.events.is_empty() {
let mut event_y = master_y + EVENT_VERTICAL_GAP + EVENT_VERTICAL_GAP;
for event in &task.events {
let event_height = estimate_event_height(event);
body.push_str(&format!(
"<g class=\"eventWrapper\"><g class=\"timeline-node section-{section_css_idx}\" \
transform=\"translate({mx},{ey})\">",
mx = *master_x,
ey = event_y,
));
render_node_background(body, NODE_WIDTH, event_height);
render_node_text(body, event, NODE_WIDTH);
body.push_str("</g></g>");
event_y += event_height + 10.0;
}
// Dashed vertical connector line with arrowhead
let line_x = *master_x + NODE_WIDTH / 2.0;
let line_y1 = master_y + max_task_height;
let line_y2 = master_y
+ max_task_height
+ EVENT_VERTICAL_GAP
+ max_event_line_length
+ DASHED_LINE_EXTENSION;
body.push_str(&format!(
"<g class=\"lineWrapper\"><line x1=\"{line_x:.1}\" y1=\"{line_y1:.1}\" \
x2=\"{line_x:.1}\" y2=\"{line_y2:.1}\" \
stroke-width=\"{CONNECTOR_STROKE_WIDTH}\" stroke=\"black\" \
marker-end=\"url(#arrowhead)\" stroke-dasharray=\"5,5\"/></g>"
));
*content_bottom = (*content_bottom).max(line_y2 + 10.0);
}
*master_x += NODE_STEP;
if is_multicolor {
section_color += 1;
}
}
}
fn estimate_text_height(text: &str) -> f64 {
let text_width = text.len() as f64 * CHAR_WIDTH;
let num_lines = (text_width / NODE_BASE_WIDTH).ceil().max(1.0);
num_lines * FONT_SIZE * 1.2
}
fn estimate_node_height(text: &str, padding: f64, max_height: f64) -> f64 {
let text_h = estimate_text_height(text);
let h = text_h + FONT_SIZE * 1.1 * 0.5 + padding;
h.max(max_height)
}
fn estimate_event_height(text: &str) -> f64 {
let text_h = estimate_text_height(text);
let h = text_h + FONT_SIZE * 1.1 * 0.5 + NODE_PADDING;
h.max(50.0)
}
/// Render the node background shape: rounded top corners, flat bottom with a line.
/// Matches mermaid.js `defaultBkg` function.
fn render_node_background(svg: &mut String, width: f64, height: f64) {
let rd = NODE_CORNER_RADIUS;
svg.push_str(&format!(
"<g><path class=\"node-bkg\" d=\"M0 {h_rd:.1} v{up:.1} q0,-{rd} {rd},-{rd} \
h{across:.1} q{rd},0 {rd},{rd} v{down:.1} H0 Z\"/>",
h_rd = height - rd,
up = -(height - 2.0 * rd),
rd = rd,
across = width - 2.0 * rd,
down = height - rd,
));
svg.push_str(&format!(
"<line x1=\"0\" y1=\"{height:.1}\" x2=\"{width:.1}\" y2=\"{height:.1}\"/>"
));
svg.push_str("</g>");
}
/// Render centered text inside a node.
fn render_node_text(svg: &mut String, text: &str, width: f64) {
let x = width / 2.0;
svg.push_str(&format!(
"<g transform=\"translate({x:.1},{ty:.1})\">\
<text x=\"0\" y=\"0\" dy=\"1em\" \
alignment-baseline=\"middle\" dominant-baseline=\"middle\" \
text-anchor=\"middle\" \
style=\"font-size:{TASK_FONT_SIZE}px;font-family:{TASK_FONT_FAMILY};\">\
{}</text></g>",
escape_xml(text),
ty = NODE_PADDING / 2.0,
));
}
// --- Data model ---
#[derive(Debug, Clone)]
struct TimelineDiagram {
title: Option<String>,
sections: Vec<String>,
tasks: Vec<TimelineTask>,
}
#[derive(Debug, Clone)]
struct TimelineTask {
period: String,
events: Vec<String>,
section: Option<String>,
}
// --- Parser ---
fn parse_timeline_diagram(input: &str) -> Result<TimelineDiagram, MermaidError> {
let lines: Vec<&str> = input.lines().collect();
let mut i = 0_usize;
while i < lines.len() {
let line = lines[i].trim();
if line.is_empty() || line.starts_with("%%") {
i += 1;
continue;
}
if line.split_whitespace().next() == Some("timeline") {
i += 1;
break;
}
return Err(MermaidError::ParseError {
line: i + 1,
message: "Expected 'timeline' declaration".to_string(),
});
}
let mut title: Option<String> = None;
let mut sections: Vec<String> = Vec::new();
let mut tasks: Vec<TimelineTask> = Vec::new();
let mut current_section: Option<String> = None;
while i < lines.len() {
let raw = lines[i];
let line = raw.trim();
i += 1;
if line.is_empty() || line.starts_with("%%") || line.starts_with('#') {
continue;
}
// Title directive
if let Some(rest) = line.strip_prefix("title ") {
let t = rest.trim();
if !t.is_empty() {
title = Some(t.to_string());
}
continue;
}
// Section directive
if let Some(rest) = line.strip_prefix("section ") {
let s = rest.trim();
if !s.is_empty() {
current_section = Some(s.to_string());
if !sections.contains(&s.to_string()) {
sections.push(s.to_string());
}
}
continue;
}
// Event line (starts with ": " — additional event for the previous task)
if let Some(event_text) = line.strip_prefix(": ") {
let event_text = event_text.trim();
if !event_text.is_empty() {
if let Some(last_task) = tasks.last_mut() {
last_task.events.push(event_text.to_string());
}
}
continue;
}
// Period with optional event: "period : event" or just "period"
if let Some((period, event)) = line.split_once(':') {
let period = period.trim();
let event = event.trim();
if !period.is_empty() {
let events = if event.is_empty() {
vec![]
} else {
vec![event.to_string()]
};
tasks.push(TimelineTask {
period: period.to_string(),
events,
section: current_section.clone(),
});
continue;
}
}
// Period without event (bare text line)
if !line.is_empty() {
tasks.push(TimelineTask {
period: line.to_string(),
events: vec![],
section: current_section.clone(),
});
}
}
Ok(TimelineDiagram {
title,
sections,
tasks,
})
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+867
View File
@@ -0,0 +1,867 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
const CHART_WIDTH: f64 = 700.0;
const CHART_HEIGHT: f64 = 500.0;
const CHART_TITLE_FONT_SIZE: f64 = 20.0;
const CHART_TITLE_PADDING: f64 = 10.0;
const AXIS_LABEL_FONT_SIZE: f64 = 14.0;
const AXIS_LABEL_PADDING: f64 = 5.0;
const AXIS_TITLE_FONT_SIZE: f64 = 16.0;
const AXIS_TITLE_PADDING: f64 = 5.0;
const AXIS_TICK_LENGTH: f64 = 5.0;
const AXIS_TICK_WIDTH: f64 = 2.0;
const AXIS_LINE_WIDTH: f64 = 2.0;
const DEFAULT_TICK_COUNT: usize = 10;
/// Floor for the auto-shrunk categorical x-axis label font (this port has no
/// label rotation, so a busy axis shrinks-to-fit down to here, then overflows).
const MIN_X_LABEL_FONT_SIZE: f64 = 8.0;
const PLOT_RIGHT_MARGIN: f64 = 12.0;
/// Per-series colors (Tableau 10), cycled by series index. Mid-tone hues stay
/// legible on both the light and dark surfaces this engine renders onto.
const SERIES_PALETTE: [&str; 10] = [
"#4e79a7", "#f28e2b", "#e15759", "#76b7b2", "#59a14f", "#edc948", "#b07aa1", "#ff9da7",
"#9c755f", "#bab0ac",
];
pub fn render_xychart_diagram_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let chart = parse_xychart(mermaid_source)?;
// Theme text color (not a fixed near-black) so axes stay visible on dark.
let axis_color = theme.text_color.as_str();
let y_ticks = d3_ticks(chart.y_min, chart.y_max, DEFAULT_TICK_COUNT);
let y_tick_labels: Vec<String> = y_ticks.iter().map(|v| format_tick(*v)).collect();
let label_text_height = approx_text_height(AXIS_LABEL_FONT_SIZE);
let y_label_max_width = y_tick_labels
.iter()
.map(|s| approx_text_width(s, AXIS_LABEL_FONT_SIZE))
.fold(0.0, f64::max);
let title_height = if chart.title.is_empty() {
0.0
} else {
approx_text_height(CHART_TITLE_FONT_SIZE) + 2.0 * CHART_TITLE_PADDING
};
let y_title_width = if chart.y_title.is_empty() {
0.0
} else {
approx_text_height(AXIS_TITLE_FONT_SIZE) + 2.0 * AXIS_TITLE_PADDING
};
let x_title_height = if chart.x_title.is_empty() {
0.0
} else {
approx_text_height(AXIS_TITLE_FONT_SIZE) + 2.0 * AXIS_TITLE_PADDING
};
let left_axis_width =
AXIS_LINE_WIDTH + AXIS_TICK_LENGTH + (y_label_max_width + 2.0 * AXIS_LABEL_PADDING);
let plot_x = y_title_width + left_axis_width;
let plot_y = title_height;
let plot_w = (CHART_WIDTH - plot_x - PLOT_RIGHT_MARGIN).max(1.0);
let point_count = chart.series.iter().map(Vec::len).max().unwrap_or(0);
let x = layout_x_axis(&chart.x_axis, plot_x, plot_w, point_count);
// Bottom band depends on the resolved (possibly shrunk) x-label font.
let x_label_height = approx_text_height(x.label_font);
let bottom_axis_height = AXIS_LINE_WIDTH
+ AXIS_TICK_LENGTH
+ (x_label_height + 2.0 * AXIS_LABEL_PADDING)
+ x_title_height;
let plot_h = (CHART_HEIGHT - plot_y - bottom_axis_height).max(1.0);
let y_outer_padding = (label_text_height / 2.0).min(0.2 * plot_h);
let y_top = plot_y + y_outer_padding;
let y_bottom = plot_y + plot_h - y_outer_padding;
let y_at = |v: f64| scale_linear(v, chart.y_min, chart.y_max, y_bottom, y_top);
let mut svg = String::new();
svg.push_str(&format!(
"<svg aria-roledescription=\"xychart\" role=\"graphics-document document\" viewBox=\"0 0 {CHART_WIDTH} {CHART_HEIGHT}\" style=\"max-width: {CHART_WIDTH}px; background-color: {};\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"100%\" id=\"my-svg\">",
theme.background
));
svg.push_str("<g/><g class=\"main\">");
svg.push_str(&format!(
"<rect fill=\"{}\" class=\"background\" height=\"{CHART_HEIGHT}\" width=\"{CHART_WIDTH}\"/>",
theme.background
));
if !chart.title.is_empty() {
let title_y = title_height / 2.0;
let title_x = CHART_WIDTH / 2.0;
svg.push_str("<g class=\"chart-title\">");
svg.push_str(&format!(
"<text transform=\"translate({title_x}, {title_y}) rotate(0)\" text-anchor=\"middle\" dominant-baseline=\"middle\" font-size=\"{CHART_TITLE_FONT_SIZE}\" fill=\"{axis_color}\" y=\"0\" x=\"0\">{}</text>",
escape_xml(&chart.title)
));
svg.push_str("</g>");
}
svg.push_str("<g class=\"plot\">");
for (idx, values) in chart.series.iter().enumerate() {
let points: Vec<(f64, f64)> = values
.iter()
.enumerate()
.map(|(i, v)| (x.series_point_x(i), y_at(*v)))
.collect();
if points.is_empty() {
continue;
}
let d = points_to_path_d(&points);
let stroke = SERIES_PALETTE[idx % SERIES_PALETTE.len()];
svg.push_str(&format!("<g class=\"line-plot-{idx}\">"));
svg.push_str(&format!(
"<path stroke-width=\"2\" stroke=\"{stroke}\" fill=\"none\" d=\"{d}\"/>",
));
svg.push_str("</g>");
}
svg.push_str("</g>");
let bottom_axis_y = plot_y + plot_h;
svg.push_str("<g class=\"bottom-axis\">");
svg.push_str("<g class=\"axis-line\">");
svg.push_str(&format!(
"<path stroke-width=\"{AXIS_LINE_WIDTH}\" stroke=\"{axis_color}\" fill=\"none\" d=\"M {plot_x},{y} L {x_end},{y}\"/>",
y = bottom_axis_y + AXIS_LINE_WIDTH / 2.0,
x_end = plot_x + plot_w,
));
svg.push_str("</g>");
svg.push_str("<g class=\"label\">");
let x_label_y = bottom_axis_y + AXIS_LABEL_PADDING + AXIS_TICK_LENGTH + AXIS_LINE_WIDTH;
for (pos, label) in x.tick_positions.iter().zip(x.tick_labels.iter()) {
svg.push_str(&format!(
"<text transform=\"translate({pos}, {x_label_y}) rotate(0)\" text-anchor=\"middle\" dominant-baseline=\"text-before-edge\" font-size=\"{font}\" fill=\"{axis_color}\" y=\"0\" x=\"0\">{}</text>",
escape_xml(label),
font = x.label_font,
));
}
svg.push_str("</g>");
svg.push_str("<g class=\"ticks\">");
let tick_y0 = bottom_axis_y + AXIS_LINE_WIDTH;
let tick_y1 = tick_y0 + AXIS_TICK_LENGTH;
for pos in &x.tick_positions {
svg.push_str(&format!(
"<path stroke-width=\"{AXIS_TICK_WIDTH}\" stroke=\"{axis_color}\" fill=\"none\" d=\"M {pos},{tick_y0} L {pos},{tick_y1}\"/>",
));
}
svg.push_str("</g>");
svg.push_str("</g>");
svg.push_str("<g class=\"left-axis\">");
svg.push_str("<g class=\"axisl-line\">");
let axis_x = plot_x - AXIS_LINE_WIDTH / 2.0;
svg.push_str(&format!(
"<path stroke-width=\"{AXIS_LINE_WIDTH}\" stroke=\"{axis_color}\" fill=\"none\" d=\"M {axis_x},{plot_y} L {axis_x},{y1}\"/>",
y1 = plot_y + plot_h,
));
svg.push_str("</g>");
svg.push_str("<g class=\"label\">");
let y_label_x = plot_x - AXIS_LABEL_PADDING - AXIS_TICK_LENGTH - AXIS_LINE_WIDTH;
for (tick_value, tick_label) in y_ticks.iter().zip(y_tick_labels.iter()) {
let y = y_at(*tick_value);
svg.push_str(&format!(
"<text transform=\"translate({y_label_x}, {y}) rotate(0)\" text-anchor=\"end\" dominant-baseline=\"middle\" font-size=\"{AXIS_LABEL_FONT_SIZE}\" fill=\"{axis_color}\" y=\"0\" x=\"0\">{}</text>",
escape_xml(tick_label)
));
}
svg.push_str("</g>");
svg.push_str("<g class=\"ticks\">");
let tick_x0 = plot_x - AXIS_LINE_WIDTH;
let tick_x1 = tick_x0 - AXIS_TICK_LENGTH;
for tick_value in &y_ticks {
let y = y_at(*tick_value);
svg.push_str(&format!(
"<path stroke-width=\"{AXIS_TICK_WIDTH}\" stroke=\"{axis_color}\" fill=\"none\" d=\"M {tick_x0},{y} L {tick_x1},{y}\"/>",
));
}
svg.push_str("</g>");
svg.push_str("</g>");
if !chart.x_title.is_empty() {
let tx = plot_x + plot_w / 2.0;
let ty = CHART_HEIGHT - x_title_height / 2.0;
svg.push_str("<g class=\"x-axis-title\">");
svg.push_str(&format!(
"<text transform=\"translate({tx}, {ty}) rotate(0)\" text-anchor=\"middle\" dominant-baseline=\"middle\" font-size=\"{AXIS_TITLE_FONT_SIZE}\" fill=\"{axis_color}\" y=\"0\" x=\"0\">{}</text>",
escape_xml(&chart.x_title)
));
svg.push_str("</g>");
}
if !chart.y_title.is_empty() {
let tx = y_title_width / 2.0;
let ty = plot_y + plot_h / 2.0;
svg.push_str("<g class=\"y-axis-title\">");
svg.push_str(&format!(
"<text transform=\"translate({tx}, {ty}) rotate(-90)\" text-anchor=\"middle\" dominant-baseline=\"middle\" font-size=\"{AXIS_TITLE_FONT_SIZE}\" fill=\"{axis_color}\" y=\"0\" x=\"0\">{}</text>",
escape_xml(&chart.y_title)
));
svg.push_str("</g>");
}
svg.push_str("</g><g class=\"mermaid-tmp-group\"/></svg>");
Ok(svg)
}
/// Either evenly-spaced named categories (`x-axis [a, b]`) or a numeric range.
#[derive(Debug, Clone, PartialEq)]
enum XAxis {
Numeric { min: f64, max: f64 },
Category(Vec<String>),
}
#[derive(Debug, Clone)]
struct XyChart {
title: String,
x_title: String,
y_title: String,
x_axis: XAxis,
y_min: f64,
y_max: f64,
series: Vec<Vec<f64>>,
}
fn parse_xychart(input: &str) -> Result<XyChart, MermaidError> {
let mut found_header = false;
let mut title = String::new();
let mut x_title = String::new();
let mut y_title = String::new();
let mut x_axis: Option<XAxis> = None;
let mut y_range: Option<(f64, f64)> = None;
let mut series: Vec<Vec<f64>> = Vec::new();
for (idx, raw) in input.lines().enumerate() {
let line_no = idx + 1;
let line = raw.trim();
if line.is_empty() || line.starts_with("%%") {
continue;
}
if !found_header {
if line.split_whitespace().next() != Some("xychart-beta") {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected 'xychart-beta' declaration".to_string(),
});
}
found_header = true;
continue;
}
if let Some(rest) = line.strip_prefix("title ") {
title = unquote(rest);
continue;
}
if let Some(rest) = line.strip_prefix("x-axis ") {
let (label, axis) = parse_x_axis(rest.trim(), line_no)?;
x_title = label;
x_axis = Some(axis);
continue;
}
if let Some(rest) = line.strip_prefix("y-axis ") {
let (label, range) = parse_y_axis(rest.trim(), line_no)?;
y_title = label;
if let Some(range) = range {
y_range = Some(range);
}
continue;
}
if let Some(values) = parse_series_line(line, line_no)? {
series.push(values);
continue;
}
// Unknown lines (e.g. an unsupported `bar` series) are ignored.
}
if !found_header {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'xychart-beta' declaration".to_string(),
});
}
if series.iter().all(|values| values.is_empty()) {
return Err(MermaidError::ParseError {
line: 1,
message: "xychart requires at least one plot".to_string(),
});
}
let x_axis = x_axis.unwrap_or(XAxis::Numeric { min: 0.0, max: 0.0 });
let (y_min, y_max) = y_range.unwrap_or_else(|| auto_y_range(&series));
Ok(XyChart {
title,
x_title,
y_title,
x_axis,
y_min,
y_max,
series,
})
}
/// Parse an `x-axis` body: optional title plus a category list or numeric range.
fn parse_x_axis(rest: &str, line: usize) -> Result<(String, XAxis), MermaidError> {
if let Some(open) = rest.find('[') {
let title = unquote(rest[..open].trim());
let close =
rest.rfind(']')
.filter(|c| *c > open)
.ok_or_else(|| MermaidError::ParseError {
line,
message: format!("Invalid x-axis categories: {rest}"),
})?;
let categories = parse_category_list(&rest[open + 1..close]);
Ok((title, XAxis::Category(categories)))
} else if rest.contains("-->") {
let (title, min, max) = parse_labeled_range(rest, line)?;
Ok((title, XAxis::Numeric { min, max }))
} else {
Ok((unquote(rest), XAxis::Category(Vec::new())))
}
}
/// Parse a `y-axis` body; a title without a range auto-ranges from the data.
fn parse_y_axis(rest: &str, line: usize) -> Result<(String, Option<(f64, f64)>), MermaidError> {
if rest.contains("-->") {
let (title, min, max) = parse_labeled_range(rest, line)?;
Ok((title, Some((min, max))))
} else {
Ok((unquote(rest), None))
}
}
/// Parse a `[title] min --> max` body; the title is everything left of `min`.
fn parse_labeled_range(s: &str, line: usize) -> Result<(String, f64, f64), MermaidError> {
let (left, right) = s
.split_once("-->")
.ok_or_else(|| MermaidError::ParseError {
line,
message: format!("Invalid axis range: {s}"),
})?;
let max: f64 = right.trim().parse().map_err(|_| MermaidError::ParseError {
line,
message: format!("Invalid axis max: {}", right.trim()),
})?;
let left = left.trim();
let (title, min_str) = match left.rsplit_once(char::is_whitespace) {
Some((title, min)) => (title.trim(), min.trim()),
None => ("", left),
};
let min: f64 = min_str.parse().map_err(|_| MermaidError::ParseError {
line,
message: format!("Invalid axis min: {min_str}"),
})?;
Ok((unquote(title), min, max))
}
/// Parse a `line [..]` series; non-`line` declarations return `None`.
fn parse_series_line(line: &str, line_no: usize) -> Result<Option<Vec<f64>>, MermaidError> {
let Some(rest) = strip_keyword(line, "line") else {
return Ok(None);
};
let values = parse_bracketed_number_list(rest.trim(), line_no)?;
Ok(Some(values))
}
/// Strip `keyword` only when it stands alone (end / whitespace / `[` follows),
/// so `line` matches but `linear` does not.
fn strip_keyword<'a>(line: &'a str, keyword: &str) -> Option<&'a str> {
let rest = line.strip_prefix(keyword)?;
match rest.chars().next() {
None => Some(rest),
Some(c) if c.is_whitespace() || c == '[' => Some(rest),
_ => None,
}
}
/// Split on top-level (quote-aware) commas, then unquote/trim each entry.
fn parse_category_list(inner: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
for c in inner.chars() {
match quote {
Some(q) => {
if c == q {
quote = None;
}
current.push(c);
}
None => match c {
'"' | '\'' => {
quote = Some(c);
current.push(c);
}
',' => out.push(std::mem::take(&mut current)),
_ => current.push(c),
},
}
}
out.push(current);
out.into_iter()
.map(|s| unquote(s.trim()))
.filter(|s| !s.is_empty())
.collect()
}
/// Strip one pair of matching surrounding quotes (`"…"` or `'…'`).
fn unquote(s: &str) -> String {
let t = s.trim();
let bytes = t.as_bytes();
if t.len() >= 2 {
let first = bytes[0];
let last = bytes[t.len() - 1];
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
return t[1..t.len() - 1].to_string();
}
}
t.to_string()
}
fn auto_y_range(series: &[Vec<f64>]) -> (f64, f64) {
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for values in series {
for &v in values {
min = min.min(v);
max = max.max(v);
}
}
if !min.is_finite() || !max.is_finite() {
return (0.0, 0.0);
}
if (max - min).abs() < f64::EPSILON {
return (min - 1.0, max + 1.0);
}
(min, max)
}
struct XAxisLayout {
tick_positions: Vec<f64>,
tick_labels: Vec<String>,
label_font: f64,
/// Longest series length, shared by every series so the same index maps to
/// the same x across series (overlaid lines stay on one x domain).
point_count: usize,
geom: XGeom,
}
enum XGeom {
/// Band scale: points/ticks at band centers.
Category { plot_left: f64, band_w: f64 },
/// Linear scale: points evenly distributed across `[x0, x1]`.
Numeric { x0: f64, x1: f64 },
}
impl XAxisLayout {
fn series_point_x(&self, i: usize) -> f64 {
match self.geom {
XGeom::Category { plot_left, band_w } => plot_left + (i as f64 + 0.5) * band_w,
XGeom::Numeric { x0, x1 } => {
if self.point_count <= 1 {
x0
} else {
x0 + (i as f64) / ((self.point_count - 1) as f64) * (x1 - x0)
}
}
}
}
}
fn layout_x_axis(x_axis: &XAxis, plot_x: f64, plot_w: f64, point_count: usize) -> XAxisLayout {
match x_axis {
XAxis::Category(categories) => {
// Size bands to whichever is larger so every series point lands in a
// band (a series longer than the category list still stays on-plot).
let n = categories.len().max(point_count).max(1);
let band_w = plot_w / n as f64;
// Shrink the font so the widest category fits its band (to a floor).
let widest_units = categories
.iter()
.map(|c| crate::text_wrap::display_width_units(c))
.fold(0.0, f64::max);
let label_font = if widest_units > 0.0 {
let fit = (band_w * 0.95) / (widest_units * 0.525);
AXIS_LABEL_FONT_SIZE.min(fit).max(MIN_X_LABEL_FONT_SIZE)
} else {
AXIS_LABEL_FONT_SIZE
};
let tick_positions = (0..categories.len())
.map(|i| plot_x + (i as f64 + 0.5) * band_w)
.collect();
XAxisLayout {
tick_positions,
tick_labels: categories.clone(),
label_font,
point_count,
geom: XGeom::Category {
plot_left: plot_x,
band_w,
},
}
}
XAxis::Numeric { min, max } => {
let ticks = d3_ticks(*min, *max, DEFAULT_TICK_COUNT);
let labels: Vec<String> = ticks.iter().map(|v| format_tick(*v)).collect();
let label_max_width = labels
.iter()
.map(|s| approx_text_width(s, AXIS_LABEL_FONT_SIZE))
.fold(0.0, f64::max);
let outer = (label_max_width / 2.0).min(0.2 * plot_w);
let x0 = plot_x + outer;
let x1 = plot_x + plot_w - outer;
let tick_positions = ticks
.iter()
.map(|v| scale_linear(*v, *min, *max, x0, x1))
.collect();
XAxisLayout {
tick_positions,
tick_labels: labels,
label_font: AXIS_LABEL_FONT_SIZE,
point_count,
geom: XGeom::Numeric { x0, x1 },
}
}
}
}
fn parse_bracketed_number_list(s: &str, line: usize) -> Result<Vec<f64>, MermaidError> {
let Some(start) = s.find('[') else {
return Err(MermaidError::ParseError {
line,
message: format!("Invalid plot data: {s}"),
});
};
let Some(end) = s.rfind(']') else {
return Err(MermaidError::ParseError {
line,
message: format!("Invalid plot data: {s}"),
});
};
let inner = &s[start + 1..end];
let mut out = Vec::new();
for part in inner.split(',') {
let p = part.trim();
if p.is_empty() {
continue;
}
let v: f64 = p.parse().map_err(|_| MermaidError::ParseError {
line,
message: format!("Invalid plot value: {p}"),
})?;
out.push(v);
}
Ok(out)
}
fn points_to_path_d(points: &[(f64, f64)]) -> String {
if points.is_empty() {
return String::new();
}
let mut d = String::new();
if let Some((x, y)) = points.first().copied() {
d.push_str(&format!("M{x},{y}"));
}
for &(x, y) in points.iter().skip(1) {
d.push_str(&format!("L{x},{y}"));
}
d
}
fn scale_linear(
value: f64,
domain_min: f64,
domain_max: f64,
range_min: f64,
range_max: f64,
) -> f64 {
if (domain_max - domain_min).abs() < f64::EPSILON {
return range_min;
}
let t = (value - domain_min) / (domain_max - domain_min);
range_min + t * (range_max - range_min)
}
fn d3_ticks(start: f64, stop: f64, count: usize) -> Vec<f64> {
if count == 0 {
return Vec::new();
}
if !start.is_finite() || !stop.is_finite() {
return Vec::new();
}
if start == stop {
return vec![start];
}
let reverse = stop < start;
let (a, b) = if reverse {
(stop, start)
} else {
(start, stop)
};
let step = tick_step(a, b, count as f64);
if !step.is_finite() || step == 0.0 {
return Vec::new();
}
let start0 = (a / step).ceil();
let stop0 = (b / step).floor();
let n = (stop0 - start0 + 1.0).max(0.0) as i64;
let mut ticks = Vec::with_capacity(n as usize);
for i in 0..n {
ticks.push((start0 + i as f64) * step);
}
if reverse {
ticks.reverse();
}
ticks
}
fn tick_step(start: f64, stop: f64, count: f64) -> f64 {
let step0 = (stop - start).abs() / count.max(1.0);
let step1 = 10.0_f64.powf(step0.log10().floor());
let error = step0 / step1;
let e10 = 50.0_f64.sqrt();
let e5 = 10.0_f64.sqrt();
let e2 = 2.0_f64.sqrt();
let step = if error >= e10 {
step1 * 10.0
} else if error >= e5 {
step1 * 5.0
} else if error >= e2 {
step1 * 2.0
} else {
step1
};
if stop < start {
-step
} else {
step
}
}
fn format_tick(value: f64) -> String {
let rounded = value.round();
if (value - rounded).abs() < 1e-9 {
return format!("{:.0}", rounded);
}
let s = format!("{value:.6}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
fn approx_text_width(text: &str, font_size: f64) -> f64 {
let n = crate::text_wrap::display_width_units(text);
n * font_size * 0.525
}
fn approx_text_height(font_size: f64) -> f64 {
(font_size * 1.15).round()
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
#[cfg(test)]
mod tests {
use super::*;
// Categorical x-axis (no `-->`), labeled+ranged y-axis, two `line` series:
// the case the numeric-only parser rejected ("opening image ... fails").
const SAMPLE: &str = r#"xychart-beta
title "Weekly active users by region"
x-axis ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
y-axis "% of users" 0 --> 40
line [20.3, 22.6, 24.2, 24.3, 26.2, 27.2, 32.4, 31.9, 31.4, 31.1, 33.6, 34.3]
line [3.2, 6.3, 10.0, 9.4, 11.1, 10.7, 15.3, 13.4, 13.5, 12.5, 15.4, 15.8]"#;
#[test]
fn parses_categorical_axis_labels_and_two_series() {
let chart = parse_xychart(SAMPLE).expect("must parse");
assert_eq!(chart.title, "Weekly active users by region");
assert_eq!(chart.y_title, "% of users");
assert!(chart.x_title.is_empty());
assert_eq!((chart.y_min, chart.y_max), (0.0, 40.0));
match &chart.x_axis {
XAxis::Category(cats) => {
assert_eq!(cats.len(), 12);
assert_eq!(cats[0], "Jan");
assert_eq!(cats[11], "Dec");
}
other => panic!("expected categorical x-axis, got {other:?}"),
}
assert_eq!(chart.series.len(), 2);
assert_eq!(chart.series[0].len(), 12);
assert_eq!(chart.series[1].len(), 12);
assert_eq!(chart.series[1][0], 3.2);
}
#[test]
fn categorical_x_axis_with_two_lines_renders() {
let svg = render_xychart_diagram_to_svg(SAMPLE, &MermaidTheme::light())
.expect("categorical xychart with two line series must render");
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
assert!(svg.contains(">Jan<"));
assert!(svg.contains(">Dec<"));
assert!(svg.contains(">% of users<"));
assert_eq!(svg.matches("class=\"line-plot-").count(), 2);
assert!(svg.contains(SERIES_PALETTE[0]));
assert!(svg.contains(SERIES_PALETTE[1]));
assert!(svg.contains("Weekly active users by region"));
assert!(!svg.contains("&quot;")); // title/label quotes stripped
}
#[test]
fn numeric_x_axis_range_still_renders() {
let src = "xychart-beta\n title Demo\n x-axis 0 --> 10\n y-axis 0 --> 100\n line [5, 10, 20, 40]";
let svg = render_xychart_diagram_to_svg(src, &MermaidTheme::light())
.expect("numeric x-axis must still render");
assert!(svg.contains("<svg"));
assert!(svg.contains("Demo"));
assert_eq!(svg.matches("class=\"line-plot-").count(), 1);
}
#[test]
fn theme_text_color_drives_axis_and_labels() {
let svg = render_xychart_diagram_to_svg(SAMPLE, &MermaidTheme::dark())
.expect("dark theme must render");
assert!(svg.contains(&format!("fill=\"{}\"", MermaidTheme::dark().text_color)));
}
#[test]
fn y_axis_label_only_auto_ranges_from_data() {
let src = "xychart-beta\n x-axis [a, b, c]\n y-axis \"score\"\n line [10, 20, 30]";
let chart = parse_xychart(src).expect("must parse");
assert_eq!(chart.y_title, "score");
assert_eq!((chart.y_min, chart.y_max), (10.0, 30.0));
}
#[test]
fn missing_plot_is_rejected() {
let src = "xychart-beta\n x-axis [a, b]\n y-axis 0 --> 10";
assert!(render_xychart_diagram_to_svg(src, &MermaidTheme::light()).is_err());
}
#[test]
fn non_xychart_source_is_rejected() {
assert!(parse_xychart("flowchart TD\n A --> B").is_err());
}
#[test]
fn empty_line_series_is_rejected() {
// `line []` declares a series with no values: still no plottable data.
assert!(parse_xychart("xychart-beta\n x-axis [a, b]\n line []").is_err());
}
#[test]
fn categorical_points_stay_on_plot() {
let (plot_x, plot_w) = (60.0, 600.0);
// A series longer than the category list, and an empty category list:
// every point must still land within [plot_x, plot_x + plot_w].
for axis in [
XAxis::Category(vec!["a".to_string(), "b".to_string()]),
XAxis::Category(Vec::new()),
] {
let layout = layout_x_axis(&axis, plot_x, plot_w, 4);
for i in 0..4 {
let x = layout.series_point_x(i);
assert!(
(plot_x..=plot_x + plot_w).contains(&x),
"point {i} at {x} escaped the plot for {axis:?}"
);
}
}
}
#[test]
fn numeric_single_point_sits_at_left_edge() {
let (plot_x, plot_w) = (60.0, 600.0);
let layout = layout_x_axis(
&XAxis::Numeric {
min: 0.0,
max: 10.0,
},
plot_x,
plot_w,
1,
);
let XGeom::Numeric { x0, .. } = layout.geom else {
panic!("expected numeric geometry");
};
assert_eq!(layout.series_point_x(0), x0);
}
#[test]
fn numeric_series_share_x_domain_across_lengths() {
// Built with the longest series' length (4); `series_point_x` ignores any
// individual series length, so every series maps index -> x identically.
let (plot_x, plot_w) = (60.0, 600.0);
let layout = layout_x_axis(
&XAxis::Numeric {
min: 0.0,
max: 10.0,
},
plot_x,
plot_w,
4,
);
let XGeom::Numeric { x0, x1 } = layout.geom else {
panic!("expected numeric geometry");
};
// Spacing uses the shared count (4 -> denominator 3), not a per-series one.
assert_eq!(layout.series_point_x(0), x0);
assert_eq!(layout.series_point_x(3), x1);
assert!((layout.series_point_x(1) - (x0 + (x1 - x0) / 3.0)).abs() < 1e-9);
}
}