merman-render 0.8.0

Headless layout + SVG renderer for Mermaid (parity-focused; upstream SVG goldens).
Documentation
use crate::svg::pipeline::{
    checkpoint_loop, escape_xml_attr_with_checkpoints,
    extract_exact_double_quoted_attr_with_checkpoints, find_with_checkpoints,
};

#[derive(Clone, Copy, Debug, Default)]
pub(super) struct Translate {
    pub(super) x: f64,
    pub(super) y: f64,
}

#[derive(Clone, Debug, Default)]
pub(super) struct GFrame {
    translate: Translate,
    class_tokens: Vec<String>,
}

impl GFrame {
    pub(super) fn from_g_tag<E>(
        tag: &str,
        checkpoint: &mut impl FnMut() -> Result<(), E>,
    ) -> Result<Self, E> {
        let translate = match extract_exact_double_quoted_attr_with_checkpoints(
            tag,
            "transform",
            checkpoint,
        )? {
            Some(transform) => parse_translate(transform, checkpoint)?,
            None => Translate::default(),
        };
        Ok(Self {
            translate,
            class_tokens: parse_class_tokens(tag, checkpoint)?,
        })
    }
}

fn find_ascii_case_insensitive_with_checkpoints<E>(
    haystack: &str,
    needle: &[u8],
    checkpoint: &mut impl FnMut() -> Result<(), E>,
) -> Result<Option<usize>, E> {
    for (iteration, offset) in (0..haystack.len()).enumerate() {
        checkpoint_loop(iteration, checkpoint)?;
        let Some(candidate) = haystack
            .as_bytes()
            .get(offset..offset.saturating_add(needle.len()))
        else {
            break;
        };
        if candidate.eq_ignore_ascii_case(needle) {
            return Ok(Some(offset));
        }
    }
    checkpoint()?;
    Ok(None)
}

fn parse_translate<E>(
    transform: &str,
    checkpoint: &mut impl FnMut() -> Result<(), E>,
) -> Result<Translate, E> {
    let Some(i) =
        find_ascii_case_insensitive_with_checkpoints(transform, b"translate(", checkpoint)?
    else {
        return Ok(Translate::default());
    };
    let after = &transform[i + "translate(".len()..];
    let Some(end) = find_with_checkpoints(after, ")", checkpoint)? else {
        return Ok(Translate::default());
    };
    let args = &after[..end];
    let mut nums = [0.0f64; 2];
    let mut nums_len = 0usize;
    let mut cur = String::new();
    for (iteration, ch) in args.chars().enumerate() {
        checkpoint_loop(iteration, checkpoint)?;
        if ch.is_ascii_digit() || ch == '.' || ch == '-' || ch == '+' || ch == 'e' || ch == 'E' {
            cur.push(ch);
        } else if !cur.is_empty() {
            if nums_len < nums.len()
                && let Ok(value) = cur.parse::<f64>()
            {
                nums[nums_len] = value;
                nums_len += 1;
            }
            cur.clear();
        }
    }
    if !cur.is_empty()
        && nums_len < nums.len()
        && let Ok(value) = cur.parse::<f64>()
    {
        nums[nums_len] = value;
        nums_len += 1;
    }
    checkpoint()?;
    Ok(Translate {
        x: nums
            .first()
            .copied()
            .filter(|_| nums_len > 0)
            .unwrap_or(0.0),
        y: nums.get(1).copied().filter(|_| nums_len > 1).unwrap_or(0.0),
    })
}

pub(super) fn sum_translate<E>(
    stack: &[GFrame],
    checkpoint: &mut impl FnMut() -> Result<(), E>,
) -> Result<Translate, E> {
    let mut acc = Translate::default();
    for (iteration, frame) in stack.iter().enumerate() {
        checkpoint_loop(iteration, checkpoint)?;
        acc.x += frame.translate.x;
        acc.y += frame.translate.y;
    }
    checkpoint()?;
    Ok(acc)
}

fn parse_class_tokens<E>(
    tag: &str,
    checkpoint: &mut impl FnMut() -> Result<(), E>,
) -> Result<Vec<String>, E> {
    let Some(value) = extract_exact_double_quoted_attr_with_checkpoints(tag, "class", checkpoint)?
    else {
        return Ok(Vec::new());
    };
    checkpoint()?;
    Ok(value.split_whitespace().map(str::to_owned).collect())
}

fn push_unique_token<E>(
    tokens: &mut Vec<String>,
    token: &str,
    iteration: &mut usize,
    checkpoint: &mut impl FnMut() -> Result<(), E>,
) -> Result<(), E> {
    for existing in tokens.iter() {
        checkpoint_loop(*iteration, checkpoint)?;
        *iteration = iteration.saturating_add(1);
        if existing == token {
            return Ok(());
        }
    }
    tokens.push(token.to_string());
    Ok(())
}

pub(super) fn source_class_attr_tokens<E>(
    g_stack: &[GFrame],
    inner: &str,
    checkpoint: &mut impl FnMut() -> Result<(), E>,
) -> Result<Option<String>, E> {
    let mut tokens = Vec::new();
    let mut iteration = 0usize;
    for frame in g_stack {
        for token in &frame.class_tokens {
            push_unique_token(&mut tokens, token, &mut iteration, checkpoint)?;
        }
    }
    for token in parse_class_tokens(inner, checkpoint)? {
        push_unique_token(&mut tokens, &token, &mut iteration, checkpoint)?;
    }
    checkpoint()?;
    if tokens.is_empty() {
        return Ok(None);
    }
    escape_xml_attr_with_checkpoints(&tokens.join(" "), checkpoint).map(Some)
}