motion-content 0.2.0

Backend-independent images, text, vectors, particles and artwork
Documentation
//! Tests for rasterizing SVG documents into image bitmaps.

use super::pixel_count;

#[cfg(test)]
mod tests {
    use crate::image::MAX_ENCODED;

    fn raster(bytes: &[u8]) -> motion_core::Result<motion_render::FrameBuffer> {
        let asset = crate::SvgAsset::from_bytes(bytes)?;
        raster_asset(
            asset.clone(),
            asset.size().width.ceil() as u32,
            asset.size().height.ceil() as u32,
        )
    }

    fn raster_to(
        bytes: &[u8],
        width: u32,
        height: u32,
    ) -> motion_core::Result<motion_render::FrameBuffer> {
        raster_asset(crate::SvgAsset::from_bytes(bytes)?, width, height)
    }

    fn raster_asset(
        asset: crate::SvgAsset,
        width: u32,
        height: u32,
    ) -> motion_core::Result<motion_render::FrameBuffer> {
        super::pixel_count(width, height)?;
        let frame = motion_render::Composition::new(
            width as usize,
            height as usize,
            motion_render::FrameClock::new(motion_core::fps!(30), 150),
        )
        .with_options(
            motion_render::RenderOptions::default().blend_space(motion_core::BlendSpace::Srgb),
        )?
        .frame_renderer(asset.size())?
        .render_output_at(
            motion_core::Time::ZERO,
            None,
            motion_render::PixelFormat::SrgbRgba8 {
                premultiplied: false,
            },
            |_| asset.tree.node(),
        )?;
        Ok(frame)
    }

    const MARK: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 4">
        <rect width="8" height="4" fill="blue"/>
    </svg>"#;

    #[test]
    fn intrinsic_viewbox_bom_and_file_content_are_supported() -> motion_core::Result {
        let mut bytes =
            b"\xef\xbb\xbf<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!-- icon -->".to_vec();
        bytes.extend_from_slice(MARK);
        let asset = raster(&bytes)?;
        assert_eq!(asset.size(), motion_core::PixelSize::new(8, 4));
        assert_eq!(&asset.bytes()[..4], &[0, 0, 255, 255]);
        let file = tempfile::Builder::new().suffix(".bin").tempfile()?;
        std::fs::write(file.path(), bytes)?;
        let loaded = raster(&std::fs::read(file.path())?)?;
        drop(file);
        assert_eq!(loaded.bytes(), asset.bytes());
        Ok(())
    }

    #[test]
    fn explicit_viewport_preserves_aspect_and_checks_limits() -> motion_core::Result {
        let asset = raster_to(MARK, 8, 8)?;
        assert_eq!(asset.size(), motion_core::PixelSize::new(8, 8));
        assert!(asset.bytes()[..8 * 2 * 4].iter().all(|byte| *byte == 0));
        assert!(asset.bytes()[8 * 6 * 4..].iter().all(|byte| *byte == 0));
        assert_eq!(&asset.bytes()[8 * 2 * 4..8 * 2 * 4 + 4], &[0, 0, 255, 255]);
        for (width, height) in [(0, 1), (1, 0), (16385, 1), (4097, 4097), (u32::MAX, 1)] {
            assert!(raster_to(MARK, width, height).is_err());
        }
        for source in [
            "<svg xmlns='http://www.w3.org/2000/svg' width='0' height='1'/>",
            "<svg xmlns='http://www.w3.org/2000/svg' width='16385' height='1'/>",
            "<svg xmlns='http://www.w3.org/2000/svg' width='4097' height='4097'/>",
        ] {
            assert!(raster(source.as_bytes()).is_err());
        }
        assert!(raster_to(&vec![b' '; MAX_ENCODED + 1], 1, 1).is_err());
        Ok(())
    }

