merman-render 0.7.0-alpha.1

Headless layout + SVG renderer for Mermaid (parity-focused; upstream SVG goldens).
Documentation
use crate::Result;
use crate::entities::decode_entities_minimal;
use crate::svg::foreign_object_label_fallback_svg_text;
use std::borrow::Cow;
use std::collections::HashSet;

use super::util::{extract_quoted_attr, find_tag_end};
use crate::svg::pipeline::{SvgPostprocessContext, SvgPostprocessor};

#[derive(Debug, Clone, Copy, Default)]
pub struct ForeignObjectFallbackPostprocessor;

impl SvgPostprocessor for ForeignObjectFallbackPostprocessor {
    fn name(&self) -> &'static str {
        "foreign-object-fallback"
    }

    fn process<'a>(
        &self,
        svg: Cow<'a, str>,
        _ctx: &SvgPostprocessContext<'_>,
    ) -> Result<Cow<'a, str>> {
        if !svg.contains("<foreignObject") {
            return Ok(svg);
        }
        Ok(Cow::Owned(foreign_object_fallback_svg(&svg)))
    }
}

#[derive(Debug, Clone, Copy, Default)]
pub struct StripForeignObjectPostprocessor;

impl SvgPostprocessor for StripForeignObjectPostprocessor {
    fn name(&self) -> &'static str {
        "strip-foreign-object"
    }

    fn process<'a>(
        &self,
        svg: Cow<'a, str>,
        _ctx: &SvgPostprocessContext<'_>,
    ) -> Result<Cow<'a, str>> {
        if !svg.contains("<foreignObject") {
            return Ok(svg);
        }
        Ok(Cow::Owned(strip_foreign_objects(&svg)))
    }
}

#[derive(Debug, Clone, Copy, Default)]
pub struct DropNativeDuplicateFallbacksPostprocessor;

impl SvgPostprocessor for DropNativeDuplicateFallbacksPostprocessor {
    fn name(&self) -> &'static str {
        "drop-native-duplicate-fallbacks"
    }

    fn process<'a>(
        &self,
        svg: Cow<'a, str>,
        _ctx: &SvgPostprocessContext<'_>,
    ) -> Result<Cow<'a, str>> {
        if !svg.contains(r#"data-merman-foreignobject="fallback""#) {
            return Ok(svg);
        }
        Ok(Cow::Owned(drop_native_duplicate_fallbacks(&svg)))
    }
}

pub(crate) fn foreign_object_fallback_svg(svg: &str) -> String {
    foreign_object_label_fallback_svg_text(svg)
}

pub(crate) fn strip_foreign_objects(svg: &str) -> String {
    let mut out = String::with_capacity(svg.len());
    let mut cursor = 0;

    while let Some(rel_start) = svg[cursor..].find("<foreignObject") {
        let start = cursor + rel_start;
        out.push_str(&svg[cursor..start]);

        let Some(open_end) = find_tag_end(svg, start) else {
            out.push_str(&svg[start..]);
            return out;
        };

        if svg[start..=open_end].trim_end().ends_with("/>") {
            cursor = open_end + 1;
            continue;
        }

        let close_start = open_end + 1;
        let Some(rel_close) = svg[close_start..].find("</foreignObject>") else {
            cursor = open_end + 1;
            continue;
        };
        cursor = close_start + rel_close + "</foreignObject>".len();
    }

    out.push_str(&svg[cursor..]);
    out
}

pub fn drop_native_duplicate_fallbacks(svg: &str) -> String {
    let native_text = collect_native_text_contents(svg);
    if native_text.is_empty() {
        return svg.to_string();
    }

    let mut out = String::with_capacity(svg.len());
    let mut cursor = 0;
    while let Some(rel_start) = svg[cursor..].find(r#"data-merman-foreignobject="fallback""#) {
        let attr_start = cursor + rel_start;
        let Some(group_start) = svg[..attr_start].rfind("<g") else {
            out.push_str(&svg[cursor..attr_start]);
            cursor = attr_start;
            continue;
        };
        if group_start < cursor {
            out.push_str(&svg[cursor..attr_start]);
            cursor = attr_start;
            continue;
        }
        let Some((close_start, group_end)) = find_matching_g_end(svg, group_start) else {
            out.push_str(&svg[cursor..attr_start]);
            cursor = attr_start;
            continue;
        };
        let Some(open_end) = find_tag_end(svg, group_start) else {
            out.push_str(&svg[cursor..attr_start]);
            cursor = attr_start;
            continue;
        };

        let fallback_text = normalize_text_content(&svg[open_end + 1..close_start]);
        if native_text.contains(fallback_text.trim()) {
            out.push_str(&svg[cursor..group_start]);
        } else {
            out.push_str(&svg[cursor..group_end]);
        }
        cursor = group_end;
    }

    out.push_str(&svg[cursor..]);
    out
}

