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;
use std::ops::Range;

const SVG_VIEWBOX_PARTS: usize = 4;
const VIEWBOX_MIN_DIMENSION: f32 = 0.0;
const SVG_DIMENSION_SCALE_DEFAULT: u32 = 320;
const SVG_DIMENSION_SCALE_MAX: f32 = 8192.0;
const MIN_SVG_DIMENSION_INDEX: usize = 2;
const MAX_SVG_DIMENSION_INDEX: usize = 3;

#[derive(Clone)]
pub(crate) enum NativeDocumentBlock {
    Text(super::native_text::NativeTextLine),
    Svg(NativeSvgBlock),
}

#[derive(Clone)]
pub(crate) struct NativeSvgBlock {
    pub(crate) svg: String,
    pub(crate) width: u32,
    pub(crate) height: u32,
}

pub(crate) struct NativeDocumentBlocks;

impl NativeDocumentBlocks {
    pub(crate) fn parse(
        html: &str,
        is_dark: bool,
    ) -> Result<Vec<NativeDocumentBlock>, MarkdownError> {
        let mut blocks = Vec::new();
        let mut cursor = 0;
        for range in NativeSvgRanges::find(html) {
            Self::push_text(&mut blocks, &html[cursor..range.start], is_dark)?;
            blocks.push(NativeDocumentBlock::Svg(NativeSvgBlock::parse(
                &html[range.clone()],
            )?));
            cursor = range.end;
        }
        Self::push_text(&mut blocks, &html[cursor..], is_dark)?;
        Ok(blocks)
    }

    fn push_text(
        blocks: &mut Vec<NativeDocumentBlock>,
        html: &str,
        is_dark: bool,
    ) -> Result<(), MarkdownError> {
        for line in super::native_text::extract_lines(html, is_dark)? {
            blocks.push(NativeDocumentBlock::Text(line));
        }
        Ok(())
    }
}

struct NativeSvgRanges;

impl NativeSvgRanges {
    fn find(html: &str) -> Vec<Range<usize>> {
        let lower_html = html.to_ascii_lowercase();
        let mut ranges = Vec::new();
        let mut cursor = 0;
        while let Some(start) = Self::find_open_tag(&lower_html, cursor) {
            let Some(end) = Self::find_matching_close_tag(&lower_html, start) else {
                break;
            };
            ranges.push(start..end);
            cursor = end;
        }
        ranges
    }

    fn find_matching_close_tag(lower_html: &str, start: usize) -> Option<usize> {
        let mut depth = 0;
        let mut cursor = start;
        loop {
            let open = Self::find_open_tag(lower_html, cursor);
            let close = lower_html[cursor..].find("</svg>").map(|it| cursor + it);
            match (open, close) {
                (Some(open), Some(close)) if open < close => {
                    depth += 1;
                    cursor = open + "<svg".len();
                }
                (_, Some(close)) if depth > 1 => {
                    depth -= 1;
                    cursor = close + "</svg>".len();
                }
                (_, Some(close)) => return Some(close + "</svg>".len()),
                _ => return None,
            }
        }
    }

    fn find_open_tag(lower_html: &str, cursor: usize) -> Option<usize> {
        let mut search = cursor;
        while let Some(found) = lower_html[search..].find("<svg") {
            let start = search + found;
            if Self::is_tag_boundary(lower_html, start + "<svg".len()) {
                return Some(start);
            }
            search = start + "<svg".len();
        }
        None
    }

    fn is_tag_boundary(lower_html: &str, index: usize) -> bool {
        lower_html
            .as_bytes()
            .get(index)
            .is_none_or(|it| it.is_ascii_whitespace() || matches!(it, b'>' | b'/'))
    }
}

impl NativeSvgBlock {
    fn parse(svg: &str) -> Result<Self, MarkdownError> {
        let (width, height) = NativeSvgSize::parse(svg);
        let svg = super::native_svg_root::NativeSvgRoot::with_numeric_size(svg, width, height)?;
        Ok(Self { svg, width, height })
    }

    pub(crate) fn scale_for(&self, max_width: u32) -> f32 {
        (max_width as f32 / self.width.max(1) as f32).min(1.0)
    }
}

struct NativeSvgSize;

impl NativeSvgSize {
    fn parse(svg: &str) -> (u32, u32) {
        if let Some(size) = Self::from_view_box(svg) {
            return size;
        }
        (
            Self::dimension(svg, "width"),
            Self::dimension(svg, "height"),
        )
    }

    fn from_view_box(svg: &str) -> Option<(u32, u32)> {
        let view_box = svg_attribute(svg, "viewBox")?;
        let values = view_box
            .split(|it: char| it.is_whitespace() || it == ',')
            .filter(|it| !it.is_empty())
            .map(str::parse::<f32>)
            .collect::<Result<Vec<_>, _>>()
            .ok();
        values.and_then(|it| Self::view_box_size(&it))
    }

    fn view_box_size(values: &[f32]) -> Option<(u32, u32)> {
        if values.len() != SVG_VIEWBOX_PARTS
            || values[MIN_SVG_DIMENSION_INDEX] <= VIEWBOX_MIN_DIMENSION
            || values[MAX_SVG_DIMENSION_INDEX] <= VIEWBOX_MIN_DIMENSION
        {
            return None;
        }
        Some((
            ceil_dimension(values[MIN_SVG_DIMENSION_INDEX]),
            ceil_dimension(values[MAX_SVG_DIMENSION_INDEX]),
        ))
    }

    fn dimension(svg: &str, name: &str) -> u32 {
        svg_attribute(svg, name)
            .map(|value| value.trim_end_matches("px"))
            .and_then(|value| value.parse::<f32>().ok())
            .filter(|value| *value > 0.0)
            .map(ceil_dimension)
            .unwrap_or(SVG_DIMENSION_SCALE_DEFAULT)
    }
}

fn ceil_dimension(value: f32) -> u32 {
    value.ceil().clamp(1.0, SVG_DIMENSION_SCALE_MAX) as u32
}

fn svg_attribute<'a>(svg: &'a str, name: &str) -> Option<&'a str> {
    let marker = format!(r#"{name}=""#);
    let start = svg.find(&marker)? + marker.len();
    let end = svg[start..].find('"')?;
    Some(&svg[start..start + end])
}

#[cfg(test)]
#[path = "native_blocks_tests.rs"]
mod tests;