mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
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:
1 parent
43f65a9210
commit
bd79e32630
9 files changed
+1560
-29
No files matched your search
@@ -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,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
@@ -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
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user