katana-canvas-forge 0.1.7

Versioned diagram rendering and document export runtime for KatanA (Mermaid, Draw.io, HTML/PDF/PNG/JPEG).
Documentation
use crate::markdown::MarkdownError;

pub(crate) struct NativeSvgRoot;

impl NativeSvgRoot {
    pub(crate) fn with_numeric_size(
        svg: &str,
        width: u32,
        height: u32,
    ) -> Result<String, MarkdownError> {
        let Some(open_end) = svg.find('>') else {
            return Ok(svg.to_string());
        };
        let open_tag = &svg[..open_end];
        let rest = &svg[open_end..];
        let sized_open_tag = Self::set_attribute(open_tag, "width", &width.to_string())?;
        let sized_open_tag = Self::set_attribute(&sized_open_tag, "height", &height.to_string())?;
        Ok(format!("{sized_open_tag}{rest}"))
    }

    fn set_attribute(open_tag: &str, name: &str, value: &str) -> Result<String, MarkdownError> {
        let Some(start) = Self::attribute_start(open_tag, name) else {
            return Ok(format!(r#"{open_tag} {name}="{value}""#));
        };
        let value_start = start + format!(r#" {name}=""#).len();
        let Some(end) = open_tag[value_start..].find('"') else {
            return Err(MarkdownError::ExportFailed(format!(
                "SVG attribute `{name}` is missing a closing quote"
            )));
        };
        Ok(format!(
            r#"{} {name}="{value}"{}"#,
            &open_tag[..start],
            &open_tag[value_start + end + 1..]
        ))
    }

    fn attribute_start(open_tag: &str, name: &str) -> Option<usize> {
        let marker = format!(r#" {name}=""#);
        if let Some(start) = open_tag.find(&marker) {
            return Some(start);
        }
        None
    }
}

#[cfg(test)]
mod tests {
    use super::NativeSvgRoot;

    #[test]
    fn rewrites_existing_svg_dimensions() -> Result<(), Box<dyn std::error::Error>> {
        let svg = r#"<svg width="10" height="20"><rect /></svg>"#;
        let sized = NativeSvgRoot::with_numeric_size(svg, 320, 240)?;

        assert!(sized.starts_with(r#"<svg width="320" height="240">"#));
        Ok(())
    }

    #[test]
    fn appends_missing_svg_dimensions() -> Result<(), Box<dyn std::error::Error>> {
        let svg = r#"<svg viewBox="0 0 10 20"><rect /></svg>"#;
        let sized = NativeSvgRoot::with_numeric_size(svg, 10, 20)?;

        assert!(sized.starts_with(r#"<svg viewBox="0 0 10 20" width="10" height="20">"#));
        Ok(())
    }

    #[test]
    fn leaves_text_without_svg_open_tag_unchanged() {
        let sized = NativeSvgRoot::with_numeric_size("plain", 10, 20);
        assert!(sized.as_ref().is_ok_and(|it| it == "plain"));
    }

    #[test]
    fn rejects_malformed_existing_dimension_attribute() {
        let sized = NativeSvgRoot::with_numeric_size(r#"<svg width="10><rect /></svg>"#, 10, 20);

        assert!(sized.is_err());
    }
}