typub-passes 0.1.0

Semantic IR passes for typub
Documentation
//! Rasterize SVG rendered payloads into local image assets.

use anyhow::{Result, bail};

use std::path::PathBuf;
use typub_ir::Document;

use crate::shared::{
    DEFAULT_ASSET_ID_PREFIX, DEFAULT_ASSET_SUBDIR, DEFAULT_PNG_SCALE, RasterizeConfig,
    RasterizeTarget, rasterize_document, sanitize_slug,
};
use crate::{Pass, PassCtx};

/// Sidecar key carrying local file artifacts generated by
/// [`RasterizeSvgToLocalAssetPass`].
pub const SIDECAR_GENERATED_RENDER_ASSETS: &str = "render.generated_assets";

#[derive(Debug, Clone)]
pub struct RasterizeSvgToLocalAssetPass {
    output_dir: PathBuf,
    slug: String,
    assets_subdir: String,
    scale: f32,
    asset_id_prefix: String,
}

impl RasterizeSvgToLocalAssetPass {
    pub fn new(output_dir: PathBuf, slug: impl Into<String>) -> Self {
        Self {
            output_dir,
            slug: sanitize_slug(&slug.into()),
            assets_subdir: DEFAULT_ASSET_SUBDIR.to_string(),
            scale: DEFAULT_PNG_SCALE,
            asset_id_prefix: DEFAULT_ASSET_ID_PREFIX.to_string(),
        }
    }

    pub fn with_assets_subdir(mut self, assets_subdir: impl Into<String>) -> Self {
        self.assets_subdir = assets_subdir.into();
        self
    }

    pub fn with_scale(mut self, scale: f32) -> Self {
        self.scale = scale;
        self
    }

    pub fn with_asset_id_prefix(mut self, prefix: impl Into<String>) -> Self {
        self.asset_id_prefix = prefix.into();
        self
    }
}

impl Pass for RasterizeSvgToLocalAssetPass {
    fn name(&self) -> &'static str {
        "rasterize_svg_to_local_asset"
    }

    fn run(&mut self, doc: &mut Document, ctx: &mut PassCtx) -> Result<()> {
        if self.scale <= 0.0 {
            bail!("rasterize_svg_to_local_asset scale must be > 0");
        }
        rasterize_document(
            doc,
            ctx,
            RasterizeConfig {
                pass_name: self.name(),
                scale: self.scale,
                asset_id_prefix: &self.asset_id_prefix,
                target: RasterizeTarget::LocalFile {
                    output_dir: self.output_dir.clone(),
                    slug: self.slug.clone(),
                    assets_subdir: self.assets_subdir.clone(),
                },
                sidecar_key: Some(SIDECAR_GENERATED_RENDER_ASSETS),
            },
        )
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::expect_used)]

    use std::path::Path;

    use serde_json::Value as JsonValue;
    use tempfile::TempDir;
    use typub_ir::{Asset, AssetSource};

    use crate::shared::test_fixtures::fixture_doc_for_render_pass;
    use crate::{Pass, PassCtx};

    use super::{RasterizeSvgToLocalAssetPass, SIDECAR_GENERATED_RENDER_ASSETS};

    #[test]
    fn rasterize_svg_to_local_asset_pass_writes_png_and_tracks_sidecar() {
        let mut doc = fixture_doc_for_render_pass();
        let temp_dir = TempDir::new().expect("create temp dir");
        let output_dir = temp_dir.path().to_path_buf();
        let mut pass = RasterizeSvgToLocalAssetPass::new(output_dir, "hello world")
            .with_assets_subdir("assets");
        let mut ctx = PassCtx::default();

        pass.run(&mut doc, &mut ctx).expect("run local-asset pass");

        assert_eq!(doc.assets.len(), 3);
        let sidecar = ctx
            .sidecar
            .get(SIDECAR_GENERATED_RENDER_ASSETS)
            .and_then(JsonValue::as_array)
            .expect("render sidecar array");
        assert_eq!(sidecar.len(), 3);

        for entry in sidecar {
            let logical = entry
                .get("logical_path")
                .and_then(JsonValue::as_str)
                .expect("logical path");
            assert!(logical.starts_with("assets/hello-world-render-"));

            let file_path = entry
                .get("file_path")
                .and_then(JsonValue::as_str)
                .expect("file path");
            assert!(Path::new(file_path).exists(), "rendered png should exist");
        }

        for asset in doc.assets.values() {
            let Asset::Image(image) = asset else {
                panic!("expected image asset");
            };
            match &image.source {
                AssetSource::LocalPath { path } => {
                    assert!(path.as_str().starts_with("assets/hello-world-render-"))
                }
                other => panic!("expected local-path source, got: {other:?}"),
            }
        }
    }
}