fn collect_native_text_contents(svg: &str) -> HashSet<String> {
    let mut contents = HashSet::new();
    let mut cursor = 0;
    while let Some(rel_start) = svg[cursor..].find("<text") {
        let start = cursor + rel_start;
        let Some(open_end) = find_tag_end(svg, start) else {
            break;
        };
        let tag = &svg[start..=open_end];
        if text_tag_is_fallback(tag) || tag.trim_end().ends_with("/>") {
            cursor = open_end + 1;
            continue;
        }

        let close_start = open_end + 1;
        let Some(rel_close) = svg[close_start..].find("</text>") else {
            cursor = open_end + 1;
            continue;
        };
        let close = close_start + rel_close;
        let text = normalize_text_content(&svg[close_start..close]);
        if !text.is_empty() {
            contents.insert(text);
        }
        cursor = close + "</text>".len();
    }
    contents
}

fn text_tag_is_fallback(tag: &str) -> bool {
    extract_quoted_attr(tag, "class").is_some_and(|classes| {
        classes
            .split_whitespace()
            .any(|class| class == "merman-foreignobject-fallback-text")
    })
}

fn normalize_text_content(fragment: &str) -> String {
    decode_entities_minimal(&strip_tags(fragment))
        .trim()
        .to_string()
}

fn strip_tags(fragment: &str) -> String {
    let mut out = String::with_capacity(fragment.len());
    let mut in_tag = false;
    for ch in fragment.chars() {
        match ch {
            '<' => in_tag = true,
            '>' => in_tag = false,
            _ if !in_tag => out.push(ch),
            _ => {}
        }
    }
    out
}

fn find_matching_g_end(svg: &str, group_start: usize) -> Option<(usize, usize)> {
    let open_end = find_tag_end(svg, group_start)?;
    if svg[group_start..=open_end].trim_end().ends_with("/>") {
        return Some((group_start, open_end + 1));
    }

    let mut depth = 1usize;
    let mut cursor = open_end + 1;
    while let Some(rel_tag) = svg[cursor..].find('<') {
        let tag_start = cursor + rel_tag;
        let Some(tag_end) = find_tag_end(svg, tag_start) else {
            break;
        };
        let tag = &svg[tag_start..=tag_end];
        if is_start_g_tag(tag) {
            if !tag.trim_end().ends_with("/>") {
                depth += 1;
            }
        } else if is_end_g_tag(tag) {
            depth = depth.checked_sub(1)?;
            if depth == 0 {
                return Some((tag_start, tag_end + 1));
            }
        }
        cursor = tag_end + 1;
    }
    None
}

fn is_start_g_tag(tag: &str) -> bool {
    let bytes = tag.as_bytes();
    tag.starts_with("<g")
        && bytes
            .get(2)
            .is_some_and(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/')
}

fn is_end_g_tag(tag: &str) -> bool {
    let bytes = tag.as_bytes();
    tag.starts_with("</g")
        && bytes
            .get(3)
            .is_some_and(|b| b.is_ascii_whitespace() || *b == b'>')
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::svg::pipeline::SvgPipeline;

    #[test]
    fn drop_native_duplicate_fallbacks_removes_only_matching_fallback_groups() {
        let svg = r##"<svg>
<text class="task">Make tea</text>
<g data-merman-foreignobject="fallback" class="dup">
  <rect/>
  <text class="merman-foreignobject-fallback-text">Make tea</text>
</g>
<g data-merman-foreignobject="fallback" class="keep">
  <text class="merman-foreignobject-fallback-text">Only fallback</text>
</g>
</svg>"##;

        let out = drop_native_duplicate_fallbacks(svg);

        assert!(out.contains(r#"<text class="task">Make tea</text>"#));
        assert!(!out.contains(r#"class="dup""#));
        assert!(out.contains(r#"class="keep""#));
        assert!(out.contains("Only fallback"));
    }

    #[test]
    fn resvg_safe_can_optionally_drop_native_duplicate_fallbacks() {
        let svg = r##"<svg xmlns="http://www.w3.org/2000/svg">
<text class="task">Make tea</text>
<g transform="translate(0,0)">
  <foreignObject width="80" height="24"><div xmlns="http://www.w3.org/1999/xhtml"><p>Make tea</p></div></foreignObject>
</g>
<g transform="translate(0,40)">
  <foreignObject width="80" height="24"><div xmlns="http://www.w3.org/1999/xhtml"><p>Only fallback</p></div></foreignObject>
</g>
</svg>"##;

        let out = SvgPipeline::resvg_safe()
            .with_postprocessor(DropNativeDuplicateFallbacksPostprocessor)
            .process_to_string(svg)
            .unwrap();

        assert!(!out.contains("<foreignObject"));
        assert_eq!(
            out.matches(r#"data-merman-foreignobject="fallback""#)
                .count(),
            1,
            "{out}"
        );
        assert!(out.contains("Only fallback"));
        assert!(out.contains(r#"<text class="task">Make tea</text>"#));
    }
}