    #[test]
    fn gradients_masks_internal_references_and_filters_render() -> motion_core::Result {
        let asset = raster(br##"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="4">
            <defs>
                <linearGradient id="shade"><stop stop-color="red"/><stop offset="1" stop-color="blue"/></linearGradient>
                <mask id="mask"><rect width="4" height="4" fill="white"/></mask>
                <rect id="art" width="8" height="4" fill="url(#shade)"/>
            </defs>
            <g mask="url(#mask)"><use href="#art"/></g>
        </svg>"##)?;
        assert!(asset.bytes()[0] > asset.bytes()[2]);
        assert!(asset.bytes()[3 * 4 + 2] > asset.bytes()[2]);
        assert_eq!(&asset.bytes()[4 * 4..8 * 4], &[0; 16]);
        let blurred = raster(br##"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12">
            <defs><filter id="soft" x="-100%" y="-100%" width="300%" height="300%"><feGaussianBlur stdDeviation="1"/></filter></defs>
            <rect x="4" y="4" width="4" height="4" fill="red" filter="url(#soft)"/>
        </svg>"##)?;
        assert!(blurred.bytes()[(3 * 12 + 5) * 4 + 3] > 0);
        assert!(blurred.bytes()[(5 * 12 + 5) * 4 + 3] > blurred.bytes()[(3 * 12 + 5) * 4 + 3]);
        Ok(())
    }

    #[test]
    fn embedded_png_is_supported_and_bad_data_is_rejected() -> motion_core::Result {
        let mut encoded = std::io::Cursor::new(Vec::new());
        image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
            1,
            1,
            image::Rgba([255, 0, 0, 128]),
        ))
        .write_to(&mut encoded, image::ImageFormat::Png)
        .map_err(crate::image_error)?;
        let escaped: String = encoded
            .get_ref()
            .iter()
            .map(|byte| format!("%{byte:02X}"))
            .collect();
        let source = format!(
            "<svg xmlns='http://www.w3.org/2000/svg' width='1' height='1'><image width='1' height='1' href='data:image/png,{escaped}'/></svg>"
        );
        let asset = raster(source.as_bytes())?;
        assert_eq!(asset.bytes(), [255, 0, 0, 128]);
        for href in [
            "data:image/png,invalid",
            "data:image/svg+xml,%3Csvg",
            "data:image/gif,GIF89a",
        ] {
            let source = format!(
                "<svg xmlns='http://www.w3.org/2000/svg' width='1' height='1'><image width='1' height='1' href='{href}'/></svg>"
            );
            assert!(raster(source.as_bytes()).is_err(), "{href}");
        }
        Ok(())
    }

    #[test]
    fn external_references_dtd_scripts_animation_and_invalid_xml_are_rejected() {
        for body in [
            "<image width='1' height='1' href='local.png'/>",
            "<image width='1' height='1' href='https://example.com/image.png'/>",
            "<use href='file:///tmp/external.svg#shape'/>",
            "<script>alert(1)</script>",
            "<animate attributeName='opacity' from='0' to='1' dur='1s'/>",
            "<foreignObject width='1' height='1'/>",
        ] {
            let source = format!(
                "<svg xmlns='http://www.w3.org/2000/svg' width='1' height='1'>{body}</svg>"
            );
            assert!(raster(source.as_bytes()).is_err(), "{body}");
        }
        assert!(raster(b"<!DOCTYPE svg [<!ENTITY size '1'>]><svg xmlns='http://www.w3.org/2000/svg' width='&size;' height='1'/>").is_err());
        for bytes in [
            b"<svg".as_slice(),
            b"<html/>",
            b"<svg>\xff</svg>",
            &[0x1f, 0x8b, 0, 0],
        ] {
            assert!(raster(bytes).is_err());
        }
    }

    #[test]
    fn excessive_xml_nesting_and_node_counts_are_rejected() {
        let nested = format!(
            "<svg xmlns='http://www.w3.org/2000/svg' width='1' height='1'>{}{}</svg>",
            "<g>".repeat(130),
            "</g>".repeat(130)
        );
        assert!(raster(nested.as_bytes()).is_err());
        let nodes = format!(
            "<svg xmlns='http://www.w3.org/2000/svg' width='1' height='1'>{}</svg>",
            "<g/>".repeat(100_001)
        );
        assert!(raster(nodes.as_bytes()).is_err());
    }

