katana-render-runtime 0.3.1

Versioned render runtime for KatanA diagrams and math (Mermaid, Draw.io, ZenUML, PlantUML, MathJax).
Documentation
pub mod dark;
pub mod light;
pub mod types;

use crate::renderer::{RenderInput, RenderThemeMode, RenderThemeSnapshot};
use std::sync::atomic::Ordering;
pub use types::{DARK_MODE, DiagramColorPreset};

impl DiagramColorPreset {
    pub const DEFAULT_EDITOR_FONT_SIZE: f32 = 14.0;

    pub fn dark() -> &'static Self {
        dark::DarkOps::get()
    }

    pub fn light() -> &'static Self {
        light::LightOps::get()
    }

    pub fn is_dark_mode() -> bool {
        DARK_MODE.load(Ordering::Relaxed)
    }

    pub fn set_dark_mode(is_dark: bool) {
        DARK_MODE.store(is_dark, Ordering::Relaxed);
    }

    pub fn current() -> &'static Self {
        if Self::is_dark_mode() {
            Self::dark()
        } else {
            Self::light()
        }
    }

    pub(crate) fn for_render_input(input: &RenderInput) -> Self {
        input
            .context
            .theme
            .as_ref()
            .map_or_else(|| Self::current().clone(), Self::from_theme_snapshot)
    }

    pub fn from_theme_snapshot(snapshot: &RenderThemeSnapshot) -> Self {
        Self {
            dark_mode: snapshot.mode == RenderThemeMode::Dark,
            background: snapshot.background.clone().into(),
            text: snapshot.text.clone().into(),
            fill: snapshot.fill.clone().into(),
            stroke: snapshot.stroke.clone().into(),
            arrow: snapshot.arrow.clone().into(),
            drawio_label_color: snapshot.drawio_label_color.clone().into(),
            mermaid_theme: snapshot.mermaid_theme.clone().into(),
            plantuml_class_bg: snapshot.plantuml_class_bg.clone().into(),
            plantuml_note_bg: snapshot.plantuml_note_bg.clone().into(),
            plantuml_note_text: snapshot.plantuml_note_text.clone().into(),
            syntax_theme_dark: snapshot.syntax_theme_dark.clone().into(),
            syntax_theme_light: snapshot.syntax_theme_light.clone().into(),
            preview_text: snapshot.preview_text.clone().into(),
            proportional_font_candidates: Self::default_proportional_fonts(),
            monospace_font_candidates: Self::default_monospace_fonts(),
            emoji_font_candidates: Self::default_emoji_fonts(),
            editor_font_size: Self::DEFAULT_EDITOR_FONT_SIZE,
        }
    }

    pub fn parse_hex_rgb(hex: &str) -> Option<(u8, u8, u8)> {
        const HEX_RGB_LEN: usize = 6;
        const HEX_RADIX: u32 = 16;
        const R_END: usize = 2;
        const G_START: usize = 2;
        const G_END: usize = 4;
        const B_START: usize = 4;

        let hex = hex.strip_prefix('#')?;
        if hex.len() != HEX_RGB_LEN {
            return None;
        }
        let r = u8::from_str_radix(&hex[0..R_END], HEX_RADIX).ok()?;
        let g = u8::from_str_radix(&hex[G_START..G_END], HEX_RADIX).ok()?;
        let b = u8::from_str_radix(&hex[B_START..HEX_RGB_LEN], HEX_RADIX).ok()?;
        Some((r, g, b))
    }

    pub fn relative_luminance(hex: &str) -> Option<f64> {
        const CHANNEL_MAX: f64 = 255.0;
        const LUMA_R: f64 = 0.2126;
        const LUMA_G: f64 = 0.7152;
        const LUMA_B: f64 = 0.0722;

        let (r, g, b) = Self::parse_hex_rgb(hex)?;
        let rf = f64::from(r) / CHANNEL_MAX;
        let gf = f64::from(g) / CHANNEL_MAX;
        let bf = f64::from(b) / CHANNEL_MAX;
        Some(LUMA_R * rf + LUMA_G * gf + LUMA_B * bf)
    }

    pub fn default_proportional_fonts() -> Vec<&'static str> {
        vec![
            "/System/Library/Fonts/\u{30d2}\u{30e9}\u{30ae}\u{30ce}\u{89d2}\u{30b4}\u{30b7}\u{30c3}\u{30af} W3.ttc",
            "/System/Library/Fonts/Hiragino Sans GB.ttc",
            "/System/Library/Fonts/AquaKana.ttc",
            "C:/Windows/Fonts/YuGothR.ttc",
            "C:/Windows/Fonts/yugothic.ttf",
            "C:/Windows/Fonts/meiryo.ttc",
            "C:/Windows/Fonts/segoeui.ttf",
            "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
            "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
            "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
        ]
    }

    pub fn default_monospace_fonts() -> Vec<&'static str> {
        vec![
            "/System/Library/Fonts/Menlo.ttc",
            "/System/Library/Fonts/SFMono-Regular.otf",
            "/System/Library/Fonts/Monaco.ttf",
            "C:/Windows/Fonts/consola.ttf",
            "C:/Windows/Fonts/cour.ttf",
            "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
            "/usr/share/fonts/truetype/ubuntu/UbuntuMono-R.ttf",
            "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
        ]
    }

    pub fn default_emoji_fonts() -> Vec<&'static str> {
        vec![
            "/System/Library/Fonts/Apple Color Emoji.ttc",
            "C:/Windows/Fonts/seguiemj.ttf",
            "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf",
            "/usr/share/fonts/google-noto-emoji/NotoColorEmoji.ttf",
        ]
    }
}

