katana-document-viewer 0.1.4

KatanA document viewer artifact, render evaluation, and export foundation.
Documentation
use super::SurfaceBadge;
use crate::export_surface_text::SurfaceTextParser;
use katana_markdown_model::{KmmNode, ListItemNode};

#[path = "export_surface_markup_html.rs"]
mod export_surface_markup_html;

pub(super) use self::export_surface_markup_html::SurfaceHtmlMarkup;

const ALERT_COLOR_TIP: image::Rgba<u8> = image::Rgba([26, 127, 55, 255]);
const ALERT_COLOR_IMPORTANT: image::Rgba<u8> = image::Rgba([130, 80, 223, 255]);
const ALERT_COLOR_WARNING: image::Rgba<u8> = image::Rgba([209, 36, 47, 255]);
const ALERT_COLOR_CAUTION: image::Rgba<u8> = image::Rgba([191, 135, 0, 255]);
const ALERT_COLOR_DEFAULT: image::Rgba<u8> = image::Rgba([9, 105, 218, 255]);

pub(super) struct SurfaceDetailsParts<'a> {
    pub(super) summary: &'a str,
    pub(super) body: &'a str,
}

impl<'a> SurfaceDetailsParts<'a> {
    pub(super) fn parse(fragment: &'a str) -> Option<Self> {
        let trimmed = fragment.trim();
        if !trimmed.starts_with("<details") {
            return None;
        }
        let summary_start = trimmed.find("<summary>")? + "<summary>".len();
        let summary_end = trimmed.find("</summary>")?;
        let body_start = summary_end + "</summary>".len();
        let body_end = trimmed.rfind("</details>")?;
        let body = Self::strip_div(&trimmed[body_start..body_end]);
        Some(Self {
            summary: &trimmed[summary_start..summary_end],
            body,
        })
    }

    fn strip_div(value: &'a str) -> &'a str {
        let trimmed = value.trim();
        if let Some(body) = trimmed.strip_prefix("<div>") {
            return body.strip_suffix("</div>").unwrap_or(body);
        }
        trimmed
    }
}

pub(super) fn list_marker_text(item: &ListItemNode, ordered: bool) -> String {
    if let Some(marker) = &item.task_marker {
        return format!("{} ", task_marker_text(marker));
    }
    if ordered {
        let number = item
            .ordered_number
            .or_else(|| ordered_number_from_marker(&item.marker))
            .unwrap_or(1);
        return format!("{number}. ");
    }
    "".to_string()
}

fn ordered_number_from_marker(marker: &str) -> Option<usize> {
    marker
        .trim_end_matches('.')
        .trim_end_matches(')')
        .parse::<usize>()
        .ok()
}

fn task_marker_text(marker: &str) -> &'static str {
    match marker {
        "[x]" => "",
        "[ ]" => "",
        "[-]" => "",
        "[/]" => "",
        _ => "",
    }
}

pub(super) fn alert_title(label: &str) -> &str {
    match label {
        "TIP" => "Tip",
        "IMPORTANT" => "Important",
        "WARNING" => "Warning",
        "CAUTION" => "Caution",
        _ => "Note",
    }
}

pub(super) fn alert_label_text(label: &str) -> String {
    alert_title(label).to_string()
}

#[cfg(test)]
pub(super) fn alert_icon_name(label: &str) -> &str {
    match label {
        "TIP" => "tip-bulb",
        "IMPORTANT" => "important-callout",
        "WARNING" => "warning-triangle",
        "CAUTION" => "caution-octagon",
        _ => "note-circle",
    }
}

pub(super) fn alert_body_lines(node: &KmmNode) -> Vec<String> {
    let lines = node
        .children
        .iter()
        .map(SurfaceTextParser::inline_text)
        .map(|text| text.trim().to_string())
        .filter(|text| !text.is_empty())
        .collect::<Vec<_>>();
    if !lines.is_empty() {
        return lines;
    }
    node.source
        .raw
        .text
        .lines()
        .filter_map(|line| line.trim_start().strip_prefix('>'))
        .map(str::trim)
        .filter(|line| !line.starts_with("[!"))
        .filter(|line| !line.is_empty())
        .map(SurfaceTextParser::inline_markdown_text)
        .collect()
}

pub(super) fn alert_color(label: &str) -> image::Rgba<u8> {
    match label {
        "TIP" => ALERT_COLOR_TIP,
        "IMPORTANT" => ALERT_COLOR_IMPORTANT,
        "WARNING" => ALERT_COLOR_WARNING,
        "CAUTION" => ALERT_COLOR_CAUTION,
        _ => ALERT_COLOR_DEFAULT,
    }
}

pub(super) fn legacy_note_quote(raw: &str) -> Option<(String, String)> {
    let mut lines = raw
        .lines()
        .filter_map(|line| line.trim_start().strip_prefix('>'));
    let title = lines
        .next()?
        .trim()
        .strip_prefix("**")?
        .strip_suffix("**")?
        .trim()
        .to_string();
    if !is_legacy_note_title(&title) {
        return None;
    }
    let body = lines
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .map(SurfaceTextParser::inline_markdown_text)
        .collect::<Vec<_>>()
        .join(" ");
    (!body.is_empty()).then_some((title, body))
}

pub(super) fn legacy_note_children(children: &[KmmNode]) -> Option<(String, String)> {
    let (first, rest) = children.split_first()?;
    let title = SurfaceTextParser::inline_text(first).trim().to_string();
    if !is_legacy_note_title(&title) {
        return None;
    }
    let body = rest
        .iter()
        .map(SurfaceTextParser::inline_text)
        .map(|text| text.trim().to_string())
        .filter(|text| !text.is_empty())
        .collect::<Vec<_>>()
        .join(" ");
    (!body.is_empty()).then_some((title, body))
}

fn is_legacy_note_title(title: &str) -> bool {
    matches!(title, "Note" | "Tip" | "Important" | "Warning" | "Caution")
}

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