diff --git a/README.md b/README.md index a29bbfeb0..45361e66e 100644 --- a/README.md +++ b/README.md @@ -174,10 +174,11 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Header<'a> { } } +#[cfg(test)] +mod model_tests; #[cfg(test)] mod tests; diff --git a/crates/lottie/src/model/layer.rs b/crates/lottie/src/model/layer.rs new file mode 100644 index 000000000..f350234ae --- /dev/null +++ b/crates/lottie/src/model/layer.rs @@ -0,0 +1,224 @@ +//! Layers, their transform, masks and effects. + +use super::{Animated, Lenient, Map, Shape, TextData, fields, object, take}; +use crate::Json; + +object! { + /// A transform (`ks` of a layer, a `tr` shape item). + Transform { + anchor / "a": Animated, + /// The position; see [`Animated::split`] for one split in dimensions. + position / "p": Animated, + /// Percent. + scale / "s": Animated, + /// Degrees. A three-dimensional layer has `rx`, `ry`, `rz` and `or` instead. + rotation / "r": Animated, + rotation_x / "rx": Animated, + rotation_y / "ry": Animated, + rotation_z / "rz": Animated, + orientation / "or": Animated, + /// 0..=100. + opacity / "o": Animated, + skew / "sk": Animated, + skew_axis / "sa": Animated, + } +} + +object! { + Mask { + name / "nm": String, + match_name / "mn": String, + /// `n` none, `a` add, `s` subtract, `i` intersect, `l` lighten, `d` darken, `f` difference. + mode / "mode": String, + inverted / "inv": bool, + /// The path. + path / "pt": Animated, + /// 0..=100. + opacity / "o": Animated, + expansion / "x": Animated, + /// Whether the path is closed, in files that keep the flag outside the path. + closed / "cl": bool, + } +} + +object! { + /// An effect, or one value of an effect. + Effect { + /// The effect type (5 custom, 20 tint, 21 fill, 25 drop shadow, 29 gaussian blur, ...) or the + /// value type (0 slider, 1 angle, 2 colour, 3 point, 4 checkbox, 7 dropdown, 10 layer). + kind / "ty": i64, + name / "nm": String, + match_name / "mn": String, + index / "ix": i64, + /// How many values the effect has. + count / "np": i64, + /// 0 when the effect is switched off. + enabled / "en": i64, + /// The value of an effect value. + value / "v": Animated, + /// The values of an effect. + values / "ef": Vec, + } +} + +fields! { + /// A layer that shows a precomposition. + PrecompLayer { + /// The id of the asset. + ref_id / "refId": String, + width / "w": f64, + height / "h": f64, + /// Time remapping: the precomposition's time in seconds. + time_remap / "tm": Animated, + } +} + +fields! { + SolidLayer { + /// `#rrggbb`. + color / "sc": String, + width / "sw": f64, + height / "sh": f64, + } +} + +/// What a layer shows, by its `ty`. +#[derive(Debug, Clone, PartialEq)] +pub enum LayerContent { + /// `ty` 0. + Precomp(PrecompLayer), + /// `ty` 1. + Solid(SolidLayer), + /// `ty` 2: the id of the image asset (`refId`). + Image(Option), + /// `ty` 3. + Null, + /// `ty` 4: its `shapes`, the topmost first. + Shape(Vec), + /// `ty` 5: its `t`. + Text(Box), + /// Any other `ty` (6 audio, 13 camera, ...), as written; `Null` when the layer has none. The + /// layer's own keys are in its `extra`. + Other(Json), +} + +impl Default for LayerContent { + fn default() -> Self { + Self::Other(Json::Null) + } +} + +fields! { + /// A layer. + Layer { + name / "nm": String, + match_name / "mn": String, + /// The number other layers name it by (`parent`, `tp`). + index / "ind": i64, + parent / "parent": i64, + hidden / "hd": bool, + three_d / "ddd": i64, + /// The first frame the layer shows at, in composition frames. + in_point / "ip": f64, + /// The frame after the last. + out_point / "op": f64, + /// The composition frame of the layer's frame 0. + start_time / "st": f64, + /// Time stretch: 1 is normal speed, 2 half speed. + stretch / "sr": f64, + auto_orient / "ao": i64, + /// The blend mode, 0 normal. + blend_mode / "bm": i64, + transform / "ks": Transform, + has_mask / "hasMask": bool, + masks / "masksProperties": Vec, + effects / "ef": Vec, + /// How the layer is matted: 1 alpha, 2 inverted alpha, 3 luma, 4 inverted luma. + matte_mode / "tt": i64, + /// The `ind` of the matte layer; without it, the layer above. + matte_parent / "tp": i64, + /// 1 when the layer is a matte for another. + is_matte / "td": i64, + collapse_transform / "ct": i64, + /// A CSS class and an element id, for players that render to a page. + class / "cl": String, + element_id / "ln": String, + } + { + content: LayerContent, + /// Every other key of the object, as it was. + extra: Map, + } +} + +impl LayerContent { + fn take(m: &mut Map) -> Self { + let Some(ty) = m.remove("ty") else { return Self::default() }; + match ty.as_i64() { + Some(0) => Self::Precomp(PrecompLayer::take(m)), + Some(1) => Self::Solid(SolidLayer::take(m)), + Some(2) => Self::Image(take(m, "refId")), + Some(3) => Self::Null, + // A shape or text layer without its content is kept as written. + Some(4) => take(m, "shapes").map_or(Self::Other(ty), Self::Shape), + Some(5) => take::(m, "t").map_or(Self::Other(ty), |t| Self::Text(Box::new(t))), + _ => Self::Other(ty), + } + } + + fn put(&self, m: &mut Map) { + let ty = match self { + Self::Precomp(p) => { + p.put(m); + 0 + } + Self::Solid(s) => { + s.put(m); + 1 + } + Self::Image(id) => { + if let Some(id) = id { + m.insert("refId".to_string(), id.to_json()); + } + 2 + } + Self::Null => 3, + Self::Shape(shapes) => { + m.insert("shapes".to_string(), shapes.to_json()); + 4 + } + Self::Text(t) => { + m.insert("t".to_string(), t.to_json()); + 5 + } + Self::Other(ty) => { + if !ty.is_null() { + m.insert("ty".to_string(), ty.clone()); + } + return; + } + }; + m.insert("ty".to_string(), Json::from(ty)); + } +} + +impl Lenient for Layer { + fn from_json(j: Json) -> Result { + match j { + Json::Object(mut m) => { + let content = LayerContent::take(&mut m); + let mut l = Self::take(&mut m); + l.content = content; + l.extra = m; + Ok(l) + } + other => Err(other), + } + } + + fn to_json(&self) -> Json { + let mut m = self.extra.clone(); + self.put(&mut m); + self.content.put(&mut m); + Json::Object(m) + } +} diff --git a/crates/lottie/src/model/mod.rs b/crates/lottie/src/model/mod.rs new file mode 100644 index 000000000..fe7535eee --- /dev/null +++ b/crates/lottie/src/model/mod.rs @@ -0,0 +1,413 @@ +//! The document model: the objects of a Lottie file as the file has them. +//! +//! Each struct is one object of the lottie-spec 1.0 object model, with a field per key it defines. +//! Every field is optional, because files leave keys out. A key the struct does not name, and a +//! key whose value has another type than the struct expects, stay in `extra` as they were, so +//! reading a document and writing it back keeps all of it. +//! +//! Animatable properties are [`Animated`]: the JSON of the property, read with +//! [`Animated::read`]. The meaning of a value depends on the property, which the file does not +//! say, so the model does not decode values. + +mod layer; +mod shape; +mod text; + +pub use layer::{Effect, Layer, LayerContent, Mask, PrecompLayer, SolidLayer, Transform}; +pub use shape::{ + Dash, Ellipse, Fill, GradientColors, GradientFill, GradientStroke, Group, Merge, OffsetPath, Path, PolyStar, PuckerBloat, Rect, Repeater, + RepeaterTransform, RoundCorners, Shape, ShapeKind, Stroke, Trim, Twist, ZigZag, +}; +pub use text::{TextAlign, TextData, TextDocKey, TextDocs, TextDocument, TextFollowPath, TextRange, TextSelector, TextStyle}; + +use crate::{Json, KeyOut, LottieError, Prop, read_prop, write_prop}; + +/// A JSON object. +pub type Map = serde_json::Map; + +/// A value of the model as JSON. `from_json` gives the value back when it has another type, so +/// the caller can keep it. +pub(crate) trait Lenient: Sized { + fn from_json(j: Json) -> Result; + fn to_json(&self) -> Json; +} + +/// A number as JSON: whole numbers without a fraction part, as files have them. +fn number(v: f64) -> Json { + if v.fract() == 0.0 && v.abs() < 9.0e15 { Json::from(v as i64) } else { Json::from(v) } +} + +impl Lenient for f64 { + fn from_json(j: Json) -> Result { + j.as_f64().ok_or(j) + } + fn to_json(&self) -> Json { + number(*self) + } +} + +impl Lenient for i64 { + /// A whole number, however it is written (`1`, `1.0`). + fn from_json(j: Json) -> Result { + let whole = j.as_i64().or_else(|| j.as_f64().filter(|f| f.fract() == 0.0 && f.abs() < 9.0e15).map(|f| f as i64)); + whole.ok_or(j) + } + fn to_json(&self) -> Json { + Json::from(*self) + } +} + +impl Lenient for bool { + /// `true` / `false`, or 1 / 0 (older files). + fn from_json(j: Json) -> Result { + match (j.as_bool(), j.as_f64()) { + (Some(b), _) => Ok(b), + (None, Some(n)) if n == 0.0 || n == 1.0 => Ok(n == 1.0), + _ => Err(j), + } + } + fn to_json(&self) -> Json { + Json::Bool(*self) + } +} + +impl Lenient for String { + fn from_json(j: Json) -> Result { + match j { + Json::String(s) => Ok(s), + other => Err(other), + } + } + fn to_json(&self) -> Json { + Json::String(self.clone()) + } +} + +impl Lenient for Map { + fn from_json(j: Json) -> Result { + match j { + Json::Object(m) => Ok(m), + other => Err(other), + } + } + fn to_json(&self) -> Json { + Json::Object(self.clone()) + } +} + +impl Lenient for Vec { + /// A list. Entries of another type than `T` are left out. + fn from_json(j: Json) -> Result { + match j { + Json::Array(a) => Ok(a.into_iter().filter_map(|v| T::from_json(v).ok()).collect()), + other => Err(other), + } + } + fn to_json(&self) -> Json { + Json::Array(self.iter().map(T::to_json).collect()) + } +} + +/// Takes key `k` out of `m` as a `T`; a value of another type stays in `m`. +fn take(m: &mut Map, k: &str) -> Option { + match T::from_json(m.remove(k)?) { + Ok(v) => Some(v), + Err(j) => { + m.insert(k.to_string(), j); + None + } + } +} + +/// A struct of optional fields, each one key of a JSON object: `field / "key": Type`. `take` +/// moves the keys out of an object and `put` writes them back. Fields after `+` are plain. +macro_rules! fields { + ($(#[$m:meta])* $name:ident { $($(#[$fm:meta])* $field:ident / $json:literal : $ty:ty,)* } $(+ { $($(#[$xm:meta])* $xf:ident : $xt:ty,)* })?) => { + $(#[$m])* + #[derive(Debug, Clone, Default, PartialEq)] + pub struct $name { + $($(#[$fm])* pub $field: Option<$ty>,)* + $($($(#[$xm])* pub $xf: $xt,)*)? + } + + impl $name { + #[allow(clippy::needless_update, dead_code)] + pub(crate) fn take(m: &mut $crate::model::Map) -> Self { + Self { $($field: $crate::model::take(m, $json),)* ..Default::default() } + } + + #[allow(dead_code)] + pub(crate) fn put(&self, m: &mut $crate::model::Map) { + $(if let Some(v) = &self.$field { + m.insert($json.to_string(), $crate::model::Lenient::to_json(v)); + })* + } + } + }; +} + +/// A [`fields!`] struct that is a whole JSON object: the keys it does not name stay in `extra`. +macro_rules! object { + ($(#[$m:meta])* $name:ident { $($body:tt)* }) => { + $crate::model::fields! { + $(#[$m])* + $name { $($body)* } + { + /// Every other key of the object, as it was. + extra: $crate::model::Map, + } + } + + impl $crate::model::Lenient for $name { + fn from_json(j: $crate::Json) -> Result { + match j { + $crate::Json::Object(mut m) => { + let mut o = Self::take(&mut m); + o.extra = m; + Ok(o) + } + other => Err(other), + } + } + + fn to_json(&self) -> $crate::Json { + let mut m = self.extra.clone(); + self.put(&mut m); + $crate::Json::Object(m) + } + } + }; +} +pub(crate) use {fields, object}; + +/// An animatable property as the file has it: `{a, k}` with keyframes or one value, sometimes +/// the bare value. +#[derive(Debug, Clone, PartialEq)] +pub struct Animated(pub Json); + +impl Animated { + /// The property's value or keyframes, and its expression. + pub fn read(&self) -> Prop<'_> { + read_prop(&self.0) + } + + /// A property with one value. + pub fn fixed(value: impl Into) -> Self { + Self(write_prop(value.into(), &[], None)) + } + + /// A keyframed property. + pub fn keyed(keys: &[KeyOut], expression: Option<&str>) -> Self { + Self(write_prop(Json::Null, keys, expression)) + } + + /// The `x`, `y` and `z` properties of a position split in dimensions (`{s: true, x, y, z}`). + pub fn split(&self) -> Option<[Option<&Json>; 3]> { + let on = self.0.get("s").is_some_and(|s| s.as_bool() == Some(true) || s.as_f64() == Some(1.0)); + on.then(|| ["x", "y", "z"].map(|k| self.0.get(k))) + } +} + +impl Lenient for Animated { + fn from_json(j: Json) -> Result { + if j.is_null() { Err(j) } else { Ok(Self(j)) } + } + fn to_json(&self) -> Json { + self.0.clone() + } +} + +object! { + /// The document. + Animation { + /// The version of the exporter that wrote the file. + version / "v": String, + name / "nm": String, + match_name / "mn": String, + /// Frames per second. + frame_rate / "fr": f64, + /// The first frame. + in_point / "ip": f64, + /// The frame after the last. + out_point / "op": f64, + width / "w": f64, + height / "h": f64, + /// 1 when a layer is three-dimensional. + three_d / "ddd": i64, + /// Images and precompositions the layers name by id. + assets / "assets": Vec, + /// The layers, the topmost first. + layers / "layers": Vec, + markers / "markers": Vec, + fonts / "fonts": Fonts, + /// Glyph outlines for players without the fonts. + chars / "chars": Vec, + /// Generator, author, keywords, description. + meta / "meta": Map, + slots / "slots": Map, + } +} + +object! { + /// An asset: an image (`p`, `u`, `e`) or a precomposition (`layers`). + Asset { + id / "id": String, + name / "nm": String, + /// The folder of an image file. + dir / "u": String, + /// The file name of an image, or a `data:` URI when `embedded` is 1. + path / "p": String, + width / "w": f64, + height / "h": f64, + embedded / "e": i64, + /// `"seq"` for an image sequence. + kind / "t": String, + /// The frame rate of a precomposition, when it has its own. + frame_rate / "fr": f64, + /// The layers of a precomposition. + layers / "layers": Vec, + } +} + +object! { + /// A marker on the time line. + Marker { + comment / "cm": String, + /// The frame. + time / "tm": f64, + /// Frames. + duration / "dr": f64, + } +} + +object! { + /// The fonts the text layers use. + Fonts { + list / "list": Vec, + } +} + +object! { + Font { + /// The name text documents refer to. + name / "fName": String, + family / "fFamily": String, + style / "fStyle": String, + path / "fPath": String, + weight / "fWeight": String, + class / "fClass": String, + /// Where the font comes from (0 local, 1 CSS URL, 2 script URL, 3 font URL). + origin / "origin": i64, + ascent / "ascent": f64, + } +} + +object! { + /// The outline of one character. + Char { + character / "ch": String, + family / "fFamily": String, + style / "style": String, + size / "size": f64, + /// The advance width at size 100. + width / "w": f64, + data / "data": CharData, + } +} + +object! { + CharData { + shapes / "shapes": Vec, + } +} + +/// What a document may hold. Each is checked by [`Animation::read_with_limits`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Limits { + /// Bytes of JSON. + pub max_bytes: usize, + /// Nesting of arrays and objects. + pub max_depth: usize, + /// Layers of the document and of every precomposition together. + pub max_layers: usize, + pub max_assets: usize, + /// Shape items of every layer and character together, groups and their contents counted. + pub max_shapes: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { max_bytes: 256 << 20, max_depth: 128, max_layers: 100_000, max_assets: 20_000, max_shapes: 2_000_000 } + } +} + +/// Whether `j` nests deeper than `max` (a scalar is depth 0). Not recursive. +fn deeper_than(j: &Json, max: usize) -> bool { + let mut stack = vec![(j, 0usize)]; + while let Some((v, depth)) = stack.pop() { + match v { + Json::Array(_) | Json::Object(_) if depth >= max => return true, + Json::Array(a) => stack.extend(a.iter().map(|c| (c, depth + 1))), + Json::Object(m) => stack.extend(m.values().map(|c| (c, depth + 1))), + _ => {} + } + } + false +} + +fn count_shapes(shapes: &[Shape]) -> usize { + shapes.iter().map(|s| 1 + if let ShapeKind::Group(g) = &s.kind { count_shapes(g.items.as_deref().unwrap_or_default()) } else { 0 }).sum() +} + +fn count_layer_shapes(layers: &[Layer]) -> usize { + layers.iter().map(|l| if let LayerContent::Shape(s) = &l.content { count_shapes(s) } else { 0 }).sum() +} + +impl Animation { + /// Reads a document with the default [`Limits`]. + pub fn read(bytes: &[u8]) -> Result { + Self::read_with_limits(bytes, &Limits::default()) + } + + /// Reads a document. Any JSON object reads: keys of another type than the model expects are + /// kept in `extra`, and a document without layers is a document without layers. + pub fn read_with_limits(bytes: &[u8], limits: &Limits) -> Result { + if bytes.len() > limits.max_bytes { + return Err(LottieError::TooLarge { what: "bytes", limit: limits.max_bytes }); + } + Self::from_document(crate::parse_document(bytes)?, limits) + } + + /// The model of parsed JSON. + pub fn from_document(doc: Json, limits: &Limits) -> Result { + if deeper_than(&doc, limits.max_depth) { + return Err(LottieError::TooLarge { what: "nesting levels", limit: limits.max_depth }); + } + let doc = ::from_json(doc).map_err(|_| LottieError::Parse("the document is not an object".into()))?; + let assets = doc.assets.as_deref().unwrap_or_default(); + let top = doc.layers.as_deref().unwrap_or_default(); + let nested = || assets.iter().map(|a| a.layers.as_deref().unwrap_or_default()); + let chars = doc.chars.as_deref().unwrap_or_default().iter().filter_map(|c| c.data.as_ref()?.shapes.as_deref()); + let check = |what, n: usize, limit| if n > limit { Err(LottieError::TooLarge { what, limit }) } else { Ok(()) }; + check("assets", assets.len(), limits.max_assets)?; + check("layers", top.len() + nested().map(<[Layer]>::len).sum::(), limits.max_layers)?; + let shapes = count_layer_shapes(top) + nested().map(count_layer_shapes).sum::() + chars.map(count_shapes).sum::(); + check("shape items", shapes, limits.max_shapes)?; + Ok(doc) + } + + /// The document as JSON. + pub fn to_json(&self) -> Json { + Lenient::to_json(self) + } + + /// The document as compact JSON text. + pub fn to_vec(&self) -> Vec { + // Writing a `Value` to a `Vec` cannot fail. + serde_json::to_vec(&self.to_json()).unwrap_or_default() + } + + /// The precomposition or image asset with this id. + pub fn asset(&self, id: &str) -> Option<&Asset> { + self.assets.as_deref()?.iter().find(|a| a.id.as_deref() == Some(id)) + } +} diff --git a/crates/lottie/src/model/shape.rs b/crates/lottie/src/model/shape.rs new file mode 100644 index 000000000..e1e95747e --- /dev/null +++ b/crates/lottie/src/model/shape.rs @@ -0,0 +1,393 @@ +//! Shape items: the contents of a shape layer. + +use super::{Animated, Lenient, Map, Transform, fields, object}; +use crate::Json; + +fields! { + /// `gr`: a group. Its last item is usually its transform (`tr`). + Group { + /// How many items it has. + count / "np": f64, + items / "it": Vec, + } +} + +fields! { + /// `rc`. + Rect { + /// The drawing direction: 1 clockwise, 3 counter-clockwise. + direction / "d": i64, + /// The centre. + position / "p": Animated, + size / "s": Animated, + /// The corner radius. + roundness / "r": Animated, + } +} + +fields! { + /// `el`. + Ellipse { + direction / "d": i64, + position / "p": Animated, + size / "s": Animated, + } +} + +fields! { + /// `sr`: a star or a polygon. + PolyStar { + direction / "d": i64, + /// 1 star, 2 polygon. + star_type / "sy": i64, + position / "p": Animated, + points / "pt": Animated, + rotation / "r": Animated, + outer_radius / "or": Animated, + /// Percent. + outer_roundness / "os": Animated, + /// Stars only. + inner_radius / "ir": Animated, + inner_roundness / "is": Animated, + } +} + +fields! { + /// `sh`: a bezier path. + Path { + direction / "d": i64, + path / "ks": Animated, + /// Whether the path is closed, in files that keep the flag outside the path. + closed / "closed": bool, + } +} + +fields! { + /// `fl`. + Fill { + color / "c": Animated, + /// 0..=100. + opacity / "o": Animated, + /// 1 non-zero, 2 even-odd. + rule / "r": i64, + } +} + +object! { + /// One entry of a dash pattern. + Dash { + /// `d` dash, `g` gap, `o` offset. + kind / "n": String, + name / "nm": String, + match_name / "mn": String, + length / "v": Animated, + } +} + +fields! { + /// `st`. + Stroke { + color / "c": Animated, + opacity / "o": Animated, + width / "w": Animated, + /// 1 butt, 2 round, 3 square. + cap / "lc": i64, + /// 1 miter, 2 round, 3 bevel. + join / "lj": i64, + miter_limit / "ml": f64, + /// The miter limit as a property. + miter_limit_animated / "ml2": Animated, + dashes / "d": Vec, + } +} + +object! { + /// The colours of a gradient: the stop list is read with [`Gradient`](crate::Gradient). + GradientColors { + /// How many colour stops the list has. + count / "p": i64, + stops / "k": Animated, + } +} + +fields! { + /// `gf`. + GradientFill { + colors / "g": GradientColors, + start / "s": Animated, + end / "e": Animated, + /// 1 linear, 2 radial. + gradient_type / "t": i64, + /// Radial: where the highlight is along the radius, percent. + highlight_length / "h": Animated, + highlight_angle / "a": Animated, + opacity / "o": Animated, + rule / "r": i64, + } +} + +fields! { + /// `gs`. + GradientStroke { + colors / "g": GradientColors, + start / "s": Animated, + end / "e": Animated, + gradient_type / "t": i64, + highlight_length / "h": Animated, + highlight_angle / "a": Animated, + opacity / "o": Animated, + width / "w": Animated, + cap / "lc": i64, + join / "lj": i64, + miter_limit / "ml": f64, + miter_limit_animated / "ml2": Animated, + dashes / "d": Vec, + } +} + +fields! { + /// `tm`: trim paths. + Trim { + /// Percent. + start / "s": Animated, + end / "e": Animated, + /// Degrees. + offset / "o": Animated, + /// 1 all paths together, 2 each on its own. + multiple / "m": i64, + } +} + +fields! { + /// `rd`. + RoundCorners { + radius / "r": Animated, + } +} + +object! { + /// The transform a repeater applies to each copy. + RepeaterTransform { + anchor / "a": Animated, + position / "p": Animated, + scale / "s": Animated, + rotation / "r": Animated, + start_opacity / "so": Animated, + end_opacity / "eo": Animated, + skew / "sk": Animated, + skew_axis / "sa": Animated, + } +} + +fields! { + /// `rp`. + Repeater { + copies / "c": Animated, + offset / "o": Animated, + /// 1 each copy above the one before, 2 below. + composite / "m": i64, + transform / "tr": RepeaterTransform, + } +} + +fields! { + /// `mm`: merge paths. + Merge { + /// 1 merge, 2 add, 3 subtract, 4 intersect, 5 exclude intersections. + mode / "mm": i64, + } +} + +fields! { + /// `pb`. + PuckerBloat { + /// Percent. + amount / "a": Animated, + } +} + +fields! { + /// `tw`. + Twist { + angle / "a": Animated, + center / "c": Animated, + } +} + +fields! { + /// `zz`. + ZigZag { + /// The size of a ridge. + amount / "r": Animated, + /// Ridges per segment. + frequency / "s": Animated, + /// 1 corner, 2 smooth. + point_type / "pt": Animated, + } +} + +fields! { + /// `op`. + OffsetPath { + amount / "a": Animated, + join / "lj": i64, + miter_limit / "ml": Animated, + } +} + +/// What a shape item is, by its `ty`. +#[derive(Debug, Clone, PartialEq)] +pub enum ShapeKind { + Group(Group), + Rect(Rect), + Ellipse(Ellipse), + PolyStar(PolyStar), + Path(Path), + Fill(Fill), + Stroke(Stroke), + GradientFill(GradientFill), + GradientStroke(GradientStroke), + /// `tr`: the transform of the group it is in. Its keys are those of a layer transform. + Transform(Box), + Trim(Trim), + RoundCorners(RoundCorners), + Repeater(Repeater), + Merge(Merge), + PuckerBloat(PuckerBloat), + Twist(Twist), + ZigZag(ZigZag), + OffsetPath(OffsetPath), + /// Any other `ty`, as written; `Null` when the item has none. The item's own keys are in its + /// `extra`. + Other(Json), +} + +impl Default for ShapeKind { + fn default() -> Self { + Self::Other(Json::Null) + } +} + +fields! { + /// A shape item. + Shape { + name / "nm": String, + match_name / "mn": String, + hidden / "hd": bool, + blend_mode / "bm": i64, + /// The property index expressions refer to. + index / "ix": i64, + class / "cl": String, + element_id / "ln": String, + } + { + kind: ShapeKind, + /// Every other key of the object, as it was. + extra: Map, + } +} + +impl ShapeKind { + fn take(m: &mut Map) -> Self { + let Some(ty) = m.remove("ty") else { return Self::default() }; + match ty.as_str().unwrap_or_default() { + "gr" => Self::Group(Group::take(m)), + "rc" => Self::Rect(Rect::take(m)), + "el" => Self::Ellipse(Ellipse::take(m)), + "sr" => Self::PolyStar(PolyStar::take(m)), + "sh" => Self::Path(Path::take(m)), + "fl" => Self::Fill(Fill::take(m)), + "st" => Self::Stroke(Stroke::take(m)), + "gf" => Self::GradientFill(GradientFill::take(m)), + "gs" => Self::GradientStroke(GradientStroke::take(m)), + // Only the transform's keys: the item's others (`nm`, `hd`, ...) stay with the item. + "tr" => Self::Transform(Box::new(Transform::take(m))), + "tm" => Self::Trim(Trim::take(m)), + "rd" => Self::RoundCorners(RoundCorners::take(m)), + "rp" => Self::Repeater(Repeater::take(m)), + "mm" => Self::Merge(Merge::take(m)), + "pb" => Self::PuckerBloat(PuckerBloat::take(m)), + "tw" => Self::Twist(Twist::take(m)), + "zz" => Self::ZigZag(ZigZag::take(m)), + "op" => Self::OffsetPath(OffsetPath::take(m)), + _ => Self::Other(ty), + } + } + + /// The `ty` of the item; `None` for [`ShapeKind::Other`]. + pub fn tag(&self) -> Option<&'static str> { + Some(match self { + Self::Group(_) => "gr", + Self::Rect(_) => "rc", + Self::Ellipse(_) => "el", + Self::PolyStar(_) => "sr", + Self::Path(_) => "sh", + Self::Fill(_) => "fl", + Self::Stroke(_) => "st", + Self::GradientFill(_) => "gf", + Self::GradientStroke(_) => "gs", + Self::Transform(_) => "tr", + Self::Trim(_) => "tm", + Self::RoundCorners(_) => "rd", + Self::Repeater(_) => "rp", + Self::Merge(_) => "mm", + Self::PuckerBloat(_) => "pb", + Self::Twist(_) => "tw", + Self::ZigZag(_) => "zz", + Self::OffsetPath(_) => "op", + Self::Other(_) => return None, + }) + } + + fn put(&self, m: &mut Map) { + match self { + Self::Group(v) => v.put(m), + Self::Rect(v) => v.put(m), + Self::Ellipse(v) => v.put(m), + Self::PolyStar(v) => v.put(m), + Self::Path(v) => v.put(m), + Self::Fill(v) => v.put(m), + Self::Stroke(v) => v.put(m), + Self::GradientFill(v) => v.put(m), + Self::GradientStroke(v) => v.put(m), + Self::Transform(v) => v.put(m), + Self::Trim(v) => v.put(m), + Self::RoundCorners(v) => v.put(m), + Self::Repeater(v) => v.put(m), + Self::Merge(v) => v.put(m), + Self::PuckerBloat(v) => v.put(m), + Self::Twist(v) => v.put(m), + Self::ZigZag(v) => v.put(m), + Self::OffsetPath(v) => v.put(m), + Self::Other(ty) => { + if !ty.is_null() { + m.insert("ty".to_string(), ty.clone()); + } + } + } + if let Some(tag) = self.tag() { + m.insert("ty".to_string(), Json::from(tag)); + } + } +} + +impl Lenient for Shape { + fn from_json(j: Json) -> Result { + match j { + Json::Object(mut m) => { + let kind = ShapeKind::take(&mut m); + let mut s = Self::take(&mut m); + s.kind = kind; + s.extra = m; + Ok(s) + } + other => Err(other), + } + } + + fn to_json(&self) -> Json { + let mut m = self.extra.clone(); + self.put(&mut m); + self.kind.put(&mut m); + Json::Object(m) + } +} diff --git a/crates/lottie/src/model/text.rs b/crates/lottie/src/model/text.rs new file mode 100644 index 000000000..8807c01d8 --- /dev/null +++ b/crates/lottie/src/model/text.rs @@ -0,0 +1,137 @@ +//! Text layers: the documents, the range animators and the path options. + +use super::{Animated, object}; + +object! { + /// The `t` of a text layer. + TextData { + documents / "d": TextDocs, + /// Range animators. + ranges / "a": Vec, + alignment / "m": TextAlign, + follow_path / "p": TextFollowPath, + } +} + +object! { + /// The text over time. It changes in steps: each key holds until the next. + TextDocs { + keys / "k": Vec, + expression / "x": String, + } +} + +object! { + TextDocKey { + /// The frame. + time / "t": f64, + document / "s": TextDocument, + } +} + +object! { + /// The text and how it is set. + TextDocument { + /// The font: the `fName` of an entry of the document's font list. + font / "f": String, + size / "s": f64, + /// `[r, g, b]`, 0..=1. + fill / "fc": Vec, + stroke / "sc": Vec, + stroke_width / "sw": f64, + /// Whether the stroke is drawn over the fill. + stroke_over_fill / "of": bool, + line_height / "lh": f64, + baseline_shift / "ls": f64, + /// Thousandths of an em. + tracking / "tr": f64, + /// 0 left, 1 right, 2 centre, 3 to 6 justified. + justify / "j": i64, + /// Lines are separated by `\r`. + text / "t": String, + /// The size of the text box. + box_size / "sz": Vec, + box_position / "ps": Vec, + /// 0 as typed, 1 all caps, 2 small caps. + caps / "ca": i64, + } +} + +object! { + /// A range animator: what changes (`a`) and for which characters (`s`). + TextRange { + name / "nm": String, + selector / "s": TextSelector, + style / "a": TextStyle, + } +} + +object! { + TextSelector { + /// 1 the range selector. + kind / "t": i64, + start / "s": Animated, + end / "e": Animated, + offset / "o": Animated, + /// The largest amount, percent. + amount / "a": Animated, + /// 1 characters, 2 characters without spaces, 3 words, 4 lines. + based_on / "b": i64, + /// 1 percent, 2 index. + units / "r": i64, + /// 1 square, 2 ramp up, 3 ramp down, 4 triangle, 5 round, 6 smooth. + shape / "sh": i64, + randomize / "rn": i64, + ease_high / "xe": Animated, + ease_low / "ne": Animated, + smoothness / "sm": Animated, + } +} + +object! { + /// What a range animator changes. + TextStyle { + position / "p": Animated, + anchor / "a": Animated, + scale / "s": Animated, + rotation / "r": Animated, + rotation_x / "rx": Animated, + rotation_y / "ry": Animated, + skew / "sk": Animated, + skew_axis / "sa": Animated, + opacity / "o": Animated, + fill / "fc": Animated, + fill_hue / "fh": Animated, + fill_saturation / "fs": Animated, + fill_brightness / "fb": Animated, + fill_opacity / "fo": Animated, + stroke / "sc": Animated, + stroke_width / "sw": Animated, + stroke_opacity / "so": Animated, + tracking / "t": Animated, + line_spacing / "ls": Animated, + blur / "bl": Animated, + } +} + +object! { + TextAlign { + /// What the anchor point is of: 1 characters, 2 words, 3 lines, 4 all. + grouping / "g": i64, + /// Percent. + anchor / "a": Animated, + } +} + +object! { + /// Text on a mask path. + TextFollowPath { + /// The index of the mask. + mask / "m": i64, + first_margin / "f": Animated, + last_margin / "l": Animated, + reverse / "r": Animated, + force_alignment / "a": Animated, + perpendicular / "p": Animated, + } +} diff --git a/crates/lottie/src/model_tests.rs b/crates/lottie/src/model_tests.rs new file mode 100644 index 000000000..e670aa18e --- /dev/null +++ b/crates/lottie/src/model_tests.rs @@ -0,0 +1,320 @@ +use serde_json::json; + +use crate::model::{Animated, Animation, Asset, Fill, Group, Layer, LayerContent, Limits, Marker, Rect, Shape, ShapeKind, SolidLayer, Transform}; +use crate::{Json, KeyOut, LottieError, PropValue, Tangents}; + +fn read(doc: &Json) -> Animation { + Animation::from_document(doc.clone(), &Limits::default()).unwrap() +} + +/// A document with every layer type, every shape item, masks, effects, text, markers, fonts, +/// glyphs and both kinds of asset. +fn everything() -> Json { + let p = |k: Json| json!({"a": 0, "k": k}); + let ks = json!({"a": p(json!([0, 0, 0])), "p": p(json!([50, 50, 0])), "s": p(json!([100, 100, 100])), "r": p(json!(0)), "o": p(json!(100))}); + let shapes = json!([ + {"ty": "gr", "nm": "Group", "np": 3, "it": [ + {"ty": "rc", "nm": "Rect", "d": 1, "p": p(json!([0, 0])), "s": p(json!([10, 20])), "r": p(json!(2))}, + {"ty": "el", "d": 3, "p": p(json!([0, 0])), "s": p(json!([5, 5]))}, + {"ty": "sr", "sy": 1, "d": 1, "p": p(json!([0, 0])), "pt": p(json!(5)), "r": p(json!(0)), "or": p(json!(10)), "os": p(json!(0)), "ir": p(json!(4)), "is": p(json!(0))}, + {"ty": "sh", "d": 1, "ks": p(json!({"c": true, "v": [[0, 0], [1, 0]], "i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]]}))}, + {"ty": "fl", "c": p(json!([1, 0, 0, 1])), "o": p(json!(100)), "r": 1, "bm": 0}, + {"ty": "st", "c": p(json!([0, 0, 1, 1])), "o": p(json!(50)), "w": p(json!(2)), "lc": 2, "lj": 1, "ml": 4, + "d": [{"n": "d", "nm": "dash", "v": p(json!(3))}, {"n": "g", "nm": "gap", "v": p(json!(1))}, {"n": "o", "nm": "offset", "v": p(json!(0))}]}, + {"ty": "gf", "g": {"p": 2, "k": p(json!([0, 1, 0, 0, 1, 0, 0, 1]))}, "s": p(json!([0, 0])), "e": p(json!([10, 0])), "t": 2, "h": p(json!(0)), "a": p(json!(0)), "o": p(json!(100)), "r": 2}, + {"ty": "gs", "g": {"p": 2, "k": p(json!([0, 1, 0, 0, 1, 0, 0, 1]))}, "s": p(json!([0, 0])), "e": p(json!([10, 0])), "t": 1, "o": p(json!(100)), "w": p(json!(1)), "lc": 1, "lj": 2, "ml2": p(json!(4))}, + {"ty": "tm", "s": p(json!(0)), "e": p(json!(50)), "o": p(json!(0)), "m": 1}, + {"ty": "rd", "r": p(json!(3))}, + {"ty": "rp", "c": p(json!(3)), "o": p(json!(0)), "m": 1, "tr": {"p": p(json!([10, 0])), "s": p(json!([100, 100])), "so": p(json!(100)), "eo": p(json!(0))}}, + {"ty": "mm", "mm": 2}, + {"ty": "pb", "a": p(json!(10))}, + {"ty": "tw", "a": p(json!(30)), "c": p(json!([0, 0]))}, + {"ty": "zz", "r": p(json!(5)), "s": p(json!(3)), "pt": p(json!(1))}, + {"ty": "op", "a": p(json!(2)), "lj": 1, "ml": p(json!(4))}, + {"ty": "tr", "nm": "Transform", "a": p(json!([0, 0])), "p": p(json!([0, 0])), "s": p(json!([100, 100])), "r": p(json!(0)), "o": p(json!(100)), "sk": p(json!(0)), "sa": p(json!(0))} + ]}, + {"ty": "future", "nm": "Later", "q": [1, 2]} + ]); + let text = json!({ + "d": {"k": [{"t": 0, "s": {"f": "Sans-Bold", "s": 24, "fc": [1, 1, 1], "sc": [0, 0, 0], "sw": 1, "of": true, "lh": 28.8, "ls": 0, "tr": 20, "j": 2, "t": "Hi\rthere", "sz": [200, 80], "ps": [-100, -40], "ca": 0}}]}, + "a": [{"nm": "Fade", "s": {"t": 1, "s": p(json!(0)), "e": p(json!(100)), "o": p(json!(0)), "a": p(json!(100)), "b": 1, "r": 1, "sh": 1, "rn": 0}, "a": {"o": p(json!(0)), "p": p(json!([0, 10])), "t": p(json!(5))}}], + "m": {"g": 1, "a": p(json!([0, 0]))}, + "p": {"m": 0, "f": p(json!(0)), "l": p(json!(0)), "r": p(json!(0)), "a": p(json!(0)), "p": p(json!(1))} + }); + json!({ + "v": "5.9.0", "nm": "All", "fr": 29.97, "ip": 0, "op": 90, "w": 320, "h": 240, "ddd": 0, + "meta": {"g": "test"}, + "markers": [{"cm": "start", "tm": 0, "dr": 10}], + "fonts": {"list": [{"fName": "Sans-Bold", "fFamily": "Sans", "fStyle": "Bold", "ascent": 75.5, "origin": 0}]}, + "chars": [{"ch": "H", "fFamily": "Sans", "style": "Bold", "size": 24, "w": 70, "data": {"shapes": [{"ty": "gr", "it": [{"ty": "sh", "ks": p(json!({"c": true, "v": [], "i": [], "o": []}))}]}]}}], + "assets": [ + {"id": "img", "nm": "Photo", "u": "images/", "p": "a.png", "w": 8, "h": 8, "e": 0}, + {"id": "pre", "nm": "Inner", "fr": 30, "layers": [{"ty": 3, "nm": "Null", "ind": 1, "ip": 0, "op": 90, "st": 0, "sr": 1, "ks": ks}]} + ], + "layers": [ + {"ty": 0, "nm": "Precomp", "ind": 1, "refId": "pre", "w": 320, "h": 240, "tm": p(json!(0)), "ip": 0, "op": 90, "st": 0, "sr": 1, "ks": ks, "tt": 1, "tp": 2}, + {"ty": 1, "nm": "Solid", "ind": 2, "sc": "#ff8000", "sw": 320, "sh": 240, "td": 1, "ip": 0, "op": 90, "st": 0, "sr": 1, "ks": ks, "hasMask": true, + "masksProperties": [{"nm": "M", "mode": "s", "inv": true, "pt": p(json!({"c": true, "v": [], "i": [], "o": []})), "o": p(json!(100)), "x": p(json!(0))}], + "ef": [{"ty": 29, "nm": "Blur", "np": 3, "en": 1, "ef": [{"ty": 0, "nm": "Blurriness", "v": p(json!(4))}, {"ty": 7, "nm": "Dimensions", "v": p(json!(1))}]}]}, + {"ty": 2, "nm": "Image", "ind": 3, "refId": "img", "parent": 5, "hd": false, "bm": 3, "ao": 0, "ip": 0, "op": 90, "st": 0, "sr": 1, "ks": ks}, + {"ty": 3, "nm": "Null", "ind": 5, "ddd": 1, "ip": 0, "op": 90, "st": 0, "sr": 1, "ks": {"p": {"s": true, "x": p(json!(1)), "y": p(json!(2))}, "rx": p(json!(0)), "ry": p(json!(0)), "rz": p(json!(0)), "or": p(json!([0, 0, 0]))}}, + {"ty": 4, "nm": "Shapes", "ind": 6, "ip": 0, "op": 90, "st": 0, "sr": 2.5, "ct": 0, "ks": ks, "shapes": shapes}, + {"ty": 5, "nm": "Text", "ind": 7, "ip": 0, "op": 90, "st": 0, "sr": 1, "ks": ks, "t": text}, + {"ty": 13, "nm": "Camera", "ind": 8, "pe": p(json!(500))} + ] + }) +} + +#[test] +fn every_object_reads_typed_and_writes_back_the_same() { + let doc = everything(); + let a = read(&doc); + assert_eq!((a.version.as_deref(), a.frame_rate, a.width, a.three_d), (Some("5.9.0"), Some(29.97), Some(320.0), Some(0))); + assert!(a.extra.is_empty(), "{:?}", a.extra); + assert_eq!( + a.markers.as_deref(), + Some(&[Marker { comment: Some("start".into()), time: Some(0.0), duration: Some(10.0), ..Marker::default() }][..]) + ); + assert_eq!(a.fonts.as_ref().unwrap().list.as_ref().unwrap()[0].ascent, Some(75.5)); + assert_eq!(a.chars.as_ref().unwrap()[0].data.as_ref().unwrap().shapes.as_ref().unwrap().len(), 1); + assert_eq!(a.asset("img").unwrap().path.as_deref(), Some("a.png")); + assert_eq!(a.asset("pre").unwrap().layers.as_ref().unwrap()[0].content, LayerContent::Null); + assert!(a.asset("none").is_none()); + + assert_eq!(a.to_json(), doc); + assert_eq!(Animation::read(&a.to_vec()).unwrap(), a); +} + +#[test] +fn every_layer_type_reads_typed() { + let a = read(&everything()); + let layers = a.layers.as_deref().unwrap(); + let LayerContent::Precomp(pre) = &layers[0].content else { panic!("precomp") }; + assert_eq!((pre.ref_id.as_deref(), pre.width, layers[0].matte_mode, layers[0].matte_parent), (Some("pre"), Some(320.0), Some(1), Some(2))); + assert_eq!(layers[1].content, LayerContent::Solid(SolidLayer { color: Some("#ff8000".into()), width: Some(320.0), height: Some(240.0) })); + let mask = &layers[1].masks.as_ref().unwrap()[0]; + assert_eq!((mask.mode.as_deref(), mask.inverted), (Some("s"), Some(true))); + let blur = &layers[1].effects.as_ref().unwrap()[0]; + assert_eq!((blur.kind, blur.values.as_ref().unwrap().len(), blur.values.as_ref().unwrap()[1].kind), (Some(29), 2, Some(7))); + assert_eq!(layers[2].content, LayerContent::Image(Some("img".into()))); + assert_eq!((layers[2].parent, layers[2].hidden, layers[2].blend_mode), (Some(5), Some(false), Some(3))); + let split = layers[3].transform.as_ref().unwrap().position.as_ref().unwrap().split().unwrap(); + assert!(split[0].is_some() && split[1].is_some() && split[2].is_none()); + assert!(layers[0].transform.as_ref().unwrap().position.as_ref().unwrap().split().is_none()); + assert_eq!(layers[4].stretch, Some(2.5)); + let LayerContent::Text(t) = &layers[5].content else { panic!("text") }; + let d = t.documents.as_ref().unwrap().keys.as_ref().unwrap()[0].document.as_ref().unwrap(); + assert_eq!( + (d.text.as_deref(), d.justify, d.fill.as_deref(), d.stroke_over_fill), + (Some("Hi\rthere"), Some(2), Some(&[1.0, 1.0, 1.0][..]), Some(true)) + ); + assert!(t.ranges.as_ref().unwrap()[0].style.as_ref().unwrap().tracking.is_some()); + assert_eq!(t.follow_path.as_ref().unwrap().mask, Some(0)); + assert_eq!(layers[6].content, LayerContent::Other(json!(13))); + assert!(layers[6].extra.contains_key("pe"), "an unknown layer keeps its own keys"); +} + +#[test] +fn every_shape_item_reads_typed() { + let a = read(&everything()); + let LayerContent::Shape(shapes) = &a.layers.as_deref().unwrap()[4].content else { panic!("shapes") }; + let ShapeKind::Group(g) = &shapes[0].kind else { panic!("group") }; + let tags: Vec<_> = g.items.as_ref().unwrap().iter().map(|s| s.kind.tag().unwrap()).collect(); + assert_eq!(tags, ["rc", "el", "sr", "sh", "fl", "st", "gf", "gs", "tm", "rd", "rp", "mm", "pb", "tw", "zz", "op", "tr"]); + let items = g.items.as_ref().unwrap(); + assert!(items.iter().all(|s| s.extra.is_empty()), "every key of every item is modelled"); + let ShapeKind::Stroke(st) = &items[5].kind else { panic!("stroke") }; + assert_eq!((st.cap, st.miter_limit, st.dashes.as_ref().unwrap().len()), (Some(2), Some(4.0), 3)); + let ShapeKind::GradientFill(gf) = &items[6].kind else { panic!("gradient") }; + let colors = gf.colors.as_ref().unwrap(); + let PropValue::Static(stops) = colors.stops.as_ref().unwrap().read().value else { panic!("static") }; + assert_eq!(crate::Gradient::from_flat(stops, colors.count.unwrap() as usize).unwrap().colors.len(), 2); + assert_eq!((items[16].name.as_deref(), matches!(items[16].kind, ShapeKind::Transform(_))), (Some("Transform"), true)); + assert_eq!(shapes[1].kind, ShapeKind::Other(json!("future"))); + assert_eq!(shapes[1].extra.get("q"), Some(&json!([1, 2]))); +} + +#[test] +fn what_the_model_does_not_know_is_kept() { + // Unknown keys at every level, known keys of another type, entries that are not objects. + let doc = json!({ + "w": "wide", "h": 10, "nm": 7, "tiny": {"x": 1}, "assets": {"not": "a list"}, + "layers": [ + {"ty": 4, "nm": ["n"], "ind": 1.5, "hd": 2, "ks": 5, "shapes": [{"ty": "rc", "d": "cw", "p": null, "zzz": 1}, {"nm": "no type"}], "custom": true}, + {"ty": 4, "nm": "no shapes"}, + {"ty": 5, "t": "not an object"}, + {"ty": "4", "shapes": []}, + {"nm": "no type at all"} + ] + }); + let a = read(&doc); + assert_eq!((a.width, a.height, &a.name, &a.assets), (None, Some(10.0), &None, &None)); + assert_eq!(a.extra.get("w"), Some(&json!("wide"))); + let layers = a.layers.as_deref().unwrap(); + assert_eq!((&layers[0].name, layers[0].index, layers[0].hidden, &layers[0].transform), (&None, None, None, &None)); + let LayerContent::Shape(shapes) = &layers[0].content else { panic!("shapes") }; + let ShapeKind::Rect(rc) = &shapes[0].kind else { panic!("rect") }; + assert_eq!(rc, &Rect::default()); + assert_eq!(shapes[1].kind, ShapeKind::Other(Json::Null)); + assert_eq!(layers[1].content, LayerContent::Other(json!(4))); + assert_eq!(layers[2].content, LayerContent::Other(json!(5))); + assert_eq!(layers[3].content, LayerContent::Other(json!("4"))); + assert_eq!(layers[4].content, LayerContent::Other(Json::Null)); + assert_eq!(a.to_json(), doc); + + // List entries of another type are the one thing left out. + let a = read(&json!({"layers": [1, "two", {"ty": 3}, null], "markers": [[], {"tm": 3}]})); + assert_eq!(a.to_json(), json!({"layers": [{"ty": 3}], "markers": [{"tm": 3}]})); +} + +#[test] +fn numbers_and_flags_are_read_however_written() { + let a = read( + &json!({"fr": 30.0, "ddd": 1.0, "w": 1e3, "layers": [{"ty": 3.0, "ind": 4.0, "hd": 1, "hasMask": 0, "sr": 0.5, "parent": 9007199254740993u64}]}), + ); + let l = &a.layers.as_ref().unwrap()[0]; + assert_eq!((a.three_d, l.index, l.hidden, l.has_mask, l.stretch), (Some(1), Some(4), Some(true), Some(false), Some(0.5))); + assert_eq!(l.content, LayerContent::Other(json!(3.0)), "a `ty` is an integer"); + // Written back: whole numbers without a fraction, flags as booleans. + let out = a.to_json(); + assert_eq!(serde_json::to_string(&out["fr"]).unwrap(), "30"); + assert_eq!(serde_json::to_string(&out["w"]).unwrap(), "1000"); + assert_eq!(out["layers"][0]["hd"], json!(true)); + assert_eq!(out["layers"][0]["sr"], json!(0.5)); + assert_eq!(out["layers"][0]["parent"], json!(9007199254740993u64)); + // Numbers that are not finite have no JSON form. + let a = Animation { frame_rate: Some(f64::NAN), width: Some(f64::INFINITY), ..Animation::default() }; + assert_eq!(a.to_json(), json!({"fr": null, "w": null})); +} + +#[test] +fn a_document_built_in_code_reads_back() { + let lin = Tangents { ox: 0.25, oy: 0.25, ix: 0.75, iy: 0.75 }; + let keys = [ + KeyOut { t: 0.0, s: json!([0]), hold: false, ease: vec![lin], spatial: None }, + KeyOut { t: 30.0, s: json!([100]), hold: false, ease: vec![], spatial: None }, + ]; + let rect = Shape { + name: Some("R".into()), + kind: ShapeKind::Rect(Rect { size: Some(Animated::fixed(json!([10, 10]))), ..Rect::default() }), + ..Shape::default() + }; + let fill = Shape { kind: ShapeKind::Fill(Fill { color: Some(Animated::fixed(json!([1, 0, 0, 1]))), ..Fill::default() }), ..Shape::default() }; + let group = Shape { kind: ShapeKind::Group(Group { count: Some(2.0), items: Some(vec![rect, fill]) }), ..Shape::default() }; + let layer = Layer { + name: Some("L".into()), + index: Some(1), + in_point: Some(0.0), + out_point: Some(30.0), + transform: Some(Transform { opacity: Some(Animated::keyed(&keys, Some("time"))), ..Transform::default() }), + content: LayerContent::Shape(vec![group]), + ..Layer::default() + }; + let a = Animation { + version: Some("5.9.0".into()), + frame_rate: Some(30.0), + in_point: Some(0.0), + out_point: Some(30.0), + width: Some(100.0), + height: Some(100.0), + assets: Some(vec![Asset { id: Some("x".into()), ..Asset::default() }]), + layers: Some(vec![layer]), + ..Animation::default() + }; + let back = Animation::read(&a.to_vec()).unwrap(); + assert_eq!(back, a); + let out = a.to_json(); + assert_eq!(out["layers"][0]["ty"], json!(4)); + assert_eq!(out["layers"][0]["shapes"][0]["it"][1]["ty"], json!("fl")); + let o = back.layers.as_ref().unwrap()[0].transform.as_ref().unwrap().opacity.as_ref().unwrap().read(); + assert_eq!(o.expression, Some("time")); + let PropValue::Keys(k) = o.value else { panic!("keys") }; + assert_eq!((k.len(), k[1].t), (2, 30.0)); + // A layer with no type and nothing else is an empty object. + assert_eq!(Animation { layers: Some(vec![Layer::default()]), ..Animation::default() }.to_json(), json!({"layers": [{}]})); +} + +#[test] +fn fixtures_round_trip() { + for name in ["bouncing-ball.json", "legacy-masks.json", "precomp-matte-text.json", "shapes-gradient.json"] { + let bytes = std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name)).unwrap(); + let doc: Json = serde_json::from_slice(&bytes).unwrap(); + let a = Animation::read(&bytes).unwrap(); + assert!(a.layers.as_ref().is_some_and(|l| !l.is_empty()), "{name}"); + assert_eq!(a.to_json(), doc, "{name}"); + } +} + +#[test] +fn every_limit_is_enforced() { + let too = |what, limit| Err(LottieError::TooLarge { what, limit }); + let at = |doc: &Json, limits: Limits| Animation::from_document(doc.clone(), &limits).map(|_| ()); + + let bytes = br#"{"w":1,"h":1}"#; + assert!(Animation::read_with_limits(bytes, &Limits { max_bytes: bytes.len(), ..Limits::default() }).is_ok()); + assert_eq!( + Animation::read_with_limits(bytes, &Limits { max_bytes: bytes.len() - 1, ..Limits::default() }).map(|_| ()), + too("bytes", bytes.len() - 1) + ); + + // Two layers at the top and two in a precomposition. + let doc = json!({"assets": [{"id": "a", "layers": [{"ty": 3}, {"ty": 3}]}, {"id": "b"}], "layers": [{"ty": 3}, {"ty": 3}]}); + assert_eq!(at(&doc, Limits { max_layers: 4, max_assets: 2, ..Limits::default() }), Ok(())); + assert_eq!(at(&doc, Limits { max_layers: 3, ..Limits::default() }), too("layers", 3)); + assert_eq!(at(&doc, Limits { max_assets: 1, ..Limits::default() }), too("assets", 1)); + + // Shape items: a group, the two in it, one in a precomposition's layer and one in a glyph. + let doc = json!({ + "chars": [{"data": {"shapes": [{"ty": "sh"}]}}], + "assets": [{"layers": [{"ty": 4, "shapes": [{"ty": "el"}]}]}], + "layers": [{"ty": 4, "shapes": [{"ty": "gr", "it": [{"ty": "rc"}, {"ty": "fl"}]}]}] + }); + assert_eq!(at(&doc, Limits { max_shapes: 5, ..Limits::default() }), Ok(())); + assert_eq!(at(&doc, Limits { max_shapes: 4, ..Limits::default() }), too("shape items", 4)); + + // Nesting: `{"layers": [{"x": 1}]}` is three levels. + let doc = json!({"layers": [{"x": 1}]}); + assert_eq!(at(&doc, Limits { max_depth: 3, ..Limits::default() }), Ok(())); + assert_eq!(at(&doc, Limits { max_depth: 2, ..Limits::default() }), too("nesting levels", 2)); + // A value built in code, deeper than a parser lets through, is refused and not walked. + let mut deep = json!(1); + for _ in 0..300 { + deep = json!({"it": [deep], "ty": "gr"}); + } + assert_eq!(at(&json!({"layers": [{"ty": 4, "shapes": [deep]}]}), Limits::default()), too("nesting levels", 128)); + // The same from text: the parser stops it. + let text = format!("{{\"layers\":{}{}}}", "[".repeat(300), "]".repeat(300)); + assert!(matches!(Animation::read(text.as_bytes()), Err(LottieError::Parse(_)))); +} + +#[test] +fn what_is_not_a_document_is_an_error() { + for bad in [&b"[1, 2]"[..], b"5", b"null", b"\"text\"", b"", b"{\"w\":", b"PK\x03\x04"] { + assert!(matches!(Animation::read(bad), Err(LottieError::Parse(_))), "{bad:?}"); + } + assert_eq!(Animation::read(b"{}").unwrap(), Animation::default()); +} + +#[test] +fn damaged_documents_never_panic() { + let doc = serde_json::to_vec(&everything()).unwrap(); + // Every prefix, and every byte replaced by a few that change the structure. + for cut in 0..doc.len() { + let _ = Animation::read(&doc[..cut]); + } + let mut seed = 0x2545_f491_4f6c_dd1du64; + for _ in 0..4000 { + let mut d = doc.clone(); + for _ in 0..3 { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let at = (seed >> 8) as usize % d.len(); + d[at] = b"{}[]\",:0n-e. t"[(seed >> 40) as usize % 14]; + } + if let Ok(a) = Animation::read(&d) { + // Whatever read writes back to something that reads the same. + assert_eq!(Animation::read(&a.to_vec()).unwrap(), a); + } + } +}