nika-engine 0.38.0

Nika workflow engine — embeddable runtime, provider, DAG, and binding logic
//! nika:svg_render — SVG to PNG rasterization.
//!
//! Uses resvg + usvg + tiny-skia. SECURITY: sanitize_svg() BEFORE parsing.
//! Resources_dir is ALWAYS None (no external resource loading).

use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, OnceLock};

use super::context::MediaToolContext;
use super::error::{invalid_args, tool_error};
use super::safety::sanitize_svg;
use super::{MediaOp, MediaOpResult};
use crate::error::NikaError;

/// Lazy-loaded fontdb database (expensive first-time init).
static FONTDB: OnceLock<Arc<fontdb::Database>> = OnceLock::new();

fn get_fontdb() -> Arc<fontdb::Database> {
    FONTDB
        .get_or_init(|| {
            let mut db = fontdb::Database::new();
            db.load_system_fonts();
            if db.is_empty() {
                tracing::warn!(
                    "svg_render: no system fonts found — text in SVGs will not render. \
         Install fonts or use a container with font packages."
                );
            }
            Arc::new(db)
        })
        .clone()
}

pub struct SvgRenderOp;

impl MediaOp for SvgRenderOp {
    fn name(&self) -> &'static str {
        "svg_render"
    }

    fn description(&self) -> &'static str {
        "Render SVG to PNG (rasterize vector graphics)"
    }

    fn parameters_schema(&self) -> serde_json::Value {
        serde_json::json!({
          "type": "object",
          "properties": {
            "hash": { "type": "string", "description": "CAS hash of the SVG file" },
            "width": { "type": "integer", "description": "Output width (optional)" },
            "height": { "type": "integer", "description": "Output height (optional)" }
          },
          "required": ["hash"],
          "additionalProperties": false
        })
    }

    fn execute<'a>(
        &'a self,
        args: serde_json::Value,
        ctx: &'a MediaToolContext,
    ) -> Pin<Box<dyn Future<Output = Result<MediaOpResult, NikaError>> + Send + 'a>> {
        Box::pin(async move {
            ctx.check_cancelled()?;
            let hash = args
                .get("hash")
                .and_then(|v| v.as_str())
                .ok_or_else(|| invalid_args("svg_render", "missing 'hash'"))?;
            let req_width = args.get("width").and_then(|v| v.as_u64()).map(|w| w as u32);
            let req_height = args
                .get("height")
                .and_then(|v| v.as_u64())
                .map(|h| h as u32);

            let data = ctx.read_media(hash).await?;

            let svg_str = String::from_utf8(data)
                .map_err(|_| tool_error("svg_render", "SVG is not valid UTF-8"))?;

            // SECURITY: sanitize BEFORE any parsing
            sanitize_svg(&svg_str)?;

            let png_data = ctx
                .compute
                .compute(move || -> Result<Vec<u8>, NikaError> {
                    let fontdb = get_fontdb();

                    let opts = usvg::Options {
                        resources_dir: None, // SECURITY: never load external resources
                        fontdb,
                        ..Default::default()
                    };

                    let tree = usvg::Tree::from_str(&svg_str, &opts)
                        .map_err(|e| tool_error("svg_render", format!("SVG parse: {e}")))?;

                    let svg_size = tree.size();
                    let (w, h) = match (req_width, req_height) {
                        (Some(w), Some(h)) => (w, h),
                        (Some(w), None) => {
                            let ratio = svg_size.height() / svg_size.width();
                            (w, (w as f32 * ratio).round() as u32)
                        }
                        (None, Some(h)) => {
                            let ratio = svg_size.width() / svg_size.height();
                            ((h as f32 * ratio).round() as u32, h)
                        }
                        (None, None) => (
                            svg_size.width().round() as u32,
                            svg_size.height().round() as u32,
                        ),
                    };

                    let w = w.clamp(1, 10_000);
                    let h = h.clamp(1, 10_000);

                    let mut pixmap = tiny_skia::Pixmap::new(w, h).ok_or_else(|| {
                        tool_error("svg_render", format!("failed to create {w}x{h} pixmap"))
                    })?;

                    let transform = tiny_skia::Transform::from_scale(
                        w as f32 / svg_size.width(),
                        h as f32 / svg_size.height(),
                    );

                    resvg::render(&tree, transform, &mut pixmap.as_mut());

                    pixmap
                        .encode_png()
                        .map_err(|e| tool_error("svg_render", format!("PNG encode: {e}")))
                })
                .await??;

            Ok(MediaOpResult::Binary {
                data: png_data,
                mime_type: "image/png".to_string(),
                extension: "png".to_string(),
                metadata: serde_json::json!({
                  "source_format": "svg",
                }),
            })
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::media::CasStore;

    async fn setup() -> (tempfile::TempDir, Arc<MediaToolContext>) {
        let dir = tempfile::tempdir().unwrap();
        let ctx = Arc::new(MediaToolContext::new(CasStore::new(dir.path())));
        (dir, ctx)
    }

    #[tokio::test]
    async fn svg_render_basic_shape() {
        let (_dir, ctx) = setup().await;
        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
      <rect width="100" height="100" fill="blue"/>
    </svg>"#;
        let sr = ctx.cas.store(svg.as_bytes()).await.unwrap();

        let op = SvgRenderOp;
        let result = op
            .execute(serde_json::json!({"hash": sr.hash}), &ctx)
            .await
            .unwrap();

        if let MediaOpResult::Binary {
            data, mime_type, ..
        } = result
        {
            assert_eq!(mime_type, "image/png");
            assert_eq!(&data[..4], &[137, 80, 78, 71]); // PNG magic
        }
    }

    #[tokio::test]
    async fn svg_render_custom_dimensions() {
        let (_dir, ctx) = setup().await;
        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100">
      <circle cx="100" cy="50" r="50" fill="red"/>
    </svg>"#;
        let sr = ctx.cas.store(svg.as_bytes()).await.unwrap();

        let op = SvgRenderOp;
        let result = op
            .execute(
                serde_json::json!({
                  "hash": sr.hash, "width": 400, "height": 200
                }),
                &ctx,
            )
            .await
            .unwrap();

        if let MediaOpResult::Binary { data, .. } = result {
            assert!(!data.is_empty());
        }
    }

    #[tokio::test]
    async fn svg_render_script_tag_rejected() {
        let (_dir, ctx) = setup().await;
        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>"#;
        let sr = ctx.cas.store(svg.as_bytes()).await.unwrap();

        let op = SvgRenderOp;
        let result = op.execute(serde_json::json!({"hash": sr.hash}), &ctx).await;
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("NIKA-297"));
    }

    #[tokio::test]
    async fn svg_render_foreign_object_rejected() {
        let (_dir, ctx) = setup().await;
        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg">
      <foreignObject width="100" height="100">
        <body xmlns="http://www.w3.org/1999/xhtml"><div>html</div></body>
      </foreignObject>
    </svg>"#;
        let sr = ctx.cas.store(svg.as_bytes()).await.unwrap();

        let op = SvgRenderOp;
        let result = op.execute(serde_json::json!({"hash": sr.hash}), &ctx).await;
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("NIKA-297"));
    }

    #[tokio::test]
    async fn svg_render_event_handler_rejected() {
        let (_dir, ctx) = setup().await;
        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)">
      <rect width="10" height="10"/>
    </svg>"#;
        let sr = ctx.cas.store(svg.as_bytes()).await.unwrap();

        let op = SvgRenderOp;
        let result = op.execute(serde_json::json!({"hash": sr.hash}), &ctx).await;
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("NIKA-297"));
    }

    #[tokio::test]
    async fn svg_render_invalid_svg() {
        let (_dir, ctx) = setup().await;
        let sr = ctx.cas.store(b"not valid xml at all").await.unwrap();

        let op = SvgRenderOp;
        let result = op.execute(serde_json::json!({"hash": sr.hash}), &ctx).await;
        assert!(result.is_err());
    }
}