Skip to main content

motion_content/
svg.rs

1//! Bounded SVG XML import into the shared semantic vector model.
2//!
3//! ```
4//! use motion_content::prelude::*;
5//! use motion_core::prelude::*;
6//! use motion_core::{
7//!     DisplayList, RasterLimits, ResourceStore, ValidatedDisplayList, MAX_PATH_VERBS,
8//!     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
9//! };
10//! use motion_render::{Composition, RenderPrepare, RenderOptions, BackendKind, render_still};
11//! use motion_content::{Svg, SvgAsset};
12//! use std::sync::Arc;
13//! let asset = Arc::new(SvgAsset::from_bytes(br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="4"><rect id="mark" width="8" height="4" fill="blue"/></svg>"#)?);
14//! let node = Svg::from_asset(asset)
15//!     .select("#mark", |node| node.opacity(0.5))
16//!     .write(1.0)
17//!     .fit(Size::new(8.0, 8.0))
18//!     .try_node()?;
19//! let frame = node
20//!     .compile(
21//!         Size::new(8.0, 8.0),
22//!         PixelSize::new(8, 8),
23//!         ValidationLimits::default(),
24//!     )?
25//!     .prepare(ValidationLimits::default(), RasterLimits::default())?
26//!     .render_linear(RasterLimits::default())?;
27//! assert_eq!(frame.linear_pixel(2, 3), Some([0.0, 0.0, 0.5, 0.5]));
28//! # Ok::<(), motion_core::Error>(())
29//! ```
30
31use crate::{VectorGroup, VectorItem, VectorPath, VectorTree};
32
33use motion_core::{Color, FillRule, Path2D, Point, Rect, Size, Transform};
34use std::{
35    str::FromStr,
36    sync::{
37        Arc,
38        atomic::{AtomicBool, AtomicUsize, Ordering},
39    },
40};
41
42mod convert;
43mod filters;
44#[cfg(test)]
45mod tests;
46mod view;
47
48use convert::{group, rect};
49use filters::filter_group;
50
51pub use view::Svg;
52
53/// Maximum accepted SVG source size in bytes.
54const MAX_SOURCE: usize = 8 * 1024 * 1024;
55
56/// Immutable SVG viewport and semantic content. Parser coordinates are
57/// f32 (usvg); converted geometry/authoring matrices are retained as f64.
58#[derive(Clone, Debug)]
59pub struct SvgAsset {
60    /// Authored viewBox, or the intrinsic viewport when omitted.
61    pub view_box: Rect,
62    /// Canonical viewport-normalized content with selection IDs.
63    pub tree: Arc<VectorTree>,
64    diagnostics: Arc<[SvgDiagnostic]>,
65    size: Size,
66}
67
68/// A retained unsupported-feature warning, never a silent raster substitution.
69#[derive(Clone, Debug)]
70pub struct SvgDiagnostic {
71    /// Referenced resource or element ID, if authored.
72    pub element: Option<Arc<str>>,
73    /// Explicit reason the resource could not be mapped.
74    pub message: Arc<str>,
75}
76
77impl SvgAsset {
78    /// Parses without system discovery, files, scripts or external resources.
79    pub fn from_bytes(bytes: &[u8]) -> motion_core::Result<Self> {
80        Self::from_bytes_with_fonts(bytes, &crate::FontLibrary::new())
81    }
82
83    /// Parses using the same immutable font database as paragraph text.
84    pub fn from_bytes_with_fonts(
85        bytes: &[u8],
86        fonts: &crate::FontLibrary,
87    ) -> motion_core::Result<Self> {
88        Self::decode(bytes, fonts.database.clone())
89    }
90
91    pub(crate) fn decode(bytes: &[u8], fonts: Arc<fontdb::Database>) -> motion_core::Result<Self> {
92        if bytes.len() > MAX_SOURCE {
93            return Err(unsupported("SVG source exceeds its encoded byte allowance"));
94        }
95        let bytes = bytes.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(bytes);
96        let text =
97            std::str::from_utf8(bytes).map_err(|error| motion_core::Error::Svg(Box::new(error)))?;
98        let document = usvg::roxmltree::Document::parse_with_options(
99            text,
100            usvg::roxmltree::ParsingOptions {
101                allow_dtd: false,
102                nodes_limit: 100000,
103                ..Default::default()
104            },
105        )
106        .map_err(|error| motion_core::Error::Svg(Box::new(error)))?;
107        for node in document.descendants() {
108            if node.ancestors().count() > 64 {
109                return Err(unsupported("SVG XML exceeds depth 64"));
110            }
111            if node.is_element() {
112                if matches!(
113                    node.tag_name().name(),
114                    "script"
115                        | "foreignObject"
116                        | "animate"
117                        | "animateMotion"
118                        | "animateTransform"
119                        | "set"
120                ) {
121                    return Err(unsupported(
122                        "Only static SVG without scripts or foreign objects is supported",
123                    ));
124                }
125                if node.attributes().any(|attribute| {
126                    attribute.name() == "href"
127                        && !attribute.value().is_empty()
128                        && !attribute.value().starts_with('#')
129                        && !attribute.value().starts_with("data:")
130                }) {
131                    return Err(unsupported("External SVG references are not supported"));
132                }
133            }
134        }
135        let has_text = document.descendants().any(|node| node.has_tag_name("text"));
136        if fonts.faces().next().is_none() && has_text {
137            return Err(unsupported(
138                "SVG text requires an explicitly registered FontLibrary",
139            ));
140        }
141        let failed = AtomicBool::new(false);
142        let pixels = AtomicUsize::new(0);
143        let nesting = AtomicUsize::new(0);
144        let options = usvg::Options {
145            fontdb: fonts,
146            image_href_resolver: usvg::ImageHrefResolver {
147                resolve_string: Box::new(|_, _| {
148                    failed.store(true, Ordering::Relaxed);
149                    None
150                }),
151                resolve_data: Box::new(|mime, data, options| {
152                    let result = (|| {
153                        if data.len() > MAX_SOURCE {
154                            return None;
155                        }
156                        if mime == "image/svg+xml" {
157                            let depth = nesting.fetch_add(1, Ordering::Relaxed);
158                            if depth >= 8 {
159                                nesting.fetch_sub(1, Ordering::Relaxed);
160                                return None;
161                            }
162                            let parsed = (|| {
163                                let text = std::str::from_utf8(&data).ok()?;
164                                let doc = usvg::roxmltree::Document::parse_with_options(
165                                    text,
166                                    usvg::roxmltree::ParsingOptions {
167                                        allow_dtd: false,
168                                        nodes_limit: 20000,
169                                        ..Default::default()
170                                    },
171                                )
172                                .ok()?;
173                                for node in doc.descendants().filter(|node| node.is_element()) {
174                                    if node.ancestors().count() > 64
175                                        || matches!(
176                                            node.tag_name().name(),
177                                            "script"
178                                                | "foreignObject"
179                                                | "animate"
180                                                | "animateMotion"
181                                                | "animateTransform"
182                                                | "set"
183                                        )
184                                        || node.attributes().any(|attribute| {
185                                            attribute.name() == "href"
186                                                && !attribute.value().is_empty()
187                                                && !attribute.value().starts_with('#')
188                                                && !attribute.value().starts_with("data:")
189                                        })
190                                    {
191                                        return None;
192                                    }
193                                }
194                                Some(usvg::ImageKind::SVG(
195                                    usvg::Tree::from_xmltree(&doc, options).ok()?,
196                                ))
197                            })();
198                            nesting.fetch_sub(1, Ordering::Relaxed);
199                            return parsed;
200                        }
201                        let reader = image::ImageReader::new(std::io::Cursor::new(data.as_slice()))
202                            .with_guessed_format()
203                            .ok()?;
204                        let format = reader.format()?;
205
206                        use image::ImageDecoder as _;
207                        let dimensions = reader.into_decoder().ok()?.dimensions();
208                        let count = dimensions.0 as usize * dimensions.1 as usize;
209                        pixels
210                            .try_update(Ordering::Relaxed, Ordering::Relaxed, |total| {
211                                total
212                                    .checked_add(count)
213                                    .filter(|total| *total <= 16_777_216)
214                            })
215                            .ok()?;
216                        Some(match format {
217                            image::ImageFormat::Png => usvg::ImageKind::PNG(data),
218                            image::ImageFormat::Jpeg => usvg::ImageKind::JPEG(data),
219                            image::ImageFormat::WebP => usvg::ImageKind::WEBP(data),
220                            _ => return None,
221                        })
222                    })();
223                    if result.is_none() {
224                        failed.store(true, Ordering::Relaxed);
225                    }
226                    result
227                }),
228            },
229            ..Default::default()
230        };
231        let parsed = usvg::Tree::from_xmltree(&document, &options)
232            .map_err(|error| motion_core::Error::Svg(Box::new(error)))?;
233        if failed.load(Ordering::Relaxed) {
234            return Err(unsupported(
235                "Invalid, external, oversized or unsupported SVG image resource",
236            ));
237        }
238        let size = Size::new(
239            f64::from(parsed.size().width()),
240            f64::from(parsed.size().height()),
241        );
242        let viewport = size.to_rect();
243        let view_box = if let Some(value) = document.root_element().attribute("viewBox") {
244            let value = svgtypes::ViewBox::from_str(value)
245                .map_err(|error| motion_core::Error::Svg(Box::new(error)))?;
246            Rect::new(value.x, value.y, value.x + value.w, value.y + value.h)
247        } else {
248            viewport
249        };
250        let mut count = 0;
251        let mut diagnostics = Vec::new();
252        let root = group(parsed.root(), 0, &mut count, &mut diagnostics)?;
253        let mut tree = VectorTree::new(viewport, root)?;
254        if let Some(root_id) = document
255            .root_element()
256            .attribute("id")
257            .filter(|id| !id.is_empty())
258            && !tree.ids().contains_key(root_id)
259        {
260            let mut root = tree.root().clone();
261            root.id = Some(root_id.into());
262            tree = VectorTree::new(viewport, root)?;
263        }
264        Ok(Self {
265            view_box,
266            tree: Arc::new(tree),
267            diagnostics: diagnostics.into(),
268            size,
269        })
270    }
271
272    /// Intrinsic viewport size after SVG units and viewBox mapping.
273    pub fn size(&self) -> Size {
274        self.size
275    }
276
277    /// Unsupported filter resources skipped as a whole, with retained reasons.
278    pub fn diagnostics(&self) -> &[SvgDiagnostic] {
279        &self.diagnostics
280    }
281}
282
283impl motion_core::Asset for SvgAsset {
284    const KIND: &'static str = "svg";
285
286    fn decode(bytes: &[u8], context: &motion_core::LoadContext) -> motion_core::Result<Self> {
287        if let Some(fonts) = context.fonts() {
288            let fonts = fonts.downcast_ref::<crate::FontLibrary>().ok_or_else(|| {
289                unsupported("SVG received an incompatible frozen font environment")
290            })?;
291            Self::from_bytes_with_fonts(bytes, fonts)
292        } else {
293            Self::from_bytes(bytes)
294        }
295    }
296
297    fn retained_bytes(&self) -> usize {
298        fn paint_bytes(paint: &motion_core::Paint) -> usize {
299            paint.gradient().map_or(0, |gradient| {
300                gradient.stops.len() * std::mem::size_of::<(f64, Color)>()
301            })
302        }
303
304        fn bytes(group: &VectorGroup) -> usize {
305            let mut total = std::mem::size_of::<VectorGroup>()
306                + group
307                    .filters
308                    .iter()
309                    .map(|filter| {
310                        std::mem::size_of::<motion_core::Filter>() + filter.heap_bytes() as usize
311                    })
312                    .sum::<usize>()
313                + group.id.as_ref().map_or(0, |id| id.len())
314                + group.children.len() * std::mem::size_of::<VectorItem>();
315            if let Some(clip) = &group.clip {
316                total += std::mem::size_of_val(clip.path.verbs());
317            }
318            if let Some(mask) = &group.mask {
319                total += bytes(mask.tree.root());
320            }
321            for item in &group.children {
322                total += match item {
323                    VectorItem::Group(group) => bytes(group),
324                    VectorItem::Path(path) => {
325                        std::mem::size_of_val(path.path.verbs())
326                            + path.id.as_ref().map_or(0, |id| id.len())
327                            + path
328                                .fill
329                                .as_ref()
330                                .map_or(0, |(paint, _)| paint_bytes(paint))
331                            + path
332                                .stroke
333                                .as_ref()
334                                .map_or(0, |(_, paint)| paint_bytes(paint))
335                    }
336                    VectorItem::Image(image) => {
337                        image.image.retained_bytes() + image.id.as_ref().map_or(0, |id| id.len())
338                    }
339                };
340            }
341            total
342        }
343        bytes(self.tree.root())
344            + self
345                .tree
346                .ids()
347                .iter()
348                .map(|(id, address)| id.len() + address.len() * std::mem::size_of::<usize>())
349                .sum::<usize>()
350            + self
351                .diagnostics
352                .iter()
353                .map(|diagnostic| {
354                    std::mem::size_of::<SvgDiagnostic>()
355                        + diagnostic.message.len()
356                        + diagnostic.element.as_ref().map_or(0, |id| id.len())
357                })
358                .sum::<usize>()
359    }
360}
361
362fn unsupported(message: impl Into<String>) -> motion_core::Error {
363    motion_core::ValidationError::unsupported("svg", "content", message.into()).into()
364}
365
366fn matrix(value: usvg::Transform) -> Transform {
367    Transform::from_coefficients([
368        f64::from(value.sx),
369        f64::from(value.ky),
370        f64::from(value.kx),
371        f64::from(value.sy),
372        f64::from(value.tx),
373        f64::from(value.ty),
374    ])
375}
376
377fn geometry(source: &usvg::tiny_skia_path::Path) -> Result<Path2D, motion_core::ValidationError> {
378    use usvg::tiny_skia_path::PathSegment;
379    let mut path = Path2D::new();
380    let point =
381        |point: usvg::tiny_skia_path::Point| Point::new(f64::from(point.x), f64::from(point.y));
382    for segment in source.segments() {
383        match segment {
384            PathSegment::MoveTo(end) => path.try_move_to(point(end)),
385            PathSegment::LineTo(end) => path.try_line_to(point(end)),
386            PathSegment::QuadTo(control, end) => path.try_quadratic_to(point(control), point(end)),
387            PathSegment::CubicTo(first, second, end) => {
388                path.try_cubic_to(point(first), point(second), point(end))
389            }
390            PathSegment::Close => path.try_close(),
391        }?;
392    }
393    Ok(path)
394}
395
396fn id(value: &str) -> Option<Arc<str>> {
397    (!value.is_empty()).then(|| Arc::from(value))
398}