craft-lottie: the document model, with a bounded reader and a writer (#938)

`craft_lottie::model` is the Lottie document as the file has it: one
struct per object of the lottie-spec 1.0 object model (animation, assets,
layers by type, transform, masks, effects, shape items, text, markers,
fonts, glyphs), one field per key. `Animation::read` reads within
`Limits` (bytes, nesting, layers, assets, shape items); `to_json` writes.

Keys the model does not name, values of another type than expected, and
layers and shape items of an unknown type stay in `extra` and are written
back, so a document survives a read and a write. Animatable properties
stay JSON (`Animated`) and are read with `read_prop`.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 18:47:08 -05:00
1 parent 43f65a9210
commit bd79e32630
9 files changed
+1560 -29

No files matched your search

+5 -4
View File
@@ -174,10 +174,11 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
(`isobmff`), Matroska/WebM (`matroska`) and MXF (`mxf`) demuxers and muxers, Ogg (`ogg`) and AVI (`avi`) demuxers, from
FilmCraft, and an ISO BMFF box reader. Containers only; codecs are in `craft-audio`, and
video codecs will go in a separate crate.
- [`craft-lottie`](crates/lottie/README.md): Lottie (Bodymovin) JSON, the wire layer: the document
header, animatable properties and keyframes (legacy end values, hold keys, spatial tangents),
ease tangents to and from speed and influence, bezier paths, gradients and embedded image data
URIs. From EffectCraft; mapping layers and shapes onto a document stays in the app.
- [`craft-lottie`](crates/lottie/README.md): Lottie (Bodymovin) JSON: the document model of
lottie-spec 1.0 (animation, assets, layers by type, shape items, masks, effects, text, markers)
with a bounded reader and a writer that keep what they do not know, and the encodings of its
values (keyframes, ease tangents, bezier paths, gradients, data URIs). From EffectCraft; mapping
onto an app's document stays in the app.
- [`craft-menu`](crates/menu/README.md): an app's menus as a macOS menu bar: the Mac layout
(app menu, Window, Help, system-key clashes), a platform backend trait, and key equivalents
fed back to egui as the input egui-winit would have made. Commands and their enabled state
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "craft-lottie"
description = "Lottie (Bodymovin) JSON, the wire layer: the document header, animated properties and their keyframes, ease tangents, bezier paths, gradients, value coercions and embedded image data URIs"
description = "Lottie (Bodymovin) JSON: the lottie-spec 1.0 document model with a bounded reader and a writer, animated properties and their keyframes, ease tangents, bezier paths, gradients, value coercions and embedded image data URIs"
version.workspace = true
edition.workspace = true
license.workspace = true
+47 -12
View File
@@ -1,12 +1,49 @@
# craft-lottie
Lottie (Bodymovin) JSON: the wire layer. A Lottie document is JSON, and most of what is hard about
it is a handful of encodings that every layer, mask and shape reuses. This crate reads and writes
those on `serde_json` values. It has no document model of its own yet: which layer, shape, effect
and text feature becomes what in an application's document is the application's work.
Lottie (Bodymovin) JSON: the document as the file has it, and the encodings its values use. Bytes
in, a neutral model out, and back. Which layer, shape, effect and text feature becomes what in an
application's document is the application's work.
No file access: callers pass bytes, so the crate builds for wasm32.
## The document model
`Animation::read(bytes)` gives the document; `to_json` and `to_vec` write it. The model is the
lottie-spec 1.0 object model, one struct per object and one field per key:
- `Animation`: version, frame rate, frame range, size, `assets`, `layers`, `markers`, `fonts`,
`chars`, `meta`.
- `Asset`: an image (`dir`, `path`, `embedded`) or a precomposition (`layers`).
- `Layer`: the keys every layer has (name, `index`, `parent`, timing, blend mode, `transform`,
`masks`, `effects`, matte keys) and `content`, by `ty`: `Precomp`, `Solid`, `Image`, `Null`,
`Shape`, `Text`, or `Other` for a type the model has no struct for.
- `Shape`: the keys every shape item has and `kind`, by `ty`: group, rectangle, ellipse, polystar,
path, fill, stroke, gradient fill and stroke, transform, trim, round corners, repeater, merge,
pucker and bloat, twist, zig-zag, offset path, or `Other`.
- `Transform`, `Mask`, `Effect` (an effect and its values), `Marker`, `Fonts`, `Font`, `Char`.
- `TextData`: the documents over time (`TextDocument`), range animators (`TextRange`,
`TextSelector`, `TextStyle`), alignment and path options.
Every field is an `Option`: files leave keys out, and a writer sets what it needs. An animatable
property is an `Animated`, the property's JSON; `Animated::read` gives its value or keyframes (see
below). What a value means depends on the property, so the model does not decode values.
Nothing a file holds is dropped because the model does not know it. A key the struct does not
name, and a key whose value has another type than the struct expects, stay in the struct's `extra`
and are written back. A layer or shape of an unknown `ty` keeps its `ty` and its keys. So a
document read and written back is the same JSON, with three exceptions: an entry of a list that is
not an object where objects are expected is left out, a whole number written `30.0` comes back
`30`, and a flag written `1` or `0` where the schema has a boolean comes back `true` or `false`.
`Limits` bounds what is read: bytes of JSON (256 MiB), nesting (128 levels, which is also where
`serde_json` stops), layers of the document and its precompositions together (100,000), assets
(20,000) and shape items (2,000,000). Passing one is `LottieError::TooLarge`. No reader panics on
any input.
## The encodings
These work on `serde_json` values, for the values the model leaves as JSON.
| Part | Read | Write |
|---|---|---|
| Document JSON and header (`w`, `h`, `fr`, `ip`, `op`, `nm`) | `parse_document`, `Header::read` | the application builds the JSON |
@@ -17,9 +54,8 @@ No file access: callers pass bytes, so the crate builds for wasm32.
| Numbers, colours, `#rrggbb` | `nums`, `color`, `hex_color` | `round6`, `to_hex` |
| Embedded images (`data:` URIs, RFC 4648 base64) | `base64::data_uri` | `base64::to_data_uri`, `image_mime` |
Not here: the layer, shape, mask, effect and text item layouts (they are still in EffectCraft's
`effectcraft-lottie`, written against its project model), and dotLottie (`.lottie`), which is a
ZIP archive around the same JSON.
Not here: dotLottie (`.lottie`), which is a ZIP archive around the same JSON, and layer styles
(`sy`), which lottie-spec 1.0 does not define (they stay in a layer's `extra`).
## What the readers accept
@@ -38,9 +74,7 @@ tolerant where players are:
- colours written 0..=255 instead of 0..=1;
- a gradient list without alpha pairs, and a colour count larger than the list.
Input is hostile: nothing is indexed by a number from the file, JSON nesting is bounded by
`serde_json` (128 levels), and no reader panics. Sizes are those of the JSON the caller parsed;
bound the file size before calling.
Nothing is indexed by a number from the file, and no reader panics.
## Ease
@@ -61,6 +95,7 @@ computes.
## Sources and clean room
Implemented from the public Lottie format description (the lottie-spec 1.0 schema and the
LottieFiles format documentation); no player or exporter source was used. The code comes from
EffectCraft's `effectcraft-lottie` crate. The four fixtures in `tests/fixtures` are hand-written
LottieFiles format documentation); no player or exporter source was used. The encodings come from
EffectCraft's `effectcraft-lottie` crate; the document model is written here from the same
description. The four fixtures in `tests/fixtures` are hand-written
JSON (original work), not exported from any product.
+20 -12
View File
@@ -1,16 +1,15 @@
//! Lottie (Bodymovin) JSON: the wire layer.
//! Lottie (Bodymovin) JSON.
//!
//! A Lottie document is JSON, and most of its difficulty is in a few encodings that every layer
//! and shape reuses. This crate reads and writes those, on `serde_json` values, without a
//! document model of its own:
//!
//! - [`parse_document`] and [`Header`]: the document and its size, frame rate and frame range;
//! - [`read_prop`] / [`write_prop`]: an animatable property (`{a, k, x}`), its keyframes with the
//! legacy end values, hold keys and spatial tangents, and [`Tangents`], the ease of a segment
//! converted to and from speed and influence;
//! - [`Bezier`]: a path (`{c, v, i, o}`); [`Gradient`]: the flat stop list of a gradient;
//! - [`nums`], [`color`], [`hex_color`]: the tolerant value readers;
//! - [`base64`] and [`image_mime`]: embedded image assets as `data:` URIs.
//! - [`model`]: the document as the file has it ([`Animation`], [`Layer`], [`Shape`], ...), one
//! struct per object of the lottie-spec 1.0 object model. [`Animation::read`] reads it within
//! [`Limits`]; [`Animation::to_json`] writes it. Keys the model does not know are kept.
//! - The encodings of the values the model leaves as JSON:
//! [`read_prop`] / [`write_prop`] for an animatable property (`{a, k, x}`), its keyframes with
//! the legacy end values, hold keys and spatial tangents, and [`Tangents`], the ease of a
//! segment converted to and from speed and influence; [`Bezier`] for a path (`{c, v, i, o}`);
//! [`Gradient`] for the flat stop list of a gradient; [`nums`], [`color`], [`hex_color`], the
//! tolerant value readers; [`base64`] and [`image_mime`] for embedded images as `data:` URIs.
//! - [`parse_document`] and [`Header`] for a caller that only wants the JSON and its header.
//!
//! Implemented from the public Lottie format description (the lottie-spec 1.0 schema and the
//! LottieFiles format documentation); no player or exporter source was used.
@@ -19,15 +18,19 @@
//! application's work. dotLottie (`.lottie`) is a ZIP archive around the same JSON and is not
//! read here.
// The test documents are large `json!` literals.
#![cfg_attr(test, recursion_limit = "512")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod base64;
mod gradient;
pub mod model;
mod path;
mod prop;
mod value;
pub use gradient::Gradient;
pub use model::{Animated, Animation, Layer, LayerContent, Limits, Shape, ShapeKind};
pub use path::Bezier;
pub use prop::{Ease, Key, KeyOut, Prop, PropValue, Tangents, is_animated, read_prop, tangent, write_keys, write_prop};
pub use serde_json::Value as Json;
@@ -38,6 +41,9 @@ pub use value::{color, hex_color, image_mime, nums, round6, to_hex};
pub enum LottieError {
#[error("not a Lottie document: {0}")]
Parse(String),
/// A [`Limits`] value was passed.
#[error("the document has more than {limit} {what}")]
TooLarge { what: &'static str, limit: usize },
}
/// Parses the JSON of a Lottie document. Nesting is bounded by `serde_json` (128 levels).
@@ -75,5 +81,7 @@ impl<'a> Header<'a> {
}
}
#[cfg(test)]
mod model_tests;
#[cfg(test)]
mod tests;
+224
View File
@@ -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<Effect>,
}
}
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<String>),
/// `ty` 3.
Null,
/// `ty` 4: its `shapes`, the topmost first.
Shape(Vec<Shape>),
/// `ty` 5: its `t`.
Text(Box<TextData>),
/// 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<Mask>,
effects / "ef": Vec<Effect>,
/// 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::<TextData>(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<Self, Json> {
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)
}
}
+413
View File
@@ -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<String, Json>;
/// 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<Self, Json>;
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<Self, Json> {
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<Self, Json> {
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<Self, Json> {
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<Self, Json> {
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<Self, Json> {
match j {
Json::Object(m) => Ok(m),
other => Err(other),
}
}
fn to_json(&self) -> Json {
Json::Object(self.clone())
}
}
impl<T: Lenient> Lenient for Vec<T> {
/// A list. Entries of another type than `T` are left out.
fn from_json(j: Json) -> Result<Self, Json> {
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<T: Lenient>(m: &mut Map, k: &str) -> Option<T> {
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<Self, $crate::Json> {
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<Json>) -> 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<Self, Json> {
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<Asset>,
/// The layers, the topmost first.
layers / "layers": Vec<Layer>,
markers / "markers": Vec<Marker>,
fonts / "fonts": Fonts,
/// Glyph outlines for players without the fonts.
chars / "chars": Vec<Char>,
/// 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<Layer>,
}
}
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<Font>,
}
}
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<Shape>,
}
}
/// 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, LottieError> {
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<Self, LottieError> {
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<Self, LottieError> {
if deeper_than(&doc, limits.max_depth) {
return Err(LottieError::TooLarge { what: "nesting levels", limit: limits.max_depth });
}
let doc = <Self as Lenient>::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::<usize>(), limits.max_layers)?;
let shapes = count_layer_shapes(top) + nested().map(count_layer_shapes).sum::<usize>() + chars.map(count_shapes).sum::<usize>();
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<u8> {
// 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))
}
}
+393
View File
@@ -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<Shape>,
}
}
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<Dash>,
}
}
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<Dash>,
}
}
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<Transform>),
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<Self, Json> {
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)
}
}
+137
View File
@@ -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<TextRange>,
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<TextDocKey>,
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<f64>,
stroke / "sc": Vec<f64>,
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<f64>,
box_position / "ps": Vec<f64>,
/// 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,
}
}
+320
View File
@@ -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);
}
}
}