markdown: use merman svg pipeline

This commit is contained in:
Latias94 2026-05-28 22:21:29 +08:00
parent f0ed342c19
commit 8d04dd1267
12 changed files with 161 additions and 564 deletions

24
Cargo.lock generated
View file

@ -5453,9 +5453,9 @@ dependencies = [
[[package]]
name = "dugong"
version = "0.4.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f5b0a9f36306eb29685e6e27b82df4d0bb5af64261324f7d5f7716d7c39ba1b"
checksum = "d91e3c577a3882067a324ad82c17e4298d9bfe27ea00c91e779aa11b06ed147b"
dependencies = [
"dugong-graphlib",
"rustc-hash 2.1.1",
@ -5465,9 +5465,9 @@ dependencies = [
[[package]]
name = "dugong-graphlib"
version = "0.4.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75aca4df30a85b3ba8cead498f4e38e9de4aff630155ce47515d11dfd729c6ea"
checksum = "953d90d1fb6aaf7bbb0620817d1ab0df21f942f05b5daef002a837eea79dd89d"
dependencies = [
"hashbrown 0.16.1",
"rustc-hash 2.1.1",
@ -10982,9 +10982,9 @@ dependencies = [
[[package]]
name = "manatee"
version = "0.4.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c5ed3cc0bf5f911d242bed4b4cdf12c00186e471e9fdf57d9dd0a033bbbdc87"
checksum = "5bbf81c24c22f1431376011564efe5af26956a569af09c3569e56f245741d01d"
dependencies = [
"indexmap 2.11.4",
"nalgebra",
@ -11279,9 +11279,9 @@ dependencies = [
[[package]]
name = "merman"
version = "0.4.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3209bcfe9c8e9787a7534f8d97f1d27f7d2fdd54d3c49d9f59bb693aedabbf95"
checksum = "cbd4316c1491c7f442c583422ca332d4bcc04e40564a6b3987de726fc13d41d0"
dependencies = [
"merman-core",
"merman-render",
@ -11290,9 +11290,9 @@ dependencies = [
[[package]]
name = "merman-core"
version = "0.4.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72fc438439ca428b449486f8eaf9ce2f8ab76cea159181d0b1b454e1192e4649"
checksum = "2daac03445212c750c25a78b56fc3e44f64f2a277bd6e156357a702804b6dba1"
dependencies = [
"chrono",
"euclid",
@ -11317,9 +11317,9 @@ dependencies = [
[[package]]
name = "merman-render"
version = "0.4.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5698e2681196051479ae8bc5153ed7eda6490f56240fd8d92da7c3932a00735"
checksum = "870f785361c55f33f8a3b73c4f1582c6054dcf8eff1380e578f9da53f927d239"
dependencies = [
"base64 0.22.1",
"chrono",

View file

@ -18,7 +18,7 @@ test-support = []
[dependencies]
anyhow.workspace = true
gpui.workspace = true
merman = { version = "0.4", features = ["render"] }
merman = { version = "0.6", features = ["render"] }
quick-xml.workspace = true
serde_json.workspace = true

View file

@ -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.

View file

@ -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.
//!
@ -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};
@ -27,27 +24,20 @@ 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);
// merman's resvg-safe pipeline removes foreignObject elements. Keep this
// Zed pass because it also drops fallback overlay groups when native SVG
// text is already present, avoiding duplicate rasterized labels.
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()));
@ -111,26 +101,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}"
);
}
}

View file

@ -146,8 +146,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> {

View file

@ -1,11 +1,14 @@
use anyhow::Result;
use quick_xml::events::Event;
use super::{NodeTracker, accent_class_name, add_class, add_to_event, parse_translate};
use super::{
AccentStackEntry, NodeTracker, accent_class_name, add_class, add_to_event,
is_foreign_object_fallback_group, parse_translate,
};
pub(crate) struct ClassDiagramAccents {
accent_count: usize,
accent_g_stack: Vec<Option<usize>>,
accent_g_stack: Vec<AccentStackEntry>,
node_counter: usize,
nodes: NodeTracker,
current_text_accent: Option<usize>,
@ -29,6 +32,11 @@ impl ClassDiagramAccents {
match &event {
Event::Start(e) if e.name().as_ref() == b"g" => {
if is_foreign_object_fallback_group(e)? {
self.accent_g_stack.push(AccentStackEntry::none());
return Ok(event);
}
let is_node = if let Some(class_attr) = e.try_get_attribute("class")? {
let class = class_attr.unescape_value()?;
class
@ -42,22 +50,26 @@ impl ClassDiagramAccents {
let accent_idx = self.node_counter % self.accent_count;
self.node_counter += 1;
if let Some((cx, cy)) = parse_translate(e) {
let tracks_node = if let Some((cx, cy)) = parse_translate(e) {
self.nodes.start_node(cx, cy, 30.0, accent_idx);
}
true
} else {
false
};
self.accent_g_stack.push(Some(accent_idx));
self.accent_g_stack
.push(AccentStackEntry::accent(accent_idx, tracks_node));
let new_elem = add_class(e, &accent_class_name(accent_idx))?;
return Ok(Event::Start(new_elem));
}
self.accent_g_stack.push(None);
self.accent_g_stack.push(AccentStackEntry::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() {
if entry.tracks_node() {
self.nodes.finish_node();
}
}

View file

@ -1,11 +1,11 @@
use anyhow::Result;
use quick_xml::events::{BytesStart, Event};
use super::NodeTracker;
use super::{AccentStackEntry, NodeTracker};
pub(super) struct MindmapAccents {
section_classes: Vec<String>,
section_g_stack: Vec<Option<usize>>,
section_g_stack: Vec<AccentStackEntry>,
nodes: NodeTracker,
current_text_section: Option<usize>,
}
@ -23,21 +23,30 @@ impl MindmapAccents {
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" => {
if super::is_foreign_object_fallback_group(e)? {
self.section_g_stack.push(AccentStackEntry::none());
return Ok(event);
}
let section_idx = self.parse_section_class(e)?;
if let Some(idx) = section_idx {
if let Some((tx, ty)) = super::parse_translate(e) {
let tracks_node = 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));
true
} else {
false
};
self.section_g_stack
.push(AccentStackEntry::accent(idx, tracks_node));
} else {
self.section_g_stack.push(None);
self.section_g_stack.push(AccentStackEntry::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() {
if let Some(entry) = self.section_g_stack.pop() {
if entry.tracks_node() {
self.nodes.finish_node();
}
}

View file

@ -1,223 +0,0 @@
//! 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&amp;lt;T&amp;gt;</text>
//! </g>
//!
//! <!-- after -->
//! <g data-merman-foreignobject="fallback">
//! <text>List&lt;T&gt;</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("&amp;lt;") || self.text_buffer.contains("&amp;gt;")
{
let fixed = self
.text_buffer
.replace("&amp;lt;", "&lt;")
.replace("&amp;gt;", "&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&amp;lt;Animal&amp;gt; animals</text></g>"##;
let result = run_fixup(svg);
assert!(
!result.contains("&amp;lt;"),
"Should fix double-escaped entities, got: {result}"
);
assert!(
result.contains("&lt;"),
"Should contain single-escaped entity, got: {result}"
);
}
#[test]
fn preserves_text_outside_fallback_group() {
let svg = r##"<text>-List&amp;lt;Animal&amp;gt;</text>"##;
let result = run_fixup(svg);
assert!(
result.contains("&amp;lt;"),
"Should not fix entities outside fallback group, got: {result}"
);
}
}

View file

@ -1,96 +0,0 @@
//! 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)
}

View file

@ -1,5 +1,10 @@
//! Strips `<foreignObject>` elements and their contents from the SVG, since
//! `usvg`/`resvg` does not support them.
//! Strips any remaining `<foreignObject>` elements and redundant fallback groups
//! from the SVG.
//!
//! merman's raster-safe SVG pipeline removes `<foreignObject>` elements before
//! Zed-specific post-processing runs. This pass is kept as a defensive filter
//! and to remove fallback overlay groups when the same label is already present
//! as native SVG text.
//!
//! ```xml
//! <!-- before -->

View file

@ -1,161 +0,0 @@
//! 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}");
}
}

View file

@ -14,9 +14,16 @@ pub(super) fn render_mermaid(source: &str, theme: &MermaidTheme) -> Result<Strin
.with_site_config(config)
.with_vendored_text_measurer()
.with_diagram_id(&diagram_id);
// Apply merman's raster-safe pipeline before Zed-specific styling. The
// pipeline handles generic rasterizer compatibility cleanup: foreignObject
// fallback text, unsupported CSS removal, and invalid SVG attribute cleanup.
// Zed also strips merman's existing `!important` declarations before
// injecting its own theme CSS so host styling wins consistently in usvg/resvg.
let pipeline = merman::render::SvgPipeline::resvg_safe()
.with_postprocessor(merman::render::CssOverridePostprocessor::strip_existing_important());
let svg = renderer
.render_svg_sync(source)
.render_svg_with_pipeline_sync(source, &pipeline)
.context("merman render failed")?
.ok_or_else(|| anyhow!("merman returned no SVG for the given input"))?;
@ -120,3 +127,39 @@ fn to_merman_config(theme: &MermaidTheme) -> merman::MermaidConfig {
"themeVariables": theme_vars,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_stage_applies_resvg_safe_pipeline() {
let html_label_source =
"classDiagram\n class Shelter {\n -List~Animal~ animals\n }";
let html_label_svg =
render_mermaid(html_label_source, &MermaidTheme::default()).expect("render failed");
assert!(
!html_label_svg.contains("<foreignObject"),
"got: {html_label_svg}"
);
assert!(
html_label_svg.contains(r#"data-merman-foreignobject="fallback""#),
"got: {html_label_svg}"
);
assert!(
!html_label_svg.contains("&amp;lt;"),
"got: {html_label_svg}"
);
let css_source = "sequenceDiagram\n Alice->>Bob: Hello\n Bob-->>Alice: Hi";
let css_svg = render_mermaid(css_source, &MermaidTheme::default()).expect("render failed");
assert!(!css_svg.contains("@keyframes"), "got: {css_svg}");
assert!(!css_svg.contains("@-webkit-keyframes"), "got: {css_svg}");
assert!(!css_svg.contains(":root"), "got: {css_svg}");
assert!(!css_svg.contains("animation:"), "got: {css_svg}");
assert!(!css_svg.contains("animation-name:"), "got: {css_svg}");
assert!(!css_svg.contains("!important"), "got: {css_svg}");
}
}