mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-31 19:05:00 +07:00
markdown: use merman svg pipeline
This commit is contained in:
parent
f0ed342c19
commit
8d04dd1267
12 changed files with 161 additions and 564 deletions
24
Cargo.lock
generated
24
Cargo.lock
generated
|
|
@ -5453,9 +5453,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dugong"
|
name = "dugong"
|
||||||
version = "0.4.0"
|
version = "0.6.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f5b0a9f36306eb29685e6e27b82df4d0bb5af64261324f7d5f7716d7c39ba1b"
|
checksum = "d91e3c577a3882067a324ad82c17e4298d9bfe27ea00c91e779aa11b06ed147b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dugong-graphlib",
|
"dugong-graphlib",
|
||||||
"rustc-hash 2.1.1",
|
"rustc-hash 2.1.1",
|
||||||
|
|
@ -5465,9 +5465,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dugong-graphlib"
|
name = "dugong-graphlib"
|
||||||
version = "0.4.0"
|
version = "0.6.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "75aca4df30a85b3ba8cead498f4e38e9de4aff630155ce47515d11dfd729c6ea"
|
checksum = "953d90d1fb6aaf7bbb0620817d1ab0df21f942f05b5daef002a837eea79dd89d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
"rustc-hash 2.1.1",
|
"rustc-hash 2.1.1",
|
||||||
|
|
@ -10982,9 +10982,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "manatee"
|
name = "manatee"
|
||||||
version = "0.4.0"
|
version = "0.6.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6c5ed3cc0bf5f911d242bed4b4cdf12c00186e471e9fdf57d9dd0a033bbbdc87"
|
checksum = "5bbf81c24c22f1431376011564efe5af26956a569af09c3569e56f245741d01d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.11.4",
|
"indexmap 2.11.4",
|
||||||
"nalgebra",
|
"nalgebra",
|
||||||
|
|
@ -11279,9 +11279,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "merman"
|
name = "merman"
|
||||||
version = "0.4.0"
|
version = "0.6.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3209bcfe9c8e9787a7534f8d97f1d27f7d2fdd54d3c49d9f59bb693aedabbf95"
|
checksum = "cbd4316c1491c7f442c583422ca332d4bcc04e40564a6b3987de726fc13d41d0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"merman-core",
|
"merman-core",
|
||||||
"merman-render",
|
"merman-render",
|
||||||
|
|
@ -11290,9 +11290,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "merman-core"
|
name = "merman-core"
|
||||||
version = "0.4.0"
|
version = "0.6.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72fc438439ca428b449486f8eaf9ce2f8ab76cea159181d0b1b454e1192e4649"
|
checksum = "2daac03445212c750c25a78b56fc3e44f64f2a277bd6e156357a702804b6dba1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"euclid",
|
"euclid",
|
||||||
|
|
@ -11317,9 +11317,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "merman-render"
|
name = "merman-render"
|
||||||
version = "0.4.0"
|
version = "0.6.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b5698e2681196051479ae8bc5153ed7eda6490f56240fd8d92da7c3932a00735"
|
checksum = "870f785361c55f33f8a3b73c4f1582c6054dcf8eff1380e578f9da53f927d239"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ test-support = []
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
merman = { version = "0.4", features = ["render"] }
|
merman = { version = "0.6", features = ["render"] }
|
||||||
quick-xml.workspace = true
|
quick-xml.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,20 +16,21 @@
|
||||||
//!
|
//!
|
||||||
//! This module uses the [`merman`] crate for rendering, rather than
|
//! This module uses the [`merman`] crate for rendering, rather than
|
||||||
//! `mermaid-rs`, which was used in the previous implementation of mermaid
|
//! `mermaid-rs`, which was used in the previous implementation of mermaid
|
||||||
//! rendering in Zed. Merman provides significantly more accurate rendering, and
|
//! rendering in Zed.
|
||||||
//! 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
|
//! Historically, this crate also carried generic `usvg`/`resvg` cleanup for SVG
|
||||||
//! has few dependencies. In fact, the [`gpui`] dependency is only needed for
|
//! constructs that merman's parity output could emit, such as HTML labels in
|
||||||
//! the [`Hsla`] and [`Rgba`] color types.
|
//! `<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:
|
//! The [`render_to_svg`] function operates in two stages:
|
||||||
//! - [`render`] the mermaid text to SVG using [`merman`].
|
//! - [`render`] the mermaid text to raster-safe SVG using [`merman`].
|
||||||
//! - [`postprocess`] the SVG to clean incorrect output and add styling.
|
//! - [`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
|
//! using [`quick_xml`], which produces an iterator of
|
||||||
//! [`Event<'_>`](quick_xml::events::Event)s. This iterator is then repeatedly
|
//! [`Event<'_>`](quick_xml::events::Event)s. This iterator is then repeatedly
|
||||||
//! transformed, and finally collected back into an SVG string.
|
//! transformed, and finally collected back into an SVG string.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
//! Each submodule is a specific pass that tweaks the SVG event iterator in a particular way.
|
||||||
//!
|
//!
|
||||||
|
|
@ -13,11 +13,8 @@
|
||||||
|
|
||||||
mod accent_colors;
|
mod accent_colors;
|
||||||
mod element_fixup;
|
mod element_fixup;
|
||||||
mod fallback_fixup;
|
|
||||||
mod foreignobject_wrap;
|
|
||||||
mod inject_css;
|
mod inject_css;
|
||||||
mod strip_foreignobject;
|
mod strip_foreignobject;
|
||||||
mod strip_invalid_css;
|
|
||||||
pub(crate) mod util;
|
pub(crate) mod util;
|
||||||
|
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
|
|
@ -27,27 +24,20 @@ use quick_xml::events::Event;
|
||||||
use crate::MermaidTheme;
|
use crate::MermaidTheme;
|
||||||
|
|
||||||
pub(super) fn postprocess(svg: &str, theme: &MermaidTheme) -> Result<String> {
|
pub(super) fn postprocess(svg: &str, theme: &MermaidTheme) -> Result<String> {
|
||||||
// Pass 1: foreignObject preparation (\n fix + word wrapping)
|
// merman 0.6 already applies the generic resvg-safe cleanup before this point.
|
||||||
let svg = foreignobject_wrap::process(svg)?;
|
// The remaining passes are Zed-specific theme and accent adjustments.
|
||||||
|
let svg_id = extract_svg_id(svg);
|
||||||
|
|
||||||
// Add <text> fallbacks alongside <foreignObject> elements
|
let mut reader = Reader::from_str(svg);
|
||||||
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;
|
reader.config_mut().check_end_names = false;
|
||||||
let events = ReaderIter::new(reader);
|
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 = strip_foreignobject::process(events);
|
||||||
let events = fallback_fixup::process(events, theme);
|
|
||||||
let events = element_fixup::process(events, theme);
|
let events = element_fixup::process(events, theme);
|
||||||
|
|
||||||
let events = accent_colors::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 events = inject_css::process(events, theme, &svg_id);
|
||||||
|
|
||||||
let mut writer = quick_xml::Writer::new(Vec::with_capacity(svg.len()));
|
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}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -146,8 +146,48 @@ pub(crate) fn add_class<'a>(e: &BytesStart<'_>, class_to_add: &str) -> Result<By
|
||||||
Ok(new_elem)
|
Ok(new_elem)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn current_stack_accent(stack: &[Option<usize>]) -> Option<usize> {
|
#[derive(Debug, Clone, Copy)]
|
||||||
stack.iter().rev().find_map(|entry| *entry)
|
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> {
|
pub(crate) fn lookup_position_accent(node_rects: &[NodeRect], e: &BytesStart<'_>) -> Option<usize> {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use quick_xml::events::Event;
|
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 {
|
pub(crate) struct ClassDiagramAccents {
|
||||||
accent_count: usize,
|
accent_count: usize,
|
||||||
accent_g_stack: Vec<Option<usize>>,
|
accent_g_stack: Vec<AccentStackEntry>,
|
||||||
node_counter: usize,
|
node_counter: usize,
|
||||||
nodes: NodeTracker,
|
nodes: NodeTracker,
|
||||||
current_text_accent: Option<usize>,
|
current_text_accent: Option<usize>,
|
||||||
|
|
@ -29,6 +32,11 @@ impl ClassDiagramAccents {
|
||||||
|
|
||||||
match &event {
|
match &event {
|
||||||
Event::Start(e) if e.name().as_ref() == b"g" => {
|
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 is_node = if let Some(class_attr) = e.try_get_attribute("class")? {
|
||||||
let class = class_attr.unescape_value()?;
|
let class = class_attr.unescape_value()?;
|
||||||
class
|
class
|
||||||
|
|
@ -42,22 +50,26 @@ impl ClassDiagramAccents {
|
||||||
let accent_idx = self.node_counter % self.accent_count;
|
let accent_idx = self.node_counter % self.accent_count;
|
||||||
self.node_counter += 1;
|
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);
|
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))?;
|
let new_elem = add_class(e, &accent_class_name(accent_idx))?;
|
||||||
return Ok(Event::Start(new_elem));
|
return Ok(Event::Start(new_elem));
|
||||||
}
|
}
|
||||||
|
|
||||||
self.accent_g_stack.push(None);
|
self.accent_g_stack.push(AccentStackEntry::none());
|
||||||
Ok(event)
|
Ok(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
Event::End(e) if e.name().as_ref() == b"g" => {
|
Event::End(e) if e.name().as_ref() == b"g" => {
|
||||||
if let Some(entry) = self.accent_g_stack.pop() {
|
if let Some(entry) = self.accent_g_stack.pop() {
|
||||||
if entry.is_some() {
|
if entry.tracks_node() {
|
||||||
self.nodes.finish_node();
|
self.nodes.finish_node();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use quick_xml::events::{BytesStart, Event};
|
use quick_xml::events::{BytesStart, Event};
|
||||||
|
|
||||||
use super::NodeTracker;
|
use super::{AccentStackEntry, NodeTracker};
|
||||||
|
|
||||||
pub(super) struct MindmapAccents {
|
pub(super) struct MindmapAccents {
|
||||||
section_classes: Vec<String>,
|
section_classes: Vec<String>,
|
||||||
section_g_stack: Vec<Option<usize>>,
|
section_g_stack: Vec<AccentStackEntry>,
|
||||||
nodes: NodeTracker,
|
nodes: NodeTracker,
|
||||||
current_text_section: Option<usize>,
|
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>> {
|
pub(super) fn process_event<'a>(&mut self, event: Event<'a>) -> Result<Event<'a>> {
|
||||||
match &event {
|
match &event {
|
||||||
Event::Start(e) if e.name().as_ref() == b"g" => {
|
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)?;
|
let section_idx = self.parse_section_class(e)?;
|
||||||
if let Some(idx) = section_idx {
|
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.nodes.start_node(tx, ty, 0.0, idx);
|
||||||
}
|
true
|
||||||
self.section_g_stack.push(Some(idx));
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
self.section_g_stack
|
||||||
|
.push(AccentStackEntry::accent(idx, tracks_node));
|
||||||
} else {
|
} else {
|
||||||
self.section_g_stack.push(None);
|
self.section_g_stack.push(AccentStackEntry::none());
|
||||||
}
|
}
|
||||||
Ok(event)
|
Ok(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
Event::End(e) if e.name().as_ref() == b"g" => {
|
Event::End(e) if e.name().as_ref() == b"g" => {
|
||||||
if let Some(maybe_section) = self.section_g_stack.pop() {
|
if let Some(entry) = self.section_g_stack.pop() {
|
||||||
if maybe_section.is_some() {
|
if entry.tracks_node() {
|
||||||
self.nodes.finish_node();
|
self.nodes.finish_node();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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&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}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
//! Strips `<foreignObject>` elements and their contents from the SVG, since
|
//! Strips any remaining `<foreignObject>` elements and redundant fallback groups
|
||||||
//! `usvg`/`resvg` does not support them.
|
//! 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
|
//! ```xml
|
||||||
//! <!-- before -->
|
//! <!-- before -->
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -14,9 +14,16 @@ pub(super) fn render_mermaid(source: &str, theme: &MermaidTheme) -> Result<Strin
|
||||||
.with_site_config(config)
|
.with_site_config(config)
|
||||||
.with_vendored_text_measurer()
|
.with_vendored_text_measurer()
|
||||||
.with_diagram_id(&diagram_id);
|
.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
|
let svg = renderer
|
||||||
.render_svg_sync(source)
|
.render_svg_with_pipeline_sync(source, &pipeline)
|
||||||
.context("merman render failed")?
|
.context("merman render failed")?
|
||||||
.ok_or_else(|| anyhow!("merman returned no SVG for the given input"))?;
|
.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,
|
"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("&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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue