M0: compilable skeleton — Kigi 0.1.0 fork surgery

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

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

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

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

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

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

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

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
+162
View File
@@ -0,0 +1,162 @@
# Vendored third-party crate.
#
# Upstream: warpdotdev/mermaid-to-svg (MIT) —
# https://github.com/warpdotdev/mermaid-to-svg
# rev 40cecf2be376e47e15053eadbfb782a531777420
#
# Why vendored (not a crates.io pin): the engine renders untrusted model output
# and is a young dependency. Vendoring gives a full audit surface and immunity to
# upstream yanks for the hardened CLI binary. Warp's engine delegates layout
# to a Rust port of dagre.js (the same algorithm mermaid.js uses), which fixes
# the cyclic-flowchart back-edge mis-routing of the previous engine. See
# crates/codegen/xai-grok-mermaid.
#
# ============================================================================
# VENDORING NOTES — single source of truth. Re-apply / re-bless ALL of the
# following on every upstream upgrade:
# ============================================================================
#
# Local modifications to the upstream source:
# 1. NOT VENDORED: `src/bin/render_mermaid.rs` (CLI binary). Its `[[bin]]` and
# the dev-only deps (insta/rust-embed/resvg/roxmltree/image/anyhow/serde*)
# are dropped — `xai-grok-mermaid` rasterizes the SVG itself with the
# workspace `resvg`, so this is used as an SVG generator only.
# 2. NOT VENDORED: every `*_tests.rs` snapshot/fixture test plus `reference_svg`
# and `fixtures` (they `include`/embed un-vendored `samples/` assets and need
# the dropped dev-deps). Their `#[cfg(test)] #[path = "..."] mod tests;`
# declarations and the `pub mod fixtures;` / `mod reference_svg;` lines were
# removed from the kept sources. The two *in-source* `mod tests` blocks
# (`src/lib.rs` public-API smoke tests, `src/mermaid_port/dagre_layout_port.rs`
# unit tests) use no dev-deps and are kept; `[lib] doctest = false`. One
# LOCAL ADDITION to the kept `src/lib.rs` tests: `test_simple_c4_diagram`
# (marked in-source), filling the only dispatched diagram type that upstream
# left without a `test_simple_*` smoke test.
# 3. HERMETIC PATCH (src/mermaid_port/mod.rs): `is_enabled()` returns `false`
# unconditionally. Upstream read the `MERMAID_TO_SVG_USE_PORT` env var to
# enable an experimental flowchart layout "port"; reading the environment is
# non-deterministic over untrusted input, and the port mis-routes cyclic
# back-edges (detached arrowheads). The default `layout::compute_layout`
# path routes cycles correctly, so the port stays off. Because the port is
# unreachable, `mermaid_port::compute_layout_ported` carries an
# `#[allow(dead_code)]`; the module is kept verbatim (not deleted) so future
# re-vendor diffs stay minimal.
# 4. Dropped the unused `petgraph` and `regex` dependencies (declared upstream,
# referenced nowhere in the vendored source; `cargo shear` confirms).
# 5. `thiserror` pinned to "2" (upstream "1.0") to match the workspace; the
# `#[derive(Error)]` / `#[error("…")]` API used in `src/error.rs` is
# unchanged across the major.
# 6. `cargo fmt` (rustfmt) applied so the crate satisfies the workspace fmt
# gate; no semantic change.
# 7. LOCAL REWRITE (src/sequence_diagram.rs; deliberately NOT marked in-source
# — this note is the sole record, so re-apply it on every upstream
# upgrade): the upstream sequence parser hard-failed ("Unrecognized
# sequenceDiagram line") on most of the standard Mermaid sequence grammar. Added: `activate`/`deactivate`
# (+ the `+`/`-` message shorthand) drawn as lifeline activation bars;
# `actor`; `create`/`destroy`; `box … end`; `autonumber` (message-number
# prefixes); `title`; `accTitle`/`accDescr` (incl. `{ … }` blocks) and
# `links`/`link`/`properties` accepted-and-skipped; `par`/`and`,
# `critical`/`option`, `break`, `rect` fragments; the full arrow set
# (`->`, `-->`, `-x`, `--x`, `-)`, `--)`, `<<->>`, `<<-->>`) with per-type
# markers. Keyword matching is now case-insensitive and word-bounded
# (Mermaid's lexer is case-insensitive; `optimize…` is no longer an `opt`
# fragment). Arrow markers are theme-colored (were unstyled black,
# invisible-on-dark) and note text is fixed-dark on the fixed light-yellow
# note fill. Covered by the (likewise unmarked) `test_sequence_*` tests in
# src/lib.rs.
# 8. LOCAL MODIFICATION (new `unicode-width` dep): text width estimates count
# East Asian wide characters as two narrow units via `text_wrap::
# display_width_units` (upstream counted chars/graphemes, halving CJK label
# widths so they overflowed their nodes). Touches `text_wrap.rs` and the
# local estimators in `sequence_diagram.rs` / `xychart_diagram.rs` /
# `mindmap_diagram.rs`; ASCII measurement is unchanged. Covered by the
# `test_cjk_*` tests in src/lib.rs.
# 9. LOCAL MODIFICATION (src/parser.rs): support Mermaid's open edge-label
# syntax (`A -- text --> B`, `-- text ---`, `== text ==>`, `== text ===`,
# `-. text .-`; upstream only parsed the `|text|` form plus `-. text .->`),
# and make `find_edge_start` bracket/quote-aware so edge tokens inside node
# labels (`A["x --> y"]`) are not treated as edges. Upstream parsed
# `B -- 是 --> C` as a literal node named "B -- 是". Covered by the
# `test_open_edge_label_*` / `test_edge_tokens_inside_node_labels_*` tests
# in src/lib.rs.
# 10. LOCAL MODIFICATION (src/text_wrap.rs): a single unbreakable token (a
# "word" with no internal whitespace) is kept whole on its own line and the
# box widens to fit it, instead of being hard-sliced mid-identifier at a
# grapheme boundary once it exceeds `wrapping_width` (upstream sliced
# `mark_filter_restore_context` into `mark_filter_restore_conte` + `xt`).
# This matches Mermaid's default `htmlLabels: true` rendering, which this
# port does not implement (src/config.rs), so it is MORE faithful to
# upstream Mermaid. Because `wrap_text_lines` is the shared wrapper, this
# applies to ALL of its callers — flowchart node labels, edge labels, and
# subgraph titles, plus state-diagram node/edge labels (state diagrams parse
# into the same `FlowchartGraph` and reuse this layout/renderer) — not just
# node boxes. The class/er/requirement diagrams have their own renderers that
# call only `line_width` (single-line, no wrapping), so they are unaffected.
# Only a pathologically long token — wider than
# SINGLE_TOKEN_WIDTH_CAP_FACTOR (5x) the wrap width — is force-broken,
# preferring the last identifier boundary (`_`/`-`/`.`/`/`) under the cap.
# Per-diagram width is now intentionally bounded only by the downstream
# rasterizer's PNG caps (xai-grok-mermaid), not by the layout — a deliberate
# legibility-vs-completeness tradeoff. Multi-word labels still wrap at spaces
# as before (`wrapping_width` unchanged). Covered by the unit tests in
# `src/text_wrap.rs`.
# 11. LOCAL MODIFICATION (src/layout.rs): flowchart edges whose endpoint is a
# subgraph id (cluster-target edges) have the dagre route points that fall
# strictly inside the destination/source cluster rect trimmed (via
# `trim_cluster_interior_points`) before `clip_edge_to_boundaries`, so the
# edge approaches the cluster boundary from outside instead of routing to the
# first-declared interior member (`subgraph_entry_node_id`) and curling back.
# A single transition point is kept per trimmed side; the polyline never
# drops below two points. Only forward (non-back) cluster-target edges are
# affected — node-target edges and back edges are unchanged. Covered by the
# `trim_*` tests in src/layout.rs.
# 12. LOCAL REWRITE (src/xychart_diagram.rs): upstream only parsed a numeric
# x-axis (`x-axis min --> max`) + a single `line`, hard-failing on the
# categorical form `x-axis ["a", …]` (so such charts fell back to a code
# block — "opening image for this diagram fails"). Added: categorical
# (band-scale) x-axis; optional quoted axis titles (y rotated, x centered);
# quote stripping for the title/labels; multiple `line` series in distinct
# palette colors; y auto-ranging when no explicit range is given; and
# theme-colored axes/text (was a fixed near-black, invisible on dark). `bar`
# stays unsupported (ignored). Covered by `xychart_diagram::tests` here and
# the `categorical_xychart_*` raster test in
# crates/codegen/xai-grok-mermaid/tests/pure_engine.rs.
#
# Re-audit checklist:
# - This crate's own source contains no `unsafe` and no filesystem / network I/O.
# Text metrics are font-free (fixed char-width in `src/text_wrap.rs`), so the
# engine performs NO system-font enumeration or on-disk font cache — glyph
# rasterization is the separate, bundled-font `xai-grok-mermaid::rasterize`.
# - SIBLING VENDORED LAYOUT DEPS (this header is the audit source-of-truth for
# the whole stack): `ordered_hashmap` retains two `unsafe` raw-pointer blocks
# in `iter_mut`/`values_mut` (documented + re-audited in its own Cargo.toml);
# `dagre_rust` and `graphlib_rust` contain no `unsafe` (dagre's upstream
# `static mut` counter was replaced with an `AtomicUsize` — see its Cargo.toml).
# - YAML frontmatter parsing (`src/config.rs`, `src/kanban_diagram.rs`) uses the
# workspace `serde_yaml`; it parses in-memory source only (no file/URL refs).
[package]
name = "mermaid-to-svg"
version = "0.1.0"
edition = "2021"
description = "Convert Mermaid diagram source to SVG via a dagre layout port (vendored, library-only)"
license = "MIT"
repository = "https://github.com/warpdotdev/mermaid-to-svg"
publish = false
[lib]
name = "mermaid_to_svg"
path = "src/lib.rs"
doctest = false
[dependencies]
thiserror = "2"
dagre_rust = { path = "../dagre_rust" }
graphlib_rust = { path = "../graphlib_rust" }
unicode-segmentation = "1"
unicode-width = "0.2"
serde_yaml = "0.9"
# Clippy lints local/path crates (this vendored crate is one) under the
# workspace's `-D warnings`. We do not restyle upstream third-party code, so
# allow clippy here; our own crate (xai-grok-mermaid) is still fully linted.
[lints.clippy]
all = { level = "allow", priority = -1 }
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2025-2026 Denver Technologies, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+106
View File
@@ -0,0 +1,106 @@
This project includes code derived from or inspired by the following
third-party projects.
================================================================================
mermaid.js
https://github.com/mermaid-js/mermaid
The MIT License (MIT)
Copyright (c) 2014 - 2022 Knut Sveidqvist
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
dagre.js
https://github.com/dagrejs/dagre
The MIT License (MIT)
Copyright (c) 2012-2014 Chris Pettitt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
graphlib.js
https://github.com/dagrejs/graphlib
Ancestry of the vendored graphlib_rust port (third_party/graphlib_rust/).
The MIT License (MIT)
Copyright (c) 2012-2014 Chris Pettitt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
dagre_rust
https://github.com/r3alst/dagre-rust
Vendored in third_party/dagre_rust/ with local modifications.
Copyright 2023 Ameer Hamza
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+75
View File
@@ -0,0 +1,75 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FlowchartGraph {
pub direction: GraphDirection,
pub statements: Vec<Statement>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GraphDirection {
TopToBottom,
BottomToTop,
LeftToRight,
RightToLeft,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Statement {
Node(Node),
Edge(Edge),
Subgraph(Subgraph),
Style(StyleStatement),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Node {
pub id: String,
pub label: Option<String>,
pub shape: NodeShape,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeShape {
Rectangle,
RoundedRectangle,
Stadium,
Diamond,
Hexagon,
Asymmetric,
Subroutine,
Cylinder,
Circle,
StartState,
EndState,
ForkJoin,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Edge {
pub from: String,
pub to: String,
pub label: Option<String>,
pub style: EdgeStyle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdgeStyle {
Arrow,
Line,
DottedArrow,
DottedLine,
ThickArrow,
ThickLine,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Subgraph {
pub id: String,
pub title: Option<String>,
pub statements: Vec<Statement>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StyleStatement {
pub node_id: String,
pub properties: Vec<(String, String)>,
}
+547
View File
@@ -0,0 +1,547 @@
use std::collections::HashMap;
use crate::error::MermaidError;
use crate::text_wrap::line_width;
use crate::theme::MermaidTheme;
/// Mermaid 11.12.2 block default padding (from `getConfig2()?.block?.padding ?? 8`).
/// This is the layout padding between sibling blocks AND the node shape padding
/// (added to bbox.width / bbox.height in rect2()).
const BLOCK_PADDING: f64 = 8.0;
/// Approximate per-character width for 16px Trebuchet MS rendered in Chromium.
/// Derived from reference SVG foreignObject measurements: "A" → 10.953, "B" → 10.984.
/// This replaces DEFAULT_CHAR_WIDTH (8.0) which is too narrow for block-beta nodes.
const BLOCK_CHAR_WIDTH: f64 = 10.97;
/// Approximate text height for a single line of 16px Trebuchet MS in Chromium foreignObject.
/// Reference SVG shows foreignObject height = 19 for single-line labels.
const BLOCK_TEXT_HEIGHT: f64 = 19.0;
/// ViewBox margin around content bounds (from `bounds2.x - 5, bounds2.y - 5, …+10, …+10`).
const VB_MARGIN: f64 = 5.0;
/// Arrow point marker offset (from mermaid's `markerOffsets.arrow_point = 4`).
/// Applied via `getLineFunctionsWithOffset` to shift the last edge point backward
/// so the arrowhead marker tip lands near the target node's edge.
const ARROW_POINT_OFFSET: f64 = 4.0;
pub fn render_block_diagram_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let diagram = parse_block_beta(mermaid_source)?;
let ordered_nodes = &diagram.node_order;
// --- Phase 1: Calculate node sizes (like mermaid's calculateBlockSize) ---
// Mermaid inserts the node into the DOM, calls getBBox(), then stores
// { width: bbox.width, height: bbox.height }. The rect2() shape adds
// `node.padding` (= block.padding = 8) to both dimensions.
// We approximate the label bbox using BLOCK_CHAR_WIDTH and BLOCK_TEXT_HEIGHT.
let mut node_sizes: HashMap<String, (f64, f64)> = HashMap::new();
for id in ordered_nodes {
let label = diagram.nodes.get(id).map(String::as_str).unwrap_or(id);
let text_w = line_width(label, BLOCK_CHAR_WIDTH);
let w = text_w + BLOCK_PADDING;
let h = BLOCK_TEXT_HEIGHT + BLOCK_PADDING;
node_sizes.insert(id.clone(), (w, h));
}
// --- Phase 2: setBlockSizes (normalize children to max width/height) ---
let max_w = node_sizes.values().map(|(w, _)| *w).fold(0.0_f64, f64::max);
let max_h = node_sizes.values().map(|(_, h)| *h).fold(0.0_f64, f64::max);
// All children get the same dimensions (mermaid normalizes to maxChildSize).
for size in node_sizes.values_mut() {
*size = (max_w, max_h);
}
// --- Phase 3: layoutBlocks (position each child) ---
// Mermaid logic: columns determine how many nodes per row.
// startingPosX = -padding (because root.size.x is 0, which is falsy in JS)
// child.x = startingPosX + padding + halfWidth; startingPosX = child.x + halfWidth
// child.y = parent.y - parent.height/2 + py*(height+padding) + height/2 + padding
//
// First compute the root block size so we can derive child y positions.
let columns = diagram.columns;
let num_items = ordered_nodes.len() as i32;
let x_size = if columns > 0 && columns < num_items {
columns
} else {
num_items
};
let y_size = if x_size > 0 {
(num_items as f64 / x_size as f64).ceil() as i32
} else {
1
};
let _root_w = x_size as f64 * (max_w + BLOCK_PADDING) + BLOCK_PADDING;
let root_h = y_size as f64 * (max_h + BLOCK_PADDING) + BLOCK_PADDING;
let mut node_layout: HashMap<String, (f64, f64)> = HashMap::new();
let half_w = max_w / 2.0;
let mut starting_pos_x = -BLOCK_PADDING;
let mut current_row: i32 = 0;
for (col_pos, id) in ordered_nodes.iter().enumerate() {
let (_, py) = calculate_block_position(columns, col_pos as i32);
if py != current_row {
current_row = py;
starting_pos_x = -BLOCK_PADDING;
}
let cx = starting_pos_x + BLOCK_PADDING + half_w;
// Mermaid: child.size.y = parent.y - parent.height/2 + py*(height+padding) + height/2 + padding
// parent.y = 0, parent.height = root_h
let cy = -root_h / 2.0 + py as f64 * (max_h + BLOCK_PADDING) + max_h / 2.0 + BLOCK_PADDING;
node_layout.insert(id.clone(), (cx, cy));
starting_pos_x = cx + half_w;
}
// --- Phase 4: findBounds ---
let (mut min_x, mut min_y, mut max_x, mut max_y) = (
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
);
for id in ordered_nodes {
let (cx, cy) = node_layout[id];
let (w, h) = node_sizes[id];
min_x = min_x.min(cx - w / 2.0);
min_y = min_y.min(cy - h / 2.0);
max_x = max_x.max(cx + w / 2.0);
max_y = max_y.max(cy + h / 2.0);
}
let bounds_w = max_x - min_x;
let bounds_h = max_y - min_y;
let vb_x = min_x - VB_MARGIN;
let vb_y = min_y - VB_MARGIN;
let vb_w = bounds_w + VB_MARGIN * 2.0;
let vb_h = bounds_h + VB_MARGIN * 2.0;
let background_color = if theme.background == "#ffffff" {
"white"
} else {
theme.background.as_str()
};
let text_color = if theme.text_color == "#333333" {
"#333"
} else {
theme.text_color.as_str()
};
let mut svg = String::new();
svg.push_str(&format!(
"<svg aria-roledescription=\"block\" role=\"graphics-document document\" viewBox=\"{vb_x} {vb_y} {vb_w} {vb_h}\" style=\"max-width: {vb_w}px; background-color: {background_color};\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" id=\"my-svg\">"
));
svg.push_str(&format!(
"<style>{}</style>",
block_css(
text_color,
&theme.edge_color,
&theme.node_fill,
&theme.node_stroke
)
));
svg.push_str("<g/>");
svg.push_str(
"<marker orient=\"auto\" markerHeight=\"12\" markerWidth=\"12\" markerUnits=\"userSpaceOnUse\" refY=\"5\" refX=\"6\" viewBox=\"0 0 10 10\" class=\"marker block\" id=\"my-svg_block-pointEnd\"><path style=\"stroke-width: 1; stroke-dasharray: 1, 0;\" class=\"arrowMarkerPath\" d=\"M 0 0 L 10 5 L 0 10 z\"/></marker>"
);
svg.push_str(
"<marker orient=\"auto\" markerHeight=\"12\" markerWidth=\"12\" markerUnits=\"userSpaceOnUse\" refY=\"5\" refX=\"4.5\" viewBox=\"0 0 10 10\" class=\"marker block\" id=\"my-svg_block-pointStart\"><path style=\"stroke-width: 1; stroke-dasharray: 1, 0;\" class=\"arrowMarkerPath\" d=\"M 0 5 L 10 10 L 10 0 z\"/></marker>"
);
svg.push_str(
"<marker orient=\"auto\" markerHeight=\"11\" markerWidth=\"11\" markerUnits=\"userSpaceOnUse\" refY=\"5\" refX=\"11\" viewBox=\"0 0 10 10\" class=\"marker block\" id=\"my-svg_block-circleEnd\"><circle style=\"stroke-width: 1; stroke-dasharray: 1, 0;\" class=\"arrowMarkerPath\" r=\"5\" cy=\"5\" cx=\"5\"/></marker>"
);
svg.push_str(
"<marker orient=\"auto\" markerHeight=\"11\" markerWidth=\"11\" markerUnits=\"userSpaceOnUse\" refY=\"5\" refX=\"-1\" viewBox=\"0 0 10 10\" class=\"marker block\" id=\"my-svg_block-circleStart\"><circle style=\"stroke-width: 1; stroke-dasharray: 1, 0;\" class=\"arrowMarkerPath\" r=\"5\" cy=\"5\" cx=\"5\"/></marker>"
);
svg.push_str(
"<marker orient=\"auto\" markerHeight=\"11\" markerWidth=\"11\" markerUnits=\"userSpaceOnUse\" refY=\"5.2\" refX=\"12\" viewBox=\"0 0 11 11\" class=\"marker cross block\" id=\"my-svg_block-crossEnd\"><path style=\"stroke-width: 2; stroke-dasharray: 1, 0;\" class=\"arrowMarkerPath\" d=\"M 1,1 l 9,9 M 10,1 l -9,9\"/></marker>"
);
svg.push_str(
"<marker orient=\"auto\" markerHeight=\"11\" markerWidth=\"11\" markerUnits=\"userSpaceOnUse\" refY=\"5.2\" refX=\"-1\" viewBox=\"0 0 11 11\" class=\"marker cross block\" id=\"my-svg_block-crossStart\"><path style=\"stroke-width: 2; stroke-dasharray: 1, 0;\" class=\"arrowMarkerPath\" d=\"M 1,1 l 9,9 M 10,1 l -9,9\"/></marker>"
);
svg.push_str("<g class=\"block\">");
// --- Render nodes ---
for id in ordered_nodes {
let Some((cx, cy)) = node_layout.get(id).copied() else {
continue;
};
let (w, h) = node_sizes.get(id).copied().unwrap_or((0.0, 0.0));
let label = diagram.nodes.get(id).map(String::as_str).unwrap_or(id);
svg.push_str(&format!(
"<g class=\"node default default flowchart-label\" id=\"{id}\" transform=\"translate({cx}, {cy})\">",
id = escape_xml(id)
));
svg.push_str(&format!(
"<rect class=\"basic label-container\" style=\"\" rx=\"0\" ry=\"0\" x=\"{x2}\" y=\"{y2}\" width=\"{w}\" height=\"{h}\"/>",
x2 = -w / 2.0,
y2 = -h / 2.0
));
svg.push_str(&format!(
"<g class=\"label\" style=\"\"><text text-anchor=\"middle\" dominant-baseline=\"central\" class=\"nodeLabel\" dy=\"0\">{}</text></g>",
escape_xml(label),
));
svg.push_str("</g>");
}
// --- Render edges ---
// Mermaid 11.12.2: 3 points [start_center, midpoint, end_center],
// clipped via node intersection, then curveBasis path.
for (idx, (from, to)) in diagram.edges.iter().enumerate() {
let Some((fx, fy)) = node_layout.get(from).copied() else {
continue;
};
let Some((tx, ty)) = node_layout.get(to).copied() else {
continue;
};
let (fw, fh) = node_sizes.get(from).copied().unwrap_or((0.0, 0.0));
let (tw, th) = node_sizes.get(to).copied().unwrap_or((0.0, 0.0));
let mid_x = fx + (tx - fx) / 2.0;
let mid_y = fy + (ty - fy) / 2.0;
let start = rect_intersect(fx, fy, fw, fh, mid_x, mid_y);
let end = rect_intersect(tx, ty, tw, th, mid_x, mid_y);
// Apply arrow_point marker offset to end point (mermaid's getLineFunctionsWithOffset).
// This shifts the curve endpoint backward by ARROW_POINT_OFFSET in the edge direction
// so the arrowhead marker tip lands at the correct position.
let edge_dx = end.0 - start.0;
let edge_dy = end.1 - start.1;
let edge_len = (edge_dx * edge_dx + edge_dy * edge_dy).sqrt();
let offset_end = if edge_len > 1e-9 {
(
end.0 - ARROW_POINT_OFFSET * edge_dx / edge_len,
end.1 - ARROW_POINT_OFFSET * edge_dy / edge_len,
)
} else {
end
};
let points = vec![start, (mid_x, mid_y), offset_end];
let d = curve_basis_path(&points);
let edge_no = idx + 1;
let ls = format!("{}1", from.to_lowercase());
let le = format!("{}1", to.to_lowercase());
svg.push_str(&format!(
"<path marker-end=\"url(#my-svg_block-pointEnd)\" class=\"edge-thickness-normal edge-pattern-solid flowchart-link LS-{ls} LE-{le}\" id=\"{edge_no}-{from}-{to}\" d=\"{d}\"/>",
from = escape_xml(from),
to = escape_xml(to)
));
}
svg.push_str("</g></svg>");
Ok(svg)
}
/// Compute the (px, py) grid position for a given column position.
/// Mirrors mermaid's `calculateBlockPosition(columns, position)`.
fn calculate_block_position(columns: i32, position: i32) -> (i32, i32) {
if columns < 0 {
return (position, 0);
}
if columns == 1 {
return (0, position);
}
let px = position % columns;
let py = position / columns;
(px, py)
}
/// Compute the intersection of a ray from inside (cx, cy) toward outside (ox, oy)
/// with the boundary of a rect centered at (cx, cy) with given width and height.
fn rect_intersect(cx: f64, cy: f64, w: f64, h: f64, ox: f64, oy: f64) -> (f64, f64) {
let hw = w / 2.0;
let hh = h / 2.0;
let dx = ox - cx;
let dy = oy - cy;
if dx.abs() < 1e-9 && dy.abs() < 1e-9 {
return (cx + hw, cy);
}
if dx.abs() > 1e-9 {
let t_x = if dx > 0.0 { hw / dx } else { -hw / dx };
let y_at_edge = cy + dy * t_x;
if (y_at_edge - cy).abs() <= hh + 1e-9 {
if dx > 0.0 {
return (cx + hw, y_at_edge);
} else {
return (cx - hw, y_at_edge);
}
}
}
if dy.abs() > 1e-9 {
let t_y = if dy > 0.0 { hh / dy } else { -hh / dy };
let x_at_edge = cx + dx * t_y;
if dy > 0.0 {
return (x_at_edge, cy + hh);
} else {
return (x_at_edge, cy - hh);
}
}
(cx + hw, cy)
}
/// Generate an SVG path string using D3's curveBasis (uniform cubic B-spline).
fn curve_basis_path(points: &[(f64, f64)]) -> String {
if points.is_empty() {
return String::new();
}
if points.len() == 1 {
return format!("M{},{}", fmt_num(points[0].0), fmt_num(points[0].1));
}
if points.len() == 2 {
let (x0, y0) = points[0];
let (x1, y1) = points[1];
return format!(
"M{},{}L{},{}",
fmt_num(x0),
fmt_num(y0),
fmt_num(x1),
fmt_num(y1)
);
}
let mut path = String::new();
let n = points.len();
let (x0, y0) = points[0];
path.push_str(&format!("M{},{}", fmt_num(x0), fmt_num(y0)));
let (x1, y1) = points[1];
let lx = (2.0 * x0 + x1) / 3.0;
let ly = (2.0 * y0 + y1) / 3.0;
path.push_str(&format!("L{},{}", fmt_num(lx), fmt_num(ly)));
for i in 1..n - 1 {
let (px, py) = points[i - 1];
let (cx, cy) = points[i];
let (nx, ny) = points[i + 1];
let cp1x = (2.0 * cx + px) / 3.0;
let cp1y = (2.0 * cy + py) / 3.0;
let cp2x = (2.0 * cx + nx) / 3.0;
let cp2y = (2.0 * cy + ny) / 3.0;
if i == n - 2 {
let end_x = (2.0 * cx + nx) / 3.0;
let end_y = (2.0 * cy + ny) / 3.0;
path.push_str(&format!(
"C{},{},{},{},{},{}",
fmt_num(cp1x),
fmt_num(cp1y),
fmt_num(cp2x),
fmt_num(cp2y),
fmt_num(end_x),
fmt_num(end_y)
));
} else {
let epx = (cx + nx) / 2.0;
let epy = (cy + ny) / 2.0;
path.push_str(&format!(
"C{},{},{},{},{},{}",
fmt_num(cp1x),
fmt_num(cp1y),
fmt_num(cp2x),
fmt_num(cp2y),
fmt_num(epx),
fmt_num(epy)
));
}
}
let (xn, yn) = points[n - 1];
path.push_str(&format!("L{},{}", fmt_num(xn), fmt_num(yn)));
path
}
fn fmt_num(v: f64) -> String {
let rounded = (v * 1000.0).round() / 1000.0;
if (rounded - rounded.round()).abs() < 1e-9 {
format!("{:.0}", rounded)
} else {
let s = format!("{rounded:.3}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
}
#[derive(Debug, Clone)]
struct BlockDiagram {
nodes: HashMap<String, String>,
/// Insertion-ordered list of node IDs (preserves declaration order).
node_order: Vec<String>,
edges: Vec<(String, String)>,
/// Number of columns (from `columns N`). -1 means auto (single row).
columns: i32,
}
fn parse_block_beta(input: &str) -> Result<BlockDiagram, MermaidError> {
let mut found_header = false;
let mut nodes: HashMap<String, String> = HashMap::new();
let mut node_order: Vec<String> = Vec::new();
let mut edges: Vec<(String, String)> = Vec::new();
let mut columns: i32 = -1;
let insert_node = |id: String,
label: String,
nodes: &mut HashMap<String, String>,
order: &mut Vec<String>| {
if let std::collections::hash_map::Entry::Vacant(e) = nodes.entry(id.clone()) {
order.push(id.clone());
e.insert(label);
} else if label != id {
// Only update the label if the new label is explicit (not just the bare ID).
nodes.insert(id, label);
}
};
for (idx, raw) in input.lines().enumerate() {
let line_no = idx + 1;
let line = raw.trim();
if line.is_empty() || line.starts_with("%%") {
continue;
}
if !found_header {
if line.split_whitespace().next() != Some("block-beta") {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected 'block-beta' declaration".to_string(),
});
}
found_header = true;
continue;
}
// columns directive
if let Some(rest) = line.strip_prefix("columns") {
let rest = rest.trim();
if rest == "auto" {
columns = -1;
} else if let Ok(n) = rest.parse::<i32>() {
columns = n;
}
continue;
}
// Skip block:/end group markers (we don't support nested groups yet
// but should not error on them)
if line == "end" || line.starts_with("block:") || line.starts_with("block ") {
continue;
}
// Skip style/classDef/class/linkStyle/space directives
if line.starts_with("style ")
|| line.starts_with("classDef ")
|| line.starts_with("class ")
|| line.starts_with("linkStyle ")
{
continue;
}
// Space directive: `space` or `space:N`
if line == "space" || line.starts_with("space:") {
// Space nodes are invisible placeholders; skip for now
continue;
}
// Edge line: contains `-->`
if let Some((lhs, rhs)) = line.split_once("-->") {
let (from_id, from_label) = parse_block_node(lhs.trim(), line_no)?;
let (to_id, to_label) = parse_block_node(rhs.trim(), line_no)?;
insert_node(from_id.clone(), from_label, &mut nodes, &mut node_order);
insert_node(to_id.clone(), to_label, &mut nodes, &mut node_order);
edges.push((from_id, to_id));
continue;
}
// Standalone node declaration
if let Ok((id, label)) = parse_block_node(line, line_no) {
insert_node(id, label, &mut nodes, &mut node_order);
continue;
}
}
if !found_header {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'block-beta' declaration".to_string(),
});
}
Ok(BlockDiagram {
nodes,
node_order,
edges,
columns,
})
}
fn parse_block_node(s: &str, line: usize) -> Result<(String, String), MermaidError> {
let s = s.trim();
if s.is_empty() {
return Err(MermaidError::ParseError {
line,
message: "Empty node".to_string(),
});
}
if let Some(bracket_start) = s.find('[') {
let id = s[..bracket_start].trim().to_string();
let inner = s[bracket_start + 1..].trim();
let inner = inner.strip_suffix(']').unwrap_or(inner).trim();
let label = strip_quotes(inner);
if id.is_empty() {
return Err(MermaidError::ParseError {
line,
message: format!("Missing node id in '{s}'"),
});
}
return Ok((id, label));
}
Ok((s.to_string(), s.to_string()))
}
fn strip_quotes(s: &str) -> String {
let s = s.trim();
if let Some(inner) = s.strip_prefix('"').and_then(|t| t.strip_suffix('"')) {
return inner.to_string();
}
if let Some(inner) = s.strip_prefix('\'').and_then(|t| t.strip_suffix('\'')) {
return inner.to_string();
}
s.to_string()
}
fn block_css(text_color: &str, edge_color: &str, node_fill: &str, node_stroke: &str) -> String {
format!(
"#my-svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:{text_color};}}@keyframes edge-animation-frame{{from{{stroke-dashoffset:0;}}}}@keyframes dash{{to{{stroke-dashoffset:0;}}}}#my-svg .edge-animation-slow{{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}}#my-svg .edge-animation-fast{{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}}#my-svg .error-icon{{fill:#552222;}}#my-svg .error-text{{fill:#552222;stroke:#552222;}}#my-svg .edge-thickness-normal{{stroke-width:1px;}}#my-svg .edge-thickness-thick{{stroke-width:3.5px;}}#my-svg .edge-pattern-solid{{stroke-dasharray:0;}}#my-svg .edge-thickness-invisible{{stroke-width:0;fill:none;}}#my-svg .edge-pattern-dashed{{stroke-dasharray:3;}}#my-svg .edge-pattern-dotted{{stroke-dasharray:2;}}#my-svg .marker{{fill:{edge_color};stroke:{edge_color};}}#my-svg .marker.cross{{stroke:{edge_color};}}#my-svg svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;}}#my-svg p{{margin:0;}}#my-svg .label{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;color:{text_color};}}#my-svg .cluster-label text{{fill:{text_color};}}#my-svg .cluster-label span,#my-svg p{{color:{text_color};}}#my-svg .label text,#my-svg span,#my-svg p{{fill:{text_color};color:{text_color};}}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{{fill:{node_fill};stroke:{node_stroke};stroke-width:1px;}}#my-svg .flowchart-label text{{text-anchor:middle;}}#my-svg .node .label{{text-align:center;}}#my-svg .node.clickable{{cursor:pointer;}}#my-svg .arrowheadPath{{fill:{edge_color};}}#my-svg .edgePath .path{{stroke:{edge_color};stroke-width:2.0px;}}#my-svg .flowchart-link{{stroke:{edge_color};fill:none;}}#my-svg .edgeLabel{{background-color:rgba(232,232,232, 0.8);text-align:center;}}#my-svg .edgeLabel rect{{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}}#my-svg .labelBkg{{background-color:rgba(232, 232, 232, 0.5);}}#my-svg .node .cluster{{fill:rgba(255, 255, 222, 0.5);stroke:rgba(170, 170, 51, 0.2);box-shadow:rgba(50, 50, 93, 0.25) 0px 13px 27px -5px,rgba(0, 0, 0, 0.3) 0px 8px 16px -8px;stroke-width:1px;}}#my-svg .cluster text{{fill:{text_color};}}#my-svg .cluster span,#my-svg p{{color:{text_color};}}#my-svg div.mermaidTooltip{{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}}#my-svg .flowchartTitleText{{text-anchor:middle;font-size:18px;fill:{text_color};}}#my-svg :root{{--mermaid-font-family:\"trebuchet ms\",verdana,arial,sans-serif;}}",
)
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+246
View File
@@ -0,0 +1,246 @@
use std::borrow::Cow;
use serde_yaml::Value;
use crate::theme::{MermaidTheme, MermaidThemePreset, MermaidThemeVariables};
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ParsedMermaidSource<'a> {
pub body: Cow<'a, str>,
pub frontmatter: Option<MermaidFrontmatter>,
pub config: RenderConfig,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MermaidFrontmatter {
pub title: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RenderConfig {
pub theme: Option<MermaidThemePreset>,
pub theme_variables: MermaidThemeVariables,
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
pub layout: Option<String>,
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
pub look: Option<String>,
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
pub security_level: Option<String>,
pub font_family: Option<String>,
pub font_size: Option<String>,
pub flowchart: FlowchartConfig,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FlowchartConfig {
pub curve: Option<String>,
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
pub html_labels: Option<bool>,
pub node_spacing: Option<u32>,
pub rank_spacing: Option<u32>,
pub padding: Option<u32>,
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
pub diagram_padding: Option<u32>,
pub wrapping_width: Option<u32>,
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
pub use_max_width: Option<bool>,
/// Parsed for Mermaid frontmatter compatibility, but not currently rendered.
pub default_renderer: Option<String>,
}
impl RenderConfig {
pub fn to_mermaid_theme(&self) -> Option<MermaidTheme> {
if self.theme.is_none() && self.theme_variables.is_empty() {
return None;
}
let mut theme = self.theme.unwrap_or(MermaidThemePreset::Default).to_theme();
self.theme_variables.apply_to(&mut theme);
Some(theme)
}
pub fn font_size_px(&self) -> Option<f64> {
self.font_size.as_deref().and_then(parse_font_size)
}
}
pub fn parse_mermaid_frontmatter(source: &str) -> ParsedMermaidSource<'_> {
let Some((yaml_start, yaml_end, body_start)) = frontmatter_bounds(source) else {
return ParsedMermaidSource {
body: Cow::Borrowed(source),
frontmatter: None,
config: RenderConfig::default(),
};
};
let body = Cow::Owned(source[body_start..].to_string());
let yaml = &source[yaml_start..yaml_end];
let Some(value) = parse_yaml_value(yaml) else {
return ParsedMermaidSource {
body,
frontmatter: Some(MermaidFrontmatter::default()),
config: RenderConfig::default(),
};
};
let frontmatter = parse_frontmatter_metadata(&value);
let config = parse_render_config(&value);
ParsedMermaidSource {
body,
frontmatter: Some(frontmatter),
config,
}
}
fn parse_yaml_value(yaml: &str) -> Option<Value> {
if yaml.trim().is_empty() {
return Some(Value::Null);
}
serde_yaml::from_str::<Value>(yaml).ok()
}
fn parse_frontmatter_metadata(value: &Value) -> MermaidFrontmatter {
MermaidFrontmatter {
title: mapping_value(value, "title").and_then(value_to_string),
}
}
fn parse_render_config(value: &Value) -> RenderConfig {
let Some(config) = mapping_value(value, "config") else {
return RenderConfig::default();
};
RenderConfig {
theme: mapping_value(config, "theme")
.and_then(value_to_string)
.and_then(|theme| MermaidThemePreset::parse(&theme)),
theme_variables: parse_theme_variables(mapping_value(config, "themeVariables")),
layout: mapping_value(config, "layout").and_then(value_to_string),
look: mapping_value(config, "look").and_then(value_to_string),
security_level: mapping_value(config, "securityLevel").and_then(value_to_string),
font_family: mapping_value(config, "fontFamily").and_then(value_to_string),
font_size: mapping_value(config, "fontSize").and_then(value_to_string),
flowchart: parse_flowchart_config(mapping_value(config, "flowchart")),
}
}
fn parse_theme_variables(value: Option<&Value>) -> MermaidThemeVariables {
let mut variables = MermaidThemeVariables::default();
let Some(Value::Mapping(mapping)) = value else {
return variables;
};
for (key, value) in mapping {
let Some(key) = key.as_str() else {
continue;
};
let Some(value) = value_to_string(value) else {
continue;
};
variables.apply_mermaid_alias(key, value);
}
variables
}
fn parse_flowchart_config(value: Option<&Value>) -> FlowchartConfig {
let Some(flowchart) = value else {
return FlowchartConfig::default();
};
FlowchartConfig {
curve: mapping_value(flowchart, "curve").and_then(value_to_string),
html_labels: mapping_value(flowchart, "htmlLabels").and_then(value_to_bool),
node_spacing: mapping_value(flowchart, "nodeSpacing").and_then(value_to_u32),
rank_spacing: mapping_value(flowchart, "rankSpacing").and_then(value_to_u32),
padding: mapping_value(flowchart, "padding").and_then(value_to_u32),
diagram_padding: mapping_value(flowchart, "diagramPadding").and_then(value_to_u32),
wrapping_width: mapping_value(flowchart, "wrappingWidth").and_then(value_to_u32),
use_max_width: mapping_value(flowchart, "useMaxWidth").and_then(value_to_bool),
default_renderer: mapping_value(flowchart, "defaultRenderer").and_then(value_to_string),
}
}
fn mapping_value<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
let Value::Mapping(mapping) = value else {
return None;
};
mapping.get(&Value::String(key.to_string()))
}
fn value_to_string(value: &Value) -> Option<String> {
match value {
Value::String(value) => Some(value.clone()),
Value::Number(value) => Some(value.to_string()),
Value::Bool(value) => Some(value.to_string()),
_ => None,
}
}
fn value_to_bool(value: &Value) -> Option<bool> {
match value {
Value::Bool(value) => Some(*value),
Value::String(value) => match value.as_str() {
"true" => Some(true),
"false" => Some(false),
_ => None,
},
_ => None,
}
}
fn value_to_u32(value: &Value) -> Option<u32> {
match value {
Value::Number(value) => value.as_u64().and_then(|value| u32::try_from(value).ok()),
Value::String(value) => value.parse().ok(),
_ => None,
}
}
fn parse_font_size(value: &str) -> Option<f64> {
let trimmed = value.trim();
let numeric = trimmed.strip_suffix("px").unwrap_or(trimmed).trim();
numeric
.parse::<f64>()
.ok()
.filter(|size| size.is_finite() && *size > 0.0)
}
fn frontmatter_bounds(source: &str) -> Option<(usize, usize, usize)> {
let mut cursor = 0;
while cursor < source.len() {
let end = next_line_end(source, cursor);
let line = source[cursor..end].trim();
if line.is_empty() {
cursor = end;
continue;
}
if line != "---" {
return None;
}
let yaml_start = end;
let mut scan = end;
while scan < source.len() {
let scan_end = next_line_end(source, scan);
if source[scan..scan_end].trim() == "---" {
return Some((yaml_start, scan, scan_end));
}
scan = scan_end;
}
return None;
}
None
}
fn next_line_end(source: &str, start: usize) -> usize {
source[start..]
.find('\n')
.map(|position| start + position + 1)
.unwrap_or(source.len())
}
+936
View File
@@ -0,0 +1,936 @@
use std::collections::{BTreeMap, BTreeSet};
use crate::error::MermaidError;
use crate::text_wrap::{line_width, DEFAULT_CHAR_WIDTH};
use crate::theme::MermaidTheme;
use dagre_rust::layout::layout as dagre_layout;
use dagre_rust::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::Graph;
// --- Constants matching mermaid.js ER renderer defaults ---
const PADDING: f64 = 10.0;
const TEXT_PADDING: f64 = 6.0;
const NODE_SEP: f64 = 140.0;
const RANK_SEP: f64 = 80.0;
const GRAPH_MARGIN: f64 = 8.0;
const LINE_HEIGHT: f64 = 36.75;
const MIN_ENTITY_WIDTH: f64 = 100.0;
const FONT_SIZE: f64 = 16.0;
const COLUMN_TEXT_PADDING: f64 = 8.0;
// --- ER-specific AST ---
#[derive(Debug, Clone, PartialEq)]
enum Cardinality {
ZeroOrOne,
ZeroOrMore,
OneOrMore,
OnlyOne,
}
impl Cardinality {
fn marker_name(&self) -> &'static str {
match self {
Cardinality::ZeroOrOne => "zeroOrOne",
Cardinality::ZeroOrMore => "zeroOrMore",
Cardinality::OneOrMore => "oneOrMore",
Cardinality::OnlyOne => "onlyOne",
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum Identification {
Identifying,
NonIdentifying,
}
#[derive(Debug, Clone)]
struct RelSpec {
card_a: Cardinality,
card_b: Cardinality,
rel_type: Identification,
}
#[derive(Debug, Clone)]
struct Attribute {
attr_type: String,
name: String,
}
#[derive(Debug, Clone)]
struct Entity {
id: String,
attributes: Vec<Attribute>,
}
#[derive(Debug, Clone)]
struct Relationship {
entity_a: String,
entity_b: String,
role: String,
rel_spec: RelSpec,
}
#[derive(Debug, Clone)]
struct ErDiagram {
entities: BTreeMap<String, Entity>,
relationships: Vec<Relationship>,
}
// --- Layout types ---
#[derive(Debug, Clone)]
struct EntityLayout {
id: String,
x: f64,
y: f64,
width: f64,
height: f64,
header_height: f64,
max_type_width: f64,
attributes: Vec<Attribute>,
row_heights: Vec<f64>,
}
#[derive(Debug, Clone)]
struct EdgeLayout {
#[allow(dead_code)]
from: String,
#[allow(dead_code)]
to: String,
role: String,
rel_spec: RelSpec,
points: Vec<(f64, f64)>,
label_pos: Option<(f64, f64)>,
label_width: f64,
label_height: f64,
}
#[derive(Debug, Clone)]
struct DiagramLayout {
entities: BTreeMap<String, EntityLayout>,
edges: Vec<EdgeLayout>,
width: f64,
height: f64,
}
// --- Public entry point ---
pub fn render_er_diagram_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let diagram = parse_er_diagram(mermaid_source)?;
let layout = compute_layout(&diagram);
Ok(render_svg(&layout, theme))
}
// --- Parser ---
fn parse_er_diagram(input: &str) -> Result<ErDiagram, MermaidError> {
let lines: Vec<&str> = input.lines().collect();
let mut i = 0_usize;
// Find header
while i < lines.len() {
let line = lines[i].trim();
if line.is_empty() || line.starts_with("%%") {
i += 1;
continue;
}
if line.split_whitespace().next() == Some("erDiagram") {
i += 1;
break;
}
return Err(MermaidError::ParseError {
line: i + 1,
message: "Expected 'erDiagram' declaration".to_string(),
});
}
let mut entities: BTreeMap<String, Entity> = BTreeMap::new();
let mut referenced_entities: BTreeSet<String> = BTreeSet::new();
let mut relationships: Vec<Relationship> = Vec::new();
while i < lines.len() {
let raw = lines[i];
let line = raw.trim();
let line_no = i + 1;
i += 1;
if line.is_empty() || line.starts_with("%%") {
continue;
}
// Entity block: `ENTITY_NAME {`
if let Some(name_raw) = line.strip_suffix('{') {
let name = name_raw.trim();
if name.is_empty() {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected entity name before '{'".to_string(),
});
}
let mut attrs: Vec<Attribute> = Vec::new();
while i < lines.len() {
let attr_raw = lines[i];
let attr_line = attr_raw.trim();
i += 1;
if attr_line.is_empty() || attr_line.starts_with("%%") {
continue;
}
if attr_line == "}" {
break;
}
// Parse "type name" pairs
let parts: Vec<&str> = attr_line.splitn(2, char::is_whitespace).collect();
if parts.len() >= 2 {
attrs.push(Attribute {
attr_type: parts[0].to_string(),
name: parts[1].trim().to_string(),
});
} else if !parts.is_empty() {
attrs.push(Attribute {
attr_type: parts[0].to_string(),
name: String::new(),
});
}
}
entities.insert(
name.to_string(),
Entity {
id: name.to_string(),
attributes: attrs,
},
);
continue;
}
if line == "}" {
continue;
}
// Relationship: `ENTITY_A ||--o{ ENTITY_B : label`
if line.contains("--") || line.contains("..") {
if let Some(rel) = parse_relationship(line, line_no)? {
referenced_entities.insert(rel.entity_a.clone());
referenced_entities.insert(rel.entity_b.clone());
relationships.push(rel);
continue;
}
}
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Unrecognized erDiagram line: {line}"),
});
}
// Ensure all referenced entities exist
for id in referenced_entities {
entities.entry(id.clone()).or_insert_with(|| Entity {
id,
attributes: Vec::new(),
});
}
Ok(ErDiagram {
entities,
relationships,
})
}
fn parse_relationship(line: &str, line_no: usize) -> Result<Option<Relationship>, MermaidError> {
// Split on `:` to get role label
let (lhs, role) = match line.split_once(':') {
Some((a, b)) => {
let label = b.trim();
(
a.trim(),
if label.is_empty() {
String::new()
} else {
label.to_string()
},
)
}
None => (line.trim(), String::new()),
};
let parts: Vec<&str> = lhs.split_whitespace().collect();
if parts.len() < 3 {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid erDiagram relationship: {line}"),
});
}
let entity_a = parts[0].to_string();
let rel_str = parts[1];
let entity_b = parts[2].to_string();
let rel_spec = parse_rel_spec(rel_str, line_no)?;
Ok(Some(Relationship {
entity_a,
entity_b,
role,
rel_spec,
}))
}
fn parse_rel_spec(s: &str, line_no: usize) -> Result<RelSpec, MermaidError> {
// Format: cardA--cardB or cardA..cardB
// Cards: || (only one), |o or o| (zero or one), }| or |{ (one or more),
// }o or o{ (zero or more)
// `--` = IDENTIFYING, `..` = NON_IDENTIFYING
let (left_part, rel_type, right_part) = if let Some(idx) = s.find("--") {
(&s[..idx], Identification::Identifying, &s[idx + 2..])
} else if let Some(idx) = s.find("..") {
(&s[..idx], Identification::NonIdentifying, &s[idx + 2..])
} else {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid relationship spec: {s}"),
});
};
let card_a = parse_cardinality(left_part, line_no)?;
let card_b = parse_cardinality(right_part, line_no)?;
Ok(RelSpec {
card_a,
card_b,
rel_type,
})
}
fn parse_cardinality(s: &str, line_no: usize) -> Result<Cardinality, MermaidError> {
match s {
"||" => Ok(Cardinality::OnlyOne),
"|o" | "o|" => Ok(Cardinality::ZeroOrOne),
"|{" | "}|" => Ok(Cardinality::OneOrMore),
"o{" | "}o" => Ok(Cardinality::ZeroOrMore),
_ => Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid cardinality: {s}"),
}),
}
}
// --- Layout computation using dagre ---
fn compute_entity_metrics(entity: &Entity) -> (f64, f64, f64, f64, Vec<f64>) {
// Returns: (total_width, total_height, header_height, max_type_width, row_heights)
let header_text = &entity.id;
let header_width = line_width(header_text, DEFAULT_CHAR_WIDTH);
let header_height = LINE_HEIGHT + TEXT_PADDING;
if entity.attributes.is_empty() {
let width = (header_width + PADDING * 2.0).max(MIN_ENTITY_WIDTH);
let height = header_height + PADDING;
return (width, height, header_height, 0.0, Vec::new());
}
let mut max_type_width: f64 = 0.0;
let mut max_name_width: f64 = 0.0;
let mut row_heights = Vec::new();
for attr in &entity.attributes {
let type_w = line_width(&attr.attr_type, DEFAULT_CHAR_WIDTH);
let name_w = line_width(&attr.name, DEFAULT_CHAR_WIDTH);
max_type_width = max_type_width.max(type_w + COLUMN_TEXT_PADDING * 2.0);
max_name_width = max_name_width.max(name_w + COLUMN_TEXT_PADDING * 2.0);
row_heights.push(LINE_HEIGHT + TEXT_PADDING);
}
let attr_total_width = max_type_width + max_name_width;
let content_width = attr_total_width.max(header_width + PADDING * 2.0);
let total_width = content_width.max(MIN_ENTITY_WIDTH);
let total_attr_height: f64 = row_heights.iter().sum();
let total_height = header_height + total_attr_height;
(
total_width,
total_height,
header_height,
max_type_width,
row_heights,
)
}
fn compute_layout(diagram: &ErDiagram) -> DiagramLayout {
let mut entity_metrics: BTreeMap<String, (f64, f64, f64, f64, Vec<f64>)> = BTreeMap::new();
for (id, entity) in &diagram.entities {
entity_metrics.insert(id.clone(), compute_entity_metrics(entity));
}
type DagreGraph = Graph<GraphConfig, GraphNode, GraphEdge>;
let mut g: DagreGraph = Graph::new(Some(graphlib_rust::GraphOption {
directed: Some(true),
multigraph: Some(true),
compound: Some(false),
}));
g.set_graph(GraphConfig {
rankdir: Some("tb".to_string()),
nodesep: Some(NODE_SEP as f32),
ranksep: Some(RANK_SEP as f32),
edgesep: Some(20.0),
marginx: Some(GRAPH_MARGIN as f32),
marginy: Some(GRAPH_MARGIN as f32),
..Default::default()
});
for (id, (w, h, _, _, _)) in &entity_metrics {
g.set_node(
id.clone(),
Some(GraphNode {
width: *w as f32,
height: *h as f32,
..Default::default()
}),
);
}
let mut edge_keys: Vec<(String, String)> = Vec::new();
for rel in &diagram.relationships {
let label_text = &rel.role;
let label_width = if label_text.is_empty() {
0.0
} else {
line_width(label_text, DEFAULT_CHAR_WIDTH)
};
let label_height = if label_text.is_empty() {
0.0
} else {
LINE_HEIGHT
};
let edge_label = GraphEdge {
labelpos: Some("c".to_string()),
width: Some(label_width as f32),
height: Some(label_height as f32),
..Default::default()
};
let _ = g.set_edge(&rel.entity_a, &rel.entity_b, Some(edge_label), None);
edge_keys.push((rel.entity_a.clone(), rel.entity_b.clone()));
}
dagre_layout(&mut g);
let mut positions: BTreeMap<String, (f64, f64)> = BTreeMap::new();
for node_id in g.nodes() {
if let Some(node) = g.node(&node_id) {
positions.insert(node_id, (node.x as f64, node.y as f64));
}
}
let mut edges: Vec<EdgeLayout> = Vec::new();
for (idx, (from, to)) in edge_keys.iter().enumerate() {
let Some(edge) = g.edge(from, to, None) else {
continue;
};
let points: Vec<(f64, f64)> = edge
.points
.as_ref()
.map(|pts| pts.iter().map(|p| (p.x as f64, p.y as f64)).collect())
.unwrap_or_default();
let label_pos = if edge.width.unwrap_or(0.0) > 0.0 || edge.height.unwrap_or(0.0) > 0.0 {
Some((edge.x as f64, edge.y as f64))
} else {
None
};
let rel = &diagram.relationships[idx];
let label_width = edge.width.unwrap_or(0.0) as f64;
let label_height = edge.height.unwrap_or(0.0) as f64;
edges.push(EdgeLayout {
from: from.clone(),
to: to.clone(),
role: rel.role.clone(),
rel_spec: rel.rel_spec.clone(),
points,
label_pos,
label_width,
label_height,
});
}
let mut entity_layouts: BTreeMap<String, EntityLayout> = BTreeMap::new();
for (id, (x, y)) in &positions {
let Some((w, h, header_h, max_type_w, row_heights)) = entity_metrics.get(id).cloned()
else {
continue;
};
let entity = diagram.entities.get(id).cloned().unwrap_or(Entity {
id: id.clone(),
attributes: Vec::new(),
});
entity_layouts.insert(
id.clone(),
EntityLayout {
id: id.clone(),
x: *x,
y: *y,
width: w,
height: h,
header_height: header_h,
max_type_width: max_type_w,
attributes: entity.attributes,
row_heights,
},
);
}
// Compute bounds and normalize
let (min_x, min_y, max_x, max_y) = compute_bounds(&entity_layouts, &edges);
let dx = GRAPH_MARGIN - min_x;
let dy = GRAPH_MARGIN - min_y;
for entity in entity_layouts.values_mut() {
entity.x += dx;
entity.y += dy;
}
for edge in &mut edges {
for p in &mut edge.points {
p.0 += dx;
p.1 += dy;
}
if let Some((x, y)) = edge.label_pos {
edge.label_pos = Some((x + dx, y + dy));
}
}
let width = (max_x - min_x) + GRAPH_MARGIN * 2.0;
let height = (max_y - min_y) + GRAPH_MARGIN * 2.0;
DiagramLayout {
entities: entity_layouts,
edges,
width,
height,
}
}
fn compute_bounds(
entities: &BTreeMap<String, EntityLayout>,
edges: &[EdgeLayout],
) -> (f64, f64, f64, f64) {
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
for entity in entities.values() {
let left = entity.x - entity.width / 2.0;
let right = entity.x + entity.width / 2.0;
let top = entity.y - entity.height / 2.0;
let bottom = entity.y + entity.height / 2.0;
min_x = min_x.min(left);
min_y = min_y.min(top);
max_x = max_x.max(right);
max_y = max_y.max(bottom);
}
for edge in edges {
for (x, y) in &edge.points {
min_x = min_x.min(*x);
min_y = min_y.min(*y);
max_x = max_x.max(*x);
max_y = max_y.max(*y);
}
if let Some((x, y)) = edge.label_pos {
let left = x - edge.label_width / 2.0;
let right = x + edge.label_width / 2.0;
let top = y - edge.label_height / 2.0;
let bottom = y + edge.label_height / 2.0;
min_x = min_x.min(left);
min_y = min_y.min(top);
max_x = max_x.max(right);
max_y = max_y.max(bottom);
}
}
if !min_x.is_finite() {
min_x = 0.0;
max_x = 0.0;
}
if !min_y.is_finite() {
min_y = 0.0;
max_y = 0.0;
}
(min_x, min_y, max_x, max_y)
}
// --- SVG Rendering ---
fn render_svg(layout: &DiagramLayout, theme: &MermaidTheme) -> String {
let mut svg = String::new();
svg.push_str(&format!(
"<svg aria-roledescription=\"er\" role=\"graphics-document document\" \
viewBox=\"0 0 {w} {h}\" style=\"max-width: {w}px; background-color: {bg};\" \
class=\"erDiagram\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" id=\"my-svg\">",
bg = theme.background,
w = layout.width,
h = layout.height
));
// CSS styles matching mermaid.js ER theme
svg.push_str(&format!(
"<style>\
#my-svg {{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:{font_size}px;fill:{text};}}\
#my-svg .entityBox {{fill:{node_fill};stroke:{node_stroke};}}\
#my-svg .relationshipLine {{stroke:{edge};stroke-width:1;fill:none;}}\
#my-svg .marker {{fill:none !important;stroke:{edge} !important;stroke-width:1;}}\
#my-svg .edgeLabel .label {{fill:{node_stroke};font-size:14px;}}
#my-svg .label {{font-family:\"trebuchet ms\",verdana,arial,sans-serif;color:{text};}}\
#my-svg .label text, #my-svg span {{fill:{text};color:{text};}}\
#my-svg .node rect, #my-svg .node circle, #my-svg .node ellipse, #my-svg .node polygon {{fill:{node_fill};stroke:{node_stroke};stroke-width:1px;}}\
#my-svg .divider {{stroke:{node_stroke};stroke-width:1;}}\
</style>",
font_size = FONT_SIZE,
text = theme.text_color,
node_fill = theme.node_fill,
node_stroke = theme.node_stroke,
edge = theme.edge_color,
));
// ER-specific SVG marker definitions
svg.push_str("<defs>");
render_er_markers(&mut svg, theme);
svg.push_str("</defs>");
svg.push_str("<g>");
// Edges (paths)
svg.push_str("<g class=\"edgePaths\">");
for edge in &layout.edges {
render_edge_path(&mut svg, edge);
}
svg.push_str("</g>");
// Edge labels
svg.push_str("<g class=\"edgeLabels\">");
for edge in &layout.edges {
render_edge_label(&mut svg, edge, theme);
}
svg.push_str("</g>");
// Entity nodes
svg.push_str("<g class=\"nodes\">");
for entity in layout.entities.values() {
render_entity_node(&mut svg, entity, theme);
}
svg.push_str("</g>");
svg.push_str("</g></svg>");
svg
}
fn render_er_markers(svg: &mut String, theme: &MermaidTheme) {
let edge_color = &theme.edge_color;
// onlyOne markers: two perpendicular bars
svg.push_str(&format!(
"<marker id=\"my-svg_er-onlyOneStart\" class=\"marker onlyOne\" \
refX=\"0\" refY=\"9\" markerWidth=\"18\" markerHeight=\"18\" orient=\"auto\">\
<path d=\"M9,0 L9,18 M15,0 L15,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
</marker>"
));
svg.push_str(&format!(
"<marker id=\"my-svg_er-onlyOneEnd\" class=\"marker onlyOne\" \
refX=\"18\" refY=\"9\" markerWidth=\"18\" markerHeight=\"18\" orient=\"auto\">\
<path d=\"M3,0 L3,18 M9,0 L9,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
</marker>"
));
// zeroOrOne markers: circle + perpendicular bar
svg.push_str(&format!(
"<marker id=\"my-svg_er-zeroOrOneStart\" class=\"marker zeroOrOne\" \
refX=\"0\" refY=\"9\" markerWidth=\"30\" markerHeight=\"18\" orient=\"auto\">\
<circle fill=\"white\" cx=\"21\" cy=\"9\" r=\"6\" stroke=\"{edge_color}\"/>\
<path d=\"M9,0 L9,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
</marker>"
));
svg.push_str(&format!(
"<marker id=\"my-svg_er-zeroOrOneEnd\" class=\"marker zeroOrOne\" \
refX=\"30\" refY=\"9\" markerWidth=\"30\" markerHeight=\"18\" orient=\"auto\">\
<circle fill=\"white\" cx=\"9\" cy=\"9\" r=\"6\" stroke=\"{edge_color}\"/>\
<path d=\"M21,0 L21,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
</marker>"
));
// oneOrMore markers: crow's foot + perpendicular bar
svg.push_str(&format!(
"<marker id=\"my-svg_er-oneOrMoreStart\" class=\"marker oneOrMore\" \
refX=\"18\" refY=\"18\" markerWidth=\"45\" markerHeight=\"36\" orient=\"auto\">\
<path d=\"M0,18 Q 18,0 36,18 Q 18,36 0,18 M42,9 L42,27\" stroke=\"{edge_color}\" fill=\"none\"/>\
</marker>"
));
svg.push_str(&format!(
"<marker id=\"my-svg_er-oneOrMoreEnd\" class=\"marker oneOrMore\" \
refX=\"27\" refY=\"18\" markerWidth=\"45\" markerHeight=\"36\" orient=\"auto\">\
<path d=\"M3,9 L3,27 M9,18 Q27,0 45,18 Q27,36 9,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
</marker>"
));
// zeroOrMore markers: circle + crow's foot
svg.push_str(&format!(
"<marker id=\"my-svg_er-zeroOrMoreStart\" class=\"marker zeroOrMore\" \
refX=\"18\" refY=\"18\" markerWidth=\"57\" markerHeight=\"36\" orient=\"auto\">\
<circle fill=\"white\" cx=\"48\" cy=\"18\" r=\"6\" stroke=\"{edge_color}\"/>\
<path d=\"M0,18 Q18,0 36,18 Q18,36 0,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
</marker>"
));
svg.push_str(&format!(
"<marker id=\"my-svg_er-zeroOrMoreEnd\" class=\"marker zeroOrMore\" \
refX=\"39\" refY=\"18\" markerWidth=\"57\" markerHeight=\"36\" orient=\"auto\">\
<circle fill=\"white\" cx=\"9\" cy=\"18\" r=\"6\" stroke=\"{edge_color}\"/>\
<path d=\"M21,18 Q39,0 57,18 Q39,36 21,18\" stroke=\"{edge_color}\" fill=\"none\"/>\
</marker>"
));
}
fn render_edge_path(svg: &mut String, edge: &EdgeLayout) {
let d = points_to_path_d(&edge.points);
let dash = if edge.rel_spec.rel_type == Identification::NonIdentifying {
" stroke-dasharray=\"8,8\""
} else {
""
};
// In mermaid.js ER: arrowTypeStart = cardA, arrowTypeEnd = cardB
// card_a is the cardinality at entity_a's side, card_b is at entity_b's side.
// marker-start decorates the start of the path (entity_a), marker-end the end (entity_b).
let marker_start_name = edge.rel_spec.card_a.marker_name();
let marker_end_name = edge.rel_spec.card_b.marker_name();
svg.push_str(&format!(
"<path class=\"edge-thickness-normal relationshipLine\" \
d=\"{d}\" \
marker-start=\"url(#my-svg_er-{marker_start_name}Start)\" \
marker-end=\"url(#my-svg_er-{marker_end_name}End)\" \
style=\"fill:none;\"{dash}/>",
));
}
fn render_edge_label(svg: &mut String, edge: &EdgeLayout, theme: &MermaidTheme) {
if edge.role.is_empty() {
return;
}
let Some((x, y)) = edge.label_pos else {
return;
};
svg.push_str(&format!(
"<g transform=\"translate({x}, {y})\" class=\"edgeLabel\">"
));
svg.push_str(&format!(
"<text x=\"0\" y=\"0\" text-anchor=\"middle\" dominant-baseline=\"middle\" \
fill=\"{color}\" style=\"font-size:14px\">{label}</text>",
color = theme.text_color,
label = escape_xml(&edge.role)
));
svg.push_str("</g>");
}
fn render_entity_node(svg: &mut String, entity: &EntityLayout, theme: &MermaidTheme) {
let x_offset = -entity.width / 2.0;
let y_offset = -entity.height / 2.0;
svg.push_str(&format!(
"<g transform=\"translate({x},{y})\" id=\"entity-{id}\" class=\"node default\">",
x = entity.x,
y = entity.y,
id = escape_xml(&entity.id)
));
// Outer rectangle (entity box)
svg.push_str(&format!(
"<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" class=\"entityBox\" rx=\"0\" ry=\"0\"/>",
x = x_offset,
y = y_offset,
w = entity.width,
h = entity.height
));
// Header text (entity name)
let header_text_y = y_offset + entity.header_height / 2.0;
svg.push_str(&format!(
"<text x=\"0\" y=\"{y}\" text-anchor=\"middle\" dominant-baseline=\"middle\" \
fill=\"{color}\" class=\"er entityLabel\">{label}</text>",
y = header_text_y,
color = theme.text_color,
label = escape_xml(&entity.id)
));
if !entity.attributes.is_empty() {
// Horizontal divider between header and attributes
let divider_y = y_offset + entity.header_height;
svg.push_str(&format!(
"<line x1=\"{x1}\" y1=\"{y}\" x2=\"{x2}\" y2=\"{y}\" class=\"divider\"/>",
x1 = x_offset,
x2 = x_offset + entity.width,
y = divider_y
));
let col_divider_x = x_offset + entity.max_type_width;
let attr_start_y = y_offset + entity.header_height;
let attr_end_y = y_offset + entity.height;
// Attribute rows
let mut current_y = attr_start_y;
// Compute alternating row fill colors matching mermaid.js erBox behaviour.
// Light themes keep the upstream values: rowOdd = lighten(primary, 75)
// ≈ #ffffff, rowEven = slightly lighter than node_fill. Dark themes
// derive both stripes from the background instead (as theme-dark does),
// so attribute text keeps its contrast instead of white-on-white.
let (row_odd_fill, row_even_fill) = if is_dark_hex(&theme.background) {
(
lighten_hex(&theme.background, 0.08),
lighten_hex(&theme.background, 0.16),
)
} else {
(String::from("#ffffff"), lighten_hex(&theme.node_fill, 0.25))
};
for (i, attr) in entity.attributes.iter().enumerate() {
let row_h = entity
.row_heights
.get(i)
.copied()
.unwrap_or(LINE_HEIGHT + TEXT_PADDING);
let text_y = current_y + row_h / 2.0;
// Alternating row background rect (zebra striping)
// Mermaid uses contentRowIndex = i + 1; isEven when contentRowIndex % 2 == 0 && i > 0
let is_even = (i + 1) % 2 == 0 && i > 0;
let row_fill = if is_even {
row_even_fill.as_str()
} else {
row_odd_fill.as_str()
};
svg.push_str(&format!(
"<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" \
style=\"fill:{fill};stroke:{stroke}\" class=\"er attributeBox{parity}\"/>",
x = x_offset,
y = current_y,
w = entity.width,
h = row_h,
fill = row_fill,
stroke = theme.node_stroke,
parity = if is_even { "Even" } else { "Odd" },
));
// Horizontal line between attribute rows (faint separator)
if i > 0 {
svg.push_str(&format!(
"<line x1=\"{x1}\" y1=\"{y}\" x2=\"{x2}\" y2=\"{y}\" class=\"divider\" style=\"stroke-opacity:0.3\"/>",
x1 = x_offset,
x2 = x_offset + entity.width,
y = current_y
));
}
// Type text (left column)
let type_text_x = x_offset + COLUMN_TEXT_PADDING;
svg.push_str(&format!(
"<text x=\"{x}\" y=\"{y}\" text-anchor=\"start\" dominant-baseline=\"middle\" \
fill=\"{color}\" class=\"er entityLabel\">{text}</text>",
x = type_text_x,
y = text_y,
color = theme.text_color,
text = escape_xml(&attr.attr_type)
));
// Name text (right column)
let name_text_x = col_divider_x + COLUMN_TEXT_PADDING;
svg.push_str(&format!(
"<text x=\"{x}\" y=\"{y}\" text-anchor=\"start\" dominant-baseline=\"middle\" \
fill=\"{color}\" class=\"er entityLabel\">{text}</text>",
x = name_text_x,
y = text_y,
color = theme.text_color,
text = escape_xml(&attr.name)
));
current_y += row_h;
}
// Vertical divider between type and name columns.
// Drawn after the row background rects so it is not covered by their fill.
svg.push_str(&format!(
"<line x1=\"{x}\" y1=\"{y1}\" x2=\"{x}\" y2=\"{y2}\" class=\"divider\"/>",
x = col_divider_x,
y1 = attr_start_y,
y2 = attr_end_y
));
}
svg.push_str("</g>");
}
fn points_to_path_d(points: &[(f64, f64)]) -> String {
if points.is_empty() {
return String::new();
}
let mut d = String::new();
let (x0, y0) = points[0];
d.push_str(&format!("M{x0},{y0}"));
for (x, y) in &points[1..] {
d.push_str(&format!("L{x},{y}"));
}
d
}
/// Lighten a hex color by blending it toward white.
/// `amount` is 0.0 (no change) to 1.0 (white).
fn is_dark_hex(hex: &str) -> bool {
let hex = hex.trim_start_matches('#');
if hex.len() < 6 {
return false;
}
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(255) as f64;
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(255) as f64;
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(255) as f64;
0.2126 * r + 0.7152 * g + 0.0722 * b < 128.0
}
fn lighten_hex(hex: &str, amount: f64) -> String {
let hex = hex.trim_start_matches('#');
if hex.len() < 6 {
return format!("#{hex}");
}
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
let lr = r as f64 + (255.0 - r as f64) * amount;
let lg = g as f64 + (255.0 - g as f64) * amount;
let lb = b as f64 + (255.0 - b as f64) * amount;
format!("#{:02X}{:02X}{:02X}", lr as u8, lg as u8, lb as u8)
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+22
View File
@@ -0,0 +1,22 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum MermaidError {
#[error("Parse error at line {line}: {message}")]
ParseError { line: usize, message: String },
#[error("Invalid graph direction: {0}")]
InvalidDirection(String),
#[error("Invalid node shape: {0}")]
InvalidNodeShape(String),
#[error("DOT generation error: {0}")]
DotGenerationError(String),
#[error("SVG rendering error: {0}")]
RenderError(String),
#[error("Unsupported diagram type: {0}")]
UnsupportedDiagramType(String),
}
+437
View File
@@ -0,0 +1,437 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
use std::collections::BTreeMap;
/// Mermaid 11.12.2 gantt config defaults (from config.schema.yaml)
const BAR_HEIGHT: f64 = 20.0;
const BAR_GAP: f64 = 4.0;
const TOP_PADDING: f64 = 50.0;
const LEFT_PADDING: f64 = 75.0;
const RIGHT_PADDING: f64 = 75.0;
const GRID_LINE_START_PADDING: f64 = 35.0;
const FONT_SIZE: f64 = 11.0;
const SECTION_FONT_SIZE: f64 = 11.0;
const TITLE_TOP_MARGIN: f64 = 25.0;
const BOTTOM_AXIS_HEIGHT: f64 = 50.0;
const RX: f64 = 3.0;
const RY: f64 = 3.0;
/// Default theme colors from Mermaid 11.12.2 theme-default.js
const SECTION_BKG_COLOR: &str = "rgba(102,102,255,0.49)";
const ALT_SECTION_BKG_COLOR: &str = "white";
const TASK_BKG_COLOR: &str = "#8a90dd";
const TASK_BORDER_COLOR: &str = "#534fbc";
const TASK_TEXT_COLOR: &str = "white";
const TASK_TEXT_DARK_COLOR: &str = "black";
const GRID_COLOR: &str = "#333";
const TITLE_COLOR: &str = "#333";
const FONT_FAMILY: &str = "'trebuchet ms', verdana, arial, sans-serif";
pub fn render_gantt_diagram_to_svg(
mermaid_source: &str,
_theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let chart = parse_gantt_diagram(mermaid_source)?;
// Collect unique categories (section types) in order
let mut categories: Vec<String> = Vec::new();
for task in &chart.tasks {
let cat = task.section.clone().unwrap_or_default();
if !categories.contains(&cat) {
categories.push(cat);
}
}
// Category heights (count of tasks per category)
let mut category_heights: BTreeMap<String, usize> = BTreeMap::new();
for task in &chart.tasks {
let cat = task.section.clone().unwrap_or_default();
*category_heights.entry(cat).or_insert(0) += 1;
}
let num_tasks = chart.tasks.len();
let gap = BAR_HEIGHT + BAR_GAP;
let h = 2.0 * TOP_PADDING + num_tasks as f64 * gap;
// Compute time domain
let mut min_day = i32::MAX;
let mut max_day = i32::MIN;
for task in &chart.tasks {
min_day = min_day.min(task.start_day);
max_day = max_day.max(task.start_day + task.duration_days);
}
let w = 784.0_f64;
let plot_width = w - LEFT_PADDING - RIGHT_PADDING;
// Time scale: maps day offset to pixel x
let span_days = (max_day - min_day).max(1) as f64;
let px_per_day = plot_width / span_days;
let mut svg = String::new();
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {w} {h}\">"
));
// Embedded <style> matching Mermaid 11.12.2 gantt styles
svg.push_str("<style>");
svg.push_str(&format!(
".section {{ stroke: none; opacity: 0.2; }}\
.section0 {{ fill: {SECTION_BKG_COLOR}; }}\
.section1 {{ fill: {ALT_SECTION_BKG_COLOR}; opacity: 0.2; }}\
.grid .tick line {{ stroke: {GRID_COLOR}; opacity: 0.8; shape-rendering: crispEdges; }}\
.grid .tick text {{ font-family: {FONT_FAMILY}; fill: #000; font-size: 10px; }}\
.grid path {{ stroke-width: 0; }}\
.task {{ stroke-width: 2; }}\
.task0 {{ fill: {TASK_BKG_COLOR}; stroke: {TASK_BORDER_COLOR}; }}\
.taskText {{ text-anchor: middle; font-family: {FONT_FAMILY}; }}\
.taskText0 {{ fill: {TASK_TEXT_COLOR}; }}\
.taskTextOutsideRight {{ fill: {TASK_TEXT_DARK_COLOR}; text-anchor: start; font-family: {FONT_FAMILY}; }}\
.taskTextOutsideLeft {{ fill: {TASK_TEXT_DARK_COLOR}; text-anchor: end; }}\
.titleText {{ text-anchor: middle; font-size: 18px; font-family: {FONT_FAMILY}; fill: {TITLE_COLOR}; }}\
.sectionTitle {{ text-anchor: start; font-family: {FONT_FAMILY}; font-size: {SECTION_FONT_SIZE}px; }}\
.sectionTitle0, .sectionTitle1 {{ fill: {TITLE_COLOR}; }}"
));
svg.push_str("</style>");
// 1. Section background bands
{
let mut task_idx = 0;
for (cat_order, cat) in categories.iter().enumerate() {
let count = category_heights.get(cat).copied().unwrap_or(0);
if count == 0 {
continue;
}
let y = task_idx as f64 * gap + TOP_PADDING - 2.0;
let rect_h = count as f64 * gap;
let section_class = format!("section section{}", cat_order % 2);
svg.push_str(&format!(
"<rect x=\"0\" y=\"{y:.1}\" width=\"{w_rect:.1}\" height=\"{rect_h:.1}\" class=\"{section_class}\"/>",
w_rect = w - RIGHT_PADDING / 2.0
));
task_idx += count;
}
}
// 2. Grid lines and bottom axis
{
let axis_y = h - BOTTOM_AXIS_HEIGHT;
svg.push_str(&format!(
"<g class=\"grid\" transform=\"translate({LEFT_PADDING}, {axis_y})\">"
));
let total_days = (max_day - min_day) as usize;
for d in 0..=total_days {
let x = d as f64 * px_per_day;
// Matches D3.js: tickSize(-h + topPadding + gridLineStartPadding)
let tick_top = -h + TOP_PADDING + GRID_LINE_START_PADDING;
svg.push_str(&format!(
"<g class=\"tick\" transform=\"translate({x:.2}, 0)\">\
<line y2=\"{tick_top:.1}\"/>\
<text dy=\"1em\" text-anchor=\"middle\">{label}</text>\
</g>",
label = day_to_ymd_str(min_day + d as i32)
));
}
svg.push_str("</g>");
}
// 3. Task bars
for (i, task) in chart.tasks.iter().enumerate() {
let x = (task.start_day - min_day) as f64 * px_per_day + LEFT_PADDING;
let bar_w = task.duration_days as f64 * px_per_day;
let y = i as f64 * gap + TOP_PADDING;
let sec_num = task
.section
.as_ref()
.and_then(|s| categories.iter().position(|c| c == s))
.unwrap_or(0)
% 4;
svg.push_str(&format!(
"<rect rx=\"{RX}\" ry=\"{RY}\" x=\"{x:.2}\" y=\"{y:.2}\" width=\"{bar_w:.2}\" height=\"{BAR_HEIGHT}\" \
class=\"task task{sec_num}\"/>"
));
}
// 4. Task text (inside bars, or outside if text doesn't fit)
for (i, task) in chart.tasks.iter().enumerate() {
let start_x = (task.start_day - min_day) as f64 * px_per_day;
let end_x = start_x + task.duration_days as f64 * px_per_day;
let bar_w = end_x - start_x;
// Estimate text width (Mermaid uses getBBox, we approximate)
let text_width = task.name.len() as f64 * FONT_SIZE * 0.6;
let (tx, text_class) = if text_width > bar_w {
if end_x + text_width + 1.5 * LEFT_PADDING > w - LEFT_PADDING {
(start_x + LEFT_PADDING - 5.0, "taskTextOutsideLeft")
} else {
(end_x + LEFT_PADDING + 5.0, "taskTextOutsideRight")
}
} else {
(bar_w / 2.0 + start_x + LEFT_PADDING, "taskText taskText0")
};
let ty = i as f64 * gap + BAR_HEIGHT / 2.0 + (FONT_SIZE / 2.0 - 2.0) + TOP_PADDING;
svg.push_str(&format!(
"<text x=\"{tx:.2}\" y=\"{ty:.2}\" font-size=\"{FONT_SIZE}\" class=\"{text_class}\">{}</text>",
escape_xml(&task.name)
));
}
// 5. Section labels (vertLabels)
{
let ordered_cats: Vec<(String, usize)> = categories
.iter()
.map(|c| (c.clone(), category_heights.get(c).copied().unwrap_or(0)))
.collect();
let mut prev_total = 0_usize;
for (i, (cat_name, count)) in ordered_cats.iter().enumerate() {
if cat_name.is_empty() {
prev_total += count;
continue;
}
let y = if i > 0 {
(*count as f64 * gap) / 2.0 + prev_total as f64 * gap + TOP_PADDING
} else {
(*count as f64 * gap) / 2.0 + TOP_PADDING
};
let sec_num = i % 4;
svg.push_str(&format!(
"<text x=\"10\" y=\"{y:.2}\" font-size=\"{SECTION_FONT_SIZE}\" class=\"sectionTitle sectionTitle{sec_num}\">{}</text>",
escape_xml(cat_name)
));
prev_total += count;
}
}
// 6. Title
if let Some(title) = &chart.title {
svg.push_str(&format!(
"<text x=\"{x:.1}\" y=\"{TITLE_TOP_MARGIN}\" class=\"titleText\">{}</text>",
escape_xml(title),
x = w / 2.0
));
}
svg.push_str("</svg>");
Ok(svg)
}
#[derive(Debug, Clone)]
struct GanttChart {
title: Option<String>,
tasks: Vec<GanttTask>,
}
#[derive(Debug, Clone)]
struct GanttTask {
section: Option<String>,
name: String,
start_day: i32,
duration_days: i32,
}
fn parse_gantt_diagram(input: &str) -> Result<GanttChart, MermaidError> {
let lines: Vec<&str> = input.lines().collect();
let mut i = 0_usize;
while i < lines.len() {
let line = lines[i].trim();
if line.is_empty() || line.starts_with("%%") {
i += 1;
continue;
}
if line.split_whitespace().next() == Some("gantt") {
i += 1;
break;
}
return Err(MermaidError::ParseError {
line: i + 1,
message: "Expected 'gantt' declaration".to_string(),
});
}
let mut title: Option<String> = None;
let mut current_section: Option<String> = None;
let mut tasks: Vec<GanttTask> = Vec::new();
let mut tasks_by_id: BTreeMap<String, (i32, i32)> = BTreeMap::new();
while i < lines.len() {
let raw = lines[i];
let line = raw.trim();
let line_no = i + 1;
i += 1;
if line.is_empty() || line.starts_with("%%") {
continue;
}
if let Some(rest) = line.strip_prefix("title ") {
let t = rest.trim();
if !t.is_empty() {
title = Some(t.to_string());
}
continue;
}
if line.starts_with("dateFormat ") {
continue;
}
if let Some(rest) = line.strip_prefix("section ") {
let name = rest.trim();
current_section = if name.is_empty() {
None
} else {
Some(name.to_string())
};
continue;
}
let Some((name_raw, spec_raw)) = line.split_once(':') else {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid gantt task line: {line}"),
});
};
let name = name_raw.trim();
let spec_parts: Vec<&str> = spec_raw
.split(',')
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.collect();
if spec_parts.len() < 3 {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid gantt task spec: {spec_raw}"),
});
}
let id = spec_parts[0].to_string();
let start_spec = spec_parts[1];
let duration_spec = spec_parts[2];
let duration_days =
parse_duration_days(duration_spec).map_err(|message| MermaidError::ParseError {
line: line_no,
message,
})?;
let start_day = if let Some(after) = start_spec.strip_prefix("after ") {
let ref_id = after.trim();
let Some((ref_start, ref_dur)) = tasks_by_id.get(ref_id).copied() else {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Unknown gantt dependency id: {ref_id}"),
});
};
ref_start + ref_dur
} else {
parse_ymd_to_day(start_spec).map_err(|message| MermaidError::ParseError {
line: line_no,
message,
})?
};
let task = GanttTask {
section: current_section.clone(),
name: name.to_string(),
start_day,
duration_days,
};
tasks_by_id.insert(id, (start_day, duration_days));
tasks.push(task);
}
if tasks.is_empty() {
return Err(MermaidError::ParseError {
line: 1,
message: "Gantt diagram requires at least one task".to_string(),
});
}
Ok(GanttChart { title, tasks })
}
fn parse_duration_days(spec: &str) -> Result<i32, String> {
let spec = spec.trim();
if spec.is_empty() {
return Err("Empty duration".to_string());
}
let (num_str, unit) = spec.split_at(spec.len().saturating_sub(1));
let n: i32 = num_str
.trim()
.parse()
.map_err(|_| format!("Invalid duration: {spec}"))?;
match unit {
"d" | "D" => Ok(n),
"w" | "W" => Ok(n * 7),
_ => Err(format!("Unsupported duration unit: {spec}")),
}
}
fn parse_ymd_to_day(s: &str) -> Result<i32, String> {
let parts: Vec<&str> = s.trim().split('-').collect();
if parts.len() != 3 {
return Err(format!("Invalid date: {s}"));
}
let y: i32 = parts[0].parse().map_err(|_| format!("Invalid year: {s}"))?;
let m: i32 = parts[1]
.parse()
.map_err(|_| format!("Invalid month: {s}"))?;
let d: i32 = parts[2].parse().map_err(|_| format!("Invalid day: {s}"))?;
Ok(days_from_civil(y, m, d))
}
/// Convert a day number back to (year, month, day).
fn day_to_ymd(day_number: i32) -> (i32, i32, i32) {
let z = day_number + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
fn day_to_ymd_str(day_number: i32) -> String {
let (y, m, d) = day_to_ymd(day_number);
format!("{y:04}-{m:02}-{d:02}")
}
fn days_from_civil(y: i32, m: i32, d: i32) -> i32 {
let y = y - if m <= 2 { 1 } else { 0 };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+576
View File
@@ -0,0 +1,576 @@
use std::collections::HashMap;
use crate::error::MermaidError;
use crate::text_wrap::line_width;
use crate::theme::MermaidTheme;
// Matches Mermaid 11.12.2 gitGraphRenderer.ts constants.
const LAYOUT_OFFSET: f64 = 10.0;
const COMMIT_STEP: f64 = 40.0;
const PX: f64 = 4.0;
const PY: f64 = 2.0;
// Gitgraph-specific char width for "trebuchet ms" at 16px.
// Browser getBBox measures ~10.0 px/char for "main" and ~9.1 for "develop";
// 9.5 is a good average that closes the gap vs the global DEFAULT_CHAR_WIDTH=8.0.
const GITGRAPH_CHAR_WIDTH: f64 = 9.5;
// Branch spacing: 50 + 40 (rotateCommitLabel) = 90.
const BRANCH_Y_GAP: f64 = 90.0;
const COMMIT_RADIUS: f64 = 10.0;
const MERGE_OUTER_RADIUS: f64 = 9.0;
const MERGE_INNER_RADIUS: f64 = 6.0;
const ARROW_STROKE_WIDTH: f64 = 8.0;
const TURN_RADIUS: f64 = 20.0;
const THEME_COLOR_LIMIT: usize = 8;
// Branch label: rect width = bbox.width + 18, x = -(bbox.width + 34).
const BRANCH_LABEL_BG_PADDING: f64 = 18.0;
const BRANCH_LABEL_BG_X_TRANSLATE: f64 = -19.0;
const BRANCH_LABEL_BG_Y: f64 = -1.5;
const BRANCH_LABEL_BG_HEIGHT: f64 = 23.0;
const VIEWBOX_MARGIN: f64 = 8.0;
// Commit label constants from Mermaid 11.12.2.
const COMMIT_LABEL_FONT_SIZE: f64 = 10.0;
const COMMIT_LABEL_RECT_HEIGHT: f64 = 15.0;
const COMMIT_LABEL_RECT_Y_OFFSET: f64 = 13.5;
const COMMIT_LABEL_TEXT_Y_OFFSET: f64 = 25.0;
pub fn render_gitgraph_diagram_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let graph = parse_gitgraph(mermaid_source)?;
let mut branch_order: Vec<String> = graph.branch_order.clone();
if branch_order.is_empty() {
branch_order.push(graph.main_branch.clone());
}
// Mermaid 11.12.2 setBranchPosition: pos += 50 + (rotateCommitLabel ? 40 : 0).
let mut y_for_branch: HashMap<&str, f64> = HashMap::new();
for (idx, b) in branch_order.iter().enumerate() {
y_for_branch.insert(b.as_str(), idx as f64 * BRANCH_Y_GAP);
}
// Mermaid 11.12.2 drawCommits: pos starts at 0, increments by COMMIT_STEP + LAYOUT_OFFSET.
// posWithOffset = pos + LAYOUT_OFFSET. So commit x values: 10, 60, 110, 160, ...
// After last commit, pos increments once more, giving maxPos.
let num_commits = graph.commits.len();
let max_pos = if num_commits == 0 {
0.0
} else {
num_commits as f64 * (COMMIT_STEP + LAYOUT_OFFSET)
};
// Compute branch label bbox widths (approximation of browser getBBox).
let bbox_height = 19.0; // Typical text bbox height at 16px.
let branch_bbox_widths: Vec<f64> = branch_order
.iter()
.map(|b| line_width(b, GITGRAPH_CHAR_WIDTH))
.collect();
// Compute viewBox bounds from branch labels.
let mut min_x: f64 = 0.0;
let mut min_y: f64 = 0.0;
for (idx, branch) in branch_order.iter().enumerate() {
let y = y_for_branch.get(branch.as_str()).copied().unwrap_or(0.0);
let text_w = branch_bbox_widths[idx];
// Mermaid 11.12.2 drawBranches: bkg rect x = -(bbox.width + 4 + 30),
// transform = translate(-19, pos - bbox.height/2).
let bg_x = -(text_w + PX + 30.0);
let bg_translate_y = y - bbox_height / 2.0;
let label_left = BRANCH_LABEL_BG_X_TRANSLATE + bg_x;
let label_top = bg_translate_y + BRANCH_LABEL_BG_Y;
min_x = min_x.min(label_left);
min_y = min_y.min(label_top);
}
let max_x = max_pos;
let y_end = (branch_order.len().saturating_sub(1) as f64) * BRANCH_Y_GAP;
let mut max_y = y_end + COMMIT_RADIUS;
// Account for commit labels below commits.
for commit in &graph.commits {
if commit.kind != CommitKind::Normal {
continue;
}
let Some(y) = y_for_branch.get(commit.branch.as_str()).copied() else {
continue;
};
let label_text = commit_label_text(commit.seq);
let text_w = line_width(&label_text, GITGRAPH_CHAR_WIDTH) * (COMMIT_LABEL_FONT_SIZE / 16.0);
let r_y = 10.0 + text_w / 25.0 * 8.5;
let label_bottom = y + r_y + COMMIT_LABEL_RECT_Y_OFFSET + COMMIT_LABEL_RECT_HEIGHT;
max_y = max_y.max(label_bottom);
}
let vb_x = min_x - VIEWBOX_MARGIN;
let vb_y = min_y - VIEWBOX_MARGIN;
let vb_w = (max_x - min_x) + VIEWBOX_MARGIN * 2.0;
let vb_h = (max_y - min_y) + VIEWBOX_MARGIN * 2.0;
let mut svg = String::new();
svg.push_str(&format!(
"<svg id=\"my-svg\" width=\"100%\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" style=\"max-width: {vb_w}px; background-color: {};\" viewBox=\"{vb_x} {vb_y} {vb_w} {vb_h}\" role=\"graphics-document document\" aria-roledescription=\"gitGraph\">",
theme.background
));
// Emit style block matching Mermaid 11.12.2 CSS.
emit_style_block(&mut svg, theme);
svg.push_str("<g/>");
svg.push_str("<g class=\"commit-bullets\"/>");
svg.push_str("<g class=\"commit-labels\"/>");
// --- Branches + labels ---
svg.push_str("<g>");
for (idx, branch) in branch_order.iter().enumerate() {
let y = y_for_branch.get(branch.as_str()).copied().unwrap_or(0.0);
svg.push_str(&format!(
"<line x1=\"0\" y1=\"{y}\" x2=\"{max_pos}\" y2=\"{y}\" class=\"branch branch{idx}\"/>",
));
let text_w = branch_bbox_widths[idx];
// Mermaid 11.12.2: rect x = -(bbox.width + 4 + 30), width = bbox.width + 18,
// y = -bbox.height/2 + 8, height = bbox.height + 4,
// transform = translate(-19, pos - bbox.height/2).
let bg_w = text_w + BRANCH_LABEL_BG_PADDING;
let bg_x = -(text_w + PX + 30.0);
let bg_translate_y = y - bbox_height / 2.0;
svg.push_str(&format!(
"<rect class=\"branchLabelBkg label{idx}\" rx=\"4\" ry=\"4\" x=\"{bg_x}\" y=\"{BRANCH_LABEL_BG_Y}\" width=\"{bg_w}\" height=\"{BRANCH_LABEL_BG_HEIGHT}\" transform=\"translate({BRANCH_LABEL_BG_X_TRANSLATE}, {bg_translate_y})\"/>",
));
// Mermaid 11.12.2: label translate(-(bbox.width + 14 + 30), pos - bbox.height/2 - 1).
let label_x = -(text_w + 14.0 + 30.0);
let label_y = y - bbox_height / 2.0 - 1.0;
svg.push_str("<g class=\"branchLabel\">");
svg.push_str(&format!(
"<g class=\"label branch-label{idx}\" transform=\"translate({label_x}, {label_y})\"><text><tspan xml:space=\"preserve\" dy=\"1em\" x=\"0\" class=\"row\">{}</tspan></text></g>",
escape_xml(branch)
));
svg.push_str("</g>");
}
svg.push_str("</g>");
// --- Arrows ---
svg.push_str("<g class=\"commit-arrows\">");
for commit in &graph.commits {
let x = commit_x(commit.seq);
let y = y_for_branch
.get(commit.branch.as_str())
.copied()
.unwrap_or(0.0);
let commit_branch_idx = branch_order
.iter()
.position(|b| b == &commit.branch)
.unwrap_or(0);
for parent in &commit.parents {
let Some(parent_commit) = graph
.commit_by_id
.get(parent)
.and_then(|idx| graph.commits.get(*idx))
else {
continue;
};
let px = commit_x(parent_commit.seq);
let py = y_for_branch
.get(parent_commit.branch.as_str())
.copied()
.unwrap_or(0.0);
let arrow_idx = if commit.kind == CommitKind::Merge {
branch_order
.iter()
.position(|b| b == &parent_commit.branch)
.unwrap_or(0)
} else {
commit_branch_idx
};
let class = format!("arrow arrow{arrow_idx}");
if (py - y).abs() < 0.1 {
svg.push_str(&format!(
"<path d=\"M {px} {py} L {x} {y}\" class=\"{class}\"/>",
));
} else if y > py {
// Branch down: from parent → vertical → arc → horizontal to commit.
let bend_y = y - TURN_RADIUS;
let arc_end_x = px + TURN_RADIUS;
svg.push_str(&format!(
"<path d=\"M {px} {py} L {px} {bend_y} A {TURN_RADIUS} {TURN_RADIUS}, 0, 0, 0, {arc_end_x} {y} L {x} {y}\" class=\"{class}\"/>",
));
} else {
// Merge up: from parent → horizontal → arc → vertical to commit.
let bend_x = x - TURN_RADIUS;
let arc_end_y = py - TURN_RADIUS;
svg.push_str(&format!(
"<path d=\"M {px} {py} L {bend_x} {py} A {TURN_RADIUS} {TURN_RADIUS}, 0, 0, 0, {x} {arc_end_y} L {x} {y}\" class=\"{class}\"/>",
));
}
}
}
svg.push_str("</g>");
// --- Commit bullets ---
svg.push_str("<g class=\"commit-bullets\">");
for commit in &graph.commits {
let x = commit_x(commit.seq);
let y = y_for_branch
.get(commit.branch.as_str())
.copied()
.unwrap_or(0.0);
let branch_idx = branch_order
.iter()
.position(|b| b == &commit.branch)
.unwrap_or(0);
let id_class = commit_id_class(commit.seq);
match commit.kind {
CommitKind::Merge => {
svg.push_str(&format!(
"<circle cx=\"{x}\" cy=\"{y}\" r=\"{MERGE_OUTER_RADIUS}\" class=\"commit {id_class} commit{branch_idx}\"/>",
));
svg.push_str(&format!(
"<circle cx=\"{x}\" cy=\"{y}\" r=\"{MERGE_INNER_RADIUS}\" class=\"commit commit-merge {id_class} commit{branch_idx}\"/>",
));
}
CommitKind::Normal => {
svg.push_str(&format!(
"<circle cx=\"{x}\" cy=\"{y}\" r=\"{COMMIT_RADIUS}\" class=\"commit {id_class} commit{branch_idx}\"/>",
));
}
}
}
svg.push_str("</g>");
// --- Commit labels ---
svg.push_str("<g class=\"commit-labels\">");
for commit in &graph.commits {
if commit.kind != CommitKind::Normal {
continue;
}
let x = commit_x(commit.seq);
let pos = commit.seq as f64 * (COMMIT_STEP + LAYOUT_OFFSET);
let Some(y) = y_for_branch.get(commit.branch.as_str()).copied() else {
continue;
};
let label = commit_label_text(commit.seq);
// Approximate bbox of commit label text at font-size 10px.
let text_w = line_width(&label, GITGRAPH_CHAR_WIDTH) * (COMMIT_LABEL_FONT_SIZE / 16.0);
let pos_with_offset = x;
// Mermaid 11.12.2: rect x = posWithOffset - bbox.width/2 - PY,
// rect width = bbox.width + 2*PY, rect height = bbox.height + 2*PY.
let rect_w = text_w + 2.0 * PY;
let rect_x = pos_with_offset - text_w / 2.0 - PY;
let rect_y = y + COMMIT_LABEL_RECT_Y_OFFSET;
let text_x = pos_with_offset - text_w / 2.0;
let text_y = y + COMMIT_LABEL_TEXT_Y_OFFSET;
// Mermaid 11.12.2: r_x = -7.5 - (bbox.width + 10) / 25 * 9.5,
// r_y = 10 + bbox.width / 25 * 8.5,
// wrapper transform = translate(r_x, r_y) rotate(-45, pos, y).
let r_x = -7.5 - (text_w + 10.0) / 25.0 * 9.5;
let r_y = 10.0 + text_w / 25.0 * 8.5;
svg.push_str(&format!(
"<g transform=\"translate({r_x}, {r_y}) rotate(-45, {pos}, {y})\">",
));
svg.push_str(&format!(
"<rect class=\"commit-label-bkg\" x=\"{rect_x}\" y=\"{rect_y}\" width=\"{rect_w}\" height=\"{COMMIT_LABEL_RECT_HEIGHT}\"/>",
));
svg.push_str(&format!(
"<text x=\"{text_x}\" y=\"{text_y}\" class=\"commit-label\">{}</text>",
escape_xml(&label)
));
svg.push_str("</g>");
}
svg.push_str("</g>");
svg.push_str("</svg>");
Ok(svg)
}
/// Compute the x position of a commit (posWithOffset in Mermaid 11.12.2).
fn commit_x(seq: usize) -> f64 {
seq as f64 * (COMMIT_STEP + LAYOUT_OFFSET) + LAYOUT_OFFSET
}
/// Generate the commit label text (deterministic hash from seq).
fn commit_label_text(seq: usize) -> String {
let hash = ((seq as u32).wrapping_mul(0x9E37_79B9) ^ 0x079A_D076) & 0x0FFF_FFFF;
format!("{seq}-{hash:07x}")
}
/// Generate the commit CSS id class.
fn commit_id_class(seq: usize) -> String {
commit_label_text(seq)
}
/// Emit the CSS style block matching Mermaid 11.12.2.
fn emit_style_block(svg: &mut String, theme: &MermaidTheme) {
svg.push_str(&format!(
"<style>#my-svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:{};}}",
theme.text_color
));
// Mermaid 11.12.2 always emits commit-id, commit-msg, branch-label base styles.
svg.push_str(
"#my-svg .commit-id,#my-svg .commit-msg,#my-svg .branch-label{fill:lightgrey;color:lightgrey;font-family:'trebuchet ms',verdana,arial,sans-serif;font-family:var(--mermaid-font-family);}"
);
// Always emit all 8 branch color sets (THEME_COLOR_LIMIT) to match the reference.
for i in 0..THEME_COLOR_LIMIT {
let color = branch_color(i);
let label_color = branch_label_color(i);
svg.push_str(&format!("#my-svg .branch-label{i}{{fill:{label_color};}}"));
svg.push_str(&format!(
"#my-svg .commit{i}{{stroke:{color};fill:{color};}}"
));
svg.push_str(&format!(
"#my-svg .commit-highlight{i}{{stroke:{color};fill:{color};}}"
));
svg.push_str(&format!("#my-svg .label{i}{{fill:{color};}}"));
svg.push_str(&format!("#my-svg .arrow{i}{{stroke:{color};}}"));
}
svg.push_str(&format!(
"#my-svg .branch{{stroke-width:1;stroke:{};stroke-dasharray:2;}}",
theme.edge_color
));
svg.push_str("#my-svg .commit-label{font-size:10px;fill:#000021;}");
svg.push_str("#my-svg .commit-label-bkg{font-size:10px;fill:#ffffde;opacity:0.5;}");
svg.push_str(&format!(
"#my-svg .tag-label{{font-size:10px;fill:{tag_label_color};}}",
tag_label_color = "#131300"
));
svg.push_str(&format!(
"#my-svg .tag-label-bkg{{fill:{};stroke:hsl(240, 60%, 86.2745098039%);}}",
theme.node_fill
));
svg.push_str(&format!("#my-svg .tag-hole{{fill:{};}}", theme.text_color));
svg.push_str(&format!(
"#my-svg .commit-merge{{stroke:{};fill:{};}}",
theme.node_fill, theme.node_fill
));
svg.push_str(&format!(
"#my-svg .commit-reverse{{stroke:{};fill:{};stroke-width:3;}}",
theme.node_fill, theme.node_fill
));
svg.push_str(&format!(
"#my-svg .commit-highlight-inner{{stroke:{};fill:{};}}",
theme.node_fill, theme.node_fill
));
svg.push_str(&format!(
"#my-svg .arrow{{stroke-width:{ARROW_STROKE_WIDTH};stroke-linecap:round;fill:none;}}"
));
svg.push_str(&format!(
"#my-svg .gitTitleText{{text-anchor:middle;font-size:18px;fill:{};}}",
theme.text_color
));
svg.push_str("</style>");
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CommitKind {
Normal,
Merge,
}
#[derive(Debug, Clone)]
struct Commit {
seq: usize,
branch: String,
parents: Vec<String>,
kind: CommitKind,
}
#[derive(Debug, Clone)]
struct GitGraph {
main_branch: String,
branch_order: Vec<String>,
commits: Vec<Commit>,
commit_by_id: HashMap<String, usize>,
}
fn parse_gitgraph(input: &str) -> Result<GitGraph, MermaidError> {
let mut found_header = false;
let main_branch = "main".to_string();
let mut branch_order: Vec<String> = vec![main_branch.clone()];
let mut branches: HashMap<String, Option<String>> = HashMap::new();
branches.insert(main_branch.clone(), None);
let mut current_branch = main_branch.clone();
let mut head: Option<String> = None;
let mut commits: Vec<Commit> = Vec::new();
let mut commit_by_id: HashMap<String, usize> = HashMap::new();
for (idx, raw) in input.lines().enumerate() {
let line_no = idx + 1;
let line = raw.trim();
if line.is_empty() || line.starts_with("%%") {
continue;
}
if !found_header {
if line.split_whitespace().next() != Some("gitGraph") {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected 'gitGraph' declaration".to_string(),
});
}
found_header = true;
continue;
}
let mut parts = line.split_whitespace();
let Some(cmd) = parts.next() else {
continue;
};
match cmd {
"commit" => {
let seq = commits.len();
let id = format!("{seq}");
let parents = head.clone().into_iter().collect();
let commit = Commit {
seq,
branch: current_branch.clone(),
parents,
kind: CommitKind::Normal,
};
commit_by_id.insert(id.clone(), seq);
commits.push(commit);
head = Some(id.clone());
branches.insert(current_branch.clone(), Some(id));
}
"branch" => {
let Some(name) = parts.next() else {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected branch name".to_string(),
});
};
let name = name.to_string();
if !branches.contains_key(&name) {
branches.insert(name.clone(), head.clone());
branch_order.push(name.clone());
}
}
"checkout" => {
let Some(name) = parts.next() else {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected branch name".to_string(),
});
};
let name = name.to_string();
current_branch = name.clone();
head = branches.get(&name).cloned().flatten();
if !branches.contains_key(&name) {
branches.insert(name.clone(), head.clone());
branch_order.push(name);
}
}
"merge" => {
let Some(other) = parts.next() else {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected branch name".to_string(),
});
};
let other = other.to_string();
let other_head = branches.get(&other).cloned().flatten();
let mut parents: Vec<String> = Vec::new();
if let Some(h) = head.clone() {
parents.push(h);
}
if let Some(oh) = other_head {
parents.push(oh);
}
let seq = commits.len();
let id = format!("{seq}");
let commit = Commit {
seq,
branch: current_branch.clone(),
parents,
kind: CommitKind::Merge,
};
commit_by_id.insert(id.clone(), seq);
commits.push(commit);
head = Some(id.clone());
branches.insert(current_branch.clone(), Some(id));
}
_ => {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Unrecognized gitGraph command: {cmd}"),
});
}
}
}
if !found_header {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'gitGraph' declaration".to_string(),
});
}
Ok(GitGraph {
main_branch,
branch_order,
commits,
commit_by_id,
})
}
fn branch_color(order: usize) -> &'static str {
match order {
0 => "hsl(240, 100%, 46.2745098039%)",
1 => "hsl(60, 100%, 43.5294117647%)",
2 => "hsl(80, 100%, 46.2745098039%)",
3 => "hsl(210, 100%, 46.2745098039%)",
4 => "hsl(180, 100%, 46.2745098039%)",
5 => "hsl(150, 100%, 46.2745098039%)",
6 => "hsl(300, 100%, 46.2745098039%)",
7 => "hsl(0, 100%, 46.2745098039%)",
_ => "hsl(180, 100%, 46.2745098039%)",
}
}
fn branch_label_color(order: usize) -> &'static str {
// Mermaid 11.12.2 default theme: branch-label0 = #ffffff, rest = black.
match order {
0 => "#ffffff",
3 => "#ffffff",
_ => "black",
}
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+56
View File
@@ -0,0 +1,56 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
const INFO_WIDTH: f64 = 400.0;
const INFO_HEIGHT: f64 = 150.0;
const PINNED_MERMAID_VERSION: &str = "11.12.2";
pub fn render_info_diagram_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
if first_diagram_type_token(mermaid_source) != Some("info") {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'info' declaration".to_string(),
});
}
let background_color = if theme.background == "#ffffff" {
"white"
} else {
theme.background.as_str()
};
let text_color = if theme.text_color == "#333333" {
"#333"
} else {
theme.text_color.as_str()
};
let mut svg = String::new();
svg.push_str(&format!(
"<svg aria-roledescription=\"info\" role=\"graphics-document document\" viewBox=\"0 0 {INFO_WIDTH} {INFO_HEIGHT}\" style=\"max-width: {INFO_WIDTH}px; background-color: {background_color};\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" id=\"my-svg\">"
));
svg.push_str(&format!(
"<style>#my-svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:{text_color};}}@keyframes edge-animation-frame{{from{{stroke-dashoffset:0;}}}}@keyframes dash{{to{{stroke-dashoffset:0;}}}}#my-svg .edge-animation-slow{{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}}#my-svg .edge-animation-fast{{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}}#my-svg .error-icon{{fill:#552222;}}#my-svg .error-text{{fill:#552222;stroke:#552222;}}#my-svg .edge-thickness-normal{{stroke-width:1px;}}#my-svg .edge-thickness-thick{{stroke-width:3.5px;}}#my-svg .edge-pattern-solid{{stroke-dasharray:0;}}#my-svg .edge-thickness-invisible{{stroke-width:0;fill:none;}}#my-svg .edge-pattern-dashed{{stroke-dasharray:3;}}#my-svg .edge-pattern-dotted{{stroke-dasharray:2;}}#my-svg .marker{{fill:{edge};stroke:{edge};}}#my-svg .marker.cross{{stroke:{edge};}}#my-svg svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;}}#my-svg p{{margin:0;}}#my-svg :root{{--mermaid-font-family:\"trebuchet ms\",verdana,arial,sans-serif;}}</style>",
edge = theme.edge_color
));
svg.push_str("<g/>");
svg.push_str(&format!(
"<g><text style=\"text-anchor: middle;\" font-size=\"32\" class=\"version\" y=\"40\" x=\"100\">v{PINNED_MERMAID_VERSION}</text></g>"
));
svg.push_str("</svg>");
Ok(svg)
}
fn first_diagram_type_token(input: &str) -> Option<&str> {
input
.lines()
.map(|l| l.trim())
.find(|l| !l.is_empty() && !l.starts_with("%%"))
.and_then(|l| l.split_whitespace().next())
}
+563
View File
@@ -0,0 +1,563 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
// --- Mermaid 11.12.2 journey config defaults ---
const DIAGRAM_MARGIN_X: f64 = 50.0;
const DIAGRAM_MARGIN_Y: f64 = 10.0;
const LEFT_MARGIN: f64 = 150.0;
const TASK_WIDTH: f64 = 150.0;
const TASK_HEIGHT: f64 = 50.0;
const TASK_MARGIN: f64 = 50.0;
const SECTION_Y: f64 = 50.0;
const FACE_RADIUS: f64 = 15.0;
const ACTOR_CIRCLE_R: f64 = 7.0;
const MAX_FACE_Y: f64 = 300.0;
const FACE_Y_PER_SCORE: f64 = 30.0;
const TASK_LINE_BOTTOM: f64 = MAX_FACE_Y + 5.0 * FACE_Y_PER_SCORE; // 450
const ARROW_Y_MULTIPLIER: f64 = 4.0; // conf.height * 4 = 200
const FONT_FAMILY: &str = "'trebuchet ms', verdana, arial, sans-serif";
const TASK_FONT_SIZE: f64 = 14.0;
const TASK_FONT_FAMILY: &str = "'Open Sans', sans-serif";
const TITLE_FONT_SIZE: &str = "4ex";
// CSS section/task fill colors from Mermaid default theme
const SECTION_FILLS: &[&str] = &[
"#ECECFF",
"#ffffde",
"hsl(304, 100%, 96.2745098039%)",
"hsl(124, 100%, 93.5294117647%)",
"hsl(176, 100%, 96.2745098039%)",
"hsl(-4, 100%, 93.5294117647%)",
"hsl(8, 100%, 96.2745098039%)",
"hsl(188, 100%, 93.5294117647%)",
];
// SVG fill attributes for section rects (from sectionFills config)
const SECTION_SVG_FILLS: &[&str] = &[
"#191970", "#8B008B", "#4B0082", "#2F4F4F", "#800000", "#8B4513", "#00008B",
];
const ACTOR_COLOURS: &[&str] = &[
"#8FBC8F", "#7CFC00", "#00FFFF", "#20B2AA", "#B0E0E6", "#FFFFE0",
];
pub fn render_journey_diagram_to_svg(
mermaid_source: &str,
_theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let journey = parse_journey_diagram(mermaid_source)?;
// Collect unique actors in order of first appearance
let mut actors: Vec<String> = Vec::new();
for row in &journey.rows {
if let JourneyRow::Task(task) = row {
for actor in &task.actors {
if !actors.contains(actor) {
actors.push(actor.clone());
}
}
}
}
// In mermaid.js: leftMargin = conf.leftMargin + maxWidth
// maxWidth is from actor legend text measurement; for simple cases it's 0
let left_margin = LEFT_MARGIN;
// Flatten tasks with their section assignments
let mut flat_tasks: Vec<FlatTask> = Vec::new();
let mut current_section: Option<String> = None;
for row in &journey.rows {
match row {
JourneyRow::Section(name) => {
current_section = Some(name.clone());
}
JourneyRow::Task(task) => {
flat_tasks.push(FlatTask {
name: task.name.clone(),
score: task.score,
actors: task.actors.clone(),
section: current_section.clone().unwrap_or_default(),
});
}
}
}
let num_tasks = flat_tasks.len();
if num_tasks == 0 {
return Err(MermaidError::ParseError {
line: 1,
message: "Journey requires at least one task".to_string(),
});
}
// Compute section info
let mut sections: Vec<SectionInfo> = Vec::new();
{
let mut last_section = String::new();
let mut section_idx: usize = 0;
for (i, task) in flat_tasks.iter().enumerate() {
if task.section != last_section {
// Count tasks in this section
let count = flat_tasks[i..]
.iter()
.take_while(|t| t.section == task.section)
.count();
let section_num = section_idx % SECTION_SVG_FILLS.len();
sections.push(SectionInfo {
name: task.section.clone(),
first_task_idx: i,
task_count: count,
section_num,
});
last_section = task.section.clone();
section_idx += 1;
}
}
}
// Compute task positions: task.x = i * taskMargin + i * width + leftMargin
let task_positions: Vec<f64> = (0..num_tasks)
.map(|i| i as f64 * TASK_MARGIN + i as f64 * TASK_WIDTH + left_margin)
.collect();
// Section vertical height for task y position
let section_v_height = TASK_HEIGHT * 2.0 + DIAGRAM_MARGIN_Y; // 110
let task_y = section_v_height; // 110 (0 + sectionVHeight)
// Arrow y: conf.height * 4 = 50 * 4 = 200
let arrow_y = TASK_HEIGHT * ARROW_Y_MULTIPLIER;
// Compute overall dimensions using mermaid.js bounds logic:
// bounds.insert(task.x, task.y, task.x + task.width + taskMargin, 300+5*30)
// where task.width = diagramMarginX (50), NOT the visual TASK_WIDTH (150)
let last_task_x = task_positions.last().copied().unwrap_or(left_margin);
let bounds_stopx = last_task_x + DIAGRAM_MARGIN_X + TASK_MARGIN; // 50 + 50 = 100
let width = left_margin + bounds_stopx + 2.0 * DIAGRAM_MARGIN_X;
// height = stopy - starty + 2 * diagramMarginY; starty=0, stopy=450
let height = TASK_LINE_BOTTOM + 2.0 * DIAGRAM_MARGIN_Y;
let has_title = journey.title.is_some();
let extra_vert_for_title = if has_title { 70.0 } else { 0.0 };
let viewbox_height = height + extra_vert_for_title;
let svg_height = height + extra_vert_for_title + 25.0;
// Arrow endpoint: width - leftMargin - 4
let arrow_x2 = width - left_margin - 4.0;
let mut svg = String::new();
// SVG header
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" \
xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
style=\"max-width: {width:.0}px;\" \
width=\"100%\" \
viewBox=\"0 -25 {width:.0} {vh:.0}\" \
preserveAspectRatio=\"xMinYMin meet\" \
height=\"{sh:.0}\" \
role=\"graphics-document document\" \
aria-roledescription=\"journey\">",
vh = viewbox_height,
sh = svg_height,
));
// CSS styles matching mermaid.js default theme
svg.push_str("<style>");
svg.push_str(&format!(
"svg {{font-family:{FONT_FAMILY};font-size:16px;fill:#333;}}"
));
svg.push_str(".mouth{stroke:#666;}");
svg.push_str("line{stroke:#333;}");
svg.push_str(&format!(".legend{{fill:#333;font-family:{FONT_FAMILY};}}"));
svg.push_str(".label text{fill:#333;}");
svg.push_str(".face{fill:#FFF8DC;stroke:#999;}");
// Section/task type fills
for (i, fill) in SECTION_FILLS.iter().enumerate() {
svg.push_str(&format!(".task-type-{i},.section-type-{i}{{fill:{fill};}}"));
}
// Actor colors
for (i, color) in ACTOR_COLOURS.iter().enumerate() {
svg.push_str(&format!(".actor-{i}{{fill:{color};}}"));
}
svg.push_str("</style>");
// Arrowhead marker definition
svg.push_str("<defs><marker id=\"arrowhead\" refX=\"5\" refY=\"2\" markerWidth=\"6\" markerHeight=\"4\" orient=\"auto\"><path d=\"M 0,0 V 4 L6,2 Z\"/></marker></defs>");
// Actor legend
let mut actor_y = 60.0_f64;
for (pos, actor) in actors.iter().enumerate() {
let color = ACTOR_COLOURS[pos % ACTOR_COLOURS.len()];
svg.push_str(&format!(
"<circle cx=\"20\" cy=\"{actor_y:.0}\" class=\"actor-{pos}\" fill=\"{color}\" stroke=\"#000\" r=\"{ACTOR_CIRCLE_R}\"/>"
));
svg.push_str(&format!(
"<text x=\"40\" y=\"{ty:.0}\" class=\"legend\"><tspan x=\"50\">{}</tspan></text>",
escape_xml(actor),
ty = actor_y + 7.0,
));
actor_y += 20.0;
}
// Draw sections
for section in &sections {
let section_x = task_positions[section.first_task_idx];
let section_width = TASK_WIDTH * section.task_count as f64
+ DIAGRAM_MARGIN_X * (section.task_count as f64 - 1.0);
let fill = SECTION_SVG_FILLS[section.section_num % SECTION_SVG_FILLS.len()];
let num = section.section_num;
svg.push_str("<g>");
svg.push_str(&format!(
"<rect x=\"{section_x:.0}\" y=\"{SECTION_Y:.0}\" fill=\"{fill}\" stroke=\"#666\" \
width=\"{section_width:.0}\" height=\"{TASK_HEIGHT:.0}\" rx=\"3\" ry=\"3\" \
class=\"journey-section section-type-{num}\"/>"
));
// Section label using foreignObject with tspan fallback
let center_x = section_x + section_width / 2.0;
let center_y = SECTION_Y + TASK_HEIGHT / 2.0;
svg.push_str(&format!(
"<switch>\
<foreignObject x=\"{section_x:.0}\" y=\"{SECTION_Y:.0}\" width=\"{section_width:.0}\" height=\"{TASK_HEIGHT:.0}\" \
requiredExtensions=\"http://www.w3.org/1999/xhtml\">\
<div class=\"journey-section section-type-{num}\" xmlns=\"http://www.w3.org/1999/xhtml\" \
style=\"display: table; height: 100%; width: 100%;\">\
<div class=\"label\" style=\"display: table-cell; text-align: center; vertical-align: middle;\">\
{}</div></div></foreignObject>\
<text x=\"{center_x:.0}\" y=\"{center_y:.0}\" dominant-baseline=\"central\" \
alignment-baseline=\"central\" class=\"journey-section\" \
style=\"text-anchor: middle; font-size: {TASK_FONT_SIZE}px; font-family: {TASK_FONT_FAMILY};\">\
<tspan x=\"{center_x:.0}\" dy=\"0\">{}</tspan></text></switch>",
escape_xml(&section.name),
escape_xml(&section.name),
));
svg.push_str("</g>");
}
// Draw tasks
let mut current_section_num = 0_usize;
let mut current_section_name = String::new();
let mut section_idx = 0_usize;
for (i, task) in flat_tasks.iter().enumerate() {
let task_counter = i;
// Track section
if task.section != current_section_name {
if section_idx < sections.len() {
current_section_num = sections[section_idx].section_num;
section_idx += 1;
}
current_section_name = task.section.clone();
}
let tx = task_positions[i];
let center = tx + TASK_WIDTH / 2.0;
let fill = SECTION_SVG_FILLS[current_section_num % SECTION_SVG_FILLS.len()];
let num = current_section_num;
svg.push_str("<g>");
// Dashed task line
svg.push_str(&format!(
"<line id=\"task{task_counter}\" x1=\"{center:.0}\" y1=\"{task_y:.0}\" \
x2=\"{center:.0}\" y2=\"{TASK_LINE_BOTTOM:.0}\" class=\"task-line\" \
stroke-width=\"1px\" stroke-dasharray=\"4 2\" stroke=\"#666\"/>"
));
// Face icon
let face_cy = MAX_FACE_Y + (5.0 - task.score as f64) * FACE_Y_PER_SCORE;
draw_face(&mut svg, center, face_cy, task.score);
// Task rectangle
svg.push_str(&format!(
"<rect x=\"{tx:.0}\" y=\"{task_y:.0}\" fill=\"{fill}\" stroke=\"#666\" \
width=\"{TASK_WIDTH:.0}\" height=\"{TASK_HEIGHT:.0}\" rx=\"3\" ry=\"3\" \
class=\"task task-type-{num}\"/>"
));
// Actor circles on the task
let mut x_pos = tx + 14.0;
for actor_name in &task.actors {
if let Some(pos) = actors.iter().position(|a| a == actor_name) {
let color = ACTOR_COLOURS[pos % ACTOR_COLOURS.len()];
svg.push_str(&format!(
"<circle cx=\"{x_pos:.0}\" cy=\"{task_y:.0}\" class=\"actor-{pos}\" \
fill=\"{color}\" stroke=\"#000\" r=\"{ACTOR_CIRCLE_R}\"><title>{}</title></circle>",
escape_xml(actor_name)
));
x_pos += 10.0;
}
}
// Task label using foreignObject with tspan fallback
let task_center_x = tx + TASK_WIDTH / 2.0;
let task_center_y = task_y + TASK_HEIGHT / 2.0;
svg.push_str(&format!(
"<switch>\
<foreignObject x=\"{tx:.0}\" y=\"{task_y:.0}\" width=\"{TASK_WIDTH:.0}\" height=\"{TASK_HEIGHT:.0}\" \
requiredExtensions=\"http://www.w3.org/1999/xhtml\">\
<div class=\"task\" xmlns=\"http://www.w3.org/1999/xhtml\" \
style=\"display: table; height: 100%; width: 100%;\">\
<div class=\"label\" style=\"display: table-cell; text-align: center; vertical-align: middle;\">\
{}</div></div></foreignObject>\
<text x=\"{task_center_x:.0}\" y=\"{task_center_y:.0}\" dominant-baseline=\"central\" \
alignment-baseline=\"central\" class=\"task\" \
style=\"text-anchor: middle; font-size: {TASK_FONT_SIZE}px; font-family: {TASK_FONT_FAMILY};\">\
<tspan x=\"{task_center_x:.0}\" dy=\"0\">{}</tspan></text></switch>",
escape_xml(&task.name),
escape_xml(&task.name),
));
svg.push_str("</g>");
}
// Title
if let Some(title) = &journey.title {
svg.push_str(&format!(
"<text x=\"{left_margin:.0}\" font-size=\"{TITLE_FONT_SIZE}\" \
font-weight=\"bold\" y=\"25\" fill=\"#333\" \
font-family=\"{FONT_FAMILY}\">{}</text>",
escape_xml(title),
));
}
// Horizontal arrow
svg.push_str(&format!(
"<line x1=\"{left_margin:.0}\" y1=\"{arrow_y:.0}\" x2=\"{arrow_x2:.0}\" y2=\"{arrow_y:.0}\" \
stroke-width=\"4\" stroke=\"black\" marker-end=\"url(#arrowhead)\"/>"
));
svg.push_str("</svg>");
Ok(svg)
}
fn draw_face(svg: &mut String, cx: f64, cy: f64, score: i32) {
// Face circle
svg.push_str(&format!(
"<circle cx=\"{cx:.0}\" cy=\"{cy:.0}\" class=\"face\" r=\"{FACE_RADIUS}\" \
stroke-width=\"2\" overflow=\"visible\"/>"
));
svg.push_str("<g>");
// Eyes
let eye_y = cy - FACE_RADIUS / 3.0;
let left_eye_x = cx - FACE_RADIUS / 3.0;
let right_eye_x = cx + FACE_RADIUS / 3.0;
svg.push_str(&format!(
"<circle cx=\"{left_eye_x:.0}\" cy=\"{eye_y:.0}\" r=\"1.5\" stroke-width=\"2\" fill=\"#666\" stroke=\"#666\"/>"
));
svg.push_str(&format!(
"<circle cx=\"{right_eye_x:.0}\" cy=\"{eye_y:.0}\" r=\"1.5\" stroke-width=\"2\" fill=\"#666\" stroke=\"#666\"/>"
));
// Mouth based on score
if score > 3 {
// Happy: smile arc
let inner_r = FACE_RADIUS / 2.0;
let outer_r = FACE_RADIUS / 2.2;
let arc_path = generate_smile_arc(inner_r, outer_r);
svg.push_str(&format!(
"<path class=\"mouth\" d=\"{arc_path}\" transform=\"translate({cx:.0},{ty:.0})\"/>",
ty = cy + 2.0,
));
} else if score < 3 {
// Sad: frown arc
let inner_r = FACE_RADIUS / 2.0;
let outer_r = FACE_RADIUS / 2.2;
let arc_path = generate_sad_arc(inner_r, outer_r);
svg.push_str(&format!(
"<path class=\"mouth\" d=\"{arc_path}\" transform=\"translate({cx:.0},{ty:.0})\"/>",
ty = cy + 7.0,
));
} else {
// Neutral: straight line
svg.push_str(&format!(
"<line class=\"mouth\" stroke=\"#666\" x1=\"{x1:.0}\" y1=\"{y1:.0}\" \
x2=\"{x2:.0}\" y2=\"{y1:.0}\" stroke-width=\"1px\"/>",
x1 = cx - 5.0,
y1 = cy + 7.0,
x2 = cx + 5.0,
));
}
svg.push_str("</g>");
}
/// Generate the smile arc path matching d3.arc with
/// startAngle=PI/2, endAngle=3*PI/2, innerRadius=r/2, outerRadius=r/2.2
fn generate_smile_arc(inner_r: f64, outer_r: f64) -> String {
// The exact path from the reference SVG is:
// M7.5,0A7.5,7.5,0,1,1,-7.5,0L-6.818,0A6.818,6.818,0,1,0,6.818,0Z
format!(
"M{or},0A{or},{or},0,1,1,-{or},0L-{ir},0A{ir},{ir},0,1,0,{ir},0Z",
or = format_num(outer_r),
ir = format_num(inner_r),
)
}
/// Generate the sad arc path matching d3.arc with
/// startAngle=3*PI/2, endAngle=5*PI/2, innerRadius=r/2, outerRadius=r/2.2
fn generate_sad_arc(inner_r: f64, outer_r: f64) -> String {
format!(
"M-{or},0A{or},{or},0,1,1,{or},0L{ir},0A{ir},{ir},0,1,0,-{ir},0Z",
or = format_num(outer_r),
ir = format_num(inner_r),
)
}
fn format_num(n: f64) -> String {
let s = format!("{n:.3}");
if s.contains('.') {
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
trimmed.to_string()
} else {
s
}
}
#[derive(Debug, Clone)]
struct JourneyDiagram {
title: Option<String>,
rows: Vec<JourneyRow>,
}
#[derive(Debug, Clone)]
enum JourneyRow {
Section(String),
Task(JourneyTask),
}
#[derive(Debug, Clone)]
struct JourneyTask {
name: String,
score: i32,
actors: Vec<String>,
}
#[derive(Debug, Clone)]
struct FlatTask {
name: String,
score: i32,
actors: Vec<String>,
section: String,
}
#[derive(Debug)]
struct SectionInfo {
name: String,
first_task_idx: usize,
task_count: usize,
section_num: usize,
}
fn parse_journey_diagram(input: &str) -> Result<JourneyDiagram, MermaidError> {
let lines: Vec<&str> = input.lines().collect();
let mut i = 0_usize;
while i < lines.len() {
let line = lines[i].trim();
if line.is_empty() || line.starts_with("%%") {
i += 1;
continue;
}
if line.split_whitespace().next() == Some("journey") {
i += 1;
break;
}
return Err(MermaidError::ParseError {
line: i + 1,
message: "Expected 'journey' declaration".to_string(),
});
}
let mut title: Option<String> = None;
let mut rows: Vec<JourneyRow> = Vec::new();
while i < lines.len() {
let raw = lines[i];
let line = raw.trim();
let line_no = i + 1;
i += 1;
if line.is_empty() || line.starts_with("%%") {
continue;
}
if let Some(rest) = line.strip_prefix("title ") {
let t = rest.trim();
if !t.is_empty() {
title = Some(t.to_string());
}
continue;
}
if let Some(rest) = line.strip_prefix("section ") {
let name = rest.trim();
if !name.is_empty() {
rows.push(JourneyRow::Section(name.to_string()));
}
continue;
}
let parts: Vec<&str> = line
.split(':')
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.collect();
if parts.len() < 2 {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid journey task line: {line}"),
});
}
let name = parts[0].to_string();
let score: i32 = parts[1].parse().map_err(|_| MermaidError::ParseError {
line: line_no,
message: format!("Invalid journey score: {line}"),
})?;
let actors: Vec<String> = if parts.len() >= 3 {
// Actors field may contain comma-separated names (e.g., "Alice, Bob")
// Join remaining parts (in case actor names contain colons) then split by comma
parts[2..]
.join(": ")
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
} else {
Vec::new()
};
rows.push(JourneyRow::Task(JourneyTask {
name,
score,
actors,
}));
}
if rows.is_empty() {
return Err(MermaidError::ParseError {
line: 1,
message: "Journey requires at least one section/task".to_string(),
});
}
Ok(JourneyDiagram { title, rows })
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+506
View File
@@ -0,0 +1,506 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
use serde_yaml::{Mapping, Value};
const COLUMN_WIDTH: f64 = 200.0;
const COLUMN_GAP: f64 = 5.0;
const HEADER_HEIGHT: f64 = 25.0;
const BOTTOM_PADDING: f64 = 10.0;
const TASK_WIDTH: f64 = 185.0;
const TASK_HEIGHT: f64 = 44.0;
const TASK_HEIGHT_WITH_ASSIGNED: f64 = 56.0;
const TASK_GAP: f64 = 5.0;
const TASK_TEXT_LINE_HEIGHT: f64 = 24.0;
const TASK_INNER_PADDING: f64 = 10.0;
const DIAGRAM_PADDING: f64 = 10.0;
/// Per the reference SVG, the priority line is inset 2px from the card rect's left edge,
/// and 2px from the top/bottom of the rect.
const PRIORITY_LINE_INSET_X: f64 = 2.0;
const PRIORITY_LINE_INSET_Y: f64 = 2.0;
const PRIORITY_LINE_STROKE_WIDTH: f64 = 4.0;
pub fn render_kanban_diagram_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let board = parse_kanban(mermaid_source)?;
let columns_count = board.columns.len().max(1) as f64;
let total_width =
DIAGRAM_PADDING * 2.0 + columns_count * COLUMN_WIDTH + (columns_count - 1.0) * COLUMN_GAP;
let mut max_col_height: f64 = 0.0;
for col in &board.columns {
let tasks_h = tasks_stack_height(&col.tasks);
let h = HEADER_HEIGHT + tasks_h + BOTTOM_PADDING;
max_col_height = max_col_height.max(h);
}
let total_height = DIAGRAM_PADDING * 2.0 + max_col_height;
let mut svg = String::new();
svg.push_str(&format!(
"<svg id=\"my-svg\" width=\"100%\" xmlns=\"http://www.w3.org/2000/svg\" \
xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
style=\"max-width: {total_width}px; background-color: white;\" \
viewBox=\"0 0 {total_width} {total_height}\" \
role=\"graphics-document document\" aria-roledescription=\"kanban\">"
));
// Emit CSS matching Mermaid 11.12.2 reference
svg.push_str("<style>");
svg.push_str(&format!(
"#my-svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:{};}}",
theme.text_color
));
svg.push_str("#my-svg p{margin:0;}");
// Section-specific CSS (sections 0-10)
for i in 0..=10i32 {
let (fill_hue, fill_sat, fill_light) = section_hsl(i);
let text_fill = section_text_color(i);
svg.push_str(&format!(
"#my-svg .section-{i} rect,#my-svg .section-{i} path,\
#my-svg .section-{i} circle,#my-svg .section-{i} polygon,\
#my-svg .section-{i} path\
{{fill:hsl({fill_hue}, {fill_sat}%, {fill_light}%);\
stroke:hsl({fill_hue}, {fill_sat}%, {fill_light}%);}}",
));
svg.push_str(&format!("#my-svg .section-{i} text{{fill:{text_fill};}}",));
}
// Node styling (matches reference exactly)
svg.push_str(&format!(
"#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,\
#my-svg .node polygon,#my-svg .node path\
{{fill:white;stroke:{};stroke-width:1px;}}",
theme.node_stroke
));
svg.push_str(&format!(
"#my-svg .kanban-ticket-link{{fill:white;stroke:{};text-decoration:underline;}}",
theme.node_stroke
));
// Cluster-label and label styling — makes header text #333 via CSS
svg.push_str(&format!(
"#my-svg .cluster-label,#my-svg .label{{color:{};fill:{};}}",
theme.text_color, theme.text_color
));
svg.push_str(&format!(
"#my-svg .cluster-label text{{fill:{};font-size:16px;}}",
theme.text_color
));
svg.push_str(&format!(
"#my-svg .label text{{fill:{};font-size:16px;}}",
theme.text_color
));
// Kanban-label class
svg.push_str(
"#my-svg .kanban-label{dy:1em;alignment-baseline:middle;\
text-anchor:middle;dominant-baseline:middle;text-align:center;}",
);
svg.push_str("</style>");
// Background rect
svg.push_str(&format!(
"<rect x=\"0\" y=\"0\" width=\"{total_width}\" height=\"{total_height}\" fill=\"white\"/>"
));
// Empty g (matches reference structure)
svg.push_str("<g/>");
// === Sections (columns) ===
svg.push_str("<g class=\"sections\">");
for (col_idx, col) in board.columns.iter().enumerate() {
let section_idx = col_idx + 1;
let col_x = DIAGRAM_PADDING + col_idx as f64 * (COLUMN_WIDTH + COLUMN_GAP);
let col_y = DIAGRAM_PADDING;
let tasks_h = tasks_stack_height(&col.tasks);
let col_h = HEADER_HEIGHT + tasks_h + BOTTOM_PADDING;
svg.push_str(&format!(
"<g class=\"cluster undefined section-{section_idx}\" id=\"{}\" data-look=\"classic\">",
escape_xml(&col.title)
));
// Section rect — no inline fill/stroke; CSS handles it via .section-N
svg.push_str(&format!(
"<rect style=\"\" rx=\"5\" ry=\"5\" x=\"{col_x}\" y=\"{col_y}\" \
width=\"{COLUMN_WIDTH}\" height=\"{col_h}\"/>"
));
// Cluster label using SVG <text> for universal renderer compatibility.
// Centered horizontally within the column, vertically within the header.
let label_x = col_x;
let label_y = col_y;
let text_x = COLUMN_WIDTH / 2.0;
let text_y = HEADER_HEIGHT / 2.0;
svg.push_str(&format!(
"<g class=\"cluster-label\" transform=\"translate({label_x}, {label_y})\">"
));
svg.push_str(&format!(
"<text x=\"{text_x}\" y=\"{text_y}\" \
text-anchor=\"middle\" dominant-baseline=\"central\" \
font-family=\"'trebuchet ms', verdana, arial, sans-serif\">{}</text>",
escape_xml(&col.title)
));
svg.push_str("</g>");
svg.push_str("</g>");
}
svg.push_str("</g>");
// === Items (task cards) ===
svg.push_str("<g class=\"items\">");
for (col_idx, col) in board.columns.iter().enumerate() {
let col_x = DIAGRAM_PADDING + col_idx as f64 * (COLUMN_WIDTH + COLUMN_GAP);
let col_y = DIAGRAM_PADDING;
let col_center_x = col_x + COLUMN_WIDTH / 2.0;
if col.tasks.is_empty() {
continue;
}
// First task center is HEADER_HEIGHT below column top, plus half the task height
let mut task_center_y = col_y + HEADER_HEIGHT + task_height(&col.tasks[0]) / 2.0;
for (task_idx, task) in col.tasks.iter().enumerate() {
if task_idx > 0 {
let prev_h = task_height(&col.tasks[task_idx - 1]);
let curr_h = task_height(task);
task_center_y += prev_h / 2.0 + TASK_GAP + curr_h / 2.0;
}
let t_h = task_height(task);
let half_w = TASK_WIDTH / 2.0;
let half_h = t_h / 2.0;
svg.push_str(&format!(
"<g class=\"node undefined\" id=\"{}\" \
transform=\"translate({col_center_x}, {task_center_y})\">",
escape_xml(&task.label)
));
// Card rect (centered at origin of the transform)
svg.push_str(&format!(
"<rect class=\"basic label-container\" style=\"\" rx=\"5\" ry=\"5\" \
x=\"{x}\" y=\"{y}\" width=\"{TASK_WIDTH}\" height=\"{t_h}\"/>",
x = -half_w,
y = -half_h,
));
// Task label — positioned as foreignObject
let label_tx = -half_w + TASK_INNER_PADDING;
let label_ty = if task.assigned.is_some() {
-half_h + 4.0 // 4px from top when assigned
} else {
-(TASK_TEXT_LINE_HEIGHT / 2.0)
};
emit_label_text(&mut svg, label_tx, label_ty, &task.label);
// Assigned/empty placeholders
match &task.assigned {
Some(assigned) => {
// Empty middle-left placeholder
emit_empty_label(&mut svg, label_tx, 0.0);
// Assigned name — bottom-right
let assigned_w = estimate_text_width(assigned);
let assigned_tx = half_w - TASK_INNER_PADDING - assigned_w;
emit_label_text(&mut svg, assigned_tx, 0.0, assigned);
}
None => {
// Two empty label placeholders (matches reference for non-assigned)
let ph_y = TASK_TEXT_LINE_HEIGHT / 2.0;
emit_empty_label(&mut svg, label_tx, ph_y);
let right_tx = half_w - TASK_INNER_PADDING;
emit_empty_label(&mut svg, right_tx, ph_y);
}
}
// Priority indicator line
if let Some(priority_color) = task.priority.as_deref().and_then(color_from_priority) {
let line_x = -half_w + PRIORITY_LINE_INSET_X;
let line_y1 = -half_h + PRIORITY_LINE_INSET_Y;
let line_y2 = half_h - PRIORITY_LINE_INSET_Y;
svg.push_str(&format!(
"<line x1=\"{line_x}\" y1=\"{line_y1}\" x2=\"{line_x}\" y2=\"{line_y2}\" \
stroke-width=\"{PRIORITY_LINE_STROKE_WIDTH}\" stroke=\"{priority_color}\"/>"
));
}
svg.push_str("</g>");
}
}
svg.push_str("</g>");
svg.push_str("</svg>");
Ok(svg)
}
/// Emit a native SVG `<text>` label inside `<g class="label">`.
/// Text is left-aligned (text-anchor="start") and vertically centred within
/// one line height so it sits in the same place the old foreignObject did.
fn emit_label_text(svg: &mut String, tx: f64, ty: f64, text: &str) {
let text_y = TASK_TEXT_LINE_HEIGHT / 2.0;
svg.push_str(&format!(
"<g class=\"label\" transform=\"translate({tx}, {ty})\">\
<text x=\"0\" y=\"{text_y}\" \
text-anchor=\"start\" dominant-baseline=\"central\" \
font-family=\"'trebuchet ms', verdana, arial, sans-serif\">\
{}</text></g>",
escape_xml(text),
));
}
/// Emit an empty placeholder `<g class="label">` (no visible content).
fn emit_empty_label(svg: &mut String, tx: f64, ty: f64) {
svg.push_str(&format!(
"<g class=\"label\" transform=\"translate({tx}, {ty})\"/>",
));
}
#[derive(Debug, Clone)]
struct KanbanBoard {
columns: Vec<KanbanColumn>,
}
#[derive(Debug, Clone)]
struct KanbanColumn {
title: String,
tasks: Vec<KanbanTask>,
}
#[derive(Debug, Clone)]
struct KanbanTask {
label: String,
assigned: Option<String>,
priority: Option<String>,
ticket: Option<String>,
}
fn parse_kanban(input: &str) -> Result<KanbanBoard, MermaidError> {
let lines = input.lines().enumerate();
let mut found_header = false;
let mut columns: Vec<KanbanColumn> = Vec::new();
let mut current_idx: Option<usize> = None;
for (idx, raw) in lines {
let line_no = idx + 1;
let line = raw.trim_end_matches(['\r', '\n']).to_string();
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("%%") {
continue;
}
if !found_header {
if trimmed.split_whitespace().next() != Some("kanban") {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected 'kanban' declaration".to_string(),
});
}
found_header = true;
continue;
}
let is_indented = raw.chars().next().is_some_and(|c| c.is_whitespace());
if !is_indented {
columns.push(KanbanColumn {
title: trimmed.to_string(),
tasks: Vec::new(),
});
current_idx = Some(columns.len() - 1);
continue;
}
let Some(cur) = current_idx else {
return Err(MermaidError::ParseError {
line: line_no,
message: "Task found before any kanban column".to_string(),
});
};
let task = parse_kanban_task(trimmed, line_no)?;
columns[cur].tasks.push(task);
}
if !found_header {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'kanban' declaration".to_string(),
});
}
Ok(KanbanBoard { columns })
}
fn task_height(task: &KanbanTask) -> f64 {
if task.assigned.is_some() {
TASK_HEIGHT_WITH_ASSIGNED
} else {
TASK_HEIGHT
}
}
fn tasks_stack_height(tasks: &[KanbanTask]) -> f64 {
let mut total = 0.0;
for (i, task) in tasks.iter().enumerate() {
if i > 0 {
total += TASK_GAP;
}
total += task_height(task);
}
total
}
fn parse_kanban_task(line: &str, line_no: usize) -> Result<KanbanTask, MermaidError> {
let (label, shape_data) = split_label_and_shape_data(line);
let mut task = KanbanTask {
label,
assigned: None,
priority: None,
ticket: None,
};
let Some(shape_data) = shape_data else {
return Ok(task);
};
apply_shape_data(&mut task, &shape_data, line_no)?;
Ok(task)
}
fn split_label_and_shape_data(line: &str) -> (String, Option<String>) {
let Some(start) = line.find("@{") else {
return (line.to_string(), None);
};
if !line.ends_with('}') {
return (line.to_string(), None);
}
let (label, rest) = line.split_at(start);
let shape_data = rest.strip_prefix("@{").unwrap_or(rest);
let shape_data = shape_data.strip_suffix('}').unwrap_or(shape_data);
(
label.trim_end().to_string(),
Some(shape_data.trim().to_string()),
)
}
fn apply_shape_data(
task: &mut KanbanTask,
shape_data: &str,
line_no: usize,
) -> Result<(), MermaidError> {
let yaml_data = if shape_data.contains('\n') {
format!("{shape_data}\n")
} else {
format!("{{\n{shape_data}\n}}")
};
let doc: Value = serde_yaml::from_str(&yaml_data).map_err(|e| MermaidError::ParseError {
line: line_no,
message: format!("Invalid kanban metadata: {e}"),
})?;
let Value::Mapping(map) = doc else {
return Err(MermaidError::ParseError {
line: line_no,
message: "Invalid kanban metadata: expected a YAML mapping".to_string(),
});
};
if let Some(label) = yaml_get_string(&map, "label") {
task.label = label;
}
if let Some(assigned) = yaml_get_string(&map, "assigned") {
task.assigned = Some(assigned);
}
if let Some(priority) = yaml_get_string(&map, "priority") {
task.priority = Some(priority);
}
if let Some(ticket) = yaml_get_string(&map, "ticket") {
task.ticket = Some(ticket);
}
Ok(())
}
fn yaml_get_string(map: &Mapping, key: &str) -> Option<String> {
let value = map.get(Value::String(key.to_string()))?;
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
fn color_from_priority(priority: &str) -> Option<&'static str> {
match priority {
"Very High" => Some("red"),
"High" => Some("orange"),
"Medium" => None,
"Low" => Some("blue"),
"Very Low" => Some("lightblue"),
_ => None,
}
}
/// Returns (hue, saturation, lightness) for a given section index.
/// Matches the Mermaid 11.12.2 CSS section color scheme.
fn section_hsl(idx: i32) -> (i32, f64, f64) {
let hues: [i32; 12] = [60, 80, 270, 300, 330, 0, 30, 90, 150, 180, 210, 240];
let wrapped = idx.rem_euclid(12);
let hue = hues[wrapped as usize];
if idx == 0 {
(hue, 100.0, 83.5294117647)
} else {
(hue, 100.0, 86.2745098039)
}
}
/// Returns the text fill color for a given section index.
fn section_text_color(idx: i32) -> &'static str {
// From the reference CSS: section-2 and section--1 use #ffffff; most others use black.
// Note: .cluster-label CSS overrides this for header text to #333.
match idx {
-1 | 2 => "#ffffff",
_ => "black",
}
}
/// Estimate text width in SVG units using a simple char-width heuristic.
/// Uses a slightly wider estimate (9px/char) to prevent clipping in foreignObject,
/// since the inner div's max-width CSS handles the real constraint.
fn estimate_text_width(text: &str) -> f64 {
let char_width = 9.5;
text.len() as f64 * char_width
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,373 @@
use std::collections::{HashMap, HashSet};
use dagre_rust::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::{Edge, Graph};
pub type DagreGraph = Graph<GraphConfig, GraphNode, GraphEdge>;
pub struct ExtractedCluster {
pub graph: DagreGraph,
pub children: HashMap<String, ExtractedCluster>,
}
#[derive(Debug, Clone)]
struct ClusterDbEntry {
anchor_id: String,
external_connections: bool,
}
#[derive(Debug, Default)]
struct AdjustState {
cluster_db: HashMap<String, ClusterDbEntry>,
descendants: HashMap<String, HashSet<String>>,
}
pub fn adjust_clusters_and_edges(graph: &mut DagreGraph) -> HashMap<String, ExtractedCluster> {
let mut state = AdjustState::default();
let mut parents: HashMap<String, String> = HashMap::new();
let nodes = graph.nodes();
for id in &nodes {
if graph.children(id).is_empty() {
continue;
}
let descendants = extract_descendants(id, graph, &mut parents);
state.descendants.insert(id.clone(), descendants);
let anchor_id = find_non_cluster_child(id, graph, id).unwrap_or_else(|| id.clone());
state.cluster_db.insert(
id.clone(),
ClusterDbEntry {
anchor_id,
external_connections: false,
},
);
}
let edges = graph.edges();
for id in &nodes {
if graph.children(id).is_empty() {
continue;
}
for edge in &edges {
let d1 = is_descendant(&edge.v, id, &state);
let d2 = is_descendant(&edge.w, id, &state);
if d1 != d2 {
if let Some(entry) = state.cluster_db.get_mut(id) {
entry.external_connections = true;
}
break;
}
}
}
let cluster_ids: Vec<String> = state.cluster_db.keys().cloned().collect();
for id in cluster_ids {
let Some(non_cluster_child) = state
.cluster_db
.get(&id)
.map(|entry| entry.anchor_id.clone())
else {
continue;
};
let Some(parent) = graph.parent(&non_cluster_child) else {
continue;
};
if parent == &id {
continue;
}
let Some(parent_entry) = state.cluster_db.get(parent) else {
continue;
};
if !parent_entry.external_connections {
if let Some(entry) = state.cluster_db.get_mut(&id) {
entry.anchor_id = parent.clone();
}
}
}
let edge_objs = graph.edges();
for edge_obj in edge_objs {
if !state.cluster_db.contains_key(&edge_obj.v)
&& !state.cluster_db.contains_key(&edge_obj.w)
{
continue;
}
let Some(edge_label) = graph.edge_with_obj(&edge_obj).cloned() else {
continue;
};
let v = get_anchor_id(&edge_obj.v, &state);
let w = get_anchor_id(&edge_obj.w, &state);
graph.remove_edge_with_obj(&edge_obj);
if v != edge_obj.v {
if let Some(parent) = graph.parent(&v) {
if let Some(entry) = state.cluster_db.get_mut(parent) {
entry.external_connections = true;
}
}
}
if w != edge_obj.w {
if let Some(parent) = graph.parent(&w) {
if let Some(entry) = state.cluster_db.get_mut(parent) {
entry.external_connections = true;
}
}
}
let _ = graph.set_edge(&v, &w, Some(edge_label), edge_obj.name.clone());
}
extractor(graph, &state, 0)
}
fn extract_descendants(
id: &String,
graph: &DagreGraph,
parents: &mut HashMap<String, String>,
) -> HashSet<String> {
let children = graph.children(id);
let mut res: HashSet<String> = children.iter().cloned().collect();
for child in children {
parents.insert(child.clone(), id.clone());
res.extend(extract_descendants(&child, graph, parents));
}
res
}
fn is_descendant(id: &String, ancestor_id: &String, state: &AdjustState) -> bool {
state
.descendants
.get(ancestor_id)
.is_some_and(|desc| desc.contains(id))
}
fn edge_in_cluster(edge: &Edge, cluster_id: &String, state: &AdjustState) -> bool {
if &edge.v == cluster_id || &edge.w == cluster_id {
return false;
}
let Some(cluster_descendants) = state.descendants.get(cluster_id) else {
return false;
};
cluster_descendants.contains(&edge.v) || cluster_descendants.contains(&edge.w)
}
fn find_common_edges(graph: &DagreGraph, id1: &String, id2: &String) -> Vec<(String, String)> {
let edges = graph.edges();
let edges1: Vec<&Edge> = edges
.iter()
.filter(|edge| &edge.v == id1 || &edge.w == id1)
.collect();
let edges2: Vec<&Edge> = edges
.iter()
.filter(|edge| &edge.v == id2 || &edge.w == id2)
.collect();
let edges1_prim: Vec<(String, String)> = edges1
.into_iter()
.map(|edge| {
let v = if &edge.v == id1 {
id2.clone()
} else {
edge.v.clone()
};
let w = if &edge.w == id1 {
id1.clone()
} else {
edge.w.clone()
};
(v, w)
})
.collect();
let edges2_prim: Vec<(String, String)> = edges2
.into_iter()
.map(|edge| (edge.v.clone(), edge.w.clone()))
.collect();
edges1_prim
.into_iter()
.filter(|(v, w)| edges2_prim.iter().any(|(v2, w2)| v == v2 && w == w2))
.collect()
}
fn find_non_cluster_child(id: &String, graph: &DagreGraph, cluster_id: &String) -> Option<String> {
let children = graph.children(id);
if children.is_empty() {
return Some(id.clone());
}
let mut reserve: Option<String> = None;
for child in children {
let Some(candidate) = find_non_cluster_child(&child, graph, cluster_id) else {
continue;
};
let candidate_id = candidate.clone();
let common_edges = find_common_edges(graph, cluster_id, &candidate_id);
if !common_edges.is_empty() {
reserve = Some(candidate);
} else {
return Some(candidate);
}
}
reserve
}
fn get_anchor_id(id: &String, state: &AdjustState) -> String {
let Some(entry) = state.cluster_db.get(id) else {
return id.clone();
};
if !entry.external_connections {
return id.clone();
}
entry.anchor_id.clone()
}
fn new_cluster_graph(rankdir: &str) -> DagreGraph {
let dir = if rankdir == "tb" { "lr" } else { "tb" };
let mut g: DagreGraph = Graph::new(Some(graphlib_rust::GraphOption {
directed: Some(true),
multigraph: Some(true),
compound: Some(true),
}));
g.set_graph(GraphConfig {
rankdir: Some(dir.to_string()),
nodesep: Some(50.0),
ranksep: Some(50.0),
marginx: Some(8.0),
marginy: Some(8.0),
..Default::default()
});
g
}
fn copy(
cluster_id: &String,
graph: &mut DagreGraph,
new_graph: &mut DagreGraph,
root_id: &String,
state: &AdjustState,
) {
let mut nodes = graph.children(cluster_id);
if cluster_id != root_id {
nodes.push(cluster_id.clone());
}
for node in nodes {
if !graph.children(&node).is_empty() {
copy(&node, graph, new_graph, root_id, state);
} else {
let Some(data) = graph.node(&node).cloned() else {
continue;
};
new_graph.set_node(node.clone(), Some(data));
if let Some(parent) = graph.parent(&node) {
if root_id != parent {
let _ = new_graph.set_parent(&node, Some(parent.clone()));
}
}
if cluster_id != root_id && node != *cluster_id {
let _ = new_graph.set_parent(&node, Some(cluster_id.clone()));
}
let edge_objs: Vec<Edge> = graph
.edges()
.into_iter()
.filter(|e| e.v == node || e.w == node)
.collect();
for edge_obj in edge_objs {
let Some(edge_label) = graph.edge_with_obj(&edge_obj).cloned() else {
continue;
};
if edge_in_cluster(&edge_obj, root_id, state) {
let _ = new_graph.set_edge(
&edge_obj.v,
&edge_obj.w,
Some(edge_label),
edge_obj.name.clone(),
);
}
}
}
graph.remove_node(&node);
}
}
fn extractor(
graph: &mut DagreGraph,
state: &AdjustState,
depth: usize,
) -> HashMap<String, ExtractedCluster> {
if depth > 10 {
return HashMap::new();
}
let nodes = graph.nodes();
if !nodes.iter().any(|node| !graph.children(node).is_empty()) {
return HashMap::new();
}
let mut extracted: HashMap<String, ExtractedCluster> = HashMap::new();
let rankdir = graph
.graph()
.rankdir
.clone()
.unwrap_or_else(|| "tb".to_string());
for node in nodes {
if graph.node(&node).is_none() {
continue;
}
if graph.children(&node).is_empty() {
continue;
}
let Some(entry) = state.cluster_db.get(&node) else {
continue;
};
if entry.external_connections {
continue;
}
let mut cluster_graph = new_cluster_graph(&rankdir);
copy(&node, graph, &mut cluster_graph, &node, state);
let children = extractor(&mut cluster_graph, state, depth + 1);
extracted.insert(
node.clone(),
ExtractedCluster {
graph: cluster_graph,
children,
},
);
}
extracted
}
@@ -0,0 +1,569 @@
use std::collections::HashMap;
use crate::ast::{EdgeStyle, FlowchartGraph, GraphDirection, NodeShape};
use crate::config::RenderConfig;
use crate::layout::{LayoutEdge, LayoutNode, LayoutResult, LayoutSubgraph};
use crate::text_wrap::{
measure_wrapped_lines_with_font_size, scale_char_width, wrap_text_lines, DEFAULT_CHAR_WIDTH,
DEFAULT_FONT_SIZE, DEFAULT_WRAP_WIDTH,
};
use dagre_rust::layout::layout as dagre_layout;
use dagre_rust::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::Graph;
use super::cluster_adjust::{adjust_clusters_and_edges, ExtractedCluster};
use super::{flow_data, flow_db};
const FLOWCHART_PADDING: f64 = 15.0;
const EDGE_LABEL_PADDING: f64 = 2.0;
const SUBGRAPH_PADDING: f64 = 8.0;
#[derive(Debug, Clone)]
struct NodeMeta {
label: String,
shape: NodeShape,
width: f64,
height: f64,
fill_color: Option<String>,
stroke_color: Option<String>,
is_group: bool,
title: Option<String>,
}
#[derive(Debug, Clone)]
struct EdgeMeta {
label: Option<String>,
style: EdgeStyle,
}
#[derive(Debug, Default)]
struct LocalLayout {
nodes: HashMap<String, LayoutNode>,
edges: Vec<LayoutEdge>,
subgraphs: Vec<LayoutSubgraph>,
width: f64,
height: f64,
}
#[derive(Debug, Clone, Copy)]
struct PortLayoutOptions {
node_spacing: f64,
rank_spacing: f64,
padding: f64,
wrapping_width: f64,
font_size: f64,
}
impl Default for PortLayoutOptions {
fn default() -> Self {
Self {
node_spacing: 50.0,
rank_spacing: 50.0,
padding: FLOWCHART_PADDING,
wrapping_width: DEFAULT_WRAP_WIDTH,
font_size: DEFAULT_FONT_SIZE,
}
}
}
impl PortLayoutOptions {
fn from_render_config(config: &RenderConfig) -> Self {
let default = Self::default();
Self {
node_spacing: config
.flowchart
.node_spacing
.map(f64::from)
.unwrap_or(default.node_spacing),
rank_spacing: config
.flowchart
.rank_spacing
.map(f64::from)
.unwrap_or(default.rank_spacing),
padding: config
.flowchart
.padding
.map(f64::from)
.unwrap_or(default.padding),
wrapping_width: config
.flowchart
.wrapping_width
.map(f64::from)
.unwrap_or(default.wrapping_width),
font_size: config.font_size_px().unwrap_or(default.font_size),
}
}
}
pub fn compute_layout_ported(flowchart: &FlowchartGraph) -> LayoutResult {
compute_layout_ported_with_config(flowchart, &RenderConfig::default())
}
pub fn compute_layout_ported_with_config(
flowchart: &FlowchartGraph,
config: &RenderConfig,
) -> LayoutResult {
let options = PortLayoutOptions::from_render_config(config);
let db = flow_db::from_flowchart_graph(flowchart);
let data = flow_data::get_data(&db);
let rankdir = match db.direction {
GraphDirection::TopToBottom => "tb",
GraphDirection::BottomToTop => "bt",
GraphDirection::LeftToRight => "lr",
GraphDirection::RightToLeft => "rl",
};
let mut node_meta: HashMap<String, NodeMeta> = HashMap::new();
for node in &data.nodes {
let (fill_color, stroke_color) =
node.styles.iter().fold((None, None), |mut acc, (k, v)| {
if k == "fill" {
acc.0 = Some(v.clone());
}
if k == "stroke" {
acc.1 = Some(v.clone());
}
acc
});
let (width, height) = if node.is_group {
(0.0, 0.0)
} else {
measure_node(&node.label, node.shape, &options)
};
node_meta.insert(
node.id.clone(),
NodeMeta {
label: node.label.clone(),
shape: node.shape,
width,
height,
fill_color,
stroke_color,
is_group: node.is_group,
title: if node.is_group {
Some(node.label.clone())
} else {
None
},
},
);
}
let mut edge_meta: HashMap<(String, String), EdgeMeta> = HashMap::new();
let mut g: Graph<GraphConfig, GraphNode, GraphEdge> =
Graph::new(Some(graphlib_rust::GraphOption {
directed: Some(true),
multigraph: Some(true),
compound: Some(true),
}));
g.set_graph(GraphConfig {
rankdir: Some(rankdir.to_string()),
nodesep: Some(options.node_spacing as f32),
ranksep: Some(options.rank_spacing as f32),
marginx: Some(8.0),
marginy: Some(8.0),
..Default::default()
});
for node in &data.nodes {
if node.is_group {
g.set_node(
node.id.clone(),
Some(GraphNode {
width: 0.0,
height: 0.0,
padding: Some(SUBGRAPH_PADDING as f32),
..Default::default()
}),
);
} else {
let Some(meta) = node_meta.get(&node.id) else {
continue;
};
g.set_node(
node.id.clone(),
Some(GraphNode {
width: meta.width as f32,
height: meta.height as f32,
..Default::default()
}),
);
}
if let Some(parent_id) = &node.parent_id {
let _ = g.set_parent(&node.id, Some(parent_id.clone()));
}
}
for edge in &data.edges {
let mut edge_label = GraphEdge {
labelpos: Some("c".to_string()),
..Default::default()
};
if let Some(label) = &edge.label {
if let Some((width, height)) = edge_label_dimensions(label, &options) {
edge_label.width = Some(width as f32);
edge_label.height = Some(height as f32);
}
}
edge_meta.insert(
(edge.start.clone(), edge.end.clone()),
EdgeMeta {
label: edge.label.clone(),
style: edge.style,
},
);
let _ = g.set_edge(&edge.start, &edge.end, Some(edge_label), None);
}
let mut extracted = adjust_clusters_and_edges(&mut g);
apply_options_to_extracted(&mut extracted, &options);
let layout = layout_recursive(&mut g, &mut extracted, &node_meta, &edge_meta);
LayoutResult {
nodes: layout.nodes,
edges: layout.edges,
subgraphs: layout.subgraphs,
width: layout.width,
height: layout.height,
}
}
fn apply_options_to_extracted(
extracted: &mut HashMap<String, ExtractedCluster>,
options: &PortLayoutOptions,
) {
for cluster in extracted.values_mut() {
let graph_config = cluster.graph.graph_mut();
graph_config.nodesep = Some(options.node_spacing as f32);
graph_config.ranksep = Some(options.rank_spacing as f32);
apply_options_to_extracted(&mut cluster.children, options);
}
}
fn layout_recursive(
graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
extracted: &mut HashMap<String, ExtractedCluster>,
node_meta: &HashMap<String, NodeMeta>,
edge_meta: &HashMap<(String, String), EdgeMeta>,
) -> LocalLayout {
let mut child_layouts: HashMap<String, LocalLayout> = HashMap::new();
for (cluster_id, cluster) in extracted.iter_mut() {
let layout = layout_recursive(
&mut cluster.graph,
&mut cluster.children,
node_meta,
edge_meta,
);
if let Some(node) = graph.node_mut(cluster_id) {
node.width = layout.width as f32;
node.height = layout.height as f32;
}
child_layouts.insert(cluster_id.clone(), layout);
}
dagre_layout(graph);
let mut layout = extract_local_layout(graph, node_meta, edge_meta);
for (cluster_id, mut child_layout) in child_layouts {
let Some(cluster_node) = graph.node(&cluster_id) else {
continue;
};
let dx = cluster_node.x as f64 - cluster_node.width as f64 / 2.0;
let dy = cluster_node.y as f64 - cluster_node.height as f64 / 2.0;
shift_layout(&mut child_layout, dx, dy);
for (id, node) in child_layout.nodes {
layout.nodes.insert(id, node);
}
layout.edges.extend(child_layout.edges);
layout.subgraphs.extend(child_layout.subgraphs);
}
layout
}
fn extract_local_layout(
graph: &Graph<GraphConfig, GraphNode, GraphEdge>,
node_meta: &HashMap<String, NodeMeta>,
edge_meta: &HashMap<(String, String), EdgeMeta>,
) -> LocalLayout {
let mut nodes: HashMap<String, LayoutNode> = HashMap::new();
let mut subgraphs: Vec<LayoutSubgraph> = Vec::new();
for node_id in graph.nodes() {
let Some(meta) = node_meta.get(&node_id) else {
continue;
};
let Some(node) = graph.node(&node_id) else {
continue;
};
if meta.is_group {
let width = node.width as f64;
let height = node.height as f64;
if width <= 0.0 || height <= 0.0 {
continue;
}
subgraphs.push(LayoutSubgraph {
id: node_id.clone(),
title: meta.title.clone(),
x: node.x as f64 - width / 2.0,
y: node.y as f64 - height / 2.0,
width,
height,
});
} else {
nodes.insert(
node_id.clone(),
LayoutNode {
id: node_id.clone(),
x: node.x as f64,
y: node.y as f64,
width: meta.width,
height: meta.height,
shape: meta.shape,
label: meta.label.clone(),
fill_color: meta.fill_color.clone(),
stroke_color: meta.stroke_color.clone(),
},
);
}
}
let mut edges: Vec<LayoutEdge> = Vec::new();
for edge_obj in graph.edges() {
let Some(edge_label) = graph.edge_with_obj(&edge_obj) else {
continue;
};
let Some(meta) = edge_meta.get(&(edge_obj.v.clone(), edge_obj.w.clone())) else {
continue;
};
let points: Vec<(f64, f64)> = edge_label
.points
.as_ref()
.map(|pts| pts.iter().map(|p| (p.x as f64, p.y as f64)).collect())
.unwrap_or_default();
let label_pos = meta.label.as_ref().and_then(|label| {
if label.trim().is_empty() {
None
} else if edge_label.width.unwrap_or(0.0) > 0.0
|| edge_label.height.unwrap_or(0.0) > 0.0
{
Some((edge_label.x as f64, edge_label.y as f64))
} else {
None
}
});
edges.push(LayoutEdge {
from: edge_obj.v,
to: edge_obj.w,
label: meta.label.clone(),
style: meta.style,
points,
label_pos,
});
}
let graph_width = graph.graph().width as f64;
let graph_height = graph.graph().height as f64;
LocalLayout {
nodes,
edges,
subgraphs,
width: graph_width,
height: graph_height,
}
}
fn shift_layout(layout: &mut LocalLayout, dx: f64, dy: f64) {
for node in layout.nodes.values_mut() {
node.x += dx;
node.y += dy;
}
for edge in &mut layout.edges {
for point in &mut edge.points {
point.0 += dx;
point.1 += dy;
}
if let Some((x, y)) = edge.label_pos.as_mut() {
*x += dx;
*y += dy;
}
}
for sg in &mut layout.subgraphs {
sg.x += dx;
sg.y += dy;
}
}
fn measure_node(label: &str, shape: NodeShape, options: &PortLayoutOptions) -> (f64, f64) {
let char_width = scale_char_width(DEFAULT_CHAR_WIDTH, options.font_size);
let lines = wrap_text_lines(label, options.wrapping_width, char_width);
let (text_width, text_height) =
measure_wrapped_lines_with_font_size(&lines, char_width, options.font_size);
let padding = options.padding;
match shape {
NodeShape::Rectangle => (text_width + padding * 4.0, text_height + padding * 2.0),
NodeShape::RoundedRectangle => (text_width + padding * 2.0, text_height + padding * 2.0),
NodeShape::Subroutine => {
let w = text_width + padding;
let h = text_height + padding;
(w + 16.0, h)
}
NodeShape::Asymmetric => {
let w = text_width + padding;
let h = text_height + padding;
(w + h / 4.0, h)
}
NodeShape::Hexagon => {
let h = text_height + padding;
let w = text_width + padding * 2.5;
(w * 7.0 / 6.0, h)
}
NodeShape::Diamond => {
let w = text_width + padding;
let h = text_height + padding;
let s = w + h;
(s, s)
}
NodeShape::Circle => {
let diameter = text_width + padding;
(diameter, diameter)
}
NodeShape::StartState => (14.0, 14.0),
NodeShape::EndState => (20.0, 20.0),
NodeShape::ForkJoin => (70.0, 10.0),
NodeShape::Stadium => {
let h = text_height + padding;
let w = text_width + h / 4.0 + padding;
(w, h)
}
NodeShape::Cylinder => {
let w = text_width + padding;
let rx = w / 2.0;
let ry = rx / (2.5 + w / 50.0);
let h = text_height + ry + padding;
(w, h + 2.0 * ry)
}
}
}
fn edge_label_dimensions(label: &str, options: &PortLayoutOptions) -> Option<(f64, f64)> {
if label.trim().is_empty() {
return None;
}
let char_width = scale_char_width(DEFAULT_CHAR_WIDTH, options.font_size);
let lines = wrap_text_lines(label, options.wrapping_width, char_width);
if lines.is_empty() {
return None;
}
let (text_width, text_height) =
measure_wrapped_lines_with_font_size(&lines, char_width, options.font_size);
let width = text_width + EDGE_LABEL_PADDING * 2.0;
let height = text_height + EDGE_LABEL_PADDING * 2.0;
Some((width, height))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::{Edge, FlowchartGraph, GraphDirection, Statement};
use crate::config::FlowchartConfig;
#[test]
fn ported_layout_uses_spacing_config() {
let graph = FlowchartGraph {
direction: GraphDirection::TopToBottom,
statements: vec![Statement::Edge(Edge {
from: "A".to_string(),
to: "B".to_string(),
label: None,
style: EdgeStyle::Arrow,
})],
};
let default_layout = compute_layout_ported(&graph);
let config = RenderConfig {
flowchart: FlowchartConfig {
rank_spacing: Some(140),
..Default::default()
},
..Default::default()
};
let configured_layout = compute_layout_ported_with_config(&graph, &config);
let default_delta = default_layout.nodes["B"].y - default_layout.nodes["A"].y;
let configured_delta = configured_layout.nodes["B"].y - configured_layout.nodes["A"].y;
assert!(configured_delta > default_delta);
}
#[test]
fn ported_layout_uses_padding_and_wrapping_config() {
let default = PortLayoutOptions::default();
let config = RenderConfig {
flowchart: FlowchartConfig {
padding: Some(4),
wrapping_width: Some(70),
..Default::default()
},
..Default::default()
};
let configured = PortLayoutOptions::from_render_config(&config);
let default_size = measure_node(
"Long label that wraps across several rendered lines",
NodeShape::Rectangle,
&default,
);
let configured_size = measure_node(
"Long label that wraps across several rendered lines",
NodeShape::Rectangle,
&configured,
);
assert!(configured_size.1 > default_size.1);
assert!(configured_size.0 < default_size.0);
}
#[test]
fn ported_layout_uses_font_size_config() {
let default = PortLayoutOptions::default();
let config = RenderConfig {
font_size: Some("32px".to_string()),
..Default::default()
};
let configured = PortLayoutOptions::from_render_config(&config);
let default_size = measure_node("Font", NodeShape::Rectangle, &default);
let configured_size = measure_node("Font", NodeShape::Rectangle, &configured);
let (default_label_width, default_label_height) =
edge_label_dimensions("Edge", &default).expect("label should have dimensions");
let (configured_label_width, configured_label_height) =
edge_label_dimensions("Edge", &configured).expect("label should have dimensions");
assert!(configured_size.0 > default_size.0);
assert!(configured_size.1 > default_size.1);
assert!(configured_label_width > default_label_width);
assert!(configured_label_height > default_label_height);
}
}
@@ -0,0 +1,75 @@
use crate::ast::{EdgeStyle, NodeShape};
use super::flow_db::{FlowDb, FlowEdge, FlowVertex};
#[derive(Debug, Clone)]
pub struct FlowData {
pub nodes: Vec<FlowDataNode>,
pub edges: Vec<FlowDataEdge>,
}
#[derive(Debug, Clone)]
pub struct FlowDataNode {
pub id: String,
pub label: String,
pub shape: NodeShape,
pub parent_id: Option<String>,
pub styles: Vec<(String, String)>,
pub is_group: bool,
}
#[derive(Debug, Clone)]
pub struct FlowDataEdge {
pub start: String,
pub end: String,
pub label: Option<String>,
pub style: EdgeStyle,
}
pub fn get_data(db: &FlowDb) -> FlowData {
let mut nodes: Vec<FlowDataNode> = Vec::new();
for sg in db.subgraphs.iter().rev() {
nodes.push(FlowDataNode {
id: sg.id.clone(),
label: sg.title.clone().unwrap_or_else(|| sg.id.clone()),
shape: NodeShape::Rectangle,
parent_id: sg.parent_id.clone(),
styles: Vec::new(),
is_group: true,
});
}
for id in &db.vertex_order {
if let Some(v) = db.vertices.get(id) {
nodes.push(make_node_data(db, v));
}
}
let edges: Vec<FlowDataEdge> = db.edges.iter().map(make_edge_data).collect();
FlowData { nodes, edges }
}
fn make_node_data(db: &FlowDb, v: &FlowVertex) -> FlowDataNode {
let parent_id = db.node_to_subgraph.get(&v.id).cloned();
let styles = db.node_styles.get(&v.id).cloned().unwrap_or_default();
FlowDataNode {
id: v.id.clone(),
label: v.label.clone(),
shape: v.shape,
parent_id,
styles,
is_group: false,
}
}
fn make_edge_data(e: &FlowEdge) -> FlowDataEdge {
FlowDataEdge {
start: e.start.clone(),
end: e.end.clone(),
label: e.label.clone(),
style: e.style,
}
}
+131
View File
@@ -0,0 +1,131 @@
use std::collections::HashMap;
use crate::ast::{EdgeStyle, FlowchartGraph, GraphDirection, NodeShape, Statement};
#[derive(Debug, Clone)]
pub struct FlowDb {
pub direction: GraphDirection,
pub vertices: HashMap<String, FlowVertex>,
pub vertex_order: Vec<String>,
pub edges: Vec<FlowEdge>,
pub subgraphs: Vec<FlowSubgraph>,
pub node_to_subgraph: HashMap<String, String>,
pub node_styles: HashMap<String, Vec<(String, String)>>,
}
#[derive(Debug, Clone)]
pub struct FlowVertex {
pub id: String,
pub label: String,
pub shape: NodeShape,
}
#[derive(Debug, Clone)]
pub struct FlowEdge {
pub start: String,
pub end: String,
pub label: Option<String>,
pub style: EdgeStyle,
}
#[derive(Debug, Clone)]
pub struct FlowSubgraph {
pub id: String,
pub title: Option<String>,
pub parent_id: Option<String>,
}
pub fn from_flowchart_graph(graph: &FlowchartGraph) -> FlowDb {
let mut db = FlowDb {
direction: graph.direction,
vertices: HashMap::new(),
vertex_order: Vec::new(),
edges: Vec::new(),
subgraphs: Vec::new(),
node_to_subgraph: HashMap::new(),
node_styles: HashMap::new(),
};
collect_statements(&mut db, &graph.statements, None);
db
}
fn collect_statements(db: &mut FlowDb, statements: &[Statement], current_subgraph: Option<&str>) {
for stmt in statements {
match stmt {
Statement::Node(node) => {
ensure_vertex(db, &node.id, node.label.as_deref(), node.shape);
maybe_assign_to_subgraph(db, &node.id, current_subgraph);
}
Statement::Edge(edge) => {
ensure_vertex(db, &edge.from, None, NodeShape::Rectangle);
ensure_vertex(db, &edge.to, None, NodeShape::Rectangle);
maybe_assign_to_subgraph(db, &edge.from, current_subgraph);
maybe_assign_to_subgraph(db, &edge.to, current_subgraph);
db.edges.push(FlowEdge {
start: edge.from.clone(),
end: edge.to.clone(),
label: edge.label.clone(),
style: edge.style,
});
}
Statement::Subgraph(subgraph) => {
collect_statements(db, &subgraph.statements, Some(&subgraph.id));
db.subgraphs.push(FlowSubgraph {
id: subgraph.id.clone(),
title: subgraph.title.clone().or_else(|| Some(subgraph.id.clone())),
parent_id: current_subgraph.map(|s| s.to_string()),
});
}
Statement::Style(style) => {
ensure_vertex(db, &style.node_id, None, NodeShape::Rectangle);
maybe_assign_to_subgraph(db, &style.node_id, current_subgraph);
db.node_styles
.entry(style.node_id.clone())
.or_default()
.extend(style.properties.iter().cloned());
}
}
}
}
fn ensure_vertex(db: &mut FlowDb, id: &str, label: Option<&str>, shape: NodeShape) {
if is_subgraph_id(db, id) {
return;
}
let id = id.to_string();
match db.vertices.get_mut(&id) {
Some(v) => {
if let Some(label) = label {
v.label = label.to_string();
v.shape = shape;
}
}
None => {
let label = label.unwrap_or(id.as_str()).to_string();
db.vertex_order.push(id.clone());
db.vertices
.insert(id.clone(), FlowVertex { id, label, shape });
}
}
}
fn maybe_assign_to_subgraph(db: &mut FlowDb, node_id: &str, current_subgraph: Option<&str>) {
if is_subgraph_id(db, node_id) {
return;
}
let Some(subgraph_id) = current_subgraph else {
return;
};
db.node_to_subgraph
.entry(node_id.to_string())
.or_insert_with(|| subgraph_id.to_string());
}
fn is_subgraph_id(db: &FlowDb, id: &str) -> bool {
db.subgraphs.iter().any(|subgraph| subgraph.id == id)
}
@@ -0,0 +1,6 @@
use crate::ast::FlowchartGraph;
use crate::error::MermaidError;
pub fn parse_flowchart(mermaid_source: &str) -> Result<FlowchartGraph, MermaidError> {
crate::parser::parse_mermaid(mermaid_source)
}
+40
View File
@@ -0,0 +1,40 @@
mod cluster_adjust;
mod dagre_layout_port;
mod flow_data;
mod flow_db;
mod flow_parser;
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
use crate::RenderConfig;
pub fn render_mermaid_to_svg_ported(
mermaid_source: &str,
theme: &MermaidTheme,
config: &RenderConfig,
) -> Result<String, MermaidError> {
let graph = flow_parser::parse_flowchart(mermaid_source)?;
let layout_result = dagre_layout_port::compute_layout_ported_with_config(&graph, config);
Ok(crate::svg_renderer::render_with_config(
&layout_result,
theme,
config,
))
}
// HERMETIC VENDORING PATCH: the experimental dagre flowchart "port" is disabled
// unconditionally. Upstream gated it on the `MERMAID_TO_SVG_USE_PORT` env var;
// reading the environment makes rendering non-deterministic over untrusted
// input, and the port mis-routes back-edges on cyclic flowcharts (detached
// arrowheads) — the exact defect this engine was adopted to fix. The default
// `layout::compute_layout` path routes cycles correctly.
pub fn is_enabled() -> bool {
false
}
#[allow(dead_code)]
pub(crate) fn compute_layout_ported(
flowchart: &crate::ast::FlowchartGraph,
) -> crate::layout::LayoutResult {
dagre_layout_port::compute_layout_ported(flowchart)
}
+670
View File
@@ -0,0 +1,670 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
/// Node type in the mindmap, following Mermaid 11.12.2 nodeType enum.
#[derive(Debug, Clone, Copy, PartialEq)]
enum MindmapNodeType {
Default, // no-border — "rounded" shape
Rect, // [text]
RoundedRect, // (text)
Circle, // ((text))
#[allow(dead_code)]
Cloud, // )text(
Bang, // ))text((
Hexagon, // {{text}}
}
/// A node in the mindmap tree.
#[derive(Debug, Clone)]
struct MindmapNode {
id: String,
label: String,
node_type: MindmapNodeType,
children: Vec<MindmapNode>,
section: Option<usize>,
}
/// Colors for a section (branch) of the mindmap.
struct SectionColors {
fill: &'static str,
text: &'static str,
edge: &'static str,
}
/// Root fill: hsl(240, 100%, 46.27%) from reference SVG.
const ROOT_FILL: &str = "hsl(240, 100%, 46.27%)";
const ROOT_TEXT: &str = "#ffffff";
/// Mermaid 11.12.2 default theme section colors, extracted from reference SVG.
/// Each section-N fill is hsl(H, 100%, ~73-76%).
const SECTION_COLORS: &[SectionColors] = &[
// Section 0: hsl(60, 100%, 73.53%) — yellow
SectionColors {
fill: "hsl(60, 100%, 73.53%)",
text: "black",
edge: "hsl(60, 100%, 73.53%)",
},
// Section 1: hsl(80, 100%, 76.27%) — yellow-green
SectionColors {
fill: "hsl(80, 100%, 76.27%)",
text: "black",
edge: "hsl(80, 100%, 76.27%)",
},
// Section 2: hsl(270, 100%, 76.27%) — purple
SectionColors {
fill: "hsl(270, 100%, 76.27%)",
text: "#ffffff",
edge: "hsl(270, 100%, 76.27%)",
},
// Section 3: hsl(300, 100%, 76.27%) — magenta
SectionColors {
fill: "hsl(300, 100%, 76.27%)",
text: "black",
edge: "hsl(300, 100%, 76.27%)",
},
// Section 4: hsl(330, 100%, 76.27%) — pink
SectionColors {
fill: "hsl(330, 100%, 76.27%)",
text: "black",
edge: "hsl(330, 100%, 76.27%)",
},
// Section 5: hsl(0, 100%, 76.27%) — red
SectionColors {
fill: "hsl(0, 100%, 76.27%)",
text: "black",
edge: "hsl(0, 100%, 76.27%)",
},
// Section 6: hsl(30, 100%, 76.27%) — orange
SectionColors {
fill: "hsl(30, 100%, 76.27%)",
text: "black",
edge: "hsl(30, 100%, 76.27%)",
},
// Section 7: hsl(90, 100%, 76.27%) — lime
SectionColors {
fill: "hsl(90, 100%, 76.27%)",
text: "black",
edge: "hsl(90, 100%, 76.27%)",
},
];
fn section_color(section: usize) -> &'static SectionColors {
&SECTION_COLORS[section % SECTION_COLORS.len()]
}
/// Layout result for a placed node.
#[derive(Debug, Clone)]
struct PlacedNode {
#[allow(dead_code)]
id: String,
label: String,
node_type: MindmapNodeType,
x: f64,
y: f64,
width: f64,
height: f64,
section: Option<usize>,
is_root: bool,
}
/// Layout result for an edge.
#[derive(Debug, Clone)]
struct PlacedEdge {
from_x: f64,
from_y: f64,
to_x: f64,
to_y: f64,
section: Option<usize>,
depth: usize,
}
// --- Parsing ---
fn parse_mindmap_tree(input: &str) -> Result<MindmapNode, MermaidError> {
let lines: Vec<&str> = input.lines().collect();
let mut i = 0_usize;
// Find "mindmap" declaration
while i < lines.len() {
let line = lines[i].trim();
if line.is_empty() || line.starts_with("%%") {
i += 1;
continue;
}
if line.split_whitespace().next() == Some("mindmap") {
i += 1;
break;
}
return Err(MermaidError::ParseError {
line: i + 1,
message: "Expected 'mindmap' declaration".to_string(),
});
}
let mut stack: Vec<(usize, MindmapNode)> = Vec::new();
let mut next_id = 0_usize;
while i < lines.len() {
let raw = lines[i];
i += 1;
if raw.trim().is_empty() || raw.trim_start().starts_with("%%") {
continue;
}
let indent = raw.chars().take_while(|c| *c == ' ' || *c == '\t').count();
let text = raw.trim();
// Skip decoration lines like ::icon(...)
if text.starts_with("::") {
continue;
}
let (label, node_type) = extract_label_and_type(text);
let node_id = format!("n{next_id}");
next_id += 1;
let is_root = stack.is_empty();
// Pop stack entries with indent >= current
while let Some((d, _)) = stack.last() {
if *d >= indent {
let (_, child) = stack.pop().unwrap();
if let Some((_, parent)) = stack.last_mut() {
parent.children.push(child);
} else {
// This was the root — re-push it
stack.push((indent, child));
break;
}
} else {
break;
}
}
let node = MindmapNode {
id: node_id,
label,
node_type: if is_root && node_type == MindmapNodeType::Default {
MindmapNodeType::Circle
} else {
node_type
},
children: Vec::new(),
section: None,
};
stack.push((indent, node));
}
// Collapse stack to get root
while stack.len() > 1 {
let (_, child) = stack.pop().unwrap();
if let Some((_, parent)) = stack.last_mut() {
parent.children.push(child);
}
}
stack.pop().map(|(_, n)| n).ok_or(MermaidError::ParseError {
line: 0,
message: "No nodes found in mindmap".to_string(),
})
}
fn extract_label_and_type(text: &str) -> (String, MindmapNodeType) {
let t = text.trim();
// (( ... )) → Circle
if let Some(start) = t.find("((") {
if t.ends_with("))") && start + 2 < t.len().saturating_sub(2) {
let label = t[start + 2..t.len() - 2].trim().to_string();
return (label, MindmapNodeType::Circle);
}
}
// {{ ... }} → Hexagon
if let Some(start) = t.find("{{") {
if t.ends_with("}}") && start + 2 < t.len().saturating_sub(2) {
let label = t[start + 2..t.len() - 2].trim().to_string();
return (label, MindmapNodeType::Hexagon);
}
}
// )) ... (( → Bang
if let Some(start) = t.find("))") {
if t.ends_with("((") && start + 2 < t.len().saturating_sub(2) {
let label = t[start + 2..t.len() - 2].trim().to_string();
return (label, MindmapNodeType::Bang);
}
}
// [ ... ] → Rect
if let Some(start) = t.find('[') {
if t.ends_with(']') && start + 1 < t.len().saturating_sub(1) {
let label = t[start + 1..t.len() - 1].trim().to_string();
return (label, MindmapNodeType::Rect);
}
}
// ( ... ) → RoundedRect
if let Some(start) = t.find('(') {
if t.ends_with(')') && start + 1 < t.len().saturating_sub(1) {
let label = t[start + 1..t.len() - 1].trim().to_string();
return (label, MindmapNodeType::RoundedRect);
}
}
// Default (no delimiter)
(t.to_string(), MindmapNodeType::Default)
}
// --- Section assignment ---
fn assign_sections(node: &mut MindmapNode, section: Option<usize>) {
node.section = section;
for (i, child) in node.children.iter_mut().enumerate() {
let child_section = if section.is_none() {
// Direct children of root get their own section number
Some(i)
} else {
section
};
assign_sections(child, child_section);
}
}
// --- Node sizing ---
const FONT_SIZE: f64 = 16.0;
const NODE_PADDING: f64 = 15.0;
const ROOT_PADDING: f64 = 20.0;
/// Estimate text width using display-width units × average character width.
/// We use this instead of `text_wrap::line_width` because in sandbox/CI
/// environments the font measurer may return 0 (no real fonts loaded).
fn estimate_text_width(text: &str) -> f64 {
// Average character width for 16px Trebuchet MS is roughly 8.5px
let avg_char_width = 8.5;
crate::text_wrap::display_width_units(text) * avg_char_width
}
fn measure_node(node: &MindmapNode) -> (f64, f64) {
let text_width = estimate_text_width(&node.label);
let text_height = FONT_SIZE;
match node.node_type {
MindmapNodeType::Circle => {
let diameter = (text_width.max(text_height) + ROOT_PADDING * 2.0).max(60.0);
(diameter, diameter)
}
MindmapNodeType::Rect | MindmapNodeType::RoundedRect | MindmapNodeType::Default => {
let w = text_width + NODE_PADDING * 2.0;
let h = text_height + NODE_PADDING * 2.0;
(w.max(40.0), h.max(36.0))
}
MindmapNodeType::Hexagon => {
let w = text_width + NODE_PADDING * 3.0;
let h = text_height + NODE_PADDING * 2.0;
(w.max(50.0), h.max(40.0))
}
MindmapNodeType::Cloud | MindmapNodeType::Bang => {
let w = text_width + NODE_PADDING * 2.5;
let h = text_height + NODE_PADDING * 2.5;
(w.max(50.0), h.max(40.0))
}
}
}
// --- Layout ---
/// Simple radial mindmap layout.
/// Root is placed at center. Children of root are distributed radially.
/// Deeper nodes extend outward from their parent.
fn layout_mindmap(root: &MindmapNode) -> (Vec<PlacedNode>, Vec<PlacedEdge>) {
let mut placed_nodes = Vec::new();
let mut placed_edges = Vec::new();
let (root_w, root_h) = measure_node(root);
// Place root at origin (will be shifted later)
placed_nodes.push(PlacedNode {
id: root.id.clone(),
label: root.label.clone(),
node_type: root.node_type,
x: 0.0,
y: 0.0,
width: root_w,
height: root_h,
section: root.section,
is_root: true,
});
let n_children = root.children.len();
if n_children == 0 {
return (placed_nodes, placed_edges);
}
// Calculate total subtree "weight" for each branch
let weights: Vec<f64> = root.children.iter().map(subtree_weight).collect();
let total_weight: f64 = weights.iter().sum();
// Distribute branches around the root
let start_angle: f64 = -std::f64::consts::FRAC_PI_2; // top
let mut current_angle = start_angle;
let base_radius = 120.0 + (n_children as f64) * 20.0;
for (i, child) in root.children.iter().enumerate() {
let weight_fraction = weights[i] / total_weight;
let sweep = std::f64::consts::TAU * weight_fraction;
let mid_angle = current_angle + sweep / 2.0;
layout_subtree(
child,
0.0,
0.0,
mid_angle,
base_radius,
1,
&mut placed_nodes,
&mut placed_edges,
);
current_angle += sweep;
}
// Normalize positions
let padding = 20.0;
let min_x = placed_nodes
.iter()
.map(|n| n.x - n.width / 2.0)
.fold(f64::INFINITY, f64::min);
let min_y = placed_nodes
.iter()
.map(|n| n.y - n.height / 2.0)
.fold(f64::INFINITY, f64::min);
let shift_x = -min_x + padding;
let shift_y = -min_y + padding;
for node in &mut placed_nodes {
node.x += shift_x;
node.y += shift_y;
}
for edge in &mut placed_edges {
edge.from_x += shift_x;
edge.from_y += shift_y;
edge.to_x += shift_x;
edge.to_y += shift_y;
}
(placed_nodes, placed_edges)
}
fn subtree_weight(node: &MindmapNode) -> f64 {
if node.children.is_empty() {
return 1.0;
}
let child_weight: f64 = node.children.iter().map(subtree_weight).sum();
child_weight.max(1.0)
}
#[allow(clippy::too_many_arguments)]
fn layout_subtree(
node: &MindmapNode,
parent_x: f64,
parent_y: f64,
angle: f64,
radius: f64,
depth: usize,
placed_nodes: &mut Vec<PlacedNode>,
placed_edges: &mut Vec<PlacedEdge>,
) {
let (node_w, node_h) = measure_node(node);
let x = parent_x + angle.cos() * radius;
let y = parent_y + angle.sin() * radius;
placed_nodes.push(PlacedNode {
id: node.id.clone(),
label: node.label.clone(),
node_type: node.node_type,
x,
y,
width: node_w,
height: node_h,
section: node.section,
is_root: false,
});
placed_edges.push(PlacedEdge {
from_x: parent_x,
from_y: parent_y,
to_x: x,
to_y: y,
section: node.section,
depth,
});
let n_children = node.children.len();
if n_children == 0 {
return;
}
let weights: Vec<f64> = node.children.iter().map(subtree_weight).collect();
let total_weight: f64 = weights.iter().sum();
// Fan out children around the parent→child direction
let fan_spread = std::f64::consts::FRAC_PI_2.min(0.8 * (n_children as f64).sqrt());
let child_radius = 100.0 + (depth as f64) * 10.0;
let mut current_angle = angle - fan_spread / 2.0;
for (i, child) in node.children.iter().enumerate() {
let weight_fraction = weights[i] / total_weight;
let sweep = fan_spread * weight_fraction;
let mid_angle = current_angle + sweep / 2.0;
layout_subtree(
child,
x,
y,
mid_angle,
child_radius,
depth + 1,
placed_nodes,
placed_edges,
);
current_angle += sweep;
}
}
// --- SVG Rendering ---
pub fn render_mindmap_to_svg(input: &str, _theme: &MermaidTheme) -> Result<String, MermaidError> {
let mut root = parse_mindmap_tree(input)?;
assign_sections(&mut root, None);
let (placed_nodes, placed_edges) = layout_mindmap(&root);
let max_x = placed_nodes
.iter()
.map(|n| n.x + n.width / 2.0)
.fold(0.0_f64, f64::max);
let max_y = placed_nodes
.iter()
.map(|n| n.y + n.height / 2.0)
.fold(0.0_f64, f64::max);
let padding = 20.0;
let svg_width = max_x + padding;
let svg_height = max_y + padding;
let mut svg = String::new();
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" \
width=\"{}\" height=\"{}\" \
viewBox=\"0 0 {} {}\" \
aria-roledescription=\"mindmap\" \
role=\"graphics-document document\" \
style=\"max-width: 100%;\">",
svg_width, svg_height, svg_width, svg_height,
));
// Render edges first (behind nodes)
for edge in &placed_edges {
render_edge(&mut svg, edge);
}
// Render nodes
for node in &placed_nodes {
render_node(&mut svg, node);
}
svg.push_str("</svg>");
Ok(svg)
}
fn render_edge(svg: &mut String, edge: &PlacedEdge) {
let color = match edge.section {
Some(s) => section_color(s).edge,
None => "#333333",
};
// Stroke width based on depth: 17 - 3*depth, minimum 2
let stroke_width = (17.0 - 3.0 * edge.depth as f64).max(2.0);
// Curved edge using quadratic bezier
let mx = (edge.from_x + edge.to_x) / 2.0;
let my = (edge.from_y + edge.to_y) / 2.0;
svg.push_str(&format!(
"<path d=\"M {:.1},{:.1} Q {:.1},{:.1} {:.1},{:.1}\" \
fill=\"none\" stroke=\"{}\" stroke-width=\"{:.1}\" \
stroke-linecap=\"round\" />",
edge.from_x, edge.from_y, mx, my, edge.to_x, edge.to_y, color, stroke_width,
));
}
fn render_node(svg: &mut String, node: &PlacedNode) {
let (fill, text_color) = if node.is_root {
(ROOT_FILL.to_string(), ROOT_TEXT.to_string())
} else {
match node.section {
Some(s) => {
let sc = section_color(s);
(sc.fill.to_string(), sc.text.to_string())
}
None => ("#ECECFF".to_string(), "#333333".to_string()),
}
};
let cx = node.x;
let cy = node.y;
match node.node_type {
MindmapNodeType::Circle => {
let r = node.width / 2.0;
svg.push_str(&format!(
"<circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"{:.1}\" \
fill=\"{}\" stroke=\"none\" />",
cx, cy, r, fill,
));
}
MindmapNodeType::Rect => {
let x = cx - node.width / 2.0;
let y = cy - node.height / 2.0;
svg.push_str(&format!(
"<rect x=\"{:.1}\" y=\"{:.1}\" width=\"{:.1}\" height=\"{:.1}\" \
rx=\"0\" ry=\"0\" fill=\"{}\" stroke=\"none\" />",
x, y, node.width, node.height, fill,
));
}
MindmapNodeType::RoundedRect | MindmapNodeType::Default => {
let x = cx - node.width / 2.0;
let y = cy - node.height / 2.0;
// Corner radius = 5 matching Mermaid reference SVG path data
svg.push_str(&format!(
"<rect x=\"{:.1}\" y=\"{:.1}\" width=\"{:.1}\" height=\"{:.1}\" \
rx=\"5\" ry=\"5\" fill=\"{}\" stroke=\"none\" />",
x, y, node.width, node.height, fill,
));
}
MindmapNodeType::Hexagon => {
let x = cx - node.width / 2.0;
let y = cy - node.height / 2.0;
let inset = node.height / 4.0;
let points = format!(
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
x + inset,
y,
x + node.width - inset,
y,
x + node.width,
cy,
x + node.width - inset,
y + node.height,
x + inset,
y + node.height,
x,
cy,
);
svg.push_str(&format!(
"<polygon points=\"{}\" fill=\"{}\" stroke=\"none\" />",
points, fill,
));
}
MindmapNodeType::Cloud | MindmapNodeType::Bang => {
let rx = node.width / 2.0;
let ry = node.height / 2.0;
svg.push_str(&format!(
"<ellipse cx=\"{:.1}\" cy=\"{:.1}\" rx=\"{:.1}\" ry=\"{:.1}\" \
fill=\"{}\" stroke=\"none\" />",
cx, cy, rx, ry, fill,
));
}
}
// Render underline decoration (non-root nodes get a colored line below)
if !node.is_root {
match node.node_type {
MindmapNodeType::Circle => {}
_ => {
let x1 = cx - node.width / 2.0;
let x2 = cx + node.width / 2.0;
let line_y = cy + node.height / 2.0 + 5.0;
// Underline uses the complementary/inverted hue color from CSS
// (section-N line stroke in reference is the hue+180 version)
svg.push_str(&format!(
"<line x1=\"{:.1}\" y1=\"{:.1}\" x2=\"{:.1}\" y2=\"{:.1}\" \
stroke=\"{}\" stroke-width=\"3\" />",
x1, line_y, x2, line_y, fill,
));
}
}
}
// Render text
svg.push_str(&format!(
"<text x=\"{:.1}\" y=\"{:.1}\" \
text-anchor=\"middle\" dominant-baseline=\"central\" \
font-family=\"'trebuchet ms', verdana, arial, sans-serif\" \
font-size=\"{}\" fill=\"{}\">{}</text>",
cx,
cy,
FONT_SIZE,
text_color,
html_escape(&node.label),
));
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
+331
View File
@@ -0,0 +1,331 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
const DEFAULT_ROW_HEIGHT: f64 = 32.0;
const DEFAULT_BIT_WIDTH: f64 = 32.0;
const DEFAULT_BITS_PER_ROW: u32 = 32;
const DEFAULT_SHOW_BITS: bool = true;
const DEFAULT_PADDING_X: f64 = 5.0;
const DEFAULT_PADDING_Y: f64 = 5.0;
pub fn render_packet_diagram_to_svg(
mermaid_source: &str,
_theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let diagram = parse_packet_diagram(mermaid_source)?;
let padding_y = DEFAULT_PADDING_Y + if DEFAULT_SHOW_BITS { 10.0 } else { 0.0 };
let total_row_height = DEFAULT_ROW_HEIGHT + padding_y;
let svg_width = DEFAULT_BIT_WIDTH * (DEFAULT_BITS_PER_ROW as f64) + 2.0;
let svg_height = total_row_height * ((diagram.rows.len() + 1) as f64)
- if diagram.title.is_some() {
0.0
} else {
DEFAULT_ROW_HEIGHT
};
let mut svg = String::new();
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {svg_width} {svg_height}\">"
));
svg.push_str(
"<style>\
.packetByte{font-size:10px;}\
.packetByte.start{fill:black;}\
.packetByte.end{fill:black;}\
.packetLabel{fill:black;font-size:12px;}\
.packetTitle{fill:black;font-size:14px;}\
.packetBlock{stroke:black;stroke-width:1;fill:#efefef;}\
</style>",
);
svg.push_str("<g>");
for (row_idx, row) in diagram.rows.iter().enumerate() {
let word_y = row_idx as f64 * total_row_height + padding_y;
for block in row {
let block_x = 1.0 + (block.start % DEFAULT_BITS_PER_ROW) as f64 * DEFAULT_BIT_WIDTH;
let width =
(block.end - block.start + 1) as f64 * DEFAULT_BIT_WIDTH - DEFAULT_PADDING_X;
svg.push_str(&format!(
"<rect class=\"packetBlock\" x=\"{block_x}\" y=\"{word_y}\" width=\"{width}\" height=\"{DEFAULT_ROW_HEIGHT}\"/>"
));
let label_x = block_x + width / 2.0;
let label_y = word_y + DEFAULT_ROW_HEIGHT / 2.0;
svg.push_str(&format!(
"<text class=\"packetLabel\" x=\"{label_x}\" y=\"{label_y}\" text-anchor=\"middle\" dominant-baseline=\"middle\">{}</text>",
escape_xml(&block.label)
));
if DEFAULT_SHOW_BITS {
let bit_y = word_y - 2.0;
if block.start == block.end {
svg.push_str(&format!(
"<text class=\"packetByte start\" x=\"{label_x}\" y=\"{bit_y}\" text-anchor=\"middle\" dominant-baseline=\"auto\">{}</text>",
block.start
));
} else {
let end_x = block_x + width;
svg.push_str(&format!(
"<text class=\"packetByte start\" x=\"{block_x}\" y=\"{bit_y}\" text-anchor=\"start\" dominant-baseline=\"auto\">{}</text>",
block.start
));
svg.push_str(&format!(
"<text class=\"packetByte end\" x=\"{end_x}\" y=\"{bit_y}\" text-anchor=\"end\" dominant-baseline=\"auto\">{}</text>",
block.end
));
}
}
}
}
svg.push_str("</g>");
let title_x = svg_width / 2.0;
let title_y = svg_height - total_row_height / 2.0;
svg.push_str(&format!(
"<text class=\"packetTitle\" x=\"{title_x}\" y=\"{title_y}\" text-anchor=\"middle\" dominant-baseline=\"middle\">{}</text>",
diagram
.title
.as_deref()
.map(escape_xml)
.unwrap_or_default()
));
svg.push_str("</svg>");
Ok(svg)
}
#[derive(Debug, Clone)]
struct PacketDiagram {
title: Option<String>,
rows: Vec<Vec<PacketBlock>>,
}
#[derive(Debug, Clone)]
struct PacketBlock {
start: u32,
end: u32,
label: String,
}
fn parse_packet_diagram(input: &str) -> Result<PacketDiagram, MermaidError> {
let lines = input.lines().enumerate();
let mut found_header = false;
let mut title: Option<String> = None;
let mut blocks: Vec<PacketBlock> = Vec::new();
for (idx, raw) in lines {
let line_no = idx + 1;
let line = raw.trim();
if line.is_empty() || line.starts_with("%%") {
continue;
}
if !found_header {
if line.split_whitespace().next() != Some("packet-beta") {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected 'packet-beta' declaration".to_string(),
});
}
found_header = true;
continue;
}
if let Some(rest) = line.strip_prefix("title ") {
let t = rest.trim();
if !t.is_empty() {
title = Some(t.to_string());
}
continue;
}
let Some((range_raw, label_raw)) = line.split_once(':') else {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid packet block: {line}"),
});
};
let (start, end) = parse_range(range_raw.trim(), line_no)?;
let label = parse_label(label_raw.trim(), line_no)?;
blocks.push(PacketBlock { start, end, label });
}
if !found_header {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'packet-beta' declaration".to_string(),
});
}
if blocks.is_empty() {
return Err(MermaidError::ParseError {
line: 1,
message: "Packet diagram requires at least one block".to_string(),
});
}
ensure_contiguous(&blocks)?;
let rows = split_into_rows(blocks, DEFAULT_BITS_PER_ROW);
Ok(PacketDiagram { title, rows })
}
fn parse_range(s: &str, line: usize) -> Result<(u32, u32), MermaidError> {
let s = s.trim();
if let Some((start_str, end_str)) = s.split_once('-') {
let start: u32 = start_str
.trim()
.parse()
.map_err(|_| MermaidError::ParseError {
line,
message: format!("Invalid packet start: {start_str}"),
})?;
let end: u32 = end_str
.trim()
.parse()
.map_err(|_| MermaidError::ParseError {
line,
message: format!("Invalid packet end: {end_str}"),
})?;
if end < start {
return Err(MermaidError::ParseError {
line,
message: format!("Packet block {start}-{end} is invalid (end < start)"),
});
}
return Ok((start, end));
}
let start: u32 = s.parse().map_err(|_| MermaidError::ParseError {
line,
message: format!("Invalid packet bit index: {s}"),
})?;
Ok((start, start))
}
fn parse_label(s: &str, line: usize) -> Result<String, MermaidError> {
let s = s.trim();
if let Some(stripped) = s.strip_prefix('"').and_then(|t| t.strip_suffix('"')) {
return Ok(stripped.to_string());
}
if let Some(stripped) = s.strip_prefix('\'').and_then(|t| t.strip_suffix('\'')) {
return Ok(stripped.to_string());
}
if s.is_empty() {
return Err(MermaidError::ParseError {
line,
message: "Packet block label cannot be empty".to_string(),
});
}
Ok(s.to_string())
}
fn ensure_contiguous(blocks: &[PacketBlock]) -> Result<(), MermaidError> {
let mut last: Option<u32> = None;
for block in blocks {
if let Some(last_bit) = last {
if block.start != last_bit + 1 {
return Err(MermaidError::ParseError {
line: 1,
message: format!(
"Packet block {}-{} is not contiguous. It should start from {}.",
block.start,
block.end,
last_bit + 1
),
});
}
}
last = Some(block.end);
}
Ok(())
}
fn split_into_rows(blocks: Vec<PacketBlock>, bits_per_row: u32) -> Vec<Vec<PacketBlock>> {
let mut rows: Vec<Vec<PacketBlock>> = Vec::new();
let mut word: Vec<PacketBlock> = Vec::new();
let mut row = 1_u32;
for block in blocks {
let mut cur = block;
loop {
let (fitting, remainder) = split_block_at_row_boundary(&cur, row, bits_per_row);
word.push(fitting);
if word
.last()
.is_some_and(|b| b.end.saturating_add(1) == row.saturating_mul(bits_per_row))
{
rows.push(std::mem::take(&mut word));
row = row.saturating_add(1);
}
let Some(next) = remainder else {
break;
};
cur = next;
}
}
if !word.is_empty() {
rows.push(word);
}
rows
}
fn split_block_at_row_boundary(
block: &PacketBlock,
row: u32,
bits_per_row: u32,
) -> (PacketBlock, Option<PacketBlock>) {
let row_end_exclusive = row.saturating_mul(bits_per_row);
if block.end.saturating_add(1) <= row_end_exclusive {
return (block.clone(), None);
}
let first = PacketBlock {
start: block.start,
end: row_end_exclusive.saturating_sub(1),
label: block.label.clone(),
};
let second = PacketBlock {
start: row_end_exclusive,
end: block.end,
label: block.label.clone(),
};
(first, Some(second))
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+659
View File
@@ -0,0 +1,659 @@
use crate::ast::{
Edge, EdgeStyle, FlowchartGraph, GraphDirection, Node, NodeShape, Statement, StyleStatement,
Subgraph,
};
use crate::error::MermaidError;
pub fn parse_mermaid(input: &str) -> Result<FlowchartGraph, MermaidError> {
if let Some(first_line) = first_non_empty_non_comment_line(input) {
let first_token = first_line.split_whitespace().next().unwrap_or("");
if first_token != "graph"
&& first_token != "flowchart"
&& is_known_mermaid_type(first_token)
{
return Err(MermaidError::UnsupportedDiagramType(
first_token.to_string(),
));
}
}
let mut parser = Parser::new(input);
parser.parse()
}
fn normalize_label(label: &str) -> String {
let label = strip_wrapping_quotes(label.trim());
decode_html_entities(label)
.replace("\\n", "\n")
.replace("<br/>", "\n")
.replace("<br />", "\n")
.replace("<br>", "\n")
.replace("<BR/>", "\n")
.replace("<BR />", "\n")
.replace("<BR>", "\n")
}
fn decode_html_entities(label: &str) -> String {
label
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&amp;", "&")
}
fn strip_wrapping_quotes(label: &str) -> &str {
let bytes = label.as_bytes();
if bytes.len() >= 2
&& ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
|| (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
{
&label[1..label.len() - 1]
} else {
label
}
}
fn first_non_empty_non_comment_line(input: &str) -> Option<&str> {
input
.lines()
.map(|l| l.trim())
.find(|l| !l.is_empty() && !l.starts_with("%%"))
}
fn is_known_mermaid_type(token: &str) -> bool {
matches!(
token,
"sequenceDiagram"
| "classDiagram"
| "classDiagram-v2"
| "stateDiagram"
| "stateDiagram-v2"
| "erDiagram"
| "journey"
| "gantt"
| "pie"
| "mindmap"
| "timeline"
| "info"
| "kanban"
| "gitGraph"
| "requirementDiagram"
| "C4Context"
| "C4Container"
| "C4Component"
| "C4Dynamic"
| "C4Deployment"
| "sankey-beta"
| "packet-beta"
| "xychart-beta"
| "radar-beta"
| "block-beta"
| "flowchart-elk"
| "quadrantChart"
)
}
struct Parser<'a> {
lines: Vec<&'a str>,
current_line: usize,
next_subgraph_index: usize,
}
impl<'a> Parser<'a> {
fn new(input: &'a str) -> Self {
let lines: Vec<&str> = input.lines().collect();
Self {
lines,
current_line: 0,
next_subgraph_index: 0,
}
}
fn parse(&mut self) -> Result<FlowchartGraph, MermaidError> {
let direction = self.parse_graph_declaration()?;
let statements = self.parse_statements()?;
Ok(FlowchartGraph {
direction,
statements,
})
}
fn current_line_content(&self) -> Option<&'a str> {
self.lines.get(self.current_line).map(|s| s.trim())
}
fn advance(&mut self) {
self.current_line += 1;
}
fn skip_empty_lines(&mut self) {
while let Some(line) = self.current_line_content() {
if line.is_empty() || line.starts_with("%%") {
self.advance();
} else {
break;
}
}
}
fn parse_graph_declaration(&mut self) -> Result<GraphDirection, MermaidError> {
self.skip_empty_lines();
let line = self
.current_line_content()
.ok_or_else(|| MermaidError::ParseError {
line: self.current_line + 1,
message: "Expected graph declaration".to_string(),
})?;
let direction = if line.starts_with("graph ") || line.starts_with("flowchart ") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 2 {
return Err(MermaidError::ParseError {
line: self.current_line + 1,
message: "Expected direction after 'graph' or 'flowchart'".to_string(),
});
}
self.parse_direction(parts[1])?
} else {
return Err(MermaidError::ParseError {
line: self.current_line + 1,
message: "Expected 'graph' or 'flowchart' declaration".to_string(),
});
};
self.advance();
Ok(direction)
}
fn parse_direction(&self, dir: &str) -> Result<GraphDirection, MermaidError> {
match dir.to_uppercase().as_str() {
"TD" | "TB" => Ok(GraphDirection::TopToBottom),
"BT" => Ok(GraphDirection::BottomToTop),
"LR" => Ok(GraphDirection::LeftToRight),
"RL" => Ok(GraphDirection::RightToLeft),
_ => Err(MermaidError::InvalidDirection(dir.to_string())),
}
}
fn parse_statements(&mut self) -> Result<Vec<Statement>, MermaidError> {
let mut statements = Vec::new();
while self.current_line_content().is_some() {
self.skip_empty_lines();
let Some(line) = self.current_line_content() else {
break;
};
if line.is_empty() {
self.advance();
continue;
}
if line == "end" {
break;
}
if line.starts_with("subgraph ") {
statements.push(Statement::Subgraph(self.parse_subgraph()?));
} else if line.starts_with("style ") {
statements.push(Statement::Style(self.parse_style()?));
} else if self.line_contains_edge(line) {
let edge_statements = self.parse_edge_chain(line)?;
statements.extend(edge_statements);
self.advance();
} else {
if let Some(node) = self.try_parse_node(line) {
statements.push(Statement::Node(node));
}
self.advance();
}
}
Ok(statements)
}
fn line_contains_edge(&self, line: &str) -> bool {
self.find_edge_start(line).is_some()
}
fn parse_edge_chain(&mut self, line: &str) -> Result<Vec<Statement>, MermaidError> {
let mut statements = Vec::new();
let mut remaining = line.trim();
let mut collected_nodes: Vec<(String, Option<Node>)> = Vec::new();
let first_node_end = self.find_edge_start(remaining).unwrap_or(remaining.len());
let first_node_str = remaining[..first_node_end].trim();
if let Some(node) = self.try_parse_node(first_node_str) {
collected_nodes.push((node.id.clone(), Some(node)));
} else {
let id = self.extract_node_id(first_node_str);
collected_nodes.push((id, None));
}
remaining = &remaining[first_node_end..];
while !remaining.is_empty() {
let (edge_style, label, edge_len) = self.parse_edge_syntax(remaining)?;
remaining = remaining[edge_len..].trim_start();
let next_node_end = self.find_edge_start(remaining).unwrap_or(remaining.len());
let next_node_str = remaining[..next_node_end].trim();
if next_node_str.is_empty() {
break;
}
let (next_id, next_node) = if let Some(node) = self.try_parse_node(next_node_str) {
(node.id.clone(), Some(node))
} else {
let id = self.extract_node_id(next_node_str);
(id, None)
};
if let Some((from_id, _)) = collected_nodes.last() {
statements.push(Statement::Edge(Edge {
from: from_id.clone(),
to: next_id.clone(),
label,
style: edge_style,
}));
}
collected_nodes.push((next_id, next_node));
remaining = &remaining[next_node_end..];
}
let mut node_statements: Vec<Statement> = collected_nodes
.into_iter()
.filter_map(|(_, node_opt)| node_opt.map(Statement::Node))
.collect();
node_statements.append(&mut statements);
statements = node_statements;
Ok(statements)
}
/// Byte index where the first edge token starts, ignoring tokens inside
/// bracket/quote-delimited node labels (`[..]`, `(..)`, `{..}`, `".."`).
fn find_edge_start(&self, s: &str) -> Option<usize> {
const PATTERNS: [&str; 9] = ["-.->", "-.-", "-->", "---", "==>", "===", "--", "==", "-."];
let bytes = s.as_bytes();
let mut depth: usize = 0;
let mut in_quote = false;
for i in 0..bytes.len() {
let b = bytes[i];
if in_quote {
if b == b'"' {
in_quote = false;
}
continue;
}
match b {
b'"' => in_quote = true,
b'[' | b'(' | b'{' => depth += 1,
b']' | b')' | b'}' => depth = depth.saturating_sub(1),
_ if depth == 0 => {
if PATTERNS
.iter()
.any(|p| bytes[i..].starts_with(p.as_bytes()))
{
return Some(i);
}
}
_ => {}
}
}
None
}
fn parse_edge_syntax(
&self,
s: &str,
) -> Result<(EdgeStyle, Option<String>, usize), MermaidError> {
let s = s.trim_start();
let edge_patterns: &[(&str, EdgeStyle, &str)] = &[
("-->|", EdgeStyle::Arrow, "|"),
("---|", EdgeStyle::Line, "|"),
("-.->|", EdgeStyle::DottedArrow, "|"),
("-.-|", EdgeStyle::DottedLine, "|"),
("==>|", EdgeStyle::ThickArrow, "|"),
("===|", EdgeStyle::ThickLine, "|"),
("-->", EdgeStyle::Arrow, ""),
("---", EdgeStyle::Line, ""),
("-.->", EdgeStyle::DottedArrow, ""),
("-.-", EdgeStyle::DottedLine, ""),
("==>", EdgeStyle::ThickArrow, ""),
("===", EdgeStyle::ThickLine, ""),
];
for (pattern, style, label_end) in edge_patterns {
if let Some(after_pattern) = s.strip_prefix(pattern) {
if !label_end.is_empty() {
if let Some(end_idx) = after_pattern.find(label_end) {
let label = normalize_label(&after_pattern[..end_idx]);
let total_len = pattern.len() + end_idx + label_end.len();
return Ok((*style, Some(label), total_len));
}
} else {
return Ok((*style, None, pattern.len()));
}
}
}
// Open-label forms: `-- text -->`, `-- text ---`, `== text ==>`,
// `== text ===`, `-. text .->`, `-. text .-`.
let open_patterns: &[(&str, &[(&str, EdgeStyle)])] = &[
("--", &[("-->", EdgeStyle::Arrow), ("---", EdgeStyle::Line)]),
(
"==",
&[
("==>", EdgeStyle::ThickArrow),
("===", EdgeStyle::ThickLine),
],
),
(
"-.",
&[
(".->", EdgeStyle::DottedArrow),
(".-", EdgeStyle::DottedLine),
],
),
];
for (opener, closers) in open_patterns {
let Some(after) = s.strip_prefix(opener) else {
continue;
};
let mut best: Option<(usize, &str, EdgeStyle)> = None;
for (closer, style) in *closers {
if let Some(idx) = after.find(closer) {
let better = match best {
Some((best_idx, best_closer, _)) => {
idx < best_idx || (idx == best_idx && closer.len() > best_closer.len())
}
None => true,
};
if better {
best = Some((idx, closer, *style));
}
}
}
if let Some((idx, closer, style)) = best {
let label = normalize_label(&after[..idx]);
let total_len = opener.len() + idx + closer.len();
return Ok((style, Some(label), total_len));
}
}
Err(MermaidError::ParseError {
line: self.current_line + 1,
message: format!("Invalid edge syntax: {}", s),
})
}
fn extract_node_id(&self, s: &str) -> String {
let s = s.trim();
for (open, _close) in [('[', ']'), ('(', ')'), ('{', '}'), ('<', '>')] {
if let Some(idx) = s.find(open) {
return s[..idx].trim().to_string();
}
}
s.to_string()
}
fn try_parse_node(&self, s: &str) -> Option<Node> {
let s = s.trim();
if s.is_empty() {
return None;
}
if let Some(paren_paren_start) = s.find("((") {
if s.ends_with("))") {
let id = s[..paren_paren_start].trim().to_string();
let label = normalize_label(&s[paren_paren_start + 2..s.len() - 2]);
let id = if id.is_empty() {
label.chars().filter(|c| c.is_alphanumeric()).collect()
} else {
id
};
return Some(Node {
id,
label: Some(label),
shape: NodeShape::Circle,
});
}
}
if let Some(bracket_paren_start) = s.find("([") {
if s.ends_with("])") {
let id = s[..bracket_paren_start].trim().to_string();
let label = normalize_label(&s[bracket_paren_start + 2..s.len() - 2]);
let id = if id.is_empty() {
label.chars().filter(|c| c.is_alphanumeric()).collect()
} else {
id
};
return Some(Node {
id,
label: Some(label),
shape: NodeShape::Stadium,
});
}
}
if let Some(paren_bracket_start) = s.find("[(") {
if s.ends_with(")]") {
let id = s[..paren_bracket_start].trim().to_string();
let label = normalize_label(&s[paren_bracket_start + 2..s.len() - 2]);
let id = if id.is_empty() {
label.chars().filter(|c| c.is_alphanumeric()).collect()
} else {
id
};
return Some(Node {
id,
label: Some(label),
shape: NodeShape::Cylinder,
});
}
}
if let Some(bracket_bracket_start) = s.find("[[") {
if s.ends_with("]]") {
let id = s[..bracket_bracket_start].trim().to_string();
let label = normalize_label(&s[bracket_bracket_start + 2..s.len() - 2]);
let id = if id.is_empty() {
label.chars().filter(|c| c.is_alphanumeric()).collect()
} else {
id
};
return Some(Node {
id,
label: Some(label),
shape: NodeShape::Subroutine,
});
}
}
if let Some(brace_brace_start) = s.find("{{") {
if s.ends_with("}}") {
let id = s[..brace_brace_start].trim().to_string();
let label = normalize_label(&s[brace_brace_start + 2..s.len() - 2]);
let id = if id.is_empty() {
label.chars().filter(|c| c.is_alphanumeric()).collect()
} else {
id
};
return Some(Node {
id,
label: Some(label),
shape: NodeShape::Hexagon,
});
}
}
if let Some(bracket_start) = s.find('[') {
if s.ends_with(']') {
let id = s[..bracket_start].trim().to_string();
let label = normalize_label(&s[bracket_start + 1..s.len() - 1]);
let id = if id.is_empty() {
label.chars().filter(|c| c.is_alphanumeric()).collect()
} else {
id
};
return Some(Node {
id,
label: Some(label),
shape: NodeShape::Rectangle,
});
}
}
if let Some(paren_start) = s.find('(') {
if s.ends_with(')') && !s.ends_with("))") {
let id = s[..paren_start].trim().to_string();
let label = normalize_label(&s[paren_start + 1..s.len() - 1]);
let id = if id.is_empty() {
label.chars().filter(|c| c.is_alphanumeric()).collect()
} else {
id
};
return Some(Node {
id,
label: Some(label),
shape: NodeShape::RoundedRectangle,
});
}
}
if let Some(brace_start) = s.find('{') {
if s.ends_with('}') && !s.ends_with("}}") {
let id = s[..brace_start].trim().to_string();
let label = normalize_label(&s[brace_start + 1..s.len() - 1]);
let id = if id.is_empty() {
label.chars().filter(|c| c.is_alphanumeric()).collect()
} else {
id
};
return Some(Node {
id,
label: Some(label),
shape: NodeShape::Diamond,
});
}
}
if s.contains('>') && s.ends_with(']') {
if let Some(gt_idx) = s.find('>') {
let id = s[..gt_idx].trim().to_string();
let label = normalize_label(&s[gt_idx + 1..s.len() - 1]);
return Some(Node {
id,
label: Some(label),
shape: NodeShape::Asymmetric,
});
}
}
if s.chars().all(|c| c.is_alphanumeric() || c == '_') {
return Some(Node {
id: s.to_string(),
label: None,
shape: NodeShape::Rectangle,
});
}
None
}
fn parse_subgraph(&mut self) -> Result<Subgraph, MermaidError> {
let line = self
.current_line_content()
.ok_or_else(|| MermaidError::ParseError {
line: self.current_line + 1,
message: "Expected subgraph".to_string(),
})?;
let after_keyword = line.strip_prefix("subgraph ").unwrap_or("").trim();
let (id, title) = if let Some(bracket_start) = after_keyword.find('[') {
if after_keyword.ends_with(']') {
let id = after_keyword[..bracket_start].trim().to_string();
let title =
normalize_label(&after_keyword[bracket_start + 1..after_keyword.len() - 1]);
(id, Some(title))
} else {
(after_keyword.to_string(), None)
}
} else if after_keyword.split_whitespace().count() > 1 {
let id = format!("subGraph{}", self.next_subgraph_index);
self.next_subgraph_index += 1;
(id, Some(normalize_label(after_keyword)))
} else {
let id = after_keyword.to_string();
(id, None)
};
self.advance();
let statements = self.parse_statements()?;
if self.current_line_content() == Some("end") {
self.advance();
}
Ok(Subgraph {
id,
title,
statements,
})
}
fn parse_style(&mut self) -> Result<StyleStatement, MermaidError> {
let line = self
.current_line_content()
.ok_or_else(|| MermaidError::ParseError {
line: self.current_line + 1,
message: "Expected style statement".to_string(),
})?;
let after_keyword = line.strip_prefix("style ").unwrap_or("").trim();
let parts: Vec<&str> = after_keyword.splitn(2, ' ').collect();
if parts.is_empty() {
return Err(MermaidError::ParseError {
line: self.current_line + 1,
message: "Expected node id after 'style'".to_string(),
});
}
let node_id = parts[0].to_string();
let properties = if parts.len() > 1 {
parts[1]
.split(',')
.filter_map(|prop| {
let kv: Vec<&str> = prop.splitn(2, ':').collect();
if kv.len() == 2 {
Some((kv[0].trim().to_string(), kv[1].trim().to_string()))
} else {
None
}
})
.collect()
} else {
Vec::new()
};
self.advance();
Ok(StyleStatement {
node_id,
properties,
})
}
}
+309
View File
@@ -0,0 +1,309 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
use std::f64::consts::PI;
/// Mermaid 11.12.2 default pie chart colors (from the default theme).
/// pie1 = primaryColor (#ECECFF), pie2 = secondaryColor (#ffffde),
/// pie3pie12 computed via adjust/darken on primary/secondary/tertiary.
const MERMAID_PIE_COLORS: [&str; 12] = [
"#ECECFF", // pie1 primaryColor
"#ffffde", // pie2 secondaryColor
"hsl(80, 100%, 56.2745098039%)", // pie3 adjust(tertiaryColor, l:-40)
"hsl(240, 60%, 86.2745098039%)", // pie4 adjust(primaryColor, l:-10)
"hsl(120, 100%, 66.2745098039%)", // pie5 adjust(secondaryColor, l:-30)
"hsl(80, 100%, 76.2745098039%)", // pie6 adjust(tertiaryColor, l:-20)
"hsl(300, 60%, 76.2745098039%)", // pie7 adjust(primaryColor, h:60, l:-20)
"hsl(180, 60%, 56.2745098039%)", // pie8 adjust(primaryColor, h:-60, l:-40)
"hsl(0, 60%, 56.2745098039%)", // pie9 adjust(primaryColor, h:120, l:-40)
"hsl(300, 60%, 56.2745098039%)", // pie10 adjust(primaryColor, h:60, l:-40)
"hsl(150, 60%, 56.2745098039%)", // pie11 adjust(primaryColor, h:-90, l:-40)
"hsl(0, 60%, 66.2745098039%)", // pie12 adjust(primaryColor, h:120, l:-30)
];
// Mermaid 11.12.2 pie chart constants (from pieRenderer.ts and default config).
const PIE_HEIGHT: f64 = 450.0;
const PIE_WIDTH: f64 = 450.0;
const MARGIN: f64 = 40.0;
const RADIUS: f64 = (PIE_WIDTH / 2.0) - MARGIN; // 185
const OUTER_STROKE_WIDTH: f64 = 2.0;
const OUTER_RADIUS: f64 = RADIUS + OUTER_STROKE_WIDTH / 2.0; // 186
const TEXT_POSITION: f64 = 0.75;
const LEGEND_RECT_SIZE: f64 = 18.0;
const LEGEND_SPACING: f64 = 4.0;
const FONT_FAMILY: &str = "trebuchet ms,verdana,arial,sans-serif";
pub fn render_pie_diagram_to_svg(
mermaid_source: &str,
_theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let chart = parse_pie_diagram(mermaid_source)?;
let total: f64 = chart.slices.iter().map(|s| s.value).sum();
if total <= 0.0 {
return Err(MermaidError::ParseError {
line: 1,
message: "Pie diagram total must be > 0".to_string(),
});
}
// Filter slices ≥1% and sort descending by value (matches d3.pie() default).
let mut slices: Vec<&PieSlice> = chart
.slices
.iter()
.filter(|s| s.value / total * 100.0 >= 1.0)
.collect();
slices.sort_by(|a, b| b.value.partial_cmp(&a.value).unwrap());
// All slices for the legend (unfiltered, original order).
let all_slices: Vec<&PieSlice> = chart.slices.iter().collect();
// Center of the pie in the translated group coordinate system is (0, 0).
let cx = PIE_WIDTH / 2.0;
let cy = PIE_HEIGHT / 2.0;
// Estimate legend text width (rough: 10px per char at 17px font).
let longest_label_len = all_slices
.iter()
.map(|s| {
if chart.show_data {
format!("{} [{}]", s.label, s.value).len()
} else {
s.label.len()
}
})
.max()
.unwrap_or(0);
let legend_text_width = longest_label_len as f64 * 10.0;
let total_width = PIE_WIDTH + MARGIN + LEGEND_RECT_SIZE + LEGEND_SPACING + legend_text_width;
let mut svg = String::new();
// Mermaid uses a CSS style block for pie chart classes.
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {total_width:.4} {PIE_HEIGHT}\" \
style=\"max-width: {total_width:.3}px; background-color: white;\" \
role=\"graphics-document document\" aria-roledescription=\"pie\">"
));
// Inline CSS matching Mermaid 11.12.2 pieStyles.
svg.push_str(&format!(
"<style>\
.pieCircle{{stroke:black;stroke-width:2px;opacity:0.7;}}\
.pieOuterCircle{{stroke:black;stroke-width:2px;fill:none;}}\
.pieTitleText{{text-anchor:middle;font-size:25px;fill:black;font-family:{FONT_FAMILY};}}\
.slice{{font-family:{FONT_FAMILY};fill:#333;font-size:17px;}}\
.legend text{{fill:black;font-family:{FONT_FAMILY};font-size:17px;}}\
</style>"
));
// Group translated to pie center (matches mermaid: translate(pieWidth/2, height/2)).
svg.push_str(&format!("<g transform=\"translate({cx},{cy})\">"));
// Outer circle.
svg.push_str(&format!(
"<circle cx=\"0\" cy=\"0\" r=\"{OUTER_RADIUS}\" class=\"pieOuterCircle\"/>"
));
// Draw pie slices.
// d3.pie() default: startAngle=0 (12 o'clock), endAngle=2π, clockwise.
// In SVG with translate to center, angle 0 points up (-y).
let mut angle = -PI / 2.0;
for (idx, slice) in slices.iter().enumerate() {
let pct = (slice.value / total * 100.0).round() as i64;
if pct == 0 {
continue;
}
let frac = slice.value / total;
let sweep = frac * 2.0 * PI;
let next = angle + sweep;
let (x0, y0) = polar(0.0, 0.0, RADIUS, angle);
let (x1, y1) = polar(0.0, 0.0, RADIUS, next);
let large_arc = if sweep > PI { 1 } else { 0 };
let fill = MERMAID_PIE_COLORS[idx % MERMAID_PIE_COLORS.len()];
// Slice path.
svg.push_str(&format!(
"<path d=\"M0,0L{x0:.3},{y0:.3}A{RADIUS},{RADIUS},0,{large_arc},1,{x1:.3},{y1:.3}Z\" \
fill=\"{fill}\" class=\"pieCircle\"/>"
));
// Percentage label inside the slice at textPosition (0.75) of radius.
let label_r = RADIUS * TEXT_POSITION;
let mid = angle + sweep / 2.0;
let (lx, ly) = polar(0.0, 0.0, label_r, mid);
svg.push_str(&format!(
"<text transform=\"translate({lx:.3},{ly:.3})\" class=\"slice\" \
style=\"text-anchor: middle;\">{pct}%</text>"
));
angle = next;
}
// Title (positioned above the pie).
if let Some(title) = &chart.title {
let title_y = -((PIE_HEIGHT - 50.0) / 2.0);
svg.push_str(&format!(
"<text x=\"0\" y=\"{title_y:.0}\" class=\"pieTitleText\">{}</text>",
escape_xml(title)
));
}
// Legend (to the right of the pie).
let legend_h = LEGEND_RECT_SIZE + LEGEND_SPACING;
let legend_offset = legend_h * all_slices.len() as f64 / 2.0;
let legend_x = 12.0 * LEGEND_RECT_SIZE; // 216
// Build a color map that assigns colors to labels in the same order as the
// sorted/filtered slices (matching d3.scaleOrdinal behavior).
let mut color_map: Vec<(&str, &str)> = Vec::new();
for (idx, slice) in slices.iter().enumerate() {
color_map.push((
&slice.label,
MERMAID_PIE_COLORS[idx % MERMAID_PIE_COLORS.len()],
));
}
for (legend_idx, slice) in all_slices.iter().enumerate() {
let vert = legend_idx as f64 * legend_h - legend_offset;
let color = color_map
.iter()
.find(|(label, _)| *label == slice.label)
.map(|(_, c)| *c)
.unwrap_or(MERMAID_PIE_COLORS[legend_idx % MERMAID_PIE_COLORS.len()]);
svg.push_str(&format!(
"<g class=\"legend\" transform=\"translate({legend_x},{vert})\">"
));
svg.push_str(&format!(
"<rect width=\"{LEGEND_RECT_SIZE}\" height=\"{LEGEND_RECT_SIZE}\" \
style=\"fill: {color}; stroke: {color};\"/>"
));
let label_text = if chart.show_data {
format!("{} [{}]", slice.label, slice.value)
} else {
slice.label.clone()
};
let text_x = LEGEND_RECT_SIZE + LEGEND_SPACING;
let text_y = LEGEND_RECT_SIZE - LEGEND_SPACING;
svg.push_str(&format!(
"<text x=\"{text_x}\" y=\"{text_y}\">{}</text>",
escape_xml(&label_text)
));
svg.push_str("</g>");
}
svg.push_str("</g>"); // close main group
svg.push_str("</svg>");
Ok(svg)
}
#[derive(Debug, Clone)]
struct PieChart {
title: Option<String>,
show_data: bool,
slices: Vec<PieSlice>,
}
#[derive(Debug, Clone)]
struct PieSlice {
label: String,
value: f64,
}
fn parse_pie_diagram(input: &str) -> Result<PieChart, MermaidError> {
let lines: Vec<&str> = input.lines().collect();
let mut i = 0_usize;
let mut show_data = false;
while i < lines.len() {
let line = lines[i].trim();
if line.is_empty() || line.starts_with("%%") {
i += 1;
continue;
}
let mut tokens = line.split_whitespace();
let first = tokens.next().unwrap_or("");
if first == "pie" {
show_data = tokens.any(|t| t == "showData");
i += 1;
break;
}
return Err(MermaidError::ParseError {
line: i + 1,
message: "Expected 'pie' declaration".to_string(),
});
}
let mut title: Option<String> = None;
let mut slices: Vec<PieSlice> = Vec::new();
while i < lines.len() {
let raw = lines[i];
let line = raw.trim();
let line_no = i + 1;
i += 1;
if line.is_empty() || line.starts_with("%%") {
continue;
}
if let Some(rest) = line.strip_prefix("title ") {
let t = rest.trim();
if !t.is_empty() {
title = Some(t.to_string());
}
continue;
}
let Some((label_raw, value_raw)) = line.split_once(':') else {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid pie slice: {line}"),
});
};
let mut label = label_raw.trim().to_string();
if let Some(stripped) = label.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
label = stripped.to_string();
}
let value_str = value_raw.trim();
let value: f64 = value_str.parse().map_err(|_| MermaidError::ParseError {
line: line_no,
message: format!("Invalid pie value: {value_str}"),
})?;
slices.push(PieSlice { label, value });
}
if slices.is_empty() {
return Err(MermaidError::ParseError {
line: 1,
message: "Pie diagram requires at least one slice".to_string(),
});
}
Ok(PieChart {
title,
show_data,
slices,
})
}
fn polar(cx: f64, cy: f64, r: f64, angle: f64) -> (f64, f64) {
(cx + r * angle.cos(), cy + r * angle.sin())
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+540
View File
@@ -0,0 +1,540 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
use std::collections::BTreeMap;
/// Quadrant chart theme colors, derived from the mermaid.js default theme.
/// Mermaid uses `primaryColor = "#ECECFF"` and derives fills by adjusting RGB channels.
struct QuadrantTheme<'a> {
quadrant1_fill: &'a str,
quadrant2_fill: &'a str,
quadrant3_fill: &'a str,
quadrant4_fill: &'a str,
border_stroke: &'a str,
title_fill: &'a str,
axis_text_fill: &'a str,
point_fill: &'a str,
point_text_fill: &'a str,
quadrant_text_fill: &'a str,
}
fn quadrant_theme_for(theme: &MermaidTheme) -> QuadrantTheme<'static> {
let is_dark = theme.background.starts_with("#1") || theme.background.starts_with("#0");
if is_dark {
QuadrantTheme {
quadrant1_fill: "#1f2020",
quadrant2_fill: "#242525",
quadrant3_fill: "#292a2a",
quadrant4_fill: "#2e2f2f",
border_stroke: "#e0dfdf",
title_fill: "#ccc",
axis_text_fill: "#ccc",
point_fill: "#ccc",
point_text_fill: "#ccc",
quadrant_text_fill: "#ccc",
}
} else {
// Default (light) theme: primaryColor = "#ECECFF"
// quadrant fills = primaryColor + adjust({r: N, g: N, b: N}) for N in 0,5,10,15
// border = mkBorder("#ECECFF", false) = adjust("#ECECFF", {s:-40, l:-10}) = #C7C7F1
// point fill = darken("#ECECFF") ≈ #333333 (text color in practice)
QuadrantTheme {
quadrant1_fill: "#ECECFF",
quadrant2_fill: "#F1F1FF",
quadrant3_fill: "#F6F6FF",
quadrant4_fill: "#FBFBFF",
border_stroke: "#C7C7F1",
title_fill: "#333333",
axis_text_fill: "#333333",
point_fill: "#333333",
point_text_fill: "#333333",
quadrant_text_fill: "#333333",
}
}
}
/// Mermaid.js default config values for quadrant charts.
const CHART_WIDTH: f64 = 500.0;
const CHART_HEIGHT: f64 = 500.0;
const TITLE_FONT_SIZE: f64 = 20.0;
const TITLE_PADDING: f64 = 10.0;
const QUADRANT_PADDING: f64 = 5.0;
const X_AXIS_LABEL_PADDING: f64 = 5.0;
const Y_AXIS_LABEL_PADDING: f64 = 5.0;
const X_AXIS_LABEL_FONT_SIZE: f64 = 16.0;
const Y_AXIS_LABEL_FONT_SIZE: f64 = 16.0;
const QUADRANT_LABEL_FONT_SIZE: f64 = 16.0;
const QUADRANT_TEXT_TOP_PADDING: f64 = 5.0;
const POINT_TEXT_PADDING: f64 = 5.0;
const POINT_LABEL_FONT_SIZE: f64 = 12.0;
const POINT_RADIUS: f64 = 5.0;
const INTERNAL_BORDER_STROKE_WIDTH: f64 = 1.0;
const EXTERNAL_BORDER_STROKE_WIDTH: f64 = 2.0;
const FONT_FAMILY: &str = "trebuchet ms,verdana,arial,sans-serif";
pub fn render_quadrant_chart_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let chart = parse_quadrant_chart(mermaid_source)?;
let qt = quadrant_theme_for(theme);
let has_points = !chart.points.is_empty();
let show_title = chart.title.is_some();
let show_x_axis = chart.x_axis.is_some();
let show_y_axis = chart.y_axis.is_some();
// x-axis goes to bottom when points exist, top otherwise
let x_axis_bottom = has_points;
// Space calculations (matches mermaid.js QuadrantBuilder.calculateSpace)
let x_axis_space = if show_x_axis {
X_AXIS_LABEL_PADDING * 2.0 + X_AXIS_LABEL_FONT_SIZE
} else {
0.0
};
let y_axis_space_left = if show_y_axis {
Y_AXIS_LABEL_PADDING * 2.0 + Y_AXIS_LABEL_FONT_SIZE
} else {
0.0
};
let title_space_top = if show_title {
TITLE_FONT_SIZE + TITLE_PADDING * 2.0
} else {
0.0
};
let x_axis_top = if !x_axis_bottom { x_axis_space } else { 0.0 };
let x_axis_bot = if x_axis_bottom { x_axis_space } else { 0.0 };
let quadrant_left = QUADRANT_PADDING + y_axis_space_left;
let quadrant_top = QUADRANT_PADDING + x_axis_top + title_space_top;
let quadrant_width = CHART_WIDTH - QUADRANT_PADDING * 2.0 - y_axis_space_left;
let quadrant_height =
CHART_HEIGHT - QUADRANT_PADDING * 2.0 - x_axis_top - x_axis_bot - title_space_top;
let half_w = quadrant_width / 2.0;
let half_h = quadrant_height / 2.0;
let half_ext = EXTERNAL_BORDER_STROKE_WIDTH / 2.0;
let mut svg = String::new();
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {CHART_WIDTH} {CHART_HEIGHT}\">"
));
svg.push_str(&format!(
"<rect x=\"0\" y=\"0\" width=\"{CHART_WIDTH}\" height=\"{CHART_HEIGHT}\" fill=\"{}\"/>",
theme.background
));
// --- Quadrant fill rects (draw FIRST, behind everything else) ---
let q1_text = chart.quadrants.get(&1).cloned().unwrap_or_default();
let q2_text = chart.quadrants.get(&2).cloned().unwrap_or_default();
let q3_text = chart.quadrants.get(&3).cloned().unwrap_or_default();
let q4_text = chart.quadrants.get(&4).cloned().unwrap_or_default();
// Mermaid quadrant layout:
// Q1 = top-right, Q2 = top-left, Q3 = bottom-left, Q4 = bottom-right
let quadrant_rects: [(f64, f64, &str); 4] = [
(quadrant_left + half_w, quadrant_top, qt.quadrant1_fill), // Q1 top-right
(quadrant_left, quadrant_top, qt.quadrant2_fill), // Q2 top-left
(quadrant_left, quadrant_top + half_h, qt.quadrant3_fill), // Q3 bottom-left
(
quadrant_left + half_w,
quadrant_top + half_h,
qt.quadrant4_fill,
), // Q4 bottom-right
];
for (rx, ry, fill) in &quadrant_rects {
svg.push_str(&format!(
"<rect x=\"{rx:.1}\" y=\"{ry:.1}\" width=\"{half_w:.1}\" height=\"{half_h:.1}\" fill=\"{fill}\"/>"
));
}
// --- Quadrant labels ---
let quadrant_labels: [(&str, f64, f64); 4] = [
(
&q1_text,
quadrant_left + half_w + half_w / 2.0,
quadrant_top,
),
(&q2_text, quadrant_left + half_w / 2.0, quadrant_top),
(
&q3_text,
quadrant_left + half_w / 2.0,
quadrant_top + half_h,
),
(
&q4_text,
quadrant_left + half_w + half_w / 2.0,
quadrant_top + half_h,
),
];
for (text, tx, ty_base) in &quadrant_labels {
if text.is_empty() {
continue;
}
// When points exist, labels go to top of quadrant; otherwise center
let ty = if has_points {
ty_base + QUADRANT_TEXT_TOP_PADDING
} else {
ty_base + half_h / 2.0
};
let dominant_baseline = if has_points { "hanging" } else { "middle" };
svg.push_str(&format!(
"<text x=\"{tx:.1}\" y=\"{ty:.1}\" text-anchor=\"middle\" dominant-baseline=\"{dominant_baseline}\" \
font-family=\"{FONT_FAMILY}\" font-size=\"{QUADRANT_LABEL_FONT_SIZE}\" \
fill=\"{fill}\">{}</text>",
escape_xml(text),
fill = qt.quadrant_text_fill
));
}
// --- Border lines (external + internal, all solid) ---
// External border: 4 lines forming the outer rectangle
let ext_lines: [(f64, f64, f64, f64); 4] = [
// top
(
quadrant_left - half_ext,
quadrant_top,
quadrant_left + quadrant_width + half_ext,
quadrant_top,
),
// right
(
quadrant_left + quadrant_width,
quadrant_top + half_ext,
quadrant_left + quadrant_width,
quadrant_top + quadrant_height - half_ext,
),
// bottom
(
quadrant_left - half_ext,
quadrant_top + quadrant_height,
quadrant_left + quadrant_width + half_ext,
quadrant_top + quadrant_height,
),
// left
(
quadrant_left,
quadrant_top + half_ext,
quadrant_left,
quadrant_top + quadrant_height - half_ext,
),
];
for (x1, y1, x2, y2) in &ext_lines {
svg.push_str(&format!(
"<line x1=\"{x1:.1}\" y1=\"{y1:.1}\" x2=\"{x2:.1}\" y2=\"{y2:.1}\" \
stroke=\"{stroke}\" stroke-width=\"{EXTERNAL_BORDER_STROKE_WIDTH}\"/>",
stroke = qt.border_stroke
));
}
// Internal dividers (solid lines, no dash)
// Vertical
svg.push_str(&format!(
"<line x1=\"{x:.1}\" y1=\"{y1:.1}\" x2=\"{x:.1}\" y2=\"{y2:.1}\" \
stroke=\"{stroke}\" stroke-width=\"{INTERNAL_BORDER_STROKE_WIDTH}\"/>",
x = quadrant_left + half_w,
y1 = quadrant_top + half_ext,
y2 = quadrant_top + quadrant_height - half_ext,
stroke = qt.border_stroke
));
// Horizontal
svg.push_str(&format!(
"<line x1=\"{x1:.1}\" y1=\"{y:.1}\" x2=\"{x2:.1}\" y2=\"{y:.1}\" \
stroke=\"{stroke}\" stroke-width=\"{INTERNAL_BORDER_STROKE_WIDTH}\"/>",
x1 = quadrant_left + half_ext,
y = quadrant_top + half_h,
x2 = quadrant_left + quadrant_width - half_ext,
stroke = qt.border_stroke
));
// --- Axis labels ---
let draw_x_labels_in_middle = chart
.x_axis
.as_ref()
.is_some_and(|(_, high)| !high.is_empty());
let draw_y_labels_in_middle = chart
.y_axis
.as_ref()
.is_some_and(|(_, high)| !high.is_empty());
if let Some((low, high)) = &chart.x_axis {
let x_axis_y = if x_axis_bottom {
X_AXIS_LABEL_PADDING + quadrant_top + quadrant_height + QUADRANT_PADDING
} else {
X_AXIS_LABEL_PADDING + title_space_top
};
let low_x = quadrant_left
+ if draw_x_labels_in_middle {
half_w / 2.0
} else {
0.0
};
let text_anchor_low = if draw_x_labels_in_middle {
"middle"
} else {
"start"
};
svg.push_str(&format!(
"<text x=\"{low_x:.1}\" y=\"{x_axis_y:.1}\" text-anchor=\"{text_anchor_low}\" dominant-baseline=\"hanging\" \
font-family=\"{FONT_FAMILY}\" font-size=\"{X_AXIS_LABEL_FONT_SIZE}\" \
fill=\"{fill}\">{}</text>",
escape_xml(low),
fill = qt.axis_text_fill
));
if !high.is_empty() {
let high_x = quadrant_left
+ half_w
+ if draw_x_labels_in_middle {
half_w / 2.0
} else {
0.0
};
svg.push_str(&format!(
"<text x=\"{high_x:.1}\" y=\"{x_axis_y:.1}\" text-anchor=\"middle\" dominant-baseline=\"hanging\" \
font-family=\"{FONT_FAMILY}\" font-size=\"{X_AXIS_LABEL_FONT_SIZE}\" \
fill=\"{fill}\">{}</text>",
escape_xml(high),
fill = qt.axis_text_fill
));
}
}
if let Some((low, high)) = &chart.y_axis {
let y_axis_x = Y_AXIS_LABEL_PADDING;
// Bottom label (low value) — rotated -90° at (y_axis_x, low_y)
let low_y = quadrant_top + quadrant_height
- if draw_y_labels_in_middle {
half_h / 2.0
} else {
0.0
};
svg.push_str(&format!(
"<text x=\"0\" y=\"0\" text-anchor=\"middle\" dominant-baseline=\"hanging\" \
font-family=\"{FONT_FAMILY}\" font-size=\"{Y_AXIS_LABEL_FONT_SIZE}\" \
fill=\"{fill}\" transform=\"translate({y_axis_x:.1}, {low_y:.1}) rotate(-90)\">{}</text>",
escape_xml(low),
fill = qt.axis_text_fill
));
// Top label (high value) — rotated -90° at (y_axis_x, high_y)
if !high.is_empty() {
let high_y = quadrant_top + half_h
- if draw_y_labels_in_middle {
half_h / 2.0
} else {
0.0
};
svg.push_str(&format!(
"<text x=\"0\" y=\"0\" text-anchor=\"middle\" dominant-baseline=\"hanging\" \
font-family=\"{FONT_FAMILY}\" font-size=\"{Y_AXIS_LABEL_FONT_SIZE}\" \
fill=\"{fill}\" transform=\"translate({y_axis_x:.1}, {high_y:.1}) rotate(-90)\">{}</text>",
escape_xml(high),
fill = qt.axis_text_fill
));
}
}
// --- Title ---
if let Some(title) = &chart.title {
svg.push_str(&format!(
"<text x=\"{x:.1}\" y=\"{y:.1}\" text-anchor=\"middle\" dominant-baseline=\"hanging\" \
font-family=\"{FONT_FAMILY}\" font-size=\"{TITLE_FONT_SIZE}\" \
fill=\"{fill}\">{}</text>",
escape_xml(title),
x = CHART_WIDTH / 2.0,
y = TITLE_PADDING,
fill = qt.title_fill
));
}
// --- Data points ---
for point in &chart.points {
let px = quadrant_left + point.x.clamp(0.0, 1.0) * quadrant_width;
let py = quadrant_top + (1.0 - point.y.clamp(0.0, 1.0)) * quadrant_height;
svg.push_str(&format!(
"<circle cx=\"{px:.1}\" cy=\"{py:.1}\" r=\"{POINT_RADIUS}\" \
fill=\"{fill}\" stroke=\"{fill}\" stroke-width=\"0\"/>",
fill = qt.point_fill
));
svg.push_str(&format!(
"<text x=\"0\" y=\"0\" text-anchor=\"middle\" dominant-baseline=\"hanging\" \
font-family=\"{FONT_FAMILY}\" font-size=\"{POINT_LABEL_FONT_SIZE}\" \
fill=\"{fill}\" transform=\"translate({px:.1}, {ty:.1})\">{}</text>",
escape_xml(&point.label),
fill = qt.point_text_fill,
ty = py + POINT_TEXT_PADDING
));
}
svg.push_str("</svg>");
Ok(svg)
}
#[derive(Debug, Clone)]
struct QuadrantChart {
title: Option<String>,
x_axis: Option<(String, String)>,
y_axis: Option<(String, String)>,
quadrants: BTreeMap<i32, String>,
points: Vec<QuadrantPoint>,
}
#[derive(Debug, Clone)]
struct QuadrantPoint {
label: String,
x: f64,
y: f64,
}
fn parse_quadrant_chart(input: &str) -> Result<QuadrantChart, MermaidError> {
let lines: Vec<&str> = input.lines().collect();
let mut i = 0_usize;
while i < lines.len() {
let line = lines[i].trim();
if line.is_empty() || line.starts_with("%%") {
i += 1;
continue;
}
if line.split_whitespace().next() == Some("quadrantChart") {
i += 1;
break;
}
return Err(MermaidError::ParseError {
line: i + 1,
message: "Expected 'quadrantChart' declaration".to_string(),
});
}
let mut title: Option<String> = None;
let mut x_axis: Option<(String, String)> = None;
let mut y_axis: Option<(String, String)> = None;
let mut quadrants: BTreeMap<i32, String> = BTreeMap::new();
let mut points: Vec<QuadrantPoint> = Vec::new();
while i < lines.len() {
let raw = lines[i];
let line = raw.trim();
let line_no = i + 1;
i += 1;
if line.is_empty() || line.starts_with("%%") {
continue;
}
if let Some(rest) = line.strip_prefix("title ") {
let t = rest.trim();
if !t.is_empty() {
title = Some(t.to_string());
}
continue;
}
if let Some(rest) = line.strip_prefix("x-axis ") {
x_axis = Some(parse_axis(rest.trim(), line_no)?);
continue;
}
if let Some(rest) = line.strip_prefix("y-axis ") {
y_axis = Some(parse_axis(rest.trim(), line_no)?);
continue;
}
if let Some(rest) = line.strip_prefix("quadrant-") {
let Some((n_str, label)) = rest.split_once(' ') else {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid quadrant label: {line}"),
});
};
let n: i32 = n_str.trim().parse().map_err(|_| MermaidError::ParseError {
line: line_no,
message: format!("Invalid quadrant label: {line}"),
})?;
quadrants.insert(n, label.trim().to_string());
continue;
}
let Some((label_raw, coords_raw)) = line.split_once(':') else {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid quadrant point: {line}"),
});
};
let mut label = label_raw.trim().to_string();
if let Some(stripped) = label.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
label = stripped.to_string();
}
let coords = coords_raw.trim();
let coords = coords
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.ok_or_else(|| MermaidError::ParseError {
line: line_no,
message: format!("Invalid quadrant point: {line}"),
})?;
let parts: Vec<&str> = coords.split(',').map(|p| p.trim()).collect();
if parts.len() != 2 {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid quadrant point: {line}"),
});
}
let x: f64 = parts[0].parse().map_err(|_| MermaidError::ParseError {
line: line_no,
message: format!("Invalid quadrant point: {line}"),
})?;
let y: f64 = parts[1].parse().map_err(|_| MermaidError::ParseError {
line: line_no,
message: format!("Invalid quadrant point: {line}"),
})?;
points.push(QuadrantPoint { label, x, y });
}
if points.is_empty() {
return Err(MermaidError::ParseError {
line: 1,
message: "Quadrant chart requires at least one point".to_string(),
});
}
Ok(QuadrantChart {
title,
x_axis,
y_axis,
quadrants,
points,
})
}
fn parse_axis(s: &str, line_no: usize) -> Result<(String, String), MermaidError> {
let Some((a, b)) = s.split_once("-->") else {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid axis: {s}"),
});
};
Ok((a.trim().to_string(), b.trim().to_string()))
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+281
View File
@@ -0,0 +1,281 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
use std::f64::consts::PI;
const WIDTH: f64 = 600.0;
const HEIGHT: f64 = 600.0;
const MARGIN: f64 = 50.0;
const AXIS_SCALE_FACTOR: f64 = 1.0;
const AXIS_LABEL_FACTOR: f64 = 1.05;
const CURVE_TENSION: f64 = 0.17;
const DEFAULT_TICKS: usize = 5;
const DEFAULT_MIN: f64 = 0.0;
const AXIS_COLOR: &str = "#333333";
const GRATICULE_COLOR: &str = "#DEDEDE";
const GRATICULE_OPACITY: f64 = 0.3;
const CURVE_COLOR_0: &str = "hsl(240, 100%, 76.2745098039%)";
pub fn render_radar_diagram_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let diagram = parse_radar(mermaid_source)?;
if diagram.axes.is_empty() {
return Err(MermaidError::ParseError {
line: 1,
message: "radar diagram requires at least one axis".to_string(),
});
}
let max_value = diagram
.curves
.iter()
.flat_map(|c| c.values.iter().copied())
.fold(0.0, f64::max)
.max(1.0);
let total_width = WIDTH + 2.0 * MARGIN;
let total_height = HEIGHT + 2.0 * MARGIN;
let center_x = MARGIN + WIDTH / 2.0;
let center_y = MARGIN + HEIGHT / 2.0;
let radius = WIDTH.min(HEIGHT) / 2.0;
let mut svg = String::new();
svg.push_str(&format!(
"<svg aria-roledescription=\"radar\" role=\"graphics-document document\" height=\"{total_height}\" viewBox=\"0 0 {total_width} {total_height}\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"{total_width}\" id=\"my-svg\" style=\"background-color: {};\">",
theme.background
));
svg.push_str("<style>");
svg.push_str(&format!(
"#my-svg .radarAxisLine{{stroke:{AXIS_COLOR};stroke-width:2;}}"
));
svg.push_str(&format!(
"#my-svg .radarAxisLabel{{dominant-baseline:middle;text-anchor:middle;font-size:12px;color:{AXIS_COLOR};}}"
));
svg.push_str(&format!(
"#my-svg .radarGraticule{{fill:{GRATICULE_COLOR};fill-opacity:{GRATICULE_OPACITY};stroke:{GRATICULE_COLOR};stroke-width:1;}}"
));
svg.push_str(
"#my-svg .radarLegendText{text-anchor:start;font-size:12px;dominant-baseline:hanging;}",
);
svg.push_str(&format!(
"#my-svg .radarCurve-0{{color:{CURVE_COLOR_0};fill:{CURVE_COLOR_0};fill-opacity:0.5;stroke:{CURVE_COLOR_0};stroke-width:2;}}"
));
svg.push_str(&format!(
"#my-svg .radarLegendBox-0{{fill:{CURVE_COLOR_0};fill-opacity:0.5;stroke:{CURVE_COLOR_0};}}"
));
svg.push_str("</style>");
svg.push_str("<g/>");
svg.push_str(&format!(
"<g transform=\"translate({center_x}, {center_y})\">"
));
for i in 0..DEFAULT_TICKS {
let r = radius * (i as f64 + 1.0) / (DEFAULT_TICKS as f64);
svg.push_str(&format!("<circle class=\"radarGraticule\" r=\"{r}\"/>"));
}
let n_axes = diagram.axes.len();
for (i, axis_label) in diagram.axes.iter().enumerate() {
let angle = 2.0 * (i as f64) * PI / (n_axes as f64) - PI / 2.0;
let x2 = radius * AXIS_SCALE_FACTOR * angle.cos();
let y2 = radius * AXIS_SCALE_FACTOR * angle.sin();
svg.push_str(&format!(
"<line class=\"radarAxisLine\" y2=\"{y2}\" x2=\"{x2}\" y1=\"0\" x1=\"0\"/>"
));
let lx = radius * AXIS_LABEL_FACTOR * angle.cos();
let ly = radius * AXIS_LABEL_FACTOR * angle.sin();
svg.push_str(&format!(
"<text class=\"radarAxisLabel\" y=\"{ly}\" x=\"{lx}\">{}</text>",
escape_xml(axis_label)
));
}
for (curve_idx, curve) in diagram.curves.iter().enumerate() {
if curve.values.len() != n_axes {
continue;
}
let mut points = Vec::with_capacity(n_axes);
for (i, v) in curve.values.iter().copied().enumerate() {
let angle = 2.0 * (i as f64) * PI / (n_axes as f64) - PI / 2.0;
let r = radius * ((v.max(DEFAULT_MIN)).min(max_value) - DEFAULT_MIN)
/ (max_value - DEFAULT_MIN);
points.push((r * angle.cos(), r * angle.sin()));
}
let d = closed_round_curve(&points, CURVE_TENSION);
svg.push_str(&format!(
"<path class=\"radarCurve-{curve_idx}\" d=\"{d}\"/>"
));
let legend_x = (WIDTH / 2.0 + MARGIN) * 3.0 / 4.0;
let legend_y = -(HEIGHT / 2.0 + MARGIN) * 3.0 / 4.0;
let item_y = legend_y + curve_idx as f64 * 20.0;
svg.push_str(&format!(
"<g transform=\"translate({legend_x}, {item_y})\">"
));
svg.push_str(&format!(
"<rect class=\"radarLegendBox-{curve_idx}\" height=\"12\" width=\"12\"/>"
));
svg.push_str(&format!(
"<text class=\"radarLegendText\" y=\"0\" x=\"16\">{}</text>",
escape_xml(&curve.name)
));
svg.push_str("</g>");
}
svg.push_str("<text y=\"-350\" x=\"0\" class=\"radarTitle\"/>");
svg.push_str("</g></svg>");
Ok(svg)
}
#[derive(Debug, Clone)]
struct RadarDiagram {
axes: Vec<String>,
curves: Vec<RadarCurve>,
}
#[derive(Debug, Clone)]
struct RadarCurve {
name: String,
values: Vec<f64>,
}
fn parse_radar(input: &str) -> Result<RadarDiagram, MermaidError> {
let mut found_header = false;
let mut axes: Vec<String> = Vec::new();
let mut curves: Vec<RadarCurve> = Vec::new();
for (idx, raw) in input.lines().enumerate() {
let line_no = idx + 1;
let line = raw.trim();
if line.is_empty() || line.starts_with("%%") {
continue;
}
if !found_header {
if line.split_whitespace().next() != Some("radar-beta") {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected 'radar-beta' declaration".to_string(),
});
}
found_header = true;
continue;
}
if let Some(rest) = line.strip_prefix("axis ") {
axes = rest
.split(',')
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.map(|p| p.to_string())
.collect();
continue;
}
if let Some(rest) = line.strip_prefix("curve ") {
let (name, values) = parse_curve(rest.trim(), line_no)?;
curves.push(RadarCurve { name, values });
continue;
}
}
if !found_header {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'radar-beta' declaration".to_string(),
});
}
Ok(RadarDiagram { axes, curves })
}
fn parse_curve(s: &str, line: usize) -> Result<(String, Vec<f64>), MermaidError> {
let Some((name, rest)) = s.split_once('{') else {
return Err(MermaidError::ParseError {
line,
message: format!("Invalid curve: {s}"),
});
};
let name = name.trim();
let inner = rest
.strip_suffix('}')
.ok_or_else(|| MermaidError::ParseError {
line,
message: format!("Invalid curve: {s}"),
})?
.trim();
let mut values: Vec<f64> = Vec::new();
for part in inner.split(',') {
let p = part.trim();
if p.is_empty() {
continue;
}
let v: f64 = p.parse().map_err(|_| MermaidError::ParseError {
line,
message: format!("Invalid curve value: {p}"),
})?;
values.push(v);
}
Ok((name.to_string(), values))
}
fn closed_round_curve(points: &[(f64, f64)], tension: f64) -> String {
if points.is_empty() {
return String::new();
}
let n = points.len();
let mut d = String::new();
d.push_str(&format!("M{},{}", points[0].0, points[0].1));
for i in 0..n {
let p0 = points[(i + n - 1) % n];
let p1 = points[i];
let p2 = points[(i + 1) % n];
let p3 = points[(i + 2) % n];
let cp1 = (
p1.0 + (p2.0 - p0.0) * tension,
p1.1 + (p2.1 - p0.1) * tension,
);
let cp2 = (
p2.0 - (p3.0 - p1.0) * tension,
p2.1 - (p3.1 - p1.1) * tension,
);
d.push_str(&format!(
" C{},{} {},{} {},{}",
cp1.0, cp1.1, cp2.0, cp2.1, p2.0, p2.1
));
}
d.push_str(" Z");
d
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
+874
View File
@@ -0,0 +1,874 @@
use std::collections::HashMap;
use crate::error::MermaidError;
use crate::text_wrap::{line_width, DEFAULT_CHAR_WIDTH};
use crate::theme::MermaidTheme;
use dagre_rust::layout::layout as dagre_layout;
use dagre_rust::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::Graph;
const BOX_PADDING: f64 = 20.0;
const BOX_GAP: f64 = 20.0;
const LINE_HEIGHT: f64 = 24.0;
const NODE_SEP: f64 = 50.0;
const RANK_SEP: f64 = 50.0;
const GRAPH_MARGIN: f64 = 8.0;
pub fn render_requirement_diagram_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let diagram = parse_requirement_diagram(mermaid_source)?;
let layout = compute_layout(&diagram);
Ok(render_svg(&layout, theme))
}
#[derive(Debug, Clone, Copy)]
enum Direction {
Tb,
Bt,
Lr,
Rl,
}
impl Direction {
fn as_rankdir(self) -> &'static str {
match self {
Direction::Tb => "tb",
Direction::Bt => "bt",
Direction::Lr => "lr",
Direction::Rl => "rl",
}
}
}
#[derive(Debug, Clone)]
struct RequirementDiagram {
direction: Direction,
nodes: HashMap<String, ReqNode>,
relations: Vec<Relation>,
}
#[derive(Debug, Clone)]
enum ReqNode {
Requirement(RequirementNode),
Element(ElementNode),
}
#[derive(Debug, Clone)]
struct RequirementNode {
name: String,
requirement_id: String,
text: String,
risk: String,
verify_method: String,
req_type: String,
}
#[derive(Debug, Clone)]
struct ElementNode {
name: String,
element_type: String,
doc_ref: String,
}
#[derive(Debug, Clone)]
struct Relation {
src: String,
dst: String,
rel_type: String,
}
#[derive(Debug, Clone)]
struct NodeLayout {
id: String,
x: f64,
y: f64,
width: f64,
height: f64,
labels: Vec<LabelLayout>,
divider_y: Option<f64>,
}
#[derive(Debug, Clone)]
struct EdgeLayout {
id: String,
rel_type: String,
label: String,
points: Vec<(f64, f64)>,
label_pos: Option<(f64, f64)>,
label_width: f64,
label_height: f64,
}
#[derive(Debug, Clone)]
struct DiagramLayout {
nodes: HashMap<String, NodeLayout>,
edges: Vec<EdgeLayout>,
width: f64,
height: f64,
}
#[derive(Debug, Clone)]
struct LabelLayout {
text: String,
x: f64,
y: f64,
anchor: &'static str,
bold: bool,
}
fn parse_requirement_diagram(input: &str) -> Result<RequirementDiagram, MermaidError> {
let mut found_header = false;
let mut direction = Direction::Tb;
let mut nodes: HashMap<String, ReqNode> = HashMap::new();
let mut relations: Vec<Relation> = Vec::new();
let lines: Vec<&str> = input.lines().collect();
let mut i = 0_usize;
while i < lines.len() {
let raw = lines[i];
let line_no = i + 1;
let line = raw.trim();
i += 1;
if line.is_empty() || line.starts_with("%%") {
continue;
}
if !found_header {
if first_diagram_type_token(line) != Some("requirementDiagram") {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected 'requirementDiagram' declaration".to_string(),
});
}
found_header = true;
continue;
}
if let Some(rest) = line.strip_prefix("direction ") {
direction = parse_direction(rest.trim(), line_no)?;
continue;
}
if let Some((node, new_i)) = try_parse_requirement_or_element(&lines, i - 1)? {
let id = match &node {
ReqNode::Requirement(r) => r.name.clone(),
ReqNode::Element(e) => e.name.clone(),
};
nodes.insert(id, node);
i = new_i;
continue;
}
if let Some(rel) = try_parse_relation(line, line_no)? {
relations.push(rel);
continue;
}
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Unrecognized requirementDiagram line: {line}"),
});
}
if !found_header {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'requirementDiagram' declaration".to_string(),
});
}
Ok(RequirementDiagram {
direction,
nodes,
relations,
})
}
fn first_diagram_type_token(line: &str) -> Option<&str> {
line.split_whitespace().next()
}
fn parse_direction(s: &str, line: usize) -> Result<Direction, MermaidError> {
match s.to_uppercase().as_str() {
"TB" | "TD" => Ok(Direction::Tb),
"BT" => Ok(Direction::Bt),
"LR" => Ok(Direction::Lr),
"RL" => Ok(Direction::Rl),
_ => Err(MermaidError::ParseError {
line,
message: format!("Invalid direction: {s}"),
}),
}
}
fn try_parse_requirement_or_element(
lines: &[&str],
start_idx: usize,
) -> Result<Option<(ReqNode, usize)>, MermaidError> {
let line = lines[start_idx].trim();
if line.is_empty() || line.starts_with("%%") {
return Ok(None);
}
let (kind, name, has_open_brace) = if let Some((kw, rest)) = split_once_ws(line) {
let kind = kw;
let (name_raw, tail) = split_once_ws(rest).unwrap_or((rest, ""));
let name = name_raw.trim();
let has_open_brace = tail.contains('{') || name.ends_with('{');
(kind, name.trim_end_matches('{').trim(), has_open_brace)
} else {
return Ok(None);
};
let kind_lower = kind.to_lowercase();
if kind_lower != "element"
&& kind_lower != "requirement"
&& kind_lower != "functionalrequirement"
&& kind_lower != "interfacerequirement"
&& kind_lower != "performancerequirement"
&& kind_lower != "physicalrequirement"
&& kind_lower != "designconstraint"
{
return Ok(None);
}
if name.is_empty() {
return Err(MermaidError::ParseError {
line: start_idx + 1,
message: format!("Expected name after '{kind}'"),
});
}
let mut i = start_idx + 1;
if !has_open_brace {
while i < lines.len() {
let l = lines[i].trim();
if l.is_empty() || l.starts_with("%%") {
i += 1;
continue;
}
if l.starts_with('{') {
i += 1;
break;
}
return Ok(None);
}
}
let mut props: HashMap<String, String> = HashMap::new();
while i < lines.len() {
let raw = lines[i];
let line_no = i + 1;
let l = raw.trim();
i += 1;
if l.is_empty() || l.starts_with("%%") {
continue;
}
if l.starts_with('}') {
break;
}
let Some((k, v)) = l.split_once(':') else {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid property line: {l}"),
});
};
let key = k.trim().to_string();
let mut value = v.trim().trim_end_matches(',').trim().to_string();
value = strip_quotes(&value);
props.insert(key, value);
}
if kind_lower == "element" {
let node = ReqNode::Element(ElementNode {
name: name.to_string(),
element_type: props.get("type").cloned().unwrap_or_else(String::new),
doc_ref: props
.get("docref")
.or_else(|| props.get("docRef"))
.cloned()
.unwrap_or_else(String::new),
});
return Ok(Some((node, i)));
}
let req_type = match kind_lower.as_str() {
"functionalrequirement" => "Functional Requirement",
"interfacerequirement" => "Interface Requirement",
"performancerequirement" => "Performance Requirement",
"physicalrequirement" => "Physical Requirement",
"designconstraint" => "Design Constraint",
_ => "Requirement",
}
.to_string();
let risk = props.get("risk").cloned().unwrap_or_else(String::new);
let verify_method = props
.get("verifyMethod")
.or_else(|| props.get("verifymethod"))
.cloned()
.unwrap_or_else(String::new);
let node = ReqNode::Requirement(RequirementNode {
name: name.to_string(),
requirement_id: props.get("id").cloned().unwrap_or_else(String::new),
text: props.get("text").cloned().unwrap_or_else(String::new),
risk: normalize_risk(&risk),
verify_method: normalize_verify_method(&verify_method),
req_type,
});
Ok(Some((node, i)))
}
fn normalize_risk(s: &str) -> String {
match s.trim().to_lowercase().as_str() {
"low" => "Low".to_string(),
"medium" => "Medium".to_string(),
"high" => "High".to_string(),
_ => s.trim().to_string(),
}
}
fn normalize_verify_method(s: &str) -> String {
match s.trim().to_lowercase().as_str() {
"analysis" => "Analysis".to_string(),
"demonstration" => "Demonstration".to_string(),
"inspection" => "Inspection".to_string(),
"test" => "Test".to_string(),
_ => s.trim().to_string(),
}
}
fn strip_quotes(s: &str) -> String {
let s = s.trim();
if let Some(inner) = s.strip_prefix('"').and_then(|t| t.strip_suffix('"')) {
return inner.to_string();
}
if let Some(inner) = s.strip_prefix('\'').and_then(|t| t.strip_suffix('\'')) {
return inner.to_string();
}
s.to_string()
}
fn split_once_ws(s: &str) -> Option<(&str, &str)> {
let mut it = s.splitn(2, char::is_whitespace);
let a = it.next()?;
let b = it.next().unwrap_or("");
Some((a, b.trim()))
}
fn try_parse_relation(line: &str, line_no: usize) -> Result<Option<Relation>, MermaidError> {
let line = line.trim();
if line.is_empty() || line.starts_with("%%") {
return Ok(None);
}
if let Some((lhs, rhs)) = line.split_once("->") {
let dst = rhs.trim();
let tokens: Vec<&str> = lhs.split_whitespace().filter(|t| *t != "-").collect();
if tokens.len() < 2 || dst.is_empty() {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid relationship: {line}"),
});
}
let src = tokens[0].trim();
let rel = tokens[1].trim();
if src.is_empty() || rel.is_empty() {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid relationship: {line}"),
});
}
return Ok(Some(Relation {
src: src.to_string(),
dst: dst.to_string(),
rel_type: rel.to_string(),
}));
}
if let Some((lhs, rhs)) = line.split_once("<-") {
let dst = lhs.trim();
let tokens: Vec<&str> = rhs.split_whitespace().filter(|t| *t != "-").collect();
if tokens.len() < 2 || dst.is_empty() {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid relationship: {line}"),
});
}
let rel = tokens[0].trim();
let src = tokens[1].trim();
if src.is_empty() || rel.is_empty() {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid relationship: {line}"),
});
}
return Ok(Some(Relation {
src: src.to_string(),
dst: dst.to_string(),
rel_type: rel.to_string(),
}));
}
Ok(None)
}
fn compute_layout(diagram: &RequirementDiagram) -> DiagramLayout {
let mut node_metrics: HashMap<String, (f64, f64, Vec<LabelLayout>, Option<f64>)> =
HashMap::new();
for (id, node) in &diagram.nodes {
let metrics = compute_requirement_box_layout(node);
node_metrics.insert(id.clone(), metrics);
}
type DagreGraph = Graph<GraphConfig, GraphNode, GraphEdge>;
let mut g: DagreGraph = Graph::new(Some(graphlib_rust::GraphOption {
directed: Some(true),
multigraph: Some(true),
compound: Some(false),
}));
g.set_graph(GraphConfig {
rankdir: Some(diagram.direction.as_rankdir().to_string()),
nodesep: Some(NODE_SEP as f32),
ranksep: Some(RANK_SEP as f32),
edgesep: Some(20.0),
marginx: Some(GRAPH_MARGIN as f32),
marginy: Some(GRAPH_MARGIN as f32),
..Default::default()
});
for (id, (w, h, _, _)) in &node_metrics {
g.set_node(
id.clone(),
Some(GraphNode {
width: *w as f32,
height: *h as f32,
..Default::default()
}),
);
}
let mut edge_keys: Vec<(String, String)> = Vec::new();
for rel in &diagram.relations {
let edge_label_text = format!("<<{}>>", rel.rel_type);
let label_width = line_width(&edge_label_text, DEFAULT_CHAR_WIDTH);
let label_height = LINE_HEIGHT;
let edge_label = GraphEdge {
labelpos: Some("c".to_string()),
width: Some(label_width as f32),
height: Some(label_height as f32),
..Default::default()
};
let _ = g.set_edge(&rel.src, &rel.dst, Some(edge_label), None);
edge_keys.push((rel.src.clone(), rel.dst.clone()));
}
dagre_layout(&mut g);
let mut positions: HashMap<String, (f64, f64)> = HashMap::new();
for node_id in g.nodes() {
if let Some(node) = g.node(&node_id) {
positions.insert(node_id, (node.x as f64, node.y as f64));
}
}
let mut edges: Vec<EdgeLayout> = Vec::new();
for (idx, (from, to)) in edge_keys.iter().enumerate() {
let Some(edge) = g.edge(from, to, None) else {
continue;
};
let points: Vec<(f64, f64)> = edge
.points
.as_ref()
.map(|pts| pts.iter().map(|p| (p.x as f64, p.y as f64)).collect())
.unwrap_or_default();
let label_pos = if edge.width.unwrap_or(0.0) > 0.0 || edge.height.unwrap_or(0.0) > 0.0 {
Some((edge.x as f64, edge.y as f64))
} else {
None
};
let rel_type = diagram
.relations
.get(idx)
.map(|r| r.rel_type.clone())
.unwrap_or_default();
let label = format!("<<{}>>", rel_type);
let label_width = edge.width.unwrap_or(0.0) as f64;
let label_height = edge.height.unwrap_or(0.0) as f64;
edges.push(EdgeLayout {
id: format!("{from}-{to}-{idx}"),
rel_type,
label,
points,
label_pos,
label_width,
label_height,
});
}
let mut layout_nodes: HashMap<String, NodeLayout> = HashMap::new();
for (id, (x, y)) in &positions {
let Some((w, h, labels, divider_y)) = node_metrics.get(id).cloned() else {
continue;
};
layout_nodes.insert(
id.clone(),
NodeLayout {
id: id.clone(),
x: *x,
y: *y,
width: w,
height: h,
labels,
divider_y,
},
);
}
let (min_x, min_y, max_x, max_y) = compute_bounds(&layout_nodes, &edges);
let dx = GRAPH_MARGIN - min_x;
let dy = GRAPH_MARGIN - min_y;
for node in layout_nodes.values_mut() {
node.x += dx;
node.y += dy;
}
for edge in &mut edges {
for p in &mut edge.points {
p.0 += dx;
p.1 += dy;
}
if let Some((x, y)) = edge.label_pos {
edge.label_pos = Some((x + dx, y + dy));
}
}
let width = (max_x - min_x) + GRAPH_MARGIN * 2.0;
let height = (max_y - min_y) + GRAPH_MARGIN * 2.0;
DiagramLayout {
nodes: layout_nodes,
edges,
width,
height,
}
}
fn compute_bounds(
nodes: &HashMap<String, NodeLayout>,
edges: &[EdgeLayout],
) -> (f64, f64, f64, f64) {
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
for node in nodes.values() {
let left = node.x - node.width / 2.0;
let right = node.x + node.width / 2.0;
let top = node.y - node.height / 2.0;
let bottom = node.y + node.height / 2.0;
min_x = min_x.min(left);
min_y = min_y.min(top);
max_x = max_x.max(right);
max_y = max_y.max(bottom);
}
for edge in edges {
for (x, y) in &edge.points {
min_x = min_x.min(*x);
min_y = min_y.min(*y);
max_x = max_x.max(*x);
max_y = max_y.max(*y);
}
if let Some((x, y)) = edge.label_pos {
let left = x - edge.label_width / 2.0;
let right = x + edge.label_width / 2.0;
let top = y - edge.label_height / 2.0;
let bottom = y + edge.label_height / 2.0;
min_x = min_x.min(left);
min_y = min_y.min(top);
max_x = max_x.max(right);
max_y = max_y.max(bottom);
}
}
if !min_x.is_finite() {
min_x = 0.0;
max_x = 0.0;
}
if !min_y.is_finite() {
min_y = 0.0;
max_y = 0.0;
}
(min_x, min_y, max_x, max_y)
}
fn compute_requirement_box_layout(node: &ReqNode) -> (f64, f64, Vec<LabelLayout>, Option<f64>) {
let (type_line, name_line, body_lines) = match node {
ReqNode::Requirement(r) => {
let mut body = Vec::new();
if !r.requirement_id.is_empty() {
body.push(format!("ID: {}", r.requirement_id));
}
if !r.text.is_empty() {
body.push(format!("Text: {}", r.text));
}
if !r.risk.is_empty() {
body.push(format!("Risk: {}", r.risk));
}
if !r.verify_method.is_empty() {
body.push(format!("Verification: {}", r.verify_method));
}
(format!("<<{}>>", r.req_type), r.name.clone(), body)
}
ReqNode::Element(e) => {
let mut body = Vec::new();
if !e.element_type.is_empty() {
body.push(format!("Type: {}", e.element_type));
}
if !e.doc_ref.is_empty() {
body.push(format!("Doc Ref: {}", e.doc_ref));
}
("<<Element>>".to_string(), e.name.clone(), body)
}
};
let type_width = line_width(&type_line, DEFAULT_CHAR_WIDTH);
let name_width = line_width(&name_line, DEFAULT_CHAR_WIDTH);
let mut max_width = type_width.max(name_width);
for line in &body_lines {
max_width = max_width.max(line_width(line, DEFAULT_CHAR_WIDTH));
}
let content_height =
LINE_HEIGHT + LINE_HEIGHT + BOX_GAP + body_lines.len() as f64 * LINE_HEIGHT;
let total_width = max_width + BOX_PADDING;
let total_height = content_height + BOX_PADDING;
let mut labels: Vec<LabelLayout> = Vec::new();
labels.push(LabelLayout {
text: type_line,
x: 0.0,
y: 0.0 - content_height / 2.0 + BOX_PADDING / 2.0,
anchor: "middle",
bold: false,
});
labels.push(LabelLayout {
text: name_line,
x: 0.0,
y: LINE_HEIGHT - content_height / 2.0 + BOX_PADDING / 2.0,
anchor: "middle",
bold: true,
});
let left_x = -total_width / 2.0 + BOX_PADDING / 2.0;
let mut y_offset = LINE_HEIGHT + LINE_HEIGHT + BOX_GAP;
for line in body_lines {
labels.push(LabelLayout {
text: line,
x: left_x,
y: y_offset - content_height / 2.0 + BOX_PADDING / 2.0,
anchor: "start",
bold: false,
});
y_offset += LINE_HEIGHT;
}
let divider_y = if y_offset > LINE_HEIGHT + LINE_HEIGHT + BOX_GAP {
Some(-total_height / 2.0 + (LINE_HEIGHT + LINE_HEIGHT + BOX_GAP))
} else {
None
};
(total_width, total_height, labels, divider_y)
}
fn render_svg(layout: &DiagramLayout, theme: &MermaidTheme) -> String {
let mut svg = String::new();
svg.push_str(&format!(
"<svg aria-roledescription=\"requirement\" role=\"graphics-document document\" viewBox=\"0 0 {w} {h}\" style=\"max-width: {w}px; background-color: {};\" class=\"requirementDiagram\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" id=\"my-svg\">",
theme.background,
w = layout.width,
h = layout.height
));
svg.push_str(&format!(
"<style>#my-svg{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:{};}}#my-svg .relationshipLine{{stroke:{};stroke-width:1;}}#my-svg .node rect{{fill:{};stroke:{};stroke-width:1.3;}}#my-svg .label{{font-family:\"trebuchet ms\",verdana,arial,sans-serif;color:{};}}#my-svg .label text,#my-svg span{{fill:{};color:{};}}#my-svg .labelBkg{{background-color:rgba(232,232,232, 0.8);}}</style>",
theme.text_color,
theme.edge_color,
theme.node_fill,
theme.node_stroke,
theme.text_color,
theme.text_color,
theme.text_color
));
svg.push_str("<g>");
svg.push_str(&format!(
"<defs><marker orient=\"auto\" markerHeight=\"20\" markerWidth=\"20\" refY=\"10\" refX=\"0\" id=\"my-svg_requirement-requirement_containsStart\"><g fill=\"none\" stroke=\"{}\" stroke-width=\"1\"><circle r=\"9\" cy=\"10\" cx=\"10\"/><line y2=\"10\" y1=\"10\" x2=\"19\" x1=\"1\"/><line x2=\"10\" x1=\"10\" y2=\"19\" y1=\"1\"/></g></marker></defs>",
theme.edge_color
));
svg.push_str(&format!(
"<defs><marker orient=\"auto\" markerHeight=\"20\" markerWidth=\"20\" refY=\"10\" refX=\"20\" id=\"my-svg_requirement-requirement_arrowEnd\"><path d=\"M0,0 L20,10 M20,10 L0,20\" fill=\"none\" stroke=\"{}\" stroke-width=\"1\"/></marker></defs>",
theme.edge_color
));
svg.push_str("<g class=\"root\">");
svg.push_str("<g class=\"clusters\"/>");
svg.push_str("<g class=\"edgePaths\">");
for edge in &layout.edges {
let d = points_to_path_d(&edge.points);
let is_contains = edge.rel_type == "contains";
let dash = if is_contains {
""
} else {
"stroke-dasharray: 10,7;"
};
let marker_end = if is_contains {
""
} else {
" marker-end=\"url(#my-svg_requirement-requirement_arrowEnd)\""
};
svg.push_str(&format!(
"<path{marker_end} style=\"fill:none;{dash}\" class=\"edge-thickness-normal edge-pattern-dashed relationshipLine\" id=\"{}\" d=\"{}\"/>",
escape_xml(&edge.id),
d
));
}
svg.push_str("</g>");
svg.push_str("<g class=\"edgeLabels\">");
for edge in &layout.edges {
let Some((x, y)) = edge.label_pos else {
continue;
};
let x2 = -edge.label_width / 2.0;
let y2 = -edge.label_height / 2.0;
svg.push_str(&format!(
"<g transform=\"translate({x}, {y})\" class=\"edgeLabel\">",
x = x,
y = y,
));
svg.push_str(&format!(
"<rect x=\"{x2}\" y=\"{y2}\" width=\"{w}\" height=\"{h}\" fill=\"#E8E8E8\" fill-opacity=\"0.8\" stroke=\"none\"/>",
x2 = x2,
y2 = y2,
w = edge.label_width,
h = edge.label_height,
));
svg.push_str(&format!(
"<text x=\"0\" y=\"0\" text-anchor=\"middle\" dominant-baseline=\"middle\" fill=\"{}\">{}</text>",
theme.text_color,
escape_xml(&edge.label)
));
svg.push_str("</g>");
}
svg.push_str("</g>");
svg.push_str("<g class=\"nodes\">");
let mut node_ids: Vec<&String> = layout.nodes.keys().collect();
node_ids.sort();
for node_id in node_ids {
let Some(node_layout) = layout.nodes.get(node_id) else {
continue;
};
let x2 = -node_layout.width / 2.0;
let y2 = -node_layout.height / 2.0;
svg.push_str(&format!(
"<g transform=\"translate({x},{y})\" id=\"{id}\" class=\"node default\">",
x = node_layout.x,
y = node_layout.y,
id = escape_xml(&node_layout.id)
));
svg.push_str(&format!(
"<rect x=\"{x2}\" y=\"{y2}\" width=\"{w}\" height=\"{h}\"/>",
x2 = x2,
y2 = y2,
w = node_layout.width,
h = node_layout.height
));
if let Some(divider_y) = node_layout.divider_y {
svg.push_str(&format!(
"<line x1=\"{x1}\" y1=\"{y}\" x2=\"{x2}\" y2=\"{y}\" stroke=\"{}\" stroke-width=\"1.3\"/>",
theme.node_stroke,
x1 = x2,
x2 = x2 + node_layout.width,
y = divider_y
));
}
for label in &node_layout.labels {
let font_weight = if label.bold {
" font-weight=\"bold\""
} else {
""
};
svg.push_str(&format!(
"<text x=\"{x}\" y=\"{y}\" text-anchor=\"{anchor}\" dominant-baseline=\"middle\" fill=\"{}\"{font_weight}>{}</text>",
theme.text_color,
escape_xml(&label.text),
x = label.x,
y = label.y,
anchor = label.anchor,
));
}
svg.push_str("</g>");
}
svg.push_str("</g>");
svg.push_str("</g></g></svg>");
svg
}
fn points_to_path_d(points: &[(f64, f64)]) -> String {
if points.is_empty() {
return String::new();
}
let mut d = String::new();
let (x0, y0) = points[0];
d.push_str(&format!("M{x0},{y0}"));
for (x, y) in &points[1..] {
d.push_str(&format!("L{x},{y}"));
}
d
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+436
View File
@@ -0,0 +1,436 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
use std::collections::{BTreeMap, BTreeSet, HashMap};
const WIDTH: f64 = 600.0;
const HEIGHT: f64 = 400.0;
const NODE_WIDTH: f64 = 10.0;
const NODE_PADDING: f64 = 25.0;
const LABEL_OFFSET: f64 = 6.0;
const NODE_COLORS: [&str; 10] = [
"#4e79a7", "#f28e2c", "#e15759", "#76b7b2", "#59a14f", "#edc948", "#b07aa1", "#9c755f",
"#bab0ab", "#ff9da7",
];
pub fn render_sankey_diagram_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let diagram = parse_sankey(mermaid_source)?;
let layout = compute_layout(&diagram);
let mut svg = String::new();
svg.push_str(&format!(
"<svg aria-roledescription=\"sankey\" role=\"graphics-document document\" viewBox=\"0 0 {WIDTH} {HEIGHT}\" style=\"max-width: {WIDTH}px; background-color: {};\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"100%\" id=\"my-svg\">",
theme.background
));
svg.push_str("<g/>");
svg.push_str(&format!(
"<rect x=\"0\" y=\"0\" width=\"{WIDTH}\" height=\"{HEIGHT}\" fill=\"{}\"/>",
theme.background
));
svg.push_str("<defs>");
for (idx, link) in layout.links.iter().enumerate() {
let grad_id = format!("linearGradient-{idx}");
svg.push_str(&format!(
"<linearGradient id=\"{grad_id}\" gradientUnits=\"userSpaceOnUse\" x1=\"{x1}\" x2=\"{x2}\">",
x1 = link.x0,
x2 = link.x1,
));
svg.push_str(&format!(
"<stop offset=\"0%\" stop-color=\"{}\"/>",
escape_xml(&link.source_color)
));
svg.push_str(&format!(
"<stop offset=\"100%\" stop-color=\"{}\"/>",
escape_xml(&link.target_color)
));
svg.push_str("</linearGradient>");
}
svg.push_str("</defs>");
svg.push_str("<g class=\"nodes\">");
for node in &layout.nodes {
svg.push_str(&format!(
"<g class=\"node\" id=\"{}\" transform=\"translate({},{})\" x=\"{}\" y=\"{}\">",
escape_xml(&node.dom_id),
node.x,
node.y,
node.x,
node.y
));
svg.push_str(&format!(
"<rect height=\"{}\" width=\"{}\" fill=\"{}\"/>",
node.height,
NODE_WIDTH,
escape_xml(&node.color)
));
svg.push_str("</g>");
}
svg.push_str("</g>");
svg.push_str("<g class=\"node-labels\" font-size=\"14\">");
for node in &layout.nodes {
let center_y = node.y + node.height / 2.0;
if node.depth == layout.max_depth {
let x = node.x - LABEL_OFFSET;
svg.push_str(&format!(
"<text x=\"{x}\" y=\"{center_y}\" dy=\"0em\" text-anchor=\"end\">{}</text>",
escape_xml(&node.display_label)
));
} else {
let x = node.x + NODE_WIDTH + LABEL_OFFSET;
svg.push_str(&format!(
"<text x=\"{x}\" y=\"{center_y}\" dy=\"0em\" text-anchor=\"start\">{}</text>",
escape_xml(&node.display_label)
));
}
}
svg.push_str("</g>");
svg.push_str("<g class=\"links\" fill=\"none\" stroke-opacity=\"0.5\">");
for (idx, link) in layout.links.iter().enumerate() {
let grad_id = format!("linearGradient-{idx}");
let mx = (link.x0 + link.x1) / 2.0;
let d = format!(
"M{sx},{sy}C{mx},{sy},{mx},{ty},{tx},{ty}",
sx = link.x0,
sy = link.y0,
mx = mx,
tx = link.x1,
ty = link.y1,
);
svg.push_str("<g class=\"link\" style=\"mix-blend-mode: multiply;\">");
svg.push_str(&format!(
"<path d=\"{d}\" stroke=\"url(#{grad_id})\" stroke-width=\"{}\"/>",
link.thickness
));
svg.push_str("</g>");
}
svg.push_str("</g>");
svg.push_str("</svg>");
Ok(svg)
}
#[derive(Debug, Clone)]
struct SankeyDiagram {
links: Vec<SankeyLink>,
node_order: Vec<String>,
}
#[derive(Debug, Clone)]
struct SankeyLink {
source: String,
target: String,
value: f64,
}
#[derive(Debug, Clone)]
struct SankeyNodeLayout {
name: String,
display_label: String,
dom_id: String,
depth: usize,
x: f64,
y: f64,
height: f64,
color: String,
}
#[derive(Debug, Clone)]
struct SankeyLinkLayout {
x0: f64,
y0: f64,
x1: f64,
y1: f64,
thickness: f64,
source_color: String,
target_color: String,
}
#[derive(Debug, Clone)]
struct SankeyLayout {
nodes: Vec<SankeyNodeLayout>,
links: Vec<SankeyLinkLayout>,
max_depth: usize,
}
fn parse_sankey(input: &str) -> Result<SankeyDiagram, MermaidError> {
let mut found_header = false;
let mut links: Vec<SankeyLink> = Vec::new();
let mut node_seen: BTreeSet<String> = BTreeSet::new();
let mut node_order: Vec<String> = Vec::new();
for (idx, raw) in input.lines().enumerate() {
let line_no = idx + 1;
let line = raw.trim();
if line.is_empty() || line.starts_with("%%") {
continue;
}
if !found_header {
if line.split_whitespace().next() != Some("sankey-beta") {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected 'sankey-beta' declaration".to_string(),
});
}
found_header = true;
continue;
}
let parts: Vec<&str> = line.split(',').map(|p| p.trim()).collect();
if parts.len() != 3 {
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Invalid sankey link: {line}"),
});
}
let source = parts[0].to_string();
let target = parts[1].to_string();
let value: f64 = parts[2].parse().map_err(|_| MermaidError::ParseError {
line: line_no,
message: format!("Invalid sankey value: {}", parts[2]),
})?;
if node_seen.insert(source.clone()) {
node_order.push(source.clone());
}
if node_seen.insert(target.clone()) {
node_order.push(target.clone());
}
links.push(SankeyLink {
source,
target,
value,
});
}
if !found_header {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'sankey-beta' declaration".to_string(),
});
}
if links.is_empty() {
return Err(MermaidError::ParseError {
line: 1,
message: "sankey diagram requires at least one link".to_string(),
});
}
Ok(SankeyDiagram { links, node_order })
}
fn compute_layout(diagram: &SankeyDiagram) -> SankeyLayout {
let mut in_sum: HashMap<&str, f64> = HashMap::new();
let mut out_sum: HashMap<&str, f64> = HashMap::new();
for link in &diagram.links {
*out_sum.entry(link.source.as_str()).or_insert(0.0) += link.value;
*in_sum.entry(link.target.as_str()).or_insert(0.0) += link.value;
}
let mut value_by_node: HashMap<&str, f64> = HashMap::new();
for node in &diagram.node_order {
let v_in = *in_sum.get(node.as_str()).unwrap_or(&0.0);
let v_out = *out_sum.get(node.as_str()).unwrap_or(&0.0);
value_by_node.insert(node.as_str(), v_in.max(v_out));
}
let mut preds: HashMap<&str, Vec<&str>> = HashMap::new();
for link in &diagram.links {
preds
.entry(link.target.as_str())
.or_default()
.push(link.source.as_str());
preds.entry(link.source.as_str()).or_default();
}
let mut depth: HashMap<&str, usize> = HashMap::new();
for node in &diagram.node_order {
depth.insert(node.as_str(), 0);
}
let mut changed = true;
for _ in 0..diagram.node_order.len().saturating_mul(2) {
if !changed {
break;
}
changed = false;
for node in &diagram.node_order {
let node = node.as_str();
let p = preds.get(node).map(|v| v.as_slice()).unwrap_or(&[]);
let mut d = 0_usize;
for &pred in p {
d = d.max(depth.get(pred).copied().unwrap_or(0).saturating_add(1));
}
if depth.get(node).copied().unwrap_or(0) != d {
depth.insert(node, d);
changed = true;
}
}
}
let max_depth = depth.values().copied().max().unwrap_or(0);
let layers = max_depth.max(1) + 1;
let mut nodes_by_depth: BTreeMap<usize, Vec<&str>> = BTreeMap::new();
for node in &diagram.node_order {
let d = depth.get(node.as_str()).copied().unwrap_or(0);
nodes_by_depth.entry(d).or_default().push(node.as_str());
}
let mut ky = f64::INFINITY;
for nodes in nodes_by_depth.values() {
let sum: f64 = nodes
.iter()
.map(|n| value_by_node.get(n).copied().unwrap_or(0.0))
.sum();
if sum <= 0.0 {
continue;
}
let n = nodes.len() as f64;
let available = HEIGHT - (n - 1.0).max(0.0) * NODE_PADDING;
ky = ky.min(available / sum);
}
if !ky.is_finite() {
ky = 1.0;
}
let mut node_layout: HashMap<&str, SankeyNodeLayout> = HashMap::new();
for (d, nodes) in &nodes_by_depth {
let sum: f64 = nodes
.iter()
.map(|n| value_by_node.get(n).copied().unwrap_or(0.0))
.sum();
let used = sum * ky + (nodes.len().saturating_sub(1) as f64) * NODE_PADDING;
let mut y = (HEIGHT - used) / 2.0;
let x = if layers <= 1 {
0.0
} else {
(WIDTH - NODE_WIDTH) * (*d as f64) / ((layers - 1) as f64)
};
for &name in nodes {
let v = value_by_node.get(name).copied().unwrap_or(0.0);
let h = v * ky;
let global_idx = diagram
.node_order
.iter()
.position(|n| n == name)
.unwrap_or(0);
let dom_id = format!("node-{}", global_idx + 1);
let color = NODE_COLORS
.get(global_idx)
.copied()
.unwrap_or(NODE_COLORS[0])
.to_string();
node_layout.insert(
name,
SankeyNodeLayout {
name: name.to_string(),
display_label: format_sankey_node_label(name, v),
dom_id,
depth: *d,
x,
y,
height: h,
color,
},
);
y += h + NODE_PADDING;
}
}
let mut out_offset: HashMap<&str, f64> = HashMap::new();
let mut in_offset: HashMap<&str, f64> = HashMap::new();
for node in &diagram.node_order {
out_offset.insert(node.as_str(), 0.0);
in_offset.insert(node.as_str(), 0.0);
}
let mut link_layouts = Vec::new();
for link in &diagram.links {
let Some(source_node) = node_layout.get(link.source.as_str()) else {
continue;
};
let Some(target_node) = node_layout.get(link.target.as_str()) else {
continue;
};
let thickness = link.value * ky;
let so = *out_offset.get(link.source.as_str()).unwrap_or(&0.0);
let ti = *in_offset.get(link.target.as_str()).unwrap_or(&0.0);
let y0 = source_node.y + so + thickness / 2.0;
let y1 = target_node.y + ti + thickness / 2.0;
out_offset.insert(link.source.as_str(), so + thickness);
in_offset.insert(link.target.as_str(), ti + thickness);
let x0 = source_node.x + NODE_WIDTH;
let x1 = target_node.x;
link_layouts.push(SankeyLinkLayout {
x0,
y0,
x1,
y1,
thickness,
source_color: source_node.color.clone(),
target_color: target_node.color.clone(),
});
}
let mut nodes_vec: Vec<SankeyNodeLayout> = diagram
.node_order
.iter()
.filter_map(|n| node_layout.get(n.as_str()).cloned())
.collect();
nodes_vec.sort_by(|a, b| {
a.depth
.cmp(&b.depth)
.then_with(|| a.y.total_cmp(&b.y))
.then_with(|| a.name.cmp(&b.name))
});
SankeyLayout {
nodes: nodes_vec,
links: link_layouts,
max_depth,
}
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
fn format_sankey_node_label(name: &str, value: f64) -> String {
if value.fract().abs() < f64::EPSILON {
format!("{name} {}", value as i64)
} else {
format!("{name} {value}")
}
}
File diff suppressed because it is too large Load Diff
+172
View File
@@ -0,0 +1,172 @@
use crate::ast::{Edge, EdgeStyle, FlowchartGraph, GraphDirection, Node, NodeShape, Statement};
use crate::error::MermaidError;
use std::collections::BTreeMap;
pub fn parse_state_diagram(input: &str) -> Result<FlowchartGraph, MermaidError> {
let lines: Vec<&str> = input.lines().collect();
let mut i = 0_usize;
let mut header: Option<&str> = None;
while i < lines.len() {
let line = lines[i].trim();
if line.is_empty() || line.starts_with("%%") {
i += 1;
continue;
}
let token = line.split_whitespace().next().unwrap_or("");
if token == "stateDiagram" || token == "stateDiagram-v2" {
header = Some(token);
i += 1;
break;
}
return Err(MermaidError::ParseError {
line: i + 1,
message: "Expected 'stateDiagram' or 'stateDiagram-v2' declaration".to_string(),
});
}
if header.is_none() {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'stateDiagram' or 'stateDiagram-v2' declaration".to_string(),
});
}
let mut nodes: BTreeMap<String, NodeShape> = BTreeMap::new();
let mut node_order: Vec<String> = Vec::new();
let mut edges: Vec<(String, String, Option<String>)> = Vec::new();
while i < lines.len() {
let raw = lines[i];
let line = raw.trim();
let line_no = i + 1;
i += 1;
if line.is_empty() || line.starts_with("%%") {
continue;
}
if let Some(rest) = line.strip_prefix("state ") {
let rest = rest.trim();
if rest.is_empty() {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected state name after 'state'".to_string(),
});
}
let name = rest.split_whitespace().next().unwrap_or("").to_string();
let shape = if rest.contains("<<choice>>") {
NodeShape::Diamond
} else if rest.contains("<<fork>>") || rest.contains("<<join>>") {
NodeShape::ForkJoin
} else {
NodeShape::RoundedRectangle
};
if !nodes.contains_key(&name) {
node_order.push(name.clone());
}
nodes.insert(name, shape);
continue;
}
if let Some((from_raw, rhs)) = line.split_once("-->") {
let from_raw = from_raw.trim();
let rhs = rhs.trim();
let (to_raw, label) = match rhs.split_once(':') {
Some((a, b)) => {
let label = b.trim();
(
a.trim(),
if label.is_empty() {
None
} else {
Some(label.to_string())
},
)
}
None => (rhs, None),
};
let from = normalize_state_id(from_raw, true);
let to = normalize_state_id(to_raw, false);
ensure_state_node(&mut nodes, &mut node_order, &from);
ensure_state_node(&mut nodes, &mut node_order, &to);
edges.push((from, to, label));
continue;
}
return Err(MermaidError::ParseError {
line: line_no,
message: format!("Unrecognized stateDiagram line: {line}"),
});
}
let mut statements: Vec<Statement> = Vec::new();
for id in node_order {
let Some(shape) = nodes.get(&id) else {
continue;
};
let label = match shape {
NodeShape::StartState | NodeShape::EndState | NodeShape::ForkJoin => None,
_ => Some(id.clone()),
};
statements.push(Statement::Node(Node {
id: id.clone(),
label,
shape: *shape,
}));
}
for (from, to, label) in edges {
statements.push(Statement::Edge(Edge {
from,
to,
label,
style: EdgeStyle::Arrow,
}));
}
Ok(FlowchartGraph {
direction: GraphDirection::TopToBottom,
statements,
})
}
fn normalize_state_id(raw: &str, is_from: bool) -> String {
let raw = raw.trim();
if raw == "[*]" {
if is_from {
"__start".to_string()
} else {
"__end".to_string()
}
} else {
raw.to_string()
}
}
fn ensure_state_node(
nodes: &mut BTreeMap<String, NodeShape>,
node_order: &mut Vec<String>,
id: &str,
) {
if nodes.contains_key(id) {
return;
}
node_order.push(id.to_string());
let shape = match id {
"__start" => NodeShape::StartState,
"__end" => NodeShape::EndState,
_ => NodeShape::RoundedRectangle,
};
nodes.insert(id.to_string(), shape);
}
+990
View File
@@ -0,0 +1,990 @@
use crate::ast::{EdgeStyle, NodeShape};
use crate::config::RenderConfig;
use crate::layout::{LayoutEdge, LayoutNode, LayoutResult, LayoutSubgraph};
use crate::text_wrap::{
line_width_words, measure_wrapped_lines_with_font_size, scale_char_width, wrap_text_lines,
wrapped_text_height_with_font_size, DEFAULT_CHAR_WIDTH, DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT,
DEFAULT_WRAP_WIDTH,
};
use crate::theme::MermaidTheme;
/// The arrowhead marker has refX="5" with viewBox 0..10 and markerWidth 8.
/// The tip at viewBox x=10 extends (105)/10 × 8 = 4 px past the reference point.
/// We shorten each arrowed edge by this amount so the tip lands exactly on the
/// target node border — matching how mermaid.js renders edges.
const EDGE_ARROWHEAD_OFFSET: f64 = 4.0;
const EDGE_ARROWHEAD_OFFSET_THICK: f64 = 5.5; // markerWidth 11 × (105)/10
const EDGE_LABEL_CHAR_WIDTH: f64 = DEFAULT_CHAR_WIDTH;
const EDGE_LABEL_PADDING_H: f64 = 2.0;
const EDGE_LABEL_PADDING_V: f64 = 2.0;
const EDGE_LABEL_BG_OPACITY: f64 = 0.8;
const SUBGRAPH_TITLE_TOP_MARGIN: f64 = 0.0;
const STATE_CHAR_WIDTH: f64 = 6.7;
const DEFAULT_FONT_FAMILY: &str = "Trebuchet MS, verdana, arial, sans-serif";
pub fn render(layout: &LayoutResult, theme: &MermaidTheme) -> String {
render_with_config(layout, theme, &RenderConfig::default())
}
pub fn render_with_config(
layout: &LayoutResult,
theme: &MermaidTheme,
config: &RenderConfig,
) -> String {
let is_state_diagram = layout.nodes.values().any(|node| {
matches!(
node.shape,
NodeShape::StartState | NodeShape::EndState | NodeShape::ForkJoin
)
});
let mut svg = SvgRenderer::new(
layout.width,
layout.height,
theme,
is_state_diagram,
SvgRenderOptions::from_render_config(config),
);
svg.render(layout)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EdgeCurve {
Basis,
Linear,
}
#[derive(Debug, Clone)]
struct SvgRenderOptions {
font_family: String,
font_size: f64,
wrapping_width: f64,
edge_curve: EdgeCurve,
}
impl Default for SvgRenderOptions {
fn default() -> Self {
Self {
font_family: DEFAULT_FONT_FAMILY.to_string(),
font_size: DEFAULT_FONT_SIZE,
wrapping_width: DEFAULT_WRAP_WIDTH,
edge_curve: EdgeCurve::Basis,
}
}
}
impl SvgRenderOptions {
fn from_render_config(config: &RenderConfig) -> Self {
let default = Self::default();
Self {
font_family: config.font_family.clone().unwrap_or(default.font_family),
font_size: config.font_size_px().unwrap_or(default.font_size),
wrapping_width: config
.flowchart
.wrapping_width
.map(f64::from)
.unwrap_or(default.wrapping_width),
edge_curve: config
.flowchart
.curve
.as_deref()
.map(EdgeCurve::from_mermaid_name)
.unwrap_or(default.edge_curve),
}
}
}
impl EdgeCurve {
fn from_mermaid_name(name: &str) -> Self {
if name.eq_ignore_ascii_case("linear") {
Self::Linear
} else {
Self::Basis
}
}
}
struct SvgRenderer<'a> {
width: f64,
height: f64,
theme: &'a MermaidTheme,
is_state_diagram: bool,
options: SvgRenderOptions,
output: String,
}
impl<'a> SvgRenderer<'a> {
fn new(
width: f64,
height: f64,
theme: &'a MermaidTheme,
is_state_diagram: bool,
options: SvgRenderOptions,
) -> Self {
Self {
width,
height,
theme,
is_state_diagram,
options,
output: String::new(),
}
}
fn render(&mut self, layout: &LayoutResult) -> String {
self.write_header();
self.write_defs();
for subgraph in &layout.subgraphs {
self.render_subgraph_background(subgraph);
}
for edge in &layout.edges {
self.render_edge_line(edge);
}
let mut nodes: Vec<&LayoutNode> = layout.nodes.values().collect();
nodes.sort_by(|a, b| a.id.cmp(&b.id));
for node in nodes {
self.render_node(node);
}
for subgraph in &layout.subgraphs {
self.render_subgraph_title(subgraph);
}
self.render_edge_labels(&layout.edges);
self.write_footer();
std::mem::take(&mut self.output)
}
/// Matches mermaid's SVG behavior: sizing via setupGraphViewbox.js and background via SVG style
/// (mermaid-cli src/index.js sets svg.style.backgroundColor), with a background rect for rasterizers.
fn write_header(&mut self) {
self.output.push_str(&format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<svg width="{:.0}" height="{:.0}" viewBox="0 0 {:.0} {:.0}" xmlns="http://www.w3.org/2000/svg" style="background-color: {};">
<rect x="0" y="0" width="{:.0}" height="{:.0}" fill="{}" stroke="none"/>
"#,
self.width,
self.height,
self.width,
self.height,
self.theme.background,
self.width,
self.height,
self.theme.background
));
}
fn write_defs(&mut self) {
self.output.push_str(&format!(
r#"<defs>
<marker id="arrowhead" markerWidth="8" markerHeight="8" refX="5" refY="5" orient="auto" markerUnits="userSpaceOnUse" viewBox="0 0 10 10">
<path d="M 0 0 L 10 5 L 0 10 z" fill="{}" stroke="{}" stroke-width="1"/>
</marker>
<marker id="arrowhead-thick" markerWidth="11" markerHeight="11" refX="5" refY="5" orient="auto" markerUnits="userSpaceOnUse" viewBox="0 0 10 10">
<path d="M 0 0 L 10 5 L 0 10 z" fill="{0}" stroke="{0}" stroke-width="1"/>
</marker>
</defs>
"#,
self.theme.edge_color, self.theme.edge_color
));
}
fn write_footer(&mut self) {
self.output.push_str("</svg>\n");
}
fn render_subgraph_background(&mut self, subgraph: &LayoutSubgraph) {
self.output.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" fill="{}" stroke="{}" stroke-width="1"/>\n"#,
subgraph.x, subgraph.y, subgraph.width, subgraph.height,
self.theme.subgraph_fill, self.theme.subgraph_stroke
));
}
fn render_subgraph_title(&mut self, subgraph: &LayoutSubgraph) {
if let Some(title) = &subgraph.title {
let char_width = scale_char_width(DEFAULT_CHAR_WIDTH, self.options.font_size);
let lines = wrap_text_lines(title, self.options.wrapping_width, char_width);
if lines.is_empty() {
return;
}
let (_, text_height) =
measure_wrapped_lines_with_font_size(&lines, char_width, self.options.font_size);
let title_x = subgraph.x + subgraph.width / 2.0;
let title_y = subgraph.y + SUBGRAPH_TITLE_TOP_MARGIN + text_height / 2.0;
self.render_text_lines(
title_x,
title_y,
&lines,
self.options.font_size,
DEFAULT_LINE_HEIGHT,
&self.theme.text_color,
);
}
}
fn render_node(&mut self, node: &LayoutNode) {
match node.shape {
NodeShape::Rectangle => self.render_rectangle(node, 0.0),
NodeShape::RoundedRectangle => self.render_rectangle(node, 5.0),
NodeShape::Stadium => self.render_rectangle(node, node.height / 2.0),
NodeShape::Diamond => self.render_diamond(node),
NodeShape::Circle => self.render_circle(node),
NodeShape::StartState => self.render_start_state(node),
NodeShape::EndState => self.render_end_state(node),
NodeShape::ForkJoin => self.render_fork_join(node),
NodeShape::Hexagon => self.render_hexagon(node),
NodeShape::Cylinder => self.render_cylinder(node),
NodeShape::Subroutine => self.render_subroutine(node),
NodeShape::Asymmetric => self.render_asymmetric(node),
}
}
fn render_rectangle(&mut self, node: &LayoutNode, rx: f64) {
let x = node.x - node.width / 2.0;
let y = node.y - node.height / 2.0;
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
let stroke = node
.stroke_color
.as_ref()
.unwrap_or(&self.theme.node_stroke);
self.output.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" rx="{:.1}" fill="{}" stroke="{}" stroke-width="1"/>
"#,
x, y, node.width, node.height, rx, fill, stroke
));
self.render_text(node.x, node.y, &node.label);
}
fn render_start_state(&mut self, node: &LayoutNode) {
let r = node.width.min(node.height) / 2.0;
self.output.push_str(&format!(
r#"<circle cx="{:.1}" cy="{:.1}" r="{:.1}" fill="{}" stroke="{}" stroke-width="1.5"/>
"#,
node.x, node.y, r, self.theme.edge_color, self.theme.edge_color
));
}
fn render_end_state(&mut self, node: &LayoutNode) {
let outer_r = node.width.min(node.height) / 2.0;
let inner_r = (outer_r - 4.0).max(outer_r * 0.55).min(outer_r - 2.0);
self.output.push_str(&format!(
r#"<circle cx="{:.1}" cy="{:.1}" r="{:.1}" fill="{}" stroke="{}" stroke-width="1"/>
"#,
node.x, node.y, outer_r, self.theme.node_stroke, self.theme.background
));
self.output.push_str(&format!(
r#"<circle cx="{:.1}" cy="{:.1}" r="{:.1}" fill="{}" stroke="none"/>
"#,
node.x, node.y, inner_r, self.theme.background
));
}
fn render_fork_join(&mut self, node: &LayoutNode) {
let x = node.x - node.width / 2.0;
let y = node.y - node.height / 2.0;
self.output.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" rx="1" fill="{}" stroke="{}" stroke-width="1"/>
"#,
x, y, node.width, node.height, self.theme.edge_color, self.theme.edge_color
));
}
fn render_diamond(&mut self, node: &LayoutNode) {
let hw = node.width / 2.0;
let hh = node.height / 2.0;
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
let stroke = node
.stroke_color
.as_ref()
.unwrap_or(&self.theme.node_stroke);
let points = format!(
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
node.x,
node.y - hh,
node.x + hw,
node.y,
node.x,
node.y + hh,
node.x - hw,
node.y
);
self.output.push_str(&format!(
r#"<polygon points="{}" fill="{}" stroke="{}" stroke-width="1"/>
"#,
points, fill, stroke
));
self.render_text(node.x, node.y, &node.label);
}
fn render_circle(&mut self, node: &LayoutNode) {
let r = node.width.min(node.height) / 2.0;
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
let stroke = node
.stroke_color
.as_ref()
.unwrap_or(&self.theme.node_stroke);
self.output.push_str(&format!(
r#"<circle cx="{:.1}" cy="{:.1}" r="{:.1}" fill="{}" stroke="{}" stroke-width="1"/>
"#,
node.x, node.y, r, fill, stroke
));
self.render_text(node.x, node.y, &node.label);
}
fn render_hexagon(&mut self, node: &LayoutNode) {
let hw = node.width / 2.0;
let hh = node.height / 2.0;
let inset = node.height / 3.0;
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
let stroke = node
.stroke_color
.as_ref()
.unwrap_or(&self.theme.node_stroke);
let points = format!(
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
node.x - hw + inset,
node.y - hh,
node.x + hw - inset,
node.y - hh,
node.x + hw,
node.y,
node.x + hw - inset,
node.y + hh,
node.x - hw + inset,
node.y + hh,
node.x - hw,
node.y
);
self.output.push_str(&format!(
r#"<polygon points="{}" fill="{}" stroke="{}" stroke-width="1"/>
"#,
points, fill, stroke
));
self.render_text(node.x, node.y, &node.label);
}
fn render_cylinder(&mut self, node: &LayoutNode) {
let hw = node.width / 2.0;
let hh = node.height / 2.0;
let ellipse_ry = (hw / 4.0).min(hh / 2.0);
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
let stroke = node
.stroke_color
.as_ref()
.unwrap_or(&self.theme.node_stroke);
let x = node.x - hw;
let y = node.y - hh;
let body_top = y + ellipse_ry;
let body_bottom = node.y + hh - ellipse_ry;
self.output.push_str(&format!(
r#"<path d="M {:.1} {:.1} L {:.1} {:.1} A {:.1} {:.1} 0 0 0 {:.1} {:.1} L {:.1} {:.1} A {:.1} {:.1} 0 0 0 {:.1} {:.1} Z" fill="{}" stroke="{}" stroke-width="1"/>
"#,
x,
body_top,
x,
body_bottom,
hw,
ellipse_ry,
node.x + hw,
body_bottom,
node.x + hw,
body_top,
hw,
ellipse_ry,
x,
body_top,
fill,
stroke
));
self.output.push_str(&format!(
r#"<ellipse cx="{:.1}" cy="{:.1}" rx="{:.1}" ry="{:.1}" fill="{}" stroke="{}" stroke-width="1"/>
"#,
node.x, body_top, hw, ellipse_ry, fill, stroke
));
// Center text in the cylinder body (below the top ellipse cap)
let body_center_y = (body_top + body_bottom) / 2.0;
self.render_text(node.x, body_center_y, &node.label);
}
fn render_subroutine(&mut self, node: &LayoutNode) {
let x = node.x - node.width / 2.0;
let y = node.y - node.height / 2.0;
let bar_inset = 8.0;
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
let stroke = node
.stroke_color
.as_ref()
.unwrap_or(&self.theme.node_stroke);
self.output.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" fill="{}" stroke="{}" stroke-width="1"/>
"#,
x, y, node.width, node.height, fill, stroke
));
self.output.push_str(&format!(
r#"<line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1"/>
"#,
x + bar_inset,
y,
x + bar_inset,
y + node.height,
stroke
));
self.output.push_str(&format!(
r#"<line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="1"/>
"#,
x + node.width - bar_inset,
y,
x + node.width - bar_inset,
y + node.height,
stroke
));
self.render_text(node.x, node.y, &node.label);
}
fn render_asymmetric(&mut self, node: &LayoutNode) {
let hw = node.width / 2.0;
let hh = node.height / 2.0;
let point_offset = hh;
let fill = node.fill_color.as_ref().unwrap_or(&self.theme.node_fill);
let stroke = node
.stroke_color
.as_ref()
.unwrap_or(&self.theme.node_stroke);
// Mermaid's `>text]` flag shape: indent (V-notch) on the LEFT, flat RIGHT.
let points = format!(
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
node.x - hw + point_offset,
node.y - hh,
node.x + hw,
node.y - hh,
node.x + hw,
node.y + hh,
node.x - hw + point_offset,
node.y + hh,
node.x - hw,
node.y,
);
self.output.push_str(&format!(
r#"<polygon points="{}" fill="{}" stroke="{}" stroke-width="1"/>
"#,
points, fill, stroke
));
self.render_text(node.x + point_offset / 4.0, node.y, &node.label);
}
fn render_text(&mut self, x: f64, y: f64, text: &str) {
let char_width = if self.is_state_diagram {
scale_char_width(STATE_CHAR_WIDTH, self.options.font_size)
} else {
scale_char_width(DEFAULT_CHAR_WIDTH, self.options.font_size)
};
let lines = wrap_text_lines(text, self.options.wrapping_width, char_width);
if lines.is_empty() {
return;
}
self.render_text_lines(
x,
y,
&lines,
self.options.font_size,
DEFAULT_LINE_HEIGHT,
&self.theme.text_color,
);
}
fn render_text_lines(
&mut self,
x: f64,
y: f64,
lines: &[Vec<String>],
font_size: f64,
line_height: f64,
color: &str,
) {
let line_height_px = font_size * line_height;
// With dominant-baseline="central", the y attribute positions the vertical
// center of the text glyph. We distribute n lines evenly around the center y.
let start_y = y - (lines.len() as f64 - 1.0) * line_height_px / 2.0;
let font_family = Self::escape_xml(&self.options.font_family);
self.output.push_str(&format!(
r#"<text text-anchor="middle" dominant-baseline="central" font-family="{}" font-size="{:.0}" fill="{}">
"#,
font_family, font_size, color
));
for (i, line) in lines.iter().enumerate() {
let line_y = start_y + (i as f64 * line_height_px);
let line_text = line.join(" ");
self.output.push_str(&format!(
r#"<tspan x="{:.1}" y="{:.1}">{}</tspan>"#,
x,
line_y,
Self::escape_xml(&line_text)
));
self.output.push('\n');
}
self.output.push_str("</text>\n");
}
/// Matches Mermaid flowchart edge thickness/pattern defaults
/// (see packages/mermaid/src/diagrams/flowchart/styles.ts and rendering-elements/edges.js).
fn render_edge_line(&mut self, edge: &LayoutEdge) {
if edge.points.len() < 2 {
return;
}
let has_arrow = matches!(
edge.style,
EdgeStyle::Arrow | EdgeStyle::DottedArrow | EdgeStyle::ThickArrow
);
let is_dotted = matches!(edge.style, EdgeStyle::DottedArrow | EdgeStyle::DottedLine);
let is_thick = matches!(edge.style, EdgeStyle::ThickArrow | EdgeStyle::ThickLine);
let marker = match (has_arrow, is_thick) {
(true, true) => r#" marker-end="url(#arrowhead-thick)""#,
(true, false) => r#" marker-end="url(#arrowhead)""#,
_ => "",
};
let stroke_width = if is_thick { 3.5 } else { 1.0 };
let dash_array = if is_dotted {
// Match mermaid's dotted style: round-capped short dashes for dot look
r#" stroke-dasharray="3 3""#
} else {
""
};
let mut points = edge.points.clone();
if has_arrow {
let offset = if is_thick {
EDGE_ARROWHEAD_OFFSET_THICK
} else {
EDGE_ARROWHEAD_OFFSET
};
Self::shorten_end_for_marker(&mut points, offset);
}
let d = self.edge_path_d(&points);
self.output.push_str(&format!(
r#"<path d="{}" fill="none" stroke="{}" stroke-width="{:.1}" stroke-linecap="round" stroke-linejoin="round"{}{}/>
"#,
d, self.theme.edge_color, stroke_width, dash_array, marker
));
}
fn shorten_end_for_marker(points: &mut [(f64, f64)], offset: f64) {
if points.len() < 2 || offset <= 0.0 {
return;
}
let last_idx = points.len() - 1;
let prev = points[last_idx - 1];
let last = points[last_idx];
let dx = last.0 - prev.0;
let dy = last.1 - prev.1;
let len = (dx * dx + dy * dy).sqrt();
if len <= offset {
return;
}
let ux = dx / len;
let uy = dy / len;
points[last_idx] = (last.0 - ux * offset, last.1 - uy * offset);
}
fn edge_path_d(&self, points: &[(f64, f64)]) -> String {
match self.options.edge_curve {
EdgeCurve::Basis => {
let points = Self::fix_corners(points);
Self::basis_spline_path_d(&points)
}
EdgeCurve::Linear => Self::linear_path_d(points),
}
}
fn linear_path_d(points: &[(f64, f64)]) -> String {
let Some((first_x, first_y)) = points.first().copied() else {
return String::new();
};
let mut d = format!("M{first_x:.1},{first_y:.1}");
for (x, y) in points.iter().skip(1) {
d.push_str(&format!("L{x:.1},{y:.1}"));
}
d
}
fn basis_spline_path_d(points: &[(f64, f64)]) -> String {
if points.is_empty() {
return String::new();
}
let mut d = String::new();
let mut x0 = f64::NAN;
let mut y0 = f64::NAN;
let mut x1 = f64::NAN;
let mut y1 = f64::NAN;
let mut point_state = 0;
for &(x, y) in points {
match point_state {
0 => {
point_state = 1;
d.push_str(&format!("M{x:.1},{y:.1}"));
}
1 => {
point_state = 2;
}
2 => {
point_state = 3;
d.push_str(&format!(
"L{:.1},{:.1}",
(5.0 * x0 + x1) / 6.0,
(5.0 * y0 + y1) / 6.0
));
d.push_str(&Self::basis_point(x0, y0, x1, y1, x, y));
}
_ => {
d.push_str(&Self::basis_point(x0, y0, x1, y1, x, y));
}
}
x0 = x1;
x1 = x;
y0 = y1;
y1 = y;
}
match point_state {
3 => {
d.push_str(&Self::basis_point(x0, y0, x1, y1, x1, y1));
d.push_str(&format!("L{x1:.1},{y1:.1}"));
}
2 => {
d.push_str(&format!("L{x1:.1},{y1:.1}"));
}
_ => {}
}
d
}
fn basis_point(x0: f64, y0: f64, x1: f64, y1: f64, x: f64, y: f64) -> String {
format!(
"C{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
(2.0 * x0 + x1) / 3.0,
(2.0 * y0 + y1) / 3.0,
(x0 + 2.0 * x1) / 3.0,
(y0 + 2.0 * y1) / 3.0,
(x0 + 4.0 * x1 + x) / 6.0,
(y0 + 4.0 * y1 + y) / 6.0,
)
}
fn fix_corners(points: &[(f64, f64)]) -> Vec<(f64, f64)> {
let corner_positions = Self::corner_positions(points);
let mut new_points = Vec::new();
for (idx, point) in points.iter().enumerate() {
if corner_positions.contains(&idx) {
let prev_point = points[idx - 1];
let next_point = points[idx + 1];
let corner_point = *point;
let new_prev = Self::find_adjacent_point(prev_point, corner_point, 5.0);
let new_next = Self::find_adjacent_point(next_point, corner_point, 5.0);
let x_diff = new_next.0 - new_prev.0;
let y_diff = new_next.1 - new_prev.1;
let mut new_corner = corner_point;
let a = (2.0_f64).sqrt() * 2.0;
if (next_point.0 - prev_point.0).abs() > 10.0
&& (next_point.1 - prev_point.1).abs() >= 10.0
{
if (corner_point.0 - new_prev.0).abs() < f64::EPSILON {
new_corner = (
if x_diff < 0.0 {
new_prev.0 - 5.0 + a
} else {
new_prev.0 + 5.0 - a
},
if y_diff < 0.0 {
new_prev.1 - a
} else {
new_prev.1 + a
},
);
} else {
new_corner = (
if x_diff < 0.0 {
new_prev.0 - a
} else {
new_prev.0 + a
},
if y_diff < 0.0 {
new_prev.1 - 5.0 + a
} else {
new_prev.1 + 5.0 - a
},
);
}
}
new_points.push(new_prev);
new_points.push(new_corner);
new_points.push(new_next);
} else {
new_points.push(*point);
}
}
new_points
}
fn corner_positions(points: &[(f64, f64)]) -> Vec<usize> {
let mut positions = Vec::new();
if points.len() < 3 {
return positions;
}
for i in 1..points.len() - 1 {
let prev = points[i - 1];
let curr = points[i];
let next = points[i + 1];
if ((prev.0 - curr.0).abs() < f64::EPSILON
&& (curr.1 - next.1).abs() < f64::EPSILON
&& (curr.0 - next.0).abs() > 5.0
&& (curr.1 - prev.1).abs() > 5.0)
|| ((prev.1 - curr.1).abs() < f64::EPSILON
&& (curr.0 - next.0).abs() < f64::EPSILON
&& (curr.0 - prev.0).abs() > 5.0
&& (curr.1 - next.1).abs() > 5.0)
{
positions.push(i);
}
}
positions
}
fn find_adjacent_point(a: (f64, f64), b: (f64, f64), distance: f64) -> (f64, f64) {
let x_diff = b.0 - a.0;
let y_diff = b.1 - a.1;
let length = (x_diff * x_diff + y_diff * y_diff).sqrt();
if length == 0.0 {
return a;
}
let ratio = distance / length;
(b.0 - ratio * x_diff, b.1 - ratio * y_diff)
}
fn render_edge_labels(&mut self, edges: &[LayoutEdge]) {
struct LabelInfo {
x: f64,
y: f64,
width: f64,
height: f64,
lines: Vec<Vec<String>>,
}
let mut labels: Vec<LabelInfo> = Vec::new();
let char_width = if self.is_state_diagram {
scale_char_width(STATE_CHAR_WIDTH, self.options.font_size)
} else {
scale_char_width(EDGE_LABEL_CHAR_WIDTH, self.options.font_size)
};
for edge in edges {
let Some(label) = &edge.label else {
continue;
};
if label.trim().is_empty() || (edge.label_pos.is_none() && edge.points.len() < 2) {
continue;
}
let (label_x, label_y) = if let Some((x, y)) = edge.label_pos {
if x > 0.0 && y > 0.0 {
(x, y)
} else {
let label_points = Self::fix_corners(&edge.points);
Self::label_position(&label_points)
}
} else {
let label_points = Self::fix_corners(&edge.points);
Self::label_position(&label_points)
};
let lines = wrap_text_lines(label, self.options.wrapping_width, char_width);
if lines.is_empty() {
continue;
}
let max_line_width = lines
.iter()
.map(|line| line_width_words(line, char_width))
.fold(0.0, f64::max);
let total_height =
wrapped_text_height_with_font_size(lines.len(), self.options.font_size);
let rect_width = max_line_width + EDGE_LABEL_PADDING_H * 2.0;
let rect_height = total_height + EDGE_LABEL_PADDING_V * 2.0;
labels.push(LabelInfo {
x: label_x,
y: label_y,
width: rect_width,
height: rect_height,
lines,
});
}
const MIN_SEPARATION: f64 = 8.0;
const MAX_ITERATIONS: usize = 10;
for _ in 0..MAX_ITERATIONS {
let mut any_collision = false;
for i in 0..labels.len() {
for j in (i + 1)..labels.len() {
let a_left = labels[i].x - labels[i].width / 2.0 - MIN_SEPARATION;
let a_right = labels[i].x + labels[i].width / 2.0 + MIN_SEPARATION;
let a_top = labels[i].y - labels[i].height / 2.0 - MIN_SEPARATION;
let a_bottom = labels[i].y + labels[i].height / 2.0 + MIN_SEPARATION;
let b_left = labels[j].x - labels[j].width / 2.0 - MIN_SEPARATION;
let b_right = labels[j].x + labels[j].width / 2.0 + MIN_SEPARATION;
let b_top = labels[j].y - labels[j].height / 2.0 - MIN_SEPARATION;
let b_bottom = labels[j].y + labels[j].height / 2.0 + MIN_SEPARATION;
let overlap_x = a_right > b_left && b_right > a_left;
let overlap_y = a_bottom > b_top && b_bottom > a_top;
if overlap_x && overlap_y {
any_collision = true;
let dx = labels[j].x - labels[i].x;
let dy = labels[j].y - labels[i].y;
let overlap_amount_x = (a_right - b_left).min(b_right - a_left);
let overlap_amount_y = (a_bottom - b_top).min(b_bottom - a_top);
if overlap_amount_x < overlap_amount_y {
let shift = overlap_amount_x / 2.0;
if dx >= 0.0 {
labels[i].x -= shift;
labels[j].x += shift;
} else {
labels[i].x += shift;
labels[j].x -= shift;
}
} else {
let shift = overlap_amount_y / 2.0;
if dy >= 0.0 {
labels[i].y -= shift;
labels[j].y += shift;
} else {
labels[i].y += shift;
labels[j].y -= shift;
}
}
}
}
}
if !any_collision {
break;
}
}
for info in &labels {
let rect_x = info.x - info.width / 2.0;
let rect_y = info.y - info.height / 2.0;
self.output.push_str(&format!(
r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" fill="rgba(232,232,232,{})" rx="2"/>
"#,
rect_x, rect_y, info.width, info.height, EDGE_LABEL_BG_OPACITY
));
self.render_text_lines(
info.x,
info.y,
&info.lines,
self.options.font_size,
DEFAULT_LINE_HEIGHT,
&self.theme.text_color,
);
}
}
fn label_position(points: &[(f64, f64)]) -> (f64, f64) {
if points.len() < 2 {
return points.first().copied().unwrap_or((0.0, 0.0));
}
let mut segment_lengths = Vec::with_capacity(points.len() - 1);
let mut total_length = 0.0;
for i in 0..points.len() - 1 {
let dx = points[i + 1].0 - points[i].0;
let dy = points[i + 1].1 - points[i].1;
let len = (dx * dx + dy * dy).sqrt();
segment_lengths.push(len);
total_length += len;
}
if total_length < 0.001 {
return points[0];
}
let target_distance = total_length * 0.5;
let mut accumulated = 0.0;
for (i, &seg_len) in segment_lengths.iter().enumerate() {
if accumulated + seg_len >= target_distance {
let remaining = target_distance - accumulated;
let t = if seg_len > 0.001 {
remaining / seg_len
} else {
0.0
};
let x = points[i].0 + t * (points[i + 1].0 - points[i].0);
let y = points[i].1 + t * (points[i + 1].1 - points[i].1);
return (x, y);
}
accumulated += seg_len;
}
let last = points.len() - 1;
(
(points[0].0 + points[last].0) / 2.0,
(points[0].1 + points[last].1) / 2.0,
)
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
}
+346
View File
@@ -0,0 +1,346 @@
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
pub const DEFAULT_FONT_SIZE: f64 = 16.0;
pub const DEFAULT_LINE_HEIGHT: f64 = 1.1;
pub const DEFAULT_WRAP_WIDTH: f64 = 200.0;
pub const DEFAULT_CHAR_WIDTH: f64 = 8.0;
pub const DEFAULT_TEXT_HEIGHT: f64 = 24.0;
/// A single unbreakable token is kept whole (its box widens to fit it, matching
/// mermaid's default `htmlLabels`) unless it is wider than this many wrap-widths.
/// ~5x keeps the worst-case whole-token box near one target-width frame, so the
/// downstream rasterizer's scale-to-`target_width_px` stays ~1x and text stays
/// legible; memory is bounded separately by the consuming crate's raster caps.
const SINGLE_TOKEN_WIDTH_CAP_FACTOR: f64 = 5.0;
/// Identifier-boundary characters preferred as break points when an over-cap
/// token must be split.
const TOKEN_BREAK_CHARS: [char; 4] = ['_', '-', '.', '/'];
/// Display width of `text` in narrow-character units (East Asian wide
/// characters count as two).
pub fn display_width_units(text: &str) -> f64 {
UnicodeWidthStr::width(text) as f64
}
/// Mirrors mermaid.js splitText.ts splitLineToFitWidth behavior for non-markdown labels.
/// Source: packages/mermaid/src/rendering-util/splitText.ts.
pub fn wrap_text_lines(text: &str, max_width: f64, char_width: f64) -> Vec<Vec<String>> {
if text.is_empty() {
return Vec::new();
}
let max_width = if max_width.is_finite() {
max_width
} else {
f64::INFINITY
};
let mut lines = Vec::new();
for raw_line in text.split('\n') {
let trimmed = raw_line.trim();
if trimmed.is_empty() {
lines.push(vec![String::new()]);
continue;
}
let words = split_line_to_words(trimmed);
let wrapped = split_line_to_fit_width(words, max_width, char_width);
lines.extend(wrapped);
}
lines
}
/// Matches mermaid.js createText.ts line-width checks using display-width
/// estimation.
pub fn line_width(line: &str, char_width: f64) -> f64 {
if line.is_empty() {
return 0.0;
}
display_width_units(line) * char_width
}
pub fn measure_wrapped_lines_with_font_size(
lines: &[Vec<String>],
char_width: f64,
font_size: f64,
) -> (f64, f64) {
let max_width = lines
.iter()
.map(|line| line_width_words(line, char_width))
.fold(0.0, f64::max);
(
max_width,
wrapped_text_height_with_font_size(lines.len(), font_size),
)
}
pub fn wrapped_text_height_with_font_size(line_count: usize, font_size: f64) -> f64 {
if line_count == 0 {
return 0.0;
}
let font_size = normalized_font_size(font_size);
let text_height = DEFAULT_TEXT_HEIGHT * font_size / DEFAULT_FONT_SIZE;
let line_spacing = font_size * DEFAULT_LINE_HEIGHT;
text_height + (line_count.saturating_sub(1)) as f64 * line_spacing
}
pub fn scale_char_width(char_width: f64, font_size: f64) -> f64 {
char_width * normalized_font_size(font_size) / DEFAULT_FONT_SIZE
}
fn normalized_font_size(font_size: f64) -> f64 {
if font_size.is_finite() && font_size > 0.0 {
font_size
} else {
DEFAULT_FONT_SIZE
}
}
fn split_line_to_words(text: &str) -> Vec<String> {
let mut words = Vec::new();
for word in text.split_whitespace() {
words.push(word.to_string());
}
if words.is_empty() {
words.push(String::new());
}
words
}
fn split_line_to_fit_width(
words: Vec<String>,
max_width: f64,
char_width: f64,
) -> Vec<Vec<String>> {
let mut remaining = std::collections::VecDeque::from(words);
let mut lines: Vec<Vec<String>> = Vec::new();
let mut current: Vec<String> = Vec::new();
loop {
if remaining.is_empty() {
if !current.is_empty() {
lines.push(current);
}
break;
}
let next_word = remaining.pop_front().unwrap_or_default();
let mut line_with_next = current.clone();
line_with_next.push(next_word.clone());
if check_fit(&line_with_next, max_width, char_width) {
current = line_with_next;
continue;
}
if !current.is_empty() {
lines.push(current);
current = Vec::new();
remaining.push_front(next_word);
continue;
}
if !next_word.is_empty() {
// Keep an unbreakable token whole so its box can widen (see const doc).
let cap = max_width * SINGLE_TOKEN_WIDTH_CAP_FACTOR;
if line_width(&next_word, char_width) <= cap {
lines.push(vec![next_word]);
} else {
let (first, rest) = split_token_at_cap(&next_word, cap, char_width);
lines.push(vec![first]);
if !rest.is_empty() {
remaining.push_front(rest);
}
}
}
}
lines
}
fn check_fit(words: &[String], max_width: f64, char_width: f64) -> bool {
line_width_words(words, char_width) <= max_width
}
fn split_word_to_fit_width(word: &str, max_width: f64, char_width: f64) -> (String, String) {
let graphemes: Vec<&str> = word.graphemes(true).collect();
if graphemes.is_empty() {
return (String::new(), String::new());
}
let mut used = Vec::new();
let mut remaining_start = graphemes.len();
for (idx, grapheme) in graphemes.iter().enumerate() {
let mut candidate = used.clone();
candidate.push(*grapheme);
let candidate_str = candidate.concat();
if line_width(&candidate_str, char_width) <= max_width || used.is_empty() {
used = candidate;
continue;
}
remaining_start = idx;
break;
}
if used.is_empty() {
used.push(graphemes[0]);
remaining_start = 1;
}
let remaining = if remaining_start < graphemes.len() {
graphemes[remaining_start..].concat()
} else {
String::new()
};
(used.concat(), remaining)
}
/// Splits an over-cap token: prefers the last identifier boundary (`_`, `-`,
/// `.`, `/`) within the cap-fitting prefix, otherwise falls back to the grapheme
/// break used elsewhere. Break points are identifier-char granular, so long
/// URLs/paths break at a separator instead of mid-segment.
fn split_token_at_cap(word: &str, cap: f64, char_width: f64) -> (String, String) {
// Grapheme prefix that fits the cap; also guarantees forward progress, so it
// is always a strict prefix here (the whole word is wider than the cap).
let (graphemic_first, graphemic_rest) = split_word_to_fit_width(word, cap, char_width);
// Break chars are single-byte ASCII, so the rfind byte index + 1 is a valid
// char boundary that keeps the separator on the first line.
if let Some(boundary) = graphemic_first.rfind(|c| TOKEN_BREAK_CHARS.contains(&c)) {
let pos = boundary + 1;
return (word[..pos].to_string(), word[pos..].to_string());
}
(graphemic_first, graphemic_rest)
}
pub fn line_width_words(words: &[String], char_width: f64) -> f64 {
let joined = join_words(words);
line_width(&joined, char_width)
}
fn join_words(words: &[String]) -> String {
let mut out = String::new();
for (idx, word) in words.iter().enumerate() {
if idx > 0 {
out.push(' ');
}
out.push_str(word);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wraps_long_single_token_whole_without_slicing() {
// A single long identifier stays whole on one line (mermaid htmlLabels
// behavior), instead of being hard-sliced mid-identifier.
let label = "mark_filter_restore_context";
let lines = wrap_text_lines(label, DEFAULT_WRAP_WIDTH, DEFAULT_CHAR_WIDTH);
assert_eq!(lines, vec![vec![label.to_string()]]);
}
#[test]
fn long_single_token_measures_wider_than_wrap_cap() {
// Keeping the token whole means the measured text width is no longer
// clamped to the wrap cap, so the node box widens to fit it.
let lines = wrap_text_lines(
"mark_filter_restore_context",
DEFAULT_WRAP_WIDTH,
DEFAULT_CHAR_WIDTH,
);
let (width, _height) =
measure_wrapped_lines_with_font_size(&lines, DEFAULT_CHAR_WIDTH, DEFAULT_FONT_SIZE);
assert!(
width > DEFAULT_WRAP_WIDTH,
"measured width {width} must exceed wrap cap {DEFAULT_WRAP_WIDTH}"
);
}
#[test]
fn long_token_with_trailing_words_keeps_token_on_first_line() {
// The long leading token stays whole on its own line; the trailing
// words wrap onto a following line instead of being merged into it.
let lines = wrap_text_lines(
"_render_sidebar_for_active column mgmt",
DEFAULT_WRAP_WIDTH,
DEFAULT_CHAR_WIDTH,
);
assert_eq!(
lines,
vec![
vec!["_render_sidebar_for_active".to_string()],
vec!["column".to_string(), "mgmt".to_string()],
]
);
}
#[test]
fn multi_word_label_still_wraps_at_spaces() {
// Regression guard: a normal multi-word label that exceeds the wrap
// width still wraps at spaces, with every word kept intact.
let phrase = "the quick brown fox jumps over the lazy dog";
let lines = wrap_text_lines(phrase, DEFAULT_WRAP_WIDTH, DEFAULT_CHAR_WIDTH);
assert!(lines.len() >= 2, "long phrase must wrap: {lines:?}");
let flat: Vec<String> = lines.iter().flatten().cloned().collect();
let words: Vec<String> = phrase.split(' ').map(str::to_string).collect();
assert_eq!(flat, words);
}
#[test]
fn pathologically_long_token_breaks_on_identifier_boundary() {
// A token wider than the cap is force-broken, but the break lands on an
// identifier boundary ('_'), not mid-segment, and loses no graphemes.
let token = "segment_".repeat(25);
let cap = SINGLE_TOKEN_WIDTH_CAP_FACTOR * DEFAULT_WRAP_WIDTH;
assert!(line_width(&token, DEFAULT_CHAR_WIDTH) > cap);
let lines = wrap_text_lines(&token, DEFAULT_WRAP_WIDTH, DEFAULT_CHAR_WIDTH);
assert!(
lines.len() >= 2,
"over-cap token must be force-broken: {lines:?}"
);
assert_eq!(lines[0].len(), 1, "each broken piece is a single word");
assert!(
lines[0][0].ends_with('_'),
"first break must land on an identifier boundary, got {:?}",
lines[0][0]
);
let rejoined: String = lines.iter().flatten().cloned().collect();
assert_eq!(rejoined, token);
}
#[test]
fn over_cap_token_without_break_char_falls_back_to_grapheme_break() {
// No identifier boundary: the grapheme-break fallback still bounds each
// line to the cap and loses no graphemes.
let token = "a".repeat(200);
let cap = SINGLE_TOKEN_WIDTH_CAP_FACTOR * DEFAULT_WRAP_WIDTH;
assert!(line_width(&token, DEFAULT_CHAR_WIDTH) > cap);
let lines = wrap_text_lines(&token, DEFAULT_WRAP_WIDTH, DEFAULT_CHAR_WIDTH);
assert!(lines.len() >= 2, "over-cap token must be broken: {lines:?}");
assert!(line_width(&lines[0].concat(), DEFAULT_CHAR_WIDTH) <= cap);
let rejoined: String = lines.iter().flatten().cloned().collect();
assert_eq!(rejoined, token);
}
#[test]
fn over_cap_cjk_token_breaks_on_boundary_and_counts_wide_chars() {
// Wide chars count as two narrow units; an over-cap CJK token with
// separators still breaks at a `_`, never panics, and rejoins losslessly.
assert_eq!(display_width_units(""), 2.0);
let token = "中文_".repeat(50);
let cap = SINGLE_TOKEN_WIDTH_CAP_FACTOR * DEFAULT_WRAP_WIDTH;
assert!(line_width(&token, DEFAULT_CHAR_WIDTH) > cap);
let lines = wrap_text_lines(&token, DEFAULT_WRAP_WIDTH, DEFAULT_CHAR_WIDTH);
assert!(lines.len() >= 2, "over-cap CJK token must break: {lines:?}");
assert!(
lines[0][0].ends_with('_'),
"CJK break must land on a boundary, got {:?}",
lines[0][0]
);
let rejoined: String = lines.iter().flatten().cloned().collect();
assert_eq!(rejoined, token);
}
}
+164
View File
@@ -0,0 +1,164 @@
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MermaidTheme {
pub background: String,
pub node_fill: String,
pub node_stroke: String,
pub text_color: String,
pub edge_color: String,
pub subgraph_fill: String,
pub subgraph_stroke: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MermaidThemePreset {
Default,
Base,
Dark,
Forest,
Neutral,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct MermaidThemeVariables {
pub background: Option<String>,
pub node_fill: Option<String>,
pub node_stroke: Option<String>,
pub text_color: Option<String>,
pub edge_color: Option<String>,
pub subgraph_fill: Option<String>,
pub subgraph_stroke: Option<String>,
}
impl Default for MermaidTheme {
fn default() -> Self {
Self::light()
}
}
impl MermaidThemePreset {
pub fn parse(value: &str) -> Option<Self> {
match value {
"default" => Some(Self::Default),
"base" => Some(Self::Base),
"dark" => Some(Self::Dark),
"forest" => Some(Self::Forest),
"neutral" => Some(Self::Neutral),
_ => None,
}
}
pub fn to_theme(self) -> MermaidTheme {
match self {
Self::Default => MermaidTheme::light(),
Self::Base => MermaidTheme::base(),
Self::Dark => MermaidTheme::dark(),
Self::Forest => MermaidTheme::forest(),
Self::Neutral => MermaidTheme::neutral(),
}
}
}
impl MermaidThemeVariables {
pub fn is_empty(&self) -> bool {
self.background.is_none()
&& self.node_fill.is_none()
&& self.node_stroke.is_none()
&& self.text_color.is_none()
&& self.edge_color.is_none()
&& self.subgraph_fill.is_none()
&& self.subgraph_stroke.is_none()
}
pub fn apply_mermaid_alias(&mut self, key: &str, value: String) -> bool {
match key {
"background" => self.background = Some(value),
"primaryColor" | "mainBkg" => self.node_fill = Some(value),
"primaryBorderColor" | "nodeBorder" => self.node_stroke = Some(value),
"primaryTextColor" | "nodeTextColor" | "textColor" => self.text_color = Some(value),
"lineColor" | "defaultLinkColor" => self.edge_color = Some(value),
"clusterBkg" => self.subgraph_fill = Some(value),
"clusterBorder" => self.subgraph_stroke = Some(value),
_ => return false,
}
true
}
pub fn apply_to(&self, theme: &mut MermaidTheme) {
if let Some(value) = &self.background {
theme.background.clone_from(value);
}
if let Some(value) = &self.node_fill {
theme.node_fill.clone_from(value);
}
if let Some(value) = &self.node_stroke {
theme.node_stroke.clone_from(value);
}
if let Some(value) = &self.text_color {
theme.text_color.clone_from(value);
}
if let Some(value) = &self.edge_color {
theme.edge_color.clone_from(value);
}
if let Some(value) = &self.subgraph_fill {
theme.subgraph_fill.clone_from(value);
}
if let Some(value) = &self.subgraph_stroke {
theme.subgraph_stroke.clone_from(value);
}
}
}
impl MermaidTheme {
pub fn light() -> Self {
Self {
background: "#ffffff".to_string(),
node_fill: "#ECECFF".to_string(),
node_stroke: "#9370DB".to_string(),
text_color: "#333333".to_string(),
edge_color: "#333333".to_string(),
subgraph_fill: "#ffffde".to_string(),
subgraph_stroke: "#aaaa33".to_string(),
}
}
pub fn dark() -> Self {
Self {
background: "#1e1e1e".to_string(),
node_fill: "#2d2d2d".to_string(),
node_stroke: "#888888".to_string(),
text_color: "#ffffff".to_string(),
edge_color: "#888888".to_string(),
subgraph_fill: "#3a3a20".to_string(),
subgraph_stroke: "#888844".to_string(),
}
}
pub fn base() -> Self {
Self::light()
}
pub fn forest() -> Self {
Self {
background: "#f4f4f4".to_string(),
node_fill: "#cde498".to_string(),
node_stroke: "#13540c".to_string(),
text_color: "#333333".to_string(),
edge_color: "#333333".to_string(),
subgraph_fill: "#cde498".to_string(),
subgraph_stroke: "#13540c".to_string(),
}
}
pub fn neutral() -> Self {
Self {
background: "#ffffff".to_string(),
node_fill: "#eeeeee".to_string(),
node_stroke: "#999999".to_string(),
text_color: "#333333".to_string(),
edge_color: "#333333".to_string(),
subgraph_fill: "#eeeeee".to_string(),
subgraph_stroke: "#999999".to_string(),
}
}
}
+513
View File
@@ -0,0 +1,513 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
// --- Mermaid 11.12.2 timeline layout constants ---
const LEFT_MARGIN: f64 = 50.0;
const INITIAL_MASTER_X: f64 = 50.0 + LEFT_MARGIN; // 100
const INITIAL_MASTER_Y: f64 = 50.0;
const NODE_BASE_WIDTH: f64 = 150.0;
const NODE_PADDING: f64 = 20.0;
const NODE_WIDTH: f64 = NODE_BASE_WIDTH + 2.0 * NODE_PADDING; // 190
const NODE_STEP: f64 = 200.0;
const FONT_SIZE: f64 = 16.0;
const EVENT_VERTICAL_GAP: f64 = 100.0;
const DASHED_LINE_EXTENSION: f64 = 100.0;
const NODE_CORNER_RADIUS: f64 = 5.0;
const MAX_SECTIONS: usize = 12;
const ARROW_STROKE_WIDTH: f64 = 4.0;
const CONNECTOR_STROKE_WIDTH: f64 = 2.0;
const NODE_LINE_STROKE_WIDTH: f64 = 3.0;
const FONT_FAMILY: &str = r#""trebuchet ms", verdana, arial, sans-serif"#;
const TASK_FONT_SIZE: f64 = 14.0;
const TASK_FONT_FAMILY: &str = "'Open Sans', sans-serif";
// Approximate character width for text measurement at 16px
const CHAR_WIDTH: f64 = 9.0;
// Mermaid 11.12.2 default theme cScale colors (after darken by 10)
const CSCALE_FILLS: &[&str] = &[
"#BABAFF", // cScale0: periwinkle (primaryColor #ECECFF)
"#FFFFAC", // cScale1: yellow (secondaryColor #ffffde)
"#E8FFB9", // cScale2: lime green (tertiaryColor)
"#D4BAFF", // cScale3
"#FFBAFF", // cScale4
"#FFBADC", // cScale5
"#BAFFBA", // cScale6
"#BAFFDC", // cScale7
"#BAFFFF", // cScale8
"#BABAFF", // cScale9
"#DCBAFF", // cScale10
"#FFBAEF", // cScale11
];
// cScaleInv = hue-shifted by 180° from cScale (for node bottom line stroke)
const CSCALE_INV: &[&str] = &[
"#FFFFAC", "#BABAFF", "#FFB9E8", "#BAFFD4", "#BAFF9A", "#BAFFDC", "#FFBA9A", "#FFBADC",
"#FFBABA", "#FFFFBA", "#BAFFBA", "#BAFFE0",
];
// cScaleLabel text colors (cScaleLabel0 and cScaleLabel3 = white, rest = black)
const CSCALE_LABEL: &[&str] = &[
"#ffffff", "#000000", "#000000", "#ffffff", "#000000", "#000000", "#000000", "#000000",
"#000000", "#000000", "#000000", "#000000",
];
pub fn render_timeline_diagram_to_svg(
mermaid_source: &str,
_theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let timeline = parse_timeline_diagram(mermaid_source)?;
let has_sections = !timeline.sections.is_empty();
let tasks = &timeline.tasks;
if tasks.is_empty() {
return Err(MermaidError::ParseError {
line: 1,
message: "Timeline requires at least one entry".to_string(),
});
}
// --- Compute layout metrics ---
let mut max_section_height = 0.0_f64;
if has_sections {
for section_name in &timeline.sections {
let h = estimate_node_height(section_name, NODE_PADDING, 0.0);
max_section_height = max_section_height.max(h + 20.0);
}
}
let mut max_task_height = 0.0_f64;
let mut max_event_line_length = 0.0_f64;
for task in tasks {
let h = estimate_node_height(&task.period, NODE_PADDING, 0.0);
max_task_height = max_task_height.max(h + 20.0);
let mut event_line_len = 0.0_f64;
for event in &task.events {
event_line_len += estimate_node_height(event, NODE_PADDING, 50.0);
}
if task.events.len() > 1 {
event_line_len += (task.events.len() - 1) as f64 * 10.0;
}
max_event_line_length = max_event_line_length.max(event_line_len);
}
// --- Build SVG body ---
let mut svg = String::with_capacity(4096);
let mut content_right = 0.0_f64;
let mut content_bottom = 0.0_f64;
// CSS styles matching mermaid.js timeline default theme
let mut css = String::new();
css.push_str(&format!(
"svg{{font-family:{FONT_FAMILY};font-size:{FONT_SIZE}px;fill:#333;}}"
));
for i in 0..MAX_SECTIONS {
let si = i as isize - 1;
let fill = CSCALE_FILLS[i % CSCALE_FILLS.len()];
let label = CSCALE_LABEL[i % CSCALE_LABEL.len()];
let inv = CSCALE_INV[i % CSCALE_INV.len()];
css.push_str(&format!(
".section-{si} rect,.section-{si} path,.section-{si} circle{{fill:{fill};}}"
));
css.push_str(&format!(".section-{si} text{{fill:{label};}}"));
css.push_str(&format!(
".section-{si} line{{stroke:{inv};stroke-width:{NODE_LINE_STROKE_WIDTH};}}"
));
}
css.push_str(".eventWrapper{filter:brightness(120%);}");
css.push_str(".lineWrapper line{stroke:black;}");
let defs = "<defs><marker id=\"arrowhead\" refX=\"5\" refY=\"2\" markerWidth=\"6\" \
markerHeight=\"4\" orient=\"auto\"><path d=\"M 0,0 V 4 L6,2 Z\"/></marker></defs>";
let mut body = String::new();
// --- Draw tasks and events ---
let mut master_x = INITIAL_MASTER_X;
let master_y = INITIAL_MASTER_Y;
let section_begin_y = INITIAL_MASTER_Y;
let mut section_number: usize = 0;
if has_sections {
for section_name in &timeline.sections {
let tasks_for_section: Vec<&TimelineTask> = tasks
.iter()
.filter(|t| t.section.as_deref() == Some(section_name.as_str()))
.collect();
let section_width = 200.0 * (tasks_for_section.len().max(1)) as f64 - 50.0;
let section_idx = section_number % MAX_SECTIONS;
let section_css_idx = section_idx as isize - 1;
body.push_str(&format!(
"<g class=\"timeline-node section-{section_css_idx}\" \
transform=\"translate({master_x},{section_begin_y})\">"
));
render_node_background(&mut body, section_width, max_section_height);
render_node_text(&mut body, section_name, section_width);
body.push_str("</g>");
let task_y = section_begin_y + max_section_height + 50.0;
if !tasks_for_section.is_empty() {
render_tasks(
&mut body,
&tasks_for_section,
section_number,
&mut master_x,
task_y,
max_task_height,
max_event_line_length,
&mut content_right,
&mut content_bottom,
false,
);
}
master_x += 200.0 * (tasks_for_section.len().max(1)) as f64;
section_number += 1;
}
} else {
let task_refs: Vec<&TimelineTask> = tasks.iter().collect();
render_tasks(
&mut body,
&task_refs,
section_number,
&mut master_x,
master_y,
max_task_height,
max_event_line_length,
&mut content_right,
&mut content_bottom,
true,
);
}
// --- Horizontal arrow ---
let depth_y = if has_sections {
max_section_height + max_task_height + 150.0
} else {
max_task_height + 100.0
};
// In mermaid.js, box.width is computed from SVG bounding box BEFORE arrow/title
let nodes_box_width = content_right;
let arrow_x1 = LEFT_MARGIN;
let arrow_x2 = nodes_box_width + 3.0 * LEFT_MARGIN;
content_right = content_right.max(arrow_x2 + 10.0);
body.push_str(&format!(
"<g class=\"lineWrapper\"><line x1=\"{arrow_x1:.1}\" y1=\"{depth_y:.1}\" \
x2=\"{arrow_x2:.1}\" y2=\"{depth_y:.1}\" \
stroke-width=\"{ARROW_STROKE_WIDTH}\" stroke=\"black\" \
marker-end=\"url(#arrowhead)\"/></g>"
));
// --- Title ---
// Position uses node bounding box width (before arrow), matching mermaid.js
let title_content = if let Some(title) = &timeline.title {
let title_x = nodes_box_width / 2.0 - LEFT_MARGIN;
// Estimate title width to expand viewBox if needed
let approx_title_width = title.len() as f64 * 24.0; // ~24px/char at 4ex
content_right = content_right.max(title_x + approx_title_width + 20.0);
format!(
"<text x=\"{title_x:.1}\" y=\"20\" font-size=\"4ex\" \
font-weight=\"bold\" fill=\"#333\">{}</text>",
escape_xml(title)
)
} else {
String::new()
};
content_bottom = content_bottom.max(depth_y + 20.0);
// --- Assemble final SVG ---
let vb_padding = 50.0;
let vb_width = content_right + vb_padding;
let vb_height = content_bottom + vb_padding;
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" \
xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
style=\"max-width: {vb_width:.0}px;\" \
width=\"100%\" \
viewBox=\"0 -25 {vb_width:.0} {vh:.0}\" \
preserveAspectRatio=\"xMinYMin meet\" \
height=\"{sh:.0}\" \
role=\"graphics-document document\" \
aria-roledescription=\"timeline\">",
vh = vb_height + 25.0,
sh = vb_height + 50.0,
));
svg.push_str(&format!("<style>{css}</style>"));
svg.push_str(defs);
svg.push_str(&title_content);
svg.push_str(&body);
svg.push_str("</svg>");
Ok(svg)
}
#[allow(clippy::too_many_arguments)]
fn render_tasks(
body: &mut String,
tasks: &[&TimelineTask],
initial_section_color: usize,
master_x: &mut f64,
master_y: f64,
max_task_height: f64,
max_event_line_length: f64,
content_right: &mut f64,
content_bottom: &mut f64,
is_multicolor: bool,
) {
let mut section_color = initial_section_color;
for task in tasks {
let section_idx = section_color % MAX_SECTIONS;
let section_css_idx = section_idx as isize - 1;
// Draw task (period) node
body.push_str(&format!(
"<g class=\"taskWrapper\"><g class=\"timeline-node section-{section_css_idx}\" \
transform=\"translate({mx},{my})\">",
mx = *master_x,
my = master_y,
));
render_node_background(body, NODE_WIDTH, max_task_height);
render_node_text(body, &task.period, NODE_WIDTH);
body.push_str("</g></g>");
*content_right = (*content_right).max(*master_x + NODE_WIDTH);
// Draw events below
if !task.events.is_empty() {
let mut event_y = master_y + EVENT_VERTICAL_GAP + EVENT_VERTICAL_GAP;
for event in &task.events {
let event_height = estimate_event_height(event);
body.push_str(&format!(
"<g class=\"eventWrapper\"><g class=\"timeline-node section-{section_css_idx}\" \
transform=\"translate({mx},{ey})\">",
mx = *master_x,
ey = event_y,
));
render_node_background(body, NODE_WIDTH, event_height);
render_node_text(body, event, NODE_WIDTH);
body.push_str("</g></g>");
event_y += event_height + 10.0;
}
// Dashed vertical connector line with arrowhead
let line_x = *master_x + NODE_WIDTH / 2.0;
let line_y1 = master_y + max_task_height;
let line_y2 = master_y
+ max_task_height
+ EVENT_VERTICAL_GAP
+ max_event_line_length
+ DASHED_LINE_EXTENSION;
body.push_str(&format!(
"<g class=\"lineWrapper\"><line x1=\"{line_x:.1}\" y1=\"{line_y1:.1}\" \
x2=\"{line_x:.1}\" y2=\"{line_y2:.1}\" \
stroke-width=\"{CONNECTOR_STROKE_WIDTH}\" stroke=\"black\" \
marker-end=\"url(#arrowhead)\" stroke-dasharray=\"5,5\"/></g>"
));
*content_bottom = (*content_bottom).max(line_y2 + 10.0);
}
*master_x += NODE_STEP;
if is_multicolor {
section_color += 1;
}
}
}
fn estimate_text_height(text: &str) -> f64 {
let text_width = text.len() as f64 * CHAR_WIDTH;
let num_lines = (text_width / NODE_BASE_WIDTH).ceil().max(1.0);
num_lines * FONT_SIZE * 1.2
}
fn estimate_node_height(text: &str, padding: f64, max_height: f64) -> f64 {
let text_h = estimate_text_height(text);
let h = text_h + FONT_SIZE * 1.1 * 0.5 + padding;
h.max(max_height)
}
fn estimate_event_height(text: &str) -> f64 {
let text_h = estimate_text_height(text);
let h = text_h + FONT_SIZE * 1.1 * 0.5 + NODE_PADDING;
h.max(50.0)
}
/// Render the node background shape: rounded top corners, flat bottom with a line.
/// Matches mermaid.js `defaultBkg` function.
fn render_node_background(svg: &mut String, width: f64, height: f64) {
let rd = NODE_CORNER_RADIUS;
svg.push_str(&format!(
"<g><path class=\"node-bkg\" d=\"M0 {h_rd:.1} v{up:.1} q0,-{rd} {rd},-{rd} \
h{across:.1} q{rd},0 {rd},{rd} v{down:.1} H0 Z\"/>",
h_rd = height - rd,
up = -(height - 2.0 * rd),
rd = rd,
across = width - 2.0 * rd,
down = height - rd,
));
svg.push_str(&format!(
"<line x1=\"0\" y1=\"{height:.1}\" x2=\"{width:.1}\" y2=\"{height:.1}\"/>"
));
svg.push_str("</g>");
}
/// Render centered text inside a node.
fn render_node_text(svg: &mut String, text: &str, width: f64) {
let x = width / 2.0;
svg.push_str(&format!(
"<g transform=\"translate({x:.1},{ty:.1})\">\
<text x=\"0\" y=\"0\" dy=\"1em\" \
alignment-baseline=\"middle\" dominant-baseline=\"middle\" \
text-anchor=\"middle\" \
style=\"font-size:{TASK_FONT_SIZE}px;font-family:{TASK_FONT_FAMILY};\">\
{}</text></g>",
escape_xml(text),
ty = NODE_PADDING / 2.0,
));
}
// --- Data model ---
#[derive(Debug, Clone)]
struct TimelineDiagram {
title: Option<String>,
sections: Vec<String>,
tasks: Vec<TimelineTask>,
}
#[derive(Debug, Clone)]
struct TimelineTask {
period: String,
events: Vec<String>,
section: Option<String>,
}
// --- Parser ---
fn parse_timeline_diagram(input: &str) -> Result<TimelineDiagram, MermaidError> {
let lines: Vec<&str> = input.lines().collect();
let mut i = 0_usize;
while i < lines.len() {
let line = lines[i].trim();
if line.is_empty() || line.starts_with("%%") {
i += 1;
continue;
}
if line.split_whitespace().next() == Some("timeline") {
i += 1;
break;
}
return Err(MermaidError::ParseError {
line: i + 1,
message: "Expected 'timeline' declaration".to_string(),
});
}
let mut title: Option<String> = None;
let mut sections: Vec<String> = Vec::new();
let mut tasks: Vec<TimelineTask> = Vec::new();
let mut current_section: Option<String> = None;
while i < lines.len() {
let raw = lines[i];
let line = raw.trim();
i += 1;
if line.is_empty() || line.starts_with("%%") || line.starts_with('#') {
continue;
}
// Title directive
if let Some(rest) = line.strip_prefix("title ") {
let t = rest.trim();
if !t.is_empty() {
title = Some(t.to_string());
}
continue;
}
// Section directive
if let Some(rest) = line.strip_prefix("section ") {
let s = rest.trim();
if !s.is_empty() {
current_section = Some(s.to_string());
if !sections.contains(&s.to_string()) {
sections.push(s.to_string());
}
}
continue;
}
// Event line (starts with ": " — additional event for the previous task)
if let Some(event_text) = line.strip_prefix(": ") {
let event_text = event_text.trim();
if !event_text.is_empty() {
if let Some(last_task) = tasks.last_mut() {
last_task.events.push(event_text.to_string());
}
}
continue;
}
// Period with optional event: "period : event" or just "period"
if let Some((period, event)) = line.split_once(':') {
let period = period.trim();
let event = event.trim();
if !period.is_empty() {
let events = if event.is_empty() {
vec![]
} else {
vec![event.to_string()]
};
tasks.push(TimelineTask {
period: period.to_string(),
events,
section: current_section.clone(),
});
continue;
}
}
// Period without event (bare text line)
if !line.is_empty() {
tasks.push(TimelineTask {
period: line.to_string(),
events: vec![],
section: current_section.clone(),
});
}
}
Ok(TimelineDiagram {
title,
sections,
tasks,
})
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+867
View File
@@ -0,0 +1,867 @@
use crate::error::MermaidError;
use crate::theme::MermaidTheme;
const CHART_WIDTH: f64 = 700.0;
const CHART_HEIGHT: f64 = 500.0;
const CHART_TITLE_FONT_SIZE: f64 = 20.0;
const CHART_TITLE_PADDING: f64 = 10.0;
const AXIS_LABEL_FONT_SIZE: f64 = 14.0;
const AXIS_LABEL_PADDING: f64 = 5.0;
const AXIS_TITLE_FONT_SIZE: f64 = 16.0;
const AXIS_TITLE_PADDING: f64 = 5.0;
const AXIS_TICK_LENGTH: f64 = 5.0;
const AXIS_TICK_WIDTH: f64 = 2.0;
const AXIS_LINE_WIDTH: f64 = 2.0;
const DEFAULT_TICK_COUNT: usize = 10;
/// Floor for the auto-shrunk categorical x-axis label font (this port has no
/// label rotation, so a busy axis shrinks-to-fit down to here, then overflows).
const MIN_X_LABEL_FONT_SIZE: f64 = 8.0;
const PLOT_RIGHT_MARGIN: f64 = 12.0;
/// Per-series colors (Tableau 10), cycled by series index. Mid-tone hues stay
/// legible on both the light and dark surfaces this engine renders onto.
const SERIES_PALETTE: [&str; 10] = [
"#4e79a7", "#f28e2b", "#e15759", "#76b7b2", "#59a14f", "#edc948", "#b07aa1", "#ff9da7",
"#9c755f", "#bab0ac",
];
pub fn render_xychart_diagram_to_svg(
mermaid_source: &str,
theme: &MermaidTheme,
) -> Result<String, MermaidError> {
let chart = parse_xychart(mermaid_source)?;
// Theme text color (not a fixed near-black) so axes stay visible on dark.
let axis_color = theme.text_color.as_str();
let y_ticks = d3_ticks(chart.y_min, chart.y_max, DEFAULT_TICK_COUNT);
let y_tick_labels: Vec<String> = y_ticks.iter().map(|v| format_tick(*v)).collect();
let label_text_height = approx_text_height(AXIS_LABEL_FONT_SIZE);
let y_label_max_width = y_tick_labels
.iter()
.map(|s| approx_text_width(s, AXIS_LABEL_FONT_SIZE))
.fold(0.0, f64::max);
let title_height = if chart.title.is_empty() {
0.0
} else {
approx_text_height(CHART_TITLE_FONT_SIZE) + 2.0 * CHART_TITLE_PADDING
};
let y_title_width = if chart.y_title.is_empty() {
0.0
} else {
approx_text_height(AXIS_TITLE_FONT_SIZE) + 2.0 * AXIS_TITLE_PADDING
};
let x_title_height = if chart.x_title.is_empty() {
0.0
} else {
approx_text_height(AXIS_TITLE_FONT_SIZE) + 2.0 * AXIS_TITLE_PADDING
};
let left_axis_width =
AXIS_LINE_WIDTH + AXIS_TICK_LENGTH + (y_label_max_width + 2.0 * AXIS_LABEL_PADDING);
let plot_x = y_title_width + left_axis_width;
let plot_y = title_height;
let plot_w = (CHART_WIDTH - plot_x - PLOT_RIGHT_MARGIN).max(1.0);
let point_count = chart.series.iter().map(Vec::len).max().unwrap_or(0);
let x = layout_x_axis(&chart.x_axis, plot_x, plot_w, point_count);
// Bottom band depends on the resolved (possibly shrunk) x-label font.
let x_label_height = approx_text_height(x.label_font);
let bottom_axis_height = AXIS_LINE_WIDTH
+ AXIS_TICK_LENGTH
+ (x_label_height + 2.0 * AXIS_LABEL_PADDING)
+ x_title_height;
let plot_h = (CHART_HEIGHT - plot_y - bottom_axis_height).max(1.0);
let y_outer_padding = (label_text_height / 2.0).min(0.2 * plot_h);
let y_top = plot_y + y_outer_padding;
let y_bottom = plot_y + plot_h - y_outer_padding;
let y_at = |v: f64| scale_linear(v, chart.y_min, chart.y_max, y_bottom, y_top);
let mut svg = String::new();
svg.push_str(&format!(
"<svg aria-roledescription=\"xychart\" role=\"graphics-document document\" viewBox=\"0 0 {CHART_WIDTH} {CHART_HEIGHT}\" style=\"max-width: {CHART_WIDTH}px; background-color: {};\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"100%\" id=\"my-svg\">",
theme.background
));
svg.push_str("<g/><g class=\"main\">");
svg.push_str(&format!(
"<rect fill=\"{}\" class=\"background\" height=\"{CHART_HEIGHT}\" width=\"{CHART_WIDTH}\"/>",
theme.background
));
if !chart.title.is_empty() {
let title_y = title_height / 2.0;
let title_x = CHART_WIDTH / 2.0;
svg.push_str("<g class=\"chart-title\">");
svg.push_str(&format!(
"<text transform=\"translate({title_x}, {title_y}) rotate(0)\" text-anchor=\"middle\" dominant-baseline=\"middle\" font-size=\"{CHART_TITLE_FONT_SIZE}\" fill=\"{axis_color}\" y=\"0\" x=\"0\">{}</text>",
escape_xml(&chart.title)
));
svg.push_str("</g>");
}
svg.push_str("<g class=\"plot\">");
for (idx, values) in chart.series.iter().enumerate() {
let points: Vec<(f64, f64)> = values
.iter()
.enumerate()
.map(|(i, v)| (x.series_point_x(i), y_at(*v)))
.collect();
if points.is_empty() {
continue;
}
let d = points_to_path_d(&points);
let stroke = SERIES_PALETTE[idx % SERIES_PALETTE.len()];
svg.push_str(&format!("<g class=\"line-plot-{idx}\">"));
svg.push_str(&format!(
"<path stroke-width=\"2\" stroke=\"{stroke}\" fill=\"none\" d=\"{d}\"/>",
));
svg.push_str("</g>");
}
svg.push_str("</g>");
let bottom_axis_y = plot_y + plot_h;
svg.push_str("<g class=\"bottom-axis\">");
svg.push_str("<g class=\"axis-line\">");
svg.push_str(&format!(
"<path stroke-width=\"{AXIS_LINE_WIDTH}\" stroke=\"{axis_color}\" fill=\"none\" d=\"M {plot_x},{y} L {x_end},{y}\"/>",
y = bottom_axis_y + AXIS_LINE_WIDTH / 2.0,
x_end = plot_x + plot_w,
));
svg.push_str("</g>");
svg.push_str("<g class=\"label\">");
let x_label_y = bottom_axis_y + AXIS_LABEL_PADDING + AXIS_TICK_LENGTH + AXIS_LINE_WIDTH;
for (pos, label) in x.tick_positions.iter().zip(x.tick_labels.iter()) {
svg.push_str(&format!(
"<text transform=\"translate({pos}, {x_label_y}) rotate(0)\" text-anchor=\"middle\" dominant-baseline=\"text-before-edge\" font-size=\"{font}\" fill=\"{axis_color}\" y=\"0\" x=\"0\">{}</text>",
escape_xml(label),
font = x.label_font,
));
}
svg.push_str("</g>");
svg.push_str("<g class=\"ticks\">");
let tick_y0 = bottom_axis_y + AXIS_LINE_WIDTH;
let tick_y1 = tick_y0 + AXIS_TICK_LENGTH;
for pos in &x.tick_positions {
svg.push_str(&format!(
"<path stroke-width=\"{AXIS_TICK_WIDTH}\" stroke=\"{axis_color}\" fill=\"none\" d=\"M {pos},{tick_y0} L {pos},{tick_y1}\"/>",
));
}
svg.push_str("</g>");
svg.push_str("</g>");
svg.push_str("<g class=\"left-axis\">");
svg.push_str("<g class=\"axisl-line\">");
let axis_x = plot_x - AXIS_LINE_WIDTH / 2.0;
svg.push_str(&format!(
"<path stroke-width=\"{AXIS_LINE_WIDTH}\" stroke=\"{axis_color}\" fill=\"none\" d=\"M {axis_x},{plot_y} L {axis_x},{y1}\"/>",
y1 = plot_y + plot_h,
));
svg.push_str("</g>");
svg.push_str("<g class=\"label\">");
let y_label_x = plot_x - AXIS_LABEL_PADDING - AXIS_TICK_LENGTH - AXIS_LINE_WIDTH;
for (tick_value, tick_label) in y_ticks.iter().zip(y_tick_labels.iter()) {
let y = y_at(*tick_value);
svg.push_str(&format!(
"<text transform=\"translate({y_label_x}, {y}) rotate(0)\" text-anchor=\"end\" dominant-baseline=\"middle\" font-size=\"{AXIS_LABEL_FONT_SIZE}\" fill=\"{axis_color}\" y=\"0\" x=\"0\">{}</text>",
escape_xml(tick_label)
));
}
svg.push_str("</g>");
svg.push_str("<g class=\"ticks\">");
let tick_x0 = plot_x - AXIS_LINE_WIDTH;
let tick_x1 = tick_x0 - AXIS_TICK_LENGTH;
for tick_value in &y_ticks {
let y = y_at(*tick_value);
svg.push_str(&format!(
"<path stroke-width=\"{AXIS_TICK_WIDTH}\" stroke=\"{axis_color}\" fill=\"none\" d=\"M {tick_x0},{y} L {tick_x1},{y}\"/>",
));
}
svg.push_str("</g>");
svg.push_str("</g>");
if !chart.x_title.is_empty() {
let tx = plot_x + plot_w / 2.0;
let ty = CHART_HEIGHT - x_title_height / 2.0;
svg.push_str("<g class=\"x-axis-title\">");
svg.push_str(&format!(
"<text transform=\"translate({tx}, {ty}) rotate(0)\" text-anchor=\"middle\" dominant-baseline=\"middle\" font-size=\"{AXIS_TITLE_FONT_SIZE}\" fill=\"{axis_color}\" y=\"0\" x=\"0\">{}</text>",
escape_xml(&chart.x_title)
));
svg.push_str("</g>");
}
if !chart.y_title.is_empty() {
let tx = y_title_width / 2.0;
let ty = plot_y + plot_h / 2.0;
svg.push_str("<g class=\"y-axis-title\">");
svg.push_str(&format!(
"<text transform=\"translate({tx}, {ty}) rotate(-90)\" text-anchor=\"middle\" dominant-baseline=\"middle\" font-size=\"{AXIS_TITLE_FONT_SIZE}\" fill=\"{axis_color}\" y=\"0\" x=\"0\">{}</text>",
escape_xml(&chart.y_title)
));
svg.push_str("</g>");
}
svg.push_str("</g><g class=\"mermaid-tmp-group\"/></svg>");
Ok(svg)
}
/// Either evenly-spaced named categories (`x-axis [a, b]`) or a numeric range.
#[derive(Debug, Clone, PartialEq)]
enum XAxis {
Numeric { min: f64, max: f64 },
Category(Vec<String>),
}
#[derive(Debug, Clone)]
struct XyChart {
title: String,
x_title: String,
y_title: String,
x_axis: XAxis,
y_min: f64,
y_max: f64,
series: Vec<Vec<f64>>,
}
fn parse_xychart(input: &str) -> Result<XyChart, MermaidError> {
let mut found_header = false;
let mut title = String::new();
let mut x_title = String::new();
let mut y_title = String::new();
let mut x_axis: Option<XAxis> = None;
let mut y_range: Option<(f64, f64)> = None;
let mut series: Vec<Vec<f64>> = Vec::new();
for (idx, raw) in input.lines().enumerate() {
let line_no = idx + 1;
let line = raw.trim();
if line.is_empty() || line.starts_with("%%") {
continue;
}
if !found_header {
if line.split_whitespace().next() != Some("xychart-beta") {
return Err(MermaidError::ParseError {
line: line_no,
message: "Expected 'xychart-beta' declaration".to_string(),
});
}
found_header = true;
continue;
}
if let Some(rest) = line.strip_prefix("title ") {
title = unquote(rest);
continue;
}
if let Some(rest) = line.strip_prefix("x-axis ") {
let (label, axis) = parse_x_axis(rest.trim(), line_no)?;
x_title = label;
x_axis = Some(axis);
continue;
}
if let Some(rest) = line.strip_prefix("y-axis ") {
let (label, range) = parse_y_axis(rest.trim(), line_no)?;
y_title = label;
if let Some(range) = range {
y_range = Some(range);
}
continue;
}
if let Some(values) = parse_series_line(line, line_no)? {
series.push(values);
continue;
}
// Unknown lines (e.g. an unsupported `bar` series) are ignored.
}
if !found_header {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'xychart-beta' declaration".to_string(),
});
}
if series.iter().all(|values| values.is_empty()) {
return Err(MermaidError::ParseError {
line: 1,
message: "xychart requires at least one plot".to_string(),
});
}
let x_axis = x_axis.unwrap_or(XAxis::Numeric { min: 0.0, max: 0.0 });
let (y_min, y_max) = y_range.unwrap_or_else(|| auto_y_range(&series));
Ok(XyChart {
title,
x_title,
y_title,
x_axis,
y_min,
y_max,
series,
})
}
/// Parse an `x-axis` body: optional title plus a category list or numeric range.
fn parse_x_axis(rest: &str, line: usize) -> Result<(String, XAxis), MermaidError> {
if let Some(open) = rest.find('[') {
let title = unquote(rest[..open].trim());
let close =
rest.rfind(']')
.filter(|c| *c > open)
.ok_or_else(|| MermaidError::ParseError {
line,
message: format!("Invalid x-axis categories: {rest}"),
})?;
let categories = parse_category_list(&rest[open + 1..close]);
Ok((title, XAxis::Category(categories)))
} else if rest.contains("-->") {
let (title, min, max) = parse_labeled_range(rest, line)?;
Ok((title, XAxis::Numeric { min, max }))
} else {
Ok((unquote(rest), XAxis::Category(Vec::new())))
}
}
/// Parse a `y-axis` body; a title without a range auto-ranges from the data.
fn parse_y_axis(rest: &str, line: usize) -> Result<(String, Option<(f64, f64)>), MermaidError> {
if rest.contains("-->") {
let (title, min, max) = parse_labeled_range(rest, line)?;
Ok((title, Some((min, max))))
} else {
Ok((unquote(rest), None))
}
}
/// Parse a `[title] min --> max` body; the title is everything left of `min`.
fn parse_labeled_range(s: &str, line: usize) -> Result<(String, f64, f64), MermaidError> {
let (left, right) = s
.split_once("-->")
.ok_or_else(|| MermaidError::ParseError {
line,
message: format!("Invalid axis range: {s}"),
})?;
let max: f64 = right.trim().parse().map_err(|_| MermaidError::ParseError {
line,
message: format!("Invalid axis max: {}", right.trim()),
})?;
let left = left.trim();
let (title, min_str) = match left.rsplit_once(char::is_whitespace) {
Some((title, min)) => (title.trim(), min.trim()),
None => ("", left),
};
let min: f64 = min_str.parse().map_err(|_| MermaidError::ParseError {
line,
message: format!("Invalid axis min: {min_str}"),
})?;
Ok((unquote(title), min, max))
}
/// Parse a `line [..]` series; non-`line` declarations return `None`.
fn parse_series_line(line: &str, line_no: usize) -> Result<Option<Vec<f64>>, MermaidError> {
let Some(rest) = strip_keyword(line, "line") else {
return Ok(None);
};
let values = parse_bracketed_number_list(rest.trim(), line_no)?;
Ok(Some(values))
}
/// Strip `keyword` only when it stands alone (end / whitespace / `[` follows),
/// so `line` matches but `linear` does not.
fn strip_keyword<'a>(line: &'a str, keyword: &str) -> Option<&'a str> {
let rest = line.strip_prefix(keyword)?;
match rest.chars().next() {
None => Some(rest),
Some(c) if c.is_whitespace() || c == '[' => Some(rest),
_ => None,
}
}
/// Split on top-level (quote-aware) commas, then unquote/trim each entry.
fn parse_category_list(inner: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
for c in inner.chars() {
match quote {
Some(q) => {
if c == q {
quote = None;
}
current.push(c);
}
None => match c {
'"' | '\'' => {
quote = Some(c);
current.push(c);
}
',' => out.push(std::mem::take(&mut current)),
_ => current.push(c),
},
}
}
out.push(current);
out.into_iter()
.map(|s| unquote(s.trim()))
.filter(|s| !s.is_empty())
.collect()
}
/// Strip one pair of matching surrounding quotes (`"…"` or `'…'`).
fn unquote(s: &str) -> String {
let t = s.trim();
let bytes = t.as_bytes();
if t.len() >= 2 {
let first = bytes[0];
let last = bytes[t.len() - 1];
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
return t[1..t.len() - 1].to_string();
}
}
t.to_string()
}
fn auto_y_range(series: &[Vec<f64>]) -> (f64, f64) {
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for values in series {
for &v in values {
min = min.min(v);
max = max.max(v);
}
}
if !min.is_finite() || !max.is_finite() {
return (0.0, 0.0);
}
if (max - min).abs() < f64::EPSILON {
return (min - 1.0, max + 1.0);
}
(min, max)
}
struct XAxisLayout {
tick_positions: Vec<f64>,
tick_labels: Vec<String>,
label_font: f64,
/// Longest series length, shared by every series so the same index maps to
/// the same x across series (overlaid lines stay on one x domain).
point_count: usize,
geom: XGeom,
}
enum XGeom {
/// Band scale: points/ticks at band centers.
Category { plot_left: f64, band_w: f64 },
/// Linear scale: points evenly distributed across `[x0, x1]`.
Numeric { x0: f64, x1: f64 },
}
impl XAxisLayout {
fn series_point_x(&self, i: usize) -> f64 {
match self.geom {
XGeom::Category { plot_left, band_w } => plot_left + (i as f64 + 0.5) * band_w,
XGeom::Numeric { x0, x1 } => {
if self.point_count <= 1 {
x0
} else {
x0 + (i as f64) / ((self.point_count - 1) as f64) * (x1 - x0)
}
}
}
}
}
fn layout_x_axis(x_axis: &XAxis, plot_x: f64, plot_w: f64, point_count: usize) -> XAxisLayout {
match x_axis {
XAxis::Category(categories) => {
// Size bands to whichever is larger so every series point lands in a
// band (a series longer than the category list still stays on-plot).
let n = categories.len().max(point_count).max(1);
let band_w = plot_w / n as f64;
// Shrink the font so the widest category fits its band (to a floor).
let widest_units = categories
.iter()
.map(|c| crate::text_wrap::display_width_units(c))
.fold(0.0, f64::max);
let label_font = if widest_units > 0.0 {
let fit = (band_w * 0.95) / (widest_units * 0.525);
AXIS_LABEL_FONT_SIZE.min(fit).max(MIN_X_LABEL_FONT_SIZE)
} else {
AXIS_LABEL_FONT_SIZE
};
let tick_positions = (0..categories.len())
.map(|i| plot_x + (i as f64 + 0.5) * band_w)
.collect();
XAxisLayout {
tick_positions,
tick_labels: categories.clone(),
label_font,
point_count,
geom: XGeom::Category {
plot_left: plot_x,
band_w,
},
}
}
XAxis::Numeric { min, max } => {
let ticks = d3_ticks(*min, *max, DEFAULT_TICK_COUNT);
let labels: Vec<String> = ticks.iter().map(|v| format_tick(*v)).collect();
let label_max_width = labels
.iter()
.map(|s| approx_text_width(s, AXIS_LABEL_FONT_SIZE))
.fold(0.0, f64::max);
let outer = (label_max_width / 2.0).min(0.2 * plot_w);
let x0 = plot_x + outer;
let x1 = plot_x + plot_w - outer;
let tick_positions = ticks
.iter()
.map(|v| scale_linear(*v, *min, *max, x0, x1))
.collect();
XAxisLayout {
tick_positions,
tick_labels: labels,
label_font: AXIS_LABEL_FONT_SIZE,
point_count,
geom: XGeom::Numeric { x0, x1 },
}
}
}
}
fn parse_bracketed_number_list(s: &str, line: usize) -> Result<Vec<f64>, MermaidError> {
let Some(start) = s.find('[') else {
return Err(MermaidError::ParseError {
line,
message: format!("Invalid plot data: {s}"),
});
};
let Some(end) = s.rfind(']') else {
return Err(MermaidError::ParseError {
line,
message: format!("Invalid plot data: {s}"),
});
};
let inner = &s[start + 1..end];
let mut out = Vec::new();
for part in inner.split(',') {
let p = part.trim();
if p.is_empty() {
continue;
}
let v: f64 = p.parse().map_err(|_| MermaidError::ParseError {
line,
message: format!("Invalid plot value: {p}"),
})?;
out.push(v);
}
Ok(out)
}
fn points_to_path_d(points: &[(f64, f64)]) -> String {
if points.is_empty() {
return String::new();
}
let mut d = String::new();
if let Some((x, y)) = points.first().copied() {
d.push_str(&format!("M{x},{y}"));
}
for &(x, y) in points.iter().skip(1) {
d.push_str(&format!("L{x},{y}"));
}
d
}
fn scale_linear(
value: f64,
domain_min: f64,
domain_max: f64,
range_min: f64,
range_max: f64,
) -> f64 {
if (domain_max - domain_min).abs() < f64::EPSILON {
return range_min;
}
let t = (value - domain_min) / (domain_max - domain_min);
range_min + t * (range_max - range_min)
}
fn d3_ticks(start: f64, stop: f64, count: usize) -> Vec<f64> {
if count == 0 {
return Vec::new();
}
if !start.is_finite() || !stop.is_finite() {
return Vec::new();
}
if start == stop {
return vec![start];
}
let reverse = stop < start;
let (a, b) = if reverse {
(stop, start)
} else {
(start, stop)
};
let step = tick_step(a, b, count as f64);
if !step.is_finite() || step == 0.0 {
return Vec::new();
}
let start0 = (a / step).ceil();
let stop0 = (b / step).floor();
let n = (stop0 - start0 + 1.0).max(0.0) as i64;
let mut ticks = Vec::with_capacity(n as usize);
for i in 0..n {
ticks.push((start0 + i as f64) * step);
}
if reverse {
ticks.reverse();
}
ticks
}
fn tick_step(start: f64, stop: f64, count: f64) -> f64 {
let step0 = (stop - start).abs() / count.max(1.0);
let step1 = 10.0_f64.powf(step0.log10().floor());
let error = step0 / step1;
let e10 = 50.0_f64.sqrt();
let e5 = 10.0_f64.sqrt();
let e2 = 2.0_f64.sqrt();
let step = if error >= e10 {
step1 * 10.0
} else if error >= e5 {
step1 * 5.0
} else if error >= e2 {
step1 * 2.0
} else {
step1
};
if stop < start {
-step
} else {
step
}
}
fn format_tick(value: f64) -> String {
let rounded = value.round();
if (value - rounded).abs() < 1e-9 {
return format!("{:.0}", rounded);
}
let s = format!("{value:.6}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
fn approx_text_width(text: &str, font_size: f64) -> f64 {
let n = crate::text_wrap::display_width_units(text);
n * font_size * 0.525
}
fn approx_text_height(font_size: f64) -> f64 {
(font_size * 1.15).round()
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
#[cfg(test)]
mod tests {
use super::*;
// Categorical x-axis (no `-->`), labeled+ranged y-axis, two `line` series:
// the case the numeric-only parser rejected ("opening image ... fails").
const SAMPLE: &str = r#"xychart-beta
title "Weekly active users by region"
x-axis ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
y-axis "% of users" 0 --> 40
line [20.3, 22.6, 24.2, 24.3, 26.2, 27.2, 32.4, 31.9, 31.4, 31.1, 33.6, 34.3]
line [3.2, 6.3, 10.0, 9.4, 11.1, 10.7, 15.3, 13.4, 13.5, 12.5, 15.4, 15.8]"#;
#[test]
fn parses_categorical_axis_labels_and_two_series() {
let chart = parse_xychart(SAMPLE).expect("must parse");
assert_eq!(chart.title, "Weekly active users by region");
assert_eq!(chart.y_title, "% of users");
assert!(chart.x_title.is_empty());
assert_eq!((chart.y_min, chart.y_max), (0.0, 40.0));
match &chart.x_axis {
XAxis::Category(cats) => {
assert_eq!(cats.len(), 12);
assert_eq!(cats[0], "Jan");
assert_eq!(cats[11], "Dec");
}
other => panic!("expected categorical x-axis, got {other:?}"),
}
assert_eq!(chart.series.len(), 2);
assert_eq!(chart.series[0].len(), 12);
assert_eq!(chart.series[1].len(), 12);
assert_eq!(chart.series[1][0], 3.2);
}
#[test]
fn categorical_x_axis_with_two_lines_renders() {
let svg = render_xychart_diagram_to_svg(SAMPLE, &MermaidTheme::light())
.expect("categorical xychart with two line series must render");
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
assert!(svg.contains(">Jan<"));
assert!(svg.contains(">Dec<"));
assert!(svg.contains(">% of users<"));
assert_eq!(svg.matches("class=\"line-plot-").count(), 2);
assert!(svg.contains(SERIES_PALETTE[0]));
assert!(svg.contains(SERIES_PALETTE[1]));
assert!(svg.contains("Weekly active users by region"));
assert!(!svg.contains("&quot;")); // title/label quotes stripped
}
#[test]
fn numeric_x_axis_range_still_renders() {
let src = "xychart-beta\n title Demo\n x-axis 0 --> 10\n y-axis 0 --> 100\n line [5, 10, 20, 40]";
let svg = render_xychart_diagram_to_svg(src, &MermaidTheme::light())
.expect("numeric x-axis must still render");
assert!(svg.contains("<svg"));
assert!(svg.contains("Demo"));
assert_eq!(svg.matches("class=\"line-plot-").count(), 1);
}
#[test]
fn theme_text_color_drives_axis_and_labels() {
let svg = render_xychart_diagram_to_svg(SAMPLE, &MermaidTheme::dark())
.expect("dark theme must render");
assert!(svg.contains(&format!("fill=\"{}\"", MermaidTheme::dark().text_color)));
}
#[test]
fn y_axis_label_only_auto_ranges_from_data() {
let src = "xychart-beta\n x-axis [a, b, c]\n y-axis \"score\"\n line [10, 20, 30]";
let chart = parse_xychart(src).expect("must parse");
assert_eq!(chart.y_title, "score");
assert_eq!((chart.y_min, chart.y_max), (10.0, 30.0));
}
#[test]
fn missing_plot_is_rejected() {
let src = "xychart-beta\n x-axis [a, b]\n y-axis 0 --> 10";
assert!(render_xychart_diagram_to_svg(src, &MermaidTheme::light()).is_err());
}
#[test]
fn non_xychart_source_is_rejected() {
assert!(parse_xychart("flowchart TD\n A --> B").is_err());
}
#[test]
fn empty_line_series_is_rejected() {
// `line []` declares a series with no values: still no plottable data.
assert!(parse_xychart("xychart-beta\n x-axis [a, b]\n line []").is_err());
}
#[test]
fn categorical_points_stay_on_plot() {
let (plot_x, plot_w) = (60.0, 600.0);
// A series longer than the category list, and an empty category list:
// every point must still land within [plot_x, plot_x + plot_w].
for axis in [
XAxis::Category(vec!["a".to_string(), "b".to_string()]),
XAxis::Category(Vec::new()),
] {
let layout = layout_x_axis(&axis, plot_x, plot_w, 4);
for i in 0..4 {
let x = layout.series_point_x(i);
assert!(
(plot_x..=plot_x + plot_w).contains(&x),
"point {i} at {x} escaped the plot for {axis:?}"
);
}
}
}
#[test]
fn numeric_single_point_sits_at_left_edge() {
let (plot_x, plot_w) = (60.0, 600.0);
let layout = layout_x_axis(
&XAxis::Numeric {
min: 0.0,
max: 10.0,
},
plot_x,
plot_w,
1,
);
let XGeom::Numeric { x0, .. } = layout.geom else {
panic!("expected numeric geometry");
};
assert_eq!(layout.series_point_x(0), x0);
}
#[test]
fn numeric_series_share_x_domain_across_lengths() {
// Built with the longest series' length (4); `series_point_x` ignores any
// individual series length, so every series maps index -> x identically.
let (plot_x, plot_w) = (60.0, 600.0);
let layout = layout_x_axis(
&XAxis::Numeric {
min: 0.0,
max: 10.0,
},
plot_x,
plot_w,
4,
);
let XGeom::Numeric { x0, x1 } = layout.geom else {
panic!("expected numeric geometry");
};
// Spacing uses the shared count (4 -> denominator 3), not a per-series one.
assert_eq!(layout.series_point_x(0), x0);
assert_eq!(layout.series_point_x(3), x1);
assert!((layout.series_point_x(1) - (x0 + (x1 - x0) / 3.0)).abs() < 1e-9);
}
}