    #[test]
    #[ignore = "requires explicit MOTION_TEST_FONT; no system discovery"]
    fn svg_text_uses_explicit_frozen_fonts_at_load_time() -> motion_core::Result {
        let mut fonts = crate::FontLibrary::new();
        fonts.load_file(std::env::var("MOTION_TEST_FONT")?)?;
        let asset = crate::SvgAsset::from_bytes_with_fonts(
            br#"<svg xmlns="http://www.w3.org/2000/svg" width="64" height="32">
            <text x="2" y="24" font-family="Arial" font-size="24">SVG</text>
        </svg>"#,
            &fonts,
        )?;
        assert!(
            raster_asset(asset, 64, 32)?
                .bytes()
                .as_chunks::<4>()
                .0
                .iter()
                .any(|pixel| pixel[3] > 0)
        );
        Ok(())
    }

    #[cfg(feature = "gpu")]
    #[test]
    #[ignore = "requires a native GPU"]
    fn svg_image_sampling_matches_software_and_replays() -> motion_core::Result {
        use crate::{Fit, Image, ImageAsset};
        use motion_core::{
            BlendSpace, Color, Node, NodeModifiers, Pivot, Point, SceneContext, Size, Time,
        };
        use motion_render::{Composition, PixelFormat};
        let vector = crate::SvgAsset::from_bytes(br##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 12">
            <defs><linearGradient id="fill"><stop stop-color="red"/><stop offset="1" stop-color="teal"/></linearGradient></defs>
            <rect x="1" y="1" width="22" height="10" rx="3" fill="url(#fill)" opacity="0.7"/>
        </svg>"##)?;
        let raster = Composition::new(
            48,
            24,
            motion_render::FrameClock::new(motion_core::fps!(30), 150),
        )
        .with_options(motion_render::RenderOptions::default().blend_space(BlendSpace::Srgb))?
        .frame_renderer(vector.size())?
        .render_output_at(
            Time::ZERO,
            None,
            PixelFormat::SrgbRgba8 {
                premultiplied: false,
            },
            |_| vector.tree.node(),
        )?;
        let asset = std::sync::Arc::new(ImageAsset::from_rgba8(
            48,
            24,
            raster.planes()[0].bytes().to_vec(),
        )?);
        for fit in [Fit::Contain, Fit::Cover, Fit::Fill] {
            let mut cpu = Composition::new(
                48,
                36,
                motion_render::FrameClock::new(motion_core::fps!(30), 150),
            )
            .with_options(
                motion_render::RenderOptions::default().background(Color::from_hex(0x23272c)),
            )?
            .frame_renderer(Size::new(48.0, 36.0))?;
            let mut gpu = Composition::new(
                48,
                36,
                motion_render::FrameClock::new(motion_core::fps!(30), 150),
            )
            .with_options(
                motion_render::RenderOptions::default()
                    .background(Color::from_hex(0x23272c))
                    .backend(motion_render::BackendPreference::gpu()),
            )?
            .frame_renderer(Size::new(48.0, 36.0))?;
            let build = |context: &SceneContext<'_>| {
                Node::group([Node::from(
                    Image::from_asset(asset.clone()).size(32.0, 26.0).fit(fit),
                )
                .translate(2.0, 2.0)])
                .pivot(Pivot::Point(Point::ORIGIN))
                .rotate(7.0)
                .opacity(0.6)
                .translate(
                    (context.time().to_frame(context.frame_rate()) % 3) as f64,
                    2.0,
                )
            };
            let first = gpu.render_frame(17, build)?;
            for index in [0, 45, 17] {
                let expected = cpu.render_frame(index, build)?;
                let actual = gpu.render_frame(index, build)?;
                for (expected, actual) in expected.packed_rgb().iter().zip(actual.packed_rgb()) {
                    for shift in [0, 8, 16] {
                        assert!(((expected >> shift) & 255).abs_diff((actual >> shift) & 255) <= 1);
                    }
                }
                if index == 17 {
                    assert_eq!(first.packed_rgb(), actual.packed_rgb());
                }
            }
        }
        Ok(())
    }
}