rasterlottie 0.2.1

Pure Rust, headless Lottie rasterizer for deterministic server-side rendering
Documentation
use std::{
    io::{Cursor, Read},
    path::Path,
};

#[cfg(feature = "images")]
use base64::{Engine, engine::general_purpose};
use serde::Deserialize;
use zip::{ZipArchive, result::ZipError};

#[cfg(feature = "images")]
use crate::Asset;
use crate::{Animation, RasterlottieError};

#[derive(Debug, Deserialize)]
struct DotLottieManifest {
    #[serde(default, rename = "activeAnimationId")]
    active_animation_id: Option<String>,
    #[serde(default)]
    animations: Vec<DotLottieManifestAnimation>,
    #[serde(default)]
    initial: Option<DotLottieInitial>,
}

#[derive(Debug, Deserialize)]
struct DotLottieManifestAnimation {
    id: String,
}

#[derive(Debug, Deserialize)]
struct DotLottieInitial {
    #[serde(default)]
    animation: Option<String>,
}

pub fn load_animation_from_dotlottie_bytes(
    dotlottie: &[u8],
) -> Result<Animation, RasterlottieError> {
    let mut archive = ZipArchive::new(Cursor::new(dotlottie))?;
    let manifest_json = read_archive_string(&mut archive, "manifest.json")?;
    let manifest = serde_json::from_str::<DotLottieManifest>(&manifest_json).map_err(|error| {
        RasterlottieError::InvalidDotLottie {
            detail: format!("failed to parse manifest.json: {error}"),
        }
    })?;
    let animation_id = select_animation_id(&manifest)?;
    let animation_json =
        read_archive_string_any(&mut archive, &animation_candidate_paths(animation_id))?;
    #[cfg(feature = "images")]
    let mut animation = Animation::from_json_str(&animation_json)?;
    #[cfg(not(feature = "images"))]
    let animation = Animation::from_json_str(&animation_json)?;
    #[cfg(feature = "images")]
    embed_archive_images(&mut archive, &mut animation)?;
    Ok(animation)
}

fn select_animation_id(manifest: &DotLottieManifest) -> Result<&str, RasterlottieError> {
    if let Some(initial) = manifest.initial.as_ref()
        && let Some(animation) = initial.animation.as_deref()
    {
        return Ok(animation);
    }
    if let Some(animation) = manifest.active_animation_id.as_deref() {
        return Ok(animation);
    }
    manifest
        .animations
        .first()
        .map(|animation| animation.id.as_str())
        .ok_or_else(|| RasterlottieError::InvalidDotLottie {
            detail: "manifest.json does not list any animations".to_string(),
        })
}

fn animation_candidate_paths(animation_id: &str) -> Vec<String> {
    let normalized = normalize_archive_path(animation_id);
    let mut candidates = Vec::new();
    if normalized.is_empty() {
        return candidates;
    }

    push_candidate(&mut candidates, normalized.clone());
    if path_uses_json_extension(&normalized) {
        push_candidate(&mut candidates, join_archive_path("a", &normalized));
        push_candidate(
            &mut candidates,
            join_archive_path("animations", &normalized),
        );
        if let Some(stem) = normalized.strip_suffix(".json") {
            push_candidate(
                &mut candidates,
                join_archive_path("a", &format!("{stem}.json")),
            );
            push_candidate(
                &mut candidates,
                join_archive_path("animations", &format!("{stem}.json")),
            );
        }
    } else {
        push_candidate(&mut candidates, format!("{normalized}.json"));
        push_candidate(
            &mut candidates,
            join_archive_path("a", &format!("{normalized}.json")),
        );
        push_candidate(
            &mut candidates,
            join_archive_path("animations", &format!("{normalized}.json")),
        );
    }

    candidates
}

fn read_archive_string(
    archive: &mut ZipArchive<Cursor<&[u8]>>,
    path: &str,
) -> Result<String, RasterlottieError> {
    let normalized = normalize_archive_path(path);
    read_archive_string_any(archive, &[normalized])
}

fn read_archive_string_any(
    archive: &mut ZipArchive<Cursor<&[u8]>>,
    candidates: &[String],
) -> Result<String, RasterlottieError> {
    let (path, bytes) = read_archive_bytes_any(archive, candidates)?;
    String::from_utf8(bytes).map_err(|error| RasterlottieError::InvalidDotLottie {
        detail: format!("archive entry `{path}` is not valid UTF-8: {error}"),
    })
}

