mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
markdown: Merman (#57644)
Big PR that replaces `mermaid-rs` with `merman`. Adds a new crate `mermaid_render` that exposes a simple API for rendering a mermaid diagram to an SVG string. ## Why is it so big? Some of this is explained in the crate-level docs for `mermaid_render`, but the short version is: - `mermaid-rs` emits "good enough" SVGs for most use cases. It also ships with a reasonable default theme - `merman` emits *very accurate* SVGs, but with borderline unusable CSS - Most of the new code in this PR are a series of passes to clean up the output SVG by: - Injecting good CSS - Fixing issues in the `merman`-generated SVGs - Tweaking the final result to avoid issues with `usvg` and `resvg`, which are what will eventually be used to rasterize the SVG - This code *could* be much smaller, but the following design decisions made it take a lot more code: - Using a real XML parser instead of basic string manipulation - Avoiding allocating strings in as many places as possible Because of this, the design is as follows: - First, construct a `merman` theme from the user's theme, and render the mermaid to an SVG string - Post-process - each step is roughly a `fn(Iterator<Item = Event<'short>>) -> Iterator<Item = Event<'short>>`, where `Event` is the type for events produced by `quick-xml` (a pull-based XML parser) ## Note for reviewers It's a big diff, sorry 😅 happy to pair review. The new crate is essentially a leaf crate - it does technically depend on `gpui`, but only for the `Hsla` and `Rgba` types. Extracting a new `gpui_color` crate felt like overkill for this already-very-big PR. Each post-process pass is in its own submodule, and has a doc comment explaining the before/after. Note that bugs in this code are perhaps less serious than bugs in other parts of the code: - The code has been thoroughly audited for potentially-panicking code paths - as far as I know, there are none (excluding some `.expect()`s on calls to `write!` with `String`, which is [cannot return `Err`][string write]) - A bug in this code (given that it will not cause a panic) will, at worst, result in an invalid diagram being rendered, or simply falling back to showing the code. - The current `mermaid-rs` renderer *already* does this in quite a lot of cases, sometimes showing outright misleading information. --- Some eye candy: | Before (`mermaid-rs`) | After (`merman`) | | - | - | | <img width="1227" height="340" alt="image" src="https://github.com/user-attachments/assets/58d6904e-64bc-478a-8d67-f75ad4ccbc9e" /> | <img width="1169" height="482" alt="image" src="https://github.com/user-attachments/assets/d4bb9cd5-240f-4bf6-ba7f-4862049ed8b0" /> | | <img width="842" height="564" alt="image" src="https://github.com/user-attachments/assets/1668a50d-68f6-4145-8cef-359e4c6a4589" /> | <img width="869" height="543" alt="image" src="https://github.com/user-attachments/assets/2ec1a7eb-fc3c-4392-b577-1ad52396b87c" /> | | Failed to render | <img width="822" height="1123" alt="image" src="https://github.com/user-attachments/assets/a97308a1-6b3a-48b6-9778-abf3507c6ad3" /> | | <img width="252" height="517" alt="image" src="https://github.com/user-attachments/assets/dbf86274-004a-4ee4-be89-cc6ff4f6cf35" /> | <img width="361" height="680" alt="image" src="https://github.com/user-attachments/assets/8457b6ed-3ca9-4bed-9496-60388ba08206" /> | | <img width="550" height="1050" alt="image" src="https://github.com/user-attachments/assets/c21b8513-fb86-422e-870a-015e0add783a" /> | <img width="819" height="1148" alt="image" src="https://github.com/user-attachments/assets/ca646165-302d-41aa-8da5-39e89c96ebb7" /> | | <img width="1218" height="225" alt="image" src="https://github.com/user-attachments/assets/3006a1bf-efe6-46f5-9f9d-289a3fdf9adc" /> | <img width="1118" height="965" alt="image" src="https://github.com/user-attachments/assets/90d76098-bf3d-4c69-bc9b-00dd9cbf6990" /> | | <img width="800" height="584" alt="image" src="https://github.com/user-attachments/assets/f688693b-df3d-4514-b105-ccaa3874e40c" /> | <img width="1153" height="417" alt="image" src="https://github.com/user-attachments/assets/eeb05f58-7184-4321-a47b-ea3cc53f0d02" /> | | <img width="539" height="464" alt="image" src="https://github.com/user-attachments/assets/0076105d-eef9-4011-9b9a-581918575e49" /> | <img width="638" height="556" alt="image" src="https://github.com/user-attachments/assets/72a51966-c296-48d0-b1e2-66835b1a0d5b" /> | | <img width="607" height="430" alt="image" src="https://github.com/user-attachments/assets/d3530814-532b-41ed-a2b6-fd5740d8db58" /> | <img width="725" height="489" alt="image" src="https://github.com/user-attachments/assets/c43687d9-f426-4cb8-8a45-23a3c0070c8f" /> | | <img width="869" height="577" alt="image" src="https://github.com/user-attachments/assets/b607f3fd-136c-4f41-a88e-596520e276b9" /> | <img width="784" height="586" alt="image" src="https://github.com/user-attachments/assets/22305a64-b4d0-474a-b6f6-8973f2bca933" /> | | <img width="1214" height="263" alt="image" src="https://github.com/user-attachments/assets/183ff0df-a6f3-470c-b271-8099c3e33044" /> | <img width="1195" height="632" alt="image" src="https://github.com/user-attachments/assets/868bcb7f-62c6-4d20-ba1e-3f25f8165fff" /> | | <img width="573" height="597" alt="image" src="https://github.com/user-attachments/assets/6414e409-b879-4892-8ef6-89489219b56f" /> | <img width="578" height="608" alt="image" src="https://github.com/user-attachments/assets/8ac71b03-f603-4e32-87aa-b993317d4e29" /> | | <img width="543" height="472" alt="image" src="https://github.com/user-attachments/assets/1f19929c-cf07-4287-b9cf-b976eea9faaa" /> | <img width="760" height="599" alt="image" src="https://github.com/user-attachments/assets/73926075-db47-4eb1-854e-7aee30522684" /> | | <img width="1205" height="219" alt="image" src="https://github.com/user-attachments/assets/5f3dc936-4b89-44f4-a00a-4dbc47e06504" /> | <img width="1133" height="349" alt="image" src="https://github.com/user-attachments/assets/e62901ff-9d75-4fde-a30e-974ff2e783b1" /> | Release Notes: - Improved: Mermaid diagrams now render faster and more accurately [string write]: https://doc.rust-lang.org/src/alloc/string.rs.html#3342-3354 --------- Co-authored-by: Danilo Leal <daniloleal09@gmail.com> Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
This commit is contained in:
parent
aa6062edaf
commit
63f725e8d6
24 changed files with 4068 additions and 185 deletions
788
Cargo.lock
generated
788
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -130,6 +130,7 @@ members = [
|
|||
"crates/lsp",
|
||||
"crates/markdown",
|
||||
"crates/markdown_preview",
|
||||
"crates/mermaid_render",
|
||||
"crates/media",
|
||||
"crates/menu",
|
||||
"crates/migrator",
|
||||
|
|
@ -389,10 +390,10 @@ lmstudio = { path = "crates/lmstudio" }
|
|||
lsp = { path = "crates/lsp" }
|
||||
markdown = { path = "crates/markdown" }
|
||||
markdown_preview = { path = "crates/markdown_preview" }
|
||||
mermaid_render = { path = "crates/mermaid_render" }
|
||||
svg_preview = { path = "crates/svg_preview" }
|
||||
media = { path = "crates/media" }
|
||||
menu = { path = "crates/menu" }
|
||||
mermaid-rs-renderer = { git = "https://github.com/zed-industries/mermaid-rs-renderer", rev = "782b89a7da3f0e91e51f98d00a93acba679be6fb", default-features = false }
|
||||
migrator = { path = "crates/migrator" }
|
||||
mistral = { path = "crates/mistral" }
|
||||
multi_buffer = { path = "crates/multi_buffer" }
|
||||
|
|
|
|||
|
|
@ -23,15 +23,13 @@ graph TD
|
|||
A[Start] --> B[End]
|
||||
```
|
||||
|
||||
The renderer supports the following diagram types: flowchart, sequence, class, state, ER, gantt, pie, gitgraph, mindmap, timeline, quadrant chart, xy chart, and journey. Other diagram types will only show as code.
|
||||
|
||||
Mermaid diagrams are automatically themed to match the user's editor theme. Do not include `%%{init}%%` directives or define your own `classDef` styles.
|
||||
|
||||
Do *NOT* include inline HTML elements in mermaid diagrams, as they cannot be rendered. It is better to simply skip formatting (e.g. bold/italic/etc.).
|
||||
|
||||
When you need accent colors for emphasis (e.g. color-coding layers, categories, or states), use the pre-defined classes `accent0` through `accent7` with the `:::` syntax:
|
||||
|
||||
A:::accent0 --> B:::accent1 --> C:::accent2
|
||||
|
||||
These classes automatically match the user's theme. Do not hardcode hex color values unless an exact color match is specifically required. Note that the rendered view may be narrow, so try to prioritize generating taller diagrams over wider ones.
|
||||
Mermaid diagrams are automatically color-coded using the user's theme accent palette. Do not hardcode hex color values unless an exact color match is specifically required. Note that the rendered view may be narrow, so try to prioritize generating taller diagrams over wider ones.
|
||||
|
||||
{{#if (gt (len available_tools) 0)}}
|
||||
## Tool Use
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ language.workspace = true
|
|||
linkify.workspace = true
|
||||
log.workspace = true
|
||||
markup5ever_rcdom.workspace = true
|
||||
mermaid-rs-renderer.workspace = true
|
||||
mermaid_render = { path = "../mermaid_render" }
|
||||
pulldown-cmark.workspace = true
|
||||
settings.workspace = true
|
||||
stacksafe.workspace = true
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ use gpui::{
|
|||
FocusHandle, Focusable, FontStyle, FontWeight, GlobalElementId, Hitbox, Hsla, Image,
|
||||
ImageFormat, ImageSource, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent,
|
||||
MouseMoveEvent, MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle,
|
||||
StyleRefinement, StyledImage, StyledText, Task, TextAlign, TextLayout, TextRun, TextStyle,
|
||||
TextStyleRefinement, actions, img, point, quad,
|
||||
StyleRefinement, StyledImage, StyledText, Subscription, Task, TextAlign, TextLayout, TextRun,
|
||||
TextStyle, TextStyleRefinement, actions, img, point, quad,
|
||||
};
|
||||
use language::{CharClassifier, Language, LanguageRegistry, Rope};
|
||||
use parser::CodeBlockMetadata;
|
||||
|
|
@ -333,6 +333,7 @@ pub struct Markdown {
|
|||
fallback_code_block_language: Option<LanguageName>,
|
||||
options: MarkdownOptions,
|
||||
mermaid_state: MermaidState,
|
||||
_mermaid_theme_subscription: Option<Subscription>,
|
||||
mermaid_showing_code: HashSet<usize>,
|
||||
copied_code_blocks: HashSet<ElementId>,
|
||||
wrapped_code_blocks: HashSet<usize>,
|
||||
|
|
@ -497,6 +498,16 @@ impl Markdown {
|
|||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
|
||||
let theme_subscription = if options.render_mermaid_diagrams {
|
||||
Some(
|
||||
cx.observe_global::<theme::GlobalTheme>(|this: &mut Self, cx| {
|
||||
this.invalidate_mermaid_cache(cx);
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut this = Self {
|
||||
source,
|
||||
selection: Selection::default(),
|
||||
|
|
@ -513,6 +524,7 @@ impl Markdown {
|
|||
fallback_code_block_language,
|
||||
options,
|
||||
mermaid_state: MermaidState::default(),
|
||||
_mermaid_theme_subscription: theme_subscription,
|
||||
mermaid_showing_code: HashSet::default(),
|
||||
copied_code_blocks: HashSet::default(),
|
||||
wrapped_code_blocks: HashSet::default(),
|
||||
|
|
@ -561,15 +573,15 @@ impl Markdown {
|
|||
.retain(|id, _| ids.contains(id));
|
||||
}
|
||||
|
||||
/// Used in the agent panel to force a re-render when the theme changes
|
||||
pub fn invalidate_mermaid_cache(&mut self, cx: &mut Context<Self>) {
|
||||
if self.options.render_mermaid_diagrams && !self.parsed_markdown.mermaid_diagrams.is_empty()
|
||||
if !self.options.render_mermaid_diagrams || self.parsed_markdown.mermaid_diagrams.is_empty()
|
||||
{
|
||||
self.mermaid_state.clear();
|
||||
let parsed_markdown = self.parsed_markdown.clone();
|
||||
self.mermaid_state.update(&parsed_markdown, cx);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
self.mermaid_state.clear();
|
||||
self.mermaid_state.update(&self.parsed_markdown, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn is_mermaid_showing_code(&self, source_offset: usize) -> bool {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use collections::HashMap;
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyElement, ClickEvent, ClipboardItem, Context, Entity, Hsla,
|
||||
ImageSource, RenderImage, Rgba, StyledText, Task, img, pulsating_between,
|
||||
Animation, AnimationExt, AnyElement, ClickEvent, ClipboardItem, Context, Entity, ImageSource,
|
||||
RenderImage, StyledText, Task, img, pulsating_between,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use std::ops::Range;
|
||||
|
|
@ -104,18 +104,12 @@ impl CachedMermaidDiagram {
|
|||
let render_image_clone = render_image.clone();
|
||||
let svg_renderer = cx.svg_renderer();
|
||||
let mermaid_theme = build_mermaid_theme(cx);
|
||||
let accent_classdefs = build_accent_classdefs(cx);
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
let value = cx
|
||||
.background_spawn(async move {
|
||||
let options = mermaid_rs_renderer::RenderOptions {
|
||||
theme: mermaid_theme,
|
||||
layout: mermaid_rs_renderer::LayoutConfig::default(),
|
||||
};
|
||||
let full_source = format!("{}\n{}", contents.contents, accent_classdefs);
|
||||
let svg_string =
|
||||
mermaid_rs_renderer::render_with_options(&full_source, options)?;
|
||||
mermaid_render::render_to_svg(&contents.contents, &mermaid_theme)?;
|
||||
let scale = contents.scale as f32 / 100.0;
|
||||
svg_renderer
|
||||
.render_single_frame(svg_string.as_bytes(), scale)
|
||||
|
|
@ -153,128 +147,71 @@ impl CachedMermaidDiagram {
|
|||
}
|
||||
}
|
||||
|
||||
/// Converts an HSLA color to a CSS hex string (e.g. `#1a2b3c`).
|
||||
fn hsla_to_hex(color: Hsla) -> String {
|
||||
let rgba: Rgba = color.to_rgb();
|
||||
let r = (rgba.r * 255.0).round() as u8;
|
||||
let g = (rgba.g * 255.0).round() as u8;
|
||||
let b = (rgba.b * 255.0).round() as u8;
|
||||
format!("#{r:02x}{g:02x}{b:02x}")
|
||||
/// Merman has somewhat limited text measurement capabilities.
|
||||
///
|
||||
/// When it doesn't have metrics for any of the specified fonts, it chooses a
|
||||
/// fairly narrow width, which causes visible overflow. Adding `sans-serif`
|
||||
/// allows it to fall back to a more conservative (i.e. wider) measurement.
|
||||
///
|
||||
/// This isn't perfect - very wide fonts will likely still cause overflow. A
|
||||
/// proper fix would involve somehow piping `resvg`'s actual measurements into
|
||||
/// `merman`, but that is a lot of work for a fairly uncommon edge case.
|
||||
fn mermaid_font_family(font_family: &str) -> String {
|
||||
let font_family = gpui::font_name_with_fallbacks(font_family, "system-ui");
|
||||
if font_family
|
||||
.split(',')
|
||||
.any(|family| family.trim().eq_ignore_ascii_case("sans-serif"))
|
||||
{
|
||||
font_family.to_string()
|
||||
} else {
|
||||
format!("{font_family}, sans-serif")
|
||||
}
|
||||
}
|
||||
|
||||
fn mermaid_font_family(font_family: &str) -> &str {
|
||||
gpui::font_name_with_fallbacks(font_family, "system-ui")
|
||||
}
|
||||
|
||||
fn build_mermaid_theme(cx: &Context<Markdown>) -> mermaid_rs_renderer::Theme {
|
||||
fn build_mermaid_theme(cx: &Context<Markdown>) -> mermaid_render::MermaidTheme {
|
||||
let colors = cx.theme().colors();
|
||||
let theme_settings = ThemeSettings::get_global(cx);
|
||||
let mut theme = mermaid_rs_renderer::Theme::modern();
|
||||
|
||||
theme.font_family = mermaid_font_family(theme_settings.ui_font.family.as_ref()).to_string();
|
||||
theme.background = hsla_to_hex(colors.editor_background);
|
||||
theme.primary_color = hsla_to_hex(colors.surface_background);
|
||||
theme.primary_text_color = hsla_to_hex(colors.text);
|
||||
theme.primary_border_color = hsla_to_hex(colors.border);
|
||||
theme.line_color = hsla_to_hex(colors.border);
|
||||
theme.secondary_color = hsla_to_hex(colors.element_background);
|
||||
theme.tertiary_color = hsla_to_hex(colors.ghost_element_hover);
|
||||
theme.edge_label_background = hsla_to_hex(colors.editor_background);
|
||||
theme.cluster_background = hsla_to_hex(colors.panel_background);
|
||||
theme.cluster_border = hsla_to_hex(colors.border_variant);
|
||||
theme.text_color = hsla_to_hex(colors.text);
|
||||
let accents = cx.theme().accents();
|
||||
let pie_colors: [String; 12] =
|
||||
std::array::from_fn(|i| hsla_to_hex(accents.color_for_index(i as u32)));
|
||||
theme.pie_colors = pie_colors;
|
||||
theme.pie_title_text_color = hsla_to_hex(colors.text);
|
||||
theme.pie_section_text_color = "#fff".to_string();
|
||||
theme.pie_legend_text_color = hsla_to_hex(colors.text);
|
||||
theme.pie_stroke_color = hsla_to_hex(colors.border);
|
||||
theme.pie_outer_stroke_color = hsla_to_hex(colors.border);
|
||||
|
||||
theme.sequence_actor_fill = hsla_to_hex(colors.element_background);
|
||||
theme.sequence_actor_border = hsla_to_hex(colors.border);
|
||||
theme.sequence_actor_line = hsla_to_hex(colors.border);
|
||||
theme.sequence_note_fill = hsla_to_hex(colors.surface_background);
|
||||
theme.sequence_note_border = hsla_to_hex(colors.border_variant);
|
||||
theme.sequence_activation_fill = hsla_to_hex(colors.ghost_element_hover);
|
||||
theme.sequence_activation_border = hsla_to_hex(colors.border);
|
||||
let is_dark = !cx.theme().appearance.is_light();
|
||||
|
||||
let players = cx.theme().players();
|
||||
theme.git_colors = std::array::from_fn(|i| hsla_to_hex(players.0[i % players.0.len()].cursor));
|
||||
theme.git_inv_colors =
|
||||
std::array::from_fn(|i| hsla_to_hex(players.0[i % players.0.len()].background));
|
||||
theme.git_branch_label_colors = std::array::from_fn(|_| "#fff".to_string());
|
||||
theme.git_commit_label_color = hsla_to_hex(colors.text);
|
||||
theme.git_commit_label_background = hsla_to_hex(colors.element_background);
|
||||
theme.git_tag_label_color = hsla_to_hex(colors.text);
|
||||
theme.git_tag_label_background = hsla_to_hex(colors.element_background);
|
||||
theme.git_tag_label_border = hsla_to_hex(colors.border);
|
||||
let git_branch_colors = std::array::from_fn(|i| players.0[i % players.0.len()].cursor);
|
||||
let git_branch_label_colors = git_branch_colors.map(mermaid_render::text_color_for_background);
|
||||
|
||||
theme
|
||||
mermaid_render::MermaidTheme {
|
||||
dark_mode: is_dark,
|
||||
font_family: mermaid_font_family(theme_settings.ui_font.family.as_ref()),
|
||||
background: colors.editor_background,
|
||||
primary_color: colors.surface_background,
|
||||
primary_text_color: colors.text,
|
||||
primary_border_color: colors.border,
|
||||
secondary_color: colors.element_background,
|
||||
tertiary_color: colors.ghost_element_hover,
|
||||
line_color: colors.border,
|
||||
text_color: colors.text,
|
||||
edge_label_background: colors.editor_background,
|
||||
cluster_background: colors.panel_background,
|
||||
cluster_border: colors.border_variant,
|
||||
note_background: colors.surface_background,
|
||||
note_border: colors.border_variant,
|
||||
actor_background: colors.element_background,
|
||||
actor_border: colors.border,
|
||||
activation_background: colors.ghost_element_hover,
|
||||
activation_border: colors.border,
|
||||
git_branch_colors,
|
||||
git_branch_label_colors,
|
||||
er_attr_bg_odd: colors.surface_background,
|
||||
er_attr_bg_even: colors.element_background,
|
||||
error_color: cx.theme().status().error,
|
||||
warning_color: cx.theme().status().warning,
|
||||
accent_colors: players
|
||||
.0
|
||||
.iter()
|
||||
.map(|player| mermaid_render::AccentColor {
|
||||
foreground: player.cursor,
|
||||
background: player.background,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
|
||||
fn build_accent_classdefs(cx: &Context<Markdown>) -> String {
|
||||
use std::fmt::Write;
|
||||
let players = &cx.theme().players();
|
||||
let is_light = cx.theme().appearance.is_light();
|
||||
let mut defs = String::new();
|
||||
for (i, player) in players.0.iter().enumerate() {
|
||||
let (fill, text_color) = accent_fill_and_text(player.background, is_light);
|
||||
let fill = hsla_to_hex(fill);
|
||||
let stroke = hsla_to_hex(player.cursor);
|
||||
let text_color = hsla_to_hex(text_color);
|
||||
writeln!(
|
||||
defs,
|
||||
"classDef accent{i} fill:{fill},stroke:{stroke},color:{text_color}"
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
defs
|
||||
}
|
||||
|
||||
/// Adjusts an accent fill color to ensure readable text contrast.
|
||||
///
|
||||
/// On dark themes, darkens the fill and uses white text.
|
||||
/// On light themes, lightens the fill and uses black text.
|
||||
/// The fill is adjusted until it meets a minimum WCAG contrast ratio
|
||||
/// of ~4.5:1 against the chosen text color.
|
||||
fn accent_fill_and_text(color: Hsla, is_light: bool) -> (Hsla, Hsla) {
|
||||
let mut fill = color;
|
||||
if is_light {
|
||||
// Lighten fill until luminance is high enough for black text.
|
||||
// Target: relative luminance >= 0.35 → contrast ratio ~8:1 with black.
|
||||
for _ in 0..50 {
|
||||
if relative_luminance(fill) >= 0.35 {
|
||||
break;
|
||||
}
|
||||
fill.l = (fill.l + 0.02).min(1.0);
|
||||
}
|
||||
(fill, gpui::black())
|
||||
} else {
|
||||
// Darken fill until luminance is low enough for white text.
|
||||
// Target: relative luminance <= 0.18 → contrast ratio ~4.6:1 with white.
|
||||
for _ in 0..50 {
|
||||
if relative_luminance(fill) <= 0.18 {
|
||||
break;
|
||||
}
|
||||
fill.l = (fill.l - 0.02).max(0.0);
|
||||
}
|
||||
(fill, gpui::white())
|
||||
}
|
||||
}
|
||||
|
||||
fn relative_luminance(color: Hsla) -> f32 {
|
||||
let rgba: Rgba = color.to_rgb();
|
||||
fn linearize(c: f32) -> f32 {
|
||||
if c <= 0.04045 {
|
||||
c / 12.92
|
||||
} else {
|
||||
((c + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
}
|
||||
0.2126 * linearize(rgba.r) + 0.7152 * linearize(rgba.g) + 0.0722 * linearize(rgba.b)
|
||||
}
|
||||
|
||||
fn parse_mermaid_info(info: &str) -> Option<u32> {
|
||||
|
|
@ -292,6 +229,38 @@ fn parse_mermaid_info(info: &str) -> Option<u32> {
|
|||
)
|
||||
}
|
||||
|
||||
/// We deliberately block rendering of some diagram types, even though `merman`
|
||||
/// supports them, because we have not yet written custom CSS to ensure text is
|
||||
/// readable.
|
||||
fn is_supported_diagram_type(source: &str) -> bool {
|
||||
/// If updating this list, also update the system prompt!
|
||||
const SUPPORTED_PREFIXES: &[&str] = &[
|
||||
"flowchart",
|
||||
"graph",
|
||||
"sequenceDiagram",
|
||||
"classDiagram",
|
||||
"stateDiagram",
|
||||
"stateDiagram-v2",
|
||||
"erDiagram",
|
||||
"gantt",
|
||||
"pie",
|
||||
"gitGraph",
|
||||
"mindmap",
|
||||
"timeline",
|
||||
"quadrantChart",
|
||||
"xychart-beta",
|
||||
"journey",
|
||||
];
|
||||
let first_token = source
|
||||
.trim_start()
|
||||
.split(|c: char| c.is_whitespace() || c == '\n')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
SUPPORTED_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| first_token.eq_ignore_ascii_case(prefix))
|
||||
}
|
||||
|
||||
pub(crate) fn extract_mermaid_diagrams(
|
||||
source: &str,
|
||||
events: &[(Range<usize>, MarkdownEvent)],
|
||||
|
|
@ -324,6 +293,9 @@ pub(crate) fn extract_mermaid_diagrams(
|
|||
.strip_suffix('\n')
|
||||
.unwrap_or(&source[metadata.content_range.clone()])
|
||||
.to_string();
|
||||
if !is_supported_diagram_type(&contents) {
|
||||
continue;
|
||||
}
|
||||
mermaid_diagrams.insert(
|
||||
source_range.start,
|
||||
ParsedMarkdownMermaidDiagram {
|
||||
|
|
@ -588,24 +560,10 @@ mod tests {
|
|||
MarkdownStyle, WrapButtonVisibility,
|
||||
};
|
||||
use collections::HashMap;
|
||||
use gpui::{Context, Hsla, IntoElement, Render, RenderImage, TestAppContext, Window, size};
|
||||
use gpui::{Context, IntoElement, Render, RenderImage, TestAppContext, Window, size};
|
||||
use std::sync::Arc;
|
||||
use ui::prelude::*;
|
||||
|
||||
#[gpui::property_test]
|
||||
fn accent_fill_and_text_sufficient_contrast(
|
||||
#[strategy = Hsla::opaque_strategy()] color: Hsla,
|
||||
light_mode: bool,
|
||||
) {
|
||||
let (fill, text) = super::accent_fill_and_text(color, light_mode);
|
||||
let fill_luminance = super::relative_luminance(fill);
|
||||
let text_luminance = super::relative_luminance(text);
|
||||
let lighter = fill_luminance.max(text_luminance);
|
||||
let darker = fill_luminance.min(text_luminance);
|
||||
let contrast_ratio = (lighter + 0.05) / (darker + 0.05);
|
||||
assert!(contrast_ratio >= 4.5,);
|
||||
}
|
||||
|
||||
fn ensure_theme_initialized(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
if !cx.has_global::<settings::SettingsStore>() {
|
||||
|
|
@ -693,11 +651,27 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_mermaid_font_family_resolves_zed_virtual_fonts() {
|
||||
assert_eq!(super::mermaid_font_family(".ZedSans"), "IBM Plex Sans");
|
||||
assert_eq!(super::mermaid_font_family("Zed Plex Sans"), "IBM Plex Sans");
|
||||
assert_eq!(super::mermaid_font_family(".ZedMono"), "Lilex");
|
||||
assert_eq!(super::mermaid_font_family(".SystemUIFont"), "system-ui");
|
||||
assert_eq!(super::mermaid_font_family("Custom Font"), "Custom Font");
|
||||
assert_eq!(
|
||||
super::mermaid_font_family(".ZedSans"),
|
||||
"IBM Plex Sans, sans-serif"
|
||||
);
|
||||
assert_eq!(
|
||||
super::mermaid_font_family("Zed Plex Sans"),
|
||||
"IBM Plex Sans, sans-serif"
|
||||
);
|
||||
assert_eq!(super::mermaid_font_family(".ZedMono"), "Lilex, sans-serif");
|
||||
assert_eq!(
|
||||
super::mermaid_font_family(".SystemUIFont"),
|
||||
"system-ui, sans-serif"
|
||||
);
|
||||
assert_eq!(
|
||||
super::mermaid_font_family("Custom Font"),
|
||||
"Custom Font, sans-serif"
|
||||
);
|
||||
assert_eq!(
|
||||
super::mermaid_font_family("Custom Font, sans-serif"),
|
||||
"Custom Font, sans-serif"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -721,6 +695,27 @@ mod tests {
|
|||
assert_eq!(diagram.contents.scale, 150);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unsupported_diagram_types_are_skipped() {
|
||||
let markdown = concat!(
|
||||
"```mermaid\nsankey-beta\n```\n\n",
|
||||
"```mermaid\nblock-beta\n```\n\n",
|
||||
"```mermaid\nflowchart TD\n A --> B\n```",
|
||||
);
|
||||
let events = crate::parser::parse_markdown_with_options(markdown, false, false).events;
|
||||
let diagrams = extract_mermaid_diagrams(markdown, &events);
|
||||
assert_eq!(
|
||||
diagrams.len(),
|
||||
1,
|
||||
"Only the flowchart should be extracted; sankey and block should be skipped"
|
||||
);
|
||||
let diagram = diagrams.values().next().unwrap();
|
||||
assert!(
|
||||
diagram.contents.contents.contains("flowchart"),
|
||||
"The extracted diagram should be the flowchart"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_mermaid_fallback_on_edit(cx: &mut TestAppContext) {
|
||||
let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]);
|
||||
|
|
|
|||
27
crates/mermaid_render/Cargo.toml
Normal file
27
crates/mermaid_render/Cargo.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[package]
|
||||
name = "mermaid_render"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/mermaid_render.rs"
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
gpui.workspace = true
|
||||
merman = { version = "0.4", features = ["render"] }
|
||||
quick-xml.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
mermaid_render = { path = ".", features = ["test-support"] }
|
||||
1
crates/mermaid_render/LICENSE-GPL
Symbolic link
1
crates/mermaid_render/LICENSE-GPL
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../LICENSE-GPL
|
||||
181
crates/mermaid_render/src/mermaid_render.rs
Normal file
181
crates/mermaid_render/src/mermaid_render.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// for a very big json! macro
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
//! Crate for rendering Mermaid diagram strings to SVG strings.
|
||||
//!
|
||||
//! The entrypoint to this crate is [`render_to_svg`].
|
||||
//!
|
||||
//! It takes a `&str` and a [`MermaidTheme`]. The output is an SVG with the
|
||||
//! following properties:
|
||||
//! - The style matches the provided theme
|
||||
//! - Nodes are given accent colors, even if none are provided in the mermaid
|
||||
//! source.
|
||||
//! - The SVG has been tweaked based on the assumption that it will be rasterized
|
||||
//! using `usvg`/`resvg`. Some bugs/quirks of `usvg`/`resvg` are accounted for
|
||||
//! in this crate.
|
||||
//!
|
||||
//! This module uses the [`merman`] crate for rendering, rather than
|
||||
//! `mermaid-rs`, which was used in the previous implementation of mermaid
|
||||
//! rendering in Zed. Merman provides significantly more accurate rendering, and
|
||||
//! seems to be somewhat faster, but by default has poor CSS, making diagrams
|
||||
//! look weird without significant cleanup. This is made worse by the fact that
|
||||
//! `usvg`/`resvg` doesn't support some features that [`merman`] relies on.
|
||||
//!
|
||||
//! As such, this crate is quite large. But the code is very self-contained, and
|
||||
//! has few dependencies. In fact, the [`gpui`] dependency is only needed for
|
||||
//! the [`Hsla`] and [`Rgba`] color types.
|
||||
//!
|
||||
//! The [`render_to_svg`] function operates in two stages:
|
||||
//! - [`render`] the mermaid text to SVG using [`merman`].
|
||||
//! - [`postprocess`] the SVG to clean incorrect output and add styling.
|
||||
//!
|
||||
//! The postprocessing is also split up into stages. We parse the generated SVG
|
||||
//! using [`quick_xml`], which produces an iterator of
|
||||
//! [`Event<'_>`](quick_xml::events::Event)s. This iterator is then repeatedly
|
||||
//! transformed, and finally collected back into an SVG string.
|
||||
//!
|
||||
//! This approach:
|
||||
//! - Avoids doing multiple expensive string insertions.
|
||||
//! - Avoids parsing the SVG multiple times (without needing to put all the
|
||||
//! logic in one huge function).
|
||||
//! - But is quite a bit more complex.
|
||||
//!
|
||||
//! I think this complexity is justified because of the drastic performance
|
||||
//! impact, as well as the low-risk nature; this code cannot panic, and errors
|
||||
//! in the output just produce weird-looking diagrams.
|
||||
//!
|
||||
//! ## Color handling
|
||||
//!
|
||||
//! We try to match the users theme, and also apply accent colors to diagrams to
|
||||
//! make them more visually interesting. Accent colors are derived from the
|
||||
//! `player_colors` in the Zed theme.
|
||||
//!
|
||||
//! There are three parts to color handling:
|
||||
//!
|
||||
//! 1. A [`merman::MermaidConfig`] is passed when initially rendering the
|
||||
//! diagram. This sets most "normal" colors (background, text, etc.). However,
|
||||
//! it's not possible to color nodes individually, and not all parts of the
|
||||
//! diagrams are correctly themed.
|
||||
//! 2. `postprocess::accent_colors` injects custom CSS classes (e.g.
|
||||
//! `zed-accent-0`) to specific elements, based on the diagram type and
|
||||
//! node.
|
||||
//! 3. `postprocess::inject_css` injects CSS rules for the classes applied by
|
||||
//! `accent_colors`
|
||||
|
||||
mod postprocess;
|
||||
mod render;
|
||||
|
||||
use anyhow::Result;
|
||||
use gpui::{Hsla, Rgba};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AccentColor {
|
||||
pub foreground: Hsla,
|
||||
pub background: Hsla,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MermaidTheme {
|
||||
pub dark_mode: bool,
|
||||
pub font_family: String,
|
||||
pub background: Hsla,
|
||||
pub primary_color: Hsla,
|
||||
pub primary_text_color: Hsla,
|
||||
pub primary_border_color: Hsla,
|
||||
pub secondary_color: Hsla,
|
||||
pub tertiary_color: Hsla,
|
||||
pub line_color: Hsla,
|
||||
pub text_color: Hsla,
|
||||
pub edge_label_background: Hsla,
|
||||
pub cluster_background: Hsla,
|
||||
pub cluster_border: Hsla,
|
||||
pub note_background: Hsla,
|
||||
pub note_border: Hsla,
|
||||
pub actor_background: Hsla,
|
||||
pub actor_border: Hsla,
|
||||
pub activation_background: Hsla,
|
||||
pub activation_border: Hsla,
|
||||
pub git_branch_colors: [Hsla; 8],
|
||||
pub git_branch_label_colors: [Hsla; 8],
|
||||
pub er_attr_bg_odd: Hsla,
|
||||
pub er_attr_bg_even: Hsla,
|
||||
pub error_color: Hsla,
|
||||
pub warning_color: Hsla,
|
||||
pub accent_colors: Vec<AccentColor>,
|
||||
}
|
||||
|
||||
/// Default theme for testing.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl Default for MermaidTheme {
|
||||
fn default() -> Self {
|
||||
use gpui::{hsla, rgb};
|
||||
let git_branch_colors: [Hsla; 8] = [
|
||||
hsla(240.0 / 360.0, 1.0, 0.462_745_1, 1.0),
|
||||
hsla(60.0 / 360.0, 1.0, 0.435_294_12, 1.0),
|
||||
hsla(80.0 / 360.0, 1.0, 0.462_745_1, 1.0),
|
||||
hsla(210.0 / 360.0, 1.0, 0.462_745_1, 1.0),
|
||||
hsla(180.0 / 360.0, 1.0, 0.462_745_1, 1.0),
|
||||
hsla(150.0 / 360.0, 1.0, 0.462_745_1, 1.0),
|
||||
hsla(300.0 / 360.0, 1.0, 0.462_745_1, 1.0),
|
||||
hsla(0.0, 1.0, 0.462_745_1, 1.0),
|
||||
];
|
||||
let git_branch_label_colors: [Hsla; 8] =
|
||||
git_branch_colors.map(crate::text_color_for_background);
|
||||
|
||||
Self {
|
||||
dark_mode: false,
|
||||
font_family: "Inter, ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", \"DejaVu Sans\", \"Liberation Sans\", sans-serif, \"Noto Color Emoji\", \"Apple Color Emoji\", \"Segoe UI Emoji\"".to_string(),
|
||||
background: rgb(0xFFFFFF).into(),
|
||||
primary_color: rgb(0xF8FAFC).into(),
|
||||
primary_text_color: rgb(0x0F172A).into(),
|
||||
primary_border_color: rgb(0x94A3B8).into(),
|
||||
secondary_color: rgb(0xE2E8F0).into(),
|
||||
tertiary_color: rgb(0xFFFFFF).into(),
|
||||
line_color: rgb(0x64748B).into(),
|
||||
text_color: rgb(0x0F172A).into(),
|
||||
edge_label_background: rgb(0xFFFFFF).into(),
|
||||
cluster_background: rgb(0xF1F5F9).into(),
|
||||
cluster_border: rgb(0xCBD5E1).into(),
|
||||
note_background: rgb(0xFFF7ED).into(),
|
||||
note_border: rgb(0xFDBA74).into(),
|
||||
actor_background: rgb(0xF8FAFC).into(),
|
||||
actor_border: rgb(0x94A3B8).into(),
|
||||
activation_background: rgb(0xE2E8F0).into(),
|
||||
activation_border: rgb(0x94A3B8).into(),
|
||||
git_branch_colors,
|
||||
git_branch_label_colors,
|
||||
er_attr_bg_odd: rgb(0x94A3B8).into(),
|
||||
er_attr_bg_even: rgb(0x0F172A).into(),
|
||||
error_color: rgb(0xDC2626).into(),
|
||||
warning_color: rgb(0xD97706).into(),
|
||||
accent_colors: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a color as a CSS hex color for embedding in SVG/CSS.
|
||||
///
|
||||
/// Emits `#rrggbb` for fully opaque colors and `#rrggbbaa` when the input
|
||||
/// has any transparency, so translucent theme colors (e.g. `ghost_element_hover`
|
||||
/// from Zed's UI palette) round-trip without silently losing their alpha.
|
||||
pub(crate) fn css_color(color: Hsla) -> String {
|
||||
let rgba = Rgba::from(color);
|
||||
let r = (rgba.r.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let g = (rgba.g.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let b = (rgba.b.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let a = (rgba.a.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
if a == 0xff {
|
||||
format!("#{r:02x}{g:02x}{b:02x}")
|
||||
} else {
|
||||
format!("#{r:02x}{g:02x}{b:02x}{a:02x}")
|
||||
}
|
||||
}
|
||||
|
||||
pub use postprocess::util::text_color_for_background;
|
||||
|
||||
/// See the [module-level docs][crate] for more info.
|
||||
pub fn render_to_svg(source: &str, theme: &MermaidTheme) -> Result<String> {
|
||||
let svg = render::render_mermaid(source, theme)?;
|
||||
let svg = postprocess::postprocess(&svg, theme)?;
|
||||
Ok(svg)
|
||||
}
|
||||
136
crates/mermaid_render/src/postprocess.rs
Normal file
136
crates/mermaid_render/src/postprocess.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
//! Post-processing of [`merman`]-produced SVGs for rasterization with `usvg`/`resvg`.
|
||||
//!
|
||||
//! Each submodule is a specific pass that tweaks the SVG event iterator in a particular way.
|
||||
//!
|
||||
//! We always produce and consume [`Event`]s with a short lifetime.
|
||||
//! [`Event<'a>`] is backed internally by a [`Cow<'a, [u8]>`](std::borrow::Cow),
|
||||
//! so we don't have lifetime issues when we need to mutate the text in an
|
||||
//! [`Event`], but also don't force allocating a new [`String`] each time.
|
||||
//!
|
||||
//! Many modules contain internal structs that implement [`Iterator`] to make
|
||||
//! reasoning about lifetimes simpler, but these are private implementation
|
||||
//! details.
|
||||
|
||||
mod accent_colors;
|
||||
mod element_fixup;
|
||||
mod fallback_fixup;
|
||||
mod foreignobject_wrap;
|
||||
mod inject_css;
|
||||
mod strip_foreignobject;
|
||||
mod strip_invalid_css;
|
||||
pub(crate) mod util;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
|
||||
use crate::MermaidTheme;
|
||||
|
||||
pub(super) fn postprocess(svg: &str, theme: &MermaidTheme) -> Result<String> {
|
||||
// Pass 1: foreignObject preparation (\n fix + word wrapping)
|
||||
let svg = foreignobject_wrap::process(svg)?;
|
||||
|
||||
// Add <text> fallbacks alongside <foreignObject> elements
|
||||
let svg = merman::render::foreign_object_label_fallback_svg_text(&svg);
|
||||
|
||||
// Extract SVG id for CSS scoping (quick scan of the first element)
|
||||
let svg_id = extract_svg_id(&svg);
|
||||
|
||||
// Pass 2: themed post-processing pipeline.
|
||||
// Each adapter takes an iterator of events and returns an iterator of events.
|
||||
// Events borrow from the `svg` string — no .into_owned() per event.
|
||||
let mut reader = Reader::from_str(&svg);
|
||||
reader.config_mut().check_end_names = false;
|
||||
let events = ReaderIter::new(reader);
|
||||
let events = strip_foreignobject::process(events);
|
||||
let events = fallback_fixup::process(events, theme);
|
||||
let events = element_fixup::process(events, theme);
|
||||
|
||||
let events = accent_colors::process(events, theme);
|
||||
let events = strip_invalid_css::process(events);
|
||||
let events = inject_css::process(events, theme, &svg_id);
|
||||
|
||||
let mut writer = quick_xml::Writer::new(Vec::with_capacity(svg.len()));
|
||||
for event in events {
|
||||
writer.write_event(event?)?;
|
||||
}
|
||||
String::from_utf8(writer.into_inner()).context("SVG output is not valid UTF-8")
|
||||
}
|
||||
|
||||
fn extract_svg_id(svg: &str) -> String {
|
||||
let mut reader = Reader::from_str(svg);
|
||||
reader.config_mut().check_end_names = false;
|
||||
for event in ReaderIter::new(reader) {
|
||||
let Ok(Event::Start(e) | Event::Empty(e)) = event else {
|
||||
continue;
|
||||
};
|
||||
if e.name().as_ref() == b"svg" {
|
||||
return e
|
||||
.try_get_attribute("id")
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|a| a.unescape_value().ok())
|
||||
.map(|v| v.into_owned())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
struct ReaderIter<'a> {
|
||||
reader: Reader<&'a [u8]>,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl<'a> ReaderIter<'a> {
|
||||
fn new(reader: Reader<&'a [u8]>) -> Self {
|
||||
Self {
|
||||
reader,
|
||||
done: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for ReaderIter<'a> {
|
||||
type Item = Result<Event<'a>>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.done {
|
||||
return None;
|
||||
}
|
||||
match self.reader.read_event() {
|
||||
Ok(Event::Eof) => {
|
||||
self.done = true;
|
||||
None
|
||||
}
|
||||
Ok(event) => Some(Ok(event)),
|
||||
Err(e) => {
|
||||
self.done = true;
|
||||
Some(Err(e.into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn default_theme() -> MermaidTheme {
|
||||
MermaidTheme::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_css_handles_style_element_with_attributes() {
|
||||
let svg = r#"<svg id="test" xmlns="http://www.w3.org/2000/svg"><style type="text/css">@keyframes bounce { 0% { transform: scale(1); } 100% { transform: scale(1.1); } } .node rect { fill: red; }</style><rect width="10" height="10"/></svg>"#;
|
||||
let result = postprocess(svg, &default_theme()).unwrap();
|
||||
assert!(
|
||||
!result.contains("@keyframes"),
|
||||
"Unsupported @keyframes should be stripped from <style type=\"text/css\">, got: {result}"
|
||||
);
|
||||
assert!(
|
||||
result.contains(".node rect"),
|
||||
"Regular CSS rules should survive stripping, got: {result}"
|
||||
);
|
||||
}
|
||||
}
|
||||
375
crates/mermaid_render/src/postprocess/accent_colors.rs
Normal file
375
crates/mermaid_render/src/postprocess/accent_colors.rs
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
//! Injects CSS classes to set accent colors. Mermaid broadly speaking does not
|
||||
//! provide a mechanism to color individual nodes. A few diagram types support
|
||||
//! `:::my-css-class` on nodes, but most don't.
|
||||
//!
|
||||
//! [`inject_css`](super::inject_css) then injects CSS rules for the classes
|
||||
//! that this module injects.
|
||||
|
||||
mod class_diagram;
|
||||
mod mindmap;
|
||||
mod sequence_diagram;
|
||||
|
||||
use anyhow::Result;
|
||||
use quick_xml::events::{BytesStart, Event};
|
||||
|
||||
use crate::MermaidTheme;
|
||||
|
||||
pub(crate) struct NodeRect {
|
||||
pub cx: f64,
|
||||
pub cy: f64,
|
||||
pub half_height: f64,
|
||||
pub accent_idx: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct NodeTracker {
|
||||
rects: Vec<NodeRect>,
|
||||
building: Option<NodeRect>,
|
||||
}
|
||||
|
||||
impl NodeTracker {
|
||||
pub fn start_node(&mut self, cx: f64, cy: f64, half_height: f64, accent_idx: usize) {
|
||||
self.building = Some(NodeRect {
|
||||
cx,
|
||||
cy,
|
||||
half_height,
|
||||
accent_idx,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn finish_node(&mut self) {
|
||||
debug_assert!(self.building.is_some());
|
||||
|
||||
if let Some(rect) = self.building.take() {
|
||||
self.rects.push(rect);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_half_height(&mut self, e: &BytesStart<'_>) {
|
||||
if let Some(node) = &mut self.building
|
||||
&& let Some(hh) = parse_path_half_height(e)
|
||||
&& hh > node.half_height
|
||||
{
|
||||
node.half_height = hh;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lookup_accent(&self, e: &BytesStart<'_>) -> Option<usize> {
|
||||
lookup_position_accent(&self.rects, e)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_translate(e: &BytesStart<'_>) -> Option<(f64, f64)> {
|
||||
let attr = e.try_get_attribute("transform").ok()??;
|
||||
let val = attr.unescape_value().ok()?;
|
||||
let inner = val.strip_prefix("translate(")?.strip_suffix(')')?;
|
||||
let (x_str, y_str) = inner.split_once(',')?;
|
||||
Some((x_str.trim().parse().ok()?, y_str.trim().parse().ok()?))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_path_half_height(e: &BytesStart<'_>) -> Option<f64> {
|
||||
let attr = e.try_get_attribute("d").ok()??;
|
||||
let d = attr.unescape_value().ok()?;
|
||||
let rest = d.strip_prefix('M')?.trim_start();
|
||||
let mut chars = rest.chars().peekable();
|
||||
while chars.peek().is_some_and(|c| *c != ' ' && *c != ',') {
|
||||
chars.next();
|
||||
}
|
||||
while chars.peek().is_some_and(|c| *c == ' ' || *c == ',') {
|
||||
chars.next();
|
||||
}
|
||||
let y_str: String = chars.take_while(|c| *c != ' ' && *c != ',').collect();
|
||||
let y: f64 = y_str.parse().ok()?;
|
||||
Some(y.abs())
|
||||
}
|
||||
|
||||
// These arrays are basically just optimized versions of `format!("zed-accent-{i}")`
|
||||
const ACCENT_CLASSES: [&str; 8] = [
|
||||
"zed-accent-0",
|
||||
"zed-accent-1",
|
||||
"zed-accent-2",
|
||||
"zed-accent-3",
|
||||
"zed-accent-4",
|
||||
"zed-accent-5",
|
||||
"zed-accent-6",
|
||||
"zed-accent-7",
|
||||
];
|
||||
|
||||
const CHART_COLOR_CLASSES: [&str; 8] = [
|
||||
"zed-chart-0",
|
||||
"zed-chart-1",
|
||||
"zed-chart-2",
|
||||
"zed-chart-3",
|
||||
"zed-chart-4",
|
||||
"zed-chart-5",
|
||||
"zed-chart-6",
|
||||
"zed-chart-7",
|
||||
];
|
||||
|
||||
pub(crate) fn accent_class_name(index: usize) -> &'static str {
|
||||
ACCENT_CLASSES[index % ACCENT_CLASSES.len()]
|
||||
}
|
||||
|
||||
fn chart_color_class_name(index: usize) -> &'static str {
|
||||
CHART_COLOR_CLASSES[index % CHART_COLOR_CLASSES.len()]
|
||||
}
|
||||
|
||||
/// Wraps [`add_class`] and preserves the `Start`/`Empty` variant of the original event.
|
||||
pub(crate) fn add_to_event<'a>(ev: &Event<'_>, e: &BytesStart<'_>, cl: &str) -> Result<Event<'a>> {
|
||||
let new_elem = add_class(e, cl)?;
|
||||
Ok(match ev {
|
||||
Event::Start(_) => Event::Start(new_elem),
|
||||
_ => Event::Empty(new_elem),
|
||||
})
|
||||
}
|
||||
|
||||
/// Adds a CSS class to an element, preserving any existing classes.
|
||||
pub(crate) fn add_class<'a>(e: &BytesStart<'_>, class_to_add: &str) -> Result<BytesStart<'a>> {
|
||||
let name = e.name();
|
||||
let tag = std::str::from_utf8(name.as_ref())?;
|
||||
let mut new_elem = BytesStart::new(tag.to_owned());
|
||||
let mut class_found = false;
|
||||
for attr in e.attributes() {
|
||||
let attr = attr?;
|
||||
if attr.key.local_name().as_ref() == b"class" {
|
||||
let existing = attr.unescape_value()?;
|
||||
let new_class = format!("{existing} {class_to_add}");
|
||||
new_elem.push_attribute(("class", new_class.as_str()));
|
||||
class_found = true;
|
||||
} else {
|
||||
new_elem.push_attribute(attr);
|
||||
}
|
||||
}
|
||||
if !class_found {
|
||||
new_elem.push_attribute(("class", class_to_add));
|
||||
}
|
||||
Ok(new_elem)
|
||||
}
|
||||
|
||||
pub(crate) fn current_stack_accent(stack: &[Option<usize>]) -> Option<usize> {
|
||||
stack.iter().rev().find_map(|entry| *entry)
|
||||
}
|
||||
|
||||
pub(crate) fn lookup_position_accent(node_rects: &[NodeRect], e: &BytesStart<'_>) -> Option<usize> {
|
||||
let parse_attr = |name| -> Option<f64> {
|
||||
e.try_get_attribute(name)
|
||||
.ok()??
|
||||
.unescape_value()
|
||||
.ok()?
|
||||
.parse()
|
||||
.ok()
|
||||
};
|
||||
let x: f64 = parse_attr("x")?;
|
||||
let y: f64 = parse_attr("y")?;
|
||||
|
||||
node_rects.iter().find_map(|rect| {
|
||||
let in_y = (y - rect.cy).abs() <= rect.half_height + 5.0;
|
||||
let in_x = (x - rect.cx).abs() <= rect.half_height * 2.0;
|
||||
(in_x && in_y).then_some(rect.accent_idx)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum DiagramType {
|
||||
Flowchart,
|
||||
Mindmap,
|
||||
ClassDiagram,
|
||||
StateDiagram,
|
||||
SequenceDiagram,
|
||||
Unhandled,
|
||||
}
|
||||
|
||||
fn detect_diagram_type(e: &BytesStart<'_>) -> DiagramType {
|
||||
let class = match e
|
||||
.try_get_attribute("class")
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|a| a.unescape_value().ok())
|
||||
{
|
||||
Some(c) => c,
|
||||
None => return DiagramType::SequenceDiagram,
|
||||
};
|
||||
|
||||
for token in class.split_whitespace() {
|
||||
match token {
|
||||
"flowchart" => return DiagramType::Flowchart,
|
||||
"mindmap" => return DiagramType::Mindmap,
|
||||
"classDiagram" => return DiagramType::ClassDiagram,
|
||||
"statediagram" => return DiagramType::StateDiagram,
|
||||
"journey" => return DiagramType::Unhandled,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
DiagramType::SequenceDiagram
|
||||
}
|
||||
|
||||
/// Different diagrams require different state when computing accent colors.
|
||||
enum Handler {
|
||||
/// Before we have identified the diagram type
|
||||
Pending,
|
||||
/// Diagram type doesn't require injecting classes.
|
||||
Passthrough,
|
||||
Flowchart(class_diagram::ClassDiagramAccents),
|
||||
Mindmap(mindmap::MindmapAccents),
|
||||
ClassDiagram(class_diagram::ClassDiagramAccents),
|
||||
StateDiagram(class_diagram::ClassDiagramAccents),
|
||||
Sequence(sequence_diagram::SequenceDiagramAccents),
|
||||
}
|
||||
|
||||
struct AccentColors<I> {
|
||||
inner: I,
|
||||
theme: MermaidTheme,
|
||||
handler: Handler,
|
||||
in_legend: bool,
|
||||
legend_color_idx: usize,
|
||||
in_plot: bool,
|
||||
plot_depth: usize,
|
||||
plot_path_done: bool,
|
||||
pie_color_idx: usize,
|
||||
quadrant_point_idx: usize,
|
||||
}
|
||||
|
||||
impl<'a, I: Iterator<Item = Result<Event<'a>>>> AccentColors<I> {
|
||||
fn process_chart_colors(&mut self, event: Event<'a>) -> Result<Event<'a>> {
|
||||
match &event {
|
||||
Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"g" => {
|
||||
if self.in_plot {
|
||||
self.plot_depth += 1;
|
||||
}
|
||||
if let Some(class_attr) = e.try_get_attribute("class")? {
|
||||
let class = class_attr.unescape_value()?;
|
||||
if class.as_ref() == "plot" {
|
||||
self.in_plot = true;
|
||||
self.plot_depth = 1;
|
||||
self.plot_path_done = false;
|
||||
} else if class.as_ref() == "legend" {
|
||||
self.in_legend = true;
|
||||
} else if class.as_ref() == "data-point" {
|
||||
let accent_count = self.theme.accent_colors.len();
|
||||
if accent_count > 0 {
|
||||
let idx = self.quadrant_point_idx % accent_count;
|
||||
self.quadrant_point_idx += 1;
|
||||
return add_to_event(&event, e, &accent_class_name(idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
Event::End(e) if e.name().as_ref() == b"g" => {
|
||||
if self.in_plot {
|
||||
self.plot_depth -= 1;
|
||||
if self.plot_depth == 0 {
|
||||
self.in_plot = false;
|
||||
}
|
||||
}
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"rect" => {
|
||||
if self.in_legend && self.legend_color_idx < 8 {
|
||||
let class = chart_color_class_name(self.legend_color_idx);
|
||||
self.legend_color_idx += 1;
|
||||
self.in_legend = false;
|
||||
add_to_event(&event, e, &class)
|
||||
} else if self.in_plot {
|
||||
add_to_event(&event, e, &chart_color_class_name(0))
|
||||
} else {
|
||||
Ok(event)
|
||||
}
|
||||
}
|
||||
|
||||
Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"path" => {
|
||||
let class_val = e
|
||||
.try_get_attribute("class")?
|
||||
.map(|a| a.unescape_value())
|
||||
.transpose()?;
|
||||
|
||||
if class_val.as_deref() == Some("pieCircle") {
|
||||
let class = chart_color_class_name(self.pie_color_idx % 8);
|
||||
self.pie_color_idx += 1;
|
||||
add_to_event(&event, e, &class)
|
||||
} else if self.in_plot
|
||||
&& !self.plot_path_done
|
||||
&& e.try_get_attribute("stroke")?.is_some()
|
||||
{
|
||||
self.plot_path_done = true;
|
||||
add_to_event(&event, e, &chart_color_class_name(1))
|
||||
} else {
|
||||
Ok(event)
|
||||
}
|
||||
}
|
||||
|
||||
_ => Ok(event),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, I: Iterator<Item = Result<Event<'a>>>> Iterator for AccentColors<I> {
|
||||
type Item = Result<Event<'a>>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let event = match self.inner.next()? {
|
||||
Ok(ev) => ev,
|
||||
Err(e) => return Some(Err(e)),
|
||||
};
|
||||
|
||||
if matches!(self.handler, Handler::Pending) {
|
||||
if let Event::Start(e) | Event::Empty(e) = &event {
|
||||
if e.name().as_ref() == b"svg" {
|
||||
let diagram_type = detect_diagram_type(e);
|
||||
let count = self.theme.accent_colors.len();
|
||||
self.handler = match diagram_type {
|
||||
DiagramType::Flowchart => {
|
||||
Handler::Flowchart(class_diagram::ClassDiagramAccents::new(count))
|
||||
}
|
||||
DiagramType::Mindmap => Handler::Mindmap(mindmap::MindmapAccents::new()),
|
||||
DiagramType::ClassDiagram => {
|
||||
Handler::ClassDiagram(class_diagram::ClassDiagramAccents::new(count))
|
||||
}
|
||||
DiagramType::StateDiagram => {
|
||||
Handler::StateDiagram(class_diagram::ClassDiagramAccents::new(count))
|
||||
}
|
||||
DiagramType::SequenceDiagram => {
|
||||
Handler::Sequence(sequence_diagram::SequenceDiagramAccents::new(count))
|
||||
}
|
||||
DiagramType::Unhandled => Handler::Passthrough,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let event = match &mut self.handler {
|
||||
Handler::Flowchart(h) | Handler::ClassDiagram(h) | Handler::StateDiagram(h) => {
|
||||
h.process_event(event)
|
||||
}
|
||||
Handler::Mindmap(h) => h.process_event(event),
|
||||
Handler::Sequence(h) => h.process_event(event),
|
||||
Handler::Passthrough | Handler::Pending => Ok(event),
|
||||
};
|
||||
|
||||
Some(match event {
|
||||
Ok(event) => self.process_chart_colors(event),
|
||||
err => err,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process<'a>(
|
||||
events: impl Iterator<Item = Result<Event<'a>>>,
|
||||
theme: &MermaidTheme,
|
||||
) -> impl Iterator<Item = Result<Event<'a>>> {
|
||||
AccentColors {
|
||||
inner: events,
|
||||
theme: theme.clone(),
|
||||
handler: Handler::Pending,
|
||||
in_legend: false,
|
||||
legend_color_idx: 0,
|
||||
in_plot: false,
|
||||
plot_depth: 0,
|
||||
plot_path_done: false,
|
||||
pie_color_idx: 0,
|
||||
quadrant_point_idx: 0,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
use anyhow::Result;
|
||||
use quick_xml::events::Event;
|
||||
|
||||
use super::{NodeTracker, accent_class_name, add_class, add_to_event, parse_translate};
|
||||
|
||||
pub(crate) struct ClassDiagramAccents {
|
||||
accent_count: usize,
|
||||
accent_g_stack: Vec<Option<usize>>,
|
||||
node_counter: usize,
|
||||
nodes: NodeTracker,
|
||||
current_text_accent: Option<usize>,
|
||||
}
|
||||
|
||||
impl ClassDiagramAccents {
|
||||
pub(super) fn new(accent_count: usize) -> Self {
|
||||
Self {
|
||||
accent_count,
|
||||
accent_g_stack: Vec::new(),
|
||||
node_counter: 0,
|
||||
nodes: NodeTracker::default(),
|
||||
current_text_accent: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process_event<'a>(&mut self, event: Event<'a>) -> Result<Event<'a>> {
|
||||
if self.accent_count == 0 {
|
||||
return Ok(event);
|
||||
}
|
||||
|
||||
match &event {
|
||||
Event::Start(e) if e.name().as_ref() == b"g" => {
|
||||
let is_node = if let Some(class_attr) = e.try_get_attribute("class")? {
|
||||
let class = class_attr.unescape_value()?;
|
||||
class
|
||||
.split_whitespace()
|
||||
.any(|token| token == "node" || token == "stateGroup")
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if is_node {
|
||||
let accent_idx = self.node_counter % self.accent_count;
|
||||
self.node_counter += 1;
|
||||
|
||||
if let Some((cx, cy)) = parse_translate(e) {
|
||||
self.nodes.start_node(cx, cy, 30.0, accent_idx);
|
||||
}
|
||||
|
||||
self.accent_g_stack.push(Some(accent_idx));
|
||||
let new_elem = add_class(e, &accent_class_name(accent_idx))?;
|
||||
return Ok(Event::Start(new_elem));
|
||||
}
|
||||
|
||||
self.accent_g_stack.push(None);
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
Event::End(e) if e.name().as_ref() == b"g" => {
|
||||
if let Some(entry) = self.accent_g_stack.pop() {
|
||||
if entry.is_some() {
|
||||
self.nodes.finish_node();
|
||||
}
|
||||
}
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
Event::Start(e) | Event::Empty(e)
|
||||
if matches!(
|
||||
e.name().as_ref(),
|
||||
b"rect" | b"path" | b"circle" | b"polygon" | b"ellipse"
|
||||
) =>
|
||||
{
|
||||
if e.name().as_ref() == b"path" {
|
||||
self.nodes.update_half_height(e);
|
||||
}
|
||||
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
Event::Start(e) | Event::Empty(e)
|
||||
if e.name().as_ref() == b"text" || e.name().as_ref() == b"tspan" =>
|
||||
{
|
||||
let is_start = matches!(event, Event::Start(_));
|
||||
let is_text = e.name().as_ref() == b"text";
|
||||
|
||||
let accent_idx = if is_text {
|
||||
self.nodes
|
||||
.lookup_accent(e)
|
||||
.or_else(|| super::current_stack_accent(&self.accent_g_stack))
|
||||
} else {
|
||||
self.current_text_accent
|
||||
};
|
||||
|
||||
if let Some(idx) = accent_idx {
|
||||
if is_text && is_start {
|
||||
self.current_text_accent = Some(idx);
|
||||
}
|
||||
return add_to_event(&event, e, &accent_class_name(idx));
|
||||
}
|
||||
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
Event::End(e) if e.name().as_ref() == b"text" => {
|
||||
self.current_text_accent = None;
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
_ => Ok(event),
|
||||
}
|
||||
}
|
||||
}
|
||||
127
crates/mermaid_render/src/postprocess/accent_colors/mindmap.rs
Normal file
127
crates/mermaid_render/src/postprocess/accent_colors/mindmap.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
use anyhow::Result;
|
||||
use quick_xml::events::{BytesStart, Event};
|
||||
|
||||
use super::NodeTracker;
|
||||
|
||||
pub(super) struct MindmapAccents {
|
||||
section_classes: Vec<String>,
|
||||
section_g_stack: Vec<Option<usize>>,
|
||||
nodes: NodeTracker,
|
||||
current_text_section: Option<usize>,
|
||||
}
|
||||
|
||||
impl MindmapAccents {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
section_classes: Vec::new(),
|
||||
section_g_stack: Vec::new(),
|
||||
nodes: NodeTracker::default(),
|
||||
current_text_section: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process_event<'a>(&mut self, event: Event<'a>) -> Result<Event<'a>> {
|
||||
match &event {
|
||||
Event::Start(e) if e.name().as_ref() == b"g" => {
|
||||
let section_idx = self.parse_section_class(e)?;
|
||||
if let Some(idx) = section_idx {
|
||||
if let Some((tx, ty)) = super::parse_translate(e) {
|
||||
self.nodes.start_node(tx, ty, 0.0, idx);
|
||||
}
|
||||
self.section_g_stack.push(Some(idx));
|
||||
} else {
|
||||
self.section_g_stack.push(None);
|
||||
}
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
Event::End(e) if e.name().as_ref() == b"g" => {
|
||||
if let Some(maybe_section) = self.section_g_stack.pop() {
|
||||
if maybe_section.is_some() {
|
||||
self.nodes.finish_node();
|
||||
}
|
||||
}
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
Event::Start(e) | Event::Empty(e)
|
||||
if matches!(
|
||||
e.name().as_ref(),
|
||||
b"path" | b"rect" | b"circle" | b"polygon" | b"ellipse"
|
||||
) =>
|
||||
{
|
||||
if e.name().as_ref() == b"path" {
|
||||
self.nodes.update_half_height(e);
|
||||
}
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
Event::Start(e) | Event::Empty(e)
|
||||
if e.name().as_ref() == b"text" || e.name().as_ref() == b"tspan" =>
|
||||
{
|
||||
let section_idx = self.current_section_accent().or_else(|| {
|
||||
if e.name().as_ref() == b"text" {
|
||||
self.nodes.lookup_accent(e)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if e.name().as_ref() == b"text" {
|
||||
self.current_text_section = section_idx;
|
||||
}
|
||||
|
||||
let idx = section_idx.or(self.current_text_section);
|
||||
|
||||
if let Some(idx) = idx {
|
||||
if let Some(class_name) = self.section_class_name(idx) {
|
||||
return super::add_to_event(&event, e, class_name);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
Event::End(e) if e.name().as_ref() == b"text" => {
|
||||
self.current_text_section = None;
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
_ => Ok(event),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_section_class(&mut self, e: &BytesStart<'_>) -> Result<Option<usize>> {
|
||||
let class_attr = match e.try_get_attribute("class")? {
|
||||
Some(attr) => attr,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let class = class_attr.unescape_value()?;
|
||||
let tokens: Vec<&str> = class.split_whitespace().collect();
|
||||
let is_root = tokens.contains(&"section-root");
|
||||
|
||||
for token in &tokens {
|
||||
if let Some(rest) = token.strip_prefix("section-") {
|
||||
if rest == "-1" || rest.parse::<u32>().is_ok() {
|
||||
let class_name = if is_root {
|
||||
"section-root section--1".to_string()
|
||||
} else {
|
||||
format!("section-{rest}")
|
||||
};
|
||||
let idx = self.section_classes.len();
|
||||
self.section_classes.push(class_name);
|
||||
return Ok(Some(idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn current_section_accent(&self) -> Option<usize> {
|
||||
super::current_stack_accent(&self.section_g_stack)
|
||||
}
|
||||
|
||||
fn section_class_name(&self, idx: usize) -> Option<&str> {
|
||||
self.section_classes.get(idx).map(|s| s.as_str())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
use anyhow::Result;
|
||||
use quick_xml::events::{BytesStart, Event};
|
||||
|
||||
use super::{accent_class_name, add_to_event};
|
||||
|
||||
pub(super) struct SequenceDiagramAccents {
|
||||
accent_count: usize,
|
||||
actor_bottom_counter: usize,
|
||||
actor_top_counter: usize,
|
||||
last_actor_accent: Option<usize>,
|
||||
current_text_accent: Option<usize>,
|
||||
}
|
||||
|
||||
impl SequenceDiagramAccents {
|
||||
pub(super) fn new(accent_count: usize) -> Self {
|
||||
Self {
|
||||
accent_count,
|
||||
actor_bottom_counter: 0,
|
||||
actor_top_counter: 0,
|
||||
last_actor_accent: None,
|
||||
current_text_accent: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process_event<'a>(&mut self, event: Event<'a>) -> Result<Event<'a>> {
|
||||
if self.accent_count == 0 {
|
||||
return Ok(event);
|
||||
}
|
||||
|
||||
match &event {
|
||||
Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"rect" => {
|
||||
if let Some(idx) = self.check_actor_rect(e)? {
|
||||
add_to_event(&event, e, &accent_class_name(idx))
|
||||
} else {
|
||||
Ok(event)
|
||||
}
|
||||
}
|
||||
|
||||
Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"text" => {
|
||||
if let Some(idx) = self.check_actor_text(e)? {
|
||||
self.current_text_accent = Some(idx);
|
||||
add_to_event(&event, e, &accent_class_name(idx))
|
||||
} else {
|
||||
Ok(event)
|
||||
}
|
||||
}
|
||||
|
||||
Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"tspan" => {
|
||||
if let Some(idx) = self.current_text_accent {
|
||||
add_to_event(&event, e, &accent_class_name(idx))
|
||||
} else {
|
||||
Ok(event)
|
||||
}
|
||||
}
|
||||
|
||||
Event::End(e) if e.name().as_ref() == b"text" => {
|
||||
self.current_text_accent = None;
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
_ => Ok(event),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_actor_rect(&mut self, e: &BytesStart<'_>) -> Result<Option<usize>> {
|
||||
let class_attr = match e.try_get_attribute("class")? {
|
||||
Some(a) => a,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let class_val = class_attr.unescape_value()?;
|
||||
if class_val.contains("actor-bottom") {
|
||||
let idx = self.actor_bottom_counter % self.accent_count;
|
||||
self.actor_bottom_counter += 1;
|
||||
self.last_actor_accent = Some(idx);
|
||||
Ok(Some(idx))
|
||||
} else if class_val.contains("actor-top") {
|
||||
let idx = self.actor_top_counter % self.accent_count;
|
||||
self.actor_top_counter += 1;
|
||||
self.last_actor_accent = Some(idx);
|
||||
Ok(Some(idx))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn check_actor_text(&mut self, e: &BytesStart<'_>) -> Result<Option<usize>> {
|
||||
let class_attr = match e.try_get_attribute("class")? {
|
||||
Some(a) => a,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let class_val = class_attr.unescape_value()?;
|
||||
if class_val.contains("actor") && class_val.contains("actor-box") {
|
||||
Ok(self.last_actor_accent.take())
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
305
crates/mermaid_render/src/postprocess/element_fixup.rs
Normal file
305
crates/mermaid_render/src/postprocess/element_fixup.rs
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
//! Fixes various issues in merman's SVG output.
|
||||
//!
|
||||
//! Replaces hardcoded white backgrounds with the theme background:
|
||||
//! ```xml
|
||||
//! <!-- before --> <svg style="background-color: white">
|
||||
//! <!-- after --> <svg style="background-color: #1e1e2e">
|
||||
//! ```
|
||||
//!
|
||||
//! Removes `<rect>` elements with missing or invalid dimensions:
|
||||
//! ```xml
|
||||
//! <!-- before --> <rect width="NaN" height="10"/>
|
||||
//! <!-- after --> (removed)
|
||||
//! ```
|
||||
//!
|
||||
//! Replaces hardcoded text colors with the theme text color:
|
||||
//! ```xml
|
||||
//! <!-- before --> <text fill="#333">Hello</text>
|
||||
//! <!-- after --> <text fill="#cdd6f4">Hello</text>
|
||||
//! ```
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use anyhow::Result;
|
||||
use quick_xml::events::{BytesStart, Event};
|
||||
|
||||
use crate::MermaidTheme;
|
||||
|
||||
struct ElementFixup<I> {
|
||||
inner: I,
|
||||
background_css: String,
|
||||
text_color_css: String,
|
||||
font_family_css: String,
|
||||
svg_seen: bool,
|
||||
skip_rect_depth: usize,
|
||||
}
|
||||
|
||||
fn rewrite_attr<'a>(
|
||||
e: &BytesStart<'_>,
|
||||
attr_name: &[u8],
|
||||
new_value: &str,
|
||||
) -> Result<BytesStart<'a>> {
|
||||
let name = e.name();
|
||||
let tag = std::str::from_utf8(name.as_ref())?;
|
||||
let mut new_elem = BytesStart::new(tag.to_owned());
|
||||
for attr in e.attributes() {
|
||||
let attr = attr?;
|
||||
if attr.key.local_name().as_ref() == attr_name {
|
||||
let local_name = attr.key.local_name();
|
||||
let key = std::str::from_utf8(local_name.as_ref())?;
|
||||
new_elem.push_attribute((key, new_value));
|
||||
} else {
|
||||
new_elem.push_attribute(attr);
|
||||
}
|
||||
}
|
||||
Ok(new_elem)
|
||||
}
|
||||
|
||||
fn rewrap<'a>(event: &Event<'_>, elem: BytesStart<'a>) -> Event<'a> {
|
||||
match event {
|
||||
Event::Start(_) => Event::Start(elem),
|
||||
_ => Event::Empty(elem),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_bad_rect(e: &BytesStart) -> Result<bool> {
|
||||
for attr_name in ["width", "height"] {
|
||||
match e.try_get_attribute(attr_name)? {
|
||||
None => return Ok(true),
|
||||
Some(attr) => {
|
||||
let val = attr.unescape_value()?;
|
||||
let trimmed = val.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
if let Ok(n) = trimmed.parse::<f64>() {
|
||||
if !n.is_finite() || n <= 0.0 {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn is_hardcoded_text_fill(val: &str) -> bool {
|
||||
matches!(
|
||||
val,
|
||||
"" | "#333" | "black" | "#000" | "#000000" | "white" | "#fff" | "#ffffff"
|
||||
)
|
||||
}
|
||||
|
||||
fn push_font_style(style: &mut String, font_family: &str) {
|
||||
write!(style, "font-family: {font_family};").expect("write to String cannot fail");
|
||||
}
|
||||
|
||||
fn font_style(font_family: &str) -> String {
|
||||
let mut style = String::with_capacity(font_family.len() + "font-family: ;".len());
|
||||
push_font_style(&mut style, font_family);
|
||||
style
|
||||
}
|
||||
|
||||
fn rewrite_background_style<'a>(style: &'a str, background_css: &str) -> Cow<'a, str> {
|
||||
const WHITE_BACKGROUND_STYLE: &str = "background-color: white";
|
||||
|
||||
let Some(background_start) = style.find(WHITE_BACKGROUND_STYLE) else {
|
||||
return Cow::Borrowed(style);
|
||||
};
|
||||
|
||||
let mut rewritten = String::with_capacity(
|
||||
style
|
||||
.len()
|
||||
.saturating_sub(WHITE_BACKGROUND_STYLE.len())
|
||||
.saturating_add("background-color: ".len())
|
||||
.saturating_add(background_css.len()),
|
||||
);
|
||||
rewritten.push_str(&style[..background_start]);
|
||||
write!(rewritten, "background-color: {background_css}").expect("write to String cannot fail");
|
||||
rewritten.push_str(&style[background_start + WHITE_BACKGROUND_STYLE.len()..]);
|
||||
Cow::Owned(rewritten)
|
||||
}
|
||||
|
||||
fn font_family_declaration_value(declaration: &str) -> Option<&str> {
|
||||
let (property, value) = declaration.split_once(':')?;
|
||||
property
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("font-family")
|
||||
.then(|| value.trim())
|
||||
}
|
||||
|
||||
fn rewrite_font_style<'a>(style: &'a str, font_family: &str) -> Cow<'a, str> {
|
||||
let mut font_family_declaration_count = 0;
|
||||
let mut has_target_font_family = false;
|
||||
for declaration in style
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|declaration| !declaration.is_empty())
|
||||
{
|
||||
if let Some(value) = font_family_declaration_value(declaration) {
|
||||
font_family_declaration_count += 1;
|
||||
has_target_font_family = value == font_family;
|
||||
}
|
||||
}
|
||||
|
||||
if font_family_declaration_count == 1 && has_target_font_family {
|
||||
return Cow::Borrowed(style);
|
||||
}
|
||||
|
||||
let mut rewritten =
|
||||
String::with_capacity(style.len() + font_family.len() + " font-family: ;".len());
|
||||
for declaration in style.split(';') {
|
||||
let declaration = declaration.trim();
|
||||
if declaration.is_empty() || font_family_declaration_value(declaration).is_some() {
|
||||
continue;
|
||||
}
|
||||
if !rewritten.is_empty() {
|
||||
rewritten.push(' ');
|
||||
}
|
||||
rewritten.push_str(declaration);
|
||||
rewritten.push(';');
|
||||
}
|
||||
if !rewritten.is_empty() {
|
||||
rewritten.push(' ');
|
||||
}
|
||||
push_font_style(&mut rewritten, font_family);
|
||||
Cow::Owned(rewritten)
|
||||
}
|
||||
|
||||
impl<'a, I: Iterator<Item = Result<Event<'a>>>> ElementFixup<I> {
|
||||
fn rewrite_svg_style(&self, e: &BytesStart<'_>) -> Result<Option<BytesStart<'a>>> {
|
||||
let Some(style) = e
|
||||
.try_get_attribute("style")?
|
||||
.map(|a| a.unescape_value())
|
||||
.transpose()?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let new_style = rewrite_background_style(&style, &self.background_css);
|
||||
if matches!(new_style, Cow::Borrowed(_)) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(rewrite_attr(e, b"style", &new_style)?))
|
||||
}
|
||||
|
||||
fn rewrite_text_element(&self, e: &BytesStart<'_>, fix_fill: bool) -> Result<BytesStart<'a>> {
|
||||
let name = e.name();
|
||||
let tag = std::str::from_utf8(name.as_ref())?;
|
||||
let mut new_elem = BytesStart::new(tag.to_owned());
|
||||
let mut has_font_family = false;
|
||||
let mut has_style = false;
|
||||
|
||||
for attr in e.attributes() {
|
||||
let attr = attr?;
|
||||
match attr.key.local_name().as_ref() {
|
||||
b"fill" if fix_fill => {
|
||||
let val = attr.unescape_value()?;
|
||||
if is_hardcoded_text_fill(&val) {
|
||||
new_elem.push_attribute(("fill", self.text_color_css.as_str()));
|
||||
} else {
|
||||
new_elem.push_attribute(attr);
|
||||
}
|
||||
}
|
||||
b"font-family" => {
|
||||
has_font_family = true;
|
||||
new_elem.push_attribute(("font-family", self.font_family_css.as_str()));
|
||||
}
|
||||
b"style" => {
|
||||
has_style = true;
|
||||
let style = attr.unescape_value()?;
|
||||
let style = rewrite_font_style(&style, &self.font_family_css);
|
||||
new_elem.push_attribute(("style", style.as_ref()));
|
||||
}
|
||||
_ => new_elem.push_attribute(attr),
|
||||
}
|
||||
}
|
||||
|
||||
if !has_font_family {
|
||||
new_elem.push_attribute(("font-family", self.font_family_css.as_str()));
|
||||
}
|
||||
if !has_style {
|
||||
let style = font_style(&self.font_family_css);
|
||||
new_elem.push_attribute(("style", style.as_str()));
|
||||
}
|
||||
|
||||
Ok(new_elem)
|
||||
}
|
||||
|
||||
fn process_event(&mut self, event: Event<'a>) -> Result<Option<Event<'a>>> {
|
||||
match &event {
|
||||
Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"svg" && !self.svg_seen => {
|
||||
self.svg_seen = true;
|
||||
if let Some(new_elem) = self.rewrite_svg_style(e)? {
|
||||
Ok(Some(rewrap(&event, new_elem)))
|
||||
} else {
|
||||
Ok(Some(event))
|
||||
}
|
||||
}
|
||||
|
||||
Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"rect" => {
|
||||
if is_bad_rect(e)? {
|
||||
if matches!(event, Event::Start(_)) {
|
||||
self.skip_rect_depth = 1;
|
||||
}
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(event))
|
||||
}
|
||||
}
|
||||
|
||||
Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"text" => {
|
||||
Ok(Some(rewrap(&event, self.rewrite_text_element(e, true)?)))
|
||||
}
|
||||
|
||||
Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"tspan" => {
|
||||
Ok(Some(rewrap(&event, self.rewrite_text_element(e, false)?)))
|
||||
}
|
||||
|
||||
_ => Ok(Some(event)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, I: Iterator<Item = Result<Event<'a>>>> Iterator for ElementFixup<I> {
|
||||
type Item = Result<Event<'a>>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
loop {
|
||||
let event = match self.inner.next()? {
|
||||
Ok(ev) => ev,
|
||||
Err(e) => return Some(Err(e)),
|
||||
};
|
||||
|
||||
if self.skip_rect_depth > 0 {
|
||||
match &event {
|
||||
Event::Start(_) => self.skip_rect_depth += 1,
|
||||
Event::End(_) => self.skip_rect_depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.process_event(event) {
|
||||
Ok(Some(ev)) => return Some(Ok(ev)),
|
||||
Ok(None) => continue,
|
||||
Err(e) => return Some(Err(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process<'a>(
|
||||
events: impl Iterator<Item = Result<Event<'a>>>,
|
||||
theme: &MermaidTheme,
|
||||
) -> impl Iterator<Item = Result<Event<'a>>> {
|
||||
ElementFixup {
|
||||
inner: events,
|
||||
background_css: crate::css_color(theme.background),
|
||||
text_color_css: crate::css_color(theme.text_color),
|
||||
font_family_css: theme.font_family.clone(),
|
||||
svg_seen: false,
|
||||
skip_rect_depth: 0,
|
||||
}
|
||||
}
|
||||
223
crates/mermaid_render/src/postprocess/fallback_fixup.rs
Normal file
223
crates/mermaid_render/src/postprocess/fallback_fixup.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
//! Fixes double-escaped HTML entities inside fallback `<text>` groups that
|
||||
//! were generated as replacements for `<foreignObject>` content.
|
||||
//!
|
||||
//! ```xml
|
||||
//! <!-- before -->
|
||||
//! <g data-merman-foreignobject="fallback">
|
||||
//! <text>List&lt;T&gt;</text>
|
||||
//! </g>
|
||||
//!
|
||||
//! <!-- after -->
|
||||
//! <g data-merman-foreignobject="fallback">
|
||||
//! <text>List<T></text>
|
||||
//! </g>
|
||||
//! ```
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use quick_xml::events::{BytesStart, BytesText, Event};
|
||||
|
||||
use crate::MermaidTheme;
|
||||
|
||||
struct FallbackFixup<'a, I> {
|
||||
inner: I,
|
||||
edge_label_bg: String,
|
||||
fallback_depth: usize,
|
||||
text_buffer: String,
|
||||
output_queue: VecDeque<Event<'a>>,
|
||||
}
|
||||
|
||||
impl<'a, I: Iterator<Item = Result<Event<'a>>>> Iterator for FallbackFixup<'a, I> {
|
||||
type Item = Result<Event<'a>>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if let Some(event) = self.output_queue.pop_front() {
|
||||
return Some(Ok(event));
|
||||
}
|
||||
|
||||
loop {
|
||||
let event = match self.inner.next()? {
|
||||
Ok(ev) => ev,
|
||||
Err(e) => return Some(Err(e)),
|
||||
};
|
||||
|
||||
match &event {
|
||||
Event::Start(e) if e.name().as_ref() == b"g" => {
|
||||
if self.fallback_depth > 0 {
|
||||
self.fallback_depth += 1;
|
||||
} else {
|
||||
match e.try_get_attribute("data-merman-foreignobject") {
|
||||
Ok(Some(attr)) if attr.value.as_ref() == b"fallback" => {
|
||||
self.fallback_depth = 1;
|
||||
}
|
||||
Err(e) => return Some(Err(e.into())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::End(e) if e.name().as_ref() == b"g" && self.fallback_depth > 0 => {
|
||||
self.flush_text_buffer();
|
||||
self.fallback_depth -= 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if self.fallback_depth == 0 {
|
||||
return Some(Ok(event));
|
||||
}
|
||||
|
||||
// Inside fallback group: accumulate text-like events, process others
|
||||
match &event {
|
||||
Event::Text(t) => {
|
||||
match std::str::from_utf8(t.as_ref()) {
|
||||
Ok(raw) => self.text_buffer.push_str(raw),
|
||||
Err(e) => eprintln!("Invalid UTF-8 in fallback text: {e}"),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Event::GeneralRef(r) => {
|
||||
self.text_buffer.push('&');
|
||||
match std::str::from_utf8(r.as_ref()) {
|
||||
Ok(name) => self.text_buffer.push_str(name),
|
||||
Err(e) => eprintln!("Invalid UTF-8 in fallback entity ref: {e}"),
|
||||
}
|
||||
self.text_buffer.push(';');
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
self.flush_text_buffer();
|
||||
|
||||
match self.process_non_text_event(event) {
|
||||
Ok(ev) => self.output_queue.push_back(ev),
|
||||
Err(e) => return Some(Err(e)),
|
||||
}
|
||||
|
||||
if let Some(event) = self.output_queue.pop_front() {
|
||||
return Some(Ok(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, I> FallbackFixup<'a, I> {
|
||||
fn flush_text_buffer(&mut self) {
|
||||
if self.text_buffer.is_empty() {
|
||||
return;
|
||||
}
|
||||
let text = if self.text_buffer.contains("&lt;") || self.text_buffer.contains("&gt;")
|
||||
{
|
||||
let fixed = self
|
||||
.text_buffer
|
||||
.replace("&lt;", "<")
|
||||
.replace("&gt;", ">");
|
||||
self.text_buffer.clear();
|
||||
fixed
|
||||
} else {
|
||||
std::mem::take(&mut self.text_buffer)
|
||||
};
|
||||
self.output_queue
|
||||
.push_back(Event::Text(BytesText::from_escaped(text)));
|
||||
}
|
||||
|
||||
fn process_non_text_event(&self, event: Event<'a>) -> Result<Event<'a>> {
|
||||
let is_start = matches!(event, Event::Start(_));
|
||||
match &event {
|
||||
Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"rect" => {
|
||||
let mut new_elem = BytesStart::new("rect");
|
||||
for attr in e.attributes() {
|
||||
let attr = attr?;
|
||||
if attr.key.local_name().as_ref() == b"fill" {
|
||||
new_elem.push_attribute(("fill", self.edge_label_bg.as_str()));
|
||||
} else {
|
||||
new_elem.push_attribute(attr);
|
||||
}
|
||||
}
|
||||
Ok(if is_start {
|
||||
Event::Start(new_elem)
|
||||
} else {
|
||||
Event::Empty(new_elem)
|
||||
})
|
||||
}
|
||||
_ => Ok(event),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process<'a>(
|
||||
events: impl Iterator<Item = Result<Event<'a>>>,
|
||||
theme: &MermaidTheme,
|
||||
) -> impl Iterator<Item = Result<Event<'a>>> {
|
||||
let edge_label_bg = crate::css_color(theme.edge_label_background);
|
||||
FallbackFixup {
|
||||
inner: events,
|
||||
edge_label_bg,
|
||||
fallback_depth: 0,
|
||||
text_buffer: String::new(),
|
||||
output_queue: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use quick_xml::Reader;
|
||||
|
||||
fn run_fixup(svg: &str) -> String {
|
||||
let reader = Reader::from_str(svg);
|
||||
let events = std::iter::from_fn({
|
||||
let mut reader = reader;
|
||||
let mut done = false;
|
||||
move || {
|
||||
if done {
|
||||
return None;
|
||||
}
|
||||
match reader.read_event() {
|
||||
Ok(quick_xml::events::Event::Eof) => {
|
||||
done = true;
|
||||
None
|
||||
}
|
||||
Ok(ev) => Some(Ok(ev)),
|
||||
Err(e) => {
|
||||
done = true;
|
||||
Some(Err(e.into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let theme = crate::MermaidTheme::default();
|
||||
let fixed = process(events, &theme);
|
||||
let mut writer = quick_xml::Writer::new(Vec::new());
|
||||
for ev in fixed {
|
||||
writer.write_event(ev.unwrap()).unwrap();
|
||||
}
|
||||
String::from_utf8(writer.into_inner()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixes_double_escaped_entities_in_fallback() {
|
||||
let svg = r##"<g data-merman-foreignobject="fallback"><text fill="#333">-List&lt;Animal&gt; animals</text></g>"##;
|
||||
let result = run_fixup(svg);
|
||||
assert!(
|
||||
!result.contains("&lt;"),
|
||||
"Should fix double-escaped entities, got: {result}"
|
||||
);
|
||||
assert!(
|
||||
result.contains("<"),
|
||||
"Should contain single-escaped entity, got: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_text_outside_fallback_group() {
|
||||
let svg = r##"<text>-List&lt;Animal&gt;</text>"##;
|
||||
let result = run_fixup(svg);
|
||||
assert!(
|
||||
result.contains("&lt;"),
|
||||
"Should not fix entities outside fallback group, got: {result}"
|
||||
);
|
||||
}
|
||||
}
|
||||
96
crates/mermaid_render/src/postprocess/foreignobject_wrap.rs
Normal file
96
crates/mermaid_render/src/postprocess/foreignobject_wrap.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
//! Converts literal `\n` escape sequences inside `<foreignObject>` elements
|
||||
//! into `<br/>` tags so that line breaks render correctly.
|
||||
//!
|
||||
//! ```xml
|
||||
//! <!-- before -->
|
||||
//! <foreignObject>Hello\nWorld</foreignObject>
|
||||
//!
|
||||
//! <!-- after -->
|
||||
//! <foreignObject>Hello<br/>World</foreignObject>
|
||||
//! ```
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use quick_xml::escape;
|
||||
use quick_xml::events::{BytesStart, BytesText, Event};
|
||||
use quick_xml::{Reader, Writer};
|
||||
|
||||
pub(super) fn process(svg: &str) -> Result<String> {
|
||||
let mut reader = Reader::from_str(svg);
|
||||
reader.config_mut().check_end_names = false;
|
||||
let mut writer = Writer::new(Vec::with_capacity(svg.len()));
|
||||
|
||||
let mut foreign_object_depth: usize = 0;
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
loop {
|
||||
let event = match reader.read_event() {
|
||||
Ok(Event::Eof) => break,
|
||||
Ok(event) => event,
|
||||
Err(e) => return Err(e).context("failed to parse SVG in foreignObject wrap pass"),
|
||||
};
|
||||
|
||||
let is_fo_start =
|
||||
matches!(&event, Event::Start(e) if e.name().as_ref() == b"foreignObject");
|
||||
let is_fo_end = matches!(&event, Event::End(e) if e.name().as_ref() == b"foreignObject");
|
||||
|
||||
if is_fo_start {
|
||||
if foreign_object_depth == 0 {
|
||||
buffer.clear();
|
||||
}
|
||||
buffer.push(event);
|
||||
foreign_object_depth += 1;
|
||||
} else if is_fo_end {
|
||||
foreign_object_depth = foreign_object_depth.saturating_sub(1);
|
||||
buffer.push(event);
|
||||
if foreign_object_depth == 0 {
|
||||
emit_buffered(std::mem::take(&mut buffer), &mut writer)?;
|
||||
}
|
||||
} else if foreign_object_depth > 0 {
|
||||
buffer.push(event);
|
||||
} else {
|
||||
writer.write_event(event)?;
|
||||
}
|
||||
}
|
||||
|
||||
String::from_utf8(writer.into_inner()).context("SVG output is not valid UTF-8")
|
||||
}
|
||||
|
||||
fn emit_buffered(buffer: Vec<Event<'_>>, writer: &mut Writer<Vec<u8>>) -> Result<()> {
|
||||
for event in buffer {
|
||||
match event {
|
||||
Event::Text(t) => {
|
||||
let processed = {
|
||||
let decoded = t.decode().unwrap_or_default();
|
||||
let text = escape::unescape(&decoded).unwrap_or_else(|_| decoded.clone());
|
||||
emit_text_content(&text, writer)?
|
||||
};
|
||||
if !processed {
|
||||
writer.write_event(Event::Text(t))?;
|
||||
}
|
||||
}
|
||||
other => {
|
||||
writer.write_event(other)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn emit_text_content(text: &str, writer: &mut Writer<Vec<u8>>) -> Result<bool> {
|
||||
if !text.contains("\\n") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut first_segment = true;
|
||||
for segment in text.split("\\n") {
|
||||
if !first_segment {
|
||||
writer.write_event(Event::Empty(BytesStart::new("br")))?;
|
||||
}
|
||||
first_segment = false;
|
||||
writer.write_event(Event::Text(BytesText::from_escaped(escape::escape(
|
||||
segment,
|
||||
))))?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
517
crates/mermaid_render/src/postprocess/inject_css.rs
Normal file
517
crates/mermaid_render/src/postprocess/inject_css.rs
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
//! Builds a theme-aware CSS stylesheet and appends it into the SVG's `<style>`
|
||||
//! element. All selectors are scoped to the SVG's `id` to prevent leaking.
|
||||
//!
|
||||
//! ```xml
|
||||
//! <!-- before -->
|
||||
//! <style>.node rect { fill: white; }</style>
|
||||
//!
|
||||
//! <!-- after -->
|
||||
//! <style>.node rect { fill: white; }
|
||||
//! #mermaid-1 .node rect { fill: #89b4fa !important; }
|
||||
//! /* ... theme rules ... */
|
||||
//! </style>
|
||||
//! ```
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fmt::Write;
|
||||
|
||||
use anyhow::Result;
|
||||
use quick_xml::events::{BytesText, Event};
|
||||
|
||||
use crate::MermaidTheme;
|
||||
|
||||
/// Morally equivalent to `format!(".section-{i}")`, but without allocating
|
||||
const MINDMAP_SECTION_SELECTORS: [&str; 11] = [
|
||||
".section-0",
|
||||
".section-1",
|
||||
".section-2",
|
||||
".section-3",
|
||||
".section-4",
|
||||
".section-5",
|
||||
".section-6",
|
||||
".section-7",
|
||||
".section-8",
|
||||
".section-9",
|
||||
".section-10",
|
||||
];
|
||||
|
||||
struct InjectCss<'a, I> {
|
||||
inner: I,
|
||||
injected_css: String,
|
||||
in_style: bool,
|
||||
injected: bool,
|
||||
pending: VecDeque<Event<'a>>,
|
||||
}
|
||||
|
||||
impl<'a, I: Iterator<Item = Result<Event<'a>>>> Iterator for InjectCss<'a, I> {
|
||||
type Item = Result<Event<'a>>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if let Some(event) = self.pending.pop_front() {
|
||||
return Some(Ok(event));
|
||||
}
|
||||
|
||||
let event = match self.inner.next()? {
|
||||
Ok(ev) => ev,
|
||||
Err(e) => return Some(Err(e)),
|
||||
};
|
||||
|
||||
match &event {
|
||||
Event::Start(e) if e.name().as_ref() == b"style" => {
|
||||
self.in_style = true;
|
||||
return Some(Ok(event));
|
||||
}
|
||||
Event::End(e) if e.name().as_ref() == b"style" => {
|
||||
self.in_style = false;
|
||||
if !self.injected {
|
||||
self.injected = true;
|
||||
self.pending
|
||||
.push_back(Event::Text(BytesText::from_escaped(std::mem::take(
|
||||
&mut self.injected_css,
|
||||
))));
|
||||
self.pending.push_back(event);
|
||||
return self.pending.pop_front().map(Ok);
|
||||
}
|
||||
return Some(Ok(event));
|
||||
}
|
||||
Event::Text(text) if self.in_style => {
|
||||
self.injected = true;
|
||||
let existing = match std::str::from_utf8(text.as_ref()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Some(Err(e.into())),
|
||||
};
|
||||
let mut combined = String::with_capacity(existing.len() + self.injected_css.len());
|
||||
combined.push_str(existing);
|
||||
combined.push_str(&self.injected_css);
|
||||
return Some(Ok(Event::Text(BytesText::from_escaped(combined))));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Some(Ok(event))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process<'a>(
|
||||
events: impl Iterator<Item = Result<Event<'a>>>,
|
||||
theme: &MermaidTheme,
|
||||
svg_id: &str,
|
||||
) -> impl Iterator<Item = Result<Event<'a>>> {
|
||||
let injected_css = build_injected_css(theme, svg_id);
|
||||
InjectCss {
|
||||
inner: events,
|
||||
injected_css,
|
||||
in_style: false,
|
||||
injected: false,
|
||||
pending: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mindmap_section_css(theme: &MermaidTheme) -> String {
|
||||
let colors: Vec<String> = theme
|
||||
.git_branch_colors
|
||||
.iter()
|
||||
.map(|c| crate::css_color(*c))
|
||||
.collect();
|
||||
let fills: Vec<String> = theme
|
||||
.git_branch_colors
|
||||
.iter()
|
||||
.map(|c| {
|
||||
crate::css_color(blend_over_background(
|
||||
*c,
|
||||
theme.background,
|
||||
ACCENT_FILL_OPACITY,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let text = crate::css_color(theme.text_color);
|
||||
let mut css = String::with_capacity(5_400);
|
||||
|
||||
let emit = |css: &mut String, selector: &str, color: &str, fill: &str, txt: &str| {
|
||||
let section_index = selector
|
||||
.trim_start_matches(".section-root.section-")
|
||||
.trim_start_matches(".section-");
|
||||
write!(
|
||||
css,
|
||||
"{selector} rect, {selector} path, {selector} circle, {selector} polygon \
|
||||
{{ fill: {fill} !important; stroke: {color} !important; }}\n\
|
||||
{selector} text, {selector} span, \
|
||||
text{selector}, tspan{selector} \
|
||||
{{ fill: {txt} !important; color: {txt} !important; }}\n\
|
||||
{selector} foreignObject div, {selector} foreignObject span, {selector} foreignObject p \
|
||||
{{ color: {txt} !important; }}\n\
|
||||
.section-edge{section_index} {{ stroke: {color} !important; }}\n",
|
||||
)
|
||||
.expect("write to String cannot fail");
|
||||
};
|
||||
|
||||
emit(
|
||||
&mut css,
|
||||
".section-root.section--1",
|
||||
&colors[0],
|
||||
&fills[0],
|
||||
&text,
|
||||
);
|
||||
emit(&mut css, ".section--1", &colors[1], &fills[1], &text);
|
||||
for (i, selector) in MINDMAP_SECTION_SELECTORS.iter().enumerate() {
|
||||
let ci = 2 + (i % 6);
|
||||
emit(&mut css, selector, &colors[ci], &fills[ci], &text);
|
||||
}
|
||||
css
|
||||
}
|
||||
|
||||
fn git_branch_css(theme: &MermaidTheme) -> String {
|
||||
let text = crate::css_color(theme.text_color);
|
||||
let mut css = String::with_capacity(8 * 200);
|
||||
for i in 0..8 {
|
||||
let c = crate::css_color(theme.git_branch_colors[i]);
|
||||
let label_fill = crate::css_color(blend_over_background(
|
||||
theme.git_branch_colors[i],
|
||||
theme.background,
|
||||
ACCENT_FILL_OPACITY,
|
||||
));
|
||||
write!(
|
||||
css,
|
||||
".commit{i} {{ stroke: {c}; fill: {c}; }}\n\
|
||||
.arrow{i} {{ stroke: {c}; }}\n\
|
||||
.label{i} {{ fill: {label_fill}; stroke: {c}; }}\n\
|
||||
.branch-label{i} {{ fill: {text}; }}\n"
|
||||
)
|
||||
.expect("write to String cannot fail");
|
||||
}
|
||||
css
|
||||
}
|
||||
|
||||
fn adjust_lightness(color: &mut gpui::Hsla, dark_mode: bool) {
|
||||
if dark_mode {
|
||||
color.l = (color.l * 0.7).max(0.0);
|
||||
} else {
|
||||
color.l = (color.l * 1.3).min(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
const ACCENT_FILL_OPACITY: f32 = 0.15;
|
||||
|
||||
fn blend_over_background(
|
||||
foreground: gpui::Hsla,
|
||||
background: gpui::Hsla,
|
||||
opacity: f32,
|
||||
) -> gpui::Hsla {
|
||||
let fg = gpui::Rgba::from(foreground);
|
||||
let bg = gpui::Rgba::from(background);
|
||||
let blended = gpui::Rgba {
|
||||
r: fg.r * opacity + bg.r * (1.0 - opacity),
|
||||
g: fg.g * opacity + bg.g * (1.0 - opacity),
|
||||
b: fg.b * opacity + bg.b * (1.0 - opacity),
|
||||
a: 1.0,
|
||||
};
|
||||
gpui::Hsla::from(blended)
|
||||
}
|
||||
|
||||
fn accent_css(theme: &MermaidTheme) -> String {
|
||||
let mut css = String::with_capacity(theme.accent_colors.len() * 420);
|
||||
let text = crate::css_color(theme.text_color);
|
||||
|
||||
for (i, accent) in theme.accent_colors.iter().enumerate() {
|
||||
let stroke = crate::css_color(accent.foreground);
|
||||
let fill = crate::css_color(blend_over_background(
|
||||
accent.background,
|
||||
theme.background,
|
||||
ACCENT_FILL_OPACITY,
|
||||
));
|
||||
let class = format!(".zed-accent-{i}");
|
||||
write!(
|
||||
css,
|
||||
"{class} rect, {class} path, {class} circle, {class} polygon, {class} ellipse, \
|
||||
rect{class}, path{class}, circle{class}, polygon{class}, ellipse{class} \
|
||||
{{ fill: {fill} !important; stroke: {stroke} !important; }}\n\
|
||||
{class} text, {class} tspan, text{class}, tspan{class} \
|
||||
{{ fill: {text} !important; }}\n",
|
||||
)
|
||||
.expect("write to String cannot fail");
|
||||
}
|
||||
css
|
||||
}
|
||||
|
||||
fn chart_color_css(theme: &MermaidTheme) -> String {
|
||||
// Each block is around 230 bytes, add some headroom
|
||||
let mut css = String::with_capacity(8 * 250);
|
||||
for i in 0..8 {
|
||||
let color = crate::css_color(theme.git_branch_colors[i]);
|
||||
let class = format!(".zed-chart-{i}");
|
||||
write!(
|
||||
css,
|
||||
"path.pieCircle{class} {{ fill: {color} !important; }}\n\
|
||||
.plot rect{class}, .legend rect{class} {{ fill: {color} !important; stroke: {color} !important; }}\n\
|
||||
.plot path{class} {{ stroke: {color} !important; }}\n"
|
||||
)
|
||||
.expect("write to String cannot fail");
|
||||
}
|
||||
css
|
||||
}
|
||||
|
||||
fn timeline_css(theme: &MermaidTheme) -> String {
|
||||
let mut css = String::with_capacity(8 * 300);
|
||||
let text = crate::css_color(theme.text_color);
|
||||
for i in 0..8 {
|
||||
let c = crate::css_color(theme.git_branch_colors[i]);
|
||||
let fill = crate::css_color(blend_over_background(
|
||||
theme.git_branch_colors[i],
|
||||
theme.background,
|
||||
ACCENT_FILL_OPACITY,
|
||||
));
|
||||
write!(
|
||||
css,
|
||||
"rect.task-type-{i}, rect.section-type-{i} {{ fill: {fill} !important; stroke: {c} !important; }}\n"
|
||||
).expect("write to String cannot fail");
|
||||
}
|
||||
for i in 0..4 {
|
||||
let c = crate::css_color(theme.git_branch_colors[i % 8]);
|
||||
let fill = crate::css_color(blend_over_background(
|
||||
theme.git_branch_colors[i % 8],
|
||||
theme.background,
|
||||
ACCENT_FILL_OPACITY,
|
||||
));
|
||||
write!(
|
||||
css,
|
||||
".section{i} {{ fill: {fill} !important; }}\n\
|
||||
.task{i} {{ fill: {fill} !important; stroke: {c} !important; }}\n\
|
||||
.taskText{i} {{ fill: {text} !important; }}\n\
|
||||
.taskTextOutside{i} {{ fill: {text} !important; }}\n"
|
||||
)
|
||||
.expect("write to String cannot fail");
|
||||
}
|
||||
css
|
||||
}
|
||||
|
||||
fn should_scope_css_line(trimmed: &str) -> bool {
|
||||
!trimmed.is_empty()
|
||||
&& (trimmed.starts_with('.')
|
||||
|| trimmed.starts_with("foreignObject")
|
||||
|| trimmed.starts_with("g.")
|
||||
|| trimmed.starts_with("text")
|
||||
|| trimmed.starts_with("tspan")
|
||||
|| trimmed.starts_with("rect.")
|
||||
|| trimmed.starts_with("path.")
|
||||
|| trimmed.starts_with("defs")
|
||||
|| trimmed.starts_with('#'))
|
||||
}
|
||||
|
||||
fn scoped_selector_count(raw_css: &str) -> usize {
|
||||
raw_css.lines().fold(0, |count, line| {
|
||||
let trimmed = line.trim();
|
||||
if !should_scope_css_line(trimmed) {
|
||||
return count;
|
||||
}
|
||||
let Some((selectors, _)) = trimmed.split_once('{') else {
|
||||
return count;
|
||||
};
|
||||
count.saturating_add(selectors.split(',').count())
|
||||
})
|
||||
}
|
||||
|
||||
fn scope_css(raw_css: &str, svg_id: &str) -> String {
|
||||
let scoped_selector_prefix_len = svg_id.len().saturating_add(2);
|
||||
let result_capacity = raw_css
|
||||
.len()
|
||||
.saturating_add(scoped_selector_count(raw_css).saturating_mul(scoped_selector_prefix_len));
|
||||
let mut result = String::with_capacity(result_capacity);
|
||||
for line in raw_css.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
if should_scope_css_line(trimmed) {
|
||||
if let Some(brace) = trimmed.find('{') {
|
||||
let (selectors, rest) = trimmed.split_at(brace);
|
||||
let mut first = true;
|
||||
for selector in selectors.split(',') {
|
||||
if !first {
|
||||
result.push_str(", ");
|
||||
}
|
||||
first = false;
|
||||
write!(result, "#{svg_id} {}", selector.trim())
|
||||
.expect("write to String cannot fail");
|
||||
}
|
||||
writeln!(result, "{rest}").expect("write to String cannot fail");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
writeln!(result, "{line}").expect("write to String cannot fail");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn build_injected_css(theme: &MermaidTheme, svg_id: &str) -> String {
|
||||
let font = &theme.font_family;
|
||||
let text = crate::css_color(theme.text_color);
|
||||
let line = crate::css_color(theme.line_color);
|
||||
let primary = crate::css_color(theme.primary_color);
|
||||
let border = crate::css_color(theme.primary_border_color);
|
||||
let secondary = crate::css_color(theme.secondary_color);
|
||||
let tertiary = crate::css_color(theme.tertiary_color);
|
||||
let background = crate::css_color(theme.background);
|
||||
let edge_label_bg = crate::css_color(theme.edge_label_background);
|
||||
let actor_bg = crate::css_color(theme.actor_background);
|
||||
let actor_border = crate::css_color(theme.actor_border);
|
||||
let error_bg = {
|
||||
let mut c = theme.error_color;
|
||||
adjust_lightness(&mut c, theme.dark_mode);
|
||||
c
|
||||
};
|
||||
let error = crate::css_color(error_bg);
|
||||
let error_text = crate::css_color(crate::postprocess::util::text_color_for_background(
|
||||
error_bg,
|
||||
));
|
||||
let warning_bg = {
|
||||
let mut c = theme.warning_color;
|
||||
adjust_lightness(&mut c, theme.dark_mode);
|
||||
c
|
||||
};
|
||||
let warning = crate::css_color(warning_bg);
|
||||
let warning_text = crate::css_color(crate::postprocess::util::text_color_for_background(
|
||||
warning_bg,
|
||||
));
|
||||
let note_bg = crate::css_color(theme.note_background);
|
||||
let note_border = crate::css_color(theme.note_border);
|
||||
let er_odd = crate::css_color(theme.er_attr_bg_odd);
|
||||
let er_even = crate::css_color(theme.er_attr_bg_even);
|
||||
|
||||
let actor_text = &text;
|
||||
let note_text = &text;
|
||||
|
||||
let raw_css = format!(
|
||||
r#"
|
||||
text, tspan, foreignObject div, foreignObject span, foreignObject p {{ font-family: {font} !important; }}
|
||||
foreignObject div, foreignObject span, foreignObject p {{ font-size: 16px; color: {text}; }}
|
||||
foreignObject p {{ margin: 0; }}
|
||||
foreignObject {{ overflow: visible; }}
|
||||
foreignObject div {{ max-width: none !important; }}
|
||||
.label-group foreignObject {{ font-weight: bold; }}
|
||||
.node rect, .node path {{ fill: {primary}; stroke: {border}; }}
|
||||
.node polygon {{ fill: {primary}; stroke: {border}; }}
|
||||
.label-container path {{ fill: {primary}; stroke: {border}; }}
|
||||
{mindmap_css}
|
||||
.mindmap-node line, .timeline-node line {{ stroke: transparent !important; }}
|
||||
g.stateGroup rect {{ fill: {primary} !important; stroke: {border} !important; }}
|
||||
g.stateGroup text {{ fill: {text} !important; }}
|
||||
g.stateGroup .state-title {{ fill: {text} !important; }}
|
||||
.stateGroup .composit {{ fill: {background} !important; }}
|
||||
.stateGroup .alt-composit {{ fill: {tertiary} !important; }}
|
||||
.state-note {{ stroke: {note_border} !important; fill: {note_bg} !important; }}
|
||||
.state-note text {{ fill: {note_text} !important; }}
|
||||
.stateLabel .box {{ fill: {primary} !important; }}
|
||||
.stateLabel text {{ fill: {text} !important; }}
|
||||
.node circle.state-start {{ fill: {line} !important; stroke: {line} !important; }}
|
||||
.node .fork-join {{ fill: {line} !important; stroke: {line} !important; }}
|
||||
.node circle.state-end {{ fill: {border} !important; stroke: {background} !important; }}
|
||||
.end-state-inner {{ fill: {background} !important; }}
|
||||
.statediagram-cluster rect {{ fill: {primary} !important; stroke: {border} !important; }}
|
||||
.statediagram-cluster.statediagram-cluster .inner {{ fill: {background} !important; }}
|
||||
.statediagram-cluster.statediagram-cluster-alt .inner {{ fill: {tertiary} !important; }}
|
||||
.statediagram-state rect.divider {{ fill: {tertiary} !important; }}
|
||||
.statediagram-note rect {{ fill: {note_bg} !important; stroke: {note_border} !important; }}
|
||||
.statediagram-note text {{ fill: {note_text} !important; }}
|
||||
.statediagramTitleText {{ fill: {text} !important; }}
|
||||
.transition {{ stroke: {line} !important; }}
|
||||
.cluster-label, .nodeLabel {{ color: {text} !important; }}
|
||||
defs #statediagram-barbEnd {{ fill: {line} !important; stroke: {line} !important; }}
|
||||
#statediagram-barbEnd {{ fill: {line} !important; }}
|
||||
.edgeLabel .label rect {{ fill: {primary} !important; }}
|
||||
.edgeLabel rect {{ fill: {primary} !important; background-color: {primary} !important; }}
|
||||
.edgeLabel .label text {{ fill: {text} !important; }}
|
||||
.edgeLabel p {{ background-color: {primary} !important; }}
|
||||
.edgeLabel {{ background-color: {primary} !important; }}
|
||||
.actor {{ stroke: {actor_border}; fill: {actor_bg}; }}
|
||||
text.actor {{ text-anchor: middle; }}
|
||||
text.actor>tspan {{ fill: {actor_text} !important; stroke: none; }}
|
||||
.labelText, .labelText>tspan {{ fill: {actor_text} !important; }}
|
||||
.actor-line {{ stroke: {actor_border} !important; }}
|
||||
.messageLine0 {{ stroke: {text} !important; }}
|
||||
.messageLine1 {{ stroke: {text} !important; }}
|
||||
#arrowhead path {{ fill: {text} !important; stroke: {text} !important; }}
|
||||
#crosshead path {{ fill: {text} !important; stroke: {text} !important; }}
|
||||
.messageText {{ fill: {text} !important; }}
|
||||
.loopText, .loopText>tspan {{ fill: {text} !important; }}
|
||||
.loopLine {{ stroke: {actor_border} !important; fill: {actor_border} !important; }}
|
||||
.note {{ stroke: {note_border} !important; fill: {note_bg} !important; }}
|
||||
.noteText, .noteText>tspan {{ fill: {note_text} !important; }}
|
||||
.activation0, .activation1, .activation2 {{ fill: {secondary} !important; stroke: {border} !important; }}
|
||||
.labelBox {{ stroke: {actor_border} !important; fill: {actor_bg} !important; }}
|
||||
.actor-man line {{ stroke: {actor_border} !important; fill: {actor_bg} !important; }}
|
||||
.actor-man circle {{ stroke: {actor_border} !important; fill: {actor_bg} !important; }}
|
||||
.pieTitleText {{ fill: {text} !important; }}
|
||||
.slice {{ fill: {text} !important; }}
|
||||
.legend text {{ fill: {text} !important; }}
|
||||
.pieOuterCircle {{ stroke: {border} !important; }}
|
||||
.pieCircle {{ stroke: {border} !important; }}
|
||||
{timeline_css}
|
||||
text.journey-section, text.task {{ fill: {text} !important; }}
|
||||
.relationshipLabelBox {{ fill: {tertiary} !important; opacity: 0.7; background-color: {tertiary} !important; }}
|
||||
.labelBkg {{ background-color: {tertiary} !important; }}
|
||||
.edgeLabel .label {{ fill: {border} !important; }}
|
||||
.label {{ color: {text} !important; }}
|
||||
.relationshipLine {{ stroke: {line} !important; fill: none !important; }}
|
||||
.entityBox {{ fill: {primary}; stroke: {border}; }}
|
||||
.node .row-rect-odd path {{ fill: {er_odd} !important; }}
|
||||
.node .row-rect-even path {{ fill: {er_even} !important; }}
|
||||
.edge-thickness-normal {{ stroke-width: 1px; }}
|
||||
.relation {{ stroke: {line}; stroke-width: 1; fill: none; }}
|
||||
.edgePaths path {{ fill: none; }}
|
||||
.marker {{ fill: {line} !important; stroke: {line} !important; }}
|
||||
.marker.er {{ fill: none !important; stroke: {line} !important; }}
|
||||
.composition {{ fill: {line} !important; stroke: {line} !important; stroke-width: 1; }}
|
||||
.extension {{ fill: transparent !important; stroke: {line} !important; stroke-width: 1; }}
|
||||
.aggregation {{ fill: transparent !important; stroke: {line} !important; stroke-width: 1; }}
|
||||
.dependency {{ fill: {line} !important; stroke: {line} !important; stroke-width: 1; }}
|
||||
.lollipop {{ fill: {primary} !important; stroke: {line} !important; stroke-width: 1; }}
|
||||
.sectionTitle0, .sectionTitle1, .sectionTitle2, .sectionTitle3 {{ fill: {text} !important; }}
|
||||
.sectionTitle {{ font-family: {font} !important; }}
|
||||
.taskTextOutsideRight {{ fill: {text} !important; font-family: {font} !important; }}
|
||||
.taskTextOutsideLeft {{ fill: {text} !important; }}
|
||||
.active0, .active1, .active2, .active3 {{ fill: {secondary} !important; stroke: {border} !important; }}
|
||||
.activeText0, .activeText1, .activeText2, .activeText3 {{ fill: {text} !important; }}
|
||||
.done0, .done1, .done2, .done3 {{ stroke: {border} !important; fill: {secondary} !important; stroke-width: 2; }}
|
||||
.doneText0, .doneText1, .doneText2, .doneText3 {{ fill: {text} !important; }}
|
||||
.crit0, .crit1, .crit2, .crit3 {{ fill: {error} !important; stroke: {error} !important; }}
|
||||
.critText0, .critText1, .critText2, .critText3 {{ fill: {error_text} !important; }}
|
||||
.activeCrit0, .activeCrit1, .activeCrit2, .activeCrit3 {{ fill: {warning} !important; stroke: {warning} !important; }}
|
||||
.activeCritText0, .activeCritText1, .activeCritText2, .activeCritText3 {{ fill: {warning_text} !important; }}
|
||||
.doneCrit0, .doneCrit1, .doneCrit2, .doneCrit3 {{ fill: {error} !important; stroke: {border} !important; stroke-width: 2; }}
|
||||
.doneCritText0, .doneCritText1, .doneCritText2, .doneCritText3 {{ fill: {error_text} !important; }}
|
||||
.titleText {{ fill: {text} !important; font-family: {font} !important; }}
|
||||
.grid .tick text {{ fill: {text} !important; font-family: {font} !important; }}
|
||||
.grid .tick {{ stroke: {border} !important; }}
|
||||
{git_branch_css}
|
||||
.commit-merge {{ stroke: {primary}; fill: {primary}; }}
|
||||
.commit-reverse {{ stroke: {primary}; fill: {primary}; stroke-width: 3; }}
|
||||
.commit-highlight-inner {{ stroke: {primary}; fill: {primary}; }}
|
||||
.tag-label {{ font-size: 10px; }}
|
||||
.tag-label-bkg {{ fill: {primary}; stroke: {border}; }}
|
||||
.tag-hole {{ fill: {line}; }}
|
||||
.commit-label {{ fill: {text}; }}
|
||||
.commit-label-bkg {{ fill: {edge_label_bg}; }}
|
||||
.commit-id, .commit-msg, .branch-label {{ fill: {text}; color: {text}; font-family: {font}; }}
|
||||
{accent_css}
|
||||
.data-point text {{ fill: {text} !important; }}
|
||||
{chart_color_css}
|
||||
"#,
|
||||
mindmap_css = mindmap_section_css(theme),
|
||||
git_branch_css = git_branch_css(theme),
|
||||
accent_css = accent_css(theme),
|
||||
chart_color_css = chart_color_css(theme),
|
||||
timeline_css = timeline_css(theme),
|
||||
);
|
||||
|
||||
scope_css(&raw_css, svg_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scope_css_prefixes_selectors() {
|
||||
let input = " .foo { color: red; }\n";
|
||||
let result = scope_css(input, "my-svg");
|
||||
assert!(result.contains("#my-svg .foo"), "got: {result}");
|
||||
}
|
||||
}
|
||||
114
crates/mermaid_render/src/postprocess/strip_foreignobject.rs
Normal file
114
crates/mermaid_render/src/postprocess/strip_foreignobject.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
//! Strips `<foreignObject>` elements and their contents from the SVG, since
|
||||
//! `usvg`/`resvg` does not support them.
|
||||
//!
|
||||
//! ```xml
|
||||
//! <!-- before -->
|
||||
//! <foreignObject><div>Hello</div></foreignObject>
|
||||
//! <text class="nodeLabel">Hello</text>
|
||||
//!
|
||||
//! <!-- after -->
|
||||
//! <text class="nodeLabel">Hello</text>
|
||||
//! ```
|
||||
|
||||
use anyhow::Result;
|
||||
use quick_xml::events::Event;
|
||||
|
||||
struct StripForeignObject<I> {
|
||||
inner: I,
|
||||
/// Depth inside a `<foreignObject>` element being stripped.
|
||||
foreign_depth: usize,
|
||||
/// Depth inside a `<g data-merman-foreignobject="fallback">` being stripped.
|
||||
fallback_depth: usize,
|
||||
/// Set to true once we see a `<text>` element outside of foreignObjects
|
||||
/// and fallback groups. When true, fallback groups are redundant and
|
||||
/// should be stripped.
|
||||
has_native_text: bool,
|
||||
}
|
||||
|
||||
impl<'a, I: Iterator<Item = Result<Event<'a>>>> Iterator for StripForeignObject<I> {
|
||||
type Item = Result<Event<'a>>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
loop {
|
||||
let event = self.inner.next()?;
|
||||
let event = match event {
|
||||
Ok(event) => event,
|
||||
Err(e) => return Some(Err(e)),
|
||||
};
|
||||
|
||||
// Strip foreignObject elements and their contents.
|
||||
match &event {
|
||||
Event::Start(e) if e.name().as_ref() == b"foreignObject" => {
|
||||
self.foreign_depth += 1;
|
||||
continue;
|
||||
}
|
||||
Event::Start(_) if self.foreign_depth > 0 => {
|
||||
self.foreign_depth += 1;
|
||||
continue;
|
||||
}
|
||||
Event::End(_) if self.foreign_depth > 0 => {
|
||||
self.foreign_depth -= 1;
|
||||
continue;
|
||||
}
|
||||
Event::Empty(e) if e.name().as_ref() == b"foreignObject" => {
|
||||
continue;
|
||||
}
|
||||
_ if self.foreign_depth > 0 => {
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Strip fallback groups when native text exists.
|
||||
match &event {
|
||||
Event::Start(e) if e.name().as_ref() == b"g" && self.fallback_depth == 0 => {
|
||||
if self.has_native_text {
|
||||
if let Ok(Some(attr)) = e.try_get_attribute("data-merman-foreignobject") {
|
||||
if attr.value.as_ref() == b"fallback" {
|
||||
self.fallback_depth = 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Start(_) if self.fallback_depth > 0 => {
|
||||
self.fallback_depth += 1;
|
||||
continue;
|
||||
}
|
||||
Event::End(_) if self.fallback_depth > 0 => {
|
||||
self.fallback_depth -= 1;
|
||||
continue;
|
||||
}
|
||||
_ if self.fallback_depth > 0 => {
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Track whether the diagram has native <text> elements.
|
||||
if !self.has_native_text {
|
||||
match &event {
|
||||
Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"text" => {
|
||||
if e.try_get_attribute("class").ok().flatten().is_some() {
|
||||
self.has_native_text = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
return Some(Ok(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process<'a>(
|
||||
inner: impl Iterator<Item = Result<Event<'a>>>,
|
||||
) -> impl Iterator<Item = Result<Event<'a>>> {
|
||||
StripForeignObject {
|
||||
inner,
|
||||
foreign_depth: 0,
|
||||
fallback_depth: 0,
|
||||
has_native_text: false,
|
||||
}
|
||||
}
|
||||
161
crates/mermaid_render/src/postprocess/strip_invalid_css.rs
Normal file
161
crates/mermaid_render/src/postprocess/strip_invalid_css.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
//! Removes CSS constructs that `usvg`/`resvg` cannot handle.
|
||||
//!
|
||||
//! - `@keyframes` and `@-webkit-keyframes` blocks
|
||||
//! - `:root { ... }` blocks (CSS custom properties)
|
||||
//! - `:not(...)` pseudo-selectors
|
||||
//! - `deg` angle units (e.g. `rotate(45deg)` → `rotate(45)`)
|
||||
//!
|
||||
//! Also removes `!important` declarations (so that our injected theme CSS
|
||||
//! always wins).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use anyhow::Result;
|
||||
use quick_xml::events::{BytesText, Event};
|
||||
|
||||
struct StripInvalidCss<I> {
|
||||
inner: I,
|
||||
in_style: bool,
|
||||
}
|
||||
|
||||
impl<'a, I: Iterator<Item = Result<Event<'a>>>> Iterator for StripInvalidCss<I> {
|
||||
type Item = Result<Event<'a>>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let event = match self.inner.next()? {
|
||||
Ok(ev) => ev,
|
||||
Err(e) => return Some(Err(e)),
|
||||
};
|
||||
|
||||
match &event {
|
||||
Event::Start(e) if e.name().as_ref() == b"style" => {
|
||||
self.in_style = true;
|
||||
}
|
||||
Event::End(e) if e.name().as_ref() == b"style" => {
|
||||
self.in_style = false;
|
||||
}
|
||||
Event::Text(text) if self.in_style => {
|
||||
let css_text = match std::str::from_utf8(text.as_ref()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Some(Err(e.into())),
|
||||
};
|
||||
return Some(match strip_unsupported_css(css_text) {
|
||||
Cow::Borrowed(_) => Ok(event),
|
||||
Cow::Owned(processed) => Ok(Event::Text(BytesText::from_escaped(processed))),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Some(Ok(event))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process<'a>(
|
||||
events: impl Iterator<Item = Result<Event<'a>>>,
|
||||
) -> impl Iterator<Item = Result<Event<'a>>> {
|
||||
StripInvalidCss {
|
||||
inner: events,
|
||||
in_style: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_unsupported_css(css: &str) -> Cow<'_, str> {
|
||||
let mut chars = css.char_indices().peekable();
|
||||
let mut result = None;
|
||||
let mut copied_until = 0;
|
||||
|
||||
while let Some((i, _)) = chars.next() {
|
||||
let remaining = &css[i..];
|
||||
|
||||
if remaining.starts_with("@keyframes")
|
||||
|| remaining.starts_with("@-webkit-keyframes")
|
||||
|| remaining.starts_with(":root")
|
||||
{
|
||||
let result = result.get_or_insert_with(|| String::with_capacity(css.len()));
|
||||
result.push_str(&css[copied_until..i]);
|
||||
skip_css_block(&mut chars);
|
||||
copied_until = chars.peek().map_or(css.len(), |&(i, _)| i);
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = if let Some(mut result) = result {
|
||||
result.push_str(&css[copied_until..]);
|
||||
Cow::Owned(result)
|
||||
} else {
|
||||
Cow::Borrowed(css)
|
||||
};
|
||||
|
||||
strip_css_angle_units(&mut result);
|
||||
strip_css_important(&mut result);
|
||||
result
|
||||
}
|
||||
|
||||
fn skip_css_block(chars: &mut std::iter::Peekable<std::str::CharIndices>) {
|
||||
for (_, c) in chars.by_ref() {
|
||||
if c == '{' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut depth = 1u32;
|
||||
for (_, c) in chars.by_ref() {
|
||||
match c {
|
||||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_all_in_place(css: &mut Cow<'_, str>, needle: &str, replacement: &str) {
|
||||
while let Some(pos) = css.as_ref().find(needle) {
|
||||
css.to_mut()
|
||||
.replace_range(pos..pos + needle.len(), replacement);
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_css_angle_units(css: &mut Cow<'_, str>) {
|
||||
replace_all_in_place(css, "deg)", ")");
|
||||
}
|
||||
|
||||
/// Strip `!important` from mermaid's generated CSS so that our injected
|
||||
/// theme CSS (which uses `!important`) always takes priority. This works
|
||||
/// around a usvg cascade bug where competing `!important` rules are
|
||||
/// resolved by first-wins rather than the CSS spec's last-wins.
|
||||
fn strip_css_important(css: &mut Cow<'_, str>) {
|
||||
replace_all_in_place(css, "!important", "");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strips_keyframes() {
|
||||
let input = "@keyframes bounce { 0% { transform: scale(1); } 100% { transform: scale(1.1); } } .node rect { fill: red; }";
|
||||
let result = strip_unsupported_css(input);
|
||||
assert!(!result.contains("@keyframes"), "got: {result}");
|
||||
assert!(result.contains(".node rect"), "got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_root_blocks() {
|
||||
let input = ":root { --bg: white; } .foo { color: red; }";
|
||||
let result = strip_unsupported_css(input);
|
||||
assert!(!result.contains(":root"), "got: {result}");
|
||||
assert!(result.contains(".foo"), "got: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_deg_units() {
|
||||
let input = ".foo { transform: rotate(45deg); }";
|
||||
let result = strip_unsupported_css(input);
|
||||
assert!(result.contains("rotate(45)"), "got: {result}");
|
||||
assert!(!result.contains("deg"), "got: {result}");
|
||||
}
|
||||
}
|
||||
148
crates/mermaid_render/src/postprocess/util.rs
Normal file
148
crates/mermaid_render/src/postprocess/util.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
use gpui::{Hsla, Rgba};
|
||||
|
||||
/// Produces a readable text color for a given background, subtly tinted by the
|
||||
/// background's own hue using the OKLCH color space.
|
||||
///
|
||||
/// The result keeps ~15% of the background's chroma so the text feels
|
||||
/// harmonious with its surroundings rather than a flat black or white.
|
||||
/// Lightness is set to ensure readable contrast against the background.
|
||||
pub fn text_color_for_background(background: Hsla) -> Hsla {
|
||||
let rgba = Rgba::from(background);
|
||||
let r_lin = srgb_to_linear(rgba.r);
|
||||
let g_lin = srgb_to_linear(rgba.g);
|
||||
let b_lin = srgb_to_linear(rgba.b);
|
||||
|
||||
let (_, ok_a, ok_b) = linear_rgb_to_oklab(r_lin, g_lin, b_lin);
|
||||
let chroma = (ok_a * ok_a + ok_b * ok_b).sqrt();
|
||||
let hue = ok_b.atan2(ok_a);
|
||||
|
||||
let bg_luminance = relative_luminance(rgba);
|
||||
let text_l = if bg_luminance > 0.18 { 0.18 } else { 0.96 };
|
||||
let text_c = chroma * 0.15;
|
||||
|
||||
let build = |c: f32| -> Rgba {
|
||||
let (tr, tg, tb) = oklab_to_linear_rgb(text_l, c * hue.cos(), c * hue.sin());
|
||||
Rgba {
|
||||
r: linear_to_srgb(tr.clamp(0.0, 1.0)),
|
||||
g: linear_to_srgb(tg.clamp(0.0, 1.0)),
|
||||
b: linear_to_srgb(tb.clamp(0.0, 1.0)),
|
||||
a: 1.0,
|
||||
}
|
||||
};
|
||||
|
||||
let meets_contrast =
|
||||
|fg: Rgba| contrast_ratio_between(bg_luminance, relative_luminance(fg)) >= 4.5;
|
||||
|
||||
let candidate = build(text_c);
|
||||
let result = if meets_contrast(candidate) {
|
||||
candidate
|
||||
} else {
|
||||
// Binary search for the maximum chroma that still meets 4.5:1.
|
||||
let mut lo = 0.0_f32;
|
||||
let mut hi = text_c;
|
||||
for _ in 0..16 {
|
||||
let mid = (lo + hi) * 0.5;
|
||||
if meets_contrast(build(mid)) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
let best = build(lo);
|
||||
// Floating-point precision can leave the binary search result just
|
||||
// below the 4.5:1 threshold. Fall back to pure black or white.
|
||||
if meets_contrast(best) {
|
||||
best
|
||||
} else if bg_luminance > 0.18 {
|
||||
Rgba {
|
||||
r: 0.0,
|
||||
g: 0.0,
|
||||
b: 0.0,
|
||||
a: 1.0,
|
||||
}
|
||||
} else {
|
||||
Rgba {
|
||||
r: 1.0,
|
||||
g: 1.0,
|
||||
b: 1.0,
|
||||
a: 1.0,
|
||||
}
|
||||
}
|
||||
};
|
||||
Hsla::from(result)
|
||||
}
|
||||
|
||||
fn srgb_to_linear(c: f32) -> f32 {
|
||||
if c <= 0.04045 {
|
||||
c / 12.92
|
||||
} else {
|
||||
((c + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
}
|
||||
|
||||
fn linear_to_srgb(c: f32) -> f32 {
|
||||
if c <= 0.0031308 {
|
||||
c * 12.92
|
||||
} else {
|
||||
1.055 * c.powf(1.0 / 2.4) - 0.055
|
||||
}
|
||||
}
|
||||
|
||||
fn linear_rgb_to_oklab(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
||||
let l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b).cbrt();
|
||||
let m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b).cbrt();
|
||||
let s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b).cbrt();
|
||||
(
|
||||
0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
|
||||
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
|
||||
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
|
||||
)
|
||||
}
|
||||
|
||||
fn oklab_to_linear_rgb(l: f32, a: f32, b: f32) -> (f32, f32, f32) {
|
||||
let l_ = l + 0.3963377774 * a + 0.2158037573 * b;
|
||||
let m_ = l - 0.1055613458 * a - 0.0638541728 * b;
|
||||
let s_ = l - 0.0894841775 * a - 1.2914855480 * b;
|
||||
(
|
||||
4.0767416621 * l_ * l_ * l_ - 3.3077115913 * m_ * m_ * m_ + 0.2309699292 * s_ * s_ * s_,
|
||||
-1.2684380046 * l_ * l_ * l_ + 2.6097574011 * m_ * m_ * m_ - 0.3413193965 * s_ * s_ * s_,
|
||||
-0.0041960863 * l_ * l_ * l_ - 0.7034186147 * m_ * m_ * m_ + 1.7076147010 * s_ * s_ * s_,
|
||||
)
|
||||
}
|
||||
|
||||
fn relative_luminance(c: Rgba) -> f32 {
|
||||
0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b)
|
||||
}
|
||||
|
||||
fn contrast_ratio_between(luminance_a: f32, luminance_b: f32) -> f32 {
|
||||
let (lighter, darker) = if luminance_a > luminance_b {
|
||||
(luminance_a, luminance_b)
|
||||
} else {
|
||||
(luminance_b, luminance_a)
|
||||
};
|
||||
(lighter + 0.05) / (darker + 0.05)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn wcag_contrast_ratio(a: Rgba, b: Rgba) -> f32 {
|
||||
contrast_ratio_between(relative_luminance(a), relative_luminance(b))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::proptest::prelude::*;
|
||||
|
||||
#[gpui::property_test]
|
||||
fn sufficient_contrast_for_any_opaque_background(
|
||||
#[strategy = Hsla::opaque_strategy()] bg: Hsla,
|
||||
) -> Result<(), TestCaseError> {
|
||||
let text = text_color_for_background(bg);
|
||||
let ratio = wcag_contrast_ratio(Rgba::from(bg), Rgba::from(text));
|
||||
prop_assert!(
|
||||
ratio >= 4.5,
|
||||
"WCAG AA contrast ratio {ratio:.2} < 4.5 for bg {bg:?} -> text {text:?}",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
122
crates/mermaid_render/src/render.rs
Normal file
122
crates/mermaid_render/src/render.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
|
||||
use crate::{MermaidTheme, css_color};
|
||||
|
||||
pub(super) fn render_mermaid(source: &str, theme: &MermaidTheme) -> Result<String> {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let diagram_id = format!("merman-{id}");
|
||||
|
||||
let config = to_merman_config(theme);
|
||||
let renderer = merman::render::HeadlessRenderer::new()
|
||||
.with_site_config(config)
|
||||
.with_vendored_text_measurer()
|
||||
.with_diagram_id(&diagram_id);
|
||||
|
||||
let svg = renderer
|
||||
.render_svg_sync(source)
|
||||
.context("merman render failed")?
|
||||
.ok_or_else(|| anyhow!("merman returned no SVG for the given input"))?;
|
||||
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
fn to_merman_config(theme: &MermaidTheme) -> merman::MermaidConfig {
|
||||
let primary = css_color(theme.primary_color);
|
||||
let primary_text = css_color(theme.primary_text_color);
|
||||
let primary_border = css_color(theme.primary_border_color);
|
||||
let line = css_color(theme.line_color);
|
||||
let secondary = css_color(theme.secondary_color);
|
||||
let tertiary = css_color(theme.tertiary_color);
|
||||
let background = css_color(theme.background);
|
||||
let cluster_bg = css_color(theme.cluster_background);
|
||||
let cluster_border = css_color(theme.cluster_border);
|
||||
let edge_label_bg = css_color(theme.edge_label_background);
|
||||
let text = css_color(theme.text_color);
|
||||
let note_bg = css_color(theme.note_background);
|
||||
let note_border = css_color(theme.note_border);
|
||||
let actor_bg = css_color(theme.actor_background);
|
||||
let actor_border = css_color(theme.actor_border);
|
||||
let activation_bg = css_color(theme.activation_background);
|
||||
let activation_border = css_color(theme.activation_border);
|
||||
let er_odd = css_color(theme.er_attr_bg_odd);
|
||||
let er_even = css_color(theme.er_attr_bg_even);
|
||||
let git: [String; 8] = theme.git_branch_colors.map(css_color);
|
||||
let git_lbl: [String; 8] = theme.git_branch_label_colors.map(css_color);
|
||||
|
||||
let mut theme_vars = serde_json::json!({
|
||||
"primaryColor": primary,
|
||||
"primaryTextColor": primary_text,
|
||||
"primaryBorderColor": primary_border,
|
||||
"lineColor": line,
|
||||
"secondaryColor": secondary,
|
||||
"secondaryTextColor": text,
|
||||
"tertiaryColor": tertiary,
|
||||
"tertiaryTextColor": text,
|
||||
"background": background,
|
||||
"mainBkg": primary,
|
||||
"nodeBorder": primary_border,
|
||||
"nodeTextColor": primary_text,
|
||||
"clusterBkg": cluster_bg,
|
||||
"clusterBorder": cluster_border,
|
||||
"titleColor": text,
|
||||
"edgeLabelBackground": edge_label_bg,
|
||||
"textColor": text,
|
||||
"fontFamily": theme.font_family,
|
||||
"noteBkgColor": note_bg,
|
||||
"noteBorderColor": note_border,
|
||||
"noteTextColor": text,
|
||||
"actorBkg": actor_bg,
|
||||
"actorBorder": actor_border,
|
||||
"actorTextColor": primary_text,
|
||||
"labelTextColor": text,
|
||||
"loopTextColor": text,
|
||||
"signalColor": text,
|
||||
"signalTextColor": text,
|
||||
"activationBkgColor": activation_bg,
|
||||
"activationBorderColor": activation_border,
|
||||
"classText": text,
|
||||
"labelColor": primary_text,
|
||||
"attributeBackgroundColorOdd": er_odd,
|
||||
"attributeBackgroundColorEven": er_even,
|
||||
"pieTitleTextColor": text,
|
||||
"pieSectionTextColor": text,
|
||||
"pieLegendTextColor": text,
|
||||
"pieStrokeColor": primary_border,
|
||||
"pieOuterStrokeColor": primary_border,
|
||||
"quadrant1Fill": primary,
|
||||
"quadrant2Fill": primary,
|
||||
"quadrant3Fill": primary,
|
||||
"quadrant4Fill": primary,
|
||||
"quadrant1TextFill": text,
|
||||
"quadrant2TextFill": text,
|
||||
"quadrant3TextFill": text,
|
||||
"quadrant4TextFill": text,
|
||||
"quadrantPointFill": line,
|
||||
"quadrantPointTextFill": text,
|
||||
"quadrantTitleFill": text,
|
||||
"quadrantXAxisTextFill": text,
|
||||
"quadrantYAxisTextFill": text,
|
||||
"quadrantExternalBorderStrokeFill": primary_border,
|
||||
"quadrantInternalBorderStrokeFill": primary_border,
|
||||
});
|
||||
|
||||
let map = theme_vars.as_object_mut().expect("just created as object");
|
||||
for i in 0..8 {
|
||||
map.insert(format!("cScale{i}"), git[i].clone().into());
|
||||
map.insert(format!("cScaleLabel{i}"), git_lbl[i].clone().into());
|
||||
map.insert(format!("pie{}", i + 1), git[i].clone().into());
|
||||
}
|
||||
|
||||
merman::MermaidConfig::from_value(serde_json::json!({
|
||||
"theme": "base",
|
||||
"darkMode": theme.dark_mode,
|
||||
"fontFamily": theme.font_family,
|
||||
"flowchart": {
|
||||
"padding": 16,
|
||||
},
|
||||
"themeVariables": theme_vars,
|
||||
}))
|
||||
}
|
||||
394
crates/mermaid_render/tests/check_invalid_attrs.rs
Normal file
394
crates/mermaid_render/tests/check_invalid_attrs.rs
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
use gpui::Hsla;
|
||||
use mermaid_render::MermaidTheme;
|
||||
|
||||
fn rgb(r: u8, g: u8, b: u8) -> Hsla {
|
||||
gpui::Rgba {
|
||||
r: r as f32 / 255.0,
|
||||
g: g as f32 / 255.0,
|
||||
b: b as f32 / 255.0,
|
||||
a: 1.0,
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
const DIAGRAMS: &[(&str, &str)] = &[
|
||||
(
|
||||
"flowchart",
|
||||
"flowchart TD\n A[Hello] --> B[World]\n B --> C{Decision}\n C -->|Yes| D[OK]\n C -->|No| E[Fail]",
|
||||
),
|
||||
(
|
||||
"sequence",
|
||||
"sequenceDiagram\n Alice->>Bob: Hello\n Bob-->>Alice: Hi\n Note over Alice,Bob: A note",
|
||||
),
|
||||
(
|
||||
"state",
|
||||
"stateDiagram-v2\n [*] --> Active\n Active --> [*]",
|
||||
),
|
||||
(
|
||||
"er",
|
||||
"erDiagram\n A { int id PK }\n B { int id PK }\n A ||--o{ B : has",
|
||||
),
|
||||
(
|
||||
"class",
|
||||
"classDiagram\n class Foo {\n +bar() void\n }",
|
||||
),
|
||||
("pie", "pie title Test\n \"A\" : 42\n \"B\" : 58"),
|
||||
(
|
||||
"gantt",
|
||||
"gantt\n title Test\n dateFormat YYYY-MM-DD\n section S\n Task :a1, 2025-01-01, 7d",
|
||||
),
|
||||
("mindmap", "mindmap\n root((Root))\n Child1\n Child2"),
|
||||
(
|
||||
"journey",
|
||||
"journey\n title Test\n section S\n Task: 5: Actor",
|
||||
),
|
||||
(
|
||||
"gitgraph",
|
||||
"gitGraph\n commit id: \"init\"\n branch dev\n commit id: \"feat\"\n checkout main\n merge dev",
|
||||
),
|
||||
(
|
||||
"quadrant",
|
||||
"quadrantChart\n title Test\n x-axis Low --> High\n y-axis Low --> High\n A: [0.3, 0.8]\n B: [0.7, 0.4]",
|
||||
),
|
||||
(
|
||||
"timeline",
|
||||
"timeline\n title Test\n section 2020s\n 2020 : Event A\n 2022 : Event B",
|
||||
),
|
||||
(
|
||||
"xychart",
|
||||
"xychart-beta\n title Test\n x-axis [\"A\", \"B\", \"C\"]\n y-axis \"Val\" 0 --> 10\n bar [3, 7, 5]",
|
||||
),
|
||||
];
|
||||
|
||||
fn rgb_theme() -> MermaidTheme {
|
||||
MermaidTheme {
|
||||
dark_mode: true,
|
||||
font_family: "system-ui".to_string(),
|
||||
background: rgb(40, 44, 51),
|
||||
primary_color: rgb(47, 52, 62),
|
||||
primary_text_color: rgb(220, 224, 229),
|
||||
primary_border_color: rgb(70, 75, 87),
|
||||
secondary_color: rgb(46, 52, 62),
|
||||
tertiary_color: rgb(54, 60, 70),
|
||||
line_color: rgb(70, 75, 87),
|
||||
text_color: rgb(220, 224, 229),
|
||||
edge_label_background: rgb(40, 44, 51),
|
||||
cluster_background: rgb(47, 52, 62),
|
||||
cluster_border: rgb(54, 60, 70),
|
||||
note_background: rgb(47, 52, 62),
|
||||
note_border: rgb(54, 60, 70),
|
||||
actor_background: rgb(46, 52, 62),
|
||||
actor_border: rgb(70, 75, 87),
|
||||
activation_background: rgb(54, 60, 70),
|
||||
activation_border: rgb(70, 75, 87),
|
||||
git_branch_colors: [
|
||||
rgb(116, 173, 232),
|
||||
rgb(190, 80, 70),
|
||||
rgb(191, 149, 106),
|
||||
rgb(180, 119, 207),
|
||||
rgb(110, 180, 191),
|
||||
rgb(208, 114, 119),
|
||||
rgb(222, 193, 132),
|
||||
rgb(161, 193, 129),
|
||||
],
|
||||
git_branch_label_colors: [
|
||||
rgb(116, 173, 232),
|
||||
rgb(190, 80, 70),
|
||||
rgb(191, 149, 106),
|
||||
rgb(180, 119, 207),
|
||||
rgb(110, 180, 191),
|
||||
rgb(208, 114, 119),
|
||||
rgb(222, 193, 132),
|
||||
rgb(161, 193, 129),
|
||||
]
|
||||
.map(mermaid_render::text_color_for_background),
|
||||
er_attr_bg_odd: rgb(47, 52, 62),
|
||||
er_attr_bg_even: rgb(46, 52, 62),
|
||||
error_color: rgb(220, 38, 38),
|
||||
warning_color: rgb(217, 119, 6),
|
||||
accent_colors: vec![
|
||||
mermaid_render::AccentColor {
|
||||
foreground: rgb(116, 173, 232),
|
||||
background: rgb(116, 173, 232),
|
||||
},
|
||||
mermaid_render::AccentColor {
|
||||
foreground: rgb(190, 80, 70),
|
||||
background: rgb(190, 80, 70),
|
||||
},
|
||||
mermaid_render::AccentColor {
|
||||
foreground: rgb(191, 149, 106),
|
||||
background: rgb(191, 149, 106),
|
||||
},
|
||||
mermaid_render::AccentColor {
|
||||
foreground: rgb(180, 119, 207),
|
||||
background: rgb(180, 119, 207),
|
||||
},
|
||||
mermaid_render::AccentColor {
|
||||
foreground: rgb(110, 180, 191),
|
||||
background: rgb(110, 180, 191),
|
||||
},
|
||||
mermaid_render::AccentColor {
|
||||
foreground: rgb(208, 114, 119),
|
||||
background: rgb(208, 114, 119),
|
||||
},
|
||||
mermaid_render::AccentColor {
|
||||
foreground: rgb(222, 193, 132),
|
||||
background: rgb(222, 193, 132),
|
||||
},
|
||||
mermaid_render::AccentColor {
|
||||
foreground: rgb(161, 193, 129),
|
||||
background: rgb(161, 193, 129),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn check_svg_issues(name: &str, svg: &str) -> Vec<String> {
|
||||
let bad_patterns = [
|
||||
"fill=\"\"",
|
||||
"stroke=\"\"",
|
||||
"width=\"\"",
|
||||
"height=\"\"",
|
||||
"NaN",
|
||||
// Also check for empty values in style attributes
|
||||
"fill: ;",
|
||||
"fill:;",
|
||||
"stroke: ;",
|
||||
"stroke:;",
|
||||
// Check for attributes with just whitespace
|
||||
"fill=\" \"",
|
||||
];
|
||||
let mut issues = Vec::new();
|
||||
for pattern in &bad_patterns {
|
||||
let mut start = 0;
|
||||
while let Some(pos) = svg[start..].find(pattern) {
|
||||
let abs = start + pos;
|
||||
let ctx_start = abs.saturating_sub(100);
|
||||
let ctx_end = (abs + pattern.len() + 60).min(svg.len());
|
||||
issues.push(format!(
|
||||
"{name}: found `{pattern}` at byte {abs}:\n ...{}...\n",
|
||||
&svg[ctx_start..ctx_end]
|
||||
));
|
||||
start = abs + pattern.len();
|
||||
}
|
||||
}
|
||||
|
||||
// Parse with quick-xml to find ANY empty attribute values on visual elements
|
||||
use quick_xml::events::Event;
|
||||
let mut reader = quick_xml::Reader::from_str(svg);
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(Event::Eof) => break,
|
||||
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
|
||||
let tag = String::from_utf8_lossy(e.name().local_name().as_ref()).to_string();
|
||||
for attr in e.attributes().flatten() {
|
||||
let key = String::from_utf8_lossy(attr.key.local_name().as_ref()).to_string();
|
||||
let val = attr.unescape_value().unwrap_or_default();
|
||||
let visual_attr = matches!(
|
||||
key.as_str(),
|
||||
"fill"
|
||||
| "stroke"
|
||||
| "width"
|
||||
| "height"
|
||||
| "x"
|
||||
| "y"
|
||||
| "r"
|
||||
| "cx"
|
||||
| "cy"
|
||||
| "rx"
|
||||
| "ry"
|
||||
| "stroke-width"
|
||||
);
|
||||
if visual_attr && val.is_empty() {
|
||||
issues.push(format!("{name}: <{tag}> has empty {key}=\"\"\n"));
|
||||
}
|
||||
// Check for CSS length units that usvg can't parse
|
||||
if visual_attr
|
||||
&& matches!(key.as_str(), "width" | "height")
|
||||
&& val.ends_with("px")
|
||||
{
|
||||
issues.push(format!("{name}: <{tag}> has {key}=\"{val}\" (px suffix)\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
issues.push(format!("{name}: XML parse error: {e}\n"));
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
issues
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accent_colors_auto_applied_to_nodes() {
|
||||
let theme = rgb_theme();
|
||||
|
||||
// A plain state diagram with no :::accent syntax should get
|
||||
// automatic accent colors applied to its node groups.
|
||||
let source = "stateDiagram-v2\n [*] --> Idle\n Idle --> Processing\n Processing --> Done\n Done --> [*]";
|
||||
|
||||
let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed");
|
||||
|
||||
// accent_fill_and_text darkens the background color for dark mode.
|
||||
// The stroke colors are direct hex conversions of the accent rgb values.
|
||||
// With 3 states (Idle, Processing, Done), we expect at least accent0 and
|
||||
// accent1 stroke colors to appear.
|
||||
let accent0_stroke = "#74ade8"; // rgb(116, 173, 232) -> hex
|
||||
let accent1_stroke = "#be5046"; // rgb(190, 80, 70) -> hex
|
||||
|
||||
assert!(
|
||||
svg.contains(accent0_stroke),
|
||||
"Expected accent0 stroke color ({accent0_stroke}) in auto-colored state diagram SVG.\n\
|
||||
This means auto-coloring did not apply accent colors to node groups.\n\
|
||||
SVG snippet: {}...",
|
||||
&svg[..svg.len().min(2000)]
|
||||
);
|
||||
assert!(
|
||||
svg.contains(accent1_stroke),
|
||||
"Expected accent1 stroke color ({accent1_stroke}) in auto-colored state diagram SVG."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generics_not_double_escaped() {
|
||||
let theme = rgb_theme();
|
||||
let source = "classDiagram\n class Shelter {\n -List~Animal~ animals\n +adopt(Animal a) bool\n }";
|
||||
let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed");
|
||||
assert!(
|
||||
!svg.contains("&lt;"),
|
||||
"Double-escaped &lt; found in SVG"
|
||||
);
|
||||
assert!(
|
||||
!svg.contains("&gt;"),
|
||||
"Double-escaped &gt; found in SVG"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backslash_n_converted_to_line_break() {
|
||||
let theme = rgb_theme();
|
||||
let source = r#"graph TD
|
||||
L7["Layer 7\nHTTP, FTP"]
|
||||
L6["Layer 6\nEncryption"]
|
||||
L7 --> L6"#;
|
||||
let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed");
|
||||
assert!(
|
||||
!svg.contains(r"\n"),
|
||||
"Literal \\n should not appear in SVG output"
|
||||
);
|
||||
assert!(
|
||||
svg.contains(">Layer 7<") && svg.contains(">HTTP, FTP<"),
|
||||
"Label lines should be split into separate <text> elements"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_diagram_fallback_text_uses_accent_classes() {
|
||||
let theme = rgb_theme();
|
||||
let source = r#"classDiagram
|
||||
class Animal {
|
||||
+String name
|
||||
+makeSound() void
|
||||
}
|
||||
class Dog {
|
||||
+String breed
|
||||
+bark() void
|
||||
}
|
||||
Dog --|> Animal"#;
|
||||
|
||||
let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed");
|
||||
|
||||
use quick_xml::events::Event;
|
||||
let mut reader = quick_xml::Reader::from_str(&svg);
|
||||
let mut in_fallback = false;
|
||||
let mut accent_classes: Vec<String> = Vec::new();
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(Event::Eof) => break,
|
||||
Ok(Event::Start(e)) => {
|
||||
if e.name().as_ref() == b"g" {
|
||||
if let Ok(Some(attr)) = e.try_get_attribute("data-merman-foreignobject") {
|
||||
if attr.value.as_ref() == b"fallback" {
|
||||
in_fallback = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if in_fallback && e.name().as_ref() == b"text" {
|
||||
if let Ok(Some(class_attr)) = e.try_get_attribute("class") {
|
||||
let class = class_attr.unescape_value().unwrap_or_default().to_string();
|
||||
for token in class.split_whitespace() {
|
||||
if token.starts_with("zed-accent-") {
|
||||
accent_classes.push(token.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::End(e)) if e.name().as_ref() == b"g" => {
|
||||
in_fallback = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
!accent_classes.is_empty(),
|
||||
"expected zed-accent-N classes on text elements in fallback groups",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_diagram_tspan_uses_accent_classes() {
|
||||
let theme = rgb_theme();
|
||||
let source = "sequenceDiagram\n participant Database";
|
||||
let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed");
|
||||
|
||||
use quick_xml::events::Event;
|
||||
let mut reader = quick_xml::Reader::from_str(&svg);
|
||||
let mut accent_classes: Vec<String> = Vec::new();
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(Event::Eof) => break,
|
||||
Ok(Event::Start(e)) if e.name().as_ref() == b"tspan" => {
|
||||
if let Ok(Some(class_attr)) = e.try_get_attribute("class") {
|
||||
let class = class_attr.unescape_value().unwrap_or_default().to_string();
|
||||
for token in class.split_whitespace() {
|
||||
if token.starts_with("zed-accent-") {
|
||||
accent_classes.push(token.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
!accent_classes.is_empty(),
|
||||
"expected zed-accent-N classes on tspan elements in sequence diagram",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_empty_attributes_or_nan_with_rgb_theme() {
|
||||
let theme = rgb_theme();
|
||||
let mut all_issues = Vec::new();
|
||||
|
||||
for (name, source) in DIAGRAMS {
|
||||
match mermaid_render::render_to_svg(source, &theme) {
|
||||
Ok(svg) => all_issues.extend(check_svg_issues(name, &svg)),
|
||||
Err(e) => eprintln!("{name}: render failed (skipped): {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
if !all_issues.is_empty() {
|
||||
panic!(
|
||||
"Found {} issues in merman SVG output (rgb theme):\n\n{}",
|
||||
all_issues.len(),
|
||||
all_issues.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -98,6 +98,10 @@ extend-ignore-re = [
|
|||
# Yarn Plug'n'Play
|
||||
"PnP",
|
||||
# `image` crate method: Delay::from_numer_denom_ms
|
||||
"numer"
|
||||
"numer",
|
||||
# Abbreviation for foreignObject in mermaid SVG processing
|
||||
"fo",
|
||||
# Mermaid CSS class name for state diagram composites
|
||||
"composit"
|
||||
]
|
||||
check-filename = true
|
||||
|
|
|
|||
Loading…
Reference in a new issue