Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/mermaid_render/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ test-support = []
[dependencies]
anyhow.workspace = true
gpui.workspace = true
merman = { git = "https://github.com/zed-industries/merman", rev = "1c765dcca2ef5092fcde7bebe8374819563623ef", features = ["render"] }
merman = { git = "https://github.com/zed-industries/merman", tag = "v0.6.2-with-patches", features = ["render"] }
quick-xml.workspace = true
serde_json.workspace = true

Expand Down
42 changes: 32 additions & 10 deletions crates/mermaid_render/src/mermaid_render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,21 @@
//!
//! 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.
//! rendering in Zed.
//!
//! 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.
//! Historically, this crate also carried generic `usvg`/`resvg` cleanup for SVG
//! constructs that merman's parity output could emit, such as HTML labels in
//! `<foreignObject>` and CSS/attribute forms that rasterizers do not handle.
//! Since merman 0.6, that generic cleanup is exposed as merman's raster-safe SVG
//! pipeline. Zed opts into that pipeline during rendering, then keeps
//! editor-specific theme and accent color rules in this crate. 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.
//! - [`render`] the mermaid text to raster-safe SVG using [`merman`].
//! - [`postprocess`] the SVG to add Zed theme and accent styling.
//!
//! The postprocessing is also split up into stages. We parse the generated SVG
//! Zed's postprocessing is 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.
Expand Down Expand Up @@ -179,3 +180,24 @@ pub fn render_to_svg(source: &str, theme: &MermaidTheme) -> Result<String> {
let svg = postprocess::postprocess(&svg, theme)?;
Ok(svg)
}

#[cfg(test)]
mod tests {
use super::*;

/// A flowchart with mutually nested subgraphs (`A` contains `B` and `B`
/// contains `A`) is an invalid containment cycle. Rendering it must return
/// gracefully rather than overflowing the stack and aborting the process.
#[test]
fn cyclic_subgraphs_do_not_crash() {
let source = "flowchart TD\n subgraph A\n B\n end\n subgraph B\n A\n end";
let result = render_to_svg(source, &MermaidTheme::default());
if let Err(err) = result {
let message = format!("{err:#}");
assert!(
message.contains("cycle"),
"expected a cycle-related error, got: {message}"
);
}
}
}
52 changes: 10 additions & 42 deletions crates/mermaid_render/src/postprocess.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Post-processing of [`merman`]-produced SVGs for rasterization with `usvg`/`resvg`.
//! Zed-specific post-processing of [`merman`]-produced SVGs.
//!
//! Each submodule is a specific pass that tweaks the SVG event iterator in a particular way.
//!
Expand All @@ -13,11 +13,8 @@

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};
Expand All @@ -27,27 +24,21 @@ 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)?;
// merman 0.6 already applies the generic resvg-safe cleanup before this point.
// The remaining passes are Zed-specific theme and accent adjustments.
let svg_id = extract_svg_id(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);
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);
// merman's resvg-safe pipeline already removes foreignObject elements and
// replaces their labels with native <text> fallback groups. This pass keeps
// those fallback labels, but drops any that merely duplicate a native
// <text> (e.g. user journey renders some labels both ways).
let events = strip_foreignobject::process(events, svg);
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()));
Expand Down Expand Up @@ -111,26 +102,3 @@ impl<'a> Iterator for ReaderIter<'a> {
}
}
}

#[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}"
);
}
}
91 changes: 68 additions & 23 deletions crates/mermaid_render/src/postprocess/accent_colors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,14 @@ 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()?;
// The path data starts with `M x,y ...`; the y coordinate is the second
// whitespace/comma-separated token.
let y: f64 = rest
.split([' ', ','])
.filter(|token| !token.is_empty())
.nth(1)?
.parse()
.ok()?;
Some(y.abs())
}

Expand Down Expand Up @@ -146,8 +145,48 @@ pub(crate) fn add_class<'a>(e: &BytesStart<'_>, class_to_add: &str) -> Result<By
Ok(new_elem)
}

