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
+63
View File
@@ -0,0 +1,63 @@
Third-party notices — vendored crates under third_party/
========================================================
This tree includes the following upstream projects, vendored in-source.
Full license texts are next to each crate. Local modifications (if any) are
documented in each crate's Cargo.toml header.
--------------------------------------------------------------------------------
mermaid-to-svg
--------------------------------------------------------------------------------
Path: third_party/mermaid-to-svg/
License: MIT
License file: third_party/mermaid-to-svg/LICENSE
Upstream: https://github.com/warpdotdev/mermaid-to-svg
Copyright: Copyright (c) 2025-2026 Denver Technologies, Inc. (see LICENSE)
Further ancestry (mermaid.js, dagre.js, and related) is listed in:
third_party/mermaid-to-svg/THIRD_PARTY_NOTICES
--------------------------------------------------------------------------------
dagre_rust
--------------------------------------------------------------------------------
Path: third_party/dagre_rust/
License: Apache License 2.0
License file: third_party/dagre_rust/LICENCE
Upstream: https://github.com/r3alst/dagre-rust
(also re-published via warpdotdev/mermaid-to-svg crates/dagre_rust)
Version: 0.0.5
crates.io: https://crates.io/crates/dagre_rust/0.0.5
--------------------------------------------------------------------------------
graphlib_rust
--------------------------------------------------------------------------------
Path: third_party/graphlib_rust/
License: Apache License 2.0
License file: third_party/graphlib_rust/LICENCE
Upstream: https://github.com/r3alst/graphlib-rust
Version: 0.0.2
crates.io: https://crates.io/crates/graphlib_rust/0.0.2
--------------------------------------------------------------------------------
ordered_hashmap
--------------------------------------------------------------------------------
Path: third_party/ordered_hashmap/
License: Apache License 2.0
License file: third_party/ordered_hashmap/LICENCE
Upstream: https://github.com/r3alst/ordered-hashmap
Version: 0.0.3
crates.io: https://crates.io/crates/ordered_hashmap/0.0.3
--------------------------------------------------------------------------------
End of notices for vendored third_party/ crates.
Full product notices (all crates.io / git dependencies shipped with the Grok
CLI, including MPL packages, Apache NOTICE files, libgit2 COPYING, and
dual-license elections) live in:
THIRD-PARTY-NOTICES
Those entries cover the legal-requirements matrix (MIT, Apache-2.0, BSD-*,
ISC, Zlib, Unicode-*, BSL-1.0, MPL-2.0, CDLA-Permissive-2.0, Unicode-DFS-2016,
and the libgit2 GPL+linking-exception bundle). Dropped-from-release packages
(CDDL inferno, EPL colored_json, WTFPL terminfo) are omitted there by design.
+59
View File
@@ -0,0 +1,59 @@
# Third-party vendored crates
This directory holds **upstream source** vendored into the repository. It is
**not** first-party application code.
## Why vendor
These crates sit on the path that renders **untrusted model output** (diagram
source → SVG). Vendoring gives a full audit surface, pins exact source, and
avoids crates.io yanks. Local patches and upgrade checklists live in each
crates `Cargo.toml` header comments — treat those as the source of truth when
re-vendoring.
## Mermaid layout stack
| Crate | Version | License | Upstream | Full license text |
|-------|---------|---------|----------|-------------------|
| [`mermaid-to-svg`](./mermaid-to-svg/) | (path) | MIT | [warpdotdev/mermaid-to-svg](https://github.com/warpdotdev/mermaid-to-svg) | [`LICENSE`](./mermaid-to-svg/LICENSE) |
| [`dagre_rust`](./dagre_rust/) | 0.0.5 | Apache-2.0 | [r3alst/dagre-rust](https://github.com/r3alst/dagre-rust) / Warp re-vendor | [`LICENCE`](./dagre_rust/LICENCE) |
| [`graphlib_rust`](./graphlib_rust/) | 0.0.2 | Apache-2.0 | [r3alst/graphlib-rust](https://github.com/r3alst/graphlib-rust) | [`LICENCE`](./graphlib_rust/LICENCE) |
| [`ordered_hashmap`](./ordered_hashmap/) | 0.0.3 | Apache-2.0 | [r3alst/ordered-hashmap](https://github.com/r3alst/ordered-hashmap) | [`LICENCE`](./ordered_hashmap/LICENCE) |
Dependency shape:
```text
xai-grok-mermaid
└── mermaid-to-svg (MIT)
├── dagre_rust (Apache-2.0)
│ ├── graphlib_rust
│ └── ordered_hashmap
└── graphlib_rust (Apache-2.0)
└── ordered_hashmap
```
## Notices and ancestry
- **[`NOTICE`](./NOTICE)** — short index of the crates above (names, licenses,
upstream links, paths to full text). Prefer that file for a one-page overview.
- **[`mermaid-to-svg/THIRD_PARTY_NOTICES`](./mermaid-to-svg/THIRD_PARTY_NOTICES)** —
additional ancestry for the SVG engine (e.g. mermaid.js, dagre.js MIT notices).
British spelling **`LICENCE`** is intentional on the Apache crates (as upstream
vendored); grepping only for `LICENSE` will miss them.
## crates.io dependencies
Normal Cargo dependencies (tokio, serde, …) are **not** under `third_party/`.
They resolve via `Cargo.lock` / crates.io. Full attribution and license texts
for the Grok CLI dependency closure are maintained in
[`THIRD-PARTY-NOTICES`](../THIRD-PARTY-NOTICES).
This directory is only for **in-tree vendored** sources.
## Upgrading
1. Read the `VENDORING NOTES` block at the top of the crates `Cargo.toml`.
2. Re-apply listed local patches (fmt, hermetic env, unsafe fixes, dropped bins/tests).
3. Confirm the license file still matches the declared `license =` field.
4. Refresh [`NOTICE`](./NOTICE) if versions or upstream URLs change.
+56
View File
@@ -0,0 +1,56 @@
# Vendored third-party crate.
#
# Upstream: dagre_rust 0.0.5 (Apache-2.0), as re-published in
# warpdotdev/mermaid-to-svg `crates/dagre_rust` ("vendored from 0.0.5
# with fixes" per upstream Cargo.toml).
# Source of truth: https://github.com/warpdotdev/mermaid-to-svg (rev
# 40cecf2be376e47e15053eadbfb782a531777420), crates/dagre_rust/.
# Base crate: https://crates.io/crates/dagre_rust/0.0.5
#
# Why vendored: the Rust port of dagre.js (the same layout engine mermaid.js
# uses). It is the layout core of the Warp `mermaid-to-svg` engine, which renders
# untrusted model output, so vendoring gives a full audit surface and immunity to
# upstream yanks. See third_party/mermaid-to-svg and crates/codegen/xai-grok-mermaid.
#
# ============================================================================
# VENDORING NOTES — re-apply / re-bless ALL of the following on every upgrade:
# ============================================================================
# Local modifications to the upstream source:
# 1. `cargo fmt` (rustfmt) applied so the crate satisfies the workspace fmt
# gate; no semantic change. Otherwise the library source (`src/**`, incl. one
# in-source unit test in `src/layout/util.rs`) is the `mermaid-to-svg`
# in-repo copy; the `graphlib_rust` / `ordered_hashmap` deps are repointed to
# the sibling vendored crates.
# 2. UNSAFE REMOVAL (src/layout/util.rs): the upstream unique-id counter was a
# `static mut UNIQUE_STARTER` incremented inside an `unsafe` block with no
# synchronization — a data race whenever the engine renders on more than one
# thread (e.g. the parallel `cargo test` suite). Replaced with a
# `std::sync::atomic::AtomicUsize` (`fetch_add`); behaviour-preserving (still
# hands out monotonic unique ids) and it removes the only `unsafe` here.
#
# Re-audit checklist:
# - No `unsafe` remains (the `static mut` counter became an `AtomicUsize`; see
# local modification 2), and no filesystem / env / network I/O in the source.
[package]
name = "dagre_rust"
version = "0.0.5"
edition = "2021"
description = "Dagre layout in Rust (vendored, library-only)"
license = "Apache-2.0"
repository = "https://github.com/r3alst/dagre-rust"
publish = false
[lib]
name = "dagre_rust"
path = "src/lib.rs"
doctest = false
[dependencies]
graphlib_rust = { path = "../graphlib_rust" }
ordered_hashmap = { path = "../ordered_hashmap" }
# 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 }
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
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.
+87
View File
@@ -0,0 +1,87 @@
use crate::layout::util::unique_id;
use crate::layout::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::{Edge, Graph};
use ordered_hashmap::OrderedHashMap;
pub fn run(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let mut fas: Option<Vec<Edge>> = None;
let graph_config = graph.graph();
if graph_config.acyclicer.is_some()
&& graph_config.acyclicer.clone().unwrap() == "greedy".to_string()
{
// TODO: need to implement this algorithm
println!("greedy_fas");
// greedyFAS
} else {
fas = Some(dfs_fas(graph));
// println!("dfs_fas");
}
let _fas = fas.unwrap_or(vec![]);
for edge in _fas {
let _edge_label = graph.edge_with_obj(&edge);
if _edge_label.is_none() {
continue;
}
let mut edge_label = _edge_label.cloned().unwrap();
graph.remove_edge_with_obj(&edge);
edge_label.forward_name = edge.name.clone();
edge_label.reversed = Some(true);
let _ = graph.set_edge(
&edge.w,
&edge.v,
Some(edge_label),
Some(format!("rev{}", unique_id())),
);
}
}
fn dfs_fas(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) -> Vec<Edge> {
let mut fas: Vec<Edge> = vec![];
let mut stack: OrderedHashMap<String, bool> = OrderedHashMap::new();
let mut visited: OrderedHashMap<String, bool> = OrderedHashMap::new();
fn dfs(
node_id: String,
stack: &mut OrderedHashMap<String, bool>,
visited: &mut OrderedHashMap<String, bool>,
graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
fas: &mut Vec<Edge>,
) {
if visited.contains_key(&node_id) {
return ();
}
visited.insert(node_id.clone(), true);
stack.insert(node_id.clone(), true);
let out_edges = graph.out_edges(&node_id, None).unwrap_or(vec![]);
for edge in out_edges.into_iter() {
if stack.contains_key(&edge.w) {
fas.push(edge.clone());
} else {
dfs(edge.w.clone(), stack, visited, graph, fas);
}
}
stack.remove(&node_id);
}
for node_id in graph.nodes() {
dfs(node_id, &mut stack, &mut visited, graph, &mut fas);
}
return fas;
}
pub fn undo(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
for e in g.edges() {
let Some(edge) = g.edge_mut_with_obj(&e) else {
continue;
};
if edge.reversed.clone().unwrap_or(false) {
let forward_name = edge.forward_name.clone();
let mut label = edge.clone();
label.reversed = None;
label.forward_name = None;
let _ = g.set_edge(&e.w, &e.v, Some(label), forward_name);
}
}
}
@@ -0,0 +1,73 @@
use crate::layout::util::add_dummy_node;
use crate::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::graph::GRAPH_NODE;
use graphlib_rust::Graph;
use ordered_hashmap::OrderedHashMap;
#[derive(Debug, Clone, PartialEq)]
pub enum BorderTypeName {
BorderLeft,
BorderRight,
}
pub fn add_border_segments(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
fn dfs(v: &String, g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let children = g.children(v);
if children.len() > 0 {
for cv in children.iter() {
dfs(cv, g);
}
}
let node = g.node_mut(v).unwrap();
if node.min_rank.is_some() {
node.border_left = Some(OrderedHashMap::new());
node.border_right = Some(OrderedHashMap::new());
let mut rank = node.min_rank.clone().unwrap_or(0);
let max_rank = node.max_rank.clone().unwrap_or(0) + 1;
while rank < max_rank {
add_border_node(g, BorderTypeName::BorderLeft, "_bl", v, &rank);
add_border_node(g, BorderTypeName::BorderRight, "_br", v, &rank);
rank += 1;
}
}
}
let children = g.children(&GRAPH_NODE.to_string());
for v in children.iter() {
dfs(v, g);
}
}
fn add_border_node(
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
prop: BorderTypeName,
prefix: &str,
sg: &String,
rank: &i32,
) {
let mut label = GraphNode::default();
label.rank = Some(rank.clone());
label.border_type = Some(prop.clone());
let curr = add_dummy_node(g, "border".to_string(), label, prefix.to_string());
let sg_node = g.node_mut(sg).unwrap();
let mut border = sg_node.border_left.as_mut().unwrap();
match prop {
BorderTypeName::BorderRight => {
border = sg_node.border_right.as_mut().unwrap();
}
_ => (),
}
border.insert(rank.clone(), curr.clone());
let prev = border.get(&(rank - 1));
if prev.is_some() {
let prev_v = prev.cloned().unwrap();
let mut graph_edge = GraphEdge::default();
graph_edge.weight = Some(1.0);
let _ = g.set_edge(&prev_v, &curr, Some(graph_edge), None);
}
let _ = g.set_parent(&curr, Some(sg.clone()));
}
+89
View File
@@ -0,0 +1,89 @@
use crate::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::Graph;
pub fn adjust(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let rank_dir = g.graph().rankdir.clone().unwrap();
if &rank_dir == "lr" || &rank_dir == "rl" {
swap_width_height(g);
}
}
pub fn undo(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let rank_dir = g.graph().rankdir.clone().unwrap();
if &rank_dir == "bt" || &rank_dir == "rl" {
reverse_y(g);
}
if &rank_dir == "lr" || &rank_dir == "rl" {
swap_x_y(g);
swap_width_height(g);
}
}
fn swap_width_height(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let nodes = g.nodes();
// =swapWidthHeightOne
nodes.iter().for_each(|v| {
let node = g.node_mut(v).unwrap();
let w = node.width.clone();
node.width = node.height;
node.height = w;
});
let edges = g.edges();
// =swapWidthHeightOne
edges.iter().for_each(|e| {
let edge_label = g.edge_mut_with_obj(&e).unwrap();
let w = edge_label.width.clone();
edge_label.width = edge_label.height;
edge_label.height = w;
});
}
fn reverse_y(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let nodes = g.nodes();
nodes.iter().for_each(|v| {
// =reverseYOne
let node = g.node_mut(v).unwrap();
node.y = -node.y;
});
let edges = g.edges();
edges.iter().for_each(|e| {
// =reverseYOne
let edge_label = g.edge_mut_with_obj(&e).unwrap();
let mut points = edge_label.points.clone().unwrap_or(vec![]);
points.iter_mut().for_each(|point| {
point.y = -point.y;
});
edge_label.points = Some(points);
edge_label.y = -edge_label.y;
});
}
fn swap_x_y(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let nodes = g.nodes();
nodes.iter().for_each(|v| {
// =swapXYOne
let node = g.node_mut(v).unwrap();
let x = node.x.clone();
node.x = node.y;
node.y = x;
});
let edges = g.edges();
edges.iter().for_each(|e| {
// =swapXYOne
let edge_label = g.edge_mut_with_obj(&e).unwrap();
let mut points = edge_label.points.clone().unwrap_or(vec![]);
points.iter_mut().for_each(|point| {
let x = point.x.clone();
point.x = point.y;
point.y = x;
});
edge_label.points = Some(points);
let x = edge_label.x.clone();
edge_label.x = edge_label.y;
edge_label.y = x;
});
}
+645
View File
@@ -0,0 +1,645 @@
use crate::layout::add_border_segments::add_border_segments;
use crate::layout::order::order;
use crate::layout::parent_dummy_chains::parent_dummy_chains;
use crate::layout::rank::rank;
use crate::layout::util::{
as_non_compound_graph, intersect_rect, normalize_ranks, remove_empty_ranks,
transfer_node_edge_labels, Rect,
};
use crate::{GraphConfig, GraphEdge, GraphEdgePoint, GraphNode};
use graphlib_rust::{Graph, GraphOption};
pub mod acyclic;
pub mod add_border_segments;
pub mod coordinate_system;
pub mod nesting_graph;
pub mod normalize;
pub mod order;
pub mod parent_dummy_chains;
pub mod position;
pub mod rank;
pub mod util;
const DEFAULT_RANK_SEP: f32 = 50.0;
pub fn layout(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let mut layout_graph = build_layout_graph(g);
run_layout(&mut layout_graph);
update_input_graph(g, &layout_graph);
}
pub fn update_input_graph(
input_graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
layout_graph: &Graph<GraphConfig, GraphNode, GraphEdge>,
) {
for v in input_graph.nodes() {
let input_label_ = input_graph.node_mut(&v);
let layout_label = layout_graph.node(&v).unwrap();
if let Some(input_label) = input_label_ {
input_label.x = layout_label.x;
input_label.y = layout_label.y;
if layout_graph.children(&v).len() > 0 {
input_label.width = layout_label.width;
input_label.height = layout_label.height;
}
}
}
for e in input_graph.edges() {
let input_label = input_graph.edge_mut_with_obj(&e).unwrap();
let layout_label = layout_graph.edge_with_obj(&e).unwrap();
input_label.points = layout_label.points.clone();
input_label.x = layout_label.x;
input_label.y = layout_label.y;
}
input_graph.graph_mut().width = layout_graph.graph().width;
input_graph.graph_mut().height = layout_graph.graph().height;
}
pub fn set_graph_label_default_values(graph_label: &mut GraphConfig) {
if graph_label.ranksep.is_none() {
graph_label.ranksep = Some(50.0);
}
if graph_label.edgesep.is_none() {
graph_label.edgesep = Some(20.0);
}
if graph_label.nodesep.is_none() {
graph_label.nodesep = Some(50.0);
}
if graph_label.rankdir.is_none() {
graph_label.rankdir = Some("tb".to_string());
}
if graph_label.marginx.is_none() {
graph_label.marginx = Some(0.0);
}
if graph_label.marginy.is_none() {
graph_label.marginy = Some(0.0);
}
}
pub fn set_edge_label_default_values(edge_label: &mut GraphEdge) {
if edge_label.minlen.is_none() {
edge_label.minlen = Some(1.0);
}
if edge_label.weight.is_none() {
edge_label.weight = Some(1.0);
}
if edge_label.width.is_none() {
edge_label.width = Some(0.0);
}
if edge_label.height.is_none() {
edge_label.height = Some(0.0);
}
if edge_label.labeloffset.is_none() {
edge_label.labeloffset = Some(10.0);
}
if edge_label.labelpos.is_none() {
edge_label.labelpos = Some("r".to_string());
}
}
/*
* Constructs a new graph from the input graph, which can be used for layout.
* This process copies only whitelisted attributes from the input graph to the
* layout graph. Thus this function serves as a good place to determine what
* attributes can influence layout.
*/
pub fn build_layout_graph(
input_graph: &Graph<GraphConfig, GraphNode, GraphEdge>,
) -> Graph<GraphConfig, GraphNode, GraphEdge> {
let mut g: Graph<GraphConfig, GraphNode, GraphEdge> = Graph::new(Some(GraphOption {
directed: Some(true),
multigraph: Some(true),
compound: Some(true),
}));
let mut graph_label: GraphConfig = input_graph.graph().clone();
set_graph_label_default_values(&mut graph_label);
g.set_graph(graph_label);
for node_id in input_graph.nodes().iter() {
let _node = input_graph.node(node_id);
if _node.is_none() {
continue;
}
g.set_node(node_id.clone(), _node.cloned());
let _ = g.set_parent(node_id, input_graph.parent(node_id).cloned());
}
for edge_obj in input_graph.edges() {
let _edge = input_graph.edge_with_obj(&edge_obj);
if _edge.is_none() {
continue;
}
let mut edge_label = _edge.cloned().unwrap();
set_edge_label_default_values(&mut edge_label);
let _ = g.set_edge_with_obj(&edge_obj, Some(edge_label));
}
return g;
}
/*
* This idea comes from the Gansner paper: to account for edge labels in our
* layout we split each rank in half by doubling minlen and halving ranksep.
* Then we can place labels at these mid-points between nodes.
*
* We also add some minimal padding to the width to push the label for the edge
* away from the edge itself a bit.
*/
pub fn make_space_for_edge_labels(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let graph_config = graph.graph_mut();
graph_config.ranksep = Some(graph_config.ranksep.unwrap_or(DEFAULT_RANK_SEP) / 2.0);
// moving in nested block due to borrow checker
{
let graph_config = graph.graph().clone();
let edge_objs = graph.edges();
for edge_obj in edge_objs.into_iter() {
let _edge = graph.edge_mut_with_obj(&edge_obj);
if _edge.is_none() {
continue;
}
let edge = _edge.unwrap();
let minlen = edge.minlen.unwrap_or(1.0);
let labelpos = edge.labelpos.clone().unwrap_or("".to_string());
let labeloffset = edge.labeloffset.unwrap_or(10.0);
let rankdir = graph_config.rankdir.clone().unwrap_or("".to_string());
edge.minlen = Some(minlen * 2.0);
if labelpos != "c" {
if rankdir == "tb" || rankdir == "bt" {
edge.width = Some(edge.width.unwrap_or(0.0) + labeloffset);
} else {
edge.height = Some(edge.height.unwrap_or(0.0) + labeloffset);
}
}
}
}
}
/*
* Creates temporary dummy nodes that capture the rank in which each edge's
* label is going to, if it has one of non-zero width and height. We do this
* so that we can safely remove empty ranks while preserving balance for the
* label's position.
*/
pub fn inject_edge_label_proxies(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let edges = g.edges();
for e in edges.into_iter() {
let edge_ = g.edge_with_obj(&e);
if let Some(edge) = edge_ {
if edge.width.clone().unwrap_or(0.0) > 0.0 && edge.height.clone().unwrap_or(0.0) > 0.0 {
let v = g.node(&e.v);
let w = g.node(&e.w);
let v_rank = v.cloned().unwrap_or(GraphNode::default()).rank.unwrap_or(0);
let w_rank = w.cloned().unwrap_or(GraphNode::default()).rank.unwrap_or(0);
let mut label = GraphNode::default();
label.rank = Some((w_rank - v_rank) / 2 + v_rank);
label.width = edge.width.unwrap_or(0.0);
label.height = edge.height.unwrap_or(0.0);
label.labelpos = edge.labelpos.clone();
label.e = Some(e.clone());
util::add_dummy_node(g, "edge-proxy".to_string(), label, "_ep".to_string());
}
}
}
}
pub fn assign_rank_min_max(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let mut max_rank = 0;
let vs = g.nodes();
for v in vs.iter() {
let node_ = g.node(v);
if node_.is_none() {
continue;
}
let node = node_.unwrap();
if node.border_top.is_some() {
let border_top = node.border_top.clone().unwrap();
let border_bottom = node.border_bottom.clone().unwrap();
let _min_rank = g.node(&border_top).cloned().unwrap().rank.unwrap_or(0);
let _max_rank = g.node(&border_bottom).cloned().unwrap().rank.unwrap_or(0);
let _node = g.node_mut(v).unwrap();
_node.min_rank = Some(_min_rank);
_node.max_rank = Some(_max_rank.clone());
max_rank = std::cmp::max(max_rank, _max_rank);
}
}
}
enum GraphElement<'a> {
Node(&'a GraphNode),
Edge(&'a GraphEdge),
}
impl<'a> GraphElement<'a> {
fn x(&self) -> f32 {
match self {
GraphElement::Node(node) => node.x,
GraphElement::Edge(edge) => edge.x,
}
}
fn y(&self) -> f32 {
match self {
GraphElement::Node(node) => node.y,
GraphElement::Edge(edge) => edge.y,
}
}
fn width(&self) -> f32 {
match self {
GraphElement::Node(node) => node.width,
GraphElement::Edge(edge) => edge.width.unwrap_or(0.0),
}
}
fn height(&self) -> f32 {
match self {
GraphElement::Node(node) => node.height,
GraphElement::Edge(edge) => edge.height.unwrap_or(0.0),
}
}
}
pub fn translate_graph(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let mut min_x = f64::INFINITY as f32;
let mut max_x: f32 = 0.0;
let mut min_y = f64::INFINITY as f32;
let mut max_y: f32 = 0.0;
let mut graph_label = g.graph().clone();
let margin_x = graph_label.marginx.unwrap_or(0.0);
let margin_y = graph_label.marginy.unwrap_or(0.0);
fn get_extremes(
attrs: &GraphElement,
min_x: &mut f32,
max_x: &mut f32,
min_y: &mut f32,
max_y: &mut f32,
) {
let x = attrs.x();
let y = attrs.y();
let w = attrs.width();
let h = attrs.height();
*min_x = min_x.min(x - w / 2.0);
*max_x = max_x.max(x + w / 2.0);
*min_y = min_y.min(y - h / 2.0);
*max_y = max_y.max(y + h / 2.0);
}
for v in g.nodes() {
get_extremes(
&GraphElement::Node(g.node(&v).unwrap()),
&mut min_x,
&mut max_x,
&mut min_y,
&mut max_y,
);
}
for e in g.edges() {
let edge = g.edge_with_obj(&e).unwrap();
if edge.width.unwrap_or(0.0) > 0.0 && edge.height.unwrap_or(0.0) > 0.0 {
get_extremes(
&GraphElement::Edge(edge),
&mut min_x,
&mut max_x,
&mut min_y,
&mut max_y,
);
}
}
min_x -= margin_x;
min_y -= margin_y;
for v in g.nodes() {
let node = g.node_mut(&v).unwrap();
node.x -= min_x;
node.y -= min_y;
}
for e in g.edges() {
let edge = g.edge_mut_with_obj(&e).unwrap();
let has_label = edge.width.unwrap_or(0.0) > 0.0 && edge.height.unwrap_or(0.0) > 0.0;
if edge.points.is_some() {
for p in edge.points.as_mut().unwrap() {
p.x -= min_x;
p.y -= min_y;
}
}
if has_label {
edge.x -= min_x;
edge.y -= min_y;
}
}
graph_label.width = max_x - min_x + margin_x;
graph_label.height = max_y - min_y + margin_y;
g.set_graph(graph_label);
}
pub fn assign_node_intersects(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
for e in g.edges() {
let mut edge = g.edge_mut_with_obj(&e).cloned().unwrap();
let node_v = g.node(&e.v).cloned().unwrap();
let node_w = g.node(&e.w).cloned().unwrap();
let (p1, p2) =
if edge.points.is_none() || edge.points.as_ref().map_or(true, |p| p.is_empty()) {
edge.points = Some(vec![]);
(
GraphEdgePoint {
x: node_w.x,
y: node_w.y,
},
GraphEdgePoint {
x: node_v.x,
y: node_v.y,
},
)
} else {
let points = edge.points.clone().unwrap();
let r1 = GraphEdgePoint {
x: points[0].x,
y: points[0].y,
};
let r2 = GraphEdgePoint {
x: points[points.len() - 1].x,
y: points[points.len() - 1].y,
};
(r1, r2)
};
let points = edge.points.as_mut().unwrap();
points.insert(
0,
intersect_rect(
&Rect {
x: node_v.x,
y: node_v.y,
width: node_v.width,
height: node_v.height,
},
&p1,
),
);
points.push(intersect_rect(
&Rect {
x: node_w.x,
y: node_w.y,
width: node_w.width,
height: node_w.height,
},
&p2,
));
let _ = g.set_edge_with_obj(&e, Some(edge));
}
}
pub fn remove_edge_label_proxies(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let vs = g.nodes();
for v in vs.iter() {
let node = g.node(v).unwrap();
if node.dummy.is_some() && node.dummy.clone().unwrap() == "edge-proxy" {
let rank = node.rank.unwrap_or(0);
let graph_edge_ = g.edge_mut_with_obj(&node.e.clone().unwrap());
if let Some(graph_edge) = graph_edge_ {
graph_edge.label_rank = Some(rank);
}
g.remove_node(v);
}
}
}
pub fn fixup_edge_label_coords(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
g.edges().iter().for_each(|e| {
let edge = g.edge_mut_with_obj(&e.to_owned()).unwrap();
if edge.x != 0.0 {
let labelpos = edge.labelpos.clone().unwrap_or("".to_string());
let labeloffset = edge.labeloffset.clone().unwrap_or(0.0);
if labelpos == "l" || labelpos == "r" {
edge.width = Some(edge.width.unwrap_or(0.0) - labeloffset);
}
if labelpos == "l" {
edge.x -= edge.width.clone().unwrap_or(0.0) / 2.0 + labeloffset;
} else if labelpos == "r" {
edge.x += edge.width.clone().unwrap_or(0.0) / 2.0 + labeloffset;
}
}
});
}
pub fn reverse_points_for_reversed_edges(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
for e in g.edges() {
let edge = g.edge_mut_with_obj(&e.to_owned()).unwrap();
if edge.reversed.clone().unwrap_or(false) {
if edge.points.is_some() {
let points = edge.points.as_mut().unwrap();
points.reverse();
}
}
}
}
pub fn remove_border_nodes(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
for v in g.nodes() {
if g.children(&v).len() > 0 {
let mut node = g.node(&v).cloned().unwrap();
let Some(border_top) = node.border_top.as_ref() else {
continue;
};
let Some(border_bottom) = node.border_bottom.as_ref() else {
continue;
};
let Some(t) = g.node(border_top).cloned() else {
continue;
};
let Some(b) = g.node(border_bottom).cloned() else {
continue;
};
let Some(border_left) = node.border_left.clone() else {
continue;
};
let Some(border_right) = node.border_right.clone() else {
continue;
};
let mut l_keys: Vec<i32> = border_left.keys().cloned().collect();
if l_keys.is_empty() {
continue;
}
l_keys.sort();
let mut r_keys: Vec<i32> = border_right.keys().cloned().collect();
if r_keys.is_empty() {
continue;
}
r_keys.sort();
let Some(l_node_id) = border_left.get(&l_keys[l_keys.len() - 1]) else {
continue;
};
let Some(r_node_id) = border_right.get(&r_keys[r_keys.len() - 1]) else {
continue;
};
let Some(l) = g.node(l_node_id).cloned() else {
continue;
};
let Some(r) = g.node(r_node_id).cloned() else {
continue;
};
node.width = (r.x - l.x).abs();
node.height = (b.y - t.y).abs();
node.x = l.x + node.width / 2.0;
node.y = t.y + node.height / 2.0;
g.set_node(v.clone(), Some(node));
}
}
g.nodes().iter().for_each(|v| {
let node = g.node(v).unwrap();
if node.dummy.is_some() && node.dummy.clone().unwrap() == "border" {
g.remove_node(v);
}
});
}
pub fn remove_self_edges(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let edge_objs = graph.edges();
for edge_obj in edge_objs.into_iter() {
if edge_obj.v == edge_obj.w {
let edge_label = graph.edge_with_obj(&edge_obj).cloned().unwrap();
let node = graph.node_mut(&edge_obj.v).unwrap();
node.self_edges.push((edge_obj.clone(), edge_label));
graph.remove_edge_with_obj(&edge_obj);
}
}
}
pub fn insert_self_edges(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let layers = util::build_layer_matrix(graph);
layers.iter().for_each(|layer| {
let mut order_shift = 0;
layer.iter().enumerate().for_each(|(i, v)| {
let node = graph.node_mut(v).unwrap();
node.order = Some(i + order_shift);
let rank = node.rank.clone();
let self_edges = node.self_edges.clone();
self_edges.into_iter().for_each(|(edge, graph_edge)| {
let mut _graph_node = GraphNode::default();
_graph_node.width = graph_edge.width.clone().unwrap_or(0.0);
_graph_node.height = graph_edge.height.clone().unwrap_or(0.0);
_graph_node.rank = rank.clone();
order_shift += 1;
_graph_node.order = Some(i + order_shift);
_graph_node.e = Some(edge.clone());
_graph_node.label = Some(graph_edge.clone());
util::add_dummy_node(
graph,
"selfedge".to_string(),
_graph_node,
"_se".to_string(),
);
});
});
})
}
pub fn position_self_edges(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
for v in g.nodes() {
let node = g.node(&v).cloned().unwrap();
if node.dummy.unwrap_or("".to_string()) == "selfedge" {
let self_node = g.node(&node.e.as_ref().unwrap().v).unwrap();
let x = self_node.x + self_node.width / 2.0;
let y = self_node.y;
let dx = node.x - x;
let dy = self_node.height / 2.0;
let mut graph_edge = node.label.clone().unwrap();
graph_edge.points = Some(vec![
GraphEdgePoint {
x: x + 2.0 * dx / 3.0,
y: y - dy,
},
GraphEdgePoint {
x: x + 2.0 * dx / 3.0,
y: y - dy,
},
GraphEdgePoint {
x: x + 5.0 * dx / 6.0,
y: y - dy,
},
GraphEdgePoint { x: x + dx, y },
GraphEdgePoint {
x: x + 5.0 * dx / 6.0,
y: y + dy,
},
GraphEdgePoint {
x: x + 2.0 * dx / 3.0,
y: y + dy,
},
]);
graph_edge.x = node.x;
graph_edge.y = node.y;
let _ = g.set_edge_with_obj(&node.e.unwrap(), Some(graph_edge));
g.remove_node(&v);
}
}
}
pub fn run_layout(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
make_space_for_edge_labels(graph);
remove_self_edges(graph);
acyclic::run(graph);
nesting_graph::run(graph);
// calculating ranks
let mut nc_graph: Graph<GraphConfig, GraphNode, GraphEdge> = as_non_compound_graph(graph);
rank(&mut nc_graph);
transfer_node_edge_labels(&nc_graph, graph);
// done with calculating ranks
inject_edge_label_proxies(graph);
remove_empty_ranks(graph);
nesting_graph::cleanup(graph);
normalize_ranks(graph);
assign_rank_min_max(graph);
remove_edge_label_proxies(graph);
normalize::run(graph);
parent_dummy_chains(graph);
add_border_segments(graph);
order(graph);
insert_self_edges(graph);
coordinate_system::adjust(graph);
position::position(graph);
position_self_edges(graph);
remove_border_nodes(graph);
normalize::undo(graph);
fixup_edge_label_coords(graph);
coordinate_system::undo(graph);
translate_graph(graph);
assign_node_intersects(graph);
reverse_points_for_reversed_edges(graph);
acyclic::undo(graph);
}
+213
View File
@@ -0,0 +1,213 @@
use crate::layout::{util, GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::graph::GRAPH_NODE;
use graphlib_rust::Graph;
use ordered_hashmap::OrderedHashMap;
/*
* A nesting graph creates dummy nodes for the tops and bottoms of subgraphs,
* adds appropriate edges to ensure that all cluster nodes are placed between
* these boundries, and ensures that the graph is connected.
*
* In addition we ensure, through the use of the minlen property, that nodes
* and subgraph border nodes to not end up on the same rank.
*
* Preconditions:
*
* 1. Input graph is a DAG
* 2. Nodes in the input graph has a minlen attribute
*
* Postconditions:
*
* 1. Input graph is connected.
* 2. Dummy nodes are added for the tops and bottoms of subgraphs.
* 3. The minlen attribute for nodes is adjusted to ensure nodes do not
* get placed on the same rank as subgraph border nodes.
*
* The nesting graph idea comes from Sander, "Layout of Compound Directed
* Graphs."
*/
pub fn run(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let graph_node = GraphNode::default();
let root = util::add_dummy_node(graph, "root".to_string(), graph_node, "_root".to_string());
let depths = tree_depths(graph);
let mut height: usize = 0;
for depth in depths.values() {
if depth > &height {
height = depth.to_owned();
}
}
if height > 0 {
height -= 1;
}
let node_sep = (2 * height + 1) as f32;
graph.graph_mut().nesting_root = Some(root.clone());
// Multiply minlen by nodeSep to align nodes on non-border ranks.
let edge_objs = graph.edges();
for edge_obj in edge_objs.into_iter() {
let _edge_label = graph.edge_mut_with_obj(&edge_obj);
if _edge_label.is_none() {
continue;
}
let edge_label = _edge_label.unwrap();
edge_label.minlen = Some(edge_label.minlen.unwrap_or(1.0) * node_sep);
}
// Calculate a weight that is sufficient to keep subgraphs vertically compact
let weight = sum_weights(graph) + 1.0;
// Create border nodes and link them up
let children = graph.children(&GRAPH_NODE.to_string());
for child_id in children.into_iter() {
dfs(
graph, &root, &node_sep, &weight, &height, &depths, &child_id,
);
}
// Save the multiplier for node layers for later removal of empty border
// layers.
graph.graph_mut().node_rank_factor = Some(node_sep);
}
fn tree_depths(graph: &Graph<GraphConfig, GraphNode, GraphEdge>) -> OrderedHashMap<String, usize> {
let mut depths: OrderedHashMap<String, usize> = OrderedHashMap::new();
fn dfs(
node_id: String,
depth: usize,
depths: &mut OrderedHashMap<String, usize>,
graph: &Graph<GraphConfig, GraphNode, GraphEdge>,
) {
let children = graph.children(&node_id);
for child_id in children.iter() {
// recursion for child node ids
dfs(child_id.clone(), depth + 1, depths, graph);
}
// setting for current node
depths.insert(node_id.clone(), depth);
}
// processing root nodes
for node_id in graph.children(&GRAPH_NODE.to_string()) {
dfs(node_id, 1, &mut depths, graph);
}
return depths;
}
fn dfs(
graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
root: &String,
node_sep: &f32,
weight: &f32,
height: &usize,
depths: &OrderedHashMap<String, usize>,
node_id: &String,
) {
let children = graph.children(node_id);
if children.len() == 0 {
if node_id != root {
let mut graph_edge = GraphEdge::default();
graph_edge.minlen = Some(node_sep.clone());
graph_edge.weight = Some(0.0);
let _ = graph.set_edge(&root, &node_id, Some(graph_edge), None);
}
return ();
}
let top = util::add_border_node(graph, "_bt", None, None);
let bottom = util::add_border_node(graph, "_bb", None, None);
let _pt = graph.set_parent(&top, Some(node_id.clone()));
let _pb = graph.set_parent(&bottom, Some(node_id.clone()));
let _label = graph.node_mut(node_id);
if let Some(label) = _label {
label.border_top = Some(top.clone());
label.border_bottom = Some(bottom.clone());
}
for child_id in children.into_iter() {
dfs(graph, root, node_sep, weight, height, depths, &child_id);
let _child_node = graph.node(&child_id);
if _child_node.is_none() {
continue;
}
let child_node = _child_node.unwrap();
let border_top = child_node.border_top.clone();
let border_bottom = child_node.border_bottom.clone();
let mut child_top = child_id.clone();
let mut child_bottom = child_id.clone();
let mut this_weight: f32 = 2.0 * weight.clone();
let mut minlen: usize = 1;
if border_top.is_some() {
child_top = border_top.clone().unwrap();
}
if border_bottom.is_some() {
child_bottom = border_bottom.unwrap();
}
if border_top.is_some() {
this_weight = weight.clone();
}
if child_top == child_bottom {
minlen = height - depths.get(node_id).cloned().unwrap_or(0) + 1;
}
let mut _ct_graph_edge = GraphEdge::default();
_ct_graph_edge.minlen = Some(minlen.clone() as f32);
_ct_graph_edge.weight = Some(this_weight.clone());
_ct_graph_edge.nesting_edge = Some(true);
let _ct = graph.set_edge(&top, &child_top, Some(_ct_graph_edge), None);
let mut _cb_graph_edge = GraphEdge::default();
_cb_graph_edge.minlen = Some(minlen.clone() as f32);
_cb_graph_edge.weight = Some(this_weight.clone());
_cb_graph_edge.nesting_edge = Some(true);
let _cb = graph.set_edge(&child_bottom, &bottom, Some(_cb_graph_edge), None);
}
if graph.parent(node_id).is_none() {
let mut graph_edge = GraphEdge::default();
graph_edge.minlen =
Some((depths.get(node_id).cloned().unwrap_or(0) + height.clone()) as f32);
graph_edge.weight = Some(0.0);
graph_edge.nesting_edge = Some(true);
let _ = graph.set_edge(&root, &top, Some(graph_edge), None);
}
}
fn sum_weights(graph: &Graph<GraphConfig, GraphNode, GraphEdge>) -> f32 {
let mut total_weights: f32 = 0.0;
for edge in graph.edges() {
if let Some(edge_label) = graph.edge_with_obj(&edge) {
if let Some(weight) = edge_label.weight {
total_weights += weight;
}
}
}
return total_weights;
}
pub fn cleanup(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let graph_label = graph.graph();
if graph_label.nesting_root.is_some() {
graph.remove_node(&graph_label.nesting_root.clone().unwrap());
}
graph.graph_mut().nesting_root = None;
// removing nesting edge
let edges = graph.edges();
for edge in edges.into_iter() {
let _edge_label = graph.edge_with_obj(&edge);
if let Some(edge_label) = _edge_label {
if edge_label.nesting_edge.clone().unwrap_or(false) {
graph.remove_edge_with_obj(&edge);
}
}
}
}
+135
View File
@@ -0,0 +1,135 @@
use crate::layout::util::add_dummy_node;
use crate::{GraphConfig, GraphEdge, GraphEdgePoint, GraphNode};
use graphlib_rust::{Edge, Graph};
/*
* Breaks any long edges in the graph into short segments that span 1 layer
* each. This operation is undoable with the denormalize function.
*
* Pre-conditions:
*
* 1. The input graph is a DAG.
* 2. Each node in the graph has a "rank" property.
*
* Post-condition:
*
* 1. All edges in the graph have a length of 1.
* 2. Dummy nodes are added where edges have been split into segments.
* 3. The graph is augmented with a "dummyChains" attribute which contains
* the first dummy in each chain of dummy nodes produced.
*/
pub fn run(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
g.graph_mut().dummy_chains = Some(vec![]);
let edges = g.edges();
for edge_obj in edges.into_iter() {
normalize_edge(g, &edge_obj);
}
}
fn normalize_edge(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>, e: &Edge) {
let mut v = e.v.clone();
let w = e.w.clone();
let mut v_rank = g
.node(&v)
.unwrap_or(&GraphNode::default())
.rank
.clone()
.unwrap_or(0);
let w_rank = g
.node(&w)
.unwrap_or(&GraphNode::default())
.rank
.clone()
.unwrap_or(0);
// let name = e.name.clone(); // TODO: it was creating error for multi-graph option
let Some(edge_label) = g.edge_mut_with_obj(&e) else {
return;
};
edge_label.points = Some(vec![]);
let weight = edge_label.weight.clone();
let label_rank = edge_label.label_rank.unwrap_or(0);
if w_rank == v_rank + 1 {
return ();
}
let _edge_label = edge_label.clone();
g.remove_edge_with_obj(&e);
let mut i = 0;
v_rank += 1;
while v_rank < w_rank {
let mut attrs = GraphNode::default();
attrs.edge_label = Some(_edge_label.clone());
attrs.edge_obj = Some(e.clone());
attrs.rank = Some(v_rank.clone());
let mut dummy_type = "edge".to_string();
if v_rank == label_rank {
attrs.width = _edge_label.width.clone().unwrap_or(0.0);
attrs.height = _edge_label.height.clone().unwrap_or(0.0);
attrs.labelpos = _edge_label.labelpos.clone();
dummy_type = "edge-label".to_string();
}
let dummy = add_dummy_node(g, dummy_type, attrs, "_d".to_string());
let mut dummy_edge_label = GraphEdge::default();
dummy_edge_label.weight = weight.clone();
let _ = g.set_edge(&v, &dummy, Some(dummy_edge_label), None); // remove name from here
if i == 0 {
let graph_label = g.graph_mut();
if graph_label.dummy_chains.is_none() {
graph_label.dummy_chains = Some(vec![]);
}
let dummy_chains = graph_label.dummy_chains.as_mut().unwrap();
dummy_chains.push(dummy.clone());
}
v = dummy.clone();
i += 1;
v_rank += 1;
}
let mut graph_edge = GraphEdge::default();
graph_edge.weight = weight;
let _ = g.set_edge(
&v,
&w,
Some(graph_edge),
None, // removed name from here
);
}
pub fn undo(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
if g.graph().dummy_chains.is_none() {
return ();
}
let dummy_chains = g.graph().dummy_chains.clone().unwrap();
for v_ in dummy_chains.iter() {
let node_ = g.node(v_);
if node_.is_none() {
continue;
}
let mut node = node_.cloned().unwrap();
let mut orig_label = node.edge_label.clone().unwrap_or(GraphEdge::default());
let edge_obj = node.edge_obj.unwrap();
let mut v = v_.clone();
while node.dummy.is_some() {
let sucs = g.successors(&v).unwrap_or(vec![]);
let default_w = "".to_string();
let w = sucs.first().unwrap_or(&default_w);
g.remove_node(&v);
let points = orig_label.points.as_mut().unwrap();
points.push(GraphEdgePoint {
x: node.x.clone(),
y: node.y.clone(),
});
if node.dummy.as_ref().unwrap() == "edge-label" {
orig_label.x = node.x.clone();
orig_label.y = node.y.clone();
orig_label.width = Some(node.width.clone());
orig_label.height = Some(node.height.clone());
}
v = w.clone();
node = g.node(&v).cloned().unwrap();
}
let _ = g.set_edge_with_obj(&edge_obj, Some(orig_label));
}
}
@@ -0,0 +1,38 @@
use crate::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::Graph;
use ordered_hashmap::OrderedHashMap;
pub fn add_subgraph_constraints(
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
cg: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
vs: &Vec<String>,
) {
let mut prev: OrderedHashMap<String, String> = OrderedHashMap::new();
let mut _root_prev: Option<String> = None;
vs.iter().for_each(|v| {
let mut child = g.parent(v).cloned();
let mut _parent: Option<String> = None;
let mut _prev_child: Option<String> = None;
while child.is_some() {
_parent = g.parent(&child.clone().unwrap()).cloned();
if _parent.is_some() {
_prev_child = prev
.get(&_parent.clone().unwrap_or("".to_string()))
.cloned();
prev.insert(_parent.clone().unwrap(), child.clone().unwrap());
} else {
_prev_child = _root_prev.clone();
_root_prev = child.clone();
}
let prev_child = _prev_child.clone().unwrap_or("".to_string());
let child_ = child.clone().unwrap_or("".to_string());
if _prev_child.is_some() && prev_child != child_ {
let _ = cg.set_edge(&prev_child, &child_, None, None);
return ();
}
child = _parent.clone();
}
});
}
+45
View File
@@ -0,0 +1,45 @@
use crate::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::Graph;
#[derive(Debug, Clone)]
pub struct Barycenter {
pub v: String,
pub barycenter: Option<f32>,
pub weight: Option<f32>,
}
pub fn barycenter(
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
movable: &Vec<String>,
) -> Vec<Barycenter> {
movable
.iter()
.map(|v| {
let in_v = g.in_edges(v, None).unwrap_or(vec![]);
if in_v.len() == 0 {
return Barycenter {
v: v.clone(),
barycenter: None,
weight: None,
};
}
//( sum, weight )
let mut sum = 0.0_f64;
let mut weight = 0.0_f64;
in_v.iter().for_each(|e| {
let edge = g.edge_with_obj(&e).unwrap();
let node_u = g.node(&e.v).unwrap();
let edge_weight = edge.weight.clone().unwrap_or(0.0) as f64;
sum += edge_weight * (node_u.order.clone().unwrap_or(0) as f64);
weight += edge_weight;
});
return Barycenter {
v: v.clone(),
barycenter: Some((sum / weight) as f32),
weight: Some(weight as f32),
};
})
.collect()
}
@@ -0,0 +1,116 @@
use crate::layout::util::unique_id;
use crate::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::{Edge, Graph, GraphOption};
/*
* Constructs a graph that can be used to sort a layer of nodes. The graph will
* contain all base and subgraph nodes from the request layer in their original
* hierarchy and any edges that are incident on these nodes and are of the type
* requested by the "relationship" parameter.
*
* Nodes from the requested rank that do not have parents are assigned a root
* node in the output graph, which is set in the root graph attribute. This
* makes it easy to walk the hierarchy of movable nodes during ordering.
*
* Pre-conditions:
*
* 1. Input graph is a DAG
* 2. Base nodes in the input graph have a rank attribute
* 3. Subgraph nodes in the input graph has minRank and maxRank attributes
* 4. Edges have an assigned weight
*
* Post-conditions:
*
* 1. Output graph has all nodes in the movable rank with preserved
* hierarchy.
* 2. Root nodes in the movable layer are made children of the node
* indicated by the root attribute of the graph.
* 3. Non-movable nodes incident on movable nodes, selected by the
* relationship parameter, are included in the graph (without hierarchy).
* 4. Edges incident on movable nodes, selected by the relationship
* parameter, are added to the output graph.
* 5. The weights for copied edges are aggregated as need, since the output
* graph is not a multi-graph.
*/
#[derive(Debug, Copy, Clone)]
pub enum GraphRelationship {
InEdges,
OutEdges,
}
pub fn build_layer_graph(
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
rank: &i32,
relationship: GraphRelationship,
) -> Graph<GraphConfig, GraphNode, GraphEdge> {
let root = create_root_node(g);
let mut result: Graph<GraphConfig, GraphNode, GraphEdge> = Graph::new(Some(GraphOption {
directed: Some(true),
compound: Some(true),
multigraph: None,
}));
let graph_label = result.graph_mut();
graph_label.root = Some(root.clone());
g.nodes().iter().for_each(|v| {
let node = g.node(v).unwrap();
let parent = g.parent(v);
let in_rank = node.rank == Some(*rank);
let in_subgraph_rank = match (node.min_rank, node.max_rank) {
(Some(min_rank), Some(max_rank)) => min_rank <= *rank && *rank <= max_rank,
_ => false,
};
let mut _relationship: Vec<Edge> = g.in_edges(v, None).unwrap_or(vec![]);
match relationship {
GraphRelationship::OutEdges => {
_relationship = g.out_edges(v, None).unwrap_or(vec![]);
}
_ => (),
}
if in_rank || in_subgraph_rank {
result.set_node(v.clone(), Some(node.clone()));
if parent.is_some() {
let _ = result.set_parent(v, parent.cloned());
} else {
let _ = result.set_parent(v, Some(root.clone()));
}
// This assumes we have only short edges!
_relationship.iter().for_each(|e| {
let u = if &e.v == v { e.w.clone() } else { e.v.clone() };
let edge = result.edge(&u, &v, None);
let weight = if edge.is_some() {
edge.unwrap().weight.clone().unwrap_or(0.0)
} else {
0.0
};
let mut edge_label = GraphEdge::default();
edge_label.weight =
Some(g.edge_with_obj(&e).unwrap().weight.clone().unwrap_or(0.0) + weight);
let _ = result.set_edge(&u, &v, Some(edge_label), None);
});
if node.min_rank.is_some() {
let mut graph_node = GraphNode::default();
graph_node.border_left_ = node.border_left.as_ref().unwrap().get(rank).cloned();
graph_node.border_right_ = node.border_right.as_ref().unwrap().get(rank).cloned();
result.set_node(v.clone(), Some(graph_node));
}
}
});
result
}
pub fn create_root_node(g: &Graph<GraphConfig, GraphNode, GraphEdge>) -> String {
let mut v = format!("_root{}", unique_id());
while g.has_node(&v) {
v = format!("_root{}", unique_id());
}
v
}
+91
View File
@@ -0,0 +1,91 @@
use std::collections::HashMap;
use graphlib_rust::Graph;
use crate::{GraphConfig, GraphEdge, GraphNode};
/*
* A function that takes a layering (an array of layers, each with an array of
* ordererd nodes) and a graph and returns a weighted crossing count.
*
* Pre-conditions:
*
* 1. Input graph must be simple (not a multigraph), directed, and include
* only simple edges.
* 2. Edges in the input graph must have assigned weights.
*
* Post-conditions:
*
* 1. The graph and layering matrix are left unchanged.
*
* This algorithm is derived from Barth, et al., "Bilayer Cross Counting."
*/
pub fn cross_count(
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
layering: &Vec<Vec<String>>,
) -> f32 {
let mut cc = 0.0;
for i in 1..layering.len() {
cc += two_layer_cross_count(g, &layering[i - 1], &layering[i]);
}
cc
}
pub fn two_layer_cross_count(
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
north_layer: &Vec<String>,
south_layer: &Vec<String>,
) -> f32 {
let mut south_pos: HashMap<String, usize> = HashMap::new();
for (i, v) in south_layer.iter().enumerate() {
south_pos.insert(v.clone(), i);
}
let mut south_entries: Vec<(usize, f32)> = vec![];
for v in north_layer {
let mut out_edges: Vec<(usize, f32)> = g
.out_edges(v, None)
.unwrap_or_default()
.into_iter()
.filter_map(|e| {
let pos = south_pos.get(&e.w)?;
let weight = g
.edge_with_obj(&e)
.and_then(|edge| edge.weight)
.unwrap_or(0.0);
Some((*pos, weight))
})
.collect();
out_edges.sort_by(|e1, e2| e1.0.cmp(&e2.0));
south_entries.extend(out_edges);
}
let mut first_index: usize = 1;
while first_index < south_layer.len() {
first_index <<= 1;
}
let tree_size = 2 * first_index - 1;
first_index -= 1;
let mut tree: Vec<f32> = vec![0.0; tree_size];
let mut cc = 0.0;
for (pos, weight) in south_entries {
let mut index = pos + first_index;
tree[index] += weight;
let mut weight_sum = 0.0;
while index > 0 {
if index % 2 != 0 {
weight_sum += tree[index + 1];
}
index = (index - 1) >> 1;
tree[index] += weight;
}
cc += weight * weight_sum;
}
cc
}
+68
View File
@@ -0,0 +1,68 @@
use crate::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::Graph;
use ordered_hashmap::OrderedHashMap;
/*
* Assigns an initial order value for each node by performing a DFS search
* starting from nodes in the first rank. Nodes are assigned an order in their
* rank as they are first visited.
*
* This approach comes from Gansner, et al., "A Technique for Drawing Directed
* Graphs."
*
* Returns a layering matrix with an array per layer and each layer sorted by
* the order of its nodes.
*/
pub fn init_order(g: &Graph<GraphConfig, GraphNode, GraphEdge>) -> Vec<Vec<String>> {
let mut visited: OrderedHashMap<String, bool> = OrderedHashMap::new();
let mut simple_nodes: Vec<String> = g
.nodes()
.into_iter()
.filter(|v| g.children(v).len() == 0)
.collect();
let max_rank = simple_nodes
.iter()
.map(|v| g.node(v).unwrap().rank.clone().unwrap_or(0))
.max()
.unwrap_or(0);
let mut layers: Vec<Vec<String>> = (0..=max_rank).map(|_| -> Vec<String> { vec![] }).collect();
fn dfs(
v: &String,
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
visited: &mut OrderedHashMap<String, bool>,
layers: &mut Vec<Vec<String>>,
) {
if visited.contains_key(v) {
return ();
}
visited.insert(v.clone(), true);
let node = g.node(v).unwrap();
let node_rank = node.rank.unwrap_or(0) as usize;
if layers.get(node_rank.clone()).is_none() {
layers.insert(node_rank.clone(), vec![]);
}
let layer: &mut Vec<String> = layers.get_mut(node_rank.clone()).unwrap();
layer.push(v.clone());
let sucs = g.successors(v).unwrap_or(vec![]);
for sv in sucs.iter() {
dfs(sv, g, visited, layers)
}
}
simple_nodes.sort_by(|v1, v2| {
let v1_rank = g.node(v1).unwrap().rank.clone().unwrap_or(0);
let v2_rank = g.node(v2).unwrap().rank.clone().unwrap_or(0);
v1_rank.cmp(&v2_rank)
});
for v in simple_nodes.iter() {
dfs(v, g, &mut visited, &mut layers);
}
return layers;
}
+107
View File
@@ -0,0 +1,107 @@
pub mod add_subgraph_constraints;
pub mod barycenter;
pub mod build_layer_graph;
pub mod cross_count;
pub mod init_order;
pub mod resolve_conflicts;
pub mod sort;
pub mod sort_subgraph;
use crate::layout::order::add_subgraph_constraints::add_subgraph_constraints;
use crate::layout::order::build_layer_graph::{build_layer_graph, GraphRelationship};
use crate::layout::order::cross_count::cross_count;
use crate::layout::order::init_order::init_order;
use crate::layout::order::sort_subgraph::sort_subgraph;
use crate::layout::util;
use crate::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::graph::GRAPH_NODE;
use graphlib_rust::Graph;
/*
* Applies heuristics to minimize edge crossings in the graph and sets the best
* order solution as an order attribute on each node.
*
* Pre-conditions:
*
* 1. Graph must be DAG
* 2. Graph nodes must be objects with a "rank" attribute
* 3. Graph edges must have the "weight" attribute
*
* Post-conditions:
*
* 1. Graph nodes will have an "order" attribute based on the results of the
* algorithm.
*/
pub fn order(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let max_rank = util::max_rank(g);
let down_layer_ranks: Vec<i32> = (1..=max_rank).collect();
let up_layer_ranks: Vec<i32> = (0..max_rank).rev().collect();
let mut layering = init_order(g);
assign_order(g, &layering);
// Start with the init ordering as the best candidate.
// The original dagre.js code starts with bestCC = Infinity, which means
// the first sweep's result always replaces it. However, if the init
// ordering already has minimal crossings, using it as the starting point
// ensures the sweep loop doesn't accidentally select a different ordering
// with the same crossing count but worse visual layout (e.g., a mirrored
// ordering produced by a biased sweep).
let init_cc = cross_count(g, &layering) as f64;
let mut best_cc = init_cc;
let mut best: Vec<Vec<String>> = layering.clone();
let mut i = 0;
let mut last_best = 0;
while last_best < 4 {
if i % 2 != 0 {
sweep_layer_graphs(g, &down_layer_ranks, GraphRelationship::InEdges, i % 4 >= 2);
} else {
sweep_layer_graphs(g, &up_layer_ranks, GraphRelationship::OutEdges, i % 4 >= 2);
}
layering = util::build_layer_matrix(g);
let cc = cross_count(g, &layering) as f64;
if cc < best_cc {
last_best = 0;
best = layering.clone();
best_cc = cc;
}
last_best += 1;
i += 1;
}
assign_order(g, &best);
}
fn sweep_layer_graphs(
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
ranks: &Vec<i32>,
relationship: GraphRelationship,
bias_right: bool,
) {
let mut cg: Graph<GraphConfig, GraphNode, GraphEdge> = Graph::new(None);
ranks.iter().for_each(|rank| {
let lg = build_layer_graph(g, rank, relationship);
let root = lg.graph().root.clone().unwrap_or(GRAPH_NODE.to_string());
let sorted = sort_subgraph(&lg, &root, &cg, &bias_right);
sorted.vs.iter().enumerate().for_each(|(i, v)| {
if let Some(node) = g.node_mut(v) {
node.order = Some(i);
}
});
add_subgraph_constraints(&lg, &mut cg, &sorted.vs);
});
}
fn assign_order(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>, layering: &Vec<Vec<String>>) {
for layer in layering {
for (i, v) in layer.iter().enumerate() {
let node_label = g.node_mut(v).unwrap();
node_label.order = Some(i);
}
}
}
@@ -0,0 +1,173 @@
/*
* Given a list of entries of the form {v, barycenter, weight} and a
* constraint graph this function will resolve any conflicts between the
* constraint graph and the barycenters for the entries. If the barycenters for
* an entry would violate a constraint in the constraint graph then we coalesce
* the nodes in the conflict into a new node that respects the contraint and
* aggregates barycenter and weight information.
*
* This implementation is based on the description in Forster, "A Fast and
* Simple Hueristic for Constrained Two-Level Crossing Reduction," thought it
* differs in some specific details.
*
* Pre-conditions:
*
* 1. Each entry has the form {v, barycenter, weight}, or if the node has
* no barycenter, then {v}.
*
* Returns:
*
* A new list of entries of the form {vs, i, barycenter, weight}. The list
* `vs` may either be a singleton or it may be an aggregation of nodes
* ordered such that they do not violate constraints from the constraint
* graph. The property `i` is the lowest original index of any of the
* elements in `vs`.
*/
use std::collections::HashMap;
use graphlib_rust::Graph;
use crate::layout::order::barycenter::Barycenter;
use crate::{GraphConfig, GraphEdge, GraphNode};
#[derive(Debug, Clone)]
pub struct ResolvedBaryEntry {
pub vs: Vec<String>,
pub i: usize,
pub barycenter: Option<f32>,
pub weight: Option<f32>,
}
#[derive(Debug, Clone)]
struct ConflictEntry {
indegree: i32,
ins: Vec<usize>,
outs: Vec<usize>,
vs: Vec<String>,
i: usize,
barycenter: Option<f32>,
weight: Option<f32>,
merged: bool,
}
pub fn resolve_conflicts(
entries: &Vec<Barycenter>,
cg: &Graph<GraphConfig, GraphNode, GraphEdge>,
) -> Vec<ResolvedBaryEntry> {
let mut id_to_idx: HashMap<String, usize> = HashMap::new();
let mut mapped_entries: Vec<ConflictEntry> = Vec::with_capacity(entries.len());
for (i, entry) in entries.iter().enumerate() {
id_to_idx.insert(entry.v.clone(), i);
mapped_entries.push(ConflictEntry {
indegree: 0,
ins: vec![],
outs: vec![],
vs: vec![entry.v.clone()],
i,
barycenter: entry.barycenter,
weight: entry.weight,
merged: false,
});
}
cg.edges().iter().for_each(|e| {
if let (Some(&v_idx), Some(&w_idx)) = (id_to_idx.get(&e.v), id_to_idx.get(&e.w)) {
mapped_entries[w_idx].indegree += 1;
mapped_entries[v_idx].outs.push(w_idx);
}
});
let mut source_set: Vec<usize> = mapped_entries
.iter()
.enumerate()
.filter_map(|(idx, entry)| if entry.indegree == 0 { Some(idx) } else { None })
.collect();
let mut entries_order: Vec<usize> = Vec::new();
while let Some(v_idx) = source_set.pop() {
entries_order.push(v_idx);
let ins = mapped_entries[v_idx].ins.clone();
ins.into_iter().rev().for_each(|u_idx| {
handle_in(&mut mapped_entries, v_idx, u_idx);
});
let outs = mapped_entries[v_idx].outs.clone();
outs.into_iter().for_each(|w_idx| {
handle_out(&mut mapped_entries, v_idx, w_idx, &mut source_set);
});
}
entries_order
.into_iter()
.filter(|idx| !mapped_entries[*idx].merged)
.map(|idx| {
let entry = &mapped_entries[idx];
ResolvedBaryEntry {
vs: entry.vs.clone(),
i: entry.i,
barycenter: entry.barycenter,
weight: entry.weight,
}
})
.collect()
}
fn handle_in(entries: &mut [ConflictEntry], v_idx: usize, u_idx: usize) {
if entries[u_idx].merged {
return;
}
let u_barycenter = entries[u_idx].barycenter;
let v_barycenter = entries[v_idx].barycenter;
if u_barycenter.is_none()
|| v_barycenter.is_none()
|| u_barycenter.unwrap() >= v_barycenter.unwrap()
{
merge_entries(entries, v_idx, u_idx);
}
}
fn handle_out(
entries: &mut [ConflictEntry],
v_idx: usize,
w_idx: usize,
source_set: &mut Vec<usize>,
) {
entries[w_idx].ins.push(v_idx);
entries[w_idx].indegree -= 1;
if entries[w_idx].indegree == 0 {
source_set.push(w_idx);
}
}
fn merge_entries(entries: &mut [ConflictEntry], target_idx: usize, source_idx: usize) {
let mut sum = 0.0;
let mut weight = 0.0;
if let (Some(target_barycenter), Some(target_weight)) =
(entries[target_idx].barycenter, entries[target_idx].weight)
{
sum += target_barycenter * target_weight;
weight += target_weight;
}
if let (Some(source_barycenter), Some(source_weight)) =
(entries[source_idx].barycenter, entries[source_idx].weight)
{
sum += source_barycenter * source_weight;
weight += source_weight;
}
let mut vs = entries[source_idx].vs.clone();
vs.extend(entries[target_idx].vs.clone());
entries[target_idx].vs = vs;
entries[target_idx].barycenter = Some(sum / weight);
entries[target_idx].weight = Some(weight);
entries[target_idx].i = std::cmp::min(entries[source_idx].i, entries[target_idx].i);
entries[source_idx].merged = true;
}
+83
View File
@@ -0,0 +1,83 @@
use std::cmp::Ordering;
use crate::layout::order::resolve_conflicts::ResolvedBaryEntry;
use crate::layout::order::sort_subgraph::SubgraphResult;
use crate::layout::util;
use crate::layout::util::PartitionResponse;
pub fn sort(entries: &Vec<ResolvedBaryEntry>, bias_right: &bool) -> SubgraphResult {
let parts: PartitionResponse<ResolvedBaryEntry> = util::partition(
entries,
Box::new(|val: &ResolvedBaryEntry| -> bool { val.barycenter.is_some() }),
);
let mut sortable = parts.lhs.clone();
let mut unsortable = parts.rhs.clone();
sortable.sort_by(|e1, e2| compare_with_bias(e1, e2, bias_right));
unsortable.sort_by(|e1, e2| e2.i.cmp(&e1.i));
let mut vs: Vec<Vec<String>> = vec![];
let mut sum = 0.0;
let mut weight = 0.0;
let mut vs_index: usize = 0;
vs_index = consume_unsortable(&mut vs, &mut unsortable, vs_index);
sortable.iter().for_each(|entry| {
vs_index += entry.vs.len();
vs.push(entry.vs.clone());
let entry_weight = entry.weight.unwrap_or(0.0);
sum += entry.barycenter.unwrap_or(0.0) * entry_weight;
weight += entry_weight;
vs_index = consume_unsortable(&mut vs, &mut unsortable, vs_index);
});
let mut result = SubgraphResult::default();
result.vs = vs.into_iter().flatten().collect();
if weight != 0.0 {
result.barycenter = Some(sum / weight);
result.weight = Some(weight);
}
result
}
fn consume_unsortable(
vs: &mut Vec<Vec<String>>,
unsortable: &mut Vec<ResolvedBaryEntry>,
mut index: usize,
) -> usize {
loop {
let last = match unsortable.last() {
Some(last) => last,
None => return index,
};
if last.i > index {
return index;
}
let last = unsortable.pop().unwrap();
vs.push(last.vs);
index += 1;
}
}
fn compare_with_bias(
entry_v: &ResolvedBaryEntry,
entry_w: &ResolvedBaryEntry,
bias: &bool,
) -> Ordering {
let barycenter_v = entry_v.barycenter.unwrap_or(0.0);
let barycenter_w = entry_w.barycenter.unwrap_or(0.0);
if barycenter_v < barycenter_w {
return Ordering::Less;
} else if barycenter_v > barycenter_w {
return Ordering::Greater;
}
if !bias {
entry_v.i.cmp(&entry_w.i)
} else {
entry_w.i.cmp(&entry_v.i)
}
}
+114
View File
@@ -0,0 +1,114 @@
use graphlib_rust::Graph;
use ordered_hashmap::OrderedHashMap;
use crate::layout::order::barycenter::{barycenter, Barycenter};
use crate::layout::order::resolve_conflicts::{resolve_conflicts, ResolvedBaryEntry};
use crate::layout::order::sort::sort;
use crate::{GraphConfig, GraphEdge, GraphNode};
#[derive(Debug, Clone, Default)]
pub struct SubgraphResult {
pub vs: Vec<String>,
pub barycenter: Option<f32>,
pub weight: Option<f32>,
}
pub fn sort_subgraph(
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
v: &String,
cg: &Graph<GraphConfig, GraphNode, GraphEdge>,
bias_right: &bool,
) -> SubgraphResult {
let mut movable = g.children(v);
let node = g.node(v);
let bl = node.and_then(|n| n.border_left_.clone());
let br = node.and_then(|n| n.border_right_.clone());
let mut subgraphs: OrderedHashMap<String, SubgraphResult> = OrderedHashMap::new();
if let (Some(bl_), Some(br_)) = (bl.as_ref(), br.as_ref()) {
movable = movable
.into_iter()
.filter(|w| w != bl_ && w != br_)
.collect();
}
let mut barycenters = barycenter(g, &movable);
barycenters.iter_mut().for_each(|entry| {
if !g.children(&entry.v).is_empty() {
let subgraph_result = sort_subgraph(g, &entry.v, cg, bias_right);
subgraphs.insert(entry.v.clone(), subgraph_result.clone());
if subgraph_result.barycenter.is_some() {
merge_barycenters(entry, &subgraph_result);
}
}
});
let mut entries = resolve_conflicts(&barycenters, cg);
expand_subgraphs(&mut entries, &subgraphs);
let mut result = sort(&entries, bias_right);
if let (Some(bl_), Some(br_)) = (bl, br) {
let mut vs: Vec<String> = Vec::with_capacity(result.vs.len() + 2);
vs.push(bl_.clone());
vs.extend(result.vs.clone());
vs.push(br_.clone());
result.vs = vs;
let bl_preds = g.predecessors(&bl_).unwrap_or_default();
if !bl_preds.is_empty() {
let br_preds = g.predecessors(&br_).unwrap_or_default();
if !br_preds.is_empty() {
let bl_pred = g.node(&bl_preds[0]).unwrap();
let br_pred = g.node(&br_preds[0]).unwrap();
let bl_pred_order = bl_pred.order.unwrap_or(0) as f32;
let br_pred_order = br_pred.order.unwrap_or(0) as f32;
let result_barycenter = result.barycenter.unwrap_or(0.0);
let result_weight = result.weight.unwrap_or(0.0);
result.barycenter = Some(
(result_barycenter * result_weight + bl_pred_order + br_pred_order)
/ (result_weight + 2.0),
);
result.weight = Some(result_weight + 2.0);
}
}
}
result
}
fn expand_subgraphs(
entries: &mut Vec<ResolvedBaryEntry>,
subgraphs: &OrderedHashMap<String, SubgraphResult>,
) {
entries.iter_mut().for_each(|entry| {
let mut vs: Vec<String> = vec![];
entry.vs.iter().for_each(|v| {
if let Some(subgraph) = subgraphs.get(v) {
vs.extend(subgraph.vs.clone());
return;
}
vs.push(v.clone());
});
entry.vs = vs;
});
}
fn merge_barycenters(target: &mut Barycenter, other: &SubgraphResult) {
let (other_barycenter, other_weight) = match (other.barycenter, other.weight) {
(Some(barycenter), Some(weight)) => (barycenter, weight),
_ => return,
};
if let (Some(target_barycenter), Some(target_weight)) = (target.barycenter, target.weight) {
target.barycenter = Some(
(target_barycenter * target_weight + other_barycenter * other_weight)
/ (target_weight + other_weight),
);
target.weight = Some(target_weight + other_weight);
} else {
target.barycenter = Some(other_barycenter);
target.weight = Some(other_weight);
}
}
+154
View File
@@ -0,0 +1,154 @@
use crate::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::graph::GRAPH_NODE;
use graphlib_rust::Graph;
use ordered_hashmap::OrderedHashMap;
pub fn parent_dummy_chains(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let post_order_nums: OrderedHashMap<String, (i32, i32)> = postorder(g);
let dummy_chains = g.graph().dummy_chains.clone().unwrap_or(vec![]);
for v_ in dummy_chains.iter() {
let mut v = v_.clone();
let mut node = g.node(&v).unwrap();
let edge_obj = node.edge_obj.clone().unwrap();
let path_data = find_path(g, &post_order_nums, &edge_obj.v, &edge_obj.w);
let path = path_data.0;
let lca = path_data.1;
let mut path_idx = 0;
let mut path_v = path.get(path_idx).cloned().unwrap_or(lca.clone());
let mut ascending = true;
while v != edge_obj.w {
node = g.node(&v).unwrap();
let node_rank = node.rank.unwrap_or(0);
if ascending {
loop {
path_v = path.get(path_idx).cloned().unwrap_or(lca.clone());
if path_v == lca {
ascending = false;
break;
}
let Some(path_v_id) = path_v.as_ref() else {
break;
};
let max_rank = g.node(path_v_id).unwrap().max_rank.unwrap_or(0);
if max_rank < node_rank {
path_idx += 1;
continue;
}
break;
}
}
if !ascending {
while path_idx < path.len().saturating_sub(1) {
let Some(next) = path.get(path_idx + 1).cloned() else {
break;
};
let Some(next_id) = next.as_ref() else {
break;
};
if g.node(next_id).unwrap().min_rank.unwrap_or(0) <= node_rank {
path_idx += 1;
} else {
break;
}
}
path_v = path.get(path_idx).cloned().unwrap_or(lca.clone());
}
let _ = g.set_parent(&v, path_v.clone());
let Some(next) = g.successors(&v).unwrap_or_default().first().cloned() else {
break;
};
v = next;
}
}
}
// Find a path from v to w through the lowest common ancestor (LCA). Return the
// full path and the LCA.
fn find_path(
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
post_order_nums: &OrderedHashMap<String, (i32, i32)>,
v: &String,
w: &String,
) -> (Vec<Option<String>>, Option<String>) {
let mut v_path: Vec<Option<String>> = vec![];
let mut w_path: Vec<Option<String>> = vec![];
let v_post_order_num = post_order_nums.get(v).cloned().unwrap_or((0, 0));
let w_post_order_num = post_order_nums.get(w).cloned().unwrap_or((0, 0));
let low = std::cmp::min(v_post_order_num.0, w_post_order_num.0);
let lim = std::cmp::max(v_post_order_num.1, w_post_order_num.1);
let mut parent: Option<String> = Some(v.clone());
loop {
parent = parent.and_then(|p| g.parent(&p).cloned());
v_path.push(parent.clone());
let Some(parent_id) = parent.as_ref() else {
break;
};
let Some(post_order_num) = post_order_nums.get(parent_id) else {
break;
};
if post_order_num.0 <= low && lim <= post_order_num.1 {
break;
}
}
let lca = parent.clone();
parent = Some(w.clone());
loop {
parent = parent.and_then(|p| g.parent(&p).cloned());
if parent == lca {
break;
}
w_path.push(parent.clone());
if parent.is_none() {
break;
}
}
w_path.reverse();
v_path.extend(w_path);
(v_path, lca)
}
fn postorder(g: &Graph<GraphConfig, GraphNode, GraphEdge>) -> OrderedHashMap<String, (i32, i32)> {
let mut result: OrderedHashMap<String, (i32, i32)> = OrderedHashMap::new();
let mut lim = 0;
fn dfs(
v: &String,
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
lim: &mut i32,
result: &mut OrderedHashMap<String, (i32, i32)>,
) {
let low = lim.clone();
g.children(&v).iter().for_each(|v_| {
dfs(v_, g, lim, result);
});
result.insert(v.clone(), (low, lim.clone()));
*lim += 1;
}
g.children(&GRAPH_NODE.to_string()).iter().for_each(|v| {
dfs(v, g, &mut lim, &mut result);
});
return result;
}
+734
View File
@@ -0,0 +1,734 @@
/*
* This module provides coordinate assignment based on Brandes and Köpf, "Fast
* and Simple Horizontal Coordinate Assignment."
*/
use crate::layout::add_border_segments::BorderTypeName;
use crate::layout::util;
use crate::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::{Graph, GraphOption};
use ordered_hashmap::OrderedHashMap;
use std::cmp::Ordering;
/*
* Marks all edges in the graph with a type-1 conflict with the "type1Conflict"
* property. A type-1 conflict is one where a non-inner segment crosses an
* inner segment. An inner segment is an edge with both incident nodes marked
* with the "dummy" property.
*
* This algorithm scans layer by layer, starting with the second, for type-1
* conflicts between the current layer and the previous layer. For each layer
* it scans the nodes from left to right until it reaches one that is incident
* on an inner segment. It then scans predecessors to determine if they have
* edges that cross that inner segment. At the end a final scan is done for all
* nodes on the current rank to see if they cross the last visited inner
* segment.
*
* This algorithm (safely) assumes that a dummy node will only be incident on a
* single node in the layers being scanned.
*/
fn find_type_1_conflicts(
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
layering: &Vec<Vec<String>>,
) -> OrderedHashMap<String, OrderedHashMap<String, bool>> {
let mut conflicts = OrderedHashMap::new();
fn visit_layer(
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
prev_layer: &Vec<String>,
layer: &Vec<String>,
conflicts: &mut OrderedHashMap<String, OrderedHashMap<String, bool>>,
) {
// last visited node in the previous layer that is incident on an inner
// segment.
let mut k0 = 0;
// Tracks the last node in this layer scanned for crossings with a type-1
// segment.
let mut scan_pos = 0;
let prev_layer_length = prev_layer.len();
let last_node = layer.last().unwrap().clone();
for (i, v) in layer.iter().enumerate() {
let w = find_other_inner_segment_node(g, v);
let k1 = if let Some(ref w) = w {
g.node(w).unwrap().order.unwrap_or(0)
} else {
prev_layer_length
};
if w.is_some() || *v == last_node {
for scan_node in layer[scan_pos..=i].iter() {
for u in g.predecessors(scan_node).unwrap() {
let u_label = g.node(&u).unwrap();
let u_pos = u_label.order.unwrap_or(0);
if (u_pos < k0 || k1 < u_pos)
&& !(u_label.dummy.is_some()
&& g.node(scan_node).unwrap().dummy.is_some())
{
add_conflict(conflicts, &u, scan_node);
}
}
}
scan_pos = i + 1;
k0 = k1;
}
}
}
layering
.iter()
.filter(|layer| !layer.is_empty())
.reduce(|prev_layer, layer| {
visit_layer(g, &prev_layer, layer, &mut conflicts);
layer
});
conflicts
}
pub fn find_type_2_conflicts(
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
layering: &Vec<Vec<String>>,
) -> OrderedHashMap<String, OrderedHashMap<String, bool>> {
let mut conflicts: OrderedHashMap<String, OrderedHashMap<String, bool>> = OrderedHashMap::new();
fn scan(
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
south: &Vec<String>,
south_pos: usize,
south_end: usize,
prev_north_border: i32,
next_north_border: i32,
conflicts: &mut OrderedHashMap<String, OrderedHashMap<String, bool>>,
) {
for i in south_pos..south_end {
let v = south.get(i).cloned().unwrap();
if g.node(&v).is_some() && g.node(&v).unwrap().dummy.is_some() {
let preds = g.predecessors(&v).unwrap_or(vec![]);
preds.iter().for_each(|u| {
let u_node_ = g.node(u);
if let Some(u_node) = u_node_ {
let u_node_order = u_node.order.unwrap_or(0) as i32;
if u_node.dummy.is_some()
&& (u_node_order < prev_north_border
|| u_node_order > next_north_border)
{
add_conflict(conflicts, u, &v);
}
}
});
}
}
}
fn visit_layer(
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
north: &Vec<String>,
south: &Vec<String>,
conflicts: &mut OrderedHashMap<String, OrderedHashMap<String, bool>>,
) {
let mut prev_north_pos: i32 = -1;
let mut next_north_pos: i32 = -1;
let mut south_pos: usize = 0;
for (south_lookahead, v) in south.iter().enumerate() {
if let Some(v_node) = g.node(v) {
if v_node.dummy.as_deref() == Some("border") {
let predecessors = g.predecessors(v).unwrap_or(vec![]);
if !predecessors.is_empty() {
next_north_pos =
g.node(&predecessors[0]).unwrap().order.unwrap_or(0) as i32;
scan(
g,
south,
south_pos,
south_lookahead,
prev_north_pos,
next_north_pos,
conflicts,
);
south_pos = south_lookahead;
prev_north_pos = next_north_pos;
}
}
}
scan(
g,
south,
south_pos,
south.len(),
next_north_pos,
north.len() as i32,
conflicts,
);
}
}
for i in 1..layering.len() {
if layering[i - 1].is_empty() || layering[i].is_empty() {
continue;
}
visit_layer(g, &layering[i - 1], &layering[i], &mut conflicts);
}
conflicts
}
fn find_other_inner_segment_node(
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
v: &String,
) -> Option<String> {
if g.node(v).unwrap().dummy.is_some() {
let preds = g.predecessors(v).unwrap_or(vec![]);
return preds
.iter()
.find(|u| g.node(u).unwrap().dummy.is_some())
.cloned();
}
None
}
pub fn add_conflict(
conflicts: &mut OrderedHashMap<String, OrderedHashMap<String, bool>>,
v_: &String,
w_: &String,
) {
let mut v = v_.clone();
let mut w = w_.clone();
if v.cmp(&w) == Ordering::Greater {
let tmp = v;
v = w;
w = tmp;
}
let _conflicts_v = conflicts.get(&v);
if _conflicts_v.is_none() {
conflicts.insert(v.clone(), OrderedHashMap::new());
}
let conflicts_v = conflicts.get_mut(&v).unwrap();
conflicts_v.insert(w.clone(), true);
}
pub fn has_conflict(
conflicts: &OrderedHashMap<String, OrderedHashMap<String, bool>>,
v_: &String,
w_: &String,
) -> bool {
let mut v = v_;
let mut w = w_;
if v_.cmp(w_) == Ordering::Greater {
let tmp = v;
v = w;
w = tmp;
}
let empty_hashmap: OrderedHashMap<String, bool> = OrderedHashMap::new();
conflicts.get(v).unwrap_or(&empty_hashmap).contains_key(w)
}
/*
* Try to align nodes into vertical "blocks" where possible. This algorithm
* attempts to align a node with one of its median neighbors. If the edge
* connecting a neighbor is a type-1 conflict then we ignore that possibility.
* If a previous node has already formed a block with a node after the node
* we're trying to form a block with, we also ignore that possibility - our
* blocks would be split in that scenario.
*/
// root -> 0, align -> 1
pub fn vertical_alignment(
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
layering: &Vec<Vec<String>>,
conflicts: &OrderedHashMap<String, OrderedHashMap<String, bool>>,
neighbor_fn: Box<dyn Fn(&Graph<GraphConfig, GraphNode, GraphEdge>, &String) -> Vec<String>>,
) -> (OrderedHashMap<String, String>, Vec<String>) {
let mut root: OrderedHashMap<String, String> = OrderedHashMap::new();
let mut align: OrderedHashMap<String, String> = OrderedHashMap::new();
let mut pos: OrderedHashMap<String, usize> = OrderedHashMap::new();
// We cache the position here based on the layering because the graph and
// layering may be out of sync. The layering matrix is manipulated to
// generate different extreme alignments.
layering.iter().for_each(|layer| {
layer.iter().enumerate().for_each(|(order, v)| {
root.insert(v.clone(), v.clone());
align.insert(v.clone(), v.clone());
pos.insert(v.clone(), order);
});
});
layering.iter().for_each(|layer| {
let mut prev_idx: i32 = -1;
layer.iter().for_each(|v| {
let mut ws: Vec<String> = neighbor_fn(g, v)
.into_iter()
.filter(|w| pos.contains_key(w))
.collect();
if ws.len() > 0 {
ws.sort_by(|w1, w2| pos.get(w1).unwrap().cmp(pos.get(w2).unwrap()));
let mp = (ws.len() as f32 - 1.0) / 2.0;
let mut i = mp as usize;
let il = mp.ceil() as usize;
while i <= il {
let w = ws[i].clone();
if align.get(v).unwrap() == v
&& prev_idx < (pos.get(&w).cloned().unwrap() as i32)
&& !has_conflict(conflicts, v, &w)
{
align.insert(w.clone(), v.clone());
root.insert(v.clone(), root.get(&w).unwrap().clone());
align.insert(v.clone(), root.get(&w).unwrap().clone());
prev_idx = pos.get(&w).unwrap().clone() as i32;
}
i += 1;
}
}
});
});
let align_keys: Vec<String> = align.keys().cloned().collect();
return (root, align_keys);
}
pub fn horizontal_compaction(
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
layering: &Vec<Vec<String>>,
root: &OrderedHashMap<String, String>,
align: &Vec<String>,
reverse_sep: bool,
) -> OrderedHashMap<String, f32> {
// This portion of the algorithm differs from BK due to a number of problems.
// Instead of their algorithm we construct a new block graph and do two
// sweeps. The first sweep places blocks with the smallest possible
// coordinates. The second sweep removes unused space by moving blocks to the
// greatest coordinates without violating separation.
let mut xs: OrderedHashMap<String, f32> = OrderedHashMap::new();
let block_g: Graph<GraphOption, String, f32> =
build_block_graph(g, layering, root, reverse_sep);
let border_type = if reverse_sep {
BorderTypeName::BorderLeft
} else {
BorderTypeName::BorderRight
};
fn iterate(
set_xs_func: fn(
&String,
&mut OrderedHashMap<String, f32>,
&Graph<GraphOption, String, f32>,
&Graph<GraphConfig, GraphNode, GraphEdge>,
&BorderTypeName,
),
next_nodes_func: Box<dyn Fn(&Graph<GraphOption, String, f32>, &String) -> Vec<String>>,
block_g: &Graph<GraphOption, String, f32>,
xs: &mut OrderedHashMap<String, f32>,
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
border_type: &BorderTypeName,
) {
let mut stack = block_g.nodes();
let mut elem = stack.pop();
let mut visited: OrderedHashMap<String, bool> = OrderedHashMap::new();
while elem.is_some() {
let elem_ = elem.unwrap();
if visited.contains_key(&elem_) {
set_xs_func(&elem_, xs, block_g, g, border_type);
} else {
visited.insert(elem_.clone(), true);
stack.push(elem_.clone());
stack.append(&mut (next_nodes_func(block_g, &elem_) as Vec<String>));
}
elem = stack.pop();
}
}
// First pass, assign smallest coordinates
fn pass1(
elem: &String,
xs: &mut OrderedHashMap<String, f32>,
block_g: &Graph<GraphOption, String, f32>,
_g: &Graph<GraphConfig, GraphNode, GraphEdge>,
_border_type: &BorderTypeName,
) {
let in_edges = block_g.in_edges(elem, None).unwrap_or(vec![]);
let val: f32 = in_edges.iter().fold(0.0, |acc, e| {
let pred_x = xs.get(&e.v).cloned().unwrap_or(0.0);
let sep = block_g.edge_with_obj(&e).cloned().unwrap_or(0.0);
let ev = pred_x + sep;
acc.max(ev)
});
xs.insert(elem.clone(), val);
}
// Second pass, assign greatest coordinates
fn pass2(
elem: &String,
xs: &mut OrderedHashMap<String, f32>,
block_g: &Graph<GraphOption, String, f32>,
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
border_type: &BorderTypeName,
) {
let out_edges = block_g.out_edges(elem, None).unwrap_or(vec![]);
let min: f64 = out_edges.iter().fold(f64::INFINITY, |acc, e| {
let succ_x = xs.get(&e.w).cloned().unwrap_or(0.0);
let sep = block_g.edge_with_obj(&e).cloned().unwrap_or(0.0);
let ev = succ_x - sep;
acc.min(ev as f64)
});
if min != f64::INFINITY {
let Some(node) = g.node(elem) else {
return;
};
if node.border_type.as_ref() != Some(border_type) {
xs.insert(
elem.clone(),
xs.get(elem).cloned().unwrap_or(0.0).max(min as f32),
);
}
}
}
iterate(
pass1,
Box::new(|block_g, v| -> Vec<String> { block_g.predecessors(v).unwrap_or(vec![]) }),
&block_g,
&mut xs,
g,
&border_type,
);
iterate(
pass2,
Box::new(|block_g, v| -> Vec<String> { block_g.successors(v).unwrap_or(vec![]) }),
&block_g,
&mut xs,
g,
&border_type,
);
// Assign x coordinates to all nodes
align.iter().for_each(|v| {
let Some(root_id) = root.get(v) else {
return;
};
let Some(root_x) = xs.get(root_id).cloned() else {
return;
};
xs.insert(v.clone(), root_x);
});
xs
}
pub fn build_block_graph(
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
layering: &Vec<Vec<String>>,
root: &OrderedHashMap<String, String>,
reverse_sep: bool,
) -> Graph<GraphOption, String, f32> {
let mut block_graph: Graph<GraphOption, String, f32> = Graph::new(None);
let graph_label = g.graph();
let sep_fn: Box<dyn Fn(&Graph<GraphConfig, GraphNode, GraphEdge>, &String, &String) -> f32> =
sep(
graph_label.nodesep.as_ref().unwrap(),
graph_label.edgesep.as_ref().unwrap(),
&reverse_sep,
);
layering.iter().for_each(|layer| {
let mut u: Option<String> = None;
layer.iter().for_each(|v| {
let v_root = root.get(v).unwrap();
block_graph.set_node(v_root.clone(), None);
if u.is_some() {
let u_ = u.as_ref().unwrap();
let u_root = root.get(u_).unwrap();
if u_root != v_root {
let prev_max = block_graph
.edge(&u_root, &v_root, None)
.cloned()
.unwrap_or(0.0);
let _ = block_graph.set_edge(
&u_root,
&v_root,
Some((sep_fn(g, v, u_) as f32).max(prev_max)),
None,
);
}
}
u = Some(v.clone());
});
});
block_graph
}
/*
* Returns the alignment that has the smallest width of the given alignments.
*/
pub fn find_smallest_width_alignment<'a>(
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
xss: &'a OrderedHashMap<String, OrderedHashMap<String, f32>>,
) -> &'a OrderedHashMap<String, f32> {
xss.values()
.min_by(|xs1, xs2| {
let mut max1 = f64::NEG_INFINITY;
let mut min1 = f64::INFINITY;
xs1.iter().for_each(|(v, x)| {
let half_width = width(g, v) / 2.0;
max1 = max1.max((*x + half_width) as f64);
min1 = min1.min((*x - half_width) as f64);
});
let r1 = max1 - min1;
let mut max2 = f64::NEG_INFINITY;
let mut min2 = f64::INFINITY;
xs2.iter().for_each(|(v, x)| {
let half_width = width(g, v) / 2.0;
max2 = max2.max((*x + half_width) as f64);
min2 = min2.min((*x - half_width) as f64);
});
let r2 = max2 - min2;
r1.total_cmp(&r2)
})
.unwrap()
}
/*
* Align the coordinates of each of the layout alignments such that
* left-biased alignments have their minimum coordinate at the same point as
* the minimum coordinate of the smallest width alignment and right-biased
* alignments have their maximum coordinate at the same point as the maximum
* coordinate of the smallest width alignment.
*/
fn align_coordinates(
xss: &mut OrderedHashMap<String, OrderedHashMap<String, f32>>,
align_to: &OrderedHashMap<String, f32>,
) {
let align_to_vals: Vec<f32> = align_to.values().cloned().collect();
let align_to_min = align_to_vals
.iter()
.min_by(|a, b| a.partial_cmp(b).unwrap())
.unwrap()
.clone();
let align_to_max = align_to_vals
.iter()
.max_by(|a, b| a.partial_cmp(b).unwrap())
.unwrap()
.clone();
vec!["u", "d"].iter().for_each(|vert| {
vec!["l", "r"].iter().for_each(|horiz| {
let alignment = vert.to_string() + horiz;
let xs = xss.get(&alignment).unwrap();
if xs == align_to {
return;
}
let xs_vals: Vec<f32> = xs.values().cloned().collect();
let delta = if *horiz == "l" {
align_to_min
- *xs_vals
.iter()
.min_by(|a, b| a.partial_cmp(b).unwrap())
.unwrap()
} else {
align_to_max
- *xs_vals
.iter()
.max_by(|a, b| a.partial_cmp(b).unwrap())
.unwrap()
};
if delta != 0.0 {
let _xs = xss.get_mut(&alignment).unwrap();
_xs.values_mut().for_each(|x| {
*x += delta;
});
}
})
})
}
pub fn balance(
xss: &OrderedHashMap<String, OrderedHashMap<String, f32>>,
align: Option<String>,
) -> OrderedHashMap<String, f32> {
let mut xss_clone = xss.clone();
if let Some(ul) = xss_clone.get_mut(&"ul".to_string()) {
let keys: Vec<String> = ul.keys().cloned().collect();
keys.iter().for_each(|v| {
if align.is_some() {
let empty_hash: OrderedHashMap<String, f32> = OrderedHashMap::new();
let empty_string = "".to_string();
let _balance = xss
.get(align.as_ref().unwrap_or(&empty_string))
.unwrap_or(&empty_hash)
.get(v)
.cloned()
.unwrap_or(0.0);
let item = ul.get_mut(v).unwrap();
*item = _balance;
} else {
let mut xs: Vec<f32> = xss
.values()
.map(|_xs| _xs.get(v).cloned().unwrap_or(f64::INFINITY as f32))
.collect();
xs.sort_by(|f1, f2| f1.total_cmp(f2));
let xs1 = xs.get(1).cloned().unwrap_or(0.0);
let xs2 = xs.get(2).cloned().unwrap_or(0.0);
let item = ul.get_mut(v).unwrap();
*item = (xs1 + xs2) / 2.0;
}
});
}
xss_clone.get_mut(&"ul".to_string()).unwrap().to_owned()
}
pub fn position_x(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) -> OrderedHashMap<String, f32> {
let layering = util::build_layer_matrix(g);
if layering.iter().all(|layer| layer.is_empty()) {
return OrderedHashMap::new();
}
let mut conflicts = find_type_1_conflicts(g, &layering);
conflicts.extend(find_type_2_conflicts(g, &layering));
let mut xss: OrderedHashMap<String, OrderedHashMap<String, f32>> = OrderedHashMap::new();
let mut adjusted_layering: Option<Vec<Vec<String>>> = None;
vec!["u", "d"].iter().for_each(|vert| {
adjusted_layering = Some(if vert == &"u" {
layering.clone()
} else {
let mut layering_ = layering.clone();
layering_.reverse();
layering_
});
vec!["l", "r"].iter().for_each(|horiz| {
if horiz == &"r" {
adjusted_layering
.as_mut()
.unwrap()
.iter_mut()
.for_each(|inner| inner.reverse());
}
let neighbor_fn: Box<
dyn Fn(&Graph<GraphConfig, GraphNode, GraphEdge>, &String) -> Vec<String>,
> = if vert == &"u" {
Box::new(
|g: &Graph<GraphConfig, GraphNode, GraphEdge>, v: &String| -> Vec<String> {
g.predecessors(v).unwrap_or(vec![])
},
)
} else {
Box::new(
|g: &Graph<GraphConfig, GraphNode, GraphEdge>, v: &String| -> Vec<String> {
g.successors(v).unwrap_or(vec![])
},
)
};
let align = vertical_alignment(
g,
adjusted_layering.as_ref().unwrap(),
&conflicts,
neighbor_fn,
);
let mut xs = horizontal_compaction(
g,
adjusted_layering.as_ref().unwrap(),
&align.0,
&align.1,
horiz == &"r",
);
if horiz == &"r" {
let mut xs_: OrderedHashMap<String, f32> = OrderedHashMap::new();
xs.iter().for_each(|(k, v)| {
xs_.insert(k.clone(), -v.clone());
});
xs = xs_;
}
xss.insert(String::from(vert.to_string() + horiz), xs);
});
});
let smallest_width = find_smallest_width_alignment(g, &xss).clone();
align_coordinates(&mut xss, &smallest_width);
return balance(&xss, g.graph().align.clone());
}
fn sep(
node_sep: &f32,
edge_sep: &f32,
reverse_sep: &bool,
) -> Box<dyn Fn(&Graph<GraphConfig, GraphNode, GraphEdge>, &String, &String) -> f32> {
let node_sep_ = node_sep.clone();
let edge_sep_ = edge_sep.clone();
let reverse_sep_ = reverse_sep.clone();
Box::new(
move |g: &Graph<GraphConfig, GraphNode, GraphEdge>, v: &String, w: &String| -> f32 {
let v_label = g.node(v).unwrap();
let w_label = g.node(w).unwrap();
let mut sum: f32 = 0.0;
let mut delta: f32 = 0.0;
sum += v_label.width / 2.0;
if let Some(v_label_labelpos) = v_label.labelpos.as_ref() {
if v_label_labelpos == "l" {
delta = -v_label.width / 2.0;
} else if v_label_labelpos == "r" {
delta = v_label.width / 2.0;
}
}
if delta != 0.0 {
sum += if reverse_sep_ { delta } else { -delta }
}
sum += if v_label.dummy.is_some() {
edge_sep_
} else {
node_sep_
} / 2.0;
sum += if w_label.dummy.is_some() {
edge_sep_
} else {
node_sep_
} / 2.0;
sum += w_label.width / 2.0;
delta = 0.0;
if let Some(w_label_labelpos) = w_label.labelpos.as_ref() {
if w_label_labelpos == "l" {
delta = w_label.width / 2.0;
} else if w_label_labelpos == "r" {
delta = -w_label.width / 2.0;
}
}
if delta != 0.0 {
sum += if reverse_sep_ { delta } else { -delta }
}
sum
},
)
}
fn width(g: &Graph<GraphConfig, GraphNode, GraphEdge>, v: &String) -> f32 {
g.node(v).unwrap().width
}
+36
View File
@@ -0,0 +1,36 @@
pub mod bk;
use crate::layout::position::bk::position_x;
use crate::layout::util;
use crate::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::Graph;
pub fn position(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let mut ncg: Graph<GraphConfig, GraphNode, GraphEdge> = util::as_non_compound_graph(g);
position_y(&mut ncg);
position_x(&mut ncg).iter().for_each(|(v, x)| {
g.node_mut(v).unwrap().x = x.clone();
g.node_mut(v).unwrap().y = ncg.node(v).unwrap().y;
});
}
fn position_y(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let layering = util::build_layer_matrix(g);
let rank_sep = g.graph().ranksep.clone().unwrap();
let mut prev_y = 0.0;
layering.iter().for_each(|layer| {
let max_height: f32 = layer
.iter()
.map(|v| g.node(v).unwrap().height as i32)
.max()
.unwrap_or(0) as f32;
layer.iter().for_each(|v| {
let node = g.node_mut(v).unwrap();
node.y = prev_y + max_height / 2.0;
});
prev_y += max_height + rank_sep;
});
}
+137
View File
@@ -0,0 +1,137 @@
use crate::layout::rank::util::slack;
use crate::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::{Edge, Graph, GraphOption};
/*
* Constructs a spanning tree with tight edges and adjusted the input node's
* ranks to achieve this. A tight edge is one that is has a length that matches
* its "minlen" attribute.
*
* The basic structure for this function is derived from Gansner, et al., "A
* Technique for Drawing Directed Graphs."
*
* Pre-conditions:
*
* 1. Graph must be a DAG.
* 2. Graph must be connected.
* 3. Graph must have at least one node.
* 5. Graph nodes must have been previously assigned a "rank" property that
* respects the "minlen" property of incident edges.
* 6. Graph edges must have a "minlen" property.
*
* Post-conditions:
*
* - Graph nodes will have their rank adjusted to ensure that all edges are
* tight.
*
* Returns a tree (undirected graph) that is constructed using only "tight"
* edges.
*/
pub fn feasible_tree(
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
) -> Graph<GraphConfig, GraphNode, GraphEdge> {
let mut t: Graph<GraphConfig, GraphNode, GraphEdge> = Graph::new(Some(GraphOption {
directed: Some(false),
multigraph: Some(false),
compound: Some(false),
}));
// Choose arbitrary node from which to start our tree
let start = g.nodes().first().cloned().unwrap_or("".to_string());
let size = g.node_count();
t.set_node(start, Some(GraphNode::default()));
while tight_tree(&mut t, g) < size {
if let Some(edge) = find_min_stack_edge(&t, g) {
let delta = if t.has_node(&edge.v) {
slack(g, &edge)
} else {
-1 * slack(g, &edge)
};
shift_ranks(&t, g, delta);
}
}
t
}
/*
* Finds a maximal tree of tight edges and returns the number of nodes in the
* tree.
*/
fn tight_tree(
t: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
) -> usize {
fn dfs(
v: &String,
t: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
) {
let node_edges = g.node_edges(v, None).unwrap_or(vec![]);
for node_edge in node_edges {
let edge_v = node_edge.v.clone();
let mut _w: Option<&String> = None;
if v == &edge_v {
_w = Some(&node_edge.w);
} else {
_w = Some(&edge_v);
}
let w = _w.unwrap().clone();
if !t.has_node(&w) && slack(g, &node_edge) == 0 {
t.set_node(w.clone(), Some(GraphNode::default()));
let _ = t.set_edge(&v, &w, Some(GraphEdge::default()), None);
dfs(&w, t, g);
}
}
}
let nodes = t.nodes();
for node_id in nodes.into_iter() {
dfs(&node_id, t, g);
}
return t.node_count();
}
/*
* Finds the edge with the smallest slack that is incident on tree and returns
* it.
*/
fn find_min_stack_edge(
t: &Graph<GraphConfig, GraphNode, GraphEdge>,
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
) -> Option<Edge> {
let edges = g.edges();
let result = edges
.iter()
.map(|e| {
let mut e_: Option<i32> = None;
if t.has_node(&e.v) != t.has_node(&e.w) {
e_ = Some(slack(g, e));
}
(e, e_)
})
.filter(|(_, e_)| e_.is_some())
.min_by(|(_, e1_), (_, e2_)| e1_.unwrap().cmp(&e2_.unwrap()));
if result.is_some() {
Some(result.unwrap().0.clone())
} else {
None
}
}
fn shift_ranks(
t: &Graph<GraphConfig, GraphNode, GraphEdge>,
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
delta: i32,
) {
let nodes = t.nodes();
for node_id in nodes.into_iter() {
let node_ = g.node_mut(&node_id);
if let Some(node) = node_ {
node.rank = Some(node.rank.unwrap_or(0) + delta);
}
}
}
+53
View File
@@ -0,0 +1,53 @@
pub mod feasible_tree;
pub mod network_simplex;
pub mod util;
use crate::layout::rank::feasible_tree::feasible_tree;
use crate::layout::rank::network_simplex::network_simplex;
use crate::layout::rank::util::longest_path;
use crate::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::Graph;
/*
* Assigns a rank to each node in the input graph that respects the "minlen"
* constraint specified on edges between nodes.
*
* This basic structure is derived from Gansner, et al., "A Technique for
* Drawing Directed Graphs."
*
* Pre-conditions:
*
* 1. Graph must be a connected DAG
* 2. Graph nodes must be objects
* 3. Graph edges must have "weight" and "minlen" attributes
*
* Post-conditions:
*
* 1. Graph nodes will have a "rank" attribute based on the results of the
* algorithm. Ranks can start at any index (including negative), we'll
* fix them up later.
*/
pub fn rank(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let _ranker = g.graph().ranker.clone();
match _ranker {
Some(ranker) => {
let ranker_str = &*ranker;
if ranker_str == "network-simplex" {
network_simplex(g);
} else if ranker_str == "tight-tree" {
tight_tree_ranker(g);
} else if ranker_str == "longest-path" {
longest_path(g);
}
}
_ => {
network_simplex(g);
}
}
}
fn tight_tree_ranker(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
longest_path(g);
feasible_tree(g);
}
@@ -0,0 +1,365 @@
use std::collections::HashMap;
use crate::layout::rank::feasible_tree::feasible_tree;
use crate::layout::rank::util::{longest_path, slack};
use crate::layout::util;
use crate::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::algo::postorder::postorder;
use graphlib_rust::{Edge, Graph};
use ordered_hashmap::OrderedHashMap;
/*
* The network simplex algorithm assigns ranks to each node in the input graph
* and iteratively improves the ranking to reduce the length of edges.
*
* Preconditions:
*
* 1. The input graph must be a DAG.
* 2. All nodes in the graph must have an object value.
* 3. All edges in the graph must have "minlen" and "weight" attributes.
*
* Postconditions:
*
* 1. All nodes in the graph will have an assigned "rank" attribute that has
* been optimized by the network simplex algorithm. Ranks start at 0.
*
*
* A rough sketch of the algorithm is as follows:
*
* 1. Assign initial ranks to each node. We use the longest path algorithm,
* which assigns ranks to the lowest position possible. In general this
* leads to very wide bottom ranks and unnecessarily long edges.
* 2. Construct a feasible tight tree. A tight tree is one such that all
* edges in the tree have no slack (difference between length of edge
* and minlen for the edge). This by itself greatly improves the assigned
* rankings by shorting edges.
* 3. Iteratively find edges that have negative cut values. Generally a
* negative cut value indicates that the edge could be removed and a new
* tree edge could be added to produce a more compact graph.
*
* Much of the algorithms here are derived from Gansner, et al., "A Technique
* for Drawing Directed Graphs." The structure of the file roughly follows the
* structure of the overall algorithm.
*/
pub fn network_simplex(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let mut simplified = util::simplify(g);
longest_path(&mut simplified);
let mut t: Graph<GraphConfig, GraphNode, GraphEdge> = feasible_tree(&mut simplified);
init_low_lim_values(&mut t, None);
init_cut_values(&mut t, &mut simplified);
while let Some(e) = leave_edge(&t) {
let Some(f) = enter_edge(&t, &simplified, &e) else {
break;
};
exchange_edges(&mut t, &mut simplified, &e, f);
}
for v in g.nodes() {
let Some(node) = g.node_mut(&v) else {
continue;
};
let Some(simple_node) = simplified.node(&v) else {
continue;
};
node.rank = simple_node.rank;
}
}
/*
* Initializes cut values for all edges in the tree.
*/
fn init_cut_values(
t: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
) {
let node_ids = t.nodes();
let mut vs = postorder(t, &node_ids);
vs.pop();
for node_id in vs {
assign_cut_value(t, g, &node_id);
}
}
/*
* Given the tight tree, its graph, and a child in the graph calculate and
* return the cut value for the edge between the child and its parent.
*/
fn assign_cut_value(
t: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
child: &String,
) {
let cutvalue = calc_cut_value(t, g, child);
let child_lab_ = t.node_mut(child);
if let Some(child_lab) = child_lab_ {
let parent = child_lab.parent.clone().unwrap_or("".to_string());
let edge_label_ = t.edge_mut(&child, &parent, None);
if let Some(edge_label) = edge_label_ {
edge_label.cutvalue = Some(cutvalue);
}
}
}
/*
* Given the tight tree, its graph, and a child in the graph calculate and
* return the cut value for the edge between the child and its parent.
*/
fn calc_cut_value(
t: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
child: &String,
) -> f32 {
// The accumulated cut value for the edge between this node and its parent
let mut cut_value = 0.0;
let child_lab_ = t.node_mut(child);
if let Some(child_lab) = child_lab_ {
let parent = child_lab.parent.clone().unwrap_or("".to_string());
// True if the child is on the tail end of the edge in the directed graph
let mut child_is_tail = true;
// The graph's view of the tree edge we're inspecting
let mut graph_edge = g.edge_mut(child, &parent, None);
if graph_edge.is_none() {
child_is_tail = false;
graph_edge = g.edge_mut(&parent, &child, None);
}
cut_value = graph_edge
.cloned()
.unwrap_or(GraphEdge::default())
.weight
.unwrap_or(0.0);
let edge_objs_ = g.node_edges(child, None);
if let Some(edge_objs) = edge_objs_ {
for e in edge_objs {
let is_out_edge = &e.v == child;
let other = if is_out_edge {
e.w.clone()
} else {
e.v.clone()
};
if other != parent {
let points_to_head = is_out_edge == child_is_tail;
let other_weight = g
.edge_with_obj(&e)
.unwrap_or(&GraphEdge::default())
.weight
.unwrap_or(0.0);
cut_value += if points_to_head {
other_weight
} else {
-other_weight
};
if is_tree_edge(t, child, &other) {
let out_cut_value = t
.edge(&child, &other, None)
.unwrap_or(&GraphEdge::default())
.cutvalue
.unwrap_or(0.0);
cut_value += if points_to_head {
-out_cut_value
} else {
out_cut_value
}
}
}
}
}
}
cut_value
}
fn init_low_lim_values(tree: &mut Graph<GraphConfig, GraphNode, GraphEdge>, root_: Option<String>) {
let mut root = tree.nodes().first().cloned().unwrap_or("".to_string());
if root_.is_some() {
root = root_.unwrap();
}
let mut visited: OrderedHashMap<String, bool> = OrderedHashMap::new();
dfs_assign_low_lim(tree, &mut visited, 1, &root, None);
}
fn dfs_assign_low_lim(
tree: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
visited: &mut OrderedHashMap<String, bool>,
next_lim_: usize,
v: &String,
parent: Option<&String>,
) -> usize {
let low = next_lim_.clone();
let mut next_lim = next_lim_.clone();
visited.entry(v.clone()).or_insert(true);
let neighbors_ = tree.neighbors(v);
if let Some(neighbors) = neighbors_ {
for w in neighbors.into_iter() {
if !visited.contains_key(&w) {
next_lim = dfs_assign_low_lim(tree, visited, next_lim.clone(), &w, Some(v));
}
}
}
let label_ = tree.node_mut(v);
if let Some(label) = label_ {
label.low = Some(low);
label.lim = Some(next_lim.clone());
next_lim += 1;
if parent.is_some() {
label.parent = Some(parent.cloned().unwrap());
} else {
// TODO should be able to remove this when we incrementally update low lim
label.parent = None;
}
}
next_lim
}
fn leave_edge(tree: &Graph<GraphConfig, GraphNode, GraphEdge>) -> Option<Edge> {
let edge_objs = tree.edges();
edge_objs
.iter()
.find(|edge_obj| {
tree.edge_with_obj(edge_obj)
.unwrap_or(&GraphEdge::default())
.cutvalue
.unwrap_or(0.0)
< 0.0
})
.cloned()
}
fn enter_edge(
t: &Graph<GraphConfig, GraphNode, GraphEdge>,
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
edge: &Edge,
) -> Option<Edge> {
let mut v = edge.v.clone();
let mut w = edge.w.clone();
// For the rest of this function we assume that v is the tail and w is the
// head, so if we don't have this edge in the graph we should flip it to
// match the correct orientation.
if !g.has_edge(&v, &w, None) {
v = edge.w.clone();
w = edge.v.clone();
}
let v_label = t.node(&v).cloned().unwrap_or(GraphNode::default());
let w_label = t.node(&w).cloned().unwrap_or(GraphNode::default());
let mut tail_label = &v_label;
let mut flip = false;
// If the root is in the tail of the edge then we need to flip the logic that
// checks for the head and tail nodes in the candidates function below.
if v_label.lim.clone().unwrap_or(0) > w_label.lim.clone().unwrap_or(0) {
tail_label = &w_label;
flip = true;
}
let edge_objs = g.edges();
let candidates = edge_objs.iter().filter(|edge_obj| {
let v_node = t.node(&edge_obj.v).cloned().unwrap_or(GraphNode::default());
let w_node = t.node(&edge_obj.w).cloned().unwrap_or(GraphNode::default());
flip == is_descendant(&v_node, tail_label) && flip != is_descendant(&w_node, tail_label)
});
candidates
.min_by(|e1, e2| slack(g, e1).cmp(&slack(g, e2)))
.cloned()
}
fn exchange_edges(
t: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
e: &Edge,
f: Edge,
) {
let v = e.v.clone();
let w = e.w.clone();
t.remove_edge(&v, &w, None);
let _ = t.set_edge(&f.v, &f.w, Some(GraphEdge::default()), None);
init_low_lim_values(t, None);
init_cut_values(t, g);
update_ranks(t, g);
}
fn update_ranks(
t: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
) {
let Some(root) = t.nodes().first().cloned() else {
return;
};
let mut children: HashMap<String, Vec<String>> = HashMap::new();
for v in t.nodes() {
let parent = t.node(&v).unwrap_or(&GraphNode::default()).parent.clone();
let Some(parent) = parent else {
continue;
};
children.entry(parent).or_default().push(v);
}
let mut stack: Vec<String> = vec![root.clone()];
while let Some(parent) = stack.pop() {
let Some(vs) = children.get(&parent).cloned() else {
continue;
};
for v in vs {
let mut edge = g.edge(&v, &parent, None);
let mut flipped = false;
if edge.is_none() {
edge = g.edge(&parent, &v, None);
flipped = true;
}
let minlen = edge.unwrap_or(&GraphEdge::default()).minlen.unwrap_or(0.0);
let parent_rank = g
.node(&parent)
.unwrap_or(&GraphNode::default())
.rank
.unwrap_or(0);
if let Some(v_node) = g.node_mut(&v) {
v_node.rank = Some(
parent_rank
+ if flipped {
minlen as i32
} else {
-(minlen as i32)
},
);
}
stack.push(v);
}
}
}
/*
* Returns true if the edge is in the tree.
*/
fn is_tree_edge(tree: &Graph<GraphConfig, GraphNode, GraphEdge>, u: &String, v: &String) -> bool {
tree.has_edge(&u, &v, None)
}
/*
* Returns true if the specified node is descendant of the root node per the
* assigned low and lim attributes in the tree.
*/
fn is_descendant(v_label: &GraphNode, root_label: &GraphNode) -> bool {
let low = root_label.low.clone().unwrap_or(0);
let v_lim = v_label.lim.clone().unwrap_or(0);
let root_lim = root_label.lim.clone().unwrap_or(0);
low <= v_lim && v_lim <= root_lim
}
+99
View File
@@ -0,0 +1,99 @@
use crate::{GraphConfig, GraphEdge, GraphNode};
use graphlib_rust::{Edge, Graph};
use ordered_hashmap::OrderedHashMap;
/*
* Initializes ranks for the input graph using the longest path algorithm. This
* algorithm scales well and is fast in practice, it yields rather poor
* solutions. Nodes are pushed to the lowest layer possible, leaving the bottom
* ranks wide and leaving edges longer than necessary. However, due to its
* speed, this algorithm is good for getting an initial ranking that can be fed
* into other algorithms.
*
* This algorithm does not normalize layers because it will be used by other
* algorithms in most cases. If using this algorithm directly, be sure to
* run normalize at the end.
*
* Pre-conditions:
*
* 1. Input graph is a DAG.
* 2. Input graph node labels can be assigned properties.
*
* Post-conditions:
*
* 1. Each node will be assign an (unnormalized) "rank" property.
*/
pub fn longest_path(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let mut visited: OrderedHashMap<String, bool> = OrderedHashMap::new();
fn dfs(
v: &String,
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
visited: &mut OrderedHashMap<String, bool>,
) -> i32 {
let node_label = g.node(v);
if visited.contains_key(v) {
return node_label
.cloned()
.unwrap_or(GraphNode::default())
.rank
.unwrap_or(0);
}
visited.insert(v.clone(), true);
let ranks: Vec<i32> = g
.out_edges(v, None)
.unwrap_or(vec![])
.iter()
.map(|e| {
dfs(&e.w, g, visited)
- (g.edge_with_obj(&e)
.cloned()
.unwrap_or(GraphEdge::default())
.minlen
.unwrap_or(0.0)
.round() as i32)
})
.collect();
let rank: i32 = ranks.iter().min().cloned().unwrap_or(0) as i32;
{
let _node_label = g.node_mut(v);
if let Some(node_label) = _node_label {
node_label.rank = Some(rank.clone());
}
}
return rank;
}
for node_id in g.sources().into_iter() {
dfs(&node_id, g, &mut visited);
}
}
/*
* Returns the amount of slack for the given edge. The slack is defined as the
* difference between the length of the edge and its minimum length.
*/
pub fn slack(g: &Graph<GraphConfig, GraphNode, GraphEdge>, e: &Edge) -> i32 {
let w_rank = g
.node(&e.w)
.cloned()
.unwrap_or(GraphNode::default())
.rank
.unwrap_or(0);
let v_rank = g
.node(&e.v)
.cloned()
.unwrap_or(GraphNode::default())
.rank
.unwrap_or(0);
let minlen = g
.edge_with_obj(e)
.cloned()
.unwrap_or(GraphEdge::default())
.minlen
.unwrap_or(10.0)
.round() as i32;
return w_rank - v_rank - minlen;
}
+393
View File
@@ -0,0 +1,393 @@
use crate::layout::{GraphConfig, GraphEdge, GraphNode};
use crate::GraphEdgePoint;
use graphlib_rust::{Graph, GraphOption};
use ordered_hashmap::OrderedHashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
// VENDORING PATCH: upstream used a `static mut` mutated in an `unsafe` block,
// which is a data race when the engine renders on multiple threads (e.g. the
// parallel `cargo test` suite). An `AtomicUsize` is behaviour-preserving (still
// hands out monotonic unique ids) and removes the only `unsafe` in this crate.
static UNIQUE_STARTER: AtomicUsize = AtomicUsize::new(0);
pub fn unique_id() -> usize {
UNIQUE_STARTER.fetch_add(1, Ordering::Relaxed) + 1
}
/*
* Adds a dummy node to the graph and return v.
*/
pub fn add_dummy_node(
graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
node_type: String,
data: GraphNode,
name: String,
) -> String {
// Generating Random Id
let mut node_id = format!("{}{}", name, unique_id());
while graph.has_node(&node_id) {
node_id = format!("{}{}", name, unique_id());
}
// Setting in Graph
let mut node_data = data.clone();
node_data.dummy = Some(node_type);
graph.set_node(node_id.clone(), Some(node_data));
return node_id;
}
/*
* Returns a new graph with only simple edges. Handles aggregation of data
* associated with multi-edges.
*/
pub fn simplify(
g: &Graph<GraphConfig, GraphNode, GraphEdge>,
) -> Graph<GraphConfig, GraphNode, GraphEdge> {
let mut simplified: Graph<GraphConfig, GraphNode, GraphEdge> = Graph::new(Some(GraphOption {
directed: Some(true),
multigraph: None,
compound: None,
}));
simplified.set_graph(g.graph().clone());
let nodes = g.nodes();
let edges = g.edges();
for node_id in nodes.into_iter() {
simplified.set_node(node_id.clone(), g.node(&node_id).cloned());
}
for edge_obj in edges.into_iter() {
let edge_label_ = g.edge_with_obj(&edge_obj);
let mut simple_label = simplified
.edge(&edge_obj.v, &edge_obj.w, None)
.cloned()
.unwrap_or_else(|| {
let mut edge = GraphEdge::default();
edge.weight = Some(0.0);
edge.minlen = Some(1.0);
edge
});
let edge_label = edge_label_.cloned().unwrap_or_else(|| {
let mut edge = GraphEdge::default();
edge.weight = Some(0.0);
edge.minlen = Some(1.0);
edge
});
let minlen = edge_label.minlen.unwrap_or(1.0);
simple_label.minlen = Some(simple_label.minlen.unwrap_or(1.0).max(minlen));
let weight = edge_label.weight.unwrap_or(0.0);
simple_label.weight = Some(simple_label.weight.unwrap_or(0.0) + weight);
let _ = simplified.set_edge(&edge_obj.v, &edge_obj.w, Some(simple_label), None);
}
simplified
}
/*
* it implement same logic as simplify do but, it uses Ref instead of creating new graph
*/
pub fn simplify_ref(g: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let edges = g.edges();
for edge_obj in edges.into_iter() {
let edge_label_ = g.edge_mut_with_obj(&edge_obj);
if let Some(edge_label) = edge_label_ {
if edge_label.weight.is_none() {
edge_label.weight = Some(0.0);
}
if edge_label.minlen.is_none() {
edge_label.minlen = Some(1.0);
}
}
}
}
pub fn as_non_compound_graph(
g: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
) -> Graph<GraphConfig, GraphNode, GraphEdge> {
let mut simplified: Graph<GraphConfig, GraphNode, GraphEdge> = Graph::new(Some(GraphOption {
directed: Some(true),
multigraph: Some(true),
compound: Some(false),
}));
simplified.set_graph(g.graph().clone());
let nodes = g.nodes();
for v in nodes.into_iter() {
if g.children(&v).len() == 0 {
simplified.set_node(
v.clone(),
Some(g.node(&v).cloned().unwrap_or(GraphNode::default())),
);
}
}
let edge_objs = g.edges();
for e in edge_objs.into_iter() {
let _ = simplified.set_edge_with_obj(&e, g.edge_with_obj(&e).cloned());
}
return simplified;
}
pub fn transfer_node_edge_labels(
source: &Graph<GraphConfig, GraphNode, GraphEdge>,
destination: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
) {
let nodes = source.nodes();
for v in nodes.into_iter() {
if source.children(&v).len() == 0 {
destination.set_node(
v.clone(),
Some(source.node(&v).cloned().unwrap_or(GraphNode::default())),
);
}
}
let edge_objs = source.edges();
for e in edge_objs.into_iter() {
let _ = destination.set_edge_with_obj(&e, source.edge_with_obj(&e).cloned());
}
}
pub struct Rect {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
/*
* Finds where a line starting at point ({x, y}) would intersect a rectangle
* ({x, y, width, height}) if it were pointing at the rectangle's center.
*/
pub fn intersect_rect(rect: &Rect, point: &GraphEdgePoint) -> GraphEdgePoint {
let x = rect.x;
let y = rect.y;
// Rectangle intersection algorithm from:
// http://math.stackexchange.com/questions/108113/find-edge-between-two-boxes
let dx = point.x - x;
let dy = point.y - y;
let w = rect.width / 2.0;
let h = rect.height / 2.0;
if dx == 0.0 && dy == 0.0 {
return GraphEdgePoint { x: x + w, y };
}
let (sx, sy) = if (dy.abs() * w) > (dx.abs() * h) {
// Intersection is top or bottom of rect.
if dy < 0.0 {
(-h * dx / dy, -h)
} else {
(h * dx / dy, h)
}
} else {
// Intersection is left or right of rect.
if dx < 0.0 {
(-w, -w * dy / dx)
} else {
(w, w * dy / dx)
}
};
GraphEdgePoint {
x: x + sx,
y: y + sy,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_close(left: f32, right: f32) {
assert!((left - right).abs() < f32::EPSILON);
}
#[test]
fn intersect_rect_returns_boundary_point_for_center_point() {
let rect = Rect {
x: 10.0,
y: 20.0,
width: 8.0,
height: 4.0,
};
let point = GraphEdgePoint { x: 10.0, y: 20.0 };
let intersection = intersect_rect(&rect, &point);
assert_close(intersection.x, 14.0);
assert_close(intersection.y, 20.0);
}
}
/*
* Given a DAG with each node assigned "rank" and "order" properties, this
* function will produce a matrix with the ids of each node.
*/
pub fn build_layer_matrix(g: &Graph<GraphConfig, GraphNode, GraphEdge>) -> Vec<Vec<String>> {
let mut layering: Vec<OrderedHashMap<usize, String>> =
(0..=max_rank(g)).map(|_| OrderedHashMap::new()).collect();
g.nodes().iter().for_each(|v| {
let node = g.node(v).unwrap();
let Some(rank) = node.rank else {
return;
};
let layer: &mut OrderedHashMap<usize, String> = layering.get_mut(rank as usize).unwrap();
layer.insert(node.order.unwrap_or(0), v.clone());
});
layering
.into_iter()
.map(|layer| {
let mut keys: Vec<usize> = layer.keys().cloned().collect();
keys.sort();
keys.iter()
.map(|key| layer.get(key).cloned().unwrap())
.collect()
})
.collect()
}
/*
* Adjusts the ranks for all nodes in the graph such that all nodes v have
* rank(v) >= 0 and at least one node w has rank(w) = 0.
*/
pub fn normalize_ranks(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let node_ids = graph.nodes();
let node_ranks: Vec<i32> = node_ids
.iter()
.map(|v| {
graph
.node(v)
.unwrap_or(&GraphNode::default())
.rank
.clone()
.unwrap_or(0)
})
.collect();
let min = node_ranks.iter().min().cloned().unwrap_or(0);
node_ids.iter().for_each(|node_id| {
let node_ = graph.node_mut(node_id);
if let Some(node) = node_ {
if node.rank.is_some() {
node.rank = Some(node.rank.unwrap() - min);
}
}
})
}
pub fn remove_empty_ranks(graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>) {
let nodes: Vec<String> = graph.nodes();
if nodes.is_empty() {
return;
}
let node_ranks: Vec<i32> = nodes
.iter()
.map(|v| {
graph
.node(v)
.cloned()
.unwrap_or(GraphNode::default())
.rank
.unwrap_or(0)
})
.collect();
let offset: i32 = node_ranks.iter().min().cloned().unwrap_or(0);
let max_rank: i32 = node_ranks.iter().max().cloned().unwrap_or(0) - offset;
let mut layers: Vec<Vec<String>> = vec![Vec::new(); (max_rank + 1).max(0) as usize];
for v in &nodes {
let rank = graph
.node(v)
.unwrap_or(&GraphNode::default())
.rank
.unwrap_or(0)
- offset;
if rank >= 0 {
layers[rank as usize].push(v.clone());
}
}
let node_rank_factor = graph.graph().node_rank_factor.clone().unwrap_or(0.0) as i32;
if node_rank_factor <= 0 {
return;
}
let mut delta = 0;
for (i, vs) in layers.iter().enumerate() {
let i = i as i32;
if vs.is_empty() && i % node_rank_factor != 0 {
delta -= 1;
} else if delta != 0 {
for v in vs {
if let Some(node) = graph.node_mut(v) {
node.rank = Some(node.rank.unwrap_or(0) + delta);
}
}
}
}
}
pub fn add_border_node(
graph: &mut Graph<GraphConfig, GraphNode, GraphEdge>,
prefix: &str,
rank: Option<&usize>,
order: Option<&usize>,
) -> String {
let mut node = GraphNode::default();
if rank.is_some() {
node.rank = Some(rank.cloned().unwrap_or(0) as i32);
}
if order.is_some() {
node.order = Some(order.cloned().unwrap_or(0));
}
return add_dummy_node(graph, "border".to_string(), node, prefix.to_string());
}
pub fn max_rank(g: &Graph<GraphConfig, GraphNode, GraphEdge>) -> i32 {
g.nodes()
.iter()
.filter_map(|v| g.node(v).and_then(|n| n.rank))
.max()
.unwrap_or(0)
}
#[derive(Debug, Clone)]
pub struct PartitionResponse<V> {
pub lhs: Vec<V>,
pub rhs: Vec<V>,
}
/*
* Partition a collection into two groups: `lhs` and `rhs`. If the supplied
* function returns true for an entry it goes into `lhs`. Otherwise it goes
* into `rhs.
*/
pub fn partition<V: Clone>(
collection: &Vec<V>,
fn_: Box<dyn Fn(&V) -> bool>,
) -> PartitionResponse<V> {
let mut result: PartitionResponse<V> = PartitionResponse {
lhs: vec![],
rhs: vec![],
};
collection.iter().for_each(|val| {
if fn_(val) {
result.lhs.push(val.clone());
} else {
result.rhs.push(val.clone());
}
});
return result;
}
+131
View File
@@ -0,0 +1,131 @@
pub mod layout;
use crate::layout::add_border_segments::BorderTypeName;
use graphlib_rust::Edge;
use ordered_hashmap::OrderedHashMap;
#[allow(dead_code)]
#[derive(Debug, Clone, Default)]
pub struct GraphNode {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
pub class: Option<String>,
pub label: Option<GraphEdge>,
pub padding: Option<f32>,
pub padding_x: Option<f32>,
pub padding_y: Option<f32>,
pub rx: Option<f32>,
pub ry: Option<f32>,
pub shape: Option<String>,
pub dummy: Option<String>,
pub rank: Option<i32>,
pub min_rank: Option<i32>,
pub max_rank: Option<i32>,
pub order: Option<usize>,
pub border_top: Option<String>,
pub border_bottom: Option<String>,
pub border_left: Option<OrderedHashMap<i32, String>>,
pub border_right: Option<OrderedHashMap<i32, String>>,
pub border_left_: Option<String>,
pub border_right_: Option<String>,
pub low: Option<usize>,
pub lim: Option<usize>,
pub parent: Option<String>,
pub e: Option<Edge>,
pub edge_label: Option<GraphEdge>,
pub edge_obj: Option<Edge>,
pub labelpos: Option<String>,
pub border_type: Option<BorderTypeName>,
pub self_edges: Vec<(Edge, GraphEdge)>,
}
#[derive(Debug, Clone, Default)]
pub struct GraphEdgePoint {
pub x: f32,
pub y: f32,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct GraphEdge {
pub forward_name: Option<String>,
pub reversed: Option<bool>,
pub minlen: Option<f32>,
pub weight: Option<f32>,
pub width: Option<f32>,
pub height: Option<f32>,
pub label_rank: Option<i32>,
pub labeloffset: Option<f32>,
pub labelpos: Option<String>,
pub nesting_edge: Option<bool>,
pub cutvalue: Option<f32>,
pub points: Option<Vec<GraphEdgePoint>>,
pub x: f32,
pub y: f32,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct GraphConfig {
pub width: f32,
pub height: f32,
pub nodesep: Option<f32>, // default 50
pub edgesep: Option<f32>, // default 20
pub ranksep: Option<f32>, // default 50
pub marginx: Option<f32>, // default 0
pub marginy: Option<f32>, // default 0
pub rankdir: Option<String>, // lr, lr, tb, bt // default tb
pub acyclicer: Option<String>, // greedy, dfs, unknown-should-still-work
pub ranker: Option<String>, // "longest-path", "tight-tree", "network-simplex", "unknown-should-still-work"
pub align: Option<String>,
pub nesting_root: Option<String>, // id of dummy nesting root
pub root: Option<String>,
pub node_rank_factor: Option<f32>, // default 0
pub dummy_chains: Option<Vec<String>>,
}
impl Default for GraphConfig {
fn default() -> Self {
Self {
width: 0.0,
height: 0.0,
nodesep: Some(50.0),
edgesep: Some(20.0),
ranksep: Some(50.0),
marginx: None,
marginy: None,
rankdir: Some("tb".to_string()),
acyclicer: None,
ranker: None,
align: None,
nesting_root: None,
root: None,
node_rank_factor: None,
dummy_chains: None,
}
}
}
impl Default for GraphEdge {
fn default() -> Self {
Self {
forward_name: None,
reversed: None,
minlen: Some(1.0),
weight: Some(1.0),
width: Some(0.0),
height: Some(0.0),
label_rank: None,
labeloffset: Some(0.0),
labelpos: Some("r".to_string()),
nesting_edge: None,
cutvalue: None,
points: None,
x: 0.0,
y: 0.0,
}
}
}
+47
View File
@@ -0,0 +1,47 @@
# Vendored third-party crate.
#
# Upstream: graphlib_rust 0.0.2 (Apache-2.0)
# Source of truth: https://crates.io/crates/graphlib_rust/0.0.2
#
# Why vendored: the Rust port of dagre.js's graphlib, used by the vendored
# `dagre_rust` layout engine and directly by the Warp `mermaid-to-svg` engine.
# It is a young single-author crate on the untrusted-input render path, so
# vendoring gives a full audit surface and immunity to upstream yanks. See
# third_party/mermaid-to-svg and crates/codegen/xai-grok-mermaid.
#
# ============================================================================
# VENDORING NOTES — re-apply / re-bless ALL of the following on every upgrade:
# ============================================================================
# Local modifications to the upstream source:
# 1. `cargo fmt` (rustfmt) applied so the crate satisfies the workspace fmt
# gate; no semantic change. The `ordered_hashmap` dep is repointed to the
# sibling vendored crate (path dep below).
#
# Re-audit checklist:
# - No `unsafe`, no filesystem / env / network I/O in the vendored source.
[package]
name = "graphlib_rust"
version = "0.0.2"
edition = "2021"
description = "Dagre's graphlib in Rust (vendored, library-only)"
license = "Apache-2.0"
repository = "https://github.com/r3alst/graphlib-rust"
publish = false
[lib]
name = "graphlib_rust"
path = "src/lib.rs"
doctest = false
[dependencies]
ordered_hashmap = { path = "../ordered_hashmap" }
# Vendored third-party code is not restyled to our lint standards, so silence
# the lints it trips under the workspace's `-D warnings` (the source is kept
# verbatim — only this manifest carries the allows). Our own crate
# (xai-grok-mermaid) is still fully linted.
[lints.rust]
noop_method_call = "allow"
[lints.clippy]
all = { level = "allow", priority = -1 }
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
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.
+107
View File
@@ -0,0 +1,107 @@
use crate::Graph;
use std::collections::HashMap;
use std::error::Error;
use std::fmt::Debug;
/*
* A helper that preforms a pre- or post-order traversal on the input graph
* and returns the nodes in the order they were visited. If the graph is
* undirected then this algorithm will navigate using neighbors. If the graph
* is directed then this algorithm will navigate using successors.
*
* If the order is not "post", it will be treated as "pre".
*/
pub fn dfs<GL: Default, N: Default + Clone + Debug, E: Default + Clone + Debug>(
g: &mut Graph<GL, N, E>,
vs: &Vec<String>,
order: &str,
) -> Result<Vec<String>, Box<dyn Error>> {
let navigation: Box<dyn Fn(&String, &Graph<GL, N, E>) -> Vec<String>> =
Box::new(move |v: &String, g: &Graph<GL, N, E>| {
if g.is_directed() {
g.successors(&v).unwrap_or(vec![])
} else {
g.neighbors(&v).unwrap_or(vec![])
}
});
let order_func = match order {
"post" => post_order_dfs,
_ => pre_order_dfs,
};
let mut acc: Vec<String> = vec![];
let mut visited: HashMap<String, bool> = HashMap::new();
for v in vs.iter() {
if !g.has_node(v) {
return Err(format!("Graph does not have node: {}", v).into());
}
order_func(v, &navigation, &mut visited, &mut acc, g);
}
Ok(acc)
}
fn post_order_dfs<GL: Default, N: Default + Clone + Debug, E: Default + Clone + Debug>(
v: &String,
navigation: &Box<dyn Fn(&String, &Graph<GL, N, E>) -> Vec<String>>,
visited: &mut HashMap<String, bool>,
acc: &mut Vec<String>,
g: &Graph<GL, N, E>,
) {
let mut stack: Vec<(String, bool)> = vec![(v.clone(), false)];
while stack.len() > 0 {
let curr_ = stack.pop();
if curr_.is_none() {
continue;
}
let curr = curr_.unwrap();
if curr.1 {
acc.push(curr.0.clone());
} else {
if !visited.contains_key(&curr.0) {
visited.insert(curr.0.clone(), true);
stack.push((curr.0.clone(), true));
// TODO: for_each_right implement in future
let _navigation_nodes: Vec<String> = navigation(&curr.0, g);
let mut idx = _navigation_nodes.len();
while idx > 0 {
let nav_node = _navigation_nodes.get(idx - 1).cloned().unwrap();
stack.push((nav_node, false));
idx -= 1;
}
}
}
}
}
fn pre_order_dfs<GL: Default, N: Default + Clone, E: Default + Clone>(
v: &String,
navigation: &Box<dyn Fn(&String, &Graph<GL, N, E>) -> Vec<String>>,
visited: &mut HashMap<String, bool>,
acc: &mut Vec<String>,
g: &Graph<GL, N, E>,
) {
let mut stack: Vec<String> = vec![v.clone()];
while stack.len() > 0 {
let curr_ = stack.pop();
if curr_.is_none() {
continue;
}
let curr = curr_.unwrap();
if !visited.contains_key(&curr) {
visited.insert(curr.clone(), true);
acc.push(curr.clone());
// TODO: for_each_right implement in future
let _navigation_nodes: Vec<String> = navigation(&curr, g);
let mut idx = _navigation_nodes.len() as i32;
while idx >= 0 {
stack.push(String::from(_navigation_nodes.get(idx as usize).unwrap()));
idx -= 1;
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod dfs;
pub mod postorder;
pub mod preorder;
+14
View File
@@ -0,0 +1,14 @@
use crate::algo::dfs::dfs;
use crate::Graph;
use std::fmt::Debug;
// TODO: need to check if exceptions are required
pub fn postorder<GL: Default, N: Default + Clone + Debug, E: Default + Clone + Debug>(
g: &mut Graph<GL, N, E>,
vs: &Vec<String>,
) -> Vec<String> {
return match dfs(g, vs, "post") {
Ok(t) => t,
_ => vec![],
};
}
+14
View File
@@ -0,0 +1,14 @@
use crate::algo::dfs::dfs;
use crate::Graph;
use std::fmt::Debug;
// TODO: need to check if exceptions are required
pub fn preorder<GL: Default, N: Default + Clone + Debug, E: Default + Clone + Debug>(
g: &mut Graph<GL, N, E>,
vs: &Vec<String>,
) -> Vec<String> {
return match dfs(g, vs, "pre") {
Ok(t) => t,
_ => vec![],
};
}
+965
View File
@@ -0,0 +1,965 @@
use ordered_hashmap::OrderedHashMap;
use std::collections::HashSet;
use std::error::Error;
use std::fmt::Debug;
use std::hash::Hash;
pub const DEFAULT_EDGE_NAME: &str = "\x00";
pub const GRAPH_NODE: &str = "\x00";
pub const EDGE_KEY_DELIM: &str = "\x01";
#[derive(Debug, Clone)]
pub struct Edge {
pub v: String,
pub w: String,
pub name: Option<String>,
}
#[derive(Default)]
pub struct GraphOption {
pub directed: Option<bool>,
pub multigraph: Option<bool>,
pub compound: Option<bool>,
}
pub enum DefaultNodeLabel<N> {
Val(Option<N>),
Func(Box<dyn Fn(String) -> Option<N>>),
}
pub enum DefaultEdgeLabel<E> {
Val(Option<E>),
Func(Box<dyn Fn(String) -> Option<E>>),
}
pub enum EdgeOrString<E> {
Edge(E),
String(String),
}
pub enum EdgeLabelOrString<E> {
EdgeLabel(E),
String(String),
}
pub struct Graph<GL, N, E>
where
GL: Default,
{
// GraphLabel Type, Node Type, Node Index Type, Edge Type, Edge Index Type
_is_directed: bool,
_is_multigraph: bool,
_is_compound: bool,
// Label for the graph itself
_label: GL,
// Defaults to be set when creating a new node
_default_node_label_fn: DefaultNodeLabel<N>,
// Defaults to be set when creating a new edge
_default_edge_label_fn: DefaultEdgeLabel<E>,
// v -> label
_nodes: OrderedHashMap<String, N>,
// v -> e -> edgeObj
_in: OrderedHashMap<String, OrderedHashMap<String, Edge>>,
// u -> v -> Number
_preds: OrderedHashMap<String, OrderedHashMap<String, usize>>,
// v -> e -> edgeObj
_out: OrderedHashMap<String, OrderedHashMap<String, Edge>>,
// v -> w -> Number
_sucs: OrderedHashMap<String, OrderedHashMap<String, usize>>,
// e -> edgeObj
_edge_objs: OrderedHashMap<String, Edge>,
// e -> label
_edge_labels: OrderedHashMap<String, E>,
/* Number of nodes in the graph. Should only be changed by the implementation. */
_node_count: usize,
/* Number of edges in the graph. Should only be changed by the implementation. */
_edge_count: usize,
// v -> w
_parent: OrderedHashMap<String, String>,
// v -> w -> boolean
_children: OrderedHashMap<String, OrderedHashMap<String, bool>>,
}
impl<GL: Default, N, E> Default for Graph<GL, N, E> {
fn default() -> Self {
Self {
_is_directed: true,
_is_multigraph: false,
_is_compound: false,
_label: GL::default(),
_default_node_label_fn: DefaultNodeLabel::Val(None),
_default_edge_label_fn: DefaultEdgeLabel::Val(None),
_nodes: OrderedHashMap::new(),
_in: OrderedHashMap::new(),
_preds: OrderedHashMap::new(),
_out: OrderedHashMap::new(),
_sucs: OrderedHashMap::new(),
_edge_objs: OrderedHashMap::new(),
_edge_labels: OrderedHashMap::new(),
_node_count: 0,
_edge_count: 0,
_parent: OrderedHashMap::new(),
_children: OrderedHashMap::new(),
}
}
}
impl<GL: Default, N: Default + Clone + Debug, E: Default + Clone + Debug> Graph<GL, N, E> {
pub fn new(opts: Option<GraphOption>) -> Self {
let mut graph = Self::default();
if let Some(_opts) = opts {
if _opts.directed.is_some() {
graph._is_directed = _opts.directed.unwrap();
} else {
graph._is_directed = true;
}
if _opts.multigraph.is_some() {
graph._is_multigraph = _opts.multigraph.unwrap();
} else {
graph._is_multigraph = false;
}
if _opts.multigraph.is_some() {
graph._is_multigraph = _opts.multigraph.unwrap();
} else {
graph._is_multigraph = false;
}
if _opts.compound.is_some() {
graph._is_compound = _opts.compound.unwrap();
} else {
graph._is_compound = false;
}
}
if graph._is_compound {
// v -> parent
graph._parent = OrderedHashMap::new();
graph._children = OrderedHashMap::new();
graph
._children
.insert(GRAPH_NODE.clone().to_string(), OrderedHashMap::new());
}
graph
}
/* === Graph functions ========= */
/**
* Whether graph was created with 'directed' flag set to true or not.
*/
pub fn is_directed(&self) -> bool {
return self._is_directed;
}
/**
* Whether graph was created with 'multigraph' flag set to true or not.
*/
pub fn is_multigraph(&self) -> bool {
return self._is_multigraph;
}
/**
* Whether graph was created with 'compound' flag set to true or not.
*/
pub fn is_compound(&self) -> bool {
return self._is_compound;
}
/**
* Sets the label of the graph.
*/
pub fn set_graph(&mut self, label: GL) -> &mut Self {
self._label = label;
return self;
}
/**
* Gets the graph label.
*/
pub fn graph(&self) -> &GL {
return &self._label;
}
/**
* Gets the graph label.
*/
pub fn graph_mut(&mut self) -> &mut GL {
return &mut self._label;
}
/* === Node functions ========== */
/**
* Sets the default node label. If newDefault is a function, it will be
* invoked ach time when setting a label for a node. Otherwise, this label
* will be assigned as default label in case if no label was specified while
* setting a node.
* Complexity: O(1).
*/
pub fn set_default_node_label(&mut self, new_default: DefaultNodeLabel<N>) -> &mut Self {
self._default_node_label_fn = new_default;
return self;
}
pub fn default_node_label(&self, node_id: String) -> N {
let mut _node_label: Option<N> = None;
match &self._default_node_label_fn {
DefaultNodeLabel::Func(node_label_fn) => {
_node_label = node_label_fn(node_id.clone());
}
DefaultNodeLabel::Val(node_label_) => {
if node_label_.is_some() {
_node_label = Some(node_label_.clone().unwrap());
} else {
_node_label = Some(N::default());
}
}
}
return _node_label.unwrap();
}
/**
* Gets the number of nodes in the graph.
* Complexity: O(1).
*/
pub fn node_count(&self) -> usize {
return self._node_count;
}
/**
* Gets all nodes of the graph. Note, the in case of compound graph subnodes are
* not included in list.
* Complexity: O(1).
*/
pub fn nodes(&self) -> Vec<String> {
return self._nodes.keys().cloned().collect();
}
/**
* Gets list of nodes without in-edges.
* Complexity: O(|V|).
*/
pub fn sources(&self) -> Vec<String> {
return self
.nodes()
.iter()
.filter(|n| {
if let Some(in_edges) = self._in.get(&n.to_owned().clone()) {
return in_edges.len() == 0;
}
return true;
})
.map(|node_id| node_id.clone())
.collect();
}
/**
* Gets list of nodes without out-edges.
* Complexity: O(|V|).
*/
pub fn sinks(&self) -> Vec<String> {
return self
.nodes()
.iter()
.filter(|n| {
if let Some(out_edges) = self._out.get(&n.to_owned().clone()) {
return out_edges.len() == 0;
}
return true;
})
.map(|node_id| node_id.clone())
.collect();
}
/**
* Invokes setNode method for each node in names list.
* Complexity: O(|names|).
*/
pub fn set_nodes(&mut self, node_ids: Vec<String>, value: Option<N>) -> &mut Self {
node_ids.iter().for_each(|node_id| {
self.set_node(node_id.to_owned(), value.clone());
});
return self;
}
/**
* Creates or updates the value for the node v in the graph. If label is supplied
* it is set as the value for the node. If label is not supplied and the node was
* created by this call then the default node label will be assigned.
* Complexity: O(1).
*/
pub fn set_node(&mut self, v: String, value: Option<N>) -> &mut Self {
if self._nodes.get(&v).is_some() {
if value.is_some() {
self._nodes.insert(v, value.unwrap());
}
return self;
}
if value.is_some() {
self._nodes.insert(v.clone(), value.unwrap());
} else {
self._nodes
.insert(v.clone(), self.default_node_label(v.clone()));
}
if self._is_compound {
let _graph_node = GRAPH_NODE.clone().to_string();
self._parent.insert(v.clone(), _graph_node.clone());
self._children.insert(v.clone(), OrderedHashMap::new());
self._children
.entry(_graph_node.clone())
.or_insert(OrderedHashMap::new())
.entry(v.clone())
.or_insert(true);
}
self._in.insert(v.clone(), OrderedHashMap::new());
self._preds.insert(v.clone(), OrderedHashMap::new());
self._out.insert(v.clone(), OrderedHashMap::new());
self._sucs.insert(v.clone(), OrderedHashMap::new());
self._node_count += 1;
return self;
}
/**
* Gets the label of node with specified name.
* Complexity: O(|V|).
*/
pub fn node(&self, v: &String) -> Option<&N> {
return self._nodes.get(v);
}
/**
* Gets the label of node with specified name.
* Complexity: O(|V|).
*/
pub fn node_mut(&mut self, v: &String) -> Option<&mut N> {
return self._nodes.get_mut(v);
}
/**
* Detects whether graph has a node with specified name or not.
*/
pub fn has_node(&self, v: &String) -> bool {
return self._nodes.contains_key(v);
}
/**
* Remove the node with the name from the graph or do nothing if the node is not in
* the graph. If the node was removed this function also removes any incident
* edges.
* Complexity: O(1).
*/
pub fn remove_node(&mut self, v: &String) -> &mut Self {
if self._nodes.contains_key(v) {
self._nodes.remove(v);
if self._is_compound {
self._remove_from_parents_child_list(v);
if self._parent.contains_key(v) {
self._parent.remove(v);
}
self.children(v).iter().for_each(|child_id| {
// TODO: exception handling
let _ = self.set_parent(child_id, None);
});
self._children.remove(v);
}
// removing in edges
if let Some(in_edges) = self._in.get(v) {
let edge_ids: Vec<String> = in_edges.keys().cloned().collect();
edge_ids.iter().for_each(|edge_id| {
if let Some(edge) = self._edge_objs.get(edge_id) {
self.remove_edge_with_obj(&edge.to_owned());
}
});
self._in.remove(v);
}
self._preds.remove(v);
// removing out edges
if let Some(out_edges) = self._out.get(v) {
let edge_ids: Vec<String> = out_edges.keys().cloned().collect();
edge_ids.iter().for_each(|edge_id| {
if let Some(edge) = self._edge_objs.get(edge_id) {
self.remove_edge_with_obj(&edge.to_owned());
}
});
self._out.remove(v);
}
self._sucs.remove(v);
self._node_count -= 1;
}
return self;
}
/**
* Sets node p as a parent for node v if it is defined, or removes the
* parent for v if p is undefined. Method throws an exception in case of
* invoking it in context of noncompound graph.
* Average-case complexity: O(1).
*/
pub fn set_parent(
&mut self,
v: &String,
parent: Option<String>,
) -> Result<&mut Self, Box<dyn Error>> {
if !self._is_compound {
return Err("Cannot set parent in a non-compound graph".into());
}
let mut _parent: String = "".to_string();
if parent.is_none() {
_parent = GRAPH_NODE.to_string();
} else {
_parent = parent.unwrap().clone();
let mut ancestor = _parent.clone();
while let Some(new_ancestor) = self.parent(&ancestor) {
if &new_ancestor == &v {
return Err(format!(
"Setting {} as parent of {} would create a cycle",
_parent.clone(),
v
)
.into());
}
ancestor = new_ancestor.clone();
}
self.set_node(_parent.clone(), None);
}
self.set_node(v.clone(), None);
self._remove_from_parents_child_list(v);
self._parent.insert(v.clone(), _parent.clone());
self._children
.entry(_parent.clone())
.or_insert_with(OrderedHashMap::new)
.insert(v.clone(), true);
Ok(self)
}
pub fn _remove_from_parents_child_list(&mut self, v: &String) {
if let Some(parent) = self._parent.get(v) {
if let Some(children) = self._children.get_mut(parent) {
children.remove(v);
}
}
}
/**
* Gets parent node for node v.
* Complexity: O(1).
*/
pub fn parent(&self, v: &String) -> Option<&String> {
if self._is_compound {
if let Some(parent) = self._parent.get(v) {
if parent != GRAPH_NODE {
return Some(parent);
}
}
}
None
}
/**
* Gets list of direct children of node v.
* Complexity: O(1).
*/
pub fn children(&self, v: &String) -> Vec<String> {
if self._is_compound {
if let Some(children) = self._children.get(v) {
return children.keys().cloned().collect();
}
} else if v == GRAPH_NODE {
return self._nodes.keys().cloned().collect();
} else if self.has_node(&v) {
return vec![];
}
vec![]
}
/**
* Return all nodes that are predecessors of the specified node or undefined if node v is not in
* the graph. Behavior is undefined for undirected graphs - use neighbors instead.
* Complexity: O(|V|).
*/
pub fn predecessors(&self, v: &String) -> Option<Vec<String>> {
if let Some(preds) = self._preds.get(v) {
return Some(preds.keys().cloned().collect());
}
None
}
/**
* Return all nodes that are successors of the specified node or undefined if node v is not in
* the graph. Behavior is undefined for undirected graphs - use neighbors instead.
* Complexity: O(|V|).
*/
pub fn successors(&self, v: &String) -> Option<Vec<String>> {
if let Some(sucs) = self._sucs.get(v) {
return Some(sucs.keys().cloned().collect());
}
None
}
/**
* Return all nodes that are predecessors or successors of the specified node or undefined if
* node v is not in the graph.
* Complexity: O(|V|).
*/
pub fn neighbors(&self, v: &String) -> Option<Vec<String>> {
if let Some(preds) = self.predecessors(v) {
let mut union: HashSet<String> = HashSet::new();
preds.into_iter().for_each(|pred| {
union.insert(pred);
});
if let Some(sucs) = self.successors(v) {
sucs.into_iter().for_each(|successor| {
union.insert(successor);
});
}
return Some(union.into_iter().collect());
}
None
}
pub fn is_leaf(&self, v: &String) -> bool {
let mut _neighbors: Option<Vec<String>> = None;
if self.is_directed() {
_neighbors = self.successors(v);
} else {
_neighbors = self.neighbors(v);
}
if _neighbors.is_none() || _neighbors.unwrap().len() == 0 {
return true;
}
false
}
/**
* Creates new graph with nodes filtered via filter. Edges incident to rejected node
* are also removed. In case of compound graph, if parent is rejected by filter,
* than all its children are rejected too.
* Average-case complexity: O(|E|+|V|).
*/
pub fn filter_nodes<F>(&self, filter: F) -> Self
where
F: Fn(&String) -> bool,
{
let mut copy: Graph<GL, N, E> = Graph::new(Some(GraphOption {
directed: Some(self._is_directed.clone()),
multigraph: Some(self._is_multigraph.clone()),
compound: Some(self._is_compound.clone()),
}));
for (v, value) in self._nodes.iter() {
if filter(v) {
copy.set_node(v.clone(), Some(value.clone()));
}
}
for e_v in self._edge_objs.values() {
if copy._nodes.contains_key(&e_v.v) && copy._nodes.contains_key(&e_v.w) {
if let Some(edge_label) = self.edge_with_obj(e_v) {
let _ = copy.set_edge_with_obj(e_v, Some(edge_label.to_owned()));
}
}
}
let mut parents: OrderedHashMap<String, String> = OrderedHashMap::new();
if self._is_compound {
let node_ids: Vec<String> = copy._nodes.keys().cloned().into_iter().collect();
for v in node_ids {
let parent = find_parent(&v, &mut parents, &mut copy, self);
let _ = copy.set_parent(&v, parent);
}
}
copy
}
/* === Edge functions ========== */
/**
* Sets the default edge label or factory function. This label will be
* assigned as default label in case if no label was specified while setting
* an edge or this function will be invoked each time when setting an edge
* with no label specified and returned value * will be used as a label for edge.
* Complexity: O(1).
*/
pub fn set_default_edge_label(&mut self, new_default: DefaultEdgeLabel<E>) -> &mut Self {
self._default_edge_label_fn = new_default;
return self;
}
pub fn default_edge_label(&self, edge_id: String) -> E {
let mut _edge_label: Option<E> = None;
match &self._default_edge_label_fn {
DefaultEdgeLabel::Func(edge_label_fn) => {
_edge_label = edge_label_fn(edge_id.clone());
}
DefaultEdgeLabel::Val(edge_label_) => {
if edge_label_.is_some() {
_edge_label = Some(edge_label_.clone().unwrap());
} else {
_edge_label = Some(E::default());
}
}
}
return _edge_label.unwrap();
}
/**
* Gets the number of edges in the graph.
* Complexity: O(1).
*/
pub fn edge_count(&self) -> usize {
return self._edge_count.clone();
}
/**
* Gets edges of the graph. In case of compound graph subgraphs are not considered.
* Complexity: O(|E|).
*/
pub fn edges(&self) -> Vec<Edge> {
return self._edge_objs.values().cloned().collect();
}
/**
* Establish an edges path over the nodes in nodes list. If some edge is already
* exists, it will update its label, otherwise it will create an edge between pair
* of nodes with label provided or default label if no label provided.
* Complexity: O(|nodes|).
*/
pub fn set_path(&mut self, vs: &Vec<String>, value: Option<E>) {
vs.iter().reduce(|v1, v2| {
let _ = self.set_edge(v1, v2, value.clone(), None);
v2
});
}
/**
* Creates or updates the label for the edge (v, w) with the optionally supplied
* name. If label is supplied it is set as the value for the edge. If label is not
* supplied and the edge was created by this call then the default edge label will
* be assigned. The name parameter is only useful with multigraphs.
*/
pub fn set_edge(
&mut self,
v: &String,
w: &String,
edge_label: Option<E>,
name: Option<String>,
) -> Result<&mut Self, Box<dyn Error>> {
let e = edge_args_to_id(&self._is_directed, v, w, &name);
if self._edge_labels.contains_key(&e) {
if edge_label.is_some() {
self._edge_labels.insert(e.clone(), edge_label.unwrap());
}
return Ok(self);
}
if name.is_some() && !self._is_multigraph {
return Err("Cannot set a named edge when isMultigraph = false".into());
}
// It didn't exist, so we need to create it.
// First ensure the nodes exist.
self.set_node(v.clone(), None);
self.set_node(w.clone(), None);
if edge_label.is_some() {
self._edge_labels
.insert(e.clone(), edge_label.clone().unwrap());
} else {
self._edge_labels
.insert(e.clone(), self.default_edge_label(e.clone()));
}
let edge_obj = edge_args_to_obj(&self.is_directed(), v, w, &name);
// Ensure we add undirected edges in a consistent way.
self._edge_objs.insert(e.clone(), edge_obj.clone());
if let Some(preds) = self._preds.get_mut(w) {
increment_or_init_entry(preds, v);
}
if let Some(sucs) = self._sucs.get_mut(v) {
increment_or_init_entry(sucs, w);
}
let in_edges = self
._in
.entry(w.clone())
.or_insert_with(OrderedHashMap::new);
in_edges.insert(e.clone(), edge_obj.clone());
let out_edges = self
._out
.entry(v.clone())
.or_insert_with(OrderedHashMap::new);
out_edges.insert(e.clone(), edge_obj.clone());
self._edge_count += 1;
return Ok(self);
}
pub fn set_edge_with_obj(
&mut self,
e: &Edge,
edge_label: Option<E>,
) -> Result<&mut Self, Box<dyn Error>> {
self.set_edge(&e.v, &e.w, edge_label, None)
}
/**
* Gets the label for the specified edge.
* Complexity: O(1).
*/
pub fn edge(&self, v: &String, w: &String, name: Option<String>) -> Option<&E> {
let e = edge_args_to_id(&self._is_directed, v, w, &name);
return self._edge_labels.get(&e);
}
/**
* Gets the label for the specified edge.
* Complexity: O(1).
*/
pub fn edge_with_obj(&self, edge: &Edge) -> Option<&E> {
let e = edge_obj_to_id(&self._is_directed, edge);
return self._edge_labels.get(&e);
}
/**
* Gets the label for the specified edge.
* Complexity: O(1).
*/
pub fn edge_mut(&mut self, v: &String, w: &String, name: Option<String>) -> Option<&mut E> {
let e = edge_args_to_id(&self._is_directed, v, w, &name);
return self._edge_labels.get_mut(&e);
}
/**
* Gets the label for the specified edge.
* Complexity: O(1).
*/
pub fn edge_mut_with_obj(&mut self, edge: &Edge) -> Option<&mut E> {
let e = edge_obj_to_id(&self._is_directed, edge);
return self._edge_labels.get_mut(&e);
}
/**
* Detects whether the graph contains specified edge or not. No subgraphs are considered.
* Complexity: O(1).
*/
pub fn has_edge(&self, v: &String, w: &String, name: Option<String>) -> bool {
let e = edge_args_to_id(&self._is_directed, v, w, &name);
self._edge_labels.contains_key(&e)
}
pub fn has_edge_with_obj(&self, edge: &Edge) -> bool {
let e = edge_obj_to_id(&self._is_directed, edge);
self._edge_labels.contains_key(&e)
}
/**
* Removes the specified edge from the graph. No subgraphs are considered.
* Complexity: O(1).
*/
pub fn remove_edge(&mut self, v: &String, w: &String, name: Option<String>) -> &mut Self {
let e: String = edge_args_to_id(&self._is_directed, v, w, &name);
if let Some(edge) = self._edge_objs.get_mut(&e) {
let v = edge.v.clone();
let w = edge.w.clone();
self._edge_labels.remove(&e);
self._edge_objs.remove(&e);
if self._preds.contains_key(&w) {
decrement_or_remove_entry(self._preds.get_mut(&w).unwrap(), &v);
}
if self._sucs.contains_key(&v) {
decrement_or_remove_entry(self._sucs.get_mut(&v).unwrap(), &w);
}
if self._in.contains_key(&w) {
self._in.get_mut(&w).unwrap().remove(&e);
}
if self._out.contains_key(&v) {
self._out.get_mut(&v).unwrap().remove(&e);
}
self._edge_count -= 1;
}
return self;
}
/**
* Removes the specified edge from the graph. No subgraphs are considered.
* Complexity: O(1).
*/
pub fn remove_edge_with_obj(&mut self, e: &Edge) -> &mut Self {
self.remove_edge(&e.v, &e.w, None);
return self;
}
/**
* Return all edges that point to the node v. Optionally filters those edges down to just those
* coming from node u. Behavior is undefined for undirected graphs - use nodeEdges instead.
* Complexity: O(|E|).
*/
pub fn in_edges(&self, v: &String, u: Option<String>) -> Option<Vec<Edge>> {
if let Some(in_edges) = self._in.get(v) {
let mut _in_edges: Vec<Edge> = in_edges.values().cloned().collect();
if u.is_none() {
return Some(_in_edges.clone());
}
let _u = u.unwrap();
return Some(_in_edges.into_iter().filter(|edge| edge.v == _u).collect());
}
None
}
/**
* Return all edges that are pointed at by node v. Optionally filters those edges down to just
* those point to w. Behavior is undefined for undirected graphs - use nodeEdges instead.
* Complexity: O(|E|).
*/
pub fn out_edges(&self, v: &String, w: Option<String>) -> Option<Vec<Edge>> {
if let Some(out_edges) = self._out.get(v) {
let mut _out_edges: Vec<Edge> = out_edges.values().cloned().collect();
if w.is_none() {
return Some(_out_edges.clone());
}
let _w = w.unwrap();
return Some(_out_edges.into_iter().filter(|edge| edge.w == _w).collect());
}
None
}
/**
* Returns all edges to or from node v regardless of direction. Optionally filters those edges
* down to just those between nodes v and w regardless of direction.
* Complexity: O(|E|).
*/
pub fn node_edges(&self, v: &String, w: Option<String>) -> Option<Vec<Edge>> {
let _in_edges = self.in_edges(v, w.clone());
if let Some(mut in_edges) = _in_edges {
let _out_edges = self.out_edges(v, w.clone());
if let Some(out_edges) = _out_edges {
in_edges.append(out_edges.clone().as_mut());
}
return Some(in_edges);
}
None
}
}
fn increment_or_init_entry<K: Hash + Eq + Clone>(map: &mut OrderedHashMap<K, usize>, k: &K) {
if let Some(e) = map.get_mut(&k) {
*e += 1;
} else {
map.insert(k.clone(), 1);
}
}
fn decrement_or_remove_entry<K: Hash + Eq + Clone>(map: &mut OrderedHashMap<K, usize>, k: &K) {
if let Some(value) = map.get_mut(k) {
*value -= 1;
if *value <= 0 {
map.remove(k);
}
}
}
fn edge_args_to_id(is_directed: &bool, v_: &String, w_: &String, name: &Option<String>) -> String {
let mut v: &str = &*v_;
let mut w: &str = &*w_;
if !is_directed.to_owned() && v > w {
let tmp = v;
v = w;
w = tmp;
}
if name.is_some() {
return v.to_owned() + EDGE_KEY_DELIM + w + EDGE_KEY_DELIM + &*name.clone().unwrap();
}
return v.to_owned() + EDGE_KEY_DELIM + w + EDGE_KEY_DELIM + DEFAULT_EDGE_NAME;
}
fn edge_args_to_obj(is_directed: &bool, v_: &String, w_: &String, name: &Option<String>) -> Edge {
let mut v: &str = &*v_;
let mut w: &str = &*w_;
if !is_directed.to_owned() && v > w {
let tmp = v;
v = w;
w = tmp;
}
return Edge {
v: v.to_string(),
w: w.to_string(),
name: name.clone(),
};
}
fn edge_obj_to_id(is_directed: &bool, edge: &Edge) -> String {
return edge_args_to_id(is_directed, &edge.v, &edge.w, &edge.name);
}
fn find_parent<GL: Default, N: Default + Clone + Debug, E: Default + Clone + Debug>(
v: &String,
parents: &mut OrderedHashMap<String, String>,
copy: &mut Graph<GL, N, E>,
graph: &Graph<GL, N, E>,
) -> Option<String> {
let parent = graph.parent(v);
if parent.is_none() || copy._nodes.contains_key(&parent.unwrap().clone()) {
if !parent.is_none() {
parents.insert(v.clone(), parent.unwrap().clone());
return parent.cloned();
}
None
} else if let Some(parent_value) = parents.get(&parent.unwrap().clone()) {
Some(parent_value.clone())
} else {
if parent.is_some() {
find_parent(parent.as_ref().unwrap(), parents, copy, graph)
} else {
None
}
}
}
+10
View File
@@ -0,0 +1,10 @@
pub mod algo;
pub mod graph;
pub use graph::DefaultEdgeLabel;
pub use graph::DefaultNodeLabel;
pub use graph::Edge;
pub use graph::EdgeLabelOrString;
pub use graph::EdgeOrString;
pub use graph::Graph;
pub use graph::GraphOption;
+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);
}
}
+51
View File
@@ -0,0 +1,51 @@
# Vendored third-party crate.
#
# Upstream: ordered_hashmap 0.0.3 (Apache-2.0)
# Source of truth: https://crates.io/crates/ordered_hashmap/0.0.3
#
# Why vendored: transitive dependency of the vendored `graphlib_rust` and
# `dagre_rust` layout crates (the Warp `mermaid-to-svg` engine stack). It is a
# young single-author crate on the untrusted-input render path, so vendoring
# gives a full audit surface and immunity to upstream yanks. See
# crates/codegen/xai-grok-mermaid and third_party/mermaid-to-svg.
#
# ============================================================================
# VENDORING NOTES — re-apply / re-bless ALL of the following on every upgrade:
# ============================================================================
# Local modifications to the upstream source:
# 1. NOT VENDORED: the upstream `tests/` integration test (library-only
# vendoring; the tests add no layout/render coverage).
# 2. `cargo fmt` (rustfmt) applied to `src/lib.rs` so the crate satisfies the
# workspace fmt gate; no semantic change.
#
# Re-audit checklist:
# - UNSAFE: src/lib.rs has two `unsafe` blocks in `iter_mut`/`values_mut`
# (raw-pointer re-borrow to hand out `&mut` while iterating `keys`). Sound
# today (the keys vec and the map are disjoint fields, never aliased); the
# `[lints.clippy] all = allow` below does NOT relax unsafe review — re-verify
# on every upgrade.
[package]
name = "ordered_hashmap"
version = "0.0.3"
edition = "2021"
description = "Ordered HashMap preserving insertion order (vendored, library-only)"
license = "Apache-2.0"
repository = "https://github.com/r3alst/ordered-hashmap"
publish = false
[lib]
name = "ordered_hashmap"
path = "src/lib.rs"
doctest = false
[dependencies]
# Vendored third-party code is not restyled to our lint standards, so silence
# the lints it trips under the workspace's `-D warnings` (the source is kept
# verbatim — only this manifest carries the allows). Our own crate
# (xai-grok-mermaid) is still fully linted.
[lints.rust]
mismatched_lifetime_syntaxes = "allow"
[lints.clippy]
all = { level = "allow", priority = -1 }
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
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.
+333
View File
@@ -0,0 +1,333 @@
use std::collections::hash_map::Entry as StdEntry;
use std::collections::HashMap;
use std::hash::Hash;
/// A custom `OrderedHashMap` struct that maintains the order of keys.
/// It wraps a `Vec` to store keys and a `HashMap` to store key-value pairs.
#[derive(Clone, Debug, PartialEq)]
pub struct OrderedHashMap<K: Eq + Hash + Clone, V> {
pub keys: Vec<K>,
pub map: HashMap<K, V>,
}
impl<K: Eq + Hash + Clone, V> OrderedHashMap<K, V> {
/// Creates a new empty `OrderedHashMap`.
///
/// # Examples
///
/// ```
/// use ordered_hashmap::OrderedHashMap;
/// let ordered_map: OrderedHashMap<String, i32> = OrderedHashMap::new();
/// ```
pub fn new() -> Self {
OrderedHashMap {
keys: Vec::new(),
map: HashMap::new(),
}
}
/// Returns `true` if the map contains a value for the specified key.
///
/// # Examples
///
/// ```
/// use ordered_hashmap::OrderedHashMap;
/// let mut ordered_map = OrderedHashMap::new();
/// ordered_map.insert("key1".to_string(), 42);
/// assert!(ordered_map.contains_key(&"key1".to_string()));
/// ```
pub fn contains_key(&self, k: &K) -> bool {
self.map.contains_key(k)
}
/// Inserts a key-value pair into the map. If the map did not have this key present, `None` is returned.
/// If the map did have this key present, the value is updated, and the old value is returned.
///
/// # Examples
///
/// ```
/// use ordered_hashmap::OrderedHashMap;
/// let mut ordered_map = OrderedHashMap::new();
/// assert_eq!(ordered_map.insert("key1".to_string(), 42), None);
/// assert_eq!(ordered_map.insert("key1".to_string(), 99), Some(42));
/// ```
pub fn insert(&mut self, key: K, value: V) -> Option<V> {
if !self.map.contains_key(&key) {
self.keys.push(key.clone());
}
self.map.insert(key, value)
}
/// Removes a key from the map, returning the value at the key if the key was previously in the map.
///
/// # Examples
///
/// ```
/// use ordered_hashmap::OrderedHashMap;
/// let mut ordered_map = OrderedHashMap::new();
/// ordered_map.insert("key1".to_string(), 42);
/// assert_eq!(ordered_map.remove(&"key1".to_string()), Some(42));
/// ```
pub fn remove(&mut self, key: &K) -> Option<V> {
self.keys.retain(|k| k != key);
self.map.remove(key)
}
/// Gets the value of the specified key.
///
/// # Examples
///
/// ```
/// use ordered_hashmap::OrderedHashMap;
/// let mut ordered_map = OrderedHashMap::new();
/// ordered_map.insert("key1".to_string(), 42);
/// assert_eq!(ordered_map.get(&"key1".to_string()), Some(&42));
/// ```
pub fn get(&self, key: &K) -> Option<&V> {
self.map.get(key)
}
/// Gets a mutable reference to the value of the specified key.
//////
/// # Examples
///
/// ```
/// use ordered_hashmap::OrderedHashMap;
/// let mut ordered_map = OrderedHashMap::new();
/// ordered_map.insert("key1".to_string(), 42);
/// *ordered_map.get_mut(&"key1".to_string()).unwrap() += 1;
/// assert_eq!(ordered_map.get(&"key1".to_string()), Some(&43));
/// ```
pub fn get_mut(&mut self, key: &K) -> Option<&mut V> {
self.map.get_mut(key)
}
/// Returns an iterator over the values in the ordered hash map.
///
/// # Examples
///
/// ```
/// use ordered_hashmap::OrderedHashMap;
/// let mut ordered_map = OrderedHashMap::new();
/// ordered_map.insert("key1".to_string(), 42);
/// ordered_map.insert("key2".to_string(), 24);
/// let values: Vec<_> = ordered_map.values().collect();
/// assert_eq!(values, vec![&42, &24]);
/// ```
pub fn values(&self) -> impl Iterator<Item = &V> {
self.keys.iter().filter_map(|k| self.map.get(k))
}
/// Returns a mutable iterator over the values in the ordered hash map.
///
/// # Examples
///
/// ```
/// use ordered_hashmap::OrderedHashMap;
/// let mut ordered_map = OrderedHashMap::new();
/// ordered_map.insert("key1".to_string(), 42);
/// ordered_map.insert("key2".to_string(), 24);
/// ordered_map.values_mut().for_each(|value| *value += 1);
/// assert_eq!(ordered_map.get(&"key1".to_string()), Some(&43));
/// assert_eq!(ordered_map.get(&"key2".to_string()), Some(&25));
/// ```
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut V> {
let map_ptr = &mut self.map as *mut HashMap<K, V>;
self.keys
.iter()
.filter_map(move |k| unsafe { (*map_ptr).get_mut(k) })
}
/// Returns an iterator over the keys in the ordered hash map.
///
/// # Examples
///
/// ```
/// use ordered_hashmap::OrderedHashMap;
/// let mut ordered_map = OrderedHashMap::new();
/// ordered_map.insert("key1".to_string(), 42);
/// ordered_map.insert("key2".to_string(), 24);
/// let keys: Vec<_> = ordered_map.keys().collect();
/// assert_eq!(keys, vec![&"key1".to_string(), &"key2".to_string()]);
/// ```
pub fn keys(&self) -> impl Iterator<Item = &K> {
self.keys.iter()
}
/// Returns an iterator over the key-value pairs in the ordered hash map.
///
/// # Examples
///
/// ```
/// use ordered_hashmap::OrderedHashMap;
/// let mut ordered_map = OrderedHashMap::new();
/// ordered_map.insert("key1".to_string(), 42);
/// ordered_map.insert("key2".to_string(), 24);
/// let pairs: Vec<_> = ordered_map.iter().collect();
/// assert_eq!(pairs, vec![(&"key1".to_string(), &42), (&"key2".to_string(), &24)]);
/// ```
pub fn iter(&self) -> impl Iterator<Item = (&K, &V)> {
self.keys
.iter()
.filter_map(move |k| self.map.get(k).map(|v| (k, v)))
}
/// Returns an `Entry` for the given key, allowing for more complex manipulation of the stored values.
///
/// # Examples
///
/// ```
/// use ordered_hashmap::OrderedHashMap;
/// let mut ordered_map = OrderedHashMap::new();
/// ordered_map.insert("key1".to_string(), 42);
/// ordered_map.entry("key1".to_string()).or_insert(99);
/// assert_eq!(ordered_map.get(&"key1".to_string()), Some(&42));
/// ```
pub fn entry(&mut self, key: K) -> Entry<K, V> {
match self.map.entry(key.clone()) {
StdEntry::Occupied(occupied) => Entry::Occupied(occupied),
StdEntry::Vacant(vacant) => {
self.keys.push(key.clone());
Entry::Vacant(vacant)
}
}
}
/// Returns a mutable iterator over the key-value pairs in the ordered hash map.
///
/// # Examples
///
/// ```
/// use ordered_hashmap::OrderedHashMap;
/// let mut ordered_map = OrderedHashMap::new();
/// ordered_map.insert("key1".to_string(), 42);
/// ordered_map.insert("key2".to_string(), 24);
/// ordered_map.iter_mut().for_each(|(key, value)| *value += 1);
/// assert_eq!(ordered_map.get(&"key1".to_string()), Some(&43));
/// assert_eq!(ordered_map.get(&"key2".to_string()), Some(&25));
/// ```
pub fn iter_mut(&mut self) -> impl Iterator<Item = (&K, &mut V)> {
let map_ptr = &mut self.map as *mut HashMap<K, V>;
self.keys
.iter()
.filter_map(move |k| unsafe { (*map_ptr).get_mut(k).map(|v| (k, v)) })
}
/// Returns a vector of the values in the map, in the order corresponding to their keys.
///
/// # Examples
///
/// ```
/// use ordered_hashmap::OrderedHashMap;
///
/// let mut map = OrderedHashMap::new();
/// map.insert(1, "one");
/// map.insert(2, "two");
/// map.insert(3, "three");
///
/// let values = map.into_values();
/// assert_eq!(values, vec!["one", "two", "three"]);
/// ```
pub fn into_values(self) -> Vec<V> {
let mut extracted_items: Vec<(K, V)> = self.map.into_iter().collect();
self.keys
.into_iter()
.filter_map(move |k| {
if let Some(index) = extracted_items.iter().position(|(key, _)| *key == k) {
Some(extracted_items.remove(index).1)
} else {
None
}
})
.collect::<Vec<_>>()
}
/// Adds all key-value pairs from another `OrderedHashMap` to this one, without replacing any existing pairs.
///
/// # Examples
///
/// ```
/// use ordered_hashmap::OrderedHashMap;
///
/// let mut map1 = OrderedHashMap::new();
/// map1.insert(1, "one");
///
/// let mut map2 = OrderedHashMap::new();
/// map2.insert(2, "two");
///
/// map1.extend(map2);
///
/// assert_eq!(map1.get(&1), Some(&"one"));
/// assert_eq!(map1.get(&2), Some(&"two"));
/// ```
pub fn extend(&mut self, slice: OrderedHashMap<K, V>) {
slice.keys.into_iter().for_each(|k| {
if !self.keys.contains(&k) {
self.keys.push(k);
}
});
self.map.extend(slice.map);
}
/// Returns the number of key-value pairs in the map.
///
/// # Examples
///
/// ```
/// use ordered_hashmap::OrderedHashMap;
///
/// let mut map = OrderedHashMap::new();
/// assert_eq!(map.len(), 0);
///
/// map.insert(1, "one");
/// assert_eq!(map.len(), 1);
///
/// map.insert(2, "two");
/// assert_eq!(map.len(), 2);
/// ```
pub fn len(&self) -> usize {
self.map.len()
}
}
pub enum Entry<'a, K: 'a, V: 'a> {
Occupied(std::collections::hash_map::OccupiedEntry<'a, K, V>),
Vacant(std::collections::hash_map::VacantEntry<'a, K, V>),
}
impl<'a, K: Eq + Hash, V> Entry<'a, K, V> {
pub fn or_insert(self, default: V) -> &'a mut V {
match self {
Entry::Occupied(occupied) => occupied.into_mut(),
Entry::Vacant(vacant) => vacant.insert(default),
}
}
pub fn or_insert_with<F: FnOnce() -> V>(self, default: F) -> &'a mut V {
match self {
Entry::Occupied(occupied) => occupied.into_mut(),
Entry::Vacant(vacant) => vacant.insert(default()),
}
}
}
impl<K: Eq + Hash + Clone, V> IntoIterator for OrderedHashMap<K, V> {
type Item = (K, V);
type IntoIter = std::vec::IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter {
let mut extracted_items: Vec<(K, V)> = self.map.into_iter().collect();
self.keys
.into_iter()
.filter_map(move |k| {
if let Some(index) = extracted_items.iter().position(|(key, _)| *key == k) {
Some(extracted_items.remove(index))
} else {
None
}
})
.collect::<Vec<_>>()
.into_iter()
}
}