#[cfg(test)]
mod tests {
    use super::DiagramColorPreset;
    use crate::renderer::{
        DiagramKind, RenderConfig, RenderContext, RenderInput, RenderPolicy, RenderThemeMode,
        RenderThemeSnapshot,
    };
    use std::sync::{Mutex, MutexGuard};

    static MODE_LOCK: Mutex<()> = Mutex::new(());

    #[test]
    fn light_and_dark_presets_keep_katana_theme_names() {
        assert_eq!(DiagramColorPreset::dark().mermaid_theme, "dark");
        assert_eq!(DiagramColorPreset::light().mermaid_theme, "default");
    }

    #[test]
    fn hex_luminance_accepts_six_digit_colors() {
        assert!(DiagramColorPreset::relative_luminance("#ffffff").is_some_and(|it| it > 0.9));
        assert_eq!(DiagramColorPreset::parse_hex_rgb("ffffff"), None);
        assert_eq!(DiagramColorPreset::parse_hex_rgb("#fff"), None);
        assert_eq!(DiagramColorPreset::parse_hex_rgb("#xxffff"), None);
    }

    #[test]
    fn current_tracks_dark_mode_and_default_font_lists_are_populated() {
        let _guard = mode_guard();
        DiagramColorPreset::set_dark_mode(true);
        assert_eq!(DiagramColorPreset::current().mermaid_theme, "dark");
        DiagramColorPreset::set_dark_mode(false);
        assert_eq!(DiagramColorPreset::current().mermaid_theme, "default");
        assert!(!DiagramColorPreset::default_proportional_fonts().is_empty());
        assert!(!DiagramColorPreset::default_monospace_fonts().is_empty());
        assert!(!DiagramColorPreset::default_emoji_fonts().is_empty());
    }

    #[test]
    fn for_render_input_prefers_render_input_theme_over_global_state() {
        let _guard = mode_guard();
        DiagramColorPreset::set_dark_mode(true);
        let preset = DiagramColorPreset::for_render_input(&input(Some(light_snapshot())));

        assert!(!preset.dark_mode);
        assert_eq!(preset.mermaid_theme, "default");
        assert_eq!(preset.text, "#333333");
    }

    #[test]
    fn for_render_input_falls_back_to_global_state() {
        let _guard = mode_guard();
        DiagramColorPreset::set_dark_mode(true);
        let preset = DiagramColorPreset::for_render_input(&input(None));

        assert!(preset.dark_mode);
        assert_eq!(preset.mermaid_theme, "dark");
    }

    #[test]
    fn mode_guard_accepts_poisoned_lock() {
        let poison = std::panic::catch_unwind(|| {
            let _guard = mode_guard();
            std::panic::resume_unwind(Box::new("poison mode guard"));
        });

        assert!(poison.is_err());
        let _guard = mode_guard();
    }

    fn input(theme: Option<RenderThemeSnapshot>) -> RenderInput {
        RenderInput {
            kind: DiagramKind::Mermaid,
            source: "graph TD; A-->B".to_string(),
            config: RenderConfig::default(),
            policy: RenderPolicy::default(),
            context: RenderContext {
                theme_fingerprint: None,
                document_id: None,
                theme,
            },
        }
    }

    fn light_snapshot() -> RenderThemeSnapshot {
        RenderThemeSnapshot {
            mode: RenderThemeMode::Light,
            background: "transparent".to_string(),
            text: "#333333".to_string(),
            fill: "#fff2cc".to_string(),
            stroke: "#d6b656".to_string(),
            arrow: "#555555".to_string(),
            drawio_label_color: "#333333".to_string(),
            mermaid_theme: "default".to_string(),
            plantuml_class_bg: "#FEFECE".to_string(),
            plantuml_note_bg: "#FBFB77".to_string(),
            plantuml_note_text: "#333333".to_string(),
            syntax_theme_dark: "base16-ocean.dark".to_string(),
            syntax_theme_light: "InspiredGitHub".to_string(),
            preview_text: "#333333".to_string(),
        }
    }

    fn mode_guard() -> MutexGuard<'static, ()> {
        match MODE_LOCK.lock() {
            Ok(guard) => guard,
            Err(error) => error.into_inner(),
        }
    }
}