katana-document-viewer 0.1.4

KatanA document viewer artifact, render evaluation, and export foundation.
Documentation
use super::types::{
    KrrMathMode, KrrRenderDiagnostic, KrrRenderKind, KrrRenderOutput, KrrRenderRequest,
    KrrRenderRuntime,
};
use katana_render_runtime::{
    MathJaxRenderer, RenderConfig, RenderContext, RenderError, RenderInput, RenderKind,
    RenderPolicy, RenderThemeSnapshot, Renderer, RuntimePathResolver,
};

#[derive(Default)]
pub(crate) struct KrrRenderRuntimeAdapter;

impl KrrRenderRuntimeAdapter {
    pub(crate) fn render_math_tex_with_theme(
        source: &str,
        math_mode: KrrMathMode,
        theme: Option<RenderThemeSnapshot>,
    ) -> KrrRenderOutput {
        Self.render(KrrRenderRequest::math_tex(source, math_mode).with_theme(theme))
    }
}

impl KrrRenderRuntime for KrrRenderRuntimeAdapter {
    fn render(&self, request: KrrRenderRequest) -> KrrRenderOutput {
        let source = request.source.trim();
        if source.is_empty() {
            return KrrRenderOutput::raw(
                String::new(),
                KrrRenderDiagnostic::new("empty-input", "KRR received empty source"),
            );
        }
        match request.kind {
            KrrRenderKind::MathTex => render_math_tex(source, request.math_mode, request.theme),
        }
    }
}

fn render_math_tex(
    source: &str,
    math_mode: KrrMathMode,
    theme: Option<RenderThemeSnapshot>,
) -> KrrRenderOutput {
    let runtime_path = match RuntimePathResolver::resolve(RenderKind::MathJax, None) {
        Ok(path) => path,
        Err(error) => return raw_error(source, error),
    };
    let renderer = MathJaxRenderer::with_runtime_path(runtime_path);
    let mathjax_source = MathJaxSourceNormalizer::normalize(source, math_mode);
    let input = MathJaxRenderInputFactory::create(&mathjax_source, math_mode, theme);
    render_math_tex_result(source, &mathjax_source, renderer.render(&input))
}

fn render_math_tex_result(
    source: &str,
    mathjax_source: &str,
    result: Result<katana_render_runtime::RenderOutput, RenderError>,
) -> KrrRenderOutput {
    match result {
        Ok(output) if output.diagnostics.errors.is_empty() && is_svg(&output.svg) => {
            KrrRenderOutput::svg(MathJaxSourceNormalizer::restore_metadata(
                &output.svg,
                source,
                mathjax_source,
            ))
        }
        Ok(output) => KrrRenderOutput::raw(
            rendered_raw(source, &output.svg),
            KrrRenderDiagnostic::new("render-failed", diagnostics_message(&output)),
        ),
        Err(error) => raw_error(source, error),
    }
}

struct MathJaxRenderInputFactory;

impl MathJaxRenderInputFactory {
    fn create(
        source: &str,
        math_mode: KrrMathMode,
        theme: Option<RenderThemeSnapshot>,
    ) -> RenderInput {
        RenderInput {
            kind: RenderKind::MathJax,
            source: source.to_string(),
            config: RenderConfig {
                vendor_config: serde_json::json!({
                    "display": matches!(math_mode, KrrMathMode::Display),
                }),
            },
            policy: RenderPolicy::default(),
            context: RenderContext {
                theme,
                ..RenderContext::default()
            },
        }
    }
}

struct MathJaxSourceNormalizer;

impl MathJaxSourceNormalizer {
    fn normalize(source: &str, math_mode: KrrMathMode) -> String {
        match math_mode {
            KrrMathMode::Inline if Self::needs_group(source) => format!("{{{source}}}"),
            _ => source.to_string(),
        }
    }

    fn needs_group(source: &str) -> bool {
        let trimmed = source.trim();
        !(trimmed.starts_with('{') && trimmed.ends_with('}'))
    }

    fn restore_metadata(svg: &str, source: &str, normalized_source: &str) -> String {
        if source == normalized_source {
            return svg.to_string();
        }
        svg.replace(
            &format!(r#"data-latex="{normalized_source}""#),
            &format!(r#"data-latex="{source}""#),
        )
        .replace(
            &format!(r#"data-latex="{{{source} }}""#),
            &format!(r#"data-latex="{source}""#),
        )
    }
}

fn is_svg(output: &str) -> bool {
    output.trim_start().starts_with("<svg")
}

fn rendered_raw(source: &str, output: &str) -> String {
    if output.trim().is_empty() {
        source.to_string()
    } else {
        output.to_string()
    }
}

fn diagnostics_message(output: &katana_render_runtime::RenderOutput) -> String {
    output
        .diagnostics
        .errors
        .iter()
        .chain(output.diagnostics.warnings.iter())
        .cloned()
        .collect::<Vec<_>>()
        .join("; ")
}

fn raw_error(source: &str, error: RenderError) -> KrrRenderOutput {
    KrrRenderOutput::raw(
        source.to_string(),
        KrrRenderDiagnostic::new(render_error_code(&error), error.to_string()),
    )
}

fn render_error_code(error: &RenderError) -> &'static str {
    match error {
        RenderError::InvalidInput(_) => "invalid-input",
        RenderError::NotInstalled { .. } => "runtime-not-installed",
        RenderError::Runtime(_) => "runtime-failed",
        RenderError::RuntimeResolution(_) => "runtime-resolution-failed",
        RenderError::UnsupportedKind => "unsupported-kind",
    }
}

#[cfg(test)]
#[path = "adapter_result_tests.rs"]
mod result_tests;
#[cfg(test)]
#[path = "adapter_tests.rs"]
mod tests;