fn read_archive_bytes_any(
    archive: &mut ZipArchive<Cursor<&[u8]>>,
    candidates: &[String],
) -> Result<(String, Vec<u8>), RasterlottieError> {
    read_archive_bytes_optional(archive, candidates)?.ok_or_else(|| {
        RasterlottieError::InvalidDotLottie {
            detail: format!(
                "archive is missing required entry; tried {}",
                candidates.join(", ")
            ),
        }
    })
}

fn read_archive_bytes_optional(
    archive: &mut ZipArchive<Cursor<&[u8]>>,
    candidates: &[String],
) -> Result<Option<(String, Vec<u8>)>, RasterlottieError> {
    for candidate in candidates {
        match archive.by_name(candidate) {
            Ok(mut file) => {
                let mut bytes = Vec::new();
                file.read_to_end(&mut bytes).map_err(|error| {
                    RasterlottieError::InvalidDotLottie {
                        detail: format!("failed to read archive entry `{candidate}`: {error}"),
                    }
                })?;
                return Ok(Some((candidate.clone(), bytes)));
            }
            Err(ZipError::FileNotFound) => {}
            Err(error) => return Err(error.into()),
        }
    }

    Ok(None)
}

#[cfg(feature = "images")]
fn embed_archive_images(
    archive: &mut ZipArchive<Cursor<&[u8]>>,
    animation: &mut Animation,
) -> Result<(), RasterlottieError> {
    for asset in &mut animation.assets {
        if !asset.is_image_asset() || asset.is_embedded_image_asset() {
            continue;
        }

        let candidates = archive_image_candidates(asset);
        let Some((path, bytes)) = read_archive_bytes_optional(archive, &candidates)? else {
            continue;
        };
        let media_type = media_type_for_path(&path);
        asset.base_path = None;
        asset.embedded = Some(1);
        asset.path = Some(format!(
            "data:{media_type};base64,{}",
            general_purpose::STANDARD.encode(bytes)
        ));
    }

    Ok(())
}

#[cfg(feature = "images")]
fn archive_image_candidates(asset: &Asset) -> Vec<String> {
    let mut candidates = Vec::new();
    let Some(path) = asset.path.as_deref() else {
        return candidates;
    };

    let relative_path = normalize_archive_path(path);
    if relative_path.is_empty() {
        return candidates;
    }

    if let Some(base_path) = asset.base_path.as_deref() {
        let normalized_base = normalize_archive_path(base_path);
        if !normalized_base.is_empty() {
            push_candidate(
                &mut candidates,
                join_archive_path(&normalized_base, &relative_path),
            );
        }
    }

    push_candidate(&mut candidates, relative_path.clone());
    push_candidate(&mut candidates, join_archive_path("i", &relative_path));
    push_candidate(&mut candidates, join_archive_path("images", &relative_path));

    if let Some(file_name) = relative_path.rsplit('/').next()
        && file_name != relative_path
    {
        push_candidate(&mut candidates, join_archive_path("i", file_name));
        push_candidate(&mut candidates, join_archive_path("images", file_name));
    }

    candidates
}

#[cfg(feature = "images")]
fn media_type_for_path(path: &str) -> &'static str {
    let extension = path.rsplit('.').next().unwrap_or_default();
    if extension.eq_ignore_ascii_case("png") {
        "image/png"
    } else if extension.eq_ignore_ascii_case("jpg") || extension.eq_ignore_ascii_case("jpeg") {
        "image/jpeg"
    } else if extension.eq_ignore_ascii_case("webp") {
        "image/webp"
    } else if extension.eq_ignore_ascii_case("gif") {
        "image/gif"
    } else if extension.eq_ignore_ascii_case("bmp") {
        "image/bmp"
    } else {
        "application/octet-stream"
    }
}

fn normalize_archive_path(path: &str) -> String {
    let mut normalized = path.replace('\\', "/");
    while let Some(stripped) = normalized.strip_prefix("./") {
        normalized = stripped.to_string();
    }
    normalized.trim_start_matches('/').to_string()
}

fn join_archive_path(base: &str, path: &str) -> String {
    let base = base.trim_end_matches('/');
    let path = path.trim_start_matches('/');
    if base.is_empty() {
        path.to_string()
    } else if path.is_empty() {
        base.to_string()
    } else {
        format!("{base}/{path}")
    }
}

