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:
+75
@@ -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
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+1201
File diff suppressed because it is too large
Load Diff
+1144
File diff suppressed because it is too large
Load Diff
+246
@@ -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
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+22
@@ -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
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+576
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+56
@@ -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
@@ -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 §ions {
|
||||
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(§ion.name),
|
||||
escape_xml(§ion.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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+506
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+3375
File diff suppressed because it is too large
Load Diff
+1069
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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
+331
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+659
@@ -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("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("&", "&")
|
||||
}
|
||||
|
||||
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
@@ -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),
|
||||
/// pie3–pie12 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+540
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+281
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+436
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
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}")
|
||||
}
|
||||
}
|
||||
+1326
File diff suppressed because it is too large
Load Diff
+172
@@ -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
@@ -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 (10−5)/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 × (10−5)/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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
}
|
||||
+346
@@ -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
@@ -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
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+867
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[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(""")); // 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user