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]]
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
//! `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 -->
|
||||
|
|
|
|||
|
|
@ -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_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("&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