fn path_uses_json_extension(path: &str) -> bool {
    Path::new(path)
        .extension()
        .and_then(|extension| extension.to_str())
        .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
}

fn push_candidate(candidates: &mut Vec<String>, candidate: String) {
    if !candidate.is_empty() && !candidates.iter().any(|existing| existing == &candidate) {
        candidates.push(candidate);
    }
}

#[cfg(test)]
mod tests {
    use std::io::{Cursor, Write};

    #[cfg(feature = "images")]
    use image::{ColorType, ImageEncoder, Rgba, RgbaImage, codecs::png::PngEncoder};
    use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};

    use super::load_animation_from_dotlottie_bytes;

    fn archive_from_entries(entries: &[(&str, &[u8])]) -> Vec<u8> {
        let cursor = Cursor::new(Vec::new());
        let mut writer = ZipWriter::new(cursor);
        let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
        for (path, bytes) in entries {
            writer.start_file(*path, options).unwrap();
            writer.write_all(bytes).unwrap();
        }
        writer.finish().unwrap().into_inner()
    }

    #[test]
    fn dotlottie_loader_prefers_manifest_initial_animation() {
        let archive = archive_from_entries(&[
            (
                "manifest.json",
                br#"{
                    "version":"2",
                    "animations":[{"id":"first"},{"id":"second"}],
                    "initial":{"animation":"second"}
                }"#,
            ),
            (
                "a/first.json",
                br#"{"v":"5.7.6","fr":30,"ip":0,"op":10,"w":16,"h":16,"layers":[{"ty":4,"nm":"First","ind":1,"shapes":[]}]}"#,
            ),
            (
                "a/second.json",
                br#"{"v":"5.7.6","fr":30,"ip":0,"op":10,"w":16,"h":16,"layers":[{"ty":4,"nm":"Second","ind":1,"shapes":[]}]}"#,
            ),
        ]);

        let animation = load_animation_from_dotlottie_bytes(&archive).unwrap();
        assert_eq!(animation.layers[0].name, "Second");
    }

    #[test]
    fn dotlottie_loader_falls_back_to_active_animation_id() {
        let archive = archive_from_entries(&[
            (
                "manifest.json",
                br#"{
                    "version":"1",
                    "activeAnimationId":"hero",
                    "animations":[{"id":"hero"}]
                }"#,
            ),
            (
                "animations/hero.json",
                br#"{"v":"5.7.6","fr":30,"ip":0,"op":10,"w":16,"h":16,"layers":[{"ty":4,"nm":"Hero","ind":1,"shapes":[]}]}"#,
            ),
        ]);

        let animation = load_animation_from_dotlottie_bytes(&archive).unwrap();
        assert_eq!(animation.layers[0].name, "Hero");
    }

    #[cfg(feature = "images")]
    #[test]
    fn dotlottie_loader_embeds_archive_images_into_data_urls() {
        let archive = archive_from_entries(&[
            ("manifest.json", br#"{"animations":[{"id":"hero"}]}"#),
            (
                "animations/hero.json",
                br#"{
                    "v":"5.7.6",
                    "fr":30,
                    "ip":0,
                    "op":10,
                    "w":16,
                    "h":16,
                    "assets":[{"id":"img","w":1,"h":1,"u":"images/","p":"cat.png"}],
                    "layers":[]
                }"#,
            ),
            ("images/cat.png", &solid_png_bytes()),
        ]);

        let animation = load_animation_from_dotlottie_bytes(&archive).unwrap();
        let asset = &animation.assets[0];
        assert_eq!(asset.base_path, None);
        assert_eq!(asset.embedded, Some(1));
        assert!(
            asset
                .path
                .as_deref()
                .is_some_and(|path| path.starts_with("data:image/png;base64,"))
        );
    }

    #[cfg(feature = "images")]
    fn solid_png_bytes() -> Vec<u8> {
        let image = RgbaImage::from_pixel(1, 1, Rgba([255, 0, 0, 255]));
        let mut bytes = Vec::new();
        PngEncoder::new(&mut bytes)
            .write_image(image.as_raw(), 1, 1, ColorType::Rgba8.into())
            .unwrap();
        bytes
    }
}