509 lines · 21.3 KB · Rust
RawDownload
//! Builds a theme-aware CSS stylesheet and appends it into the SVG's `<style>`//! element. All selectors are scoped to the SVG's `id` to prevent leaking.//!//! ```xml//! <!-- before -->//! <style>.node rect { fill: white; }</style>//!//! <!-- after -->//! <style>.node rect { fill: white; }//! #mermaid-1 .node rect { fill: #89b4fa !important; }//! /* ... theme rules ... *///! </style>//! ``` use std::collections::VecDeque;use std::fmt::Write; use anyhow::Result;use quick_xml::events::{BytesText, Event}; use crate::MermaidTheme; /// Morally equivalent to `format!(".section-{i}")`, but without allocatingconst MINDMAP_SECTION_SELECTORS: [&str; 11] = [    ".section-0",    ".section-1",    ".section-2",    ".section-3",    ".section-4",    ".section-5",    ".section-6",    ".section-7",    ".section-8",    ".section-9",    ".section-10",]; struct InjectCss<'a, I> {    inner: I,    injected_css: String,    in_style: bool,    injected: bool,    pending: VecDeque<Event<'a>>,} impl<'a, I: Iterator<Item = Result<Event<'a>>>> Iterator for InjectCss<'a, I> {    type Item = Result<Event<'a>>;     fn next(&mut self) -> Option<Self::Item> {        if let Some(event) = self.pending.pop_front() {            return Some(Ok(event));        }         let event = match self.inner.next()? {            Ok(ev) => ev,            Err(e) => return Some(Err(e)),        };         match &event {            Event::Start(e) if e.name().as_ref() == b"style" => {                self.in_style = true;                return Some(Ok(event));            }            Event::End(e) if e.name().as_ref() == b"style" => {                self.in_style = false;                if !self.injected {                    self.injected = true;                    self.pending                        .push_back(Event::Text(BytesText::from_escaped(std::mem::take(                            &mut self.injected_css,                        ))));                    self.pending.push_back(event);                    return self.pending.pop_front().map(Ok);                }                return Some(Ok(event));            }            Event::Text(text) if self.in_style => {                self.injected = true;                let existing = match std::str::from_utf8(text.as_ref()) {                    Ok(s) => s,                    Err(e) => return Some(Err(e.into())),                };                let mut combined = String::with_capacity(existing.len() + self.injected_css.len());                combined.push_str(existing);                combined.push_str(&self.injected_css);                return Some(Ok(Event::Text(BytesText::from_escaped(combined))));            }            _ => {}        }         Some(Ok(event))    }} pub(super) fn process<'a>(    events: impl Iterator<Item = Result<Event<'a>>>,    theme: &MermaidTheme,    svg_id: &str,) -> impl Iterator<Item = Result<Event<'a>>> {    let injected_css = build_injected_css(theme, svg_id);    InjectCss {        inner: events,        injected_css,        in_style: false,        injected: false,        pending: VecDeque::new(),    }} fn mindmap_section_css(theme: &MermaidTheme) -> String {    let colors: [String; 8] = theme.git_branch_colors.map(crate::css_color);    let fills: [String; 8] = theme.git_branch_colors.map(|c| {        crate::css_color(blend_over_background(            c,            theme.background,            ACCENT_FILL_OPACITY,        ))    });    let text = crate::css_color(theme.text_color);    let mut css = String::with_capacity(5_400);     let emit = |css: &mut String, selector: &str, color: &str, fill: &str, txt: &str| {        let section_index = selector            .trim_start_matches(".section-root.section-")            .trim_start_matches(".section-");        write!(            css,            "{selector} rect, {selector} path, {selector} circle, {selector} polygon \             {{ fill: {fill} !important; stroke: {color} !important; }}\n\             {selector} text, {selector} span, \             text{selector}, tspan{selector} \             {{ fill: {txt} !important; color: {txt} !important; }}\n\             {selector} foreignObject div, {selector} foreignObject span, {selector} foreignObject p \             {{ color: {txt} !important; }}\n\             .section-edge{section_index} {{ stroke: {color} !important; }}\n",        )        .expect("write to String cannot fail");    };     emit(        &mut css,        ".section-root.section--1",        &colors[0],        &fills[0],        &text,    );    emit(&mut css, ".section--1", &colors[1], &fills[1], &text);    for (i, selector) in MINDMAP_SECTION_SELECTORS.iter().enumerate() {        let ci = 2 + (i % 6);        emit(&mut css, selector, &colors[ci], &fills[ci], &text);    }    css} fn git_branch_css(theme: &MermaidTheme) -> String {    let text = crate::css_color(theme.text_color);    let mut css = String::with_capacity(8 * 200);    for i in 0..8 {        let c = crate::css_color(theme.git_branch_colors[i]);        let label_fill = crate::css_color(blend_over_background(            theme.git_branch_colors[i],            theme.background,            ACCENT_FILL_OPACITY,        ));        write!(            css,            ".commit{i} {{ stroke: {c}; fill: {c}; }}\n\             .arrow{i} {{ stroke: {c}; }}\n\             .label{i} {{ fill: {label_fill}; stroke: {c}; }}\n\             .branch-label{i} {{ fill: {text}; }}\n"        )        .expect("write to String cannot fail");    }    css} fn adjust_lightness(color: &mut gpui::Hsla, dark_mode: bool) {    if dark_mode {        color.l = (color.l * 0.7).max(0.0);    } else {        color.l = (color.l * 1.3).min(1.0);    }} const ACCENT_FILL_OPACITY: f32 = 0.15; fn blend_over_background(    foreground: gpui::Hsla,    background: gpui::Hsla,    opacity: f32,) -> gpui::Hsla {    let fg = gpui::Rgba::from(foreground);    let bg = gpui::Rgba::from(background);    let blended = gpui::Rgba {        r: fg.r * opacity + bg.r * (1.0 - opacity),        g: fg.g * opacity + bg.g * (1.0 - opacity),        b: fg.b * opacity + bg.b * (1.0 - opacity),        a: 1.0,    };    gpui::Hsla::from(blended)} fn accent_css(theme: &MermaidTheme) -> String {    let mut css = String::with_capacity(theme.accent_colors.len() * 420);    let text = crate::css_color(theme.text_color);     for (i, accent) in theme.accent_colors.iter().enumerate() {        let stroke = crate::css_color(accent.foreground);        let fill = crate::css_color(blend_over_background(            accent.background,            theme.background,            ACCENT_FILL_OPACITY,        ));        let class = format!(".zed-accent-{i}");        write!(            css,            "{class} rect, {class} path, {class} circle, {class} polygon, {class} ellipse, \             rect{class}, path{class}, circle{class}, polygon{class}, ellipse{class} \             {{ fill: {fill} !important; stroke: {stroke} !important; }}\n\             {class} text, {class} tspan, text{class}, tspan{class} \             {{ fill: {text} !important; }}\n",        )        .expect("write to String cannot fail");    }    css} fn chart_color_css(theme: &MermaidTheme) -> String {    // Each block is around 230 bytes, add some headroom    let mut css = String::with_capacity(8 * 250);    for i in 0..8 {        let color = crate::css_color(theme.git_branch_colors[i]);        let class = format!(".zed-chart-{i}");        write!(            css,            "path.pieCircle{class} {{ fill: {color} !important; }}\n\             .plot rect{class}, .legend rect{class} {{ fill: {color} !important; stroke: {color} !important; }}\n\             .plot path{class} {{ stroke: {color} !important; }}\n"        )        .expect("write to String cannot fail");    }    css} fn timeline_css(theme: &MermaidTheme) -> String {    let mut css = String::with_capacity(8 * 300);    let text = crate::css_color(theme.text_color);    for i in 0..8 {        let c = crate::css_color(theme.git_branch_colors[i]);        let fill = crate::css_color(blend_over_background(            theme.git_branch_colors[i],            theme.background,            ACCENT_FILL_OPACITY,        ));        write!(            css,            "rect.task-type-{i}, rect.section-type-{i} {{ fill: {fill} !important; stroke: {c} !important; }}\n"        ).expect("write to String cannot fail");    }    for i in 0..4 {        let c = crate::css_color(theme.git_branch_colors[i % 8]);        let fill = crate::css_color(blend_over_background(            theme.git_branch_colors[i % 8],            theme.background,            ACCENT_FILL_OPACITY,        ));        write!(            css,            ".section{i} {{ fill: {fill} !important; }}\n\             .task{i} {{ fill: {fill} !important; stroke: {c} !important; }}\n\             .taskText{i} {{ fill: {text} !important; }}\n\             .taskTextOutside{i} {{ fill: {text} !important; }}\n"        )        .expect("write to String cannot fail");    }    css} fn should_scope_css_line(trimmed: &str) -> bool {    !trimmed.is_empty()        && (trimmed.starts_with('.')            || trimmed.starts_with("foreignObject")            || trimmed.starts_with("g.")            || trimmed.starts_with("text")            || trimmed.starts_with("tspan")            || trimmed.starts_with("rect.")            || trimmed.starts_with("path.")            || trimmed.starts_with("defs")            || trimmed.starts_with('#'))} fn scoped_selector_count(raw_css: &str) -> usize {    raw_css.lines().fold(0, |count, line| {        let trimmed = line.trim();        if !should_scope_css_line(trimmed) {            return count;        }        let Some((selectors, _)) = trimmed.split_once('{') else {            return count;        };        count.saturating_add(selectors.split(',').count())    })} fn scope_css(raw_css: &str, svg_id: &str) -> String {    let scoped_selector_prefix_len = svg_id.len().saturating_add(2);    let result_capacity = raw_css        .len()        .saturating_add(scoped_selector_count(raw_css).saturating_mul(scoped_selector_prefix_len));    let mut result = String::with_capacity(result_capacity);    for line in raw_css.lines() {        let trimmed = line.trim();         if should_scope_css_line(trimmed) {            if let Some(brace) = trimmed.find('{') {                let (selectors, rest) = trimmed.split_at(brace);                let mut first = true;                for selector in selectors.split(',') {                    if !first {                        result.push_str(", ");                    }                    first = false;                    write!(result, "#{svg_id} {}", selector.trim())                        .expect("write to String cannot fail");                }                writeln!(result, "{rest}").expect("write to String cannot fail");                continue;            }        }        writeln!(result, "{line}").expect("write to String cannot fail");    }    result} fn build_injected_css(theme: &MermaidTheme, svg_id: &str) -> String {    let font = &theme.font_family;    let text = crate::css_color(theme.text_color);    let line = crate::css_color(theme.line_color);    let primary = crate::css_color(theme.primary_color);    let border = crate::css_color(theme.primary_border_color);    let secondary = crate::css_color(theme.secondary_color);    let tertiary = crate::css_color(theme.tertiary_color);    let background = crate::css_color(theme.background);    let edge_label_bg = crate::css_color(theme.edge_label_background);    let actor_bg = crate::css_color(theme.actor_background);    let actor_border = crate::css_color(theme.actor_border);    let error_bg = {        let mut c = theme.error_color;        adjust_lightness(&mut c, theme.dark_mode);        c    };    let error = crate::css_color(error_bg);    let error_text = crate::css_color(crate::postprocess::util::text_color_for_background(        error_bg,    ));    let warning_bg = {        let mut c = theme.warning_color;        adjust_lightness(&mut c, theme.dark_mode);        c    };    let warning = crate::css_color(warning_bg);    let warning_text = crate::css_color(crate::postprocess::util::text_color_for_background(        warning_bg,    ));    let note_bg = crate::css_color(theme.note_background);    let note_border = crate::css_color(theme.note_border);    let er_odd = crate::css_color(theme.er_attr_bg_odd);    let er_even = crate::css_color(theme.er_attr_bg_even);     let actor_text = &text;    let note_text = &text;     let raw_css = format!(        r#"        text, tspan, foreignObject div, foreignObject span, foreignObject p {{ font-family: {font} !important; }}        foreignObject div, foreignObject span, foreignObject p {{ font-size: 16px; color: {text}; }}        foreignObject p {{ margin: 0; }}        foreignObject {{ overflow: visible; }}        foreignObject div {{ max-width: none !important; }}        .label-group foreignObject {{ font-weight: bold; }}        .node rect, .node path {{ fill: {primary}; stroke: {border}; }}        .node polygon {{ fill: {primary}; stroke: {border}; }}        .label-container path {{ fill: {primary}; stroke: {border}; }}        {mindmap_css}        .mindmap-node line, .timeline-node line {{ stroke: transparent !important; }}        g.stateGroup rect {{ fill: {primary} !important; stroke: {border} !important; }}        g.stateGroup text {{ fill: {text} !important; }}        g.stateGroup .state-title {{ fill: {text} !important; }}        .stateGroup .composit {{ fill: {background} !important; }}        .stateGroup .alt-composit {{ fill: {tertiary} !important; }}        .state-note {{ stroke: {note_border} !important; fill: {note_bg} !important; }}        .state-note text {{ fill: {note_text} !important; }}        .stateLabel .box {{ fill: {primary} !important; }}        .stateLabel text {{ fill: {text} !important; }}        .node circle.state-start {{ fill: {line} !important; stroke: {line} !important; }}        .node .fork-join {{ fill: {line} !important; stroke: {line} !important; }}        .node circle.state-end {{ fill: {border} !important; stroke: {background} !important; }}        .end-state-inner {{ fill: {background} !important; }}        .statediagram-cluster rect {{ fill: {primary} !important; stroke: {border} !important; }}        .statediagram-cluster.statediagram-cluster .inner {{ fill: {background} !important; }}        .statediagram-cluster.statediagram-cluster-alt .inner {{ fill: {tertiary} !important; }}        .statediagram-state rect.divider {{ fill: {tertiary} !important; }}        .statediagram-note rect {{ fill: {note_bg} !important; stroke: {note_border} !important; }}        .statediagram-note text {{ fill: {note_text} !important; }}        .statediagramTitleText {{ fill: {text} !important; }}        .transition {{ stroke: {line} !important; }}        .cluster-label, .nodeLabel {{ color: {text} !important; }}        defs #statediagram-barbEnd {{ fill: {line} !important; stroke: {line} !important; }}        #statediagram-barbEnd {{ fill: {line} !important; }}        .edgeLabel .label rect {{ fill: {edge_label_bg} !important; opacity: 1 !important; }}        .edgeLabel rect {{ fill: {edge_label_bg} !important; opacity: 1 !important; background-color: {edge_label_bg} !important; }}        .edgeLabel .label text {{ fill: {text} !important; }}        .edgeLabel p {{ background-color: {primary} !important; }}        .edgeLabel {{ background-color: {primary} !important; }}        .actor {{ stroke: {actor_border}; fill: {actor_bg}; }}        text.actor {{ text-anchor: middle; }}        text.actor>tspan {{ fill: {actor_text} !important; stroke: none; }}        .labelText, .labelText>tspan {{ fill: {actor_text} !important; }}        .actor-line {{ stroke: {actor_border} !important; }}        .messageLine0 {{ stroke: {text} !important; }}        .messageLine1 {{ stroke: {text} !important; }}        #arrowhead path {{ fill: {text} !important; stroke: {text} !important; }}        #crosshead path {{ fill: {text} !important; stroke: {text} !important; }}        .messageText {{ fill: {text} !important; }}        .loopText, .loopText>tspan {{ fill: {text} !important; }}        .loopLine {{ stroke: {actor_border} !important; fill: {actor_border} !important; }}        .note {{ stroke: {note_border} !important; fill: {note_bg} !important; }}        .noteText, .noteText>tspan {{ fill: {note_text} !important; }}        .activation0, .activation1, .activation2 {{ fill: {secondary} !important; stroke: {border} !important; }}        .labelBox {{ stroke: {actor_border} !important; fill: {actor_bg} !important; }}        .actor-man line {{ stroke: {actor_border} !important; fill: {actor_bg} !important; }}        .actor-man circle {{ stroke: {actor_border} !important; fill: {actor_bg} !important; }}        .pieTitleText {{ fill: {text} !important; }}        .slice {{ fill: {text} !important; }}        .legend text {{ fill: {text} !important; }}        .pieOuterCircle {{ stroke: {border} !important; }}        .pieCircle {{ stroke: {border} !important; }}        {timeline_css}        text.journey-section, text.task {{ fill: {text} !important; }}        .relationshipLabelBox {{ fill: {tertiary} !important; opacity: 0.7; background-color: {tertiary} !important; }}        .labelBkg {{ background-color: {tertiary} !important; }}        .edgeLabel .label {{ fill: {text} !important; }}        .label {{ color: {text} !important; }}        .relationshipLine {{ stroke: {line} !important; fill: none !important; }}        .entityBox {{ fill: {primary}; stroke: {border}; }}        .node .row-rect-odd path {{ fill: {er_odd} !important; }}        .node .row-rect-even path {{ fill: {er_even} !important; }}        .edge-thickness-normal {{ stroke-width: 1px; }}        .relation {{ stroke: {line}; stroke-width: 1; fill: none; }}        .edgePaths path {{ fill: none; }}        .marker {{ fill: {line} !important; stroke: {line} !important; }}        .marker.er {{ fill: none !important; stroke: {line} !important; }}        .composition {{ fill: {line} !important; stroke: {line} !important; stroke-width: 1; }}        .extension {{ fill: transparent !important; stroke: {line} !important; stroke-width: 1; }}        .aggregation {{ fill: transparent !important; stroke: {line} !important; stroke-width: 1; }}        .dependency {{ fill: {line} !important; stroke: {line} !important; stroke-width: 1; }}        .lollipop {{ fill: {primary} !important; stroke: {line} !important; stroke-width: 1; }}        .sectionTitle0, .sectionTitle1, .sectionTitle2, .sectionTitle3 {{ fill: {text} !important; }}        .sectionTitle {{ font-family: {font} !important; }}        .taskTextOutsideRight {{ fill: {text} !important; font-family: {font} !important; }}        .taskTextOutsideLeft {{ fill: {text} !important; }}        .active0, .active1, .active2, .active3 {{ fill: {secondary} !important; stroke: {border} !important; }}        .activeText0, .activeText1, .activeText2, .activeText3 {{ fill: {text} !important; }}        .done0, .done1, .done2, .done3 {{ stroke: {border} !important; fill: {secondary} !important; stroke-width: 2; }}        .doneText0, .doneText1, .doneText2, .doneText3 {{ fill: {text} !important; }}        .crit0, .crit1, .crit2, .crit3 {{ fill: {error} !important; stroke: {error} !important; }}        .critText0, .critText1, .critText2, .critText3 {{ fill: {error_text} !important; }}        .activeCrit0, .activeCrit1, .activeCrit2, .activeCrit3 {{ fill: {warning} !important; stroke: {warning} !important; }}        .activeCritText0, .activeCritText1, .activeCritText2, .activeCritText3 {{ fill: {warning_text} !important; }}        .doneCrit0, .doneCrit1, .doneCrit2, .doneCrit3 {{ fill: {error} !important; stroke: {border} !important; stroke-width: 2; }}        .doneCritText0, .doneCritText1, .doneCritText2, .doneCritText3 {{ fill: {error_text} !important; }}        .titleText {{ fill: {text} !important; font-family: {font} !important; }}        .grid .tick text {{ fill: {text} !important; font-family: {font} !important; }}        .grid .tick {{ stroke: {border} !important; }}        {git_branch_css}        .commit-merge {{ stroke: {primary}; fill: {primary}; }}        .commit-reverse {{ stroke: {primary}; fill: {primary}; stroke-width: 3; }}        .commit-highlight-inner {{ stroke: {primary}; fill: {primary}; }}        .tag-label {{ font-size: 10px; fill: {text}; }}        .tag-label-bkg {{ fill: {primary}; stroke: {border}; }}        .tag-hole {{ fill: {line}; }}        .commit-label {{ fill: {text}; }}        .commit-label-bkg {{ fill: {edge_label_bg}; }}        .commit-id, .commit-msg, .branch-label {{ fill: {text}; color: {text}; font-family: {font}; }}        {accent_css}        .data-point text {{ fill: {text} !important; }}        {chart_color_css}        "#,        mindmap_css = mindmap_section_css(theme),        git_branch_css = git_branch_css(theme),        accent_css = accent_css(theme),        chart_color_css = chart_color_css(theme),        timeline_css = timeline_css(theme),    );     scope_css(&raw_css, svg_id)} #[cfg(test)]mod tests {

Lines 1–500 of 509.