pub(crate) fn current_stack_accent(stack: &[Option<usize>]) -> Option<usize> {
stack.iter().rev().find_map(|entry| *entry)
#[derive(Debug, Clone, Copy)]
pub(crate) struct AccentStackEntry {
/// The accent inherited by nested text, even when the group has no layout geometry.
accent_idx: Option<usize>,
/// Whether this group called `NodeTracker::start_node` and must be finished later.
tracks_node: bool,
}

impl AccentStackEntry {
pub fn none() -> Self {
Self {
accent_idx: None,
tracks_node: false,
}
}

pub fn accent(accent_idx: usize, tracks_node: bool) -> Self {
Self {
accent_idx: Some(accent_idx),
tracks_node,
}
}

pub fn tracks_node(self) -> bool {
self.tracks_node
}

fn accent_idx(self) -> Option<usize> {
self.accent_idx
}
}

pub(crate) fn current_stack_accent(stack: &[AccentStackEntry]) -> Option<usize> {
stack.iter().rev().find_map(|entry| entry.accent_idx())
}

// merman's fallback overlay groups intentionally preserve source classes such
// as `node` and `section-*` so host CSS can style fallback text. They are not
// layout nodes though, so accent tracking must not count them as new nodes.
pub(crate) fn is_foreign_object_fallback_group(e: &BytesStart<'_>) -> Result<bool> {
Ok(e.try_get_attribute("data-merman-foreignobject")?
.is_some_and(|attr| attr.value.as_ref() == b"fallback"))
}

pub(crate) fn lookup_position_accent(node_rects: &[NodeRect], e: &BytesStart<'_>) -> Option<usize> {
Expand Down Expand Up @@ -217,9 +256,9 @@ enum Handler {
Sequence(sequence_diagram::SequenceDiagramAccents),
}

struct AccentColors<I> {
struct AccentColors<'theme, I> {
inner: I,
theme: MermaidTheme,
theme: &'theme MermaidTheme,
handler: Handler,
in_legend: bool,
legend_color_idx: usize,
Expand All @@ -230,19 +269,25 @@ struct AccentColors<I> {
quadrant_point_idx: usize,
}

impl<'a, I: Iterator<Item = Result<Event<'a>>>> AccentColors<I> {
impl<'a, 'theme, I: Iterator<Item = Result<Event<'a>>>> AccentColors<'theme, 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 {
let is_start = matches!(event, Event::Start(_));
// Only a real opening tag increases nesting depth. Self-closing `<g/>`
// elements have no matching `</g>`, so counting them would leave
// `plot_depth` permanently inflated and `in_plot` stuck on.
if self.in_plot && is_start {
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;
if is_start && !self.in_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" {
Expand All @@ -259,7 +304,7 @@ impl<'a, I: Iterator<Item = Result<Event<'a>>>> AccentColors<I> {

Event::End(e) if e.name().as_ref() == b"g" => {
if self.in_plot {
self.plot_depth -= 1;
self.plot_depth = self.plot_depth.saturating_sub(1);
if self.plot_depth == 0 {
self.in_plot = false;
}
Expand Down Expand Up @@ -306,7 +351,7 @@ impl<'a, I: Iterator<Item = Result<Event<'a>>>> AccentColors<I> {
}
}

impl<'a, I: Iterator<Item = Result<Event<'a>>>> Iterator for AccentColors<I> {
impl<'a, 'theme, I: Iterator<Item = Result<Event<'a>>>> Iterator for AccentColors<'theme, I> {
type Item = Result<Event<'a>>;

fn next(&mut self) -> Option<Self::Item> {
Expand Down Expand Up @@ -356,13 +401,13 @@ impl<'a, I: Iterator<Item = Result<Event<'a>>>> Iterator for AccentColors<I> {
}
}

pub(super) fn process<'a>(
pub(super) fn process<'a, 'theme>(
events: impl Iterator<Item = Result<Event<'a>>>,
theme: &MermaidTheme,
theme: &'theme MermaidTheme,
) -> impl Iterator<Item = Result<Event<'a>>> {
AccentColors {
inner: events,
theme: theme.clone(),
theme,
handler: Handler::Pending,
in_legend: false,
legend_color_idx: 0,
Expand Down
Loading
Loading